|
- "use client";
-
- import React, {
- useCallback,
- useEffect,
- useMemo,
- useRef,
- useState,
- } from "react";
- import {
- Alert,
- Box,
- Badge,
- Button,
- ButtonGroup,
- Chip,
- Card,
- CardContent,
- Checkbox,
- CircularProgress,
- Collapse,
- Divider,
- Dialog,
- DialogActions,
- DialogContent,
- DialogTitle,
- Drawer,
- IconButton,
- FormControl,
- InputAdornment,
- InputLabel,
- List,
- ListItemButton,
- ListItemText,
- ListSubheader,
- MenuItem,
- Paper,
- Popover,
- Select,
- Snackbar,
- Stack,
- TextField,
- Tooltip,
- Typography,
- Autocomplete,
- } from "@mui/material";
- import { alpha } from "@mui/material/styles";
- import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
- import FilterListIcon from "@mui/icons-material/FilterList";
- import {
- AlertTriangle,
- ArrowRight,
- Bell,
- Building2,
- Clock,
- CreditCard,
- ChevronRight,
- Download,
- FileText,
- History,
- Info,
- LayoutDashboard,
- Phone,
- Plus,
- RotateCcw,
- Save,
- Search,
- Trash2,
- Truck as TruckIcon,
- Upload,
- Users,
- X,
- MapPin,
- Pencil,
- GripVertical,
- CarFront,
- } from "lucide-react";
- import type { TFunction } from "i18next";
- import { useTranslation } from "react-i18next";
- import {
- fetchAllShopsClient,
- diffTruckLaneVersionsClient,
- listTruckLaneVersionsClient,
- restoreTruckLaneVersionClient,
- findAllByTruckLanceCodeAndRemarkAndDeletedFalseClient,
- findAllForRouteBoardClient,
- exportRouteLanesExcelClient,
- exportRouteReportExcelClient,
- exportTruckLaneVersionReportExcelClient,
- importRouteLanesExcelClient,
- parseRouteLanesExcelClient,
- createTruckLaneSnapshotClient,
- createTruckLaneScheduleClient,
- updateTruckLaneVersionNoteClient,
- updateTruckLaneClient,
- updateLaneLogisticClient,
- createTruckClient,
- createTruckWithoutShopClient,
- deleteTruckLaneClient,
- } from "@/app/api/shop/client";
- import {
- deleteLogisticClient,
- findAllLogisticsClient,
- saveLogisticClient,
- saveLogisticsBatchCreateClient,
- type LogisticRow,
- type SaveLogisticRequest,
- } from "@/app/api/logistic/client";
- import type {
- CreateTruckWithoutShopRequest,
- LogisticMasterDiffLine,
- MessageResponse,
- SaveTruckLane,
- Truck,
- TruckLaneVersionDiffLine,
- } from "@/app/api/shop/actions";
- import { formatDepartureTime, normalizeStoreId } from "@/app/utils/formatUtil";
- import {
- buildStagedBoardLogEntries,
- diffLinesToShopRows,
- formatLaneLabel,
- resolveHeadVersionId,
- resolveVersionLogLaneLabel,
- resolveVersionLogShopHeadline,
- splitVersionCreated,
- resolveVersionActor,
- summarizeVersionRows,
- VERSION_LOG_LOADING_SEQUENCE_LABEL,
- type StagedDeleteMeta,
- type ShopRowBaseline,
- } from "@/components/Shop/routeBoardVersionLog";
- import {
- computeTruckLaneWarnings,
- appendSyntheticPendingShopRow,
- type TruckLaneWarning,
- type TruckLaneWarningInputRow,
- type TruckLaneWarningLaneRef,
- } from "@/components/Shop/computeTruckLaneWarnings";
- import { mergeImportPreviewIntoLanes } from "@/components/Shop/routeBoardImportPreview";
- import ScheduleChangeModal, {
- type ScheduleChangePayload,
- type ScheduleShopRow,
- } from "@/components/Shop/ScheduleChangeModal";
- import ScheduleTaskHistoryModal from "@/components/Shop/ScheduleTaskHistoryModal";
- import { extractApiErrorMessage } from "@/components/Shop/scheduleClientHelpers";
- import { formatScheduleValidationErrors } from "@/components/Shop/scheduleUiHelpers";
- import { useRouteBoardScheduleIndicators } from "@/components/Shop/useRouteBoardScheduleIndicators";
- import {
- buildRequestFromPayload,
- validatePayloadSubmit,
- } from "@/components/Shop/truckLaneMovePlanner";
- import {
- computeMovedLoadingSequence,
- flattenDisplayOrder,
- groupByDistrict,
- formatShopCardSubtitle,
- toDistrictDisplayName,
- toDistrictRawValue,
- } from "@/components/Shop/routeBoardDisplayOrder";
-
- const JAVA_INT_MAX = 2_147_483_647;
-
- /** 後端 `driverNumber` 為 Int;只送可安全表示的十進位數字,不靜默改值。 */
- function phoneDigitsToDriverNumber(phone: string): number | null {
- const d = String(phone).replace(/\D/g, "");
- if (!d) return null;
- const fitsIntString = (s: string): boolean =>
- /^\d{1,10}$/.test(s) && BigInt(s) <= BigInt(JAVA_INT_MAX);
- const parseBlock = (s: string): number | null => {
- if (!fitsIntString(s)) return null;
- const n = parseInt(s, 10);
- return Number.isFinite(n) && n >= 0 && n <= JAVA_INT_MAX ? n : null;
- };
- let n = parseBlock(d);
- if (n != null) return n;
- // HK +852 + 8 位本地號(11 位數字)
- if (d.startsWith("852") && d.length === 11) {
- n = parseBlock(d.slice(3));
- if (n != null) return n;
- }
- return null;
- }
-
- type ShopCard = {
- /** truck table row id */
- id: number;
- /** master shop.id(後端 Truck.shop);新增/去重用 */
- shopEntityId?: number | null;
- /** 分店名(truck table 的 ShopName) */
- branchName: string;
- shopCode: string;
- /** districtReference raw value from backend (never use display label for save) */
- districtReferenceRaw: string | null;
- /** brand/label is not in current API; keep optional for future */
- brand?: string;
- /** periods is not in current API; keep optional for future */
- periods?: string;
- /** ordering inside a lane */
- loadingSequence: number;
- /** remark shown in existing UIs */
- remark?: string | null;
- /** store ID 2F/4F */
- storeId: string;
- /** departure time HH:mm or HH:mm:ss */
- departureTime: string;
- };
-
- type Lane = {
- /** 穩定鍵:encodeURIComponent(code)|encodeURIComponent(remark) */
- id: string;
- /** 寫入 truck API 的車線代碼 */
- truckLanceCode: string;
- plate?: string;
- logisticsCompany?: string;
- /** `logistic.id`,來自 truck.logistic;無綁定為 null */
- logisticId?: number | null;
- driver?: string;
- phone?: string;
- startTime: string;
- storeId: string;
- remark?: string | null;
- shops: ShopCard[];
- };
-
- type PendingNewLane = {
- laneKey: string;
- payload: CreateTruckWithoutShopRequest;
- };
-
- const parseTimeForBackend = (time: string): string => {
- const s = String(time ?? "").trim();
- if (!s) return "";
- // allow HH:mm or HH:mm:ss
- 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")}` : ""
- }`;
- }
- return s;
- };
-
- const 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 summarizeLogisticsColumnStats(lanes: Lane[]): {
- laneCount: number;
- shopCount: number;
- count2F: number;
- count4F: number;
- } {
- let shopCount = 0;
- let count2F = 0;
- let count4F = 0;
- for (const l of lanes) {
- shopCount += l.shops.length;
- if (normalizeStoreId(l.storeId) === "4F") count4F++;
- else count2F++;
- }
- return {
- laneCount: lanes.length,
- shopCount,
- count2F,
- count4F,
- };
- }
-
- function resolveLogisticMasterRow(
- company: string,
- lanes: Lane[],
- masters: LogisticRow[],
- ): LogisticRow | null {
- if (company === "未分配物流商") return null;
- const byLaneId = lanes.find(
- (l) => l.logisticId != null && Number.isFinite(l.logisticId),
- )?.logisticId;
- if (byLaneId != null) {
- const hit = masters.find((m) => m.id === byLaneId);
- if (hit) return hit;
- }
- const name = String(company).trim();
- return (
- masters.find((m) => String(m.logisticName ?? "").trim() === name) ?? null
- );
- }
-
- /**
- * 地區區塊標題編輯語意(RouteBoard):
- * 後端每筆 truck row 存 `districtReference`,無 lane-level overlay 表。
- * 編輯某「區塊」顯示名稱=把該 lane 內目前落在該顯示 bucket 的所有 shop 之
- * `districtReferenceRaw` 批量改成 `toDistrictRawValue(新顯示名)`,並對 `id > 0`
- * 的列 `dirtyMoves.set` 以延遲寫 DB。
- * 僅前端存在、尚無店鋪的「空區」用 `pendingEmptyDistrictsByLane` 記錄顯示名,
- * 與 `groupByDistrict` 合併渲染;儲存成功或僅空區時會清掉暫存列。
- */
- function buildLaneDistrictSections(
- shops: ShopCard[],
- pendingExtraDistrictDisplays: string[] | undefined,
- ): Array<{ district: string; shops: ShopCard[]; isPendingEmpty: boolean }> {
- const grouped = groupByDistrict(shops);
- const keysFromShops = new Set(grouped.map((g) => g.district));
- const extras = (pendingExtraDistrictDisplays ?? []).filter(
- (d) => !keysFromShops.has(d),
- );
- const merged = [
- ...grouped.map((g) => ({
- district: g.district,
- shops: g.shops,
- isPendingEmpty: false,
- })),
- ...extras.map((district) => ({
- district,
- shops: [] as ShopCard[],
- isPendingEmpty: true,
- })),
- ];
- merged.sort((a, b) => {
- if (a.district === "未分類") return -1;
- if (b.district === "未分類") return 1;
- return a.district.localeCompare(b.district, "zh-Hant");
- });
- return merged;
- }
-
- function districtDisplayExistsInLane(
- lane: Lane,
- pendingExtra: string[] | undefined,
- display: string,
- ): boolean {
- const set = new Set(groupByDistrict(lane.shops).map((g) => g.district));
- for (const d of pendingExtra ?? []) set.add(d);
- return set.has(display);
- }
-
- /** pending 顯示名陣列去重(保序),避免 rename 撞名造成重複 key / 重複區塊 */
- function dedupeDistrictPendingOrder(items: string[]): string[] {
- const seen = new Set<string>();
- const out: string[] = [];
- for (const x of items) {
- if (seen.has(x)) continue;
- seen.add(x);
- out.push(x);
- }
- return out;
- }
-
- const LANE_ID_SEP = "|";
-
- /** 與後端 lane 唯一鍵一致:`TruckLanceCode` + 正規化後 remark(NULL/空白 視為同組) */
- function encodeLaneId(
- truckLanceCode: string,
- laneRemark: string | null | undefined,
- ): string {
- const code = String(truckLanceCode || "").trim();
- const rem =
- laneRemark != null && String(laneRemark).trim() !== ""
- ? String(laneRemark).trim()
- : "";
- return `${encodeURIComponent(code)}${LANE_ID_SEP}${encodeURIComponent(rem)}`;
- }
-
- function decodeLaneId(laneId: string): {
- truckLanceCode: string;
- remark: string | null;
- } | null {
- const i = laneId.indexOf(LANE_ID_SEP);
- if (i < 0) return null;
- try {
- const code = decodeURIComponent(laneId.slice(0, i));
- const remEnc = laneId.slice(i + LANE_ID_SEP.length);
- const rem = decodeURIComponent(remEnc);
- return {
- truckLanceCode: code,
- remark: rem !== "" ? rem : null,
- };
- } catch {
- return null;
- }
- }
-
- function lanesToWarningInputRows(lanes: Lane[]): TruckLaneWarningInputRow[] {
- const out: TruckLaneWarningInputRow[] = [];
- for (const lane of lanes) {
- const code = lane.truckLanceCode;
- const laneRemark = lane.remark ?? null;
- for (const s of lane.shops) {
- out.push({
- truckRowId: s.id,
- truckLanceCode: code,
- laneRemark,
- storeId: s.storeId,
- departureTime: s.departureTime,
- shopEntityId: s.shopEntityId ?? null,
- shopCode: s.shopCode,
- shopDisplayName: s.branchName,
- });
- }
- }
- return out;
- }
-
- /** 尚未儲存的新增店鋪(負數 truck row id) */
- function stripDraftShopRows(lanes: Lane[]): Lane[] {
- return lanes.map((l) => ({
- ...l,
- shops: l.shops.filter((s) => s.id > 0),
- }));
- }
-
- function warningTouchesPickedShop(
- w: TruckLaneWarning,
- pick: { id: number; code: string },
- ): boolean {
- if (
- w.shopEntityId != null &&
- Number.isFinite(w.shopEntityId) &&
- w.shopEntityId === pick.id
- ) {
- return true;
- }
- const wc = String(w.shopCode || "").trim().toLowerCase();
- const pc = String(pick.code || "").trim().toLowerCase();
- return wc !== "" && pc !== "" && wc === pc;
- }
-
- function formatLaneWarningDetail(
- L: TruckLaneWarningLaneRef,
- tr: TFunction<"shop">,
- ): string {
- const dash = tr("emDash");
- const parts = [`${tr("warnClipboardStore")} ${L.storeId}`];
- if (L.weekday) {
- parts.push(`${tr("warnClipboardWeekday")} ${L.weekday}`);
- }
- parts.push(`${tr("warnClipboardDep")} ${L.departureTimeDisplay ?? dash}`);
- return parts.join(" · ");
- }
-
- function formatWarningSummary(
- w: TruckLaneWarning,
- tr: TFunction<"shop">,
- ): string {
- if (w.rule === "RULE_1_WEEKDAY") {
- return tr("mtmsRouteWarn_conflict4f", { weekday: w.triggerValue });
- }
- return tr("mtmsRouteWarn_conflictDep", { time: w.triggerValue });
- }
-
- function formatLaneWarningsClipboard(
- warnings: TruckLaneWarning[],
- tr: TFunction<"shop">,
- ): string {
- return warnings
- .map((w, idx) => {
- const shopLine = [w.shopCode, w.shopDisplayName]
- .map((s) => String(s ?? "").trim())
- .filter(Boolean)
- .join(" ");
- const lanes = w.lanes
- .map((L) => {
- const laneTitle = `${L.truckLanceCode}${
- L.laneRemark ? ` · ${L.laneRemark}` : ""
- }`;
- return ` - ${laneTitle} | ${formatLaneWarningDetail(L, tr)}`;
- })
- .join("\n");
- return `[${idx + 1}] ${tr("mtmsRouteWarn_shop")}: ${shopLine}\n${formatWarningSummary(w, tr)}\n${lanes}`;
- })
- .join("\n\n");
- }
-
- function formatDiffFieldLabel(label: string, tr: TFunction<"shop">): string {
- if (label === "versionLogField_logisticId") return tr("diffField_logisticsCompany");
- // Translate i18n keys; fallback to raw label if not found
- const translated = tr(label as any);
- return translated !== label ? translated : label;
- }
-
- function downloadBase64Xlsx(base64: string, filename: string) {
- const binary = atob(base64);
- const bytes = new Uint8Array(binary.length);
- for (let i = 0; i < binary.length; i++) {
- bytes[i] = binary.charCodeAt(i);
- }
- const blob = new Blob([bytes], {
- type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
- });
- const url = URL.createObjectURL(blob);
- const a = document.createElement("a");
- a.href = url;
- a.download = filename;
- a.click();
- URL.revokeObjectURL(url);
- }
-
- function sortLanesByCode(lanes: Lane[]): Lane[] {
- return lanes.slice().sort((a, b) => {
- const c = a.truckLanceCode.localeCompare(b.truckLanceCode, "zh-Hant");
- if (c !== 0) return c;
- return String(a.remark ?? "").localeCompare(
- String(b.remark ?? ""),
- "zh-Hant",
- );
- });
- }
-
- /** 由尚未儲存的新增車線 payload 建立看板 Lane(shops 為空) */
- function buildLaneFromPendingNewLane(p: PendingNewLane): Lane {
- const payload = p.payload;
- const code = String(payload.truckLanceCode || "").trim();
- const storeNorm = normalizeStoreId(payload.store_id);
- const remarkRaw =
- storeNorm === "4F" &&
- payload.remark != null &&
- String(payload.remark).trim() !== ""
- ? String(payload.remark).trim()
- : null;
- const dep = parseTimeForBackend(payload.departureTime || "") || "00:00:00";
- return {
- id: p.laneKey,
- truckLanceCode: code,
- logisticsCompany: "",
- logisticId:
- payload.logisticId != null && Number.isFinite(Number(payload.logisticId))
- ? Number(payload.logisticId)
- : null,
- plate: "",
- driver: "",
- phone: "",
- startTime: dep,
- storeId: storeNorm,
- remark: remarkRaw,
- shops: [],
- };
- }
-
- /** 合併尚未寫入後端的新增車線,避免 loadLanes 覆蓋後從看板消失 */
- function mergePendingNewLanesIntoLanes(
- lanes: Lane[],
- pending: PendingNewLane[],
- ): Lane[] {
- if (pending.length === 0) return lanes;
- const ids = new Set(lanes.map((l) => l.id));
- const additions = pending
- .filter((p) => !ids.has(p.laneKey))
- .map((p) => buildLaneFromPendingNewLane(p));
- if (additions.length === 0) return lanes;
- return sortLanesByCode([...lanes, ...additions]);
- }
-
- /** 合併增量刷新後的車線(維持排序;追加後端新出現的 lane) */
- function mergeRefreshedLanes(prev: Lane[], refreshed: Lane[]): Lane[] {
- const map = new Map(refreshed.map((l) => [l.id, l]));
- const prevIds = new Set(prev.map((l) => l.id));
- const replaced: Lane[] = [];
- for (const l of prev) {
- const n = map.get(l.id);
- if (n !== undefined) {
- replaced.push(n);
- } else {
- replaced.push(l);
- }
- }
- const additions = refreshed.filter((l) => !prevIds.has(l.id));
- return sortLanesByCode([...replaced, ...additions]);
- }
-
- function buildLaneFromTruckRows(
- truckLanceCode: string,
- remark: string | null,
- rows: Truck[] | null | undefined,
- meta?: Partial<Truck> | Record<string, unknown> | null,
- ): Lane {
- const code = String(truckLanceCode || "").trim();
- const r = rows || [];
- const first = r[0];
-
- const startSource =
- meta != null
- ? (meta as any).departureTime ?? first?.departureTime ?? "00:00:00"
- : first != null
- ? first.departureTime
- : "00:00:00";
-
- const startTime =
- parseTimeForBackend(formatDepartureTime(startSource as any)) || "00:00:00";
-
- const storeId = normalizeStoreId(
- meta != null
- ? (meta as any)?.storeId ?? (meta as any)?.store_id
- : (first as any)?.storeId ?? (first as any)?.store_id,
- );
-
- const id = encodeLaneId(code, remark);
-
- const shops: ShopCard[] = r
- .filter((row) => row.id != null)
- .filter((row) => {
- const shopRef = (row as any).shop;
- const hasShopRef =
- shopRef != null && typeof shopRef === "object" && shopRef.id != null;
- const nm = row.shopName != null ? String(row.shopName).trim() : "";
- const cd = row.shopCode != null ? String(row.shopCode).trim() : "";
- if (hasShopRef) return true;
- if (nm === "" && cd === "") return false;
- const u = (s: string) => s.trim().toLowerCase();
- if (u(nm) === "unassign" || u(cd) === "unassign") return false;
- if (u(nm) === "unassigned" || u(cd) === "unassigned") return false;
- return true;
- })
- .map((row) => {
- const districtRaw =
- row.districtReference != null &&
- String(row.districtReference).trim() !== ""
- ? String(row.districtReference).trim()
- : null;
- const shopRef = (row as any).shop;
- const shopEntityId =
- shopRef && typeof shopRef === "object" && shopRef.id != null
- ? Number(shopRef.id)
- : null;
- return {
- id: row.id as number,
- shopEntityId: Number.isFinite(shopEntityId as number)
- ? (shopEntityId as number)
- : null,
- branchName: row.shopName != null ? String(row.shopName) : "",
- shopCode: row.shopCode != null ? String(row.shopCode) : "",
- districtReferenceRaw: districtRaw,
- loadingSequence: Number(row.loadingSequence ?? 0) || 0,
- remark: row.remark != null ? String(row.remark) : null,
- storeId: normalizeStoreId(
- (row as any).storeId ?? (row as any).store_id,
- ),
- departureTime: parseTimeForBackend(
- formatDepartureTime(row.departureTime as any),
- ),
- };
- });
-
- let laneLogisticId: number | null = null;
- let laneLogisticsName = "";
- let lanePlate = "";
- let laneDriver = "";
- let lanePhone = "";
- for (const row of r) {
- const log = (row as { logistic?: Record<string, unknown> | null }).logistic;
- if (!log || typeof log !== "object") continue;
- if (
- laneLogisticId == null &&
- log.id != null &&
- Number.isFinite(Number(log.id))
- ) {
- laneLogisticId = Number(log.id);
- }
- const nm = String(log.logisticName ?? "").trim();
- if (nm !== "" && laneLogisticsName === "") laneLogisticsName = nm;
- const p = String(log.carPlate ?? "").trim();
- const d = String(log.driverName ?? "").trim();
- const ph =
- log.driverNumber != null && Number.isFinite(Number(log.driverNumber))
- ? String(log.driverNumber)
- : "";
- if (p !== "" && lanePlate === "") lanePlate = p;
- if (d !== "" && laneDriver === "") laneDriver = d;
- if (ph !== "" && lanePhone === "") lanePhone = ph;
- }
- if (laneLogisticId == null && r.length > 0) {
- for (const row of r) {
- const lid = (row as { logisticId?: unknown }).logisticId;
- if (lid != null && Number.isFinite(Number(lid))) {
- laneLogisticId = Number(lid);
- break;
- }
- }
- }
-
- return {
- id,
- truckLanceCode: code,
- logisticsCompany: laneLogisticsName,
- logisticId: laneLogisticId,
- driver: laneDriver,
- phone: lanePhone,
- plate: lanePlate,
- startTime: startTime || "00:00:00",
- storeId: storeId || "2F",
- remark,
- shops,
- };
- }
-
- async function fetchLaneByKey(
- truckLanceCode: string,
- remark: string | null,
- meta?: Partial<Truck> | null,
- ): Promise<Lane | null> {
- const c = String(truckLanceCode || "").trim();
- if (!c) return null;
- // NOTE:
- // - Next dev StrictMode 會讓初始化 useEffect 跑兩次,若不去重會把同一批 lane 打兩輪
- // - 這裡做「同 key in-flight」去重,避免重複打 API
- const inflight = (fetchLaneByKey as any)._inflight as
- | Map<string, Promise<Lane | null>>
- | undefined;
- const map: Map<string, Promise<Lane | null>> = inflight ?? new Map();
- (fetchLaneByKey as any)._inflight = map;
-
- const key = encodeLaneId(c, remark);
- const existing = map.get(key);
- if (existing) return existing;
-
- const p = (async () => {
- const rows = (await findAllByTruckLanceCodeAndRemarkAndDeletedFalseClient(
- c,
- remark,
- )) as Truck[];
- return buildLaneFromTruckRows(c, remark, rows, meta);
- })();
- map.set(key, p);
- try {
- return await p;
- } finally {
- map.delete(key);
- }
- }
-
- function laneTargetConflicts(
- shop: ShopCard,
- lane: Lane,
- excludeTruckRowId?: number,
- ): boolean {
- for (const s of lane.shops) {
- if (excludeTruckRowId != null && s.id === excludeTruckRowId) continue;
- if (
- shop.shopEntityId != null &&
- s.shopEntityId != null &&
- shop.shopEntityId === s.shopEntityId
- ) {
- return true;
- }
- const a = String(shop.shopCode || "")
- .trim()
- .toLowerCase();
- const b = String(s.shopCode || "")
- .trim()
- .toLowerCase();
- if (a !== "" && b !== "" && a === b) return true;
- }
- return false;
- }
-
- /** `/shop/combo/allShop` 常有 join 重複列:依 shop.id、再依 code 去重(保留 id 較小) */
- function dedupeShopMasterRows(
- rows: Array<{ id?: unknown; code?: unknown; name?: unknown }>,
- ): Array<{ id: number; name: string; code: string }> {
- const byId = new Map<number, { id: number; name: string; code: string }>();
- for (const s of rows || []) {
- const id = Number(s?.id);
- if (!Number.isFinite(id) || id <= 0 || byId.has(id)) continue;
- const rawCode = String(s?.code ?? "").trim();
- const name = String(s?.name ?? "").trim();
- byId.set(id, {
- id,
- name: name || rawCode || String(id),
- code: rawCode || String(id),
- });
- }
- const byCode = new Map<string, { id: number; name: string; code: string }>();
- for (const row of Array.from(byId.values())) {
- const ck = String(row.code).trim().toLowerCase();
- const key = ck || `__id_${row.id}`;
- const prev = byCode.get(key);
- if (!prev || row.id < prev.id) byCode.set(key, row);
- }
- return Array.from(byCode.values()).sort((a, b) =>
- a.code.localeCompare(b.code, undefined, { numeric: true }),
- );
- }
-
- /**
- * 車線店鋪管理看板(對齊 MTMS_ISSUE_LOG.pdf / 圖1 / 圖2)
- * - 左:checkbox 多選車線 + search
- * - 右:所選車線按「地區(districtReference)」分組顯示店鋪,可拖拽跨車線
- * - 支援儲存:把被拖動的店鋪批量呼叫 `updateTruckLaneClient`
- *
- * Logistic:後端 `truck.logistic` join;車線 Excel 見 MTMS_ROUTE_V1(PDF 圖1)。
- */
- const RouteBoard: React.FC = () => {
- const { t } = useTranslation("shop");
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState<string | null>(null);
- const [laneWarnDrawerOpen, setLaneWarnDrawerOpen] = useState(false);
- const [laneWarnExpandedIdx, setLaneWarnExpandedIdx] = useState<number | null>(
- null,
- );
- const [laneWarnSnackbar, setLaneWarnSnackbar] = useState<string | null>(null);
- const [searchTerm, setSearchTerm] = useState("");
- const didInitialLoadRef = useRef(false);
- const loadLanesInFlightRef = useRef(false);
- const importRouteFileInputRef = useRef<HTMLInputElement>(null);
- const pendingImportFileRef = useRef<File | null>(null);
- const [pendingImportMeta, setPendingImportMeta] = useState<{
- fileName: string;
- sheetCount: number;
- rowCount: number;
- } | null>(null);
- const [routeExcelBusy, setRouteExcelBusy] = useState(false);
- const routeExcelExportLockRef = useRef(false);
-
- // shopCode(lowercase) -> shop table real name
- const [shopNameByCodeMap, setShopNameByCodeMap] = useState<
- Map<string, string>
- >(new Map());
- const [lanes, setLanes] = useState<Lane[]>([]);
- const laneWarningsMemo = useMemo(
- () => computeTruckLaneWarnings(lanesToWarningInputRows(lanes)),
- [lanes],
- );
- const laneWarnCount = laneWarningsMemo.warnings.length;
- const selectLanesFromWarning = useCallback((w: TruckLaneWarning) => {
- const ids = Array.from(
- new Set(
- w.lanes
- .map((L) => String(L.laneKey ?? "").trim())
- .filter((k) => k !== ""),
- ),
- );
- if (ids.length === 0) return;
- setSelectedLaneIds(ids);
- setLaneWarnDrawerOpen(false);
- }, []);
-
- useEffect(() => {
- if (!laneWarnDrawerOpen) setLaneWarnExpandedIdx(null);
- }, [laneWarnDrawerOpen]);
- // Keep latest lanes snapshot for drag/drop computations.
- // This avoids relying on React state updater execution timing and prevents
- // side-effects inside state updaters (which can break under StrictMode/Concurrent).
- const lanesRef = useRef<Lane[]>([]);
- useEffect(() => {
- lanesRef.current = lanes;
- }, [lanes]);
- const versionDiffReqSeq = useRef(0);
- const [selectedLaneIds, setSelectedLaneIds] = useState<string[]>([]);
- const [routeBoardTab, setRouteBoardTab] = useState<"board" | "logistics">(
- "board",
- );
- const [laneFilter, setLaneFilter] = useState<{
- floor: "all" | "2F" | "4F";
- query: string;
- }>({
- floor: "all",
- query: "",
- });
- const [laneFilterAnchor, setLaneFilterAnchor] = useState<HTMLElement | null>(
- null,
- );
-
- // drag state (HTML5 drag & drop)
- const draggedRef = useRef<{ shopId: number; fromLaneId: string } | null>(
- null,
- );
- /** 物流商管理頁:拖曳整條車線指派 logistic */
- const logisticsLaneDragIdRef = useRef<string | null>(null);
- /** baseline: 後端目前 lane logisticId(用於判斷「只改物流商」也要能 Save) */
- const laneLogisticBaselineRef = useRef<Map<string, number | null>>(new Map());
- /** 店鋪列地區 baseline(載入/refresh 後同步),供未儲存清單標註地區差 */
- const shopDistrictBaselineRef = useRef<Map<number, string>>(new Map());
- const shopRowBaselineRef = useRef<Map<number, ShopRowBaseline>>(new Map());
- const [districtBaselineEpoch, setDistrictBaselineEpoch] = useState(0);
- const syncShopDistrictBaselineFromLanes = useCallback((laneList: Lane[]) => {
- const districtMap = new Map<number, string>();
- const rowMap = new Map<number, ShopRowBaseline>();
- for (const lane of laneList) {
- const fromLaneLabel = formatLaneLabel(lane.truckLanceCode, lane.remark);
- for (const s of lane.shops) {
- if (s.id <= 0) continue;
- districtMap.set(s.id, toDistrictDisplayName(s.districtReferenceRaw));
- rowMap.set(s.id, {
- laneId: lane.id,
- fromLaneLabel,
- departureTime: parseTimeForBackend(
- formatDepartureTime(s.departureTime as any),
- ),
- loadingSequence: Number(s.loadingSequence ?? 0) || 0,
- districtDisplay: toDistrictDisplayName(s.districtReferenceRaw),
- });
- }
- }
- shopDistrictBaselineRef.current = districtMap;
- shopRowBaselineRef.current = rowMap;
- setDistrictBaselineEpoch((e) => e + 1);
- }, []);
- const [dropIndicator, setDropIndicator] = useState<{
- laneId: string;
- beforeShopId: number | null;
- } | null>(null);
- /** 跨線拖曳等:來源 lane 可能沒有任何 dirty 列,儲存/還原時仍須一併 refetch */
- const lanesNeedingRefreshOnSaveRef = useRef<Set<string>>(new Set());
-
- // dirty tracking (shop row id -> new laneId)
- const [dirtyMoves, setDirtyMoves] = useState<Map<number, string>>(new Map());
- // staged deletes (truck row ids)
- const [dirtyDeletes, setDirtyDeletes] = useState<Set<number>>(new Set());
- const dirtyDeletesRef = useRef<Set<number>>(new Set());
- /** 暫刪列在 UI 已移除時仍要在版本 LOG「未儲存」小節顯示店鋪/來源車線 */
- const stagedDeleteMetaRef = useRef<Map<number, StagedDeleteMeta>>(new Map());
- useEffect(() => {
- dirtyDeletesRef.current = dirtyDeletes;
- }, [dirtyDeletes]);
-
- /** 立刻同步 ref,避免同一 tick 內 await 後 setLanes 仍讀到舊暫刪 */
- const clearDirtyDeletesState = useCallback(() => {
- dirtyDeletesRef.current = new Set();
- stagedDeleteMetaRef.current.clear();
- setDirtyDeletes(new Set());
- }, []);
-
- /** refresh/load 會用後端資料覆蓋 UI,須再過濾未 Save 的暫刪列 */
- const filterStagedDeletedShops = useCallback(
- (laneList: Lane[], del: Set<number>): Lane[] => {
- if (del.size === 0) return laneList;
- return laneList.map((lane) => ({
- ...lane,
- shops: lane.shops.filter((s) => !del.has(s.id)),
- }));
- },
- [],
- );
-
- const [departureEditLaneId, setDepartureEditLaneId] = useState<string | null>(
- null,
- );
- const [departureEditDraft, setDepartureEditDraft] = useState("");
- const [seqEditTarget, setSeqEditTarget] = useState<{
- laneId: string;
- shopId: number;
- } | null>(null);
- const [seqEditDraft, setSeqEditDraft] = useState("");
- const [saving, setSaving] = useState(false);
- const saveInFlightRef = useRef(false);
- const [saveResult, setSaveResult] = useState<{
- ok: boolean;
- message: string;
- } | null>(null);
-
- // version log (snapshot) UI
- const [logDialogOpen, setLogDialogOpen] = useState(false);
- const [loadingVersions, setLoadingVersions] = useState(false);
- const [logVersions, setLogVersions] = useState<any[]>([]);
- const [selectedLogVersionId, setSelectedLogVersionId] = useState<
- number | null
- >(null);
- const [diffLoading, setDiffLoading] = useState(false);
- const [diffError, setDiffError] = useState<string | null>(null);
- const [versionFilterAnchor, setVersionFilterAnchor] =
- useState<HTMLElement | null>(null);
- const [versionFilterQuery, setVersionFilterQuery] = useState("");
- const [versionFilterDate, setVersionFilterDate] = useState("");
- const [changedShopIds, setChangedShopIds] = useState<Set<number>>(new Set());
- const [logisticMasterDiffLines, setLogisticMasterDiffLines] = useState<
- LogisticMasterDiffLine[]
- >([]);
- const [diffLines, setDiffLines] = useState<
- Array<{
- truckRowId: number;
- shopCode: string | null;
- changes: Array<{ field: string; from: string | null; to: string | null }>;
- }>
- >([]);
- /** 版本 LOG:已排程、待「儲存更改」時才呼叫 restore API */
- const [pendingRestoreVersionId, setPendingRestoreVersionId] = useState<
- number | null
- >(null);
- const [versionNoteDrafts, setVersionNoteDrafts] = useState<
- Record<number, string>
- >({});
- const [savingVersionNoteId, setSavingVersionNoteId] = useState<number | null>(
- null,
- );
- const [versionNoteSaveError, setVersionNoteSaveError] = useState<{
- id: number;
- message: string;
- } | null>(null);
-
- const headVersionId = useMemo(
- () => resolveHeadVersionId(logVersions),
- [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() !== "";
-
- const filteredLogVersions = useMemo(() => {
- const q = String(versionFilterQuery || "")
- .trim()
- .toLowerCase();
- const exactDate = String(versionFilterDate || "").trim();
- const hasDate = exactDate !== "";
- const hasQ = q !== "";
- if (!hasDate && !hasQ) return logVersions;
-
- return (logVersions || []).filter((v) => {
- const id = Number(v?.id);
- const created = String(v?.created || "");
- const { date } = splitVersionCreated(created);
- if (hasDate) {
- if (date !== exactDate) return false;
- }
- if (!hasQ) return true;
- const note = v?.note != null ? String(v.note) : "";
- const editor = resolveVersionActor(v ?? {});
- const hay = `${id} ${note} ${editor} ${created}`.toLowerCase();
- return hay.includes(q);
- });
- }, [logVersions, versionFilterDate, versionFilterQuery]);
-
- const versionShopRows = useMemo(
- () => diffLinesToShopRows(diffLines as TruckLaneVersionDiffLine[]),
- [diffLines],
- );
-
- const versionRowSummary = useMemo(
- () => summarizeVersionRows(versionShopRows),
- [versionShopRows],
- );
-
- /** 新增店鋪:從 shop master 挑選 */
- const [allShopsMaster, setAllShopsMaster] = useState<
- Array<{ id: number; name: string; code: string }>
- >([]);
- const [scheduleModalOpen, setScheduleModalOpen] = useState(false);
- const [scheduleHistoryOpen, setScheduleHistoryOpen] = useState(false);
- const scheduleModalOpenRef = useRef(scheduleModalOpen);
- scheduleModalOpenRef.current = scheduleModalOpen;
- const {
- pendingScheduleShopIds,
- lockedScheduleShopIds,
- failedScheduleShopIds,
- failedScheduleCount,
- refreshScheduleIndicators,
- } = useRouteBoardScheduleIndicators({ paused: scheduleModalOpen });
- /** 硬鎖:APPLYING 或進入鎖定時間窗的排程;遠期排程僅標記不鎖。 */
- const scheduledShopIdSet = lockedScheduleShopIds;
-
- const [addShopDialogOpen, setAddShopDialogOpen] = useState(false);
- const [addShopLaneId, setAddShopLaneId] = useState<string | null>(null);
- const [addShopPick, setAddShopPick] = useState<{
- id: number;
- name: string;
- code: string;
- } | null>(null);
- type PendingShopAdd = {
- tempTruckRowId: number;
- laneId: string;
- shopId: number;
- shopName: string;
- shopCode: string;
- loadingSequence: number;
- };
- const [pendingShopAdds, setPendingShopAdds] = useState<PendingShopAdd[]>(
- [],
- );
- const nextDraftTruckRowIdRef = useRef(-1_000_000_000);
- const addShopConfirmLockRef = useRef(false);
- const addRouteInFlightRef = useRef(false);
-
- /** 車牌/司機等:truck 表尚無欄位,先暫存於此並在 loadLanes 後疊加到 Lane(刷新頁面會丟失) */
- type LaneDisplayOverlay = Partial<
- Pick<Lane, "plate" | "driver" | "phone" | "logisticsCompany">
- >;
- const laneDisplayOverlayRef = useRef<Map<string, LaneDisplayOverlay>>(
- new Map(),
- );
-
- type NewRouteFormState = {
- truckLanceCode: string;
- /** 物流主檔 id;null = 未指定(至物流商管理指派) */
- logisticId: number | null;
- startTime: string;
- storeId: "2F" | "4F";
- remark: string;
- };
-
- const emptyNewRouteForm = (): NewRouteFormState => ({
- truckLanceCode: "",
- logisticId: null,
- startTime: "07:30",
- storeId: "2F",
- remark: "",
- });
-
- const [addRouteDialogOpen, setAddRouteDialogOpen] = useState(false);
- const [newRouteForm, setNewRouteForm] =
- useState<NewRouteFormState>(emptyNewRouteForm);
- const [addRouteSubmitting, setAddRouteSubmitting] = useState(false);
- const [addRouteError, setAddRouteError] = useState<string | null>(null);
- /** 尚未呼叫後端的新增車線(按「儲存更改」才 createTruckWithoutShop) */
- const [pendingNewLanes, setPendingNewLanes] = useState<PendingNewLane[]>([]);
- const pendingNewLanesRef = useRef<PendingNewLane[]>([]);
- useEffect(() => {
- pendingNewLanesRef.current = pendingNewLanes;
- }, [pendingNewLanes]);
- /** 看板末端「+」:從篩選後車線清單選一條,加入左欄勾選並捲動到該欄 */
- const [boardQuickPickAnchorEl, setBoardQuickPickAnchorEl] =
- useState<HTMLElement | null>(null);
- /** 「+」快速選車線 Popover 內關鍵字(不影響左欄篩選) */
- const [boardQuickPickSearch, setBoardQuickPickSearch] = useState("");
- /** 車線內尚無任何店鋪列的暫存「地區」顯示名(僅前端;見 `buildLaneDistrictSections` 註解) */
- const [pendingEmptyDistrictsByLane, setPendingEmptyDistrictsByLane] =
- useState<Record<string, string[]>>({});
- type DistrictEditCtx =
- | { laneId: string; mode: "add" }
- | { laneId: string; mode: "rename"; oldDisplay: string };
- const [districtEditOpen, setDistrictEditOpen] = useState(false);
- const [districtEditCtx, setDistrictEditCtx] = useState<DistrictEditCtx | null>(
- null,
- );
- const [districtEditDraft, setDistrictEditDraft] = useState("");
- const [districtEditError, setDistrictEditError] = useState<string | null>(null);
- const districtEditSubmitLockRef = useRef(false);
- /** `logistic` 表 logisticName(GET /logistic/all) */
- const [logisticNamesFromDb, setLogisticNamesFromDb] = useState<string[]>([]);
- const [logisticRowsFromDb, setLogisticRowsFromDb] = useState<LogisticRow[]>(
- [],
- );
- const [pendingLogisticMasterAdds, setPendingLogisticMasterAdds] = useState<
- Array<{ tempId: number } & SaveLogisticRequest>
- >([]);
- const [pendingLogisticMasterEdits, setPendingLogisticMasterEdits] = useState<
- Map<number, SaveLogisticRequest>
- >(new Map());
- const [pendingLogisticMasterDeletes, setPendingLogisticMasterDeletes] =
- useState<Set<number>>(new Set());
- const nextPendingLogisticTempIdRef = useRef(-1);
- const addLogisticInFlightRef = useRef(false);
- const logisticRowsEffective = useMemo(() => {
- const pendingRows: LogisticRow[] = pendingLogisticMasterAdds.map((p) => ({
- id: p.tempId,
- logisticName: p.logisticName,
- carPlate: p.carPlate,
- driverName: p.driverName,
- driverNumber: p.driverNumber,
- }));
- const dbWithEdits = logisticRowsFromDb
- .filter((r) => !pendingLogisticMasterDeletes.has(Number(r.id)))
- .map((r) => {
- const id = Number(r.id);
- const edit = pendingLogisticMasterEdits.get(id);
- if (!edit) return r;
- return {
- ...r,
- logisticName: edit.logisticName,
- carPlate: edit.carPlate,
- driverName: edit.driverName,
- driverNumber: edit.driverNumber,
- };
- });
- return [...pendingRows, ...dbWithEdits];
- }, [
- pendingLogisticMasterAdds,
- pendingLogisticMasterEdits,
- pendingLogisticMasterDeletes,
- logisticRowsFromDb,
- ]);
- const logisticNameById = useMemo(() => {
- const m = new Map<number, string>();
- for (const r of logisticRowsEffective) {
- const id = Number((r as any)?.id);
- const name = String((r as any)?.logisticName ?? "").trim();
- if (!Number.isFinite(id) || id === 0 || name === "") continue;
- m.set(id, name);
- }
- return m;
- }, [logisticRowsEffective]);
-
- const buildPendingLaneFromForm = useCallback(
- (form: NewRouteFormState): Lane => {
- const code = String(form.truckLanceCode || "").trim();
- const storeNorm = normalizeStoreId(form.storeId);
- const remarkRaw =
- storeNorm === "4F" && String(form.remark || "").trim() !== ""
- ? String(form.remark).trim()
- : null;
- const dep = parseTimeForBackend(form.startTime || "") || "00:00:00";
- const laneKey = encodeLaneId(code, remarkRaw);
- const lid =
- form.logisticId != null && Number.isFinite(Number(form.logisticId))
- ? Number(form.logisticId)
- : null;
- const master =
- lid != null
- ? logisticRowsEffective.find((r) => Number((r as any).id) === lid) ??
- null
- : null;
- return {
- id: laneKey,
- truckLanceCode: code,
- logisticsCompany: master
- ? String(master.logisticName ?? "").trim()
- : "",
- logisticId: lid,
- plate: master ? String(master.carPlate ?? "").trim() : "",
- driver: master ? String(master.driverName ?? "").trim() : "",
- phone:
- master != null &&
- (master as any).driverNumber != null &&
- Number.isFinite(Number((master as any).driverNumber))
- ? String((master as any).driverNumber)
- : "",
- startTime: dep,
- storeId: storeNorm,
- remark: remarkRaw,
- shops: [],
- };
- },
- [logisticRowsEffective],
- );
-
- const buildCreateTruckWithoutShopPayload = useCallback(
- (form: NewRouteFormState): CreateTruckWithoutShopRequest => {
- const code = String(form.truckLanceCode || "").trim();
- const storeNorm = normalizeStoreId(form.storeId);
- const remarkRaw =
- storeNorm === "4F" && String(form.remark || "").trim() !== ""
- ? String(form.remark).trim()
- : null;
- return {
- store_id: storeNorm,
- truckLanceCode: code,
- departureTime: parseTimeForBackend(form.startTime || "") || "00:00:00",
- loadingSequence: 0,
- districtReference: null,
- remark: remarkRaw,
- logisticId: form.logisticId,
- };
- },
- [],
- );
-
- const [addLogisticOpen, setAddLogisticOpen] = useState(false);
- const [addLogisticSubmitting, setAddLogisticSubmitting] = useState(false);
- const [addLogisticError, setAddLogisticError] = useState<string | null>(null);
- const [addLogisticForm, setAddLogisticForm] = useState({
- logisticName: "",
- carPlate: "",
- driverName: "",
- driverPhone: "",
- });
- const [editLogisticOpen, setEditLogisticOpen] = useState(false);
- const [editLogisticSubmitting, setEditLogisticSubmitting] = useState(false);
- const editLogisticInFlightRef = useRef(false);
- const [editLogisticError, setEditLogisticError] = useState<string | null>(
- null,
- );
- const [editLogisticForm, setEditLogisticForm] = useState({
- id: 0,
- logisticName: "",
- carPlate: "",
- driverName: "",
- driverPhone: "",
- });
- const [logisticsDropHoverCompany, setLogisticsDropHoverCompany] = useState<
- string | null
- >(null);
-
- const enrichLanesWithLogisticMaster = useCallback(
- (list: Lane[]): Lane[] => {
- if (!Array.isArray(list) || list.length === 0) return list;
- if (!Array.isArray(logisticRowsEffective) || logisticRowsEffective.length === 0)
- return list;
-
- const byId = new Map<number, LogisticRow>();
- for (const r of logisticRowsEffective) {
- const id = Number((r as any)?.id);
- if (!Number.isFinite(id) || id === 0) continue;
- if (!byId.has(id)) byId.set(id, r);
- }
-
- return list.map((lane) => {
- const lid = lane.logisticId != null ? Number(lane.logisticId) : NaN;
- if (!Number.isFinite(lid) || lid === 0) return lane;
- const master = byId.get(lid);
- if (!master) return lane;
-
- const plate = String((master as any).carPlate ?? "").trim();
- const driver = String((master as any).driverName ?? "").trim();
- const phone =
- (master as any).driverNumber != null &&
- Number.isFinite(Number((master as any).driverNumber))
- ? String((master as any).driverNumber)
- : "";
-
- return {
- ...lane,
- plate:
- lane.plate && String(lane.plate).trim() !== "" ? lane.plate : plate,
- driver:
- lane.driver && String(lane.driver).trim() !== ""
- ? lane.driver
- : driver,
- phone:
- lane.phone && String(lane.phone).trim() !== "" ? lane.phone : phone,
- };
- });
- },
- [logisticRowsEffective],
- );
-
- const applyLaneDisplayOverlays = (list: Lane[]): Lane[] => {
- const map = laneDisplayOverlayRef.current;
- return list.map((lane) => {
- const o = map.get(lane.id);
- if (!o) return lane;
- return {
- ...lane,
- plate: o.plate !== undefined && o.plate !== "" ? o.plate : lane.plate,
- driver:
- o.driver !== undefined && o.driver !== "" ? o.driver : lane.driver,
- phone: o.phone !== undefined && o.phone !== "" ? o.phone : lane.phone,
- logisticsCompany:
- o.logisticsCompany !== undefined && o.logisticsCompany !== ""
- ? o.logisticsCompany
- : lane.logisticsCompany,
- // DB 的 logisticId 不給 overlay 改
- logisticId: lane.logisticId,
- };
- });
- };
-
- const refreshLanesByIds = async (
- laneIds: string[],
- options: { preserveStagedLogistics?: boolean } = {},
- ): Promise<Lane[] | null> => {
- const uniq = Array.from(new Set(laneIds)).filter(Boolean);
- if (uniq.length === 0) return null;
- const preserveStagedLogistics = options.preserveStagedLogistics ?? true;
- const stagedLogisticsByLane = new Map<string, Lane>();
- if (preserveStagedLogistics) {
- for (const lane of lanesRef.current) {
- const currentLogisticId =
- lane.logisticId != null ? Number(lane.logisticId) : null;
- const baselineLogisticId = laneLogisticBaselineRef.current.has(lane.id)
- ? laneLogisticBaselineRef.current.get(lane.id) ?? null
- : null;
- if (currentLogisticId !== baselineLogisticId) {
- stagedLogisticsByLane.set(lane.id, lane);
- }
- }
- }
- const refreshed: Lane[] = [];
- for (const laneId of uniq) {
- const d = decodeLaneId(laneId);
- if (!d) continue;
- try {
- const lane = await fetchLaneByKey(d.truckLanceCode, d.remark);
- if (lane) refreshed.push(lane);
- } catch (e) {
- console.warn("refresh lane failed", laneId, e);
- }
- }
- if (refreshed.length === 0) return null;
- const mergedRefreshed = refreshed.map((lane) => {
- const staged = stagedLogisticsByLane.get(lane.id);
- if (!staged) return lane;
- return {
- ...lane,
- logisticId: staged.logisticId ?? null,
- logisticsCompany: staged.logisticsCompany,
- plate: staged.plate,
- driver: staged.driver,
- phone: staged.phone,
- };
- });
- // 同步 baseline(以 server 回來的 lane 為準)
- for (const lane of refreshed) {
- if (stagedLogisticsByLane.has(lane.id)) continue;
- laneLogisticBaselineRef.current.set(
- lane.id,
- lane.logisticId != null ? Number(lane.logisticId) : null,
- );
- }
- let nextApplied: Lane[] | null = null;
- setLanes((prev) => {
- const next = filterStagedDeletedShops(
- applyLaneDisplayOverlays(
- enrichLanesWithLogisticMaster(
- mergePendingNewLanesIntoLanes(
- mergeRefreshedLanes(prev, mergedRefreshed),
- pendingNewLanesRef.current,
- ),
- ),
- ),
- dirtyDeletesRef.current,
- );
- nextApplied = next;
- return next;
- });
- if (nextApplied != null) {
- syncShopDistrictBaselineFromLanes(nextApplied);
- }
- return nextApplied;
- };
-
- const loadLanes = async () => {
- if (loadLanesInFlightRef.current) return;
- loadLanesInFlightRef.current = true;
- setLoading(true);
- setError(null);
- setPendingEmptyDistrictsByLane({});
- closeDistrictEdit();
- try {
- // build shopCode -> real shop name map (from shop table)
- try {
- const shopRows = (await fetchAllShopsClient()) as Array<{
- id?: any;
- code?: any;
- name?: any;
- }>;
- const master = dedupeShopMasterRows(shopRows || []);
- const map = new Map<string, string>();
- master.forEach((row) => {
- const code = String(row.code ?? "")
- .trim()
- .toLowerCase();
- const name = String(row.name ?? "").trim();
- if (code && name) map.set(code, name);
- });
- setAllShopsMaster(master);
- setShopNameByCodeMap(map);
- } catch (e) {
- // non-blocking: still show branchName if shop table fetch fails
- console.warn("Failed to load shop table names:", e);
- }
-
- // O(1) load: 後端一次回傳所有 truck rows;前端自行按 lane 分桶
- const allRows = (await findAllForRouteBoardClient()) as Truck[];
- const bucket = new Map<
- string,
- {
- truckLanceCode: string;
- remark: string | null;
- meta: Truck;
- rows: Truck[];
- }
- >();
- for (const row of allRows || []) {
- const code = String((row as any)?.truckLanceCode ?? "").trim();
- if (!code) continue;
- const remarkRaw = String((row as any)?.remark ?? "").trim();
- const remark = remarkRaw !== "" ? remarkRaw : null;
- const id = encodeLaneId(code, remark);
- const b = bucket.get(id);
- if (!b) {
- bucket.set(id, {
- truckLanceCode: code,
- remark,
- meta: row,
- rows: [row],
- });
- } else {
- b.rows.push(row);
- }
- }
-
- const loaded: Lane[] = Array.from(bucket.values())
- .map((b) => {
- b.rows.sort((a: any, c: any) => {
- const sa = Number(a?.loadingSequence ?? 0) || 0;
- const sb = Number(c?.loadingSequence ?? 0) || 0;
- if (sa !== sb) return sa - sb;
- const ia = Number(a?.id ?? 0) || 0;
- const ib = Number(c?.id ?? 0) || 0;
- return ia - ib;
- });
- return buildLaneFromTruckRows(
- b.truckLanceCode,
- b.remark,
- b.rows,
- b.meta,
- );
- });
-
- const loadedWithPending = mergePendingNewLanesIntoLanes(
- sortLanesByCode(loaded),
- pendingNewLanesRef.current,
- );
-
- const nextBoard = filterStagedDeletedShops(
- applyLaneDisplayOverlays(
- enrichLanesWithLogisticMaster(loadedWithPending),
- ),
- dirtyDeletesRef.current,
- );
- lanesRef.current = nextBoard;
- setLanes(nextBoard);
- // 初始化 baseline(以 server 回來的 lane 為準)
- laneLogisticBaselineRef.current = new Map(
- nextBoard.map((l) => [
- l.id,
- l.logisticId != null ? Number(l.logisticId) : null,
- ]),
- );
- syncShopDistrictBaselineFromLanes(nextBoard);
- setPendingLogisticMasterAdds([]);
- setPendingLogisticMasterEdits(new Map());
- setPendingLogisticMasterDeletes(new Set());
- pendingImportFileRef.current = null;
- setPendingImportMeta(null);
- // default: select none (user will pick lanes)
- setSelectedLaneIds((prev) => prev);
- } catch (e: any) {
- console.error("Failed to load lanes:", e);
- setError(e?.message ?? String(e) ?? t("err_loadLanes"));
- } finally {
- setLoading(false);
- loadLanesInFlightRef.current = false;
- }
- };
-
- useEffect(() => {
- // Next dev StrictMode 會 double-invoke effect;這裡保證初始化只載一次
- if (didInitialLoadRef.current) return;
- didInitialLoadRef.current = true;
- void loadLanes();
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
-
- const reloadLogisticNamesFromDb = useCallback(async () => {
- try {
- const rows = await findAllLogisticsClient();
- setLogisticRowsFromDb(rows || []);
- const names = Array.from(
- new Set(
- (rows || [])
- .map((r) => String(r.logisticName ?? "").trim())
- .filter((n) => n !== ""),
- ),
- ).sort((a, b) => a.localeCompare(b, "zh-Hant"));
- setLogisticNamesFromDb(names);
- } catch (e) {
- console.warn("Failed to load logistic master (logisticName):", e);
- }
- }, []);
-
- const resolveLogisticIdForCompanyLabel = useCallback(
- (company: string): number | null => {
- const c = String(company).trim();
- if (c === "" || c === "未分配物流商") return null;
- const row = logisticRowsEffective.find(
- (r) => String(r.logisticName ?? "").trim() === c,
- );
- return row != null && row.id != null ? Number(row.id) : null;
- },
- [logisticRowsEffective],
- );
- const getColumnTargetLogisticId = useCallback(
- (company: string, lanesInColumn: Lane[]): number | null => {
- if (company === "未分配物流商") return null;
- const withId = lanesInColumn.find(
- (l) =>
- l.logisticId != null &&
- Number.isFinite(Number(l.logisticId)) &&
- Number(l.logisticId) !== 0,
- );
- if (withId?.logisticId != null) return Number(withId.logisticId);
- return resolveLogisticIdForCompanyLabel(company);
- },
- [resolveLogisticIdForCompanyLabel],
- );
-
- useEffect(() => {
- void reloadLogisticNamesFromDb();
- }, [reloadLogisticNamesFromDb]);
-
- useEffect(() => {
- if (!Array.isArray(logisticRowsEffective) || logisticRowsEffective.length === 0)
- return;
- setLanes((prev) =>
- filterStagedDeletedShops(
- applyLaneDisplayOverlays(enrichLanesWithLogisticMaster(prev)),
- dirtyDeletesRef.current,
- ),
- );
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [logisticRowsEffective]);
-
- const submitAddLogistic = async () => {
- if (addLogisticInFlightRef.current) return;
- const logisticName = String(addLogisticForm.logisticName || "").trim();
- const carPlate = String(addLogisticForm.carPlate || "").trim();
- const driverName = String(addLogisticForm.driverName || "").trim();
- const driverNumber = phoneDigitsToDriverNumber(addLogisticForm.driverPhone);
- if (!logisticName || !carPlate || !driverName) {
- setAddLogisticError(t("val_logisticsRequired"));
- return;
- }
- if (driverNumber == null) {
- setAddLogisticError(t("val_phoneInvalid"));
- return;
- }
- const dup = logisticRowsEffective.some(
- (r) =>
- String(r.logisticName ?? "").trim().toLowerCase() ===
- logisticName.toLowerCase(),
- );
- if (dup) {
- setAddLogisticError(t("val_logisticsDuplicateName"));
- return;
- }
- addLogisticInFlightRef.current = true;
- setAddLogisticError(null);
- setAddLogisticSubmitting(true);
- try {
- const tempId = nextPendingLogisticTempIdRef.current;
- nextPendingLogisticTempIdRef.current -= 1;
- setPendingLogisticMasterAdds((prev) => [
- ...prev,
- {
- tempId,
- logisticName,
- carPlate,
- driverName,
- driverNumber,
- },
- ]);
- setAddLogisticForm({
- logisticName: "",
- carPlate: "",
- driverName: "",
- driverPhone: "",
- });
- setAddLogisticOpen(false);
- setSaveResult(null);
- } finally {
- setAddLogisticSubmitting(false);
- addLogisticInFlightRef.current = false;
- }
- };
-
- const openEditLogistic = (row: LogisticRow) => {
- setEditLogisticError(null);
- const id = Number(row.id);
- const pending = pendingLogisticMasterEdits.get(id);
- setEditLogisticForm({
- id: row.id,
- logisticName: String(
- pending?.logisticName ?? row.logisticName ?? "",
- ).trim(),
- carPlate: String(pending?.carPlate ?? row.carPlate ?? "").trim(),
- driverName: String(pending?.driverName ?? row.driverName ?? "").trim(),
- driverPhone:
- pending?.driverNumber != null && Number.isFinite(pending.driverNumber)
- ? String(pending.driverNumber)
- : row.driverNumber != null && Number.isFinite(row.driverNumber)
- ? String(row.driverNumber)
- : "",
- });
- setEditLogisticOpen(true);
- };
-
- const submitEditLogistic = async () => {
- if (editLogisticInFlightRef.current) return;
- const logisticName = String(editLogisticForm.logisticName || "").trim();
- const carPlate = String(editLogisticForm.carPlate || "").trim();
- const driverName = String(editLogisticForm.driverName || "").trim();
- const driverNumber = phoneDigitsToDriverNumber(
- editLogisticForm.driverPhone,
- );
- if (!editLogisticForm.id || editLogisticForm.id <= 0) {
- setEditLogisticError(t("err_invalidMasterId"));
- return;
- }
- if (!logisticName || !carPlate || !driverName) {
- setEditLogisticError(t("val_logisticsRequired"));
- return;
- }
- if (driverNumber == null) {
- setEditLogisticError(t("val_phoneInvalid"));
- return;
- }
- setEditLogisticError(null);
- editLogisticInFlightRef.current = true;
- setEditLogisticSubmitting(true);
- try {
- const lid = editLogisticForm.id;
- setPendingLogisticMasterEdits((prev) => {
- const next = new Map(prev);
- next.set(lid, {
- id: lid,
- logisticName,
- carPlate,
- driverName,
- driverNumber,
- });
- return next;
- });
- const phone =
- driverNumber != null && Number.isFinite(driverNumber)
- ? String(driverNumber)
- : "";
- setLanes((prev) =>
- filterStagedDeletedShops(
- applyLaneDisplayOverlays(
- prev.map((lane) =>
- lane.logisticId === lid
- ? {
- ...lane,
- logisticsCompany: logisticName,
- plate: carPlate,
- driver: driverName,
- phone,
- }
- : lane,
- ),
- ),
- dirtyDeletesRef.current,
- ),
- );
- setSaveResult(null);
- setEditLogisticOpen(false);
- } catch (e: any) {
- setEditLogisticError(e?.message ?? String(e) ?? t("err_save"));
- } finally {
- setEditLogisticSubmitting(false);
- editLogisticInFlightRef.current = false;
- }
- };
-
- const stageDeleteLogistic = useCallback(
- (row: LogisticRow, companyLabel: string) => {
- const id = Number(row.id);
- if (!Number.isFinite(id)) return;
- const name =
- String(companyLabel || row.logisticName || "").trim() || "—";
- if (!window.confirm(t("confirm_deleteLogistic", { name }))) return;
- if (id < 0) {
- setPendingLogisticMasterAdds((prev) =>
- prev.filter((p) => p.tempId !== id),
- );
- } else {
- setPendingLogisticMasterDeletes((prev) => {
- const next = new Set(prev);
- next.add(id);
- return next;
- });
- }
- setPendingLogisticMasterEdits((prev) => {
- const next = new Map(prev);
- next.delete(id);
- return next;
- });
- setSaveResult(null);
- },
- [t],
- );
-
- const handleExportSelectedLanesExcel = useCallback(async () => {
- if (selectedLaneIds.length === 0) {
- setError(t("err_exportNeedSelection"));
- return;
- }
- setRouteExcelBusy(true);
- setError(null);
- try {
- const { base64, filename } =
- await exportRouteLanesExcelClient(selectedLaneIds);
- downloadBase64Xlsx(base64, filename);
- } catch (e: any) {
- setError(e?.message ?? String(e) ?? t("err_export"));
- } finally {
- setRouteExcelBusy(false);
- }
- }, [selectedLaneIds, t]);
-
- const handleExportRouteReportExcel = useCallback(async () => {
- if (lanes.length === 0) {
- setError(t("err_noLanes"));
- return;
- }
- setRouteExcelBusy(true);
- setError(null);
- try {
- // 空 laneIds = 匯出 RouteBoard 全部(避免傳輸一大串 ids)
- const { base64, filename } = await exportRouteReportExcelClient([]);
- downloadBase64Xlsx(base64, filename);
- } catch (e: any) {
- setError(e?.message ?? String(e) ?? t("err_export"));
- } finally {
- setRouteExcelBusy(false);
- }
- }, [lanes, t]);
-
- const handleImportRouteExcelChange = async (
- e: React.ChangeEvent<HTMLInputElement>,
- ) => {
- const file = e.target.files?.[0];
- e.target.value = "";
- if (!file) return;
-
- const hadOther =
- dirtyMoves.size > 0 ||
- dirtyDeletes.size > 0 ||
- pendingShopAdds.length > 0 ||
- pendingNewLanes.length > 0 ||
- pendingLogisticMasterAdds.length > 0 ||
- pendingLogisticMasterEdits.size > 0 ||
- pendingLogisticMasterDeletes.size > 0 ||
- pendingRestoreVersionId != null ||
- pendingImportMeta != null ||
- Object.values(pendingEmptyDistrictsByLane).some(
- (a) => (a?.length ?? 0) > 0,
- );
- if (hadOther && !window.confirm(t("confirm_importDiscardEdits"))) return;
-
- setRouteExcelBusy(true);
- setError(null);
- try {
- const fd = new FormData();
- fd.append("multipartFileList", file);
- const parsed = await parseRouteLanesExcelClient(fd);
- if (!parsed.rowCount || parsed.rowCount <= 0) {
- setError(t("err_importEmpty"));
- return;
- }
- setDirtyMoves(new Map());
- clearDirtyDeletesState();
- setPendingShopAdds([]);
- setPendingNewLanes([]);
- setPendingLogisticMasterAdds([]);
- setPendingLogisticMasterEdits(new Map());
- setPendingLogisticMasterDeletes(new Set());
- setPendingRestoreVersionId(null);
- setPendingEmptyDistrictsByLane({});
- setDistrictEditOpen(false);
- setDistrictEditCtx(null);
- setDepartureEditLaneId(null);
- setSeqEditTarget(null);
- lanesNeedingRefreshOnSaveRef.current.clear();
- laneDisplayOverlayRef.current.clear();
- const merged = mergeImportPreviewIntoLanes(lanesRef.current, parsed.rows);
- const nextBoard = applyLaneDisplayOverlays(
- enrichLanesWithLogisticMaster(merged),
- );
- lanesRef.current = nextBoard;
- setLanes(nextBoard);
- syncShopDistrictBaselineFromLanes(nextBoard);
- pendingImportFileRef.current = file;
- setPendingImportMeta({
- fileName: file.name,
- sheetCount: parsed.sheetCount,
- rowCount: parsed.rowCount,
- });
- setSaveResult({
- ok: true,
- message: t("import_staged_preview", {
- file: file.name,
- sheets: parsed.sheetCount,
- rows: parsed.rowCount,
- }),
- });
- } catch (err: any) {
- setError(err?.message ?? String(err) ?? t("err_import"));
- } finally {
- setRouteExcelBusy(false);
- }
- };
-
- const filteredLanes = useMemo(() => {
- const picked = lanes.filter((l) => selectedLaneIds.includes(l.id));
- const term = String(searchTerm || "")
- .trim()
- .toLowerCase();
- if (!term) return picked;
-
- return picked.map((lane) => ({
- ...lane,
- shops: lane.shops.filter((s) => {
- const codeLower = String(s.shopCode || "")
- .trim()
- .toLowerCase();
- const realName = shopNameByCodeMap.get(codeLower) ?? "";
- const name = String(realName || "").toLowerCase();
- const branch = String(s.branchName || "").toLowerCase();
- const code = String(s.shopCode || "").toLowerCase();
- const district = String(s.districtReferenceRaw || "").toLowerCase();
- return (
- name.includes(term) ||
- branch.includes(term) ||
- code.includes(term) ||
- district.includes(term)
- );
- }),
- }));
- }, [lanes, selectedLaneIds, searchTerm, shopNameByCodeMap]);
-
- const visibleLaneOptions = useMemo(() => {
- const q = String(laneFilter.query || "")
- .trim()
- .toLowerCase();
- return (lanes || []).filter((lane) => {
- const code = String(lane.truckLanceCode || "").toLowerCase();
- const rem = String(lane.remark ?? "").toLowerCase();
- const floorOk =
- laneFilter.floor === "all"
- ? true
- : normalizeStoreId(lane.storeId) === laneFilter.floor;
- const qOk = !q ? true : code.includes(q) || rem.includes(q);
- return floorOk && qOk;
- });
- }, [lanes, laneFilter.floor, laneFilter.query]);
-
- /** 「+」與看板欄:只吃樓層,不吃左欄關鍵字(與 `visibleLaneOptions` 分離) */
- const lanesMatchingFloorOnly = useMemo(() => {
- return (lanes || []).filter((lane) => {
- const floorOk =
- laneFilter.floor === "all"
- ? true
- : normalizeStoreId(lane.storeId) === laneFilter.floor;
- return floorOk;
- });
- }, [lanes, laneFilter.floor]);
-
- const boardQuickPickFilteredLanes = useMemo(() => {
- const q = String(boardQuickPickSearch).trim().toLowerCase();
- if (!q) return lanesMatchingFloorOnly;
- return lanesMatchingFloorOnly.filter((lane) => {
- const code = String(lane.truckLanceCode || "").toLowerCase();
- const rem = String(lane.remark ?? "").toLowerCase();
- const driver = String(lane.driver ?? "").toLowerCase();
- const plate = String(lane.plate ?? "").toLowerCase();
- return (
- code.includes(q) ||
- rem.includes(q) ||
- driver.includes(q) ||
- plate.includes(q)
- );
- });
- }, [lanesMatchingFloorOnly, boardQuickPickSearch]);
-
- const scrollBoardLaneCardIntoView = useCallback((laneId: string) => {
- const safe =
- typeof CSS !== "undefined" && typeof CSS.escape === "function"
- ? CSS.escape(laneId)
- : laneId.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
- document
- .querySelector(`[data-lane-id="${safe}"]`)
- ?.scrollIntoView({
- behavior: "smooth",
- inline: "center",
- block: "nearest",
- });
- }, []);
-
- const applyBoardQuickPickLane = useCallback(
- (laneId: string) => {
- setBoardQuickPickAnchorEl(null);
- setBoardQuickPickSearch("");
- setSelectedLaneIds((prev) =>
- prev.includes(laneId) ? prev : [...prev, laneId],
- );
- requestAnimationFrame(() => {
- requestAnimationFrame(() => scrollBoardLaneCardIntoView(laneId));
- });
- },
- [scrollBoardLaneCardIntoView],
- );
-
- /** 新增車線:物流公司下拉選單(依名稱排序) */
- const logisticRowsSortedForSelect = useMemo(
- () =>
- [...logisticRowsEffective].sort((a, b) =>
- String(a.logisticName ?? "").localeCompare(
- String(b.logisticName ?? ""),
- "zh-Hant",
- ),
- ),
- [logisticRowsEffective],
- );
-
- const logisticNamesEffective = useMemo(() => {
- const set = new Set<string>();
- for (const p of pendingLogisticMasterAdds) {
- const n = String(p.logisticName ?? "").trim();
- if (n) set.add(n);
- }
- for (const r of logisticRowsFromDb) {
- const id = Number(r.id);
- if (pendingLogisticMasterDeletes.has(id)) continue;
- const s = String(r.logisticName ?? "").trim();
- if (s) set.add(s);
- }
- return Array.from(set).sort((a, b) => a.localeCompare(b, "zh-Hant"));
- }, [
- pendingLogisticMasterAdds,
- pendingLogisticMasterDeletes,
- logisticRowsFromDb,
- ]);
-
- /** 依樓層(不含車線下拉搜尋關鍵字)分組;併入 GET /logistic/all 有、但尚未掛車線的公司 */
- const lanesByLogisticsCompany = useMemo(() => {
- const map = new Map<string, Lane[]>();
- for (const lane of lanesMatchingFloorOnly) {
- if (
- selectedLaneIds.length > 0 &&
- !selectedLaneIds.includes(lane.id)
- ) {
- continue;
- }
- const company =
- String(lane.logisticsCompany ?? "").trim() || "未分配物流商";
- const arr = map.get(company) ?? [];
- arr.push(lane);
- map.set(company, arr);
- }
- for (const name of logisticNamesEffective) {
- const n = String(name).trim();
- if (n && !map.has(n)) map.set(n, []);
- }
- const entries = Array.from(map.entries());
- entries.sort((a, b) => {
- if (a[0] === "未分配物流商" && b[0] !== "未分配物流商") return 1;
- if (b[0] === "未分配物流商" && a[0] !== "未分配物流商") return -1;
- return a[0].localeCompare(b[0], "zh-Hant");
- });
- return entries;
- }, [lanesMatchingFloorOnly, logisticNamesEffective, selectedLaneIds]);
-
- const addShopCandidates = useMemo(() => {
- if (!addShopLaneId) return [];
- const lane = lanes.find((l) => l.id === addShopLaneId);
- if (!lane) return [];
- const codesInLane = new Set(
- lane.shops
- .map((s) =>
- String(s.shopCode || "")
- .trim()
- .toLowerCase(),
- )
- .filter((c) => c !== ""),
- );
- const idsInLane = new Set(
- lane.shops
- .map((s) => s.shopEntityId)
- .filter((x): x is number => typeof x === "number" && x > 0),
- );
- return allShopsMaster.filter((m) => {
- const c = String(m.code || "")
- .trim()
- .toLowerCase();
- if (c && codesInLane.has(c)) return false;
- if (idsInLane.has(m.id)) return false;
- return true;
- });
- }, [addShopLaneId, lanes, allShopsMaster]);
-
- const assertMsgOk = (res: MessageResponse, fallback: string) => {
- const msg = res?.message != null ? String(res.message) : "";
- if (msg.startsWith("Error:") || msg.startsWith("Error :")) {
- throw new Error(msg || fallback);
- }
- };
-
- const handleLogisticsDropOnCompany = (
- targetCompany: string,
- companyLanes: Lane[],
- ) => {
- const dragId = logisticsLaneDragIdRef.current;
- logisticsLaneDragIdRef.current = null;
- setLogisticsDropHoverCompany(null);
- if (!dragId) return;
- const lane = lanesRef.current.find((l) => l.id === dragId);
- if (!lane) return;
-
- const targetId = getColumnTargetLogisticId(targetCompany, companyLanes);
- if (targetCompany !== "未分配物流商" && targetId == null) {
- setError(
- t("logistic_needMasterTpl", { name: targetCompany }),
- );
- return;
- }
- const fromId = lane.logisticId ?? null;
- if (targetCompany === "未分配物流商") {
- if (fromId == null) return;
- } else if (targetId != null && fromId === targetId) {
- return;
- }
-
- const targetMaster =
- targetId != null
- ? logisticRowsEffective.find((r) => Number(r.id) === targetId)
- : null;
- const nextLane: Lane = {
- ...lane,
- logisticId: targetId,
- logisticsCompany:
- targetCompany === "未分配物流商"
- ? ""
- : targetMaster?.logisticName?.trim() || targetCompany,
- plate: targetMaster?.carPlate?.trim() || "",
- driver: targetMaster?.driverName?.trim() || "",
- phone:
- targetMaster?.driverNumber != null &&
- Number.isFinite(Number(targetMaster.driverNumber))
- ? String(targetMaster.driverNumber)
- : "",
- };
- const next = lanesRef.current.map((l) => (l.id === lane.id ? nextLane : l));
- lanesNeedingRefreshOnSaveRef.current.add(lane.id);
- laneDisplayOverlayRef.current.delete(lane.id);
- lanesRef.current = next;
- setLanes(next);
- setSaveResult(null);
- setError(null);
- };
-
- const openAddShopDialog = (laneId: string) => {
- setAddShopLaneId(laneId);
- setAddShopPick(null);
- setAddShopDialogOpen(true);
- };
-
- const closeAddShopDialog = () => {
- setAddShopDialogOpen(false);
- setAddShopLaneId(null);
- setAddShopPick(null);
- };
-
- const closeDistrictEdit = () => {
- setDistrictEditOpen(false);
- setDistrictEditCtx(null);
- setDistrictEditDraft("");
- setDistrictEditError(null);
- };
-
- const openDistrictAdd = (laneId: string) => {
- setDistrictEditCtx({ laneId, mode: "add" });
- setDistrictEditDraft("");
- setDistrictEditError(null);
- setDistrictEditOpen(true);
- };
-
- const openDistrictRename = (laneId: string, oldDisplay: string) => {
- setDistrictEditCtx({ laneId, mode: "rename", oldDisplay });
- setDistrictEditDraft(oldDisplay === "未分類" ? "" : oldDisplay);
- setDistrictEditError(null);
- setDistrictEditOpen(true);
- };
-
- const removePendingEmptyDistrict = (laneId: string, display: string) => {
- setPendingEmptyDistrictsByLane((prev) => {
- const arr = prev[laneId];
- if (!arr?.length) return prev;
- const nextArr = arr.filter((d) => d !== display);
- if (nextArr.length === arr.length) return prev;
- const next = { ...prev };
- if (nextArr.length === 0) delete next[laneId];
- else next[laneId] = nextArr;
- return next;
- });
- setSaveResult(null);
- };
-
- const applyDistrictEdit = () => {
- if (districtEditSubmitLockRef.current || !districtEditCtx) return;
- districtEditSubmitLockRef.current = true;
- setDistrictEditError(null);
- try {
- const lane = lanesRef.current.find((l) => l.id === districtEditCtx.laneId);
- if (!lane) {
- closeDistrictEdit();
- return;
- }
- const pendingExtra =
- pendingEmptyDistrictsByLane[districtEditCtx.laneId] ?? [];
- const trimmed = districtEditDraft.trim();
-
- if (districtEditCtx.mode === "add") {
- if (!trimmed) {
- setDistrictEditError(t("district_err_name"));
- return;
- }
- if (trimmed === "未分類") {
- setDistrictEditError(t("district_err_reserved"));
- return;
- }
- if (districtDisplayExistsInLane(lane, pendingExtra, trimmed)) {
- setDistrictEditError(t("district_err_exists"));
- return;
- }
- setPendingEmptyDistrictsByLane((prev) => {
- const lid = districtEditCtx.laneId;
- const merged = dedupeDistrictPendingOrder([
- ...(prev[lid] ?? []),
- trimmed,
- ]);
- return { ...prev, [lid]: merged };
- });
- setSaveResult(null);
- closeDistrictEdit();
- return;
- }
-
- const oldDisp = districtEditCtx.oldDisplay;
- const newDisp = trimmed === "" ? "未分類" : trimmed;
- if (newDisp === oldDisp) {
- closeDistrictEdit();
- return;
- }
-
- const newRaw = toDistrictRawValue(newDisp);
- const touchedIds: number[] = [];
- const next = lanesRef.current.map((l) => {
- if (l.id !== districtEditCtx.laneId) return l;
- return {
- ...l,
- shops: l.shops.map((s) => {
- if (toDistrictDisplayName(s.districtReferenceRaw) !== oldDisp)
- return s;
- touchedIds.push(s.id);
- return { ...s, districtReferenceRaw: newRaw };
- }),
- };
- });
- lanesRef.current = next;
- setLanes(next);
- setDirtyMoves((prev) => {
- const n = new Map(prev);
- for (const id of touchedIds) {
- if (id > 0) n.set(id, districtEditCtx.laneId);
- }
- return n;
- });
- lanesNeedingRefreshOnSaveRef.current.add(districtEditCtx.laneId);
- const lid = districtEditCtx.laneId;
- setPendingEmptyDistrictsByLane((prev) => {
- const arr = prev[lid];
- let pendingList = arr ? [...arr] : [];
- if (pendingList.includes(oldDisp)) {
- pendingList = pendingList.map((d) => (d === oldDisp ? newDisp : d));
- }
- const laneNext = next.find((l) => l.id === lid);
- const withShops = new Set(
- groupByDistrict(laneNext?.shops ?? []).map((g) => g.district),
- );
- const pruned = dedupeDistrictPendingOrder(
- pendingList.filter((d) => !withShops.has(d)),
- );
- const out = { ...prev };
- if (pruned.length === 0) delete out[lid];
- else out[lid] = pruned;
- return out;
- });
- setSaveResult(null);
- closeDistrictEdit();
- } finally {
- districtEditSubmitLockRef.current = false;
- }
- };
-
- const openAddRouteDialog = () => {
- setNewRouteForm(emptyNewRouteForm());
- setAddRouteError(null);
- setAddRouteDialogOpen(true);
- };
-
- const closeAddRouteDialog = () => {
- setAddRouteDialogOpen(false);
- setNewRouteForm(emptyNewRouteForm());
- setAddRouteError(null);
- };
-
- const submitAddRoute = () => {
- const code = String(newRouteForm.truckLanceCode || "").trim();
- const storeNorm = normalizeStoreId(newRouteForm.storeId);
- const remarkRaw =
- storeNorm === "4F" && String(newRouteForm.remark || "").trim() !== ""
- ? String(newRouteForm.remark).trim()
- : null;
- const laneKey = encodeLaneId(code, remarkRaw);
-
- if (!code) {
- setAddRouteError(t("route_err_code"));
- return;
- }
- const dep = parseTimeForBackend(newRouteForm.startTime || "");
- if (!dep) {
- setAddRouteError(t("route_err_departure"));
- return;
- }
-
- if (
- lanesRef.current.some((l) => l.id === laneKey) ||
- pendingNewLanesRef.current.some((p) => p.laneKey === laneKey)
- ) {
- setAddRouteError(t("route_err_duplicate"));
- return;
- }
-
- if (addRouteInFlightRef.current) return;
- addRouteInFlightRef.current = true;
-
- setAddRouteSubmitting(true);
- setAddRouteError(null);
- try {
- const payload = buildCreateTruckWithoutShopPayload(newRouteForm);
- const draftLane = buildPendingLaneFromForm(newRouteForm);
- setPendingNewLanes((prev) => [...prev, { laneKey, payload }]);
- laneLogisticBaselineRef.current.set(
- laneKey,
- newRouteForm.logisticId != null &&
- Number.isFinite(Number(newRouteForm.logisticId))
- ? Number(newRouteForm.logisticId)
- : null,
- );
- setLanes((prev) => {
- const next = sortLanesByCode([...prev, draftLane]);
- return applyLaneDisplayOverlays(enrichLanesWithLogisticMaster(next));
- });
- closeAddRouteDialog();
- setSelectedLaneIds((prev) =>
- prev.includes(laneKey) ? prev : [...prev, laneKey],
- );
- requestAnimationFrame(() => {
- requestAnimationFrame(() => scrollBoardLaneCardIntoView(laneKey));
- });
- setSaveResult(null);
- } catch (e: any) {
- setAddRouteError(e?.message ?? String(e) ?? t("route_err_create"));
- } finally {
- setAddRouteSubmitting(false);
- addRouteInFlightRef.current = false;
- }
- };
-
- const submitAddShop = () => {
- if (!addShopLaneId || !addShopPick) return;
- if (addShopConfirmLockRef.current) return;
- const lane = lanes.find((l) => l.id === addShopLaneId);
- if (!lane) return;
-
- addShopConfirmLockRef.current = true;
- setError(null);
- try {
- const flat = flattenDisplayOrder(lane.shops);
- const last = flat[flat.length - 1];
- const newSeq = last != null ? last.loadingSequence : 0;
- const seq = Number(newSeq) || 0;
- const storeId = normalizeStoreId(lane.storeId);
- const remark =
- storeId === "4F" &&
- lane.remark != null &&
- String(lane.remark).trim() !== ""
- ? String(lane.remark).trim()
- : null;
-
- const tempId = nextDraftTruckRowIdRef.current;
- nextDraftTruckRowIdRef.current -= 1;
-
- const baseRows = lanesToWarningInputRows(lanes);
- const hypo = appendSyntheticPendingShopRow(
- baseRows,
- {
- truckLanceCode: lane.truckLanceCode,
- laneRemark: lane.remark ?? null,
- storeId: lane.storeId,
- startTime: lane.startTime,
- },
- addShopPick,
- tempId,
- );
- const wr = computeTruckLaneWarnings(hypo);
- const touching = wr.warnings.filter((w) =>
- warningTouchesPickedShop(w, addShopPick),
- );
- if (touching.length > 0) {
- const ok = window.confirm(
- t("confirm_addShopConflict", { count: touching.length }),
- );
- if (!ok) return;
- }
-
- const newCard: ShopCard = {
- id: tempId,
- shopEntityId: addShopPick.id,
- branchName: addShopPick.name,
- shopCode: addShopPick.code,
- districtReferenceRaw: null,
- loadingSequence: seq,
- remark,
- storeId,
- departureTime: parseTimeForBackend(lane.startTime),
- };
-
- setLanes((prev) =>
- prev.map((l) =>
- l.id !== lane.id ? l : { ...l, shops: [...l.shops, newCard] },
- ),
- );
- setPendingShopAdds((prev) => [
- ...prev,
- {
- tempTruckRowId: tempId,
- laneId: lane.id,
- shopId: addShopPick.id,
- shopName: addShopPick.name,
- shopCode: addShopPick.code,
- loadingSequence: seq,
- },
- ]);
- lanesNeedingRefreshOnSaveRef.current.add(lane.id);
- closeAddShopDialog();
- if (wr.warnings.some((w) => warningTouchesPickedShop(w, addShopPick))) {
- setLaneWarnSnackbar(t("mtmsRouteWarn_postAddConflict"));
- }
- } finally {
- addShopConfirmLockRef.current = false;
- }
- };
-
- const handleDeleteTruckRow = async (truckRowId: number) => {
- if (truckRowId > 0 && scheduledShopIdSet.has(truckRowId)) {
- return;
- }
- if (truckRowId < 0) {
- if (!window.confirm(t("confirm_discardDraftShop"))) return;
- setError(null);
- setPendingShopAdds((prev) =>
- prev.filter((p) => p.tempTruckRowId !== truckRowId),
- );
- setLanes((prev) =>
- prev.map((lane) => ({
- ...lane,
- shops: lane.shops.filter((s) => s.id !== truckRowId),
- })),
- );
- setSaveResult(null);
- return;
- }
- if (!window.confirm(t("confirm_removeShop")))
- return;
- setError(null);
- const affectedLaneId = lanesRef.current.find((l) =>
- l.shops.some((s) => s.id === truckRowId),
- )?.id;
- if (affectedLaneId) {
- lanesNeedingRefreshOnSaveRef.current.add(affectedLaneId);
- }
-
- const srcLane = lanesRef.current.find((l) =>
- l.shops.some((s) => s.id === truckRowId),
- );
- const shop = srcLane?.shops.find((s) => s.id === truckRowId);
- if (srcLane && shop && truckRowId > 0) {
- stagedDeleteMetaRef.current.set(truckRowId, {
- shopCode: String(shop.shopCode ?? "").trim(),
- branchName: String(shop.branchName ?? "").trim(),
- fromLane: formatLaneLabel(srcLane.truckLanceCode, srcLane.remark),
- });
- }
-
- // delete beats move
- setDirtyMoves((prev) => {
- const next = new Map(prev);
- next.delete(truckRowId);
- return next;
- });
- setDirtyDeletes((prev) => {
- const next = new Set(prev);
- next.add(truckRowId);
- return next;
- });
-
- // optimistic UI: remove now; cancel will refetch
- setLanes((prev) =>
- prev.map((lane) =>
- lane.shops.some((s) => s.id === truckRowId)
- ? { ...lane, shops: lane.shops.filter((s) => s.id !== truckRowId) }
- : lane,
- ),
- );
- setSaveResult(null);
- };
-
- /** 清空整桶店鋪:與單筆刪除相同,僅標記 dirtyDeletes,按「儲存更改」才 deleteTruckLaneClient */
- const handleClearLaneShops = (lane: Lane) => {
- if (lane.shops.length === 0) return;
- if (
- lane.shops.some((s) => s.id > 0 && scheduledShopIdSet.has(s.id))
- ) {
- return;
- }
- if (
- !window.confirm(
- t("confirm_clearLane", {
- laneLabel: `${lane.truckLanceCode}${
- lane.remark != null && String(lane.remark).trim() !== ""
- ? ` · ${lane.remark}`
- : ""
- }`,
- count: lane.shops.length,
- }),
- )
- )
- return;
- setError(null);
- const draftIds = new Set(
- lane.shops.filter((s) => s.id < 0).map((s) => s.id),
- );
- const serverShopIds = lane.shops
- .filter((s) => s.id > 0)
- .map((s) => s.id);
-
- if (draftIds.size > 0) {
- setPendingShopAdds((prev) =>
- prev.filter((p) => !draftIds.has(p.tempTruckRowId)),
- );
- }
-
- if (serverShopIds.length > 0) {
- lanesNeedingRefreshOnSaveRef.current.add(lane.id);
- for (const sid of serverShopIds) {
- const shop = lane.shops.find((s) => s.id === sid);
- if (shop && sid > 0) {
- stagedDeleteMetaRef.current.set(sid, {
- shopCode: String(shop.shopCode ?? "").trim(),
- branchName: String(shop.branchName ?? "").trim(),
- fromLane: formatLaneLabel(lane.truckLanceCode, lane.remark),
- });
- }
- }
- setDirtyMoves((prev) => {
- const next = new Map(prev);
- for (const id of serverShopIds) next.delete(id);
- return next;
- });
- setDirtyDeletes((prev) => {
- const next = new Set(prev);
- for (const id of serverShopIds) next.add(id);
- return next;
- });
- }
-
- setLanes((prev) =>
- prev.map((l) => (l.id === lane.id ? { ...l, shops: [] } : l)),
- );
- setSaveResult(null);
- };
-
- const handleDragStart = (shopId: number, fromLaneId: string) => {
- draggedRef.current = { shopId, fromLaneId };
- setDropIndicator(null);
- };
-
- const clearDragState = () => {
- draggedRef.current = null;
- setDropIndicator(null);
- };
-
- const getBeforeShopIdByPointer = (
- laneId: string,
- clientY: number,
- ): number | null => {
- const laneEl = document.querySelector<HTMLElement>(
- `[data-lane-id="${CSS.escape(laneId)}"]`,
- );
- if (!laneEl) return null;
- const cards = Array.from(
- laneEl.querySelectorAll<HTMLElement>("[data-shop-id]"),
- );
- for (const cardEl of cards) {
- const rect = cardEl.getBoundingClientRect();
- const midY = rect.top + rect.height / 2;
- if (clientY < midY) {
- const idStr = cardEl.getAttribute("data-shop-id");
- const id = idStr ? Number(idStr) : NaN;
- return Number.isFinite(id) ? id : null;
- }
- }
- return null; // append
- };
-
- const handleDropToLane = (toLaneId: string) => {
- const before =
- dropIndicator != null && dropIndicator.laneId === toLaneId
- ? dropIndicator.beforeShopId
- : null;
- handleDropToPosition(toLaneId, before);
- };
-
- const handleDropToPosition = (
- toLaneId: string,
- beforeShopId: number | null,
- targetDistrict?: string | null,
- ) => {
- const dragged = draggedRef.current;
- if (!dragged) return;
- if (dragged.shopId < 0) {
- setError(
- t("drag_blockDraftShop"),
- );
- clearDragState();
- return;
- }
- if (beforeShopId != null && beforeShopId === dragged.shopId) {
- clearDragState();
- return;
- }
-
- const dirtyToAdd: Array<[number, string]> = [];
- const base = lanesRef.current;
- const next = base.map((lane) => ({ ...lane, shops: lane.shops.slice() }));
- const from = next.find((l) => l.id === dragged.fromLaneId);
- const to = next.find((l) => l.id === toLaneId);
- if (!from || !to) return;
-
- const shop = from.shops.find((s) => s.id === dragged.shopId);
- if (!shop) return;
-
- if (from.id !== to.id && laneTargetConflicts(shop, to)) {
- setError(t("err_dragDuplicateShop"));
- clearDragState();
- return;
- }
-
- const oldSeqFrom = new Map<number, number>(
- from.shops.map((s) => [s.id, s.loadingSequence]),
- );
- const oldSeqTo = new Map<number, number>(
- to.shops.map((s) => [s.id, s.loadingSequence]),
- );
-
- // build display-ordered lists (matches what user sees on screen)
- const fromFlat = flattenDisplayOrder(from.shops).filter(
- (s) => s.id !== dragged.shopId,
- );
- const toFlatRaw = flattenDisplayOrder(to.shops);
- const toFlat =
- from.id === to.id
- ? toFlatRaw.filter((s) => s.id !== dragged.shopId)
- : toFlatRaw.slice();
-
- const beforeShop =
- beforeShopId != null ? toFlat.find((s) => s.id === beforeShopId) : null;
- const targetDistrictRaw =
- targetDistrict !== undefined
- ? toDistrictRawValue(targetDistrict)
- : beforeShop
- ? toDistrictRawValue(beforeShop.districtReferenceRaw)
- : shop.districtReferenceRaw;
-
- // keep fields but update departure/store/remark/district to match target position
- const moved: ShopCard = {
- ...shop,
- districtReferenceRaw: targetDistrictRaw,
- departureTime: to.startTime,
- storeId: to.storeId,
- remark:
- normalizeStoreId(to.storeId) === "4F"
- ? to.remark != null && String(to.remark).trim() !== ""
- ? String(to.remark).trim()
- : null
- : null,
- };
-
- // insert into target by DISPLAY ORDER (beforeShopId if provided, else append)
- const insertIdx =
- beforeShopId != null
- ? toFlat.findIndex((s) => s.id === beforeShopId)
- : targetDistrict !== undefined
- ? (() => {
- const targetDisplay = toDistrictDisplayName(targetDistrictRaw);
- for (let i = toFlat.length - 1; i >= 0; i -= 1) {
- if (
- toDistrictDisplayName(toFlat[i].districtReferenceRaw) ===
- targetDisplay
- ) {
- return i + 1;
- }
- }
- return -1;
- })()
- : -1;
- const inserted =
- insertIdx >= 0
- ? [...toFlat.slice(0, insertIdx), moved, ...toFlat.slice(insertIdx)]
- : [...toFlat, moved];
-
- // write back lists
- from.shops = fromFlat;
- to.shops = inserted;
-
- // IMPORTANT: Do NOT renumber all loadingSequence.
- // loadingSequence can be duplicated intentionally (e.g. 4F grouping).
- // On drag/drop, only update the moved shop's loadingSequence so it joins the target group.
- const newSeq = computeMovedLoadingSequence(
- inserted,
- moved.id,
- targetDistrict !== undefined && beforeShopId == null,
- );
- to.shops = to.shops.map((s) =>
- s.id === moved.id ? { ...s, loadingSequence: newSeq } : s,
- );
-
- if (
- from.id !== to.id ||
- toDistrictDisplayName(shop.districtReferenceRaw) !==
- toDistrictDisplayName(targetDistrictRaw)
- ) {
- dirtyToAdd.push([moved.id, to.id]);
- }
-
- // mark dirty for any sequence changes + moved shop
- from.shops.forEach((s) => {
- const old = oldSeqFrom.get(s.id);
- if (old == null || old !== s.loadingSequence)
- dirtyToAdd.push([s.id, from.id]);
- });
- if (to.id !== from.id) {
- to.shops.forEach((s) => {
- const old = oldSeqTo.get(s.id);
- if (old == null || old !== s.loadingSequence)
- dirtyToAdd.push([s.id, to.id]);
- });
- } else {
- // same lane: compare using oldSeqFrom for all shops
- to.shops.forEach((s) => {
- const old = oldSeqFrom.get(s.id);
- if (old == null || old !== s.loadingSequence)
- dirtyToAdd.push([s.id, to.id]);
- });
- }
-
- dirtyToAdd.forEach(([, laneId]) =>
- lanesNeedingRefreshOnSaveRef.current.add(laneId),
- );
- if (from.id !== to.id) {
- lanesNeedingRefreshOnSaveRef.current.add(from.id);
- }
-
- if (dirtyToAdd.length === 0) {
- clearDragState();
- return;
- }
-
- // Make rapid successive drops in same tick see latest snapshot.
- setError(null);
- lanesRef.current = next;
- setLanes(next);
- if (dirtyToAdd.length > 0) {
- setDirtyMoves((prevDirty) => {
- const nextDirty = new Map(prevDirty);
- dirtyToAdd.forEach(([shopId, laneId]) => nextDirty.set(shopId, laneId));
- return nextDirty;
- });
- }
-
- clearDragState();
- };
-
- const scheduleLaneOptions = useMemo(
- () =>
- lanes.map((lane) => ({
- id: lane.id,
- label: formatLaneLabel(lane.truckLanceCode, lane.remark),
- 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) => ({
- truckRowId: s.id,
- districtReferenceRaw: s.districtReferenceRaw ?? null,
- loadingSequence: s.loadingSequence ?? 0,
- })),
- })),
- [lanes],
- );
-
- const scheduleShopRows = useMemo((): ScheduleShopRow[] => {
- const rows: ScheduleShopRow[] = [];
- for (const lane of lanes) {
- const currentLaneLabel = formatLaneLabel(
- lane.truckLanceCode,
- lane.remark,
- );
- for (const shop of lane.shops) {
- if (shop.id < 0) continue;
- const codeLower = String(shop.shopCode || "")
- .trim()
- .toLowerCase();
- const realName = shopNameByCodeMap.get(codeLower);
- const displayName =
- realName && String(realName).trim() !== ""
- ? String(realName).trim()
- : String(shop.branchName || "").trim() || "-";
- const district = toDistrictDisplayName(shop.districtReferenceRaw);
- const location = [
- district,
- shop.shopCode || "-",
- shop.storeId ? normalizeStoreId(shop.storeId) : "",
- ]
- .filter(Boolean)
- .join(" · ");
- rows.push({
- truckRowId: shop.id,
- shopCode: String(shop.shopCode || "").trim() || "-",
- displayName,
- branchName: String(shop.branchName || "").trim(),
- storeId: shop.storeId ? normalizeStoreId(shop.storeId) : "",
- location,
- districtDisplay: district,
- districtReferenceRaw: shop.districtReferenceRaw ?? null,
- currentLaneId: lane.id,
- currentLaneLabel,
- });
- }
- }
- return rows.sort((a, b) =>
- a.displayName.localeCompare(b.displayName, "zh-Hant"),
- );
- }, [lanes, shopNameByCodeMap]);
-
- const handleScheduleConfirmManual = useCallback(
- async (payload: ScheduleChangePayload) => {
- const validation = validatePayloadSubmit({
- payload,
- lanes: scheduleLaneOptions,
- shops: scheduleShopRows,
- pendingTruckRowIds: pendingScheduleShopIds,
- });
- if (!validation.ok) {
- setError(formatScheduleValidationErrors(t, validation.errors));
- return;
- }
- const request = buildRequestFromPayload(payload, scheduleLaneOptions);
- if (!request || (request.lines?.length ?? 0) === 0) {
- setError(t("schedule_err_conflict"));
- return;
- }
- try {
- await createTruckLaneScheduleClient(request);
- setLaneWarnSnackbar(
- t("schedule_registered_snackbar", { count: request.lines?.length ?? 0 }),
- );
- } catch (err: unknown) {
- setError(extractApiErrorMessage(err) ?? t("schedule_err_generic"));
- throw err;
- }
- },
- [t, scheduleLaneOptions, scheduleShopRows, pendingScheduleShopIds],
- );
-
-
- const prevPendingScheduleSizeRef = useRef(0);
- useEffect(() => {
- const n = pendingScheduleShopIds.size;
- if (
- prevPendingScheduleSizeRef.current > 0 &&
- n < prevPendingScheduleSizeRef.current
- ) {
- void loadLanes();
- }
- prevPendingScheduleSizeRef.current = n;
- }, [pendingScheduleShopIds]);
-
- const handleDragOver: React.DragEventHandler = (e) => {
- e.preventDefault();
- };
-
- const getLaneLogisticChanges = useCallback((): Array<{
- laneId: string;
- logisticId: number | null;
- }> => {
- const changes: Array<{ laneId: string; logisticId: number | null }> = [];
- for (const lane of lanesRef.current) {
- const nextId = lane.logisticId != null ? Number(lane.logisticId) : null;
- const prevId = laneLogisticBaselineRef.current.has(lane.id)
- ? laneLogisticBaselineRef.current.get(lane.id) ?? null
- : null;
- if (prevId !== nextId)
- changes.push({ laneId: lane.id, logisticId: nextId });
- }
- return changes;
- }, []);
-
- const hasDirtyLaneLogistics = getLaneLogisticChanges().length > 0;
- const dirtyLaneLogisticIds = new Set(
- getLaneLogisticChanges().map((c) => c.laneId),
- );
- const hasUnsavedChanges =
- dirtyMoves.size > 0 ||
- dirtyDeletes.size > 0 ||
- hasDirtyLaneLogistics ||
- pendingShopAdds.length > 0 ||
- pendingNewLanes.length > 0 ||
- pendingLogisticMasterAdds.length > 0 ||
- pendingLogisticMasterEdits.size > 0 ||
- pendingLogisticMasterDeletes.size > 0 ||
- pendingImportMeta != null ||
- pendingRestoreVersionId != null ||
- Object.values(pendingEmptyDistrictsByLane).some(
- (a) => (a?.length ?? 0) > 0,
- );
-
- const laneLogisticChangesForStaged = useMemo(() => {
- const changes: Array<{ laneId: string; logisticId: number | null }> = [];
- for (const lane of lanes) {
- const nextId = lane.logisticId != null ? Number(lane.logisticId) : null;
- const prevId = laneLogisticBaselineRef.current.has(lane.id)
- ? laneLogisticBaselineRef.current.get(lane.id) ?? null
- : null;
- if (prevId !== nextId)
- changes.push({ laneId: lane.id, logisticId: nextId });
- }
- return changes;
- }, [lanes]);
-
- const stagedDeleteSig = useMemo(
- () =>
- Array.from(dirtyDeletes.values())
- .sort((a, b) => a - b)
- .join(","),
- [dirtyDeletes],
- );
-
- const dirtyMoveDistrictHints = useMemo(() => {
- const hints = new Map<number, string>();
- dirtyMoves.forEach((laneId, shopId) => {
- if (!Number.isFinite(shopId) || shopId <= 0) return;
- const baseDisp = shopDistrictBaselineRef.current.get(shopId);
- if (baseDisp === undefined) return;
- const lane = lanes.find((l) => l.id === laneId);
- const shop = lane?.shops.find((s) => s.id === shopId);
- if (!shop) return;
- const curDisp = toDistrictDisplayName(shop.districtReferenceRaw);
- if (curDisp !== baseDisp)
- hints.set(
- shopId,
- t("diff_staged_shopDistrictHint", {
- from: baseDisp,
- to: curDisp,
- }),
- );
- });
- return hints;
- }, [lanes, dirtyMoves, districtBaselineEpoch, stagedDeleteSig, t]);
-
- const stagedLogEntriesView = useMemo(
- () =>
- buildStagedBoardLogEntries({
- pendingRestoreVersionId,
- dirtyMoves,
- dirtyMoveDistrictHints,
- dirtyDeletes,
- stagedDeleteMeta: stagedDeleteMetaRef.current,
- pendingShopAdds,
- pendingNewLanes,
- pendingLogisticMasterAdds: pendingLogisticMasterAdds.map((p) => ({
- tempId: p.tempId,
- logisticName: p.logisticName,
- carPlate: p.carPlate,
- })),
- pendingLogisticMasterEdits: Array.from(
- pendingLogisticMasterEdits.entries(),
- ).map(([id, edit]) => {
- const prev = logisticRowsFromDb.find((r) => Number(r.id) === id);
- return {
- id,
- logisticName: edit.logisticName,
- carPlate: edit.carPlate,
- fromName: prev?.logisticName ?? "",
- fromPlate: prev?.carPlate ?? "",
- };
- }),
- pendingLogisticMasterDeletes: Array.from(
- pendingLogisticMasterDeletes,
- ).map((id) => {
- const prev = logisticRowsFromDb.find((r) => Number(r.id) === id);
- return {
- id,
- logisticName: String(prev?.logisticName ?? "").trim() || "—",
- };
- }),
- pendingImport: pendingImportMeta,
- laneLogisticChanges: laneLogisticChangesForStaged,
- lanes,
- pendingEmptyDistrictsByLane,
- logisticNameById,
- shopDistrictBaseline: new Map(shopDistrictBaselineRef.current),
- shopRowBaseline: new Map(shopRowBaselineRef.current),
- }),
- [
- pendingRestoreVersionId,
- dirtyMoves,
- dirtyMoveDistrictHints,
- dirtyDeletes,
- pendingShopAdds,
- pendingNewLanes,
- pendingLogisticMasterAdds,
- pendingLogisticMasterEdits,
- pendingLogisticMasterDeletes,
- pendingImportMeta,
- laneLogisticChangesForStaged,
- lanes,
- pendingEmptyDistrictsByLane,
- logisticNameById,
- logisticRowsFromDb,
- stagedDeleteSig,
- districtBaselineEpoch,
- ],
- );
-
- useEffect(() => {
- if (!hasUnsavedChanges) return;
- const message = t("nav_unsavedLeave");
- const currentUrl = window.location.href;
- const guardKey = `routeBoard:${Date.now()}:${Math.random()}`;
- let bypassNavigationGuard = false;
- let confirmedNavigation = false;
- let guardReleased = false;
- const guardState = {
- ...(window.history.state &&
- typeof window.history.state === "object" &&
- !Array.isArray(window.history.state)
- ? window.history.state
- : {}),
- __routeBoardUnsavedGuard: guardKey,
- };
- const isSameLocation = (url: string | URL | null | undefined) => {
- if (url == null) return true;
- const nextUrl = new URL(String(url), window.location.href);
- const here = new URL(currentUrl);
- return (
- nextUrl.origin === here.origin &&
- nextUrl.pathname === here.pathname &&
- nextUrl.search === here.search
- );
- };
- const confirmLeave = () => window.confirm(message);
- window.history.pushState(guardState, "", currentUrl);
- const handleBeforeUnload = (event: BeforeUnloadEvent) => {
- event.preventDefault();
- event.returnValue = message;
- return message;
- };
- const handleDocumentClick = (event: MouseEvent) => {
- if (
- event.defaultPrevented ||
- event.button !== 0 ||
- event.metaKey ||
- event.ctrlKey ||
- event.shiftKey ||
- event.altKey
- ) {
- return;
- }
- const target = event.target as HTMLElement | null;
- const anchor = target?.closest("a[href]") as HTMLAnchorElement | null;
- if (
- !anchor ||
- anchor.target === "_blank" ||
- anchor.hasAttribute("download")
- )
- return;
- if (isSameLocation(anchor.href)) return;
- if (!confirmLeave()) {
- event.preventDefault();
- event.stopPropagation();
- return;
- }
- releaseGuard();
- };
- const handlePopState = () => {
- if (confirmedNavigation) return;
- if (confirmLeave()) {
- releaseGuard();
- window.history.back();
- return;
- }
- bypassNavigationGuard = true;
- window.history.pushState(guardState, "", currentUrl);
- bypassNavigationGuard = false;
- };
- const originalPushState = window.history.pushState;
- const originalReplaceState = window.history.replaceState;
- function releaseGuard() {
- if (guardReleased) return;
- guardReleased = true;
- confirmedNavigation = true;
- window.removeEventListener("beforeunload", handleBeforeUnload);
- window.removeEventListener("popstate", handlePopState);
- document.removeEventListener("click", handleDocumentClick, true);
- window.history.pushState = originalPushState;
- window.history.replaceState = originalReplaceState;
- }
- window.history.pushState = function patchedPushState(
- data: any,
- unused: string,
- url?: string | URL | null,
- ) {
- if (!bypassNavigationGuard && !isSameLocation(url)) {
- if (!confirmLeave()) return;
- releaseGuard();
- }
- return originalPushState.call(window.history, data, unused, url);
- };
- window.history.replaceState = function patchedReplaceState(
- data: any,
- unused: string,
- url?: string | URL | null,
- ) {
- if (!bypassNavigationGuard && !isSameLocation(url)) {
- if (!confirmLeave()) return;
- releaseGuard();
- }
- return originalReplaceState.call(window.history, data, unused, url);
- };
- window.addEventListener("beforeunload", handleBeforeUnload);
- window.addEventListener("popstate", handlePopState);
- document.addEventListener("click", handleDocumentClick, true);
- return () => {
- if (guardReleased) return;
- window.removeEventListener("beforeunload", handleBeforeUnload);
- window.removeEventListener("popstate", handlePopState);
- document.removeEventListener("click", handleDocumentClick, true);
- window.history.pushState = originalPushState;
- window.history.replaceState = originalReplaceState;
- if (
- !confirmedNavigation &&
- window.location.href === currentUrl &&
- window.history.state?.__routeBoardUnsavedGuard === guardKey
- ) {
- window.history.back();
- }
- };
- }, [hasUnsavedChanges, t]);
-
- const handleSave = async () => {
- if (saveInFlightRef.current) return;
- saveInFlightRef.current = true;
- try {
- const pendingRestoreId =
- pendingRestoreVersionId != null &&
- Number.isFinite(Number(pendingRestoreVersionId)) &&
- Number(pendingRestoreVersionId) > 0
- ? Number(pendingRestoreVersionId)
- : null;
-
- const pendingLogisticMasterAddsSnapshot = [...pendingLogisticMasterAdds];
- const pendingLogisticMasterEditsSnapshot = new Map(
- pendingLogisticMasterEdits,
- );
- const pendingLogisticMasterDeletesSnapshot = new Set(
- pendingLogisticMasterDeletes,
- );
- const pendingImportFile = pendingImportFileRef.current;
-
- let laneLogisticChangesLive = getLaneLogisticChanges();
- const pendingSnapshot = [...pendingShopAdds];
- const pendingNewLanesSnapshot = [...pendingNewLanesRef.current];
-
- const hasPersistedWork =
- pendingSnapshot.length > 0 ||
- pendingNewLanesSnapshot.length > 0 ||
- dirtyMoves.size > 0 ||
- dirtyDeletes.size > 0 ||
- laneLogisticChangesLive.length > 0 ||
- pendingLogisticMasterAddsSnapshot.length > 0 ||
- pendingLogisticMasterEditsSnapshot.size > 0 ||
- pendingLogisticMasterDeletesSnapshot.size > 0 ||
- pendingImportFile != null ||
- pendingRestoreId != null;
- const hasPendingEmptyDistrictsOnly =
- !hasPersistedWork &&
- Object.values(pendingEmptyDistrictsByLane).some(
- (a) => (a?.length ?? 0) > 0,
- );
-
- if (!hasPersistedWork && !hasPendingEmptyDistrictsOnly) {
- setSaveResult({ ok: true, message: t("No changes") });
- return;
- }
-
- if (hasPendingEmptyDistrictsOnly) {
- setPendingEmptyDistrictsByLane({});
- setSaveResult({
- ok: true,
- message: t("save_clearedEmptyDistricts"),
- });
- return;
- }
-
- setSaving(true);
- setSaveResult(null);
-
- if (pendingRestoreId != null) {
- const hadRestoreWithOtherWork =
- pendingSnapshot.length > 0 ||
- pendingNewLanesSnapshot.length > 0 ||
- dirtyMoves.size > 0 ||
- dirtyDeletes.size > 0 ||
- laneLogisticChangesLive.length > 0 ||
- pendingLogisticMasterAddsSnapshot.length > 0 ||
- pendingLogisticMasterEditsSnapshot.size > 0 ||
- pendingLogisticMasterDeletesSnapshot.size > 0 ||
- pendingImportFile != null;
- if (hadRestoreWithOtherWork) {
- if (!window.confirm(t("confirm_restoreSaveWillDropStaging")))
- return;
- }
- try {
- await restoreTruckLaneVersionClient(pendingRestoreId);
- setPendingRestoreVersionId(null);
- setDirtyMoves(new Map());
- clearDirtyDeletesState();
- setPendingShopAdds([]);
- setPendingNewLanes([]);
- setPendingLogisticMasterAdds([]);
- setPendingLogisticMasterEdits(new Map());
- setPendingLogisticMasterDeletes(new Set());
- pendingImportFileRef.current = null;
- setPendingImportMeta(null);
- setPendingEmptyDistrictsByLane({});
- closeDistrictEdit();
- closeDepartureEdit();
- setSeqEditTarget(null);
- lanesNeedingRefreshOnSaveRef.current.clear();
- laneDisplayOverlayRef.current.clear();
- await loadLanes();
- setSaveResult({
- ok: true,
- message: hadRestoreWithOtherWork
- ? t("restore_appliedDroppedStaging")
- : t("restore_applied"),
- });
- } catch (e: any) {
- console.error("Restore snapshot failed:", e);
- setSaveResult({
- ok: false,
- message: e?.message ?? String(e) ?? t("diff_restoreFail"),
- });
- }
- return;
- }
-
- if (pendingLogisticMasterEditsSnapshot.size > 0) {
- for (const [id, req] of Array.from(
- pendingLogisticMasterEditsSnapshot.entries(),
- )) {
- if (pendingLogisticMasterDeletesSnapshot.has(id)) continue;
- await saveLogisticClient({ ...req, id });
- }
- setPendingLogisticMasterEdits(new Map());
- await reloadLogisticNamesFromDb();
- }
-
- if (pendingLogisticMasterDeletesSnapshot.size > 0) {
- for (const id of Array.from(pendingLogisticMasterDeletesSnapshot)) {
- await deleteLogisticClient(id);
- }
- setPendingLogisticMasterDeletes(new Set());
- await reloadLogisticNamesFromDb();
- }
-
- if (pendingLogisticMasterAddsSnapshot.length > 0) {
- const savedRows = await saveLogisticsBatchCreateClient(
- pendingLogisticMasterAddsSnapshot.map((p) => ({
- logisticName: p.logisticName,
- carPlate: p.carPlate,
- driverName: p.driverName,
- driverNumber: p.driverNumber,
- })),
- );
- if (
- !Array.isArray(savedRows) ||
- savedRows.length !== pendingLogisticMasterAddsSnapshot.length
- ) {
- throw new Error(
- `logistic save-batch: expected ${pendingLogisticMasterAddsSnapshot.length} rows, got ${savedRows?.length ?? 0}`,
- );
- }
- const byTemp = new Map<number, LogisticRow>();
- pendingLogisticMasterAddsSnapshot.forEach((p, i) => {
- byTemp.set(p.tempId, savedRows[i]!);
- });
- const remapped = lanesRef.current.map((lane) => {
- const lid = lane.logisticId != null ? Number(lane.logisticId) : null;
- if (lid == null || lid >= 0) return lane;
- const saved = byTemp.get(lid);
- if (!saved) return lane;
- const realId = Number(saved.id);
- if (!Number.isFinite(realId) || realId <= 0) return lane;
- return {
- ...lane,
- logisticId: realId,
- logisticsCompany: String(saved.logisticName ?? "").trim(),
- plate: String(saved.carPlate ?? "").trim(),
- driver: String(saved.driverName ?? "").trim(),
- phone:
- saved.driverNumber != null &&
- Number.isFinite(Number(saved.driverNumber))
- ? String(saved.driverNumber)
- : "",
- };
- });
- lanesRef.current = remapped;
- setLanes(remapped);
- setPendingLogisticMasterAdds([]);
- await reloadLogisticNamesFromDb();
- laneLogisticChangesLive = getLaneLogisticChanges();
- }
-
- const laneIdsFromPendingNewLanes = new Set<string>();
- if (pendingNewLanesSnapshot.length > 0) {
- for (const p of pendingNewLanesSnapshot) {
- const res = await createTruckWithoutShopClient(p.payload);
- assertMsgOk(res, t("api_fail_createLane"));
- laneIdsFromPendingNewLanes.add(p.laneKey);
- }
- setPendingNewLanes([]);
- if (laneIdsFromPendingNewLanes.size > 0) {
- await refreshLanesByIds(Array.from(laneIdsFromPendingNewLanes), {
- preserveStagedLogistics: true,
- });
- }
- }
-
- const laneIdsTouchedByCreate = new Set<string>();
- if (pendingSnapshot.length > 0) {
- for (const p of pendingSnapshot) {
- const lane = lanesRef.current.find((l) => l.id === p.laneId);
- if (!lane) continue;
- const sid = normalizeStoreId(lane.storeId);
- const remark =
- sid === "4F" &&
- lane.remark != null &&
- String(lane.remark).trim() !== ""
- ? String(lane.remark).trim()
- : null;
- const res = await createTruckClient({
- store_id: sid,
- truckLanceCode: lane.truckLanceCode,
- departureTime: parseTimeForBackend(lane.startTime),
- shopId: p.shopId,
- shopName: p.shopName,
- shopCode: p.shopCode,
- loadingSequence: p.loadingSequence,
- districtReference: null,
- remark,
- });
- assertMsgOk(res, t("api_fail_addShop"));
- laneIdsTouchedByCreate.add(p.laneId);
- }
- setPendingShopAdds([]);
- setLanes((prev) => stripDraftShopRows(prev));
- if (laneIdsTouchedByCreate.size > 0) {
- await refreshLanesByIds(Array.from(laneIdsTouchedByCreate), {
- preserveStagedLogistics: true,
- });
- }
- }
-
- // build a map shopId -> lane + shopCard(以 ref 最新列為準,含剛 refresh)
- const shopById = new Map<number, { lane: Lane; shop: ShopCard }>();
- for (const lane of lanesRef.current) {
- for (const s of lane.shops) {
- shopById.set(s.id, { lane, shop: s });
- }
- }
-
- const updates: SaveTruckLane[] = [];
- const deletes = Array.from(dirtyDeletes);
- const deleteSet = new Set<number>(deletes);
- for (const shopId of Array.from(dirtyMoves.keys())) {
- if (shopId <= 0) continue;
- if (deleteSet.has(shopId)) continue; // delete wins
- const current = shopById.get(shopId);
- if (!current) continue;
-
- const s = current.shop;
- updates.push({
- id: s.id,
- truckLanceCode: current.lane.truckLanceCode,
- departureTime: parseTimeForBackend(s.departureTime),
- loadingSequence: Number(s.loadingSequence ?? 0) || 0,
- districtReference:
- s.districtReferenceRaw != null &&
- String(s.districtReferenceRaw).trim() !== ""
- ? String(s.districtReferenceRaw).trim()
- : null,
- storeId: normalizeStoreId(s.storeId),
- remark:
- s.remark != null && String(s.remark).trim() !== ""
- ? String(s.remark)
- : null,
- /** 與目標車線桶一致;跨線拖曳時否則後端不會改 truck.logistic(Save 未帶 updateLogistic 時略過) */
- logisticId: current.lane.logisticId ?? null,
- updateLogistic: true,
- });
- }
-
- // lane logistic(整桶更新):避免「只改物流商」或「跨線拖曳後 lane 內 logistic 不一致」冇落 DB
- const laneIdsToUpdateLogistic = new Set<string>([
- ...laneLogisticChangesLive.map((c) => c.laneId),
- ]);
- const laneLogisticUpdateReqs = Array.from(laneIdsToUpdateLogistic)
- .map((laneId) => lanesRef.current.find((l) => l.id === laneId))
- .filter((l): l is Lane => l != null)
- .map((lane) => ({
- truckLanceCode: lane.truckLanceCode,
- remark:
- lane.remark != null && String(lane.remark).trim() !== ""
- ? String(lane.remark).trim()
- : null,
- logisticId: lane.logisticId != null ? Number(lane.logisticId) : null,
- }));
-
- const results = await Promise.allSettled(
- updates.map(async (u) => {
- const res = await updateTruckLaneClient(u);
- assertMsgOk(res, t("api_fail_updateLane"));
- return res;
- }),
- );
- const deleteResults = await Promise.allSettled(
- deletes.map(async (id) => {
- const res = await deleteTruckLaneClient({ id });
- assertMsgOk(res, t("api_fail_deleteShop"));
- return res;
- }),
- );
- const logisticResults = await Promise.allSettled(
- laneLogisticUpdateReqs.map(async (req) => {
- const res = await updateLaneLogisticClient(req);
- assertMsgOk(res, t("api_fail_updateLogistics"));
- return res;
- }),
- );
- const failedIdx: number[] = [];
- results.forEach((r, idx) => {
- if (r.status === "rejected") failedIdx.push(idx);
- });
- const failedDeleteIds = new Set<number>();
- deleteResults.forEach((r, idx) => {
- if (r.status === "rejected") failedDeleteIds.add(deletes[idx]);
- });
- const failedLaneLogistics = new Set<string>();
- logisticResults.forEach((r, idx) => {
- if (r.status !== "rejected") return;
- const lane = lanesRef.current.find(
- (l) =>
- l.truckLanceCode === laneLogisticUpdateReqs[idx]?.truckLanceCode &&
- String(l.remark ?? "").trim() ===
- String(laneLogisticUpdateReqs[idx]?.remark ?? "").trim(),
- );
- if (lane) failedLaneLogistics.add(lane.id);
- });
-
- if (
- failedIdx.length === 0 &&
- failedDeleteIds.size === 0 &&
- failedLaneLogistics.size === 0
- ) {
- const hadPendingImport = pendingImportFile != null;
- if (hadPendingImport) {
- const importFd = new FormData();
- importFd.append("multipartFileList", pendingImportFile);
- const importRes = await importRouteLanesExcelClient(importFd);
- assertMsgOk(importRes, t("err_import"));
- pendingImportFileRef.current = null;
- setPendingImportMeta(null);
- }
-
- const dm = dirtyMoves;
- const laneIdsToRefresh = new Set<string>(
- lanesNeedingRefreshOnSaveRef.current,
- );
- lanesNeedingRefreshOnSaveRef.current.clear();
- for (const lid of Array.from(dm.values())) laneIdsToRefresh.add(lid);
- for (const lid of Array.from(laneIdsToUpdateLogistic))
- laneIdsToRefresh.add(lid);
- setDirtyMoves(new Map());
- clearDirtyDeletesState();
- setPendingEmptyDistrictsByLane({});
- for (const c of laneLogisticChangesLive) {
- laneLogisticBaselineRef.current.set(c.laneId, c.logisticId);
- }
- if (hadPendingImport) {
- await loadLanes();
- } else if (laneIdsToRefresh.size > 0) {
- await refreshLanesByIds(Array.from(laneIdsToRefresh), {
- preserveStagedLogistics: false,
- });
- }
- try {
- await createTruckLaneSnapshotClient({
- truckLanceCode: null,
- note: "board save",
- });
- await refreshLogVersions();
- } catch (snapErr: any) {
- console.warn("Auto snapshot after board save failed:", snapErr);
- }
- setSaveResult({ ok: true, message: t("Saved") });
- return;
- }
-
- const failedIds = new Set<number>(failedIdx.map((i) => updates[i].id));
- setDirtyMoves((prev) => {
- const next = new Map<number, string>();
- prev.forEach((laneId, shopId) => {
- if (failedIds.has(shopId)) next.set(shopId, laneId);
- });
- return next;
- });
- setDirtyDeletes((prev) => {
- const next = new Set<number>();
- prev.forEach((id) => {
- if (failedDeleteIds.has(id)) next.add(id);
- });
- return next;
- });
-
- const firstReason = (results[failedIdx[0]] as PromiseRejectedResult)
- ?.reason as any;
- const reasonText =
- firstReason?.message ??
- (firstReason != null ? String(firstReason) : "");
- setSaveResult({
- ok: false,
- message: `Saved ${updates.length - failedIdx.length}, Failed ${
- failedIdx.length
- }, Deleted ${deletes.length - failedDeleteIds.size}, DeleteFailed ${
- failedDeleteIds.size
- }, LaneLogisticFailed ${failedLaneLogistics.size}${
- reasonText ? `: ${reasonText}` : ""
- }`,
- });
- } catch (e: any) {
- console.error("Save failed:", e);
- setSaveResult({
- ok: false,
- message: e?.message ?? String(e) ?? t("Failed to save"),
- });
- } finally {
- setSaving(false);
- saveInFlightRef.current = false;
- }
- };
-
- const handleCancel = async () => {
- const pendingLaneKeys = new Set(
- pendingNewLanesRef.current.map((p) => p.laneKey),
- );
- setPendingShopAdds([]);
- setPendingNewLanes([]);
- setPendingLogisticMasterAdds([]);
- setPendingLogisticMasterEdits(new Map());
- setPendingLogisticMasterDeletes(new Set());
- pendingImportFileRef.current = null;
- setPendingImportMeta(null);
- pendingLaneKeys.forEach((id) =>
- laneLogisticBaselineRef.current.delete(id),
- );
- const snapshot = stripDraftShopRows(lanesRef.current).filter(
- (l) => !pendingLaneKeys.has(l.id),
- );
- const dm = dirtyMoves;
- const laneIdsToRefresh = new Set<string>(
- lanesNeedingRefreshOnSaveRef.current,
- );
- lanesNeedingRefreshOnSaveRef.current.clear();
- dm.forEach((lid) => laneIdsToRefresh.add(lid));
- snapshot.forEach((lane) => {
- if (lane.shops.some((s) => dm.has(s.id))) laneIdsToRefresh.add(lane.id);
- const currentLogisticId =
- lane.logisticId != null ? Number(lane.logisticId) : null;
- const baselineLogisticId = laneLogisticBaselineRef.current.has(lane.id)
- ? laneLogisticBaselineRef.current.get(lane.id) ?? null
- : null;
- if (currentLogisticId !== baselineLogisticId)
- laneIdsToRefresh.add(lane.id);
- });
- const restoredLogistics = snapshot.map((lane) => {
- const currentLogisticId =
- lane.logisticId != null ? Number(lane.logisticId) : null;
- const baselineLogisticId = laneLogisticBaselineRef.current.has(lane.id)
- ? laneLogisticBaselineRef.current.get(lane.id) ?? null
- : null;
- if (currentLogisticId === baselineLogisticId) return lane;
- const master =
- baselineLogisticId != null
- ? logisticRowsFromDb.find((r) => Number(r.id) === baselineLogisticId)
- : null;
- return {
- ...lane,
- logisticId: baselineLogisticId,
- logisticsCompany:
- baselineLogisticId == null
- ? ""
- : master?.logisticName?.trim() ||
- logisticNameById.get(baselineLogisticId) ||
- "",
- plate: master?.carPlate?.trim() || "",
- driver: master?.driverName?.trim() || "",
- phone:
- master?.driverNumber != null &&
- Number.isFinite(Number(master.driverNumber))
- ? String(master.driverNumber)
- : "",
- };
- });
- lanesRef.current = restoredLogistics;
- setLanes(restoredLogistics);
- setDirtyMoves(new Map());
- clearDirtyDeletesState();
- setPendingEmptyDistrictsByLane({});
- closeDistrictEdit();
- setSaveResult(null);
- setPendingRestoreVersionId(null);
- if (laneIdsToRefresh.size > 0) {
- await refreshLanesByIds(Array.from(laneIdsToRefresh), {
- preserveStagedLogistics: false,
- });
- } else {
- await loadLanes();
- }
- };
-
- const closeDepartureEdit = () => {
- setDepartureEditLaneId(null);
- };
-
- const openDepartureEdit = (lane: Lane) => {
- setDepartureEditDraft(toTimeInputValue(lane.startTime));
- setDepartureEditLaneId(lane.id);
- };
-
- const applyDepartureEdit = () => {
- if (!departureEditLaneId) return;
- const backendTime = parseTimeForBackend(departureEditDraft);
- const targetLane = lanesRef.current.find(
- (l) => l.id === departureEditLaneId,
- );
- const shopIds = targetLane?.shops.map((s) => s.id) ?? [];
- if (shopIds.length === 0) {
- 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;
- }
- setLanes(nextLanes);
- setDirtyMoves((prev) => {
- const next = new Map(prev);
- for (const id of shopIds) {
- if (id > 0) next.set(id, departureEditLaneId);
- }
- return next;
- });
- setSaveResult(null);
- closeDepartureEdit();
- };
-
- const closeSeqEdit = () => {
- setSeqEditTarget(null);
- };
-
- const openSeqEdit = (lane: Lane, shop: ShopCard) => {
- setSeqEditDraft(String(shop.loadingSequence ?? 0));
- setSeqEditTarget({ laneId: lane.id, shopId: shop.id });
- };
-
- const applySeqEdit = () => {
- if (!seqEditTarget) return;
- const n = Number(seqEditDraft);
- const seq = Number.isFinite(n) ? Math.trunc(n) : 0;
- setLanes((prev) =>
- prev.map((lane) =>
- lane.id !== seqEditTarget.laneId
- ? lane
- : {
- ...lane,
- shops: lane.shops.map((s) =>
- s.id === seqEditTarget.shopId
- ? { ...s, loadingSequence: seq }
- : s,
- ),
- },
- ),
- );
- setDirtyMoves((prev) => {
- const next = new Map(prev);
- next.set(seqEditTarget.shopId, seqEditTarget.laneId);
- return next;
- });
- setSaveResult(null);
- closeSeqEdit();
- };
-
- const normalizeChangedLines = (diff: any) => {
- const lines = (diff?.changed || []) as any[];
- return lines
- .map((line: any) => {
- const truckRowId = Number(line?.truckRowId);
- const shopCode = line?.shopCode != null ? String(line.shopCode) : null;
- const changes = Array.isArray(line?.changes)
- ? (line.changes as any[]).map((c) => ({
- field: c?.field != null ? String(c.field) : "",
- from: c?.from != null ? String(c.from) : null,
- to: c?.to != null ? String(c.to) : null,
- }))
- : [];
- return {
- truckRowId,
- shopCode,
- changes: changes.filter((c) => c.field),
- truckLanceCode:
- line?.truckLanceCode != null ? String(line.truckLanceCode) : null,
- remark: line?.remark != null ? String(line.remark) : null,
- };
- })
- .filter((x) => Number.isFinite(x.truckRowId) && x.truckRowId > 0);
- };
-
- const loadVersionDiff = async (versionId: number, list: any[]) => {
- const ticket = ++versionDiffReqSeq.current;
- setDiffLoading(true);
- setDiffError(null);
- try {
- const idx = list.findIndex((v) => Number(v?.id) === versionId);
- const olderId =
- idx >= 0 && idx < list.length - 1 ? Number(list[idx + 1]?.id) : null;
- if (olderId == null || !Number.isFinite(olderId) || olderId <= 0) {
- if (versionDiffReqSeq.current === ticket) {
- setDiffLines([]);
- setLogisticMasterDiffLines([]);
- setChangedShopIds(new Set());
- }
- return;
- }
- const diff = await diffTruckLaneVersionsClient(olderId, versionId);
- if (versionDiffReqSeq.current !== ticket) return;
- const normalized = normalizeChangedLines(diff);
- const logisticMaster = Array.isArray(diff?.logisticMasterChanges)
- ? (diff.logisticMasterChanges as LogisticMasterDiffLine[])
- : [];
- if (versionDiffReqSeq.current !== ticket) return;
- const ids = new Set<number>();
- normalized.forEach((line) => {
- const id = Number(line?.truckRowId);
- if (Number.isFinite(id) && id > 0) ids.add(id);
- });
- setChangedShopIds(ids);
- setDiffLines(normalized);
- setLogisticMasterDiffLines(logisticMaster);
- } catch (e: any) {
- if (versionDiffReqSeq.current === ticket) {
- setDiffError(e?.message ?? String(e) ?? t("diff_loadFail"));
- setChangedShopIds(new Set());
- setDiffLines([]);
- setLogisticMasterDiffLines([]);
- }
- } finally {
- if (versionDiffReqSeq.current === ticket) setDiffLoading(false);
- }
- };
-
- const refreshLogVersions = useCallback(async () => {
- try {
- const list = await listTruckLaneVersionsClient();
- const arr = Array.isArray(list) ? list : [];
- setLogVersions(arr);
- return arr;
- } catch (e) {
- console.warn("Failed to load truck lane versions:", e);
- return [];
- }
- }, []);
-
- useEffect(() => {
- void refreshLogVersions();
- }, [refreshLogVersions]);
-
- const openLogDialog = async () => {
- setLogDialogOpen(true);
- setDiffError(null);
- setChangedShopIds(new Set());
- setSelectedLogVersionId(null);
- setDiffLines([]);
-
- setLoadingVersions(true);
- try {
- const arr = await refreshLogVersions();
- const head = resolveHeadVersionId(arr);
- setSelectedLogVersionId(head);
- if (head != null) {
- await loadVersionDiff(head, arr);
- }
- } finally {
- setLoadingVersions(false);
- }
- };
-
- const closeLogDialog = () => {
- versionDiffReqSeq.current += 1;
- setLogDialogOpen(false);
- setDiffError(null);
- setChangedShopIds(new Set());
- setSelectedLogVersionId(null);
- setDiffLines([]);
- setLogisticMasterDiffLines([]);
- setVersionNoteDrafts({});
- setVersionNoteSaveError(null);
- setSavingVersionNoteId(null);
- };
-
- const saveVersionNote = useCallback(
- async (id: number, serverNote: string, currentValue: string) => {
- const trimmed = currentValue.trim();
- const prev = (serverNote ?? "").trim();
- if (trimmed === prev) {
- setVersionNoteDrafts((p) => {
- if (p[id] === undefined) return p;
- const n = { ...p };
- delete n[id];
- return n;
- });
- return;
- }
- setVersionNoteSaveError(null);
- setSavingVersionNoteId(id);
- try {
- const res = await updateTruckLaneVersionNoteClient(id, {
- note: trimmed === "" ? null : trimmed,
- });
- setLogVersions((list) =>
- list.map((x) =>
- Number(x?.id) === id ? { ...x, note: res.note } : x,
- ),
- );
- setVersionNoteDrafts((p) => {
- const n = { ...p };
- delete n[id];
- return n;
- });
- } catch (e: any) {
- setVersionNoteSaveError({
- id,
- message: e?.message ?? String(e) ?? t("versionNote_saveFail"),
- });
- } finally {
- setSavingVersionNoteId(null);
- }
- },
- [t],
- );
-
- const restoreVersion = (versionId: number) => {
- if (!Number.isFinite(versionId) || versionId <= 0) return;
-
- if (pendingRestoreVersionId === versionId) {
- setSaveResult({ ok: true, message: t("diff_restoreAlreadyPending") });
- closeLogDialog();
- return;
- }
-
- const hasOtherPending =
- dirtyMoves.size > 0 ||
- dirtyDeletes.size > 0 ||
- pendingShopAdds.length > 0 ||
- pendingNewLanes.length > 0 ||
- pendingLogisticMasterAdds.length > 0 ||
- pendingLogisticMasterEdits.size > 0 ||
- pendingLogisticMasterDeletes.size > 0 ||
- pendingImportMeta != null ||
- getLaneLogisticChanges().length > 0 ||
- Object.values(pendingEmptyDistrictsByLane).some(
- (a) => (a?.length ?? 0) > 0,
- );
-
- if (hasOtherPending && !window.confirm(t("confirm_restoreDiscardsEdits")))
- return;
-
- if (!hasOtherPending && pendingRestoreVersionId != null) {
- laneDisplayOverlayRef.current.clear();
- setPendingRestoreVersionId(versionId);
- setSaveResult({
- ok: true,
- message: t("diff_restoreScheduled", { versionId }),
- });
- closeLogDialog();
- return;
- }
-
- laneDisplayOverlayRef.current.clear();
-
- const pendingLaneKeys = new Set(
- pendingNewLanesRef.current.map((p) => p.laneKey),
- );
- pendingLaneKeys.forEach((id) => laneLogisticBaselineRef.current.delete(id));
- setPendingNewLanes([]);
-
- setLanes((prev) => {
- const next = stripDraftShopRows(prev).filter(
- (l) => !pendingLaneKeys.has(l.id),
- );
- lanesRef.current = next;
- return next;
- });
- setPendingShopAdds([]);
- setDirtyMoves(new Map());
- clearDirtyDeletesState();
- setPendingEmptyDistrictsByLane({});
- closeDistrictEdit();
- closeDepartureEdit();
- setSeqEditTarget(null);
- lanesNeedingRefreshOnSaveRef.current.clear();
-
- for (const lane of lanesRef.current) {
- laneLogisticBaselineRef.current.set(
- lane.id,
- lane.logisticId != null ? Number(lane.logisticId) : null,
- );
- }
-
- setPendingRestoreVersionId(versionId);
- setSaveResult({
- ok: true,
- message: t("diff_restoreScheduled", { versionId }),
- });
- closeLogDialog();
- };
-
- const handleExportVersionLogReportExcel = useCallback(async () => {
- if (routeExcelExportLockRef.current) return;
- if (hasUnsavedChanges) {
- setDiffError(t("diff_export_blockedError"));
- return;
- }
- routeExcelExportLockRef.current = true;
- if (selectedLogVersionId == null) {
- routeExcelExportLockRef.current = false;
- return;
- }
- const idx = logVersions.findIndex(
- (v) => Number(v?.id) === Number(selectedLogVersionId),
- );
- const olderId =
- idx >= 0 && idx < logVersions.length - 1
- ? Number(logVersions[idx + 1]?.id)
- : null;
- if (olderId == null || !Number.isFinite(olderId) || olderId <= 0) {
- setDiffError(t("diff_noOlderCompare"));
- routeExcelExportLockRef.current = false;
- return;
- }
- setRouteExcelBusy(true);
- setDiffError(null);
- try {
- const { base64, filename } =
- await exportTruckLaneVersionReportExcelClient(
- olderId,
- selectedLogVersionId,
- );
- downloadBase64Xlsx(base64, filename);
- } catch (e: any) {
- setDiffError(e?.message ?? String(e) ?? t("err_export"));
- } finally {
- setRouteExcelBusy(false);
- routeExcelExportLockRef.current = false;
- }
- }, [hasUnsavedChanges, logVersions, selectedLogVersionId, t]);
-
- const renderLaneHeader = (lane: Lane) => {
- const isDirty = lane.shops.some((s) => dirtyMoves.has(s.id));
- return (
- <Box
- sx={{
- px: 2,
- py: 2,
- bgcolor: "background.paper",
- borderBottom: "1px solid",
- borderColor: "divider",
- position: "sticky",
- top: 0,
- zIndex: 2,
- }}
- >
- <Stack
- direction="row"
- justifyContent="space-between"
- alignItems="flex-start"
- spacing={1.5}
- >
- <Stack spacing={1} sx={{ flex: 1, minWidth: 0, pr: 0.5 }}>
- <Box>
- <Typography
- component="h3"
- sx={{
- m: 0,
- fontWeight: 800,
- color: "primary.main",
- fontSize: "1rem",
- lineHeight: 1.35,
- letterSpacing: 0.01,
- wordBreak: "break-word",
- overflowWrap: "anywhere",
- }}
- >
- {lane.truckLanceCode}
- </Typography>
- <Stack
- direction="row"
- alignItems="center"
- spacing={0.75}
- flexWrap="wrap"
- useFlexGap
- sx={{ mt: 0.75 }}
- >
- {lane.remark != null && String(lane.remark).trim() !== "" && (
- <Chip
- label={lane.remark}
- size="small"
- variant="outlined"
- color="secondary"
- sx={{
- height: 22,
- maxWidth: "100%",
- "& .MuiChip-label": {
- px: 1,
- fontWeight: 700,
- fontSize: "0.7rem",
- whiteSpace: "normal",
- lineHeight: 1.2,
- },
- }}
- />
- )}
- {isDirty && (
- <Typography
- variant="caption"
- sx={{ color: "warning.main", fontWeight: 700 }}
- >
- {t("Changed")}
- </Typography>
- )}
- </Stack>
- </Box>
-
- <Stack spacing={0.5} sx={{ color: "text.secondary" }}>
- <Stack direction="row" spacing={0.75} alignItems="center">
- <Box
- sx={{
- display: "flex",
- color: "text.secondary",
- flexShrink: 0,
- }}
- >
- <TruckIcon size={14} />
- </Box>
- <Typography variant="body2" sx={{ fontWeight: 600 }}>
- {lane.logisticsCompany || t("Logistic")}
- </Typography>
- </Stack>
- <Stack
- direction="row"
- spacing={0.75}
- alignItems="center"
- flexWrap="wrap"
- useFlexGap
- >
- <Box sx={{ display: "flex", flexShrink: 0 }}>
- <Users size={14} />
- </Box>
- <Typography variant="body2" component="span">
- {lane.driver ? lane.driver : t("Driver")}
- </Typography>
- <Box
- sx={{
- display: "inline-flex",
- alignItems: "center",
- gap: 0.35,
- ml: 0.5,
- }}
- >
- <Phone size={14} />
- <Typography
- variant="caption"
- component="span"
- sx={{ color: "primary.main", fontWeight: 700 }}
- >
- {lane.phone || "—"}
- </Typography>
- </Box>
- </Stack>
- </Stack>
- </Stack>
-
- <Stack alignItems="flex-end" spacing={1} sx={{ flexShrink: 0 }}>
- <Typography
- variant="caption"
- sx={{
- fontFamily: "monospace",
- px: 1,
- py: 0.35,
- border: "1px solid",
- borderColor: "divider",
- borderRadius: 1,
- bgcolor: "grey.50",
- display: "block",
- textAlign: "center",
- }}
- >
- {lane.plate || t("Plate")}
- </Typography>
- <Stack direction="row" alignItems="center" spacing={0.25}>
- <Typography
- variant="caption"
- sx={{
- fontWeight: 700,
- bgcolor: "grey.100",
- px: 1,
- py: 0.35,
- borderRadius: 1,
- whiteSpace: "nowrap",
- }}
- >
- {t("Departure")}: {lane.startTime || "-"}
- </Typography>
- <Tooltip
- title={
- lane.shops.length === 0
- ? t("departureTooltipNeedShops")
- : t("departureTooltipEditSave")
- }
- >
- <span>
- <IconButton
- size="small"
- onClick={(e) => {
- e.stopPropagation();
- openDepartureEdit(lane);
- }}
- disabled={loading || lane.shops.length === 0}
- aria-label={t("departureEditAria")}
- >
- <Pencil size={14} />
- </IconButton>
- </span>
- </Tooltip>
- </Stack>
- <Typography
- variant="caption"
- sx={{ color: "text.secondary", fontWeight: 600 }}
- >
- {t("Shops")}: {lane.shops.length}
- </Typography>
- </Stack>
- </Stack>
- </Box>
- );
- };
-
- return (
- <Box
- sx={{
- flex: 1,
- minHeight: 0,
- height: "100%",
- width: "100%",
- display: "flex",
- flexDirection: "column",
- bgcolor: "grey.50",
- overflow: "hidden",
- }}
- >
- {/* Header (match your reference code structure) */}
- <Box
- sx={{
- bgcolor: "background.paper",
- borderBottom: "1px solid",
- borderColor: "divider",
- px: 2,
- py: 1.5,
- position: "sticky",
- top: 0,
- zIndex: 10,
- }}
- >
- <Stack
- direction="row"
- justifyContent="space-between"
- alignItems="center"
- spacing={2}
- >
- <Box>
- <Typography variant="h6" sx={{ fontWeight: 800 }}>
- {t("pageTitle")}
- </Typography>
- <Typography variant="caption" color="text.secondary">
- {t("Current version")}: {displayedVersionLabel}
- </Typography>
- </Box>
-
- <Stack
- direction="row"
- spacing={1}
- alignItems="center"
- useFlexGap
- flexWrap="wrap"
- justifyContent="flex-end"
- sx={{ minWidth: 0, rowGap: 0.5 }}
- >
- <input
- ref={importRouteFileInputRef}
- type="file"
- accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
- style={{ display: "none" }}
- onChange={handleImportRouteExcelChange}
- />
- <ButtonGroup
- variant="outlined"
- size="small"
- sx={{
- flexShrink: 0,
- "& .MuiButtonGroup-grouped": {
- minWidth: 0,
- px: 1.5,
- fontWeight: 700,
- },
- }}
- >
- <Button
- startIcon={<Upload size={16} />}
- disabled={loading || routeExcelBusy}
- onClick={() => importRouteFileInputRef.current?.click()}
- >
- {t("importRoutes")}
- </Button>
- <Button
- startIcon={<Download size={16} />}
- disabled={loading || routeExcelBusy}
- onClick={() => void handleExportSelectedLanesExcel()}
- >
- {t("exportRoutes")}
- </Button>
- </ButtonGroup>
- <Button
- variant="outlined"
- size="small"
- startIcon={<FileText size={16} />}
- disabled={loading || routeExcelBusy}
- onClick={() => void handleExportRouteReportExcel()}
- >
- {t("routeReport")}
- </Button>
- <ButtonGroup
- variant="outlined"
- size="small"
- sx={{
- flexShrink: 0,
- "& .MuiButtonGroup-grouped": {
- minWidth: 0,
- px: 1.5,
- fontWeight: 700,
- },
- }}
- >
- <Button
- startIcon={<Clock size={16} />}
- disabled={loading || lanes.length === 0}
- onClick={() => setScheduleModalOpen(true)}
- >
- {t("btn_scheduleChange")}
- </Button>
- <Tooltip
- title={
- failedScheduleCount > 0
- ? t("schedule_log_failed_hint", { count: failedScheduleCount })
- : ""
- }
- >
- <Button
- startIcon={
- <Badge
- color="error"
- badgeContent={failedScheduleCount}
- invisible={failedScheduleCount === 0}
- sx={{
- "& .MuiBadge-badge": {
- fontSize: 10,
- height: 16,
- minWidth: 16,
- padding: "0 4px",
- },
- }}
- >
- <FileText size={16} />
- </Badge>
- }
- disabled={loading}
- {...(failedScheduleCount > 0 ? { color: "error" as const } : {})}
- onClick={() => setScheduleHistoryOpen(true)}
- >
- {t("btn_scheduleHistory")}
- </Button>
- </Tooltip>
- </ButtonGroup>
- <Tooltip
- title={
- laneWarnCount > 0
- ? t("mtmsRouteWarn_tooltipHas", { count: laneWarnCount })
- : t("mtmsRouteWarn_tooltipNone")
- }
- >
- <Box
- component="span"
- sx={{ flexShrink: 0, display: "inline-flex" }}
- >
- <IconButton
- size="small"
- onClick={() => setLaneWarnDrawerOpen(true)}
- aria-label={t("mtmsRouteWarn_title")}
- sx={{
- color:
- laneWarnCount > 0 ? "warning.main" : "text.secondary",
- }}
- >
- <Badge
- color="error"
- badgeContent={laneWarnCount}
- invisible={laneWarnCount === 0}
- >
- <Bell size={20} />
- </Badge>
- </IconButton>
- </Box>
- </Tooltip>
- <Divider flexItem orientation="vertical" />
- <Button
- variant="contained"
- size="small"
- startIcon={<Save size={16} />}
- onClick={handleSave}
- disabled={saving || !hasUnsavedChanges}
- title={
- saving || hasUnsavedChanges
- ? undefined
- : t("saveDisabledTooltip")
- }
- >
- {saving ? t("Submitting...") : t("saveChanges")}
- </Button>
- <Button
- variant="outlined"
- size="small"
- startIcon={<X size={16} />}
- onClick={handleCancel}
- disabled={saving}
- >
- {t("cancel")}
- </Button>
- </Stack>
- </Stack>
- {saveResult && (
- <Alert sx={{ mt: 1 }} severity={saveResult.ok ? "success" : "error"}>
- {saveResult.message}
- </Alert>
- )}
- </Box>
-
- <Drawer
- anchor="right"
- open={laneWarnDrawerOpen}
- onClose={() => setLaneWarnDrawerOpen(false)}
- PaperProps={{
- sx: {
- width: { xs: "100%", sm: 440 },
- p: 0,
- display: "flex",
- flexDirection: "column",
- maxHeight: "100vh",
- },
- }}
- >
- <Box sx={{ p: 2, borderBottom: 1, borderColor: "divider" }}>
- <Stack
- direction="row"
- alignItems="center"
- justifyContent="space-between"
- spacing={1}
- >
- <Typography variant="h6" sx={{ fontWeight: 800 }}>
- {t("mtmsRouteWarn_title")}
- </Typography>
- <IconButton
- size="small"
- aria-label={t("drawerClose")}
- onClick={() => setLaneWarnDrawerOpen(false)}
- >
- <X size={18} />
- </IconButton>
- </Stack>
- <Stack direction="row" spacing={1} sx={{ mt: 1.5 }} flexWrap="wrap">
- <Button
- size="small"
- variant="outlined"
- disabled={loading}
- onClick={() => void loadLanes()}
- >
- {loading ? t("mtmsRouteWarn_refreshing") : t("mtmsRouteWarn_refresh")}
- </Button>
- <Button
- size="small"
- variant="outlined"
- disabled={laneWarningsMemo.warnings.length === 0}
- onClick={async () => {
- const text = formatLaneWarningsClipboard(
- laneWarningsMemo.warnings,
- t,
- );
- try {
- await navigator.clipboard.writeText(text);
- } catch {
- console.warn("clipboard write failed");
- }
- }}
- >
- {t("mtmsRouteWarn_copyAll")}
- </Button>
- </Stack>
- </Box>
- <Box sx={{ p: 1.5, overflow: "auto", flex: 1, minHeight: 0 }}>
- {laneWarningsMemo.weekdayParseFailures.length > 0 && (
- <Alert severity="info" sx={{ mb: 1 }}>
- {t("mtmsRouteWarn_parseHint", {
- count: laneWarningsMemo.weekdayParseFailures.length,
- })}
- </Alert>
- )}
- {laneWarningsMemo.warnings.length === 0 ? (
- <Typography variant="body2" color="text.secondary">
- {t("mtmsRouteWarn_empty")}
- </Typography>
- ) : (
- laneWarningsMemo.warnings.map((w, i) => {
- const shopHeadline = [w.shopCode, w.shopDisplayName]
- .map((s) => String(s ?? "").trim())
- .filter(Boolean)
- .join(" ");
- const expanded = laneWarnExpandedIdx === i;
- return (
- <Card
- key={`${w.rule}-${w.shopCode}-${w.triggerValue}-${i}`}
- variant="outlined"
- sx={{ mb: 1 }}
- >
- <CardContent
- sx={{ py: 1, px: 1.5, "&:last-child": { pb: 1 } }}
- >
- <Stack
- direction="row"
- alignItems="flex-start"
- spacing={0.5}
- >
- <Box
- role="button"
- tabIndex={0}
- onClick={() => selectLanesFromWarning(w)}
- onKeyDown={(e) => {
- if (e.key === "Enter" || e.key === " ") {
- e.preventDefault();
- selectLanesFromWarning(w);
- }
- }}
- sx={{ flex: 1, minWidth: 0, cursor: "pointer" }}
- >
- <Typography variant="subtitle1" fontWeight={800}>
- {t("mtmsRouteWarn_shop")}: {shopHeadline || "—"}
- </Typography>
- <Typography variant="body2" color="text.secondary">
- {formatWarningSummary(w, t)}
- </Typography>
- </Box>
- <IconButton
- size="small"
- aria-label={expanded ? t("warnCollapse") : t("warnExpand")}
- onClick={(e) => {
- e.stopPropagation();
- setLaneWarnExpandedIdx((prev) =>
- prev === i ? null : i,
- );
- }}
- >
- <ExpandMoreIcon
- sx={{
- transform: expanded
- ? "rotate(180deg)"
- : "none",
- transition: "transform 0.2s",
- }}
- />
- </IconButton>
- </Stack>
- </CardContent>
- <Collapse in={expanded} timeout="auto" unmountOnExit>
- <Box sx={{ px: 1.5, pb: 1.5 }}>
- <Stack spacing={1}>
- {w.lanes.map((L) => (
- <Paper
- key={L.laneKey}
- variant="outlined"
- sx={{ p: 1 }}
- >
- <Typography
- variant="body2"
- sx={{ fontWeight: 600 }}
- >
- {L.truckLanceCode}
- {L.laneRemark ? ` · ${L.laneRemark}` : ""}
- </Typography>
- <Typography variant="caption" display="block">
- {formatLaneWarningDetail(L, t)}
- </Typography>
- </Paper>
- ))}
- </Stack>
- </Box>
- </Collapse>
- </Card>
- );
- })
- )}
- </Box>
- </Drawer>
-
- <Snackbar
- open={laneWarnSnackbar != null}
- autoHideDuration={9000}
- onClose={() => setLaneWarnSnackbar(null)}
- anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
- >
- <Alert
- onClose={() => setLaneWarnSnackbar(null)}
- severity="warning"
- variant="filled"
- sx={{ width: "100%" }}
- >
- {laneWarnSnackbar}
- </Alert>
- </Snackbar>
-
- <Box sx={{ flex: 1, minHeight: 0, display: "flex" }}>
- {scheduleModalOpen ? (
- <ScheduleChangeModal
- open
- onClose={() => setScheduleModalOpen(false)}
- lanes={scheduleLaneOptions}
- shops={scheduleShopRows}
- allShopsMaster={allShopsMaster}
- pendingTruckRowIds={pendingScheduleShopIds}
- onConfirmManual={handleScheduleConfirmManual}
- onAfterScheduleChange={async () => {
- await refreshScheduleIndicators();
- }}
- />
- ) : null}
- {scheduleHistoryOpen ? (
- <ScheduleTaskHistoryModal
- open
- onClose={() => setScheduleHistoryOpen(false)}
- lanes={scheduleLaneOptions}
- onAfterChange={async () => {
- await refreshScheduleIndicators();
- await loadLanes();
- }}
- />
- ) : null}
-
- <Dialog
- open={logDialogOpen}
- onClose={closeLogDialog}
- maxWidth="lg"
- fullWidth
- PaperProps={{ sx: { height: "min(85vh, 880px)", maxHeight: "90vh" } }}
- >
- <DialogTitle
- sx={{
- display: "flex",
- alignItems: "center",
- justifyContent: "space-between",
- py: 1.5,
- pl: 2,
- pr: 1,
- }}
- >
- <Typography variant="h6" sx={{ fontWeight: 800, textAlign: "left" }}>
- {t("versionLogDialogTitle")}
- </Typography>
- <IconButton
- aria-label={t("drawerClose")}
- onClick={closeLogDialog}
- size="small"
- disabled={saving}
- >
- <X size={20} />
- </IconButton>
- </DialogTitle>
- <DialogContent
- dividers
- sx={{
- p: 0,
- display: "flex",
- flexDirection: "column",
- minHeight: 0,
- }}
- >
- <Stack direction="row" sx={{ flex: 1, minHeight: 0 }}>
- {/* 左:版本列表 */}
- <Box
- sx={{
- width: "50%",
- borderRight: 1,
- borderColor: "divider",
- bgcolor: "grey.50",
- display: "flex",
- flexDirection: "column",
- minHeight: 0,
- }}
- >
- <Box
- sx={{
- px: 2,
- py: 1.5,
- borderBottom: 1,
- borderColor: "divider",
- bgcolor: "background.paper",
- }}
- >
- <Stack
- direction="row"
- alignItems="center"
- justifyContent="space-between"
- spacing={1}
- >
- <Stack direction="row" alignItems="baseline" spacing={1}>
- <Typography
- variant="overline"
- sx={{ fontWeight: 800, color: "text.secondary" }}
- >
- {t("version_ui_historyTitle")}
- </Typography>
- {!loadingVersions && logVersions.length > 0 && (
- <Typography variant="caption" color="text.secondary">
- {filteredLogVersions.length}/{logVersions.length}
- </Typography>
- )}
- </Stack>
- <IconButton
- size="small"
- onClick={(e) => setVersionFilterAnchor(e.currentTarget)}
- disabled={loadingVersions || logVersions.length === 0}
- aria-label={t("version_ui_filterAria")}
- >
- <FilterListIcon
- fontSize="small"
- color={versionFilterActive ? "primary" : "inherit"}
- />
- </IconButton>
- </Stack>
- </Box>
- <Box sx={{ flex: 1, overflow: "auto", p: 2 }}>
- {loadingVersions ? (
- <Box
- sx={{ display: "flex", justifyContent: "center", py: 4 }}
- >
- <CircularProgress size={28} />
- </Box>
- ) : (
- <List
- disablePadding
- component="div"
- role="list"
- aria-label={t("version_ui_listAria")}
- >
- {filteredLogVersions.map((v) => {
- const id = Number(v?.id);
- const created = String(v?.created || "");
- const { date, time } = splitVersionCreated(created);
- const note = v?.note != null ? String(v.note) : "";
- const isSel = selectedLogVersionId === id;
- const isHead =
- headVersionId != null && id === headVersionId;
- return (
- <ListItemButton
- key={id}
- selected={isSel}
- onClick={() => {
- setSelectedLogVersionId(id);
- void loadVersionDiff(id, logVersions);
- }}
- sx={{
- p: 2,
- mb: 1.5,
- flexDirection: "column",
- alignItems: "stretch",
- border: 2,
- borderColor: isSel
- ? "primary.main"
- : "transparent",
- bgcolor: "background.paper",
- borderRadius: 2,
- boxShadow: isSel ? 2 : 1,
- outline: isSel ? "4px solid" : "none",
- outlineColor: isSel
- ? "primary.light"
- : "transparent",
- transition:
- "box-shadow 0.15s, border-color 0.15s",
- "&.Mui-selected": {
- bgcolor: "background.paper",
- },
- "&:hover": {
- borderColor: isSel ? "primary.main" : "divider",
- },
- }}
- >
- <Stack
- direction="row"
- justifyContent="space-between"
- alignItems="flex-start"
- sx={{ mb: 1 }}
- >
- <Typography
- variant="caption"
- sx={{
- fontWeight: 800,
- color: "primary.main",
- letterSpacing: 0.5,
- }}
- >
- {date}
- {time ? ` ${time}` : ""}
- </Typography>
- {isHead && (
- <Typography
- component="span"
- variant="caption"
- sx={{
- px: 1,
- py: 0.25,
- bgcolor: "success.main",
- color: "success.contrastText",
- fontWeight: 800,
- borderRadius: 1,
- }}
- >
- {t("version_ui_snapshotBadge")}
- </Typography>
- )}
- </Stack>
- <Typography
- variant="subtitle2"
- sx={{ fontWeight: 900, mb: 0.5 }}
- >
- {t("version_ui_id", { id })}
- </Typography>
- {(() => {
- const actor = resolveVersionActor(v ?? {});
- return actor ? (
- <Typography
- variant="caption"
- color="text.secondary"
- sx={{ display: "block", mb: 0.5 }}
- >
- {t("version_ui_editedBy", {
- name: actor,
- })}
- </Typography>
- ) : null;
- })()}
- <Box
- onMouseDown={(e) => e.stopPropagation()}
- onClick={(e) => e.stopPropagation()}
- sx={{ mb: 1 }}
- >
- <TextField
- size="small"
- fullWidth
- multiline
- maxRows={3}
- placeholder={t("version_note_placeholder")}
- disabled={savingVersionNoteId === id}
- value={
- Object.prototype.hasOwnProperty.call(
- versionNoteDrafts,
- id,
- )
- ? versionNoteDrafts[id] ?? ""
- : note
- }
- onChange={(e) =>
- setVersionNoteDrafts((p) => ({
- ...p,
- [id]: e.target.value,
- }))
- }
- onBlur={(e) => {
- void saveVersionNote(
- id,
- note,
- e.target.value,
- );
- }}
- inputProps={{ maxLength: 500 }}
- helperText={
- savingVersionNoteId === id
- ? t("version_note_saving")
- : versionNoteSaveError?.id === id
- ? versionNoteSaveError.message
- : ""
- }
- FormHelperTextProps={{
- sx:
- versionNoteSaveError?.id === id
- ? { mt: 0.25, color: "error.main" }
- : { mt: 0.25 },
- }}
- onFocus={() => setVersionNoteSaveError(null)}
- sx={{
- "& .MuiInputBase-input": {
- fontSize: "0.8125rem",
- fontStyle: "italic",
- },
- }}
- />
- </Box>
- </ListItemButton>
- );
- })}
- {!loadingVersions &&
- logVersions.length > 0 &&
- filteredLogVersions.length === 0 && (
- <Typography variant="body2" color="text.secondary">
- {t("version_empty_filtered")}
- </Typography>
- )}
- {logVersions.length === 0 && (
- <Typography variant="body2" color="text.secondary">
- {t("version_empty_list")}
- </Typography>
- )}
- </List>
- )}
- </Box>
-
- <Popover
- open={Boolean(versionFilterAnchor)}
- anchorEl={versionFilterAnchor}
- onClose={() => setVersionFilterAnchor(null)}
- anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
- transformOrigin={{ vertical: "top", horizontal: "right" }}
- >
- <Box sx={{ p: 2, width: 340 }}>
- <Stack spacing={1.5}>
- <TextField
- size="small"
- label={t("version_search_label")}
- placeholder={t("version_search_placeholder")}
- value={versionFilterQuery}
- onChange={(e) => setVersionFilterQuery(e.target.value)}
- autoFocus
- />
- <TextField
- size="small"
- label={t("version_date_label")}
- type="date"
- value={versionFilterDate}
- onChange={(e) => setVersionFilterDate(e.target.value)}
- InputLabelProps={{ shrink: true }}
- fullWidth
- />
- <Stack direction="row" justifyContent="space-between">
- <Button
- size="small"
- onClick={() => {
- setVersionFilterQuery("");
- setVersionFilterDate("");
- }}
- disabled={!versionFilterActive}
- >
- {t("filter_clear")}
- </Button>
- <Button
- size="small"
- variant="contained"
- onClick={() => setVersionFilterAnchor(null)}
- >
- {t("filter_apply")}
- </Button>
- </Stack>
- </Stack>
- </Box>
- </Popover>
- </Box>
-
- {/* 右:異動詳情 */}
- <Box
- sx={{
- width: "50%",
- display: "flex",
- flexDirection: "column",
- minHeight: 0,
- bgcolor: "background.paper",
- }}
- >
- <Box
- sx={{
- flex: 1,
- overflow: "auto",
- p: 2,
- display: "flex",
- flexDirection: "column",
- }}
- >
- {diffError && (
- <Alert severity="error" sx={{ mb: 2 }}>
- {diffError}
- </Alert>
- )}
-
- {selectedLogVersionId == null && !loadingVersions && (
- <Stack
- alignItems="center"
- justifyContent="center"
- sx={{ flex: 1, color: "text.disabled", py: 6 }}
- >
- <History size={48} strokeWidth={1} opacity={0.25} />
- <Typography variant="body2" sx={{ mt: 1 }}>
- {t("diff_clickLeft")}
- </Typography>
- </Stack>
- )}
-
- {selectedLogVersionId != null && (
- <>
- {(() => {
- const idx = logVersions.findIndex(
- (v) => Number(v?.id) === selectedLogVersionId,
- );
- const hasOlder =
- idx >= 0 && idx < logVersions.length - 1;
- const sel = logVersions[idx];
- const note = sel?.note != null ? String(sel.note) : "";
- const editor = sel ? resolveVersionActor(sel) : null;
-
- return (
- <>
- {!hasOlder && !diffLoading && (
- <Alert severity="info" sx={{ mb: 2 }}>
- {t("diff_oldestSnapshot")}
- </Alert>
- )}
-
- <Paper
- variant="outlined"
- sx={{
- p: 2,
- mb: 2,
- bgcolor: (theme) =>
- alpha(theme.palette.primary.main, 0.08),
- borderColor: "primary.light",
- flexShrink: 0,
- }}
- >
- <Stack
- direction="row"
- spacing={1}
- alignItems="center"
- sx={{ mb: 1, color: "primary.dark" }}
- >
- <Info size={18} />
- <Typography
- variant="subtitle2"
- sx={{ fontWeight: 800 }}
- >
- {t("diff_summary_title")}
- </Typography>
- <Box sx={{ flex: 1 }} />
- <Tooltip
- title={
- hasUnsavedChanges
- ? t("diff_export_blockedTooltip")
- : ""
- }
- >
- <span>
- <Button
- variant="outlined"
- size="small"
- startIcon={<FileText size={16} />}
- disabled={
- saving ||
- routeExcelBusy ||
- hasUnsavedChanges ||
- selectedLogVersionId == null ||
- logVersions.length < 2
- }
- onClick={() =>
- void handleExportVersionLogReportExcel()
- }
- >
- {t("diff_export_reportBtn")}
- </Button>
- </span>
- </Tooltip>
- </Stack>
- {editor != null && (
- <Typography
- variant="caption"
- color="text.secondary"
- sx={{ display: "block", mb: 1 }}
- >
- {t("version_ui_editedBy", { name: editor })}
- </Typography>
- )}
- <Typography
- variant="body2"
- sx={{ color: "text.primary", mb: 2 }}
- >
- {note || "—"}
- </Typography>
- <Stack direction="row" spacing={1}>
- <Box
- flex={1}
- sx={{
- bgcolor: "background.paper",
- borderRadius: 1,
- p: 1,
- textAlign: "center",
- border: 1,
- borderColor: "divider",
- }}
- >
- <Typography
- variant="caption"
- color="text.secondary"
- >
- {t("diff_summary_added")}
- </Typography>
- <Typography
- variant="h6"
- sx={{
- fontWeight: 900,
- color: "success.main",
- }}
- >
- {diffLoading
- ? t("diff_loadingEllipsis")
- : versionRowSummary.added}
- </Typography>
- </Box>
- <Box
- flex={1}
- sx={{
- bgcolor: "background.paper",
- borderRadius: 1,
- p: 1,
- textAlign: "center",
- border: 1,
- borderColor: "divider",
- }}
- >
- <Typography
- variant="caption"
- color="text.secondary"
- >
- {t("diff_summary_moved")}
- </Typography>
- <Typography
- variant="h6"
- sx={{
- fontWeight: 900,
- color: "warning.main",
- }}
- >
- {diffLoading
- ? t("diff_loadingEllipsis")
- : versionRowSummary.moved}
- </Typography>
- </Box>
- <Box
- flex={1}
- sx={{
- bgcolor: "background.paper",
- borderRadius: 1,
- p: 1,
- textAlign: "center",
- border: 1,
- borderColor: "divider",
- }}
- >
- <Typography
- variant="caption"
- color="text.secondary"
- >
- {t("diff_summary_deleted")}
- </Typography>
- <Typography
- variant="h6"
- sx={{
- fontWeight: 900,
- color: "error.main",
- }}
- >
- {diffLoading
- ? t("diff_loadingEllipsis")
- : versionRowSummary.deleted}
- </Typography>
- </Box>
- <Box
- flex={1}
- sx={{
- bgcolor: "background.paper",
- borderRadius: 1,
- p: 1,
- textAlign: "center",
- border: 1,
- borderColor: "divider",
- }}
- >
- <Typography
- variant="caption"
- color="text.secondary"
- >
- {t("diff_summary_fieldChange")}
- </Typography>
- <Typography
- variant="h6"
- sx={{
- fontWeight: 900,
- color: "text.secondary",
- }}
- >
- {diffLoading
- ? t("diff_loadingEllipsis")
- : versionRowSummary.fieldChanges}
- </Typography>
- </Box>
- </Stack>
- {hasOlder && stagedLogEntriesView.length > 0 && (
- <Typography
- variant="caption"
- color="text.secondary"
- sx={{ display: "block", mt: 1 }}
- >
- {t("diff_staged_boardPendingLine", {
- count: stagedLogEntriesView.length,
- })}
- </Typography>
- )}
- </Paper>
-
- <Stack
- direction="row"
- alignItems="baseline"
- justifyContent="space-between"
- spacing={1}
- sx={{ mb: 1.5, flexShrink: 0 }}
- >
- <Typography
- variant="overline"
- sx={{
- fontWeight: 800,
- color: "text.secondary",
- }}
- >
- {t("diff_shopList_title")}
- </Typography>
- {changedShopIds.size > 0 && (
- <Typography
- variant="caption"
- color="text.secondary"
- >
- {t("diff_markedCount", {
- count: changedShopIds.size,
- })}
- </Typography>
- )}
- </Stack>
-
- <Box
- sx={{ flex: 1, minHeight: 0, overflow: "auto" }}
- >
- <Stack spacing={1.5} sx={{ pb: 2 }}>
- {stagedLogEntriesView.length > 0 && (
- <>
- <Typography
- variant="overline"
- sx={{
- fontWeight: 800,
- color: "warning.dark",
- display: "block",
- }}
- >
- {t("diff_staged_section_title")}
- </Typography>
- <Typography
- variant="caption"
- color="text.secondary"
- sx={{ display: "block", mb: 0.5 }}
- >
- {t("diff_staged_section_subtitle")}
- </Typography>
- <Stack spacing={1.25}>
- {stagedLogEntriesView.map((entry) => {
- if (entry.kind === "restore") {
- return (
- <Alert
- key={entry.key}
- severity="info"
- variant="outlined"
- sx={{ py: 0.75 }}
- >
- {t("diff_staged_restoreScheduled", {
- versionId: entry.versionId,
- })}
- </Alert>
- );
- }
- if (entry.kind === "text") {
- return (
- <Paper
- key={entry.key}
- variant="outlined"
- sx={{
- p: 1.25,
- bgcolor: "grey.50",
- borderColor: "warning.light",
- }}
- >
- <Stack
- direction="row"
- alignItems="center"
- spacing={1}
- flexWrap="wrap"
- useFlexGap
- >
- <Chip
- size="small"
- label={t("diff_staged_tag_unsaved")}
- color="warning"
- />
- <Typography variant="body2">
- {t(
- entry.titleKey as
- | "diff_staged_deleteUnknown"
- | "diff_staged_newLane"
- | "diff_staged_laneLogistic"
- | "diff_staged_emptyDistricts"
- | "diff_staged_shopPendingOnLane"
- | "diff_staged_shopDistrictOnly"
- | "diff_staged_pendingLogisticMaster"
- | "diff_staged_editLogisticMaster"
- | "diff_staged_deleteLogisticMaster"
- | "diff_staged_importPending",
- entry.titleParams as Record<
- string,
- string | number
- >,
- )}
- </Typography>
- </Stack>
- </Paper>
- );
- }
- const row = entry.row;
- const { headline, detail } =
- resolveVersionLogShopHeadline(
- row,
- shopNameByCodeMap,
- );
- return (
- <Paper
- key={entry.key}
- variant="outlined"
- sx={{
- p: 1.25,
- bgcolor: "grey.50",
- borderColor: "warning.light",
- }}
- >
- <Stack
- direction="row"
- spacing={1}
- alignItems="flex-start"
- >
- <Chip
- size="small"
- label={t("diff_staged_tag_unsaved")}
- color="warning"
- sx={{ mt: 0.25, flexShrink: 0 }}
- />
- <Box sx={{ flex: 1, minWidth: 0 }}>
- <Typography
- variant="body2"
- sx={{ fontWeight: 800 }}
- >
- {headline}
- </Typography>
- {detail != null && detail !== "" && (
- <Typography
- variant="caption"
- color="text.secondary"
- sx={{ display: "block", mt: 0.25 }}
- >
- {detail}
- </Typography>
- )}
- <Typography
- variant="caption"
- sx={{
- fontFamily: "monospace",
- display: "block",
- mt: 0.5,
- }}
- >
- {row.shopCode || "—"}
- </Typography>
- </Box>
- </Stack>
- </Paper>
- );
- })}
- </Stack>
- <Divider sx={{ my: 1.5 }} />
- </>
- )}
- {diffLoading && (
- <Box
- sx={{
- display: "flex",
- justifyContent: "center",
- py: 4,
- }}
- >
- <CircularProgress size={24} />
- </Box>
- )}
- {!diffLoading && (
- <>
- {logisticMasterDiffLines.length > 0 && (
- <>
- <Typography
- variant="overline"
- sx={{
- fontWeight: 800,
- color: "text.secondary",
- }}
- >
- {t("diff_logisticMaster_section")}
- </Typography>
- {logisticMasterDiffLines.map((lm) => (
- <Paper
- key={`lm-${lm.logisticId}`}
- variant="outlined"
- sx={{
- p: 1.25,
- bgcolor: "grey.50",
- borderColor:
- lm.type === "ADDED"
- ? "success.light"
- : "divider",
- }}
- >
- <Stack
- direction="row"
- spacing={1}
- alignItems="center"
- flexWrap="wrap"
- useFlexGap
- >
- <Chip
- size="small"
- label={
- lm.type === "ADDED"
- ? t("diff_logisticMaster_added")
- : t("diff_logisticMaster_edited")
- }
- color={
- lm.type === "ADDED"
- ? "success"
- : "default"
- }
- />
- <Typography variant="body2">
- {lm.changeText ||
- `${lm.logisticName}(${lm.carPlate})`}
- </Typography>
- </Stack>
- </Paper>
- ))}
- </>
- )}
- {hasOlder &&
- versionShopRows.length === 0 &&
- logisticMasterDiffLines.length === 0 &&
- stagedLogEntriesView.length === 0 &&
- !diffError && (
- <Alert severity="success">
- {t("diff_noDiffFromPrev")}
- </Alert>
- )}
- {hasOlder &&
- versionShopRows.length === 0 &&
- logisticMasterDiffLines.length === 0 &&
- stagedLogEntriesView.length > 0 &&
- !diffError && (
- <Alert severity="info">
- {t("diff_noShopDiffHasBoardStaged")}
- </Alert>
- )}
- {versionShopRows.map((row, ri) => {
- const { headline, detail } =
- resolveVersionLogShopHeadline(
- row,
- shopNameByCodeMap,
- );
- const laneLabelForFields =
- resolveVersionLogLaneLabel(row);
- return (
- <Paper
- key={`${row.truckRowId}-${ri}`}
- variant="outlined"
- sx={{
- p: 1.5,
- display: "flex",
- gap: 1.5,
- alignItems: "stretch",
- bgcolor: "grey.50",
- borderColor: "divider",
- }}
- >
- <Box
- sx={{
- width: 6,
- alignSelf: "stretch",
- borderRadius: 1,
- bgcolor:
- row.type === "moved"
- ? "warning.main"
- : row.type === "added"
- ? "success.main"
- : row.type === "deleted"
- ? "error.main"
- : "grey.400",
- }}
- />
- <Box sx={{ flex: 1, minWidth: 0 }}>
- <Stack
- direction="row"
- justifyContent="space-between"
- alignItems="flex-start"
- spacing={1}
- >
- <Box sx={{ minWidth: 0 }}>
- <Typography
- variant="body2"
- sx={{ fontWeight: 800 }}
- >
- {headline}
- </Typography>
- {detail != null &&
- detail !== "" && (
- <Typography
- variant="caption"
- color="text.secondary"
- sx={{
- display: "block",
- mt: 0.25,
- }}
- >
- {detail}
- </Typography>
- )}
- </Box>
- <Typography
- variant="caption"
- sx={{
- fontFamily: "monospace",
- bgcolor: "background.paper",
- px: 0.5,
- borderRadius: 0.5,
- flexShrink: 0,
- }}
- >
- {row.shopCode || "—"}
- </Typography>
- </Stack>
- <Stack
- direction="row"
- alignItems="center"
- spacing={0.5}
- sx={{ mt: 0.75 }}
- flexWrap="wrap"
- >
- {row.type === "moved" && (
- <>
- <Typography
- variant="caption"
- sx={{
- px: 0.75,
- py: 0.25,
- bgcolor: "grey.200",
- borderRadius: 1,
- }}
- >
- {t("diff_moveFrom", {
- lane: row.fromLane ?? t("emDash"),
- })}
- </Typography>
- <ArrowRight size={14} />
- <Typography
- variant="caption"
- sx={{
- px: 0.75,
- py: 0.25,
- bgcolor: "primary.light",
- color: "primary.dark",
- fontWeight: 800,
- }}
- >
- {t("diff_moveTo", {
- lane: row.toLane ?? t("emDash"),
- })}
- </Typography>
- </>
- )}
- {row.type === "added" && (
- <Typography
- variant="caption"
- sx={{
- px: 0.75,
- py: 0.25,
- bgcolor: "success.light",
- color: "success.dark",
- fontWeight: 800,
- }}
- >
- {t("diff_addedToLane", {
- lane: row.toLane ?? t("emDash"),
- })}
- </Typography>
- )}
- {row.type === "deleted" && (
- <Typography
- variant="caption"
- sx={{
- px: 0.75,
- py: 0.25,
- bgcolor: "error.light",
- color: "error.dark",
- fontWeight: 800,
- }}
- >
- {t("diff_removedFromLane", {
- lane: row.fromLane ?? t("emDash"),
- })}
- </Typography>
- )}
- {row.fieldEdits != null &&
- row.fieldEdits.length > 0 && (
- <Box
- sx={{
- width: "100%",
- flexBasis: "100%",
- mt: 0.5,
- }}
- >
- <Stack spacing={0.35}>
- {row.fieldEdits.map(
- (fe, fei) => {
- const isLogistic =
- fe.label ===
- "versionLogField_logisticId";
- const resolveLogisticDisplay =
- (raw: string) => {
- const s = String(
- raw ?? "",
- ).trim();
- if (
- s === "" ||
- s === "—" ||
- s === "未分配" ||
- s === "未分配物流商"
- )
- return t(
- "diffLogistic_unassigned",
- );
- const n = Number(s);
- if (
- Number.isFinite(n)
- ) {
- return (
- logisticNameById.get(
- n,
- ) ?? s
- );
- }
- return s;
- };
- const from = isLogistic
- ? resolveLogisticDisplay(
- fe.from,
- )
- : fe.from;
- const to = isLogistic
- ? resolveLogisticDisplay(
- fe.to,
- )
- : fe.to;
- const isLoadingSeq =
- fe.label ===
- VERSION_LOG_LOADING_SEQUENCE_LABEL ||
- fe.label ===
- "loadingSequence";
- const showLaneOnSeq =
- isLoadingSeq &&
- laneLabelForFields !=
- null &&
- laneLabelForFields !==
- "";
-
- return (
- <Typography
- key={`${fe.label}-${fei}`}
- variant="caption"
- color="text.secondary"
- sx={{
- display: "block",
- }}
- >
- <Box
- component="span"
- sx={{
- fontWeight: 700,
- color:
- "text.primary",
- }}
- >
- {formatDiffFieldLabel(
- fe.label,
- t,
- )}
- </Box>
- {showLaneOnSeq && (
- <Box
- component="span"
- sx={{
- fontWeight: 600,
- color:
- "text.secondary",
- }}
- >
- {" "}
- (
- {t(
- "diff_onLane",
- {
- lane: laneLabelForFields,
- },
- )}
- )
- </Box>
- )}
- {":"}
- {from} → {to}
- </Typography>
- );
- },
- )}
- </Stack>
- </Box>
- )}
- {row.type === "edited" &&
- (!row.fieldEdits ||
- row.fieldEdits.length ===
- 0) && (
- <Typography
- variant="caption"
- color="text.secondary"
- >
- {t("diff_editedCaption")}
- </Typography>
- )}
- </Stack>
- </Box>
- </Paper>
- );
- })}
- </>
- )}
- </Stack>
- </Box>
-
- <Box sx={{ pt: 1, flexShrink: 0 }}>
- <Button
- fullWidth
- variant={
- headVersionId != null &&
- selectedLogVersionId === headVersionId
- ? "outlined"
- : "contained"
- }
- color="primary"
- disabled={
- saving || selectedLogVersionId == null
- }
- startIcon={<RotateCcw size={18} />}
- sx={{ py: 1.5, fontWeight: 800 }}
- onClick={() =>
- selectedLogVersionId != null &&
- restoreVersion(selectedLogVersionId)
- }
- >
- {headVersionId != null &&
- selectedLogVersionId === headVersionId
- ? t("diff_restoreToHead")
- : t("diff_restoreToSelected")}
- </Button>
- </Box>
- </>
- );
- })()}
- </>
- )}
- </Box>
- </Box>
- </Stack>
- </DialogContent>
- <DialogActions sx={{ px: 2, py: 1 }}>
- <Button onClick={closeLogDialog} disabled={saving}>
- {t("dialog_close")}
- </Button>
- </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>
-
- <Dialog
- open={districtEditOpen}
- onClose={closeDistrictEdit}
- maxWidth="xs"
- fullWidth
- >
- <DialogTitle>
- {districtEditCtx?.mode === "add"
- ? t("district_dialog_add")
- : t("district_dialog_edit")}
- </DialogTitle>
- <DialogContent dividers>
- <TextField
- autoFocus
- margin="dense"
- label={t("district_name_label")}
- fullWidth
- value={districtEditDraft}
- onChange={(e) => {
- setDistrictEditDraft(e.target.value);
- setDistrictEditError(null);
- }}
- error={Boolean(districtEditError)}
- InputLabelProps={{ shrink: true }}
- />
- </DialogContent>
- <DialogActions>
- <Button onClick={closeDistrictEdit}>{t("cancel")}</Button>
- <Button variant="contained" onClick={() => applyDistrictEdit()}>
- {t("btn_apply")}
- </Button>
- </DialogActions>
- </Dialog>
-
- <Dialog
- open={departureEditLaneId != null}
- onClose={closeDepartureEdit}
- maxWidth="xs"
- fullWidth
- >
- <DialogTitle>{t("departureDialog_title")}</DialogTitle>
- <DialogContent>
- <TextField
- margin="dense"
- fullWidth
- type="time"
- label={t("seq_edit_departureLabel")}
- value={departureEditDraft}
- onChange={(e) => setDepartureEditDraft(e.target.value)}
- 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={closeDepartureEdit}>{t("cancel")}</Button>
- <Button variant="contained" onClick={applyDepartureEdit}>
- {t("filter_apply")}
- </Button>
- </DialogActions>
- </Dialog>
-
- <Dialog
- open={seqEditTarget != null}
- onClose={closeSeqEdit}
- maxWidth="xs"
- fullWidth
- >
- <DialogTitle>{t("seqDialog_title")}</DialogTitle>
- <DialogContent>
- <TextField
- margin="dense"
- fullWidth
- type="number"
- label={t("seq_edit_seqLabel")}
- value={seqEditDraft}
- onChange={(e) => setSeqEditDraft(e.target.value)}
- inputProps={{ step: 1 }}
- sx={{ mt: 1 }}
- />
- </DialogContent>
- <DialogActions>
- <Button onClick={closeSeqEdit}>{t("cancel")}</Button>
- <Button variant="contained" onClick={applySeqEdit}>
- {t("filter_apply")}
- </Button>
- </DialogActions>
- </Dialog>
-
- <Dialog
- open={addRouteDialogOpen}
- onClose={() => {
- if (!addRouteSubmitting) closeAddRouteDialog();
- }}
- maxWidth="sm"
- fullWidth
- PaperProps={{ sx: { borderRadius: 3 } }}
- >
- <DialogTitle
- sx={{
- display: "flex",
- alignItems: "center",
- justifyContent: "space-between",
- pr: 1,
- py: 2,
- borderBottom: 1,
- borderColor: "divider",
- bgcolor: "grey.50",
- }}
- >
- <Stack direction="row" spacing={1.5} alignItems="center">
- <Box
- sx={{
- bgcolor: "primary.main",
- p: 1,
- borderRadius: 1.5,
- display: "inline-flex",
- }}
- >
- <Plus size={20} color="white" />
- </Box>
- <Typography variant="h6" sx={{ fontWeight: 800 }}>
- {t("addRoute_dialogTitle")}
- </Typography>
- </Stack>
- <IconButton
- onClick={closeAddRouteDialog}
- size="small"
- disabled={addRouteSubmitting}
- aria-label={t("drawerClose")}
- >
- <X size={20} />
- </IconButton>
- </DialogTitle>
- <DialogContent dividers>
- {addRouteError && (
- <Alert severity="error" sx={{ mb: 2 }}>
- {addRouteError}
- </Alert>
- )}
- <Box
- sx={{
- display: "grid",
- gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
- gap: 2,
- alignItems: "start",
- // small Outlined 預設約 40px,整體略加高 ≈5px
- "& .MuiOutlinedInput-root": { minHeight: 45 },
- }}
- >
- <TextField
- size="small"
- fullWidth
- required
- label={t("route_new_code_label")}
- value={newRouteForm.truckLanceCode}
- onChange={(e) =>
- setNewRouteForm((p) => ({
- ...p,
- truckLanceCode: e.target.value,
- }))
- }
- sx={{
- "& .MuiInputBase-input": {
- fontWeight: 800,
- color: "primary.main",
- },
- }}
- InputProps={{
- startAdornment: (
- <InputAdornment position="start">
- <TruckIcon size={18} />
- </InputAdornment>
- ),
- }}
- />
- <TextField
- size="small"
- fullWidth
- required
- label={t("route_new_time_label")}
- type="time"
- value={newRouteForm.startTime}
- onChange={(e) =>
- setNewRouteForm((p) => ({ ...p, startTime: e.target.value }))
- }
- InputLabelProps={{ shrink: true }}
- InputProps={{
- startAdornment: (
- <InputAdornment position="start">
- <Clock size={18} />
- </InputAdornment>
- ),
- }}
- />
- <FormControl
- size="small"
- fullWidth
- sx={{ gridColumn: { xs: "1", sm: "1 / -1" } }}
- >
- <InputLabel id="new-route-logistic-label">{t("route_new_logistic_label")}</InputLabel>
- <Select
- labelId="new-route-logistic-label"
- label={t("route_new_logistic_label")}
- value={
- newRouteForm.logisticId == null
- ? ""
- : String(newRouteForm.logisticId)
- }
- onChange={(e) => {
- const v = e.target.value;
- setNewRouteForm((p) => ({
- ...p,
- logisticId:
- v === "" ? null : Number.parseInt(String(v), 10),
- }));
- }}
- >
- <MenuItem value="">{t("route_logisticUnspecified")}</MenuItem>
- {logisticRowsSortedForSelect.map((row) => (
- <MenuItem key={row.id} value={String(row.id)}>
- {row.logisticName}
- </MenuItem>
- ))}
- </Select>
- </FormControl>
- <FormControl
- size="small"
- fullWidth
- sx={{ gridColumn: { xs: "1", sm: "1 / -1" } }}
- >
- <InputLabel>{t("route_new_store_label")}</InputLabel>
- <Select
- label={t("route_new_store_label")}
- value={newRouteForm.storeId}
- onChange={(e) =>
- setNewRouteForm((p) => ({
- ...p,
- storeId: e.target.value as "2F" | "4F",
- }))
- }
- >
- <MenuItem value="2F">2F</MenuItem>
- <MenuItem value="4F">4F</MenuItem>
- </Select>
- </FormControl>
- {newRouteForm.storeId === "4F" && (
- <TextField
- size="small"
- fullWidth
- sx={{ gridColumn: { xs: "1", sm: "1 / -1" } }}
- label={t("route_new_remark_label")}
- value={newRouteForm.remark}
- onChange={(e) =>
- setNewRouteForm((p) => ({ ...p, remark: e.target.value }))
- }
- />
- )}
- </Box>
- </DialogContent>
- <DialogActions
- sx={{
- px: 3,
- py: 2,
- bgcolor: "grey.50",
- borderTop: 1,
- borderColor: "divider",
- gap: 1.5,
- justifyContent: "flex-end",
- flexWrap: "nowrap",
- }}
- >
- <Button
- variant="outlined"
- onClick={closeAddRouteDialog}
- disabled={addRouteSubmitting}
- >
- {t("btn_cancelBack")}
- </Button>
- <Button
- variant="contained"
- disabled={
- addRouteSubmitting ||
- !String(newRouteForm.truckLanceCode || "").trim() ||
- !String(newRouteForm.startTime || "").trim()
- }
- onClick={() => void submitAddRoute()}
- >
- {addRouteSubmitting
- ? t("addRoute_submitting")
- : t("addRoute_confirm")}
- </Button>
- </DialogActions>
- </Dialog>
-
- <Dialog
- open={addLogisticOpen}
- onClose={() => {
- if (!addLogisticSubmitting) setAddLogisticOpen(false);
- }}
- maxWidth="sm"
- fullWidth
- PaperProps={{ sx: { borderRadius: 3 } }}
- >
- <DialogTitle sx={{ fontWeight: 800 }}>{t("dialog_addLogisticsTitle")}</DialogTitle>
- <DialogContent dividers>
- {addLogisticError && (
- <Alert severity="error" sx={{ mb: 2 }}>
- {addLogisticError}
- </Alert>
- )}
- <Stack spacing={2} sx={{ pt: 0.5 }}>
- <TextField
- size="small"
- fullWidth
- required
- label={t("logistic_companyName")}
- value={addLogisticForm.logisticName}
- onChange={(e) =>
- setAddLogisticForm((p) => ({
- ...p,
- logisticName: e.target.value,
- }))
- }
- InputProps={{
- startAdornment: (
- <InputAdornment position="start">
- <Building2 size={18} />
- </InputAdornment>
- ),
- }}
- />
- <TextField
- size="small"
- fullWidth
- required
- label={t("logistic_plate")}
- value={addLogisticForm.carPlate}
- onChange={(e) =>
- setAddLogisticForm((p) => ({
- ...p,
- carPlate: e.target.value,
- }))
- }
- InputProps={{
- startAdornment: (
- <InputAdornment position="start">
- <CreditCard size={18} />
- </InputAdornment>
- ),
- }}
- />
- <TextField
- size="small"
- fullWidth
- required
- label={t("logistic_driver")}
- value={addLogisticForm.driverName}
- onChange={(e) =>
- setAddLogisticForm((p) => ({
- ...p,
- driverName: e.target.value,
- }))
- }
- InputProps={{
- startAdornment: (
- <InputAdornment position="start">
- <Users size={18} />
- </InputAdornment>
- ),
- }}
- />
- <TextField
- size="small"
- fullWidth
- required
- label={t("logistic_phone")}
- value={addLogisticForm.driverPhone}
- onChange={(e) =>
- setAddLogisticForm((p) => ({
- ...p,
- driverPhone: e.target.value,
- }))
- }
- InputProps={{
- startAdornment: (
- <InputAdornment position="start">
- <Phone size={18} />
- </InputAdornment>
- ),
- }}
- />
- </Stack>
- </DialogContent>
- <DialogActions sx={{ px: 3, py: 2 }}>
- <Button
- onClick={() => setAddLogisticOpen(false)}
- disabled={addLogisticSubmitting}
- >
- {t("cancel")}
- </Button>
- <Button
- variant="contained"
- disabled={addLogisticSubmitting}
- onClick={() => void submitAddLogistic()}
- >
- {addLogisticSubmitting
- ? t("Submitting...")
- : t("logistic_btn_save")}
- </Button>
- </DialogActions>
- </Dialog>
-
- <Dialog
- open={editLogisticOpen}
- onClose={() => {
- if (!editLogisticSubmitting) {
- setEditLogisticOpen(false);
- setEditLogisticError(null);
- }
- }}
- maxWidth="sm"
- fullWidth
- PaperProps={{ sx: { borderRadius: 3 } }}
- >
- <DialogTitle sx={{ fontWeight: 800 }}>{t("dialog_editLogisticsTitle")}</DialogTitle>
- <DialogContent dividers>
- {editLogisticError && (
- <Alert severity="error" sx={{ mb: 2 }}>
- {editLogisticError}
- </Alert>
- )}
- <Stack spacing={2} sx={{ pt: 0.5 }}>
- <TextField
- size="small"
- fullWidth
- required
- label={t("logistic_companyName")}
- value={editLogisticForm.logisticName}
- onChange={(e) =>
- setEditLogisticForm((p) => ({
- ...p,
- logisticName: e.target.value,
- }))
- }
- InputProps={{
- startAdornment: (
- <InputAdornment position="start">
- <Building2 size={18} />
- </InputAdornment>
- ),
- }}
- />
- <TextField
- size="small"
- fullWidth
- required
- label={t("logistic_plate")}
- value={editLogisticForm.carPlate}
- onChange={(e) =>
- setEditLogisticForm((p) => ({
- ...p,
- carPlate: e.target.value,
- }))
- }
- InputProps={{
- startAdornment: (
- <InputAdornment position="start">
- <CreditCard size={18} />
- </InputAdornment>
- ),
- }}
- />
- <TextField
- size="small"
- fullWidth
- required
- label={t("logistic_driver")}
- value={editLogisticForm.driverName}
- onChange={(e) =>
- setEditLogisticForm((p) => ({
- ...p,
- driverName: e.target.value,
- }))
- }
- InputProps={{
- startAdornment: (
- <InputAdornment position="start">
- <Users size={18} />
- </InputAdornment>
- ),
- }}
- />
- <TextField
- size="small"
- fullWidth
- required
- label={t("logistic_phone")}
- value={editLogisticForm.driverPhone}
- onChange={(e) =>
- setEditLogisticForm((p) => ({
- ...p,
- driverPhone: e.target.value,
- }))
- }
- InputProps={{
- startAdornment: (
- <InputAdornment position="start">
- <Phone size={18} />
- </InputAdornment>
- ),
- }}
- />
- </Stack>
- </DialogContent>
- <DialogActions sx={{ px: 3, py: 2 }}>
- <Button
- onClick={() => {
- setEditLogisticOpen(false);
- setEditLogisticError(null);
- }}
- disabled={editLogisticSubmitting}
- >
- {t("cancel")}
- </Button>
- <Button
- variant="contained"
- disabled={editLogisticSubmitting}
- onClick={() => void submitEditLogistic()}
- >
- {editLogisticSubmitting
- ? t("Submitting...")
- : t("logistic_btn_apply")}
- </Button>
- </DialogActions>
- </Dialog>
-
- {/* Sidebar */}
- <Box
- sx={{
- width: 320,
- flexShrink: 0,
- minHeight: 0,
- bgcolor: "background.paper",
- borderRight: "1px solid",
- borderColor: "divider",
- p: 2,
- overflow: "auto",
- }}
- >
- <Stack
- direction="row"
- spacing={0.5}
- sx={{
- bgcolor: "grey.100",
- p: 0.5,
- borderRadius: 2,
- mb: 2,
- }}
- >
- <Button
- fullWidth
- size="small"
- variant={routeBoardTab === "board" ? "contained" : "text"}
- color={routeBoardTab === "board" ? "primary" : "inherit"}
- onClick={() => setRouteBoardTab("board")}
- startIcon={<LayoutDashboard size={14} />}
- sx={{
- py: 1,
- fontSize: "0.75rem",
- fontWeight: 800,
- textTransform: "none",
- }}
- >
- {t("tabBoard")}
- </Button>
- <Button
- fullWidth
- size="small"
- variant={routeBoardTab === "logistics" ? "contained" : "text"}
- color={routeBoardTab === "logistics" ? "primary" : "inherit"}
- onClick={() => setRouteBoardTab("logistics")}
- startIcon={<Building2 size={14} />}
- sx={{
- py: 1,
- fontSize: "0.75rem",
- fontWeight: 800,
- textTransform: "none",
- }}
- >
- {t("tabLogistics")}
- </Button>
- </Stack>
-
- {(() => {
- const laneIds = visibleLaneOptions.map((l) => l.id);
- const total = laneIds.length;
- const selectedVisible = laneIds.filter((id) =>
- selectedLaneIds.includes(id),
- ).length;
- const filterActive =
- laneFilter.floor !== "all" ||
- String(laneFilter.query || "").trim() !== "";
- return (
- <Box sx={{ mb: routeBoardTab === "logistics" ? 2 : 0 }}>
- <Typography
- variant="overline"
- sx={{ fontWeight: 800, color: "text.secondary" }}
- >
- {t("lane_selectTitle")}
- </Typography>
-
- <Select
- multiple
- size="small"
- value={selectedLaneIds}
- displayEmpty
- fullWidth
- renderValue={(selected) => {
- const arr = selected as string[];
- if (arr.length === 0) {
- return (
- <Box component="span" sx={{ color: "text.secondary" }}>
- {t("lane_selectedNone")}
- </Box>
- );
- }
- if (filterActive)
- return t("lane_selectedCount", {
- count: selectedVisible,
- });
- return t("lane_selectedCount", { count: arr.length });
- }}
- onChange={(e) => {
- const value = e.target.value as unknown as string[];
- setSelectedLaneIds(value);
- }}
- MenuProps={{
- autoFocus: false,
- MenuListProps: { autoFocus: false, dense: false },
- PaperProps: {
- sx: {
- maxHeight: 420,
- minWidth: 360,
- maxWidth: "min(100vw - 32px, 480px)",
- },
- },
- }}
- sx={{ mt: 1 }}
- >
- <ListSubheader
- sx={{
- px: 1.5,
- py: 1,
- lineHeight: 1.2,
- position: "sticky",
- top: 0,
- zIndex: 2,
- bgcolor: "background.paper",
- borderBottom: 1,
- borderColor: "divider",
- }}
- onClick={(e) => e.stopPropagation()}
- >
- <Stack direction="row" spacing={0.5} alignItems="center">
- <TextField
- size="small"
- fullWidth
- placeholder={t("lane_searchPh")}
- value={laneFilter.query}
- onChange={(e) =>
- setLaneFilter((prev) => ({
- ...prev,
- query: e.target.value,
- }))
- }
- onKeyDown={(e) => e.stopPropagation()}
- onClick={(e) => e.stopPropagation()}
- onMouseDown={(e) => e.stopPropagation()}
- InputProps={{
- startAdornment: (
- <Box
- sx={{
- mr: 0.5,
- display: "inline-flex",
- color: "text.disabled",
- }}
- >
- <Search size={16} />
- </Box>
- ),
- }}
- sx={{
- flex: 1,
- minWidth: 0,
- "& .MuiOutlinedInput-root": {
- borderRadius: 2,
- bgcolor: "grey.50",
- },
- "& .MuiInputBase-input": {
- textAlign: "left",
- fontSize: "0.8125rem",
- color: "text.secondary",
- py: 0.75,
- },
- "& .MuiInputBase-input::placeholder": {
- opacity: 1,
- color: "text.disabled",
- textAlign: "left",
- },
- }}
- />
- <Tooltip title={t("floor_label")}>
- <span>
- <IconButton
- size="small"
- aria-label={t("floor_label")}
- disabled={(lanes || []).length === 0}
- color={filterActive ? "primary" : "default"}
- onMouseDown={(e) => e.stopPropagation()}
- onClick={(e) => {
- e.stopPropagation();
- setLaneFilterAnchor(e.currentTarget);
- }}
- sx={{
- flexShrink: 0,
- width: 32,
- height: 32,
- }}
- >
- <FilterListIcon fontSize="small" />
- </IconButton>
- </span>
- </Tooltip>
- <Button
- size="small"
- variant="text"
- disabled={total === 0}
- onMouseDown={(e) => e.stopPropagation()}
- onClick={(e) => {
- e.stopPropagation();
- setSelectedLaneIds((prev) => {
- const set = new Set(prev);
- if (laneIds.every((id) => set.has(id))) {
- laneIds.forEach((id) => set.delete(id));
- } else {
- laneIds.forEach((id) => set.add(id));
- }
- return Array.from(set);
- });
- }}
- sx={{
- flexShrink: 0,
- minWidth: "auto",
- px: 1,
- py: 0.25,
- }}
- >
- {t("lane_selectAll")}
- </Button>
- </Stack>
- </ListSubheader>
- {visibleLaneOptions.map((lane) => {
- const checked = selectedLaneIds.includes(lane.id);
- const rem =
- lane.remark != null &&
- String(lane.remark).trim() !== ""
- ? String(lane.remark).trim()
- : null;
- const driverPlate =
- (lane.driver || "—") +
- (lane.plate ? ` · ${lane.plate}` : "");
- const secondaryLine = rem
- ? `${rem} · ${driverPlate}`
- : driverPlate;
- return (
- <MenuItem
- key={lane.id}
- value={lane.id}
- sx={{
- alignItems: "flex-start",
- py: 1,
- gap: 1,
- }}
- >
- <Checkbox
- checked={checked}
- size="small"
- sx={{ p: 0.5, mt: 0.15 }}
- />
- <ListItemText
- primary={lane.truckLanceCode}
- secondary={secondaryLine}
- primaryTypographyProps={{
- sx: {
- fontWeight: 800,
- fontSize: "0.9rem",
- wordBreak: "break-word",
- overflowWrap: "anywhere",
- },
- }}
- secondaryTypographyProps={{
- sx: {
- fontSize: "0.72rem",
- color: "text.secondary",
- mt: 0.25,
- },
- }}
- />
- </MenuItem>
- );
- })}
- </Select>
-
- <Popover
- open={Boolean(laneFilterAnchor)}
- anchorEl={laneFilterAnchor}
- onClose={() => setLaneFilterAnchor(null)}
- anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
- transformOrigin={{ vertical: "top", horizontal: "left" }}
- >
- <Box sx={{ p: 2, width: 260 }}>
- <Stack spacing={1.5}>
- <FormControl size="small" fullWidth>
- <InputLabel>{t("floor_label")}</InputLabel>
- <Select
- label={t("floor_label")}
- value={laneFilter.floor}
- onChange={(e) =>
- setLaneFilter((prev) => ({
- ...prev,
- floor: e.target.value as any,
- }))
- }
- >
- <MenuItem value="all">{t("floor_all")}</MenuItem>
- <MenuItem value="2F">2F</MenuItem>
- <MenuItem value="4F">4F</MenuItem>
- </Select>
- </FormControl>
- <Stack
- direction="row"
- spacing={1}
- justifyContent="flex-end"
- >
- <Button
- size="small"
- onClick={() =>
- setLaneFilter((prev) => ({
- ...prev,
- floor: "all",
- }))
- }
- >
- {t("filter_clear")}
- </Button>
- <Button
- size="small"
- variant="contained"
- onClick={() => setLaneFilterAnchor(null)}
- >
- {t("filter_apply")}
- </Button>
- </Stack>
- </Stack>
- </Box>
- </Popover>
-
- {routeBoardTab === "board" && (
- <Button
- variant="outlined"
- size="small"
- fullWidth
- startIcon={<Plus size={16} />}
- disabled={loading || addRouteSubmitting}
- sx={{ mt: 1 }}
- onClick={openAddRouteDialog}
- >
- {t("btn_addLane")}
- </Button>
- )}
- </Box>
- );
- })()}
-
- {routeBoardTab === "logistics" && (
- <Box sx={{ mb: 2 }}>
- <Typography
- variant="overline"
- sx={{ fontWeight: 800, color: "text.secondary" }}
- >
- {t("quickIndex")}
- </Typography>
- <Button
- fullWidth
- size="small"
- variant="outlined"
- color="primary"
- startIcon={<Plus size={16} />}
- sx={{ mt: 1, fontWeight: 800, textTransform: "none" }}
- onClick={() => {
- setAddLogisticError(null);
- setAddLogisticOpen(true);
- }}
- >
- {t("btn_addLogistics")}
- </Button>
- <Stack spacing={1} sx={{ mt: 1 }}>
- {lanesByLogisticsCompany.map(([company, list]) => {
- const logisticMaster = resolveLogisticMasterRow(
- company,
- list,
- logisticRowsEffective,
- );
- const canManage =
- company !== "未分配物流商" && logisticMaster != null;
- const masterId =
- logisticMaster != null
- ? Number(logisticMaster.id)
- : null;
- const isPendingDelete =
- masterId != null &&
- Number.isFinite(masterId) &&
- masterId > 0 &&
- pendingLogisticMasterDeletes.has(masterId);
- const canDelete =
- canManage && list.length === 0 && !isPendingDelete;
-
- return (
- <Box
- key={
- logisticMaster
- ? `log-${logisticMaster.id}`
- : `co-${company}`
- }
- sx={{
- p: 1,
- borderRadius: 1,
- bgcolor: "grey.50",
- border: "1px solid",
- borderColor: isPendingDelete
- ? "warning.light"
- : "divider",
- opacity: isPendingDelete ? 0.65 : 1,
- }}
- >
- <Stack
- direction="row"
- alignItems="center"
- spacing={0.75}
- sx={{ minHeight: 28 }}
- >
- <Typography
- variant="caption"
- sx={{
- fontWeight: 700,
- flex: 1,
- minWidth: 0,
- overflow: "hidden",
- textOverflow: "ellipsis",
- whiteSpace: "nowrap",
- lineHeight: 1.25,
- }}
- >
- {company}
- </Typography>
- {canManage && (
- <Stack
- direction="row"
- alignItems="center"
- spacing={0}
- sx={{ flexShrink: 0 }}
- >
- <Tooltip title={t("tooltip_editLogisticsDb")}>
- <IconButton
- size="small"
- aria-label={t("aria_editLogistics")}
- onClick={() =>
- openEditLogistic(logisticMaster)
- }
- sx={{
- width: 28,
- height: 28,
- p: 0,
- }}
- >
- <Pencil size={14} />
- </IconButton>
- </Tooltip>
- <Tooltip
- title={
- list.length > 0
- ? t("err_logisticDeleteHasLanes", {
- count: list.length,
- })
- : t("tooltip_deleteLogistics")
- }
- >
- <Box
- component="span"
- sx={{
- display: "inline-flex",
- alignItems: "center",
- }}
- >
- <IconButton
- size="small"
- aria-label={t("aria_deleteLogistics")}
- disabled={!canDelete}
- onClick={() =>
- stageDeleteLogistic(
- logisticMaster,
- company,
- )
- }
- sx={{
- width: 28,
- height: 28,
- p: 0,
- }}
- >
- <Trash2 size={14} />
- </IconButton>
- </Box>
- </Tooltip>
- </Stack>
- )}
- <Chip
- label={t("lane_companyChip", { count: list.length })}
- size="small"
- sx={{
- fontWeight: 800,
- height: 22,
- fontSize: "0.65rem",
- flexShrink: 0,
- "& .MuiChip-label": {
- px: 0.75,
- lineHeight: 1.2,
- },
- }}
- />
- </Stack>
- {isPendingDelete && (
- <Typography
- variant="caption"
- color="warning.dark"
- sx={{ display: "block", mt: 0.5, fontWeight: 700 }}
- >
- {t("diff_staged_tag_unsaved")} ·{" "}
- {t("diff_staged_deleteLogisticMaster", {
- name: company,
- })}
- </Typography>
- )}
- </Box>
- );
- })}
- {lanesByLogisticsCompany.length === 0 && (
- <Typography variant="caption" color="text.secondary">
- {t("logistics_sidebarEmpty")}
- </Typography>
- )}
- </Stack>
- </Box>
- )}
-
- {routeBoardTab !== "logistics" && (
- <>
- <Typography
- variant="overline"
- sx={{
- fontWeight: 800,
- color: "text.secondary",
- mt: 3,
- display: "block",
- }}
- >
- {t("tools_title")}
- </Typography>
-
- <Stack spacing={1} sx={{ mt: 1 }}>
- <TextField
- size="small"
- placeholder={t("shop_searchPh")}
- value={searchTerm}
- onChange={(e) => setSearchTerm(e.target.value)}
- InputProps={{
- startAdornment: (
- <Box
- sx={{
- mr: 1,
- display: "inline-flex",
- alignItems: "center",
- color: "text.secondary",
- }}
- >
- <Search size={16} />
- </Box>
- ),
- sx: {
- alignItems: "center",
- },
- }}
- sx={{
- "& .MuiInputBase-root": {
- alignItems: "center",
- },
- "& .MuiInputBase-input": {
- textAlign: "left",
- py: 0.75,
- },
- "& .MuiInputBase-input::placeholder": {
- opacity: 1,
- color: "text.disabled",
- textAlign: "left",
- },
- }}
- />
- <Button
- variant="outlined"
- size="small"
- startIcon={<History size={16} />}
- onClick={openLogDialog}
- disabled={loading || lanes.length === 0}
- >
- {t("btn_openVersionLog")}
- </Button>
-
- <Button
- variant="outlined"
- size="small"
- onClick={loadLanes}
- disabled={loading}
- >
- {loading ? t("btn_loading") : t("btn_refresh")}
- </Button>
- </Stack>
- </>
- )}
- </Box>
-
- {/* Board */}
- <Box
- sx={{
- flex: 1,
- minWidth: 0,
- minHeight: 0,
- p: 2,
- overflow: "auto",
- bgcolor: "grey.100",
- }}
- >
- {error && (
- <Alert severity="error" sx={{ mb: 2 }}>
- {error}
- </Alert>
- )}
- {loading ? (
- <Box sx={{ display: "flex", justifyContent: "center", py: 6 }}>
- <CircularProgress />
- </Box>
- ) : routeBoardTab === "logistics" ? (
- <Box sx={{ width: "100%", maxWidth: 1400, mx: "auto" }}>
- <Typography variant="h5" sx={{ fontWeight: 900, mb: 3 }}>
- {t("logistics_overviewTitle")}
- </Typography>
-
- <Box
- sx={{
- display: "grid",
- gridTemplateColumns: {
- xs: "1fr",
- md: "repeat(2, 1fr)",
- xl: "repeat(3, 1fr)",
- },
- gap: 3,
- }}
- >
- {lanesByLogisticsCompany.map(([company, companyLanes]) => {
- const colStats = summarizeLogisticsColumnStats(companyLanes);
- const columnHasDirtyLogistics = companyLanes.some((lane) =>
- dirtyLaneLogisticIds.has(lane.id),
- );
- const logisticMaster = resolveLogisticMasterRow(
- company,
- companyLanes,
- logisticRowsEffective,
- );
- return (
- <Card
- key={
- logisticMaster
- ? `lid:${logisticMaster.id}`
- : `co:${company}`
- }
- variant="outlined"
- onDragOver={(e) => {
- e.preventDefault();
- e.dataTransfer.dropEffect = "move";
- if (logisticsLaneDragIdRef.current) {
- setLogisticsDropHoverCompany(company);
- }
- }}
- onDrop={(e) => {
- e.preventDefault();
- handleLogisticsDropOnCompany(company, companyLanes);
- }}
- sx={{
- borderRadius: 3,
- overflow: "hidden",
- display: "flex",
- flexDirection: "column",
- transition: "border-color 0.15s, outline-color 0.15s",
- borderColor: columnHasDirtyLogistics
- ? "warning.main"
- : "divider",
- outline:
- logisticsDropHoverCompany === company
- ? "2px solid"
- : "1px solid transparent",
- outlineColor:
- logisticsDropHoverCompany === company
- ? "primary.main"
- : "transparent",
- }}
- >
- <Box
- sx={{
- p: 2,
- borderBottom: 1,
- borderColor: "divider",
- bgcolor: columnHasDirtyLogistics
- ? "warning.50"
- : "grey.50",
- display: "flex",
- alignItems: "flex-start",
- justifyContent: "space-between",
- gap: 1,
- }}
- >
- <Stack
- direction="row"
- spacing={1.5}
- alignItems="flex-start"
- sx={{ minWidth: 0, flex: 1 }}
- >
- <Box
- sx={{
- p: 1,
- borderRadius: 1,
- bgcolor: "primary.50",
- color: "primary.main",
- display: "flex",
- flexShrink: 0,
- }}
- >
- <Building2 size={20} />
- </Box>
- <Box sx={{ minWidth: 0 }}>
- <Stack
- direction="row"
- alignItems="center"
- spacing={0.75}
- flexWrap="wrap"
- useFlexGap
- >
- <Typography
- sx={{
- fontWeight: 900,
- fontSize: "1.1rem",
- flex: 1,
- minWidth: 0,
- lineHeight: 1.25,
- }}
- >
- {company}
- </Typography>
- {columnHasDirtyLogistics && (
- <Chip
- size="small"
- color="warning"
- label={t("logistics_dirtyColumnBadge")}
- sx={{ height: 22, fontWeight: 800 }}
- />
- )}
- </Stack>
- {logisticMaster && (
- <Stack
- spacing={0.5}
- sx={{ mt: 0.75, alignSelf: "stretch", color: "text.secondary" }}
- >
- <Stack
- direction="row"
- spacing={0.75}
- alignItems="center"
- sx={{ alignSelf: "stretch" }}
- >
- <Box
- sx={{
- display: "flex",
- alignItems: "center",
- flexShrink: 0,
- color: "inherit",
- }}
- >
- <Users size={13} />
- </Box>
- <Typography
- variant="body2"
- sx={{
- fontWeight: 500,
- fontSize: "0.8125rem",
- lineHeight: 1.35,
- color: "inherit",
- }}
- >
- {String(
- logisticMaster.driverName ?? "",
- ).trim() || "—"}
- </Typography>
- </Stack>
- <Stack
- direction="row"
- spacing={0.75}
- alignItems="center"
- sx={{ alignSelf: "stretch" }}
- >
- <Box
- sx={{
- display: "flex",
- alignItems: "center",
- flexShrink: 0,
- color: "inherit",
- }}
- >
- <Phone size={13} />
- </Box>
- <Typography
- variant="body2"
- sx={{
- fontWeight: 500,
- fontSize: "0.8125rem",
- lineHeight: 1.35,
- color: "inherit",
- }}
- >
- {logisticMaster.driverNumber != null &&
- Number.isFinite(logisticMaster.driverNumber)
- ? String(logisticMaster.driverNumber)
- : "—"}
- </Typography>
- </Stack>
- <Stack
- direction="row"
- spacing={0.75}
- alignItems="center"
- sx={{ alignSelf: "stretch" }}
- >
- <Box
- sx={{
- display: "flex",
- alignItems: "center",
- flexShrink: 0,
- color: "inherit",
- }}
- >
- <CarFront size={13} />
- </Box>
- <Typography
- variant="body2"
- sx={{
- fontWeight: 500,
- fontSize: "0.8125rem",
- lineHeight: 1.35,
- color: "inherit",
- }}
- >
- {String(
- logisticMaster.carPlate ?? "",
- ).trim() || "—"}
- </Typography>
- </Stack>
- </Stack>
- )}
- <Stack
- direction="row"
- flexWrap="wrap"
- useFlexGap
- spacing={0.75}
- sx={{
- mt: logisticMaster ? 1.5 : 1,
- pt: logisticMaster ? 1.5 : 0,
- borderTop: logisticMaster ? 1 : 0,
- borderColor: "divider",
- alignItems: "center",
- }}
- >
- <Chip
- size="small"
- variant="outlined"
- color="primary"
- label={t("logistics_colLaneCount", {
- count: colStats.laneCount,
- })}
- sx={{ fontWeight: 700, height: 24 }}
- />
- <Chip
- size="small"
- variant="outlined"
- label={t("logistics_colShopCount", {
- count: colStats.shopCount,
- })}
- sx={{ fontWeight: 700, height: 24 }}
- />
- <Chip
- size="small"
- label={`2F · ${colStats.count2F}`}
- sx={{
- height: 24,
- fontWeight: 700,
- bgcolor: "action.hover",
- }}
- />
- <Chip
- size="small"
- label={`4F · ${colStats.count4F}`}
- sx={{
- height: 24,
- fontWeight: 700,
- bgcolor: "action.hover",
- }}
- />
- </Stack>
- </Box>
- </Stack>
- <ChevronRight
- size={20}
- color="var(--mui-palette-text-disabled)"
- style={{ flexShrink: 0 }}
- />
- </Box>
- <Stack spacing={1.5} sx={{ p: 2, flex: 1 }}>
- {companyLanes.map((lane) => {
- const logisticDirty = dirtyLaneLogisticIds.has(
- lane.id,
- );
- return (
- <Paper
- key={lane.id}
- component="div"
- draggable
- onDragStart={(e) => {
- logisticsLaneDragIdRef.current = lane.id;
- e.dataTransfer.effectAllowed = "move";
- e.dataTransfer.setData(
- "application/x-fpsms-lane-id",
- lane.id,
- );
- }}
- onDragEnd={() => {
- logisticsLaneDragIdRef.current = null;
- setLogisticsDropHoverCompany(null);
- }}
- variant="outlined"
- sx={{
- p: 1.5,
- borderRadius: 2,
- display: "flex",
- flexDirection: "column",
- gap: 1,
- bgcolor: logisticDirty
- ? "warning.50"
- : "grey.50",
- borderColor: logisticDirty
- ? "warning.main"
- : "divider",
- boxShadow: logisticDirty
- ? "0 0 0 2px rgba(245, 124, 0, 0.18)"
- : "none",
- minHeight: 88,
- cursor: "grab",
- overflow: "hidden",
- "&:hover": {
- bgcolor: logisticDirty
- ? "warning.50"
- : "action.hover",
- borderColor: logisticDirty
- ? "warning.dark"
- : "primary.light",
- },
- }}
- >
- <Stack
- direction="row"
- alignItems="flex-start"
- justifyContent="space-between"
- spacing={1}
- sx={{ minWidth: 0 }}
- >
- <Box sx={{ flex: 1, minWidth: 0 }}>
- <Typography
- sx={{
- fontWeight: 900,
- fontStyle: "italic",
- color: "primary.main",
- fontSize: "1.05rem",
- lineHeight: 1.3,
- wordBreak: "break-word",
- overflowWrap: "anywhere",
- }}
- >
- {lane.truckLanceCode}
- {lane.remark != null &&
- String(lane.remark).trim() !== "" && (
- <Typography
- component="span"
- variant="caption"
- color="secondary"
- sx={{ ml: 0.5, fontStyle: "normal" }}
- >
- ·{lane.remark}
- </Typography>
- )}
- </Typography>
- {logisticDirty && (
- <Chip
- size="small"
- color="warning"
- label={t("logistics_dirtyLaneBadge")}
- sx={{
- mt: 0.75,
- height: 22,
- fontWeight: 800,
- }}
- />
- )}
- {(lane.driver || lane.plate) && (
- <Stack
- direction="row"
- spacing={0.75}
- alignItems="center"
- flexWrap="wrap"
- sx={{ mt: 0.5 }}
- >
- {!!lane.driver && (
- <Typography
- variant="caption"
- sx={{ fontWeight: 700 }}
- >
- {lane.driver}
- </Typography>
- )}
- {lane.plate && (
- <Chip
- label={lane.plate}
- size="small"
- variant="outlined"
- sx={{
- fontSize: "0.65rem",
- height: 22,
- maxWidth: "100%",
- }}
- />
- )}
- </Stack>
- )}
- </Box>
- <Stack
- direction="row"
- spacing={0.25}
- alignItems="flex-start"
- sx={{ flexShrink: 0, pt: 0.25 }}
- >
- <GripVertical
- size={18}
- aria-hidden
- color="var(--mui-palette-text-secondary)"
- />
- <Tooltip title={t("tooltip_openLaneBoard")}>
- <IconButton
- size="small"
- onClick={() => {
- setRouteBoardTab("board");
- setSelectedLaneIds([lane.id]);
- }}
- aria-label={t("aria_openLaneBoard")}
- >
- <LayoutDashboard size={18} />
- </IconButton>
- </Tooltip>
- </Stack>
- </Stack>
-
- <Stack
- direction="row"
- alignItems="center"
- spacing={2}
- sx={{
- flexWrap: "nowrap",
- pt: 0.75,
- mt: "auto",
- borderTop: 1,
- borderColor: "divider",
- typography: "caption",
- color: "text.secondary",
- }}
- >
- <Box
- component="span"
- sx={{
- display: "inline-flex",
- alignItems: "center",
- gap: 0.5,
- whiteSpace: "nowrap",
- flexShrink: 0,
- }}
- >
- <Clock size={12} aria-hidden />
- {lane.startTime || "—"}
- </Box>
- <Box
- component="span"
- sx={{
- display: "inline-flex",
- alignItems: "center",
- gap: 0.5,
- whiteSpace: "nowrap",
- flexShrink: 0,
- }}
- >
- <MapPin size={12} aria-hidden />
- {t("lane_shopCountInline", {
- count: lane.shops.length,
- })}
- </Box>
- </Stack>
- </Paper>
- );
- })}
- </Stack>
- </Card>
- );
- })}
- </Box>
- </Box>
- ) : (
- <Stack
- direction="row"
- spacing={2}
- alignItems="flex-start"
- sx={{ width: "max-content" }}
- >
- {filteredLanes
- .filter((lane) =>
- lanesMatchingFloorOnly.some((v) => v.id === lane.id),
- )
- .map((lane) => {
- const districtSections = buildLaneDistrictSections(
- lane.shops,
- pendingEmptyDistrictsByLane[lane.id],
- );
- return (
- <Card
- key={lane.id}
- data-lane-id={lane.id}
- onDragOver={(e) => {
- handleDragOver(e);
- if (!draggedRef.current) return;
- const beforeShopId = getBeforeShopIdByPointer(
- lane.id,
- e.clientY,
- );
- setDropIndicator((prev) => {
- if (
- prev?.laneId === lane.id &&
- prev.beforeShopId === beforeShopId
- )
- return prev;
- return { laneId: lane.id, beforeShopId };
- });
- }}
- onDrop={(e) => {
- e.preventDefault();
- e.stopPropagation();
- handleDropToLane(lane.id);
- }}
- sx={{
- width: 360,
- flexShrink: 0,
- borderTop: "4px solid",
- borderTopColor: "primary.main",
- overflow: "hidden",
- }}
- >
- {renderLaneHeader(lane)}
-
- <Box
- sx={{
- p: 1.5,
- maxHeight: "calc(100vh - 220px)",
- overflow: "auto",
- }}
- >
- {districtSections.map(
- ({ district, shops, isPendingEmpty }) => (
- <Box
- key={`${lane.id}::${district}`}
- onDragOver={(e) => {
- e.preventDefault();
- if (!draggedRef.current) return;
- setDropIndicator({
- laneId: lane.id,
- beforeShopId: null,
- });
- }}
- onDrop={(e) => {
- e.preventDefault();
- e.stopPropagation();
- handleDropToPosition(lane.id, null, district);
- }}
- sx={{ mb: 2 }}
- >
- <Stack
- direction="row"
- spacing={0.5}
- alignItems="center"
- sx={{ px: 0.5, mb: 1, minWidth: 0 }}
- >
- <MapPin size={14} />
- <Typography
- variant="caption"
- sx={{
- fontWeight: 900,
- color: "text.secondary",
- minWidth: 0,
- }}
- noWrap
- >
- {district}
- </Typography>
- <Box
- sx={{
- flex: 1,
- height: 1,
- bgcolor: "grey.200",
- mx: 0.5,
- }}
- />
- <Typography
- variant="caption"
- color="text.secondary"
- >
- {shops.length}
- </Typography>
- <Tooltip title={t("tooltip_editDistrict")}>
- <span>
- <IconButton
- size="small"
- sx={{ p: 0.25 }}
- onMouseDown={(e) => e.stopPropagation()}
- onClick={(e) => {
- e.stopPropagation();
- openDistrictRename(lane.id, district);
- }}
- disabled={loading}
- aria-label={t("aria_editDistrict")}
- >
- <Pencil size={14} />
- </IconButton>
- </span>
- </Tooltip>
- {isPendingEmpty && (
- <Tooltip title={t("tooltip_removeEmptyDistrict")}>
- <span>
- <IconButton
- size="small"
- sx={{ p: 0.25 }}
- onMouseDown={(e) => e.stopPropagation()}
- onClick={(e) => {
- e.stopPropagation();
- removePendingEmptyDistrict(
- lane.id,
- district,
- );
- }}
- disabled={loading}
- aria-label={t("aria_removeEmptyDistrict")}
- >
- <X size={14} />
- </IconButton>
- </span>
- </Tooltip>
- )}
- </Stack>
-
- <Stack spacing={1}>
- {shops.map((shop) => {
- 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);
- const showInsertLine =
- dropIndicator != null &&
- dropIndicator.laneId === lane.id &&
- dropIndicator.beforeShopId === shop.id;
- return (
- <Card
- key={shop.id}
- variant="outlined"
- data-shop-id={shop.id}
- draggable={shop.id > 0 && !isScheduledMove}
- onDragStart={() =>
- handleDragStart(shop.id, lane.id)
- }
- onDragEnd={() => clearDragState()}
- onDragOver={(e) => {
- e.preventDefault();
- e.stopPropagation();
- if (draggedRef.current)
- setDropIndicator({
- laneId: lane.id,
- beforeShopId: shop.id,
- });
- }}
- onDrop={(e) => {
- e.preventDefault();
- e.stopPropagation();
- handleDropToPosition(
- lane.id,
- shop.id,
- district,
- );
- }}
- sx={{
- cursor:
- shop.id > 0 && !isScheduledMove
- ? "grab"
- : "default",
- opacity: isScheduledMove ? 0.55 : 1,
- borderColor: changed
- ? "warning.main"
- : shop.id < 0
- ? "warning.light"
- : changedShopIds.has(shop.id)
- ? "info.main"
- : "divider",
- bgcolor: isScheduledMove
- ? "action.hover"
- : changed
- ? "warning.50"
- : shop.id < 0
- ? "grey.100"
- : changedShopIds.has(shop.id)
- ? "info.50"
- : "background.paper",
- "&:active":
- shop.id > 0
- ? { cursor: "grabbing" }
- : undefined,
- position: "relative",
- }}
- >
- {isFailedScheduledMove && (
- <Tooltip
- title={t("schedule_history_status_failed")}
- >
- <Box
- sx={{
- position: "absolute",
- top: 0,
- right: 0,
- p: 0.75,
- bgcolor: "error.main",
- color: "error.contrastText",
- borderBottomLeftRadius: 8,
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- zIndex: 2,
- }}
- >
- <AlertTriangle size={12} />
- </Box>
- </Tooltip>
- )}
- {isScheduledMove && !isFailedScheduledMove && (
- <Tooltip title={t("schedule_shop_locked")}>
- <Box
- sx={{
- position: "absolute",
- top: 0,
- right: 0,
- p: 0.75,
- bgcolor: "warning.main",
- color: "warning.contrastText",
- borderBottomLeftRadius: 8,
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- zIndex: 1,
- }}
- >
- <Clock size={12} />
- </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={{
- position: "absolute",
- top: -1,
- left: 0,
- right: 0,
- height: 4,
- bgcolor: "primary.main",
- borderRadius: 1,
- }}
- />
- )}
- <CardContent
- sx={{
- py: 1.25,
- "&:last-child": { pb: 1.25 },
- }}
- >
- <Stack
- direction="row"
- justifyContent="space-between"
- alignItems="flex-start"
- spacing={1}
- >
- <Box sx={{ minWidth: 0 }}>
- <Typography
- variant="subtitle2"
- sx={{ fontWeight: 900 }}
- noWrap
- >
- {(() => {
- const codeLower = String(
- shop.shopCode || "",
- )
- .trim()
- .toLowerCase();
- const realName =
- shopNameByCodeMap.get(
- codeLower,
- );
- return realName &&
- String(realName).trim() !== ""
- ? realName
- : shop.branchName || "-";
- })()}
- </Typography>
- <Typography
- variant="caption"
- color="text.secondary"
- >
- {formatShopCardSubtitle(shop)}
- </Typography>
- <Stack
- direction="row"
- alignItems="center"
- spacing={0.5}
- >
- <Typography
- variant="caption"
- sx={{
- display: "block",
- color: "text.secondary",
- }}
- >
- Seq: {shop.loadingSequence ?? "-"}
- </Typography>
- <Tooltip title={t("tooltip_editSeq")}>
- <span>
- <IconButton
- size="small"
- sx={{ p: 0.25 }}
- onMouseDown={(e) =>
- e.stopPropagation()
- }
- onClick={(e) => {
- e.stopPropagation();
- openSeqEdit(lane, shop);
- }}
- disabled={loading}
- aria-label={t("aria_editSeq")}
- >
- <Pencil size={12} />
- </IconButton>
- </span>
- </Tooltip>
- </Stack>
- </Box>
- <Stack
- direction="row"
- spacing={0.5}
- alignItems="center"
- >
- <Tooltip
- title={
- isScheduledMove
- ? t("schedule_shop_locked")
- : t("tooltip_removeFromLane")
- }
- >
- <span>
- <IconButton
- size="small"
- onMouseDown={(e) =>
- e.stopPropagation()
- }
- onClick={(e) => {
- e.stopPropagation();
- void handleDeleteTruckRow(
- shop.id,
- );
- }}
- disabled={
- loading ||
- dirtyDeletes.has(shop.id) ||
- isScheduledMove
- }
- >
- <Trash2 size={16} />
- </IconButton>
- </span>
- </Tooltip>
- </Stack>
- </Stack>
- </CardContent>
- </Card>
- );
- })}
- </Stack>
- </Box>
- ),
- )}
-
- <Button
- size="small"
- variant="text"
- startIcon={<Plus size={14} />}
- onClick={() => openDistrictAdd(lane.id)}
- disabled={loading}
- sx={{ alignSelf: "flex-start", mb: 1 }}
- >
- {t("btn_addDistrict")}
- </Button>
-
- {dropIndicator != null &&
- dropIndicator.laneId === lane.id &&
- dropIndicator.beforeShopId == null &&
- lane.shops.length > 0 && (
- <Box
- sx={{
- mt: 1,
- height: 8,
- borderRadius: 1,
- bgcolor: "primary.main",
- opacity: 0.35,
- }}
- />
- )}
-
- {districtSections.length === 0 && (
- <Box
- sx={{
- border: "2px dashed",
- borderColor: "grey.300",
- borderRadius: 2,
- height: 120,
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- color: "text.secondary",
- }}
- >
- <Stack alignItems="center" spacing={1}>
- <TruckIcon size={28} />
- <Typography variant="caption">
- {t("empty_lane_noShops")}
- </Typography>
- </Stack>
- </Box>
- )}
- </Box>
-
- <Box
- sx={{
- p: 1.25,
- borderTop: "1px solid",
- borderColor: "divider",
- bgcolor: "grey.50",
- }}
- >
- <Stack direction="row" spacing={1}>
- <Button
- fullWidth
- size="small"
- variant="outlined"
- startIcon={<Plus size={16} />}
- onClick={() => openAddShopDialog(lane.id)}
- disabled={loading}
- >
- {t("btn_addShopToLane")}
- </Button>
- <Tooltip
- title={
- lane.shops.some(
- (s) =>
- s.id > 0 && scheduledShopIdSet.has(s.id),
- )
- ? t("schedule_shop_locked")
- : t("tooltip_clearLaneShops")
- }
- >
- <span>
- <IconButton
- size="small"
- onClick={() => handleClearLaneShops(lane)}
- disabled={
- loading ||
- lane.shops.length === 0 ||
- lane.shops.some(
- (s) =>
- s.id > 0 &&
- scheduledShopIdSet.has(s.id),
- )
- }
- >
- <Trash2 size={16} />
- </IconButton>
- </span>
- </Tooltip>
- </Stack>
- </Box>
- </Card>
- );
- })}
-
- <Card
- variant="outlined"
- sx={{
- width: 56,
- height: 56,
- flexShrink: 0,
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- bgcolor: "background.paper",
- }}
- >
- <Tooltip title={t("tooltip_pickLane")}>
- <span>
- <IconButton
- aria-label={t("aria_pickLane")}
- onClick={(e) => setBoardQuickPickAnchorEl(e.currentTarget)}
- disabled={loading || lanesMatchingFloorOnly.length === 0}
- >
- <Plus size={22} />
- </IconButton>
- </span>
- </Tooltip>
- </Card>
- <Popover
- open={boardQuickPickAnchorEl != null}
- anchorEl={boardQuickPickAnchorEl}
- onClose={() => {
- setBoardQuickPickAnchorEl(null);
- setBoardQuickPickSearch("");
- }}
- anchorOrigin={{ vertical: "center", horizontal: "right" }}
- transformOrigin={{ vertical: "center", horizontal: "left" }}
- slotProps={{
- paper: {
- sx: {
- width: "min(100vw - 32px, 360px)",
- maxHeight: 420,
- overflow: "hidden",
- display: "flex",
- flexDirection: "column",
- },
- },
- }}
- >
- <Box sx={{ px: 1.5, py: 1, borderBottom: 1, borderColor: "divider" }}>
- <TextField
- size="small"
- fullWidth
- placeholder={t("lane_searchPh")}
- value={boardQuickPickSearch}
- onChange={(e) => setBoardQuickPickSearch(e.target.value)}
- sx={{
- "& .MuiOutlinedInput-root": {
- borderRadius: 2,
- bgcolor: "grey.50",
- },
- "& .MuiInputBase-input": {
- textAlign: "left",
- fontSize: "0.8125rem",
- color: "text.secondary",
- py: 0.75,
- },
- "& .MuiInputBase-input::placeholder": {
- opacity: 1,
- color: "text.disabled",
- },
- }}
- InputProps={{
- startAdornment: (
- <Box
- sx={{
- mr: 0.5,
- display: "inline-flex",
- color: "text.disabled",
- }}
- >
- <Search size={16} />
- </Box>
- ),
- }}
- inputProps={{ "aria-label": t("aria_searchLanes") }}
- onKeyDown={(e) => e.stopPropagation()}
- />
- </Box>
- <List dense sx={{ overflow: "auto", py: 0, flex: 1, minHeight: 0 }}>
- {boardQuickPickFilteredLanes.length === 0 ? (
- <Box sx={{ px: 2, py: 2 }}>
- <Typography variant="body2" color="text.secondary">
- {lanesMatchingFloorOnly.length === 0
- ? t("quickPick_noLanes")
- : t("quickPick_noKeyword")}
- </Typography>
- </Box>
- ) : (
- boardQuickPickFilteredLanes.map((lane) => {
- const rem =
- lane.remark != null &&
- String(lane.remark).trim() !== ""
- ? String(lane.remark).trim()
- : null;
- const picked = selectedLaneIds.includes(lane.id);
- return (
- <ListItemButton
- key={lane.id}
- selected={picked}
- onClick={() => applyBoardQuickPickLane(lane.id)}
- sx={{ alignItems: "flex-start", py: 1 }}
- >
- <ListItemText
- primary={lane.truckLanceCode}
- secondary={rem ?? undefined}
- primaryTypographyProps={{
- sx: { fontWeight: 800, fontSize: "0.9rem" },
- }}
- secondaryTypographyProps={{
- sx: { fontSize: "0.72rem" },
- }}
- />
- </ListItemButton>
- );
- })
- )}
- </List>
- </Popover>
- </Stack>
- )}
- </Box>
- </Box>
- </Box>
- );
- };
-
- export default RouteBoard;
|