Преглед на файлове

Merge branch 'MergeProblem1' into production

production
DESKTOP-064TTA1\Fai LUK преди 1 седмица
родител
ревизия
5c1903be35
променени са 55 файла, в които са добавени 18144 реда и са изтрити 162 реда
  1. +36
    -0
      src/app/(main)/do copy 2/edit/page.tsx
  2. +35
    -0
      src/app/(main)/do copy 2/page.tsx
  3. +36
    -0
      src/app/(main)/do copy/edit/page.tsx
  4. +29
    -0
      src/app/(main)/do copy/page.tsx
  5. +46
    -0
      src/app/(main)/doworkbench/edit/page.tsx
  6. +25
    -0
      src/app/(main)/doworkbench/page.tsx
  7. +6
    -0
      src/app/(main)/doworkbench/pick/page.tsx
  8. +31
    -0
      src/app/(main)/doworkbenchsearch/page.tsx
  9. +26
    -6
      src/app/(main)/jo/page.tsx
  10. +27
    -0
      src/app/(main)/jo/workbench/page.tsx
  11. +92
    -4
      src/app/api/do/actions.tsx
  12. +372
    -0
      src/app/api/doworkbench/actions.ts
  13. +5
    -0
      src/app/api/doworkbench/client.ts
  14. +9
    -0
      src/app/api/doworkbench/index.tsx
  15. +74
    -0
      src/app/api/doworkbench/truckRoutingSummaryWorkbenchApi.ts
  16. +14
    -0
      src/app/api/doworkbench/workbenchScanPickUtils.ts
  17. +14
    -4
      src/app/api/inventory/actions.ts
  18. +25
    -5
      src/app/api/jo/actions.ts
  19. +57
    -0
      src/app/api/jo/workbenchActions.ts
  20. +127
    -10
      src/app/api/pickOrder/actions.ts
  21. +17
    -0
      src/app/api/stockTake/actions.ts
  22. +9
    -2
      src/app/utils/fetchUtil.ts
  23. +5
    -3
      src/components/Breadcrumb/Breadcrumb.tsx
  24. +28
    -6
      src/components/DoSearch/DoSearch.tsx
  25. +737
    -0
      src/components/DoSearchWorkbench/DoSearchWorkbench.tsx
  26. +1
    -0
      src/components/DoSearchWorkbench/index.ts
  27. +96
    -0
      src/components/DoWorkbench/DoWorkbenchPickShell.tsx
  28. +232
    -0
      src/components/DoWorkbench/DoWorkbenchTabs.tsx
  29. +659
    -0
      src/components/DoWorkbench/GoodPickExecutionWorkbenchRecord.tsx
  30. +155
    -0
      src/components/DoWorkbench/TruckRoutingSummaryTabWorkbench.tsx
  31. +484
    -0
      src/components/DoWorkbench/WorkbenchFloorLanePanel.tsx
  32. +1221
    -0
      src/components/DoWorkbench/WorkbenchGoodPickExecution.tsx
  33. +4148
    -0
      src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx
  34. +792
    -0
      src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx
  35. +352
    -0
      src/components/DoWorkbench/WorkbenchTicketReleaseTable.tsx
  36. +4
    -0
      src/components/DoWorkbench/index.ts
  37. +15
    -5
      src/components/FinishedGoodSearch/FinishedGoodCartonDashboardTab.tsx
  38. +1
    -1
      src/components/FinishedGoodSearch/GoodPickExecutiondetail.tsx
  39. +321
    -0
      src/components/JoWorkbench/JoPickOrderList.tsx
  40. +923
    -0
      src/components/JoWorkbench/JoWorkbenchSearch.tsx
  41. +60
    -0
      src/components/JoWorkbench/JoWorkbenchTabs.tsx
  42. +4947
    -0
      src/components/JoWorkbench/newJobPickExecution.tsx
  43. +2
    -1
      src/components/Jodetail/JodetailSearch.tsx
  44. +36
    -3
      src/components/NavigationContent/NavigationContent.tsx
  45. +9
    -4
      src/components/PickOrderSearch/AssignAndRelease.tsx
  46. +7
    -3
      src/components/PickOrderSearch/PickOrderSearch.tsx
  47. +1691
    -0
      src/components/PickOrderSearch/WorkbenchPickExecution.tsx
  48. +2
    -28
      src/components/PickOrderSearch/assignTo.tsx
  49. +30
    -8
      src/components/ProductionProcess/ProductionProcessList.tsx
  50. +18
    -64
      src/components/StockTakeManagement/ApproverStockTakeAll.tsx
  51. +8
    -2
      src/i18n/index.tsx
  52. +12
    -0
      src/i18n/zh/common.json
  53. +5
    -0
      src/i18n/zh/do.json
  54. +9
    -1
      src/i18n/zh/jo.json
  55. +22
    -2
      src/i18n/zh/pickOrder.json

+ 36
- 0
src/app/(main)/do copy 2/edit/page.tsx Целия файл

@@ -0,0 +1,36 @@
import { SearchParams } from "@/app/utils/fetchUtil";
import DoDetail from "@/components/DoDetail/DoDetailWrapper";
import PageTitleBar from "@/components/PageTitleBar";
import { I18nProvider, getServerI18n } from "@/i18n";
import { isArray } from "lodash";
import { Metadata } from "next";
import { notFound } from "next/navigation";
import { Suspense } from "react";

export const metadata: Metadata = {
title: "Edit Delivery Order Detail",
};

type Props = SearchParams;

const DoEdit: React.FC<Props> = async ({ searchParams }) => {
const { t } = await getServerI18n("do");
const id = searchParams["id"];

if (!id || isArray(id) || !isFinite(parseInt(id))) {
notFound();
}

return (
<>
<PageTitleBar title={t("Edit Delivery Order Detail")} className="mb-4" />
<I18nProvider namespaces={["do", "common"]}>
<Suspense fallback={<DoDetail.Loading />}>
<DoDetail id={parseInt(id)} />
</Suspense>
</I18nProvider>
</>
);
};

export default DoEdit;

+ 35
- 0
src/app/(main)/do copy 2/page.tsx Целия файл

@@ -0,0 +1,35 @@
import DoSearchWorkbench from "@/components/DoSearchWorkbench/DoSearchWorkbench";
import { getServerI18n } from "@/i18n";
import PageTitleBar from "@/components/PageTitleBar";
import { I18nProvider } from "@/i18n";
import { Metadata } from "next";
import { Suspense } from "react";
import GeneralLoading from "@/components/General/GeneralLoading";
import Link from "next/link";

export const metadata: Metadata = {
title: "DO Workbench (copy)",
};

/** Dev alias — prefer canonical route `/doworkbench`. */
const Page: React.FC = async () => {
const { t } = await getServerI18n("do");

return (
<>
<PageTitleBar title={t("DO Workbench (dev)", { defaultValue: "DO Workbench (dev)" })} className="mb-4" />
<p className="mb-2 text-sm text-gray-600">
<Link href="/doworkbench" className="underline">
/doworkbench
</Link>
</p>
<I18nProvider namespaces={["do", "common"]}>
<Suspense fallback={<GeneralLoading />}>
<DoSearchWorkbench workbenchHrefBase="/do copy 2" />
</Suspense>
</I18nProvider>
</>
);
};

export default Page;

+ 36
- 0
src/app/(main)/do copy/edit/page.tsx Целия файл

@@ -0,0 +1,36 @@
import { SearchParams } from "@/app/utils/fetchUtil";
import DoDetail from "@/components/DoDetail/DoDetailWrapper";
import PageTitleBar from "@/components/PageTitleBar";
import { I18nProvider, getServerI18n } from "@/i18n";
import { isArray } from "lodash";
import { Metadata } from "next";
import { notFound } from "next/navigation";
import { Suspense } from "react";

export const metadata: Metadata = {
title: "Edit Delivery Order Detail",
};

type Props = SearchParams;

const DoEdit: React.FC<Props> = async ({ searchParams }) => {
const { t } = await getServerI18n("do");
const id = searchParams["id"];

if (!id || isArray(id) || !isFinite(parseInt(id))) {
notFound();
}

return (
<>
<PageTitleBar title={t("Edit Delivery Order Detail")} className="mb-4" />
<I18nProvider namespaces={["do", "common"]}>
<Suspense fallback={<DoDetail.Loading />}>
<DoDetail id={parseInt(id)} />
</Suspense>
</I18nProvider>
</>
);
};

export default DoEdit;

+ 29
- 0
src/app/(main)/do copy/page.tsx Целия файл

@@ -0,0 +1,29 @@
// import DoSearch from "@/components/DoSearch";
// import { getServerI18n } from "@/i18n"
import DoSearch from "../../../components/DoSearch";
import { getServerI18n } from "../../../i18n";
import PageTitleBar from "@/components/PageTitleBar";
import { I18nProvider } from "@/i18n";
import { Metadata } from "next";
import { Suspense } from "react";

export const metadata: Metadata = {
title: "Delivery Order",
};

const DeliveryOrder: React.FC = async () => {
const { t } = await getServerI18n("do");

return (
<>
<PageTitleBar title={t("Delivery Order")} className="mb-4" />
<I18nProvider namespaces={["do", "common"]}>
<Suspense fallback={<DoSearch.Loading />}>
<DoSearch />
</Suspense>
</I18nProvider>
</>
);
};

export default DeliveryOrder;

+ 46
- 0
src/app/(main)/doworkbench/edit/page.tsx Целия файл

@@ -0,0 +1,46 @@
import { SearchParams } from "@/app/utils/fetchUtil";
import DoDetail from "@/components/DoDetail/DoDetailWrapper";
import PageTitleBar from "@/components/PageTitleBar";
import { I18nProvider, getServerI18n } from "@/i18n";
import { isArray } from "lodash";
import { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { Suspense } from "react";

export const metadata: Metadata = {
title: "DO Workbench — Delivery Order Detail",
};

type Props = SearchParams;

const Page: React.FC<Props> = async ({ searchParams }) => {
const { t } = await getServerI18n("do");
const id = searchParams["id"];

if (!id || isArray(id) || !isFinite(parseInt(id))) {
notFound();
}

return (
<>
<PageTitleBar title={t("Edit Delivery Order Detail")} className="mb-4" />
<p className="mb-4 text-sm">
<Link href="/doworkbench" className="text-primary underline">
{t("DO Workbench", { defaultValue: "DO Workbench" })}
</Link>
{" · "}
<Link href="/doworkbenchsearch" className="text-primary underline">
{t("DO Workbench Search", { defaultValue: "DO Workbench Search" })}
</Link>
</p>
<I18nProvider namespaces={["do", "common"]}>
<Suspense fallback={<DoDetail.Loading />}>
<DoDetail id={parseInt(id)} />
</Suspense>
</I18nProvider>
</>
);
};

export default Page;

+ 25
- 0
src/app/(main)/doworkbench/page.tsx Целия файл

@@ -0,0 +1,25 @@
import DoWorkbenchTabs from "@/components/DoWorkbench/DoWorkbenchTabs";
import PageTitleBar from "@/components/PageTitleBar";
import { getServerI18n, I18nProvider } from "@/i18n";
import { Metadata } from "next";
import { fetchPrinterCombo } from "@/app/api/settings/printer";

export const metadata: Metadata = {
title: "DO Workbench",
};

const DoWorkbenchPage: React.FC = async () => {
const { t } = await getServerI18n("do");
const printerCombo = await fetchPrinterCombo();

return (
<>
<PageTitleBar title={t("DO Workbench", { defaultValue: "DO Workbench" })} className="mb-4" />
<I18nProvider namespaces={["pickOrder", "common", "ticketReleaseTable", "do"]}>
<DoWorkbenchTabs printerCombo={printerCombo ?? []} />
</I18nProvider>
</>
);
};

export default DoWorkbenchPage;

+ 6
- 0
src/app/(main)/doworkbench/pick/page.tsx Целия файл

@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";

/** 揀貨工作台已合併至 `/doworkbench`,保留此路徑以利舊連結。 */
export default function DoWorkbenchPickLegacyRedirect() {
redirect("/doworkbench");
}

+ 31
- 0
src/app/(main)/doworkbenchsearch/page.tsx Целия файл

@@ -0,0 +1,31 @@
import DoSearchWorkbench from "@/components/DoSearchWorkbench";
import { getServerI18n } from "@/i18n";
import PageTitleBar from "@/components/PageTitleBar";
import { I18nProvider } from "@/i18n";
import { Metadata } from "next";
import { Suspense } from "react";
import GeneralLoading from "@/components/General/GeneralLoading";

export const metadata: Metadata = {
title: "DO Workbench Search",
};

const DoWorkbenchSearchPage: React.FC = async () => {
const { t } = await getServerI18n("do");

return (
<>
<PageTitleBar
title={t("DO Workbench Search", { defaultValue: "DO Workbench Search" })}
className="mb-4"
/>
<I18nProvider namespaces={["do", "common"]}>
<Suspense fallback={<GeneralLoading />}>
<DoSearchWorkbench />
</Suspense>
</I18nProvider>
</>
);
};

export default DoWorkbenchSearchPage;

+ 26
- 6
src/app/(main)/jo/page.tsx Целия файл

@@ -1,6 +1,9 @@
import { preloadBomCombo } from "@/app/api/bom";
import JoSearch from "@/components/JoSearch";
import { fetchBomCombo } from "@/app/api/bom";
import { fetchPrinterCombo } from "@/app/api/settings/printer";
import { fetchAllJobTypes, type SearchJoResultRequest } from "@/app/api/jo/actions";
import GeneralLoading from "@/components/General/GeneralLoading";
import PageTitleBar from "@/components/PageTitleBar";
import JoWorkbenchSearch from "@/components/JoWorkbench/JoWorkbenchSearch";
import { I18nProvider, getServerI18n } from "@/i18n";
import { Metadata } from "next";
import React, { Suspense } from "react";
@@ -11,15 +14,32 @@ export const metadata: Metadata = {

const Jo: React.FC = async () => {
const { t } = await getServerI18n("jo");

preloadBomCombo();
const today = new Date();
const todayStr = today.toISOString().split("T")[0];
const defaultInputs: SearchJoResultRequest = {
code: "",
itemName: "",
planStart: `${todayStr}T00:00`,
planStartTo: `${todayStr}T23:59:59`,
joSearchStatus: "all",
};
const [bomCombo, printerCombo, jobTypes] = await Promise.all([
fetchBomCombo(),
fetchPrinterCombo(),
fetchAllJobTypes(),
]);

return (
<>
<PageTitleBar title={t("Search Job Order/ Create Job Order")} className="mb-4" />
<I18nProvider namespaces={["jo", "common", "purchaseOrder", "dashboard"]}>
<Suspense fallback={<JoSearch.Loading />}>
<JoSearch />
<Suspense fallback={<GeneralLoading />}>
<JoWorkbenchSearch
defaultInputs={defaultInputs}
bomCombo={bomCombo ?? []}
printerCombo={printerCombo ?? []}
jobTypes={jobTypes ?? []}
/>
</Suspense>
</I18nProvider>
</>


+ 27
- 0
src/app/(main)/jo/workbench/page.tsx Целия файл

@@ -0,0 +1,27 @@
import GeneralLoading from "@/components/General/GeneralLoading";
import PageTitleBar from "@/components/PageTitleBar";
import JoPickOrderList from "@/components/JoWorkbench/JoPickOrderList";
import { I18nProvider, getServerI18n } from "@/i18n";
import { Metadata } from "next";
import React, { Suspense } from "react";

export const metadata: Metadata = {
title: "Job Order Pick List",
};

const JoWorkbenchPage = async () => {
const { t } = await getServerI18n("jo");

return (
<>
<PageTitleBar title={t("Job Order Pickexcution", { defaultValue: "Job Order Pickexcution" })} className="mb-4" />
<I18nProvider namespaces={["jo", "common", "pickOrder", "purchaseOrder", "dashboard"]}>
<Suspense fallback={<GeneralLoading />}>
<JoPickOrderList />
</Suspense>
</I18nProvider>
</>
);
};

export default JoWorkbenchPage;

+ 92
- 4
src/app/api/do/actions.tsx Целия файл

@@ -148,6 +148,35 @@ export interface getTicketReleaseTable {
isActiveDoPickOrder?: boolean;
}

export interface WorkbenchTicketReleaseTable {
deliveryOrderPickOrderId: number;
storeId: string | null;
ticketNo: string | null;
loadingSequence: number | null;
ticketStatus: string | null;
truckDepartureTime: string | null;
handledBy: number | null;
ticketReleaseTime: string | null;
ticketCompleteDateTime: string | null;
truckLanceCode: string | null;
shopCode: string | null;
shopName: string | null;
requiredDeliveryDate: string | null;
handlerName: string | null;
numberOfFGItems: number;
isActiveWorkbenchTicket?: boolean;
}

export interface WorkbenchTicketOpResponse {
id: number | null;
name: string | null;
code: string;
type: string | null;
message: string | null;
errorPosition: string | null;
entity?: any;
}

export interface TruckScheduleDashboardItem {
storeId: string | null;
truckId: number | null;
@@ -213,6 +242,39 @@ export const fetchTicketReleaseTable = cache(async (startDate: string, endDate:
);
});

export const fetchWorkbenchTicketReleaseTable = cache(async (startDate: string, endDate: string)=> {
return await serverFetchJson<WorkbenchTicketReleaseTable[]>(
`${BASE_API_URL}/doPickOrder/workbench/ticket-release-table/${startDate}&${endDate}`,
{
method: "GET",
}
);
});

export async function forceCompleteWorkbenchTicket(
deliveryOrderPickOrderId: number,
): Promise<WorkbenchTicketOpResponse> {
return await serverFetchJson<WorkbenchTicketOpResponse>(
`${BASE_API_URL}/doPickOrder/workbench/force-complete/${deliveryOrderPickOrderId}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
},
);
}

export async function revertWorkbenchTicketAssignment(
deliveryOrderPickOrderId: number,
): Promise<WorkbenchTicketOpResponse> {
return await serverFetchJson<WorkbenchTicketOpResponse>(
`${BASE_API_URL}/doPickOrder/workbench/revert-assignment/${deliveryOrderPickOrderId}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
},
);
}

export const fetchTruckScheduleDashboard = cache(async (date?: string) => {
const url = date
? `${BASE_API_URL}/doPickOrder/truck-schedule-dashboard?date=${date}`
@@ -451,7 +513,7 @@ export async function printDNLabelsReprint(request: PrintDNLabelsReprintRequest)

return { success: true, message: "Print job sent successfully (reprint labels)"} as PrintDeliveryNoteResponse
}
/*
export interface PrintWorkbenchDeliveryNoteRequest{
deliveryOrderPickOrderId: number;
printerId: number;
@@ -466,6 +528,14 @@ export interface PrintWorkbenchDNLabelsRequest{
printQty: number;
numOfCarton: number;
}
export interface PrintWorkbenchDNLabelsReprintRequest{
deliveryOrderPickOrderId: number;
printerId: number;
printQty: number;
fromCarton: number;
toCarton: number;
totalCartonsOnShipment: number;
}
export async function printDNWorkbench(request: PrintWorkbenchDeliveryNoteRequest){
const params = new URLSearchParams();
params.append("doPickOrderId", request.deliveryOrderPickOrderId.toString());
@@ -477,7 +547,7 @@ export async function printDNWorkbench(request: PrintWorkbenchDeliveryNoteReques
params.append("isDraft", request.isDraft.toString());

try {
const response = await serverFetch(`${BASE_API_URL}/do/workbench/print-DN?${params.toString()}`, {
const response = await serverFetch(`${BASE_API_URL}/doPickOrder/workbench/print-DN?${params.toString()}`, {
method: "GET",
});
if (response.ok) {
@@ -507,13 +577,31 @@ export async function printDNLabelsWorkbench(request: PrintWorkbenchDNLabelsRequ
}
params.append("numOfCarton", request.numOfCarton.toString());

await serverFetchWithNoContent(`${BASE_API_URL}/do/workbench/print-DNLabels?${params.toString()}`,{
await serverFetchWithNoContent(`${BASE_API_URL}/doPickOrder/workbench/print-DNLabels?${params.toString()}`,{
method: "GET"
});

return { success: true, message: "Print job sent successfully (workbench labels)"} as PrintDeliveryNoteResponse
}
*/

export async function printDNLabelsReprintWorkbench(request: PrintWorkbenchDNLabelsReprintRequest){
const params = new URLSearchParams();
params.append("doPickOrderId", request.deliveryOrderPickOrderId.toString());
params.append("printerId", request.printerId.toString());
if (request.printQty !== null && request.printQty !== undefined) {
params.append("printQty", request.printQty.toString());
}
params.append("fromCarton", request.fromCarton.toString());
params.append("toCarton", request.toCarton.toString());
params.append("totalCartonsOnShipment", request.totalCartonsOnShipment.toString());

await serverFetchWithNoContent(`${BASE_API_URL}/doPickOrder/workbench/print-DNLabels-reprint?${params.toString()}`,{
method: "GET"
});

return { success: true, message: "Print job sent successfully (workbench reprint labels)"} as PrintDeliveryNoteResponse
}

export interface Check4FTruckBatchResponse {
hasProblem: boolean;
problems: ProblemDoDto[];


+ 372
- 0
src/app/api/doworkbench/actions.ts Целия файл

@@ -0,0 +1,372 @@
"use server";

import { revalidateTag } from "next/cache";
import { BASE_API_URL } from "@/config/api";
import { serverFetchJson } from "@/app/utils/fetchUtil";
import type {
PostPickOrderResponse,
ReleasedDoPickOrderListItem,
StoreLaneSummary,
} from "@/app/api/pickOrder/actions";
import dayjs from "dayjs";

/** Aligns with backend MessageResponse for workbench endpoints */
export type WorkbenchMessageResponse = {
id?: number | null;
code?: string | null;
name?: string | null;
type?: string | null;
message?: string | null;
errorPosition?: string | null;
entity?: unknown;
};

export async function startWorkbenchBatchReleaseAsync(data: {
ids: number[];
userId: number;
}): Promise<WorkbenchMessageResponse> {
const { ids, userId } = data;
return serverFetchJson<WorkbenchMessageResponse>(
`${BASE_API_URL}/doPickOrder/workbench/batch-release/async?userId=${userId}`,
{
method: "POST",
body: JSON.stringify(ids),
headers: { "Content-Type": "application/json" },
}
);
}

/** V2: no SPL/stock out at batch release; created when assigning delivery_order_pick_order. */
export async function startWorkbenchBatchReleaseAsyncV2(data: {
ids: number[];
userId: number;
}): Promise<WorkbenchMessageResponse> {
const { ids, userId } = data;
return serverFetchJson<WorkbenchMessageResponse>(
`${BASE_API_URL}/doPickOrder/workbench/batch-release/async-v2?userId=${userId}`,
{
method: "POST",
body: JSON.stringify(ids),
headers: { "Content-Type": "application/json" },
}
);
}

export async function workbenchBatchReleaseSyncV2(data: {
ids: number[];
userId: number;
}): Promise<WorkbenchMessageResponse> {
const { ids, userId } = data;
return serverFetchJson<WorkbenchMessageResponse>(
`${BASE_API_URL}/doPickOrder/workbench/batch-release/sync-v1?userId=${userId}`,
{
method: "POST",
body: JSON.stringify(ids),
headers: { "Content-Type": "application/json" },
}
);
}

export async function getWorkbenchBatchReleaseProgress(
jobId: string
): Promise<WorkbenchMessageResponse> {
return serverFetchJson<WorkbenchMessageResponse>(
`${BASE_API_URL}/doPickOrder/workbench/batch-release/progress/${encodeURIComponent(jobId)}`,
{ method: "GET" }
);
}

export type WorkbenchScanPickBody = {
stockOutLineId: number;
lotNo: string;
/** From QR: ties to a single `inventory_lot` when lotNo is reused across stock-ins */
stockInLineId?: number | null;
/**
* When set (e.g. label-print modal row), backend resolves this exact inventory lot line
* instead of stockInLineId / newest-by-lotNo.
*/
inventoryLotLineId?: number | null;
/** Optional store scope (e.g. 2/F). When set, split re-suggestions must stay within the same store. */
storeId?: string | null;
/** Optional: exclude these warehouse codes from resuggest logic */
excludeWarehouseCodes?: string[] | null;
/** Optional decimal string or number serialized by JSON */
qty?: number | string | null;
userId: number;
};

function serializeWorkbenchQty(
qty: number | string | null | undefined
): number | undefined {
if (qty === null || qty === undefined || qty === "") return undefined;
const n = typeof qty === "string" ? Number(qty) : qty;
if (typeof n !== "number" || Number.isNaN(n) || !Number.isFinite(n)) return undefined;
// 0 is a valid explicit workbench short submit (must be sent, not omitted)
return n;
}

/**
* DO workbench scan-pick. Omit `qty` for full remaining on this SOL chunk (backend may split if lot runs out).
* Pass `qty` less than remaining for short submit (POL/SOL completed without `partially_completed` on POL).
* Pass `qty` greater than remaining to overscan: backend posts up to lot availability, then rebuild/ensure SOL.
*/
export async function workbenchScanPick(
body: WorkbenchScanPickBody
): Promise<WorkbenchMessageResponse> {
const qty = serializeWorkbenchQty(body.qty);
const sil = body.stockInLineId;
const stockInLineId =
typeof sil === "number" && Number.isFinite(sil) && sil > 0 ? sil : undefined;
const storeId =
typeof body.storeId === "string" && body.storeId.trim() !== ""
? body.storeId.trim()
: undefined;
const excludeWarehouseCodes =
Array.isArray(body.excludeWarehouseCodes) && body.excludeWarehouseCodes.length > 0
? body.excludeWarehouseCodes
.map((c) => (typeof c === "string" ? c.trim() : ""))
.filter((c) => c !== "")
: undefined;
const ill = body.inventoryLotLineId;
const inventoryLotLineId =
typeof ill === "number" && Number.isFinite(ill) && ill > 0 ? ill : undefined;
return serverFetchJson<WorkbenchMessageResponse>(
`${BASE_API_URL}/doPickOrder/workbench/scan-pick`,
{
method: "POST",
body: JSON.stringify({
stockOutLineId: body.stockOutLineId,
lotNo: body.lotNo,
...(stockInLineId !== undefined ? { stockInLineId } : {}),
...(inventoryLotLineId !== undefined ? { inventoryLotLineId } : {}),
...(storeId !== undefined ? { storeId } : {}),
...(excludeWarehouseCodes !== undefined ? { excludeWarehouseCodes } : {}),
...(qty !== undefined ? { qty } : {}),
userId: body.userId,
}),
headers: { "Content-Type": "application/json" },
}
);
}

export type WorkbenchBatchScanPickBody = {
lines: WorkbenchScanPickBody[];
};

/**
* DO workbench batch scan-pick.
* Intended for batch-submit style flows where we close multiple SOLs (commonly qty=0 for noLot/expired/unavailable).
*/
export async function workbenchBatchScanPick(
body: WorkbenchBatchScanPickBody,
): Promise<WorkbenchMessageResponse> {
const lines = Array.isArray(body.lines) ? body.lines : [];
return serverFetchJson<WorkbenchMessageResponse>(
`${BASE_API_URL}/doPickOrder/workbench/scan-pick/batch`,
{
method: "POST",
body: JSON.stringify({
lines: lines.map((l) => {
const qty = serializeWorkbenchQty(l.qty);
const sil = l.stockInLineId;
const stockInLineId =
typeof sil === "number" && Number.isFinite(sil) && sil > 0 ? sil : undefined;
const storeId =
typeof l.storeId === "string" && l.storeId.trim() !== "" ? l.storeId.trim() : undefined;
const excludeWarehouseCodes =
Array.isArray(l.excludeWarehouseCodes) && l.excludeWarehouseCodes.length > 0
? l.excludeWarehouseCodes
.map((c) => (typeof c === "string" ? c.trim() : ""))
.filter((c) => c !== "")
: undefined;
const ill = l.inventoryLotLineId;
const inventoryLotLineId =
typeof ill === "number" && Number.isFinite(ill) && ill > 0 ? ill : undefined;
return {
stockOutLineId: l.stockOutLineId,
lotNo: l.lotNo ?? "",
...(stockInLineId !== undefined ? { stockInLineId } : {}),
...(inventoryLotLineId !== undefined ? { inventoryLotLineId } : {}),
...(storeId !== undefined ? { storeId } : {}),
...(excludeWarehouseCodes !== undefined ? { excludeWarehouseCodes } : {}),
...(qty !== undefined ? { qty } : {}),
userId: l.userId,
};
}),
}),
headers: { "Content-Type": "application/json" },
},
);
}

/** Store lane grid backed by `delivery_order_pick_order` + `pick_order.deliveryOrderPickOrderId`. */
export async function fetchWorkbenchStoreLaneSummary(
storeId: string,
requiredDate?: string,
releaseType?: string
): Promise<StoreLaneSummary> {
const dateToUse = requiredDate || dayjs().format("YYYY-MM-DD");
const rt = releaseType || "all";
const url = `${BASE_API_URL}/doPickOrder/workbench/summary-by-store?storeId=${encodeURIComponent(storeId)}&requiredDate=${encodeURIComponent(dateToUse)}&releaseType=${encodeURIComponent(rt)}`;
return serverFetchJson<StoreLaneSummary>(url, {
method: "GET",
cache: "no-store",
next: { revalidate: 0 },
});
}

/** Past-date `delivery_order_pick_order` tickets (same shape as `/doPickOrder/released`). */
export async function fetchWorkbenchReleasedDoPickOrdersForSelection(
shopName?: string,
storeId?: string,
truck?: string
): Promise<ReleasedDoPickOrderListItem[]> {
const params = new URLSearchParams();
if (shopName?.trim()) params.append("shopName", shopName.trim());
if (storeId?.trim()) params.append("storeId", storeId.trim());
if (truck?.trim()) params.append("truck", truck.trim());
const query = params.toString();
const url = `${BASE_API_URL}/doPickOrder/workbench/released${query ? `?${query}` : ""}`;
const response = await serverFetchJson<ReleasedDoPickOrderListItem[]>(url, { method: "GET" });
return response ?? [];
}

export async function fetchWorkbenchReleasedDoPickOrdersForSelectionToday(
shopName?: string,
storeId?: string,
truck?: string
): Promise<ReleasedDoPickOrderListItem[]> {
const params = new URLSearchParams();
if (shopName?.trim()) params.append("shopName", shopName.trim());
if (storeId?.trim()) params.append("storeId", storeId.trim());
if (truck?.trim()) params.append("truck", truck.trim());
const query = params.toString();
const url = `${BASE_API_URL}/doPickOrder/workbench/released-today${query ? `?${query}` : ""}`;
const response = await serverFetchJson<ReleasedDoPickOrderListItem[]>(url, { method: "GET" });
return response ?? [];
}

/** Same body as `/doPickOrder/assign-by-lane` but resolves `delivery_order_pick_order`. */
export async function assignWorkbenchByLane(data: {
userId: number;
storeId: string;
truckLanceCode: string;
truckDepartureTime?: string;
requiredDate?: string;
}): Promise<PostPickOrderResponse> {
const res = await serverFetchJson<PostPickOrderResponse>(
`${BASE_API_URL}/doPickOrder/workbench/assign-by-lane`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}
);
revalidateTag("pickorder");
return res;
}

/** Assign V1 (legacy): old FG-style, no atomic conflict guard. */
export async function assignWorkbenchByLaneV1(data: {
userId: number;
storeId: string;
truckLanceCode: string;
truckDepartureTime?: string;
requiredDate?: string;
}): Promise<PostPickOrderResponse> {
const res = await serverFetchJson<PostPickOrderResponse>(
`${BASE_API_URL}/doPickOrder/workbench/assign-by-lane-v1`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}
);
revalidateTag("pickorder");
return res;
}

export async function assignByDeliveryOrderPickOrderId(
userId: number,
deliveryOrderPickOrderId: number
): Promise<PostPickOrderResponse> {
const res = await serverFetchJson<PostPickOrderResponse>(
`${BASE_API_URL}/doPickOrder/workbench/assign-by-delivery-order-pick-order-id`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, deliveryOrderPickOrderId }),
}
);
revalidateTag("pickorder");
return res;
}

/** Assign V1 (legacy): old FG-style, no atomic conflict guard. */
export async function assignByDeliveryOrderPickOrderIdV1(
userId: number,
deliveryOrderPickOrderId: number
): Promise<PostPickOrderResponse> {
const res = await serverFetchJson<PostPickOrderResponse>(
`${BASE_API_URL}/doPickOrder/workbench/assign-by-delivery-order-pick-order-id-v1`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, deliveryOrderPickOrderId }),
}
);
revalidateTag("pickorder");
return res;
}

export async function fetchWorkbenchCompletedLotDetails(
deliveryOrderPickOrderId: number,
): Promise<any> {
return serverFetchJson<any>(
`${BASE_API_URL}/doPickOrder/workbench/completed-lot-details/${deliveryOrderPickOrderId}`,
{ method: "GET" },
);
}
export type WorkbenchScanPayload = {
itemId: number;
stockInLineId: number;
};
export async function fetchWorkbenchPrinters() {
return serverFetchJson<any[]>(`${BASE_API_URL}/printers`, {
method: "GET",
cache: "no-store",
});
}
export async function analyzeWorkbenchQrCode(payload: WorkbenchScanPayload) {
return serverFetchJson<any>(`${BASE_API_URL}/inventoryLotLine/workbench/analyze-qr-code`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
cache: "no-store",
});
}

export async function fetchWorkbenchAvailableLotsByItem(itemId: number) {
return serverFetchJson<any>(
`${BASE_API_URL}/inventoryLotLine/workbench/available-lots-by-item/${itemId}`,
{
method: "GET",
cache: "no-store",
},
);
}

export async function printWorkbenchLotLabel(params: {
inventoryLotLineId: number;
printerId: number;
printQty: number;
}) {
const searchParams = new URLSearchParams();
searchParams.set("inventoryLotLineId", String(params.inventoryLotLineId));
searchParams.set("printerId", String(params.printerId));
searchParams.set("printQty", String(params.printQty));
return serverFetchJson<WorkbenchMessageResponse>(
`${BASE_API_URL}/inventoryLotLine/workbench/print-label?${searchParams.toString()}`,
{ method: "GET", cache: "no-store" },
);
}

+ 5
- 0
src/app/api/doworkbench/client.ts Целия файл

@@ -0,0 +1,5 @@
/** Server actions live in ./actions — import them directly in client components. */
export type {
WorkbenchMessageResponse,
WorkbenchScanPickBody,
} from "./actions";

+ 9
- 0
src/app/api/doworkbench/index.tsx Целия файл

@@ -0,0 +1,9 @@
export {
startWorkbenchBatchReleaseAsync,
startWorkbenchBatchReleaseAsyncV2,
workbenchBatchReleaseSyncV2,
getWorkbenchBatchReleaseProgress,
workbenchScanPick,
type WorkbenchMessageResponse,
type WorkbenchScanPickBody,
} from "./actions";

+ 74
- 0
src/app/api/doworkbench/truckRoutingSummaryWorkbenchApi.ts Целия файл

@@ -0,0 +1,74 @@
"use client";

import { clientAuthFetch } from "@/app/utils/clientAuthFetch";
import { NEXT_PUBLIC_API_URL } from "@/config/api";

export interface WorkbenchReportOption {
label: string;
value: string;
}

export interface WorkbenchTruckRoutingSummaryPrecheck {
unpickedOrderCount: number;
hasUnpickedOrders: boolean;
}

export async function fetchWorkbenchTruckRoutingStoreOptions(): Promise<WorkbenchReportOption[]> {
const response = await clientAuthFetch(
`${NEXT_PUBLIC_API_URL}/doPickOrder/workbench/truck-routing-summary/store-options`,
{
method: "GET",
headers: { "Content-Type": "application/json" },
}
);
if (!response.ok) throw new Error(`Failed to fetch workbench store options: ${response.status}`);
const data = await response.json();
if (!Array.isArray(data)) return [];
return data.map((item: any) => ({
label: item?.label ?? item?.value ?? "",
value: item?.value ?? "",
}));
}

export async function fetchWorkbenchTruckRoutingLaneOptions(storeId?: string): Promise<WorkbenchReportOption[]> {
const qs = storeId ? `?storeId=${encodeURIComponent(storeId)}` : "";
const response = await clientAuthFetch(
`${NEXT_PUBLIC_API_URL}/doPickOrder/workbench/truck-routing-summary/lane-options${qs}`,
{
method: "GET",
headers: { "Content-Type": "application/json" },
}
);
if (!response.ok) throw new Error(`Failed to fetch workbench lane options: ${response.status}`);
const data = await response.json();
if (!Array.isArray(data)) return [];
return data.map((item: any) => ({
label: item?.label ?? item?.value ?? "",
value: item?.value ?? "",
}));
}

export async function fetchWorkbenchTruckRoutingSummaryPrecheck(params: {
storeId: string;
truckLanceCode: string;
date: string;
}): Promise<WorkbenchTruckRoutingSummaryPrecheck> {
const qs = new URLSearchParams({
storeId: params.storeId,
truckLanceCode: params.truckLanceCode,
date: params.date,
}).toString();
const response = await clientAuthFetch(
`${NEXT_PUBLIC_API_URL}/doPickOrder/workbench/truck-routing-summary/precheck?${qs}`,
{
method: "GET",
headers: { "Content-Type": "application/json" },
}
);
if (!response.ok) throw new Error(`Failed to precheck workbench routing summary: ${response.status}`);
const data = await response.json();
return {
unpickedOrderCount: Number(data?.unpickedOrderCount ?? 0),
hasUnpickedOrders: Boolean(data?.hasUnpickedOrders),
};
}

+ 14
- 0
src/app/api/doworkbench/workbenchScanPickUtils.ts Целия файл

@@ -0,0 +1,14 @@
/**
* Pure helpers for workbench scan-pick (not server actions — keep out of `actions.ts` with "use server").
*/

/**
* When true, the server created/reshaped lines (e.g. split pick); UI should reload hierarchical workbench data.
* Normal scans only need to patch the row from `entity`.
*/
export function workbenchScanPickResponseNeedsFullRefresh(res: {
message?: string | null;
}): boolean {
const m = (res.message ?? "").toLowerCase();
return m.includes("next stock-out line") || m.includes("remaining quantity allocated");
}

+ 14
- 4
src/app/api/inventory/actions.ts Целия файл

@@ -71,16 +71,26 @@ export interface SameItemLotInfo {
availableQty: number;
uom: string;
}
export const analyzeQrCode = async (data: {
itemId: number;
stockInLineId: number;
}) => {
/** FG / inventory label modal: same-item lots use in − out − hold. */
export const analyzeQrCode = async (data: { itemId: number; stockInLineId: number }) => {
return serverFetchJson<QrCodeAnalysisResponse>(`${BASE_API_URL}/inventoryLotLine/analyze-qr-code`, {
method: 'POST',
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
});
};

/** DO workbench label modal only: same-item lots use in − out (no hold). */
export const workbenchAnalyzeQrCode = async (data: { itemId: number; stockInLineId: number }) => {
return serverFetchJson<QrCodeAnalysisResponse>(
`${BASE_API_URL}/inventoryLotLine/workbench/analyze-qr-code`,
{
method: 'POST',
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
},
);
};
export const updateInventoryStatus = async (data: {
itemId: number;
lotId: number;


+ 25
- 5
src/app/api/jo/actions.ts Целия файл

@@ -346,6 +346,7 @@ export interface AllJoborderProductProcessInfoResponse {
pickOrderStatus: string;
itemCode: string;
itemName: string;
bomDescription?: string | null;
lotNo: string;
requiredQty: number;
jobOrderId: number;
@@ -536,7 +537,9 @@ export interface AllJoPickOrderResponse {
jobOrderType: string | null;
itemId: number;
itemName: string;
bomDescription?: string | null;
lotNo: string | null;
planStart?: string | number[] | null;
reqQty: number;
uomId: number;
uomName: string;
@@ -606,8 +609,8 @@ export interface StockOutLineDetailResponse {
availableQty: number | null;
noLot: boolean;
/** Workbench API: matched suggest_pick_lot qty for this SOL lot line */
// suggestedPickQty?: number | null;
//suggestedPickLotId?: number | null;
suggestedPickQty?: number | null;
suggestedPickLotId?: number | null;
}

export interface LotDetailResponse {
@@ -718,7 +721,7 @@ export const fetchJobOrderLotsHierarchicalByPickOrderId = cache(async (pickOrder
});

/** JO Workbench: in−out available (matches scan-pick); stockouts include suggestedPickQty / suggestedPickLotId when SPL matches SOL lot line */
/*
export const fetchJobOrderLotsHierarchicalByPickOrderIdWorkbench = cache(
async (pickOrderId: number) => {
return serverFetchJson<JobOrderLotsHierarchicalResponse>(
@@ -730,13 +733,30 @@ export const fetchJobOrderLotsHierarchicalByPickOrderIdWorkbench = cache(
);
},
);
*/
// NOTE: Do NOT wrap in `cache()` because the list needs to reflect just-completed lines
// immediately when navigating back from JobPickExecution.
export const fetchAllJoPickOrders = async (type?: string | null, floor?: string | null) => {
export interface FetchAllJoPickOrdersFilters {
jobOrderCode?: string | null;
pickOrderCode?: string | null;
itemName?: string | null;
bomDescription?: string | null;
planStart?: string | null;
}

export const fetchAllJoPickOrders = async (
type?: string | null,
floor?: string | null,
filters?: FetchAllJoPickOrdersFilters,
) => {
const params = new URLSearchParams();
if (type) params.set("type", type);
if (floor) params.set("floor", floor);
if (filters?.jobOrderCode) params.set("jobOrderCode", filters.jobOrderCode);
if (filters?.pickOrderCode) params.set("pickOrderCode", filters.pickOrderCode);
if (filters?.itemName) params.set("itemName", filters.itemName);
if (filters?.bomDescription) params.set("bomDescription", filters.bomDescription);
if (filters?.planStart) params.set("planStart", filters.planStart);
const query = params.toString() ? `?${params.toString()}` : "";
return serverFetchJson<AllJoPickOrderResponse[]>(
`${BASE_API_URL}/jo/AllJoPickOrder${query}`,


+ 57
- 0
src/app/api/jo/workbenchActions.ts Целия файл

@@ -0,0 +1,57 @@
"use server";

import { cache } from "react";
import { serverFetchJson } from "@/app/utils/fetchUtil";
import { BASE_API_URL } from "@/config/api";
import { revalidateTag } from "next/cache";
import { convertObjToURLSearchParams } from "@/app/utils/commonUtil";
import type {
AssignJobOrderResponse,
CommonActionJoResponse,
SearchJoResultRequest,
SearchJoResultResponse,
} from "@/app/api/jo/actions";

/** Workbench-only release body (no flags — endpoint defines behavior). */
export interface WorkbenchReleaseJoRequest {
id: number;
}

/** Job Order Workbench search — separate URL from `/jo/getRecordByPage`. */
export const fetchJosForWorkbench = cache(async (data?: SearchJoResultRequest) => {
const queryStr = convertObjToURLSearchParams(data);
return serverFetchJson<SearchJoResultResponse>(
`${BASE_API_URL}/jo/workbench/getRecordByPage?${queryStr}`,
{
method: "GET",
headers: { "Content-Type": "application/json" },
next: {
tags: ["jos-workbench"],
},
},
);
});

/** Job Order Workbench release — defers stock out / SPL / SOL to first assign. */
export const releaseJoForWorkbench = cache(async (data: WorkbenchReleaseJoRequest) => {
const response = await serverFetchJson<CommonActionJoResponse>(`${BASE_API_URL}/jo/workbench/release`, {
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
});
revalidateTag("jo");
revalidateTag("jos-workbench");
revalidateTag("jos");
return response;
});

/** Workbench assign — primes SPL/SOL when release was deferred; use only from Jo Workbench UI. */
export const assignJobOrderPickOrderForWorkbench = async (pickOrderId: number, userId: number) => {
return serverFetchJson<AssignJobOrderResponse>(
`${BASE_API_URL}/jo/workbench/assign-job-order-pick-order/${pickOrderId}/${userId}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
},
);
};

+ 127
- 10
src/app/api/pickOrder/actions.ts Целия файл

@@ -462,7 +462,7 @@ export interface LaneBtn {
loadingSequence?: number | null;
unassigned: number;
total: number;
handlerName: string;
handlerName?: string | null;
}

export interface QrPickBatchSubmitRequest {
@@ -694,7 +694,7 @@ export const fetchCompletedDoPickOrders = async (


/** DO workbench: completed tickets from `delivery_order_pick_order.ticketStatus = completed`. **/
/*
export const fetchCompletedDoPickOrdersWorkbench = async (
userId: number,
searchParams?: CompletedDoPickOrderSearchParams,
@@ -723,7 +723,36 @@ export const fetchCompletedDoPickOrdersWorkbench = async (
method: "GET",
});
};
*/

/** DO workbench: completed tickets from `delivery_order_pick_order.ticketStatus = completed` (all users). */
export const fetchCompletedDoPickOrdersWorkbenchAll = async (
searchParams?: CompletedDoPickOrderSearchParams,
): Promise<CompletedDoPickOrderResponse[]> => {
const params = new URLSearchParams();

if (searchParams?.deliveryNoteCode) {
params.append("deliveryNoteCode", searchParams.deliveryNoteCode);
}
if (searchParams?.shopName) {
params.append("shopName", searchParams.shopName);
}
if (searchParams?.targetDate) {
params.append("targetDate", searchParams.targetDate);
}
if (searchParams?.truckLanceCode) {
params.append("truckLanceCode", searchParams.truckLanceCode);
}

const queryString = params.toString();
const url = `${BASE_API_URL}/pickOrder/completed-do-pick-orders-workbench-all${
queryString ? `?${queryString}` : ""
}`;

return serverFetchJson<CompletedDoPickOrderResponse[]>(url, {
method: "GET",
});
};

/** 全部已完成 DO 提貨記錄(不限經手人),需後端 `/completed-do-pick-orders-all` */
export const fetchCompletedDoPickOrdersAll = async (
searchParams?: CompletedDoPickOrderSearchParams
@@ -806,7 +835,7 @@ export const fetchFGPickOrdersByUserId = async (userId: number) => {

/** DO workbench: FG headers from `delivery_order_pick_order`, not `do_pick_order_line`. */

/*
export const fetchFGPickOrdersByUserIdWorkbench = async (userId: number) => {
return serverFetchJson<FGPickOrderResponse[]>(
`${BASE_API_URL}/pickOrder/fg-pick-orders-workbench/${userId}`,
@@ -818,7 +847,7 @@ export const fetchFGPickOrdersByUserIdWorkbench = async (userId: number) => {
},
);
};
*/
export const updateSuggestedLotLineId = async (suggestedPickLotId: number, newLotLineId: number) => {
const response = await serverFetchJson<PostPickOrderResponse<UpdateSuggestedLotLineIdRequest>>(
`${BASE_API_URL}/suggestedPickLot/update-suggested-lot/${suggestedPickLotId}`,
@@ -893,6 +922,83 @@ export const resuggestPickOrder = async (pickOrderId: number) => {
return result;
};

/**
* Workbench suggest (no-hold path target).
* Current backend route is shared with legacy resuggest, but we expose a dedicated
* API name so PickOrder workbench pages can migrate independently.
*/
export const suggestPickOrderWorkbenchV2 = async (pickOrderId: number, userId: number) => {
const result = await serverFetchJson<PostPickOrderResponse>(
`${BASE_API_URL}/pickOrder/workbench/suggest-v2/${pickOrderId}`,
{
method: "POST",
body: JSON.stringify({ userId }),
headers: { "Content-Type": "application/json" },
},
);
revalidateTag("pickorder");
return result;
};

/**
* Workbench release V2 (no-hold): do not create stock_out at release time.
* Downstream suggestion/stock_out_line are created when assigning workbench ticket.
*/
export const releasePickOrderWorkbenchV2 = async (data: {
pickOrderIds: number[];
assignTo: number;
}) => {
const response = await serverFetchJson<PostPickOrderResponse>(
`${BASE_API_URL}/pickOrder/workbench/release-v2`,
{
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
},
);
revalidateTag("pickorder");
return response;
};

/** Consumable workbench hierarchical lots (not DO workbench). */
export const fetchConsumableWorkbenchPickOrderLotsHierarchical = cache(async (userId: number): Promise<any> => {
try {
const data = await serverFetchJson<any>(
`${BASE_API_URL}/pickOrder/workbench/all-lots-hierarchical/${userId}`,
{
method: "GET",
next: { tags: ["pickorder"] },
},
);
return data;
} catch (error) {
console.error("❌ Error fetching consumable workbench hierarchical lot details:", error);
return {
fgInfo: null,
pickOrders: [],
};
}
});

/**
* Workbench assign: assign by delivery_order_pick_order id.
*/
export const assignPickOrderWorkbenchV2 = async (data: {
pickOrderIds: number[];
assignTo: number;
}) => {
const response = await serverFetchJson<PostPickOrderResponse>(
`${BASE_API_URL}/pickOrder/workbench/assign-v2`,
{
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
},
);
revalidateTag("pickorder");
return response;
};

export const updateStockOutLineStatus = async (data: {
id: number;
status: string;
@@ -938,6 +1044,7 @@ export const releaseAssignedPickOrders = async (data: AssignPickOrderInputs) =>
revalidateTag("pickorder");
return response;
};

// Get latest group name and create it automatically
export const getLatestGroupNameAndCreate = async () => {
return serverFetchJson<PostPickOrderResponse>(
@@ -996,9 +1103,11 @@ export const fetchPickOrderDetails = cache(async (ids: string) => {
});
export interface PickOrderLotDetailResponse {
lotId: number | null; // ✅ 改为可空
stockInLineId?: number | null;
lotNo: string | null; // ✅ 改为可空
expiryDate: string | null; // ✅ 改为可空
location: string | null; // ✅ 改为可空
itemId: number | null;
stockUnit: string | null;
inQty: number | null;
availableQty: number | null; // ✅ 改为可空
@@ -1190,15 +1299,14 @@ export const fetchAllPickOrderLotsHierarchical = cache(async (userId: number): P
};
}
});

/** DO workbench: hierarchical lots where header is `delivery_order_pick_order`. */
/*
export const fetchAllPickOrderLotsHierarchicalWorkbench = cache(async (userId: number): Promise<any> => {
try {
const data = await serverFetchJson<any>(
`${BASE_API_URL}/pickOrder/all-lots-hierarchical-workbench/${userId}`,
{
method: "GET",
method: 'GET',
next: { tags: ["pickorder"] },
},
);
@@ -1211,7 +1319,8 @@ export const fetchAllPickOrderLotsHierarchicalWorkbench = cache(async (userId: n
};
}
});
*/


export const fetchLotDetailsByDoPickOrderRecordId = async (doPickOrderRecordId: number): Promise<{
fgInfo: any;
pickOrders: any[];
@@ -1272,7 +1381,15 @@ export const fetchPickOrderLineLotDetails = cache(async (pickOrderLineId: number
);
});


export const fetchWorkbenchPickOrderLineDetailV2 = cache(async (pickOrderLineId: number) => {
return serverFetchJson<PickOrderLotDetailResponse[]>(
`${BASE_API_URL}/pickOrder/workbench/line-detail-v2/${pickOrderLineId}`,
{
method: "GET",
next: { tags: ["pickorder"] },
},
);
});





+ 17
- 0
src/app/api/stockTake/actions.ts Целия файл

@@ -394,6 +394,11 @@ export interface BatchSaveApproverStockTakeAllRequest {
sectionDescription?: string | null;
stockTakeSections?: string | null; // 逗號字串
}
export interface BatchSaveApproverStockTakeByIdsRequest {
stockTakeId: number;
approverId: number;
recordIds: number[];
}
export const saveApproverStockTakeRecord = async (
request: SaveApproverStockTakeRecordRequest,
stockTakeId: number
@@ -451,6 +456,18 @@ export const batchSaveApproverStockTakeRecordsAll = cache(async (data: BatchSave
return r
})

export const batchSaveApproverStockTakeRecordsByIds = cache(async (data: BatchSaveApproverStockTakeByIdsRequest) => {
const r = await serverFetchJson<BatchSaveApproverStockTakeRecordResponse>(
`${BASE_API_URL}/stockTakeRecord/batchSaveApproverStockTakeRecordsByIds`,
{
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
}
)
return r
})

export const updateStockTakeRecordStatusToNotMatch = async (
stockTakeRecordId: number
) => {


+ 9
- 2
src/app/utils/fetchUtil.ts Целия файл

@@ -93,8 +93,11 @@ export const serverFetch: typeof fetch = async (input, init) => {
type FetchParams = Parameters<typeof fetch>;

export async function serverFetchJson<T>(...args: FetchParams) {
const url = String(args[0]);
const t0 = performance.now();
const response = await serverFetch(...args);
console.log("serverFetchJson - Status:", response.status, "URL:", args[0]);
const t1 = performance.now();
console.log(`[serverFetchJson] ${response.status} ${(t1 - t0).toFixed(1)}ms ${url}`);
if (response.ok) {
if (response.status === 204) {
return response.status as T;
@@ -117,7 +120,11 @@ export async function serverFetchJson<T>(...args: FetchParams) {
}

export async function serverFetchString<T>(...args: FetchParams) {
const response = await serverFetch(...args);
const url = String(args[0]);
const t0 = performance.now();
const response = await serverFetch(...args);
const t1 = performance.now();
console.log(`[serverFetchJson] ${response.status} ${(t1 - t0).toFixed(1)}ms ${url}`);

if (response.ok) {
return response.text() as T;


+ 5
- 3
src/components/Breadcrumb/Breadcrumb.tsx Целия файл

@@ -37,9 +37,10 @@ const pathToLabelMap: { [path: string]: string } = {
"/inventory": "Inventory",
"/settings/importTesting": "Import Testing",
"/do": "Delivery Order",
//"/doworkbench": "DO Workbench",
// "/doworkbench/pick": "DO Workbench pick",
// "/doworkbench/edit": "DO Workbench detail",
"/doworkbench": "DO Workbench",
"/doworkbenchsearch": "DO Workbench Search",
"/doworkbench/pick": "DO Workbench pick",
"/doworkbench/edit": "DO Workbench detail",
"/pickOrder": "Pick Order",
"/po": "Purchase Order",
"/po/workbench": "PO Workbench",
@@ -47,6 +48,7 @@ const pathToLabelMap: { [path: string]: string } = {
"/jo": "Job Order",
"/jo/edit": "Edit Job Order",
"/jo/testing": "Job order testing",
"/jo/workbench": "Job Order Workbench",
"/putAway": "Put Away",
"/stockIssue": "Stock Issue",
"/report": "Report",


+ 28
- 6
src/components/DoSearch/DoSearch.tsx Целия файл

@@ -2,6 +2,10 @@

import { DoResult } from "@/app/api/do";
import { DoSearchAll, DoSearchLiteResponse, fetchDoSearch, fetchAllDoSearch, fetchDoSearchList, releaseDo ,startBatchReleaseAsync, getBatchReleaseProgress} from "@/app/api/do/actions";
import {
startWorkbenchBatchReleaseAsyncV2,
getWorkbenchBatchReleaseProgress,
} from "@/app/api/doworkbench/actions";

import { useRouter } from "next/navigation";
import React, { ForwardedRef, useCallback, useEffect, useMemo, useState } from "react";
@@ -74,7 +78,7 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea

const [searchAllDos, setSearchAllDos] = useState<DoSearchAll[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [isWorkbench, setIsWorkbench] = useState(false);
const [pagingController, setPagingController] = useState({
pageNum: 1,
pageSize: 10,
@@ -485,7 +489,7 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => {
}
}, [hasSearched, currentSearchParams]);

const handleBatchRelease = useCallback(async () => {
const handleBatchRelease = useCallback(async (isWorkbench: boolean) => {
try {
// 根据当前搜索条件获取所有匹配的记录(不分页)
let estArrStartDate = currentSearchParams.estimatedArrivalDate;
@@ -575,7 +579,14 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => {
if (result.isConfirmed) {
try {
const startRes = await startBatchReleaseAsync({ ids: idsToRelease, userId: currentUserId ?? 1 });
let startRes ;
if(isWorkbench){
startRes = await startWorkbenchBatchReleaseAsyncV2({ ids: idsToRelease, userId: currentUserId ?? 1 });
}
else{
startRes = await startBatchReleaseAsync({ ids: idsToRelease, userId: currentUserId ?? 1 });
}
//await startBatchReleaseAsync({ ids: idsToRelease, userId: currentUserId ?? 1 });
const jobId = startRes?.entity?.jobId;
if (!jobId) {
@@ -596,7 +607,9 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => {
const timer = setInterval(async () => {
try {
const p = await getBatchReleaseProgress(jobId);
const p = isWorkbench
? await getWorkbenchBatchReleaseProgress(jobId)
: await getBatchReleaseProgress(jobId);
const e = p?.entity || {};
const total = e.total ?? 0;
@@ -659,14 +672,23 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => {
onSubmit={formProps.handleSubmit(onSubmit, onSubmitError)}
>
{hasSearched && hasResults && (
<Stack direction="row" justifyContent="flex-end" sx={{ mb: 1 }}>
<Stack direction="row" justifyContent="flex-end" spacing={2}sx={{ mb: 1 }}>
<Button
name="batch_release"
variant="contained"
onClick={() => handleBatchRelease(true)}
>
{t("Workbench Batch Release")}
</Button>
{/*
<Button
name="batch_release"
variant="contained"
onClick={handleBatchRelease}
onClick={() => handleBatchRelease(false)}
>
{t("Batch Release")}
</Button>
*/}
</Stack>
)}



+ 737
- 0
src/components/DoSearchWorkbench/DoSearchWorkbench.tsx Целия файл

@@ -0,0 +1,737 @@
"use client";

import { DoResult } from "@/app/api/do";
import { DoSearchAll, DoSearchLiteResponse, fetchDoSearch, fetchAllDoSearch, fetchDoSearchList, releaseDo } from "@/app/api/do/actions";
import {
startWorkbenchBatchReleaseAsyncV2,
getWorkbenchBatchReleaseProgress,
} from "@/app/api/doworkbench/actions";

import { useRouter } from "next/navigation";
import React, { ForwardedRef, useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Criterion } from "../SearchBox";
import { isEmpty, sortBy, uniqBy, upperFirst } from "lodash";
import { arrayToDateString, arrayToDayjs } from "@/app/utils/formatUtil";
import SearchBox from "../SearchBox/SearchBox";
import { EditNote } from "@mui/icons-material";
import InputDataGrid from "../InputDataGrid";
import { CreateConsoDoInput } from "@/app/api/do/actions";
import { TableRow } from "../InputDataGrid/InputDataGrid";
import {
FooterPropsOverrides,
GridColDef,
GridRowModel,
GridToolbarContainer,
useGridApiRef,
} from "@mui/x-data-grid";
import {
FormProvider,
SubmitErrorHandler,
SubmitHandler,
useForm,
} from "react-hook-form";
import { Box, Button, Paper, Stack, Typography, TablePagination } from "@mui/material";
import StyledDataGrid from "../StyledDataGrid";
import { GridRowSelectionModel } from "@mui/x-data-grid";
import Swal from "sweetalert2";
import { useSession } from "next-auth/react";
import { SessionWithTokens } from "@/config/authConfig";

type Props = {
filterArgs?: Record<string, any>;
searchQuery?: Record<string, any>;
onDeliveryOrderSearch?: () => void;
/** 明細頁路由前綴,預設 `/doworkbench`;在 `/do copy 2` 等別名頁面請傳對應 base */
workbenchHrefBase?: string;
};
type SearchBoxInputs = Record<"code" | "status" | "estimatedArrivalDate" | "orderDate" | "supplierName" | "shopName" | "deliveryOrderLines" | "truckLanceCode" | "codeTo" | "statusTo" | "estimatedArrivalDateTo" | "orderDateTo" | "supplierNameTo" | "shopNameTo" | "deliveryOrderLinesTo" | "truckLanceCodeTo", string>;
type SearchParamNames = keyof SearchBoxInputs;

// put all this into a new component
// ConsoDoForm
type EntryError =
| {
[field in keyof DoResult]?: string;
}
| undefined;
type DoRow = TableRow<Partial<DoResult>, EntryError>;


const DoSearchWorkbench: React.FC<Props> = ({
filterArgs,
searchQuery,
onDeliveryOrderSearch,
workbenchHrefBase = "/doworkbench",
}) => {
const apiRef = useGridApiRef();

const formProps = useForm<CreateConsoDoInput>({
defaultValues: {},
});
const { setValue } = formProps;
const errors = formProps.formState.errors;

const { t } = useTranslation("do");
const router = useRouter();
const { data: session } = useSession() as { data: SessionWithTokens | null };
const currentUserId = session?.id ? parseInt(session.id) : undefined;
console.log("🔍 DoSearch - session:", session);
console.log("🔍 DoSearch - currentUserId:", currentUserId);
const [searchTimeout, setSearchTimeout] = useState<NodeJS.Timeout | null>(null);
/** 使用者明確取消勾選的送貨單 id;未在此集合中的搜尋結果視為「已選」以便跨頁記憶 */
const [excludedRowIds, setExcludedRowIds] = useState<number[]>([]);

const [searchAllDos, setSearchAllDos] = useState<DoSearchAll[]>([]);
const [totalCount, setTotalCount] = useState(0);

const [pagingController, setPagingController] = useState({
pageNum: 1,
pageSize: 10,
});

const [currentSearchParams, setCurrentSearchParams] = useState<SearchBoxInputs>({
code: "",
status: "",
estimatedArrivalDate: "",
orderDate: "",
supplierName: "",
shopName: "",
deliveryOrderLines: "",
truckLanceCode: "", // 添加这个字段
codeTo: "",
statusTo: "",
estimatedArrivalDateTo: "",
orderDateTo: "",
supplierNameTo: "",
shopNameTo: "",
deliveryOrderLinesTo: "",
truckLanceCodeTo: "" // 这个字段已经存在,但需要确保在类型定义中
});

const [hasSearched, setHasSearched] = useState(false);
const [hasResults, setHasResults] = useState(false);

const excludedIdSet = useMemo(() => new Set(excludedRowIds), [excludedRowIds]);

const rowSelectionModel = useMemo<GridRowSelectionModel>(() => {
return searchAllDos
.map((r) => r.id)
.filter((id) => !excludedIdSet.has(id));
}, [searchAllDos, excludedIdSet]);

const applyRowSelectionChange = useCallback(
(newModel: GridRowSelectionModel) => {
const pageIds = searchAllDos.map((r) => r.id);
const selectedSet = new Set(
newModel.map((id) => (typeof id === "string" ? Number(id) : id)),
);
setExcludedRowIds((prev) => {
const next = new Set(prev);
for (const id of pageIds) {
next.delete(id);
}
for (const id of pageIds) {
if (!selectedSet.has(id)) {
next.add(id);
}
}
return Array.from(next);
});
setValue("ids", newModel);
},
[searchAllDos, setValue],
);

// 当搜索条件变化时,重置到第一页
useEffect(() => {
setPagingController(p => ({
...p,
pageNum: 1,
}));
}, [currentSearchParams.code, currentSearchParams.shopName, currentSearchParams.status, currentSearchParams.estimatedArrivalDate]);


const searchCriteria: Criterion<SearchParamNames>[] = useMemo(
() => [
{ label: t("Code"), paramName: "code", type: "text" },
{ label: t("Shop Name"), paramName: "shopName", type: "text" },
{ label: t("Truck Lance Code"), paramName: "truckLanceCode", type: "text" },
{
label: t("Estimated Arrival"),
paramName: "estimatedArrivalDate",
type: "date",
},
{
label: t("Status"),
paramName: "status",
type: "autocomplete",
options:[
{label: t('Pending'), value: 'pending'},
{label: t('Receiving'), value: 'receiving'},
{label: t('Completed'), value: 'completed'}
]
}
],
[t],
);

const onReset = useCallback(async () => {
try {
setSearchAllDos([]);
setTotalCount(0);
setHasSearched(false);
setHasResults(false);
setExcludedRowIds([]);
setPagingController({ pageNum: 1, pageSize: 10 });
}
catch (error) {
console.error("Error: ", error);
setSearchAllDos([]);
setTotalCount(0);
}
}, []);

const onDetailClick = useCallback(
(doResult: DoResult) => {
if (typeof window !== 'undefined') {
sessionStorage.setItem('doSearchParams', JSON.stringify(currentSearchParams));
}
const base = workbenchHrefBase.replace(/\/$/, "");
router.push(`${base}/edit?id=${doResult.id}`);
},
[router, currentSearchParams, workbenchHrefBase],
);

const validationTest = useCallback(
(
newRow: GridRowModel<DoRow>,
): EntryError => {
const error: EntryError = {};
console.log(newRow);
return Object.keys(error).length > 0 ? error : undefined;
},
[],
);

const columns = useMemo<GridColDef[]>(
() => [
{
field: "id",
headerName: t("Details"),
width: 100,
renderCell: (params) => (
<Button
variant="outlined"
size="small"
startIcon={<EditNote />}
onClick={() => onDetailClick(params.row)}
>
{t("Details")}
</Button>
),
},
{
field: "code",
headerName: t("code"),
flex: 1.5,
},
{
field: "shopName",
headerName: t("Shop Name"),
flex: 1,
},
{
field: "supplierName",
headerName: t("Supplier Name"),
flex: 1,
},
{
field: "truckLanceCode",
headerName: t("Truck Lance Code"),
flex: 1,
},
{
field: "orderDate",
headerName: t("Order Date"),
flex: 1,
renderCell: (params) => {
return params.row.orderDate
? arrayToDateString(params.row.orderDate)
: "N/A";
},
},
{
field: "estimatedArrivalDate",
headerName: t("Estimated Arrival"),
flex: 1,
renderCell: (params) => {
return params.row.estimatedArrivalDate
? arrayToDateString(params.row.estimatedArrivalDate)
: "N/A";
},
},
{
field: "status",
headerName: t("Status"),
flex: 1,
renderCell: (params) => {
return t(upperFirst(params.row.status));
},
},
],
[t, arrayToDateString, onDetailClick],
);

const onSubmit = useCallback<SubmitHandler<CreateConsoDoInput>>(
async (data, event) => {
const hasErrors = false;
console.log(errors);
},
[errors],
);
const onSubmitError = useCallback<SubmitErrorHandler<CreateConsoDoInput>>(
(errors) => {},
[],
);

//SEARCH FUNCTION
const handleSearch = useCallback(async (query: SearchBoxInputs) => {
try {
setCurrentSearchParams(query);

let estArrStartDate = query.estimatedArrivalDate;
const time = "T00:00:00";
if(estArrStartDate != ""){
estArrStartDate = query.estimatedArrivalDate + time;
}
let status = "";
if(query.status == "All"){
status = "";
}
else{
status = query.status;
}
// 调用新的 API,传入分页参数和 truckLanceCode
const response = await fetchDoSearch(
query.code || "",
query.shopName || "",
status,
"", // orderStartDate - 不再使用
"", // orderEndDate - 不再使用
estArrStartDate,
"", // estArrEndDate - 不再使用
pagingController.pageNum, // 传入当前页码
pagingController.pageSize, // 传入每页大小
query.truckLanceCode || "" // 添加 truckLanceCode 参数
);

setSearchAllDos(response.records);
setTotalCount(response.total); // 设置总记录数
setHasSearched(true);
setHasResults(response.records.length > 0);
setExcludedRowIds([]);

} catch (error) {
console.error("Error: ", error);
setSearchAllDos([]);
setTotalCount(0);
setHasSearched(true);
setHasResults(false);
setExcludedRowIds([]);
}
}, [pagingController]);

useEffect(() => {
if (typeof window !== 'undefined') {
const savedSearchParams = sessionStorage.getItem('doSearchParams');
if (savedSearchParams) {
try {
const params = JSON.parse(savedSearchParams);
setCurrentSearchParams(params);
// 自动使用保存的搜索条件重新搜索,获取最新数据
const timer = setTimeout(async () => {
await handleSearch(params);
// 搜索完成后,清除 sessionStorage
if (typeof window !== 'undefined') {
sessionStorage.removeItem('doSearchParams');
sessionStorage.removeItem('doSearchResults');
sessionStorage.removeItem('doSearchHasSearched');
}
}, 100);
return () => clearTimeout(timer);
} catch (e) {
console.error('Error restoring search state:', e);
// 如果出错,也清除 sessionStorage
if (typeof window !== 'undefined') {
sessionStorage.removeItem('doSearchParams');
sessionStorage.removeItem('doSearchResults');
sessionStorage.removeItem('doSearchHasSearched');
}
}
}
}
}, [handleSearch]);

const debouncedSearch = useCallback((query: SearchBoxInputs) => {
if (searchTimeout) {
clearTimeout(searchTimeout);
}
const timeout = setTimeout(() => {
handleSearch(query);
}, 300);
setSearchTimeout(timeout);
}, [handleSearch, searchTimeout]);

// 分页变化时重新搜索
const handlePageChange = useCallback((event: unknown, newPage: number) => {
const newPagingController = {
...pagingController,
pageNum: newPage + 1,
};
setPagingController(newPagingController);
// 如果已经搜索过,重新搜索
if (hasSearched && currentSearchParams) {
// 使用新的分页参数重新搜索
const searchWithNewPage = async () => {
try {
let estArrStartDate = currentSearchParams.estimatedArrivalDate;
const time = "T00:00:00";
if(estArrStartDate != ""){
estArrStartDate = currentSearchParams.estimatedArrivalDate + time;
}
let status = "";
if(currentSearchParams.status == "All"){
status = "";
}
else{
status = currentSearchParams.status;
}
const response = await fetchDoSearch(
currentSearchParams.code || "",
currentSearchParams.shopName || "",
status,
"",
"",
estArrStartDate,
"",
newPagingController.pageNum,
newPagingController.pageSize,
currentSearchParams.truckLanceCode || "" // 添加这个参数
);
setSearchAllDos(response.records);
setTotalCount(response.total);
} catch (error) {
console.error("Error: ", error);
}
};
searchWithNewPage();
}
}, [pagingController, hasSearched, currentSearchParams]);

const handlePageSizeChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const newPageSize = parseInt(event.target.value, 10);
const newPagingController = {
pageNum: 1, // 改变每页大小时重置到第一页
pageSize: newPageSize,
};
setPagingController(newPagingController);
// 如果已经搜索过,重新搜索
if (hasSearched && currentSearchParams) {
const searchWithNewPageSize = async () => {
try {
let estArrStartDate = currentSearchParams.estimatedArrivalDate;
const time = "T00:00:00";
if(estArrStartDate != ""){
estArrStartDate = currentSearchParams.estimatedArrivalDate + time;
}
let status = "";
if(currentSearchParams.status == "All"){
status = "";
}
else{
status = currentSearchParams.status;
}
const response = await fetchDoSearch(
currentSearchParams.code || "",
currentSearchParams.shopName || "",
status,
"",
"",
estArrStartDate,
"",
1, // 重置到第一页
newPageSize,
currentSearchParams.truckLanceCode || "" // 添加这个参数
);
setSearchAllDos(response.records);
setTotalCount(response.total);
} catch (error) {
console.error("Error: ", error);
}
};
searchWithNewPageSize();
}
}, [hasSearched, currentSearchParams]);

const handleBatchRelease = useCallback(async () => {
try {
// 根据当前搜索条件获取所有匹配的记录(不分页)
let estArrStartDate = currentSearchParams.estimatedArrivalDate;
const time = "T00:00:00";
if(estArrStartDate != ""){
estArrStartDate = currentSearchParams.estimatedArrivalDate + time;
}
let status = "";
if(currentSearchParams.status == "All"){
status = "";
}
else{
status = currentSearchParams.status;
}
// 显示加载提示
const loadingSwal = Swal.fire({
title: t("Loading"),
text: t("Fetching all matching records..."),
allowOutsideClick: false,
allowEscapeKey: false,
showConfirmButton: false,
didOpen: () => {
Swal.showLoading();
}
});
// 获取所有匹配的记录
const allMatchingDos = await fetchAllDoSearch(
currentSearchParams.code || "",
currentSearchParams.shopName || "",
status,
estArrStartDate,
currentSearchParams.truckLanceCode || ""
);
Swal.close();
if (allMatchingDos.length === 0) {
await Swal.fire({
icon: "warning",
title: t("No Records"),
text: t("No matching records found for batch release."),
confirmButtonText: t("OK")
});
return;
}

const idsToRelease = allMatchingDos
.map((d) => d.id)
.filter((id) => !excludedIdSet.has(id));

if (idsToRelease.length === 0) {
await Swal.fire({
icon: "warning",
title: t("No Records"),
text: t("No delivery orders selected for batch release. Uncheck orders you want to exclude, or search again to reset selection."),
confirmButtonText: t("OK"),
});
return;
}
// 显示确认对话框
const result = await Swal.fire({
icon: "question",
title: t("Batch Release"),
html: `
<div>
<p>${t("Selected Shop(s): ")}${idsToRelease.length}</p>
<p style="font-size: 0.9em; color: #666; margin-top: 8px;">
${currentSearchParams.code ? `${t("Code")}: ${currentSearchParams.code} ` : ""}
${currentSearchParams.shopName ? `${t("Shop Name")}: ${currentSearchParams.shopName} ` : ""}
${currentSearchParams.estimatedArrivalDate ? `${t("Estimated Arrival")}: ${currentSearchParams.estimatedArrivalDate} ` : ""}
${status ? `${t("Status")}: ${status} ` : ""}
</p>
</div>
`,
showCancelButton: true,
confirmButtonText: t("Confirm"),
cancelButtonText: t("Cancel"),
confirmButtonColor: "#8dba00",
cancelButtonColor: "#F04438"
});
if (result.isConfirmed) {
try {
const startRes = await startWorkbenchBatchReleaseAsyncV2({ ids: idsToRelease, userId: currentUserId ?? 1 });
const startEntity = startRes?.entity as { jobId?: string } | undefined;
const jobId = startEntity?.jobId;
if (!jobId) {
await Swal.fire({ icon: "error", title: t("Error"), text: t("Failed to start batch release") });
return;
}
const progressSwal = Swal.fire({
title: t("Releasing"),
text: "0% (0 / 0)",
allowOutsideClick: false,
allowEscapeKey: false,
showConfirmButton: false,
didOpen: () => {
Swal.showLoading();
}
});
const timer = setInterval(async () => {
try {
const p = await getWorkbenchBatchReleaseProgress(jobId);

const e = (p?.entity || {}) as {
total?: number;
finished?: number;
running?: boolean;
};
const total = e.total ?? 0;
const finished = e.finished ?? 0;
const percentage = total > 0 ? Math.round((finished / total) * 100) : 0;
const textContent = document.querySelector('.swal2-html-container');
if (textContent) {
textContent.textContent = `${percentage}% (${finished} / ${total})`;
}
if (p.code === "FINISHED" || e.running === false) {
clearInterval(timer);
await new Promise(resolve => setTimeout(resolve, 500));
Swal.close();
await Swal.fire({
icon: "success",
title: t("Completed"),
text: t("Batch release completed successfully."),
confirmButtonText: t("Confirm"),
confirmButtonColor: "#8dba00"
});
if (currentSearchParams && Object.keys(currentSearchParams).length > 0) {
await handleSearch(currentSearchParams);
}
}
} catch (err) {
console.error("progress poll error:", err);
}
}, 800);
} catch (error) {
console.error("Batch release error:", error);
await Swal.fire({
icon: "error",
title: t("Error"),
text: t("An error occurred during batch release"),
confirmButtonText: t("OK")
});
}
}
} catch (error) {
console.error("Error fetching all matching records:", error);
await Swal.fire({
icon: "error",
title: t("Error"),
text: t("Failed to fetch matching records"),
confirmButtonText: t("OK")
});
}
}, [t, currentUserId, currentSearchParams, handleSearch, excludedIdSet]);

return (
<>
<FormProvider {...formProps}>
<Stack
spacing={2}
component="form"
onSubmit={formProps.handleSubmit(onSubmit, onSubmitError)}
>
{hasSearched && hasResults && (
<Stack direction="row" justifyContent="flex-end" sx={{ mb: 1 }}>
<Button
name="batch_release"
variant="contained"
onClick={handleBatchRelease}
>
{t("Workbench batch release", { defaultValue: "Workbench batch release" })}
</Button>
</Stack>
)}

<SearchBox
criteria={searchCriteria}
onSearch={handleSearch}
onReset={onReset}
/>

<Paper variant="outlined" sx={{ overflow: "hidden" }}>
<StyledDataGrid
rows={searchAllDos}
columns={columns}
checkboxSelection
rowSelectionModel={rowSelectionModel}
onRowSelectionModelChange={applyRowSelectionChange}
slots={{
footer: FooterToolbar,
noRowsOverlay: NoRowsOverlay,
}}
/>
<TablePagination
component="div"
count={totalCount}
page={(pagingController.pageNum - 1)}
rowsPerPage={pagingController.pageSize}
onPageChange={handlePageChange}
onRowsPerPageChange={handlePageSizeChange}
rowsPerPageOptions={[10, 25, 50]}
/>
</Paper>

</Stack>
</FormProvider>
</>
);
};


const FooterToolbar: React.FC<FooterPropsOverrides> = ({ child }) => {
return <GridToolbarContainer sx={{ p: 2 }}>{child}</GridToolbarContainer>;
};
const NoRowsOverlay: React.FC = () => {
const { t } = useTranslation("home");
return (
<Box
display="flex"
justifyContent="center"
alignItems="center"
height="100%"
>
<Typography variant="caption">{t("Add some entries!")}</Typography>
</Box>
);
};

export default DoSearchWorkbench;

+ 1
- 0
src/components/DoSearchWorkbench/index.ts Целия файл

@@ -0,0 +1 @@
export { default } from "./DoSearchWorkbench";

+ 96
- 0
src/components/DoWorkbench/DoWorkbenchPickShell.tsx Целия файл

@@ -0,0 +1,96 @@
"use client";

import { Box, CircularProgress } from "@mui/material";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useSession } from "next-auth/react";
import { SessionWithTokens } from "@/config/authConfig";
import {
fetchAllPickOrderLotsHierarchicalWorkbench,
} from "@/app/api/pickOrder/actions";
import WorkbenchFloorLanePanel from "./WorkbenchFloorLanePanel";
import WorkbenchGoodPickExecutionDetail from "./WorkbenchGoodPickExecutionDetail";

/**
* FG workbench: 未指派顯示樓層/車線指派;已指派顯示揀貨明細(workbench API)。
*/
const DoWorkbenchPickShell: React.FC = () => {
const { data: session, status } = useSession() as {
data: SessionWithTokens | null;
status: "loading" | "authenticated" | "unauthenticated";
};
const currentUserId = session?.id ? parseInt(session.id, 10) : undefined;
const [showDetail, setShowDetail] = useState(false);
const [viewLoading, setViewLoading] = useState(true);
const filterArgs = useMemo(() => ({}), []);

const refreshWorkbenchView = useCallback(async () => {
if (!currentUserId) {
setShowDetail(false);
setViewLoading(false);
return;
}
setViewLoading(true);
try {
const data = await fetchAllPickOrderLotsHierarchicalWorkbench(currentUserId);
const ticketStatus = String(data?.ticketStatus ?? "").trim().toLowerCase();
const hasDetailData = Boolean(data?.fgInfo && Array.isArray(data?.pickOrders) && data.pickOrders.length > 0);
const shouldShowDetail = ticketStatus !== "completed" && (ticketStatus.length > 0 || hasDetailData);
setShowDetail(shouldShowDetail);
} catch {
setShowDetail(false);
} finally {
setViewLoading(false);
}
}, [currentUserId]);

useEffect(() => {
if (status === "loading") return;
if (status !== "authenticated" || !currentUserId) {
setViewLoading(false);
setShowDetail(false);
return;
}
void refreshWorkbenchView();
}, [status, currentUserId, refreshWorkbenchView]);

useEffect(() => {
const onAssigned = () => {
void refreshWorkbenchView();
};
window.addEventListener("pickOrderAssigned", onAssigned);
return () => window.removeEventListener("pickOrderAssigned", onAssigned);
}, [refreshWorkbenchView]);

const onWorkbenchHierarchyEmpty = useCallback(() => {
void refreshWorkbenchView();
}, [refreshWorkbenchView]);

if (status === "loading") {
return (
<Box sx={{ display: "flex", justifyContent: "center", p: 3, width: "100%" }}>
<CircularProgress />
</Box>
);
}

const showAssignmentSpinner = viewLoading;

return (
<Box sx={{ width: "100%" }}>
{showAssignmentSpinner ? (
<Box sx={{ display: "flex", justifyContent: "center", p: 3 }}>
<CircularProgress />
</Box>
) : !showDetail ? (
<WorkbenchFloorLanePanel onPickOrderAssigned={() => void refreshWorkbenchView()} />
) : (
<WorkbenchGoodPickExecutionDetail
filterArgs={filterArgs}
onWorkbenchHierarchyEmpty={onWorkbenchHierarchyEmpty}
/>
)}
</Box>
);
};

export default DoWorkbenchPickShell;

+ 232
- 0
src/components/DoWorkbench/DoWorkbenchTabs.tsx Целия файл

@@ -0,0 +1,232 @@
"use client";

import { Autocomplete, Box, Tab, Tabs, TextField, Typography } from "@mui/material";
import React from "react";
import DoWorkbenchPickShell from "./DoWorkbenchPickShell";
import type { PrinterCombo } from "@/app/api/settings/printer";
import GoodPickExecutionWorkbenchRecord from "./GoodPickExecutionWorkbenchRecord";
import { useTranslation } from "react-i18next";
import WorkbenchTicketReleaseTableTab from "./WorkbenchTicketReleaseTable";
import { Stack } from "@mui/system";
import Swal from "sweetalert2";
import { printDNWorkbench } from "@/app/api/do/actions";
import { fetchWorkbenchReleasedDoPickOrdersForSelectionToday } from "@/app/api/doworkbench/actions";
import { Button } from "@mui/material";
import FinishedGoodCartonDashboardTab from "../FinishedGoodSearch/FinishedGoodCartonDashboardTab";
import TruckRoutingSummaryTabWorkbench from "./TruckRoutingSummaryTabWorkbench";
type Props = {
defaultTabIndex?: 0 | 1;
printerCombo?: PrinterCombo[];
};

function TabPanel(props: { value: number; index: number; children: React.ReactNode }) {
const { value, index, children } = props;
if (value !== index) return null;
return <Box sx={{ pt: 2 }}>{children}</Box>;
}

const DoWorkbenchTabs: React.FC<Props> = ({ defaultTabIndex = 0, printerCombo = [] }) => {
const [tab, setTab] = React.useState<number>(defaultTabIndex);
const [a4Printer, setA4Printer] = React.useState<PrinterCombo | null>(null);
const [labelPrinter, setLabelPrinter] = React.useState<PrinterCombo | null>(null);
const [releasedOrderCount, setReleasedOrderCount] = React.useState(0);
const { t } = useTranslation( );
const a4Printers = React.useMemo(
() => (printerCombo || []).filter((printer) => printer.type === "A4"),
[printerCombo],
);
const labelPrinters = React.useMemo(
() => (printerCombo || []).filter((printer) => printer.type === "Label"),
[printerCombo],
);

const fetchReleasedOrderCount = React.useCallback(async () => {
try {
const releasedOrders = await fetchWorkbenchReleasedDoPickOrdersForSelectionToday();
setReleasedOrderCount(releasedOrders.length);
} catch (error) {
console.error("Error fetching workbench released order count:", error);
setReleasedOrderCount(0);
}
}, []);

React.useEffect(() => {
void fetchReleasedOrderCount();
}, [fetchReleasedOrderCount]);

const handleAllDraft = React.useCallback(async () => {
try {
if (!a4Printer) {
await Swal.fire({
position: "bottom-end",
icon: "warning",
text: t("Please select a printer first"),
showConfirmButton: false,
timer: 1500,
});
return;
}

const releasedOrders = await fetchWorkbenchReleasedDoPickOrdersForSelectionToday();
if (releasedOrders.length === 0) {
await Swal.fire({
title: "",
text: t("No released pick order records found."),
icon: "info",
});
return;
}

const confirmResult = await Swal.fire({
title: t("Batch Print"),
text: `${t("Confirm print: (")}${releasedOrders.length}${t("piece(s))")}`,
icon: "question",
showCancelButton: true,
confirmButtonText: t("Confirm"),
cancelButtonText: t("Cancel"),
confirmButtonColor: "#8dba00",
cancelButtonColor: "#F04438",
});
if (!confirmResult.isConfirmed) return;

Swal.fire({
title: t("Printing..."),
text: t("Please wait..."),
allowOutsideClick: false,
allowEscapeKey: false,
didOpen: () => Swal.showLoading(),
});

for (const order of releasedOrders) {
const printRequest = {
printerId: a4Printer.id,
printQty: 1,
isDraft: true,
numOfCarton: 0,
deliveryOrderPickOrderId: order.id,
};
const response = await printDNWorkbench(printRequest);
if (!response.success) {
console.error(`Workbench print draft failed for deliveryOrderPickOrderId ${order.id}:`, response.message);
}
}

Swal.close();
await Swal.fire({
position: "bottom-end",
icon: "success",
text: t("Printed Successfully."),
showConfirmButton: false,
timer: 1500,
});
await fetchReleasedOrderCount();
} catch (error) {
Swal.close();
console.error("Error in workbench handleAllDraft:", error);
await Swal.fire({
icon: "error",
text: t("An error occurred during batch print"),
});
}
}, [a4Printer, t, fetchReleasedOrderCount]);

return (
<Box>
<Stack
direction="row"
spacing={2}
sx={{
alignItems: "center",
justifyContent: "flex-end",
flexWrap: "wrap",
rowGap: 1,
mb: 1,
}}
>
<Typography variant="body2" sx={{ minWidth: "fit-content" }}>
{t("A4 Printer")}:
</Typography>
<Autocomplete
options={a4Printers}
getOptionLabel={(option) => option.name || option.label || option.code || `Printer ${option.id}`}
value={a4Printer}
onChange={(_, newValue) => setA4Printer(newValue)}
sx={{ minWidth: 200 }}
size="small"
renderInput={(params) => (
<TextField
{...params}
placeholder={t("A4 Printer")}
inputProps={{ ...params.inputProps, readOnly: true }}
/>
)}
/>
<Typography variant="body2" sx={{ minWidth: "fit-content" }}>
{t("Label Printer")}:
</Typography>
<Autocomplete
options={labelPrinters}
getOptionLabel={(option) => option.name || option.label || option.code || `Printer ${option.id}`}
value={labelPrinter}
onChange={(_, newValue) => setLabelPrinter(newValue)}
sx={{ minWidth: 200 }}
size="small"
renderInput={(params) => (
<TextField
{...params}
placeholder={t("Label Printer")}
inputProps={{ ...params.inputProps, readOnly: true }}
/>
)}
/>
<Button
variant="contained"
onClick={() => void handleAllDraft()}
>
{`${t("Print All Draft")} (${releasedOrderCount})`}
</Button>
</Stack>
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ borderBottom: 1, borderColor: "divider" }}>
<Tab label={t("Pick Order Detail")} value={0} />
<Tab label={t("Finished Good Record")} value={1} />
<Tab label={t("Finished Good Record (All)")} value={2} />
<Tab label={t("Ticket Release Table")} value={3} />
<Tab label={t("成品出倉出箱數量")} value={5} />
<Tab label={t("送貨路線摘要")} value={6} />
</Tabs>

<TabPanel value={tab} index={0}>
<DoWorkbenchPickShell />
</TabPanel>
<TabPanel value={tab} index={1}>
<GoodPickExecutionWorkbenchRecord
printerCombo={printerCombo}
listScope="mine"
a4Printer={a4Printer}
labelPrinter={labelPrinter}
/>
</TabPanel>
<TabPanel value={tab} index={2}>
<GoodPickExecutionWorkbenchRecord
printerCombo={printerCombo}
listScope="all"
a4Printer={a4Printer}
labelPrinter={labelPrinter}
/>
</TabPanel>
<TabPanel value={tab} index={3}>
<WorkbenchTicketReleaseTableTab />
</TabPanel>
<TabPanel value={tab} index={5}>
<FinishedGoodCartonDashboardTab mode="workbench" />
</TabPanel>
<TabPanel value={tab} index={6}>
<TruckRoutingSummaryTabWorkbench />
</TabPanel>

</Box>
);
};

export default DoWorkbenchTabs;


+ 659
- 0
src/components/DoWorkbench/GoodPickExecutionWorkbenchRecord.tsx Целия файл

@@ -0,0 +1,659 @@
"use client";

import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
Button,
Card,
CardActions,
CardContent,
Chip,
CircularProgress,
Paper,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
} from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import type { PrinterCombo } from "@/app/api/settings/printer";
import { useTranslation } from "react-i18next";
import Swal from "sweetalert2";
import dayjs from "dayjs";
import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil";
import { useSession } from "next-auth/react";
import { SessionWithTokens } from "@/config/authConfig";
import {
CompletedDoPickOrderResponse,
fetchCompletedDoPickOrdersWorkbench,
fetchCompletedDoPickOrdersWorkbenchAll,
} from "@/app/api/pickOrder/actions";
import { printDNWorkbench, printDNLabelsWorkbench, printDNLabelsReprintWorkbench } from "@/app/api/do/actions";
import { fetchWorkbenchCompletedLotDetails } from "@/app/api/doworkbench/actions";
import SearchBox, { Criterion } from "../SearchBox";

type Props = {
printerCombo: PrinterCombo[];
listScope?: "mine" | "all";
a4Printer: PrinterCombo | null;
labelPrinter: PrinterCombo | null;
};

const GoodPickExecutionWorkbenchRecord: React.FC<Props> = ({
printerCombo,
listScope = "mine",
a4Printer,
labelPrinter,
}) => {
const { t } = useTranslation("pickOrder");
const { data: session } = useSession() as { data: SessionWithTokens | null };
const currentUserId = session?.id ? parseInt(session.id, 10) : undefined;

const [loading, setLoading] = useState(false);
const [records, setRecords] = useState<CompletedDoPickOrderResponse[]>([]);
const [searchQuery, setSearchQuery] = useState<Record<string, any>>({
targetDate: dayjs().format("YYYY-MM-DD"),
});
const [showDetailView, setShowDetailView] = useState(false);
const [selectedRecord, setSelectedRecord] = useState<CompletedDoPickOrderResponse | null>(null);
const [detailLotData, setDetailLotData] = useState<any[]>([]);

const loadData = useCallback(async (searchParams?: {
targetDate?: string;
shopName?: string;
deliveryNoteCode?: string;
truckLanceCode?: string;
}) => {
setLoading(true);
try {
const data =
listScope === "all"
? await fetchCompletedDoPickOrdersWorkbenchAll(searchParams)
: currentUserId
? await fetchCompletedDoPickOrdersWorkbench(currentUserId, searchParams)
: [];
setRecords(data || []);
} catch (e) {
console.error("Failed to load workbench completed records:", e);
setRecords([]);
} finally {
setLoading(false);
}
}, [currentUserId, listScope]);

useEffect(() => {
void loadData({ targetDate: dayjs().format("YYYY-MM-DD") });
}, [loadData]);

const searchCriteria: Criterion<any>[] = useMemo(
() => [
{
label: t("Delivery Note Code"),
paramName: "deliveryNoteCode",
type: "text",
},
{
label: t("Shop Name"),
paramName: "shopName",
type: "text",
},
{
label: t("Truck Lance Code"),
paramName: "truckLanceCode",
type: "text",
},
{
label: t("Target Date"),
paramName: "targetDate",
type: "date",
defaultValue: dayjs().format("YYYY-MM-DD"),
},
],
[t],
);

const handleSearch = useCallback((query: Record<string, any>) => {
setSearchQuery({ ...query });
void loadData({
targetDate: query.targetDate || undefined,
shopName: query.shopName || undefined,
deliveryNoteCode: query.deliveryNoteCode || undefined,
truckLanceCode: query.truckLanceCode || undefined,
});
}, [loadData]);

const handleSearchReset = useCallback(() => {
const today = dayjs().format("YYYY-MM-DD");
const resetQuery = { targetDate: today };
setSearchQuery(resetQuery);
void loadData({ targetDate: today });
}, [loadData]);

const searchDateDisplay = useMemo(() => {
const raw = searchQuery.targetDate;
if (raw && String(raw).trim() !== "") {
const d = dayjs(raw);
return d.isValid() ? d.format(OUTPUT_DATE_FORMAT) : t("All dates");
}
return t("All dates");
}, [searchQuery.targetDate, t]);

const askNumOfCarton = useCallback(async () => {
const result = await Swal.fire({
title: t("Enter the number of cartons: "),
icon: "info",
input: "number",
inputPlaceholder: t("Number of cartons"),
inputAttributes: {
min: "1",
step: "1",
},
inputValidator: (value) => {
if (!value) return t("You need to enter a number");
if (parseInt(value, 10) < 1) return t("Number must be at least 1");
return null;
},
showCancelButton: true,
confirmButtonText: t("Confirm"),
cancelButtonText: t("Cancel"),
confirmButtonColor: "#8dba00",
cancelButtonColor: "#F04438",
});
if (!result.isConfirmed) return null;
return parseInt(result.value, 10);
}, [t]);

const handlePrintDN = useCallback(
async (recordId: number) => {
if (!a4Printer) {
await Swal.fire({
position: "bottom-end",
icon: "warning",
text: t("Please select a printer first"),
showConfirmButton: false,
timer: 1500,
});
return;
}
const cartonQty = await askNumOfCarton();
if (!cartonQty) return;
const response = await printDNWorkbench({
deliveryOrderPickOrderId: recordId,
printerId: a4Printer.id,
printQty: 1,
isDraft: false,
numOfCarton: cartonQty,
});
if (response.success) {
await Swal.fire({
position: "bottom-end",
icon: "success",
text: t("Printed Successfully."),
showConfirmButton: false,
timer: 1500,
});
await loadData();
}
},
[a4Printer, askNumOfCarton, loadData, t],
);

const handlePrintLabel = useCallback(
async (recordId: number) => {
if (!labelPrinter) {
await Swal.fire({
position: "bottom-end",
icon: "warning",
text: t("Please select a label printer first"),
showConfirmButton: false,
timer: 1500,
});
return;
}
const cartonQty = await askNumOfCarton();
if (!cartonQty) return;
const response = await printDNLabelsWorkbench({
deliveryOrderPickOrderId: recordId,
printerId: labelPrinter.id,
printQty: 1,
numOfCarton: cartonQty,
});
if (response.success) {
await Swal.fire({
position: "bottom-end",
icon: "success",
text: t("Printed Successfully."),
showConfirmButton: false,
timer: 1500,
});
await loadData();
}
},
[askNumOfCarton, labelPrinter, loadData, t],
);

const handlePrintBoth = useCallback(
async (recordId: number) => {
if (!a4Printer || !labelPrinter) {
await Swal.fire({
position: "bottom-end",
icon: "warning",
text: t("Please select a printer first"),
showConfirmButton: false,
timer: 1500,
});
return;
}
const cartonQty = await askNumOfCarton();
if (!cartonQty) return;
const [labelRes, dnRes] = await Promise.all([
printDNLabelsWorkbench({
deliveryOrderPickOrderId: recordId,
printerId: labelPrinter.id,
printQty: 1,
numOfCarton: cartonQty,
}),
printDNWorkbench({
deliveryOrderPickOrderId: recordId,
printerId: a4Printer.id,
printQty: 1,
isDraft: false,
numOfCarton: cartonQty,
}),
]);
if (labelRes.success && dnRes.success) {
await Swal.fire({
position: "bottom-end",
icon: "success",
text: t("Printed Successfully."),
showConfirmButton: false,
timer: 1500,
});
await loadData();
}
},
[a4Printer, askNumOfCarton, labelPrinter, loadData, t],
);
const handleLabelReprint = useCallback(async (doPickOrder: CompletedDoPickOrderResponse) => {
if (!labelPrinter) {
Swal.fire({
position: "bottom-end",
icon: "warning",
text: t("Please select a label printer first"),
showConfirmButton: false,
timer: 1500
});
return;
}

const defaultTotalCartons = Math.max(1, doPickOrder.numberOfCartons || 1);
const result = await Swal.fire({
title: t("Reprint DN Label"),
html: `
<div style="display:flex;flex-direction:column;gap:10px;text-align:left;">
<div style="display:flex;align-items:center;gap:12px;">
<label for="swal-from-carton" style="min-width:120px;">${t("From carton")}</label>
<input id="swal-from-carton" class="swal2-input" type="number" min="1" step="1" value="1" style="margin:0;flex:1;outline:none;box-shadow:none;border:1px solid #d9d9d9;" onfocus="this.style.outline='none';this.style.boxShadow='none';this.style.borderColor='#d9d9d9';" />
</div>
<div style="display:flex;align-items:center;gap:12px;">
<label for="swal-to-carton" style="min-width:120px;">${t("To carton")}</label>
<input id="swal-to-carton" class="swal2-input" type="number" min="1" step="1" value="1" style="margin:0;flex:1;outline:none;box-shadow:none;border:1px solid #d9d9d9;" onfocus="this.style.outline='none';this.style.boxShadow='none';this.style.borderColor='#d9d9d9';" />
</div>
<div style="display:flex;align-items:center;gap:12px;">
<label for="swal-total-carton" style="min-width:120px;">${t("Total cartons on shipment")}</label>
<input id="swal-total-carton" class="swal2-input" type="number" min="1" step="1" value="${defaultTotalCartons}" style="margin:0;flex:1;outline:none;box-shadow:none;border:1px solid #d9d9d9;" onfocus="this.style.outline='none';this.style.boxShadow='none';this.style.borderColor='#d9d9d9';" />
</div>
</div>
`,
showCancelButton: true,
confirmButtonText: t("Confirm"),
cancelButtonText: t("Cancel"),
confirmButtonColor: "#8dba00",
cancelButtonColor: "#F04438",
focusConfirm: false,
preConfirm: () => {
const fromCarton = Number((document.getElementById("swal-from-carton") as HTMLInputElement | null)?.value || "0");
const toCarton = Number((document.getElementById("swal-to-carton") as HTMLInputElement | null)?.value || "0");
const totalCartonsOnShipment = Number((document.getElementById("swal-total-carton") as HTMLInputElement | null)?.value || "0");

if (!Number.isInteger(fromCarton) || fromCarton < 1) {
Swal.showValidationMessage(t("From carton must be at least 1"));
return null;
}
if (!Number.isInteger(toCarton) || toCarton < fromCarton) {
Swal.showValidationMessage(t("To carton must be greater than or equal to from carton"));
return null;
}
if (!Number.isInteger(totalCartonsOnShipment) || totalCartonsOnShipment < 1) {
Swal.showValidationMessage(t("Total cartons on shipment must be at least 1"));
return null;
}
if (toCarton > totalCartonsOnShipment) {
Swal.showValidationMessage(t("To carton cannot be greater than total cartons on shipment"));
return null;
}

return {
fromCarton,
toCarton,
totalCartonsOnShipment,
};
}
});

if (!result.isConfirmed || !result.value) {
return;
}

try {
const response = await printDNLabelsReprintWorkbench({
deliveryOrderPickOrderId: doPickOrder.doPickOrderRecordId,
printerId: labelPrinter.id,
printQty: 1,
fromCarton: result.value.fromCarton,
toCarton: result.value.toCarton,
totalCartonsOnShipment: result.value.totalCartonsOnShipment,
});

if (response.success) {
Swal.fire({
position: "bottom-end",
icon: "success",
text: t("Printed Successfully."),
showConfirmButton: false,
timer: 1500
});
} else {
console.error("Reprint failed:", response.message);
}
} catch (error) {
console.error("reprint error: ", error);
}
}, [labelPrinter, t]);

const handleDetailClick = useCallback(
async (record: CompletedDoPickOrderResponse) => {
setSelectedRecord(record);
setShowDetailView(true);
try {
const hierarchicalData = await fetchWorkbenchCompletedLotDetails(record.doPickOrderRecordId);
const flatLotData: any[] = [];

if (hierarchicalData?.pickOrders?.length > 0) {
const toProc = (s?: string) => {
if (!s) return "pending";
const v = s.toLowerCase();
if (v === "completed" || v === "complete") return "completed";
if (v === "rejected") return "rejected";
if (v === "partially_completed") return "pending";
return "pending";
};

hierarchicalData.pickOrders.forEach((po: any) => {
po.pickOrderLines?.forEach((line: any) => {
const lineRequiredQty = Number(line.requiredQty ?? line.qty ?? 0);
const lineStockouts = line.stockouts || [];
const lots = line.lots || [];

if (lots.length > 0) {
lots.forEach((lot: any) => {
const sos = lineStockouts.filter((so: any) => (so.lotId ?? null) === (lot.id ?? null));
if (sos.length > 0) {
sos.forEach((so: any) => {
flatLotData.push({
pickOrderCode: po.pickOrderCodes?.[0] || po.pickOrderCode,
itemCode: line.item?.code,
itemName: line.item?.name,
lotNo: so.lotNo || lot.lotNo,
location: so.location || lot.location,
deliveryOrderCode: po.deliveryOrderCodes?.[0] || po.deliveryOrderCode,
requiredQty: lineRequiredQty,
actualPickQty: so.qty ?? lot.actualPickQty ?? 0,
processingStatus: toProc(so.status),
stockOutLineStatus: so.status,
noLot: so.noLot === true,
});
});
} else {
flatLotData.push({
pickOrderCode: po.pickOrderCodes?.[0] || po.pickOrderCode,
itemCode: line.item?.code,
itemName: line.item?.name,
lotNo: lot.lotNo,
location: lot.location,
deliveryOrderCode: po.deliveryOrderCodes?.[0] || po.deliveryOrderCode,
requiredQty: lot.requiredQty,
actualPickQty: lot.actualPickQty ?? 0,
processingStatus: lot.processingStatus || "pending",
stockOutLineStatus: lot.stockOutLineStatus || "pending",
noLot: false,
});
}
});
} else if (lineStockouts.length > 0) {
lineStockouts.forEach((so: any) => {
flatLotData.push({
pickOrderCode: po.pickOrderCodes?.[0] || po.pickOrderCode,
itemCode: line.item?.code,
itemName: line.item?.name,
lotNo: so.lotNo || "",
location: so.location || "",
deliveryOrderCode: po.deliveryOrderCodes?.[0] || po.deliveryOrderCode,
requiredQty: line.requiredQty ?? 0,
actualPickQty: so.qty ?? 0,
processingStatus: toProc(so.status),
stockOutLineStatus: so.status,
noLot: so.noLot === true,
});
});
}
});
});
}
setDetailLotData(flatLotData);
} catch (e) {
console.error("Failed to load completed lot details:", e);
setDetailLotData([]);
}
},
[],
);

const handleBackToList = useCallback(() => {
setShowDetailView(false);
setSelectedRecord(null);
setDetailLotData([]);
}, []);

if (showDetailView && selectedRecord) {
return (
<Box>
<Box sx={{ mb: 2, display: "flex", alignItems: "center", gap: 2 }}>
<Button variant="outlined" onClick={handleBackToList}>
{t("Back to List")}
</Button>
<Typography variant="h6">
{t("Pick Order Details")}: {selectedRecord.ticketNo}
</Typography>
</Box>

<Paper sx={{ mb: 2, p: 2 }}>
<Stack spacing={1}>
<Typography variant="subtitle1">
<strong>{t("Shop Name")}:</strong> {selectedRecord.shopName}
</Typography>
<Typography variant="subtitle1">
<strong>{t("Store ID")}:</strong> {selectedRecord.storeId}
</Typography>
<Typography variant="subtitle1">
<strong>{t("Ticket No.")}:</strong> {selectedRecord.ticketNo}
</Typography>
<Typography variant="subtitle1">
<strong>{t("Handler")}:</strong>{" "}
{selectedRecord.handlerName?.trim() ? selectedRecord.handlerName : "—"}
</Typography>
<Typography variant="subtitle1">
<strong>{t("Truck Lance Code")}:</strong> {selectedRecord.truckLanceCode}
</Typography>
<Typography variant="subtitle1">
<strong>{t("Completed Date")}:</strong>{" "}
{selectedRecord.completedDate ? dayjs(selectedRecord.completedDate).format(OUTPUT_DATE_FORMAT) : "-"}
</Typography>
</Stack>
</Paper>

{detailLotData.length === 0 ? (
<Box sx={{ p: 3, textAlign: "center" }}>
<Typography variant="body2" color="text.secondary">
{t("No lot details found for this order")}
</Typography>
</Box>
) : (
<Stack spacing={2}>
{Object.entries(
detailLotData.reduce((acc: any, lot: any) => {
const key = lot.pickOrderCode || "Unknown";
if (!acc[key]) acc[key] = { lots: [], deliveryOrderCode: lot.deliveryOrderCode || "N/A" };
acc[key].lots.push(lot);
return acc;
}, {}),
).map(([pickOrderCode, data]: [string, any]) => (
<Accordion key={pickOrderCode} defaultExpanded>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="subtitle1" fontWeight="bold">
{t("Pick Order")}: {pickOrderCode} ({data.lots.length} {t("items")}){" | "}
{t("Delivery Order")}: {data.deliveryOrderCode}
</Typography>
</AccordionSummary>
<AccordionDetails>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>{t("Index")}</TableCell>
<TableCell>{t("Item Code")}</TableCell>
<TableCell>{t("Item Name")}</TableCell>
<TableCell>{t("Lot No")}</TableCell>
<TableCell>{t("Location")}</TableCell>
<TableCell align="right">{t("Required Qty")}</TableCell>
<TableCell align="right">{t("Actual Pick Qty")}</TableCell>
<TableCell align="center">{t("Status")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{data.lots.map((lot: any, index: number) => (
<TableRow key={index}>
<TableCell>{index + 1}</TableCell>
<TableCell>{lot.itemCode || "N/A"}</TableCell>
<TableCell>{lot.itemName || "N/A"}</TableCell>
<TableCell>{lot.lotNo || "N/A"}</TableCell>
<TableCell>{lot.location || "N/A"}</TableCell>
<TableCell align="right">{lot.requiredQty || 0}</TableCell>
<TableCell align="right">{lot.actualPickQty || 0}</TableCell>
<TableCell align="center">
<Chip
label={t(lot.processingStatus || "unknown")}
color={lot.processingStatus === "completed" ? "success" : "default"}
size="small"
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</AccordionDetails>
</Accordion>
))}
</Stack>
)}
</Box>
);
}

return (
<Box>
<Box sx={{ mb: 2 }}>
<SearchBox
criteria={searchCriteria}
onSearch={handleSearch}
onReset={handleSearchReset}
// searchQuery={searchQuery}
/>
</Box>
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
sx={{ mb: 2, gap: 2, flexWrap: "wrap" }}
>
<Typography variant="body2" color="text.secondary">
{t("Search date")}: {searchDateDisplay} | {t("Completed DO pick orders: ")} {records.length}
</Typography>
</Stack>

{loading ? (
<Box sx={{ display: "flex", justifyContent: "center", p: 3 }}>
<CircularProgress />
</Box>
) : records.length === 0 ? (
<Box sx={{ p: 3, textAlign: "center" }}>
<Typography variant="body2" color="text.secondary">
{t("No completed DO pick orders found")}
</Typography>
</Box>
) : (
<Stack spacing={2}>
{records.map((row) => (
<Card key={row.id}>
<CardContent>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Box>
<Typography variant="h6">{row.deliveryNoteCode || "-"}</Typography>
<Typography variant="body2" color="text.secondary">
{row.shopName}
</Typography>
<Typography variant="body2" color="text.secondary">
{t("Completed")}:{" "}
{row.completedDate ? dayjs(row.completedDate).format(OUTPUT_DATE_FORMAT) : "-"}
</Typography>
<Typography variant="body2" color="text.secondary">
{t("Ticket No.")}: {row.ticketNo || "-"}
</Typography>
</Box>
<Chip label={t("completed")} color="success" size="small" />
</Stack>
</CardContent>
<CardActions>
<Button variant="outlined" onClick={() => void handleDetailClick(row)}>
{t("View Details")}
</Button>
<Button variant="contained" onClick={() => void handlePrintDN(row.doPickOrderRecordId)}>
{t("Print Pick Order")}
</Button>
<Button variant="contained" onClick={() => void handlePrintBoth(row.doPickOrderRecordId)}>
{t("Print DN & Label")}
</Button>
<Button variant="contained" onClick={() => void handlePrintLabel(row.doPickOrderRecordId)}>
{t("Print Label")}
</Button>
<Button variant="contained" onClick={() => void handleLabelReprint(row)}>
{t("Reprint Label(s)")}
</Button>
</CardActions>
</Card>
))}
</Stack>
)}
</Box>
);
};

export default GoodPickExecutionWorkbenchRecord;

+ 155
- 0
src/components/DoWorkbench/TruckRoutingSummaryTabWorkbench.tsx Целия файл

@@ -0,0 +1,155 @@
"use client";

import { useEffect, useState } from "react";
import { Box, Button, MenuItem, Stack, TextField, Typography } from "@mui/material";
import DownloadIcon from "@mui/icons-material/Download";
import { clientAuthFetch } from "@/app/utils/clientAuthFetch";
import { NEXT_PUBLIC_API_URL } from "@/config/api";
import {
WorkbenchReportOption,
fetchWorkbenchTruckRoutingLaneOptions,
fetchWorkbenchTruckRoutingStoreOptions,
fetchWorkbenchTruckRoutingSummaryPrecheck,
} from "@/app/api/doworkbench/truckRoutingSummaryWorkbenchApi";
import {
FEATURE_USAGE,
FEATURE_USAGE_ACTION,
logFeatureUsage,
} from "@/lib/featureUsageLog";

const TruckRoutingSummaryTabWorkbench: React.FC = () => {
const [storeOptions, setStoreOptions] = useState<WorkbenchReportOption[]>([]);
const [laneOptions, setLaneOptions] = useState<WorkbenchReportOption[]>([]);
const [storeId, setStoreId] = useState("");
const [truckLanceCode, setTruckLanceCode] = useState("");
const [date, setDate] = useState("");
const [loading, setLoading] = useState(false);

useEffect(() => {
fetchWorkbenchTruckRoutingStoreOptions()
.then(setStoreOptions)
.catch((err) => console.error("Failed to load workbench store options", err));
}, []);

const onStoreChange = async (value: string) => {
setStoreId(value);
setTruckLanceCode("");
try {
const lanes = await fetchWorkbenchTruckRoutingLaneOptions(value);
setLaneOptions(lanes);
} catch (error) {
console.error("Failed to load workbench lane options", error);
setLaneOptions([]);
}
};

const canDownload = storeId && truckLanceCode && date && !loading;

const onDownload = async () => {
if (!canDownload) return;
try {
const precheck = await fetchWorkbenchTruckRoutingSummaryPrecheck({
storeId,
truckLanceCode,
date,
});
if (precheck.hasUnpickedOrders) {
const confirmed = window.confirm(
`此車線仍有 ${precheck.unpickedOrderCount} 張訂單未執拾。\n是否仍要列印 / 下載送貨路線摘要?`
);
if (!confirmed) return;
}

setLoading(true);
const qs = new URLSearchParams({
storeId,
truckLanceCode,
date,
}).toString();
const url = `${NEXT_PUBLIC_API_URL}/doPickOrder/workbench/truck-routing-summary/print?${qs}`;
const response = await clientAuthFetch(url, {
method: "GET",
headers: { Accept: "application/pdf" },
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP ${response.status}: ${errorText}`);
}

const blob = await response.blob();
const downloadUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = downloadUrl;
link.setAttribute("download", "TruckRoutingSummary.pdf");
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(downloadUrl);
logFeatureUsage(
FEATURE_USAGE.TRUCK_ROUTING_SUMMARY,
FEATURE_USAGE_ACTION.DOWNLOAD,
`workbench-pdf:${storeId}:${truckLanceCode}:${date}`,
);
} catch (error) {
console.error("Failed to download Workbench Truck Routing Summary", error);
alert("下載 Workbench 送貨路線摘要失敗,請稍後再試。");
} finally {
setLoading(false);
}
};

return (
<Box sx={{ maxWidth: 820 }}>
<Typography variant="h6" sx={{ mb: 2 }}>
送貨路線摘要 (Workbench)
</Typography>
<Stack direction={{ xs: "column", md: "row" }} spacing={2} sx={{ mb: 2 }}>
<TextField
select
fullWidth
label="2/F 或 4/F"
value={storeId}
onChange={(e) => onStoreChange(e.target.value)}
>
{storeOptions.map((opt) => (
<MenuItem key={opt.value} value={opt.value}>
{opt.label}
</MenuItem>
))}
</TextField>
<TextField
select
fullWidth
label="車線"
value={truckLanceCode}
onChange={(e) => setTruckLanceCode(e.target.value)}
disabled={!storeId}
>
{laneOptions.map((opt) => (
<MenuItem key={opt.value} value={opt.value}>
{opt.label}
</MenuItem>
))}
</TextField>
<TextField
fullWidth
label="日期"
type="date"
value={date}
InputLabelProps={{ shrink: true }}
onChange={(e) => setDate(e.target.value)}
/>
</Stack>
<Button
variant="contained"
startIcon={<DownloadIcon />}
disabled={!canDownload}
onClick={onDownload}
>
{loading ? "生成中..." : "下載報告 (PDF)"}
</Button>
</Box>
);
};

export default TruckRoutingSummaryTabWorkbench;

+ 484
- 0
src/components/DoWorkbench/WorkbenchFloorLanePanel.tsx Целия файл

@@ -0,0 +1,484 @@
"use client";

import { Box, Button, Grid, Stack, Typography, Select, MenuItem, FormControl, InputLabel } from "@mui/material";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useSession } from "next-auth/react";
import { SessionWithTokens } from "@/config/authConfig";
import type { StoreLaneSummary, LaneRow, LaneBtn } from "@/app/api/pickOrder/actions";
import {
assignByDeliveryOrderPickOrderId,
assignWorkbenchByLane,
fetchWorkbenchReleasedDoPickOrdersForSelection,
fetchWorkbenchReleasedDoPickOrdersForSelectionToday,
fetchWorkbenchStoreLaneSummary,
} from "@/app/api/doworkbench/actions";
import Swal from "sweetalert2";
import dayjs from "dayjs";
import ReleasedDoPickOrderSelectModal from "@/components/FinishedGoodSearch/ReleasedDoPickOrderSelectModal";

interface Props {
onPickOrderAssigned?: () => void;
onSwitchToDetailTab?: () => void;
initialReleaseType?: string;
}

type LaneSlot4F = { truckDepartureTime: string; lane: LaneBtn };
type TruckGroup4F = { truckLanceCode: string; slots: (LaneSlot4F & { sequenceIndex: number })[] };

const WorkbenchFloorLanePanel: React.FC<Props> = ({ onPickOrderAssigned, onSwitchToDetailTab, initialReleaseType = "batch" }) => {
const { t } = useTranslation("pickOrder");
const { data: session } = useSession() as { data: SessionWithTokens | null };
const currentUserId = session?.id ? parseInt(session.id) : undefined;
const [selectedStore, setSelectedStore] = useState<string>("2/F");
const [selectedTruck, setSelectedTruck] = useState<string>("");
const [modalOpen, setModalOpen] = useState(false);
const [truckCounts2F, setTruckCounts2F] = useState<{ truck: string; count: number }[]>([]);
const [truckCounts4F, setTruckCounts4F] = useState<{ truck: string; count: number }[]>([]);
const [summary2F, setSummary2F] = useState<StoreLaneSummary | null>(null);
const [summary4F, setSummary4F] = useState<StoreLaneSummary | null>(null);
const [defaultDateScope, setDefaultDateScope] = useState<"today" | "before">("today");
const [isLoadingSummary, setIsLoadingSummary] = useState(false);
const [isAssigning, setIsAssigning] = useState(false);
const [isDefaultTruck, setIsDefaultTruck] = useState(false);
const [beforeTodayTruckXCount, setBeforeTodayTruckXCount] = useState(0);
const [selectedDate, setSelectedDate] = useState<string>("today");
const [releaseType, setReleaseType] = useState<string>(initialReleaseType);
const [ticketFloor, setTicketFloor] = useState<"2/F" | "4/F">("2/F");
const defaultTruckCount = summary4F?.defaultTruckCount ?? 0;

const hasLoggedRef = useRef(false);
const fullReadyLoggedRef = useRef(false);
const pendingRef = useRef(0);

const workbenchReleasedListBridge = useMemo(
() => ({
loadBeforeToday: fetchWorkbenchReleasedDoPickOrdersForSelection,
loadToday: fetchWorkbenchReleasedDoPickOrdersForSelectionToday,
assignByListItemId: assignByDeliveryOrderPickOrderId,
}),
[],
);

const startFullTimer = () => {
if (typeof window === "undefined") return;
const key = "__FG_FLOOR_FULL_TIMER_STARTED__" as const;
if (!(window as any)[key]) {
(window as any)[key] = true;
console.time("[FG] FloorLanePanel full ready");
}
};
const tryEndFullTimer = () => {
if (typeof window === "undefined") return;
const key = "__FG_FLOOR_FULL_TIMER_STARTED__" as const;
if ((window as any)[key] && !fullReadyLoggedRef.current && pendingRef.current === 0) {
fullReadyLoggedRef.current = true;
console.timeEnd("[FG] FloorLanePanel full ready");
delete (window as any)[key];
}
};

const loadSummaries = useCallback(async () => {
setIsLoadingSummary(true);
pendingRef.current += 1;
startFullTimer();
try {
let dateParam: string | undefined;
if (selectedDate === "today") dateParam = dayjs().format("YYYY-MM-DD");
else if (selectedDate === "tomorrow") dateParam = dayjs().add(1, "day").format("YYYY-MM-DD");
else if (selectedDate === "dayAfterTomorrow") dateParam = dayjs().add(2, "day").format("YYYY-MM-DD");
const [s2, s4] = await Promise.all([
fetchWorkbenchStoreLaneSummary("2/F", dateParam, releaseType),
fetchWorkbenchStoreLaneSummary("4/F", dateParam, releaseType),
]);
setSummary2F(s2);
setSummary4F(s4);
} catch (error) {
console.error("Error loading summaries:", error);
} finally {
setIsLoadingSummary(false);
pendingRef.current -= 1;
tryEndFullTimer();
if (!hasLoggedRef.current) {
hasLoggedRef.current = true;
}
}
}, [selectedDate, releaseType]);

useEffect(() => {
void loadSummaries();
}, [loadSummaries]);

useEffect(() => {
const loadCounts = async () => {
pendingRef.current += 1;
startFullTimer();
try {
const [list2F, list4F] = await Promise.all([
fetchWorkbenchReleasedDoPickOrdersForSelection(undefined, "2/F"),
fetchWorkbenchReleasedDoPickOrdersForSelection(undefined, "4/F"),
]);
const groupByTruck = (list: { truckLanceCode?: string | null }[]) => {
const map: Record<string, number> = {};
list.forEach((item) => {
const lane = item.truckLanceCode || "-";
map[lane] = (map[lane] || 0) + 1;
});
return Object.entries(map)
.map(([truck, count]) => ({ truck, count }))
.sort((a, b) => a.truck.localeCompare(b.truck));
};
setTruckCounts2F(groupByTruck(list2F));
setTruckCounts4F(groupByTruck(list4F));
} catch (e) {
console.error("Error loading counts:", e);
setTruckCounts2F([]);
setTruckCounts4F([]);
} finally {
pendingRef.current -= 1;
tryEndFullTimer();
}
};
void loadCounts();
}, [loadSummaries]);

useEffect(() => {
const loadBeforeTodayTruckX = async () => {
pendingRef.current += 1;
startFullTimer();
try {
const list = await fetchWorkbenchReleasedDoPickOrdersForSelection(undefined, undefined, "車線-X");
setBeforeTodayTruckXCount(list.length);
} catch {
setBeforeTodayTruckXCount(0);
} finally {
pendingRef.current -= 1;
tryEndFullTimer();
}
};
void loadBeforeTodayTruckX();
}, []);

const handleAssignByLane = useCallback(
async (storeId: string, truckDepartureTime: string, truckLanceCode: string, requiredDate: string) => {
if (!currentUserId) return;
let dateParam: string | undefined;
if (requiredDate === "today") dateParam = dayjs().format("YYYY-MM-DD");
else if (requiredDate === "tomorrow") dateParam = dayjs().add(1, "day").format("YYYY-MM-DD");
else if (requiredDate === "dayAfterTomorrow") dateParam = dayjs().add(2, "day").format("YYYY-MM-DD");
setIsAssigning(true);
try {
const res = await assignWorkbenchByLane({
userId: currentUserId,
storeId,
truckLanceCode,
truckDepartureTime,
requiredDate: dateParam,
});
console.log("assignByLane result:", res);
if (res.code === "SUCCESS") {
window.dispatchEvent(new CustomEvent("pickOrderAssigned"));
void loadSummaries();
onPickOrderAssigned?.();
onSwitchToDetailTab?.();
}
} catch {
await Swal.fire({ icon: "error", title: t("Error"), text: t("Error occurred during assignment."), confirmButtonText: t("Confirm"), confirmButtonColor: "#8dba00" });
} finally {
setIsAssigning(false);
}
},
[currentUserId, loadSummaries, onPickOrderAssigned, onSwitchToDetailTab, t],
);

const handleLaneButtonClick = useCallback(
async (
storeId: string,
truckDepartureTime: string,
truckLanceCode: string,
loadingSequence: number | null | undefined,
requiredDate: string,
unassigned: number,
total: number,
) => {
let dateDisplay = requiredDate;
if (requiredDate === "today") dateDisplay = dayjs().format("YYYY-MM-DD");
else if (requiredDate === "tomorrow") dateDisplay = dayjs().add(1, "day").format("YYYY-MM-DD");
else if (requiredDate === "dayAfterTomorrow") dateDisplay = dayjs().add(2, "day").format("YYYY-MM-DD");
const result = await Swal.fire({
title: t("Confirm Assignment"),
html: `<div style="text-align: left; padding: 10px 0;">
<p><strong>${t("Store")}:</strong> ${storeId}</p>
<p><strong>${t("Lane Code")}:</strong> ${truckLanceCode}</p>
${loadingSequence != null ? `<p><strong>${t("Loading Sequence")}:</strong> ${loadingSequence}</p>` : ""}
<p><strong>${t("Departure Time")}:</strong> ${truckDepartureTime}</p>
<p><strong>${t("Required Date")}:</strong> ${dateDisplay}</p>
<p><strong>${t("Available Orders")}:</strong> ${unassigned}/${total}</p>
</div>`,
icon: "question",
showCancelButton: true,
confirmButtonText: t("Confirm"),
cancelButtonText: t("Cancel"),
confirmButtonColor: "#8dba00",
cancelButtonColor: "#F04438",
});
if (result.isConfirmed) {
await handleAssignByLane(storeId, truckDepartureTime, truckLanceCode, requiredDate);
}
},
[handleAssignByLane, t],
);

const getDateLabel = (offset: number) => dayjs().add(offset, "day").format("YYYY-MM-DD");

const truckGroups4F = useMemo((): TruckGroup4F[] => {
const rows = summary4F?.rows as LaneRow[] | undefined;
if (!rows?.length) return [];
const map = new Map<string, LaneSlot4F[]>();
for (const row of rows) {
for (const lane of row.lanes) {
const list = map.get(lane.truckLanceCode);
const slot: LaneSlot4F = { truckDepartureTime: row.truckDepartureTime, lane };
if (list) list.push(slot);
else map.set(lane.truckLanceCode, [slot]);
}
}
return Array.from(map.entries()).map(([truckLanceCode, slots]) => ({
truckLanceCode,
slots: slots
.slice()
.sort((a, b) => (a.lane.loadingSequence ?? 999) - (b.lane.loadingSequence ?? 999))
.map((s, i) => ({ ...s, sequenceIndex: i + 1 })),
}));
}, [summary4F?.rows]);

const renderNoEntry = () => (
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 600, fontSize: "1rem", textAlign: "center", py: 1 }}>
{t("No entries available")}
</Typography>
);

return (
<Box sx={{ mb: 2 }}>
<Stack direction="row" spacing={2} sx={{ mb: 2, alignItems: "flex-start" }}>
<Box sx={{ maxWidth: 300 }}>
<FormControl fullWidth size="small">
<InputLabel id="date-select-label">{t("Select Date")}</InputLabel>
<Select labelId="date-select-label" value={selectedDate} label={t("Select Date")} onChange={(e) => setSelectedDate(e.target.value)}>
<MenuItem value="today">{t("Today")} ({getDateLabel(0)})</MenuItem>
<MenuItem value="tomorrow">{t("Tomorrow")} ({getDateLabel(1)})</MenuItem>
<MenuItem value="dayAfterTomorrow">{t("Day After Tomorrow")} ({getDateLabel(2)})</MenuItem>
</Select>
</FormControl>
</Box>
<Box sx={{ minWidth: 140, maxWidth: 300 }}>
<FormControl fullWidth size="small">
<InputLabel id="release-type-select-label">{t("Release Type")}</InputLabel>
<Select labelId="release-type-select-label" value={releaseType} label={t("Release Type")} onChange={(e) => setReleaseType(e.target.value)}>
<MenuItem value="batch">{t("Batch")}</MenuItem>
<MenuItem value="single">{t("Single")}</MenuItem>
</Select>
</FormControl>
</Box>
<Box sx={{ minWidth: 120, maxWidth: 200 }}>
<FormControl fullWidth size="small">
<InputLabel id="ticket-floor-select-label">{t("Floor ticket")}</InputLabel>
<Select labelId="ticket-floor-select-label" value={ticketFloor} label={t("Floor ticket")} onChange={(e) => setTicketFloor(e.target.value as "2/F" | "4/F")}>
<MenuItem value="2/F">{t("2F ticket")}</MenuItem>
<MenuItem value="4/F">{t("4F ticket")}</MenuItem>
</Select>
</FormControl>
</Box>
</Stack>

<Grid container spacing={2}>
{ticketFloor === "2/F" && (
<Grid item xs={12}>
<Stack direction="row" spacing={2} alignItems="flex-start">
<Typography variant="h6" sx={{ fontWeight: 600, minWidth: 60, pt: 1 }}>2/F</Typography>
<Box sx={{ border: "1px solid #e0e0e0", borderRadius: 1, p: 1, backgroundColor: "#fafafa", flex: 1 }}>
{isLoadingSummary ? <Typography variant="caption">{t("Loading...")}</Typography> : !summary2F?.rows?.length ? renderNoEntry() : (
<Grid container spacing={1}>
{summary2F.rows.map((row) => (
<Grid item xs={12} key={row.truckDepartureTime}>
<Stack direction={{ xs: "column", sm: "row" }} spacing={1} alignItems={{ xs: "stretch", sm: "center" }} sx={{ border: "1px solid #e0e0e0", borderRadius: 0.5, p: 1, backgroundColor: "#fff" }}>
<Typography variant="body2" sx={{ fontWeight: 600, minWidth: { sm: 60 } }}>{row.truckDepartureTime}</Typography>
<Stack direction="row" flexWrap="wrap" sx={{ gap: 1 }}>
{row.lanes.map((lane) => (
<Button key={`${row.truckDepartureTime}-${lane.truckLanceCode}`} variant="outlined" disabled={lane.unassigned === 0 || isAssigning} onClick={() => void handleLaneButtonClick("2/F", row.truckDepartureTime, lane.truckLanceCode, null, selectedDate, lane.unassigned, lane.total)}>
{`${lane.truckLanceCode} (${lane.unassigned}/${lane.total})`}
</Button>
))}
</Stack>
</Stack>
</Grid>
))}
</Grid>
)}
</Box>
</Stack>
</Grid>
)}

{ticketFloor === "4/F" && (
<Grid item xs={12}>
<Stack direction="row" spacing={2} alignItems="flex-start">
<Typography variant="h6" sx={{ fontWeight: 600, minWidth: 60, pt: 1 }}>4/F</Typography>
<Box sx={{ border: "1px solid #e0e0e0", borderRadius: 1, p: 1, backgroundColor: "#fafafa", flex: 1 }}>
{isLoadingSummary ? <Typography variant="caption">{t("Loading...")}</Typography> : !truckGroups4F.length ? renderNoEntry() : (
<Grid container spacing={1}>
{truckGroups4F.map(({ truckLanceCode, slots }) => (
<Grid item xs={12} key={truckLanceCode}>
<Stack direction={{ xs: "column", sm: "row" }} spacing={1} alignItems={{ xs: "stretch", sm: "center" }} sx={{ border: "1px solid #e0e0e0", borderRadius: 0.5, p: 1, backgroundColor: "#fff" }}>
<Typography variant="body2" sx={{ fontWeight: 700, minWidth: { sm: 160 } }}>{truckLanceCode}</Typography>
<Stack direction="row" flexWrap="wrap" sx={{ gap: 1 }}>
{slots.map((slot) => {
const handlerName = (slot.lane.handlerName ?? "").trim();
return (
<Button key={`${truckLanceCode}-${slot.sequenceIndex}-${slot.truckDepartureTime}`} variant="outlined" disabled={slot.lane.unassigned === 0 || isAssigning} onClick={() => void handleLaneButtonClick("4/F", slot.truckDepartureTime, slot.lane.truckLanceCode, slot.lane.loadingSequence ?? null, selectedDate, slot.lane.unassigned, slot.lane.total)}>
{`${t("Loading sequence n", { n: slot.lane.loadingSequence ?? slot.sequenceIndex })} (${slot.lane.unassigned}/${slot.lane.total})${handlerName ? ` ${handlerName}` : ""}`}
</Button>
);
})}
</Stack>
</Stack>
</Grid>
))}
</Grid>
)}
</Box>
</Stack>
</Grid>
)}

<Grid item xs={12}>
<Box sx={{ py: 2, mt: 1, mb: 0.5, borderTop: "1px solid #e0e0e0" }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 0.5 }}>
{t("Not yet finished released do pick orders")}
</Typography>
<Typography variant="body2" color="text.secondary">
{t("Released orders not yet completed - click lane to select and assign")}
</Typography>
</Box>
</Grid>

{ticketFloor === "2/F" && (
<Grid item xs={12}>
<Stack direction="row" spacing={2} alignItems="flex-start">
<Typography variant="h6" sx={{ fontWeight: 600, minWidth: 60, pt: 1 }}>2/F</Typography>
<Box sx={{ border: "1px solid #e0e0e0", borderRadius: 1, p: 1, backgroundColor: "#fafafa", flex: 1 }}>
{truckCounts2F.length === 0 ? renderNoEntry() : (
<Grid container spacing={1}>
{truckCounts2F.map(({ truck, count }) => (
<Grid item xs={6} sm={4} md={3} key={`2F-${truck}`} sx={{ display: "flex" }}>
<Button
variant="outlined"
onClick={() => {
setIsDefaultTruck(false);
setSelectedStore("2/F");
setSelectedTruck(truck);
setModalOpen(true);
}}
sx={{ flex: 1 }}
>
{`${truck} (${count})`}
</Button>
</Grid>
))}
</Grid>
)}
</Box>
</Stack>
</Grid>
)}

{ticketFloor === "4/F" && (
<Grid item xs={12}>
<Stack direction="row" spacing={2} alignItems="flex-start">
<Typography variant="h6" sx={{ fontWeight: 600, minWidth: 60, pt: 1 }}>4/F</Typography>
<Box sx={{ border: "1px solid #e0e0e0", borderRadius: 1, p: 1, backgroundColor: "#fafafa", flex: 1 }}>
{truckCounts4F.length === 0 ? renderNoEntry() : (
<Grid container spacing={1}>
{truckCounts4F.map(({ truck, count }) => (
<Grid item xs={6} sm={4} md={3} key={`4F-${truck}`} sx={{ display: "flex" }}>
<Button
variant="outlined"
onClick={() => {
setIsDefaultTruck(false);
setSelectedStore("4/F");
setSelectedTruck(truck);
setModalOpen(true);
}}
sx={{ flex: 1 }}
>
{`${truck} (${count})`}
</Button>
</Grid>
))}
</Grid>
)}
</Box>
</Stack>
</Grid>
)}

<Grid item xs={12}>
<Stack direction="row" spacing={2} alignItems="flex-start">
<Typography sx={{ fontWeight: 600, minWidth: 60, pt: 1 }}>{t("Truck X")}</Typography>
<Box sx={{ border: "1px solid #e0e0e0", borderRadius: 1, p: 1, backgroundColor: "#fafafa", flex: 1 }}>
{beforeTodayTruckXCount === 0 && defaultTruckCount === 0 ? renderNoEntry() : (
<Stack direction="row" spacing={1}>
{defaultTruckCount > 0 && (
<Button
variant="outlined"
onClick={() => {
setSelectedStore("");
setSelectedTruck("車線-X");
setIsDefaultTruck(true);
setDefaultDateScope("today");
setModalOpen(true);
}}
>
{`${t("Today")} (${defaultTruckCount})`}
</Button>
)}
{beforeTodayTruckXCount > 0 && (
<Button
variant="outlined"
onClick={() => {
setSelectedStore("4/F");
setSelectedTruck("車線-X");
setIsDefaultTruck(true);
setDefaultDateScope("before");
setModalOpen(true);
}}
>
{`${t("車線-X")} (${beforeTodayTruckXCount})`}
</Button>
)}
</Stack>
)}
</Box>
</Stack>
</Grid>

<ReleasedDoPickOrderSelectModal
open={modalOpen}
storeId={selectedStore}
truck={selectedTruck}
isDefaultTruck={isDefaultTruck}
defaultDateScope={defaultDateScope}
listBridge={workbenchReleasedListBridge}
onClose={() => setModalOpen(false)}
onAssigned={() => {
void loadSummaries();
onPickOrderAssigned?.();
onSwitchToDetailTab?.();
}}
/>
</Grid>
</Box>
);
};

export default WorkbenchFloorLanePanel;

+ 1221
- 0
src/components/DoWorkbench/WorkbenchGoodPickExecution.tsx
Файловите разлики са ограничени, защото са твърде много
Целия файл


+ 4148
- 0
src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx
Файловите разлики са ограничени, защото са твърде много
Целия файл


+ 792
- 0
src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx Целия файл

@@ -0,0 +1,792 @@
"use client";

/**
* Workbench copy of `LotLabelPrintModal`: same label-print flow, plus optional
* 「掃碼提貨」 per listed lot row (parent calls `workbenchScanPick` with `inventoryLotLineId`).
*/

import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
Alert,
Box,
Button,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControl,
InputLabel,
MenuItem,
Select,
Snackbar,
Stack,
TextField,
Typography,
} from "@mui/material";
import {
analyzeWorkbenchQrCode,
fetchWorkbenchAvailableLotsByItem,
fetchWorkbenchPrinters,
printWorkbenchLotLabel,
} from "@/app/api/doworkbench/actions";
import { QRCodeSVG } from "qrcode.react";

type ScanPayload = {
itemId: number;
stockInLineId: number;
};

type Printer = {
id: number;
name?: string;
description?: string;
ip?: string;
port?: number;
type?: string;
brand?: string;
};

type QrCodeAnalysisResponse = {
itemId: number;
itemCode: string;
itemName: string;
scanned?: {
stockInLineId: number;
lotNo: string;
inventoryLotLineId: number;
warehouseCode?: string | null;
warehouseName?: string | null;
} | null;
sameItemLots: Array<{
lotNo: string;
inventoryLotLineId: number;
stockInLineId?: number | null;
availableQty: number;
uom: string;
warehouseCode?: string | null;
warehouseName?: string | null;
}>;
};

export interface WorkbenchLotLabelPrintModalProps {
open: boolean;
onClose: () => void;
initialPayload?: ScanPayload | null;
initialItemId?: number | null;
defaultPrinterName?: string;
hideScanSection?: boolean;
reminderText?: string;
statusTitleText?: string;
/** 與 statusTitleText 搭配;預設 error(舊版固定紅字) */
statusTitleSeverity?: "success" | "warning" | "error";
warehouseCodePrefixFilter?: string;
/**
* When true, omit the API 「scanned」 lot from the merged list (legacy FG-style).
* Workbench should leave false so the current row’s lot appears for label print / scan-pick.
*/
hideTriggeredLot?: boolean;
/** 提貨台表格列上的可用量/單位(API 的 sameItemLots 不含掃描行,需補上才能顯示「目前這筆」) */
triggerLotAvailableQty?: number | null;
triggerLotUom?: string | null;
/** 此出庫行已掃碼/已完成時為 true,停用所有「掃碼提貨」(仍可列印標籤) */
disableScanPick?: boolean;
/**
* When set, each lot row shows 「掃碼提貨」. Parent should call `workbenchScanPick`
* with `inventoryLotLineId` and throw on failure.
*/
onWorkbenchScanPick?: (args: {
inventoryLotLineId: number;
lotNo: string;
qty?: number;
}) => Promise<void>;
/** Global submit qty shared with outer "Qty will submit". */
submitQty?: number | null;
onSubmitQtyChange?: (qty: number) => void;
}

function safeParseScanPayload(raw: string): ScanPayload | null {
try {
const obj = JSON.parse(raw);
const itemId = Number(obj?.itemId);
const stockInLineId = Number(obj?.stockInLineId);
if (!Number.isFinite(itemId) || !Number.isFinite(stockInLineId))
return null;
return { itemId, stockInLineId };
} catch {
return null;
}
}

function formatPrinterLabel(p: Printer): string {
const name = (p.name || "").trim();
if (name) return name;
const desc = (p.description || "").trim();
if (desc) return desc;
const code = (p as { code?: string }).code?.trim?.() ?? "";
if (code) return code;
return `#${p.id}`;
}

function isLabelPrinter(p: Printer): boolean {
const s = `${p.name ?? ""} ${p.description ?? ""} ${
(p as { code?: string }).code ?? ""
} ${p.type ?? ""} ${p.brand ?? ""}`.toLowerCase();
return s.includes("label") && !s.includes("a4");
}

const WorkbenchLotLabelPrintModal: React.FC<WorkbenchLotLabelPrintModalProps> = ({
open,
onClose,
initialPayload = null,
initialItemId = null,
defaultPrinterName,
hideScanSection,
reminderText,
statusTitleText,
statusTitleSeverity = "error",
warehouseCodePrefixFilter,
hideTriggeredLot = false,
triggerLotAvailableQty = null,
triggerLotUom = null,
disableScanPick = false,
onWorkbenchScanPick,
submitQty = null,
onSubmitQtyChange,
}) => {
const scanInputRef = useRef<HTMLInputElement | null>(null);
const [scanInput, setScanInput] = useState("");
const [scanError, setScanError] = useState<string | null>(null);

const [printers, setPrinters] = useState<Printer[]>([]);
const [printersLoading, setPrintersLoading] = useState(false);
const [selectedPrinterId, setSelectedPrinterId] = useState<number | "">("");

const [analysisLoading, setAnalysisLoading] = useState(false);
const [analysis, setAnalysis] = useState<QrCodeAnalysisResponse | null>(null);
const [lastPayload, setLastPayload] = useState<ScanPayload | null>(null);
const [lastItemId, setLastItemId] = useState<number | null>(null);

const [printQty, setPrintQty] = useState(1);
const [printingLotLineId, setPrintingLotLineId] = useState<number | null>(
null,
);
const [qrVisibleLotLineId, setQrVisibleLotLineId] = useState<number | null>(
null,
);

const [snackbar, setSnackbar] = useState<{
open: boolean;
message: string;
severity?: "success" | "info" | "error";
}>({
open: false,
message: "",
severity: "info",
});

const resetAll = useCallback(() => {
setScanInput("");
setScanError(null);
setAnalysis(null);
setPrintQty(1);
setPrintingLotLineId(null);
setQrVisibleLotLineId(null);
}, []);

useEffect(() => {
if (!open) return;
resetAll();
const t = setTimeout(() => scanInputRef.current?.focus(), 50);
return () => clearTimeout(t);
}, [open, resetAll]);

const loadPrinters = useCallback(async () => {
setPrintersLoading(true);
try {
const data = (await fetchWorkbenchPrinters()) as Printer[];
const list = Array.isArray(data) ? data : [];
setPrinters(list.filter(isLabelPrinter));
} catch (e) {
setPrinters([]);
setSnackbar({
open: true,
message: e instanceof Error ? e.message : "載入印表機清單失敗",
severity: "error",
});
} finally {
setPrintersLoading(false);
}
}, []);

useEffect(() => {
if (!open) return;
void loadPrinters();
}, [open, loadPrinters]);

const effectiveHideScanSection = hideScanSection ?? initialPayload != null;

const pickDefaultPrinterId = useCallback(
(list: Printer[]): number | null => {
if (!defaultPrinterName) return null;
const target = defaultPrinterName.trim().toLowerCase();
if (!target) return null;
const byExact = list.find(
(p) => formatPrinterLabel(p).trim().toLowerCase() === target,
);
if (byExact) return byExact.id;
const byIncludes = list.find((p) =>
formatPrinterLabel(p).trim().toLowerCase().includes(target),
);
return byIncludes?.id ?? null;
},
[defaultPrinterName],
);

useEffect(() => {
if (!open) return;
if (selectedPrinterId !== "") return;
if (printers.length === 0) return;
const id = pickDefaultPrinterId(printers);
if (id != null) setSelectedPrinterId(id);
}, [open, printers, selectedPrinterId, pickDefaultPrinterId]);

const analyzePayload = useCallback(
async (payload: ScanPayload) => {
setLastPayload(payload);
setScanError(null);
setAnalysisLoading(true);
try {
const data = (await analyzeWorkbenchQrCode(payload)) as QrCodeAnalysisResponse;
setAnalysis(data);
setSnackbar({
open: true,
message: "已載入同品可用批號清單",
severity: "success",
});
} catch (e) {
setAnalysis(null);
setScanError(e instanceof Error ? e.message : "分析失敗");
} finally {
setAnalysisLoading(false);
}
},
[],
);

const analyzeByItem = useCallback(
async (itemId: number) => {
if (!Number.isFinite(itemId) || itemId <= 0) {
setScanError("無效 itemId,無法載入批號清單。");
return;
}
setLastItemId(itemId);
setScanError(null);
setAnalysisLoading(true);
try {
const data = (await fetchWorkbenchAvailableLotsByItem(itemId)) as {
itemId: number;
itemCode: string;
itemName: string;
sameItemLots: QrCodeAnalysisResponse["sameItemLots"];
};
setAnalysis({
itemId: data.itemId,
itemCode: data.itemCode,
itemName: data.itemName,
scanned: null,
sameItemLots: data.sameItemLots ?? [],
});
setSnackbar({
open: true,
message: "已載入同品可用批號清單",
severity: "success",
});
} catch (e) {
setAnalysis(null);
setScanError(e instanceof Error ? e.message : "分析失敗");
} finally {
setAnalysisLoading(false);
}
},
[],
);

const handleAnalyze = useCallback(async () => {
const raw = scanInput.trim();
const payload = safeParseScanPayload(raw);
if (!payload) {
setScanError(
'掃碼內容格式錯誤,請重新掃碼',
);
setAnalysis(null);
return;
}
await analyzePayload(payload);
}, [scanInput, analyzePayload]);

const handleRefreshLots = useCallback(async () => {
const payload = lastPayload ?? safeParseScanPayload(scanInput.trim());
if (payload) {
await analyzePayload(payload);
return;
}
const candidateItemId =
(Number.isFinite(lastItemId ?? NaN) && (lastItemId ?? 0) > 0
? (lastItemId as number)
: Number(initialItemId));
if (Number.isFinite(candidateItemId) && candidateItemId > 0) {
await analyzeByItem(candidateItemId);
return;
}
if (!payload) {
setSnackbar({
open: true,
message: "請先掃碼或查詢一次,才可刷新批號清單。",
severity: "info",
});
return;
}
}, [analyzeByItem, analyzePayload, initialItemId, lastItemId, lastPayload, scanInput]);

useEffect(() => {
if (!open) return;
if (initialPayload) {
setScanInput(JSON.stringify(initialPayload));
void analyzePayload(initialPayload);
return;
}
if (Number.isFinite(Number(initialItemId)) && Number(initialItemId) > 0) {
void analyzeByItem(Number(initialItemId));
}
}, [open, initialPayload, initialItemId, analyzePayload, analyzeByItem]);

const availableLots = useMemo(() => {
if (!analysis) return [];
const list = (analysis.sameItemLots ?? []).filter(
(x) => Number(x.availableQty) > 0 && !!String(x.lotNo || "").trim(),
);
const scannedLotLineId = analysis.scanned?.inventoryLotLineId;
const scannedRow = scannedLotLineId
? list.find((x) => x.inventoryLotLineId === scannedLotLineId)
: undefined;
const tableQty = Number(triggerLotAvailableQty);
const fromTable =
Number.isFinite(tableQty) && tableQty >= 0 ? tableQty : 0;
const fromApi = Number(scannedRow?.availableQty ?? 0);
const scanned = analysis.scanned;
const scannedLot = scannedLotLineId
? {
lotNo: scanned?.lotNo ?? "",
inventoryLotLineId: scannedLotLineId,
stockInLineId: Number(scanned?.stockInLineId ?? 0) || null,
availableQty: Math.max(fromApi, fromTable) as number,
uom: (scannedRow?.uom ?? triggerLotUom ?? "") as string,
warehouseCode:
scanned?.warehouseCode ?? scannedRow?.warehouseCode,
warehouseName:
scanned?.warehouseName ?? scannedRow?.warehouseName,
_scanned: true as const,
}
: null;

const merged = [
...(!hideTriggeredLot && scannedLot ? [scannedLot] : []),
...list
.filter((x) => x.inventoryLotLineId !== scannedLotLineId)
.map((x) => ({ ...x, _scanned: false as const })),
];

return merged;
}, [analysis, hideTriggeredLot, triggerLotAvailableQty, triggerLotUom]);

const filteredLots = useMemo(() => {
const prefix = String(warehouseCodePrefixFilter ?? "").trim();
if (!prefix) return availableLots;
return availableLots.filter((lot) => {
// 使用者從本列開啟視窗:即使 API 未帶 warehouseCode,仍應顯示目前這筆批號
if (lot._scanned) return true;
return String(lot.warehouseCode ?? "").startsWith(prefix);
});
}, [availableLots, warehouseCodePrefixFilter]);

const selectedPrinter = useMemo(() => {
if (selectedPrinterId === "") return null;
return printers.find((p) => p.id === selectedPrinterId) ?? null;
}, [printers, selectedPrinterId]);

const canPrint =
!!analysis && selectedPrinterId !== "" && printQty >= 1 && !analysisLoading;

const handlePrintOne = useCallback(
async (inventoryLotLineId: number, lotNo: string) => {
if (selectedPrinterId === "") {
setSnackbar({
open: true,
message: "請先選擇印表機",
severity: "error",
});
return;
}
if (printQty < 1 || !Number.isFinite(printQty)) {
setSnackbar({
open: true,
message: "列印張數需為大於等於 1 的整數",
severity: "error",
});
return;
}

setPrintingLotLineId(inventoryLotLineId);
try {
await printWorkbenchLotLabel({
inventoryLotLineId,
printerId: selectedPrinterId,
printQty: Math.floor(printQty),
});
setSnackbar({
open: true,
message: `已送出列印:Lot ${lotNo}`,
severity: "success",
});
} catch (e) {
setSnackbar({
open: true,
message: e instanceof Error ? e.message : "列印失敗",
severity: "error",
});
} finally {
setPrintingLotLineId(null);
}
},
[selectedPrinterId, printQty],
);

return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle>批號標籤列印(提貨台)</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
{statusTitleText ? (
<Typography
variant="h6"
sx={{
fontWeight: 800,
color:
statusTitleSeverity === "success"
? "success.main"
: statusTitleSeverity === "warning"
? "warning.main"
: "error.main",
}}
>
{statusTitleText}
</Typography>
) : null}
{reminderText ? (
<Alert severity="warning">{reminderText}</Alert>
) : null}
{effectiveHideScanSection ? null : (
<>
{/*
<Alert severity="info">
請掃描條碼(JSON 格式),例如{" "}
<code>{'{"itemId":16431,"stockInLineId":10381'}</code>。
</Alert>
*/}
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
alignItems={{ xs: "stretch", md: "center" }}
>
<TextField
inputRef={scanInputRef}
label="掃碼內容"
value={scanInput}
onChange={(e) => setScanInput(e.target.value)}
fullWidth
size="small"
error={!!scanError}
helperText={scanError || "掃描後按 Enter 或點「查詢」"}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void handleAnalyze();
}
}}
disabled={analysisLoading}
/>
<Button
variant="contained"
onClick={() => void handleAnalyze()}
disabled={analysisLoading || !scanInput.trim()}
>
{analysisLoading ? <CircularProgress size={18} /> : "查詢"}
</Button>
<Button
variant="outlined"
onClick={() => {
resetAll();
scanInputRef.current?.focus();
}}
disabled={analysisLoading}
>
清除
</Button>
</Stack>
</>
)}

<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
alignItems={{ xs: "stretch", md: "center" }}
>
<FormControl
size="small"
sx={{ minWidth: 260 }}
disabled={printersLoading}
>
<InputLabel>印表機</InputLabel>
<Select
label="印表機"
value={selectedPrinterId}
onChange={(e) =>
setSelectedPrinterId((e.target.value as number) ?? "")
}
>
<MenuItem value="">
<em>{printersLoading ? "載入中..." : "請選擇"}</em>
</MenuItem>
{printers.map((p) => (
<MenuItem key={p.id} value={p.id}>
{formatPrinterLabel(p)}
</MenuItem>
))}
</Select>
</FormControl>

<TextField
label="列印張數"
size="small"
type="number"
inputProps={{ min: 1, step: 1 }}
value={printQty}
onChange={(e) => setPrintQty(Number(e.target.value))}
sx={{ width: 140 }}
disabled={analysisLoading}
/>

{onWorkbenchScanPick ? (
<TextField
label="提交數量"
size="small"
type="number"
inputProps={{ min: 0, step: 1 }}
value={
Number.isFinite(Number(submitQty)) ? Number(submitQty) : 0
}
onChange={(e) => {
const n = Number(e.target.value);
if (!Number.isFinite(n) || n < 0) return;
onSubmitQtyChange?.(n);
}}
sx={{ width: 140 }}
disabled={analysisLoading}
/>
) : null}

<Button
variant="outlined"
onClick={() => void handleRefreshLots()}
disabled={analysisLoading}
>
{analysisLoading ? (
<CircularProgress size={18} />
) : (
"刷新批號清單"
)}
</Button>

{selectedPrinter && (
<Typography
variant="body2"
color="text.secondary"
sx={{ ml: { md: "auto" } }}
>
已選:{formatPrinterLabel(selectedPrinter)}
</Typography>
)}
</Stack>

{analysis && (
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1 }}>
品號:{analysis.itemCode} {analysis.itemName}
</Typography>

{filteredLots.length === 0 ? (
<Alert severity="warning">
找不到該樓層有可用批號(availableQty &gt; 0)。
</Alert>
) : (
<Stack spacing={1}>
{filteredLots.map((lot) => {
const isPrinting =
printingLotLineId === lot.inventoryLotLineId;
const loc = String(lot.warehouseCode ?? "").trim();
const canShowLotQr =
!!onWorkbenchScanPick &&
!!analysis &&
!analysisLoading &&
!disableScanPick;
const lotQrPayload =
Number.isFinite(Number(analysis?.itemId)) &&
Number.isFinite(Number(lot.stockInLineId))
? {
itemId: Number(analysis?.itemId),
stockInLineId: Number(lot.stockInLineId),
}
: null;
return (
<Box
key={lot.inventoryLotLineId}
sx={{
p: 1.25,
borderRadius: 1,
border: "1px solid",
borderColor: "divider",
display: "flex",
alignItems: "center",
gap: 2,
backgroundColor: lot._scanned
? "rgba(25, 118, 210, 0.08)"
: "transparent",
}}
>
<Box sx={{ minWidth: 220 }}>
<Typography
variant="body1"
sx={{ fontWeight: lot._scanned ? 800 : 600 }}
>
Lot:{lot.lotNo}
{lot._scanned ? "(當前批次)" : ""}
</Typography>
<Typography variant="body2" color="text.secondary">
位置:{loc || "—"}
</Typography>
<Typography variant="body2" color="text.secondary">
可用量:{Number(lot.availableQty).toLocaleString()}{" "}
單位:{lot.uom || ""}
</Typography>
</Box>
<Stack
direction="row"
spacing={1}
sx={{ ml: "auto" }}
flexWrap="wrap"
useFlexGap
>
<Button
variant="contained"
disabled={!canPrint || isPrinting}
onClick={() =>
void handlePrintOne(
lot.inventoryLotLineId,
lot.lotNo,
)
}
>
{isPrinting ? (
<CircularProgress size={18} />
) : (
"列印標籤"
)}
</Button>
{onWorkbenchScanPick ? (
<Button
variant="outlined"
color="secondary"
title={
!lotQrPayload
? "此列無法取得 QR payload(需 stockInLineId)"
: disableScanPick
? "此出庫行已掃碼或已完成,無法顯示 QR"
: undefined
}
disabled={
!canShowLotQr || !lotQrPayload || isPrinting
}
onClick={() =>
setQrVisibleLotLineId((prev) =>
prev === lot.inventoryLotLineId
? null
: lot.inventoryLotLineId,
)
}
>
顯示 QR
</Button>
) : null}
</Stack>
{qrVisibleLotLineId === lot.inventoryLotLineId &&
lotQrPayload ? (
<Box
sx={{
mt: 1.5,
ml: "auto",
p: 1.5,
borderRadius: 1,
border: "1px dashed",
borderColor: "divider",
textAlign: "center",
minWidth: 220,
}}
>
<QRCodeSVG
value={JSON.stringify(lotQrPayload)}
size={160}
includeMargin
/>
</Box>
) : null}
</Box>
);
})}
</Stack>
)}
</Box>
)}

{!analysis && !analysisLoading && (
<Typography variant="body2" color="text.secondary">

{onWorkbenchScanPick
? "沒有任何批號可列印標籤"
: ""}
</Typography>
)}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>關閉</Button>
</DialogActions>

<Snackbar
open={snackbar.open}
autoHideDuration={3500}
onClose={() => setSnackbar((s) => ({ ...s, open: false }))}
message={snackbar.message}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
/>
</Dialog>
);
};

export default WorkbenchLotLabelPrintModal;

+ 352
- 0
src/components/DoWorkbench/WorkbenchTicketReleaseTable.tsx Целия файл

@@ -0,0 +1,352 @@
"use client";

import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Box,
Card,
CardContent,
Chip,
CircularProgress,
FormControl,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TablePagination,
TableRow,
Typography,
Button,
Tooltip,
} from "@mui/material";
import { useTranslation } from "react-i18next";
import { useSession } from "next-auth/react";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import dayjs, { Dayjs } from "dayjs";
import { arrayToDayjs } from "@/app/utils/formatUtil";
import {
fetchWorkbenchTicketReleaseTable,
forceCompleteWorkbenchTicket,
revertWorkbenchTicketAssignment,
WorkbenchTicketReleaseTable,
} from "@/app/api/do/actions";
import Swal from "sweetalert2";
import { AUTH } from "@/authorities";
import { SessionWithTokens } from "@/config/authConfig";

function requiredDeliveryDateToDayString(value: unknown): string {
if (value == null) return "";
if (Array.isArray(value) && value.length >= 3 && value.every((x) => typeof x === "number")) {
return arrayToDayjs(value as number[]).format("YYYY-MM-DD");
}
return dayjs(value as string | number | Date).format("YYYY-MM-DD");
}

function isCompletedStatus(status: string | null | undefined): boolean {
return (status ?? "").toLowerCase() === "completed";
}

function showDoPickOpsButtons(row: WorkbenchTicketReleaseTable): boolean {
return (
row.isActiveWorkbenchTicket === true &&
!isCompletedStatus(row.ticketStatus) &&
row.handledBy != null
);
}

const WorkbenchTicketReleaseTableTab: React.FC = () => {
const { t } = useTranslation("ticketReleaseTable");
const { data: session } = useSession() as { data: SessionWithTokens | null };
const abilities = session?.abilities ?? session?.user?.abilities ?? [];
const canManageDoPickOps = abilities.some((a) => a.trim() === AUTH.ADMIN);
const [queryDate, setQueryDate] = useState<Dayjs>(() => dayjs());
const [selectedFloor, setSelectedFloor] = useState<string>("");
const [selectedStatus, setSelectedStatus] = useState<string>("released");
const [data, setData] = useState<WorkbenchTicketReleaseTable[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [paginationController, setPaginationController] = useState({
pageNum: 0,
pageSize: 5,
});

const loadData = useCallback(async () => {
setLoading(true);
try {
const dayStr = queryDate.format("YYYY-MM-DD");
const result = await fetchWorkbenchTicketReleaseTable(dayStr, dayStr);
setData(result);
} catch (error) {
console.error("Error fetching workbench ticket release table:", error);
setData([]);
} finally {
setLoading(false);
}
}, [queryDate]);

useEffect(() => {
void loadData();
}, [loadData]);

const dayStr = queryDate.format("YYYY-MM-DD");
const filteredData = useMemo(() => {
return data.filter((item) => {
if (selectedFloor && item.storeId !== selectedFloor) return false;
if (item.requiredDeliveryDate) {
const itemDate = requiredDeliveryDateToDayString(item.requiredDeliveryDate);
if (itemDate !== dayStr) return false;
}
if (selectedStatus && item.ticketStatus?.toLowerCase() !== selectedStatus.toLowerCase()) return false;
return true;
});
}, [data, dayStr, selectedFloor, selectedStatus]);

const paginatedData = useMemo(() => {
const startIndex = paginationController.pageNum * paginationController.pageSize;
const endIndex = startIndex + paginationController.pageSize;
return filteredData.slice(startIndex, endIndex);
}, [filteredData, paginationController]);

const handleRevert = useCallback(
async (row: WorkbenchTicketReleaseTable) => {
if (!canManageDoPickOps) return;
const r = await Swal.fire({
title: t("Confirm revert assignment"),
text: t("Revert assignment hint"),
icon: "warning",
showCancelButton: true,
confirmButtonText: t("Confirm"),
cancelButtonText: t("Cancel"),
});
if (!r.isConfirmed) return;
try {
const res = await revertWorkbenchTicketAssignment(row.deliveryOrderPickOrderId);
if (res.code === "SUCCESS") {
await Swal.fire({
icon: "success",
text: t("Operation succeeded"),
timer: 1500,
showConfirmButton: false,
});
await loadData();
} else {
await Swal.fire({ icon: "error", title: res.code ?? "", text: res.message ?? "" });
}
} catch (e) {
console.error(e);
await Swal.fire({ icon: "error", text: String(e) });
}
},
[canManageDoPickOps, loadData, t],
);

const handleForceComplete = useCallback(
async (row: WorkbenchTicketReleaseTable) => {
if (!canManageDoPickOps) return;
const r = await Swal.fire({
title: t("Confirm force complete"),
text: t("Force complete hint"),
icon: "warning",
showCancelButton: true,
confirmButtonText: t("Confirm"),
cancelButtonText: t("Cancel"),
});
if (!r.isConfirmed) return;
try {
const res = await forceCompleteWorkbenchTicket(row.deliveryOrderPickOrderId);
if (res.code === "SUCCESS") {
await Swal.fire({
icon: "success",
text: t("Operation succeeded"),
timer: 1500,
showConfirmButton: false,
});
await loadData();
} else {
await Swal.fire({ icon: "error", title: res.code ?? "", text: res.message ?? "" });
}
} catch (e) {
console.error(e);
await Swal.fire({ icon: "error", text: String(e) });
}
},
[canManageDoPickOps, loadData, t],
);

const opsTooltip = !canManageDoPickOps ? t("Manager only hint") : "";

return (
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-hk">
<Card sx={{ mb: 2 }}>
<CardContent>
<Typography variant="h5" sx={{ fontWeight: 600, mb: 2 }}>
{t("Ticket Release Table")}
</Typography>
<Stack direction="row" spacing={2} sx={{ mb: 3, flexWrap: "wrap", alignItems: "center" }}>
<DatePicker
label={t("Target Date")}
value={queryDate}
onChange={(v) => v && setQueryDate(v)}
slotProps={{ textField: { size: "small", sx: { minWidth: 180 } } }}
/>
<Button variant="outlined" size="small" onClick={() => void loadData()}>
{t("Reload data")}
</Button>
<FormControl sx={{ minWidth: 150 }} size="small">
<InputLabel id="workbench-floor-select-label" shrink>
{t("Floor")}
</InputLabel>
<Select
labelId="workbench-floor-select-label"
value={selectedFloor}
label={t("Floor")}
onChange={(e) => setSelectedFloor(e.target.value)}
displayEmpty
>
<MenuItem value="">{t("All Floors")}</MenuItem>
<MenuItem value="2/F">2/F</MenuItem>
<MenuItem value="4/F">4/F</MenuItem>
</Select>
</FormControl>
<FormControl sx={{ minWidth: 150 }} size="small">
<InputLabel id="workbench-status-select-label" shrink>
{t("Status")}
</InputLabel>
<Select
labelId="workbench-status-select-label"
value={selectedStatus}
label={t("Status")}
onChange={(e) => setSelectedStatus(e.target.value)}
displayEmpty
>
<MenuItem value="">{t("All Statuses")}</MenuItem>
<MenuItem value="pending">{t("pending")}</MenuItem>
<MenuItem value="released">{t("released")}</MenuItem>
<MenuItem value="completed">{t("completed")}</MenuItem>
</Select>
</FormControl>
</Stack>

{loading ? (
<Box sx={{ display: "flex", justifyContent: "center", p: 3 }}>
<CircularProgress />
</Box>
) : (
<>
<TableContainer component={Paper} sx={{ maxHeight: 440, overflow: "auto" }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>{t("Store ID")}</TableCell>
<TableCell>{t("Required Delivery Date")}</TableCell>
<TableCell>{t("Truck Information")}</TableCell>
<TableCell>{t("Shop Name")}</TableCell>
<TableCell align="right">{t("Loading Sequence")}</TableCell>
<TableCell>{t("Ticket Information")}</TableCell>
<TableCell>{t("Handler Name")}</TableCell>
<TableCell align="right">{t("Number of FG Items (Order Item(s) Count)")}</TableCell>
<TableCell align="center" sx={{ minWidth: 200 }}>
{t("Actions")}
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{paginatedData.length === 0 ? (
<TableRow>
<TableCell colSpan={9} align="center">
{t("No data available")}
</TableCell>
</TableRow>
) : (
paginatedData.map((row) => (
<TableRow key={row.deliveryOrderPickOrderId}>
<TableCell>{row.storeId || "-"}</TableCell>
<TableCell>
{row.requiredDeliveryDate ? requiredDeliveryDateToDayString(row.requiredDeliveryDate) : "-"}
</TableCell>
<TableCell>
<Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap" }}>
{row.truckLanceCode && <Chip label={row.truckLanceCode} size="small" color="primary" />}
{row.truckDepartureTime && <Chip label={String(row.truckDepartureTime).slice(0, 5)} size="small" color="secondary" />}
</Box>
</TableCell>
<TableCell>{row.shopName || "-"}</TableCell>
<TableCell align="right">{row.loadingSequence ?? "-"}</TableCell>
<TableCell>
{row.ticketNo || "-"} ({row.ticketStatus ? t(row.ticketStatus.toLowerCase()) : "-"})
</TableCell>
<TableCell>{row.handlerName ?? "-"}</TableCell>
<TableCell align="right">{row.numberOfFGItems ?? 0}</TableCell>
<TableCell align="center">
{showDoPickOpsButtons(row) ? (
<Stack direction="row" spacing={1} justifyContent="center" flexWrap="wrap" useFlexGap>
<Tooltip title={opsTooltip}>
<span>
<Button
size="small"
variant="outlined"
color="warning"
disabled={!canManageDoPickOps}
onClick={() => void handleRevert(row)}
>
{t("Revert assignment")}
</Button>
</span>
</Tooltip>
<Tooltip title={opsTooltip}>
<span>
<Button
size="small"
variant="outlined"
color="primary"
disabled={!canManageDoPickOps}
onClick={() => void handleForceComplete(row)}
>
{t("Force complete DO")}
</Button>
</span>
</Tooltip>
</Stack>
) : (
<Typography variant="caption" color="text.secondary">
</Typography>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
{filteredData.length > 0 && (
<TablePagination
component="div"
count={filteredData.length}
page={paginationController.pageNum}
rowsPerPage={paginationController.pageSize}
onPageChange={(_, page) =>
setPaginationController((prev) => ({ ...prev, pageNum: page }))
}
onRowsPerPageChange={(event) =>
setPaginationController({ pageNum: 0, pageSize: parseInt(event.target.value, 10) })
}
rowsPerPageOptions={[5, 10, 15]}
labelRowsPerPage={t("Rows per page")}
/>
)}
</>
)}
</CardContent>
</Card>
</LocalizationProvider>
);
};

export default WorkbenchTicketReleaseTableTab;

+ 4
- 0
src/components/DoWorkbench/index.ts Целия файл

@@ -0,0 +1,4 @@
export { default as DoWorkbenchPickShell } from "./DoWorkbenchPickShell";
export { default as WorkbenchGoodPickExecution } from "./WorkbenchGoodPickExecution";
export { default as WorkbenchGoodPickExecutionDetail } from "./WorkbenchGoodPickExecutionDetail";
export { default as WorkbenchFloorLanePanel } from "./WorkbenchFloorLanePanel";

+ 15
- 5
src/components/FinishedGoodSearch/FinishedGoodCartonDashboardTab.tsx Целия файл

@@ -23,6 +23,7 @@ import dayjs from "dayjs";
import {
CompletedDoPickOrderResponse,
fetchCompletedDoPickOrdersAll,
fetchCompletedDoPickOrdersWorkbenchAll,
} from "@/app/api/pickOrder/actions";
import SafeApexCharts from "@/components/charts/SafeApexCharts";

@@ -36,7 +37,11 @@ type DailySummaryRow = {
total: number;
};

const FinishedGoodCartonDashboardTab: React.FC = () => {
type Props = {
mode?: "normal" | "workbench";
};

const FinishedGoodCartonDashboardTab: React.FC<Props> = ({ mode = "normal" }) => {
const [floor, setFloor] = useState<FloorFilter>("all");
const [date, setDate] = useState<string>(dayjs().format("YYYY-MM-DD"));
const [loading, setLoading] = useState(false);
@@ -47,9 +52,14 @@ const FinishedGoodCartonDashboardTab: React.FC = () => {
setLoading(true);
setError("");
try {
const data = await fetchCompletedDoPickOrdersAll(
date ? { targetDate: date } : undefined,
);
const data =
mode === "workbench"
? await fetchCompletedDoPickOrdersWorkbenchAll(
date ? { targetDate: date } : undefined,
)
: await fetchCompletedDoPickOrdersAll(
date ? { targetDate: date } : undefined,
);
setRecords(data);
} catch (err) {
console.error("Failed to load finished good carton dashboard data", err);
@@ -58,7 +68,7 @@ const FinishedGoodCartonDashboardTab: React.FC = () => {
} finally {
setLoading(false);
}
}, [date]);
}, [date, mode]);

useEffect(() => {
loadData();


+ 1
- 1
src/components/FinishedGoodSearch/GoodPickExecutiondetail.tsx Целия файл

@@ -3723,7 +3723,7 @@ const PickExecution: React.FC<Props> = ({
);
} else if (completionResponse.message === "not completed") {
console.log(
`Pick order not completed yet, more lines remaining`,
`Pick order not completed yet, more lines remaining`,
);
} else {
console.error(


+ 321
- 0
src/components/JoWorkbench/JoPickOrderList.tsx Целия файл

@@ -0,0 +1,321 @@
"use client";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Box,
Button,
Card,
CardContent,
CardActions,
Stack,
Typography,
Chip,
CircularProgress,
Grid,
} from "@mui/material";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import { useTranslation } from "react-i18next";
import { fetchAllJoPickOrders, AllJoPickOrderResponse } from "@/app/api/jo/actions";
import JobPickExecution from "./newJobPickExecution";
import SearchBox, { Criterion } from "../SearchBox";
import dayjs from "dayjs";

interface Props {
/** Reserved for tabs parity with Jodetail; not used in workbench list yet. */
onSwitchToRecordTab?: () => void;
}

/** Jo workbench: same list + detail flow as Jodetail `JoPickOrderList`, detail uses `JoWorkbench/newJobPickExecution`. */
const JoPickOrderList: React.FC<Props> = () => {
const { t } = useTranslation(["common", "jo"]);
const today = dayjs().format("YYYY-MM-DD");
const [loading, setLoading] = useState(false);
const [pickOrders, setPickOrders] = useState<AllJoPickOrderResponse[]>([]);
const [selectedPickOrderId, setSelectedPickOrderId] = useState<number | undefined>(undefined);
const [selectedJobOrderId, setSelectedJobOrderId] = useState<number | undefined>(undefined);
const [searchQuery, setSearchQuery] = useState<Record<string, any>>({ planStart: today });

type FloorFilter = "ALL" | "2F" | "3F" | "4F" | "NO_LOT";

const searchCriteria: Criterion<any>[] = [
{ label: t("Job Order Code"), paramName: "jobOrderCode", type: "text" },
{ label: t("Pick Order"), paramName: "pickOrderCode", type: "text" },
{ label: t("Item Name"), paramName: "itemName", type: "text" },
{
label: t("Job Order Type"),
paramName: "BOM Description",
type: "select-labelled",
options: [
{ label: t("All"), value: "All" },
{ label: t("FG"), value: "FG" },
{ label: t("WIP"), value: "WIP" },
],
},
{ label: t("Plan Start"), paramName: "planStart", type: "date" },
{
label: t("BOM Type"),
paramName: "bomType",
type: "select-labelled",
options: [
{ label: t("All"), value: "All" },
{ label: t("Drink"), value: "drink" },
{ label: t("Powder Mixture"), value: "Powder_Mixture" },
{ label: t("Other"), value: "other" },
],
},
{
label: t("Floor"),
paramName: "floor",
type: "select-labelled",
options: [
{ label: t("All"), value: "ALL" },
{ label: "2F", value: "2F" },
{ label: "3F", value: "3F" },
{ label: "4F", value: "4F" },
{ label: t("No Lot"), value: "NO_LOT" },
],
},
];

const selectedFloor: FloorFilter = (() => {
const floor = String(searchQuery.floor || "ALL");
if (floor === "2F" || floor === "3F" || floor === "4F" || floor === "NO_LOT") {
return floor;
}
return "ALL";
})();

const fetchPickOrders = useCallback(async (query?: Record<string, any>) => {
setLoading(true);
try {
const currentQuery = query ?? { planStart: today };
const bomTypeValue = String(currentQuery.bomType || "All");
const floorValue = String(currentQuery.floor || "ALL");
const isAllOption = (value: string) => {
const normalized = value.trim().toLowerCase();
return normalized === "" || normalized === "all";
};
const typeParam = isAllOption(bomTypeValue) ? undefined : bomTypeValue;
const floorParam = isAllOption(floorValue) ? undefined : floorValue;
const jobOrderCode = String(currentQuery.jobOrderCode || "").trim();
const pickOrderCode = String(currentQuery.pickOrderCode || "").trim();
const itemName = String(currentQuery.itemName || "").trim();
const bomDescription = String(currentQuery.bomDescription || "").trim();
const planStart = String(currentQuery.planStart || "").trim();

const data = await fetchAllJoPickOrders(typeParam, floorParam, {
jobOrderCode: jobOrderCode || undefined,
pickOrderCode: pickOrderCode || undefined,
itemName: itemName || undefined,
bomDescription: isAllOption(bomDescription) ? undefined : bomDescription,
planStart: planStart || undefined,
});
setPickOrders(Array.isArray(data) ? data : []);
} catch (e) {
console.error(e);
setPickOrders([]);
} finally {
setLoading(false);
}
}, [today]);

const handleSearch = useCallback((query: Record<string, any>) => {
const nextQuery = { ...query };
setSearchQuery(nextQuery);
void fetchPickOrders(nextQuery);
}, [fetchPickOrders]);

const handleSearchReset = useCallback(() => {
const resetQuery = {};
setSearchQuery(resetQuery);
void fetchPickOrders(resetQuery);
}, [fetchPickOrders]);

useEffect(() => {
void fetchPickOrders({ planStart: today });
}, [fetchPickOrders, today]);
const handleBackToList = useCallback(() => {
setSelectedPickOrderId(undefined);
setSelectedJobOrderId(undefined);
void fetchPickOrders(searchQuery);
}, [fetchPickOrders, searchQuery]);
if (selectedPickOrderId !== undefined) {
return (
<Box>
<Box sx={{ mb: 2 }}>
<Button
variant="outlined"
onClick={handleBackToList}
startIcon={<ArrowBackIcon />}
>
{t("Back to List")}
</Button>
</Box>
<JobPickExecution
filterArgs={{ pickOrderId: selectedPickOrderId, jobOrderId: selectedJobOrderId }}
onBackToList={handleBackToList}
/>
</Box>
);
}

return (
<Box>
<Box sx={{ mb: 2 }}>
<SearchBox
criteria={searchCriteria}
onSearch={handleSearch}
onReset={handleSearchReset}
/>
</Box>
{loading ? (
<Box sx={{ display: "flex", justifyContent: "center", p: 3 }}>
<CircularProgress />
</Box>
) : (
<Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{t("Total pick orders")}: {pickOrders.length}
</Typography>
<Grid container spacing={2}>
{pickOrders.map((pickOrder) => {
const status = String(pickOrder.jobOrderStatus || "");
const statusLower = status.toLowerCase();
const statusColor =
statusLower === "completed"
? "success"
: statusLower === "pending" || statusLower === "processing"
? "primary"
: "default";
const finishedCount = pickOrder.finishedPickOLineCount ?? 0;
return (
<Grid key={pickOrder.id} item xs={12} sm={6} md={4}>
<Card
sx={{
minHeight: 180,
maxHeight: 280,
display: "flex",
flexDirection: "column",
}}
>
<CardContent
sx={{
pb: 1,
flexGrow: 1,
overflow: "auto",
}}
>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Box sx={{ minWidth: 0 }}>
<Typography variant="subtitle1">
{t("Job Order")}: {pickOrder.jobOrderCode || "-"}
</Typography>
</Box>
<Chip size="small" label={t(status)} color={statusColor as any} />
</Stack>
<Typography variant="body2" color="text.secondary">
{t("Lot No")}: {pickOrder.lotNo || "-"}
</Typography>
<Typography variant="body2" color="text.secondary">
{t("Pick Order")}: {pickOrder.pickOrderCode || "-"}
</Typography>
<Typography variant="body2" color="text.secondary">
{t("Item Name")}: {pickOrder.itemName}
{pickOrder.bomDescription ? ` (${t(pickOrder.bomDescription)})` : ""}
</Typography>
<Typography variant="body2" color="text.secondary">
{t("Required Qty")}: {pickOrder.reqQty} ({pickOrder.uomName})
</Typography>
{selectedFloor === "ALL" ? (
<>
{pickOrder.floorPickCounts?.map(({ floor, finishedCount, totalCount }) => (
<Typography
key={floor}
variant="body2"
color="text.secondary"
component="span"
sx={{ mr: 1 }}
>
{floor}: {finishedCount}/{totalCount}
</Typography>
))}
{!!pickOrder.noLotPickCount && (
<Typography
key="NO_LOT"
variant="body2"
color="text.secondary"
component="span"
sx={{ mr: 1 }}
>
{t("No Lot")}: {pickOrder.noLotPickCount.finishedCount}/{pickOrder.noLotPickCount.totalCount}
</Typography>
)}
</>
) : selectedFloor === "NO_LOT" ? (
!!pickOrder.noLotPickCount && (
<Typography
key="NO_LOT"
variant="body2"
color="text.secondary"
component="span"
sx={{ mr: 1 }}
>
{t("No Lot")}: {pickOrder.noLotPickCount.finishedCount}/{pickOrder.noLotPickCount.totalCount}
</Typography>
)
) : (
pickOrder.floorPickCounts
?.filter((c) => c.floor === selectedFloor)
.map(({ floor, finishedCount, totalCount }) => (
<Typography
key={floor}
variant="body2"
color="text.secondary"
component="span"
sx={{ mr: 1 }}
>
{floor}: {finishedCount}/{totalCount}
</Typography>
))
)}
{typeof pickOrder.suggestedFailCount === "number" && pickOrder.suggestedFailCount > 0 && (
<Typography variant="body2" color="error" sx={{ mt: 0.5 }}>
{t("Suggested Fail")}: {pickOrder.suggestedFailCount}
</Typography>
)}
{statusLower !== "pending" && finishedCount > 0 && (
<Box sx={{ mt: 1 }}>
<Typography variant="body2" fontWeight={600}>
{t("Finished lines")}: {finishedCount}
</Typography>
</Box>
)}
</CardContent>
<CardActions sx={{ pt: 0.5 }}>
<Button
variant="contained"
size="small"
onClick={() => {
setSelectedPickOrderId(pickOrder.pickOrderId ?? undefined);
setSelectedJobOrderId(pickOrder.jobOrderId ?? undefined);
}}
>
{t("View Details")}
</Button>
<Box sx={{ flex: 1 }} />
</CardActions>
</Card>
</Grid>
);
})}
</Grid>
</Box>
)}
</Box>
);
};

export default JoPickOrderList;

+ 923
- 0
src/components/JoWorkbench/JoWorkbenchSearch.tsx Целия файл

@@ -0,0 +1,923 @@
"use client"
import { SearchJoResultRequest, setJobOrderHidden, updateJo, updateProductProcessPriority, updateJoReqQty } from "@/app/api/jo/actions";
import { fetchJosForWorkbench, releaseJoForWorkbench } from "@/app/api/jo/workbenchActions";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Criterion } from "../SearchBox";
import SearchResults, { Column, defaultPagingController } from "../SearchResults/SearchResults";
import { EditNote } from "@mui/icons-material";
import { arrayToDateString, arrayToDateTimeString, integerFormatter, dayjsToDateString } from "@/app/utils/formatUtil";
import { orderBy, uniqBy, upperFirst } from "lodash";
import SearchBox from "../SearchBox/SearchBox";
import { useRouter } from "next/navigation";
import { FormProvider, SubmitErrorHandler, SubmitHandler, useForm } from "react-hook-form";
import { StockInLineInput } from "@/app/api/stockIn";
import { JobOrder, JoDetailPickLine, JoStatus } from "@/app/api/jo";
import { Button, Stack, Dialog, DialogTitle, DialogContent, DialogActions, TextField, IconButton, InputAdornment, Typography, Box, CircularProgress } from "@mui/material";
import { BomCombo } from "@/app/api/bom";
import JoCreateFormModal from "@/components/JoSearch/JoCreateFormModal";
import AddIcon from '@mui/icons-material/Add';
import EditIcon from '@mui/icons-material/Edit';
import QcStockInModal from "../Qc/QcStockInModal";
import { useSession } from "next-auth/react";
import { SessionWithTokens } from "@/config/authConfig";
import { createStockInLine } from "@/app/api/stockIn/actions";
import { msg } from "../Swal/CustomAlerts";
import dayjs from "dayjs";
//import { fetchInventories } from "@/app/api/inventory/actions";
import { InventoryResult } from "@/app/api/inventory";
import { PrinterCombo } from "@/app/api/settings/printer";
import { JobTypeResponse } from "@/app/api/jo/actions";
import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { updateJoPlanStart } from "@/app/api/jo/actions";
import { arrayToDayjs } from "@/app/utils/formatUtil";

interface Props {
defaultInputs: SearchJoResultRequest,
bomCombo: BomCombo[]
printerCombo: PrinterCombo[];
jobTypes: JobTypeResponse[];
}

type SearchParamNames = "code" | "itemName" | "planStart" | "planStartTo" | "jobTypeName" | "joSearchStatus";

const JoWorkbenchSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobTypes = [] }) => {
const { t } = useTranslation("jo");
const router = useRouter()
const [filteredJos, setFilteredJos] = useState<JobOrder[]>([]);
const [inputs, setInputs] = useState(defaultInputs);
const [pagingController, setPagingController] = useState(
defaultPagingController
)
const [totalCount, setTotalCount] = useState(0)
const [isCreateJoModalOpen, setIsCreateJoModalOpen] = useState(false)

const [inventoryData, setInventoryData] = useState<InventoryResult[]>([]);
const [detailedJos, setDetailedJos] = useState<Map<number, JobOrder>>(new Map());
const [checkboxIds, setCheckboxIds] = useState<(string | number)[]>([]);
const [releasingJoIds, setReleasingJoIds] = useState<Set<number>>(new Set());
const [isBatchReleasing, setIsBatchReleasing] = useState(false);
const [cancelConfirmJoId, setCancelConfirmJoId] = useState<number | null>(null);
const [cancelSubmitting, setCancelSubmitting] = useState(false);
const [cancelingJoIds, setCancelingJoIds] = useState<Set<number>>(new Set());
// 合并后的统一编辑 Dialog 状态
const [openEditDialog, setOpenEditDialog] = useState(false);
const [selectedJoForEdit, setSelectedJoForEdit] = useState<JobOrder | null>(null);
const [editPlanStartDate, setEditPlanStartDate] = useState<dayjs.Dayjs | null>(null);
const [editReqQtyMultiplier, setEditReqQtyMultiplier] = useState<number>(1);
const [editBomForReqQty, setEditBomForReqQty] = useState<BomCombo | null>(null);
const [editProductionPriority, setEditProductionPriority] = useState<number>(50);
const [editProductProcessId, setEditProductProcessId] = useState<number | null>(null);
const fetchJoDetailClient = async (id: number): Promise<JobOrder> => {
const response = await fetch(`/api/jo/detail?id=${id}`);
if (!response.ok) {
throw new Error('Failed to fetch JO detail');
}
return response.json();
};
/*
useEffect(() => {
const fetchDetailedJos = async () => {
const detailedMap = new Map<number, JobOrder>();
try {
const results = await Promise.all(
filteredJos.map((jo) =>
fetchJoDetailClient(jo.id).then((detail) => ({ id: jo.id, detail })).catch((error) => {
console.error(`Error fetching detail for JO ${jo.id}:`, error);
return null;
})
)
);
results.forEach((r) => {
if (r) detailedMap.set(r.id, r.detail);
});
} catch (error) {
console.error("Error fetching JO details:", error);
}
setDetailedJos(detailedMap);
};

if (filteredJos.length > 0) {
fetchDetailedJos();
}
}, [filteredJos]);
*/
/*
useEffect(() => {
const fetchInventoryData = async () => {
try {
const inventoryResponse = await fetchInventories({
code: "",
name: "",
type: "",
pageNum: 0,
pageSize: 200,
});
setInventoryData(inventoryResponse.records ?? []);
} catch (error) {
console.error("Error fetching inventory data:", error);
}
};

fetchInventoryData();
}, []);
*/
const getStockAvailable = (pickLine: JoDetailPickLine) => {
const inventory = inventoryData.find(inventory =>
inventory.itemCode === pickLine.code || inventory.itemName === pickLine.name
);
if (inventory) {
return inventory.availableQty || (inventory.onHandQty - inventory.onHoldQty - inventory.unavailableQty);
}
return 0;
};

const isStockSufficient = (pickLine: JoDetailPickLine) => {
const stockAvailable = getStockAvailable(pickLine);
return stockAvailable >= pickLine.reqQty;
};

const getStockCounts = (jo: JobOrder) => {
return {
sufficient: jo.sufficientCount,
insufficient: jo.insufficientCount
};
};

const searchCriteria: Criterion<SearchParamNames>[] = useMemo(() => [
{ label: t("Code"), paramName: "code", type: "text" },
{ label: t("Item Name"), paramName: "itemName", type: "text" },
{ label: t("Plan Start"), label2: t("Plan Start To"), paramName: "planStart", type: "dateRange", preFilledValue: {
from: dayjsToDateString(dayjs(), "input"),
to: dayjsToDateString(dayjs(), "input")
} },
{
label: t("Job Type"),
paramName: "jobTypeName",
type: "select",
options: (jobTypes ?? []).map(jt => jt.name)
},
{
label: t("Status"),
paramName: "joSearchStatus",
type: "select-labelled",
options: [
{ label: t("Pending"), value: "pending" },
{ label: t("Packaging"), value: "packaging" },
{ label: t("Processing"), value: "processing" },
{ label: t("Storing"), value: "storing" },
{ label: t("Put Awayed"), value: "putAwayed" },
{ label: t("cancel"), value: "cancel" },
],
},
], [t, jobTypes])
const fetchBomForJo = useCallback(async (jo: JobOrder): Promise<BomCombo | null> => {
try {
// 若列表的 jo 已有 bomId(之後若 API 有回傳),可直接用
const bomId = (jo as { bomId?: number }).bomId;
if (bomId != null) {
const matchingBom = bomCombo.find(bom => bom.id === bomId);
if (matchingBom) return matchingBom;
}
// 否則打明細 API,明細會帶 bomId
const detailedJo = detailedJos.get(jo.id) ?? await fetchJoDetailClient(jo.id);
const detailBomId = (detailedJo as { bomId?: number }).bomId;
if (detailBomId != null) {
const matchingBom = bomCombo.find(bom => bom.id === detailBomId);
if (matchingBom) return matchingBom;
}
return null;
} catch (error) {
console.error("Error fetching BOM for JO:", error);
return null;
}
}, [bomCombo, detailedJos]);
// 统一的打开编辑对话框函数
const handleOpenEditDialog = useCallback(async (jo: JobOrder) => {
setSelectedJoForEdit(jo);
// 设置 Plan Start Date
if (jo.planStart && Array.isArray(jo.planStart)) {
setEditPlanStartDate(arrayToDayjs(jo.planStart));
} else {
setEditPlanStartDate(dayjs());
}
// 设置 Production Priority
setEditProductionPriority(jo.productionPriority ?? 50);
// 获取 productProcessId
try {
const { fetchProductProcessesByJobOrderId } = await import("@/app/api/jo/actions");
const processes = await fetchProductProcessesByJobOrderId(jo.id);
if (processes && processes.length > 0) {
setEditProductProcessId(processes[0].id);
}
} catch (error) {
console.error("Error fetching product process:", error);
}
// 设置 ReqQty
const bom = await fetchBomForJo(jo);
if (bom) {
setEditBomForReqQty(bom);
const currentMultiplier = bom.outputQty > 0
? Math.round(jo.reqQty / bom.outputQty)
: 1;
setEditReqQtyMultiplier(currentMultiplier);
}
setOpenEditDialog(true);
}, [fetchBomForJo]);
// 统一的关闭函数
const handleCloseEditDialog = useCallback((_event?: object, _reason?: "backdropClick" | "escapeKeyDown") => {
setOpenEditDialog(false);
setSelectedJoForEdit(null);
setEditPlanStartDate(null);
setEditReqQtyMultiplier(1);
setEditBomForReqQty(null);
setEditProductionPriority(50);
setEditProductProcessId(null);
}, []);

const newPageFetch = useCallback(
async (
pagingController: { pageNum: number; pageSize: number },
filterArgs: SearchJoResultRequest,
) => {
const params: SearchJoResultRequest = {
...filterArgs,
pageNum: pagingController.pageNum - 1,
pageSize: pagingController.pageSize,
};
const response = await fetchJosForWorkbench(params);
console.log("newPageFetch params:", params)
console.log("newPageFetch response:", response)
if (response && response.records) {
console.log("newPageFetch - setting filteredJos with", response.records.length, "records");
setTotalCount(response.total);
setFilteredJos(response.records);
console.log("newPageFetch - filteredJos set, first record id:", response.records[0]?.id);
} else {
console.warn("newPageFetch - no response or no records");
setFilteredJos([]);
}
},
[],
);

const isPlanningJo = useCallback((jo: JobOrder) => {
return String(jo.status ?? "").toLowerCase() === "planning";
}, []);
const handleReleaseJo = useCallback(async (joId: number) => {
if (!joId) return;
setReleasingJoIds((prev) => {
const next = new Set(prev);
next.add(joId);
return next;
});
try {
const response = await releaseJoForWorkbench({ id: joId });
if (response) {
msg(t("update success"));
setCheckboxIds((prev) => prev.filter((id) => Number(id) !== joId));
await newPageFetch(pagingController, inputs);
}
} catch (error) {
console.error("Error releasing JO:", error);
msg(t("update failed"));
} finally {
setReleasingJoIds((prev) => {
const next = new Set(prev);
next.delete(joId);
return next;
});
}
}, [inputs, pagingController, t, newPageFetch]);

const handleConfirmCancelJobOrder = useCallback(async () => {
if (cancelConfirmJoId == null) return;
const id = cancelConfirmJoId;
setCancelSubmitting(true);
setCancelingJoIds((prev) => new Set(prev).add(id));
try {
await setJobOrderHidden(id, true);
msg(t("update success"));
setCancelConfirmJoId(null);
await newPageFetch(pagingController, inputs);
} catch (error) {
console.error("Error cancelling job order:", error);
msg(t("update failed"));
} finally {
setCancelSubmitting(false);
setCancelingJoIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
}
}, [cancelConfirmJoId, newPageFetch, pagingController, inputs, t]);
const selectedPlanningJoIds = useMemo(() => {
const selectedIds = new Set(checkboxIds.map((id) => Number(id)));
return filteredJos
.filter((jo) => selectedIds.has(jo.id))
.filter((jo) => isPlanningJo(jo))
.map((jo) => jo.id);
}, [checkboxIds, filteredJos, isPlanningJo]);
const handleBatchRelease = useCallback(async () => {
if (selectedPlanningJoIds.length === 0) return;
setIsBatchReleasing(true);
try {
const results = await Promise.allSettled(
selectedPlanningJoIds.map((id) => releaseJoForWorkbench({ id })),
);
const successCount = results.filter((r) => r.status === "fulfilled").length;
const failedCount = results.length - successCount;
if (successCount > 0 && failedCount === 0) {
msg(t("update success"));
} else if (successCount > 0) {
msg(`${t("update success")} (${successCount}), ${t("update failed")} (${failedCount})`);
} else {
msg(t("update failed"));
}
setCheckboxIds((prev) => prev.filter((id) => !selectedPlanningJoIds.includes(Number(id))));
await newPageFetch(pagingController, inputs);
} catch (error) {
console.error("Error batch releasing JOs:", error);
msg(t("update failed"));
} finally {
setIsBatchReleasing(false);
}
}, [inputs, pagingController, selectedPlanningJoIds, t, newPageFetch]);
const columns = useMemo<Column<JobOrder>[]>(
() => [
{
name: "id",
label: "",
type: "checkbox",
disabled: (row) => {
const id = row.id;
return !isPlanningJo(row) || releasingJoIds.has(id) || isBatchReleasing;
}
},
{
name: "planStart",
label: t("Estimated Production Date"),
align: "left",
headerAlign: "left",
renderCell: (row) => {
return (
<Stack direction="row" alignItems="center" spacing={1}>
{row.status == "planning" && (
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
handleOpenEditDialog(row);
}}
sx={{ padding: '4px' }}
>
<EditIcon fontSize="small" />
</IconButton>
)}
<span>{row.planStart ? arrayToDateString(row.planStart) : '-'}</span>
</Stack>
);
}
},
{
name: "productionPriority",
label: t("Production Priority"),
renderCell: (row) => {
return (
<Stack direction="row" alignItems="center" spacing={1}>
<span>{integerFormatter.format(row.productionPriority)}</span>
</Stack>
);
}
},
{
name: "code",
label: t("Code / Lot No"),
flex: 2,
renderCell: (row) => (
<span>
{row.code}
<br />
{row.lotNo ?? "-"}
</span>
),
},
{
name: "item",
label: `${t("Item Name")}`,
renderCell: (row) => {
return row.item ? <>{t(row.jobTypeName)} {t(row.item.code)} {t(row.item.name)}</> : '-'
}
},
{
name: "reqQty",
label: t("Req. Qty"),
align: "right",
headerAlign: "right",
renderCell: (row) => {
return (
<Stack direction="row" alignItems="center" spacing={1} justifyContent="flex-end">
<span>{integerFormatter.format(row.reqQty)}</span>
</Stack>
);
}
},
{
name: "item",
label: t("UoM"),
align: "left",
headerAlign: "left",
renderCell: (row) => {
return row.item?.uom ? t(row.item.uom.udfudesc) : '-'
}
},
{
name: "stockStatus" as keyof JobOrder,
label: t("BOM Status"),
align: "left",
headerAlign: "left",
renderCell: (row) => {
const stockCounts = getStockCounts(row);
return (
<span style={{ color: stockCounts.insufficient > 0 ? 'red' : 'green' }}>
{stockCounts.sufficient}/{stockCounts.sufficient + stockCounts.insufficient}
</span>
);
}
},
{
name: "status",
label: t("Status"),
renderCell: (row) => {
return <span style={{color: row.stockInLineStatus == "escalated" ? "red" : "inherit"}}>
{t(upperFirst(row.status))}
</span>
}
},
{
name: "id",
label: t("Actions"),
renderCell: (row) => {
const isPlanning = isPlanningJo(row);
const isReleasing = releasingJoIds.has(row.id) || isBatchReleasing;
const isCancelingRow = cancelingJoIds.has(row.id);
const isPutAwayed = row.stockInLineStatus?.toLowerCase() === "completed";
return (
<Stack direction="row" spacing={1} alignItems="center">
<Button
type="button"
variant="contained"
color="primary"
sx={{ minWidth: 120 }}
onClick={(e) => {
e.stopPropagation();
onDetailClick(row);
}}
>
{t("View")}
</Button>
{isPlanning ? (
<Button
type="button"
variant="contained"
color="success"
disabled={!isPlanning || isReleasing}
sx={{ minWidth: 120 }}
onClick={(e) => {
e.stopPropagation();
handleReleaseJo(row.id);
}}
startIcon={isReleasing && isPlanning ? <CircularProgress size={16} color="inherit" /> : undefined}
>
{t("Release")}
</Button>
) : (
<Button
type="button"
variant="contained"
color="warning"
disabled={isPutAwayed || isCancelingRow || isBatchReleasing || cancelSubmitting}
sx={{ minWidth: 120 }}
onClick={(e) => {
e.stopPropagation();
setCancelConfirmJoId(row.id);
}}
startIcon={isCancelingRow ? <CircularProgress size={16} color="inherit" /> : undefined}
>
{t("Cancel Job Order")}
</Button>
)}
</Stack>
)
}
},
], [t, inventoryData, detailedJos, handleOpenEditDialog, handleReleaseJo, isPlanningJo, releasingJoIds, isBatchReleasing, cancelingJoIds, cancelSubmitting]
)

const handleUpdateReqQty = useCallback(async (jobOrderId: number, newReqQty: number) => {
try {
const response = await updateJoReqQty({
id: jobOrderId,
reqQty: newReqQty
});
if (response) {
msg(t("update success"));
await newPageFetch(pagingController, inputs);
}
} catch (error) {
console.error("Error updating reqQty:", error);
msg(t("update failed"));
}
}, [pagingController, inputs, newPageFetch, t]);
const handleUpdatePlanStart = useCallback(async (jobOrderId: number, planStart: string) => {
const response = await updateJoPlanStart({ id: jobOrderId, planStart });
if (response) {
await newPageFetch(pagingController, inputs);
}
}, [pagingController, inputs, newPageFetch]);
const handleUpdateOperationPriority = useCallback(async (productProcessId: number, productionPriority: number) => {
const response = await updateProductProcessPriority(productProcessId, productionPriority)
if (response) {
await newPageFetch(pagingController, inputs);
}
}, [pagingController, inputs, newPageFetch]);
// 统一的确认函数
const handleConfirmEdit = useCallback(async () => {
if (!selectedJoForEdit) return;
try {
// 更新 Plan Start
if (editPlanStartDate) {
const dateString = `${dayjsToDateString(editPlanStartDate, "input")}T00:00:00`;
await handleUpdatePlanStart(selectedJoForEdit.id, dateString);
}
// 更新 ReqQty
if (editBomForReqQty) {
const newReqQty = editReqQtyMultiplier * editBomForReqQty.outputQty;
await handleUpdateReqQty(selectedJoForEdit.id, newReqQty);
}
// 更新 Production Priority
if (editProductProcessId) {
await handleUpdateOperationPriority(editProductProcessId, Number(editProductionPriority));
}
setOpenEditDialog(false);
setSelectedJoForEdit(null);
setEditPlanStartDate(null);
setEditReqQtyMultiplier(1);
setEditBomForReqQty(null);
setEditProductionPriority(50);
setEditProductProcessId(null);
} catch (error) {
console.error("Error updating:", error);
msg(t("update failed"));
}
}, [selectedJoForEdit, editPlanStartDate, editBomForReqQty, editReqQtyMultiplier, editProductionPriority, editProductProcessId, handleUpdatePlanStart, handleUpdateReqQty, handleUpdateOperationPriority, t]);
useEffect(() => {
newPageFetch(pagingController, inputs);
}, [newPageFetch, pagingController, inputs]);

const handleUpdate = useCallback(async (jo: JobOrder) => {
console.log(jo);
try {
if (jo.id) {
const response = await updateJo({ id: jo.id, status: "storing" });
console.log(`%c Updated JO:`, "color:lime", response);
const postData = {
itemId: jo?.item?.id!!,
acceptedQty: jo?.reqQty ?? 1,
productLotNo: jo?.code,
productionDate: arrayToDateString(dayjs(), "input"),
jobOrderId: jo?.id,
};
const res = await createStockInLine(postData);
console.log(`%c Created Stock In Line`, "color:lime", res);
msg(t("update success"));
setInputs(defaultInputs);
setPagingController(defaultPagingController);
}
} catch (e) {
console.log(e);
} finally {
// setIsUploading(false)
}
}, [defaultInputs, t])

const getButtonSx = (jo : JobOrder) => {
const joStatus = jo.status?.toLowerCase();
const silStatus = jo.stockInLineStatus?.toLowerCase();
let btnSx = {label:"", color:""};
switch (joStatus) {
case "planning": btnSx = {label: t("release jo"), color:"primary.main"}; break;
case "pending": btnSx = {label: t("scan picked material"), color:"error.main"}; break;
case "processing": btnSx = {label: t("complete jo"), color:"warning.main"}; break;
case "storing":
switch (silStatus) {
case "pending": btnSx = {label: t("process epqc"), color:"success.main"}; break;
case "received": btnSx = {label: t("view putaway"), color:"secondary.main"}; break;
case "escalated":
if (sessionToken?.id == jo.silHandlerId) {
btnSx = {label: t("escalation processing"), color:"warning.main"};
break;
}
default: btnSx = {label: t("view stockin"), color:"info.main"};
}
break;
case "completed": btnSx = {label: t("view stockin"), color:"info.main"}; break;
default: btnSx = {label: t("scan picked material"), color:"success.main"};
}
return btnSx
};

const { data: session } = useSession();
const sessionToken = session as SessionWithTokens | null;

const [openModal, setOpenModal] = useState<boolean>(false);
const [modalInfo, setModalInfo] = useState<StockInLineInput>();

const onDetailClick = useCallback((record: JobOrder) => {
router.push(`/jo/edit?id=${record.id}`)
}, [router])

const closeNewModal = useCallback(() => {
setOpenModal(false);
setInputs(defaultInputs);
setPagingController(defaultPagingController);
}, [defaultInputs]);

const onSearch = useCallback((query: Record<SearchParamNames, string>) => {
const transformedQuery = {
...query,
planStart: query.planStart ? `${query.planStart}T00:00` : query.planStart,
planStartTo: query.planStartTo ? `${query.planStartTo}T23:59:59` : query.planStartTo,
jobTypeName: query.jobTypeName && query.jobTypeName !== "All" ? query.jobTypeName : "",
joSearchStatus: query.joSearchStatus && query.joSearchStatus !== "All" ? query.joSearchStatus : "all",
};
setInputs({
code: transformedQuery.code,
itemName: transformedQuery.itemName,
planStart: transformedQuery.planStart,
planStartTo: transformedQuery.planStartTo,
jobTypeName: transformedQuery.jobTypeName,
joSearchStatus: transformedQuery.joSearchStatus
});
setPagingController(defaultPagingController);
}, [defaultInputs])

const onReset = useCallback(() => {
setInputs(defaultInputs);
setPagingController(defaultPagingController);
}, [defaultInputs])

const onOpenCreateJoModal = useCallback(() => {
setIsCreateJoModalOpen(() => true)
}, [])

const onCloseCreateJoModal = useCallback(() => {
setIsCreateJoModalOpen(() => false)
}, [])

return <>
<Stack
direction="row"
justifyContent="flex-end"
spacing={2}
sx={{ mt: 2 }}
>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mr: "auto" }}>
<Typography variant="body2" color="text.secondary">
{t("Selected")}: {selectedPlanningJoIds.length}
</Typography>
<Button
variant="contained"
color="success"
disabled={selectedPlanningJoIds.length === 0 || isBatchReleasing}
onClick={handleBatchRelease}
startIcon={isBatchReleasing ? <CircularProgress size={16} color="inherit" /> : undefined}
>
{t("Release")} ({selectedPlanningJoIds.length})
</Button>
<Button
variant="outlined"
disabled={checkboxIds.length === 0 || isBatchReleasing}
onClick={() => setCheckboxIds([])}
>
{t("Reset")}
</Button>
</Stack>
<Button
variant="outlined"
startIcon={<AddIcon />}
onClick={onOpenCreateJoModal}
>
{t("Create Job Order")}
</Button>
</Stack>
<SearchBox
criteria={searchCriteria}
onSearch={onSearch}
onReset={onReset}
/>
<SearchResults<JobOrder>
items={filteredJos}
columns={columns}
setPagingController={setPagingController}
pagingController={pagingController}
totalCount={totalCount}
isAutoPaging={false}
checkboxIds={checkboxIds}
setCheckboxIds={setCheckboxIds}
/>
<JoCreateFormModal
open={isCreateJoModalOpen}
bomCombo={bomCombo}
jobTypes={jobTypes}
onClose={onCloseCreateJoModal}
onSearch={() => {
setInputs({ ...defaultInputs });
setPagingController(defaultPagingController);
}}
/>

<QcStockInModal
session={sessionToken}
open={openModal}
onClose={closeNewModal}
inputDetail={modalInfo}
printerCombo={printerCombo}
/>
{/* 合并后的统一编辑 Dialog */}
<Dialog
open={openEditDialog}
onClose={handleCloseEditDialog}
fullWidth
maxWidth="sm"
>
<DialogTitle>{t("Edit Job Order")}</DialogTitle>
<DialogContent>
<Stack spacing={3} sx={{ mt: 1 }}>
{/* Plan Start Date */}
<LocalizationProvider dateAdapter={AdapterDayjs}>
<DatePicker
label={t("Estimated Production Date")}
value={editPlanStartDate}
onChange={(newValue) => setEditPlanStartDate(newValue)}
slotProps={{
textField: {
fullWidth: true,
margin: "dense",
}
}}
/>
</LocalizationProvider>
{/* Production Priority */}
<TextField
label={t("Production Priority")}
type="number"
fullWidth
value={editProductionPriority}
onChange={(e) => {
const val = Number(e.target.value);
if (val >= 1 && val <= 100) {
setEditProductionPriority(val);
}
}}
inputProps={{
min: 1,
max: 100,
step: 1
}}
/>
{/* ReqQty */}
{editBomForReqQty && (
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
<TextField
label={t("Base Qty")}
fullWidth
type="number"
variant="outlined"
value={editBomForReqQty.outputQty}
disabled
InputProps={{
endAdornment: editBomForReqQty.outputQtyUom ? (
<InputAdornment position="end">
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{editBomForReqQty.outputQtyUom}
</Typography>
</InputAdornment>
) : null
}}
sx={{ flex: 1 }}
/>
<Typography variant="body1" sx={{ color: "text.secondary" }}>
×
</Typography>
<TextField
label={t("Batch Count")}
fullWidth
type="number"
variant="outlined"
value={editReqQtyMultiplier}
onChange={(e) => {
const val = e.target.value === "" ? 1 : Math.max(1, Math.floor(Number(e.target.value)));
setEditReqQtyMultiplier(val);
}}
inputProps={{
min: 1,
step: 1
}}
sx={{ flex: 1 }}
/>
<Typography variant="body1" sx={{ color: "text.secondary" }}>
=
</Typography>
<TextField
label={t("Req. Qty")}
fullWidth
variant="outlined"
type="number"
value={editBomForReqQty ? (editReqQtyMultiplier * editBomForReqQty.outputQty) : ""}
disabled
InputProps={{
endAdornment: editBomForReqQty.outputQtyUom ? (
<InputAdornment position="end">
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{editBomForReqQty.outputQtyUom}
</Typography>
</InputAdornment>
) : null
}}
sx={{ flex: 1 }}
/>
</Box>
)}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseEditDialog}>{t("Cancel")}</Button>
<Button
variant="contained"
onClick={handleConfirmEdit}
disabled={!editPlanStartDate || !editBomForReqQty}
>
{t("Save")}
</Button>
</DialogActions>
</Dialog>

<Dialog
open={cancelConfirmJoId !== null}
onClose={() => !cancelSubmitting && setCancelConfirmJoId(null)}
maxWidth="xs"
fullWidth
>
<DialogTitle>{t("Confirm cancel job order")}</DialogTitle>
<DialogContent>
<Typography variant="body2">{t("Cancel job order confirm message")}</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setCancelConfirmJoId(null)} disabled={cancelSubmitting}>{t("Cancel")}</Button>
<Button variant="contained" color="warning" onClick={() => void handleConfirmCancelJobOrder()} disabled={cancelSubmitting}>
{cancelSubmitting ? <CircularProgress size={20} /> : t("Cancel Job Order")}
</Button>
</DialogActions>
</Dialog>
</>
}

export default JoWorkbenchSearch;

+ 60
- 0
src/components/JoWorkbench/JoWorkbenchTabs.tsx Целия файл

@@ -0,0 +1,60 @@
"use client";

import { Box, Tab, Tabs } from "@mui/material";
import React from "react";
import { useTranslation } from "react-i18next";
import JoWorkbenchSearch from "@/components/JoWorkbench/JoWorkbenchSearch";
import JoPickOrderList from "@/components/JoWorkbench/JoPickOrderList";
import type { SearchJoResultRequest } from "@/app/api/jo/actions";
import type { BomCombo } from "@/app/api/bom";
import type { PrinterCombo } from "@/app/api/settings/printer";
import type { JobTypeResponse } from "@/app/api/jo/actions";

export type JoWorkbenchTabsProps = {
defaultSearchInputs: SearchJoResultRequest;
bomCombo: BomCombo[];
printerCombo: PrinterCombo[];
jobTypes: JobTypeResponse[];
defaultTabIndex?: number;
};

function TabPanel(props: { value: number; index: number; children: React.ReactNode }) {
const { value, index, children } = props;
if (value !== index) return null;
return <Box sx={{ pt: 2 }}>{children}</Box>;
}

const JoWorkbenchTabs: React.FC<JoWorkbenchTabsProps> = ({
defaultSearchInputs,
bomCombo,
printerCombo,
jobTypes,
defaultTabIndex = 0,
}) => {
const { t } = useTranslation("jo");
const [tab, setTab] = React.useState<number>(defaultTabIndex);

return (
<Box>
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ borderBottom: 1, borderColor: "divider" }}>
<Tab label={t("Workbench search", { defaultValue: "Search / Release" })} value={0} />
<Tab label={t("Workbench pick list", { defaultValue: "Pick order list" })} value={1} />
</Tabs>

<TabPanel value={tab} index={0}>
<JoWorkbenchSearch
defaultInputs={defaultSearchInputs}
bomCombo={bomCombo}
printerCombo={printerCombo}
jobTypes={jobTypes}
/>
</TabPanel>

<TabPanel value={tab} index={1}>
<JoPickOrderList />
</TabPanel>
</Box>
);
};

export default JoWorkbenchTabs;

+ 4947
- 0
src/components/JoWorkbench/newJobPickExecution.tsx
Файловите разлики са ограничени, защото са твърде много
Целия файл


+ 2
- 1
src/components/Jodetail/JodetailSearch.tsx Целия файл

@@ -15,6 +15,7 @@ import {
import {
arrayToDayjs,
} from "@/app/utils/formatUtil";
import JoPickOrderList from "@/components/JoWorkbench/JoPickOrderList";
import { Button, Grid, Stack, Tab, Tabs, TabsProps, Typography, Box, TextField, Autocomplete } from "@mui/material";
import Jodetail from "./Jodetail"
import PickExecution from "./JobPickExecution";
@@ -26,7 +27,7 @@ import JobPickExecutionsecondscan from "./JobPickExecutionsecondscan";
import FInishedJobOrderRecord from "./FInishedJobOrderRecord";
import JobPickExecution from "./JobPickExecution";
import CompleteJobOrderRecord from "./completeJobOrderRecord";
import JoPickOrderList from "./JoPickOrderList";
//import JoPickOrderList from "./JoPickOrderList";
import {
fetchUnassignedJobOrderPickOrders,
assignJobOrderPickOrder,


+ 36
- 3
src/components/NavigationContent/NavigationContent.tsx Целия файл

@@ -117,12 +117,14 @@ const NavigationContent: React.FC = () => {
requiredAbility: [AUTH.STOCK, AUTH.STOCK_TAKE, AUTH.STOCK_IN_BIND, AUTH.ADMIN],
path: "/putAway",
},
/*
{
icon: <ViewModule />,
label: "Finished Good Order",
requiredAbility: [AUTH.STOCK_FG, AUTH.ADMIN],
path: "/finishedGood",
},
*/
{
icon: <ViewModule />,
label: "Finished Good Management",
@@ -135,14 +137,15 @@ const NavigationContent: React.FC = () => {
requiredAbility: [AUTH.STOCK, AUTH.STOCK_TAKE, AUTH.STOCK_IN_BIND, AUTH.STOCK_FG, AUTH.ADMIN],
path: "/stockRecord",
},
/*

{
icon: <Description />,
label: "Do Workbench",
requiredAbility: [AUTH.STOCK, AUTH.STOCK_TAKE, AUTH.STOCK_IN_BIND, AUTH.STOCK_FG, AUTH.ADMIN],
path: "/doworkbench",
},
*/
],
},
{
@@ -188,6 +191,14 @@ const NavigationContent: React.FC = () => {
requiredAbility: [AUTH.JOB_PROD, AUTH.ADMIN],
path: "/bag",
},
/*
{
icon: <Inventory2 />,
label: "Job Order Workbench",
requiredAbility: [AUTH.JOB_PROD, AUTH.ADMIN],
path: "/jo/workbench",
},
*/
],
},
{
@@ -389,6 +400,22 @@ const NavigationContent: React.FC = () => {
if (pathname === "/productionProcess" || pathname.startsWith("/productionProcess/")) {
ensureOpen.push("Management Job Order");
}
if (
pathname === "/jo/workbench" ||
pathname.startsWith("/jo/workbench/") ||
pathname === "/jodetail" ||
pathname.startsWith("/jodetail/")
) {
ensureOpen.push("Management Job Order");
}
if (
pathname === "/doworkbench" ||
pathname.startsWith("/doworkbench/") ||
pathname === "/doworkbenchsearch" ||
pathname.startsWith("/doworkbenchsearch/")
) {
ensureOpen.push("Store Management");
}
if (ensureOpen.length === 0) return;
setOpenItems((prev) => {
const set = new Set(prev);
@@ -423,7 +450,13 @@ const NavigationContent: React.FC = () => {
walk(navigationItems);

// Pick the most specific (longest) match to avoid double-highlighting
const matches = leafPaths.filter((p) => pathname === p || pathname.startsWith(p + "/"));
const matches = leafPaths.filter((p) => {
if (pathname === p) return true;
if (!pathname.startsWith(p + "/")) return false;
// `/doworkbench` must not claim `/doworkbenchsearch` (prefix without trailing slash)
if (p === "/doworkbench" && pathname.startsWith("/doworkbenchsearch")) return false;
return true;
});
matches.sort((a, b) => b.length - a.length);
return matches[0] ?? "";
}, [hasAbility, navigationItems, pathname]);


+ 9
- 4
src/components/PickOrderSearch/AssignAndRelease.tsx Целия файл

@@ -25,6 +25,8 @@ import {
newassignPickOrder,
AssignPickOrderInputs,
releaseAssignedPickOrders,
assignPickOrderWorkbenchV2,
releasePickOrderWorkbenchV2,
} from "@/app/api/pickOrder/actions";
import { fetchNameList, NameList ,fetchNewNameList, NewNameList} from "@/app/api/user/actions";
import { FormProvider, useForm } from "react-hook-form";
@@ -158,8 +160,9 @@ const AssignAndRelease: React.FC<Props> = ({ filterArgs }) => {
console.log("First record:", res.records[0]);
// 新增:在前端也过滤掉 "assigned" 状态的项目
const filteredRecords = res.records.filter((item: any) => item.status !== "assigned");
const filteredRecords = res.records.filter(
(item: any) => (item.status || "").toLowerCase() === "pending"
);
const itemRows: ItemRow[] = filteredRecords.map((item: any) => ({
id: item.id,
pickOrderId: item.pickOrderId,
@@ -356,7 +359,7 @@ const AssignAndRelease: React.FC<Props> = ({ filterArgs }) => {
setIsUploading(true);
try {
// Step 1: Assign the pick orders
const assignRes = await newassignPickOrder({
const assignRes = await assignPickOrderWorkbenchV2({
pickOrderIds: selectedPickOrderIds,
assignTo: data.assignTo,
});
@@ -365,7 +368,7 @@ const AssignAndRelease: React.FC<Props> = ({ filterArgs }) => {
console.log("Assign successful:", assignRes);
// Step 2: Release the assigned pick orders
const releaseRes = await releaseAssignedPickOrders({
const releaseRes = await releasePickOrderWorkbenchV2({
pickOrderIds: selectedPickOrderIds,
assignTo: data.assignTo,
});
@@ -623,6 +626,7 @@ const AssignAndRelease: React.FC<Props> = ({ filterArgs }) => {
<Button variant="outlined" onClick={() => setModalOpen(false)}>
{t("Cancel")}
</Button>
{/*
<Button
variant="contained"
color="primary"
@@ -630,6 +634,7 @@ const AssignAndRelease: React.FC<Props> = ({ filterArgs }) => {
>
{t("Assign")}
</Button>
*/}
<Button
variant="contained"
color="secondary"


+ 7
- 3
src/components/PickOrderSearch/PickOrderSearch.tsx Целия файл

@@ -2,6 +2,7 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Grid, Stack, Tab, Tabs, TabsProps, Typography, Box } from "@mui/material";
import WorkbenchPickExecution from "./WorkbenchPickExecution";
import PickExecution from "./PickExecution";
import NewCreateItem from "./newcreatitem";
import AssignAndRelease from "./AssignAndRelease";
@@ -119,8 +120,10 @@ const PickOrderSearch: React.FC<Props> = () => {
<Tab label={t("Select Items")} iconPosition="end" />
{/* <Tab label={t("Select Job Order Items")} iconPosition="end" /> */}
<Tab label={t("Assign")} iconPosition="end" />
<Tab label={t("Release")} iconPosition="end" />
{/* <Tab label={t("Release")} iconPosition="end" /> */}
{/* <Tab label={t("Pick Execution")} iconPosition="end" /> */}
<Tab label={t("Pick Execution")} iconPosition="end" />
{/* <Tab label={t("old Pick Execution")} iconPosition="end" /> */}
</Tabs>
</Box>

@@ -128,7 +131,8 @@ const PickOrderSearch: React.FC<Props> = () => {
<Box sx={{
p: 2
}}>
{tabIndex === 3 && <PickExecution filterArgs={filterArgs} />}
{tabIndex === 2 && <WorkbenchPickExecution filterArgs={filterArgs} />}
{/* {tabIndex === 3 && <PickExecution filterArgs={filterArgs} />} */}
{tabIndex === 0 && (
<NewCreateItem
filterArgs={filterArgs}
@@ -138,7 +142,7 @@ const PickOrderSearch: React.FC<Props> = () => {
)}
{/* {tabIndex === 1 && <Jobcreatitem filterArgs={filterArgs} />} */}
{tabIndex === 1 && <AssignAndRelease filterArgs={filterArgs} />}
{tabIndex === 2 && <AssignTo filterArgs={filterArgs} />}
{/* {tabIndex === 2 && <AssignTo filterArgs={filterArgs} />} */}
</Box>
</Box>
);


+ 1691
- 0
src/components/PickOrderSearch/WorkbenchPickExecution.tsx
Файловите разлики са ограничени, защото са твърде много
Целия файл


+ 2
- 28
src/components/PickOrderSearch/assignTo.tsx Целия файл

@@ -22,9 +22,8 @@ import {
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
newassignPickOrder,
AssignPickOrderInputs,
releaseAssignedPickOrders,
releasePickOrderWorkbenchV2,
fetchPickOrderWithStockClient, // Add this import
} from "@/app/api/pickOrder/actions";
import { fetchNameList, NameList } from "@/app/api/user/actions";
@@ -39,7 +38,6 @@ import dayjs from "dayjs";
import arraySupport from "dayjs/plugin/arraySupport";
import SearchBox, { Criterion } from "../SearchBox";
import { sortBy, uniqBy } from "lodash";
import { createStockOutLine, CreateStockOutLine, fetchPickOrderDetails } from "@/app/api/pickOrder/actions";
dayjs.extend(arraySupport);

interface Props {
@@ -196,7 +194,7 @@ const handleRelease = useCallback(async () => {
console.log("Using assigned user:", assignToValue);
console.log("selectedPickOrderIds:", selectedPickOrderIds);
const releaseRes = await releaseAssignedPickOrders({
const releaseRes = await releasePickOrderWorkbenchV2({
pickOrderIds: selectedPickOrderIds.map(id => parseInt(id)),
assignTo: assignToValue
});
@@ -204,30 +202,6 @@ const handleRelease = useCallback(async () => {
if (releaseRes.code === "SUCCESS") {
console.log("Pick orders released successfully");
// Get the consoCode from the response
const consoCode = (releaseRes.entity as any)?.consoCode;
if (consoCode) {
// Create StockOutLine records for each pick order line
for (const pickOrder of selectedPickOrders) {
for (const line of pickOrder.pickOrderLines) {
try {
const stockOutLineData = {
consoCode: consoCode,
pickOrderLineId: line.id,
inventoryLotLineId: 0, // This will be set when user scans QR code
qty: line.requiredQty,
};
console.log("Creating stock out line:", stockOutLineData);
await createStockOutLine(stockOutLineData);
} catch (error) {
console.error("Error creating stock out line for line", line.id, error);
}
}
}
}
fetchNewPageItems(pagingController, filterArgs);
} else {
console.error("Release failed:", releaseRes.message);


+ 30
- 8
src/components/ProductionProcess/ProductionProcessList.tsx Целия файл

@@ -158,17 +158,38 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({
}

const result = new Map<number, boolean>();
const isDone = (status: unknown) => {
const s = String(status ?? "").trim().toLowerCase();
return s === "completed" || s === "pass";
};
byJobOrder.forEach((jobOrderProcesses, jobOrderId) => {
const hasStockInLine = jobOrderProcesses.some((p) => p.stockInLineId != null);
const allLinesDone =
jobOrderProcesses.length > 0 &&
jobOrderProcesses.every((p) => {
const packingProcesses = jobOrderProcesses.filter(
(p) => String((p as any).code ?? "").trim() === "包裝"
);
const nonPackingProcesses = jobOrderProcesses.filter(
(p) => String((p as any).code ?? "").trim() !== "包裝"
);
const allNonPackingDone =
nonPackingProcesses.length === 0 ||
nonPackingProcesses.every((p) => {
const lines = p.lines ?? [];
return lines.length > 0 && lines.every((l) => isDone(l.status));
});
const hasOnePackingDone =
packingProcesses.length > 0 &&
packingProcesses.some((p) => {
const lines = p.lines ?? [];
// 没有 lines 的情况认为未完成,避免误放行
return lines.length > 0 && lines.every((l) => lineDone(l.status));
return lines.some((l) => isDone(l.status));
});

result.set(jobOrderId, hasStockInLine && allLinesDone);
const packingOk = packingProcesses.length === 0 ? true : hasOnePackingDone;
result.set(jobOrderId, hasStockInLine && allNonPackingDone && packingOk);
});

return result;
@@ -486,7 +507,7 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({

const finishedCount =
(process.lines || []).filter(
(l) => String(l.status ?? "").trim().toLowerCase() === "completed"
(l) => String(l.status ?? "").trim().toLowerCase() === "completed" || String(l.status ?? "").trim().toLowerCase() === "pass"
).length;

const totalCount = process.productProcessLineCount ?? process.lines?.length ?? 0;
@@ -543,6 +564,7 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({
<Typography variant="subtitle1" color="blue">
{/* <strong>{t("Item Name")}:</strong> */}
{process.itemCode} {process.itemName}
{process.bomDescription ? ` (${t(process.bomDescription as string)})` : ""}
</Typography>
<Typography variant="body2" color="text.secondary">
{t("Production Priority")}: {process.productionPriority}


+ 18
- 64
src/components/StockTakeManagement/ApproverStockTakeAll.tsx Целия файл

@@ -31,6 +31,7 @@ import {
InventoryLotDetailResponse,
SaveApproverStockTakeRecordRequest,
saveApproverStockTakeRecord,
batchSaveApproverStockTakeRecordsByIds,
getApproverInventoryLotDetailsAllPending,
getApproverInventoryLotDetailsAllApproved,
updateStockTakeRecordStatusToNotMatch,
@@ -745,75 +746,32 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({
}

setBatchSaving(true);
let successCount = 0;
let skippedApproverEmpty = 0;
let errorCount = 0;

try {
for (const detail of sortedDetails) {
if (detail.stockTakeRecordStatus === "completed") {
continue;
}
const recordIds = sortedDetails
.map((d) => d.stockTakeRecordId)
.filter((id): id is number => typeof id === "number" && id > 0);

const built = buildApproverSaveRequest(
detail,
qtySelection,
approverQty,
approverBadQty,
currentUserId,
t
);
if (!built.ok) {
if (built.reason === "skip_approver_empty") {
skippedApproverEmpty += 1;
continue;
}
errorCount += 1;
continue;
}

try {
await saveApproverStockTakeRecord(built.request, selectedSession.stockTakeId);
successCount += 1;
const { goodQty, finalQty, finalBadQty, selection } = built;
setInventoryLotDetails((prev) =>
prev.map((d) =>
d.id === detail.id
? {
...d,
finalQty: goodQty,
approverQty: selection === "approver" ? finalQty : d.approverQty,
approverBadQty: selection === "approver" ? finalBadQty : d.approverBadQty,
stockTakeRecordStatus: "completed",
}
: d
)
);
} catch (e: any) {
errorCount += 1;
let msg = e?.message || t("Failed to save approver stock take record");
if (e?.response) {
try {
const errorData = await e.response.json();
msg = errorData.message || errorData.error || msg;
} catch {
/* ignore */
}
}
console.error("Batch save row failed", detail.id, msg);
}
if (recordIds.length === 0) {
onSnackbar(t("No valid records to batch save"), "warning");
return;
}

const result = await batchSaveApproverStockTakeRecordsByIds({
stockTakeId: selectedSession.stockTakeId,
approverId: currentUserId,
recordIds,
});

onSnackbar(
t("Batch approver save completed: {{success}} success, {{skipped}} skipped, {{errors}} errors", {
success: successCount,
skipped: skippedApproverEmpty,
errors: errorCount,
t("Batch approver save completed: {{success}} success, {{errors}} errors", {
success: result.successCount,
errors: result.errorCount,
}),
errorCount > 0 ? "warning" : "success"
result.errorCount > 0 ? "warning" : "success"
);

if (appliedFilters && successCount > 0) {
if (appliedFilters && result.successCount > 0) {
await loadDetails(appliedFilters);
}
} catch (e: any) {
@@ -835,10 +793,6 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({
mode,
appliedFilters,
inventoryLotDetails.length,
sortedDetails,
qtySelection,
approverQty,
approverBadQty,
]);

const formatNumber = (num: number | null | undefined): string => {


+ 8
- 2
src/i18n/index.tsx Целия файл

@@ -19,15 +19,19 @@ export const detectLanguage = async (): Promise<string> => {
{},
);
const headersList = headers();
console.time("[i18n] detectLanguage total");
console.time("[i18n] getServerSession");
const session = await getServerSession(authOptions);

console.timeEnd("[i18n] getServerSession");
console.time("[i18n] universalLanguageDetect");
const lang = universalLanguageDetect({
supportedLanguages: SUPPORTED_LANGUAGES,
fallbackLanguage: FALLBACK_LANG,
acceptLanguageHeader: headersList.get("accept-language") || undefined,
serverCookies: cookiesObj,
});

console.timeEnd("[i18n] universalLanguageDetect");
console.timeEnd("[i18n] detectLanguage total");
return lang;
};

@@ -38,6 +42,8 @@ const languageDetector: LanguageDetectorAsyncModule = {
};

const initI18next = async (namespaces: string[]): Promise<i18n> => {
const label = `[i18n] initI18next ns=${namespaces.join(",")}`;
console.time(label);
const i18nInstance = createInstance();
await i18nInstance
.use(languageDetector)


+ 12
- 0
src/i18n/zh/common.json Целия файл

@@ -7,11 +7,23 @@
"Stock Record": "庫存記錄",
"No options": "沒有選項",
"Drink": "飲料",
"packaging": "提料中",
"Issue BOM List": "問題 BOM 列表",
"File Name": "檔案名稱",
"Please Select BOM": "請選擇 BOM",
"Plan Start": "預計生產日期",
"Floor": "樓層",
"Job Order Type": "工單類型",

"FG": "成品",
"WIP": "半成品",
"BOM Type": "BOM 類型",
"No Lot": "沒有批號",
"Select All": "全選",
"Do Workbench": "新版成品出倉",
"DO Workbench": "新版成品出倉",
"storing": "待品檢入倉",
"Submit Qty": "提交數量",
"Waiting QC Put Away Job Orders": "待QC上架工單",
"Put Awayed Job Orders": "已上架工單",
"Loading BOM Detail...": "正在載入 BOM 明細…",


+ 5
- 0
src/i18n/zh/do.json Целия файл

@@ -9,11 +9,16 @@
"Estimated Arrival From": "預計送貨日期",
"Estimated Arrival To": "預計送貨日期至",
"Status": "來貨狀態",
"DO Workbench": "新版成品出倉",
"Order Date From": "訂單日期",
"Workbench Batch Release": "批量放單",
"do workbench": "新版成品出倉",
"Do Workbench": "新版成品出倉",
"Delivery Order Code": "送貨訂單編號",
"Truck Lance Code": "車線號碼",
"Select Remark": "選擇備註",
"Confirm Assignment": "確認分配",
"Submit Qty": "提交數量",
"Required Date": "所需日期",
"Submit Miss Item": "提交缺貨品",
"Submit Quantity": "提交數量",


+ 9
- 1
src/i18n/zh/jo.json Целия файл

@@ -8,8 +8,10 @@
"Process": "工序",
"Create Job Order": "建立工單",
"Code": "工單編號",
"storing": "待品檢入倉",
"Name": "成品/半成品名稱",
"Picked Qty": "已提料數量",
"Insufficient available quantity on lot (may have been picked by another user)": "掃描的批次已被其他用戶完全提料。請掃描其他批次。",
"Please check around have QR code or not, may be have just now stock in or transfer in or transfer out.": "請檢查周圍是否有QR碼,可能是剛剛入庫或轉移入庫或轉移出庫。",
"is expired. Please check around have available QR code or not.": "已過期。請檢查周圍是否有可用的 QR 碼。",
"Confirm All": "確認所有提料",
@@ -113,7 +115,8 @@
"Today": "今天",
"Yesterday": "昨天",
"Two Days Ago": "前天",
"Item Code": "成品/半成品編號",
"Item Code": "物料編號",
"Floor": "樓層",
"Paused": "已暫停",
"paused": "已暫停",
"Total pick orders": "總提料單數量",
@@ -166,6 +169,7 @@
"Job Order Code": "工單編號",
"View Details": "查看詳情",
"Skip": "跳過",
"packaging": "提料中",
"Handler": "提料員",
"RELEASED": "已放單",
"Released": "已放單",
@@ -385,6 +389,7 @@
"success": "成功",
"Total (Verified + Bad + Missing) must equal Required quantity": "驗證數量 + 不良數量 + 缺失數量必須等於需求數量",
"BOM Status": "材料預備狀況",
"Job Order Type": "工單類型",
"Estimated Production Date": "預計生產日期",
"Plan Start": "預計生產日期",
"Plan Start From": "預計生產日期",
@@ -597,5 +602,8 @@
"seq": "序號",
"Handled By": "處理者",
"Job Order Pick Execution": "工單提料",
"BOM Type": "BOM 類型",
"BOM Description": "BOM 說明",
"Floor": "樓層",
"Finish": "完成"
}

+ 22
- 2
src/i18n/zh/pickOrder.json Целия файл

@@ -9,7 +9,13 @@
"Status": "來貨狀態",
"N/A": "不適用",
"Release Pick Orders": "放單",
"released": "已放單",
"Loading...": "載入中...",
"Suggestion success": "建議成功",
"Scan pick success": "掃描提料成功",
"Remark": "備註",
"Available Qty": "可用數量",
"Picked Qty": "已提料數量",
"Escalated": "上報狀態",
"NotEscalated": "無上報",
"Assigned To": "已分配",
@@ -27,6 +33,8 @@
"Lane Code": "車線號碼",
"Fetching all matching records...": "正在獲取所有匹配的記錄...",
"Edit": "改數",
"Submit Qty": "提交數量",
"Suggestion success": "建議成功",
"Just Completed": "已完成",
"Just Completed (workbench): requires a valid lot number and quantity; expired rows must not use this button.": "已完成(工作台):需有效批號與可提交數量;過期列請勿使用此按鈕。",
"Do you want to start?": "確定開始嗎?",
@@ -254,7 +262,12 @@
"The input is not the same as the expected lot number.": "輸入的批次號碼與預期的不符。",
"Verified successfully!": "驗證成功!",
"Cancel": "取消",
"storing": "待品檢入倉",
"pick successful": "提料成功",
"Suggestion success": "建議成功",
"Insufficient available quantity on lot (may have been picked by another user)": "掃描的批次已被其他用戶完全提料。請掃描其他批次。",
"Scan": "掃描",
"Before today": "今天之前",
"Scanned": "已掃描",
"Loading data...": "正在載入數據...",
"No available stock for this item": "沒有可用庫存",
@@ -274,6 +287,7 @@
"Selected items will join above created group": "已選擇的貨品將加入以上建立的分組",
"Issue":"問題",
"Pick Execution Issue Form":"提料問題表單",
"Lot line is unavailable":"掃描批次不可用",
"This form is for reporting issues only. You must report either missing items or bad items.":"此表單僅用於報告問題。您必須報告缺少的貨品或不良貨品。",
"Bad item Qty":"不良貨品數量",
"Missing item Qty":"貨品遺失數量",
@@ -449,11 +463,15 @@
"No entries available": "該樓層未有需處理訂單",
"Today": "是日",
"Tomorrow": "翌日",
"packaging": "提料中",
"No Stock Available": "沒有庫存可用",
"This lot is not available, please scan another lot.": "此批號不可用,請掃描其他批號。",
"Please check around have QR code or not, may be have just now stock in or transfer in or transfer out.": "請檢查周圍是否有 QR 碼,可能有剛剛入庫或轉移入庫 QR 碼。",
"Please check around have QR code or not, may be have just now stock in or transfer in or transfer out.": "請檢查周圍是否有 QR 碼,可能有剛剛入庫、轉移入庫或轉移出庫的 QR 碼。",
"Lot is expired (expiry={{expiry}})": "掃描批號已過期(到期日={{expiry}})",
"Day After Tomorrow": "後日",
"Lot line is unavailable": "掃描批次不可用",
"Select Date": "請選擇日期",
"Suggest Lot No.": "推薦批號",
"Search by Shop": "搜尋商店",
"Search by Truck": "搜尋貨車",
"Print DN & Label": "列印提料單和送貨單標籤",
@@ -497,5 +515,7 @@
"SuggestedPickLot qty is invalid: {{qty}}": "建議揀貨數量無效:{{qty}}。",
"Reject switch lot: available {{available}} less than required {{required}}": "此批次貨品已被其他送貨單留起,請掃描其他批次。",
"Reject switch lot: picked {{picked}} already greater or equal required {{required}}": "換批被拒:已揀數量({{picked}})已達或超過建議量({{required}}),無法再拆分換批。",
"Lot status is unavailable. Cannot switch or bind; pick line was not updated.": "批號狀態為「不可用」,無法換批或綁定;揀貨行未更新。"
"Lot status is unavailable. Cannot switch or bind; pick line was not updated.": "批號狀態為「不可用」,無法換批或綁定;揀貨行未更新。",
"No lot rows. Select a line in the table above.": "尚無批號資料。請在上方表格勾選一行提料單明細。",
"No stock out line for this lot": "此批號尚無出庫行,無法提交。"
}

Зареждане…
Отказ
Запис