| @@ -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 PageTitleBar from "@/components/PageTitleBar"; | ||||
| import JoWorkbenchSearch from "@/components/JoWorkbench/JoWorkbenchSearch"; | |||||
| import { I18nProvider, getServerI18n } from "@/i18n"; | import { I18nProvider, getServerI18n } from "@/i18n"; | ||||
| import { Metadata } from "next"; | import { Metadata } from "next"; | ||||
| import React, { Suspense } from "react"; | import React, { Suspense } from "react"; | ||||
| @@ -11,15 +14,32 @@ export const metadata: Metadata = { | |||||
| const Jo: React.FC = async () => { | const Jo: React.FC = async () => { | ||||
| const { t } = await getServerI18n("jo"); | 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 ( | return ( | ||||
| <> | <> | ||||
| <PageTitleBar title={t("Search Job Order/ Create Job Order")} className="mb-4" /> | <PageTitleBar title={t("Search Job Order/ Create Job Order")} className="mb-4" /> | ||||
| <I18nProvider namespaces={["jo", "common", "purchaseOrder", "dashboard"]}> | <I18nProvider namespaces={["jo", "common", "purchaseOrder", "dashboard"]}> | ||||
| <Suspense fallback={<JoSearch.Loading />}> | |||||
| <JoSearch /> | |||||
| <Suspense fallback={<GeneralLoading />}> | |||||
| <JoWorkbenchSearch | |||||
| defaultInputs={defaultInputs} | |||||
| bomCombo={bomCombo ?? []} | |||||
| printerCombo={printerCombo ?? []} | |||||
| jobTypes={jobTypes ?? []} | |||||
| /> | |||||
| </Suspense> | </Suspense> | ||||
| </I18nProvider> | </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; | 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 { | export interface TruckScheduleDashboardItem { | ||||
| storeId: string | null; | storeId: string | null; | ||||
| truckId: number | 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) => { | export const fetchTruckScheduleDashboard = cache(async (date?: string) => { | ||||
| const url = date | const url = date | ||||
| ? `${BASE_API_URL}/doPickOrder/truck-schedule-dashboard?date=${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 | return { success: true, message: "Print job sent successfully (reprint labels)"} as PrintDeliveryNoteResponse | ||||
| } | } | ||||
| /* | |||||
| export interface PrintWorkbenchDeliveryNoteRequest{ | export interface PrintWorkbenchDeliveryNoteRequest{ | ||||
| deliveryOrderPickOrderId: number; | deliveryOrderPickOrderId: number; | ||||
| printerId: number; | printerId: number; | ||||
| @@ -477,7 +539,7 @@ export async function printDNWorkbench(request: PrintWorkbenchDeliveryNoteReques | |||||
| params.append("isDraft", request.isDraft.toString()); | params.append("isDraft", request.isDraft.toString()); | ||||
| try { | 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", | method: "GET", | ||||
| }); | }); | ||||
| if (response.ok) { | if (response.ok) { | ||||
| @@ -507,13 +569,13 @@ export async function printDNLabelsWorkbench(request: PrintWorkbenchDNLabelsRequ | |||||
| } | } | ||||
| params.append("numOfCarton", request.numOfCarton.toString()); | 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" | method: "GET" | ||||
| }); | }); | ||||
| return { success: true, message: "Print job sent successfully (workbench labels)"} as PrintDeliveryNoteResponse | return { success: true, message: "Print job sent successfully (workbench labels)"} as PrintDeliveryNoteResponse | ||||
| } | } | ||||
| */ | |||||
| export interface Check4FTruckBatchResponse { | export interface Check4FTruckBatchResponse { | ||||
| hasProblem: boolean; | hasProblem: boolean; | ||||
| problems: ProblemDoDto[]; | 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<number>( | |||||
| `${BASE_API_URL}/inventoryLotLine/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; | availableQty: number; | ||||
| uom: string; | 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`, { | return serverFetchJson<QrCodeAnalysisResponse>(`${BASE_API_URL}/inventoryLotLine/analyze-qr-code`, { | ||||
| method: 'POST', | method: 'POST', | ||||
| body: JSON.stringify(data), | body: JSON.stringify(data), | ||||
| headers: { "Content-Type": "application/json" }, | 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: { | export const updateInventoryStatus = async (data: { | ||||
| itemId: number; | itemId: number; | ||||
| lotId: number; | lotId: number; | ||||
| @@ -606,8 +606,8 @@ export interface StockOutLineDetailResponse { | |||||
| availableQty: number | null; | availableQty: number | null; | ||||
| noLot: boolean; | noLot: boolean; | ||||
| /** Workbench API: matched suggest_pick_lot qty for this SOL lot line */ | /** 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 { | export interface LotDetailResponse { | ||||
| @@ -718,7 +718,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 */ | /** JO Workbench: in−out available (matches scan-pick); stockouts include suggestedPickQty / suggestedPickLotId when SPL matches SOL lot line */ | ||||
| /* | |||||
| export const fetchJobOrderLotsHierarchicalByPickOrderIdWorkbench = cache( | export const fetchJobOrderLotsHierarchicalByPickOrderIdWorkbench = cache( | ||||
| async (pickOrderId: number) => { | async (pickOrderId: number) => { | ||||
| return serverFetchJson<JobOrderLotsHierarchicalResponse>( | return serverFetchJson<JobOrderLotsHierarchicalResponse>( | ||||
| @@ -730,7 +730,7 @@ export const fetchJobOrderLotsHierarchicalByPickOrderIdWorkbench = cache( | |||||
| ); | ); | ||||
| }, | }, | ||||
| ); | ); | ||||
| */ | |||||
| // NOTE: Do NOT wrap in `cache()` because the list needs to reflect just-completed lines | // NOTE: Do NOT wrap in `cache()` because the list needs to reflect just-completed lines | ||||
| // immediately when navigating back from JobPickExecution. | // immediately when navigating back from JobPickExecution. | ||||
| export const fetchAllJoPickOrders = async (type?: string | null, floor?: string | null) => { | export const fetchAllJoPickOrders = async (type?: string | null, floor?: string | null) => { | ||||
| @@ -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; | loadingSequence?: number | null; | ||||
| unassigned: number; | unassigned: number; | ||||
| total: number; | total: number; | ||||
| handlerName: string; | |||||
| handlerName?: string | null; | |||||
| } | } | ||||
| export interface QrPickBatchSubmitRequest { | export interface QrPickBatchSubmitRequest { | ||||
| @@ -694,7 +694,7 @@ export const fetchCompletedDoPickOrders = async ( | |||||
| /** DO workbench: completed tickets from `delivery_order_pick_order.ticketStatus = completed`. **/ | /** DO workbench: completed tickets from `delivery_order_pick_order.ticketStatus = completed`. **/ | ||||
| /* | |||||
| export const fetchCompletedDoPickOrdersWorkbench = async ( | export const fetchCompletedDoPickOrdersWorkbench = async ( | ||||
| userId: number, | userId: number, | ||||
| searchParams?: CompletedDoPickOrderSearchParams, | searchParams?: CompletedDoPickOrderSearchParams, | ||||
| @@ -723,7 +723,36 @@ export const fetchCompletedDoPickOrdersWorkbench = async ( | |||||
| method: "GET", | 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` */ | /** 全部已完成 DO 提貨記錄(不限經手人),需後端 `/completed-do-pick-orders-all` */ | ||||
| export const fetchCompletedDoPickOrdersAll = async ( | export const fetchCompletedDoPickOrdersAll = async ( | ||||
| searchParams?: CompletedDoPickOrderSearchParams | 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`. */ | /** DO workbench: FG headers from `delivery_order_pick_order`, not `do_pick_order_line`. */ | ||||
| /* | |||||
| export const fetchFGPickOrdersByUserIdWorkbench = async (userId: number) => { | export const fetchFGPickOrdersByUserIdWorkbench = async (userId: number) => { | ||||
| return serverFetchJson<FGPickOrderResponse[]>( | return serverFetchJson<FGPickOrderResponse[]>( | ||||
| `${BASE_API_URL}/pickOrder/fg-pick-orders-workbench/${userId}`, | `${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) => { | export const updateSuggestedLotLineId = async (suggestedPickLotId: number, newLotLineId: number) => { | ||||
| const response = await serverFetchJson<PostPickOrderResponse<UpdateSuggestedLotLineIdRequest>>( | const response = await serverFetchJson<PostPickOrderResponse<UpdateSuggestedLotLineIdRequest>>( | ||||
| `${BASE_API_URL}/suggestedPickLot/update-suggested-lot/${suggestedPickLotId}`, | `${BASE_API_URL}/suggestedPickLot/update-suggested-lot/${suggestedPickLotId}`, | ||||
| @@ -893,6 +922,82 @@ export const resuggestPickOrder = async (pickOrderId: number) => { | |||||
| return result; | 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) => { | |||||
| const result = await serverFetchJson<PostPickOrderResponse>( | |||||
| `${BASE_API_URL}/suggestedPickLot/resuggest/${pickOrderId}`, | |||||
| { | |||||
| method: "POST", | |||||
| 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: { | export const updateStockOutLineStatus = async (data: { | ||||
| id: number; | id: number; | ||||
| status: string; | status: string; | ||||
| @@ -938,6 +1043,7 @@ export const releaseAssignedPickOrders = async (data: AssignPickOrderInputs) => | |||||
| revalidateTag("pickorder"); | revalidateTag("pickorder"); | ||||
| return response; | return response; | ||||
| }; | }; | ||||
| // Get latest group name and create it automatically | // Get latest group name and create it automatically | ||||
| export const getLatestGroupNameAndCreate = async () => { | export const getLatestGroupNameAndCreate = async () => { | ||||
| return serverFetchJson<PostPickOrderResponse>( | return serverFetchJson<PostPickOrderResponse>( | ||||
| @@ -1190,15 +1296,14 @@ export const fetchAllPickOrderLotsHierarchical = cache(async (userId: number): P | |||||
| }; | }; | ||||
| } | } | ||||
| }); | }); | ||||
| /** DO workbench: hierarchical lots where header is `delivery_order_pick_order`. */ | /** DO workbench: hierarchical lots where header is `delivery_order_pick_order`. */ | ||||
| /* | |||||
| export const fetchAllPickOrderLotsHierarchicalWorkbench = cache(async (userId: number): Promise<any> => { | export const fetchAllPickOrderLotsHierarchicalWorkbench = cache(async (userId: number): Promise<any> => { | ||||
| try { | try { | ||||
| const data = await serverFetchJson<any>( | const data = await serverFetchJson<any>( | ||||
| `${BASE_API_URL}/pickOrder/all-lots-hierarchical-workbench/${userId}`, | `${BASE_API_URL}/pickOrder/all-lots-hierarchical-workbench/${userId}`, | ||||
| { | { | ||||
| method: "GET", | |||||
| method: 'GET', | |||||
| next: { tags: ["pickorder"] }, | next: { tags: ["pickorder"] }, | ||||
| }, | }, | ||||
| ); | ); | ||||
| @@ -1211,7 +1316,8 @@ export const fetchAllPickOrderLotsHierarchicalWorkbench = cache(async (userId: n | |||||
| }; | }; | ||||
| } | } | ||||
| }); | }); | ||||
| */ | |||||
| export const fetchLotDetailsByDoPickOrderRecordId = async (doPickOrderRecordId: number): Promise<{ | export const fetchLotDetailsByDoPickOrderRecordId = async (doPickOrderRecordId: number): Promise<{ | ||||
| fgInfo: any; | fgInfo: any; | ||||
| pickOrders: any[]; | pickOrders: any[]; | ||||
| @@ -1272,7 +1378,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"] }, | |||||
| }, | |||||
| ); | |||||
| }); | |||||
| @@ -93,8 +93,11 @@ export const serverFetch: typeof fetch = async (input, init) => { | |||||
| type FetchParams = Parameters<typeof fetch>; | type FetchParams = Parameters<typeof fetch>; | ||||
| export async function serverFetchJson<T>(...args: FetchParams) { | export async function serverFetchJson<T>(...args: FetchParams) { | ||||
| const url = String(args[0]); | |||||
| const t0 = performance.now(); | |||||
| const response = await serverFetch(...args); | 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.ok) { | ||||
| if (response.status === 204) { | if (response.status === 204) { | ||||
| return response.status as T; | return response.status as T; | ||||
| @@ -117,7 +120,11 @@ export async function serverFetchJson<T>(...args: FetchParams) { | |||||
| } | } | ||||
| export async function serverFetchString<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) { | if (response.ok) { | ||||
| return response.text() as T; | return response.text() as T; | ||||
| @@ -37,9 +37,10 @@ const pathToLabelMap: { [path: string]: string } = { | |||||
| "/inventory": "Inventory", | "/inventory": "Inventory", | ||||
| "/settings/importTesting": "Import Testing", | "/settings/importTesting": "Import Testing", | ||||
| "/do": "Delivery Order", | "/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", | "/pickOrder": "Pick Order", | ||||
| "/po": "Purchase Order", | "/po": "Purchase Order", | ||||
| "/po/workbench": "PO Workbench", | "/po/workbench": "PO Workbench", | ||||
| @@ -47,6 +48,7 @@ const pathToLabelMap: { [path: string]: string } = { | |||||
| "/jo": "Job Order", | "/jo": "Job Order", | ||||
| "/jo/edit": "Edit Job Order", | "/jo/edit": "Edit Job Order", | ||||
| "/jo/testing": "Job order testing", | "/jo/testing": "Job order testing", | ||||
| "/jo/workbench": "Job Order Workbench", | |||||
| "/putAway": "Put Away", | "/putAway": "Put Away", | ||||
| "/stockIssue": "Stock Issue", | "/stockIssue": "Stock Issue", | ||||
| "/report": "Report", | "/report": "Report", | ||||
| @@ -2,6 +2,10 @@ | |||||
| import { DoResult } from "@/app/api/do"; | import { DoResult } from "@/app/api/do"; | ||||
| import { DoSearchAll, DoSearchLiteResponse, fetchDoSearch, fetchAllDoSearch, fetchDoSearchList, releaseDo ,startBatchReleaseAsync, getBatchReleaseProgress} from "@/app/api/do/actions"; | 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 { useRouter } from "next/navigation"; | ||||
| import React, { ForwardedRef, useCallback, useEffect, useMemo, useState } from "react"; | 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 [searchAllDos, setSearchAllDos] = useState<DoSearchAll[]>([]); | ||||
| const [totalCount, setTotalCount] = useState(0); | const [totalCount, setTotalCount] = useState(0); | ||||
| const [isWorkbench, setIsWorkbench] = useState(false); | |||||
| const [pagingController, setPagingController] = useState({ | const [pagingController, setPagingController] = useState({ | ||||
| pageNum: 1, | pageNum: 1, | ||||
| pageSize: 10, | pageSize: 10, | ||||
| @@ -485,7 +489,7 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { | |||||
| } | } | ||||
| }, [hasSearched, currentSearchParams]); | }, [hasSearched, currentSearchParams]); | ||||
| const handleBatchRelease = useCallback(async () => { | |||||
| const handleBatchRelease = useCallback(async (isWorkbench: boolean) => { | |||||
| try { | try { | ||||
| // 根据当前搜索条件获取所有匹配的记录(不分页) | // 根据当前搜索条件获取所有匹配的记录(不分页) | ||||
| let estArrStartDate = currentSearchParams.estimatedArrivalDate; | let estArrStartDate = currentSearchParams.estimatedArrivalDate; | ||||
| @@ -575,7 +579,14 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { | |||||
| if (result.isConfirmed) { | if (result.isConfirmed) { | ||||
| try { | 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; | const jobId = startRes?.entity?.jobId; | ||||
| if (!jobId) { | if (!jobId) { | ||||
| @@ -596,7 +607,9 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { | |||||
| const timer = setInterval(async () => { | const timer = setInterval(async () => { | ||||
| try { | try { | ||||
| const p = await getBatchReleaseProgress(jobId); | |||||
| const p = isWorkbench | |||||
| ? await getWorkbenchBatchReleaseProgress(jobId) | |||||
| : await getBatchReleaseProgress(jobId); | |||||
| const e = p?.entity || {}; | const e = p?.entity || {}; | ||||
| const total = e.total ?? 0; | const total = e.total ?? 0; | ||||
| @@ -659,11 +672,19 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { | |||||
| onSubmit={formProps.handleSubmit(onSubmit, onSubmitError)} | onSubmit={formProps.handleSubmit(onSubmit, onSubmitError)} | ||||
| > | > | ||||
| {hasSearched && hasResults && ( | {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 | <Button | ||||
| name="batch_release" | name="batch_release" | ||||
| variant="contained" | variant="contained" | ||||
| onClick={handleBatchRelease} | |||||
| onClick={() => handleBatchRelease(false)} | |||||
| > | > | ||||
| {t("Batch Release")} | {t("Batch Release")} | ||||
| </Button> | </Button> | ||||
| @@ -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,559 @@ | |||||
| "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 } 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 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 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: lot.requiredQty, | |||||
| 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> | |||||
| </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("Before today")} (${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"; | |||||
| 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; | |||||
| 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 [workbenchScanPickLotLineId, setWorkbenchScanPickLotLineId] = 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); | |||||
| setWorkbenchScanPickLotLineId(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, | |||||
| 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], | |||||
| ); | |||||
| const handleWorkbenchScanPickOne = useCallback( | |||||
| async (inventoryLotLineId: number, lotNo: string) => { | |||||
| if (!onWorkbenchScanPick) return; | |||||
| setWorkbenchScanPickLotLineId(inventoryLotLineId); | |||||
| try { | |||||
| const n = Number(submitQty); | |||||
| const qtyPayload = | |||||
| Number.isFinite(n) && n >= 0 ? { qty: n } : {}; | |||||
| await onWorkbenchScanPick({ inventoryLotLineId, lotNo, ...qtyPayload }); | |||||
| setSnackbar({ | |||||
| open: true, | |||||
| message: `掃碼提貨成功:Lot ${lotNo}`, | |||||
| severity: "success", | |||||
| }); | |||||
| } catch (e) { | |||||
| setSnackbar({ | |||||
| open: true, | |||||
| message: e instanceof Error ? e.message : "掃碼提貨失敗", | |||||
| severity: "error", | |||||
| }); | |||||
| } finally { | |||||
| setWorkbenchScanPickLotLineId(null); | |||||
| } | |||||
| }, | |||||
| [onWorkbenchScanPick, submitQty], | |||||
| ); | |||||
| 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="Submit Qty" | |||||
| 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 isScanPicking = | |||||
| workbenchScanPickLotLineId === lot.inventoryLotLineId; | |||||
| const loc = String(lot.warehouseCode ?? "").trim(); | |||||
| const canWorkbenchPick = | |||||
| !!onWorkbenchScanPick && | |||||
| !!analysis && | |||||
| !analysisLoading && | |||||
| !disableScanPick; | |||||
| 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 || isScanPicking} | |||||
| onClick={() => | |||||
| void handlePrintOne( | |||||
| lot.inventoryLotLineId, | |||||
| lot.lotNo, | |||||
| ) | |||||
| } | |||||
| > | |||||
| {isPrinting ? ( | |||||
| <CircularProgress size={18} /> | |||||
| ) : ( | |||||
| "列印標籤" | |||||
| )} | |||||
| </Button> | |||||
| {onWorkbenchScanPick ? ( | |||||
| <Button | |||||
| variant="outlined" | |||||
| color="secondary" | |||||
| title={ | |||||
| disableScanPick | |||||
| ? "此出庫行已掃碼或已完成,無法再掃碼提貨" | |||||
| : undefined | |||||
| } | |||||
| disabled={ | |||||
| !canWorkbenchPick || | |||||
| isPrinting || | |||||
| isScanPicking | |||||
| } | |||||
| onClick={() => | |||||
| void handleWorkbenchScanPickOne( | |||||
| lot.inventoryLotLineId, | |||||
| lot.lotNo, | |||||
| ) | |||||
| } | |||||
| > | |||||
| {isScanPicking ? ( | |||||
| <CircularProgress size={18} /> | |||||
| ) : ( | |||||
| "掃碼提貨" | |||||
| )} | |||||
| </Button> | |||||
| ) : null} | |||||
| </Stack> | |||||
| </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 { | import { | ||||
| CompletedDoPickOrderResponse, | CompletedDoPickOrderResponse, | ||||
| fetchCompletedDoPickOrdersAll, | fetchCompletedDoPickOrdersAll, | ||||
| fetchCompletedDoPickOrdersWorkbenchAll, | |||||
| } from "@/app/api/pickOrder/actions"; | } from "@/app/api/pickOrder/actions"; | ||||
| import SafeApexCharts from "@/components/charts/SafeApexCharts"; | import SafeApexCharts from "@/components/charts/SafeApexCharts"; | ||||
| @@ -36,7 +37,11 @@ type DailySummaryRow = { | |||||
| total: number; | 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 [floor, setFloor] = useState<FloorFilter>("all"); | ||||
| const [date, setDate] = useState<string>(dayjs().format("YYYY-MM-DD")); | const [date, setDate] = useState<string>(dayjs().format("YYYY-MM-DD")); | ||||
| const [loading, setLoading] = useState(false); | const [loading, setLoading] = useState(false); | ||||
| @@ -47,9 +52,14 @@ const FinishedGoodCartonDashboardTab: React.FC = () => { | |||||
| setLoading(true); | setLoading(true); | ||||
| setError(""); | setError(""); | ||||
| try { | 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); | setRecords(data); | ||||
| } catch (err) { | } catch (err) { | ||||
| console.error("Failed to load finished good carton dashboard data", err); | console.error("Failed to load finished good carton dashboard data", err); | ||||
| @@ -58,7 +68,7 @@ const FinishedGoodCartonDashboardTab: React.FC = () => { | |||||
| } finally { | } finally { | ||||
| setLoading(false); | setLoading(false); | ||||
| } | } | ||||
| }, [date]); | |||||
| }, [date, mode]); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| loadData(); | loadData(); | ||||
| @@ -3723,7 +3723,7 @@ const PickExecution: React.FC<Props> = ({ | |||||
| ); | ); | ||||
| } else if (completionResponse.message === "not completed") { | } else if (completionResponse.message === "not completed") { | ||||
| console.log( | console.log( | ||||
| `⏳ Pick order not completed yet, more lines remaining`, | |||||
| `Pick order not completed yet, more lines remaining`, | |||||
| ); | ); | ||||
| } else { | } else { | ||||
| console.error( | console.error( | ||||
| @@ -0,0 +1,299 @@ | |||||
| "use client"; | |||||
| import React, { useCallback, useEffect, 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"; | |||||
| 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 [loading, setLoading] = useState(false); | |||||
| const [pickOrders, setPickOrders] = useState<AllJoPickOrderResponse[]>([]); | |||||
| const [selectedPickOrderId, setSelectedPickOrderId] = useState<number | undefined>(undefined); | |||||
| const [selectedJobOrderId, setSelectedJobOrderId] = useState<number | undefined>(undefined); | |||||
| type PickOrderFilter = "all" | "drink" | "Powder_Mixture" | "other"; | |||||
| const [filter, setFilter] = useState<PickOrderFilter>("all"); | |||||
| type FloorFilter = "ALL" | "2F" | "3F" | "4F" | "NO_LOT"; | |||||
| const [floorFilter, setFloorFilter] = useState<FloorFilter>("ALL"); | |||||
| const fetchPickOrders = useCallback(async () => { | |||||
| setLoading(true); | |||||
| try { | |||||
| const typeParam = filter === "all" ? undefined : filter; | |||||
| const floorParam = floorFilter === "ALL" ? undefined : floorFilter; | |||||
| const data = await fetchAllJoPickOrders(typeParam, floorParam); | |||||
| setPickOrders(Array.isArray(data) ? data : []); | |||||
| } catch (e) { | |||||
| console.error(e); | |||||
| setPickOrders([]); | |||||
| } finally { | |||||
| setLoading(false); | |||||
| } | |||||
| }, [filter, floorFilter]); | |||||
| useEffect(() => { | |||||
| fetchPickOrders(); | |||||
| }, [fetchPickOrders, filter, floorFilter]); | |||||
| const handleBackToList = useCallback(() => { | |||||
| setSelectedPickOrderId(undefined); | |||||
| setSelectedJobOrderId(undefined); | |||||
| fetchPickOrders(); | |||||
| }, [fetchPickOrders]); | |||||
| 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> | |||||
| {loading ? ( | |||||
| <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}> | |||||
| <CircularProgress /> | |||||
| </Box> | |||||
| ) : ( | |||||
| <Box> | |||||
| <Box sx={{ display: "flex", gap: 1, alignItems: "center", flexWrap: "wrap", mb: 2 }}> | |||||
| <Button | |||||
| variant={filter === "all" ? "contained" : "outlined"} | |||||
| size="small" | |||||
| onClick={() => setFilter("all")} | |||||
| > | |||||
| {t("All")} | |||||
| </Button> | |||||
| <Button | |||||
| variant={filter === "drink" ? "contained" : "outlined"} | |||||
| size="small" | |||||
| onClick={() => setFilter("drink")} | |||||
| > | |||||
| {t("Drink")} | |||||
| </Button> | |||||
| <Button | |||||
| variant={filter === "Powder_Mixture" ? "contained" : "outlined"} | |||||
| size="small" | |||||
| onClick={() => setFilter("Powder_Mixture")} | |||||
| > | |||||
| {t("Powder Mixture")} | |||||
| </Button> | |||||
| <Button | |||||
| variant={filter === "other" ? "contained" : "outlined"} | |||||
| size="small" | |||||
| onClick={() => setFilter("other")} | |||||
| > | |||||
| {t("Other")} | |||||
| </Button> | |||||
| </Box> | |||||
| <Box sx={{ display: "flex", gap: 1, alignItems: "center", flexWrap: "wrap", mb: 2 }}> | |||||
| <Button | |||||
| variant={floorFilter === "ALL" ? "contained" : "outlined"} | |||||
| size="small" | |||||
| onClick={() => setFloorFilter("ALL")} | |||||
| > | |||||
| {t("Select All")} | |||||
| </Button> | |||||
| <Button | |||||
| variant={floorFilter === "2F" ? "contained" : "outlined"} | |||||
| size="small" | |||||
| onClick={() => setFloorFilter("2F")} | |||||
| > | |||||
| 2F | |||||
| </Button> | |||||
| <Button | |||||
| variant={floorFilter === "3F" ? "contained" : "outlined"} | |||||
| size="small" | |||||
| onClick={() => setFloorFilter("3F")} | |||||
| > | |||||
| 3F | |||||
| </Button> | |||||
| <Button | |||||
| variant={floorFilter === "4F" ? "contained" : "outlined"} | |||||
| size="small" | |||||
| onClick={() => setFloorFilter("4F")} | |||||
| > | |||||
| 4F | |||||
| </Button> | |||||
| <Button | |||||
| variant={floorFilter === "NO_LOT" ? "contained" : "outlined"} | |||||
| size="small" | |||||
| onClick={() => setFloorFilter("NO_LOT")} | |||||
| > | |||||
| {t("No Lot")} | |||||
| </Button> | |||||
| </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} | |||||
| </Typography> | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| {t("Required Qty")}: {pickOrder.reqQty} ({pickOrder.uomName}) | |||||
| </Typography> | |||||
| {floorFilter === "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> | |||||
| )} | |||||
| </> | |||||
| ) : floorFilter === "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 === floorFilter) | |||||
| .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; | |||||
| @@ -135,14 +135,15 @@ const NavigationContent: React.FC = () => { | |||||
| requiredAbility: [AUTH.STOCK, AUTH.STOCK_TAKE, AUTH.STOCK_IN_BIND, AUTH.STOCK_FG, AUTH.ADMIN], | requiredAbility: [AUTH.STOCK, AUTH.STOCK_TAKE, AUTH.STOCK_IN_BIND, AUTH.STOCK_FG, AUTH.ADMIN], | ||||
| path: "/stockRecord", | path: "/stockRecord", | ||||
| }, | }, | ||||
| /* | |||||
| { | { | ||||
| icon: <Description />, | icon: <Description />, | ||||
| label: "Do Workbench", | label: "Do Workbench", | ||||
| requiredAbility: [AUTH.STOCK, AUTH.STOCK_TAKE, AUTH.STOCK_IN_BIND, AUTH.STOCK_FG, AUTH.ADMIN], | requiredAbility: [AUTH.STOCK, AUTH.STOCK_TAKE, AUTH.STOCK_IN_BIND, AUTH.STOCK_FG, AUTH.ADMIN], | ||||
| path: "/doworkbench", | path: "/doworkbench", | ||||
| }, | }, | ||||
| */ | |||||
| ], | ], | ||||
| }, | }, | ||||
| { | { | ||||
| @@ -174,7 +175,7 @@ const NavigationContent: React.FC = () => { | |||||
| icon: <Inventory />, | icon: <Inventory />, | ||||
| label: "Job Order Pickexcution", | label: "Job Order Pickexcution", | ||||
| requiredAbility: [AUTH.JOB_PICK, AUTH.JOB_MAT, AUTH.ADMIN], | requiredAbility: [AUTH.JOB_PICK, AUTH.JOB_MAT, AUTH.ADMIN], | ||||
| path: "/jodetail", | |||||
| path: "/jo/workbench", | |||||
| }, | }, | ||||
| { | { | ||||
| icon: <Kitchen />, | icon: <Kitchen />, | ||||
| @@ -188,6 +189,14 @@ const NavigationContent: React.FC = () => { | |||||
| requiredAbility: [AUTH.JOB_PROD, AUTH.ADMIN], | requiredAbility: [AUTH.JOB_PROD, AUTH.ADMIN], | ||||
| path: "/bag", | path: "/bag", | ||||
| }, | }, | ||||
| /* | |||||
| { | |||||
| icon: <Inventory2 />, | |||||
| label: "Job Order Workbench", | |||||
| requiredAbility: [AUTH.JOB_PROD, AUTH.ADMIN], | |||||
| path: "/jo/workbench", | |||||
| }, | |||||
| */ | |||||
| ], | ], | ||||
| }, | }, | ||||
| { | { | ||||
| @@ -389,6 +398,17 @@ const NavigationContent: React.FC = () => { | |||||
| if (pathname === "/productionProcess" || pathname.startsWith("/productionProcess/")) { | if (pathname === "/productionProcess" || pathname.startsWith("/productionProcess/")) { | ||||
| ensureOpen.push("Management Job Order"); | ensureOpen.push("Management Job Order"); | ||||
| } | } | ||||
| if (pathname === "/jo/workbench" || pathname.startsWith("/jo/workbench/")) { | |||||
| 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; | if (ensureOpen.length === 0) return; | ||||
| setOpenItems((prev) => { | setOpenItems((prev) => { | ||||
| const set = new Set(prev); | const set = new Set(prev); | ||||
| @@ -423,7 +443,13 @@ const NavigationContent: React.FC = () => { | |||||
| walk(navigationItems); | walk(navigationItems); | ||||
| // Pick the most specific (longest) match to avoid double-highlighting | // 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); | matches.sort((a, b) => b.length - a.length); | ||||
| return matches[0] ?? ""; | return matches[0] ?? ""; | ||||
| }, [hasAbility, navigationItems, pathname]); | }, [hasAbility, navigationItems, pathname]); | ||||
| @@ -25,6 +25,8 @@ import { | |||||
| newassignPickOrder, | newassignPickOrder, | ||||
| AssignPickOrderInputs, | AssignPickOrderInputs, | ||||
| releaseAssignedPickOrders, | releaseAssignedPickOrders, | ||||
| assignPickOrderWorkbenchV2, | |||||
| releasePickOrderWorkbenchV2, | |||||
| } from "@/app/api/pickOrder/actions"; | } from "@/app/api/pickOrder/actions"; | ||||
| import { fetchNameList, NameList ,fetchNewNameList, NewNameList} from "@/app/api/user/actions"; | import { fetchNameList, NameList ,fetchNewNameList, NewNameList} from "@/app/api/user/actions"; | ||||
| import { FormProvider, useForm } from "react-hook-form"; | import { FormProvider, useForm } from "react-hook-form"; | ||||
| @@ -158,8 +160,9 @@ const AssignAndRelease: React.FC<Props> = ({ filterArgs }) => { | |||||
| console.log("First record:", res.records[0]); | console.log("First record:", res.records[0]); | ||||
| // 新增:在前端也过滤掉 "assigned" 状态的项目 | // 新增:在前端也过滤掉 "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) => ({ | const itemRows: ItemRow[] = filteredRecords.map((item: any) => ({ | ||||
| id: item.id, | id: item.id, | ||||
| pickOrderId: item.pickOrderId, | pickOrderId: item.pickOrderId, | ||||
| @@ -356,7 +359,7 @@ const AssignAndRelease: React.FC<Props> = ({ filterArgs }) => { | |||||
| setIsUploading(true); | setIsUploading(true); | ||||
| try { | try { | ||||
| // Step 1: Assign the pick orders | // Step 1: Assign the pick orders | ||||
| const assignRes = await newassignPickOrder({ | |||||
| const assignRes = await assignPickOrderWorkbenchV2({ | |||||
| pickOrderIds: selectedPickOrderIds, | pickOrderIds: selectedPickOrderIds, | ||||
| assignTo: data.assignTo, | assignTo: data.assignTo, | ||||
| }); | }); | ||||
| @@ -365,7 +368,7 @@ const AssignAndRelease: React.FC<Props> = ({ filterArgs }) => { | |||||
| console.log("Assign successful:", assignRes); | console.log("Assign successful:", assignRes); | ||||
| // Step 2: Release the assigned pick orders | // Step 2: Release the assigned pick orders | ||||
| const releaseRes = await releaseAssignedPickOrders({ | |||||
| const releaseRes = await releasePickOrderWorkbenchV2({ | |||||
| pickOrderIds: selectedPickOrderIds, | pickOrderIds: selectedPickOrderIds, | ||||
| assignTo: data.assignTo, | assignTo: data.assignTo, | ||||
| }); | }); | ||||
| @@ -2,6 +2,7 @@ | |||||
| import { useCallback, useEffect, useState } from "react"; | import { useCallback, useEffect, useState } from "react"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { Grid, Stack, Tab, Tabs, TabsProps, Typography, Box } from "@mui/material"; | import { Grid, Stack, Tab, Tabs, TabsProps, Typography, Box } from "@mui/material"; | ||||
| import WorkbenchPickExecution from "./WorkbenchPickExecution"; | |||||
| import PickExecution from "./PickExecution"; | import PickExecution from "./PickExecution"; | ||||
| import NewCreateItem from "./newcreatitem"; | import NewCreateItem from "./newcreatitem"; | ||||
| import AssignAndRelease from "./AssignAndRelease"; | import AssignAndRelease from "./AssignAndRelease"; | ||||
| @@ -121,6 +122,7 @@ const PickOrderSearch: React.FC<Props> = () => { | |||||
| <Tab label={t("Assign")} 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("Pick Execution V2")} iconPosition="end" /> | |||||
| </Tabs> | </Tabs> | ||||
| </Box> | </Box> | ||||
| @@ -128,7 +130,8 @@ const PickOrderSearch: React.FC<Props> = () => { | |||||
| <Box sx={{ | <Box sx={{ | ||||
| p: 2 | p: 2 | ||||
| }}> | }}> | ||||
| {tabIndex === 3 && <PickExecution filterArgs={filterArgs} />} | |||||
| {tabIndex === 4 && <WorkbenchPickExecution filterArgs={filterArgs} />} | |||||
| {tabIndex === 3 && <PickExecution filterArgs={filterArgs} />} | |||||
| {tabIndex === 0 && ( | {tabIndex === 0 && ( | ||||
| <NewCreateItem | <NewCreateItem | ||||
| filterArgs={filterArgs} | filterArgs={filterArgs} | ||||
| @@ -138,7 +141,7 @@ const PickOrderSearch: React.FC<Props> = () => { | |||||
| )} | )} | ||||
| {/* {tabIndex === 1 && <Jobcreatitem filterArgs={filterArgs} />} */} | {/* {tabIndex === 1 && <Jobcreatitem filterArgs={filterArgs} />} */} | ||||
| {tabIndex === 1 && <AssignAndRelease filterArgs={filterArgs} />} | {tabIndex === 1 && <AssignAndRelease filterArgs={filterArgs} />} | ||||
| {tabIndex === 2 && <AssignTo filterArgs={filterArgs} />} | |||||
| {/* {tabIndex === 2 && <AssignTo filterArgs={filterArgs} />} */} | |||||
| </Box> | </Box> | ||||
| </Box> | </Box> | ||||
| ); | ); | ||||
| @@ -0,0 +1,620 @@ | |||||
| "use client"; | |||||
| import { useCallback, useEffect, useMemo, useState } from "react"; | |||||
| import { | |||||
| Alert, | |||||
| Button, | |||||
| Checkbox, | |||||
| CircularProgress, | |||||
| Grid, | |||||
| Paper, | |||||
| Stack, | |||||
| Table, | |||||
| TableBody, | |||||
| TableCell, | |||||
| TableContainer, | |||||
| TableHead, | |||||
| TablePagination, | |||||
| TableRow, | |||||
| TextField, | |||||
| Typography, | |||||
| } from "@mui/material"; | |||||
| import { useSession } from "next-auth/react"; | |||||
| import { useTranslation } from "react-i18next"; | |||||
| import dayjs from "dayjs"; | |||||
| import arraySupport from "dayjs/plugin/arraySupport"; | |||||
| import { SessionWithTokens } from "@/config/authConfig"; | |||||
| import { | |||||
| fetchPickOrderWithStockClient, | |||||
| fetchWorkbenchPickOrderLineDetailV2, | |||||
| suggestPickOrderWorkbenchV2, | |||||
| type PickOrderLotDetailResponse, | |||||
| } from "@/app/api/pickOrder/actions"; | |||||
| import { workbenchScanPick } from "@/app/api/doworkbench/actions"; | |||||
| import { workbenchScanPickResponseNeedsFullRefresh } from "@/app/api/doworkbench/workbenchScanPickUtils"; | |||||
| import SearchBox, { Criterion } from "../SearchBox"; | |||||
| import { OUTPUT_DATE_FORMAT, arrayToDayjs } from "@/app/utils/formatUtil"; | |||||
| dayjs.extend(arraySupport); | |||||
| type Row = { | |||||
| key: string; | |||||
| pickOrderId: number; | |||||
| pickOrderLineId: number; | |||||
| pickOrderCode: string; | |||||
| itemCode: string; | |||||
| itemName: string; | |||||
| uomDesc: string; | |||||
| requiredQty: number; | |||||
| availableQty: number; | |||||
| originalAvailableQty: number; | |||||
| expiryDate: string; | |||||
| location: string; | |||||
| stockOutLineId: number; | |||||
| status: string; | |||||
| pickedQty: number; | |||||
| lotNo: string; | |||||
| stockInLineId?: number; | |||||
| }; | |||||
| type TopRow = { | |||||
| rowKey: string; | |||||
| pickOrderId: number; | |||||
| pickOrderLineId: number; | |||||
| pickOrderCode: string; | |||||
| itemCode: string; | |||||
| itemName: string; | |||||
| requiredQty: number; | |||||
| currentStock: number; | |||||
| pickedQty: number; | |||||
| stockUnit: string; | |||||
| targetDate: string | number[]; | |||||
| status: string; | |||||
| }; | |||||
| interface Props { | |||||
| filterArgs?: Record<string, any>; | |||||
| } | |||||
| const toNum = (v: unknown, d = 0) => { | |||||
| const n = Number(v); | |||||
| return Number.isFinite(n) ? n : d; | |||||
| }; | |||||
| const toStr = (v: unknown) => (typeof v === "string" ? v : ""); | |||||
| function safeDisplayTargetDate(targetDate: string | number[]): string { | |||||
| try { | |||||
| if (Array.isArray(targetDate) && targetDate.length >= 3) { | |||||
| return arrayToDayjs(targetDate).format(OUTPUT_DATE_FORMAT); | |||||
| } | |||||
| const s = typeof targetDate === "string" ? targetDate : String(targetDate ?? ""); | |||||
| const d = dayjs(s); | |||||
| return d.isValid() ? d.format(OUTPUT_DATE_FORMAT) : "-"; | |||||
| } catch { | |||||
| return "-"; | |||||
| } | |||||
| } | |||||
| function lineHasStockOutOrSuggestion(details: PickOrderLotDetailResponse[]): boolean { | |||||
| if (!details.length) return false; | |||||
| return details.some((d) => { | |||||
| const sol = toNum(d.stockOutLineId); | |||||
| const spl = toNum(d.suggestedPickLotId); | |||||
| return sol > 0 || spl > 0 || d.noLot === true; | |||||
| }); | |||||
| } | |||||
| function mapLotDetailsToRows( | |||||
| details: PickOrderLotDetailResponse[], | |||||
| ctx: { | |||||
| pickOrderId: number; | |||||
| pickOrderLineId: number; | |||||
| pickOrderCode: string; | |||||
| itemCode: string; | |||||
| itemName: string; | |||||
| }, | |||||
| ): Row[] { | |||||
| return details.map((d, i) => { | |||||
| const inQty = toNum(d.inQty); | |||||
| const outQty = toNum(d.outQty); | |||||
| const solId = toNum(d.stockOutLineId); | |||||
| const lotId = toNum(d.lotId, i); | |||||
| return { | |||||
| key: solId > 0 ? `sol:${solId}` : `lot:${lotId}:${i}`, | |||||
| pickOrderId: ctx.pickOrderId, | |||||
| pickOrderLineId: ctx.pickOrderLineId, | |||||
| pickOrderCode: ctx.pickOrderCode, | |||||
| itemCode: ctx.itemCode, | |||||
| itemName: ctx.itemName, | |||||
| uomDesc: toStr(d.stockUnit), | |||||
| requiredQty: toNum(d.requiredQty), | |||||
| availableQty: toNum(d.remainingAfterAllPickOrders ?? d.availableQty), | |||||
| originalAvailableQty: inQty - outQty, | |||||
| expiryDate: toStr(d.expiryDate), | |||||
| location: toStr(d.location), | |||||
| stockOutLineId: solId, | |||||
| status: toStr(d.stockOutLineStatus ?? "pending"), | |||||
| pickedQty: toNum(d.actualPickQty ?? d.stockOutLineQty), | |||||
| lotNo: toStr(d.lotNo), | |||||
| }; | |||||
| }); | |||||
| } | |||||
| const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| const { t } = useTranslation("pickOrder"); | |||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | |||||
| const userId = session?.id ? parseInt(session.id, 10) : 0; | |||||
| const [originalTopRows, setOriginalTopRows] = useState<TopRow[]>([]); | |||||
| const [filteredTopRows, setFilteredTopRows] = useState<TopRow[]>([]); | |||||
| const [pickOrderLoading, setPickOrderLoading] = useState(false); | |||||
| const [pagingController, setPagingController] = useState({ | |||||
| pageNum: 1, | |||||
| pageSize: 10, | |||||
| }); | |||||
| const [totalCountItems, setTotalCountItems] = useState<number>(0); | |||||
| const [selectedPickOrderLineId, setSelectedPickOrderLineId] = useState<number | null>(null); | |||||
| const [selectedPickOrderId, setSelectedPickOrderId] = useState<number | null>(null); | |||||
| const [selectedTopMeta, setSelectedTopMeta] = useState<{ | |||||
| pickOrderCode: string; | |||||
| itemCode: string; | |||||
| itemName: string; | |||||
| } | null>(null); | |||||
| const [rows, setRows] = useState<Row[]>([]); | |||||
| const [qtyBySolId, setQtyBySolId] = useState<Record<number, string>>({}); | |||||
| const [loading, setLoading] = useState(false); | |||||
| const [submittingSolId, setSubmittingSolId] = useState<number | null>(null); | |||||
| const [message, setMessage] = useState(""); | |||||
| const [error, setError] = useState(""); | |||||
| const fetchNewPageItems = useCallback( | |||||
| async (paging: Record<string, number>, extra: Record<string, any>) => { | |||||
| if (!userId) return; | |||||
| setPickOrderLoading(true); | |||||
| setError(""); | |||||
| try { | |||||
| const params = { | |||||
| ...paging, | |||||
| ...extra, | |||||
| pageNum: (paging.pageNum || 1) - 1, | |||||
| pageSize: paging.pageSize || 10, | |||||
| status: "released", | |||||
| type: "consumable", | |||||
| assignTo: userId, | |||||
| }; | |||||
| const res = await fetchPickOrderWithStockClient(params); | |||||
| if (res?.records) { | |||||
| const topRows: TopRow[] = res.records.flatMap((r: any) => { | |||||
| const pickOrderId = toNum(r?.id); | |||||
| const code = toStr(r?.code); | |||||
| const status = toStr(r?.status); | |||||
| const targetDate = r?.targetDate; | |||||
| const lines = Array.isArray(r?.pickOrderLines) ? r.pickOrderLines : []; | |||||
| return lines.map((line: any, idx: number) => ({ | |||||
| rowKey: `po:${pickOrderId}:line:${toNum(line?.id, idx)}`, | |||||
| pickOrderId, | |||||
| pickOrderLineId: toNum(line?.id), | |||||
| pickOrderCode: code, | |||||
| itemCode: toStr(line?.itemCode), | |||||
| itemName: toStr(line?.itemName), | |||||
| requiredQty: toNum(line?.requiredQty), | |||||
| currentStock: toNum(line?.availableQty), | |||||
| pickedQty: toNum(line?.pickedQty), | |||||
| stockUnit: toStr(line?.uomDesc ?? line?.uomShortDesc), | |||||
| targetDate: targetDate ?? "", | |||||
| status, | |||||
| })); | |||||
| }); | |||||
| setOriginalTopRows(topRows); | |||||
| setFilteredTopRows(topRows); | |||||
| setTotalCountItems(res.total ?? 0); | |||||
| } else { | |||||
| setOriginalTopRows([]); | |||||
| setFilteredTopRows([]); | |||||
| setTotalCountItems(0); | |||||
| } | |||||
| } catch (e) { | |||||
| console.error(e); | |||||
| setError(t("Load released pick orders failed")); | |||||
| setOriginalTopRows([]); | |||||
| setFilteredTopRows([]); | |||||
| setTotalCountItems(0); | |||||
| } finally { | |||||
| setPickOrderLoading(false); | |||||
| } | |||||
| }, | |||||
| [t, userId], | |||||
| ); | |||||
| const searchCriteria: Criterion<any>[] = useMemo( | |||||
| () => [ | |||||
| { | |||||
| label: t("Item Code"), | |||||
| paramName: "itemCode", | |||||
| type: "text", | |||||
| }, | |||||
| { | |||||
| label: t("Pick Order Code"), | |||||
| paramName: "pickOrderCode", | |||||
| type: "text", | |||||
| }, | |||||
| { | |||||
| label: t("Item Name"), | |||||
| paramName: "itemName", | |||||
| type: "text", | |||||
| }, | |||||
| { | |||||
| label: t("Target Date From"), | |||||
| label2: t("Target Date To"), | |||||
| paramName: "targetDate", | |||||
| type: "dateRange", | |||||
| }, | |||||
| ], | |||||
| [t], | |||||
| ); | |||||
| const handleSearch = useCallback( | |||||
| (query: Record<string, any>) => { | |||||
| const filtered = originalTopRows.filter((row) => { | |||||
| const targetDate = Array.isArray(row.targetDate) | |||||
| ? arrayToDayjs(row.targetDate) | |||||
| : dayjs(typeof row.targetDate === "string" ? row.targetDate : ""); | |||||
| const itemCodeMatch = | |||||
| !query.itemCode || row.itemCode?.toLowerCase().includes((query.itemCode || "").toLowerCase()); | |||||
| const pickOrderCodeMatch = | |||||
| !query.pickOrderCode || | |||||
| row.pickOrderCode?.toLowerCase().includes((query.pickOrderCode || "").toLowerCase()); | |||||
| const itemNameMatch = | |||||
| !query.itemName || row.itemName?.toLowerCase().includes((query.itemName || "").toLowerCase()); | |||||
| let dateMatch = true; | |||||
| if (query.targetDate || query.targetDateTo) { | |||||
| try { | |||||
| if (!targetDate.isValid()) { | |||||
| dateMatch = true; | |||||
| } else if (query.targetDate && !query.targetDateTo) { | |||||
| const fromDate = dayjs(query.targetDate); | |||||
| dateMatch = targetDate.isSame(fromDate, "day") || targetDate.isAfter(fromDate, "day"); | |||||
| } else if (!query.targetDate && query.targetDateTo) { | |||||
| const toDate = dayjs(query.targetDateTo); | |||||
| dateMatch = targetDate.isSame(toDate, "day") || targetDate.isBefore(toDate, "day"); | |||||
| } else if (query.targetDate && query.targetDateTo) { | |||||
| const fromDate = dayjs(query.targetDate); | |||||
| const toDate = dayjs(query.targetDateTo); | |||||
| dateMatch = | |||||
| (targetDate.isSame(fromDate, "day") || targetDate.isAfter(fromDate, "day")) && | |||||
| (targetDate.isSame(toDate, "day") || targetDate.isBefore(toDate, "day")); | |||||
| } | |||||
| } catch { | |||||
| dateMatch = true; | |||||
| } | |||||
| } | |||||
| return itemCodeMatch && pickOrderCodeMatch && itemNameMatch && dateMatch; | |||||
| }); | |||||
| setFilteredTopRows(filtered); | |||||
| }, | |||||
| [originalTopRows], | |||||
| ); | |||||
| const handleReset = useCallback(() => { | |||||
| setFilteredTopRows(originalTopRows); | |||||
| }, [originalTopRows]); | |||||
| const handlePageChange = useCallback((event: unknown, newPage: number) => { | |||||
| setPagingController((prev) => ({ | |||||
| ...prev, | |||||
| pageNum: newPage + 1, | |||||
| })); | |||||
| }, []); | |||||
| const handlePageSizeChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => { | |||||
| const newPageSize = parseInt(event.target.value, 10); | |||||
| setPagingController({ | |||||
| pageNum: 1, | |||||
| pageSize: newPageSize, | |||||
| }); | |||||
| }, []); | |||||
| useEffect(() => { | |||||
| if (userId) { | |||||
| fetchNewPageItems(pagingController, filterArgs || {}); | |||||
| } | |||||
| }, [userId, pagingController, filterArgs, fetchNewPageItems]); | |||||
| const loadLineDetailV2 = useCallback( | |||||
| async ( | |||||
| pickOrderId: number, | |||||
| pickOrderLineId: number, | |||||
| meta: { pickOrderCode: string; itemCode: string; itemName: string }, | |||||
| ) => { | |||||
| if (!userId || pickOrderLineId <= 0) return; | |||||
| setLoading(true); | |||||
| setError(""); | |||||
| setMessage(""); | |||||
| try { | |||||
| let details = await fetchWorkbenchPickOrderLineDetailV2(pickOrderLineId); | |||||
| let list = Array.isArray(details) ? details : []; | |||||
| if (!lineHasStockOutOrSuggestion(list)) { | |||||
| const suggestRes = await suggestPickOrderWorkbenchV2(pickOrderId); | |||||
| if (suggestRes.code !== "SUCCESS") { | |||||
| setError(suggestRes.message || t("Suggest pick failed")); | |||||
| setRows([]); | |||||
| return; | |||||
| } | |||||
| details = await fetchWorkbenchPickOrderLineDetailV2(pickOrderLineId); | |||||
| list = Array.isArray(details) ? details : []; | |||||
| setMessage(suggestRes.message || t("Suggestion created")); | |||||
| } | |||||
| setRows( | |||||
| mapLotDetailsToRows(list, { | |||||
| pickOrderId, | |||||
| pickOrderLineId, | |||||
| pickOrderCode: meta.pickOrderCode, | |||||
| itemCode: meta.itemCode, | |||||
| itemName: meta.itemName, | |||||
| }), | |||||
| ); | |||||
| } catch (e) { | |||||
| console.error(e); | |||||
| setError(t("Load workbench data failed")); | |||||
| setRows([]); | |||||
| } finally { | |||||
| setLoading(false); | |||||
| } | |||||
| }, | |||||
| [t, userId], | |||||
| ); | |||||
| const submitRow = useCallback( | |||||
| async (row: Row, forceQty?: number, forceLotNo?: string) => { | |||||
| if (!userId) return; | |||||
| if (!row.stockOutLineId) { | |||||
| setError(t("No stock out line for this lot")); | |||||
| return; | |||||
| } | |||||
| const qtyInput = qtyBySolId[row.stockOutLineId]; | |||||
| const qtyValue = forceQty ?? (qtyInput === "" || qtyInput == null ? undefined : Number(qtyInput)); | |||||
| const lotNo = (forceLotNo ?? row.lotNo).trim(); | |||||
| setSubmittingSolId(row.stockOutLineId); | |||||
| setError(""); | |||||
| setMessage(""); | |||||
| try { | |||||
| const res = await workbenchScanPick({ | |||||
| stockOutLineId: row.stockOutLineId, | |||||
| lotNo, | |||||
| ...(row.stockInLineId ? { stockInLineId: row.stockInLineId } : {}), | |||||
| ...(typeof qtyValue === "number" && Number.isFinite(qtyValue) ? { qty: qtyValue } : {}), | |||||
| userId, | |||||
| }); | |||||
| if (res.code !== "SUCCESS") { | |||||
| setError((res.message as string) || t("Scan pick failed")); | |||||
| return; | |||||
| } | |||||
| setMessage((res.message as string) || t("Scan pick success")); | |||||
| if (workbenchScanPickResponseNeedsFullRefresh(res)) { | |||||
| if (selectedPickOrderId != null && selectedPickOrderLineId != null && selectedTopMeta) { | |||||
| await loadLineDetailV2(selectedPickOrderId, selectedPickOrderLineId, selectedTopMeta); | |||||
| } | |||||
| } else { | |||||
| const entity = res.entity as any; | |||||
| setRows((prev) => | |||||
| prev.map((r) => | |||||
| r.stockOutLineId === row.stockOutLineId | |||||
| ? { ...r, status: toStr(entity?.status || r.status), pickedQty: toNum(entity?.qty, r.pickedQty) } | |||||
| : r, | |||||
| ), | |||||
| ); | |||||
| } | |||||
| } catch (e) { | |||||
| console.error(e); | |||||
| setError(t("Scan pick failed")); | |||||
| } finally { | |||||
| setSubmittingSolId(null); | |||||
| } | |||||
| }, | |||||
| [qtyBySolId, loadLineDetailV2, selectedPickOrderId, selectedPickOrderLineId, selectedTopMeta, t, userId], | |||||
| ); | |||||
| const handleLineSelect = useCallback( | |||||
| async (row: TopRow, checked: boolean) => { | |||||
| if (!checked) { | |||||
| if (selectedPickOrderLineId === row.pickOrderLineId) { | |||||
| setSelectedPickOrderLineId(null); | |||||
| setSelectedPickOrderId(null); | |||||
| setSelectedTopMeta(null); | |||||
| setRows([]); | |||||
| setQtyBySolId({}); | |||||
| } | |||||
| return; | |||||
| } | |||||
| setSelectedPickOrderLineId(row.pickOrderLineId); | |||||
| setSelectedPickOrderId(row.pickOrderId); | |||||
| setSelectedTopMeta({ | |||||
| pickOrderCode: row.pickOrderCode, | |||||
| itemCode: row.itemCode, | |||||
| itemName: row.itemName, | |||||
| }); | |||||
| setRows([]); | |||||
| setQtyBySolId({}); | |||||
| setMessage(""); | |||||
| await loadLineDetailV2(row.pickOrderId, row.pickOrderLineId, { | |||||
| pickOrderCode: row.pickOrderCode, | |||||
| itemCode: row.itemCode, | |||||
| itemName: row.itemName, | |||||
| }); | |||||
| }, | |||||
| [loadLineDetailV2, selectedPickOrderLineId], | |||||
| ); | |||||
| return ( | |||||
| <Stack spacing={2}> | |||||
| <Paper variant="outlined" sx={{ p: 2 }}> | |||||
| <Stack spacing={1}> | |||||
| <SearchBox criteria={searchCriteria} onSearch={handleSearch} onReset={handleReset} /> | |||||
| <Grid container rowGap={1}> | |||||
| <Grid item xs={12}> | |||||
| {pickOrderLoading ? ( | |||||
| <CircularProgress size={40} /> | |||||
| ) : ( | |||||
| <TableContainer> | |||||
| <Table size="small"> | |||||
| <TableHead> | |||||
| <TableRow> | |||||
| <TableCell>{t("Selected")}</TableCell> | |||||
| <TableCell>{t("Pick Order Code")}</TableCell> | |||||
| <TableCell>{t("Item Code")}</TableCell> | |||||
| <TableCell>{t("Item Name")}</TableCell> | |||||
| <TableCell align="right">{t("Order Quantity")}</TableCell> | |||||
| <TableCell align="right">{t("Current Stock")}</TableCell> | |||||
| <TableCell align="right">{t("Picked Qty")}</TableCell> | |||||
| <TableCell>{t("Stock Unit")}</TableCell> | |||||
| <TableCell>{t("Target Date")}</TableCell> | |||||
| <TableCell>{t("Status")}</TableCell> | |||||
| </TableRow> | |||||
| </TableHead> | |||||
| <TableBody> | |||||
| {filteredTopRows.map((row) => ( | |||||
| <TableRow key={row.rowKey} selected={selectedPickOrderLineId === row.pickOrderLineId} hover> | |||||
| <TableCell> | |||||
| <Checkbox | |||||
| checked={selectedPickOrderLineId === row.pickOrderLineId} | |||||
| onChange={(e) => handleLineSelect(row, e.target.checked)} | |||||
| disabled={loading} | |||||
| /> | |||||
| </TableCell> | |||||
| <TableCell>{row.pickOrderCode}</TableCell> | |||||
| <TableCell>{row.itemCode}</TableCell> | |||||
| <TableCell>{row.itemName}</TableCell> | |||||
| <TableCell align="right">{row.requiredQty}</TableCell> | |||||
| <TableCell align="right" sx={{ color: "success.main", fontWeight: 600 }}> | |||||
| {row.currentStock.toLocaleString()} | |||||
| </TableCell> | |||||
| <TableCell align="right">{row.pickedQty}</TableCell> | |||||
| <TableCell>{row.stockUnit || "-"}</TableCell> | |||||
| <TableCell>{safeDisplayTargetDate(row.targetDate)}</TableCell> | |||||
| <TableCell>{row.status || "-"}</TableCell> | |||||
| </TableRow> | |||||
| ))} | |||||
| {filteredTopRows.length === 0 ? ( | |||||
| <TableRow> | |||||
| <TableCell colSpan={10}> | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| {t("No released consumable assigned to current user")} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ) : null} | |||||
| </TableBody> | |||||
| </Table> | |||||
| </TableContainer> | |||||
| )} | |||||
| </Grid> | |||||
| <Grid item xs={12}> | |||||
| <TablePagination | |||||
| component="div" | |||||
| count={totalCountItems || 0} | |||||
| page={pagingController.pageNum - 1} | |||||
| rowsPerPage={pagingController.pageSize} | |||||
| onPageChange={handlePageChange} | |||||
| onRowsPerPageChange={handlePageSizeChange} | |||||
| rowsPerPageOptions={[10, 25, 50, 100]} | |||||
| labelRowsPerPage={t("Rows per page")} | |||||
| labelDisplayedRows={({ from, to, count }) => | |||||
| `${from}-${to} of ${count !== -1 ? count : `more than ${to}`}` | |||||
| } | |||||
| /> | |||||
| </Grid> | |||||
| </Grid> | |||||
| </Stack> | |||||
| </Paper> | |||||
| {loading ? ( | |||||
| <Stack direction="row" alignItems="center" spacing={1}> | |||||
| <CircularProgress size={24} /> | |||||
| <Typography variant="body2">{t("Loading")}</Typography> | |||||
| </Stack> | |||||
| ) : null} | |||||
| {error ? <Alert severity="error">{error}</Alert> : null} | |||||
| {message ? <Alert severity="success">{message}</Alert> : null} | |||||
| <Paper variant="outlined"> | |||||
| <TableContainer> | |||||
| <Table size="small"> | |||||
| <TableHead> | |||||
| <TableRow> | |||||
| <TableCell>{t("Lot#")}</TableCell> | |||||
| <TableCell>{t("Lot Expiry Date")}</TableCell> | |||||
| <TableCell>{t("Lot Location")}</TableCell> | |||||
| <TableCell>{t("Stock Unit")}</TableCell> | |||||
| <TableCell align="right">{t("Lot Required Pick Qty")}</TableCell> | |||||
| <TableCell align="right">{t("Original Available Qty")}</TableCell> | |||||
| <TableCell align="center">{t("Lot Actual Pick Qty")}</TableCell> | |||||
| <TableCell align="right">{t("Remaining Available Qty")}</TableCell> | |||||
| <TableCell align="center">{t("Action")}</TableCell> | |||||
| </TableRow> | |||||
| </TableHead> | |||||
| <TableBody> | |||||
| {rows.map((r) => ( | |||||
| <TableRow key={r.key}> | |||||
| <TableCell>{r.lotNo || "-"}</TableCell> | |||||
| <TableCell>{r.expiryDate || "-"}</TableCell> | |||||
| <TableCell>{r.location || "-"}</TableCell> | |||||
| <TableCell>{r.uomDesc || "-"}</TableCell> | |||||
| <TableCell align="right">{r.requiredQty}</TableCell> | |||||
| <TableCell align="right">{r.originalAvailableQty.toLocaleString()}</TableCell> | |||||
| <TableCell align="center"> | |||||
| <TextField | |||||
| size="small" | |||||
| type="number" | |||||
| value={qtyBySolId[r.stockOutLineId] ?? ""} | |||||
| onChange={(e) => setQtyBySolId((p) => ({ ...p, [r.stockOutLineId]: e.target.value }))} | |||||
| sx={{ width: 84 }} | |||||
| disabled={!r.stockOutLineId} | |||||
| /> | |||||
| </TableCell> | |||||
| <TableCell align="right">{r.availableQty.toLocaleString()}</TableCell> | |||||
| <TableCell align="center"> | |||||
| <Stack direction="row" spacing={1} justifyContent="center"> | |||||
| <Button | |||||
| size="small" | |||||
| variant="contained" | |||||
| disabled={submittingSolId === r.stockOutLineId || !r.stockOutLineId} | |||||
| onClick={() => submitRow(r)} | |||||
| > | |||||
| {submittingSolId === r.stockOutLineId ? <CircularProgress size={14} /> : t("Submit")} | |||||
| </Button> | |||||
| <Button size="small" variant="text" disabled={!r.stockOutLineId} onClick={() => submitRow(r, 0)}> | |||||
| {t("Just Complete")} | |||||
| </Button> | |||||
| </Stack> | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ))} | |||||
| {rows.length === 0 ? ( | |||||
| <TableRow> | |||||
| <TableCell colSpan={9}> | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| {t("No lot rows. Select a line in the table above.")} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ) : null} | |||||
| </TableBody> | |||||
| </Table> | |||||
| </TableContainer> | |||||
| </Paper> | |||||
| </Stack> | |||||
| ); | |||||
| }; | |||||
| export default WorkbenchPickExecution; | |||||
| @@ -22,9 +22,8 @@ import { | |||||
| import { useCallback, useEffect, useMemo, useState } from "react"; | import { useCallback, useEffect, useMemo, useState } from "react"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { | import { | ||||
| newassignPickOrder, | |||||
| AssignPickOrderInputs, | AssignPickOrderInputs, | ||||
| releaseAssignedPickOrders, | |||||
| releasePickOrderWorkbenchV2, | |||||
| fetchPickOrderWithStockClient, // Add this import | fetchPickOrderWithStockClient, // Add this import | ||||
| } from "@/app/api/pickOrder/actions"; | } from "@/app/api/pickOrder/actions"; | ||||
| import { fetchNameList, NameList } from "@/app/api/user/actions"; | import { fetchNameList, NameList } from "@/app/api/user/actions"; | ||||
| @@ -39,7 +38,6 @@ import dayjs from "dayjs"; | |||||
| import arraySupport from "dayjs/plugin/arraySupport"; | import arraySupport from "dayjs/plugin/arraySupport"; | ||||
| import SearchBox, { Criterion } from "../SearchBox"; | import SearchBox, { Criterion } from "../SearchBox"; | ||||
| import { sortBy, uniqBy } from "lodash"; | import { sortBy, uniqBy } from "lodash"; | ||||
| import { createStockOutLine, CreateStockOutLine, fetchPickOrderDetails } from "@/app/api/pickOrder/actions"; | |||||
| dayjs.extend(arraySupport); | dayjs.extend(arraySupport); | ||||
| interface Props { | interface Props { | ||||
| @@ -196,7 +194,7 @@ const handleRelease = useCallback(async () => { | |||||
| console.log("Using assigned user:", assignToValue); | console.log("Using assigned user:", assignToValue); | ||||
| console.log("selectedPickOrderIds:", selectedPickOrderIds); | console.log("selectedPickOrderIds:", selectedPickOrderIds); | ||||
| const releaseRes = await releaseAssignedPickOrders({ | |||||
| const releaseRes = await releasePickOrderWorkbenchV2({ | |||||
| pickOrderIds: selectedPickOrderIds.map(id => parseInt(id)), | pickOrderIds: selectedPickOrderIds.map(id => parseInt(id)), | ||||
| assignTo: assignToValue | assignTo: assignToValue | ||||
| }); | }); | ||||
| @@ -204,30 +202,6 @@ const handleRelease = useCallback(async () => { | |||||
| if (releaseRes.code === "SUCCESS") { | if (releaseRes.code === "SUCCESS") { | ||||
| console.log("Pick orders released successfully"); | 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); | fetchNewPageItems(pagingController, filterArgs); | ||||
| } else { | } else { | ||||
| console.error("Release failed:", releaseRes.message); | console.error("Release failed:", releaseRes.message); | ||||
| @@ -158,17 +158,38 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||||
| } | } | ||||
| const result = new Map<number, boolean>(); | 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) => { | byJobOrder.forEach((jobOrderProcesses, jobOrderId) => { | ||||
| const hasStockInLine = jobOrderProcesses.some((p) => p.stockInLineId != null); | 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 ?? []; | 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; | return result; | ||||
| @@ -486,7 +507,7 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||||
| const finishedCount = | const finishedCount = | ||||
| (process.lines || []).filter( | (process.lines || []).filter( | ||||
| (l) => String(l.status ?? "").trim().toLowerCase() === "completed" | |||||
| (l) => String(l.status ?? "").trim().toLowerCase() === "completed" || String(l.status ?? "").trim().toLowerCase() === "pass" | |||||
| ).length; | ).length; | ||||
| const totalCount = process.productProcessLineCount ?? process.lines?.length ?? 0; | const totalCount = process.productProcessLineCount ?? process.lines?.length ?? 0; | ||||
| @@ -19,15 +19,19 @@ export const detectLanguage = async (): Promise<string> => { | |||||
| {}, | {}, | ||||
| ); | ); | ||||
| const headersList = headers(); | const headersList = headers(); | ||||
| console.time("[i18n] detectLanguage total"); | |||||
| console.time("[i18n] getServerSession"); | |||||
| const session = await getServerSession(authOptions); | const session = await getServerSession(authOptions); | ||||
| console.timeEnd("[i18n] getServerSession"); | |||||
| console.time("[i18n] universalLanguageDetect"); | |||||
| const lang = universalLanguageDetect({ | const lang = universalLanguageDetect({ | ||||
| supportedLanguages: SUPPORTED_LANGUAGES, | supportedLanguages: SUPPORTED_LANGUAGES, | ||||
| fallbackLanguage: FALLBACK_LANG, | fallbackLanguage: FALLBACK_LANG, | ||||
| acceptLanguageHeader: headersList.get("accept-language") || undefined, | acceptLanguageHeader: headersList.get("accept-language") || undefined, | ||||
| serverCookies: cookiesObj, | serverCookies: cookiesObj, | ||||
| }); | }); | ||||
| console.timeEnd("[i18n] universalLanguageDetect"); | |||||
| console.timeEnd("[i18n] detectLanguage total"); | |||||
| return lang; | return lang; | ||||
| }; | }; | ||||
| @@ -38,6 +42,8 @@ const languageDetector: LanguageDetectorAsyncModule = { | |||||
| }; | }; | ||||
| const initI18next = async (namespaces: string[]): Promise<i18n> => { | const initI18next = async (namespaces: string[]): Promise<i18n> => { | ||||
| const label = `[i18n] initI18next ns=${namespaces.join(",")}`; | |||||
| console.time(label); | |||||
| const i18nInstance = createInstance(); | const i18nInstance = createInstance(); | ||||
| await i18nInstance | await i18nInstance | ||||
| .use(languageDetector) | .use(languageDetector) | ||||
| @@ -7,11 +7,14 @@ | |||||
| "Stock Record": "庫存記錄", | "Stock Record": "庫存記錄", | ||||
| "No options": "沒有選項", | "No options": "沒有選項", | ||||
| "Drink": "飲料", | "Drink": "飲料", | ||||
| "packaging": "提料中", | |||||
| "Issue BOM List": "問題 BOM 列表", | "Issue BOM List": "問題 BOM 列表", | ||||
| "File Name": "檔案名稱", | "File Name": "檔案名稱", | ||||
| "Please Select BOM": "請選擇 BOM", | "Please Select BOM": "請選擇 BOM", | ||||
| "No Lot": "沒有批號", | "No Lot": "沒有批號", | ||||
| "Select All": "全選", | "Select All": "全選", | ||||
| "Do Workbench": "新版成品出倉", | |||||
| "Waiting QC Put Away Job Orders": "待QC上架工單", | "Waiting QC Put Away Job Orders": "待QC上架工單", | ||||
| "Put Awayed Job Orders": "已上架工單", | "Put Awayed Job Orders": "已上架工單", | ||||
| "Loading BOM Detail...": "正在載入 BOM 明細…", | "Loading BOM Detail...": "正在載入 BOM 明細…", | ||||
| @@ -10,6 +10,9 @@ | |||||
| "Estimated Arrival To": "預計送貨日期至", | "Estimated Arrival To": "預計送貨日期至", | ||||
| "Status": "來貨狀態", | "Status": "來貨狀態", | ||||
| "Order Date From": "訂單日期", | "Order Date From": "訂單日期", | ||||
| "Workbench Batch Release": "工作台批量放單", | |||||
| "do workbench": "新版成品出倉", | |||||
| "Do Workbench": "新版成品出倉", | |||||
| "Delivery Order Code": "送貨訂單編號", | "Delivery Order Code": "送貨訂單編號", | ||||
| "Truck Lance Code": "車線號碼", | "Truck Lance Code": "車線號碼", | ||||
| "Select Remark": "選擇備註", | "Select Remark": "選擇備註", | ||||
| @@ -166,6 +166,7 @@ | |||||
| "Job Order Code": "工單編號", | "Job Order Code": "工單編號", | ||||
| "View Details": "查看詳情", | "View Details": "查看詳情", | ||||
| "Skip": "跳過", | "Skip": "跳過", | ||||
| "packaging": "提料中", | |||||
| "Handler": "提料員", | "Handler": "提料員", | ||||
| "RELEASED": "已放單", | "RELEASED": "已放單", | ||||
| "Released": "已放單", | "Released": "已放單", | ||||
| @@ -274,6 +274,7 @@ | |||||
| "Selected items will join above created group": "已選擇的貨品將加入以上建立的分組", | "Selected items will join above created group": "已選擇的貨品將加入以上建立的分組", | ||||
| "Issue":"問題", | "Issue":"問題", | ||||
| "Pick Execution Issue Form":"提料問題表單", | "Pick Execution Issue Form":"提料問題表單", | ||||
| "Lot line is unavailable":"掃描批次不可用", | |||||
| "This form is for reporting issues only. You must report either missing items or bad items.":"此表單僅用於報告問題。您必須報告缺少的貨品或不良貨品。", | "This form is for reporting issues only. You must report either missing items or bad items.":"此表單僅用於報告問題。您必須報告缺少的貨品或不良貨品。", | ||||
| "Bad item Qty":"不良貨品數量", | "Bad item Qty":"不良貨品數量", | ||||
| "Missing item Qty":"貨品遺失數量", | "Missing item Qty":"貨品遺失數量", | ||||
| @@ -449,11 +450,15 @@ | |||||
| "No entries available": "該樓層未有需處理訂單", | "No entries available": "該樓層未有需處理訂單", | ||||
| "Today": "是日", | "Today": "是日", | ||||
| "Tomorrow": "翌日", | "Tomorrow": "翌日", | ||||
| "packaging": "提料中", | |||||
| "No Stock Available": "沒有庫存可用", | "No Stock Available": "沒有庫存可用", | ||||
| "This lot is not available, please scan another lot.": "此批號不可用,請掃描其他批號。", | "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": "後日", | "Day After Tomorrow": "後日", | ||||
| "Lot line is unavailable": "掃描批次不可用", | |||||
| "Select Date": "請選擇日期", | "Select Date": "請選擇日期", | ||||
| "Suggest Lot No.": "推薦批號", | |||||
| "Search by Shop": "搜尋商店", | "Search by Shop": "搜尋商店", | ||||
| "Search by Truck": "搜尋貨車", | "Search by Truck": "搜尋貨車", | ||||
| "Print DN & Label": "列印提料單和送貨單標籤", | "Print DN & Label": "列印提料單和送貨單標籤", | ||||
| @@ -497,5 +502,7 @@ | |||||
| "SuggestedPickLot qty is invalid: {{qty}}": "建議揀貨數量無效:{{qty}}。", | "SuggestedPickLot qty is invalid: {{qty}}": "建議揀貨數量無效:{{qty}}。", | ||||
| "Reject switch lot: available {{available}} less than required {{required}}": "此批次貨品已被其他送貨單留起,請掃描其他批次。", | "Reject switch lot: available {{available}} less than required {{required}}": "此批次貨品已被其他送貨單留起,請掃描其他批次。", | ||||
| "Reject switch lot: picked {{picked}} already greater or equal required {{required}}": "換批被拒:已揀數量({{picked}})已達或超過建議量({{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": "此批號尚無出庫行,無法提交。" | |||||
| } | } | ||||