From 7fabcc3e37bf6bb5d480798a314a39347c79272d Mon Sep 17 00:00:00 2001 From: tommy Date: Wed, 10 Jun 2026 20:37:36 +0800 Subject: [PATCH 1/5] transaltion --- src/components/Shop/RouteBoard.tsx | 3 --- src/i18n/en/pickOrder.json | 2 ++ src/i18n/en/routeboard.json | 1 - src/i18n/zh/pickOrder.json | 2 ++ src/i18n/zh/routeboard.json | 9 ++++----- 5 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/components/Shop/RouteBoard.tsx b/src/components/Shop/RouteBoard.tsx index 3313fe0..1c609b0 100644 --- a/src/components/Shop/RouteBoard.tsx +++ b/src/components/Shop/RouteBoard.tsx @@ -5982,9 +5982,6 @@ const RouteBoard: React.FC = () => { {addRouteError} )} - - {t("addRoute_hint")} - Date: Wed, 10 Jun 2026 20:50:05 +0800 Subject: [PATCH 2/5] truck routeboard default navigation --- src/components/NavigationContent/NavigationContent.tsx | 9 ++++++++- src/components/Shop/ShopDetail.tsx | 6 +++--- src/components/Shop/TruckLaneDetail.tsx | 2 +- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/components/NavigationContent/NavigationContent.tsx b/src/components/NavigationContent/NavigationContent.tsx index d020e51..6e0ee6e 100644 --- a/src/components/NavigationContent/NavigationContent.tsx +++ b/src/components/NavigationContent/NavigationContent.tsx @@ -363,7 +363,7 @@ const NavigationContent: React.FC = () => { id: "nav.settings.shopAndTruck", icon: , labelKey: "nav.settings.shopAndTruck", - path: "/settings/shop", + path: "/settings/shop/board", }, { id: "nav.settings.deliveryOrderFloor", @@ -495,6 +495,13 @@ const NavigationContent: React.FC = () => { if (!pathname.startsWith(p + "/")) return false; // `/doworkbench` must not claim `/doworkbenchsearch` (prefix without trailing slash) if (p === "/doworkbench" && pathname.startsWith("/doworkbenchsearch")) return false; + // Shop sub-routes (detail, truckdetail, legacy tab page) share one nav entry → board + if ( + p === "/settings/shop/board" && + (pathname === "/settings/shop" || pathname.startsWith("/settings/shop/")) + ) { + return true; + } return true; }); matches.sort((a, b) => b.length - a.length); diff --git a/src/components/Shop/ShopDetail.tsx b/src/components/Shop/ShopDetail.tsx index 135902d..f83f3ab 100644 --- a/src/components/Shop/ShopDetail.tsx +++ b/src/components/Shop/ShopDetail.tsx @@ -325,7 +325,7 @@ const ShopDetail: React.FC = () => { {error} - + ); } @@ -336,7 +336,7 @@ const ShopDetail: React.FC = () => { {t("Shop not found")} - + ); } @@ -347,7 +347,7 @@ const ShopDetail: React.FC = () => { {t("Shop Information")} - + diff --git a/src/components/Shop/TruckLaneDetail.tsx b/src/components/Shop/TruckLaneDetail.tsx index 9f617eb..782a9ef 100644 --- a/src/components/Shop/TruckLaneDetail.tsx +++ b/src/components/Shop/TruckLaneDetail.tsx @@ -493,7 +493,7 @@ const TruckLaneDetail: React.FC = () => { }; const handleBack = () => { - router.push("/settings/shop?tab=1"); + router.push("/settings/shop/board"); }; const handleOpenAddShopDialog = () => { From 1b23594ae8b917b95dbd455e7de27ed780275f30 Mon Sep 17 00:00:00 2001 From: tommy Date: Thu, 11 Jun 2026 13:24:50 +0800 Subject: [PATCH 3/5] report permission --- src/app/(main)/report/layout.tsx | 15 ++++++++++++++- src/authorities.ts | 4 ++-- .../NavigationContent/NavigationContent.tsx | 2 +- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/app/(main)/report/layout.tsx b/src/app/(main)/report/layout.tsx index 6b277b0..bb79527 100644 --- a/src/app/(main)/report/layout.tsx +++ b/src/app/(main)/report/layout.tsx @@ -1,10 +1,23 @@ import { I18nProvider } from "@/i18n"; +import { authOptions } from "@/config/authConfig"; +import { AUTH } from "@/authorities"; +import { getServerSession } from "next-auth"; +import { redirect } from "next/navigation"; -export default function ReportLayout({ +export default async function ReportLayout({ children, }: { children: React.ReactNode; }) { + const session = await getServerSession(authOptions); + const abilities = session?.user?.abilities ?? []; + const canAccess = + abilities.includes(AUTH.REPORT_MGMT) || abilities.includes(AUTH.ADMIN); + + if (!canAccess) { + redirect("/dashboard"); + } + return ( {children} diff --git a/src/authorities.ts b/src/authorities.ts index 7a412a7..b3b75ce 100644 --- a/src/authorities.ts +++ b/src/authorities.ts @@ -16,6 +16,6 @@ export const AUTH = { JOB_CREATE: "JOB_CREATE", JOB_PICK: "JOB_PICK", JOB_MAT: "JOB_MAT", - JOB_PROD: "JOB_PROD", - + JOB_PROD: "JOB_PROD", + REPORT_MGMT: "REPORT_MGMT", } as const; \ No newline at end of file diff --git a/src/components/NavigationContent/NavigationContent.tsx b/src/components/NavigationContent/NavigationContent.tsx index 6e0ee6e..426db03 100644 --- a/src/components/NavigationContent/NavigationContent.tsx +++ b/src/components/NavigationContent/NavigationContent.tsx @@ -226,7 +226,7 @@ const NavigationContent: React.FC = () => { icon: , labelKey: "nav.report", path: "/report", - requiredAbility: [AUTH.TESTING, AUTH.ADMIN], + requiredAbility: [AUTH.REPORT_MGMT, AUTH.ADMIN], isHidden: false, }, { From 4d9cbb49dd9fe6a17caca826897c49a4348d8ca4 Mon Sep 17 00:00:00 2001 From: tommy Date: Thu, 11 Jun 2026 16:45:37 +0800 Subject: [PATCH 4/5] translate and re-schedule truck --- src/app/api/shop/actions.ts | 12 + src/app/api/shop/client.ts | 8 + src/components/Shop/RouteBoard.tsx | 54 ++-- .../Shop/ScheduleTaskHistoryModal.tsx | 259 +++++++++++++++--- src/i18n/en/routeboard.json | 3 - src/i18n/en/shop.json | 8 +- src/i18n/zh/routeboard.json | 33 +-- src/i18n/zh/shop.json | 38 +-- 8 files changed, 322 insertions(+), 93 deletions(-) diff --git a/src/app/api/shop/actions.ts b/src/app/api/shop/actions.ts index 6339c99..8a6ff34 100644 --- a/src/app/api/shop/actions.ts +++ b/src/app/api/shop/actions.ts @@ -855,6 +855,18 @@ export const retryFailedTruckLaneScheduleAction = async ( }); }; +export const reactivateCancelledTruckLaneScheduleAction = async ( + id: number, + body?: RetryFailedTruckLaneScheduleRequest, +): Promise => { + const endpoint = `${BASE_API_URL}/truckLaneSchedule/${id}/reactivate`; + return serverFetchJson(endpoint, { + method: "POST", + body: JSON.stringify(body ?? {}), + headers: { "Content-Type": "application/json" }, + }); +}; + export const ignoreTruckLaneScheduleAction = async ( id: number, ): Promise => { diff --git a/src/app/api/shop/client.ts b/src/app/api/shop/client.ts index d7b710f..67963e2 100644 --- a/src/app/api/shop/client.ts +++ b/src/app/api/shop/client.ts @@ -38,6 +38,7 @@ import { cancelTruckLaneScheduleAction, applyNowTruckLaneScheduleAction, retryFailedTruckLaneScheduleAction, + reactivateCancelledTruckLaneScheduleAction, ignoreTruckLaneScheduleAction, type RetryFailedTruckLaneScheduleRequest, parseTruckLaneScheduleExcelAction, @@ -255,6 +256,13 @@ export const retryFailedTruckLaneScheduleClient = async ( return await retryFailedTruckLaneScheduleAction(id, body); }; +export const reactivateCancelledTruckLaneScheduleClient = async ( + id: number, + body?: RetryFailedTruckLaneScheduleRequest, +): Promise => { + return await reactivateCancelledTruckLaneScheduleAction(id, body); +}; + export const ignoreTruckLaneScheduleClient = async ( id: number, ): Promise => { diff --git a/src/components/Shop/RouteBoard.tsx b/src/components/Shop/RouteBoard.tsx index 1c609b0..22b51c6 100644 --- a/src/components/Shop/RouteBoard.tsx +++ b/src/components/Shop/RouteBoard.tsx @@ -2425,6 +2425,9 @@ const RouteBoard: React.FC = () => { }; const handleDeleteTruckRow = async (truckRowId: number) => { + if (truckRowId > 0 && scheduledShopIdSet.has(truckRowId)) { + return; + } if (truckRowId < 0) { if (!window.confirm(t("confirm_discardDraftShop"))) return; setError(null); @@ -2488,6 +2491,11 @@ const RouteBoard: React.FC = () => { /** 清空整桶店鋪:與單筆刪除相同,僅標記 dirtyDeletes,按「儲存更改」才 deleteTruckLaneClient */ const handleClearLaneShops = (lane: Lane) => { if (lane.shops.length === 0) return; + if ( + lane.shops.some((s) => s.id > 0 && scheduledShopIdSet.has(s.id)) + ) { + return; + } if ( !window.confirm( t("confirm_clearLane", { @@ -5845,13 +5853,6 @@ const RouteBoard: React.FC = () => { setDistrictEditError(null); }} error={Boolean(districtEditError)} - helperText={ - districtEditError || - (districtEditCtx?.mode === "rename" && - districtEditCtx.oldDisplay === "未分類" - ? t("district_help_null") - : t("district_help_mapped")) - } InputLabelProps={{ shrink: true }} /> @@ -5915,13 +5916,6 @@ const RouteBoard: React.FC = () => { inputProps={{ step: 1 }} sx={{ mt: 1 }} /> - - {t("seqDialog_hint")} - @@ -7843,7 +7837,13 @@ const RouteBoard: React.FC = () => { spacing={0.5} alignItems="center" > - + { }} disabled={ loading || - dirtyDeletes.has(shop.id) + dirtyDeletes.has(shop.id) || + isScheduledMove } > @@ -7944,12 +7945,29 @@ const RouteBoard: React.FC = () => { > {t("btn_addShopToLane")} - + + s.id > 0 && scheduledShopIdSet.has(s.id), + ) + ? t("schedule_shop_locked") + : t("tooltip_clearLaneShops") + } + > handleClearLaneShops(lane)} - disabled={loading || lane.shops.length === 0} + disabled={ + loading || + lane.shops.length === 0 || + lane.shops.some( + (s) => + s.id > 0 && + scheduledShopIdSet.has(s.id), + ) + } > diff --git a/src/components/Shop/ScheduleTaskHistoryModal.tsx b/src/components/Shop/ScheduleTaskHistoryModal.tsx index 4914d05..7b420a4 100644 --- a/src/components/Shop/ScheduleTaskHistoryModal.tsx +++ b/src/components/Shop/ScheduleTaskHistoryModal.tsx @@ -14,6 +14,7 @@ import { DialogTitle, IconButton, Stack, + TextField, Typography, } from "@mui/material"; import { @@ -38,16 +39,23 @@ import { applyNowTruckLaneScheduleClient, cancelTruckLaneScheduleClient, createTruckLaneScheduleClient, + reactivateCancelledTruckLaneScheduleClient, getTruckLaneScheduleClient, ignoreTruckLaneScheduleClient, listTruckLaneSchedulesClient, retryFailedTruckLaneScheduleClient, + type TruckLaneScheduleLineRequest, type TruckLaneScheduleLineResponse, type TruckLaneScheduleResponse, } from "@/app/api/shop/client"; import type { ScheduleLaneOption } from "@/components/Shop/ScheduleChangeModal"; import { extractApiErrorMessage } from "@/components/Shop/scheduleClientHelpers"; -import { resolveRescheduleExecuteAt, formatScheduleDisplayDateTime } from "@/components/Shop/scheduleExecuteAt"; +import { + buildExecuteAtIso, + isExecuteAtTooEarly, + resolveRescheduleExecuteAt, + formatScheduleDisplayDateTime, +} from "@/components/Shop/scheduleExecuteAt"; import { buildScheduleLineDescription, } from "@/components/Shop/scheduleLineDisplay"; @@ -84,7 +92,7 @@ function uiStatusFromLines( return null; } -/** Prefer line outcomes over header status when they disagree (e.g. PARTIAL still stored as PENDING). */ +/** Prefer fresh list aggregates over cached detail lines (detail can be stale after mutations). */ function resolveUiStatus( task: TruckLaneScheduleResponse, detail?: TruckLaneScheduleResponse | null, @@ -93,11 +101,6 @@ function resolveUiStatus( if (st === "CANCELLED") return "cancelled"; if (st === "IGNORED") return "ignored"; - const fromDetailLines = detail?.lines?.length - ? uiStatusFromLines(detail.lines) - : null; - if (fromDetailLines) return fromDetailLines; - if (task.lineCounts) { const fromCounts = uiStatusFromLineCounts(task.lineCounts); if (fromCounts) return fromCounts; @@ -108,6 +111,12 @@ function resolveUiStatus( if (st === "PARTIAL") { return (task.lineCounts?.failed ?? 0) > 0 ? "failed" : "success"; } + + const fromDetailLines = detail?.lines?.length + ? uiStatusFromLines(detail.lines) + : null; + if (fromDetailLines) return fromDetailLines; + if (st === "PENDING" || st === "APPLYING") return "pending"; return "pending"; } @@ -118,13 +127,34 @@ function canManagePendingSchedule( ): boolean { const st = String(task.status ?? "").toUpperCase(); if (st !== "PENDING" && st !== "APPLYING") return false; + const c = task.lineCounts; + if (c) { + return c.pending > 0 && c.applied === 0 && c.failed === 0; + } const lines = detail?.lines ?? []; if (lines.length > 0) { return lines.every((l) => l.lineStatus === "PENDING"); } - const c = task.lineCounts; - if (!c) return true; - return c.pending > 0 && c.applied === 0 && c.failed === 0; + return true; +} + +function scheduleLinesToRequests( + lines: TruckLaneScheduleLineResponse[], +): TruckLaneScheduleLineRequest[] { + return lines + .filter((l) => l.lineStatus !== "APPLIED") + .map((l) => ({ + action: l.action, + truckRowId: l.truckRowId, + toTruckLanceCode: l.toTruckLanceCode, + toRemark: l.toRemark, + toStoreId: l.toStoreId, + toLoadingSequence: l.toLoadingSequence ?? 0, + toDistrictReference: l.toDistrictReference ?? null, + shopCode: l.shopCode, + shopName: l.shopName, + departureTime: l.departureTime, + })); } function splitExecuteAt(executeAt: string): { date: string; time: string } { @@ -163,6 +193,12 @@ const ScheduleTaskHistoryModal: React.FC = ({ const [actionId, setActionId] = useState(null); const [actionError, setActionError] = useState(null); const [actionNotice, setActionNotice] = useState(null); + const [redoTarget, setRedoTarget] = useState( + null, + ); + const [redoDate, setRedoDate] = useState(""); + const [redoTime, setRedoTime] = useState(""); + const [redoSubmitting, setRedoSubmitting] = useState(false); const laneById = useMemo( () => new Map(lanes.map((l) => [l.id, l])), @@ -218,6 +254,9 @@ const ScheduleTaskHistoryModal: React.FC = ({ setDetailById({}); setActionError(null); setActionNotice(null); + setRedoTarget(null); + setRedoDate(""); + setRedoTime(""); void loadTasks(); }, [open, loadTasks]); @@ -238,15 +277,22 @@ const ScheduleTaskHistoryModal: React.FC = ({ return tasks.filter((task) => { const detail = detailById[task.id]; const ui = resolveUiStatus(task, detail); - if (filter === "all") return ui !== "cancelled"; + if (filter === "all") return true; if (filter === "pending") return ui === "pending"; if (filter === "success") return ui === "success"; return ui === "failed"; }); }, [tasks, filter, detailById]); - const ensureDetail = async (id: number) => { - if (detailById[id]) return detailById[id]; + const invalidateTaskDetail = useCallback((id: number) => { + setDetailById((prev) => { + if (!prev[id]) return prev; + const { [id]: _removed, ...rest } = prev; + return rest; + }); + }, []); + + const refreshTaskDetail = useCallback(async (id: number) => { setDetailLoadingId(id); try { const detail = await getTruckLaneScheduleClient(id); @@ -255,8 +301,31 @@ const ScheduleTaskHistoryModal: React.FC = ({ } finally { setDetailLoadingId(null); } + }, []); + + const ensureDetail = async (id: number) => { + if (detailById[id]) return detailById[id]; + return refreshTaskDetail(id); }; + const reloadAfterMutation = useCallback( + async (id: number) => { + invalidateTaskDetail(id); + await onAfterChange?.(); + await loadTasks(); + if (expandedId === id) { + await refreshTaskDetail(id); + } + }, + [ + expandedId, + invalidateTaskDetail, + loadTasks, + onAfterChange, + refreshTaskDetail, + ], + ); + const toggleExpand = async (id: number) => { if (expandedId === id) { setExpandedId(null); @@ -271,8 +340,7 @@ const ScheduleTaskHistoryModal: React.FC = ({ setActionError(null); try { await cancelTruckLaneScheduleClient(id); - await onAfterChange?.(); - await loadTasks(); + await reloadAfterMutation(id); } catch (err: unknown) { setActionError(extractApiErrorMessage(err) ?? t("schedule_err_generic")); } finally { @@ -285,8 +353,7 @@ const ScheduleTaskHistoryModal: React.FC = ({ setActionError(null); try { await applyNowTruckLaneScheduleClient(id); - await onAfterChange?.(); - await loadTasks(); + await reloadAfterMutation(id); } catch (err: unknown) { setActionError(extractApiErrorMessage(err) ?? t("schedule_err_generic")); } finally { @@ -294,6 +361,59 @@ const ScheduleTaskHistoryModal: React.FC = ({ } }; + const openRedoDialog = (task: TruckLaneScheduleResponse) => { + const { executeAt } = resolveRescheduleExecuteAt(task.executeAt); + const { date, time } = splitExecuteAt(executeAt); + setRedoTarget(task); + setRedoDate(date !== "-" ? date : ""); + setRedoTime(time && time !== "-" ? time : ""); + setActionError(null); + }; + + const closeRedoDialog = () => { + if (redoSubmitting) return; + setRedoTarget(null); + setRedoDate(""); + setRedoTime(""); + }; + + const handleConfirmRedo = async () => { + if (!redoTarget) return; + const executeAt = buildExecuteAtIso(redoDate, redoTime); + if (!executeAt) { + setActionError(t("schedule_err_execute_at_past")); + return; + } + if (isExecuteAtTooEarly(executeAt)) { + setActionError(t("schedule_err_execute_at_past")); + return; + } + + setRedoSubmitting(true); + setActionId(redoTarget.id); + setActionError(null); + setActionNotice(null); + try { + const updated = await reactivateCancelledTruckLaneScheduleClient( + redoTarget.id, + { executeAt }, + ); + setActionNotice( + t("schedule_reschedule_created", { + id: updated.id, + at: formatScheduleDisplayDateTime(executeAt), + }), + ); + closeRedoDialog(); + await reloadAfterMutation(redoTarget.id); + } catch (err: unknown) { + setActionError(extractApiErrorMessage(err) ?? t("schedule_err_generic")); + } finally { + setRedoSubmitting(false); + setActionId(null); + } + }; + const handleReschedule = async (task: TruckLaneScheduleResponse) => { if (String(task.status ?? "").toUpperCase() === "PARTIAL") { setActionError(t("schedule_retry_rejects_partial")); @@ -315,18 +435,7 @@ const ScheduleTaskHistoryModal: React.FC = ({ (l) => l.lineStatus === "FAILED" || l.lineStatus === "PENDING", ); if (retryLines.length === 0) throw retryErr; - const lines = retryLines.map((l) => ({ - action: l.action, - truckRowId: l.truckRowId, - toTruckLanceCode: l.toTruckLanceCode, - toRemark: l.toRemark, - toStoreId: l.toStoreId, - toLoadingSequence: l.toLoadingSequence ?? 0, - toDistrictReference: l.toDistrictReference ?? null, - shopCode: l.shopCode, - shopName: l.shopName, - departureTime: l.departureTime, - })); + const lines = scheduleLinesToRequests(retryLines); await createTruckLaneScheduleClient({ executeAt, lines }); } if (adjusted) { @@ -334,8 +443,7 @@ const ScheduleTaskHistoryModal: React.FC = ({ t("schedule_reschedule_time_adjusted", { at: executeAt }), ); } - await onAfterChange?.(); - await loadTasks(); + await reloadAfterMutation(task.id); } catch (err: unknown) { setActionError(extractApiErrorMessage(err) ?? t("schedule_err_generic")); } finally { @@ -348,8 +456,7 @@ const ScheduleTaskHistoryModal: React.FC = ({ setActionError(null); try { await ignoreTruckLaneScheduleClient(id); - await onAfterChange?.(); - await loadTasks(); + await reloadAfterMutation(id); } catch (err: unknown) { setActionError(extractApiErrorMessage(err) ?? t("schedule_err_generic")); } finally { @@ -452,6 +559,22 @@ const ScheduleTaskHistoryModal: React.FC = ({ /> ); } + if (ui === "cancelled") { + return ( + } + label={t("schedule_history_status_cancelled")} + sx={{ + fontWeight: 700, + bgcolor: "grey.100", + color: "text.secondary", + border: 1, + borderColor: "grey.400", + }} + /> + ); + } return ( = ({ {t("schedule_history_status_ignored")} )} + {ui === "cancelled" && ( + + )} @@ -988,6 +1129,58 @@ const ScheduleTaskHistoryModal: React.FC = ({ {t("schedule_history_close")} + + + + {t("schedule_reschedule_dialog_title")} + + + + setRedoDate(e.target.value)} + InputLabelProps={{ shrink: true }} + /> + setRedoTime(e.target.value)} + InputLabelProps={{ shrink: true }} + /> + + + + + + + ); }; diff --git a/src/i18n/en/routeboard.json b/src/i18n/en/routeboard.json index f2de673..453fad3 100644 --- a/src/i18n/en/routeboard.json +++ b/src/i18n/en/routeboard.json @@ -175,8 +175,6 @@ "district_dialog_edit": "Edit district", "district_name_label": "District display name", "district_name_ph": "Blank means \"Unclassified\"", - "district_help_null": "Unclassified maps to districtReference = null on server", - "district_help_mapped": "Display name is written via toDistrictRawValue to each shop's districtReference; API runs on \"Save changes\"", "seq_edit_departureLabel": "Departure time", "seq_edit_seqLabel": "Load sequence (Seq)", "route_new_code_label": "Lane code", @@ -206,7 +204,6 @@ "departureDialog_title": "Edit departure time", "departureDialog_hint": "Applies to all shop rows on this lane; press \"Save changes\" above to persist.", "seqDialog_title": "Edit load sequence", - "seqDialog_hint": "Press \"Save changes\" to persist to truck rows.", "logistics_colLaneCount": "{{count}} lane(s)", "logistics_masterNoLanes": "Master record exists but no lanes are bound yet; pick this company when adding/editing lanes on the route board.", "tooltip_openLaneBoard": "Open this lane on the route board", diff --git a/src/i18n/en/shop.json b/src/i18n/en/shop.json index 72449c8..502fc89 100644 --- a/src/i18n/en/shop.json +++ b/src/i18n/en/shop.json @@ -196,8 +196,6 @@ "district_err_exists": "This district already exists", "district_err_name": "Enter a district name", "district_err_reserved": "\"Unclassified\" is built-in; do not add it again", - "district_help_mapped": "Display name is written via toDistrictRawValue to each shop's districtReference; API runs on \"Save changes\"", - "district_help_null": "Unclassified maps to districtReference = null on server", "district_name_label": "District display name", "district_name_ph": "Blank means \"Unclassified\"", "drag_blockDraftShop": "Unsaved \"new shop\" rows must be saved with \"Save changes\" or removed from the card before dragging.", @@ -324,6 +322,11 @@ "schedule_history_reschedule": "Reschedule", "schedule_history_status_failed": "Failed", "schedule_history_status_ignored": "Ignored", + "schedule_history_status_cancelled": "Cancelled", + "schedule_reschedule_dialog_title": "Reschedule", + "schedule_reschedule_dialog_subtitle": "Pick a new run date and time. This cancelled task will be updated in place.", + "schedule_reschedule_dialog_confirm": "Update schedule", + "schedule_reschedule_created": "Schedule #{{id}} updated to run at {{at}}.", "schedule_history_status_pending": "Scheduled", "schedule_history_status_success": "Succeeded", "schedule_history_subtitle": "Monitor, run, or troubleshoot scheduled shop-lane changes", @@ -406,7 +409,6 @@ "schedule_tab_import": "Import route Excel", "schedule_tab_manual": "Drag scheduling", "schedule_target_unset": "Not set", - "seqDialog_hint": "Press \"Save changes\" to persist to truck rows.", "seqDialog_title": "Edit load sequence", "seq_edit_departureLabel": "Departure time", "seq_edit_seqLabel": "Load sequence (Seq)", diff --git a/src/i18n/zh/routeboard.json b/src/i18n/zh/routeboard.json index 2192651..cbced1c 100644 --- a/src/i18n/zh/routeboard.json +++ b/src/i18n/zh/routeboard.json @@ -34,7 +34,7 @@ "exportRoutes": "匯出車線", "routeReport": "車線報告", "departureTooltipNeedShops": "先新增店鋪才能設定出車時間", - "departureTooltipEditSave": "編輯出車時間(按「儲存更改」寫回)", + "departureTooltipEditSave": "編輯出車時間", "departureEditAria": "編輯出車時間", "saveDisabledTooltip": "請先拖曳、編輯出車時間/裝車順序/物流商等變更後再儲存", "cancel": "取消", @@ -80,9 +80,9 @@ "confirm_restoreDiscardsEdits": "排程版本還原會捨棄目前看板上其他未儲存變更(拖曳、刪除、新增店鋪/車線、物流欄等)。確定繼續?", "diff_restoreScheduled": "已排程還原至版本 #{{versionId}};請按「儲存更改」寫入後端。", "diff_restoreAlreadyPending": "此版本已在排程還原中;請按「儲存更改」套用。", - "restore_applied": "已從 snapshot 還原並重新載入看板。", - "restore_appliedDroppedStaging": "已套用 snapshot 還原;本次儲存略過其他暫存變更(請重新編輯)。", - "confirm_restoreSaveWillDropStaging": "儲存時將先套用 snapshot 還原,本次其他暫存變更會被略過。確定繼續?", + "restore_applied": "已從 版本還原並重新載入看板。", + "restore_appliedDroppedStaging": "已套用 版本還原;本次儲存略過其他暫存變更(請重新編輯)。", + "confirm_restoreSaveWillDropStaging": "儲存時將先套用 版本還原,本次其他暫存變更會被略過。確定繼續?", "diff_noOlderCompare": "沒有上一筆版本可比較(請選擇較新的版本)", "logistic_needMasterTpl": "「{{name}}」尚無對應物流公司,請先用「新增物流商」建立。", "diffField_logisticsCompany": "物流公司", @@ -140,11 +140,11 @@ "diff_shopList_title": "店鋪異動清單", "diff_staged_serverCountsOnly": "上列四格為「後端相鄰兩版快照」統計,不含看板上尚未儲存的編輯。", "diff_staged_boardPendingLine": "看板另有 {{count}} 筆未儲存/已排程項(見下方清單)。", - "diff_staged_section_title": "看板未儲存/已排程(尚未寫入後端)", + "diff_staged_section_title": "看板未儲存/已排程", "diff_staged_section_subtitle": "下列與「儲存更改」後才會落庫的狀態一致;與上方版本 diff 分開列,避免與 Excel(僅後端快照)混淆。", "diff_staged_tag_unsaved": "未儲存", "diff_staged_tag_scheduled": "已排程", - "diff_staged_restoreScheduled": "已排程還原至版本 #{{versionId}}(須按「儲存更改」才會呼叫 restore)。", + "diff_staged_restoreScheduled": "已排程還原至版本 #{{versionId}}。", "diff_staged_deleteUnknown": "刪除 truck id={{id}}(未儲存;明細請先儲存或取消後重試)", "diff_staged_newLane": "新增車線(未儲存):{{lane}}", "diff_staged_laneLogistic": "整線物流(未儲存):{{lane}} → {{company}}", @@ -154,9 +154,9 @@ "diff_staged_shopDistrictOnly": "{{name}}({{code}})— 車線 {{lane}}:地區 {{from}}→{{to}}(未儲存;按「儲存更改」寫入)", "diff_staged_pendingLogisticMaster": "新增物流公司(未落庫):{{name}}(車牌 {{plate}});將與路線一併於「儲存更改」寫入", "diff_staged_editLogisticMaster": "修改物流公司(未落庫):{{fromName}}({{fromPlate}})→ {{name}}({{plate}})", - "diff_staged_importPending": "匯入 Excel(未落庫):{{file}} — {{sheets}} 個工作表、{{rows}} 列(按「儲存更改」寫入)", + "diff_staged_importPending": "匯入 Excel(未落庫):{{file}} — {{sheets}} 個工作表、{{rows}} 列", "confirm_importDiscardEdits": "匯入將取代目前看板上未儲存的變更,是否繼續?", - "import_staged_preview": "已載入匯入預覽:{{file}}({{sheets}} 表 / {{rows}} 列)。按「儲存更改」才會寫入後端。", + "import_staged_preview": "已載入匯入預覽:{{file}}({{sheets}} 表 / {{rows}} 列)。", "err_importEmpty": "匯入檔案無有效車線資料列", "diff_logisticMaster_section": "物流公司異動", "diff_logisticMaster_added": "新增", @@ -175,10 +175,8 @@ "district_dialog_edit": "編輯地區", "district_name_label": "地區顯示名稱", "district_name_ph": "空白表示「未分類」", - "district_help_null": "未分類對應後端 districtReference 為 null", - "district_help_mapped": "顯示名稱經 toDistrictRawValue 寫入各店鋪 districtReference;按「儲存更改」才打 API", "seq_edit_departureLabel": "出車時間", - "seq_edit_seqLabel": "裝車順序 (Seq)", + "seq_edit_seqLabel": "裝車順序", "route_new_code_label": "車線編號", "route_new_time_label": "出車時間", "route_new_logistic_label": "物流公司", @@ -206,30 +204,29 @@ "departureDialog_title": "編輯出車時間", "departureDialog_hint": "套用至此車線所有店鋪列。", "seqDialog_title": "編輯裝車順序", - "seqDialog_hint": "按「儲存更改」後寫入。", "logistics_colLaneCount": "{{count}} 條車線", "logistics_masterNoLanes": "主檔已建立,尚無綁定車線;至「車線看板」新增/編輯車線時可填此公司名稱。", "tooltip_openLaneBoard": "在車線看板開此車線", "aria_openLaneBoard": "開啟車線看板", "tooltip_removeFromLane": "從此車線移除", - "tooltip_clearLaneShops": "清空此車線所有店鋪(按「儲存更改」才寫入)", + "tooltip_clearLaneShops": "清空此車線所有店鋪", "tooltip_pickLane": "選擇車線(加入看板勾選並捲動到該欄)", "aria_pickLane": "選擇車線", "aria_searchLanes": "搜索車線", "logistics_colShopCount": "{{count}} 家店鋪", - "tooltip_editLogisticsDb": "編輯物流公司(須按「儲存更改」寫入)", - "tooltip_deleteLogistics": "刪除物流公司(須按「儲存更改」寫入)", + "tooltip_editLogisticsDb": "編輯物流公司", + "tooltip_deleteLogistics": "刪除物流公司", "aria_editLogistics": "編輯物流公司", "aria_deleteLogistics": "刪除物流公司", - "confirm_deleteLogistic": "確定刪除物流公司「{{name}}」?須按「儲存更改」寫入。", + "confirm_deleteLogistic": "確定刪除物流公司「{{name}}」?", "err_logisticDeleteHasLanes": "此物流公司尚有 {{count}} 條車線,請先移走或改派後再刪除。", "diff_staged_deleteLogisticMaster": "刪除物流公司(未落庫):{{name}}", "logistic_btn_apply": "套用", - "tooltip_editDistrict": "編輯地區名稱(按「儲存更改」才寫入)", + "tooltip_editDistrict": "編輯地區名稱", "aria_editDistrict": "編輯地區", "tooltip_removeEmptyDistrict": "移除此暫存區塊(未儲存前可刪)", "aria_removeEmptyDistrict": "移除空區", - "tooltip_editSeq": "編輯裝車順序(按「儲存更改」寫回)", + "tooltip_editSeq": "編輯裝車順序", "aria_editSeq": "編輯裝車順序", "diff_moveFrom": "從 {{lane}}", "logistics_dirtyColumnBadge": "有未儲存物流更改", diff --git a/src/i18n/zh/shop.json b/src/i18n/zh/shop.json index 483cd88..2964d66 100644 --- a/src/i18n/zh/shop.json +++ b/src/i18n/zh/shop.json @@ -34,7 +34,7 @@ "exportRoutes": "匯出車線", "routeReport": "車線報告", "departureTooltipNeedShops": "先新增店鋪才能設定出車時間", - "departureTooltipEditSave": "編輯出車時間(按「儲存更改」寫回)", + "departureTooltipEditSave": "編輯出車時間", "departureEditAria": "編輯出車時間", "saveDisabledTooltip": "請先拖曳、編輯出車時間/裝車順序/物流商等變更後再儲存", "cancel": "取消", @@ -139,11 +139,11 @@ "diff_summary_fieldChange": "欄位變更", "diff_shopList_title": "店鋪變更清單", "diff_staged_boardPendingLine": "看板另有 {{count}} 筆未儲存/已排程項(見下方清單)。", - "diff_staged_section_title": "看板未儲存/已排程(尚未寫入後端)", + "diff_staged_section_title": "看板未儲存/已排程", "diff_staged_section_subtitle": "下列與「儲存更改」後才會落庫的狀態一致;與上方版本 diff 分開列,避免與 Excel(僅後端版本)混淆。", "diff_staged_tag_unsaved": "未儲存", "diff_staged_tag_scheduled": "已排程", - "diff_staged_restoreScheduled": "已排程還原至版本 #{{versionId}}(須按「儲存更改」才會呼叫 restore)。", + "diff_staged_restoreScheduled": "已排程還原至版本 #{{versionId}}。", "diff_staged_deleteUnknown": "刪除 truck id={{id}}(未儲存;明細請先儲存或取消後重試)", "diff_staged_newLane": "新增車線(未儲存):{{lane}}", "diff_staged_laneLogistic": "整線物流(未儲存):{{lane}} → {{company}}", @@ -153,9 +153,9 @@ "diff_staged_shopDistrictOnly": "{{name}}({{code}})— 車線 {{lane}}:地區 {{from}}→{{to}}(未儲存;按「儲存更改」寫入)", "diff_staged_pendingLogisticMaster": "新增物流公司(未落庫):{{name}}(車牌 {{plate}});將與路線一併於「儲存更改」寫入", "diff_staged_editLogisticMaster": "修改物流公司(未落庫):{{fromName}}({{fromPlate}})→ {{name}}({{plate}})", - "diff_staged_importPending": "匯入 Excel(未落庫):{{file}} — {{sheets}} 個工作表、{{rows}} 列(按「儲存更改」寫入)", + "diff_staged_importPending": "匯入 Excel(未落庫):{{file}} — {{sheets}} 個工作表、{{rows}} 列", "confirm_importDiscardEdits": "匯入將取代目前看板上未儲存的變更,是否繼續?", - "import_staged_preview": "已載入匯入預覽:{{file}}({{sheets}} 表 / {{rows}} 列)。按「儲存更改」才會寫入後端。", + "import_staged_preview": "已載入匯入預覽:{{file}}({{sheets}} 表 / {{rows}} 列)。", "err_importEmpty": "匯入檔案無有效車線資料列", "diff_logisticMaster_section": "物流公司變更", "diff_logisticMaster_added": "新增", @@ -175,10 +175,8 @@ "district_dialog_edit": "編輯地區", "district_name_label": "地區顯示名稱", "district_name_ph": "空白表示「未分類」", - "district_help_null": "未分類對應後端 districtReference 為 null", - "district_help_mapped": "顯示名稱經 toDistrictRawValue 寫入各店鋪 districtReference;按「儲存更改」才打 API", "seq_edit_departureLabel": "出車時間", - "seq_edit_seqLabel": "裝車順序 (Seq)", + "seq_edit_seqLabel": "裝車順序", "route_new_code_label": "車線編號", "route_new_time_label": "出車時間", "route_new_logistic_label": "物流公司", @@ -206,7 +204,6 @@ "departureDialog_title": "編輯出車時間", "departureDialog_hint": "套用至此車線所有店鋪列;按上方「儲存更改」寫入後端。", "seqDialog_title": "編輯裝車順序", - "seqDialog_hint": "按「儲存更改」後寫入 truck 列。", "logistics_colLaneCount": "{{count}} 條車線", "tooltip_openLaneBoard": "在車線看板開此車線", "aria_openLaneBoard": "開啟車線看板", @@ -220,15 +217,15 @@ "tooltip_deleteLogistics": "刪除物流公司(須按「儲存更改」寫入)", "aria_editLogistics": "編輯物流公司", "aria_deleteLogistics": "刪除物流公司", - "confirm_deleteLogistic": "確定刪除物流公司「{{name}}」?須按「儲存更改」寫入。", + "confirm_deleteLogistic": "確定刪除物流公司「{{name}}」?", "err_logisticDeleteHasLanes": "此物流公司尚有 {{count}} 條車線,請先移走或改派後再刪除。", "diff_staged_deleteLogisticMaster": "刪除物流公司(未落庫):{{name}}", "logistic_btn_apply": "套用", - "tooltip_editDistrict": "編輯地區名稱(按「儲存更改」才寫入)", + "tooltip_editDistrict": "編輯地區名稱", "aria_editDistrict": "編輯地區", "tooltip_removeEmptyDistrict": "移除此暫存區塊(未儲存前可刪)", "aria_removeEmptyDistrict": "移除空區", - "tooltip_editSeq": "編輯裝車順序(按「儲存更改」寫回)", + "tooltip_editSeq": "編輯裝車順序", "aria_editSeq": "編輯裝車順序", "diff_moveFrom": "從 {{lane}}", "logistics_dirtyColumnBadge": "有未儲存物流更改", @@ -312,7 +309,7 @@ "schedule_review_seq": "裝載順序:", "schedule_drop_hint": "請拖曳店鋪卡片至此車線", "schedule_moved_badge": "移入", - "schedule_drag_seq": "裝載順序 Seq: {{seq}}", + "schedule_drag_seq": "裝載順序: {{seq}}", "schedule_seq_edit_btn": "編輯裝載順序", "schedule_seq_dialog_hint": "變更會加入右側預覽佇列,確認排程後才套用。", "schedule_planned_label": "執行預定", @@ -320,7 +317,7 @@ "schedule_applied_snackbar": "已登記 {{count}} 筆預約變更並更新看板,請按「儲存更改」寫入後端。", "schedule_err_conflict": "部分店舖無法移至目標車線(重複店鋪或草稿列)。", "schedule_err_execute_at_past": "排程執行時間已過去,請選擇未來的日期與時間。", - "schedule_err_open_pending": "店舖列 #{{id}} 已有待執行的排程。", + "schedule_err_open_pending": "店舖已有待執行的排程。", "schedule_err_duplicate_shop": "目標車線上已有店舖 {{shop}}。", "schedule_err_target_lane_missing": "找不到目標車線 {{lane}}。", "schedule_err_target_lane_empty": "目標車線 {{lane}} 尚無店舖,請先加入店舖。", @@ -329,7 +326,7 @@ "schedule_reschedule_time_adjusted": "原執行時間已過去,已自動調整為 {{at}}。", "schedule_shop_badge": "已排程變更", "schedule_shop_locked": "排程執行中,此店鋪暫不可手改", - "schedule_retry_rejects_partial": "PARTIAL 排程不可重試,請先還原看板後重新建立排程", + "schedule_retry_rejects_partial": "部分排程不可重試,請先還原看板後重新建立排程", "schedule_registered_snackbar": "已登記 {{count}} 筆預約變更,將於執行時間由伺服器自動套用。", "schedule_import_parse_summary": "解析完成:有效 {{valid}} 筆,錯誤 {{errors}} 筆", "schedule_import_confirm_btn": "確認匯入並建立排程", @@ -349,8 +346,8 @@ "schedule_history_applied_at": "成功執行時間:{{at}}", "schedule_history_view_lines": "檢視店舖明細", "schedule_history_no_lines": "尚無明細資料", - "schedule_history_failed_lines": "{{count}} 筆店舖移線失敗", - "schedule_history_success_lines": "{{count}} 筆店舖已成功移線", + "schedule_history_failed_lines": "{{count}} 筆店舖移動失敗", + "schedule_history_success_lines": "{{count}} 筆店舖已成功移動", "schedule_history_apply_now": "立即執行", "schedule_history_cancel": "取消排程", "schedule_history_archived": "已執行", @@ -358,8 +355,13 @@ "schedule_history_reschedule": "重新排程", "schedule_history_ignore": "忽略", "schedule_history_status_ignored": "已忽略", + "schedule_history_status_cancelled": "已取消", + "schedule_reschedule_dialog_title": "重新排程", + "schedule_reschedule_dialog_subtitle": "選擇新的執行日期與時間,將更新此筆已取消的預約任務。", + "schedule_reschedule_dialog_confirm": "更新排程", + "schedule_reschedule_created": "排程 #{{id}} 已更新,將於 {{at}} 執行。", "schedule_history_close": "關閉視窗", - "PENDING": "待處理", + "PENDING": "待執行", "APPLYING": "執行中", "APPLIED": "已套用", "PARTIAL": "部分完成", From 3617a02292a4231ec57ca8d137f37985a7adbf6d Mon Sep 17 00:00:00 2001 From: "kelvin.yau" Date: Thu, 11 Jun 2026 18:34:19 +0800 Subject: [PATCH 5/5] replenishment setup --- src/app/api/do/actions.tsx | 3 + .../DoSearch/DoReplenishmentTab.tsx | 785 ++++++++++++++++++ src/components/DoSearch/DoSearch.tsx | 74 +- .../DoSearch/ReplenishmentFilterField.tsx | 430 ++++++++++ src/i18n/en/do.json | 47 +- src/i18n/zh/do.json | 48 +- 6 files changed, 1352 insertions(+), 35 deletions(-) create mode 100644 src/components/DoSearch/DoReplenishmentTab.tsx create mode 100644 src/components/DoSearch/ReplenishmentFilterField.tsx diff --git a/src/app/api/do/actions.tsx b/src/app/api/do/actions.tsx index 36f7853..fcb66f8 100644 --- a/src/app/api/do/actions.tsx +++ b/src/app/api/do/actions.tsx @@ -27,6 +27,8 @@ export interface DoDetail { status: string; /** 加單 DO */ isExtra?: boolean; + /** 揀貨員名稱(delivery_order_pick_order.handlerName) */ + handlerName?: string | null; deliveryOrderLines: DoDetailLine[]; } @@ -56,6 +58,7 @@ export interface DoSearchAll { shopName: string; shopAddress?: string; isExtra?: boolean; + truckLanceCode?: string | null; } export interface DoSearchLiteResponse { records: DoSearchAll[]; diff --git a/src/components/DoSearch/DoReplenishmentTab.tsx b/src/components/DoSearch/DoReplenishmentTab.tsx new file mode 100644 index 0000000..71b4725 --- /dev/null +++ b/src/components/DoSearch/DoReplenishmentTab.tsx @@ -0,0 +1,785 @@ +"use client"; + +import React, { useCallback, useMemo, useRef, useState } from "react"; +import { + Autocomplete, + Box, + Button, + Dialog, + DialogContent, + DialogTitle, + FormControl, + IconButton, + InputLabel, + MenuItem, + Paper, + Select, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tooltip, + Typography, +} from "@mui/material"; +import CalendarTodayIcon from "@mui/icons-material/CalendarToday"; +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; +import ReceiptLongIcon from "@mui/icons-material/ReceiptLong"; +import StorefrontIcon from "@mui/icons-material/Storefront"; +import { Add, Close, Delete, Search } from "@mui/icons-material"; +import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import dayjs, { Dayjs } from "dayjs"; +import { useTranslation } from "react-i18next"; +import { GridColDef } from "@mui/x-data-grid"; +import Swal from "sweetalert2"; +import StyledDataGrid from "../StyledDataGrid"; +import { DoDetail, DoDetailLine, fetchDoDetail, fetchDoSearch } from "@/app/api/do/actions"; +import { arrayToDateString } from "@/app/utils/formatUtil"; +import { + REPLENISHMENT_FIELD_ICON_SX, + REPLENISHMENT_TABLE_AUTOCOMPLETE_SX, + REPLENISHMENT_TABLE_ENTRY_ROW_SX, + REPLENISHMENT_TABLE_INLINE_TEXTFIELD_SX, + REPLENISHMENT_TABLE_SX, + REPLENISHMENT_LOOKUP_BUTTON_SX, + REPLENISHMENT_SOURCE_HEADER_SX, + REPLENISHMENT_TABLE_ACTION_CELL_INNER_SX, + REPLENISHMENT_TEXTFIELD_SX, + ReplenishmentFieldLabel, + ReplenishmentItemEntryPlainText, + ReplenishmentTextField, + replenishmentSearchGridInputSx, + replenishmentSearchGridLabelSx, + replenishmentSearchGridShopRowSx, +} from "./ReplenishmentFilterField"; + +export type ReplenishmentStatus = "pending" | "processing" | "completed"; + +export type ReplenishmentDraftRow = { + rowId: string; + deliveryDate: string; + sourceDoId: number; + sourceDoCode: string; + sourceDoLineId: number; + itemId?: number; + itemNo: string; + itemName: string; + originalQty: number; + replenishQty: number; + shortUom?: string; + shopCode?: string; + shopName?: string; + truckLaneCode?: string; +}; + +export type ReplenishmentRecord = ReplenishmentDraftRow & { + id: number; + code: string; + targetDoId?: number; + targetDoCode?: string; + pickOrderLineId?: number; + status: ReplenishmentStatus; + created: string; +}; + +type SourceDoContext = { + doId: number; + doCode: string; + shopCode?: string; + shopName?: string; + truckLaneCode?: string | null; + status: string; + lines: DoDetailLine[]; +}; + +let localIdSeq = 1; +let replenishmentCodeSeq = 1; + +function nextReplenishmentCode(deliveryDate: string): string { + const ymd = deliveryDate.replace(/-/g, ""); + const seq = String(replenishmentCodeSeq++).padStart(3, "0"); + return `RP-${ymd}-${seq}`; +} + +/** Shop code: partial match. Shop name: prefix match (e.g. first 4 characters). */ +function matchesShopInput(detail: DoDetail, shopInput: string): boolean { + const normalized = shopInput.trim().toLowerCase(); + if (!normalized) return false; + const code = detail.shopCode?.toLowerCase() ?? ""; + const name = detail.shopName?.toLowerCase() ?? ""; + return code.includes(normalized) || name.startsWith(normalized); +} + +function matchesDeliveryDate( + estimatedArrivalDate: number[] | undefined, + deliveryDateStr: string, +): boolean { + if (!estimatedArrivalDate?.length) return false; + return arrayToDateString(estimatedArrivalDate) === deliveryDateStr; +} + +function lineUomDisplay(line?: DoDetailLine | null): string { + if (!line) return ""; + return (line.shortUom ?? line.uomCode ?? line.uom ?? "").trim(); +} + +const DoReplenishmentTab: React.FC = () => { + const { t } = useTranslation("do"); + const inFlightRef = useRef(false); + const itemCodeInputRef = useRef(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isLookingUp, setIsLookingUp] = useState(false); + + const [deliveryDate, setDeliveryDate] = useState(dayjs()); + const [doCodeSuffix, setDoCodeSuffix] = useState(""); + const [shopInput, setShopInput] = useState(""); + const [sourceDo, setSourceDo] = useState(null); + const [selectedLine, setSelectedLine] = useState(null); + const [replenishQtyInput, setReplenishQtyInput] = useState(""); + + const [draftRows, setDraftRows] = useState([]); + const [records, setRecords] = useState([]); + const [trackStatusFilter, setTrackStatusFilter] = useState("all"); + const [trackDateFilter, setTrackDateFilter] = useState(null); + const [trackingDialogOpen, setTrackingDialogOpen] = useState(false); + + const deliveryDateStr = deliveryDate?.format("YYYY-MM-DD") ?? ""; + + const handleLookupSourceDo = useCallback(async () => { + const suffix = doCodeSuffix.trim(); + const shop = shopInput.trim(); + if (suffix.length !== 4) { + await Swal.fire({ + icon: "warning", + title: t("DO code suffix must be exactly 4 characters"), + }); + return; + } + if (!deliveryDateStr) { + await Swal.fire({ icon: "warning", title: t("Delivery date is required") }); + return; + } + if (!shop) { + await Swal.fire({ icon: "warning", title: t("Shop code or name is required") }); + return; + } + setIsLookingUp(true); + try { + const searchRes = await fetchDoSearch( + suffix, + "", + "completed", + "", + "", + `${deliveryDateStr}T00:00:00`, + "", + 1, + 100, + undefined, + null, + null, + ); + const candidates = searchRes.records.filter( + (r) => + r.code.endsWith(suffix) && + matchesDeliveryDate(r.estimatedArrivalDate, deliveryDateStr), + ); + if (candidates.length === 0) { + await Swal.fire({ icon: "error", title: t("Source DO not found") }); + setSourceDo(null); + return; + } + const details = await Promise.all(candidates.map((c) => fetchDoDetail(c.id))); + const matched = details.filter((d) => matchesShopInput(d, shop)); + if (matched.length === 0) { + await Swal.fire({ icon: "error", title: t("Source DO not found") }); + setSourceDo(null); + return; + } + if (matched.length > 1) { + await Swal.fire({ + icon: "error", + title: t("Multiple source DOs matched"), + text: t("Please verify DO code suffix, delivery date and shop."), + }); + setSourceDo(null); + return; + } + const detail = matched[0]; + const matchedCandidate = candidates.find((c) => c.id === detail.id); + if (detail.status !== "completed") { + await Swal.fire({ + icon: "error", + title: t("Source DO must be completed"), + text: t("Only completed delivery orders can be used as replenishment source."), + }); + setSourceDo(null); + return; + } + setSourceDo({ + doId: detail.id, + doCode: detail.code, + shopCode: detail.shopCode, + shopName: detail.shopName, + truckLaneCode: matchedCandidate?.truckLanceCode ?? null, + status: detail.status, + lines: detail.deliveryOrderLines ?? [], + }); + setDraftRows([]); + setSelectedLine(null); + setReplenishQtyInput(""); + } catch { + await Swal.fire({ icon: "error", title: t("Failed to lookup source DO") }); + } finally { + setIsLookingUp(false); + } + }, [deliveryDateStr, doCodeSuffix, shopInput, t]); + + const handleAddDraftRow = useCallback(() => { + if (!sourceDo) { + void Swal.fire({ icon: "warning", title: t("Please lookup source DO first") }); + return; + } + if (!deliveryDateStr) { + void Swal.fire({ icon: "warning", title: t("Delivery date is required") }); + return; + } + if (!selectedLine) { + void Swal.fire({ icon: "warning", title: t("Please select an item") }); + return; + } + const qty = Number(replenishQtyInput); + if (!Number.isFinite(qty) || qty <= 0) { + void Swal.fire({ icon: "warning", title: t("Replenish qty must be greater than zero") }); + return; + } + const line = selectedLine; + + const duplicate = draftRows.some( + (r) => r.sourceDoLineId === line.id && r.sourceDoId === sourceDo.doId, + ); + if (duplicate) { + void Swal.fire({ icon: "warning", title: t("This item is already in the draft list") }); + return; + } + + setDraftRows((prev) => [ + ...prev, + { + rowId: `draft-${Date.now()}-${prev.length}`, + deliveryDate: deliveryDateStr, + sourceDoId: sourceDo.doId, + sourceDoCode: sourceDo.doCode, + sourceDoLineId: line.id, + itemNo: line.itemNo ?? "", + itemName: line.itemName ?? line.itemNo ?? "", + originalQty: line.qty ?? 0, + replenishQty: qty, + shortUom: lineUomDisplay(line) || undefined, + shopCode: sourceDo.shopCode, + shopName: sourceDo.shopName, + truckLaneCode: undefined, + }, + ]); + setSelectedLine(null); + setReplenishQtyInput(""); + window.setTimeout(() => itemCodeInputRef.current?.focus(), 0); + }, [ + deliveryDateStr, + draftRows, + replenishQtyInput, + selectedLine, + sourceDo, + t, + ]); + + const handleRemoveDraftRow = useCallback((rowId: string) => { + setDraftRows((prev) => prev.filter((r) => r.rowId !== rowId)); + }, []); + + const handleSubmit = useCallback(async () => { + if (inFlightRef.current) return; + if (draftRows.length === 0) { + await Swal.fire({ icon: "warning", title: t("No draft rows to submit") }); + return; + } + inFlightRef.current = true; + setIsSubmitting(true); + try { + const now = new Date().toISOString(); + const newRecords: ReplenishmentRecord[] = draftRows.map((row) => ({ + ...row, + id: localIdSeq++, + code: nextReplenishmentCode(row.deliveryDate), + status: "pending", + created: now, + })); + setRecords((prev) => [...newRecords, ...prev]); + setDraftRows([]); + await Swal.fire({ + icon: "info", + title: t("Replenishment API not ready"), + text: t("Records saved locally for preview. Backend integration pending."), + }); + } finally { + setIsSubmitting(false); + inFlightRef.current = false; + } + }, [draftRows, t]); + + const trackColumns: GridColDef[] = useMemo( + () => [ + { field: "code", headerName: t("Replenishment Code"), width: 140 }, + { field: "sourceDoCode", headerName: t("Source DO"), width: 120 }, + { field: "shopName", headerName: t("Shop Name"), flex: 1, minWidth: 120 }, + { + field: "truckLaneCode", + headerName: t("Truck Lance Code"), + width: 120, + valueGetter: (params) => params.row.truckLaneCode ?? "—", + }, + { field: "itemNo", headerName: t("Item No."), width: 100 }, + { field: "itemName", headerName: t("Item Name"), flex: 1, minWidth: 120 }, + { + field: "replenishQty", + headerName: t("Replenish Qty"), + width: 120, + valueGetter: (params) => { + const row = params.row as ReplenishmentRecord; + return row.shortUom ? `${row.replenishQty} ${row.shortUom}` : row.replenishQty; + }, + }, + { + field: "targetDoCode", + headerName: t("Target DO"), + width: 120, + valueGetter: (params) => params.row.targetDoCode ?? "—", + }, + { + field: "status", + headerName: t("Status"), + width: 110, + valueFormatter: (params) => t(String(params.value)), + }, + { + field: "created", + headerName: t("Created"), + width: 160, + valueFormatter: (params) => + params.value ? dayjs(String(params.value)).format("YYYY-MM-DD HH:mm") : "", + }, + ], + [t], + ); + + const selectedLineUom = lineUomDisplay(selectedLine); + + const filteredRecords = useMemo(() => { + return records.filter((r) => { + if (trackStatusFilter !== "all" && r.status !== trackStatusFilter) return false; + if (trackDateFilter && r.deliveryDate !== trackDateFilter.format("YYYY-MM-DD")) { + return false; + } + return true; + }); + }, [records, trackDateFilter, trackStatusFilter]); + + const datePickerSlotProps = useMemo( + () => ({ + textField: { + size: "small" as const, + fullWidth: true, + variant: "filled" as const, + placeholder: t("replenishmentDatePlaceholder"), + sx: REPLENISHMENT_TEXTFIELD_SX, + InputProps: { disableUnderline: true }, + }, + }), + [t], + ); + + return ( + + (theme.palette.mode === "dark" ? "grey.900" : "grey.50"), + }} + > + + setTrackingDialogOpen(true)} + aria-label={t("Replenishment Tracking")} + sx={{ + position: "absolute", + top: 8, + right: 8, + zIndex: 1, + color: "text.secondary", + }} + > + + + + + + } + title={t("Estimated Arrival Date")} + required + sx={replenishmentSearchGridLabelSx(1)} + /> + + + { + setDeliveryDate(v); + setSourceDo(null); + }} + slotProps={datePickerSlotProps} + sx={{ width: "100%" }} + /> + + + + } + title={t("DO Code Last 4")} + required + sx={replenishmentSearchGridLabelSx(2)} + /> + + { + setDoCodeSuffix(e.target.value.slice(0, 4)); + setSourceDo(null); + }} + placeholder={t("replenishmentDoSuffixPlaceholder")} + inputProps={{ maxLength: 4 }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void handleLookupSourceDo(); + } + }} + /> + + + } + title={t("Shop Code")} + required + sx={replenishmentSearchGridLabelSx(3)} + /> + + { + setShopInput(e.target.value); + setSourceDo(null); + }} + placeholder={t("replenishmentShopPlaceholder")} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void handleLookupSourceDo(); + } + }} + /> + + + + + {sourceDo && ( + + + {t("Delivery Order Code")}: {sourceDo.doCode} + {" "} + {t("Shop Name")}: {sourceDo.shopName ?? "—"} + {" "} + {t("Truck Lance Code")}:{" "} + {sourceDo.truckLaneCode?.trim() ? sourceDo.truckLaneCode : t("Truck X")} + + + )} + + {sourceDo && ( + + ({ + border: `1px solid ${theme.palette.divider}`, + borderRadius: 2, + bgcolor: theme.palette.mode === "dark" ? "grey.900" : "common.white", + })} + > + + + + + {t("Replenishment item code")} + + {t("Item Name")} + + {t("Original Shipment Qty")} + + + {t("Replenish Qty")} + + + {t("uom")} + + + {t("Action")} + + + + + {draftRows.map((row) => ( + + {row.itemNo} + {row.itemName} + {row.originalQty} + {row.replenishQty} + {row.shortUom || "—"} + + + handleRemoveDraftRow(row.rowId)} + aria-label={t("Delete")} + > + + + + + + ))} + + + + !draftRows.some( + (r) => + r.sourceDoLineId === line.id && r.sourceDoId === sourceDo.doId, + ), + )} + value={selectedLine} + onChange={(_, newValue) => setSelectedLine(newValue)} + getOptionLabel={(line) => line.itemNo ?? ""} + isOptionEqualToValue={(a, b) => a.id === b.id} + filterOptions={(options, { inputValue }) => { + const query = inputValue.trim().toLowerCase(); + if (!query) return options; + return options.filter((line) => + (line.itemNo ?? "").toLowerCase().includes(query), + ); + }} + renderInput={(params) => { + const { inputProps } = params; + return ( + { + itemCodeInputRef.current = node; + const { ref } = inputProps; + if (typeof ref === "function") ref(node); + else if (ref) { + ( + ref as React.MutableRefObject + ).current = node; + } + }, + }} + InputProps={{ ...params.InputProps, disableUnderline: true }} + /> + ); + }} + sx={REPLENISHMENT_TABLE_AUTOCOMPLETE_SX} + /> + + + + + + + + + + setReplenishQtyInput(e.target.value)} + inputProps={{ min: 0, step: "any", style: { textAlign: "right" } }} + sx={(theme) => ({ + ...REPLENISHMENT_TABLE_INLINE_TEXTFIELD_SX(theme), + width: 72, + "& .MuiFilledInput-input": { + textAlign: "right", + }, + })} + /> + + + + + + + + + + + + +
+
+ + {draftRows.length > 0 && ( + + + + )} +
+ )} +
+
+ + setTrackingDialogOpen(false)} + maxWidth="lg" + fullWidth + > + + {t("Replenishment Tracking")} + setTrackingDialogOpen(false)} + aria-label={t("Cancel")} + > + + + + + + + + + setTrackDateFilter(v)} + slotProps={datePickerSlotProps} + /> + + + + {t("Status")} + + + + + + + +
+ ); +}; + +export default DoReplenishmentTab; diff --git a/src/components/DoSearch/DoSearch.tsx b/src/components/DoSearch/DoSearch.tsx index 86bbefa..c528b2e 100644 --- a/src/components/DoSearch/DoSearch.tsx +++ b/src/components/DoSearch/DoSearch.tsx @@ -37,6 +37,7 @@ import Swal from "sweetalert2"; import { useSession } from "next-auth/react"; import { SessionWithTokens } from "@/config/authConfig"; import { useDoSearchRowSelection } from "./useDoSearchRowSelection"; +import DoReplenishmentTab from "./DoReplenishmentTab"; type Props = { filterArgs?: Record; @@ -45,7 +46,7 @@ type Props = { }; 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 DoSearchTab = "2F" | "4F" | "TRUCK_X" | "ETRA" | "REPLENISH"; type TabFilter = { floor: "2F" | "4F" | null; isExtra: boolean; forceTruckKeyword?: string }; // put all this into a new component @@ -313,8 +314,10 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea case "TRUCK_X": return { floor: null, isExtra: false, forceTruckKeyword: "x" }; case "ETRA": - default: return { floor: null, isExtra: true }; + case "REPLENISH": + default: + return { floor: null, isExtra: false }; } }, []); @@ -747,9 +750,10 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea + - {hasSearched && hasResults && ( + {activeTab !== "REPLENISH" && hasSearched && hasResults && (