diff --git a/src/app/api/do/actions.tsx b/src/app/api/do/actions.tsx index fcb66f8..70553c6 100644 --- a/src/app/api/do/actions.tsx +++ b/src/app/api/do/actions.tsx @@ -29,6 +29,8 @@ export interface DoDetail { isExtra?: boolean; /** 揀貨員名稱(delivery_order_pick_order.handlerName) */ handlerName?: string | null; + /** 來源 DO 車線 */ + truckLaneCode?: string | null; deliveryOrderLines: DoDetailLine[]; } @@ -671,3 +673,60 @@ export async function fetchAllDoSearch( return data.records; } + +export interface SubmitDoReplenishmentLineRequest { + deliveryDate: string; + sourceDoId: number; + sourceDoLineId: number; + replenishQty: number; + truckLaneCode?: string; +} + +export interface DoReplenishmentRecord { + id: number; + code: string; + deliveryDate: string; + sourceDoId: number; + sourceDoCode?: string; + sourceDoLineId: number; + itemId: number; + itemNo?: string; + itemName?: string; + replenishQty: number; + shortUom?: string; + shopCode?: string; + shopName?: string; + truckLaneCode?: string; + targetDoId?: number; + targetDoCode?: string; + pickOrderLineId?: number; + status: string; + created?: string; +} + +export async function submitDoReplenishment( + lines: SubmitDoReplenishmentLineRequest[], +): Promise { + return serverFetchJson(`${BASE_API_URL}/do/replenishment`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ lines }), + }); +} + +export async function fetchDoReplenishmentList(params: { + deliveryDate?: string; + status?: string; +}): Promise { + const query = convertObjToURLSearchParams({ + deliveryDate: params.deliveryDate || undefined, + status: params.status && params.status !== "all" ? params.status : undefined, + }); + const url = query + ? `${BASE_API_URL}/do/replenishment?${query}` + : `${BASE_API_URL}/do/replenishment`; + return serverFetchJson(url, { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); +} diff --git a/src/app/api/shop/actions.ts b/src/app/api/shop/actions.ts index 8a6ff34..abea44e 100644 --- a/src/app/api/shop/actions.ts +++ b/src/app/api/shop/actions.ts @@ -620,6 +620,7 @@ export type TruckLaneMoveTargetRequest = { toStoreId: string; toLoadingSequence: number; toDistrictReference?: string | null; + departureTime?: string | null; }; export type TruckLaneScheduleLineRequest = { @@ -723,7 +724,10 @@ export type TruckLaneScheduleResponse = { }; export type PendingTruckRowIdsResponse = { + /** 所有開放排程(PENDING/APPLYING)涉及的 truck rows(標記、排程驗證用) */ truckRowIds: number[]; + /** 已進入鎖定時間窗(或 APPLYING)的 truck rows,看板不可手改 */ + lockedTruckRowIds?: number[]; }; export type TruckLaneScheduleExcelPreviewRow = { diff --git a/src/components/DoSearch/DoReplenishmentTab.tsx b/src/components/DoSearch/DoReplenishmentTab.tsx index 71b4725..97976b4 100644 --- a/src/components/DoSearch/DoReplenishmentTab.tsx +++ b/src/components/DoSearch/DoReplenishmentTab.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useCallback, useMemo, useRef, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Autocomplete, Box, @@ -36,7 +36,15 @@ 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 { + DoDetail, + DoDetailLine, + DoReplenishmentRecord, + fetchDoDetail, + fetchDoReplenishmentList, + fetchDoSearch, + submitDoReplenishment, +} from "@/app/api/do/actions"; import { arrayToDateString } from "@/app/utils/formatUtil"; import { REPLENISHMENT_FIELD_ICON_SX, @@ -95,13 +103,30 @@ type SourceDoContext = { 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}`; +function mapApiRecord(record: DoReplenishmentRecord): ReplenishmentRecord { + return { + rowId: `record-${record.id}`, + deliveryDate: record.deliveryDate, + sourceDoId: record.sourceDoId, + sourceDoCode: record.sourceDoCode ?? "", + sourceDoLineId: record.sourceDoLineId, + itemId: record.itemId, + itemNo: record.itemNo ?? "", + itemName: record.itemName ?? "", + originalQty: 0, + replenishQty: Number(record.replenishQty), + shortUom: record.shortUom, + shopCode: record.shopCode, + shopName: record.shopName, + truckLaneCode: record.truckLaneCode, + id: record.id, + code: record.code, + targetDoId: record.targetDoId, + targetDoCode: record.targetDoCode, + pickOrderLineId: record.pickOrderLineId, + status: record.status as ReplenishmentStatus, + created: record.created ?? "", + }; } /** Shop code: partial match. Shop name: prefix match (e.g. first 4 characters). */ @@ -142,6 +167,7 @@ const DoReplenishmentTab: React.FC = () => { const [draftRows, setDraftRows] = useState([]); const [records, setRecords] = useState([]); + const [isLoadingTracking, setIsLoadingTracking] = useState(false); const [trackStatusFilter, setTrackStatusFilter] = useState("all"); const [trackDateFilter, setTrackDateFilter] = useState(null); const [trackingDialogOpen, setTrackingDialogOpen] = useState(false); @@ -210,6 +236,8 @@ const DoReplenishmentTab: React.FC = () => { } const detail = matched[0]; const matchedCandidate = candidates.find((c) => c.id === detail.id); + const resolvedTruckLaneCode = + detail.truckLaneCode?.trim() || matchedCandidate?.truckLanceCode?.trim() || null; if (detail.status !== "completed") { await Swal.fire({ icon: "error", @@ -224,7 +252,7 @@ const DoReplenishmentTab: React.FC = () => { doCode: detail.code, shopCode: detail.shopCode, shopName: detail.shopName, - truckLaneCode: matchedCandidate?.truckLanceCode ?? null, + truckLaneCode: resolvedTruckLaneCode, status: detail.status, lines: detail.deliveryOrderLines ?? [], }); @@ -258,32 +286,38 @@ const DoReplenishmentTab: React.FC = () => { } const line = selectedLine; - const duplicate = draftRows.some( + const existingRowIndex = draftRows.findIndex( (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, - }, - ]); + if (existingRowIndex >= 0) { + setDraftRows((prev) => + prev.map((row, index) => + index === existingRowIndex + ? { ...row, replenishQty: row.replenishQty + qty } + : row, + ), + ); + } else { + 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: sourceDo.truckLaneCode?.trim() || undefined, + }, + ]); + } setSelectedLine(null); setReplenishQtyInput(""); window.setTimeout(() => itemCodeInputRef.current?.focus(), 0); @@ -309,27 +343,52 @@ const DoReplenishmentTab: React.FC = () => { 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]); + const created = await submitDoReplenishment( + draftRows.map((row) => ({ + deliveryDate: row.deliveryDate, + sourceDoId: row.sourceDoId, + sourceDoLineId: row.sourceDoLineId, + replenishQty: row.replenishQty, + truckLaneCode: row.truckLaneCode, + })), + ); setDraftRows([]); await Swal.fire({ - icon: "info", - title: t("Replenishment API not ready"), - text: t("Records saved locally for preview. Backend integration pending."), + icon: "success", + title: t("Replenishment submitted successfully"), + text: created.map((row) => row.code).join(", "), }); + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : t("Failed to submit replenishment"); + await Swal.fire({ icon: "error", title: message }); } finally { setIsSubmitting(false); inFlightRef.current = false; } }, [draftRows, t]); + const loadTrackingRecords = useCallback(async () => { + setIsLoadingTracking(true); + try { + const data = await fetchDoReplenishmentList({ + deliveryDate: trackDateFilter?.format("YYYY-MM-DD"), + status: trackStatusFilter, + }); + setRecords(data.map(mapApiRecord)); + } catch { + await Swal.fire({ icon: "error", title: t("Failed to load replenishment records") }); + } finally { + setIsLoadingTracking(false); + } + }, [trackDateFilter, trackStatusFilter, t]); + + useEffect(() => { + if (trackingDialogOpen) { + void loadTrackingRecords(); + } + }, [trackingDialogOpen, loadTrackingRecords]); + const trackColumns: GridColDef[] = useMemo( () => [ { field: "code", headerName: t("Replenishment Code"), width: 140 }, @@ -377,16 +436,6 @@ const DoReplenishmentTab: React.FC = () => { 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: { @@ -559,6 +608,9 @@ const DoReplenishmentTab: React.FC = () => { {t("uom")} + + {t("Truck Lance Code")} + {t("Action")} @@ -572,6 +624,9 @@ const DoReplenishmentTab: React.FC = () => { {row.originalQty} {row.replenishQty} {row.shortUom || "—"} + + {row.truckLaneCode?.trim() || sourceDo.truckLaneCode?.trim() || t("Truck X")} + { - !draftRows.some( - (r) => - r.sourceDoLineId === line.id && r.sourceDoId === sourceDo.doId, - ), - )} + options={sourceDo.lines} value={selectedLine} onChange={(_, newValue) => setSelectedLine(newValue)} getOptionLabel={(line) => line.itemNo ?? ""} @@ -675,6 +724,17 @@ const DoReplenishmentTab: React.FC = () => { sx={{ whiteSpace: "nowrap" }} /> + + + + + + ); }; diff --git a/src/components/Shop/ScheduleDragWorkspacePane.tsx b/src/components/Shop/ScheduleDragWorkspacePane.tsx index 0ce17d0..1546f24 100644 --- a/src/components/Shop/ScheduleDragWorkspacePane.tsx +++ b/src/components/Shop/ScheduleDragWorkspacePane.tsx @@ -55,8 +55,23 @@ type Props = { onAddEmptyDistrict: (laneId: string, display: string) => void; onRemoveEmptyDistrict: (laneId: string, display: string) => void; onDeleteShop: (shopId: number, laneId: string) => void; + onAddShop: (laneId: string) => void; + onSetLaneDepartureTime: (laneId: string, departureTime: string) => boolean; }; +function toTimeInputValue(t: string | undefined): string { + const s = String(t ?? "").trim(); + if (!s) return "00:00"; + const m = s.match(/^(\d{1,2}):(\d{2})(?::\d{2})?/); + if (m) return `${m[1].padStart(2, "0")}:${m[2]}`; + return "00:00"; +} + +function formatDepartureChip(t: string): string { + const m = String(t ?? "").trim().match(/^(\d{1,2}):(\d{2})/); + return m ? `${m[1].padStart(2, "0")}:${m[2]}` : "-"; +} + function getBeforeShopIdByPointer( laneId: string, clientY: number, @@ -386,6 +401,11 @@ const LaneColumn = memo(function LaneColumn({ onStartSeqEdit, dropHint, addDistrictLabel, + addShopLabel, + onAddShop, + departureLabel, + departureEditAriaLabel, + onStartDepartureEdit, }: { lane: PlannedLane; pendingEmptyDistricts: string[]; @@ -411,6 +431,11 @@ const LaneColumn = memo(function LaneColumn({ onStartSeqEdit: (laneId: string, shopId: number, current: number) => void; dropHint: string; addDistrictLabel: string; + addShopLabel: string; + onAddShop: () => void; + departureLabel: string; + departureEditAriaLabel: string; + onStartDepartureEdit: (laneId: string, current: string) => void; }) { const districtSections = buildLaneDistrictSections( lane.shops, @@ -448,8 +473,8 @@ const LaneColumn = memo(function LaneColumn({ flexShrink: 0, }} > - - + + {lane.label} @@ -459,6 +484,33 @@ const LaneColumn = memo(function LaneColumn({ sx={{ height: 18, fontSize: "0.65rem", fontFamily: "monospace" }} /> + + + {departureLabel}: {formatDepartureChip(lane.departureTime)} + + + { + e.stopPropagation(); + onStartDepartureEdit(lane.id, lane.departureTime); + }} + aria-label={departureEditAriaLabel} + > + + + + )} + + + + ); }); @@ -576,6 +650,8 @@ const ScheduleDragWorkspacePane: React.FC = ({ onAddEmptyDistrict, onRemoveEmptyDistrict, onDeleteShop, + onAddShop, + onSetLaneDepartureTime, }) => { const { t } = useTranslation("shop"); const draggedRef = useRef<{ shopId: number; fromLaneId: string } | null>( @@ -596,6 +672,27 @@ const ScheduleDragWorkspacePane: React.FC = ({ const [districtAddError, setDistrictAddError] = useState( null, ); + const [departureEditTarget, setDepartureEditTarget] = useState<{ + laneId: string; + draft: string; + } | null>(null); + const [departureEditError, setDepartureEditError] = useState( + null, + ); + + const applyDepartureEdit = useCallback(() => { + if (!departureEditTarget) return; + const ok = onSetLaneDepartureTime( + departureEditTarget.laneId, + departureEditTarget.draft, + ); + if (!ok) { + setDepartureEditError(t("route_err_departure")); + return; + } + setDepartureEditError(null); + setDepartureEditTarget(null); + }, [departureEditTarget, onSetLaneDepartureTime, t]); const leftLane = plannedLanes.find((l) => l.id === leftLaneId); const rightLane = plannedLanes.find((l) => l.id === rightLaneId); @@ -862,6 +959,17 @@ const ScheduleDragWorkspacePane: React.FC = ({ onStartSeqEdit: handleStartSeqEdit, dropHint: t("schedule_drop_hint"), addDistrictLabel: t("btn_addDistrict"), + addShopLabel: t("btn_addShopToLane"), + onAddShop: () => onAddShop(lane.id), + departureLabel: t("Departure"), + departureEditAriaLabel: t("departureTooltipEditSave"), + onStartDepartureEdit: (laneId: string, current: string) => { + setDepartureEditError(null); + setDepartureEditTarget({ + laneId, + draft: toTimeInputValue(current), + }); + }, }); return ( @@ -882,6 +990,64 @@ const ScheduleDragWorkspacePane: React.FC = ({ + { + setDepartureEditError(null); + setDepartureEditTarget(null); + }} + maxWidth="xs" + fullWidth + > + {t("departureDialog_title")} + + { + setDepartureEditError(null); + setDepartureEditTarget((prev) => + prev ? { ...prev, draft: e.target.value } : prev, + ); + }} + onKeyDown={(e) => { + if (e.key === "Enter" && departureEditTarget) { + e.preventDefault(); + applyDepartureEdit(); + } + }} + InputLabelProps={{ shrink: true }} + sx={{ mt: 1 }} + /> + + {t("departureDialog_hint")} + + + + + + + + void; onRevertDelete: (truckRowId: number) => void; + onRevertCreate: (truckRowId: number) => void; + onRevertDeparture?: (laneId: string) => void; errorTruckRowIds?: Set; validationErrorsByTruckRowId?: Map; }; +function formatDepartureShort(t: string): string { + const m = String(t ?? "").trim().match(/^(\d{1,2}):(\d{2})/); + return m ? `${m[1].padStart(2, "0")}:${m[2]}` : t || "-"; +} + const ScheduleReviewQueue: React.FC = ({ modifications, pendingDeletes, + pendingCreates, + departureModifications = [], onRevert, onRevertDelete, + onRevertCreate, + onRevertDeparture, errorTruckRowIds = new Set(), validationErrorsByTruckRowId = new Map(), }) => { const { t } = useTranslation("shop"); - const totalCount = modifications.length + pendingDeletes.length; + const totalCount = + modifications.length + + pendingDeletes.length + + pendingCreates.length + + departureModifications.length; const isEmpty = totalCount === 0; return ( @@ -100,6 +119,176 @@ const ScheduleReviewQueue: React.FC = ({ ) : ( + {departureModifications.map((dep) => ( + + + + + + + {dep.laneLabel} + + + + {dep.shopCount > 0 + ? t("schedule_review_departure_shops", { + count: dep.shopCount, + }) + : t("schedule_review_departure_pending_creates", { + count: dep.pendingCreateCount, + })} + + + {onRevertDeparture ? ( + + ) : null} + + + + {t("schedule_line_departure_change", { + from: formatDepartureShort(dep.oldDepartureTime), + to: formatDepartureShort(dep.newDepartureTime), + })} + + + + ))} + + {pendingCreates.map((create) => ( + + + + + + + {create.shopCode} + + + + {create.displayName} + + + + + + + + + {t("schedule_review_create_action")} + + + {create.toLaneLabel} + + + {create.location && ( + + {create.location} + + )} + + + ))} + {pendingDeletes.map((del) => ( l.laneKey) + .sort() + .join(","); + const shopKey = + w.shopEntityId != null && Number.isFinite(w.shopEntityId) && w.shopEntityId > 0 + ? `id:${w.shopEntityId}` + : `code:${String(w.shopCode || "").trim().toLowerCase()}`; + return `${w.rule}|${shopKey}|${w.triggerValue}|${laneKeys}`; +} + +/** Warnings present after but not before (e.g. only prompt on newly introduced conflicts). */ +export function diffNewTruckLaneWarnings( + before: readonly TruckLaneWarning[], + after: readonly TruckLaneWarning[], +): TruckLaneWarning[] { + const beforeKeys = new Set(before.map(truckLaneWarningFingerprint)); + return after.filter((w) => !beforeKeys.has(truckLaneWarningFingerprint(w))); +} + /** 模擬「即將新增」的一筆店鋪列(`tempTruckRowId` 建議用負數);供 Rule1/2 試算。 */ export function appendSyntheticPendingShopRow( baseRows: TruckLaneWarningInputRow[], diff --git a/src/components/Shop/scheduleApiAdapter.ts b/src/components/Shop/scheduleApiAdapter.ts index 01a69e9..a06af22 100644 --- a/src/components/Shop/scheduleApiAdapter.ts +++ b/src/components/Shop/scheduleApiAdapter.ts @@ -10,16 +10,44 @@ import type { ScheduleMoveSelection, } from "@/components/Shop/ScheduleChangeModal"; import type { + PlannedLane, + PlannedShop, ScheduleModification, ScheduleDragWorkspaceState, ScheduledDeleteSnapshot, } from "@/components/Shop/scheduleDragWorkspace"; +import { + listModifications, + normalizeDepartureTime, +} from "@/components/Shop/scheduleDragWorkspace"; + +function plannedLaneById( + plannedLanes: ScheduleDragWorkspaceState | undefined, +): Map { + return new Map((plannedLanes ?? []).map((l) => [l.id, l])); +} + +function departureTimeForPlannedLane( + laneId: string, + plannedLanes: ScheduleDragWorkspaceState | undefined, +): string | null { + const planned = plannedLaneById(plannedLanes).get(laneId); + if (!planned) return null; + if ( + normalizeDepartureTime(planned.departureTime) === + normalizeDepartureTime(planned.originalDepartureTime) + ) { + return null; + } + return normalizeDepartureTime(planned.departureTime); +} export function buildScheduleMoveFromSelection( truckRowId: number, selection: ScheduleMoveSelection, lane: ScheduleLaneOption, toDistrictReference?: string | null, + plannedLanes?: ScheduleDragWorkspaceState, ): TruckLaneMoveTargetRequest { return { truckRowId, @@ -31,6 +59,28 @@ export function buildScheduleMoveFromSelection( toStoreId: normalizeStoreId(lane.storeId), toLoadingSequence: selection.toLoadingSequence, toDistrictReference: toDistrictReference ?? null, + departureTime: departureTimeForPlannedLane(selection.laneId, plannedLanes), + }; +} + +export function buildCreateScheduleLine( + shop: PlannedShop, + lane: ScheduleLaneOption, +): TruckLaneScheduleLineRequest { + return { + action: "CREATE", + shopId: shop.shopEntityId ?? null, + shopCode: shop.shopCode, + shopName: shop.displayName, + toTruckLanceCode: lane.truckLanceCode, + toRemark: + lane.remark != null && String(lane.remark).trim() !== "" + ? String(lane.remark).trim() + : null, + toStoreId: normalizeStoreId(lane.storeId), + toLoadingSequence: shop.loadingSequence, + toDistrictReference: shop.districtReferenceRaw ?? null, + departureTime: lane.departureTime || "00:00:00", }; } @@ -87,12 +137,51 @@ export function buildScheduleMovesFromMappings( sel, lane, districtByTruckRowId.get(truckRowId), + plannedLanes, ), ); } return out; } +function buildDepartureOnlyMoves( + plannedLanes: ScheduleDragWorkspaceState, + pendingDeleteIds: Set, +): TruckLaneMoveTargetRequest[] { + const modifiedIds = new Set( + listModifications(plannedLanes).map((m) => m.truckRowId), + ); + const out: TruckLaneMoveTargetRequest[] = []; + for (const lane of plannedLanes) { + const departureTime = departureTimeForPlannedLane(lane.id, plannedLanes); + if (!departureTime) continue; + const laneOpt: ScheduleLaneOption = { + id: lane.id, + label: lane.label, + truckLanceCode: lane.truckLanceCode, + remark: lane.remark, + storeId: lane.storeId, + departureTime: lane.departureTime, + shops: [], + }; + for (const shop of lane.shops) { + if (shop.truckRowId < 0) continue; + if (pendingDeleteIds.has(shop.truckRowId)) continue; + if (modifiedIds.has(shop.truckRowId)) continue; + out.push( + buildScheduleMoveFromSelection( + shop.truckRowId, + { laneId: lane.id, toLoadingSequence: shop.loadingSequence }, + laneOpt, + shop.districtReferenceRaw, + plannedLanes, + ), + ); + } + } + return out; +} + export function buildScheduleLinesFromPlan(input: { modifications: ScheduleModification[]; pendingDeletes: ScheduledDeleteSnapshot[]; @@ -109,13 +198,17 @@ export function buildScheduleLinesFromPlan(input: { { laneId: m.toLaneId, toLoadingSequence: m.newLoadingSequence }, ]), ); - const moveLines = movesToScheduleLines( - buildScheduleMovesFromMappings( + const moveTargets = [ + ...buildScheduleMovesFromMappings( mappings, input.lanes, input.plannedLanes, ), - ); + ...(input.plannedLanes + ? buildDepartureOnlyMoves(input.plannedLanes, deleteIds) + : []), + ]; + const moveLines = movesToScheduleLines(moveTargets); const deleteLines = input.pendingDeletes .map((d) => { const lane = laneById.get(d.fromLaneId); @@ -128,7 +221,23 @@ export function buildScheduleLinesFromPlan(input: { ); }) .filter((line): line is TruckLaneScheduleLineRequest => line != null); - return [...moveLines, ...deleteLines]; + const createLines: TruckLaneScheduleLineRequest[] = []; + if (input.plannedLanes) { + for (const lane of input.plannedLanes) { + const laneOpt = laneById.get(lane.id); + if (!laneOpt) continue; + const effectiveLane: ScheduleLaneOption = { + ...laneOpt, + departureTime: lane.departureTime || laneOpt.departureTime, + }; + for (const shop of lane.shops) { + if (shop.truckRowId >= 0) continue; + if (!shop.shopEntityId || shop.shopEntityId <= 0) continue; + createLines.push(buildCreateScheduleLine(shop, effectiveLane)); + } + } + } + return [...moveLines, ...deleteLines, ...createLines]; } export function buildScheduleMovesFromModifications( diff --git a/src/components/Shop/scheduleDragWorkspace.ts b/src/components/Shop/scheduleDragWorkspace.ts index 1f09a59..de4bd33 100644 --- a/src/components/Shop/scheduleDragWorkspace.ts +++ b/src/components/Shop/scheduleDragWorkspace.ts @@ -3,6 +3,7 @@ import type { ScheduleMoveSelection, ScheduleShopRow, } from "@/components/Shop/ScheduleChangeModal"; +import type { TruckLaneWarningInputRow } from "@/components/Shop/computeTruckLaneWarnings"; import { computeMovedLoadingSequence, flattenDisplayOrder, @@ -14,6 +15,7 @@ import { export type PlannedShop = { id: number; truckRowId: number; + shopEntityId?: number; shopCode: string; displayName: string; branchName: string; @@ -31,9 +33,21 @@ export type PlannedLane = { truckLanceCode: string; remark?: string | null; storeId: string; + /** Lane departure (HH:mm or HH:mm:ss). */ + departureTime: string; + originalDepartureTime: string; shops: PlannedShop[]; }; +export type ScheduleDepartureModification = { + laneId: string; + laneLabel: string; + oldDepartureTime: string; + newDepartureTime: string; + shopCount: number; + pendingCreateCount: number; +}; + export type ScheduleDragWorkspaceState = PlannedLane[]; export type ScheduleModification = { @@ -64,6 +78,16 @@ export type ScheduledDeleteSnapshot = { shop: PlannedShop; }; +export type SchedulePendingCreate = { + truckRowId: number; + shopEntityId: number; + shopCode: string; + displayName: string; + location: string; + toLaneId: string; + toLaneLabel: string; +}; + type MoveShopArgs = { shopId: number; fromLaneId: string; @@ -78,6 +102,138 @@ function laneHasShopCode(lane: PlannedLane, shopCode: string, excludeId: number) ); } +/** Parse user-entered departure time; returns null when empty or invalid. */ +export function parseScheduleDepartureTime(time: string): string | null { + const s = String(time ?? "").trim(); + if (!s) return null; + if (!/^\d{1,2}:\d{2}(:\d{2})?$/.test(s)) return null; + const [hh, mm, ss] = s.split(":"); + const h = Number(hh); + const m = Number(mm); + if (!Number.isFinite(h) || !Number.isFinite(m) || h > 23 || m > 59) { + return null; + } + if (ss != null) { + const sec = Number(ss); + if (!Number.isFinite(sec) || sec > 59) return null; + } + return normalizeDepartureTime(s); +} + +export function normalizeDepartureTime(time: string): string { + const s = String(time ?? "").trim(); + if (!s) return "00:00:00"; + if (/^\d{1,2}:\d{2}(:\d{2})?$/.test(s)) { + const [hh, mm, ss] = s.split(":"); + return `${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}${ + ss != null ? `:${String(ss).padStart(2, "0")}` : ":00" + }`; + } + return s; +} + +export function plannedLanesToWarningInputRows( + state: ScheduleDragWorkspaceState, +): TruckLaneWarningInputRow[] { + const out: TruckLaneWarningInputRow[] = []; + for (const lane of state) { + for (const shop of lane.shops) { + out.push({ + truckRowId: shop.truckRowId, + truckLanceCode: lane.truckLanceCode, + laneRemark: lane.remark ?? null, + storeId: shop.storeId, + departureTime: lane.departureTime, + shopEntityId: shop.shopEntityId ?? null, + shopCode: shop.shopCode, + shopDisplayName: shop.branchName || shop.displayName, + }); + } + } + return out; +} + +function departureTimesEqual(a: string, b: string): boolean { + return normalizeDepartureTime(a) === normalizeDepartureTime(b); +} + +function planHasShopEntity( + state: ScheduleDragWorkspaceState, + shopEntityId: number, + shopCode: string, + excludeTruckRowId = -1, +): boolean { + const code = shopCode.trim().toLowerCase(); + for (const lane of state) { + for (const shop of lane.shops) { + if (shop.truckRowId === excludeTruckRowId) continue; + if (shop.shopEntityId === shopEntityId) return true; + if (code && shop.shopCode.trim().toLowerCase() === code) return true; + } + } + return false; +} + +export function addShopToPlan( + state: ScheduleDragWorkspaceState, + laneId: string, + pick: { id: number; name: string; code: string }, + tempTruckRowId: number, +): ScheduleDragWorkspaceState { + const lane = state.find((l) => l.id === laneId); + if (!lane) return state; + + const shopCode = String(pick.code || "").trim(); + const shopName = String(pick.name || "").trim(); + if (!shopCode || !shopName || pick.id <= 0) return state; + if (planHasShopEntity(state, pick.id, shopCode)) return state; + + const flat = flattenDisplayOrder(lane.shops); + const last = flat[flat.length - 1]; + const seq = last != null ? Number(last.loadingSequence) || 0 : 0; + + const newShop: PlannedShop = { + id: tempTruckRowId, + truckRowId: tempTruckRowId, + shopEntityId: pick.id, + shopCode, + displayName: `${shopCode} - ${shopName}`, + branchName: shopName, + storeId: lane.storeId, + districtReferenceRaw: null, + loadingSequence: seq, + originalLaneId: laneId, + originalLoadingSequence: seq, + originalDistrictReferenceRaw: null, + }; + + return state.map((l) => + l.id !== laneId ? l : { ...l, shops: [...l.shops, newShop] }, + ); +} + +export function listPendingCreates( + state: ScheduleDragWorkspaceState, +): SchedulePendingCreate[] { + const out: SchedulePendingCreate[] = []; + for (const lane of state) { + for (const shop of lane.shops) { + if (shop.truckRowId >= 0) continue; + if (!shop.shopEntityId || shop.shopEntityId <= 0) continue; + out.push({ + truckRowId: shop.truckRowId, + shopEntityId: shop.shopEntityId, + shopCode: shop.shopCode, + displayName: shop.displayName, + location: formatPlannedShopLocation(shop), + toLaneId: lane.id, + toLaneLabel: lane.label, + }); + } + } + return out.sort((a, b) => a.shopCode.localeCompare(b.shopCode, "zh-Hant")); +} + export function initPlannedLanes( lanes: ScheduleLaneOption[], shops: ScheduleShopRow[], @@ -106,17 +262,58 @@ export function initPlannedLanes( }) .filter((s): s is PlannedShop => s != null); + const departureTime = normalizeDepartureTime(lane.departureTime || "00:00:00"); return { id: lane.id, label: lane.label, truckLanceCode: lane.truckLanceCode, remark: lane.remark, storeId: lane.storeId, + departureTime, + originalDepartureTime: departureTime, shops: plannedShops, }; }); } +export function setLaneDepartureTime( + state: ScheduleDragWorkspaceState, + laneId: string, + departureTime: string, +): ScheduleDragWorkspaceState { + const normalized = normalizeDepartureTime(departureTime); + return state.map((lane) => + lane.id !== laneId ? lane : { ...lane, departureTime: normalized }, + ); +} + +export function revertLaneDeparture( + state: ScheduleDragWorkspaceState, + laneId: string, +): ScheduleDragWorkspaceState { + return state.map((lane) => + lane.id !== laneId + ? lane + : { ...lane, departureTime: lane.originalDepartureTime }, + ); +} + +export function listDepartureModifications( + state: ScheduleDragWorkspaceState, +): ScheduleDepartureModification[] { + return state + .filter((lane) => !departureTimesEqual(lane.departureTime, lane.originalDepartureTime)) + .map((lane) => ({ + laneId: lane.id, + laneLabel: lane.label, + oldDepartureTime: lane.originalDepartureTime, + newDepartureTime: lane.departureTime, + shopCount: lane.shops.filter((s) => s.truckRowId >= 0).length, + pendingCreateCount: lane.shops.filter((s) => s.truckRowId < 0).length, + })) + .filter((d) => d.shopCount > 0 || d.pendingCreateCount > 0); +} + export function moveShop( state: ScheduleDragWorkspaceState, args: MoveShopArgs, @@ -324,6 +521,7 @@ export function listModifications( for (const lane of state) { for (const shop of lane.shops) { + if (shop.truckRowId < 0) continue; const isLaneChanged = shop.originalLaneId !== lane.id; const isSeqChanged = shop.originalLoadingSequence !== shop.loadingSequence; const isDistrictChanged = diff --git a/src/components/Shop/scheduleLineAdapter.ts b/src/components/Shop/scheduleLineAdapter.ts index 38ced03..b74e54b 100644 --- a/src/components/Shop/scheduleLineAdapter.ts +++ b/src/components/Shop/scheduleLineAdapter.ts @@ -14,6 +14,7 @@ export function moveToScheduleLine( toStoreId: move.toStoreId, toLoadingSequence: move.toLoadingSequence, toDistrictReference: move.toDistrictReference ?? null, + departureTime: move.departureTime ?? null, }; } diff --git a/src/components/Shop/truckLaneMovePlanner.ts b/src/components/Shop/truckLaneMovePlanner.ts index fac137c..e8e6e52 100644 --- a/src/components/Shop/truckLaneMovePlanner.ts +++ b/src/components/Shop/truckLaneMovePlanner.ts @@ -79,8 +79,19 @@ export function listPlannedModifications( export function buildShopCodeByTruckRowId( shops: ScheduleShopRow[], + plannedLanes?: ScheduleDragWorkspaceState, ): Map { - return new Map(shops.map((s) => [s.truckRowId, s.shopCode])); + const map = new Map(shops.map((s) => [s.truckRowId, s.shopCode])); + if (plannedLanes) { + for (const lane of plannedLanes) { + for (const shop of lane.shops) { + if (shop.truckRowId < 0 && shop.shopCode) { + map.set(shop.truckRowId, shop.shopCode); + } + } + } + } + return map; } export function validatePlannedSubmit( @@ -106,7 +117,10 @@ export function validatePlannedSubmit( plannedLanes: input.plannedLanes, pendingTruckRowIds: input.pendingTruckRowIds, executeAt, - shopCodeByTruckRowId: buildShopCodeByTruckRowId(input.shops), + shopCodeByTruckRowId: buildShopCodeByTruckRowId( + input.shops, + input.plannedLanes, + ), }); if (!validation.ok) { return validation; @@ -150,7 +164,10 @@ export function validatePayloadSubmit(input: { plannedLanes: input.payload.plannedLanes, pendingTruckRowIds: input.pendingTruckRowIds, executeAt, - shopCodeByTruckRowId: buildShopCodeByTruckRowId(input.shops), + shopCodeByTruckRowId: buildShopCodeByTruckRowId( + input.shops, + input.payload.plannedLanes, + ), }); } diff --git a/src/components/Shop/useRouteBoardScheduleIndicators.ts b/src/components/Shop/useRouteBoardScheduleIndicators.ts index dd6e606..3c295c2 100644 --- a/src/components/Shop/useRouteBoardScheduleIndicators.ts +++ b/src/components/Shop/useRouteBoardScheduleIndicators.ts @@ -22,6 +22,9 @@ export function useRouteBoardScheduleIndicators(options?: { const [pendingScheduleShopIds, setPendingScheduleShopIds] = useState< Set >(new Set()); + const [lockedScheduleShopIds, setLockedScheduleShopIds] = useState< + Set + >(new Set()); const [failedScheduleShopIds, setFailedScheduleShopIds] = useState< Set >(new Set()); @@ -37,6 +40,9 @@ export function useRouteBoardScheduleIndicators(options?: { ), ]); setPendingScheduleShopIds(new Set(pendingIds.truckRowIds ?? [])); + setLockedScheduleShopIds( + new Set(pendingIds.lockedTruckRowIds ?? pendingIds.truckRowIds ?? []), + ); const failedSchedules = filterFailedSchedules(scheduleList); setFailedScheduleCount(failedSchedules.length); @@ -75,6 +81,7 @@ export function useRouteBoardScheduleIndicators(options?: { return { pendingScheduleShopIds, + lockedScheduleShopIds, failedScheduleShopIds, failedScheduleCount, refreshScheduleIndicators, diff --git a/src/i18n/en/do.json b/src/i18n/en/do.json index 157f501..80ab7d6 100644 --- a/src/i18n/en/do.json +++ b/src/i18n/en/do.json @@ -89,7 +89,11 @@ "Replenish Qty": "Replenish Qty", "Replenish qty must be greater than zero": "Replenish qty must be greater than zero", "Replenishment": "Replenishment", + "Delivery date is required": "Delivery date is required", + "Failed to load replenishment records": "Failed to load replenishment records", + "Failed to submit replenishment": "Failed to submit replenishment", "Replenishment API not ready": "Replenishment API not ready", + "Replenishment submitted successfully": "Replenishment submitted successfully", "Replenishment Entry": "Replenishment Entry", "Replenishment item code": "Item Code", "Replenishment Tracking": "Replenishment Tracking", diff --git a/src/i18n/en/routeboard.json b/src/i18n/en/routeboard.json index 453fad3..d7622d5 100644 --- a/src/i18n/en/routeboard.json +++ b/src/i18n/en/routeboard.json @@ -174,7 +174,6 @@ "district_dialog_add": "Add district", "district_dialog_edit": "Edit district", "district_name_label": "District display name", - "district_name_ph": "Blank means \"Unclassified\"", "seq_edit_departureLabel": "Departure time", "seq_edit_seqLabel": "Load sequence (Seq)", "route_new_code_label": "Lane code", @@ -200,8 +199,6 @@ "dialog_editLogisticsTitle": "Edit logistics master", "btn_apply": "Apply", "addShop_confirm": "Confirm", - "addShop_listHint": "Shop codes already on this lane are hidden from the list. After adding, reorder by drag; like other edits, press \"Save changes\" to persist to truck rows.", - "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", "logistics_colLaneCount": "{{count}} lane(s)", diff --git a/src/i18n/en/shop.json b/src/i18n/en/shop.json index 502fc89..d0fdfb3 100644 --- a/src/i18n/en/shop.json +++ b/src/i18n/en/shop.json @@ -96,7 +96,6 @@ "addRoute_submitting": "Adding…", "addShop_confirm": "Confirm", "addShop_dialogTitle": "Add shop to lane", - "addShop_listHint": "Shop codes already on this lane are hidden from the list. After adding, reorder by drag; like other edits, press \"Save changes\" to persist to truck rows.", "api_fail_addShop": "Failed to add shop", "api_fail_createLane": "Failed to create lane", "api_fail_deleteShop": "Failed to delete shop", @@ -197,7 +196,6 @@ "district_err_name": "Enter a district name", "district_err_reserved": "\"Unclassified\" is built-in; do not add it again", "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.", "drawerClose": "Close", "emDash": "—", @@ -387,19 +385,23 @@ "schedule_retry_rejects_partial": "PARTIAL schedules cannot be retried. Restore the board and create a new schedule.", "schedule_review_count": "{{count}} change(s)", "schedule_review_delete_action": "Remove from:", + "schedule_review_create_action": "Add to:", "schedule_review_district_change": "District:", "schedule_review_empty": "No pending changes", - "schedule_review_empty_hint": "Drag shops to adjust lane or loading sequence, or use the delete button to remove shops", + "schedule_review_empty_hint": "Drag shops to adjust lane or loading sequence, edit departure time, or use Add shop / delete to change lane contents", "schedule_review_lane_change": "Lane:", "schedule_review_queue": "Change preview queue", "schedule_review_revert": "Revert", "schedule_review_seq": "Sequence:", + "schedule_review_departure_shops": "Affects {{count}} shop(s)", + "schedule_review_departure_pending_creates": "Affects {{count}} pending new shop(s)", "schedule_search_ph": "Search shop name / code...", "schedule_seq_dialog_hint": "The change is added to the preview queue and applied when you confirm the schedule.", "schedule_seq_edit_btn": "Edit load sequence", "schedule_seq_hint": "Default: max sequence in target lane for same district + 1", "schedule_shop_badge": "Scheduled change", - "schedule_shop_locked": "Schedule is applying; this shop cannot be edited manually", + "schedule_shop_locked": "Schedule executes soon (or is applying); this shop is locked", + "schedule_shop_scheduled": "This shop has a pending schedule (not locked yet; it will lock before execution)", "schedule_step_method": "2. Open lanes to schedule and review planned changes", "schedule_step_time": "1. Set execution time", "schedule_summary_changes": "{{count}} change(s) (lane moves and sequence updates) will be scheduled", @@ -445,6 +447,7 @@ "version_ui_filterAria": "Filter version list", "version_ui_historyTitle": "Version history", "version_ui_id": "Version #{{id}}", + "version_ui_pendingRestore": "Version #{{id}} (restore pending)", "version_ui_listAria": "Version history list", "version_ui_none": "No snapshot yet", "version_ui_snapshotBadge": "Current snapshot", diff --git a/src/i18n/zh/do.json b/src/i18n/zh/do.json index ede472c..8316ac3 100644 --- a/src/i18n/zh/do.json +++ b/src/i18n/zh/do.json @@ -38,7 +38,11 @@ "Replenish Qty": "補貨數量", "Replenish qty must be greater than zero": "補貨數量必須大於零", "Replenishment": "補貨", + "Delivery date is required": "請選擇送貨日期", + "Failed to load replenishment records": "載入補貨記錄失敗", + "Failed to submit replenishment": "提交補貨失敗", "Replenishment API not ready": "補貨 API 尚未就緒", + "Replenishment submitted successfully": "補貨已提交", "Replenishment Entry": "補貨填表", "Replenishment item code": "貨品編號", "Replenishment Tracking": "補貨進度追蹤", diff --git a/src/i18n/zh/routeboard.json b/src/i18n/zh/routeboard.json index cbced1c..7e990a5 100644 --- a/src/i18n/zh/routeboard.json +++ b/src/i18n/zh/routeboard.json @@ -53,7 +53,7 @@ "err_export": "匯出失敗", "err_noLanes": "目前無車線資料", "err_import": "匯入失敗", - "err_dragDuplicateShop": "目標車線已有相同店鋪(同一 shop / 同一 shopCode),無法拖入", + "err_dragDuplicateShop": "目標車線已有相同店鋪(同一店鋪/ 同一店鋪編號),無法拖入", "district_err_name": "請輸入地區名稱", "district_err_reserved": "「未分類」已內建,請勿重複新增", "district_err_exists": "此地區已存在", @@ -61,10 +61,10 @@ "route_err_departure": "請選擇或輸入出車時間", "route_err_duplicate": "此車線(含備註組合)已存在", "route_err_create": "新增車線失敗", - "confirm_addShopConflict": "偵測到 {{count}} 筆可能與其他車線衝突(見右上角鈴鐺)。將先加入畫面,按「儲存更改」才寫入後端。仍要加入?", + "confirm_addShopConflict": "偵測到 {{count}} 筆可能與其他車線衝突(見右上角鈴鐺)。仍要加入?", "confirm_discardDraftShop": "捨棄尚未儲存的「新增店鋪」?", "confirm_removeShop": "從此車線移除此店鋪?(按「儲存更改」才會寫入)", - "confirm_clearLane": "確定清空車線「{{laneLabel}}」的 {{count}} 筆店鋪?(按「儲存更改」才會從後端刪除)", + "confirm_clearLane": "確定清空車線「{{laneLabel}}」的 {{count}} 筆店鋪?", "confirm_departureConflict": "變更出車時間後,偵測到 {{count}} 筆可能衝突(見鈴鐺)。仍要套用?", "drag_blockDraftShop": "尚未儲存的「新增店鋪」請先按「儲存更改」寫入,或從卡片刪除草稿後再拖曳。", "nav_unsavedLeave": "有未儲存的更改,確定要離開?", @@ -78,8 +78,8 @@ "versionNote_saveFail": "備註儲存失敗", "diff_restoreFail": "恢復失敗", "confirm_restoreDiscardsEdits": "排程版本還原會捨棄目前看板上其他未儲存變更(拖曳、刪除、新增店鋪/車線、物流欄等)。確定繼續?", - "diff_restoreScheduled": "已排程還原至版本 #{{versionId}};請按「儲存更改」寫入後端。", - "diff_restoreAlreadyPending": "此版本已在排程還原中;請按「儲存更改」套用。", + "diff_restoreScheduled": "已排程還原至版本 #{{versionId}}。", + "diff_restoreAlreadyPending": "此版本已在排程還原中。", "restore_applied": "已從 版本還原並重新載入看板。", "restore_appliedDroppedStaging": "已套用 版本還原;本次儲存略過其他暫存變更(請重新編輯)。", "confirm_restoreSaveWillDropStaging": "儲存時將先套用 版本還原,本次其他暫存變更會被略過。確定繼續?", @@ -168,13 +168,12 @@ "diff_noDiffFromPrev": "與上一版無差異", "diff_loadingEllipsis": "…", "addShop_dialogTitle": "新增店鋪到車線", - "addRoute_dialogTitle": "新增配送車線", + "addRoute_dialogTitle": "新增車線", "addRoute_confirm": "確認新增車線", "addRoute_submitting": "新增中…", "district_dialog_add": "新增地區", "district_dialog_edit": "編輯地區", - "district_name_label": "地區顯示名稱", - "district_name_ph": "空白表示「未分類」", + "district_name_label": "地區名稱", "seq_edit_departureLabel": "出車時間", "seq_edit_seqLabel": "裝車順序", "route_new_code_label": "車線編號", @@ -200,7 +199,6 @@ "dialog_editLogisticsTitle": "編輯物流公司", "btn_apply": "套用", "addShop_confirm": "確認", - "addShop_listHint": "同車線內已存在的店鋪代碼不會出現在清單。新增後若要改順序可拖曳。", "departureDialog_title": "編輯出車時間", "departureDialog_hint": "套用至此車線所有店鋪列。", "seqDialog_title": "編輯裝車順序", diff --git a/src/i18n/zh/shop.json b/src/i18n/zh/shop.json index 2964d66..27eee2d 100644 --- a/src/i18n/zh/shop.json +++ b/src/i18n/zh/shop.json @@ -53,7 +53,7 @@ "err_export": "匯出失敗", "err_noLanes": "目前無車線資料", "err_import": "匯入失敗", - "err_dragDuplicateShop": "目標車線已有相同店鋪(同一 shop / 同一 shopCode),無法拖入", + "err_dragDuplicateShop": "目標車線已有相同店鋪(同一店鋪/ 同一店鋪編號),無法拖入", "district_err_name": "請輸入地區名稱", "district_err_reserved": "「未分類」已內建,請勿重複新增", "district_err_exists": "此地區已存在", @@ -61,11 +61,11 @@ "route_err_departure": "請選擇或輸入出車時間", "route_err_duplicate": "此車線(含備註組合)已存在", "route_err_create": "新增車線失敗", - "confirm_addShopConflict": "偵測到 {{count}} 筆可能與其他車線衝突(見右上角鈴鐺)。將先加入畫面,按「儲存更改」才寫入後端。仍要加入?", + "confirm_addShopConflict": "偵測到 {{count}} 筆可能與其他車線衝突(見右上角鈴鐺)。仍要加入?", "confirm_discardDraftShop": "捨棄尚未儲存的「新增店鋪」?", "confirm_removeShop": "從此車線移除此店鋪?(按「儲存更改」才會寫入)", "confirm_schedule_removeShop": "從此車線移除此店鋪?此操作將列入預約排程的刪除項目。", - "confirm_clearLane": "確定清空車線「{{laneLabel}}」的 {{count}} 筆店鋪?(按「儲存更改」才會從後端刪除)", + "confirm_clearLane": "確定清空車線「{{laneLabel}}」的 {{count}} 筆店鋪?", "confirm_departureConflict": "變更出車時間後,偵測到 {{count}} 筆可能衝突(見鈴鐺)。仍要套用?", "drag_blockDraftShop": "尚未儲存的「新增店鋪」請先按「儲存更改」寫入,或從卡片刪除草稿後再拖曳。", "nav_unsavedLeave": "有未儲存的更改,確定要離開?", @@ -79,8 +79,8 @@ "versionNote_saveFail": "備註儲存失敗", "diff_restoreFail": "恢復失敗", "confirm_restoreDiscardsEdits": "排程版本還原會捨棄目前看板上其他未儲存變更(拖曳、刪除、新增店鋪/車線、物流欄等)。確定繼續?", - "diff_restoreScheduled": "已排程還原至版本 #{{versionId}};請按「儲存更改」寫入後端。", - "diff_restoreAlreadyPending": "此版本已在排程還原中;請按「儲存更改」套用。", + "diff_restoreScheduled": "已排程還原至版本 #{{versionId}}。", + "diff_restoreAlreadyPending": "此版本已在排程還原中。", "restore_applied": "已從版本還原並重新載入看板。", "restore_appliedDroppedStaging": "已套用版本還原;本次儲存略過其他暫存變更(請重新編輯)。", "confirm_restoreSaveWillDropStaging": "儲存時將先套用版本還原,本次其他暫存變更會被略過。確定繼續?", @@ -120,6 +120,7 @@ "version_ui_listAria": "版本歷史列表", "version_ui_snapshotBadge": "目前版本", "version_ui_id": "版本#{{id}}", + "version_ui_pendingRestore": "版本#{{id}}(待還原)", "version_ui_none": "尚無版本", "version_ui_editedBy": "編輯者:{{name}}", "version_note_placeholder": "備註(離開欄位即儲存)", @@ -161,8 +162,8 @@ "diff_logisticMaster_added": "新增", "diff_logisticMaster_edited": "修改", "diff_noShopDiffHasBoardStaged": "與上一版本相比,店鋪列無差異;下列為看板上尚未按「儲存更改」寫入的變更(含新增物流公司)。", - "diff_export_blockedTooltip": "匯出檔為後端兩版本比對,不含看板未儲存變更。請先按「儲存更改」或取消變更後再匯出。", - "diff_export_blockedError": "有看板未儲存變更時無法匯出(Excel 僅含已落庫版本)。", + "diff_export_blockedTooltip": "匯出檔為後端兩版本比對,不含未儲存變更。請先按「儲存更改」或取消變更後再匯出。", + "diff_export_blockedError": "有未儲存變更時無法匯出。", "diff_markedCount": "{{count}} 筆變更", "diff_noDiffFromPrev": "與上一版無差異", "diff_loadingEllipsis": "…", @@ -173,8 +174,7 @@ "addRoute_submitting": "新增中…", "district_dialog_add": "新增地區", "district_dialog_edit": "編輯地區", - "district_name_label": "地區顯示名稱", - "district_name_ph": "空白表示「未分類」", + "district_name_label": "地區名稱", "seq_edit_departureLabel": "出車時間", "seq_edit_seqLabel": "裝車順序", "route_new_code_label": "車線編號", @@ -200,21 +200,20 @@ "dialog_editLogisticsTitle": "編輯物流公司", "btn_apply": "套用", "addShop_confirm": "確認", - "addShop_listHint": "同車線內已存在的店鋪代碼不會出現在清單。新增後若要改順序可拖曳;與其他編輯相同,需按「儲存更改」才會寫入後端 truck。", "departureDialog_title": "編輯出車時間", - "departureDialog_hint": "套用至此車線所有店鋪列;按上方「儲存更改」寫入後端。", + "departureDialog_hint": "套用至此車線所有店鋪列。", "seqDialog_title": "編輯裝車順序", "logistics_colLaneCount": "{{count}} 條車線", "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}}」?", @@ -301,12 +300,15 @@ "schedule_review_queue": "變更預覽佇列", "schedule_review_count": "{{count}} 項變更", "schedule_review_empty": "暫無待排程變更", - "schedule_review_empty_hint": "請拖曳店鋪以調整其車線分配或裝載排程順序,或使用刪除按鈕移除店鋪", + "schedule_review_empty_hint": "請拖曳店鋪以調整其車線分配或裝載排程順序,或編輯出車時間、新增店鋪、刪除按鈕變更車線內容", "schedule_review_revert": "復原", + "schedule_review_create_action": "新增至:", "schedule_review_delete_action": "刪除自:", "schedule_review_lane_change": "車線變更:", "schedule_review_district_change": "區域參考:", "schedule_review_seq": "裝載順序:", + "schedule_review_departure_shops": "影響 {{count}} 間店鋪", + "schedule_review_departure_pending_creates": "影響 {{count}} 筆待新增店鋪", "schedule_drop_hint": "請拖曳店鋪卡片至此車線", "schedule_moved_badge": "移入", "schedule_drag_seq": "裝載順序: {{seq}}", @@ -325,7 +327,8 @@ "schedule_err_generic": "排程請求失敗,請稍後再試。", "schedule_reschedule_time_adjusted": "原執行時間已過去,已自動調整為 {{at}}。", "schedule_shop_badge": "已排程變更", - "schedule_shop_locked": "排程執行中,此店鋪暫不可手改", + "schedule_shop_locked": "排程即將執行(或執行中),此店鋪已鎖定不可手改", + "schedule_shop_scheduled": "此店鋪有待執行排程(尚未鎖定,仍可編輯;執行前將鎖定)", "schedule_retry_rejects_partial": "部分排程不可重試,請先還原看板後重新建立排程", "schedule_registered_snackbar": "已登記 {{count}} 筆預約變更,將於執行時間由伺服器自動套用。", "schedule_import_parse_summary": "解析完成:有效 {{valid}} 筆,錯誤 {{errors}} 筆",