@@ -97,6 +97,9 @@ import {
createTruckClient,
createTruckWithoutShopClient,
deleteTruckLaneClient,
previewTruckLaneRenameClient,
renameTruckLaneClient,
listTruckLaneRenameLogsClient,
} from "@/app/api/shop/client";
import {
deleteLogisticClient,
@@ -113,6 +116,7 @@ import type {
SaveTruckLane,
Truck,
TruckLaneVersionDiffLine,
TruckLaneRenameLogResponse,
} from "@/app/api/shop/actions";
import { formatDepartureTime, normalizeStoreId } from "@/app/utils/formatUtil";
import {
@@ -132,6 +136,7 @@ import {
import {
computeTruckLaneWarnings,
appendSyntheticPendingShopRow,
parseWeekdayFromTruckLanceCode,
type TruckLaneWarning,
type TruckLaneWarningInputRow,
type TruckLaneWarningLaneRef,
@@ -220,6 +225,12 @@ type Lane = {
storeId: string;
remark?: string | null;
shops: ShopCard[];
/** Server name before a local rename. Absent until the user edits the name. */
originalTruckLanceCode?: string;
/** Server departure time before a local time edit in the same unsaved session. */
originalStartTime?: string;
/** False for a lane that exists only in this unsaved session. */
persistedLane?: boolean;
};
type PendingNewLane = {
@@ -693,9 +704,31 @@ function buildLaneFromTruckRows(
storeId: storeId || "2F",
remark,
shops,
persistedLane: true,
};
}
function laneRenamePending(lane: Lane): boolean {
if (lane.persistedLane === false) return false;
const orig = String(lane.originalTruckLanceCode ?? "").trim();
const cur = String(lane.truckLanceCode ?? "").trim();
return orig !== "" && orig !== cur;
}
function renameErrorText(raw: string, t: TFunction): string {
const codes = [
"LANE_NAME_EMPTY",
"LANE_NAME_TOO_LONG",
"LANE_NAME_RESERVED",
"LANE_NAME_TAKEN",
"LANE_NAME_REMARK_AMBIGUOUS",
"LANE_NOT_FOUND",
"LANE_TIME_INVALID",
];
const code = codes.find((c) => raw.includes(c));
return code ? t(`renameErr_${code}`) : raw;
}
async function fetchLaneByKey(
truckLanceCode: string,
remark: string | null,
@@ -939,6 +972,11 @@ const RouteBoard: React.FC = () => {
null,
);
const [departureEditDraft, setDepartureEditDraft] = useState("");
const [departureEditNameDraft, setDepartureEditNameDraft] = useState("");
const [departureEditBusy, setDepartureEditBusy] = useState(false);
const [renameLogs, setRenameLogs] = useState<TruckLaneRenameLogResponse[]>(
[],
);
const [seqEditTarget, setSeqEditTarget] = useState<{
laneId: string;
shopId: number;
@@ -1234,6 +1272,7 @@ const RouteBoard: React.FC = () => {
storeId: storeNorm,
remark: remarkRaw,
shops: [],
persistedLane: false,
};
},
[logisticRowsEffective],
@@ -2922,6 +2961,7 @@ const RouteBoard: React.FC = () => {
pendingLogisticMasterDeletes.size > 0 ||
pendingImportMeta != null ||
pendingRestoreVersionId != null ||
lanes.some(laneRenamePending) ||
Object.values(pendingEmptyDistrictsByLane).some(
(a) => (a?.length ?? 0) > 0,
);
@@ -3193,7 +3233,8 @@ const RouteBoard: React.FC = () => {
pendingLogisticMasterEditsSnapshot.size > 0 ||
pendingLogisticMasterDeletesSnapshot.size > 0 ||
pendingImportFile != null ||
pendingRestoreId != null;
pendingRestoreId != null ||
lanesRef.current.some(laneRenamePending);
const hasPendingEmptyDistrictsOnly =
!hasPersistedWork &&
Object.values(pendingEmptyDistrictsByLane).some(
@@ -3267,6 +3308,54 @@ const RouteBoard: React.FC = () => {
return;
}
const renameTargets = lanesRef.current.filter(laneRenamePending);
if (renameTargets.length > 0) {
const renamed: string[] = [];
let renameFailure: string | null = null;
for (const lane of renameTargets) {
const orig = String(lane.originalTruckLanceCode ?? "").trim();
const timeBaseline = lane.originalStartTime ?? lane.startTime;
const departureTimeChanged =
parseTimeForBackend(timeBaseline) !== parseTimeForBackend(lane.startTime);
try {
await renameTruckLaneClient({
truckLanceCode: orig,
storeId: normalizeStoreId(lane.storeId),
remark: lane.remark ?? null,
newTruckLanceCode: lane.truckLanceCode.trim(),
departureTimeChanged,
newDepartureTime: departureTimeChanged ? lane.startTime : null,
});
renamed.push(`${orig} → ${lane.truckLanceCode.trim()}`);
const committed = lanesRef.current.map((row) =>
row.id === lane.id
? {
...row,
originalTruckLanceCode: row.truckLanceCode,
originalStartTime: row.startTime,
}
: row,
);
lanesRef.current = committed;
setLanes(committed);
} catch (e: any) {
renameFailure = `${lane.truckLanceCode.trim()}:${renameErrorText(
e?.message ?? String(e),
t,
)}`;
break;
}
}
if (renameFailure) {
const ok = renamed.length > 0 ? renamed.join("、") : t("rename_none");
setSaveResult({
ok: false,
message: t("rename_savePartial", { ok, fail: renameFailure }),
});
return;
}
}
if (pendingLogisticMasterEditsSnapshot.size > 0) {
for (const [id, req] of Array.from(
pendingLogisticMasterEditsSnapshot.entries(),
@@ -3657,47 +3746,188 @@ const RouteBoard: React.FC = () => {
const openDepartureEdit = (lane: Lane) => {
setDepartureEditDraft(toTimeInputValue(lane.startTime));
setDepartureEditNameDraft(lane.truckLanceCode);
setDepartureEditLaneId(lane.id);
};
const applyDepartureEdit = () => {
if (!departureEditLaneId) return;
const backendTime = parseTimeForBackend(departureEditDraft);
const targetLane = lanesRef.current.find(
(l) => l.id === departureEditLaneId,
const retargetLaneId = (oldId: string, newId: string, newCode: string) => {
if (oldId === newId) return;
setDirtyMoves((prev) => {
const next = new Map(prev);
for (const [shopId, laneId] of next) {
if (laneId === oldId) next.set(shopId, newId);
}
return next;
});
setPendingShopAdds((prev) =>
prev.map((row) => (row.laneId === oldId ? { ...row, laneId: newId } : row)),
);
const shopIds = targetLane?.shops.map((s) => s.id) ?? [];
if (shopIds.length === 0) {
setPendingEmptyDistrictsByLane((prev) => {
if (!(oldId in prev)) return prev;
const next = { ...prev, [newId]: prev[oldId] };
delete next[oldId];
return next;
});
setSelectedLaneIds((prev) => prev.map((id) => (id === oldId ? newId : id)));
setPendingNewLanes((prev) =>
prev.map((row) =>
row.laneKey === oldId
? {
laneKey: newId,
payload: { ...row.payload, truckLanceCode: newCode },
}
: row,
),
);
if (laneLogisticBaselineRef.current.has(oldId)) {
const value = laneLogisticBaselineRef.current.get(oldId) ?? null;
laneLogisticBaselineRef.current.delete(oldId);
laneLogisticBaselineRef.current.set(newId, value);
}
if (laneDisplayOverlayRef.current.has(oldId)) {
const value = laneDisplayOverlayRef.current.get(oldId);
laneDisplayOverlayRef.current.delete(oldId);
if (value) laneDisplayOverlayRef.current.set(newId, value);
}
if (lanesNeedingRefreshOnSaveRef.current.delete(oldId)) {
lanesNeedingRefreshOnSaveRef.current.add(newId);
}
};
const applyDepartureEdit = async () => {
if (!departureEditLaneId || departureEditBusy) return;
const backendTime = parseTimeForBackend(departureEditDraft);
const targetLane = lanesRef.current.find((l) => l.id === departureEditLaneId);
if (!targetLane) {
closeDepartureEdit();
return;
}
const nextLanes = lanesRef.current.map((lane) =>
lane.id !== departureEditLaneId
? lane
: {
...lane,
startTime: backendTime,
shops: lane.shops.map((s) => ({
...s,
departureTime: backendTime,
})),
},
);
const wr = computeTruckLaneWarnings(lanesToWarningInputRows(nextLanes));
if (wr.warnings.length > 0) {
const ok = window.confirm(
t("confirm_departureConflict", { count: wr.warnings.length }),
);
if (!ok) return;
const newCode = departureEditNameDraft.trim();
const oldCode = targetLane.truckLanceCode.trim();
if (!newCode) {
window.alert(t("renameErr_LANE_NAME_EMPTY"));
return;
}
setLanes(nextLanes);
setDirtyMoves((prev) => {
const next = new Map(prev);
for (const id of shopIds) {
if (id > 0) next.set(id, departureEditLaneId);
if (newCode.length > 50) {
window.alert(t("renameErr_LANE_NAME_TOO_LONG"));
return;
}
if (newCode === "車線-X" || oldCode === "車線-X") {
window.alert(t("renameErr_LANE_NAME_RESERVED"));
return;
}
const nameChanged = newCode !== oldCode;
const shopIds = targetLane.shops.map((s) => s.id);
if (!nameChanged && shopIds.length === 0) {
closeDepartureEdit();
return;
}
if (nameChanged) {
const floor = normalizeStoreId(targetLane.storeId);
const remarkKey =
floor === "4F" ? String(targetLane.remark ?? "").trim() : "";
const clash = lanesRef.current.find((lane) => {
if (lane.id === targetLane.id) return false;
if (normalizeStoreId(lane.storeId) !== floor) return false;
const otherRemark =
floor === "4F" ? String(lane.remark ?? "").trim() : "";
const otherCode = lane.truckLanceCode.trim();
if (otherRemark === remarkKey && otherCode.toLowerCase() === newCode.toLowerCase()) {
return true;
}
return otherRemark !== remarkKey && otherCode === newCode;
});
if (clash) {
const sameRemark =
floor !== "4F" || String(clash.remark ?? "").trim() === remarkKey;
window.alert(
sameRemark
? t("renameErr_LANE_NAME_TAKEN")
: t("renameErr_LANE_NAME_REMARK_AMBIGUOUS"),
);
return;
}
return next;
if (floor === "4F") {
const before = parseWeekdayFromTruckLanceCode(oldCode);
const after = parseWeekdayFromTruckLanceCode(newCode);
if (before !== after) {
if (!window.confirm(t("confirm_weekdayToken"))) return;
}
}
if (targetLane.persistedLane !== false) {
setDepartureEditBusy(true);
try {
const preview = await previewTruckLaneRenameClient({
truckLanceCode: targetLane.originalTruckLanceCode?.trim() || oldCode,
storeId: floor,
remark: targetLane.remark ?? null,
});
const handlers =
preview.handlerNames.length > 0
? preview.handlerNames.slice(0, 8).join("、")
: t("rename_noHandlers");
const ok = window.confirm(
t("confirm_renameLane", {
from: targetLane.originalTruckLanceCode?.trim() || oldCode,
to: newCode,
unfinished: preview.unfinishedCount,
completed: preview.completedCount,
handlers,
schedules: preview.openScheduleCount,
}),
);
if (!ok) return;
} catch (e: any) {
window.alert(
renameErrorText(e?.message ?? String(e), t) || t("renameErr_preview"),
);
return;
} finally {
setDepartureEditBusy(false);
}
}
}
const newId = nameChanged
? encodeLaneId(newCode, targetLane.remark ?? null)
: targetLane.id;
const nextLanes = lanesRef.current.map((lane) => {
if (lane.id !== targetLane.id) return lane;
const timeChanged = toTimeInputValue(lane.startTime) !== departureEditDraft;
return {
...lane,
id: newId,
truckLanceCode: nameChanged ? newCode : lane.truckLanceCode,
originalTruckLanceCode: nameChanged
? lane.originalTruckLanceCode ?? oldCode
: lane.originalTruckLanceCode,
originalStartTime: timeChanged
? lane.originalStartTime ?? lane.startTime
: lane.originalStartTime,
startTime: backendTime,
shops: lane.shops.map((s) => ({ ...s, departureTime: backendTime })),
};
});
if (shopIds.length > 0) {
const wr = computeTruckLaneWarnings(lanesToWarningInputRows(nextLanes));
if (wr.warnings.length > 0) {
const ok = window.confirm(
t("confirm_departureConflict", { count: wr.warnings.length }),
);
if (!ok) return;
}
}
lanesRef.current = nextLanes;
setLanes(nextLanes);
if (nameChanged) retargetLaneId(targetLane.id, newId, newCode);
if (shopIds.length > 0 && (nameChanged || toTimeInputValue(targetLane.startTime) !== departureEditDraft)) {
setDirtyMoves((prev) => {
const next = new Map(prev);
for (const id of shopIds) {
if (id > 0) next.set(id, newId);
}
return next;
});
}
setSaveResult(null);
closeDepartureEdit();
};
@@ -3832,6 +4062,9 @@ const RouteBoard: React.FC = () => {
setLoadingVersions(true);
try {
const arr = await refreshLogVersions();
void listTruckLaneRenameLogsClient()
.then((rows) => setRenameLogs(Array.isArray(rows) ? rows : []))
.catch(() => setRenameLogs([]));
const head = resolveHeadVersionId(arr);
setSelectedLogVersionId(head);
if (head != null) {
@@ -3922,6 +4155,8 @@ const RouteBoard: React.FC = () => {
if (hasOtherPending && !window.confirm(t("confirm_restoreDiscardsEdits")))
return;
if (!window.confirm(t("confirm_restoreKeepsTickets"))) return;
if (!hasOtherPending && pendingRestoreVersionId != null) {
laneDisplayOverlayRef.current.clear();
setPendingRestoreVersionId(versionId);
@@ -4169,11 +4404,7 @@ const RouteBoard: React.FC = () => {
{t("Departure")}: {lane.startTime || "-"}
</Typography>
<Tooltip
title={
lane.shops.length === 0
? t("departureTooltipNeedShops")
: t("departureTooltipEditSave")
}
title={t("laneEditTooltip")}
>
<span>
<IconButton
@@ -4182,7 +4413,7 @@ const RouteBoard: React.FC = () => {
e.stopPropagation();
openDepartureEdit(lane);
}}
disabled={loading || lane.shops.length === 0 }
disabled={loading}
aria-label={t("departureEditAria")}
>
<Pencil size={14} />
@@ -4710,6 +4941,33 @@ const RouteBoard: React.FC = () => {
</IconButton>
</Stack>
</Box>
{renameLogs.length > 0 && (
<Box
sx={{
px: 2,
py: 1,
borderBottom: 1,
borderColor: "divider",
bgcolor: "background.paper",
}}
>
<Typography variant="overline" sx={{ fontWeight: 800, color: "text.secondary" }}>
{t("renameLog_title")}
</Typography>
{renameLogs.slice(0, 8).map((row) => (
<Typography key={row.id} variant="caption" sx={{ display: "block" }}>
{t("renameLog_line", {
when: row.created ?? "",
who: row.createdBy ?? "",
from: row.oldCode,
to: row.newCode,
floor: row.storeId,
tickets: row.ticketCount,
})}
</Typography>
))}
</Box>
)}
<Box sx={{ flex: 1, overflow: "auto", p: 2 }}>
{loadingVersions ? (
<Box
@@ -5883,6 +6141,29 @@ const RouteBoard: React.FC = () => {
>
<DialogTitle>{t("departureDialog_title")}</DialogTitle>
<DialogContent>
<TextField
margin="dense"
fullWidth
label={t("departureDialog_nameLabel")}
value={departureEditNameDraft}
onChange={(e) => setDepartureEditNameDraft(e.target.value)}
inputProps={{ maxLength: 50 }}
sx={{ mt: 1 }}
/>
{departureEditLaneId &&
normalizeStoreId(
lanes.find((l) => l.id === departureEditLaneId)?.storeId,
) === "4F" &&
String(
lanes.find((l) => l.id === departureEditLaneId)?.remark ?? "",
).trim() !== "" && (
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 0.5 }}>
{t("departureDialog_remarkReadonly")}:
{String(
lanes.find((l) => l.id === departureEditLaneId)?.remark ?? "",
).trim()}
</Typography>
)}
<TextField
margin="dense"
fullWidth
@@ -5902,8 +6183,16 @@ const RouteBoard: React.FC = () => {
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={closeDepartureEdit}>{t("cancel")}</Button>
<Button variant="contained" onClick={applyDepartureEdit}>
<Button onClick={closeDepartureEdit} disabled={departureEditBusy}>
{t("cancel")}
</Button>
<Button
variant="contained"
onClick={() => {
void applyDepartureEdit();
}}
disabled={departureEditBusy}
>
{t("filter_apply")}
</Button>
</DialogActions>