2 Commits

19 changed files with 1195 additions and 119 deletions
Split View
  1. +59
    -0
      src/app/api/do/actions.tsx
  2. +4
    -0
      src/app/api/shop/actions.ts
  3. +124
    -63
      src/components/DoSearch/DoReplenishmentTab.tsx
  4. +49
    -10
      src/components/Shop/RouteBoard.tsx
  5. +198
    -3
      src/components/Shop/ScheduleChangeModal.tsx
  6. +168
    -2
      src/components/Shop/ScheduleDragWorkspacePane.tsx
  7. +191
    -2
      src/components/Shop/ScheduleReviewQueue.tsx
  8. +22
    -0
      src/components/Shop/computeTruckLaneWarnings.ts
  9. +113
    -4
      src/components/Shop/scheduleApiAdapter.ts
  10. +198
    -0
      src/components/Shop/scheduleDragWorkspace.ts
  11. +1
    -0
      src/components/Shop/scheduleLineAdapter.ts
  12. +20
    -3
      src/components/Shop/truckLaneMovePlanner.ts
  13. +7
    -0
      src/components/Shop/useRouteBoardScheduleIndicators.ts
  14. +4
    -0
      src/i18n/en/do.json
  15. +0
    -3
      src/i18n/en/routeboard.json
  16. +7
    -4
      src/i18n/en/shop.json
  17. +4
    -0
      src/i18n/zh/do.json
  18. +7
    -9
      src/i18n/zh/routeboard.json
  19. +19
    -16
      src/i18n/zh/shop.json

+ 59
- 0
src/app/api/do/actions.tsx View File

@@ -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<DoReplenishmentRecord[]> {
return serverFetchJson<DoReplenishmentRecord[]>(`${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<DoReplenishmentRecord[]> {
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<DoReplenishmentRecord[]>(url, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
}

+ 4
- 0
src/app/api/shop/actions.ts View File

@@ -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 = {


+ 124
- 63
src/components/DoSearch/DoReplenishmentTab.tsx View File

@@ -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<ReplenishmentDraftRow[]>([]);
const [records, setRecords] = useState<ReplenishmentRecord[]>([]);
const [isLoadingTracking, setIsLoadingTracking] = useState(false);
const [trackStatusFilter, setTrackStatusFilter] = useState<ReplenishmentStatus | "all">("all");
const [trackDateFilter, setTrackDateFilter] = useState<Dayjs | null>(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<ReplenishmentRecord>[] = 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 = () => {
<TableCell sx={{ width: { md: "8%" }, minWidth: { md: 48 }, whiteSpace: "nowrap" }}>
{t("uom")}
</TableCell>
<TableCell sx={{ width: { md: "10%" }, minWidth: { md: 88 }, whiteSpace: "nowrap" }}>
{t("Truck Lance Code")}
</TableCell>
<TableCell align="center" sx={{ width: { md: 120 }, whiteSpace: "nowrap" }}>
{t("Action")}
</TableCell>
@@ -572,6 +624,9 @@ const DoReplenishmentTab: React.FC = () => {
<TableCell align="right">{row.originalQty}</TableCell>
<TableCell align="right">{row.replenishQty}</TableCell>
<TableCell>{row.shortUom || "—"}</TableCell>
<TableCell>
{row.truckLaneCode?.trim() || sourceDo.truckLaneCode?.trim() || t("Truck X")}
</TableCell>
<TableCell align="center">
<Box sx={REPLENISHMENT_TABLE_ACTION_CELL_INNER_SX}>
<IconButton
@@ -591,13 +646,7 @@ const DoReplenishmentTab: React.FC = () => {
<Autocomplete
size="small"
fullWidth
options={sourceDo.lines.filter(
(line) =>
!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" }}
/>
</TableCell>
<TableCell>
<ReplenishmentItemEntryPlainText
reserveSpace
value={
sourceDo.truckLaneCode?.trim()
? sourceDo.truckLaneCode
: t("Truck X")
}
sx={{ whiteSpace: "nowrap" }}
/>
</TableCell>
<TableCell align="center">
<Box sx={REPLENISHMENT_TABLE_ACTION_CELL_INNER_SX}>
<Button
@@ -769,9 +829,10 @@ const DoReplenishmentTab: React.FC = () => {
</Stack>
</Box>
<StyledDataGrid
rows={filteredRecords}
rows={records}
columns={trackColumns}
autoHeight
loading={isLoadingTracking}
disableRowSelectionOnClick
pageSizeOptions={[10, 25, 50]}
initialState={{ pagination: { paginationModel: { pageSize: 10 } } }}


+ 49
- 10
src/components/Shop/RouteBoard.tsx View File

@@ -871,7 +871,7 @@ const RouteBoard: React.FC = () => {
const logisticsLaneDragIdRef = useRef<string | null>(null);
/** baseline: 後端目前 lane logisticId(用於判斷「只改物流商」也要能 Save) */
const laneLogisticBaselineRef = useRef<Map<string, number | null>>(new Map());
/** 店鋪列地區顯示 baseline(載入/refresh 後同步),供未儲存清單標註地區差 */
/** 店鋪列地區 baseline(載入/refresh 後同步),供未儲存清單標註地區差 */
const shopDistrictBaselineRef = useRef<Map<number, string>>(new Map());
const shopRowBaselineRef = useRef<Map<number, ShopRowBaseline>>(new Map());
const [districtBaselineEpoch, setDistrictBaselineEpoch] = useState(0);
@@ -995,6 +995,20 @@ const RouteBoard: React.FC = () => {
[logVersions],
);

const displayedVersionLabel = useMemo(() => {
if (
pendingRestoreVersionId != null &&
Number.isFinite(pendingRestoreVersionId) &&
pendingRestoreVersionId > 0
) {
return t("version_ui_pendingRestore", { id: pendingRestoreVersionId });
}
if (headVersionId != null) {
return t("version_ui_id", { id: headVersionId });
}
return t("version_ui_none");
}, [pendingRestoreVersionId, headVersionId, t]);

const versionFilterActive =
String(versionFilterQuery || "").trim() !== "" ||
String(versionFilterDate || "").trim() !== "";
@@ -1043,11 +1057,13 @@ const RouteBoard: React.FC = () => {
scheduleModalOpenRef.current = scheduleModalOpen;
const {
pendingScheduleShopIds,
lockedScheduleShopIds,
failedScheduleShopIds,
failedScheduleCount,
refreshScheduleIndicators,
} = useRouteBoardScheduleIndicators({ paused: scheduleModalOpen });
const scheduledShopIdSet = pendingScheduleShopIds;
/** 硬鎖:APPLYING 或進入鎖定時間窗的排程;遠期排程僅標記不鎖。 */
const scheduledShopIdSet = lockedScheduleShopIds;

const [addShopDialogOpen, setAddShopDialogOpen] = useState(false);
const [addShopLaneId, setAddShopLaneId] = useState<string | null>(null);
@@ -2772,6 +2788,7 @@ const RouteBoard: React.FC = () => {
truckLanceCode: lane.truckLanceCode,
remark: lane.remark,
storeId: normalizeStoreId(lane.storeId),
departureTime: parseTimeForBackend(lane.startTime) || "00:00:00",
shops: lane.shops
.filter((s) => s.id >= 0)
.map((s) => ({
@@ -4222,10 +4239,7 @@ const RouteBoard: React.FC = () => {
{t("pageTitle")}
</Typography>
<Typography variant="caption" color="text.secondary">
{t("Current version")}:{" "}
{headVersionId != null
? t("version_ui_id", { id: headVersionId })
: t("version_ui_none")}
{t("Current version")}: {displayedVersionLabel}
</Typography>
</Box>

@@ -4584,6 +4598,7 @@ const RouteBoard: React.FC = () => {
onClose={() => setScheduleModalOpen(false)}
lanes={scheduleLaneOptions}
shops={scheduleShopRows}
allShopsMaster={allShopsMaster}
pendingTruckRowIds={pendingScheduleShopIds}
onConfirmManual={handleScheduleConfirmManual}
onAfterScheduleChange={async () => {
@@ -5812,9 +5827,6 @@ const RouteBoard: React.FC = () => {
: t("shop_autocomplete_noOptions")
}
/>
<Typography variant="caption" color="text.secondary">
{t("addShop_listHint")}
</Typography>
</Stack>
</DialogContent>
<DialogActions>
@@ -5845,7 +5857,6 @@ const RouteBoard: React.FC = () => {
autoFocus
margin="dense"
label={t("district_name_label")}
placeholder={t("district_name_ph")}
fullWidth
value={districtEditDraft}
onChange={(e) => {
@@ -7636,6 +7647,10 @@ const RouteBoard: React.FC = () => {
const changed = dirtyMoves.has(shop.id);
const isScheduledMove =
shop.id > 0 && scheduledShopIdSet.has(shop.id);
const isScheduledLater =
shop.id > 0 &&
!isScheduledMove &&
pendingScheduleShopIds.has(shop.id);
const isFailedScheduledMove =
shop.id > 0 &&
failedScheduleShopIds.has(shop.id);
@@ -7744,6 +7759,30 @@ const RouteBoard: React.FC = () => {
</Box>
</Tooltip>
)}
{isScheduledLater &&
!isFailedScheduledMove && (
<Tooltip
title={t("schedule_shop_scheduled")}
>
<Box
sx={{
position: "absolute",
top: 0,
right: 0,
p: 0.75,
bgcolor: "info.light",
color: "info.contrastText",
borderBottomLeftRadius: 8,
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1,
}}
>
<Clock size={12} />
</Box>
</Tooltip>
)}
{showInsertLine && (
<Box
sx={{


+ 198
- 3
src/components/Shop/ScheduleChangeModal.tsx View File

@@ -10,6 +10,7 @@ import React, {
} from "react";
import {
Alert,
Autocomplete,
Box,
Button,
Chip,
@@ -48,9 +49,20 @@ import ScheduleDragWorkspacePane from "@/components/Shop/ScheduleDragWorkspacePa
import ScheduleReviewQueue from "@/components/Shop/ScheduleReviewQueue";
import { extractApiErrorMessage } from "@/components/Shop/scheduleClientHelpers";
import {
computeTruckLaneWarnings,
diffNewTruckLaneWarnings,
} from "@/components/Shop/computeTruckLaneWarnings";
import {
addShopToPlan,
defaultFocusedLaneIds,
initPlannedLanes,
listDepartureModifications,
listModifications,
parseScheduleDepartureTime,
plannedLanesToWarningInputRows,
revertLaneDeparture,
setLaneDepartureTime,
listPendingCreates,
moveShop,
removeShopFromPlan,
restoreDeletedShop,
@@ -83,6 +95,8 @@ export type ScheduleLaneOption = {
truckLanceCode: string;
remark?: string | null;
storeId: string;
/** Lane departure time (HH:mm:ss) for CREATE schedule lines. */
departureTime: string;
shops: ScheduleLaneShopSnapshot[];
};

@@ -114,11 +128,14 @@ export type ScheduleChangePayload = {
pendingDeletes: ScheduledDeleteSnapshot[];
};

type ShopMasterOption = { id: number; name: string; code: string };

type Props = {
open: boolean;
onClose: () => void;
lanes: ScheduleLaneOption[];
shops: ScheduleShopRow[];
allShopsMaster: ShopMasterOption[];
pendingTruckRowIds: Set<number>;
onConfirmManual: (payload: ScheduleChangePayload) => void | Promise<void>;
onAfterScheduleChange?: () => void | Promise<void>;
@@ -129,12 +146,14 @@ const ScheduleChangeModal: React.FC<Props> = ({
onClose,
lanes,
shops,
allShopsMaster,
pendingTruckRowIds,
onConfirmManual,
onAfterScheduleChange,
}) => {
const { t } = useTranslation("shop");
const importFileRef = useRef<HTMLInputElement>(null);
const nextTempTruckRowIdRef = useRef(-1);
const [tab, setTab] = useState<"manual" | "import">("manual");
const [scheduledDate, setScheduledDate] = useState("");
const [scheduledTime, setScheduledTime] = useState("");
@@ -168,9 +187,13 @@ const ScheduleChangeModal: React.FC<Props> = ({
const [pendingDeletes, setPendingDeletes] = useState<ScheduledDeleteSnapshot[]>(
[],
);
const [addShopDialogOpen, setAddShopDialogOpen] = useState(false);
const [addShopLaneId, setAddShopLaneId] = useState<string | null>(null);
const [addShopPick, setAddShopPick] = useState<ShopMasterOption | null>(null);

useEffect(() => {
if (!open) return;
nextTempTruckRowIdRef.current = -1;
setTab("manual");
setScheduledDate("");
setScheduledTime("");
@@ -190,6 +213,9 @@ const ScheduleChangeModal: React.FC<Props> = ({
setStagedPlanCounts(null);
setStagedRowErrors([]);
setSubmitError(null);
setAddShopDialogOpen(false);
setAddShopLaneId(null);
setAddShopPick(null);
}, [open, lanes, shops]);

const modifications = useMemo(
@@ -197,6 +223,41 @@ const ScheduleChangeModal: React.FC<Props> = ({
[plannedLanes],
);

const pendingCreates = useMemo(
() => listPendingCreates(plannedLanes),
[plannedLanes],
);

const departureModifications = useMemo(
() => listDepartureModifications(plannedLanes),
[plannedLanes],
);

const addShopCandidates = useMemo(() => {
if (!addShopLaneId) return [];
const codesOnPlan = new Set<string>();
const idsOnPlan = new Set<number>();
for (const lane of plannedLanes) {
for (const s of lane.shops) {
const c = String(s.shopCode || "")
.trim()
.toLowerCase();
if (c) codesOnPlan.add(c);
if (s.shopEntityId != null && s.shopEntityId > 0) {
idsOnPlan.add(s.shopEntityId);
}
}
}
return allShopsMaster.filter((m) => {
const c = String(m.code || "")
.trim()
.toLowerCase();
if (c && codesOnPlan.has(c)) return false;
if (idsOnPlan.has(m.id)) return false;
return true;
});
}, [addShopLaneId, plannedLanes, allShopsMaster]);

const handleMoveShop = useCallback(
(
shopId: number,
@@ -234,6 +295,38 @@ const ScheduleChangeModal: React.FC<Props> = ({
[],
);

const handleSetLaneDepartureTime = useCallback(
(laneId: string, departureTime: string): boolean => {
const backendTime = parseScheduleDepartureTime(departureTime);
if (!backendTime) return false;

let applied = false;
setPlannedLanes((prev) => {
const beforeRows = plannedLanesToWarningInputRows(prev);
const beforeWarnings = computeTruckLaneWarnings(beforeRows).warnings;
const next = setLaneDepartureTime(prev, laneId, backendTime);
const afterWarnings = computeTruckLaneWarnings(
plannedLanesToWarningInputRows(next),
).warnings;
const newWarnings = diffNewTruckLaneWarnings(beforeWarnings, afterWarnings);
if (newWarnings.length > 0) {
const ok = window.confirm(
t("confirm_departureConflict", { count: newWarnings.length }),
);
if (!ok) return prev;
}
applied = true;
return next;
});
return applied;
},
[t],
);

const handleRevertLaneDeparture = useCallback((laneId: string) => {
setPlannedLanes((prev) => revertLaneDeparture(prev, laneId));
}, []);

const handleAddEmptyDistrict = useCallback((laneId: string, display: string) => {
setPendingEmptyDistrictsByLane((prev) => {
const merged = dedupeDistrictPendingOrder([...(prev[laneId] ?? []), display]);
@@ -254,8 +347,39 @@ const ScheduleChangeModal: React.FC<Props> = ({
});
}, []);

const openAddShopDialog = useCallback((laneId: string) => {
setAddShopLaneId(laneId);
setAddShopPick(null);
setAddShopDialogOpen(true);
}, []);

const closeAddShopDialog = useCallback(() => {
setAddShopDialogOpen(false);
setAddShopLaneId(null);
setAddShopPick(null);
}, []);

const submitAddShop = useCallback(() => {
if (!addShopLaneId || !addShopPick) return;
const tempId = nextTempTruckRowIdRef.current;
nextTempTruckRowIdRef.current -= 1;
setPlannedLanes((prev) =>
addShopToPlan(prev, addShopLaneId, addShopPick, tempId),
);
closeAddShopDialog();
}, [addShopLaneId, addShopPick, closeAddShopDialog]);

const handleRevertCreate = useCallback((truckRowId: number) => {
setPlannedLanes((prev) => removeShopFromPlan(prev, truckRowId).next);
}, []);

const handleDeleteShop = useCallback(
(truckRowId: number, laneId: string) => {
if (truckRowId < 0) {
if (!window.confirm(t("confirm_schedule_removeShop"))) return;
setPlannedLanes((prev) => removeShopFromPlan(prev, truckRowId).next);
return;
}
if (truckRowId <= 0) return;
if (!window.confirm(t("confirm_schedule_removeShop"))) return;

@@ -507,10 +631,15 @@ const ScheduleChangeModal: React.FC<Props> = ({
}
};

const hasDepartureMoves = departureModifications.some((d) => d.shopCount > 0);

const canSubmitManual =
!isProcessing &&
plannedSubmit.ok &&
(modifications.length > 0 || pendingDeletes.length > 0);
(modifications.length > 0 ||
pendingDeletes.length > 0 ||
pendingCreates.length > 0 ||
hasDepartureMoves);

const canSubmitImport =
!isProcessing &&
@@ -691,6 +820,8 @@ const ScheduleChangeModal: React.FC<Props> = ({
onAddEmptyDistrict={handleAddEmptyDistrict}
onRemoveEmptyDistrict={handleRemoveEmptyDistrict}
onDeleteShop={handleDeleteShop}
onAddShop={openAddShopDialog}
onSetLaneDepartureTime={handleSetLaneDepartureTime}
/>
</Box>
<Box
@@ -706,8 +837,12 @@ const ScheduleChangeModal: React.FC<Props> = ({
<ScheduleReviewQueue
modifications={modifications}
pendingDeletes={pendingDeletes}
pendingCreates={pendingCreates}
departureModifications={departureModifications}
onRevert={handleRevertShop}
onRevertDelete={handleRevertDelete}
onRevertCreate={handleRevertCreate}
onRevertDeparture={handleRevertLaneDeparture}
errorTruckRowIds={validationErrorTruckRowIds}
validationErrorsByTruckRowId={validationErrorsByTruckRowId}
/>
@@ -933,7 +1068,9 @@ const ScheduleChangeModal: React.FC<Props> = ({
)}

{tab === "manual" &&
(modifications.length > 0 || pendingDeletes.length > 0) && (
(modifications.length > 0 ||
pendingDeletes.length > 0 ||
pendingCreates.length > 0) && (
<Alert
severity="warning"
icon={<AlertCircle size={20} />}
@@ -945,7 +1082,10 @@ const ScheduleChangeModal: React.FC<Props> = ({
>
<Typography variant="body2" sx={{ fontWeight: 800 }}>
{t("schedule_summary_changes", {
count: modifications.length + pendingDeletes.length,
count:
modifications.length +
pendingDeletes.length +
pendingCreates.length,
})}
</Typography>
{scheduledDate && scheduledTime && (
@@ -996,6 +1136,61 @@ const ScheduleChangeModal: React.FC<Props> = ({
)}
</DialogActions>
</Dialog>

<Dialog
open={addShopDialogOpen}
onClose={closeAddShopDialog}
maxWidth="sm"
fullWidth
>
<DialogTitle>
{t("addShop_dialogTitle")}{" "}
{(() => {
const lane = addShopLaneId
? lanes.find((l) => l.id === addShopLaneId)
: null;
if (!lane) return "";
return `「${lane.truckLanceCode}${
lane.remark != null && String(lane.remark).trim() !== ""
? ` · ${lane.remark}`
: ""
}」`;
})()}
</DialogTitle>
<DialogContent dividers>
<Stack spacing={2} sx={{ pt: 1 }}>
<Autocomplete
options={addShopCandidates}
getOptionLabel={(o) => `${o.name} (${o.code})`}
isOptionEqualToValue={(a, b) => a.id === b.id}
value={addShopPick}
onChange={(_e, v) => setAddShopPick(v)}
renderInput={(params) => (
<TextField
{...params}
label={t("shop_autocomplete_label")}
placeholder={t("shop_autocomplete_ph")}
/>
)}
noOptionsText={
allShopsMaster.length === 0
? t("shop_autocomplete_loading")
: t("shop_autocomplete_noOptions")
}
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={closeAddShopDialog}>{t("cancel")}</Button>
<Button
onClick={submitAddShop}
variant="contained"
disabled={!addShopPick}
>
{t("addShop_confirm")}
</Button>
</DialogActions>
</Dialog>
</>
);
};


+ 168
- 2
src/components/Shop/ScheduleDragWorkspacePane.tsx View File

@@ -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,
}}
>
<Box sx={{ minWidth: 0 }}>
<Stack direction="row" spacing={0.75} alignItems="center">
<Box sx={{ minWidth: 0, flex: 1 }}>
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap">
<Typography variant="body2" sx={{ fontWeight: 800 }} noWrap>
{lane.label}
</Typography>
@@ -459,6 +484,33 @@ const LaneColumn = memo(function LaneColumn({
sx={{ height: 18, fontSize: "0.65rem", fontFamily: "monospace" }}
/>
</Stack>
<Stack direction="row" alignItems="center" spacing={0.25} sx={{ mt: 0.35 }}>
<Typography
variant="caption"
sx={{
fontWeight: 700,
bgcolor: "grey.100",
px: 0.75,
py: 0.2,
borderRadius: 1,
whiteSpace: "nowrap",
}}
>
{departureLabel}: {formatDepartureChip(lane.departureTime)}
</Typography>
<Tooltip title={departureEditAriaLabel}>
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
onStartDepartureEdit(lane.id, lane.departureTime);
}}
aria-label={departureEditAriaLabel}
>
<Pencil size={14} />
</IconButton>
</Tooltip>
</Stack>
</Box>
<Chip
size="small"
@@ -558,6 +610,28 @@ const LaneColumn = memo(function LaneColumn({
</Stack>
)}
</Box>

<Box
sx={{
px: 1.5,
py: 1,
borderTop: 1,
borderColor: "divider",
bgcolor: "background.paper",
flexShrink: 0,
}}
>
<Button
fullWidth
size="small"
variant="outlined"
startIcon={<Plus size={14} />}
onClick={onAddShop}
sx={{ textTransform: "none", fontWeight: 700 }}
>
{addShopLabel}
</Button>
</Box>
</Paper>
);
});
@@ -576,6 +650,8 @@ const ScheduleDragWorkspacePane: React.FC<Props> = ({
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<Props> = ({
const [districtAddError, setDistrictAddError] = useState<string | null>(
null,
);
const [departureEditTarget, setDepartureEditTarget] = useState<{
laneId: string;
draft: string;
} | null>(null);
const [departureEditError, setDepartureEditError] = useState<string | null>(
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<Props> = ({
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<Props> = ({
</Box>
</Stack>

<Dialog
open={departureEditTarget != null}
onClose={() => {
setDepartureEditError(null);
setDepartureEditTarget(null);
}}
maxWidth="xs"
fullWidth
>
<DialogTitle>{t("departureDialog_title")}</DialogTitle>
<DialogContent>
<TextField
margin="dense"
fullWidth
autoFocus
type="time"
label={t("seq_edit_departureLabel")}
value={departureEditTarget?.draft ?? ""}
error={departureEditError != null}
helperText={departureEditError ?? undefined}
onChange={(e) => {
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 }}
/>
<Typography
variant="caption"
color="text.secondary"
sx={{ mt: 1, display: "block" }}
>
{t("departureDialog_hint")}
</Typography>
</DialogContent>
<DialogActions>
<Button
onClick={() => {
setDepartureEditError(null);
setDepartureEditTarget(null);
}}
>
{t("cancel")}
</Button>
<Button variant="contained" onClick={applyDepartureEdit}>
{t("btn_apply")}
</Button>
</DialogActions>
</Dialog>

<Dialog
open={seqEditTarget != null}
onClose={handleCancelSeqEdit}


+ 191
- 2
src/components/Shop/ScheduleReviewQueue.tsx View File

@@ -10,32 +10,51 @@ import {
Typography,
} from "@mui/material";
import { alpha } from "@mui/material/styles";
import { ArrowRight, Clock, HelpCircle, Trash2, Undo2 } from "lucide-react";
import { ArrowRight, Clock, HelpCircle, Plus, Trash2, Undo2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import type {
ScheduleDepartureModification,
ScheduleModification,
SchedulePendingCreate,
ScheduledDeleteSnapshot,
} from "@/components/Shop/scheduleDragWorkspace";

type Props = {
modifications: ScheduleModification[];
pendingDeletes: ScheduledDeleteSnapshot[];
pendingCreates: SchedulePendingCreate[];
departureModifications?: ScheduleDepartureModification[];
onRevert: (shopId: number, currentLaneId: string) => void;
onRevertDelete: (truckRowId: number) => void;
onRevertCreate: (truckRowId: number) => void;
onRevertDeparture?: (laneId: string) => void;
errorTruckRowIds?: Set<number>;
validationErrorsByTruckRowId?: Map<number, string>;
};

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<Props> = ({
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<Props> = ({
</Stack>
) : (
<Stack spacing={1} sx={{ width: "100%" }}>
{departureModifications.map((dep) => (
<Paper
key={`departure-${dep.laneId}`}
variant="outlined"
sx={{
p: 1.25,
width: "100%",
boxSizing: "border-box",
borderColor: "warning.light",
bgcolor: alpha("#ed6c02", 0.04),
"&:hover": {
borderColor: "warning.main",
boxShadow: 1,
},
}}
>
<Stack
direction="row"
justifyContent="space-between"
alignItems="flex-start"
spacing={1}
>
<Box sx={{ minWidth: 0 }}>
<Stack direction="row" spacing={0.5} alignItems="center">
<Clock size={12} color="#ed6c02" />
<Typography variant="caption" sx={{ fontWeight: 800 }}>
{dep.laneLabel}
</Typography>
</Stack>
<Typography
variant="caption"
color="text.secondary"
display="block"
sx={{ mt: 0.25 }}
>
{dep.shopCount > 0
? t("schedule_review_departure_shops", {
count: dep.shopCount,
})
: t("schedule_review_departure_pending_creates", {
count: dep.pendingCreateCount,
})}
</Typography>
</Box>
{onRevertDeparture ? (
<Button
size="small"
onClick={() => onRevertDeparture(dep.laneId)}
startIcon={<Undo2 size={12} />}
sx={{
minWidth: 0,
px: 1,
py: 0.25,
fontSize: "0.65rem",
fontWeight: 700,
textTransform: "none",
flexShrink: 0,
}}
>
{t("schedule_review_revert")}
</Button>
) : null}
</Stack>
<Box
sx={{
mt: 1,
p: 1,
borderRadius: 1,
bgcolor: "grey.50",
fontSize: "0.65rem",
}}
>
<Typography variant="caption" color="text.secondary">
{t("schedule_line_departure_change", {
from: formatDepartureShort(dep.oldDepartureTime),
to: formatDepartureShort(dep.newDepartureTime),
})}
</Typography>
</Box>
</Paper>
))}

{pendingCreates.map((create) => (
<Paper
key={`create-${create.truckRowId}`}
variant="outlined"
sx={{
p: 1.25,
width: "100%",
boxSizing: "border-box",
borderColor: "success.light",
bgcolor: alpha("#2e7d32", 0.04),
"&:hover": {
borderColor: "success.main",
boxShadow: 1,
},
}}
>
<Stack
direction="row"
justifyContent="space-between"
alignItems="flex-start"
spacing={1}
>
<Box sx={{ minWidth: 0 }}>
<Stack direction="row" spacing={0.5} alignItems="center">
<Plus size={12} color="#2e7d32" />
<Typography variant="caption" sx={{ fontWeight: 800 }}>
{create.shopCode}
</Typography>
</Stack>
<Typography
variant="caption"
color="text.secondary"
display="block"
noWrap
>
{create.displayName}
</Typography>
</Box>
<Button
size="small"
onClick={() => onRevertCreate(create.truckRowId)}
startIcon={<Undo2 size={12} />}
sx={{
minWidth: 0,
px: 1,
py: 0.25,
fontSize: "0.65rem",
fontWeight: 700,
textTransform: "none",
flexShrink: 0,
}}
>
{t("schedule_review_revert")}
</Button>
</Stack>

<Box
sx={{
mt: 1,
p: 1,
borderRadius: 1,
bgcolor: "grey.50",
fontSize: "0.65rem",
}}
>
<Stack direction="row" spacing={0.5} alignItems="center" flexWrap="wrap">
<Typography variant="caption" color="text.secondary">
{t("schedule_review_create_action")}
</Typography>
<Typography variant="caption" sx={{ fontWeight: 700 }}>
{create.toLaneLabel}
</Typography>
</Stack>
{create.location && (
<Typography
variant="caption"
color="text.secondary"
display="block"
sx={{ mt: 0.5 }}
noWrap
>
{create.location}
</Typography>
)}
</Box>
</Paper>
))}

{pendingDeletes.map((del) => (
<Paper
key={`delete-${del.truckRowId}`}


+ 22
- 0
src/components/Shop/computeTruckLaneWarnings.ts View File

@@ -331,6 +331,28 @@ export function computeTruckLaneWarnings(
return { warnings, weekdayParseFailures };
}

/** Stable key for comparing warning sets before/after a hypothetical edit. */
export function truckLaneWarningFingerprint(w: TruckLaneWarning): string {
const laneKeys = w.lanes
.map((l) => 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[],


+ 113
- 4
src/components/Shop/scheduleApiAdapter.ts View File

@@ -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<string, PlannedLane> {
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<number>,
): 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(


+ 198
- 0
src/components/Shop/scheduleDragWorkspace.ts View File

@@ -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 =


+ 1
- 0
src/components/Shop/scheduleLineAdapter.ts View File

@@ -14,6 +14,7 @@ export function moveToScheduleLine(
toStoreId: move.toStoreId,
toLoadingSequence: move.toLoadingSequence,
toDistrictReference: move.toDistrictReference ?? null,
departureTime: move.departureTime ?? null,
};
}



+ 20
- 3
src/components/Shop/truckLaneMovePlanner.ts View File

@@ -79,8 +79,19 @@ export function listPlannedModifications(

export function buildShopCodeByTruckRowId(
shops: ScheduleShopRow[],
plannedLanes?: ScheduleDragWorkspaceState,
): Map<number, string> {
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,
),
});
}



+ 7
- 0
src/components/Shop/useRouteBoardScheduleIndicators.ts View File

@@ -22,6 +22,9 @@ export function useRouteBoardScheduleIndicators(options?: {
const [pendingScheduleShopIds, setPendingScheduleShopIds] = useState<
Set<number>
>(new Set());
const [lockedScheduleShopIds, setLockedScheduleShopIds] = useState<
Set<number>
>(new Set());
const [failedScheduleShopIds, setFailedScheduleShopIds] = useState<
Set<number>
>(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,


+ 4
- 0
src/i18n/en/do.json View File

@@ -91,7 +91,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",


+ 0
- 3
src/i18n/en/routeboard.json View File

@@ -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)",


+ 7
- 4
src/i18n/en/shop.json View File

@@ -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",


+ 4
- 0
src/i18n/zh/do.json View File

@@ -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": "補貨進度追蹤",


+ 7
- 9
src/i18n/zh/routeboard.json View File

@@ -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": "編輯裝車順序",


+ 19
- 16
src/i18n/zh/shop.json View File

@@ -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}} 筆",


Loading…
Cancel
Save