From 1bbaa24c0051424d8d76e2706eca377617733d59 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Thu, 14 May 2026 15:05:03 +0800 Subject: [PATCH] new supplier isEtra new do chart do saerch batch release button put down not lot requied qty show 0 fix --- src/app/(main)/chart/delivery/page.tsx | 87 +++- src/app/(main)/m18Syn/page.tsx | 4 +- .../settings/deliveryOrderFloor/page.tsx | 21 + src/app/api/chart/client.ts | 9 +- src/app/api/do/actions.tsx | 12 +- src/app/api/doworkbench/actions.ts | 31 +- src/app/api/pickOrder/actions.ts | 4 + .../api/settings/deliveryOrderFloor/client.ts | 75 +++ .../settings/deliveryOrderFloor/constants.ts | 5 + .../DeliveryOrderFloorSettings.tsx | 482 ++++++++++++++++++ src/components/DoSearch/DoSearch.tsx | 449 ++++++++-------- .../DoWorkbench/DoWorkbenchPickShell.tsx | 26 +- .../DoWorkbench/DoWorkbenchTabs.tsx | 166 +++++- .../GoodPickExecutionWorkbenchRecord.tsx | 26 +- .../DoWorkbench/WorkbenchFloorLanePanel.tsx | 269 ++++++++-- .../WorkbenchGoodPickExecutionDetail.tsx | 183 +++---- .../ReleasedDoPickOrderSelectModal.tsx | 46 +- .../JoWorkbench/newJobPickExecution.tsx | 113 +--- .../NavigationContent/NavigationContent.tsx | 6 + src/i18n/en/common.json | 1 + src/i18n/en/deliveryOrderFloor.json | 33 ++ src/i18n/zh/common.json | 1 + src/i18n/zh/deliveryOrderFloor.json | 33 ++ src/i18n/zh/pickOrder.json | 6 +- 24 files changed, 1543 insertions(+), 545 deletions(-) create mode 100644 src/app/(main)/settings/deliveryOrderFloor/page.tsx create mode 100644 src/app/api/settings/deliveryOrderFloor/client.ts create mode 100644 src/app/api/settings/deliveryOrderFloor/constants.ts create mode 100644 src/components/DeliveryOrderFloorSettings/DeliveryOrderFloorSettings.tsx create mode 100644 src/i18n/en/deliveryOrderFloor.json create mode 100644 src/i18n/zh/deliveryOrderFloor.json diff --git a/src/app/(main)/chart/delivery/page.tsx b/src/app/(main)/chart/delivery/page.tsx index fd0f8b1..c8ba2a2 100644 --- a/src/app/(main)/chart/delivery/page.tsx +++ b/src/app/(main)/chart/delivery/page.tsx @@ -21,6 +21,7 @@ import { fetchTopDeliveryItemsItemOptions, fetchStaffDeliveryPerformance, fetchStaffDeliveryPerformanceHandlers, + type StaffDeliveryPerformanceStoreFilter, type StaffOption, type TopDeliveryItemOption, } from "@/app/api/chart/client"; @@ -31,16 +32,38 @@ import SafeApexCharts from "@/components/charts/SafeApexCharts"; const PAGE_TITLE = "發貨與配送"; +const STAFF_PERF_STORE_FILTER_OPTIONS: { + value: StaffDeliveryPerformanceStoreFilter; + label: string; +}[] = [ + { value: "all", label: "全部" }, + { value: "2/F", label: "2/F" }, + { value: "4/F", label: "4/F" }, + { value: "null_only", label: "車線-X" }, +]; + type Criteria = { delivery: { rangeDays: number }; topItems: { rangeDays: number; limit: number }; - staffPerf: { rangeDays: number }; + staffPerf: { + rangeDays: number; + startDate: string; + endDate: string; + storeFilter: StaffDeliveryPerformanceStoreFilter; + }; }; +const defaultStaffPerfDateRange = toDateRange(DEFAULT_RANGE_DAYS); + const defaultCriteria: Criteria = { delivery: { rangeDays: DEFAULT_RANGE_DAYS }, topItems: { rangeDays: DEFAULT_RANGE_DAYS, limit: 10 }, - staffPerf: { rangeDays: DEFAULT_RANGE_DAYS }, + staffPerf: { + rangeDays: DEFAULT_RANGE_DAYS, + startDate: defaultStaffPerfDateRange.startDate, + endDate: defaultStaffPerfDateRange.endDate, + storeFilter: "all", + }, }; export default function DeliveryChartPage() { @@ -101,10 +124,20 @@ export default function DeliveryChartPage() { }, [criteria.topItems, topItemsSelected, setChartLoading]); React.useEffect(() => { - const { startDate: s, endDate: e } = toDateRange(criteria.staffPerf.rangeDays); + const s = criteria.staffPerf.startDate; + const e = criteria.staffPerf.endDate; + if (!s || !e) { + setChartData((prev) => ({ ...prev, staffPerf: [] })); + return; + } + if (s > e) { + setError("員工發貨績效的起始日期不能晚於結束日期"); + setChartData((prev) => ({ ...prev, staffPerf: [] })); + return; + } const staffNos = staffSelected.length > 0 ? staffSelected.map((o) => o.staffNo) : undefined; setChartLoading("staffPerf", true); - fetchStaffDeliveryPerformance(s, e, staffNos) + fetchStaffDeliveryPerformance(s, e, staffNos, criteria.staffPerf.storeFilter) .then((data) => setChartData((prev) => ({ ...prev, @@ -270,8 +303,52 @@ export default function DeliveryChartPage() { <> updateCriteria("staffPerf", (c) => ({ ...c, rangeDays: v }))} + onChange={(v) => + updateCriteria("staffPerf", (c) => { + const { startDate, endDate } = toDateRange(v); + return { ...c, rangeDays: v, startDate, endDate }; + }) + } + /> + + updateCriteria("staffPerf", (c) => ({ ...c, startDate: e.target.value })) + } + InputLabelProps={{ shrink: true }} /> + + updateCriteria("staffPerf", (c) => ({ ...c, endDate: e.target.value })) + } + InputLabelProps={{ shrink: true }} + /> + + 倉別 + + { if (m18DoExtraInFlightRef.current) return; const raw = m18DoExtraCode.trim(); @@ -339,7 +339,7 @@ export default function M18SynPage() { -
+
+ + {t("title")} + + + + ); +} diff --git a/src/app/api/chart/client.ts b/src/app/api/chart/client.ts index 39287bd..defade0 100644 --- a/src/app/api/chart/client.ts +++ b/src/app/api/chart/client.ts @@ -574,15 +574,22 @@ export async function fetchPlannedOutputByDateAndItem( })); } +/** Warehouse / lane filter for staff delivery performance chart (delivery_order_pick_order.store_id). */ +export type StaffDeliveryPerformanceStoreFilter = "all" | "2/F" | "4/F" | "null_only"; + export async function fetchStaffDeliveryPerformance( startDate?: string, endDate?: string, - staffNos?: string[] + staffNos?: string[], + storeFilter: StaffDeliveryPerformanceStoreFilter = "all" ): Promise { const p = new URLSearchParams(); if (startDate) p.set("startDate", startDate); if (endDate) p.set("endDate", endDate); (staffNos ?? []).forEach((no) => p.append("staffNo", no)); + if (storeFilter === "null_only") p.set("storeIdNull", "true"); + else if (storeFilter === "2/F") p.set("storeId", "2/F"); + else if (storeFilter === "4/F") p.set("storeId", "4/F"); const q = p.toString(); const res = await clientAuthFetch( q ? `${BASE}/staff-delivery-performance?${q}` : `${BASE}/staff-delivery-performance` diff --git a/src/app/api/do/actions.tsx b/src/app/api/do/actions.tsx index 7468979..56cc069 100644 --- a/src/app/api/do/actions.tsx +++ b/src/app/api/do/actions.tsx @@ -26,7 +26,7 @@ export interface DoDetail { completeDate: string; status: string; /** 加單 DO */ - isEtra?: boolean; + isExtra?: boolean; deliveryOrderLines: DoDetailLine[]; } @@ -51,7 +51,7 @@ export interface DoSearchAll { supplierName: string; shopName: string; shopAddress?: string; - isEtra?: boolean; + isExtra?: boolean; } export interface DoSearchLiteResponse { records: DoSearchAll[]; @@ -380,7 +380,7 @@ export async function fetchDoSearch( /** 後端:All/null 為全部;2F/4F 依供應商白名單篩選 */ floor?: string | null, /** null:不篩;true/false:只顯示加單或非加單 DO */ - isEtra?: boolean | null, + isExtra?: boolean | null, ): Promise { // 构建请求体 const requestBody: any = { @@ -392,7 +392,7 @@ export async function fetchDoSearch( pageNum: pageNum || 1, pageSize: pageSize || 10, floor: floor && floor !== "All" ? floor : null, - ...(isEtra !== undefined && isEtra !== null ? { isEtra } : {}), + ...(isExtra !== undefined && isExtra !== null ? { isExtra } : {}), }; // 如果日期不为空,转换为 LocalDateTime 格式 @@ -632,7 +632,7 @@ export async function fetchAllDoSearch( estArrStartDate: string, truckLanceCode?: string, floor?: string | null, - isEtra?: boolean | null, + isExtra?: boolean | null, ): Promise { // 使用一个很大的 pageSize 来获取所有匹配的记录 const requestBody: any = { @@ -644,7 +644,7 @@ export async function fetchAllDoSearch( pageNum: 1, pageSize: 10000, // 使用一个很大的值来获取所有记录 floor: floor && floor !== "All" ? floor : null, - ...(isEtra !== undefined && isEtra !== null ? { isEtra } : {}), + ...(isExtra !== undefined && isExtra !== null ? { isExtra } : {}), }; if (estArrStartDate) { diff --git a/src/app/api/doworkbench/actions.ts b/src/app/api/doworkbench/actions.ts index 7f40677..ab61c4d 100644 --- a/src/app/api/doworkbench/actions.ts +++ b/src/app/api/doworkbench/actions.ts @@ -4,6 +4,7 @@ import { revalidateTag } from "next/cache"; import { BASE_API_URL } from "@/config/api"; import { serverFetchJson } from "@/app/utils/fetchUtil"; import type { + LaneBtn, PostPickOrderResponse, ReleasedDoPickOrderListItem, StoreLaneSummary, @@ -215,16 +216,38 @@ export async function fetchWorkbenchStoreLaneSummary( }); } +/** All Etra tickets (`releaseType=isExtra`) for a calendar day, grouped by shop → lanes. */ +export type WorkbenchEtraShopLaneGroup = { + shopCode: string | null; + shopName: string | null; + lanes: LaneBtn[]; +}; + +export async function fetchWorkbenchEtraLaneSummary( + requiredDate?: string +): Promise { + const dateToUse = requiredDate || dayjs().format("YYYY-MM-DD"); + const url = `${BASE_API_URL}/doPickOrder/workbench/summary-is-etra?requiredDate=${encodeURIComponent(dateToUse)}`; + const data = await serverFetchJson(url, { + method: "GET", + cache: "no-store", + next: { revalidate: 0 }, + }); + return Array.isArray(data) ? data : []; +} + /** Past-date `delivery_order_pick_order` tickets (same shape as `/doPickOrder/released`). */ export async function fetchWorkbenchReleasedDoPickOrdersForSelection( shopName?: string, storeId?: string, - truck?: string + truck?: string, + releaseType?: string ): Promise { 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()); + if (releaseType?.trim()) params.append("releaseType", releaseType.trim()); const query = params.toString(); const url = `${BASE_API_URL}/doPickOrder/workbench/released${query ? `?${query}` : ""}`; const response = await serverFetchJson(url, { method: "GET" }); @@ -236,13 +259,15 @@ export async function fetchWorkbenchReleasedDoPickOrdersForSelectionToday( shopName?: string, storeId?: string, truck?: string, - requiredDeliveryDate?: string + requiredDeliveryDate?: string, + releaseType?: string ): Promise { 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()); if (requiredDeliveryDate?.trim()) params.append("requiredDate", requiredDeliveryDate.trim()); + if (releaseType?.trim()) params.append("releaseType", releaseType.trim()); const query = params.toString(); const url = `${BASE_API_URL}/doPickOrder/workbench/released-today${query ? `?${query}` : ""}`; const response = await serverFetchJson(url, { method: "GET" }); @@ -258,6 +283,8 @@ export async function assignWorkbenchByLane(data: { truckDepartureTime?: string; loadingSequence?: number | null; requiredDate?: string; + /** Backend normalizes to isExtra / isExtra filter on `delivery_order_pick_order.releaseType` */ + releaseType?: string; }): Promise { const res = await serverFetchJson( `${BASE_API_URL}/doPickOrder/workbench/assign-by-lane`, diff --git a/src/app/api/pickOrder/actions.ts b/src/app/api/pickOrder/actions.ts index 17f0401..4e8f6f9 100644 --- a/src/app/api/pickOrder/actions.ts +++ b/src/app/api/pickOrder/actions.ts @@ -464,6 +464,10 @@ export interface LaneBtn { unassigned: number; total: number; handlerName?: string | null; + /** Workbench Etra: dop storeId for assign scope */ + storeId?: string | null; + /** ISO local time string for workbench assign-by-lane (Etra summary) */ + truckDepartureTime?: string | null; } export interface QrPickBatchSubmitRequest { diff --git a/src/app/api/settings/deliveryOrderFloor/client.ts b/src/app/api/settings/deliveryOrderFloor/client.ts new file mode 100644 index 0000000..8f0367a --- /dev/null +++ b/src/app/api/settings/deliveryOrderFloor/client.ts @@ -0,0 +1,75 @@ +"use client"; + +import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; +import { NEXT_PUBLIC_API_URL } from "@/config/api"; +import { + SETTING_DO_FLOOR_SUPPLIERS_2F, + SETTING_DO_FLOOR_SUPPLIERS_4F, +} from "./constants"; + +const base = NEXT_PUBLIC_API_URL; + +export type ShopComboRow = { + id: number; + code: string; + name: string; + value: number; + label: string; +}; + +export type SettingsRow = { + id: number; + name: string; + value: string; + category?: string | null; + type?: string | null; +}; + +async function parseJson(res: Response): Promise { + if (!res.ok) { + const t = await res.text().catch(() => ""); + throw new Error(t || `HTTP ${res.status}`); + } + return res.json() as Promise; +} + +/** 供應商列表:`GET /shop/combo/supplier` */ +export async function fetchSupplierComboClient(): Promise { + const res = await clientAuthFetch(`${base}/shop/combo/supplier`, { method: "GET" }); + return parseJson(res); +} + +/** 店鋪列表:`GET /shop/combo/shop` */ +export async function fetchShopComboClient(): Promise { + const res = await clientAuthFetch(`${base}/shop/combo/shop`, { method: "GET" }); + return parseJson(res); +} + +export async function fetchAllSettingsClient(): Promise { + const res = await clientAuthFetch(`${base}/settings`, { method: "GET" }); + return parseJson(res); +} + +export async function fetchDoFloorSettingsClient(): Promise<{ + suppliers2F: string; + suppliers4F: string; +}> { + const all = await fetchAllSettingsClient(); + const get = (name: string) => all.find((s) => s.name === name)?.value ?? ""; + return { + suppliers2F: get(SETTING_DO_FLOOR_SUPPLIERS_2F), + suppliers4F: get(SETTING_DO_FLOOR_SUPPLIERS_4F), + }; +} + +export async function postSettingClient(name: string, value: string): Promise { + const res = await clientAuthFetch(`${base}/settings/${encodeURIComponent(name)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ value }), + }); + if (!res.ok) { + const t = await res.text().catch(() => ""); + throw new Error(t || `Failed to save setting: ${res.status}`); + } +} \ No newline at end of file diff --git a/src/app/api/settings/deliveryOrderFloor/constants.ts b/src/app/api/settings/deliveryOrderFloor/constants.ts new file mode 100644 index 0000000..25ce37e --- /dev/null +++ b/src/app/api/settings/deliveryOrderFloor/constants.ts @@ -0,0 +1,5 @@ +/** `settings.name`:逗號分隔 supplier **code**(須與後端讀取邏輯一致)。 */ +export const SETTING_DO_FLOOR_SUPPLIERS_2F = "DO.floor.suppliers.2F"; +export const SETTING_DO_FLOOR_SUPPLIERS_4F = "DO.floor.suppliers.4F"; + +export const SETTING_DO_FLOOR_CATEGORY = "DO_FLOOR"; \ No newline at end of file diff --git a/src/components/DeliveryOrderFloorSettings/DeliveryOrderFloorSettings.tsx b/src/components/DeliveryOrderFloorSettings/DeliveryOrderFloorSettings.tsx new file mode 100644 index 0000000..2ffb8f9 --- /dev/null +++ b/src/components/DeliveryOrderFloorSettings/DeliveryOrderFloorSettings.tsx @@ -0,0 +1,482 @@ +"use client"; + +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import EditOutlined from "@mui/icons-material/EditOutlined"; +import DeleteOutline from "@mui/icons-material/DeleteOutline"; +import Add from "@mui/icons-material/Add"; +import { + Alert, + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + IconButton, + InputLabel, + MenuItem, + Select, + type SelectChangeEvent, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, + Typography, +} from "@mui/material"; +import { + fetchDoFloorSettingsClient, + fetchSupplierComboClient, + postSettingClient, + type ShopComboRow, +} from "@/app/api/settings/deliveryOrderFloor/client"; +import { + SETTING_DO_FLOOR_SUPPLIERS_2F, + SETTING_DO_FLOOR_SUPPLIERS_4F, +} from "@/app/api/settings/deliveryOrderFloor/constants"; + +function normalizeCodesCsv(raw: string): string { + return raw + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + .join(","); +} + +/** 顯示為 `[XXX, YYY]`;無代碼時為 `[]` */ +function formatBracketList(codesCsv: string): string { + const n = normalizeCodesCsv(codesCsv); + if (!n) return "[]"; + return `[${n.split(",").join(", ")}]`; +} + +type EditFloor = "2F" | "4F"; + +type FloorRow = { code: string; name: string }; + +function findSupplierRow(combo: ShopComboRow[], raw: string): ShopComboRow | undefined { + const t = raw.trim(); + if (!t) return undefined; + const lower = t.toLowerCase(); + const exact = combo.find((r) => r.code?.trim() === t); + if (exact) return exact; + return combo.find((r) => (r.code?.trim().toLowerCase() ?? "") === lower); +} + +function csvToFloorRows(csv: string, combo: ShopComboRow[]): FloorRow[] { + const n = normalizeCodesCsv(csv); + if (!n) return []; + return n.split(",").map((code) => { + const hit = findSupplierRow(combo, code); + const canonical = hit?.code?.trim() || code; + return { code: canonical, name: hit?.name?.trim() || "" }; + }); +} + +function floorRowsToCsv(rows: FloorRow[]): string { + return rows.map((r) => r.code.trim()).filter(Boolean).join(","); +} + +const DeliveryOrderFloorSettings: React.FC = () => { + const { t } = useTranslation("deliveryOrderFloor"); + const saveInFlightRef = useRef(false); + const addInFlightRef = useRef(false); + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const [codes2F, setCodes2F] = useState(""); + const [codes4F, setCodes4F] = useState(""); + + const [editOpen, setEditOpen] = useState(false); + const [editFloor, setEditFloor] = useState("2F"); + const [dialogSaving, setDialogSaving] = useState(false); + const [comboLoading, setComboLoading] = useState(false); + const [supplierCombo, setSupplierCombo] = useState(null); + const [draftRows2F, setDraftRows2F] = useState([]); + const [draftRows4F, setDraftRows4F] = useState([]); + + const [addOpen, setAddOpen] = useState(false); + const [addCodeInput, setAddCodeInput] = useState(""); + const [addError, setAddError] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + setSuccess(null); + try { + const floor = await fetchDoFloorSettingsClient(); + setCodes2F(floor.suppliers2F); + setCodes4F(floor.suppliers4F); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const display2F = useMemo(() => formatBracketList(codes2F), [codes2F]); + const display4F = useMemo(() => formatBracketList(codes4F), [codes4F]); + + const currentDraftRows = editFloor === "2F" ? draftRows2F : draftRows4F; + const setCurrentDraftRows = editFloor === "2F" ? setDraftRows2F : setDraftRows4F; + + const openEdit = async (floor: EditFloor) => { + setEditFloor(floor); + setEditOpen(true); + setError(null); + setSuccess(null); + setAddOpen(false); + setAddCodeInput(""); + setAddError(null); + setComboLoading(true); + try { + const combo = await fetchSupplierComboClient(); + setSupplierCombo(combo); + setDraftRows2F(csvToFloorRows(codes2F, combo)); + setDraftRows4F(csvToFloorRows(codes4F, combo)); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)); + setDraftRows2F([]); + setDraftRows4F([]); + setSupplierCombo(null); + } finally { + setComboLoading(false); + } + }; + + const closeEdit = () => { + if (dialogSaving) return; + setEditOpen(false); + setAddOpen(false); + setAddCodeInput(""); + setAddError(null); + }; + + const saveCurrentFloor = async () => { + if (saveInFlightRef.current) return; + saveInFlightRef.current = true; + setDialogSaving(true); + setError(null); + setSuccess(null); + try { + const rows = editFloor === "2F" ? draftRows2F : draftRows4F; + const normalized = normalizeCodesCsv(floorRowsToCsv(rows)); + const key = + editFloor === "2F" ? SETTING_DO_FLOOR_SUPPLIERS_2F : SETTING_DO_FLOOR_SUPPLIERS_4F; + await postSettingClient(key, normalized); + if (editFloor === "2F") setCodes2F(normalized); + else setCodes4F(normalized); + setSuccess(t("Saved")); + setEditOpen(false); + setAddOpen(false); + setAddCodeInput(""); + setAddError(null); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setDialogSaving(false); + saveInFlightRef.current = false; + } + }; + + const onFloorSelectChange = (e: SelectChangeEvent) => { + setEditFloor(e.target.value as EditFloor); + setAddOpen(false); + setAddCodeInput(""); + setAddError(null); + }; + + const removeRow = (code: string) => { + const next = currentDraftRows.filter((r) => r.code !== code); + setCurrentDraftRows(next); + }; + + const openAddMapping = () => { + if (!supplierCombo?.length) { + setError(t("Supplier list unavailable")); + return; + } + setAddCodeInput(""); + setAddError(null); + setAddOpen(true); + }; + + const closeAdd = () => { + if (addInFlightRef.current) return; + setAddOpen(false); + setAddCodeInput(""); + setAddError(null); + }; + + const confirmAddMapping = () => { + if (addInFlightRef.current) return; + addInFlightRef.current = true; + setAddError(null); + try { + const raw = addCodeInput.trim(); + if (!raw) { + setAddError(t("Enter supplier code")); + return; + } + const hit = findSupplierRow(supplierCombo ?? [], raw); + if (!hit?.code) { + setAddError(t("Supplier code not found")); + return; + } + const canonical = hit.code.trim(); + const otherRows = editFloor === "2F" ? draftRows4F : draftRows2F; + if (currentDraftRows.some((r) => r.code.toLowerCase() === canonical.toLowerCase())) { + setAddError(t("Duplicate in floor")); + return; + } + if (otherRows.some((r) => r.code.toLowerCase() === canonical.toLowerCase())) { + setAddError(t("Duplicate in other floor")); + return; + } + const name = hit.name?.trim() || ""; + setCurrentDraftRows([...currentDraftRows, { code: canonical, name }]); + setAddOpen(false); + setAddCodeInput(""); + } finally { + addInFlightRef.current = false; + } + }; + + if (loading) { + return ( + + + + ); + } + + return ( + + + {t("Intro")} + + + {error ? {error} : null} + {success ? {success} : null} + + + + + {t("2F supplier")} + + + {display2F} + + void openEdit("2F")} + size="small" + > + + + + + + + {t("4F supplier")} + + + {display4F} + + void openEdit("4F")} + size="small" + > + + + + + + + {t("Edit dialog title")} + + + + + {t("Floor label")} + + + + + + + + {comboLoading ? ( + + + + ) : ( + + + + + {t("Col code")} + {t("Col name")} + {t("Col type")} + + {t("Col actions")} + + + + + {currentDraftRows.length === 0 ? ( + + + + {t("Empty floor list")} + + + + ) : ( + currentDraftRows.map((row) => ( + + {row.code} + + {row.name || ( + + {t("Unknown supplier name")} + + )} + + {editFloor} + + removeRow(row.code)} + disabled={dialogSaving} + > + + + + + )) + )} + +
+
+ )} +
+
+ + + +
+ + + {t("Add mapping title")} + + + { + setAddCodeInput(e.target.value); + setAddError(null); + }} + placeholder={t("Add code placeholder")} + /> + {addError ? {addError} : null} + + + + + + + +
+ ); +}; + +export default DeliveryOrderFloorSettings; diff --git a/src/components/DoSearch/DoSearch.tsx b/src/components/DoSearch/DoSearch.tsx index d279dba..aec1273 100644 --- a/src/components/DoSearch/DoSearch.tsx +++ b/src/components/DoSearch/DoSearch.tsx @@ -31,7 +31,7 @@ import { SubmitHandler, useForm, } from "react-hook-form"; -import { Box, Button, Paper, Stack, Typography, TablePagination } from "@mui/material"; +import { Box, Button, Paper, Stack, Tab, Tabs, TablePagination, Typography } from "@mui/material"; import StyledDataGrid from "../StyledDataGrid"; import { GridRowSelectionModel } from "@mui/x-data-grid"; import Swal from "sweetalert2"; @@ -43,8 +43,10 @@ type Props = { searchQuery?: Record; onDeliveryOrderSearch?: () => void; }; -type SearchBoxInputs = Record<"code" | "status" | "estimatedArrivalDate" | "orderDate" | "supplierName" | "shopName" | "deliveryOrderLines" | "truckLanceCode" | "floor" | "codeTo" | "statusTo" | "estimatedArrivalDateTo" | "orderDateTo" | "supplierNameTo" | "shopNameTo" | "deliveryOrderLinesTo" | "truckLanceCodeTo" | "floorTo", 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; +type DoSearchTab = "2F" | "4F" | "TRUCK_X" | "ETRA"; +type TabFilter = { floor: "2F" | "4F" | null; isExtra: boolean; forceTruckKeyword?: string }; // put all this into a new component // ConsoDoForm @@ -83,6 +85,8 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea const [searchAllDos, setSearchAllDos] = useState([]); const [totalCount, setTotalCount] = useState(0); const [isWorkbench, setIsWorkbench] = useState(false); + const [activeTab, setActiveTab] = useState("2F"); + const [searchBoxResetKey, setSearchBoxResetKey] = useState(0); const [pagingController, setPagingController] = useState({ pageNum: 1, pageSize: 10, @@ -96,8 +100,7 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea supplierName: "", shopName: "", deliveryOrderLines: "", - truckLanceCode: "", // 添加这个字段 - floor: "All", + truckLanceCode: "", codeTo: "", statusTo: "", estimatedArrivalDateTo: "", @@ -106,8 +109,28 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea shopNameTo: "", deliveryOrderLinesTo: "", truckLanceCodeTo: "", - floorTo: "", }); + const createClearedSearchParams = useCallback( + (source: SearchBoxInputs): SearchBoxInputs => ({ + code: "", + status: "", + estimatedArrivalDate: source.estimatedArrivalDate || "", + orderDate: "", + supplierName: "", + shopName: "", + deliveryOrderLines: "", + truckLanceCode: "", + codeTo: "", + statusTo: "", + estimatedArrivalDateTo: source.estimatedArrivalDateTo || "", + orderDateTo: "", + supplierNameTo: "", + shopNameTo: "", + deliveryOrderLinesTo: "", + truckLanceCodeTo: "", + }), + [], + ); const [hasSearched, setHasSearched] = useState(false); const [hasResults, setHasResults] = useState(false); @@ -155,7 +178,6 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea currentSearchParams.status, currentSearchParams.estimatedArrivalDate, currentSearchParams.truckLanceCode, - currentSearchParams.floor, ]); @@ -164,19 +186,11 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea { 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("Floor"), - paramName: "floor", - type: "select-labelled", - options: [ - { label: "2F", value: "2F" }, - { label: "4F", value: "4F" }, - ], - }, { label: t("Estimated Arrival"), paramName: "estimatedArrivalDate", type: "date", + preFilledValue: currentSearchParams.estimatedArrivalDate || "", }, { label: t("Status"), @@ -189,7 +203,7 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea ] } ], - [t], + [t, currentSearchParams.estimatedArrivalDate], ); const onReset = useCallback(async () => { @@ -316,67 +330,103 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea [], ); - //SEARCH FUNCTION -const handleSearch = useCallback(async (query: SearchBoxInputs) => { - try { - if (isTruckLaneSearchMissingEta(query.truckLanceCode ?? "", query.estimatedArrivalDate ?? "")) { - await Swal.fire({ - icon: "warning", - title: t("Truck lane search requires date title"), - text: t("Truck lane search requires date message"), - confirmButtonText: t("Confirm"), - }); - return; + const resolveTabFilter = useCallback((tab: DoSearchTab): TabFilter => { + switch (tab) { + case "2F": + return { floor: "2F", isExtra: false }; + case "4F": + return { floor: "4F", isExtra: false }; + case "TRUCK_X": + return { floor: null, isExtra: false, forceTruckKeyword: "x" }; + case "ETRA": + default: + return { floor: null, isExtra: true }; } + }, []); - setCurrentSearchParams(query); + const performSearch = useCallback( + async ( + query: SearchBoxInputs, + pageNum: number, + pageSize: number, + options?: { resetExcludedRows?: boolean; markSearched?: boolean; tabOverride?: DoSearchTab }, + ) => { + const effectiveTab = options?.tabOverride ?? activeTab; + const tabFilter = resolveTabFilter(effectiveTab); + const tabTruckKeyword = tabFilter.forceTruckKeyword ?? ""; + const effectiveTruckLanceCode = tabTruckKeyword || query.truckLanceCode || ""; + const shouldValidateTruckLane = effectiveTab !== "TRUCK_X"; - 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; - } + if ( + shouldValidateTruckLane && + isTruckLaneSearchMissingEta(effectiveTruckLanceCode, query.estimatedArrivalDate ?? "") + ) { + await Swal.fire({ + icon: "warning", + title: t("Truck lane search requires date title"), + text: t("Truck lane search requires date message"), + confirmButtonText: t("Confirm"), + }); + return false; + } - const floorParam = query.floor === "All" || !query.floor ? null : query.floor; - - // 调用新的 API,传入分页参数和 truckLanceCode - const response = await fetchDoSearch( - query.code || "", - query.shopName || "", - status, - "", // orderStartDate - 不再使用 - "", // orderEndDate - 不再使用 - estArrStartDate, - "", // estArrEndDate - 不再使用 - pagingController.pageNum, // 传入当前页码 - pagingController.pageSize, // 传入每页大小 - query.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, t]); + let estArrStartDate = query.estimatedArrivalDate; + const time = "T00:00:00"; + if (estArrStartDate !== "") { + estArrStartDate = `${query.estimatedArrivalDate}${time}`; + } + + const status = query.status === "All" ? "" : query.status; + + const response = await fetchDoSearch( + query.code || "", + query.shopName || "", + status, + "", + "", + estArrStartDate, + "", + pageNum, + pageSize, + effectiveTruckLanceCode, + tabFilter.floor, + tabFilter.isExtra, + ); + + setSearchAllDos(response.records); + setTotalCount(response.total); + if (options?.markSearched ?? false) { + setHasSearched(true); + setHasResults(response.records.length > 0); + } + if (options?.resetExcludedRows ?? false) { + setExcludedRowIds([]); + } + return true; + }, + [activeTab, resolveTabFilter, t], + ); + + //SEARCH FUNCTION + const handleSearch = useCallback( + async (query: SearchBoxInputs) => { + try { + setCurrentSearchParams(query); + await performSearch(query, pagingController.pageNum, pagingController.pageSize, { + resetExcludedRows: true, + markSearched: true, + }); + } catch (error) { + console.error("Error: ", error); + setSearchAllDos([]); + setTotalCount(0); + setHasSearched(true); + setHasResults(false); + setExcludedRowIds([]); + } + }, + [pagingController.pageNum, pagingController.pageSize, performSearch], + ); useEffect(() => { if (typeof window !== 'undefined') { @@ -425,147 +475,53 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { }, [handleSearch, searchTimeout]); // 分页变化时重新搜索 - const handlePageChange = useCallback((event: unknown, newPage: number) => { - const newPagingController = { - ...pagingController, - pageNum: newPage + 1, - }; - setPagingController(newPagingController); - // 如果已经搜索过,重新搜索 - if (hasSearched && currentSearchParams) { - // 使用新的分页参数重新搜索 - const searchWithNewPage = async () => { - try { - if ( - isTruckLaneSearchMissingEta( - currentSearchParams.truckLanceCode ?? "", - currentSearchParams.estimatedArrivalDate ?? "", - ) - ) { - await Swal.fire({ - icon: "warning", - title: t("Truck lane search requires date title"), - text: t("Truck lane search requires date message"), - confirmButtonText: t("Confirm"), - }); - return; - } - 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 floorParam = - currentSearchParams.floor === "All" || !currentSearchParams.floor - ? null - : currentSearchParams.floor; - - 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); - } + const handlePageChange = useCallback( + (event: unknown, newPage: number) => { + const newPagingController = { + ...pagingController, + pageNum: newPage + 1, }; - searchWithNewPage(); - } - }, [pagingController, hasSearched, currentSearchParams, t]); - - const handlePageSizeChange = useCallback((event: React.ChangeEvent) => { - const newPageSize = parseInt(event.target.value, 10); - const newPagingController = { - pageNum: 1, // 改变每页大小时重置到第一页 - pageSize: newPageSize, - }; - setPagingController(newPagingController); - // 如果已经搜索过,重新搜索 - if (hasSearched && currentSearchParams) { - const searchWithNewPageSize = async () => { - try { - if ( - isTruckLaneSearchMissingEta( - currentSearchParams.truckLanceCode ?? "", - currentSearchParams.estimatedArrivalDate ?? "", - ) - ) { - await Swal.fire({ - icon: "warning", - title: t("Truck lane search requires date title"), - text: t("Truck lane search requires date message"), - confirmButtonText: t("Confirm"), - }); - return; - } - 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 floorParam = - currentSearchParams.floor === "All" || !currentSearchParams.floor - ? null - : currentSearchParams.floor; - - const response = await fetchDoSearch( - currentSearchParams.code || "", - currentSearchParams.shopName || "", - status, - "", - "", - estArrStartDate, - "", - 1, // 重置到第一页 - newPageSize, - currentSearchParams.truckLanceCode || "", - ); - - setSearchAllDos(response.records); - setTotalCount(response.total); - } catch (error) { + setPagingController(newPagingController); + if (hasSearched && currentSearchParams) { + void performSearch( + currentSearchParams, + newPagingController.pageNum, + newPagingController.pageSize, + ).catch((error) => { console.error("Error: ", error); - } + }); + } + }, + [pagingController, hasSearched, currentSearchParams, performSearch], + ); + + const handlePageSizeChange = useCallback( + (event: React.ChangeEvent) => { + const newPageSize = parseInt(event.target.value, 10); + const newPagingController = { + pageNum: 1, + pageSize: newPageSize, }; - searchWithNewPageSize(); - } - }, [hasSearched, currentSearchParams, t]); + setPagingController(newPagingController); + if (hasSearched && currentSearchParams) { + void performSearch(currentSearchParams, 1, newPageSize).catch((error) => { + console.error("Error: ", error); + }); + } + }, + [hasSearched, currentSearchParams, performSearch], + ); const handleBatchRelease = useCallback(async (isWorkbench: boolean) => { try { + const tabFilter = resolveTabFilter(activeTab); + const tabTruckKeyword = tabFilter.forceTruckKeyword ?? ""; + const effectiveTruckLanceCode = tabTruckKeyword || currentSearchParams.truckLanceCode || ""; + const shouldValidateTruckLane = activeTab !== "TRUCK_X"; if ( + shouldValidateTruckLane && isTruckLaneSearchMissingEta( - currentSearchParams.truckLanceCode ?? "", + effectiveTruckLanceCode, currentSearchParams.estimatedArrivalDate ?? "", ) ) { @@ -593,11 +549,6 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { status = currentSearchParams.status; } - const floorParam = - currentSearchParams.floor === "All" || !currentSearchParams.floor - ? null - : currentSearchParams.floor; - // 显示加载提示 const loadingSwal = Swal.fire({ title: t("Loading"), @@ -616,7 +567,9 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { currentSearchParams.shopName || "", status, estArrStartDate, - currentSearchParams.truckLanceCode || "", + effectiveTruckLanceCode, + tabFilter.floor, + tabFilter.isExtra, ); Swal.close(); @@ -752,7 +705,29 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { confirmButtonText: t("OK") }); } - }, [t, currentUserId, currentSearchParams, handleSearch, excludedIdSet]); + }, [t, currentUserId, currentSearchParams, handleSearch, excludedIdSet, activeTab, resolveTabFilter]); + + const handleTabChange = useCallback( + (_: React.SyntheticEvent, nextTab: DoSearchTab) => { + if (nextTab === activeTab) return; + const nextSearchParams = createClearedSearchParams(currentSearchParams); + setActiveTab(nextTab); + setCurrentSearchParams(nextSearchParams); + setSearchBoxResetKey((prev) => prev + 1); + setPagingController((prev) => ({ ...prev, pageNum: 1 })); + setExcludedRowIds([]); + // 切換 tab 僅重置搜尋條件與結果;由使用者再次按「搜尋」後才查詢。 + setSearchAllDos([]); + setTotalCount(0); + setHasSearched(false); + setHasResults(false); + }, + [ + activeTab, + currentSearchParams, + createClearedSearchParams, + ], + ); return ( <> @@ -762,28 +737,36 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { component="form" onSubmit={formProps.handleSubmit(onSubmit, onSubmitError)} > - {hasSearched && hasResults && ( - - - {/* - - */} - - )} + + + + + + + + + + + + {hasSearched && hasResults && ( + + )} + + + { +const DoWorkbenchPickShell: React.FC = ({ laneMode = "normal" }) => { const { data: session, status } = useSession() as { data: SessionWithTokens | null; status: "loading" | "authenticated" | "unauthenticated"; }; const currentUserId = session?.id ? parseInt(session.id, 10) : undefined; + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); const [showDetail, setShowDetail] = useState(false); const [viewLoading, setViewLoading] = useState(true); const filterArgs = useMemo(() => ({}), []); @@ -65,6 +76,13 @@ const DoWorkbenchPickShell: React.FC = () => { void refreshWorkbenchView(); }, [refreshWorkbenchView]); + const goNormalAssignTab = useCallback(() => { + const p = new URLSearchParams(searchParams.toString()); + p.set("tab", "0"); + const qs = p.toString(); + router.replace(qs ? `${pathname}?${qs}` : `${pathname}?tab=0`, { scroll: false }); + }, [pathname, router, searchParams]); + if (status === "loading") { return ( @@ -82,7 +100,11 @@ const DoWorkbenchPickShell: React.FC = () => { ) : !showDetail ? ( - void refreshWorkbenchView()} /> + void refreshWorkbenchView()} + etraOnly={laneMode === "etra"} + onRequestNormalLaneTab={laneMode === "etra" ? goNormalAssignTab : undefined} + /> ) : ( = ({ defaultTabIndex = 0, printerCom const [a4Printer, setA4Printer] = React.useState(null); const [labelPrinter, setLabelPrinter] = React.useState(null); const [releasedOrderCount, setReleasedOrderCount] = React.useState(0); + const [etraIncompleteDopoCount, setEtraIncompleteDopoCount] = React.useState(0); const { t } = useTranslation( ); const a4Printers = React.useMemo( () => (printerCombo || []).filter((printer) => printer.type === "A4"), @@ -55,19 +81,46 @@ const DoWorkbenchTabsInner: React.FC = ({ defaultTabIndex = 0, printerCom [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); + const refreshWorkbenchCounts = React.useCallback(async () => { + const [releasedRes, etraRes] = await Promise.allSettled([ + fetchWorkbenchReleasedDoPickOrdersForSelectionToday(), + fetchWorkbenchEtraLaneSummary(), + ]); + if (releasedRes.status === "fulfilled") { + setReleasedOrderCount(releasedRes.value.length); + } else { + console.error("Error fetching workbench released order count:", releasedRes.reason); setReleasedOrderCount(0); } + if (etraRes.status === "fulfilled") { + setEtraIncompleteDopoCount(sumIncompleteEtraDopoTickets(etraRes.value)); + } else { + console.error("Error fetching workbench Etra incomplete count:", etraRes.reason); + setEtraIncompleteDopoCount(0); + } }, []); React.useEffect(() => { - void fetchReleasedOrderCount(); - }, [fetchReleasedOrderCount]); + void refreshWorkbenchCounts(); + }, [refreshWorkbenchCounts]); + + React.useEffect(() => { + const onAssigned = () => { + void refreshWorkbenchCounts(); + }; + window.addEventListener("pickOrderAssigned", onAssigned); + return () => window.removeEventListener("pickOrderAssigned", onAssigned); + }, [refreshWorkbenchCounts]); + + /** Opening Etra tab refreshes badge (completion does not always dispatch `pickOrderAssigned`). */ + const etraTabMountSkipRef = React.useRef(false); + React.useEffect(() => { + if (!etraTabMountSkipRef.current) { + etraTabMountSkipRef.current = true; + return; + } + if (tab === 1) void refreshWorkbenchCounts(); + }, [tab, refreshWorkbenchCounts]); React.useEffect(() => { if (urlTabStr == null || urlTabStr === "") return; @@ -82,7 +135,8 @@ const DoWorkbenchTabsInner: React.FC = ({ defaultTabIndex = 0, printerCom setTab(newTab); const params = new URLSearchParams(searchParams.toString()); params.set("tab", String(newTab)); - if (newTab !== 1) { + /* ticketNo deep-link only for "Finished Good Record" (mine) */ + if (newTab !== 2) { params.delete("ticketNo"); } const qs = params.toString(); @@ -156,7 +210,7 @@ const DoWorkbenchTabsInner: React.FC = ({ defaultTabIndex = 0, printerCom showConfirmButton: false, timer: 1500, }); - await fetchReleasedOrderCount(); + await refreshWorkbenchCounts(); } catch (error) { Swal.close(); console.error("Error in workbench handleAllDraft:", error); @@ -165,7 +219,7 @@ const DoWorkbenchTabsInner: React.FC = ({ defaultTabIndex = 0, printerCom text: t("An error occurred during batch print"), }); } - }, [a4Printer, t, fetchReleasedOrderCount]); + }, [a4Printer, t, refreshWorkbenchCounts]); return ( @@ -223,19 +277,85 @@ const DoWorkbenchTabsInner: React.FC = ({ defaultTabIndex = 0, printerCom {`${t("Print All Draft")} (${releasedOrderCount})`}
- + - - - + 99 ? 5 : etraIncompleteDopoCount > 0 ? 4 : 2, + }} + label={ + 0 + ? t("Etra incomplete badge tooltip", { count: etraIncompleteDopoCount }) + : t("Etra incomplete badge tooltip none") + } + > + + 99 ? "99+" : etraIncompleteDopoCount} + invisible={etraIncompleteDopoCount === 0} + sx={{ + "& .MuiBadge-badge": { + fontWeight: 800, + fontSize: "0.7rem", + minWidth: 18, + height: 18, + lineHeight: "18px", + px: 0.5, + right: -8, + top: 2, + }, + }} + > + 0 ? 1 : 0 }} + > + {t("Etra Pick Order Detail")} + + + + + } + /> + + + - + + + + = ({ defaultTabIndex = 0, printerCom initialTicketNo={urlTicketNo} /> - + - + diff --git a/src/components/DoWorkbench/GoodPickExecutionWorkbenchRecord.tsx b/src/components/DoWorkbench/GoodPickExecutionWorkbenchRecord.tsx index ec5eb8b..9f46403 100644 --- a/src/components/DoWorkbench/GoodPickExecutionWorkbenchRecord.tsx +++ b/src/components/DoWorkbench/GoodPickExecutionWorkbenchRecord.tsx @@ -633,7 +633,29 @@ const GoodPickExecutionWorkbenchRecord: React.FC = ({ - {row.deliveryNoteCode || "-"} + + {row.deliveryNoteCode || "-"} + {(row.ticketNo ?? "").trim().toUpperCase().startsWith("TI-E-") && ( + theme.typography.h6.fontSize, fontWeight: 600 }, + height: 30, + }} + /> + )} + theme.typography.h6.fontSize, fontWeight: 600 }, + height: 30, + }} + /> + {row.ticketNo || "-"} @@ -648,7 +670,7 @@ const GoodPickExecutionWorkbenchRecord: React.FC = ({ {t("Ticket No.")}: {row.ticketNo || "-"} - + {/**/} diff --git a/src/components/DoWorkbench/WorkbenchFloorLanePanel.tsx b/src/components/DoWorkbench/WorkbenchFloorLanePanel.tsx index caa29d5..4b00e7b 100644 --- a/src/components/DoWorkbench/WorkbenchFloorLanePanel.tsx +++ b/src/components/DoWorkbench/WorkbenchFloorLanePanel.tsx @@ -9,9 +9,11 @@ import type { StoreLaneSummary, LaneRow, LaneBtn } from "@/app/api/pickOrder/act import { assignByDeliveryOrderPickOrderId, assignWorkbenchByLane, + fetchWorkbenchEtraLaneSummary, fetchWorkbenchReleasedDoPickOrdersForSelection, fetchWorkbenchReleasedDoPickOrdersForSelectionToday, fetchWorkbenchStoreLaneSummary, + type WorkbenchEtraShopLaneGroup, } from "@/app/api/doworkbench/actions"; import Swal from "sweetalert2"; import dayjs from "dayjs"; @@ -21,12 +23,22 @@ interface Props { onPickOrderAssigned?: () => void; onSwitchToDetailTab?: () => void; initialReleaseType?: string; + /** When true (workbench tab "Etra"), only the Etra shop×lane grid is shown; use top tab to return to normal. */ + etraOnly?: boolean; + /** With [etraOnly], navigates to normal assign tab (tab 0). */ + onRequestNormalLaneTab?: () => void; } type LaneSlot4F = { truckDepartureTime: string; lane: LaneBtn }; type TruckGroup4F = { truckLanceCode: string; slots: (LaneSlot4F & { sequenceIndex: number })[] }; -const WorkbenchFloorLanePanel: React.FC = ({ onPickOrderAssigned, onSwitchToDetailTab, initialReleaseType = "batch" }) => { +const WorkbenchFloorLanePanel: React.FC = ({ + onPickOrderAssigned, + onSwitchToDetailTab, + initialReleaseType = "batch", + etraOnly = false, + onRequestNormalLaneTab, +}) => { const { t } = useTranslation("pickOrder"); const { data: session } = useSession() as { data: SessionWithTokens | null }; const currentUserId = session?.id ? parseInt(session.id) : undefined; @@ -45,7 +57,16 @@ const WorkbenchFloorLanePanel: React.FC = ({ onPickOrderAssigned, onSwitc const [selectedDate, setSelectedDate] = useState("today"); const [releaseType, setReleaseType] = useState(initialReleaseType); const [ticketFloor, setTicketFloor] = useState<"2/F" | "4/F">("2/F"); + const [isExtraView, setisExtraView] = useState(false); + const [etraGroups, setEtraGroups] = useState([]); + const [isLoadingEtra, setIsLoadingEtra] = useState(false); + const [modalReleaseTypeFilter, setModalReleaseTypeFilter] = useState(undefined); + const [modalFilterRequiredDeliveryDate, setModalFilterRequiredDeliveryDate] = useState(undefined); + const [modalInitialShopSearch, setModalInitialShopSearch] = useState(undefined); const defaultTruckCount = summary4F?.defaultTruckCount ?? 0; + const etraEnterInFlightRef = useRef(false); + + const inEtraUi = useMemo(() => etraOnly || isExtraView, [etraOnly, isExtraView]); const selectedDeliveryDateYmd = useMemo(() => { if (selectedDate === "today") return dayjs().format("YYYY-MM-DD"); @@ -60,14 +81,20 @@ const WorkbenchFloorLanePanel: React.FC = ({ onPickOrderAssigned, onSwitc const workbenchReleasedListBridge = useMemo( () => ({ - loadBeforeToday: fetchWorkbenchReleasedDoPickOrdersForSelection, + loadBeforeToday: ( + shopName?: string, + storeId?: string, + truck?: string, + releaseType?: string + ) => fetchWorkbenchReleasedDoPickOrdersForSelection(shopName, storeId, truck, releaseType), loadToday: ( shopName?: string, storeId?: string, truck?: string, - requiredDeliveryDate?: string + requiredDeliveryDate?: string, + releaseType?: string ) => - fetchWorkbenchReleasedDoPickOrdersForSelectionToday(shopName, storeId, truck, requiredDeliveryDate), + fetchWorkbenchReleasedDoPickOrdersForSelectionToday(shopName, storeId, truck, requiredDeliveryDate, releaseType), assignByListItemId: assignByDeliveryOrderPickOrderId, }), [], @@ -118,12 +145,35 @@ const WorkbenchFloorLanePanel: React.FC = ({ onPickOrderAssigned, onSwitc } }, [selectedDate, releaseType]); + const loadEtraSummaries = useCallback(async () => { + setIsLoadingEtra(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 data = await fetchWorkbenchEtraLaneSummary(dateParam); + setEtraGroups(data); + } catch (error) { + console.error("Error loading Etra summary:", error); + setEtraGroups([]); + } finally { + setIsLoadingEtra(false); + pendingRef.current -= 1; + tryEndFullTimer(); + } + }, [selectedDate]); + useEffect(() => { - void loadSummaries(); - }, [loadSummaries]); + if (inEtraUi) void loadEtraSummaries(); + else void loadSummaries(); + }, [inEtraUi, loadEtraSummaries, loadSummaries]); useEffect(() => { const loadCounts = async () => { + if (inEtraUi) return; pendingRef.current += 1; startFullTimer(); try { @@ -153,10 +203,11 @@ const WorkbenchFloorLanePanel: React.FC = ({ onPickOrderAssigned, onSwitc } }; void loadCounts(); - }, [loadSummaries]); + }, [inEtraUi, loadSummaries]); useEffect(() => { const loadBeforeTodayTruckX = async () => { + if (inEtraUi) return; pendingRef.current += 1; startFullTimer(); try { @@ -170,6 +221,35 @@ const WorkbenchFloorLanePanel: React.FC = ({ onPickOrderAssigned, onSwitc } }; void loadBeforeTodayTruckX(); + }, [inEtraUi]); + + const clearModalEtraContext = useCallback(() => { + setModalReleaseTypeFilter(undefined); + setModalFilterRequiredDeliveryDate(undefined); + setModalInitialShopSearch(undefined); + }, []); + + const openEnterEtraView = useCallback(async () => { + if (etraEnterInFlightRef.current) return; + etraEnterInFlightRef.current = true; + try { + /* + const r = await Swal.fire({ + title: t("Enter isExtra workbench view?"), + text: t("Etra view groups all add-on tickets by shop and lane for the selected date."), + icon: "question", + showCancelButton: true, + confirmButtonText: t("Confirm"), + cancelButtonText: t("Cancel"), + confirmButtonColor: "#8dba00", + cancelButtonColor: "#F04438", + }); + if (r.isConfirmed) setisExtraView(true); + */ + setisExtraView(true); + } finally { + etraEnterInFlightRef.current = false; + } }, []); const handleAssignByLane = useCallback( @@ -198,7 +278,7 @@ const WorkbenchFloorLanePanel: React.FC = ({ onPickOrderAssigned, onSwitc console.log("assignByLane result:", res); if (res.code === "SUCCESS") { window.dispatchEvent(new CustomEvent("pickOrderAssigned")); - void loadSummaries(); + void (inEtraUi ? loadEtraSummaries() : loadSummaries()); onPickOrderAssigned?.(); onSwitchToDetailTab?.(); } @@ -208,7 +288,7 @@ const WorkbenchFloorLanePanel: React.FC = ({ onPickOrderAssigned, onSwitc setIsAssigning(false); } }, - [currentUserId, loadSummaries, onPickOrderAssigned, onSwitchToDetailTab, t], + [currentUserId, inEtraUi, loadEtraSummaries, loadSummaries, onPickOrderAssigned, onSwitchToDetailTab, t], ); const handleLaneButtonClick = useCallback( @@ -280,7 +360,7 @@ const WorkbenchFloorLanePanel: React.FC = ({ onPickOrderAssigned, onSwitc return ( - + {t("Select Date")} @@ -291,26 +371,60 @@ const WorkbenchFloorLanePanel: React.FC = ({ onPickOrderAssigned, onSwitc - - - {t("Release Type")} - - - - - - {t("Floor ticket")} - - - + {!inEtraUi && ( + <> + + + {t("Release Type")} + + + + + + {t("Floor ticket")} + + + + {/* + + + + */} + + )} + {/* + {inEtraUi && !etraOnly && ( + + + + )} + {etraOnly && onRequestNormalLaneTab && ( + + + + )} + */} + {!inEtraUi ? ( {ticketFloor === "2/F" && ( @@ -388,6 +502,7 @@ const WorkbenchFloorLanePanel: React.FC = ({ onPickOrderAssigned, onSwitc + + ); + }), + )} + + )} + + )} = ({ onPickOrderAssigned, onSwitc defaultDateScope={defaultDateScope} defaultTruckRequiredDeliveryDate={selectedDeliveryDateYmd} listBridge={workbenchReleasedListBridge} - onClose={() => setModalOpen(false)} + releaseTypeFilter={modalReleaseTypeFilter} + filterRequiredDeliveryDate={modalFilterRequiredDeliveryDate} + initialShopSearch={modalInitialShopSearch} + onClose={() => { + setModalOpen(false); + clearModalEtraContext(); + }} onAssigned={() => { - void loadSummaries(); + if (inEtraUi) void loadEtraSummaries(); + else void loadSummaries(); onPickOrderAssigned?.(); onSwitchToDetailTab?.(); }} /> - ); }; diff --git a/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx b/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx index 0d7a1a5..e6e3428 100644 --- a/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx +++ b/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx @@ -534,6 +534,10 @@ const [pickOrderSwitching, setPickOrderSwitching] = useState(false); const [fgPickOrders, setFgPickOrders] = useState([]); const [fgPickOrdersLoading, setFgPickOrdersLoading] = useState(false); + const isExtraTicket = useMemo(() => { + const ticketNo = String(fgPickOrders?.[0]?.ticketNo ?? "").trim().toUpperCase(); + return ticketNo.startsWith("TI-E-"); + }, [fgPickOrders]); const lotFloorPrefixFilter = useMemo(() => { const storeId = String(fgPickOrders?.[0]?.storeId ?? "") @@ -605,21 +609,23 @@ const [pickOrderSwitching, setPickOrderSwitching] = useState(false); // TODO: Implement QR code functionality }; const progress = useMemo(() => { - if (combinedLotData.length === 0) { + // 只给进度条统计可正常拣货的 lot(排除 unavailable / expired) + const progressLots = combinedLotData.filter( + (lot) => !isLotAvailabilityExpired(lot) && !isInventoryLotLineUnavailable(lot) + ); + + if (progressLots.length === 0) { return { completed: 0, total: 0 }; } - - // 與 allItemsReady 一致:noLot / 過期 / unavailable 的 pending 也算「已面對該行」可收尾 - const nonPendingCount = combinedLotData.filter((lot) => { - const status = lot.stockOutLineStatus?.toLowerCase(); - if (status !== "pending") return true; - if (lot.noLot === true || isLotAvailabilityExpired(lot) || isInventoryLotLineUnavailable(lot)) return true; - return false; + + const completedCount = progressLots.filter((lot) => { + const status = String(lot.stockOutLineStatus || "").toLowerCase(); + return status !== "pending"; }).length; - + return { - completed: nonPendingCount, - total: combinedLotData.length, + completed: completedCount, + total: progressLots.length, }; }, [combinedLotData]); @@ -744,7 +750,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO ) { workbenchFinishNavigateDoneRef.current = true; router.replace( - `${pathname}?tab=1&ticketNo=${encodeURIComponent(ticketForRedirect)}`, + `${pathname}?tab=2&ticketNo=${encodeURIComponent(ticketForRedirect)}`, { scroll: false }, ); } @@ -832,8 +838,6 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO pickOrderLinesForDisplay.forEach((line: any) => { // 用来记录这一行已经通过 lots 出现过的 lotId const lotIdSet = new Set(); - /** 已由有批次建議分配的量(加總後與 pick_order_line.requiredQty 的差額 = 無批次列應顯示的數) */ - let lotsAllocatedSumForLine = 0; // ✅ lots:按 lotId 去重并合并 requiredQty if (line.lots && line.lots.length > 0) { @@ -851,7 +855,6 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO }); lotMap.forEach((lot: any) => { - lotsAllocatedSumForLine += Number(lot.requiredQty) || 0; if (lot.id != null) { lotIdSet.add(lot.id); } @@ -915,6 +918,15 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO return; } + const stockoutRequiredQty = Number( + stockout?.requiredQty ?? stockout?.suggestedPickLotQty, + ); + const effectiveStockoutRequiredQty = Number.isFinite(stockoutRequiredQty) + ? stockoutRequiredQty + : Number(line.requiredQty) || 0; + const fallbackRouteFromLine = + line?.lots?.[0]?.router?.route ?? line?.lots?.[0]?.location ?? null; + // 只渲染: // - noLot === true 的 Null stock 行 // - 或者 lotId 在 lots 中不存在的特殊情况 @@ -942,13 +954,9 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO location: stockout.location || null, stockUnit: line.item.uomDesc, availableQty: stockout.availableQty || 0, - // 無批次列對應 suggested_pick_lot 的缺口量(如 11),勿用整行 POL 需求(100)以免顯示成 89 / 100 - requiredQty: stockout.noLot - ? Math.max( - 0, - (Number(line.requiredQty) || 0) - lotsAllocatedSumForLine - ) - : Number(line.requiredQty) || 0, + // Workbench stockout row required qty should come from backend stockout payload + // (linked by stockOutLineId to suggested_pick_lot), not inferred from line gap. + requiredQty: effectiveStockoutRequiredQty, actualPickQty: stockout.qty || 0, inQty: 0, outQty: 0, @@ -956,7 +964,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO lotStatus: stockout.noLot ? "unavailable" : "available", lotAvailability: stockout.noLot ? "insufficient_stock" : "available", processingStatus: stockout.status || "pending", - suggestedPickLotId: null, + suggestedPickLotId: stockout.suggestedPickLotId ?? null, stockOutLineId: stockout.id || null, stockOutLineStatus: stockout.status || null, stockOutLineQty: stockout.qty || 0, @@ -964,8 +972,8 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO routerId: null, routerIndex: stockout.noLot ? 999999 : null, - routerRoute: null, - routerArea: null, + routerRoute: fallbackRouteFromLine, + routerArea: fallbackRouteFromLine, noLot: !!stockout.noLot, }); }); @@ -1166,7 +1174,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO const event = new CustomEvent('pickOrderCompletionStatus', { detail: { allLotsCompleted, - tabIndex: 1 // 明确指定这是来自标签页 1 的事件 + tabIndex: 2 // DO workbench「Finished Good Record (mine)」分頁索引 } }); window.dispatchEvent(event); @@ -2537,59 +2545,7 @@ useEffect(() => { console.log("Pick execution form opened for lot ID:", lot.lotId); }, []); - const handlePickExecutionFormSubmit = useCallback(async (data: any) => { - try { - console.log("Pick execution form submitted:", data); - const issueData = { - ...data, - type: "Do", // Delivery Order Record 类型 - pickerName: session?.user?.name || '', - }; - - const result = await recordPickExecutionIssue(issueData); - console.log("Pick execution issue recorded:", result); - - if (result && result.code === "SUCCESS") { - console.log(" Pick execution issue recorded successfully"); - // 关键:issue form 只记录问题,不会更新 SOL.qty - // 但 batch submit 需要知道“实际拣到多少”,否则会按 requiredQty 补拣到满 - const solId = Number(issueData.stockOutLineId || issueData.stockOutLineId === 0 ? issueData.stockOutLineId : data?.stockOutLineId); - if (solId > 0) { - const picked = Number(issueData.actualPickQty || 0); - setIssuePickedQtyBySolId((prev) => { - const next = { ...prev, [solId]: picked }; - const doId = fgPickOrders[0]?.doPickOrderId; - if (doId) saveIssuePickedMap(doId, next); - return next; - }); - setCombinedLotData(prev => prev.map(lot => { - if (Number(lot.stockOutLineId) === solId) { - return { ...lot, actualPickQty: picked, stockOutLineQty: picked }; - } - return lot; - })); - } - } else { - console.error(" Failed to record pick execution issue:", result); - } - - setPickExecutionFormOpen(false); - setSelectedLotForExecutionForm(null); - setQrScanError(false); - setQrScanSuccess(false); - setQrScanInput(''); - // ✅ Keep scanner active after form submission - don't stop scanning - // Only clear processed QR codes for the specific lot, not all - // setIsManualScanning(false); // Removed - keep scanner active - // stopScan(); // Removed - keep scanner active - // resetScan(); // Removed - keep scanner active - // Don't clear all processed codes - only clear for this specific lot if needed - await fetchAllCombinedLotData(); - } catch (error) { - console.error("Error submitting pick execution form:", error); - } - }, [fetchAllCombinedLotData, session, fgPickOrders]); - + // Calculate remaining required quantity const calculateRemainingRequiredQty = useCallback((lot: any) => { const requiredQty = lot.requiredQty || 0; @@ -2710,12 +2666,14 @@ useEffect(() => { >(); combinedLotData.forEach((lot: any, originalIndex: number) => { const routeKey = String(lot?.routerRoute ?? "").trim(); + const pickOrderLineKey = + lot?.pickOrderLineId != null ? `pol:${String(lot.pickOrderLineId)}` : "pol:unknown"; const itemKey = lot?.itemId != null ? `itemId:${String(lot.itemId)}` : `itemCode:${String(lot?.itemCode ?? "").trim()}`; - // Group only within same route to avoid collapsing different routes visually. - const key = `${routeKey}__${itemKey}`; + // Group by pickOrderLine first so no-lot row stays with its lot rows even when route is empty. + const key = `${pickOrderLineKey}__${itemKey}__${routeKey}`; const g = groups.get(key); if (!g) { groups.set(key, { firstIndex: originalIndex, items: [{ lot, originalIndex }] }); @@ -3487,7 +3445,7 @@ const handleSubmitAllScanned = useCallback(async () => { variant="outlined" startIcon={} onClick={handleStopScan} - color="secondary" + color={isExtraTicket ? "secondary" : "primary"} sx={{ minWidth: '120px' }} > {t("Stop QR Scan")} @@ -3497,7 +3455,7 @@ const handleSubmitAllScanned = useCallback(async () => { variant="contained" startIcon={} onClick={handleStartScan} - color="primary" + color={isExtraTicket ? "secondary" : "primary"} sx={{ minWidth: '120px' }} > {t("Start QR Scan")} @@ -3507,7 +3465,7 @@ const handleSubmitAllScanned = useCallback(async () => { {/* 保留:Submit All Scanned Button */}