From 83736a130d33a5d3265676baa58138fd1ba90f14 Mon Sep 17 00:00:00 2001 From: "PC-20260115JRSN\\Administrator" Date: Wed, 7 Oct 2026 14:13:51 +0800 Subject: [PATCH 1/9] make user with KPI can see the chart with KPI only --- src/components/NavigationContent/NavigationContent.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/NavigationContent/NavigationContent.tsx b/src/components/NavigationContent/NavigationContent.tsx index 05916740..d5d3de24 100644 --- a/src/components/NavigationContent/NavigationContent.tsx +++ b/src/components/NavigationContent/NavigationContent.tsx @@ -251,7 +251,7 @@ const NavigationContent: React.FC = () => { icon: , labelKey: "nav.chartReports", path: "", - requiredAbility: [AUTH.TESTING, AUTH.ADMIN], + requiredAbility: [AUTH.TESTING, AUTH.ADMIN, AUTH.PERFORMANCE], isHidden: false, children: [ { @@ -280,7 +280,7 @@ const NavigationContent: React.FC = () => { icon: , labelKey: "nav.chart.delivery", path: "/chart/delivery", - requiredAbility: [AUTH.TESTING, AUTH.ADMIN], + requiredAbility: [AUTH.TESTING, AUTH.ADMIN, AUTH.PERFORMANCE], }, { id: "nav.chart.warehouse", From 027a4d641b1939809cbc5ef7ff7cfdc7ef1378a2 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Thu, 15 Oct 2026 12:19:50 +0800 Subject: [PATCH 2/9] Refresh the production list in the background after QC and search. Finishing QC or putaway moves the job order to Put Awayed without a full-page spinner, and Search refetches even when the criteria are unchanged. Co-authored-by: Cursor --- .../ProductionProcessList.tsx | 33 ++++++++++++++----- src/components/Qc/QcStockInModal.tsx | 15 +++++---- 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/src/components/ProductionProcess/ProductionProcessList.tsx b/src/components/ProductionProcess/ProductionProcessList.tsx index 665d6ba9..38667525 100644 --- a/src/components/ProductionProcess/ProductionProcessList.tsx +++ b/src/components/ProductionProcess/ProductionProcessList.tsx @@ -29,6 +29,7 @@ import { import ChevronLeft from "@mui/icons-material/ChevronLeft"; import ChevronRight from "@mui/icons-material/ChevronRight"; import { useTranslation } from "react-i18next"; +import type { StockInLine } from "@/app/api/stockIn"; import { fetchItemForPutAway } from "@/app/api/stockIn/actions"; import QcStockInModal from "../Qc/QcStockInModal"; import { useSession } from "next-auth/react"; @@ -178,7 +179,7 @@ function isWaitingQcPutAway( return s !== "completed" && s !== "rejected"; } -/** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.9 | 2026-08-10 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.10 | 2026-10-15 */ const ProductProcessList: React.FC = ({ onSelectProcess, printerCombo, @@ -191,6 +192,8 @@ const ProductProcessList: React.FC = ({ const { data: session } = useSession() as { data: SessionWithTokens | null }; const sessionToken = session as SessionWithTokens | null; const [loading, setLoading] = useState(false); + /** Bumps on Search/Reset so the same criteria still refetch. */ + const [listReloadKey, setListReloadKey] = useState(0); const [productionCache, setProductionCache] = useState< AllJoborderProductProcessInfoResponse[] >([]); @@ -234,6 +237,8 @@ const ProductProcessList: React.FC = ({ () => printerOptions[0] ?? null, ); const printInFlightRef = useRef(false); + const loadSeqRef = useRef(0); + const hasLoadedOnceRef = useRef(false); const [printingSilId, setPrintingSilId] = useState(null); useEffect(() => { @@ -328,6 +333,7 @@ const ProductProcessList: React.FC = ({ const handleApplySearch = useCallback( (inputs: Record) => { const selectedProcessType = (inputs.processType || "all") as ProcessFilter; + setListReloadKey((k) => k + 1); onListPersistedStateChange((prev) => ({ ...prev, filter: selectedProcessType, @@ -342,6 +348,7 @@ const ProductProcessList: React.FC = ({ ); const handleResetSearch = useCallback(() => { + setListReloadKey((k) => k + 1); onListPersistedStateChange((prev) => ({ ...prev, filter: "all", @@ -355,8 +362,10 @@ const ProductProcessList: React.FC = ({ }, [disableDateFilter, onListPersistedStateChange]); /** Load all tab datasets once per search; tab switches use cache only. */ - const loadAllCaches = useCallback(async () => { - setLoading(true); + const loadAllCaches = useCallback(async (options?: { silent?: boolean }) => { + const silent = options?.silent === true; + const seq = ++loadSeqRef.current; + if (!silent) setLoading(true); try { const typeParam = filter === "all" ? undefined : filter; const base = { @@ -391,6 +400,8 @@ const ProductProcessList: React.FC = ({ }), ]); + if (seq !== loadSeqRef.current) return; + const prodContent = (prod?.content || []).filter((p) => !isPutawayCompleted(p)); const qcContent = (pendingQc?.content || []).filter((p) => !isPutawayCompleted(p)); const putawayContent = putawayed?.content || []; @@ -405,6 +416,7 @@ const ProductProcessList: React.FC = ({ setCarriedOverCount(prod?.carriedOverCount ?? 0); } catch (e) { console.error(e); + if (seq !== loadSeqRef.current || silent) return; setProductionCache([]); setPendingQcCache([]); setPutawayedCache([]); @@ -413,12 +425,14 @@ const ProductProcessList: React.FC = ({ setPutawayedCount(0); setCarriedOverCount(0); } finally { - setLoading(false); + if (seq === loadSeqRef.current) setLoading(false); } - }, [appliedSearch, disableDateFilter, filter]); + }, [appliedSearch, disableDateFilter, filter, listReloadKey]); useEffect(() => { - void loadAllCaches(); + const silent = hasLoadedOnceRef.current; + hasLoadedOnceRef.current = true; + void loadAllCaches(silent ? { silent: true } : undefined); }, [loadAllCaches]); const offPlanRows = useMemo(() => { @@ -670,9 +684,12 @@ const ProductProcessList: React.FC = ({ closeConfirm(); } }, [pendingConfirmAction, closeConfirm]); - const closeNewModal = useCallback(() => { + const closeNewModal = useCallback((updatedStockInLine?: StockInLine) => { setOpenModal(false); - }, []); + if (updatedStockInLine) { + void loadAllCaches({ silent: true }); + } + }, [loadAllCaches]); const handlePrintLabel = useCallback( async (process: AllJoborderProductProcessInfoResponse) => { diff --git a/src/components/Qc/QcStockInModal.tsx b/src/components/Qc/QcStockInModal.tsx index 5e9e201e..72887158 100644 --- a/src/components/Qc/QcStockInModal.tsx +++ b/src/components/Qc/QcStockInModal.tsx @@ -77,7 +77,7 @@ interface Props extends CommonProps { // itemDetail: StockInLine & { qcResult?: PurchaseQcResult[] } & { escResult?: EscalationResult[] }; } -/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.5 | 2026-09-18 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.7 | 2026-10-15 */ const QcStockInModal: React.FC = ({ open, onClose, @@ -617,10 +617,10 @@ const QcStockInModal: React.FC = ({ } - closeWithResult(qcRes); + closeWithResult(qcRes ?? stockInLineInfo); // setTabIndex(1); // Need to go Putaway tab? } else { - closeWithResult(qcRes); + closeWithResult(qcRes ?? stockInLineInfo); } setIsSubmitting(false); qcSubmitInFlightRef.current = false; @@ -700,11 +700,12 @@ const QcStockInModal: React.FC = ({ // Handle putaway submission logic here const res = await postStockInLine(putawayData); console.log("result ", res); - - // Close modal after successful putaway - closeHandler({}, "backdropClick"); + const updated = Array.isArray(res.entity) ? res.entity[0] : res.entity; + + // Close modal after successful putaway and tell the parent to refresh. + closeWithResult(updated ?? stockInLineInfo); }, - [closeHandler], + [closeWithResult, postStockInLine, stockInLineInfo], ); // Print handler useEffect(() => { From a688bdafd62fa8d280d32f6723e9e3094cc123d7 Mon Sep 17 00:00:00 2001 From: tommy Date: Mon, 26 Oct 2026 17:24:46 +0800 Subject: [PATCH 3/9] truckname rename --- src/app/api/shop/actions.ts | 72 ++++++ src/app/api/shop/client.ts | 21 +- src/components/Shop/RouteBoard.tsx | 369 +++++++++++++++++++++++++---- src/i18n/en/shop.json | 23 +- src/i18n/zh/shop.json | 23 +- 5 files changed, 463 insertions(+), 45 deletions(-) diff --git a/src/app/api/shop/actions.ts b/src/app/api/shop/actions.ts index abea44e1..d26404e0 100644 --- a/src/app/api/shop/actions.ts +++ b/src/app/api/shop/actions.ts @@ -924,4 +924,76 @@ export const importTruckLaneScheduleExcelAction = async ( ); } return (await response.json()) as TruckLaneScheduleResponse[]; +}; + +export interface PreviewTruckLaneRenameRequest { + truckLanceCode: string; + storeId: string; + remark?: string | null; +} + +export interface PreviewTruckLaneRenameResponse { + unfinishedCount: number; + completedCount: number; + handlerNames: string[]; + openScheduleCount: number; +} + +export interface RenameTruckLaneRequest { + truckLanceCode: string; + storeId: string; + remark?: string | null; + newTruckLanceCode: string; + departureTimeChanged: boolean; + newDepartureTime?: string | null; +} + +export interface RenameTruckLaneResponse { + truckLanceCode: string; + ticketCount: number; + scheduleLineCount: number; +} + +export interface TruckLaneRenameLogResponse { + id: number; + created?: string | null; + createdBy?: string | null; + oldCode: string; + newCode: string; + storeId: string; + remark?: string | null; + oldDepartureTime?: string | null; + newDepartureTime?: string | null; + ticketCount: number; +} + +export const previewTruckLaneRenameAction = async ( + data: PreviewTruckLaneRenameRequest, +) => { + return serverFetchJson( + `${BASE_API_URL}/truck/previewTruckLaneRename`, + { + method: "POST", + body: JSON.stringify(data), + headers: { "Content-Type": "application/json" }, + }, + ); +}; + +export const renameTruckLaneAction = async (data: RenameTruckLaneRequest) => { + return serverFetchJson( + `${BASE_API_URL}/truck/renameTruckLane`, + { + method: "POST", + body: JSON.stringify(data), + headers: { "Content-Type": "application/json" }, + }, + ); +}; + +export const listTruckLaneRenameLogsAction = async () => { + return serverFetchJson( + `${BASE_API_URL}/truck/truckLaneRenameLog`, + { method: "GET", headers: { "Content-Type": "application/json" } }, + ); }; \ No newline at end of file diff --git a/src/app/api/shop/client.ts b/src/app/api/shop/client.ts index 67963e27..80356270 100644 --- a/src/app/api/shop/client.ts +++ b/src/app/api/shop/client.ts @@ -66,7 +66,12 @@ import { type SaveTruckRequest, type UpdateTruckShopDetailsRequest, type CreateTruckWithoutShopRequest, - type MessageResponse + type MessageResponse, + previewTruckLaneRenameAction, + renameTruckLaneAction, + listTruckLaneRenameLogsAction, + type PreviewTruckLaneRenameRequest, + type RenameTruckLaneRequest, } from "./actions"; export const fetchAllShopsClient = async (params?: Record) => { @@ -276,6 +281,20 @@ export const parseTruckLaneScheduleExcelClient = async ( return await parseTruckLaneScheduleExcelAction(formData, defaultExecuteAt); }; +export const previewTruckLaneRenameClient = async ( + data: PreviewTruckLaneRenameRequest, +) => { + return await previewTruckLaneRenameAction(data); +}; + +export const renameTruckLaneClient = async (data: RenameTruckLaneRequest) => { + return await renameTruckLaneAction(data); +}; + +export const listTruckLaneRenameLogsClient = async () => { + return await listTruckLaneRenameLogsAction(); +}; + export const importTruckLaneScheduleExcelClient = async ( formData: FormData, defaultExecuteAt?: string | null, diff --git a/src/components/Shop/RouteBoard.tsx b/src/components/Shop/RouteBoard.tsx index 0dc16fcb..45fc18e9 100644 --- a/src/components/Shop/RouteBoard.tsx +++ b/src/components/Shop/RouteBoard.tsx @@ -97,6 +97,9 @@ import { createTruckClient, createTruckWithoutShopClient, deleteTruckLaneClient, + previewTruckLaneRenameClient, + renameTruckLaneClient, + listTruckLaneRenameLogsClient, } from "@/app/api/shop/client"; import { deleteLogisticClient, @@ -113,6 +116,7 @@ import type { SaveTruckLane, Truck, TruckLaneVersionDiffLine, + TruckLaneRenameLogResponse, } from "@/app/api/shop/actions"; import { formatDepartureTime, normalizeStoreId } from "@/app/utils/formatUtil"; import { @@ -132,6 +136,7 @@ import { import { computeTruckLaneWarnings, appendSyntheticPendingShopRow, + parseWeekdayFromTruckLanceCode, type TruckLaneWarning, type TruckLaneWarningInputRow, type TruckLaneWarningLaneRef, @@ -220,6 +225,12 @@ type Lane = { storeId: string; remark?: string | null; shops: ShopCard[]; + /** Server name before a local rename. Absent until the user edits the name. */ + originalTruckLanceCode?: string; + /** Server departure time before a local time edit in the same unsaved session. */ + originalStartTime?: string; + /** False for a lane that exists only in this unsaved session. */ + persistedLane?: boolean; }; type PendingNewLane = { @@ -693,9 +704,31 @@ function buildLaneFromTruckRows( storeId: storeId || "2F", remark, shops, + persistedLane: true, }; } +function laneRenamePending(lane: Lane): boolean { + if (lane.persistedLane === false) return false; + const orig = String(lane.originalTruckLanceCode ?? "").trim(); + const cur = String(lane.truckLanceCode ?? "").trim(); + return orig !== "" && orig !== cur; +} + +function renameErrorText(raw: string, t: TFunction): string { + const codes = [ + "LANE_NAME_EMPTY", + "LANE_NAME_TOO_LONG", + "LANE_NAME_RESERVED", + "LANE_NAME_TAKEN", + "LANE_NAME_REMARK_AMBIGUOUS", + "LANE_NOT_FOUND", + "LANE_TIME_INVALID", + ]; + const code = codes.find((c) => raw.includes(c)); + return code ? t(`renameErr_${code}`) : raw; +} + async function fetchLaneByKey( truckLanceCode: string, remark: string | null, @@ -939,6 +972,11 @@ const RouteBoard: React.FC = () => { null, ); const [departureEditDraft, setDepartureEditDraft] = useState(""); + const [departureEditNameDraft, setDepartureEditNameDraft] = useState(""); + const [departureEditBusy, setDepartureEditBusy] = useState(false); + const [renameLogs, setRenameLogs] = useState( + [], + ); const [seqEditTarget, setSeqEditTarget] = useState<{ laneId: string; shopId: number; @@ -1234,6 +1272,7 @@ const RouteBoard: React.FC = () => { storeId: storeNorm, remark: remarkRaw, shops: [], + persistedLane: false, }; }, [logisticRowsEffective], @@ -2922,6 +2961,7 @@ const RouteBoard: React.FC = () => { pendingLogisticMasterDeletes.size > 0 || pendingImportMeta != null || pendingRestoreVersionId != null || + lanes.some(laneRenamePending) || Object.values(pendingEmptyDistrictsByLane).some( (a) => (a?.length ?? 0) > 0, ); @@ -3193,7 +3233,8 @@ const RouteBoard: React.FC = () => { pendingLogisticMasterEditsSnapshot.size > 0 || pendingLogisticMasterDeletesSnapshot.size > 0 || pendingImportFile != null || - pendingRestoreId != null; + pendingRestoreId != null || + lanesRef.current.some(laneRenamePending); const hasPendingEmptyDistrictsOnly = !hasPersistedWork && Object.values(pendingEmptyDistrictsByLane).some( @@ -3267,6 +3308,54 @@ const RouteBoard: React.FC = () => { return; } + const renameTargets = lanesRef.current.filter(laneRenamePending); + if (renameTargets.length > 0) { + const renamed: string[] = []; + let renameFailure: string | null = null; + for (const lane of renameTargets) { + const orig = String(lane.originalTruckLanceCode ?? "").trim(); + const timeBaseline = lane.originalStartTime ?? lane.startTime; + const departureTimeChanged = + parseTimeForBackend(timeBaseline) !== parseTimeForBackend(lane.startTime); + try { + await renameTruckLaneClient({ + truckLanceCode: orig, + storeId: normalizeStoreId(lane.storeId), + remark: lane.remark ?? null, + newTruckLanceCode: lane.truckLanceCode.trim(), + departureTimeChanged, + newDepartureTime: departureTimeChanged ? lane.startTime : null, + }); + renamed.push(`${orig} → ${lane.truckLanceCode.trim()}`); + const committed = lanesRef.current.map((row) => + row.id === lane.id + ? { + ...row, + originalTruckLanceCode: row.truckLanceCode, + originalStartTime: row.startTime, + } + : row, + ); + lanesRef.current = committed; + setLanes(committed); + } catch (e: any) { + renameFailure = `${lane.truckLanceCode.trim()}:${renameErrorText( + e?.message ?? String(e), + t, + )}`; + break; + } + } + if (renameFailure) { + const ok = renamed.length > 0 ? renamed.join("、") : t("rename_none"); + setSaveResult({ + ok: false, + message: t("rename_savePartial", { ok, fail: renameFailure }), + }); + return; + } + } + if (pendingLogisticMasterEditsSnapshot.size > 0) { for (const [id, req] of Array.from( pendingLogisticMasterEditsSnapshot.entries(), @@ -3657,47 +3746,188 @@ const RouteBoard: React.FC = () => { const openDepartureEdit = (lane: Lane) => { setDepartureEditDraft(toTimeInputValue(lane.startTime)); + setDepartureEditNameDraft(lane.truckLanceCode); setDepartureEditLaneId(lane.id); }; - const applyDepartureEdit = () => { - if (!departureEditLaneId) return; - const backendTime = parseTimeForBackend(departureEditDraft); - const targetLane = lanesRef.current.find( - (l) => l.id === departureEditLaneId, + const retargetLaneId = (oldId: string, newId: string, newCode: string) => { + if (oldId === newId) return; + setDirtyMoves((prev) => { + const next = new Map(prev); + for (const [shopId, laneId] of next) { + if (laneId === oldId) next.set(shopId, newId); + } + return next; + }); + setPendingShopAdds((prev) => + prev.map((row) => (row.laneId === oldId ? { ...row, laneId: newId } : row)), ); - const shopIds = targetLane?.shops.map((s) => s.id) ?? []; - if (shopIds.length === 0) { + setPendingEmptyDistrictsByLane((prev) => { + if (!(oldId in prev)) return prev; + const next = { ...prev, [newId]: prev[oldId] }; + delete next[oldId]; + return next; + }); + setSelectedLaneIds((prev) => prev.map((id) => (id === oldId ? newId : id))); + setPendingNewLanes((prev) => + prev.map((row) => + row.laneKey === oldId + ? { + laneKey: newId, + payload: { ...row.payload, truckLanceCode: newCode }, + } + : row, + ), + ); + if (laneLogisticBaselineRef.current.has(oldId)) { + const value = laneLogisticBaselineRef.current.get(oldId) ?? null; + laneLogisticBaselineRef.current.delete(oldId); + laneLogisticBaselineRef.current.set(newId, value); + } + if (laneDisplayOverlayRef.current.has(oldId)) { + const value = laneDisplayOverlayRef.current.get(oldId); + laneDisplayOverlayRef.current.delete(oldId); + if (value) laneDisplayOverlayRef.current.set(newId, value); + } + if (lanesNeedingRefreshOnSaveRef.current.delete(oldId)) { + lanesNeedingRefreshOnSaveRef.current.add(newId); + } + }; + + const applyDepartureEdit = async () => { + if (!departureEditLaneId || departureEditBusy) return; + const backendTime = parseTimeForBackend(departureEditDraft); + const targetLane = lanesRef.current.find((l) => l.id === departureEditLaneId); + if (!targetLane) { closeDepartureEdit(); return; } - const nextLanes = lanesRef.current.map((lane) => - lane.id !== departureEditLaneId - ? lane - : { - ...lane, - startTime: backendTime, - shops: lane.shops.map((s) => ({ - ...s, - departureTime: backendTime, - })), - }, - ); - const wr = computeTruckLaneWarnings(lanesToWarningInputRows(nextLanes)); - if (wr.warnings.length > 0) { - const ok = window.confirm( - t("confirm_departureConflict", { count: wr.warnings.length }), - ); - if (!ok) return; + const newCode = departureEditNameDraft.trim(); + const oldCode = targetLane.truckLanceCode.trim(); + if (!newCode) { + window.alert(t("renameErr_LANE_NAME_EMPTY")); + return; } - setLanes(nextLanes); - setDirtyMoves((prev) => { - const next = new Map(prev); - for (const id of shopIds) { - if (id > 0) next.set(id, departureEditLaneId); + if (newCode.length > 50) { + window.alert(t("renameErr_LANE_NAME_TOO_LONG")); + return; + } + if (newCode === "車線-X" || oldCode === "車線-X") { + window.alert(t("renameErr_LANE_NAME_RESERVED")); + return; + } + const nameChanged = newCode !== oldCode; + const shopIds = targetLane.shops.map((s) => s.id); + if (!nameChanged && shopIds.length === 0) { + closeDepartureEdit(); + return; + } + if (nameChanged) { + const floor = normalizeStoreId(targetLane.storeId); + const remarkKey = + floor === "4F" ? String(targetLane.remark ?? "").trim() : ""; + const clash = lanesRef.current.find((lane) => { + if (lane.id === targetLane.id) return false; + if (normalizeStoreId(lane.storeId) !== floor) return false; + const otherRemark = + floor === "4F" ? String(lane.remark ?? "").trim() : ""; + const otherCode = lane.truckLanceCode.trim(); + if (otherRemark === remarkKey && otherCode.toLowerCase() === newCode.toLowerCase()) { + return true; + } + return otherRemark !== remarkKey && otherCode === newCode; + }); + if (clash) { + const sameRemark = + floor !== "4F" || String(clash.remark ?? "").trim() === remarkKey; + window.alert( + sameRemark + ? t("renameErr_LANE_NAME_TAKEN") + : t("renameErr_LANE_NAME_REMARK_AMBIGUOUS"), + ); + return; } - return next; + if (floor === "4F") { + const before = parseWeekdayFromTruckLanceCode(oldCode); + const after = parseWeekdayFromTruckLanceCode(newCode); + if (before !== after) { + if (!window.confirm(t("confirm_weekdayToken"))) return; + } + } + if (targetLane.persistedLane !== false) { + setDepartureEditBusy(true); + try { + const preview = await previewTruckLaneRenameClient({ + truckLanceCode: targetLane.originalTruckLanceCode?.trim() || oldCode, + storeId: floor, + remark: targetLane.remark ?? null, + }); + const handlers = + preview.handlerNames.length > 0 + ? preview.handlerNames.slice(0, 8).join("、") + : t("rename_noHandlers"); + const ok = window.confirm( + t("confirm_renameLane", { + from: targetLane.originalTruckLanceCode?.trim() || oldCode, + to: newCode, + unfinished: preview.unfinishedCount, + completed: preview.completedCount, + handlers, + schedules: preview.openScheduleCount, + }), + ); + if (!ok) return; + } catch (e: any) { + window.alert( + renameErrorText(e?.message ?? String(e), t) || t("renameErr_preview"), + ); + return; + } finally { + setDepartureEditBusy(false); + } + } + } + const newId = nameChanged + ? encodeLaneId(newCode, targetLane.remark ?? null) + : targetLane.id; + const nextLanes = lanesRef.current.map((lane) => { + if (lane.id !== targetLane.id) return lane; + const timeChanged = toTimeInputValue(lane.startTime) !== departureEditDraft; + return { + ...lane, + id: newId, + truckLanceCode: nameChanged ? newCode : lane.truckLanceCode, + originalTruckLanceCode: nameChanged + ? lane.originalTruckLanceCode ?? oldCode + : lane.originalTruckLanceCode, + originalStartTime: timeChanged + ? lane.originalStartTime ?? lane.startTime + : lane.originalStartTime, + startTime: backendTime, + shops: lane.shops.map((s) => ({ ...s, departureTime: backendTime })), + }; }); + if (shopIds.length > 0) { + const wr = computeTruckLaneWarnings(lanesToWarningInputRows(nextLanes)); + if (wr.warnings.length > 0) { + const ok = window.confirm( + t("confirm_departureConflict", { count: wr.warnings.length }), + ); + if (!ok) return; + } + } + lanesRef.current = nextLanes; + setLanes(nextLanes); + if (nameChanged) retargetLaneId(targetLane.id, newId, newCode); + if (shopIds.length > 0 && (nameChanged || toTimeInputValue(targetLane.startTime) !== departureEditDraft)) { + setDirtyMoves((prev) => { + const next = new Map(prev); + for (const id of shopIds) { + if (id > 0) next.set(id, newId); + } + return next; + }); + } setSaveResult(null); closeDepartureEdit(); }; @@ -3832,6 +4062,9 @@ const RouteBoard: React.FC = () => { setLoadingVersions(true); try { const arr = await refreshLogVersions(); + void listTruckLaneRenameLogsClient() + .then((rows) => setRenameLogs(Array.isArray(rows) ? rows : [])) + .catch(() => setRenameLogs([])); const head = resolveHeadVersionId(arr); setSelectedLogVersionId(head); if (head != null) { @@ -3922,6 +4155,8 @@ const RouteBoard: React.FC = () => { if (hasOtherPending && !window.confirm(t("confirm_restoreDiscardsEdits"))) return; + if (!window.confirm(t("confirm_restoreKeepsTickets"))) return; + if (!hasOtherPending && pendingRestoreVersionId != null) { laneDisplayOverlayRef.current.clear(); setPendingRestoreVersionId(versionId); @@ -4169,11 +4404,7 @@ const RouteBoard: React.FC = () => { {t("Departure")}: {lane.startTime || "-"} { e.stopPropagation(); openDepartureEdit(lane); }} - disabled={loading || lane.shops.length === 0} + disabled={loading} aria-label={t("departureEditAria")} > @@ -4710,6 +4941,33 @@ const RouteBoard: React.FC = () => { + {renameLogs.length > 0 && ( + + + {t("renameLog_title")} + + {renameLogs.slice(0, 8).map((row) => ( + + {t("renameLog_line", { + when: row.created ?? "", + who: row.createdBy ?? "", + from: row.oldCode, + to: row.newCode, + floor: row.storeId, + tickets: row.ticketCount, + })} + + ))} + + )} {loadingVersions ? ( { > {t("departureDialog_title")} + setDepartureEditNameDraft(e.target.value)} + inputProps={{ maxLength: 50 }} + sx={{ mt: 1 }} + /> + {departureEditLaneId && + normalizeStoreId( + lanes.find((l) => l.id === departureEditLaneId)?.storeId, + ) === "4F" && + String( + lanes.find((l) => l.id === departureEditLaneId)?.remark ?? "", + ).trim() !== "" && ( + + {t("departureDialog_remarkReadonly")}: + {String( + lanes.find((l) => l.id === departureEditLaneId)?.remark ?? "", + ).trim()} + + )} { - - + diff --git a/src/i18n/en/shop.json b/src/i18n/en/shop.json index d0fdfb34..908e6348 100644 --- a/src/i18n/en/shop.json +++ b/src/i18n/en/shop.json @@ -132,8 +132,27 @@ "confirm_restoreDiscardsEdits": "Scheduling a version restore will discard other unsaved board changes (drags, deletes, pending shops/lanes, logistics fields, etc.). Continue?", "confirm_restoreSaveWillDropStaging": "Save will apply the snapshot restore first; other staged edits in this save will be skipped. Continue?", "confirm_schedule_removeShop": "Remove this shop from the lane? It will be scheduled for deletion when you submit.", - "departureDialog_hint": "Applies to all shop rows on this lane; press \"Save changes\" above to persist.", - "departureDialog_title": "Edit departure time", + "departureDialog_hint": "Name and departure apply to every master row on this lane. Press \"Save changes\" to write them. Completed delivery tickets keep the old name.", + "departureDialog_title": "Edit lane", + "departureDialog_nameLabel": "Lane name", + "departureDialog_remarkReadonly": "Remark (not edited here)", + "laneEditTooltip": "Edit lane name and departure time (written when you press Save changes)", + "confirm_weekdayToken": "The weekday token in the lane name will change or become unreadable. 4F assignment matches Mon–Sun inside the name. Apply anyway?", + "confirm_renameLane": "Rename \"{{from}}\" to \"{{to}}\".\n{{unfinished}} unfinished delivery ticket(s) will follow (handlers: {{handlers}}).\n{{completed}} completed ticket(s) keep the old name.\n{{schedules}} open schedule line(s) will change from/to.\nNothing is written until you press Save changes.", + "confirm_restoreKeepsTickets": "Restore puts lane master data, and open schedules that point at the current name, back to this snapshot. Unfinished delivery tickets stay on the current name.", + "rename_noHandlers": "none", + "rename_none": "none", + "rename_savePartial": "Renamed: {{ok}}. Failed: {{fail}}. The failed lane was not written. Successful lanes stay renamed. Refresh, then save again.", + "renameErr_preview": "Could not load ticket and schedule counts, so this edit was not applied.", + "renameErr_LANE_NAME_EMPTY": "Lane name cannot be blank", + "renameErr_LANE_NAME_TOO_LONG": "Lane name is limited to 50 characters", + "renameErr_LANE_NAME_RESERVED": "\"車線-X\" cannot be used or renamed", + "renameErr_LANE_NAME_TAKEN": "This floor and remark already has that lane", + "renameErr_LANE_NAME_REMARK_AMBIGUOUS": "This floor already has the same name with a different remark. Delivery tickets cannot tell them apart.", + "renameErr_LANE_NOT_FOUND": "No live lane rows were found, so nothing was written", + "renameErr_LANE_TIME_INVALID": "Departure time is not a valid time", + "renameLog_title": "Lane renames", + "renameLog_line": "{{when}} {{who}}: {{from}} → {{to}} ({{floor}}, tickets {{tickets}})", "departureEditAria": "Edit departure time", "departureTooltipEditSave": "Edit departure time (saved with \"Save changes\")", "departureTooltipNeedShops": "Add shops before setting departure time", diff --git a/src/i18n/zh/shop.json b/src/i18n/zh/shop.json index 27eee2dd..bfd753d0 100644 --- a/src/i18n/zh/shop.json +++ b/src/i18n/zh/shop.json @@ -200,8 +200,27 @@ "dialog_editLogisticsTitle": "編輯物流公司", "btn_apply": "套用", "addShop_confirm": "確認", - "departureDialog_title": "編輯出車時間", - "departureDialog_hint": "套用至此車線所有店鋪列。", + "departureDialog_title": "編輯車線", + "departureDialog_nameLabel": "車線名稱", + "departureDialog_remarkReadonly": "備註(這次不改)", + "departureDialog_hint": "名稱與出車時間會套用到此車線全部主檔。按上方「儲存更改」才寫入。已完成的出倉票不會改名。", + "laneEditTooltip": "編輯車線名稱與出車時間(按「儲存更改」才寫入)", + "confirm_weekdayToken": "車線名稱裡的星期代碼會改變,或改完後無法辨認。4F 派車是用名稱裡的 Mon–Sun 對日子。仍要套用?", + "confirm_renameLane": "把「{{from}}」改成「{{to}}」。\n未完成出倉票 {{unfinished}} 張會改成新名稱(負責人:{{handlers}})。\n已完成 {{completed}} 張維持舊名稱。\n未完成排程 {{schedules}} 筆的起迄名稱會改。\n按「儲存更改」後才寫入。", + "confirm_restoreKeepsTickets": "還原會把車線主檔,以及指向目前名稱的未完成排程,改回此版本的名稱。未完成的出倉票會留在現在的名稱,不會跟回去。", + "rename_noHandlers": "無", + "rename_none": "無", + "rename_savePartial": "已改名:{{ok}}。失敗:{{fail}}。失敗的那一桶沒有寫入;已經成功的不會自動改回去。請重新整理後再存。", + "renameErr_preview": "讀不到出倉票與排程數量,這次沒有套用。", + "renameErr_LANE_NAME_EMPTY": "車線名稱不可空白", + "renameErr_LANE_NAME_TOO_LONG": "車線名稱最多 50 字", + "renameErr_LANE_NAME_RESERVED": "不能使用或改掉「車線-X」", + "renameErr_LANE_NAME_TAKEN": "同一樓層、同一備註已有這條車線", + "renameErr_LANE_NAME_REMARK_AMBIGUOUS": "同一樓層已有相同名稱但備註不同,出倉票無法分辨,已拒絕", + "renameErr_LANE_NOT_FOUND": "找不到這條車線的主檔,沒有寫入", + "renameErr_LANE_TIME_INVALID": "出車時間格式不正確", + "renameLog_title": "車線改名紀錄", + "renameLog_line": "{{when}} {{who}}:{{from}} → {{to}}({{floor}},票 {{tickets}})", "seqDialog_title": "編輯裝車順序", "logistics_colLaneCount": "{{count}} 條車線", "tooltip_openLaneBoard": "在車線看板開此車線", From f6e2d58e5734d291b0c3ded63634efc035885246 Mon Sep 17 00:00:00 2001 From: tommy Date: Tue, 27 Oct 2026 16:25:32 +0800 Subject: [PATCH 4/9] rename trucklancecode --- src/components/Shop/RouteBoard.tsx | 6 ++++-- src/i18n/en/shop.json | 4 ++-- src/i18n/zh/shop.json | 4 ++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/components/Shop/RouteBoard.tsx b/src/components/Shop/RouteBoard.tsx index 45fc18e9..d966f0b0 100644 --- a/src/components/Shop/RouteBoard.tsx +++ b/src/components/Shop/RouteBoard.tsx @@ -3907,7 +3907,9 @@ const RouteBoard: React.FC = () => { shops: lane.shops.map((s) => ({ ...s, departureTime: backendTime })), }; }); - if (shopIds.length > 0) { + const timeChanged = + toTimeInputValue(targetLane.startTime) !== departureEditDraft; + if (shopIds.length > 0 && timeChanged) { const wr = computeTruckLaneWarnings(lanesToWarningInputRows(nextLanes)); if (wr.warnings.length > 0) { const ok = window.confirm( @@ -4414,7 +4416,7 @@ const RouteBoard: React.FC = () => { openDepartureEdit(lane); }} disabled={loading} - aria-label={t("departureEditAria")} + aria-label={t("laneEditTooltip")} > diff --git a/src/i18n/en/shop.json b/src/i18n/en/shop.json index 908e6348..251a5b69 100644 --- a/src/i18n/en/shop.json +++ b/src/i18n/en/shop.json @@ -132,13 +132,13 @@ "confirm_restoreDiscardsEdits": "Scheduling a version restore will discard other unsaved board changes (drags, deletes, pending shops/lanes, logistics fields, etc.). Continue?", "confirm_restoreSaveWillDropStaging": "Save will apply the snapshot restore first; other staged edits in this save will be skipped. Continue?", "confirm_schedule_removeShop": "Remove this shop from the lane? It will be scheduled for deletion when you submit.", - "departureDialog_hint": "Name and departure apply to every master row on this lane. Press \"Save changes\" to write them. Completed delivery tickets keep the old name.", + "departureDialog_hint": "Name and departure apply to every master row on this lane. Press \"Save changes\" to write them. Existing delivery tickets keep the old name; later releases use the new name.", "departureDialog_title": "Edit lane", "departureDialog_nameLabel": "Lane name", "departureDialog_remarkReadonly": "Remark (not edited here)", "laneEditTooltip": "Edit lane name and departure time (written when you press Save changes)", "confirm_weekdayToken": "The weekday token in the lane name will change or become unreadable. 4F assignment matches Mon–Sun inside the name. Apply anyway?", - "confirm_renameLane": "Rename \"{{from}}\" to \"{{to}}\".\n{{unfinished}} unfinished delivery ticket(s) will follow (handlers: {{handlers}}).\n{{completed}} completed ticket(s) keep the old name.\n{{schedules}} open schedule line(s) will change from/to.\nNothing is written until you press Save changes.", + "confirm_renameLane": "Rename \"{{from}}\" to \"{{to}}\".\n{{unfinished}} unfinished delivery ticket(s) stay on the old name (handlers: {{handlers}}).\n{{completed}} completed ticket(s) stay on the old name.\nLater releases use the new name.\n{{schedules}} open schedule line(s) will change from/to.\nNothing is written until you press Save changes.", "confirm_restoreKeepsTickets": "Restore puts lane master data, and open schedules that point at the current name, back to this snapshot. Unfinished delivery tickets stay on the current name.", "rename_noHandlers": "none", "rename_none": "none", diff --git a/src/i18n/zh/shop.json b/src/i18n/zh/shop.json index bfd753d0..13f1aaa7 100644 --- a/src/i18n/zh/shop.json +++ b/src/i18n/zh/shop.json @@ -203,10 +203,10 @@ "departureDialog_title": "編輯車線", "departureDialog_nameLabel": "車線名稱", "departureDialog_remarkReadonly": "備註(這次不改)", - "departureDialog_hint": "名稱與出車時間會套用到此車線全部主檔。按上方「儲存更改」才寫入。已完成的出倉票不會改名。", + "departureDialog_hint": "名稱與出車時間會套用到此車線全部主檔。按上方「儲存更改」才寫入。既有出倉票維持舊名稱;之後放單才用新名稱。", "laneEditTooltip": "編輯車線名稱與出車時間(按「儲存更改」才寫入)", "confirm_weekdayToken": "車線名稱裡的星期代碼會改變,或改完後無法辨認。4F 派車是用名稱裡的 Mon–Sun 對日子。仍要套用?", - "confirm_renameLane": "把「{{from}}」改成「{{to}}」。\n未完成出倉票 {{unfinished}} 張會改成新名稱(負責人:{{handlers}})。\n已完成 {{completed}} 張維持舊名稱。\n未完成排程 {{schedules}} 筆的起迄名稱會改。\n按「儲存更改」後才寫入。", + "confirm_renameLane": "把「{{from}}」改成「{{to}}」。\n未完成出倉票 {{unfinished}} 張維持舊名稱(負責人:{{handlers}})。\n已完成 {{completed}} 張維持舊名稱。\n之後放單才用新名稱。\n未完成排程 {{schedules}} 筆的起迄名稱會改。\n按「儲存更改」後才寫入。", "confirm_restoreKeepsTickets": "還原會把車線主檔,以及指向目前名稱的未完成排程,改回此版本的名稱。未完成的出倉票會留在現在的名稱,不會跟回去。", "rename_noHandlers": "無", "rename_none": "無", From 299f810a3761a764dee4ececc751efe04943f321 Mon Sep 17 00:00:00 2001 From: tommy Date: Wed, 28 Oct 2026 17:55:48 +0800 Subject: [PATCH 5/9] truck rename --- src/app/api/shop/actions.ts | 2 + .../truckSchedule/TruckScheduleDashboard.tsx | 14 +- src/components/Shop/RouteBoard.tsx | 931 +++++++++++++----- src/i18n/en/routeboard.json | 2 +- src/i18n/en/shop.json | 10 +- src/i18n/zh/routeboard.json | 2 +- src/i18n/zh/shop.json | 12 +- 7 files changed, 700 insertions(+), 273 deletions(-) diff --git a/src/app/api/shop/actions.ts b/src/app/api/shop/actions.ts index d26404e0..aa9bc199 100644 --- a/src/app/api/shop/actions.ts +++ b/src/app/api/shop/actions.ts @@ -965,6 +965,8 @@ export interface TruckLaneRenameLogResponse { oldDepartureTime?: string | null; newDepartureTime?: string | null; ticketCount: number; + /** `truck.id` of shops in the renamed lane, comma-separated. */ + truckIds?: string | null; } export const previewTruckLaneRenameAction = async ( diff --git a/src/components/DashboardPage/truckSchedule/TruckScheduleDashboard.tsx b/src/components/DashboardPage/truckSchedule/TruckScheduleDashboard.tsx index 5c12aad5..55d7f5bb 100644 --- a/src/components/DashboardPage/truckSchedule/TruckScheduleDashboard.tsx +++ b/src/components/DashboardPage/truckSchedule/TruckScheduleDashboard.tsx @@ -203,13 +203,13 @@ const TruckScheduleDashboard: React.FC = () => { return '-'; }; - // Compare a timestamp against the selected date (today/tomorrow/...) and return a short label. - // Used to avoid confusion when picking crosses midnight. + // Compare a timestamp against calendar today. Same-day picks show no chip; + // only midnight-crossing (yesterday / tomorrow / N days) gets a label. + // Does not filter rows — trucklane tickets for the selected delivery date still show. const relativeDayLabel = useCallback( - (dateTimeData: string | number[] | null, dateOption: string): string | null => { + (dateTimeData: string | number[] | null): string | null => { if (!dateTimeData) return null; - const dateOffset = getDateOffset(dateOption); - const baseDate = dayjs().startOf('day').add(dateOffset, 'day'); + const baseDate = dayjs().startOf('day'); let dt: dayjs.Dayjs; if (Array.isArray(dateTimeData)) { @@ -518,8 +518,8 @@ const TruckScheduleDashboard: React.FC = () => { filteredData.map((row, index) => { const timeRemaining = calculateTimeRemaining(row, selectedDate); const chipColor = getTimeChipColor(row, selectedDate); - const startRel = relativeDayLabel(row.firstTicketStartTime, selectedDate); - const endRel = relativeDayLabel(row.lastTicketEndTime, selectedDate); + const startRel = relativeDayLabel(row.firstTicketStartTime); + const endRel = relativeDayLabel(row.lastTicketEndTime); return ( { return "00:00"; }; +const laneFilterBtnSx = { + minWidth: 0, + px: 1, + py: 0.25, + textTransform: "none" as const, + fontWeight: 700, + fontSize: "0.75rem", + lineHeight: 1.4, +}; + +/** Inject filter side panel beside Select MenuList without wrapping MenuItems (breaks selection). */ +const LaneSelectMenuExtrasContext = React.createContext<{ + open: boolean; + panel: React.ReactNode; +}>({ open: false, panel: null }); + +const LaneSelectMenuPaper = React.forwardRef< + HTMLDivElement, + React.ComponentProps +>(function LaneSelectMenuPaper(props, ref) { + const { open, panel } = React.useContext(LaneSelectMenuExtrasContext); + const { children, sx, ...other } = props; + return ( + + {children} + {open ? panel : null} + + ); +}); + +/** Single-select facet as wrap buttons (lane filter panel). + * No "全部" chip — click selected again to clear that facet; use Clear all for all. */ +function LaneFilterFacetButtons(props: { + label: string; + value: string; + options: string[]; + formatOption?: (v: string) => string; + onChange: (v: string) => void; +}) { + const { label, value, options, formatOption, onChange } = props; + return ( + + + {label} + + + {options.map((opt) => ( + + ))} + + + ); +} + +/** Multi-select facet (地區). Empty selection = no area filter. */ +function LaneFilterFacetMultiButtons(props: { + label: string; + values: string[]; + options: string[]; + formatOption?: (v: string) => string; + onChange: (v: string[]) => void; +}) { + const { label, values, options, formatOption, onChange } = props; + const selected = new Set(values); + return ( + + + {label} + + + {options.map((opt) => { + const on = selected.has(opt); + return ( + + ); + })} + + + ); +} + +/** 地區按鈕排序:港島 → 九龍 → 新界東 → 新界西 → 離島;未知名排後端。 */ +const HK_AREA_SORT_RANK: Record = { + 中環: 10, + 上環: 11, + 西環: 12, + 灣仔: 13, + 銅鑼灣: 14, + 北角: 15, + 鰂魚涌: 16, + 太古: 17, + 西灣河: 18, + 筲箕灣: 19, + 柴灣: 20, + 香港仔: 21, + 鴨脷洲: 22, + 薄扶林: 23, + 赤柱: 24, + 九龍: 100, + 尖沙咀: 101, + 油麻地: 102, + 旺角: 103, + 大角咀: 104, + 深水埗: 105, + 長沙灣: 106, + 荔枝角: 107, + 美孚: 108, + 紅磡: 109, + 土瓜灣: 110, + 九龍城: 111, + 新蒲崗: 112, + 黃大仙: 113, + 鑽石山: 114, + 慈雲山: 115, + 觀塘: 116, + 牛頭角: 117, + 藍田: 118, + 油塘: 119, + 九龍灣: 120, + 彩虹: 121, + 樂富: 122, + 沙田: 200, + 大圍: 201, + 火炭: 202, + 馬鞍山: 203, + 大埔: 204, + 粉嶺: 205, + 上水: 206, + 打鼓嶺: 207, + 西貢: 208, + 將軍澳: 209, + 坑口: 210, + 調景嶺: 211, + 荃灣: 300, + 葵涌: 301, + 青衣: 302, + 葵芳: 303, + 屯門: 304, + 元朗: 305, + 天水圍: 306, + 洪水橋: 307, + 錦田: 308, + 流浮山: 309, + 東涌: 400, + 大嶼山: 401, + 長洲: 402, + 南丫島: 403, + 坪洲: 404, + 機場: 405, +}; + +function compareLaneAreaName(a: string, b: string): number { + const ra = HK_AREA_SORT_RANK[a] ?? 9000; + const rb = HK_AREA_SORT_RANK[b] ?? 9000; + if (ra !== rb) return ra - rb; + return a.localeCompare(b, "zh-Hant"); +} + /** 物流商總覽欄標題用:條數、店數、樓層 */ function summarizeLogisticsColumnStats(lanes: Lane[]): { laneCount: number; @@ -395,6 +604,99 @@ function decodeLaneId(laneId: string): { } } +const LANE_WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] as const; + +type LanePickFacets = { + special: string; + zones: string[]; + weekdays: string[]; + areas: string[]; +}; + +function canonWeekdayToken(token: string): string | null { + const key = token.trim().toLowerCase(); + return LANE_WEEKDAYS.find((day) => day.toLowerCase() === key) ?? null; +} + +/** 車線-特12 → 特12;4F 的 P06B_Mon_區7_青衣,東涌 → 星期、區、地區。備註裡的星期一併算。 */ +function parseLanePickFacets( + codeRaw: string, + remarkRaw: string | null | undefined, +): LanePickFacets { + const code = String(codeRaw || "").trim(); + const remark = String(remarkRaw ?? "").trim(); + const special = code.startsWith("車線-") + ? code.slice("車線-".length).trim() + : ""; + const weekdays = new Set(); + const zones = new Set(); + const areas = new Set(); + + const scan = (text: string) => { + if (!text || text.startsWith("車線-")) return; + for (const part of text.split("_")) { + const token = part.trim(); + if (!token) continue; + const weekday = canonWeekdayToken(token); + if (weekday) { + weekdays.add(weekday); + continue; + } + const zone = /^區\d+/.exec(token)?.[0]; + if (zone && zone === token) { + zones.add(zone); + continue; + } + if (/^[A-Za-z0-9]+$/.test(token)) continue; + for (const area of token.split(/[,,]/)) { + const name = area.trim(); + if (name) areas.add(name); + } + } + }; + + if (!special) scan(code); + scan(remark); + return { + special, + zones: [...zones], + weekdays: [...weekdays], + areas: [...areas], + }; +} + +function compareSpecialLaneToken(a: string, b: string): number { + const na = /^特(\d+)$/.exec(a); + const nb = /^特(\d+)$/.exec(b); + if (na && nb) return Number(na[1]) - Number(nb[1]); + if (na) return -1; + if (nb) return 1; + return a.localeCompare(b, "zh-Hant"); +} + +function laneMatchesFacetFilter( + lane: { truckLanceCode: string; remark?: string | null }, + filter: { + special: string; + zone: string; + weekday: string; + area: string[]; + }, +): boolean { + const facets = parseLanePickFacets(lane.truckLanceCode, lane.remark); + if (filter.special !== "all" && facets.special !== filter.special) return false; + if (filter.zone !== "all" && !facets.zones.includes(filter.zone)) return false; + if (filter.weekday !== "all" && !facets.weekdays.includes(filter.weekday)) + return false; + if ( + filter.area.length > 0 && + !filter.area.some((a) => facets.areas.includes(a)) + ) { + return false; + } + return true; +} + function lanesToWarningInputRows(lanes: Lane[]): TruckLaneWarningInputRow[] { const out: TruckLaneWarningInputRow[] = []; for (const lane of lanes) { @@ -888,13 +1190,20 @@ const RouteBoard: React.FC = () => { const [laneFilter, setLaneFilter] = useState<{ floor: "all" | "2F" | "4F"; query: string; + special: string; + zone: string; + weekday: string; + /** Empty = no area filter; otherwise OR-match any selected 地區. */ + area: string[]; }>({ floor: "all", query: "", + special: "all", + zone: "all", + weekday: "all", + area: [], }); - const [laneFilterAnchor, setLaneFilterAnchor] = useState( - null, - ); + const [laneFilterPanelOpen, setLaneFilterPanelOpen] = useState(false); // drag state (HTML5 drag & drop) const draggedRef = useRef<{ shopId: number; fromLaneId: string } | null>( @@ -974,9 +1283,6 @@ const RouteBoard: React.FC = () => { const [departureEditDraft, setDepartureEditDraft] = useState(""); const [departureEditNameDraft, setDepartureEditNameDraft] = useState(""); const [departureEditBusy, setDepartureEditBusy] = useState(false); - const [renameLogs, setRenameLogs] = useState( - [], - ); const [seqEditTarget, setSeqEditTarget] = useState<{ laneId: string; shopId: number; @@ -1955,6 +2261,36 @@ const RouteBoard: React.FC = () => { })); }, [lanes, selectedLaneIds, searchTerm, shopNameByCodeMap]); + const laneFilterChoices = useMemo(() => { + const specials = new Set(); + const zones = new Set(); + const weekdays = new Set(); + const areas = new Set(); + for (const lane of lanes || []) { + if ( + laneFilter.floor !== "all" && + normalizeStoreId(lane.storeId) !== laneFilter.floor + ) { + continue; + } + const facets = parseLanePickFacets(lane.truckLanceCode, lane.remark); + if (facets.special) specials.add(facets.special); + for (const zone of facets.zones) zones.add(zone); + for (const day of facets.weekdays) weekdays.add(day); + for (const area of facets.areas) areas.add(area); + } + return { + specials: [...specials].sort(compareSpecialLaneToken), + zones: [...zones].sort((a, b) => { + const na = Number(/^區(\d+)/.exec(a)?.[1] ?? 0); + const nb = Number(/^區(\d+)/.exec(b)?.[1] ?? 0); + return na - nb || a.localeCompare(b, "zh-Hant"); + }), + weekdays: LANE_WEEKDAYS.filter((day) => weekdays.has(day)), + areas: [...areas].sort(compareLaneAreaName), + }; + }, [lanes, laneFilter.floor]); + const visibleLaneOptions = useMemo(() => { const q = String(laneFilter.query || "") .trim() @@ -1967,20 +2303,20 @@ const RouteBoard: React.FC = () => { ? true : normalizeStoreId(lane.storeId) === laneFilter.floor; const qOk = !q ? true : code.includes(q) || rem.includes(q); - return floorOk && qOk; + return floorOk && qOk && laneMatchesFacetFilter(lane, laneFilter); }); - }, [lanes, laneFilter.floor, laneFilter.query]); + }, [lanes, laneFilter]); - /** 「+」與看板欄:只吃樓層,不吃左欄關鍵字(與 `visibleLaneOptions` 分離) */ + /** 「+」與看板欄:吃樓層與篩選面板,不吃左欄關鍵字 */ const lanesMatchingFloorOnly = useMemo(() => { return (lanes || []).filter((lane) => { const floorOk = laneFilter.floor === "all" ? true : normalizeStoreId(lane.storeId) === laneFilter.floor; - return floorOk; + return floorOk && laneMatchesFacetFilter(lane, laneFilter); }); - }, [lanes, laneFilter.floor]); + }, [lanes, laneFilter]); const boardQuickPickFilteredLanes = useMemo(() => { const q = String(boardQuickPickSearch).trim().toLowerCase(); @@ -3608,7 +3944,7 @@ const RouteBoard: React.FC = () => { try { await createTruckLaneSnapshotClient({ truckLanceCode: null, - note: "board save", + note: null, }); await refreshLogVersions(); } catch (snapErr: any) { @@ -4064,9 +4400,6 @@ const RouteBoard: React.FC = () => { setLoadingVersions(true); try { const arr = await refreshLogVersions(); - void listTruckLaneRenameLogsClient() - .then((rows) => setRenameLogs(Array.isArray(rows) ? rows : [])) - .catch(() => setRenameLogs([])); const head = resolveHeadVersionId(arr); setSelectedLogVersionId(head); if (head != null) { @@ -4943,33 +5276,6 @@ const RouteBoard: React.FC = () => { - {renameLogs.length > 0 && ( - - - {t("renameLog_title")} - - {renameLogs.slice(0, 8).map((row) => ( - - {t("renameLog_line", { - when: row.created ?? "", - who: row.createdBy ?? "", - from: row.oldCode, - to: row.newCode, - floor: row.storeId, - tickets: row.ticketCount, - })} - - ))} - - )} {loadingVersions ? ( { inputProps={{ maxLength: 50 }} sx={{ mt: 1 }} /> - {departureEditLaneId && - normalizeStoreId( - lanes.find((l) => l.id === departureEditLaneId)?.storeId, - ) === "4F" && - String( - lanes.find((l) => l.id === departureEditLaneId)?.remark ?? "", - ).trim() !== "" && ( - - {t("departureDialog_remarkReadonly")}: - {String( - lanes.find((l) => l.id === departureEditLaneId)?.remark ?? "", - ).trim()} - - )} { ).length; const filterActive = laneFilter.floor !== "all" || + laneFilter.special !== "all" || + laneFilter.zone !== "all" || + laneFilter.weekday !== "all" || + laneFilter.area.length > 0 || String(laneFilter.query || "").trim() !== ""; + const laneFilterSidePanel = ( + e.stopPropagation()} + onMouseDown={(e) => { + e.stopPropagation(); + e.preventDefault(); + }} + onKeyDown={(e) => e.stopPropagation()} + > + + + { + const next = floor as "all" | "2F" | "4F"; + setLaneFilter((prev) => ({ + ...prev, + floor: next, + special: next === "4F" ? "all" : prev.special, + zone: next === "2F" ? "all" : prev.zone, + weekday: next === "2F" ? "all" : prev.weekday, + area: next === "2F" ? [] : prev.area, + })); + }} + /> + {laneFilter.floor !== "4F" && + laneFilterChoices.specials.length > 0 && ( + `車線-${token}`} + onChange={(special) => + setLaneFilter((prev) => ({ + ...prev, + special, + })) + } + /> + )} + {laneFilter.floor !== "2F" && + laneFilterChoices.zones.length > 0 && ( + + setLaneFilter((prev) => ({ ...prev, zone })) + } + /> + )} + {laneFilter.floor !== "2F" && + laneFilterChoices.weekdays.length > 0 && ( + + setLaneFilter((prev) => ({ + ...prev, + weekday, + })) + } + /> + )} + {laneFilter.floor !== "2F" && + laneFilterChoices.areas.length > 0 && ( + + setLaneFilter((prev) => ({ ...prev, area })) + } + /> + )} + + + + + + + + ); + return ( { {t("lane_selectTitle")} - - - setLaneFilterAnchor(null)} - anchorOrigin={{ vertical: "bottom", horizontal: "left" }} - transformOrigin={{ vertical: "top", horizontal: "left" }} - > - - - - {t("floor_label")} - - - - - - - - - + + {routeBoardTab === "board" && ( diff --git a/src/components/ProductionProcess/ProductionProcessDetail.tsx b/src/components/ProductionProcess/ProductionProcessDetail.tsx index 7700330f..2c30fa27 100644 --- a/src/components/ProductionProcess/ProductionProcessDetail.tsx +++ b/src/components/ProductionProcess/ProductionProcessDetail.tsx @@ -64,13 +64,16 @@ interface ProductProcessDetailProps { jobOrderId: number; onBack: () => void; fromJosave?: boolean; + /** 進入步驟執行頁時通知父層(用來隱藏「返回列表」) */ + onStepViewChange?: (inStepView: boolean) => void; } -/** FP-MTMS Version Checklist | Functions Ref. No. 50 | v1.0.1 | 2026-08-10 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 50 | v1.0.2 | 2026-10-29 */ const ProductionProcessDetail: React.FC = ({ jobOrderId, onBack, fromJosave, + onStepViewChange, }) => { // console.log(" ProductionProcessDetail RENDER", { jobOrderId, fromJosave }); @@ -143,6 +146,12 @@ const fetchProcessDetailRef = useRef<() => Promise>(); useEffect(() => { onBackRef.current = onBack; }, [onBack]); + + useEffect(() => { + const inStepView = isExecutingLine && selectedLineId != null; + onStepViewChange?.(inStepView); + return () => onStepViewChange?.(false); + }, [isExecutingLine, selectedLineId, onStepViewChange]); // 获取 process 和 lines 数据 const fetchProcessDetail = useCallback(async () => { // console.log(" fetchProcessDetail CALLED", { jobOrderId, timestamp: new Date().toISOString() }); diff --git a/src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx b/src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx index 78818a53..8fb026bc 100644 --- a/src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx +++ b/src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx @@ -54,7 +54,7 @@ interface ProductProcessJobOrderDetailProps { initialTabIndex?: number; } -/** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.7 | 2026-08-06 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.11 | 2026-10-29 */ const ProductionProcessJobOrderDetail: React.FC = ({ jobOrderId, onBack, @@ -68,6 +68,7 @@ const ProductionProcessJobOrderDetail: React.FC([]); const [tabIndex, setTabIndex] = useState(initialTabIndex); const [selectedProcessId, setSelectedProcessId] = useState(null); + const [inStepView, setInStepView] = useState(false); const [operationPriority, setOperationPriority] = useState(50); const [openOperationPriorityDialog, setOpenOperationPriorityDialog] = useState(false); const [openPlanStartDialog, setOpenPlanStartDialog] = useState(false); @@ -320,6 +321,9 @@ const handleRelease = useCallback(async (jobOrderId: number) => { const handleTabChange = useCallback>( (_e, newValue) => { setTabIndex(newValue); + if (newValue !== 2) { + setInStepView(false); + } }, [], ); @@ -796,12 +800,14 @@ const handleRelease = useCallback(async (jobOrderId: number) => { return ( - {/* 返回按钮 */} - - - + {/* 步驟執行頁不顯示「返回列表」,避免誤離開工單 */} + {!inStepView && ( + + + + )} {/* 标签页 */} @@ -833,6 +839,7 @@ const handleRelease = useCallback(async (jobOrderId: number) => { }} fromJosave={Boolean(fromJosave && !isPutAwayed)} + onStepViewChange={setInStepView} /> )} {tabIndex === 3 && } diff --git a/src/components/ProductionProcess/ProductionProcessList.tsx b/src/components/ProductionProcess/ProductionProcessList.tsx index 38667525..48d6d8d1 100644 --- a/src/components/ProductionProcess/ProductionProcessList.tsx +++ b/src/components/ProductionProcess/ProductionProcessList.tsx @@ -179,7 +179,7 @@ function isWaitingQcPutAway( return s !== "completed" && s !== "rejected"; } -/** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.10 | 2026-10-15 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.11 | 2026-10-29 */ const ProductProcessList: React.FC = ({ onSelectProcess, printerCombo, diff --git a/src/components/ProductionProcess/ProductionProcessStepExecution.tsx b/src/components/ProductionProcess/ProductionProcessStepExecution.tsx index 096acbb5..3fa51bff 100644 --- a/src/components/ProductionProcess/ProductionProcessStepExecution.tsx +++ b/src/components/ProductionProcess/ProductionProcessStepExecution.tsx @@ -28,6 +28,7 @@ import CheckCircleIcon from "@mui/icons-material/CheckCircle"; import StopIcon from "@mui/icons-material/Stop"; import PauseIcon from "@mui/icons-material/Pause"; import PlayArrowIcon from "@mui/icons-material/PlayArrow"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import { useTranslation } from "react-i18next"; import { JobOrderProcessLineDetailResponse, @@ -47,6 +48,35 @@ import { fetchNameList, NameList } from "@/app/api/user/actions"; import BagConsumptionForm from "./BagConsumptionForm"; // ✅ 添加导入 import OverallTimeRemainingCard from "./OverallTimeRemainingCard"; // ✅ 添加导入 import dayjs from "dayjs"; + +type QtyInput = number | ""; + +type OutputFormState = Omit< + UpdateProductProcessLineQtyRequest, + | "outputFromProcessQty" + | "byproductQty" + | "defectQty" + | "defect2Qty" + | "defect3Qty" + | "scrapQty" +> & { + outputFromProcessQty: QtyInput; + byproductQty: QtyInput; + defectQty: QtyInput; + defect2Qty: QtyInput; + defect3Qty: QtyInput; + scrapQty: QtyInput; +}; + +const parseQtyInput = (raw: string): QtyInput => { + if (raw === "") return ""; + const n = parseInt(raw, 10); + return Number.isNaN(n) ? "" : n; +}; + +const toSubmitQty = (v: QtyInput | null | undefined): number => + v === "" || v == null || Number.isNaN(Number(v)) ? 0 : Number(v); + interface ProductionProcessStepExecutionProps { lineId: number | null; onBack: () => void; @@ -55,7 +85,7 @@ interface ProductionProcessStepExecutionProps { jobOrderId?: number; // ✅ 添加 } -/** FP-MTMS Version Checklist | Functions Ref. No. 59 | v1.0.0 | 2026-08-10 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 59 | v1.0.1 | 2026-10-29 */ const ProductionProcessStepExecution: React.FC = ({ lineId, onBack, @@ -75,24 +105,20 @@ const ProductionProcessStepExecution: React.FC({ + const [outputData, setOutputData] = useState({ productProcessLineId: lineId ?? 0, - outputFromProcessQty: 0, + outputFromProcessQty: "", outputFromProcessUom: "", - defectQty: 0, + defectQty: "", defectUom: "", - scrapQty: 0, + scrapQty: "", scrapUom: "", byproductName: "", - byproductQty: 0, + byproductQty: "", byproductUom: "", - defect2Qty: 0, + defect2Qty: "", defect2Uom: "", - defect3Qty: 0, + defect3Qty: "", defect3Uom: "", defectDescription: "", defectDescription2: "", @@ -153,14 +179,14 @@ const ProductionProcessStepExecution: React.FC ({ ...prev, productProcessLineId: detail.id, - outputFromProcessQty: (detail as any).outputFromProcessQty || 0, + outputFromProcessQty: (detail as any).outputFromProcessQty || "", outputFromProcessUom: (detail as any).outputFromProcessUom || "", - defectQty: detail.defectQty || 0, + defectQty: detail.defectQty || "", defectUom: detail.defectUom || "", - scrapQty: detail.scrapQty || 0, + scrapQty: detail.scrapQty || "", scrapUom: detail.scrapUom || "", byproductName: detail.byproductName || "", - byproductQty: detail.byproductQty || 0, + byproductQty: detail.byproductQty || "", byproductUom: detail.byproductUom || "" })); }) @@ -351,20 +377,20 @@ const ProductionProcessStepExecution: React.FC ({ ...prev, productProcessLineId: detail.id, - outputFromProcessQty: (detail as any).outputFromProcessQty || 0, + outputFromProcessQty: (detail as any).outputFromProcessQty || "", outputFromProcessUom: (detail as any).outputFromProcessUom || "", - defectQty: detail.defectQty || 0, + defectQty: detail.defectQty || "", defectUom: detail.defectUom || "", defectDescription: detail.defectDescription || "", defectDescription2: detail.defectDescription2 || "", defectDescription3: detail.defectDescription3 || "", - defectQty2: detail.defectQty2 || 0, - defectUom2: detail.defectUom2 || "", - defectQty3: detail.defectQty3 || 0, - defectUom3: detail.defectUom3 || "", - scrapQty: detail.scrapQty || 0, + defect2Qty: (detail as any).defect2Qty || "", + defect2Uom: (detail as any).defect2Uom || "", + defect3Qty: (detail as any).defect3Qty || "", + defect3Uom: (detail as any).defect3Uom || "", + scrapQty: detail.scrapQty || "", scrapUom: detail.scrapUom || "", byproductName: detail.byproductName || "", - byproductQty: detail.byproductQty || 0, + byproductQty: detail.byproductQty || "", byproductUom: detail.byproductUom || "" })); }) @@ -561,8 +587,13 @@ const ProductionProcessStepExecution: React.FC - {processData && ( @@ -812,7 +843,7 @@ const ProductionProcessStepExecution: React.FC setOutputData({ ...outputData, - outputFromProcessQty: parseInt(e.target.value) || 0 + outputFromProcessQty: parseQtyInput(e.target.value) })} /> @@ -854,7 +885,7 @@ const ProductionProcessStepExecution: React.FC setOutputData({ ...outputData, - defectQty: parseInt(e.target.value) || 0 + defectQty: parseQtyInput(e.target.value) })} /> @@ -902,7 +933,7 @@ const ProductionProcessStepExecution: React.FC setOutputData({ ...outputData, - defect2Qty: parseInt(e.target.value) || 0 + defect2Qty: parseQtyInput(e.target.value) })} /> @@ -950,7 +981,7 @@ const ProductionProcessStepExecution: React.FC setOutputData({ ...outputData, - defect3Qty: parseInt(e.target.value) || 0 + defect3Qty: parseQtyInput(e.target.value) })} /> @@ -998,7 +1029,7 @@ const ProductionProcessStepExecution: React.FC setOutputData({ ...outputData, - scrapQty: parseInt(e.target.value) || 0 + scrapQty: parseQtyInput(e.target.value) })} /> diff --git a/src/i18n/en/jo.json b/src/i18n/en/jo.json index 06a212c3..dcfa37ea 100644 --- a/src/i18n/en/jo.json +++ b/src/i18n/en/jo.json @@ -32,6 +32,8 @@ "BOM Type": "BOM Type", "Back": "Back", "Back to List": "Back to List", + "Back to Process": "Back to Process Steps", + "Back to Process Steps": "Back to Process Steps", "Bad Item Qty": "Bad Item Qty", "Bad Package Qty": "Bad Package Qty", "Bag": "Bag", diff --git a/src/i18n/en/productionProcess.json b/src/i18n/en/productionProcess.json index efdff3e8..17f8b3c1 100644 --- a/src/i18n/en/productionProcess.json +++ b/src/i18n/en/productionProcess.json @@ -10,6 +10,8 @@ "Auto-refresh every 10 minutes": "Auto-refresh every 10 minutes", "Back": "Back", "Back to List": "Back to List", + "Back to Process": "Back to Process Steps", + "Back to Process Steps": "Back to Process Steps", "Bag": "Bag", "Bag Consumption": "Bag Consumption", "Balance": "Balance", diff --git a/src/i18n/zh/jo.json b/src/i18n/zh/jo.json index 84135db3..ffe12d1a 100644 --- a/src/i18n/zh/jo.json +++ b/src/i18n/zh/jo.json @@ -34,6 +34,8 @@ "BOM Type": "BOM 類型", "Back": "返回", "Back to List": "返回列表", + "Back to Process": "返回列表", + "Back to Process Steps": "返回列表", "Bad Item Qty": "不良物品數量", "Bad Package Qty": "不良包裝數量", "Bag": "包裝袋", diff --git a/src/i18n/zh/productionProcess.json b/src/i18n/zh/productionProcess.json index 814ff3fc..d0130616 100644 --- a/src/i18n/zh/productionProcess.json +++ b/src/i18n/zh/productionProcess.json @@ -10,6 +10,8 @@ "Auto-refresh every 10 minutes": "每10分鐘自動刷新", "Back": "返回", "Back to List": "返回列表", + "Back to Process": "返回列表", + "Back to Process Steps": "返回列表", "Bag": "包裝袋", "Bag Consumption": "包裝袋消耗", "Balance": "可用數量", From c0738db99770b61632c36063cfd3be3f729867b9 Mon Sep 17 00:00:00 2001 From: "kelvin.yau" Date: Fri, 30 Oct 2026 00:23:13 +0800 Subject: [PATCH 7/9] type fix --- src/components/Shop/RouteBoard.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/components/Shop/RouteBoard.tsx b/src/components/Shop/RouteBoard.tsx index 1ccf20bf..4a0d451a 100644 --- a/src/components/Shop/RouteBoard.tsx +++ b/src/components/Shop/RouteBoard.tsx @@ -378,7 +378,7 @@ function LaneFilterFacetMultiButtons(props: { const next = new Set(selected); if (on) next.delete(opt); else next.add(opt); - onChange([...next]); + onChange(Array.from(next)); }} sx={laneFilterBtnSx} > @@ -659,9 +659,9 @@ function parseLanePickFacets( scan(remark); return { special, - zones: [...zones], - weekdays: [...weekdays], - areas: [...areas], + zones: Array.from(zones), + weekdays: Array.from(weekdays), + areas: Array.from(areas), }; } @@ -2280,14 +2280,14 @@ const RouteBoard: React.FC = () => { for (const area of facets.areas) areas.add(area); } return { - specials: [...specials].sort(compareSpecialLaneToken), - zones: [...zones].sort((a, b) => { + specials: Array.from(specials).sort(compareSpecialLaneToken), + zones: Array.from(zones).sort((a, b) => { const na = Number(/^區(\d+)/.exec(a)?.[1] ?? 0); const nb = Number(/^區(\d+)/.exec(b)?.[1] ?? 0); return na - nb || a.localeCompare(b, "zh-Hant"); }), weekdays: LANE_WEEKDAYS.filter((day) => weekdays.has(day)), - areas: [...areas].sort(compareLaneAreaName), + areas: Array.from(areas).sort(compareLaneAreaName), }; }, [lanes, laneFilter.floor]); From 11d133562dd8cf5495b9163f73e2a8fe13af643d Mon Sep 17 00:00:00 2001 From: "kelvin.yau" Date: Fri, 30 Oct 2026 00:26:13 +0800 Subject: [PATCH 8/9] type fix 2 --- src/components/Shop/RouteBoard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Shop/RouteBoard.tsx b/src/components/Shop/RouteBoard.tsx index 4a0d451a..4a1af874 100644 --- a/src/components/Shop/RouteBoard.tsx +++ b/src/components/Shop/RouteBoard.tsx @@ -4090,7 +4090,7 @@ const RouteBoard: React.FC = () => { if (oldId === newId) return; setDirtyMoves((prev) => { const next = new Map(prev); - for (const [shopId, laneId] of next) { + for (const [shopId, laneId] of Array.from(next.entries())) { if (laneId === oldId) next.set(shopId, newId); } return next; From a9253fb9f439a8c2be094ea8fa0a0aff9dcfc610 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Mon, 2 Nov 2026 14:51:52 +0800 Subject: [PATCH 9/9] =?UTF-8?q?=E5=B7=A5=E5=96=AE=E6=8F=90=E8=B2=A8?= =?UTF-8?q?=E5=8F=B0=E6=89=B9=E8=99=9F=E6=A8=99=E7=B1=A4=E6=B8=85=E5=96=AE?= =?UTF-8?q?=E6=8E=92=E9=99=A4=E5=85=A8=E9=83=A8=204F=20=E5=80=89=E4=BD=8D?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../WorkbenchLotLabelPrintModal.tsx | 27 ++++++++++++++----- .../JoWorkbench/newJobPickExecution.tsx | 3 ++- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx b/src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx index 1d477005..7ed3bbc8 100644 --- a/src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx +++ b/src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx @@ -91,6 +91,12 @@ export interface WorkbenchLotLabelPrintModalProps { /** 與 statusTitleText 搭配;預設 error(舊版固定紅字) */ statusTitleSeverity?: "success" | "warning" | "error"; warehouseCodePrefixFilter?: string; + /** + * Hide lots whose warehouse code equals an entry or starts with `{entry}-`. + * JO passes `4F` so the pick-station list matches the suggestion block list. + * The row that opened the dialog stays visible. + */ + excludeWarehouseCodePrefixes?: 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. @@ -147,7 +153,7 @@ function isLabelPrinter(p: Printer): boolean { return s.includes("label") && !s.includes("a4"); } -/** FP-MTMS Version Checklist | Functions Ref. No. 30 | v1.0.1 | 2026-07-22 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 30 | v1.0.2 | 2026-11-02 */ const WorkbenchLotLabelPrintModal: React.FC = ({ open, onClose, @@ -159,6 +165,7 @@ const WorkbenchLotLabelPrintModal: React.FC = statusTitleText, statusTitleSeverity = "error", warehouseCodePrefixFilter, + excludeWarehouseCodePrefixes, hideTriggeredLot = false, triggerLotAvailableQty = null, triggerLotUom = null, @@ -450,14 +457,22 @@ const WorkbenchLotLabelPrintModal: React.FC = ]); const filteredLots = useMemo(() => { - const prefix = String(warehouseCodePrefixFilter ?? "").trim(); - if (!prefix) return availableLots; + const prefix = String(warehouseCodePrefixFilter ?? "").trim().toUpperCase(); + const blocked = (excludeWarehouseCodePrefixes ?? []) + .map((entry) => entry.trim().toUpperCase()) + .filter((entry) => entry.length > 0); return availableLots.filter((lot) => { - // 使用者從本列開啟視窗:即使 API 未帶 warehouseCode,仍應顯示目前這筆批號 + // 使用者從本列開啟視窗:即使倉位在排除清單,仍顯示目前這筆批號 if (lot._scanned) return true; - return String(lot.warehouseCode ?? "").startsWith(prefix); + const code = String(lot.warehouseCode ?? "").trim().toUpperCase(); + const blockedHit = blocked.some( + (entry) => code === entry || code.startsWith(`${entry}-`), + ); + if (blockedHit) return false; + if (!prefix) return true; + return code.startsWith(prefix); }); - }, [availableLots, warehouseCodePrefixFilter]); + }, [availableLots, warehouseCodePrefixFilter, excludeWarehouseCodePrefixes]); const selectedPrinter = useMemo(() => { if (selectedPrinterId === "") return null; diff --git a/src/components/JoWorkbench/newJobPickExecution.tsx b/src/components/JoWorkbench/newJobPickExecution.tsx index e62f9d5a..dc51879e 100644 --- a/src/components/JoWorkbench/newJobPickExecution.tsx +++ b/src/components/JoWorkbench/newJobPickExecution.tsx @@ -618,7 +618,7 @@ const QrCodeModal: React.FC<{ ); }; -/** FP-MTMS Version Checklist | Functions Ref. No. 43 | v1.0.1 | 2026-08-10 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 43 | v1.0.2 | 2026-11-02 */ const JobPickExecution: React.FC = ({ filterArgs, onBackToList, printerCombo = [] }) => { const workbenchMode = true; const { t } = useTranslation("jo"); @@ -4642,6 +4642,7 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList, printerCo workbenchLotLabelContextLot != null } warehouseCodePrefixFilter={lotFloorPrefixFilter} + excludeWarehouseCodePrefixes={["4F"]} triggerLotAvailableQty={ workbenchLotLabelContextLot != null ? Number(workbenchLotLabelContextLot.availableQty)