| @@ -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; | |||
| @@ -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; | |||
| @@ -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; | |||
| @@ -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; | |||
| @@ -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; | |||
| @@ -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; | |||
| @@ -0,0 +1,6 @@ | |||
| import { redirect } from "next/navigation"; | |||
| /** 揀貨工作台已合併至 `/doworkbench`,保留此路徑以利舊連結。 */ | |||
| export default function DoWorkbenchPickLegacyRedirect() { | |||
| redirect("/doworkbench"); | |||
| } | |||
| @@ -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; | |||
| @@ -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> | |||
| </> | |||
| @@ -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; | |||
| @@ -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[]; | |||
| @@ -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" }, | |||
| ); | |||
| } | |||
| @@ -0,0 +1,5 @@ | |||
| /** Server actions live in ./actions — import them directly in client components. */ | |||
| export type { | |||
| WorkbenchMessageResponse, | |||
| WorkbenchScanPickBody, | |||
| } from "./actions"; | |||
| @@ -0,0 +1,9 @@ | |||
| export { | |||
| startWorkbenchBatchReleaseAsync, | |||
| startWorkbenchBatchReleaseAsyncV2, | |||
| workbenchBatchReleaseSyncV2, | |||
| getWorkbenchBatchReleaseProgress, | |||
| workbenchScanPick, | |||
| type WorkbenchMessageResponse, | |||
| type WorkbenchScanPickBody, | |||
| } from "./actions"; | |||
| @@ -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), | |||
| }; | |||
| } | |||
| @@ -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"); | |||
| } | |||
| @@ -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; | |||
| @@ -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}`, | |||
| @@ -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" }, | |||
| }, | |||
| ); | |||
| }; | |||
| @@ -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"] }, | |||
| }, | |||
| ); | |||
| }); | |||
| @@ -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 | |||
| ) => { | |||
| @@ -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; | |||
| @@ -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", | |||
| @@ -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> | |||
| )} | |||
| @@ -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; | |||
| @@ -0,0 +1 @@ | |||
| export { default } from "./DoSearchWorkbench"; | |||
| @@ -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; | |||
| @@ -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; | |||
| @@ -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; | |||
| @@ -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; | |||
| @@ -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; | |||
| @@ -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 > 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; | |||
| @@ -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; | |||
| @@ -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"; | |||
| @@ -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(); | |||
| @@ -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( | |||
| @@ -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; | |||
| @@ -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; | |||
| @@ -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; | |||
| @@ -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, | |||
| @@ -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]); | |||
| @@ -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" | |||
| @@ -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> | |||
| ); | |||
| @@ -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); | |||
| @@ -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} | |||
| @@ -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 => { | |||
| @@ -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) | |||
| @@ -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 明細…", | |||
| @@ -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": "提交數量", | |||
| @@ -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": "完成" | |||
| } | |||
| @@ -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": "此批號尚無出庫行,無法提交。" | |||
| } | |||