| @@ -855,6 +855,18 @@ export const retryFailedTruckLaneScheduleAction = async ( | |||
| }); | |||
| }; | |||
| export const reactivateCancelledTruckLaneScheduleAction = async ( | |||
| id: number, | |||
| body?: RetryFailedTruckLaneScheduleRequest, | |||
| ): Promise<TruckLaneScheduleResponse> => { | |||
| const endpoint = `${BASE_API_URL}/truckLaneSchedule/${id}/reactivate`; | |||
| return serverFetchJson<TruckLaneScheduleResponse>(endpoint, { | |||
| method: "POST", | |||
| body: JSON.stringify(body ?? {}), | |||
| headers: { "Content-Type": "application/json" }, | |||
| }); | |||
| }; | |||
| export const ignoreTruckLaneScheduleAction = async ( | |||
| id: number, | |||
| ): Promise<TruckLaneScheduleResponse> => { | |||
| @@ -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<TruckLaneScheduleResponse> => { | |||
| return await reactivateCancelledTruckLaneScheduleAction(id, body); | |||
| }; | |||
| export const ignoreTruckLaneScheduleClient = async ( | |||
| id: number, | |||
| ): Promise<TruckLaneScheduleResponse> => { | |||
| @@ -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 }} | |||
| /> | |||
| </DialogContent> | |||
| @@ -5915,13 +5916,6 @@ const RouteBoard: React.FC = () => { | |||
| inputProps={{ step: 1 }} | |||
| sx={{ mt: 1 }} | |||
| /> | |||
| <Typography | |||
| variant="caption" | |||
| color="text.secondary" | |||
| sx={{ mt: 1, display: "block" }} | |||
| > | |||
| {t("seqDialog_hint")} | |||
| </Typography> | |||
| </DialogContent> | |||
| <DialogActions> | |||
| <Button onClick={closeSeqEdit}>{t("cancel")}</Button> | |||
| @@ -7843,7 +7837,13 @@ const RouteBoard: React.FC = () => { | |||
| spacing={0.5} | |||
| alignItems="center" | |||
| > | |||
| <Tooltip title={t("tooltip_removeFromLane")}> | |||
| <Tooltip | |||
| title={ | |||
| isScheduledMove | |||
| ? t("schedule_shop_locked") | |||
| : t("tooltip_removeFromLane") | |||
| } | |||
| > | |||
| <span> | |||
| <IconButton | |||
| size="small" | |||
| @@ -7858,7 +7858,8 @@ const RouteBoard: React.FC = () => { | |||
| }} | |||
| disabled={ | |||
| loading || | |||
| dirtyDeletes.has(shop.id) | |||
| dirtyDeletes.has(shop.id) || | |||
| isScheduledMove | |||
| } | |||
| > | |||
| <Trash2 size={16} /> | |||
| @@ -7944,12 +7945,29 @@ const RouteBoard: React.FC = () => { | |||
| > | |||
| {t("btn_addShopToLane")} | |||
| </Button> | |||
| <Tooltip title={t("tooltip_clearLaneShops")}> | |||
| <Tooltip | |||
| title={ | |||
| lane.shops.some( | |||
| (s) => | |||
| s.id > 0 && scheduledShopIdSet.has(s.id), | |||
| ) | |||
| ? t("schedule_shop_locked") | |||
| : t("tooltip_clearLaneShops") | |||
| } | |||
| > | |||
| <span> | |||
| <IconButton | |||
| size="small" | |||
| onClick={() => 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), | |||
| ) | |||
| } | |||
| > | |||
| <Trash2 size={16} /> | |||
| </IconButton> | |||
| @@ -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<Props> = ({ | |||
| const [actionId, setActionId] = useState<number | null>(null); | |||
| const [actionError, setActionError] = useState<string | null>(null); | |||
| const [actionNotice, setActionNotice] = useState<string | null>(null); | |||
| const [redoTarget, setRedoTarget] = useState<TruckLaneScheduleResponse | null>( | |||
| 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<Props> = ({ | |||
| setDetailById({}); | |||
| setActionError(null); | |||
| setActionNotice(null); | |||
| setRedoTarget(null); | |||
| setRedoDate(""); | |||
| setRedoTime(""); | |||
| void loadTasks(); | |||
| }, [open, loadTasks]); | |||
| @@ -238,15 +277,22 @@ const ScheduleTaskHistoryModal: React.FC<Props> = ({ | |||
| 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<Props> = ({ | |||
| } 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<Props> = ({ | |||
| 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<Props> = ({ | |||
| 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<Props> = ({ | |||
| } | |||
| }; | |||
| 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<Props> = ({ | |||
| (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<Props> = ({ | |||
| 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<Props> = ({ | |||
| 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<Props> = ({ | |||
| /> | |||
| ); | |||
| } | |||
| if (ui === "cancelled") { | |||
| return ( | |||
| <Chip | |||
| size="small" | |||
| icon={<Ban size={12} />} | |||
| label={t("schedule_history_status_cancelled")} | |||
| sx={{ | |||
| fontWeight: 700, | |||
| bgcolor: "grey.100", | |||
| color: "text.secondary", | |||
| border: 1, | |||
| borderColor: "grey.400", | |||
| }} | |||
| /> | |||
| ); | |||
| } | |||
| return ( | |||
| <Chip | |||
| size="small" | |||
| @@ -966,6 +1089,24 @@ const ScheduleTaskHistoryModal: React.FC<Props> = ({ | |||
| {t("schedule_history_status_ignored")} | |||
| </Typography> | |||
| )} | |||
| {ui === "cancelled" && ( | |||
| <Button | |||
| size="small" | |||
| variant="contained" | |||
| disabled={busy} | |||
| startIcon={ | |||
| busy ? ( | |||
| <CircularProgress size={14} color="inherit" /> | |||
| ) : ( | |||
| <RotateCcw size={14} /> | |||
| ) | |||
| } | |||
| onClick={() => openRedoDialog(task)} | |||
| sx={{ fontWeight: 700 }} | |||
| > | |||
| {t("schedule_history_reschedule")} | |||
| </Button> | |||
| )} | |||
| </Stack> | |||
| </Stack> | |||
| </Box> | |||
| @@ -988,6 +1129,58 @@ const ScheduleTaskHistoryModal: React.FC<Props> = ({ | |||
| {t("schedule_history_close")} | |||
| </Button> | |||
| </DialogActions> | |||
| <Dialog | |||
| open={redoTarget != null} | |||
| onClose={closeRedoDialog} | |||
| maxWidth="xs" | |||
| fullWidth | |||
| > | |||
| <DialogTitle sx={{ fontWeight: 800 }}> | |||
| {t("schedule_reschedule_dialog_title")} | |||
| </DialogTitle> | |||
| <DialogContent> | |||
| <Stack direction={{ xs: "column", sm: "row" }} spacing={2}> | |||
| <TextField | |||
| label={t("schedule_exec_date")} | |||
| type="date" | |||
| size="small" | |||
| fullWidth | |||
| value={redoDate} | |||
| onChange={(e) => setRedoDate(e.target.value)} | |||
| InputLabelProps={{ shrink: true }} | |||
| /> | |||
| <TextField | |||
| label={t("schedule_exec_time")} | |||
| type="time" | |||
| size="small" | |||
| fullWidth | |||
| value={redoTime} | |||
| onChange={(e) => setRedoTime(e.target.value)} | |||
| InputLabelProps={{ shrink: true }} | |||
| /> | |||
| </Stack> | |||
| </DialogContent> | |||
| <DialogActions sx={{ px: 2, pb: 2 }}> | |||
| <Button onClick={closeRedoDialog} disabled={redoSubmitting}> | |||
| {t("cancel")} | |||
| </Button> | |||
| <Button | |||
| variant="contained" | |||
| disabled={ | |||
| redoSubmitting || !redoDate.trim() || !redoTime.trim() | |||
| } | |||
| onClick={() => void handleConfirmRedo()} | |||
| sx={{ fontWeight: 700 }} | |||
| > | |||
| {redoSubmitting ? ( | |||
| <CircularProgress size={18} color="inherit" /> | |||
| ) : ( | |||
| t("schedule_reschedule_dialog_confirm") | |||
| )} | |||
| </Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| </Dialog> | |||
| ); | |||
| }; | |||
| @@ -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", | |||
| @@ -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)", | |||
| @@ -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": "有未儲存物流更改", | |||
| @@ -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": "部分完成", | |||