FPSMS-frontend
25'ten fazla konu seçemezsiniz Konular bir harf veya rakamla başlamalı, kısa çizgiler ('-') içerebilir ve en fazla 35 karakter uzunluğunda olabilir.
 
 

8446 satır
315 KiB

  1. "use client";
  2. import React, {
  3. useCallback,
  4. useEffect,
  5. useMemo,
  6. useRef,
  7. useState,
  8. } from "react";
  9. import {
  10. Alert,
  11. Box,
  12. Badge,
  13. Button,
  14. ButtonGroup,
  15. Chip,
  16. Card,
  17. CardContent,
  18. Checkbox,
  19. CircularProgress,
  20. Collapse,
  21. Divider,
  22. Dialog,
  23. DialogActions,
  24. DialogContent,
  25. DialogTitle,
  26. Drawer,
  27. IconButton,
  28. FormControl,
  29. InputAdornment,
  30. InputLabel,
  31. List,
  32. ListItemButton,
  33. ListItemText,
  34. ListSubheader,
  35. MenuItem,
  36. Paper,
  37. Popover,
  38. Select,
  39. Snackbar,
  40. Stack,
  41. TextField,
  42. Tooltip,
  43. Typography,
  44. Autocomplete,
  45. } from "@mui/material";
  46. import { alpha } from "@mui/material/styles";
  47. import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
  48. import FilterListIcon from "@mui/icons-material/FilterList";
  49. import {
  50. AlertTriangle,
  51. ArrowRight,
  52. Bell,
  53. Building2,
  54. Clock,
  55. CreditCard,
  56. ChevronRight,
  57. Download,
  58. FileText,
  59. History,
  60. Info,
  61. LayoutDashboard,
  62. Phone,
  63. Plus,
  64. RotateCcw,
  65. Save,
  66. Search,
  67. Trash2,
  68. Truck as TruckIcon,
  69. Upload,
  70. Users,
  71. X,
  72. MapPin,
  73. Pencil,
  74. GripVertical,
  75. CarFront,
  76. } from "lucide-react";
  77. import type { TFunction } from "i18next";
  78. import { useTranslation } from "react-i18next";
  79. import {
  80. fetchAllShopsClient,
  81. diffTruckLaneVersionsClient,
  82. listTruckLaneVersionsClient,
  83. restoreTruckLaneVersionClient,
  84. findAllByTruckLanceCodeAndRemarkAndDeletedFalseClient,
  85. findAllForRouteBoardClient,
  86. exportRouteLanesExcelClient,
  87. exportRouteReportExcelClient,
  88. exportTruckLaneVersionReportExcelClient,
  89. importRouteLanesExcelClient,
  90. parseRouteLanesExcelClient,
  91. createTruckLaneSnapshotClient,
  92. createTruckLaneScheduleClient,
  93. updateTruckLaneVersionNoteClient,
  94. updateTruckLaneClient,
  95. updateLaneLogisticClient,
  96. createTruckClient,
  97. createTruckWithoutShopClient,
  98. deleteTruckLaneClient,
  99. previewTruckLaneRenameClient,
  100. renameTruckLaneClient,
  101. listTruckLaneRenameLogsClient,
  102. } from "@/app/api/shop/client";
  103. import {
  104. deleteLogisticClient,
  105. findAllLogisticsClient,
  106. saveLogisticClient,
  107. saveLogisticsBatchCreateClient,
  108. type LogisticRow,
  109. type SaveLogisticRequest,
  110. } from "@/app/api/logistic/client";
  111. import type {
  112. CreateTruckWithoutShopRequest,
  113. LogisticMasterDiffLine,
  114. MessageResponse,
  115. SaveTruckLane,
  116. Truck,
  117. TruckLaneVersionDiffLine,
  118. TruckLaneRenameLogResponse,
  119. } from "@/app/api/shop/actions";
  120. import { formatDepartureTime, normalizeStoreId } from "@/app/utils/formatUtil";
  121. import {
  122. buildStagedBoardLogEntries,
  123. diffLinesToShopRows,
  124. formatLaneLabel,
  125. resolveHeadVersionId,
  126. resolveVersionLogLaneLabel,
  127. resolveVersionLogShopHeadline,
  128. splitVersionCreated,
  129. resolveVersionActor,
  130. summarizeVersionRows,
  131. VERSION_LOG_LOADING_SEQUENCE_LABEL,
  132. type StagedDeleteMeta,
  133. type ShopRowBaseline,
  134. } from "@/components/Shop/routeBoardVersionLog";
  135. import {
  136. computeTruckLaneWarnings,
  137. appendSyntheticPendingShopRow,
  138. parseWeekdayFromTruckLanceCode,
  139. type TruckLaneWarning,
  140. type TruckLaneWarningInputRow,
  141. type TruckLaneWarningLaneRef,
  142. } from "@/components/Shop/computeTruckLaneWarnings";
  143. import { mergeImportPreviewIntoLanes } from "@/components/Shop/routeBoardImportPreview";
  144. import ScheduleChangeModal, {
  145. type ScheduleChangePayload,
  146. type ScheduleShopRow,
  147. } from "@/components/Shop/ScheduleChangeModal";
  148. import ScheduleTaskHistoryModal from "@/components/Shop/ScheduleTaskHistoryModal";
  149. import { extractApiErrorMessage } from "@/components/Shop/scheduleClientHelpers";
  150. import { formatScheduleValidationErrors } from "@/components/Shop/scheduleUiHelpers";
  151. import { useRouteBoardScheduleIndicators } from "@/components/Shop/useRouteBoardScheduleIndicators";
  152. import {
  153. buildRequestFromPayload,
  154. validatePayloadSubmit,
  155. } from "@/components/Shop/truckLaneMovePlanner";
  156. import {
  157. computeMovedLoadingSequence,
  158. flattenDisplayOrder,
  159. groupByDistrict,
  160. formatShopCardSubtitle,
  161. toDistrictDisplayName,
  162. toDistrictRawValue,
  163. } from "@/components/Shop/routeBoardDisplayOrder";
  164. const JAVA_INT_MAX = 2_147_483_647;
  165. /** 後端 `driverNumber` 為 Int;只送可安全表示的十進位數字,不靜默改值。 */
  166. function phoneDigitsToDriverNumber(phone: string): number | null {
  167. const d = String(phone).replace(/\D/g, "");
  168. if (!d) return null;
  169. const fitsIntString = (s: string): boolean =>
  170. /^\d{1,10}$/.test(s) && BigInt(s) <= BigInt(JAVA_INT_MAX);
  171. const parseBlock = (s: string): number | null => {
  172. if (!fitsIntString(s)) return null;
  173. const n = parseInt(s, 10);
  174. return Number.isFinite(n) && n >= 0 && n <= JAVA_INT_MAX ? n : null;
  175. };
  176. let n = parseBlock(d);
  177. if (n != null) return n;
  178. // HK +852 + 8 位本地號(11 位數字)
  179. if (d.startsWith("852") && d.length === 11) {
  180. n = parseBlock(d.slice(3));
  181. if (n != null) return n;
  182. }
  183. return null;
  184. }
  185. type ShopCard = {
  186. /** truck table row id */
  187. id: number;
  188. /** master shop.id(後端 Truck.shop);新增/去重用 */
  189. shopEntityId?: number | null;
  190. /** 分店名(truck table 的 ShopName) */
  191. branchName: string;
  192. shopCode: string;
  193. /** districtReference raw value from backend (never use display label for save) */
  194. districtReferenceRaw: string | null;
  195. /** brand/label is not in current API; keep optional for future */
  196. brand?: string;
  197. /** periods is not in current API; keep optional for future */
  198. periods?: string;
  199. /** ordering inside a lane */
  200. loadingSequence: number;
  201. /** remark shown in existing UIs */
  202. remark?: string | null;
  203. /** store ID 2F/4F */
  204. storeId: string;
  205. /** departure time HH:mm or HH:mm:ss */
  206. departureTime: string;
  207. };
  208. type Lane = {
  209. /** 穩定鍵:encodeURIComponent(code)|encodeURIComponent(remark) */
  210. id: string;
  211. /** 寫入 truck API 的車線代碼 */
  212. truckLanceCode: string;
  213. plate?: string;
  214. logisticsCompany?: string;
  215. /** `logistic.id`,來自 truck.logistic;無綁定為 null */
  216. logisticId?: number | null;
  217. driver?: string;
  218. phone?: string;
  219. startTime: string;
  220. storeId: string;
  221. remark?: string | null;
  222. shops: ShopCard[];
  223. /** Server name before a local rename. Absent until the user edits the name. */
  224. originalTruckLanceCode?: string;
  225. /** Server departure time before a local time edit in the same unsaved session. */
  226. originalStartTime?: string;
  227. /** False for a lane that exists only in this unsaved session. */
  228. persistedLane?: boolean;
  229. };
  230. type PendingNewLane = {
  231. laneKey: string;
  232. payload: CreateTruckWithoutShopRequest;
  233. };
  234. const parseTimeForBackend = (time: string): string => {
  235. const s = String(time ?? "").trim();
  236. if (!s) return "";
  237. // allow HH:mm or HH:mm:ss
  238. if (/^\d{1,2}:\d{2}(:\d{2})?$/.test(s)) {
  239. const [hh, mm, ss] = s.split(":");
  240. return `${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}${
  241. ss != null ? `:${String(ss).padStart(2, "0")}` : ""
  242. }`;
  243. }
  244. return s;
  245. };
  246. const toTimeInputValue = (t: string | undefined): string => {
  247. const s = String(t ?? "").trim();
  248. if (!s) return "00:00";
  249. const m = s.match(/^(\d{1,2}):(\d{2})(?::\d{2})?/);
  250. if (m) return `${m[1].padStart(2, "0")}:${m[2]}`;
  251. return "00:00";
  252. };
  253. /** 物流商總覽欄標題用:條數、店數、樓層 */
  254. function summarizeLogisticsColumnStats(lanes: Lane[]): {
  255. laneCount: number;
  256. shopCount: number;
  257. count2F: number;
  258. count4F: number;
  259. } {
  260. let shopCount = 0;
  261. let count2F = 0;
  262. let count4F = 0;
  263. for (const l of lanes) {
  264. shopCount += l.shops.length;
  265. if (normalizeStoreId(l.storeId) === "4F") count4F++;
  266. else count2F++;
  267. }
  268. return {
  269. laneCount: lanes.length,
  270. shopCount,
  271. count2F,
  272. count4F,
  273. };
  274. }
  275. function resolveLogisticMasterRow(
  276. company: string,
  277. lanes: Lane[],
  278. masters: LogisticRow[],
  279. ): LogisticRow | null {
  280. if (company === "未分配物流商") return null;
  281. const byLaneId = lanes.find(
  282. (l) => l.logisticId != null && Number.isFinite(l.logisticId),
  283. )?.logisticId;
  284. if (byLaneId != null) {
  285. const hit = masters.find((m) => m.id === byLaneId);
  286. if (hit) return hit;
  287. }
  288. const name = String(company).trim();
  289. return (
  290. masters.find((m) => String(m.logisticName ?? "").trim() === name) ?? null
  291. );
  292. }
  293. /**
  294. * 地區區塊標題編輯語意(RouteBoard):
  295. * 後端每筆 truck row 存 `districtReference`,無 lane-level overlay 表。
  296. * 編輯某「區塊」顯示名稱=把該 lane 內目前落在該顯示 bucket 的所有 shop 之
  297. * `districtReferenceRaw` 批量改成 `toDistrictRawValue(新顯示名)`,並對 `id > 0`
  298. * 的列 `dirtyMoves.set` 以延遲寫 DB。
  299. * 僅前端存在、尚無店鋪的「空區」用 `pendingEmptyDistrictsByLane` 記錄顯示名,
  300. * 與 `groupByDistrict` 合併渲染;儲存成功或僅空區時會清掉暫存列。
  301. */
  302. function buildLaneDistrictSections(
  303. shops: ShopCard[],
  304. pendingExtraDistrictDisplays: string[] | undefined,
  305. ): Array<{ district: string; shops: ShopCard[]; isPendingEmpty: boolean }> {
  306. const grouped = groupByDistrict(shops);
  307. const keysFromShops = new Set(grouped.map((g) => g.district));
  308. const extras = (pendingExtraDistrictDisplays ?? []).filter(
  309. (d) => !keysFromShops.has(d),
  310. );
  311. const merged = [
  312. ...grouped.map((g) => ({
  313. district: g.district,
  314. shops: g.shops,
  315. isPendingEmpty: false,
  316. })),
  317. ...extras.map((district) => ({
  318. district,
  319. shops: [] as ShopCard[],
  320. isPendingEmpty: true,
  321. })),
  322. ];
  323. merged.sort((a, b) => {
  324. if (a.district === "未分類") return -1;
  325. if (b.district === "未分類") return 1;
  326. return a.district.localeCompare(b.district, "zh-Hant");
  327. });
  328. return merged;
  329. }
  330. function districtDisplayExistsInLane(
  331. lane: Lane,
  332. pendingExtra: string[] | undefined,
  333. display: string,
  334. ): boolean {
  335. const set = new Set(groupByDistrict(lane.shops).map((g) => g.district));
  336. for (const d of pendingExtra ?? []) set.add(d);
  337. return set.has(display);
  338. }
  339. /** pending 顯示名陣列去重(保序),避免 rename 撞名造成重複 key / 重複區塊 */
  340. function dedupeDistrictPendingOrder(items: string[]): string[] {
  341. const seen = new Set<string>();
  342. const out: string[] = [];
  343. for (const x of items) {
  344. if (seen.has(x)) continue;
  345. seen.add(x);
  346. out.push(x);
  347. }
  348. return out;
  349. }
  350. const LANE_ID_SEP = "|";
  351. /** 與後端 lane 唯一鍵一致:`TruckLanceCode` + 正規化後 remark(NULL/空白 視為同組) */
  352. function encodeLaneId(
  353. truckLanceCode: string,
  354. laneRemark: string | null | undefined,
  355. ): string {
  356. const code = String(truckLanceCode || "").trim();
  357. const rem =
  358. laneRemark != null && String(laneRemark).trim() !== ""
  359. ? String(laneRemark).trim()
  360. : "";
  361. return `${encodeURIComponent(code)}${LANE_ID_SEP}${encodeURIComponent(rem)}`;
  362. }
  363. function decodeLaneId(laneId: string): {
  364. truckLanceCode: string;
  365. remark: string | null;
  366. } | null {
  367. const i = laneId.indexOf(LANE_ID_SEP);
  368. if (i < 0) return null;
  369. try {
  370. const code = decodeURIComponent(laneId.slice(0, i));
  371. const remEnc = laneId.slice(i + LANE_ID_SEP.length);
  372. const rem = decodeURIComponent(remEnc);
  373. return {
  374. truckLanceCode: code,
  375. remark: rem !== "" ? rem : null,
  376. };
  377. } catch {
  378. return null;
  379. }
  380. }
  381. function lanesToWarningInputRows(lanes: Lane[]): TruckLaneWarningInputRow[] {
  382. const out: TruckLaneWarningInputRow[] = [];
  383. for (const lane of lanes) {
  384. const code = lane.truckLanceCode;
  385. const laneRemark = lane.remark ?? null;
  386. for (const s of lane.shops) {
  387. out.push({
  388. truckRowId: s.id,
  389. truckLanceCode: code,
  390. laneRemark,
  391. storeId: s.storeId,
  392. departureTime: s.departureTime,
  393. shopEntityId: s.shopEntityId ?? null,
  394. shopCode: s.shopCode,
  395. shopDisplayName: s.branchName,
  396. });
  397. }
  398. }
  399. return out;
  400. }
  401. /** 尚未儲存的新增店鋪(負數 truck row id) */
  402. function stripDraftShopRows(lanes: Lane[]): Lane[] {
  403. return lanes.map((l) => ({
  404. ...l,
  405. shops: l.shops.filter((s) => s.id > 0),
  406. }));
  407. }
  408. function warningTouchesPickedShop(
  409. w: TruckLaneWarning,
  410. pick: { id: number; code: string },
  411. ): boolean {
  412. if (
  413. w.shopEntityId != null &&
  414. Number.isFinite(w.shopEntityId) &&
  415. w.shopEntityId === pick.id
  416. ) {
  417. return true;
  418. }
  419. const wc = String(w.shopCode || "").trim().toLowerCase();
  420. const pc = String(pick.code || "").trim().toLowerCase();
  421. return wc !== "" && pc !== "" && wc === pc;
  422. }
  423. function formatLaneWarningDetail(
  424. L: TruckLaneWarningLaneRef,
  425. tr: TFunction<"shop">,
  426. ): string {
  427. const dash = tr("emDash");
  428. const parts = [`${tr("warnClipboardStore")} ${L.storeId}`];
  429. if (L.weekday) {
  430. parts.push(`${tr("warnClipboardWeekday")} ${L.weekday}`);
  431. }
  432. parts.push(`${tr("warnClipboardDep")} ${L.departureTimeDisplay ?? dash}`);
  433. return parts.join(" · ");
  434. }
  435. function formatWarningSummary(
  436. w: TruckLaneWarning,
  437. tr: TFunction<"shop">,
  438. ): string {
  439. if (w.rule === "RULE_1_WEEKDAY") {
  440. return tr("mtmsRouteWarn_conflict4f", { weekday: w.triggerValue });
  441. }
  442. return tr("mtmsRouteWarn_conflictDep", { time: w.triggerValue });
  443. }
  444. function formatLaneWarningsClipboard(
  445. warnings: TruckLaneWarning[],
  446. tr: TFunction<"shop">,
  447. ): string {
  448. return warnings
  449. .map((w, idx) => {
  450. const shopLine = [w.shopCode, w.shopDisplayName]
  451. .map((s) => String(s ?? "").trim())
  452. .filter(Boolean)
  453. .join(" ");
  454. const lanes = w.lanes
  455. .map((L) => {
  456. const laneTitle = `${L.truckLanceCode}${
  457. L.laneRemark ? ` · ${L.laneRemark}` : ""
  458. }`;
  459. return ` - ${laneTitle} | ${formatLaneWarningDetail(L, tr)}`;
  460. })
  461. .join("\n");
  462. return `[${idx + 1}] ${tr("mtmsRouteWarn_shop")}: ${shopLine}\n${formatWarningSummary(w, tr)}\n${lanes}`;
  463. })
  464. .join("\n\n");
  465. }
  466. function formatDiffFieldLabel(label: string, tr: TFunction<"shop">): string {
  467. if (label === "versionLogField_logisticId") return tr("diffField_logisticsCompany");
  468. // Translate i18n keys; fallback to raw label if not found
  469. const translated = tr(label as any);
  470. return translated !== label ? translated : label;
  471. }
  472. function downloadBase64Xlsx(base64: string, filename: string) {
  473. const binary = atob(base64);
  474. const bytes = new Uint8Array(binary.length);
  475. for (let i = 0; i < binary.length; i++) {
  476. bytes[i] = binary.charCodeAt(i);
  477. }
  478. const blob = new Blob([bytes], {
  479. type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
  480. });
  481. const url = URL.createObjectURL(blob);
  482. const a = document.createElement("a");
  483. a.href = url;
  484. a.download = filename;
  485. a.click();
  486. URL.revokeObjectURL(url);
  487. }
  488. function sortLanesByCode(lanes: Lane[]): Lane[] {
  489. return lanes.slice().sort((a, b) => {
  490. const c = a.truckLanceCode.localeCompare(b.truckLanceCode, "zh-Hant");
  491. if (c !== 0) return c;
  492. return String(a.remark ?? "").localeCompare(
  493. String(b.remark ?? ""),
  494. "zh-Hant",
  495. );
  496. });
  497. }
  498. /** 由尚未儲存的新增車線 payload 建立看板 Lane(shops 為空) */
  499. function buildLaneFromPendingNewLane(p: PendingNewLane): Lane {
  500. const payload = p.payload;
  501. const code = String(payload.truckLanceCode || "").trim();
  502. const storeNorm = normalizeStoreId(payload.store_id);
  503. const remarkRaw =
  504. storeNorm === "4F" &&
  505. payload.remark != null &&
  506. String(payload.remark).trim() !== ""
  507. ? String(payload.remark).trim()
  508. : null;
  509. const dep = parseTimeForBackend(payload.departureTime || "") || "00:00:00";
  510. return {
  511. id: p.laneKey,
  512. truckLanceCode: code,
  513. logisticsCompany: "",
  514. logisticId:
  515. payload.logisticId != null && Number.isFinite(Number(payload.logisticId))
  516. ? Number(payload.logisticId)
  517. : null,
  518. plate: "",
  519. driver: "",
  520. phone: "",
  521. startTime: dep,
  522. storeId: storeNorm,
  523. remark: remarkRaw,
  524. shops: [],
  525. };
  526. }
  527. /** 合併尚未寫入後端的新增車線,避免 loadLanes 覆蓋後從看板消失 */
  528. function mergePendingNewLanesIntoLanes(
  529. lanes: Lane[],
  530. pending: PendingNewLane[],
  531. ): Lane[] {
  532. if (pending.length === 0) return lanes;
  533. const ids = new Set(lanes.map((l) => l.id));
  534. const additions = pending
  535. .filter((p) => !ids.has(p.laneKey))
  536. .map((p) => buildLaneFromPendingNewLane(p));
  537. if (additions.length === 0) return lanes;
  538. return sortLanesByCode([...lanes, ...additions]);
  539. }
  540. /** 合併增量刷新後的車線(維持排序;追加後端新出現的 lane) */
  541. function mergeRefreshedLanes(prev: Lane[], refreshed: Lane[]): Lane[] {
  542. const map = new Map(refreshed.map((l) => [l.id, l]));
  543. const prevIds = new Set(prev.map((l) => l.id));
  544. const replaced: Lane[] = [];
  545. for (const l of prev) {
  546. const n = map.get(l.id);
  547. if (n !== undefined) {
  548. replaced.push(n);
  549. } else {
  550. replaced.push(l);
  551. }
  552. }
  553. const additions = refreshed.filter((l) => !prevIds.has(l.id));
  554. return sortLanesByCode([...replaced, ...additions]);
  555. }
  556. function buildLaneFromTruckRows(
  557. truckLanceCode: string,
  558. remark: string | null,
  559. rows: Truck[] | null | undefined,
  560. meta?: Partial<Truck> | Record<string, unknown> | null,
  561. ): Lane {
  562. const code = String(truckLanceCode || "").trim();
  563. const r = rows || [];
  564. const first = r[0];
  565. const startSource =
  566. meta != null
  567. ? (meta as any).departureTime ?? first?.departureTime ?? "00:00:00"
  568. : first != null
  569. ? first.departureTime
  570. : "00:00:00";
  571. const startTime =
  572. parseTimeForBackend(formatDepartureTime(startSource as any)) || "00:00:00";
  573. const storeId = normalizeStoreId(
  574. meta != null
  575. ? (meta as any)?.storeId ?? (meta as any)?.store_id
  576. : (first as any)?.storeId ?? (first as any)?.store_id,
  577. );
  578. const id = encodeLaneId(code, remark);
  579. const shops: ShopCard[] = r
  580. .filter((row) => row.id != null)
  581. .filter((row) => {
  582. const shopRef = (row as any).shop;
  583. const hasShopRef =
  584. shopRef != null && typeof shopRef === "object" && shopRef.id != null;
  585. const nm = row.shopName != null ? String(row.shopName).trim() : "";
  586. const cd = row.shopCode != null ? String(row.shopCode).trim() : "";
  587. if (hasShopRef) return true;
  588. if (nm === "" && cd === "") return false;
  589. const u = (s: string) => s.trim().toLowerCase();
  590. if (u(nm) === "unassign" || u(cd) === "unassign") return false;
  591. if (u(nm) === "unassigned" || u(cd) === "unassigned") return false;
  592. return true;
  593. })
  594. .map((row) => {
  595. const districtRaw =
  596. row.districtReference != null &&
  597. String(row.districtReference).trim() !== ""
  598. ? String(row.districtReference).trim()
  599. : null;
  600. const shopRef = (row as any).shop;
  601. const shopEntityId =
  602. shopRef && typeof shopRef === "object" && shopRef.id != null
  603. ? Number(shopRef.id)
  604. : null;
  605. return {
  606. id: row.id as number,
  607. shopEntityId: Number.isFinite(shopEntityId as number)
  608. ? (shopEntityId as number)
  609. : null,
  610. branchName: row.shopName != null ? String(row.shopName) : "",
  611. shopCode: row.shopCode != null ? String(row.shopCode) : "",
  612. districtReferenceRaw: districtRaw,
  613. loadingSequence: Number(row.loadingSequence ?? 0) || 0,
  614. remark: row.remark != null ? String(row.remark) : null,
  615. storeId: normalizeStoreId(
  616. (row as any).storeId ?? (row as any).store_id,
  617. ),
  618. departureTime: parseTimeForBackend(
  619. formatDepartureTime(row.departureTime as any),
  620. ),
  621. };
  622. });
  623. let laneLogisticId: number | null = null;
  624. let laneLogisticsName = "";
  625. let lanePlate = "";
  626. let laneDriver = "";
  627. let lanePhone = "";
  628. for (const row of r) {
  629. const log = (row as { logistic?: Record<string, unknown> | null }).logistic;
  630. if (!log || typeof log !== "object") continue;
  631. if (
  632. laneLogisticId == null &&
  633. log.id != null &&
  634. Number.isFinite(Number(log.id))
  635. ) {
  636. laneLogisticId = Number(log.id);
  637. }
  638. const nm = String(log.logisticName ?? "").trim();
  639. if (nm !== "" && laneLogisticsName === "") laneLogisticsName = nm;
  640. const p = String(log.carPlate ?? "").trim();
  641. const d = String(log.driverName ?? "").trim();
  642. const ph =
  643. log.driverNumber != null && Number.isFinite(Number(log.driverNumber))
  644. ? String(log.driverNumber)
  645. : "";
  646. if (p !== "" && lanePlate === "") lanePlate = p;
  647. if (d !== "" && laneDriver === "") laneDriver = d;
  648. if (ph !== "" && lanePhone === "") lanePhone = ph;
  649. }
  650. if (laneLogisticId == null && r.length > 0) {
  651. for (const row of r) {
  652. const lid = (row as { logisticId?: unknown }).logisticId;
  653. if (lid != null && Number.isFinite(Number(lid))) {
  654. laneLogisticId = Number(lid);
  655. break;
  656. }
  657. }
  658. }
  659. return {
  660. id,
  661. truckLanceCode: code,
  662. logisticsCompany: laneLogisticsName,
  663. logisticId: laneLogisticId,
  664. driver: laneDriver,
  665. phone: lanePhone,
  666. plate: lanePlate,
  667. startTime: startTime || "00:00:00",
  668. storeId: storeId || "2F",
  669. remark,
  670. shops,
  671. persistedLane: true,
  672. };
  673. }
  674. function laneRenamePending(lane: Lane): boolean {
  675. if (lane.persistedLane === false) return false;
  676. const orig = String(lane.originalTruckLanceCode ?? "").trim();
  677. const cur = String(lane.truckLanceCode ?? "").trim();
  678. return orig !== "" && orig !== cur;
  679. }
  680. function renameErrorText(raw: string, t: TFunction): string {
  681. const codes = [
  682. "LANE_NAME_EMPTY",
  683. "LANE_NAME_TOO_LONG",
  684. "LANE_NAME_RESERVED",
  685. "LANE_NAME_TAKEN",
  686. "LANE_NAME_REMARK_AMBIGUOUS",
  687. "LANE_NOT_FOUND",
  688. "LANE_TIME_INVALID",
  689. ];
  690. const code = codes.find((c) => raw.includes(c));
  691. return code ? t(`renameErr_${code}`) : raw;
  692. }
  693. async function fetchLaneByKey(
  694. truckLanceCode: string,
  695. remark: string | null,
  696. meta?: Partial<Truck> | null,
  697. ): Promise<Lane | null> {
  698. const c = String(truckLanceCode || "").trim();
  699. if (!c) return null;
  700. // NOTE:
  701. // - Next dev StrictMode 會讓初始化 useEffect 跑兩次,若不去重會把同一批 lane 打兩輪
  702. // - 這裡做「同 key in-flight」去重,避免重複打 API
  703. const inflight = (fetchLaneByKey as any)._inflight as
  704. | Map<string, Promise<Lane | null>>
  705. | undefined;
  706. const map: Map<string, Promise<Lane | null>> = inflight ?? new Map();
  707. (fetchLaneByKey as any)._inflight = map;
  708. const key = encodeLaneId(c, remark);
  709. const existing = map.get(key);
  710. if (existing) return existing;
  711. const p = (async () => {
  712. const rows = (await findAllByTruckLanceCodeAndRemarkAndDeletedFalseClient(
  713. c,
  714. remark,
  715. )) as Truck[];
  716. return buildLaneFromTruckRows(c, remark, rows, meta);
  717. })();
  718. map.set(key, p);
  719. try {
  720. return await p;
  721. } finally {
  722. map.delete(key);
  723. }
  724. }
  725. function laneTargetConflicts(
  726. shop: ShopCard,
  727. lane: Lane,
  728. excludeTruckRowId?: number,
  729. ): boolean {
  730. for (const s of lane.shops) {
  731. if (excludeTruckRowId != null && s.id === excludeTruckRowId) continue;
  732. if (
  733. shop.shopEntityId != null &&
  734. s.shopEntityId != null &&
  735. shop.shopEntityId === s.shopEntityId
  736. ) {
  737. return true;
  738. }
  739. const a = String(shop.shopCode || "")
  740. .trim()
  741. .toLowerCase();
  742. const b = String(s.shopCode || "")
  743. .trim()
  744. .toLowerCase();
  745. if (a !== "" && b !== "" && a === b) return true;
  746. }
  747. return false;
  748. }
  749. /** `/shop/combo/allShop` 常有 join 重複列:依 shop.id、再依 code 去重(保留 id 較小) */
  750. function dedupeShopMasterRows(
  751. rows: Array<{ id?: unknown; code?: unknown; name?: unknown }>,
  752. ): Array<{ id: number; name: string; code: string }> {
  753. const byId = new Map<number, { id: number; name: string; code: string }>();
  754. for (const s of rows || []) {
  755. const id = Number(s?.id);
  756. if (!Number.isFinite(id) || id <= 0 || byId.has(id)) continue;
  757. const rawCode = String(s?.code ?? "").trim();
  758. const name = String(s?.name ?? "").trim();
  759. byId.set(id, {
  760. id,
  761. name: name || rawCode || String(id),
  762. code: rawCode || String(id),
  763. });
  764. }
  765. const byCode = new Map<string, { id: number; name: string; code: string }>();
  766. for (const row of Array.from(byId.values())) {
  767. const ck = String(row.code).trim().toLowerCase();
  768. const key = ck || `__id_${row.id}`;
  769. const prev = byCode.get(key);
  770. if (!prev || row.id < prev.id) byCode.set(key, row);
  771. }
  772. return Array.from(byCode.values()).sort((a, b) =>
  773. a.code.localeCompare(b.code, undefined, { numeric: true }),
  774. );
  775. }
  776. /**
  777. * 車線店鋪管理看板(對齊 MTMS_ISSUE_LOG.pdf / 圖1 / 圖2)
  778. * - 左:checkbox 多選車線 + search
  779. * - 右:所選車線按「地區(districtReference)」分組顯示店鋪,可拖拽跨車線
  780. * - 支援儲存:把被拖動的店鋪批量呼叫 `updateTruckLaneClient`
  781. *
  782. * Logistic:後端 `truck.logistic` join;車線 Excel 見 MTMS_ROUTE_V1(PDF 圖1)。
  783. */
  784. const RouteBoard: React.FC = () => {
  785. const { t } = useTranslation("shop");
  786. const [loading, setLoading] = useState(false);
  787. const [error, setError] = useState<string | null>(null);
  788. const [laneWarnDrawerOpen, setLaneWarnDrawerOpen] = useState(false);
  789. const [laneWarnExpandedIdx, setLaneWarnExpandedIdx] = useState<number | null>(
  790. null,
  791. );
  792. const [laneWarnSnackbar, setLaneWarnSnackbar] = useState<string | null>(null);
  793. const [searchTerm, setSearchTerm] = useState("");
  794. const didInitialLoadRef = useRef(false);
  795. const loadLanesInFlightRef = useRef(false);
  796. const importRouteFileInputRef = useRef<HTMLInputElement>(null);
  797. const pendingImportFileRef = useRef<File | null>(null);
  798. const [pendingImportMeta, setPendingImportMeta] = useState<{
  799. fileName: string;
  800. sheetCount: number;
  801. rowCount: number;
  802. } | null>(null);
  803. const [routeExcelBusy, setRouteExcelBusy] = useState(false);
  804. const routeExcelExportLockRef = useRef(false);
  805. // shopCode(lowercase) -> shop table real name
  806. const [shopNameByCodeMap, setShopNameByCodeMap] = useState<
  807. Map<string, string>
  808. >(new Map());
  809. const [lanes, setLanes] = useState<Lane[]>([]);
  810. const laneWarningsMemo = useMemo(
  811. () => computeTruckLaneWarnings(lanesToWarningInputRows(lanes)),
  812. [lanes],
  813. );
  814. const laneWarnCount = laneWarningsMemo.warnings.length;
  815. const selectLanesFromWarning = useCallback((w: TruckLaneWarning) => {
  816. const ids = Array.from(
  817. new Set(
  818. w.lanes
  819. .map((L) => String(L.laneKey ?? "").trim())
  820. .filter((k) => k !== ""),
  821. ),
  822. );
  823. if (ids.length === 0) return;
  824. setSelectedLaneIds(ids);
  825. setLaneWarnDrawerOpen(false);
  826. }, []);
  827. useEffect(() => {
  828. if (!laneWarnDrawerOpen) setLaneWarnExpandedIdx(null);
  829. }, [laneWarnDrawerOpen]);
  830. // Keep latest lanes snapshot for drag/drop computations.
  831. // This avoids relying on React state updater execution timing and prevents
  832. // side-effects inside state updaters (which can break under StrictMode/Concurrent).
  833. const lanesRef = useRef<Lane[]>([]);
  834. useEffect(() => {
  835. lanesRef.current = lanes;
  836. }, [lanes]);
  837. const versionDiffReqSeq = useRef(0);
  838. const [selectedLaneIds, setSelectedLaneIds] = useState<string[]>([]);
  839. const [routeBoardTab, setRouteBoardTab] = useState<"board" | "logistics">(
  840. "board",
  841. );
  842. const [laneFilter, setLaneFilter] = useState<{
  843. floor: "all" | "2F" | "4F";
  844. query: string;
  845. }>({
  846. floor: "all",
  847. query: "",
  848. });
  849. const [laneFilterAnchor, setLaneFilterAnchor] = useState<HTMLElement | null>(
  850. null,
  851. );
  852. // drag state (HTML5 drag & drop)
  853. const draggedRef = useRef<{ shopId: number; fromLaneId: string } | null>(
  854. null,
  855. );
  856. /** 物流商管理頁:拖曳整條車線指派 logistic */
  857. const logisticsLaneDragIdRef = useRef<string | null>(null);
  858. /** baseline: 後端目前 lane logisticId(用於判斷「只改物流商」也要能 Save) */
  859. const laneLogisticBaselineRef = useRef<Map<string, number | null>>(new Map());
  860. /** 店鋪列地區 baseline(載入/refresh 後同步),供未儲存清單標註地區差 */
  861. const shopDistrictBaselineRef = useRef<Map<number, string>>(new Map());
  862. const shopRowBaselineRef = useRef<Map<number, ShopRowBaseline>>(new Map());
  863. const [districtBaselineEpoch, setDistrictBaselineEpoch] = useState(0);
  864. const syncShopDistrictBaselineFromLanes = useCallback((laneList: Lane[]) => {
  865. const districtMap = new Map<number, string>();
  866. const rowMap = new Map<number, ShopRowBaseline>();
  867. for (const lane of laneList) {
  868. const fromLaneLabel = formatLaneLabel(lane.truckLanceCode, lane.remark);
  869. for (const s of lane.shops) {
  870. if (s.id <= 0) continue;
  871. districtMap.set(s.id, toDistrictDisplayName(s.districtReferenceRaw));
  872. rowMap.set(s.id, {
  873. laneId: lane.id,
  874. fromLaneLabel,
  875. departureTime: parseTimeForBackend(
  876. formatDepartureTime(s.departureTime as any),
  877. ),
  878. loadingSequence: Number(s.loadingSequence ?? 0) || 0,
  879. districtDisplay: toDistrictDisplayName(s.districtReferenceRaw),
  880. });
  881. }
  882. }
  883. shopDistrictBaselineRef.current = districtMap;
  884. shopRowBaselineRef.current = rowMap;
  885. setDistrictBaselineEpoch((e) => e + 1);
  886. }, []);
  887. const [dropIndicator, setDropIndicator] = useState<{
  888. laneId: string;
  889. beforeShopId: number | null;
  890. } | null>(null);
  891. /** 跨線拖曳等:來源 lane 可能沒有任何 dirty 列,儲存/還原時仍須一併 refetch */
  892. const lanesNeedingRefreshOnSaveRef = useRef<Set<string>>(new Set());
  893. // dirty tracking (shop row id -> new laneId)
  894. const [dirtyMoves, setDirtyMoves] = useState<Map<number, string>>(new Map());
  895. // staged deletes (truck row ids)
  896. const [dirtyDeletes, setDirtyDeletes] = useState<Set<number>>(new Set());
  897. const dirtyDeletesRef = useRef<Set<number>>(new Set());
  898. /** 暫刪列在 UI 已移除時仍要在版本 LOG「未儲存」小節顯示店鋪/來源車線 */
  899. const stagedDeleteMetaRef = useRef<Map<number, StagedDeleteMeta>>(new Map());
  900. useEffect(() => {
  901. dirtyDeletesRef.current = dirtyDeletes;
  902. }, [dirtyDeletes]);
  903. /** 立刻同步 ref,避免同一 tick 內 await 後 setLanes 仍讀到舊暫刪 */
  904. const clearDirtyDeletesState = useCallback(() => {
  905. dirtyDeletesRef.current = new Set();
  906. stagedDeleteMetaRef.current.clear();
  907. setDirtyDeletes(new Set());
  908. }, []);
  909. /** refresh/load 會用後端資料覆蓋 UI,須再過濾未 Save 的暫刪列 */
  910. const filterStagedDeletedShops = useCallback(
  911. (laneList: Lane[], del: Set<number>): Lane[] => {
  912. if (del.size === 0) return laneList;
  913. return laneList.map((lane) => ({
  914. ...lane,
  915. shops: lane.shops.filter((s) => !del.has(s.id)),
  916. }));
  917. },
  918. [],
  919. );
  920. const [departureEditLaneId, setDepartureEditLaneId] = useState<string | null>(
  921. null,
  922. );
  923. const [departureEditDraft, setDepartureEditDraft] = useState("");
  924. const [departureEditNameDraft, setDepartureEditNameDraft] = useState("");
  925. const [departureEditBusy, setDepartureEditBusy] = useState(false);
  926. const [renameLogs, setRenameLogs] = useState<TruckLaneRenameLogResponse[]>(
  927. [],
  928. );
  929. const [seqEditTarget, setSeqEditTarget] = useState<{
  930. laneId: string;
  931. shopId: number;
  932. } | null>(null);
  933. const [seqEditDraft, setSeqEditDraft] = useState("");
  934. const [saving, setSaving] = useState(false);
  935. const saveInFlightRef = useRef(false);
  936. const [saveResult, setSaveResult] = useState<{
  937. ok: boolean;
  938. message: string;
  939. } | null>(null);
  940. // version log (snapshot) UI
  941. const [logDialogOpen, setLogDialogOpen] = useState(false);
  942. const [loadingVersions, setLoadingVersions] = useState(false);
  943. const [logVersions, setLogVersions] = useState<any[]>([]);
  944. const [selectedLogVersionId, setSelectedLogVersionId] = useState<
  945. number | null
  946. >(null);
  947. const [diffLoading, setDiffLoading] = useState(false);
  948. const [diffError, setDiffError] = useState<string | null>(null);
  949. const [versionFilterAnchor, setVersionFilterAnchor] =
  950. useState<HTMLElement | null>(null);
  951. const [versionFilterQuery, setVersionFilterQuery] = useState("");
  952. const [versionFilterDate, setVersionFilterDate] = useState("");
  953. const [changedShopIds, setChangedShopIds] = useState<Set<number>>(new Set());
  954. const [logisticMasterDiffLines, setLogisticMasterDiffLines] = useState<
  955. LogisticMasterDiffLine[]
  956. >([]);
  957. const [diffLines, setDiffLines] = useState<
  958. Array<{
  959. truckRowId: number;
  960. shopCode: string | null;
  961. changes: Array<{ field: string; from: string | null; to: string | null }>;
  962. }>
  963. >([]);
  964. /** 版本 LOG:已排程、待「儲存更改」時才呼叫 restore API */
  965. const [pendingRestoreVersionId, setPendingRestoreVersionId] = useState<
  966. number | null
  967. >(null);
  968. const [versionNoteDrafts, setVersionNoteDrafts] = useState<
  969. Record<number, string>
  970. >({});
  971. const [savingVersionNoteId, setSavingVersionNoteId] = useState<number | null>(
  972. null,
  973. );
  974. const [versionNoteSaveError, setVersionNoteSaveError] = useState<{
  975. id: number;
  976. message: string;
  977. } | null>(null);
  978. const headVersionId = useMemo(
  979. () => resolveHeadVersionId(logVersions),
  980. [logVersions],
  981. );
  982. const displayedVersionLabel = useMemo(() => {
  983. if (
  984. pendingRestoreVersionId != null &&
  985. Number.isFinite(pendingRestoreVersionId) &&
  986. pendingRestoreVersionId > 0
  987. ) {
  988. return t("version_ui_pendingRestore", { id: pendingRestoreVersionId });
  989. }
  990. if (headVersionId != null) {
  991. return t("version_ui_id", { id: headVersionId });
  992. }
  993. return t("version_ui_none");
  994. }, [pendingRestoreVersionId, headVersionId, t]);
  995. const versionFilterActive =
  996. String(versionFilterQuery || "").trim() !== "" ||
  997. String(versionFilterDate || "").trim() !== "";
  998. const filteredLogVersions = useMemo(() => {
  999. const q = String(versionFilterQuery || "")
  1000. .trim()
  1001. .toLowerCase();
  1002. const exactDate = String(versionFilterDate || "").trim();
  1003. const hasDate = exactDate !== "";
  1004. const hasQ = q !== "";
  1005. if (!hasDate && !hasQ) return logVersions;
  1006. return (logVersions || []).filter((v) => {
  1007. const id = Number(v?.id);
  1008. const created = String(v?.created || "");
  1009. const { date } = splitVersionCreated(created);
  1010. if (hasDate) {
  1011. if (date !== exactDate) return false;
  1012. }
  1013. if (!hasQ) return true;
  1014. const note = v?.note != null ? String(v.note) : "";
  1015. const editor = resolveVersionActor(v ?? {});
  1016. const hay = `${id} ${note} ${editor} ${created}`.toLowerCase();
  1017. return hay.includes(q);
  1018. });
  1019. }, [logVersions, versionFilterDate, versionFilterQuery]);
  1020. const versionShopRows = useMemo(
  1021. () => diffLinesToShopRows(diffLines as TruckLaneVersionDiffLine[]),
  1022. [diffLines],
  1023. );
  1024. const versionRowSummary = useMemo(
  1025. () => summarizeVersionRows(versionShopRows),
  1026. [versionShopRows],
  1027. );
  1028. /** 新增店鋪:從 shop master 挑選 */
  1029. const [allShopsMaster, setAllShopsMaster] = useState<
  1030. Array<{ id: number; name: string; code: string }>
  1031. >([]);
  1032. const [scheduleModalOpen, setScheduleModalOpen] = useState(false);
  1033. const [scheduleHistoryOpen, setScheduleHistoryOpen] = useState(false);
  1034. const scheduleModalOpenRef = useRef(scheduleModalOpen);
  1035. scheduleModalOpenRef.current = scheduleModalOpen;
  1036. const {
  1037. pendingScheduleShopIds,
  1038. lockedScheduleShopIds,
  1039. failedScheduleShopIds,
  1040. failedScheduleCount,
  1041. refreshScheduleIndicators,
  1042. } = useRouteBoardScheduleIndicators({ paused: scheduleModalOpen });
  1043. /** 硬鎖:APPLYING 或進入鎖定時間窗的排程;遠期排程僅標記不鎖。 */
  1044. const scheduledShopIdSet = lockedScheduleShopIds;
  1045. const [addShopDialogOpen, setAddShopDialogOpen] = useState(false);
  1046. const [addShopLaneId, setAddShopLaneId] = useState<string | null>(null);
  1047. const [addShopPick, setAddShopPick] = useState<{
  1048. id: number;
  1049. name: string;
  1050. code: string;
  1051. } | null>(null);
  1052. type PendingShopAdd = {
  1053. tempTruckRowId: number;
  1054. laneId: string;
  1055. shopId: number;
  1056. shopName: string;
  1057. shopCode: string;
  1058. loadingSequence: number;
  1059. };
  1060. const [pendingShopAdds, setPendingShopAdds] = useState<PendingShopAdd[]>(
  1061. [],
  1062. );
  1063. const nextDraftTruckRowIdRef = useRef(-1_000_000_000);
  1064. const addShopConfirmLockRef = useRef(false);
  1065. const addRouteInFlightRef = useRef(false);
  1066. /** 車牌/司機等:truck 表尚無欄位,先暫存於此並在 loadLanes 後疊加到 Lane(刷新頁面會丟失) */
  1067. type LaneDisplayOverlay = Partial<
  1068. Pick<Lane, "plate" | "driver" | "phone" | "logisticsCompany">
  1069. >;
  1070. const laneDisplayOverlayRef = useRef<Map<string, LaneDisplayOverlay>>(
  1071. new Map(),
  1072. );
  1073. type NewRouteFormState = {
  1074. truckLanceCode: string;
  1075. /** 物流主檔 id;null = 未指定(至物流商管理指派) */
  1076. logisticId: number | null;
  1077. startTime: string;
  1078. storeId: "2F" | "4F";
  1079. remark: string;
  1080. };
  1081. const emptyNewRouteForm = (): NewRouteFormState => ({
  1082. truckLanceCode: "",
  1083. logisticId: null,
  1084. startTime: "07:30",
  1085. storeId: "2F",
  1086. remark: "",
  1087. });
  1088. const [addRouteDialogOpen, setAddRouteDialogOpen] = useState(false);
  1089. const [newRouteForm, setNewRouteForm] =
  1090. useState<NewRouteFormState>(emptyNewRouteForm);
  1091. const [addRouteSubmitting, setAddRouteSubmitting] = useState(false);
  1092. const [addRouteError, setAddRouteError] = useState<string | null>(null);
  1093. /** 尚未呼叫後端的新增車線(按「儲存更改」才 createTruckWithoutShop) */
  1094. const [pendingNewLanes, setPendingNewLanes] = useState<PendingNewLane[]>([]);
  1095. const pendingNewLanesRef = useRef<PendingNewLane[]>([]);
  1096. useEffect(() => {
  1097. pendingNewLanesRef.current = pendingNewLanes;
  1098. }, [pendingNewLanes]);
  1099. /** 看板末端「+」:從篩選後車線清單選一條,加入左欄勾選並捲動到該欄 */
  1100. const [boardQuickPickAnchorEl, setBoardQuickPickAnchorEl] =
  1101. useState<HTMLElement | null>(null);
  1102. /** 「+」快速選車線 Popover 內關鍵字(不影響左欄篩選) */
  1103. const [boardQuickPickSearch, setBoardQuickPickSearch] = useState("");
  1104. /** 車線內尚無任何店鋪列的暫存「地區」顯示名(僅前端;見 `buildLaneDistrictSections` 註解) */
  1105. const [pendingEmptyDistrictsByLane, setPendingEmptyDistrictsByLane] =
  1106. useState<Record<string, string[]>>({});
  1107. type DistrictEditCtx =
  1108. | { laneId: string; mode: "add" }
  1109. | { laneId: string; mode: "rename"; oldDisplay: string };
  1110. const [districtEditOpen, setDistrictEditOpen] = useState(false);
  1111. const [districtEditCtx, setDistrictEditCtx] = useState<DistrictEditCtx | null>(
  1112. null,
  1113. );
  1114. const [districtEditDraft, setDistrictEditDraft] = useState("");
  1115. const [districtEditError, setDistrictEditError] = useState<string | null>(null);
  1116. const districtEditSubmitLockRef = useRef(false);
  1117. /** `logistic` 表 logisticName(GET /logistic/all) */
  1118. const [logisticNamesFromDb, setLogisticNamesFromDb] = useState<string[]>([]);
  1119. const [logisticRowsFromDb, setLogisticRowsFromDb] = useState<LogisticRow[]>(
  1120. [],
  1121. );
  1122. const [pendingLogisticMasterAdds, setPendingLogisticMasterAdds] = useState<
  1123. Array<{ tempId: number } & SaveLogisticRequest>
  1124. >([]);
  1125. const [pendingLogisticMasterEdits, setPendingLogisticMasterEdits] = useState<
  1126. Map<number, SaveLogisticRequest>
  1127. >(new Map());
  1128. const [pendingLogisticMasterDeletes, setPendingLogisticMasterDeletes] =
  1129. useState<Set<number>>(new Set());
  1130. const nextPendingLogisticTempIdRef = useRef(-1);
  1131. const addLogisticInFlightRef = useRef(false);
  1132. const logisticRowsEffective = useMemo(() => {
  1133. const pendingRows: LogisticRow[] = pendingLogisticMasterAdds.map((p) => ({
  1134. id: p.tempId,
  1135. logisticName: p.logisticName,
  1136. carPlate: p.carPlate,
  1137. driverName: p.driverName,
  1138. driverNumber: p.driverNumber,
  1139. }));
  1140. const dbWithEdits = logisticRowsFromDb
  1141. .filter((r) => !pendingLogisticMasterDeletes.has(Number(r.id)))
  1142. .map((r) => {
  1143. const id = Number(r.id);
  1144. const edit = pendingLogisticMasterEdits.get(id);
  1145. if (!edit) return r;
  1146. return {
  1147. ...r,
  1148. logisticName: edit.logisticName,
  1149. carPlate: edit.carPlate,
  1150. driverName: edit.driverName,
  1151. driverNumber: edit.driverNumber,
  1152. };
  1153. });
  1154. return [...pendingRows, ...dbWithEdits];
  1155. }, [
  1156. pendingLogisticMasterAdds,
  1157. pendingLogisticMasterEdits,
  1158. pendingLogisticMasterDeletes,
  1159. logisticRowsFromDb,
  1160. ]);
  1161. const logisticNameById = useMemo(() => {
  1162. const m = new Map<number, string>();
  1163. for (const r of logisticRowsEffective) {
  1164. const id = Number((r as any)?.id);
  1165. const name = String((r as any)?.logisticName ?? "").trim();
  1166. if (!Number.isFinite(id) || id === 0 || name === "") continue;
  1167. m.set(id, name);
  1168. }
  1169. return m;
  1170. }, [logisticRowsEffective]);
  1171. const buildPendingLaneFromForm = useCallback(
  1172. (form: NewRouteFormState): Lane => {
  1173. const code = String(form.truckLanceCode || "").trim();
  1174. const storeNorm = normalizeStoreId(form.storeId);
  1175. const remarkRaw =
  1176. storeNorm === "4F" && String(form.remark || "").trim() !== ""
  1177. ? String(form.remark).trim()
  1178. : null;
  1179. const dep = parseTimeForBackend(form.startTime || "") || "00:00:00";
  1180. const laneKey = encodeLaneId(code, remarkRaw);
  1181. const lid =
  1182. form.logisticId != null && Number.isFinite(Number(form.logisticId))
  1183. ? Number(form.logisticId)
  1184. : null;
  1185. const master =
  1186. lid != null
  1187. ? logisticRowsEffective.find((r) => Number((r as any).id) === lid) ??
  1188. null
  1189. : null;
  1190. return {
  1191. id: laneKey,
  1192. truckLanceCode: code,
  1193. logisticsCompany: master
  1194. ? String(master.logisticName ?? "").trim()
  1195. : "",
  1196. logisticId: lid,
  1197. plate: master ? String(master.carPlate ?? "").trim() : "",
  1198. driver: master ? String(master.driverName ?? "").trim() : "",
  1199. phone:
  1200. master != null &&
  1201. (master as any).driverNumber != null &&
  1202. Number.isFinite(Number((master as any).driverNumber))
  1203. ? String((master as any).driverNumber)
  1204. : "",
  1205. startTime: dep,
  1206. storeId: storeNorm,
  1207. remark: remarkRaw,
  1208. shops: [],
  1209. persistedLane: false,
  1210. };
  1211. },
  1212. [logisticRowsEffective],
  1213. );
  1214. const buildCreateTruckWithoutShopPayload = useCallback(
  1215. (form: NewRouteFormState): CreateTruckWithoutShopRequest => {
  1216. const code = String(form.truckLanceCode || "").trim();
  1217. const storeNorm = normalizeStoreId(form.storeId);
  1218. const remarkRaw =
  1219. storeNorm === "4F" && String(form.remark || "").trim() !== ""
  1220. ? String(form.remark).trim()
  1221. : null;
  1222. return {
  1223. store_id: storeNorm,
  1224. truckLanceCode: code,
  1225. departureTime: parseTimeForBackend(form.startTime || "") || "00:00:00",
  1226. loadingSequence: 0,
  1227. districtReference: null,
  1228. remark: remarkRaw,
  1229. logisticId: form.logisticId,
  1230. };
  1231. },
  1232. [],
  1233. );
  1234. const [addLogisticOpen, setAddLogisticOpen] = useState(false);
  1235. const [addLogisticSubmitting, setAddLogisticSubmitting] = useState(false);
  1236. const [addLogisticError, setAddLogisticError] = useState<string | null>(null);
  1237. const [addLogisticForm, setAddLogisticForm] = useState({
  1238. logisticName: "",
  1239. carPlate: "",
  1240. driverName: "",
  1241. driverPhone: "",
  1242. });
  1243. const [editLogisticOpen, setEditLogisticOpen] = useState(false);
  1244. const [editLogisticSubmitting, setEditLogisticSubmitting] = useState(false);
  1245. const editLogisticInFlightRef = useRef(false);
  1246. const [editLogisticError, setEditLogisticError] = useState<string | null>(
  1247. null,
  1248. );
  1249. const [editLogisticForm, setEditLogisticForm] = useState({
  1250. id: 0,
  1251. logisticName: "",
  1252. carPlate: "",
  1253. driverName: "",
  1254. driverPhone: "",
  1255. });
  1256. const [logisticsDropHoverCompany, setLogisticsDropHoverCompany] = useState<
  1257. string | null
  1258. >(null);
  1259. const enrichLanesWithLogisticMaster = useCallback(
  1260. (list: Lane[]): Lane[] => {
  1261. if (!Array.isArray(list) || list.length === 0) return list;
  1262. if (!Array.isArray(logisticRowsEffective) || logisticRowsEffective.length === 0)
  1263. return list;
  1264. const byId = new Map<number, LogisticRow>();
  1265. for (const r of logisticRowsEffective) {
  1266. const id = Number((r as any)?.id);
  1267. if (!Number.isFinite(id) || id === 0) continue;
  1268. if (!byId.has(id)) byId.set(id, r);
  1269. }
  1270. return list.map((lane) => {
  1271. const lid = lane.logisticId != null ? Number(lane.logisticId) : NaN;
  1272. if (!Number.isFinite(lid) || lid === 0) return lane;
  1273. const master = byId.get(lid);
  1274. if (!master) return lane;
  1275. const plate = String((master as any).carPlate ?? "").trim();
  1276. const driver = String((master as any).driverName ?? "").trim();
  1277. const phone =
  1278. (master as any).driverNumber != null &&
  1279. Number.isFinite(Number((master as any).driverNumber))
  1280. ? String((master as any).driverNumber)
  1281. : "";
  1282. return {
  1283. ...lane,
  1284. plate:
  1285. lane.plate && String(lane.plate).trim() !== "" ? lane.plate : plate,
  1286. driver:
  1287. lane.driver && String(lane.driver).trim() !== ""
  1288. ? lane.driver
  1289. : driver,
  1290. phone:
  1291. lane.phone && String(lane.phone).trim() !== "" ? lane.phone : phone,
  1292. };
  1293. });
  1294. },
  1295. [logisticRowsEffective],
  1296. );
  1297. const applyLaneDisplayOverlays = (list: Lane[]): Lane[] => {
  1298. const map = laneDisplayOverlayRef.current;
  1299. return list.map((lane) => {
  1300. const o = map.get(lane.id);
  1301. if (!o) return lane;
  1302. return {
  1303. ...lane,
  1304. plate: o.plate !== undefined && o.plate !== "" ? o.plate : lane.plate,
  1305. driver:
  1306. o.driver !== undefined && o.driver !== "" ? o.driver : lane.driver,
  1307. phone: o.phone !== undefined && o.phone !== "" ? o.phone : lane.phone,
  1308. logisticsCompany:
  1309. o.logisticsCompany !== undefined && o.logisticsCompany !== ""
  1310. ? o.logisticsCompany
  1311. : lane.logisticsCompany,
  1312. // DB 的 logisticId 不給 overlay 改
  1313. logisticId: lane.logisticId,
  1314. };
  1315. });
  1316. };
  1317. const refreshLanesByIds = async (
  1318. laneIds: string[],
  1319. options: { preserveStagedLogistics?: boolean } = {},
  1320. ): Promise<Lane[] | null> => {
  1321. const uniq = Array.from(new Set(laneIds)).filter(Boolean);
  1322. if (uniq.length === 0) return null;
  1323. const preserveStagedLogistics = options.preserveStagedLogistics ?? true;
  1324. const stagedLogisticsByLane = new Map<string, Lane>();
  1325. if (preserveStagedLogistics) {
  1326. for (const lane of lanesRef.current) {
  1327. const currentLogisticId =
  1328. lane.logisticId != null ? Number(lane.logisticId) : null;
  1329. const baselineLogisticId = laneLogisticBaselineRef.current.has(lane.id)
  1330. ? laneLogisticBaselineRef.current.get(lane.id) ?? null
  1331. : null;
  1332. if (currentLogisticId !== baselineLogisticId) {
  1333. stagedLogisticsByLane.set(lane.id, lane);
  1334. }
  1335. }
  1336. }
  1337. const refreshed: Lane[] = [];
  1338. for (const laneId of uniq) {
  1339. const d = decodeLaneId(laneId);
  1340. if (!d) continue;
  1341. try {
  1342. const lane = await fetchLaneByKey(d.truckLanceCode, d.remark);
  1343. if (lane) refreshed.push(lane);
  1344. } catch (e) {
  1345. console.warn("refresh lane failed", laneId, e);
  1346. }
  1347. }
  1348. if (refreshed.length === 0) return null;
  1349. const mergedRefreshed = refreshed.map((lane) => {
  1350. const staged = stagedLogisticsByLane.get(lane.id);
  1351. if (!staged) return lane;
  1352. return {
  1353. ...lane,
  1354. logisticId: staged.logisticId ?? null,
  1355. logisticsCompany: staged.logisticsCompany,
  1356. plate: staged.plate,
  1357. driver: staged.driver,
  1358. phone: staged.phone,
  1359. };
  1360. });
  1361. // 同步 baseline(以 server 回來的 lane 為準)
  1362. for (const lane of refreshed) {
  1363. if (stagedLogisticsByLane.has(lane.id)) continue;
  1364. laneLogisticBaselineRef.current.set(
  1365. lane.id,
  1366. lane.logisticId != null ? Number(lane.logisticId) : null,
  1367. );
  1368. }
  1369. let nextApplied: Lane[] | null = null;
  1370. setLanes((prev) => {
  1371. const next = filterStagedDeletedShops(
  1372. applyLaneDisplayOverlays(
  1373. enrichLanesWithLogisticMaster(
  1374. mergePendingNewLanesIntoLanes(
  1375. mergeRefreshedLanes(prev, mergedRefreshed),
  1376. pendingNewLanesRef.current,
  1377. ),
  1378. ),
  1379. ),
  1380. dirtyDeletesRef.current,
  1381. );
  1382. nextApplied = next;
  1383. return next;
  1384. });
  1385. if (nextApplied != null) {
  1386. syncShopDistrictBaselineFromLanes(nextApplied);
  1387. }
  1388. return nextApplied;
  1389. };
  1390. const loadLanes = async () => {
  1391. if (loadLanesInFlightRef.current) return;
  1392. loadLanesInFlightRef.current = true;
  1393. setLoading(true);
  1394. setError(null);
  1395. setPendingEmptyDistrictsByLane({});
  1396. closeDistrictEdit();
  1397. try {
  1398. // build shopCode -> real shop name map (from shop table)
  1399. try {
  1400. const shopRows = (await fetchAllShopsClient()) as Array<{
  1401. id?: any;
  1402. code?: any;
  1403. name?: any;
  1404. }>;
  1405. const master = dedupeShopMasterRows(shopRows || []);
  1406. const map = new Map<string, string>();
  1407. master.forEach((row) => {
  1408. const code = String(row.code ?? "")
  1409. .trim()
  1410. .toLowerCase();
  1411. const name = String(row.name ?? "").trim();
  1412. if (code && name) map.set(code, name);
  1413. });
  1414. setAllShopsMaster(master);
  1415. setShopNameByCodeMap(map);
  1416. } catch (e) {
  1417. // non-blocking: still show branchName if shop table fetch fails
  1418. console.warn("Failed to load shop table names:", e);
  1419. }
  1420. // O(1) load: 後端一次回傳所有 truck rows;前端自行按 lane 分桶
  1421. const allRows = (await findAllForRouteBoardClient()) as Truck[];
  1422. const bucket = new Map<
  1423. string,
  1424. {
  1425. truckLanceCode: string;
  1426. remark: string | null;
  1427. meta: Truck;
  1428. rows: Truck[];
  1429. }
  1430. >();
  1431. for (const row of allRows || []) {
  1432. const code = String((row as any)?.truckLanceCode ?? "").trim();
  1433. if (!code) continue;
  1434. const remarkRaw = String((row as any)?.remark ?? "").trim();
  1435. const remark = remarkRaw !== "" ? remarkRaw : null;
  1436. const id = encodeLaneId(code, remark);
  1437. const b = bucket.get(id);
  1438. if (!b) {
  1439. bucket.set(id, {
  1440. truckLanceCode: code,
  1441. remark,
  1442. meta: row,
  1443. rows: [row],
  1444. });
  1445. } else {
  1446. b.rows.push(row);
  1447. }
  1448. }
  1449. const loaded: Lane[] = Array.from(bucket.values())
  1450. .map((b) => {
  1451. b.rows.sort((a: any, c: any) => {
  1452. const sa = Number(a?.loadingSequence ?? 0) || 0;
  1453. const sb = Number(c?.loadingSequence ?? 0) || 0;
  1454. if (sa !== sb) return sa - sb;
  1455. const ia = Number(a?.id ?? 0) || 0;
  1456. const ib = Number(c?.id ?? 0) || 0;
  1457. return ia - ib;
  1458. });
  1459. return buildLaneFromTruckRows(
  1460. b.truckLanceCode,
  1461. b.remark,
  1462. b.rows,
  1463. b.meta,
  1464. );
  1465. });
  1466. const loadedWithPending = mergePendingNewLanesIntoLanes(
  1467. sortLanesByCode(loaded),
  1468. pendingNewLanesRef.current,
  1469. );
  1470. const nextBoard = filterStagedDeletedShops(
  1471. applyLaneDisplayOverlays(
  1472. enrichLanesWithLogisticMaster(loadedWithPending),
  1473. ),
  1474. dirtyDeletesRef.current,
  1475. );
  1476. lanesRef.current = nextBoard;
  1477. setLanes(nextBoard);
  1478. // 初始化 baseline(以 server 回來的 lane 為準)
  1479. laneLogisticBaselineRef.current = new Map(
  1480. nextBoard.map((l) => [
  1481. l.id,
  1482. l.logisticId != null ? Number(l.logisticId) : null,
  1483. ]),
  1484. );
  1485. syncShopDistrictBaselineFromLanes(nextBoard);
  1486. setPendingLogisticMasterAdds([]);
  1487. setPendingLogisticMasterEdits(new Map());
  1488. setPendingLogisticMasterDeletes(new Set());
  1489. pendingImportFileRef.current = null;
  1490. setPendingImportMeta(null);
  1491. // default: select none (user will pick lanes)
  1492. setSelectedLaneIds((prev) => prev);
  1493. } catch (e: any) {
  1494. console.error("Failed to load lanes:", e);
  1495. setError(e?.message ?? String(e) ?? t("err_loadLanes"));
  1496. } finally {
  1497. setLoading(false);
  1498. loadLanesInFlightRef.current = false;
  1499. }
  1500. };
  1501. useEffect(() => {
  1502. // Next dev StrictMode 會 double-invoke effect;這裡保證初始化只載一次
  1503. if (didInitialLoadRef.current) return;
  1504. didInitialLoadRef.current = true;
  1505. void loadLanes();
  1506. // eslint-disable-next-line react-hooks/exhaustive-deps
  1507. }, []);
  1508. const reloadLogisticNamesFromDb = useCallback(async () => {
  1509. try {
  1510. const rows = await findAllLogisticsClient();
  1511. setLogisticRowsFromDb(rows || []);
  1512. const names = Array.from(
  1513. new Set(
  1514. (rows || [])
  1515. .map((r) => String(r.logisticName ?? "").trim())
  1516. .filter((n) => n !== ""),
  1517. ),
  1518. ).sort((a, b) => a.localeCompare(b, "zh-Hant"));
  1519. setLogisticNamesFromDb(names);
  1520. } catch (e) {
  1521. console.warn("Failed to load logistic master (logisticName):", e);
  1522. }
  1523. }, []);
  1524. const resolveLogisticIdForCompanyLabel = useCallback(
  1525. (company: string): number | null => {
  1526. const c = String(company).trim();
  1527. if (c === "" || c === "未分配物流商") return null;
  1528. const row = logisticRowsEffective.find(
  1529. (r) => String(r.logisticName ?? "").trim() === c,
  1530. );
  1531. return row != null && row.id != null ? Number(row.id) : null;
  1532. },
  1533. [logisticRowsEffective],
  1534. );
  1535. const getColumnTargetLogisticId = useCallback(
  1536. (company: string, lanesInColumn: Lane[]): number | null => {
  1537. if (company === "未分配物流商") return null;
  1538. const withId = lanesInColumn.find(
  1539. (l) =>
  1540. l.logisticId != null &&
  1541. Number.isFinite(Number(l.logisticId)) &&
  1542. Number(l.logisticId) !== 0,
  1543. );
  1544. if (withId?.logisticId != null) return Number(withId.logisticId);
  1545. return resolveLogisticIdForCompanyLabel(company);
  1546. },
  1547. [resolveLogisticIdForCompanyLabel],
  1548. );
  1549. useEffect(() => {
  1550. void reloadLogisticNamesFromDb();
  1551. }, [reloadLogisticNamesFromDb]);
  1552. useEffect(() => {
  1553. if (!Array.isArray(logisticRowsEffective) || logisticRowsEffective.length === 0)
  1554. return;
  1555. setLanes((prev) =>
  1556. filterStagedDeletedShops(
  1557. applyLaneDisplayOverlays(enrichLanesWithLogisticMaster(prev)),
  1558. dirtyDeletesRef.current,
  1559. ),
  1560. );
  1561. // eslint-disable-next-line react-hooks/exhaustive-deps
  1562. }, [logisticRowsEffective]);
  1563. const submitAddLogistic = async () => {
  1564. if (addLogisticInFlightRef.current) return;
  1565. const logisticName = String(addLogisticForm.logisticName || "").trim();
  1566. const carPlate = String(addLogisticForm.carPlate || "").trim();
  1567. const driverName = String(addLogisticForm.driverName || "").trim();
  1568. const driverNumber = phoneDigitsToDriverNumber(addLogisticForm.driverPhone);
  1569. if (!logisticName || !carPlate || !driverName) {
  1570. setAddLogisticError(t("val_logisticsRequired"));
  1571. return;
  1572. }
  1573. if (driverNumber == null) {
  1574. setAddLogisticError(t("val_phoneInvalid"));
  1575. return;
  1576. }
  1577. const dup = logisticRowsEffective.some(
  1578. (r) =>
  1579. String(r.logisticName ?? "").trim().toLowerCase() ===
  1580. logisticName.toLowerCase(),
  1581. );
  1582. if (dup) {
  1583. setAddLogisticError(t("val_logisticsDuplicateName"));
  1584. return;
  1585. }
  1586. addLogisticInFlightRef.current = true;
  1587. setAddLogisticError(null);
  1588. setAddLogisticSubmitting(true);
  1589. try {
  1590. const tempId = nextPendingLogisticTempIdRef.current;
  1591. nextPendingLogisticTempIdRef.current -= 1;
  1592. setPendingLogisticMasterAdds((prev) => [
  1593. ...prev,
  1594. {
  1595. tempId,
  1596. logisticName,
  1597. carPlate,
  1598. driverName,
  1599. driverNumber,
  1600. },
  1601. ]);
  1602. setAddLogisticForm({
  1603. logisticName: "",
  1604. carPlate: "",
  1605. driverName: "",
  1606. driverPhone: "",
  1607. });
  1608. setAddLogisticOpen(false);
  1609. setSaveResult(null);
  1610. } finally {
  1611. setAddLogisticSubmitting(false);
  1612. addLogisticInFlightRef.current = false;
  1613. }
  1614. };
  1615. const openEditLogistic = (row: LogisticRow) => {
  1616. setEditLogisticError(null);
  1617. const id = Number(row.id);
  1618. const pending = pendingLogisticMasterEdits.get(id);
  1619. setEditLogisticForm({
  1620. id: row.id,
  1621. logisticName: String(
  1622. pending?.logisticName ?? row.logisticName ?? "",
  1623. ).trim(),
  1624. carPlate: String(pending?.carPlate ?? row.carPlate ?? "").trim(),
  1625. driverName: String(pending?.driverName ?? row.driverName ?? "").trim(),
  1626. driverPhone:
  1627. pending?.driverNumber != null && Number.isFinite(pending.driverNumber)
  1628. ? String(pending.driverNumber)
  1629. : row.driverNumber != null && Number.isFinite(row.driverNumber)
  1630. ? String(row.driverNumber)
  1631. : "",
  1632. });
  1633. setEditLogisticOpen(true);
  1634. };
  1635. const submitEditLogistic = async () => {
  1636. if (editLogisticInFlightRef.current) return;
  1637. const logisticName = String(editLogisticForm.logisticName || "").trim();
  1638. const carPlate = String(editLogisticForm.carPlate || "").trim();
  1639. const driverName = String(editLogisticForm.driverName || "").trim();
  1640. const driverNumber = phoneDigitsToDriverNumber(
  1641. editLogisticForm.driverPhone,
  1642. );
  1643. if (!editLogisticForm.id || editLogisticForm.id <= 0) {
  1644. setEditLogisticError(t("err_invalidMasterId"));
  1645. return;
  1646. }
  1647. if (!logisticName || !carPlate || !driverName) {
  1648. setEditLogisticError(t("val_logisticsRequired"));
  1649. return;
  1650. }
  1651. if (driverNumber == null) {
  1652. setEditLogisticError(t("val_phoneInvalid"));
  1653. return;
  1654. }
  1655. setEditLogisticError(null);
  1656. editLogisticInFlightRef.current = true;
  1657. setEditLogisticSubmitting(true);
  1658. try {
  1659. const lid = editLogisticForm.id;
  1660. setPendingLogisticMasterEdits((prev) => {
  1661. const next = new Map(prev);
  1662. next.set(lid, {
  1663. id: lid,
  1664. logisticName,
  1665. carPlate,
  1666. driverName,
  1667. driverNumber,
  1668. });
  1669. return next;
  1670. });
  1671. const phone =
  1672. driverNumber != null && Number.isFinite(driverNumber)
  1673. ? String(driverNumber)
  1674. : "";
  1675. setLanes((prev) =>
  1676. filterStagedDeletedShops(
  1677. applyLaneDisplayOverlays(
  1678. prev.map((lane) =>
  1679. lane.logisticId === lid
  1680. ? {
  1681. ...lane,
  1682. logisticsCompany: logisticName,
  1683. plate: carPlate,
  1684. driver: driverName,
  1685. phone,
  1686. }
  1687. : lane,
  1688. ),
  1689. ),
  1690. dirtyDeletesRef.current,
  1691. ),
  1692. );
  1693. setSaveResult(null);
  1694. setEditLogisticOpen(false);
  1695. } catch (e: any) {
  1696. setEditLogisticError(e?.message ?? String(e) ?? t("err_save"));
  1697. } finally {
  1698. setEditLogisticSubmitting(false);
  1699. editLogisticInFlightRef.current = false;
  1700. }
  1701. };
  1702. const stageDeleteLogistic = useCallback(
  1703. (row: LogisticRow, companyLabel: string) => {
  1704. const id = Number(row.id);
  1705. if (!Number.isFinite(id)) return;
  1706. const name =
  1707. String(companyLabel || row.logisticName || "").trim() || "—";
  1708. if (!window.confirm(t("confirm_deleteLogistic", { name }))) return;
  1709. if (id < 0) {
  1710. setPendingLogisticMasterAdds((prev) =>
  1711. prev.filter((p) => p.tempId !== id),
  1712. );
  1713. } else {
  1714. setPendingLogisticMasterDeletes((prev) => {
  1715. const next = new Set(prev);
  1716. next.add(id);
  1717. return next;
  1718. });
  1719. }
  1720. setPendingLogisticMasterEdits((prev) => {
  1721. const next = new Map(prev);
  1722. next.delete(id);
  1723. return next;
  1724. });
  1725. setSaveResult(null);
  1726. },
  1727. [t],
  1728. );
  1729. const handleExportSelectedLanesExcel = useCallback(async () => {
  1730. if (selectedLaneIds.length === 0) {
  1731. setError(t("err_exportNeedSelection"));
  1732. return;
  1733. }
  1734. setRouteExcelBusy(true);
  1735. setError(null);
  1736. try {
  1737. const { base64, filename } =
  1738. await exportRouteLanesExcelClient(selectedLaneIds);
  1739. downloadBase64Xlsx(base64, filename);
  1740. } catch (e: any) {
  1741. setError(e?.message ?? String(e) ?? t("err_export"));
  1742. } finally {
  1743. setRouteExcelBusy(false);
  1744. }
  1745. }, [selectedLaneIds, t]);
  1746. const handleExportRouteReportExcel = useCallback(async () => {
  1747. if (lanes.length === 0) {
  1748. setError(t("err_noLanes"));
  1749. return;
  1750. }
  1751. setRouteExcelBusy(true);
  1752. setError(null);
  1753. try {
  1754. // 空 laneIds = 匯出 RouteBoard 全部(避免傳輸一大串 ids)
  1755. const { base64, filename } = await exportRouteReportExcelClient([]);
  1756. downloadBase64Xlsx(base64, filename);
  1757. } catch (e: any) {
  1758. setError(e?.message ?? String(e) ?? t("err_export"));
  1759. } finally {
  1760. setRouteExcelBusy(false);
  1761. }
  1762. }, [lanes, t]);
  1763. const handleImportRouteExcelChange = async (
  1764. e: React.ChangeEvent<HTMLInputElement>,
  1765. ) => {
  1766. const file = e.target.files?.[0];
  1767. e.target.value = "";
  1768. if (!file) return;
  1769. const hadOther =
  1770. dirtyMoves.size > 0 ||
  1771. dirtyDeletes.size > 0 ||
  1772. pendingShopAdds.length > 0 ||
  1773. pendingNewLanes.length > 0 ||
  1774. pendingLogisticMasterAdds.length > 0 ||
  1775. pendingLogisticMasterEdits.size > 0 ||
  1776. pendingLogisticMasterDeletes.size > 0 ||
  1777. pendingRestoreVersionId != null ||
  1778. pendingImportMeta != null ||
  1779. Object.values(pendingEmptyDistrictsByLane).some(
  1780. (a) => (a?.length ?? 0) > 0,
  1781. );
  1782. if (hadOther && !window.confirm(t("confirm_importDiscardEdits"))) return;
  1783. setRouteExcelBusy(true);
  1784. setError(null);
  1785. try {
  1786. const fd = new FormData();
  1787. fd.append("multipartFileList", file);
  1788. const parsed = await parseRouteLanesExcelClient(fd);
  1789. if (!parsed.rowCount || parsed.rowCount <= 0) {
  1790. setError(t("err_importEmpty"));
  1791. return;
  1792. }
  1793. setDirtyMoves(new Map());
  1794. clearDirtyDeletesState();
  1795. setPendingShopAdds([]);
  1796. setPendingNewLanes([]);
  1797. setPendingLogisticMasterAdds([]);
  1798. setPendingLogisticMasterEdits(new Map());
  1799. setPendingLogisticMasterDeletes(new Set());
  1800. setPendingRestoreVersionId(null);
  1801. setPendingEmptyDistrictsByLane({});
  1802. setDistrictEditOpen(false);
  1803. setDistrictEditCtx(null);
  1804. setDepartureEditLaneId(null);
  1805. setSeqEditTarget(null);
  1806. lanesNeedingRefreshOnSaveRef.current.clear();
  1807. laneDisplayOverlayRef.current.clear();
  1808. const merged = mergeImportPreviewIntoLanes(lanesRef.current, parsed.rows);
  1809. const nextBoard = applyLaneDisplayOverlays(
  1810. enrichLanesWithLogisticMaster(merged),
  1811. );
  1812. lanesRef.current = nextBoard;
  1813. setLanes(nextBoard);
  1814. syncShopDistrictBaselineFromLanes(nextBoard);
  1815. pendingImportFileRef.current = file;
  1816. setPendingImportMeta({
  1817. fileName: file.name,
  1818. sheetCount: parsed.sheetCount,
  1819. rowCount: parsed.rowCount,
  1820. });
  1821. setSaveResult({
  1822. ok: true,
  1823. message: t("import_staged_preview", {
  1824. file: file.name,
  1825. sheets: parsed.sheetCount,
  1826. rows: parsed.rowCount,
  1827. }),
  1828. });
  1829. } catch (err: any) {
  1830. setError(err?.message ?? String(err) ?? t("err_import"));
  1831. } finally {
  1832. setRouteExcelBusy(false);
  1833. }
  1834. };
  1835. const filteredLanes = useMemo(() => {
  1836. const picked = lanes.filter((l) => selectedLaneIds.includes(l.id));
  1837. const term = String(searchTerm || "")
  1838. .trim()
  1839. .toLowerCase();
  1840. if (!term) return picked;
  1841. return picked.map((lane) => ({
  1842. ...lane,
  1843. shops: lane.shops.filter((s) => {
  1844. const codeLower = String(s.shopCode || "")
  1845. .trim()
  1846. .toLowerCase();
  1847. const realName = shopNameByCodeMap.get(codeLower) ?? "";
  1848. const name = String(realName || "").toLowerCase();
  1849. const branch = String(s.branchName || "").toLowerCase();
  1850. const code = String(s.shopCode || "").toLowerCase();
  1851. const district = String(s.districtReferenceRaw || "").toLowerCase();
  1852. return (
  1853. name.includes(term) ||
  1854. branch.includes(term) ||
  1855. code.includes(term) ||
  1856. district.includes(term)
  1857. );
  1858. }),
  1859. }));
  1860. }, [lanes, selectedLaneIds, searchTerm, shopNameByCodeMap]);
  1861. const visibleLaneOptions = useMemo(() => {
  1862. const q = String(laneFilter.query || "")
  1863. .trim()
  1864. .toLowerCase();
  1865. return (lanes || []).filter((lane) => {
  1866. const code = String(lane.truckLanceCode || "").toLowerCase();
  1867. const rem = String(lane.remark ?? "").toLowerCase();
  1868. const floorOk =
  1869. laneFilter.floor === "all"
  1870. ? true
  1871. : normalizeStoreId(lane.storeId) === laneFilter.floor;
  1872. const qOk = !q ? true : code.includes(q) || rem.includes(q);
  1873. return floorOk && qOk;
  1874. });
  1875. }, [lanes, laneFilter.floor, laneFilter.query]);
  1876. /** 「+」與看板欄:只吃樓層,不吃左欄關鍵字(與 `visibleLaneOptions` 分離) */
  1877. const lanesMatchingFloorOnly = useMemo(() => {
  1878. return (lanes || []).filter((lane) => {
  1879. const floorOk =
  1880. laneFilter.floor === "all"
  1881. ? true
  1882. : normalizeStoreId(lane.storeId) === laneFilter.floor;
  1883. return floorOk;
  1884. });
  1885. }, [lanes, laneFilter.floor]);
  1886. const boardQuickPickFilteredLanes = useMemo(() => {
  1887. const q = String(boardQuickPickSearch).trim().toLowerCase();
  1888. if (!q) return lanesMatchingFloorOnly;
  1889. return lanesMatchingFloorOnly.filter((lane) => {
  1890. const code = String(lane.truckLanceCode || "").toLowerCase();
  1891. const rem = String(lane.remark ?? "").toLowerCase();
  1892. const driver = String(lane.driver ?? "").toLowerCase();
  1893. const plate = String(lane.plate ?? "").toLowerCase();
  1894. return (
  1895. code.includes(q) ||
  1896. rem.includes(q) ||
  1897. driver.includes(q) ||
  1898. plate.includes(q)
  1899. );
  1900. });
  1901. }, [lanesMatchingFloorOnly, boardQuickPickSearch]);
  1902. const scrollBoardLaneCardIntoView = useCallback((laneId: string) => {
  1903. const safe =
  1904. typeof CSS !== "undefined" && typeof CSS.escape === "function"
  1905. ? CSS.escape(laneId)
  1906. : laneId.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
  1907. document
  1908. .querySelector(`[data-lane-id="${safe}"]`)
  1909. ?.scrollIntoView({
  1910. behavior: "smooth",
  1911. inline: "center",
  1912. block: "nearest",
  1913. });
  1914. }, []);
  1915. const applyBoardQuickPickLane = useCallback(
  1916. (laneId: string) => {
  1917. setBoardQuickPickAnchorEl(null);
  1918. setBoardQuickPickSearch("");
  1919. setSelectedLaneIds((prev) =>
  1920. prev.includes(laneId) ? prev : [...prev, laneId],
  1921. );
  1922. requestAnimationFrame(() => {
  1923. requestAnimationFrame(() => scrollBoardLaneCardIntoView(laneId));
  1924. });
  1925. },
  1926. [scrollBoardLaneCardIntoView],
  1927. );
  1928. /** 新增車線:物流公司下拉選單(依名稱排序) */
  1929. const logisticRowsSortedForSelect = useMemo(
  1930. () =>
  1931. [...logisticRowsEffective].sort((a, b) =>
  1932. String(a.logisticName ?? "").localeCompare(
  1933. String(b.logisticName ?? ""),
  1934. "zh-Hant",
  1935. ),
  1936. ),
  1937. [logisticRowsEffective],
  1938. );
  1939. const logisticNamesEffective = useMemo(() => {
  1940. const set = new Set<string>();
  1941. for (const p of pendingLogisticMasterAdds) {
  1942. const n = String(p.logisticName ?? "").trim();
  1943. if (n) set.add(n);
  1944. }
  1945. for (const r of logisticRowsFromDb) {
  1946. const id = Number(r.id);
  1947. if (pendingLogisticMasterDeletes.has(id)) continue;
  1948. const s = String(r.logisticName ?? "").trim();
  1949. if (s) set.add(s);
  1950. }
  1951. return Array.from(set).sort((a, b) => a.localeCompare(b, "zh-Hant"));
  1952. }, [
  1953. pendingLogisticMasterAdds,
  1954. pendingLogisticMasterDeletes,
  1955. logisticRowsFromDb,
  1956. ]);
  1957. /** 依樓層(不含車線下拉搜尋關鍵字)分組;併入 GET /logistic/all 有、但尚未掛車線的公司 */
  1958. const lanesByLogisticsCompany = useMemo(() => {
  1959. const map = new Map<string, Lane[]>();
  1960. for (const lane of lanesMatchingFloorOnly) {
  1961. if (
  1962. selectedLaneIds.length > 0 &&
  1963. !selectedLaneIds.includes(lane.id)
  1964. ) {
  1965. continue;
  1966. }
  1967. const company =
  1968. String(lane.logisticsCompany ?? "").trim() || "未分配物流商";
  1969. const arr = map.get(company) ?? [];
  1970. arr.push(lane);
  1971. map.set(company, arr);
  1972. }
  1973. for (const name of logisticNamesEffective) {
  1974. const n = String(name).trim();
  1975. if (n && !map.has(n)) map.set(n, []);
  1976. }
  1977. const entries = Array.from(map.entries());
  1978. entries.sort((a, b) => {
  1979. if (a[0] === "未分配物流商" && b[0] !== "未分配物流商") return 1;
  1980. if (b[0] === "未分配物流商" && a[0] !== "未分配物流商") return -1;
  1981. return a[0].localeCompare(b[0], "zh-Hant");
  1982. });
  1983. return entries;
  1984. }, [lanesMatchingFloorOnly, logisticNamesEffective, selectedLaneIds]);
  1985. const addShopCandidates = useMemo(() => {
  1986. if (!addShopLaneId) return [];
  1987. const lane = lanes.find((l) => l.id === addShopLaneId);
  1988. if (!lane) return [];
  1989. const codesInLane = new Set(
  1990. lane.shops
  1991. .map((s) =>
  1992. String(s.shopCode || "")
  1993. .trim()
  1994. .toLowerCase(),
  1995. )
  1996. .filter((c) => c !== ""),
  1997. );
  1998. const idsInLane = new Set(
  1999. lane.shops
  2000. .map((s) => s.shopEntityId)
  2001. .filter((x): x is number => typeof x === "number" && x > 0),
  2002. );
  2003. return allShopsMaster.filter((m) => {
  2004. const c = String(m.code || "")
  2005. .trim()
  2006. .toLowerCase();
  2007. if (c && codesInLane.has(c)) return false;
  2008. if (idsInLane.has(m.id)) return false;
  2009. return true;
  2010. });
  2011. }, [addShopLaneId, lanes, allShopsMaster]);
  2012. const assertMsgOk = (res: MessageResponse, fallback: string) => {
  2013. const msg = res?.message != null ? String(res.message) : "";
  2014. if (msg.startsWith("Error:") || msg.startsWith("Error :")) {
  2015. throw new Error(msg || fallback);
  2016. }
  2017. };
  2018. const handleLogisticsDropOnCompany = (
  2019. targetCompany: string,
  2020. companyLanes: Lane[],
  2021. ) => {
  2022. const dragId = logisticsLaneDragIdRef.current;
  2023. logisticsLaneDragIdRef.current = null;
  2024. setLogisticsDropHoverCompany(null);
  2025. if (!dragId) return;
  2026. const lane = lanesRef.current.find((l) => l.id === dragId);
  2027. if (!lane) return;
  2028. const targetId = getColumnTargetLogisticId(targetCompany, companyLanes);
  2029. if (targetCompany !== "未分配物流商" && targetId == null) {
  2030. setError(
  2031. t("logistic_needMasterTpl", { name: targetCompany }),
  2032. );
  2033. return;
  2034. }
  2035. const fromId = lane.logisticId ?? null;
  2036. if (targetCompany === "未分配物流商") {
  2037. if (fromId == null) return;
  2038. } else if (targetId != null && fromId === targetId) {
  2039. return;
  2040. }
  2041. const targetMaster =
  2042. targetId != null
  2043. ? logisticRowsEffective.find((r) => Number(r.id) === targetId)
  2044. : null;
  2045. const nextLane: Lane = {
  2046. ...lane,
  2047. logisticId: targetId,
  2048. logisticsCompany:
  2049. targetCompany === "未分配物流商"
  2050. ? ""
  2051. : targetMaster?.logisticName?.trim() || targetCompany,
  2052. plate: targetMaster?.carPlate?.trim() || "",
  2053. driver: targetMaster?.driverName?.trim() || "",
  2054. phone:
  2055. targetMaster?.driverNumber != null &&
  2056. Number.isFinite(Number(targetMaster.driverNumber))
  2057. ? String(targetMaster.driverNumber)
  2058. : "",
  2059. };
  2060. const next = lanesRef.current.map((l) => (l.id === lane.id ? nextLane : l));
  2061. lanesNeedingRefreshOnSaveRef.current.add(lane.id);
  2062. laneDisplayOverlayRef.current.delete(lane.id);
  2063. lanesRef.current = next;
  2064. setLanes(next);
  2065. setSaveResult(null);
  2066. setError(null);
  2067. };
  2068. const openAddShopDialog = (laneId: string) => {
  2069. setAddShopLaneId(laneId);
  2070. setAddShopPick(null);
  2071. setAddShopDialogOpen(true);
  2072. };
  2073. const closeAddShopDialog = () => {
  2074. setAddShopDialogOpen(false);
  2075. setAddShopLaneId(null);
  2076. setAddShopPick(null);
  2077. };
  2078. const closeDistrictEdit = () => {
  2079. setDistrictEditOpen(false);
  2080. setDistrictEditCtx(null);
  2081. setDistrictEditDraft("");
  2082. setDistrictEditError(null);
  2083. };
  2084. const openDistrictAdd = (laneId: string) => {
  2085. setDistrictEditCtx({ laneId, mode: "add" });
  2086. setDistrictEditDraft("");
  2087. setDistrictEditError(null);
  2088. setDistrictEditOpen(true);
  2089. };
  2090. const openDistrictRename = (laneId: string, oldDisplay: string) => {
  2091. setDistrictEditCtx({ laneId, mode: "rename", oldDisplay });
  2092. setDistrictEditDraft(oldDisplay === "未分類" ? "" : oldDisplay);
  2093. setDistrictEditError(null);
  2094. setDistrictEditOpen(true);
  2095. };
  2096. const removePendingEmptyDistrict = (laneId: string, display: string) => {
  2097. setPendingEmptyDistrictsByLane((prev) => {
  2098. const arr = prev[laneId];
  2099. if (!arr?.length) return prev;
  2100. const nextArr = arr.filter((d) => d !== display);
  2101. if (nextArr.length === arr.length) return prev;
  2102. const next = { ...prev };
  2103. if (nextArr.length === 0) delete next[laneId];
  2104. else next[laneId] = nextArr;
  2105. return next;
  2106. });
  2107. setSaveResult(null);
  2108. };
  2109. const applyDistrictEdit = () => {
  2110. if (districtEditSubmitLockRef.current || !districtEditCtx) return;
  2111. districtEditSubmitLockRef.current = true;
  2112. setDistrictEditError(null);
  2113. try {
  2114. const lane = lanesRef.current.find((l) => l.id === districtEditCtx.laneId);
  2115. if (!lane) {
  2116. closeDistrictEdit();
  2117. return;
  2118. }
  2119. const pendingExtra =
  2120. pendingEmptyDistrictsByLane[districtEditCtx.laneId] ?? [];
  2121. const trimmed = districtEditDraft.trim();
  2122. if (districtEditCtx.mode === "add") {
  2123. if (!trimmed) {
  2124. setDistrictEditError(t("district_err_name"));
  2125. return;
  2126. }
  2127. if (trimmed === "未分類") {
  2128. setDistrictEditError(t("district_err_reserved"));
  2129. return;
  2130. }
  2131. if (districtDisplayExistsInLane(lane, pendingExtra, trimmed)) {
  2132. setDistrictEditError(t("district_err_exists"));
  2133. return;
  2134. }
  2135. setPendingEmptyDistrictsByLane((prev) => {
  2136. const lid = districtEditCtx.laneId;
  2137. const merged = dedupeDistrictPendingOrder([
  2138. ...(prev[lid] ?? []),
  2139. trimmed,
  2140. ]);
  2141. return { ...prev, [lid]: merged };
  2142. });
  2143. setSaveResult(null);
  2144. closeDistrictEdit();
  2145. return;
  2146. }
  2147. const oldDisp = districtEditCtx.oldDisplay;
  2148. const newDisp = trimmed === "" ? "未分類" : trimmed;
  2149. if (newDisp === oldDisp) {
  2150. closeDistrictEdit();
  2151. return;
  2152. }
  2153. const newRaw = toDistrictRawValue(newDisp);
  2154. const touchedIds: number[] = [];
  2155. const next = lanesRef.current.map((l) => {
  2156. if (l.id !== districtEditCtx.laneId) return l;
  2157. return {
  2158. ...l,
  2159. shops: l.shops.map((s) => {
  2160. if (toDistrictDisplayName(s.districtReferenceRaw) !== oldDisp)
  2161. return s;
  2162. touchedIds.push(s.id);
  2163. return { ...s, districtReferenceRaw: newRaw };
  2164. }),
  2165. };
  2166. });
  2167. lanesRef.current = next;
  2168. setLanes(next);
  2169. setDirtyMoves((prev) => {
  2170. const n = new Map(prev);
  2171. for (const id of touchedIds) {
  2172. if (id > 0) n.set(id, districtEditCtx.laneId);
  2173. }
  2174. return n;
  2175. });
  2176. lanesNeedingRefreshOnSaveRef.current.add(districtEditCtx.laneId);
  2177. const lid = districtEditCtx.laneId;
  2178. setPendingEmptyDistrictsByLane((prev) => {
  2179. const arr = prev[lid];
  2180. let pendingList = arr ? [...arr] : [];
  2181. if (pendingList.includes(oldDisp)) {
  2182. pendingList = pendingList.map((d) => (d === oldDisp ? newDisp : d));
  2183. }
  2184. const laneNext = next.find((l) => l.id === lid);
  2185. const withShops = new Set(
  2186. groupByDistrict(laneNext?.shops ?? []).map((g) => g.district),
  2187. );
  2188. const pruned = dedupeDistrictPendingOrder(
  2189. pendingList.filter((d) => !withShops.has(d)),
  2190. );
  2191. const out = { ...prev };
  2192. if (pruned.length === 0) delete out[lid];
  2193. else out[lid] = pruned;
  2194. return out;
  2195. });
  2196. setSaveResult(null);
  2197. closeDistrictEdit();
  2198. } finally {
  2199. districtEditSubmitLockRef.current = false;
  2200. }
  2201. };
  2202. const openAddRouteDialog = () => {
  2203. setNewRouteForm(emptyNewRouteForm());
  2204. setAddRouteError(null);
  2205. setAddRouteDialogOpen(true);
  2206. };
  2207. const closeAddRouteDialog = () => {
  2208. setAddRouteDialogOpen(false);
  2209. setNewRouteForm(emptyNewRouteForm());
  2210. setAddRouteError(null);
  2211. };
  2212. const submitAddRoute = () => {
  2213. const code = String(newRouteForm.truckLanceCode || "").trim();
  2214. const storeNorm = normalizeStoreId(newRouteForm.storeId);
  2215. const remarkRaw =
  2216. storeNorm === "4F" && String(newRouteForm.remark || "").trim() !== ""
  2217. ? String(newRouteForm.remark).trim()
  2218. : null;
  2219. const laneKey = encodeLaneId(code, remarkRaw);
  2220. if (!code) {
  2221. setAddRouteError(t("route_err_code"));
  2222. return;
  2223. }
  2224. const dep = parseTimeForBackend(newRouteForm.startTime || "");
  2225. if (!dep) {
  2226. setAddRouteError(t("route_err_departure"));
  2227. return;
  2228. }
  2229. if (
  2230. lanesRef.current.some((l) => l.id === laneKey) ||
  2231. pendingNewLanesRef.current.some((p) => p.laneKey === laneKey)
  2232. ) {
  2233. setAddRouteError(t("route_err_duplicate"));
  2234. return;
  2235. }
  2236. if (addRouteInFlightRef.current) return;
  2237. addRouteInFlightRef.current = true;
  2238. setAddRouteSubmitting(true);
  2239. setAddRouteError(null);
  2240. try {
  2241. const payload = buildCreateTruckWithoutShopPayload(newRouteForm);
  2242. const draftLane = buildPendingLaneFromForm(newRouteForm);
  2243. setPendingNewLanes((prev) => [...prev, { laneKey, payload }]);
  2244. laneLogisticBaselineRef.current.set(
  2245. laneKey,
  2246. newRouteForm.logisticId != null &&
  2247. Number.isFinite(Number(newRouteForm.logisticId))
  2248. ? Number(newRouteForm.logisticId)
  2249. : null,
  2250. );
  2251. setLanes((prev) => {
  2252. const next = sortLanesByCode([...prev, draftLane]);
  2253. return applyLaneDisplayOverlays(enrichLanesWithLogisticMaster(next));
  2254. });
  2255. closeAddRouteDialog();
  2256. setSelectedLaneIds((prev) =>
  2257. prev.includes(laneKey) ? prev : [...prev, laneKey],
  2258. );
  2259. requestAnimationFrame(() => {
  2260. requestAnimationFrame(() => scrollBoardLaneCardIntoView(laneKey));
  2261. });
  2262. setSaveResult(null);
  2263. } catch (e: any) {
  2264. setAddRouteError(e?.message ?? String(e) ?? t("route_err_create"));
  2265. } finally {
  2266. setAddRouteSubmitting(false);
  2267. addRouteInFlightRef.current = false;
  2268. }
  2269. };
  2270. const submitAddShop = () => {
  2271. if (!addShopLaneId || !addShopPick) return;
  2272. if (addShopConfirmLockRef.current) return;
  2273. const lane = lanes.find((l) => l.id === addShopLaneId);
  2274. if (!lane) return;
  2275. addShopConfirmLockRef.current = true;
  2276. setError(null);
  2277. try {
  2278. const flat = flattenDisplayOrder(lane.shops);
  2279. const last = flat[flat.length - 1];
  2280. const newSeq = last != null ? last.loadingSequence : 0;
  2281. const seq = Number(newSeq) || 0;
  2282. const storeId = normalizeStoreId(lane.storeId);
  2283. const remark =
  2284. storeId === "4F" &&
  2285. lane.remark != null &&
  2286. String(lane.remark).trim() !== ""
  2287. ? String(lane.remark).trim()
  2288. : null;
  2289. const tempId = nextDraftTruckRowIdRef.current;
  2290. nextDraftTruckRowIdRef.current -= 1;
  2291. const baseRows = lanesToWarningInputRows(lanes);
  2292. const hypo = appendSyntheticPendingShopRow(
  2293. baseRows,
  2294. {
  2295. truckLanceCode: lane.truckLanceCode,
  2296. laneRemark: lane.remark ?? null,
  2297. storeId: lane.storeId,
  2298. startTime: lane.startTime,
  2299. },
  2300. addShopPick,
  2301. tempId,
  2302. );
  2303. const wr = computeTruckLaneWarnings(hypo);
  2304. const touching = wr.warnings.filter((w) =>
  2305. warningTouchesPickedShop(w, addShopPick),
  2306. );
  2307. if (touching.length > 0) {
  2308. const ok = window.confirm(
  2309. t("confirm_addShopConflict", { count: touching.length }),
  2310. );
  2311. if (!ok) return;
  2312. }
  2313. const newCard: ShopCard = {
  2314. id: tempId,
  2315. shopEntityId: addShopPick.id,
  2316. branchName: addShopPick.name,
  2317. shopCode: addShopPick.code,
  2318. districtReferenceRaw: null,
  2319. loadingSequence: seq,
  2320. remark,
  2321. storeId,
  2322. departureTime: parseTimeForBackend(lane.startTime),
  2323. };
  2324. setLanes((prev) =>
  2325. prev.map((l) =>
  2326. l.id !== lane.id ? l : { ...l, shops: [...l.shops, newCard] },
  2327. ),
  2328. );
  2329. setPendingShopAdds((prev) => [
  2330. ...prev,
  2331. {
  2332. tempTruckRowId: tempId,
  2333. laneId: lane.id,
  2334. shopId: addShopPick.id,
  2335. shopName: addShopPick.name,
  2336. shopCode: addShopPick.code,
  2337. loadingSequence: seq,
  2338. },
  2339. ]);
  2340. lanesNeedingRefreshOnSaveRef.current.add(lane.id);
  2341. closeAddShopDialog();
  2342. if (wr.warnings.some((w) => warningTouchesPickedShop(w, addShopPick))) {
  2343. setLaneWarnSnackbar(t("mtmsRouteWarn_postAddConflict"));
  2344. }
  2345. } finally {
  2346. addShopConfirmLockRef.current = false;
  2347. }
  2348. };
  2349. const handleDeleteTruckRow = async (truckRowId: number) => {
  2350. if (truckRowId > 0 && scheduledShopIdSet.has(truckRowId)) {
  2351. return;
  2352. }
  2353. if (truckRowId < 0) {
  2354. if (!window.confirm(t("confirm_discardDraftShop"))) return;
  2355. setError(null);
  2356. setPendingShopAdds((prev) =>
  2357. prev.filter((p) => p.tempTruckRowId !== truckRowId),
  2358. );
  2359. setLanes((prev) =>
  2360. prev.map((lane) => ({
  2361. ...lane,
  2362. shops: lane.shops.filter((s) => s.id !== truckRowId),
  2363. })),
  2364. );
  2365. setSaveResult(null);
  2366. return;
  2367. }
  2368. if (!window.confirm(t("confirm_removeShop")))
  2369. return;
  2370. setError(null);
  2371. const affectedLaneId = lanesRef.current.find((l) =>
  2372. l.shops.some((s) => s.id === truckRowId),
  2373. )?.id;
  2374. if (affectedLaneId) {
  2375. lanesNeedingRefreshOnSaveRef.current.add(affectedLaneId);
  2376. }
  2377. const srcLane = lanesRef.current.find((l) =>
  2378. l.shops.some((s) => s.id === truckRowId),
  2379. );
  2380. const shop = srcLane?.shops.find((s) => s.id === truckRowId);
  2381. if (srcLane && shop && truckRowId > 0) {
  2382. stagedDeleteMetaRef.current.set(truckRowId, {
  2383. shopCode: String(shop.shopCode ?? "").trim(),
  2384. branchName: String(shop.branchName ?? "").trim(),
  2385. fromLane: formatLaneLabel(srcLane.truckLanceCode, srcLane.remark),
  2386. });
  2387. }
  2388. // delete beats move
  2389. setDirtyMoves((prev) => {
  2390. const next = new Map(prev);
  2391. next.delete(truckRowId);
  2392. return next;
  2393. });
  2394. setDirtyDeletes((prev) => {
  2395. const next = new Set(prev);
  2396. next.add(truckRowId);
  2397. return next;
  2398. });
  2399. // optimistic UI: remove now; cancel will refetch
  2400. setLanes((prev) =>
  2401. prev.map((lane) =>
  2402. lane.shops.some((s) => s.id === truckRowId)
  2403. ? { ...lane, shops: lane.shops.filter((s) => s.id !== truckRowId) }
  2404. : lane,
  2405. ),
  2406. );
  2407. setSaveResult(null);
  2408. };
  2409. /** 清空整桶店鋪:與單筆刪除相同,僅標記 dirtyDeletes,按「儲存更改」才 deleteTruckLaneClient */
  2410. const handleClearLaneShops = (lane: Lane) => {
  2411. if (lane.shops.length === 0) return;
  2412. if (
  2413. lane.shops.some((s) => s.id > 0 && scheduledShopIdSet.has(s.id))
  2414. ) {
  2415. return;
  2416. }
  2417. if (
  2418. !window.confirm(
  2419. t("confirm_clearLane", {
  2420. laneLabel: `${lane.truckLanceCode}${
  2421. lane.remark != null && String(lane.remark).trim() !== ""
  2422. ? ` · ${lane.remark}`
  2423. : ""
  2424. }`,
  2425. count: lane.shops.length,
  2426. }),
  2427. )
  2428. )
  2429. return;
  2430. setError(null);
  2431. const draftIds = new Set(
  2432. lane.shops.filter((s) => s.id < 0).map((s) => s.id),
  2433. );
  2434. const serverShopIds = lane.shops
  2435. .filter((s) => s.id > 0)
  2436. .map((s) => s.id);
  2437. if (draftIds.size > 0) {
  2438. setPendingShopAdds((prev) =>
  2439. prev.filter((p) => !draftIds.has(p.tempTruckRowId)),
  2440. );
  2441. }
  2442. if (serverShopIds.length > 0) {
  2443. lanesNeedingRefreshOnSaveRef.current.add(lane.id);
  2444. for (const sid of serverShopIds) {
  2445. const shop = lane.shops.find((s) => s.id === sid);
  2446. if (shop && sid > 0) {
  2447. stagedDeleteMetaRef.current.set(sid, {
  2448. shopCode: String(shop.shopCode ?? "").trim(),
  2449. branchName: String(shop.branchName ?? "").trim(),
  2450. fromLane: formatLaneLabel(lane.truckLanceCode, lane.remark),
  2451. });
  2452. }
  2453. }
  2454. setDirtyMoves((prev) => {
  2455. const next = new Map(prev);
  2456. for (const id of serverShopIds) next.delete(id);
  2457. return next;
  2458. });
  2459. setDirtyDeletes((prev) => {
  2460. const next = new Set(prev);
  2461. for (const id of serverShopIds) next.add(id);
  2462. return next;
  2463. });
  2464. }
  2465. setLanes((prev) =>
  2466. prev.map((l) => (l.id === lane.id ? { ...l, shops: [] } : l)),
  2467. );
  2468. setSaveResult(null);
  2469. };
  2470. const handleDragStart = (shopId: number, fromLaneId: string) => {
  2471. draggedRef.current = { shopId, fromLaneId };
  2472. setDropIndicator(null);
  2473. };
  2474. const clearDragState = () => {
  2475. draggedRef.current = null;
  2476. setDropIndicator(null);
  2477. };
  2478. const getBeforeShopIdByPointer = (
  2479. laneId: string,
  2480. clientY: number,
  2481. ): number | null => {
  2482. const laneEl = document.querySelector<HTMLElement>(
  2483. `[data-lane-id="${CSS.escape(laneId)}"]`,
  2484. );
  2485. if (!laneEl) return null;
  2486. const cards = Array.from(
  2487. laneEl.querySelectorAll<HTMLElement>("[data-shop-id]"),
  2488. );
  2489. for (const cardEl of cards) {
  2490. const rect = cardEl.getBoundingClientRect();
  2491. const midY = rect.top + rect.height / 2;
  2492. if (clientY < midY) {
  2493. const idStr = cardEl.getAttribute("data-shop-id");
  2494. const id = idStr ? Number(idStr) : NaN;
  2495. return Number.isFinite(id) ? id : null;
  2496. }
  2497. }
  2498. return null; // append
  2499. };
  2500. const handleDropToLane = (toLaneId: string) => {
  2501. const before =
  2502. dropIndicator != null && dropIndicator.laneId === toLaneId
  2503. ? dropIndicator.beforeShopId
  2504. : null;
  2505. handleDropToPosition(toLaneId, before);
  2506. };
  2507. const handleDropToPosition = (
  2508. toLaneId: string,
  2509. beforeShopId: number | null,
  2510. targetDistrict?: string | null,
  2511. ) => {
  2512. const dragged = draggedRef.current;
  2513. if (!dragged) return;
  2514. if (dragged.shopId < 0) {
  2515. setError(
  2516. t("drag_blockDraftShop"),
  2517. );
  2518. clearDragState();
  2519. return;
  2520. }
  2521. if (beforeShopId != null && beforeShopId === dragged.shopId) {
  2522. clearDragState();
  2523. return;
  2524. }
  2525. const dirtyToAdd: Array<[number, string]> = [];
  2526. const base = lanesRef.current;
  2527. const next = base.map((lane) => ({ ...lane, shops: lane.shops.slice() }));
  2528. const from = next.find((l) => l.id === dragged.fromLaneId);
  2529. const to = next.find((l) => l.id === toLaneId);
  2530. if (!from || !to) return;
  2531. const shop = from.shops.find((s) => s.id === dragged.shopId);
  2532. if (!shop) return;
  2533. if (from.id !== to.id && laneTargetConflicts(shop, to)) {
  2534. setError(t("err_dragDuplicateShop"));
  2535. clearDragState();
  2536. return;
  2537. }
  2538. const oldSeqFrom = new Map<number, number>(
  2539. from.shops.map((s) => [s.id, s.loadingSequence]),
  2540. );
  2541. const oldSeqTo = new Map<number, number>(
  2542. to.shops.map((s) => [s.id, s.loadingSequence]),
  2543. );
  2544. // build display-ordered lists (matches what user sees on screen)
  2545. const fromFlat = flattenDisplayOrder(from.shops).filter(
  2546. (s) => s.id !== dragged.shopId,
  2547. );
  2548. const toFlatRaw = flattenDisplayOrder(to.shops);
  2549. const toFlat =
  2550. from.id === to.id
  2551. ? toFlatRaw.filter((s) => s.id !== dragged.shopId)
  2552. : toFlatRaw.slice();
  2553. const beforeShop =
  2554. beforeShopId != null ? toFlat.find((s) => s.id === beforeShopId) : null;
  2555. const targetDistrictRaw =
  2556. targetDistrict !== undefined
  2557. ? toDistrictRawValue(targetDistrict)
  2558. : beforeShop
  2559. ? toDistrictRawValue(beforeShop.districtReferenceRaw)
  2560. : shop.districtReferenceRaw;
  2561. // keep fields but update departure/store/remark/district to match target position
  2562. const moved: ShopCard = {
  2563. ...shop,
  2564. districtReferenceRaw: targetDistrictRaw,
  2565. departureTime: to.startTime,
  2566. storeId: to.storeId,
  2567. remark:
  2568. normalizeStoreId(to.storeId) === "4F"
  2569. ? to.remark != null && String(to.remark).trim() !== ""
  2570. ? String(to.remark).trim()
  2571. : null
  2572. : null,
  2573. };
  2574. // insert into target by DISPLAY ORDER (beforeShopId if provided, else append)
  2575. const insertIdx =
  2576. beforeShopId != null
  2577. ? toFlat.findIndex((s) => s.id === beforeShopId)
  2578. : targetDistrict !== undefined
  2579. ? (() => {
  2580. const targetDisplay = toDistrictDisplayName(targetDistrictRaw);
  2581. for (let i = toFlat.length - 1; i >= 0; i -= 1) {
  2582. if (
  2583. toDistrictDisplayName(toFlat[i].districtReferenceRaw) ===
  2584. targetDisplay
  2585. ) {
  2586. return i + 1;
  2587. }
  2588. }
  2589. return -1;
  2590. })()
  2591. : -1;
  2592. const inserted =
  2593. insertIdx >= 0
  2594. ? [...toFlat.slice(0, insertIdx), moved, ...toFlat.slice(insertIdx)]
  2595. : [...toFlat, moved];
  2596. // write back lists
  2597. from.shops = fromFlat;
  2598. to.shops = inserted;
  2599. // IMPORTANT: Do NOT renumber all loadingSequence.
  2600. // loadingSequence can be duplicated intentionally (e.g. 4F grouping).
  2601. // On drag/drop, only update the moved shop's loadingSequence so it joins the target group.
  2602. const newSeq = computeMovedLoadingSequence(
  2603. inserted,
  2604. moved.id,
  2605. targetDistrict !== undefined && beforeShopId == null,
  2606. );
  2607. to.shops = to.shops.map((s) =>
  2608. s.id === moved.id ? { ...s, loadingSequence: newSeq } : s,
  2609. );
  2610. if (
  2611. from.id !== to.id ||
  2612. toDistrictDisplayName(shop.districtReferenceRaw) !==
  2613. toDistrictDisplayName(targetDistrictRaw)
  2614. ) {
  2615. dirtyToAdd.push([moved.id, to.id]);
  2616. }
  2617. // mark dirty for any sequence changes + moved shop
  2618. from.shops.forEach((s) => {
  2619. const old = oldSeqFrom.get(s.id);
  2620. if (old == null || old !== s.loadingSequence)
  2621. dirtyToAdd.push([s.id, from.id]);
  2622. });
  2623. if (to.id !== from.id) {
  2624. to.shops.forEach((s) => {
  2625. const old = oldSeqTo.get(s.id);
  2626. if (old == null || old !== s.loadingSequence)
  2627. dirtyToAdd.push([s.id, to.id]);
  2628. });
  2629. } else {
  2630. // same lane: compare using oldSeqFrom for all shops
  2631. to.shops.forEach((s) => {
  2632. const old = oldSeqFrom.get(s.id);
  2633. if (old == null || old !== s.loadingSequence)
  2634. dirtyToAdd.push([s.id, to.id]);
  2635. });
  2636. }
  2637. dirtyToAdd.forEach(([, laneId]) =>
  2638. lanesNeedingRefreshOnSaveRef.current.add(laneId),
  2639. );
  2640. if (from.id !== to.id) {
  2641. lanesNeedingRefreshOnSaveRef.current.add(from.id);
  2642. }
  2643. if (dirtyToAdd.length === 0) {
  2644. clearDragState();
  2645. return;
  2646. }
  2647. // Make rapid successive drops in same tick see latest snapshot.
  2648. setError(null);
  2649. lanesRef.current = next;
  2650. setLanes(next);
  2651. if (dirtyToAdd.length > 0) {
  2652. setDirtyMoves((prevDirty) => {
  2653. const nextDirty = new Map(prevDirty);
  2654. dirtyToAdd.forEach(([shopId, laneId]) => nextDirty.set(shopId, laneId));
  2655. return nextDirty;
  2656. });
  2657. }
  2658. clearDragState();
  2659. };
  2660. const scheduleLaneOptions = useMemo(
  2661. () =>
  2662. lanes.map((lane) => ({
  2663. id: lane.id,
  2664. label: formatLaneLabel(lane.truckLanceCode, lane.remark),
  2665. truckLanceCode: lane.truckLanceCode,
  2666. remark: lane.remark,
  2667. storeId: normalizeStoreId(lane.storeId),
  2668. departureTime: parseTimeForBackend(lane.startTime) || "00:00:00",
  2669. shops: lane.shops
  2670. .filter((s) => s.id >= 0)
  2671. .map((s) => ({
  2672. truckRowId: s.id,
  2673. districtReferenceRaw: s.districtReferenceRaw ?? null,
  2674. loadingSequence: s.loadingSequence ?? 0,
  2675. })),
  2676. })),
  2677. [lanes],
  2678. );
  2679. const scheduleShopRows = useMemo((): ScheduleShopRow[] => {
  2680. const rows: ScheduleShopRow[] = [];
  2681. for (const lane of lanes) {
  2682. const currentLaneLabel = formatLaneLabel(
  2683. lane.truckLanceCode,
  2684. lane.remark,
  2685. );
  2686. for (const shop of lane.shops) {
  2687. if (shop.id < 0) continue;
  2688. const codeLower = String(shop.shopCode || "")
  2689. .trim()
  2690. .toLowerCase();
  2691. const realName = shopNameByCodeMap.get(codeLower);
  2692. const displayName =
  2693. realName && String(realName).trim() !== ""
  2694. ? String(realName).trim()
  2695. : String(shop.branchName || "").trim() || "-";
  2696. const district = toDistrictDisplayName(shop.districtReferenceRaw);
  2697. const location = [
  2698. district,
  2699. shop.shopCode || "-",
  2700. shop.storeId ? normalizeStoreId(shop.storeId) : "",
  2701. ]
  2702. .filter(Boolean)
  2703. .join(" · ");
  2704. rows.push({
  2705. truckRowId: shop.id,
  2706. shopCode: String(shop.shopCode || "").trim() || "-",
  2707. displayName,
  2708. branchName: String(shop.branchName || "").trim(),
  2709. storeId: shop.storeId ? normalizeStoreId(shop.storeId) : "",
  2710. location,
  2711. districtDisplay: district,
  2712. districtReferenceRaw: shop.districtReferenceRaw ?? null,
  2713. currentLaneId: lane.id,
  2714. currentLaneLabel,
  2715. });
  2716. }
  2717. }
  2718. return rows.sort((a, b) =>
  2719. a.displayName.localeCompare(b.displayName, "zh-Hant"),
  2720. );
  2721. }, [lanes, shopNameByCodeMap]);
  2722. const handleScheduleConfirmManual = useCallback(
  2723. async (payload: ScheduleChangePayload) => {
  2724. const validation = validatePayloadSubmit({
  2725. payload,
  2726. lanes: scheduleLaneOptions,
  2727. shops: scheduleShopRows,
  2728. pendingTruckRowIds: pendingScheduleShopIds,
  2729. });
  2730. if (!validation.ok) {
  2731. setError(formatScheduleValidationErrors(t, validation.errors));
  2732. return;
  2733. }
  2734. const request = buildRequestFromPayload(payload, scheduleLaneOptions);
  2735. if (!request || (request.lines?.length ?? 0) === 0) {
  2736. setError(t("schedule_err_conflict"));
  2737. return;
  2738. }
  2739. try {
  2740. await createTruckLaneScheduleClient(request);
  2741. setLaneWarnSnackbar(
  2742. t("schedule_registered_snackbar", { count: request.lines?.length ?? 0 }),
  2743. );
  2744. } catch (err: unknown) {
  2745. setError(extractApiErrorMessage(err) ?? t("schedule_err_generic"));
  2746. throw err;
  2747. }
  2748. },
  2749. [t, scheduleLaneOptions, scheduleShopRows, pendingScheduleShopIds],
  2750. );
  2751. const prevPendingScheduleSizeRef = useRef(0);
  2752. useEffect(() => {
  2753. const n = pendingScheduleShopIds.size;
  2754. if (
  2755. prevPendingScheduleSizeRef.current > 0 &&
  2756. n < prevPendingScheduleSizeRef.current
  2757. ) {
  2758. void loadLanes();
  2759. }
  2760. prevPendingScheduleSizeRef.current = n;
  2761. }, [pendingScheduleShopIds]);
  2762. const handleDragOver: React.DragEventHandler = (e) => {
  2763. e.preventDefault();
  2764. };
  2765. const getLaneLogisticChanges = useCallback((): Array<{
  2766. laneId: string;
  2767. logisticId: number | null;
  2768. }> => {
  2769. const changes: Array<{ laneId: string; logisticId: number | null }> = [];
  2770. for (const lane of lanesRef.current) {
  2771. const nextId = lane.logisticId != null ? Number(lane.logisticId) : null;
  2772. const prevId = laneLogisticBaselineRef.current.has(lane.id)
  2773. ? laneLogisticBaselineRef.current.get(lane.id) ?? null
  2774. : null;
  2775. if (prevId !== nextId)
  2776. changes.push({ laneId: lane.id, logisticId: nextId });
  2777. }
  2778. return changes;
  2779. }, []);
  2780. const hasDirtyLaneLogistics = getLaneLogisticChanges().length > 0;
  2781. const dirtyLaneLogisticIds = new Set(
  2782. getLaneLogisticChanges().map((c) => c.laneId),
  2783. );
  2784. const hasUnsavedChanges =
  2785. dirtyMoves.size > 0 ||
  2786. dirtyDeletes.size > 0 ||
  2787. hasDirtyLaneLogistics ||
  2788. pendingShopAdds.length > 0 ||
  2789. pendingNewLanes.length > 0 ||
  2790. pendingLogisticMasterAdds.length > 0 ||
  2791. pendingLogisticMasterEdits.size > 0 ||
  2792. pendingLogisticMasterDeletes.size > 0 ||
  2793. pendingImportMeta != null ||
  2794. pendingRestoreVersionId != null ||
  2795. lanes.some(laneRenamePending) ||
  2796. Object.values(pendingEmptyDistrictsByLane).some(
  2797. (a) => (a?.length ?? 0) > 0,
  2798. );
  2799. const laneLogisticChangesForStaged = useMemo(() => {
  2800. const changes: Array<{ laneId: string; logisticId: number | null }> = [];
  2801. for (const lane of lanes) {
  2802. const nextId = lane.logisticId != null ? Number(lane.logisticId) : null;
  2803. const prevId = laneLogisticBaselineRef.current.has(lane.id)
  2804. ? laneLogisticBaselineRef.current.get(lane.id) ?? null
  2805. : null;
  2806. if (prevId !== nextId)
  2807. changes.push({ laneId: lane.id, logisticId: nextId });
  2808. }
  2809. return changes;
  2810. }, [lanes]);
  2811. const stagedDeleteSig = useMemo(
  2812. () =>
  2813. Array.from(dirtyDeletes.values())
  2814. .sort((a, b) => a - b)
  2815. .join(","),
  2816. [dirtyDeletes],
  2817. );
  2818. const dirtyMoveDistrictHints = useMemo(() => {
  2819. const hints = new Map<number, string>();
  2820. dirtyMoves.forEach((laneId, shopId) => {
  2821. if (!Number.isFinite(shopId) || shopId <= 0) return;
  2822. const baseDisp = shopDistrictBaselineRef.current.get(shopId);
  2823. if (baseDisp === undefined) return;
  2824. const lane = lanes.find((l) => l.id === laneId);
  2825. const shop = lane?.shops.find((s) => s.id === shopId);
  2826. if (!shop) return;
  2827. const curDisp = toDistrictDisplayName(shop.districtReferenceRaw);
  2828. if (curDisp !== baseDisp)
  2829. hints.set(
  2830. shopId,
  2831. t("diff_staged_shopDistrictHint", {
  2832. from: baseDisp,
  2833. to: curDisp,
  2834. }),
  2835. );
  2836. });
  2837. return hints;
  2838. }, [lanes, dirtyMoves, districtBaselineEpoch, stagedDeleteSig, t]);
  2839. const stagedLogEntriesView = useMemo(
  2840. () =>
  2841. buildStagedBoardLogEntries({
  2842. pendingRestoreVersionId,
  2843. dirtyMoves,
  2844. dirtyMoveDistrictHints,
  2845. dirtyDeletes,
  2846. stagedDeleteMeta: stagedDeleteMetaRef.current,
  2847. pendingShopAdds,
  2848. pendingNewLanes,
  2849. pendingLogisticMasterAdds: pendingLogisticMasterAdds.map((p) => ({
  2850. tempId: p.tempId,
  2851. logisticName: p.logisticName,
  2852. carPlate: p.carPlate,
  2853. })),
  2854. pendingLogisticMasterEdits: Array.from(
  2855. pendingLogisticMasterEdits.entries(),
  2856. ).map(([id, edit]) => {
  2857. const prev = logisticRowsFromDb.find((r) => Number(r.id) === id);
  2858. return {
  2859. id,
  2860. logisticName: edit.logisticName,
  2861. carPlate: edit.carPlate,
  2862. fromName: prev?.logisticName ?? "",
  2863. fromPlate: prev?.carPlate ?? "",
  2864. };
  2865. }),
  2866. pendingLogisticMasterDeletes: Array.from(
  2867. pendingLogisticMasterDeletes,
  2868. ).map((id) => {
  2869. const prev = logisticRowsFromDb.find((r) => Number(r.id) === id);
  2870. return {
  2871. id,
  2872. logisticName: String(prev?.logisticName ?? "").trim() || "—",
  2873. };
  2874. }),
  2875. pendingImport: pendingImportMeta,
  2876. laneLogisticChanges: laneLogisticChangesForStaged,
  2877. lanes,
  2878. pendingEmptyDistrictsByLane,
  2879. logisticNameById,
  2880. shopDistrictBaseline: new Map(shopDistrictBaselineRef.current),
  2881. shopRowBaseline: new Map(shopRowBaselineRef.current),
  2882. }),
  2883. [
  2884. pendingRestoreVersionId,
  2885. dirtyMoves,
  2886. dirtyMoveDistrictHints,
  2887. dirtyDeletes,
  2888. pendingShopAdds,
  2889. pendingNewLanes,
  2890. pendingLogisticMasterAdds,
  2891. pendingLogisticMasterEdits,
  2892. pendingLogisticMasterDeletes,
  2893. pendingImportMeta,
  2894. laneLogisticChangesForStaged,
  2895. lanes,
  2896. pendingEmptyDistrictsByLane,
  2897. logisticNameById,
  2898. logisticRowsFromDb,
  2899. stagedDeleteSig,
  2900. districtBaselineEpoch,
  2901. ],
  2902. );
  2903. useEffect(() => {
  2904. if (!hasUnsavedChanges) return;
  2905. const message = t("nav_unsavedLeave");
  2906. const currentUrl = window.location.href;
  2907. const guardKey = `routeBoard:${Date.now()}:${Math.random()}`;
  2908. let bypassNavigationGuard = false;
  2909. let confirmedNavigation = false;
  2910. let guardReleased = false;
  2911. const guardState = {
  2912. ...(window.history.state &&
  2913. typeof window.history.state === "object" &&
  2914. !Array.isArray(window.history.state)
  2915. ? window.history.state
  2916. : {}),
  2917. __routeBoardUnsavedGuard: guardKey,
  2918. };
  2919. const isSameLocation = (url: string | URL | null | undefined) => {
  2920. if (url == null) return true;
  2921. const nextUrl = new URL(String(url), window.location.href);
  2922. const here = new URL(currentUrl);
  2923. return (
  2924. nextUrl.origin === here.origin &&
  2925. nextUrl.pathname === here.pathname &&
  2926. nextUrl.search === here.search
  2927. );
  2928. };
  2929. const confirmLeave = () => window.confirm(message);
  2930. window.history.pushState(guardState, "", currentUrl);
  2931. const handleBeforeUnload = (event: BeforeUnloadEvent) => {
  2932. event.preventDefault();
  2933. event.returnValue = message;
  2934. return message;
  2935. };
  2936. const handleDocumentClick = (event: MouseEvent) => {
  2937. if (
  2938. event.defaultPrevented ||
  2939. event.button !== 0 ||
  2940. event.metaKey ||
  2941. event.ctrlKey ||
  2942. event.shiftKey ||
  2943. event.altKey
  2944. ) {
  2945. return;
  2946. }
  2947. const target = event.target as HTMLElement | null;
  2948. const anchor = target?.closest("a[href]") as HTMLAnchorElement | null;
  2949. if (
  2950. !anchor ||
  2951. anchor.target === "_blank" ||
  2952. anchor.hasAttribute("download")
  2953. )
  2954. return;
  2955. if (isSameLocation(anchor.href)) return;
  2956. if (!confirmLeave()) {
  2957. event.preventDefault();
  2958. event.stopPropagation();
  2959. return;
  2960. }
  2961. releaseGuard();
  2962. };
  2963. const handlePopState = () => {
  2964. if (confirmedNavigation) return;
  2965. if (confirmLeave()) {
  2966. releaseGuard();
  2967. window.history.back();
  2968. return;
  2969. }
  2970. bypassNavigationGuard = true;
  2971. window.history.pushState(guardState, "", currentUrl);
  2972. bypassNavigationGuard = false;
  2973. };
  2974. const originalPushState = window.history.pushState;
  2975. const originalReplaceState = window.history.replaceState;
  2976. function releaseGuard() {
  2977. if (guardReleased) return;
  2978. guardReleased = true;
  2979. confirmedNavigation = true;
  2980. window.removeEventListener("beforeunload", handleBeforeUnload);
  2981. window.removeEventListener("popstate", handlePopState);
  2982. document.removeEventListener("click", handleDocumentClick, true);
  2983. window.history.pushState = originalPushState;
  2984. window.history.replaceState = originalReplaceState;
  2985. }
  2986. window.history.pushState = function patchedPushState(
  2987. data: any,
  2988. unused: string,
  2989. url?: string | URL | null,
  2990. ) {
  2991. if (!bypassNavigationGuard && !isSameLocation(url)) {
  2992. if (!confirmLeave()) return;
  2993. releaseGuard();
  2994. }
  2995. return originalPushState.call(window.history, data, unused, url);
  2996. };
  2997. window.history.replaceState = function patchedReplaceState(
  2998. data: any,
  2999. unused: string,
  3000. url?: string | URL | null,
  3001. ) {
  3002. if (!bypassNavigationGuard && !isSameLocation(url)) {
  3003. if (!confirmLeave()) return;
  3004. releaseGuard();
  3005. }
  3006. return originalReplaceState.call(window.history, data, unused, url);
  3007. };
  3008. window.addEventListener("beforeunload", handleBeforeUnload);
  3009. window.addEventListener("popstate", handlePopState);
  3010. document.addEventListener("click", handleDocumentClick, true);
  3011. return () => {
  3012. if (guardReleased) return;
  3013. window.removeEventListener("beforeunload", handleBeforeUnload);
  3014. window.removeEventListener("popstate", handlePopState);
  3015. document.removeEventListener("click", handleDocumentClick, true);
  3016. window.history.pushState = originalPushState;
  3017. window.history.replaceState = originalReplaceState;
  3018. if (
  3019. !confirmedNavigation &&
  3020. window.location.href === currentUrl &&
  3021. window.history.state?.__routeBoardUnsavedGuard === guardKey
  3022. ) {
  3023. window.history.back();
  3024. }
  3025. };
  3026. }, [hasUnsavedChanges, t]);
  3027. const handleSave = async () => {
  3028. if (saveInFlightRef.current) return;
  3029. saveInFlightRef.current = true;
  3030. try {
  3031. const pendingRestoreId =
  3032. pendingRestoreVersionId != null &&
  3033. Number.isFinite(Number(pendingRestoreVersionId)) &&
  3034. Number(pendingRestoreVersionId) > 0
  3035. ? Number(pendingRestoreVersionId)
  3036. : null;
  3037. const pendingLogisticMasterAddsSnapshot = [...pendingLogisticMasterAdds];
  3038. const pendingLogisticMasterEditsSnapshot = new Map(
  3039. pendingLogisticMasterEdits,
  3040. );
  3041. const pendingLogisticMasterDeletesSnapshot = new Set(
  3042. pendingLogisticMasterDeletes,
  3043. );
  3044. const pendingImportFile = pendingImportFileRef.current;
  3045. let laneLogisticChangesLive = getLaneLogisticChanges();
  3046. const pendingSnapshot = [...pendingShopAdds];
  3047. const pendingNewLanesSnapshot = [...pendingNewLanesRef.current];
  3048. const hasPersistedWork =
  3049. pendingSnapshot.length > 0 ||
  3050. pendingNewLanesSnapshot.length > 0 ||
  3051. dirtyMoves.size > 0 ||
  3052. dirtyDeletes.size > 0 ||
  3053. laneLogisticChangesLive.length > 0 ||
  3054. pendingLogisticMasterAddsSnapshot.length > 0 ||
  3055. pendingLogisticMasterEditsSnapshot.size > 0 ||
  3056. pendingLogisticMasterDeletesSnapshot.size > 0 ||
  3057. pendingImportFile != null ||
  3058. pendingRestoreId != null ||
  3059. lanesRef.current.some(laneRenamePending);
  3060. const hasPendingEmptyDistrictsOnly =
  3061. !hasPersistedWork &&
  3062. Object.values(pendingEmptyDistrictsByLane).some(
  3063. (a) => (a?.length ?? 0) > 0,
  3064. );
  3065. if (!hasPersistedWork && !hasPendingEmptyDistrictsOnly) {
  3066. setSaveResult({ ok: true, message: t("No changes") });
  3067. return;
  3068. }
  3069. if (hasPendingEmptyDistrictsOnly) {
  3070. setPendingEmptyDistrictsByLane({});
  3071. setSaveResult({
  3072. ok: true,
  3073. message: t("save_clearedEmptyDistricts"),
  3074. });
  3075. return;
  3076. }
  3077. setSaving(true);
  3078. setSaveResult(null);
  3079. if (pendingRestoreId != null) {
  3080. const hadRestoreWithOtherWork =
  3081. pendingSnapshot.length > 0 ||
  3082. pendingNewLanesSnapshot.length > 0 ||
  3083. dirtyMoves.size > 0 ||
  3084. dirtyDeletes.size > 0 ||
  3085. laneLogisticChangesLive.length > 0 ||
  3086. pendingLogisticMasterAddsSnapshot.length > 0 ||
  3087. pendingLogisticMasterEditsSnapshot.size > 0 ||
  3088. pendingLogisticMasterDeletesSnapshot.size > 0 ||
  3089. pendingImportFile != null;
  3090. if (hadRestoreWithOtherWork) {
  3091. if (!window.confirm(t("confirm_restoreSaveWillDropStaging")))
  3092. return;
  3093. }
  3094. try {
  3095. await restoreTruckLaneVersionClient(pendingRestoreId);
  3096. setPendingRestoreVersionId(null);
  3097. setDirtyMoves(new Map());
  3098. clearDirtyDeletesState();
  3099. setPendingShopAdds([]);
  3100. setPendingNewLanes([]);
  3101. setPendingLogisticMasterAdds([]);
  3102. setPendingLogisticMasterEdits(new Map());
  3103. setPendingLogisticMasterDeletes(new Set());
  3104. pendingImportFileRef.current = null;
  3105. setPendingImportMeta(null);
  3106. setPendingEmptyDistrictsByLane({});
  3107. closeDistrictEdit();
  3108. closeDepartureEdit();
  3109. setSeqEditTarget(null);
  3110. lanesNeedingRefreshOnSaveRef.current.clear();
  3111. laneDisplayOverlayRef.current.clear();
  3112. await loadLanes();
  3113. setSaveResult({
  3114. ok: true,
  3115. message: hadRestoreWithOtherWork
  3116. ? t("restore_appliedDroppedStaging")
  3117. : t("restore_applied"),
  3118. });
  3119. } catch (e: any) {
  3120. console.error("Restore snapshot failed:", e);
  3121. setSaveResult({
  3122. ok: false,
  3123. message: e?.message ?? String(e) ?? t("diff_restoreFail"),
  3124. });
  3125. }
  3126. return;
  3127. }
  3128. const renameTargets = lanesRef.current.filter(laneRenamePending);
  3129. if (renameTargets.length > 0) {
  3130. const renamed: string[] = [];
  3131. let renameFailure: string | null = null;
  3132. for (const lane of renameTargets) {
  3133. const orig = String(lane.originalTruckLanceCode ?? "").trim();
  3134. const timeBaseline = lane.originalStartTime ?? lane.startTime;
  3135. const departureTimeChanged =
  3136. parseTimeForBackend(timeBaseline) !== parseTimeForBackend(lane.startTime);
  3137. try {
  3138. await renameTruckLaneClient({
  3139. truckLanceCode: orig,
  3140. storeId: normalizeStoreId(lane.storeId),
  3141. remark: lane.remark ?? null,
  3142. newTruckLanceCode: lane.truckLanceCode.trim(),
  3143. departureTimeChanged,
  3144. newDepartureTime: departureTimeChanged ? lane.startTime : null,
  3145. });
  3146. renamed.push(`${orig} → ${lane.truckLanceCode.trim()}`);
  3147. const committed = lanesRef.current.map((row) =>
  3148. row.id === lane.id
  3149. ? {
  3150. ...row,
  3151. originalTruckLanceCode: row.truckLanceCode,
  3152. originalStartTime: row.startTime,
  3153. }
  3154. : row,
  3155. );
  3156. lanesRef.current = committed;
  3157. setLanes(committed);
  3158. } catch (e: any) {
  3159. renameFailure = `${lane.truckLanceCode.trim()}:${renameErrorText(
  3160. e?.message ?? String(e),
  3161. t,
  3162. )}`;
  3163. break;
  3164. }
  3165. }
  3166. if (renameFailure) {
  3167. const ok = renamed.length > 0 ? renamed.join("、") : t("rename_none");
  3168. setSaveResult({
  3169. ok: false,
  3170. message: t("rename_savePartial", { ok, fail: renameFailure }),
  3171. });
  3172. return;
  3173. }
  3174. }
  3175. if (pendingLogisticMasterEditsSnapshot.size > 0) {
  3176. for (const [id, req] of Array.from(
  3177. pendingLogisticMasterEditsSnapshot.entries(),
  3178. )) {
  3179. if (pendingLogisticMasterDeletesSnapshot.has(id)) continue;
  3180. await saveLogisticClient({ ...req, id });
  3181. }
  3182. setPendingLogisticMasterEdits(new Map());
  3183. await reloadLogisticNamesFromDb();
  3184. }
  3185. if (pendingLogisticMasterDeletesSnapshot.size > 0) {
  3186. for (const id of Array.from(pendingLogisticMasterDeletesSnapshot)) {
  3187. await deleteLogisticClient(id);
  3188. }
  3189. setPendingLogisticMasterDeletes(new Set());
  3190. await reloadLogisticNamesFromDb();
  3191. }
  3192. if (pendingLogisticMasterAddsSnapshot.length > 0) {
  3193. const savedRows = await saveLogisticsBatchCreateClient(
  3194. pendingLogisticMasterAddsSnapshot.map((p) => ({
  3195. logisticName: p.logisticName,
  3196. carPlate: p.carPlate,
  3197. driverName: p.driverName,
  3198. driverNumber: p.driverNumber,
  3199. })),
  3200. );
  3201. if (
  3202. !Array.isArray(savedRows) ||
  3203. savedRows.length !== pendingLogisticMasterAddsSnapshot.length
  3204. ) {
  3205. throw new Error(
  3206. `logistic save-batch: expected ${pendingLogisticMasterAddsSnapshot.length} rows, got ${savedRows?.length ?? 0}`,
  3207. );
  3208. }
  3209. const byTemp = new Map<number, LogisticRow>();
  3210. pendingLogisticMasterAddsSnapshot.forEach((p, i) => {
  3211. byTemp.set(p.tempId, savedRows[i]!);
  3212. });
  3213. const remapped = lanesRef.current.map((lane) => {
  3214. const lid = lane.logisticId != null ? Number(lane.logisticId) : null;
  3215. if (lid == null || lid >= 0) return lane;
  3216. const saved = byTemp.get(lid);
  3217. if (!saved) return lane;
  3218. const realId = Number(saved.id);
  3219. if (!Number.isFinite(realId) || realId <= 0) return lane;
  3220. return {
  3221. ...lane,
  3222. logisticId: realId,
  3223. logisticsCompany: String(saved.logisticName ?? "").trim(),
  3224. plate: String(saved.carPlate ?? "").trim(),
  3225. driver: String(saved.driverName ?? "").trim(),
  3226. phone:
  3227. saved.driverNumber != null &&
  3228. Number.isFinite(Number(saved.driverNumber))
  3229. ? String(saved.driverNumber)
  3230. : "",
  3231. };
  3232. });
  3233. lanesRef.current = remapped;
  3234. setLanes(remapped);
  3235. setPendingLogisticMasterAdds([]);
  3236. await reloadLogisticNamesFromDb();
  3237. laneLogisticChangesLive = getLaneLogisticChanges();
  3238. }
  3239. const laneIdsFromPendingNewLanes = new Set<string>();
  3240. if (pendingNewLanesSnapshot.length > 0) {
  3241. for (const p of pendingNewLanesSnapshot) {
  3242. const res = await createTruckWithoutShopClient(p.payload);
  3243. assertMsgOk(res, t("api_fail_createLane"));
  3244. laneIdsFromPendingNewLanes.add(p.laneKey);
  3245. }
  3246. setPendingNewLanes([]);
  3247. if (laneIdsFromPendingNewLanes.size > 0) {
  3248. await refreshLanesByIds(Array.from(laneIdsFromPendingNewLanes), {
  3249. preserveStagedLogistics: true,
  3250. });
  3251. }
  3252. }
  3253. const laneIdsTouchedByCreate = new Set<string>();
  3254. if (pendingSnapshot.length > 0) {
  3255. for (const p of pendingSnapshot) {
  3256. const lane = lanesRef.current.find((l) => l.id === p.laneId);
  3257. if (!lane) continue;
  3258. const sid = normalizeStoreId(lane.storeId);
  3259. const remark =
  3260. sid === "4F" &&
  3261. lane.remark != null &&
  3262. String(lane.remark).trim() !== ""
  3263. ? String(lane.remark).trim()
  3264. : null;
  3265. const res = await createTruckClient({
  3266. store_id: sid,
  3267. truckLanceCode: lane.truckLanceCode,
  3268. departureTime: parseTimeForBackend(lane.startTime),
  3269. shopId: p.shopId,
  3270. shopName: p.shopName,
  3271. shopCode: p.shopCode,
  3272. loadingSequence: p.loadingSequence,
  3273. districtReference: null,
  3274. remark,
  3275. });
  3276. assertMsgOk(res, t("api_fail_addShop"));
  3277. laneIdsTouchedByCreate.add(p.laneId);
  3278. }
  3279. setPendingShopAdds([]);
  3280. setLanes((prev) => stripDraftShopRows(prev));
  3281. if (laneIdsTouchedByCreate.size > 0) {
  3282. await refreshLanesByIds(Array.from(laneIdsTouchedByCreate), {
  3283. preserveStagedLogistics: true,
  3284. });
  3285. }
  3286. }
  3287. // build a map shopId -> lane + shopCard(以 ref 最新列為準,含剛 refresh)
  3288. const shopById = new Map<number, { lane: Lane; shop: ShopCard }>();
  3289. for (const lane of lanesRef.current) {
  3290. for (const s of lane.shops) {
  3291. shopById.set(s.id, { lane, shop: s });
  3292. }
  3293. }
  3294. const updates: SaveTruckLane[] = [];
  3295. const deletes = Array.from(dirtyDeletes);
  3296. const deleteSet = new Set<number>(deletes);
  3297. for (const shopId of Array.from(dirtyMoves.keys())) {
  3298. if (shopId <= 0) continue;
  3299. if (deleteSet.has(shopId)) continue; // delete wins
  3300. const current = shopById.get(shopId);
  3301. if (!current) continue;
  3302. const s = current.shop;
  3303. updates.push({
  3304. id: s.id,
  3305. truckLanceCode: current.lane.truckLanceCode,
  3306. departureTime: parseTimeForBackend(s.departureTime),
  3307. loadingSequence: Number(s.loadingSequence ?? 0) || 0,
  3308. districtReference:
  3309. s.districtReferenceRaw != null &&
  3310. String(s.districtReferenceRaw).trim() !== ""
  3311. ? String(s.districtReferenceRaw).trim()
  3312. : null,
  3313. storeId: normalizeStoreId(s.storeId),
  3314. remark:
  3315. s.remark != null && String(s.remark).trim() !== ""
  3316. ? String(s.remark)
  3317. : null,
  3318. /** 與目標車線桶一致;跨線拖曳時否則後端不會改 truck.logistic(Save 未帶 updateLogistic 時略過) */
  3319. logisticId: current.lane.logisticId ?? null,
  3320. updateLogistic: true,
  3321. });
  3322. }
  3323. // lane logistic(整桶更新):避免「只改物流商」或「跨線拖曳後 lane 內 logistic 不一致」冇落 DB
  3324. const laneIdsToUpdateLogistic = new Set<string>([
  3325. ...laneLogisticChangesLive.map((c) => c.laneId),
  3326. ]);
  3327. const laneLogisticUpdateReqs = Array.from(laneIdsToUpdateLogistic)
  3328. .map((laneId) => lanesRef.current.find((l) => l.id === laneId))
  3329. .filter((l): l is Lane => l != null)
  3330. .map((lane) => ({
  3331. truckLanceCode: lane.truckLanceCode,
  3332. remark:
  3333. lane.remark != null && String(lane.remark).trim() !== ""
  3334. ? String(lane.remark).trim()
  3335. : null,
  3336. logisticId: lane.logisticId != null ? Number(lane.logisticId) : null,
  3337. }));
  3338. const results = await Promise.allSettled(
  3339. updates.map(async (u) => {
  3340. const res = await updateTruckLaneClient(u);
  3341. assertMsgOk(res, t("api_fail_updateLane"));
  3342. return res;
  3343. }),
  3344. );
  3345. const deleteResults = await Promise.allSettled(
  3346. deletes.map(async (id) => {
  3347. const res = await deleteTruckLaneClient({ id });
  3348. assertMsgOk(res, t("api_fail_deleteShop"));
  3349. return res;
  3350. }),
  3351. );
  3352. const logisticResults = await Promise.allSettled(
  3353. laneLogisticUpdateReqs.map(async (req) => {
  3354. const res = await updateLaneLogisticClient(req);
  3355. assertMsgOk(res, t("api_fail_updateLogistics"));
  3356. return res;
  3357. }),
  3358. );
  3359. const failedIdx: number[] = [];
  3360. results.forEach((r, idx) => {
  3361. if (r.status === "rejected") failedIdx.push(idx);
  3362. });
  3363. const failedDeleteIds = new Set<number>();
  3364. deleteResults.forEach((r, idx) => {
  3365. if (r.status === "rejected") failedDeleteIds.add(deletes[idx]);
  3366. });
  3367. const failedLaneLogistics = new Set<string>();
  3368. logisticResults.forEach((r, idx) => {
  3369. if (r.status !== "rejected") return;
  3370. const lane = lanesRef.current.find(
  3371. (l) =>
  3372. l.truckLanceCode === laneLogisticUpdateReqs[idx]?.truckLanceCode &&
  3373. String(l.remark ?? "").trim() ===
  3374. String(laneLogisticUpdateReqs[idx]?.remark ?? "").trim(),
  3375. );
  3376. if (lane) failedLaneLogistics.add(lane.id);
  3377. });
  3378. if (
  3379. failedIdx.length === 0 &&
  3380. failedDeleteIds.size === 0 &&
  3381. failedLaneLogistics.size === 0
  3382. ) {
  3383. const hadPendingImport = pendingImportFile != null;
  3384. if (hadPendingImport) {
  3385. const importFd = new FormData();
  3386. importFd.append("multipartFileList", pendingImportFile);
  3387. const importRes = await importRouteLanesExcelClient(importFd);
  3388. assertMsgOk(importRes, t("err_import"));
  3389. pendingImportFileRef.current = null;
  3390. setPendingImportMeta(null);
  3391. }
  3392. const dm = dirtyMoves;
  3393. const laneIdsToRefresh = new Set<string>(
  3394. lanesNeedingRefreshOnSaveRef.current,
  3395. );
  3396. lanesNeedingRefreshOnSaveRef.current.clear();
  3397. for (const lid of Array.from(dm.values())) laneIdsToRefresh.add(lid);
  3398. for (const lid of Array.from(laneIdsToUpdateLogistic))
  3399. laneIdsToRefresh.add(lid);
  3400. setDirtyMoves(new Map());
  3401. clearDirtyDeletesState();
  3402. setPendingEmptyDistrictsByLane({});
  3403. for (const c of laneLogisticChangesLive) {
  3404. laneLogisticBaselineRef.current.set(c.laneId, c.logisticId);
  3405. }
  3406. if (hadPendingImport) {
  3407. await loadLanes();
  3408. } else if (laneIdsToRefresh.size > 0) {
  3409. await refreshLanesByIds(Array.from(laneIdsToRefresh), {
  3410. preserveStagedLogistics: false,
  3411. });
  3412. }
  3413. try {
  3414. await createTruckLaneSnapshotClient({
  3415. truckLanceCode: null,
  3416. note: "board save",
  3417. });
  3418. await refreshLogVersions();
  3419. } catch (snapErr: any) {
  3420. console.warn("Auto snapshot after board save failed:", snapErr);
  3421. }
  3422. setSaveResult({ ok: true, message: t("Saved") });
  3423. return;
  3424. }
  3425. const failedIds = new Set<number>(failedIdx.map((i) => updates[i].id));
  3426. setDirtyMoves((prev) => {
  3427. const next = new Map<number, string>();
  3428. prev.forEach((laneId, shopId) => {
  3429. if (failedIds.has(shopId)) next.set(shopId, laneId);
  3430. });
  3431. return next;
  3432. });
  3433. setDirtyDeletes((prev) => {
  3434. const next = new Set<number>();
  3435. prev.forEach((id) => {
  3436. if (failedDeleteIds.has(id)) next.add(id);
  3437. });
  3438. return next;
  3439. });
  3440. const firstReason = (results[failedIdx[0]] as PromiseRejectedResult)
  3441. ?.reason as any;
  3442. const reasonText =
  3443. firstReason?.message ??
  3444. (firstReason != null ? String(firstReason) : "");
  3445. setSaveResult({
  3446. ok: false,
  3447. message: `Saved ${updates.length - failedIdx.length}, Failed ${
  3448. failedIdx.length
  3449. }, Deleted ${deletes.length - failedDeleteIds.size}, DeleteFailed ${
  3450. failedDeleteIds.size
  3451. }, LaneLogisticFailed ${failedLaneLogistics.size}${
  3452. reasonText ? `: ${reasonText}` : ""
  3453. }`,
  3454. });
  3455. } catch (e: any) {
  3456. console.error("Save failed:", e);
  3457. setSaveResult({
  3458. ok: false,
  3459. message: e?.message ?? String(e) ?? t("Failed to save"),
  3460. });
  3461. } finally {
  3462. setSaving(false);
  3463. saveInFlightRef.current = false;
  3464. }
  3465. };
  3466. const handleCancel = async () => {
  3467. const pendingLaneKeys = new Set(
  3468. pendingNewLanesRef.current.map((p) => p.laneKey),
  3469. );
  3470. setPendingShopAdds([]);
  3471. setPendingNewLanes([]);
  3472. setPendingLogisticMasterAdds([]);
  3473. setPendingLogisticMasterEdits(new Map());
  3474. setPendingLogisticMasterDeletes(new Set());
  3475. pendingImportFileRef.current = null;
  3476. setPendingImportMeta(null);
  3477. pendingLaneKeys.forEach((id) =>
  3478. laneLogisticBaselineRef.current.delete(id),
  3479. );
  3480. const snapshot = stripDraftShopRows(lanesRef.current).filter(
  3481. (l) => !pendingLaneKeys.has(l.id),
  3482. );
  3483. const dm = dirtyMoves;
  3484. const laneIdsToRefresh = new Set<string>(
  3485. lanesNeedingRefreshOnSaveRef.current,
  3486. );
  3487. lanesNeedingRefreshOnSaveRef.current.clear();
  3488. dm.forEach((lid) => laneIdsToRefresh.add(lid));
  3489. snapshot.forEach((lane) => {
  3490. if (lane.shops.some((s) => dm.has(s.id))) laneIdsToRefresh.add(lane.id);
  3491. const currentLogisticId =
  3492. lane.logisticId != null ? Number(lane.logisticId) : null;
  3493. const baselineLogisticId = laneLogisticBaselineRef.current.has(lane.id)
  3494. ? laneLogisticBaselineRef.current.get(lane.id) ?? null
  3495. : null;
  3496. if (currentLogisticId !== baselineLogisticId)
  3497. laneIdsToRefresh.add(lane.id);
  3498. });
  3499. const restoredLogistics = snapshot.map((lane) => {
  3500. const currentLogisticId =
  3501. lane.logisticId != null ? Number(lane.logisticId) : null;
  3502. const baselineLogisticId = laneLogisticBaselineRef.current.has(lane.id)
  3503. ? laneLogisticBaselineRef.current.get(lane.id) ?? null
  3504. : null;
  3505. if (currentLogisticId === baselineLogisticId) return lane;
  3506. const master =
  3507. baselineLogisticId != null
  3508. ? logisticRowsFromDb.find((r) => Number(r.id) === baselineLogisticId)
  3509. : null;
  3510. return {
  3511. ...lane,
  3512. logisticId: baselineLogisticId,
  3513. logisticsCompany:
  3514. baselineLogisticId == null
  3515. ? ""
  3516. : master?.logisticName?.trim() ||
  3517. logisticNameById.get(baselineLogisticId) ||
  3518. "",
  3519. plate: master?.carPlate?.trim() || "",
  3520. driver: master?.driverName?.trim() || "",
  3521. phone:
  3522. master?.driverNumber != null &&
  3523. Number.isFinite(Number(master.driverNumber))
  3524. ? String(master.driverNumber)
  3525. : "",
  3526. };
  3527. });
  3528. lanesRef.current = restoredLogistics;
  3529. setLanes(restoredLogistics);
  3530. setDirtyMoves(new Map());
  3531. clearDirtyDeletesState();
  3532. setPendingEmptyDistrictsByLane({});
  3533. closeDistrictEdit();
  3534. setSaveResult(null);
  3535. setPendingRestoreVersionId(null);
  3536. if (laneIdsToRefresh.size > 0) {
  3537. await refreshLanesByIds(Array.from(laneIdsToRefresh), {
  3538. preserveStagedLogistics: false,
  3539. });
  3540. } else {
  3541. await loadLanes();
  3542. }
  3543. };
  3544. const closeDepartureEdit = () => {
  3545. setDepartureEditLaneId(null);
  3546. };
  3547. const openDepartureEdit = (lane: Lane) => {
  3548. setDepartureEditDraft(toTimeInputValue(lane.startTime));
  3549. setDepartureEditNameDraft(lane.truckLanceCode);
  3550. setDepartureEditLaneId(lane.id);
  3551. };
  3552. const retargetLaneId = (oldId: string, newId: string, newCode: string) => {
  3553. if (oldId === newId) return;
  3554. setDirtyMoves((prev) => {
  3555. const next = new Map(prev);
  3556. for (const [shopId, laneId] of next) {
  3557. if (laneId === oldId) next.set(shopId, newId);
  3558. }
  3559. return next;
  3560. });
  3561. setPendingShopAdds((prev) =>
  3562. prev.map((row) => (row.laneId === oldId ? { ...row, laneId: newId } : row)),
  3563. );
  3564. setPendingEmptyDistrictsByLane((prev) => {
  3565. if (!(oldId in prev)) return prev;
  3566. const next = { ...prev, [newId]: prev[oldId] };
  3567. delete next[oldId];
  3568. return next;
  3569. });
  3570. setSelectedLaneIds((prev) => prev.map((id) => (id === oldId ? newId : id)));
  3571. setPendingNewLanes((prev) =>
  3572. prev.map((row) =>
  3573. row.laneKey === oldId
  3574. ? {
  3575. laneKey: newId,
  3576. payload: { ...row.payload, truckLanceCode: newCode },
  3577. }
  3578. : row,
  3579. ),
  3580. );
  3581. if (laneLogisticBaselineRef.current.has(oldId)) {
  3582. const value = laneLogisticBaselineRef.current.get(oldId) ?? null;
  3583. laneLogisticBaselineRef.current.delete(oldId);
  3584. laneLogisticBaselineRef.current.set(newId, value);
  3585. }
  3586. if (laneDisplayOverlayRef.current.has(oldId)) {
  3587. const value = laneDisplayOverlayRef.current.get(oldId);
  3588. laneDisplayOverlayRef.current.delete(oldId);
  3589. if (value) laneDisplayOverlayRef.current.set(newId, value);
  3590. }
  3591. if (lanesNeedingRefreshOnSaveRef.current.delete(oldId)) {
  3592. lanesNeedingRefreshOnSaveRef.current.add(newId);
  3593. }
  3594. };
  3595. const applyDepartureEdit = async () => {
  3596. if (!departureEditLaneId || departureEditBusy) return;
  3597. const backendTime = parseTimeForBackend(departureEditDraft);
  3598. const targetLane = lanesRef.current.find((l) => l.id === departureEditLaneId);
  3599. if (!targetLane) {
  3600. closeDepartureEdit();
  3601. return;
  3602. }
  3603. const newCode = departureEditNameDraft.trim();
  3604. const oldCode = targetLane.truckLanceCode.trim();
  3605. if (!newCode) {
  3606. window.alert(t("renameErr_LANE_NAME_EMPTY"));
  3607. return;
  3608. }
  3609. if (newCode.length > 50) {
  3610. window.alert(t("renameErr_LANE_NAME_TOO_LONG"));
  3611. return;
  3612. }
  3613. if (newCode === "車線-X" || oldCode === "車線-X") {
  3614. window.alert(t("renameErr_LANE_NAME_RESERVED"));
  3615. return;
  3616. }
  3617. const nameChanged = newCode !== oldCode;
  3618. const shopIds = targetLane.shops.map((s) => s.id);
  3619. if (!nameChanged && shopIds.length === 0) {
  3620. closeDepartureEdit();
  3621. return;
  3622. }
  3623. if (nameChanged) {
  3624. const floor = normalizeStoreId(targetLane.storeId);
  3625. const remarkKey =
  3626. floor === "4F" ? String(targetLane.remark ?? "").trim() : "";
  3627. const clash = lanesRef.current.find((lane) => {
  3628. if (lane.id === targetLane.id) return false;
  3629. if (normalizeStoreId(lane.storeId) !== floor) return false;
  3630. const otherRemark =
  3631. floor === "4F" ? String(lane.remark ?? "").trim() : "";
  3632. const otherCode = lane.truckLanceCode.trim();
  3633. if (otherRemark === remarkKey && otherCode.toLowerCase() === newCode.toLowerCase()) {
  3634. return true;
  3635. }
  3636. return otherRemark !== remarkKey && otherCode === newCode;
  3637. });
  3638. if (clash) {
  3639. const sameRemark =
  3640. floor !== "4F" || String(clash.remark ?? "").trim() === remarkKey;
  3641. window.alert(
  3642. sameRemark
  3643. ? t("renameErr_LANE_NAME_TAKEN")
  3644. : t("renameErr_LANE_NAME_REMARK_AMBIGUOUS"),
  3645. );
  3646. return;
  3647. }
  3648. if (floor === "4F") {
  3649. const before = parseWeekdayFromTruckLanceCode(oldCode);
  3650. const after = parseWeekdayFromTruckLanceCode(newCode);
  3651. if (before !== after) {
  3652. if (!window.confirm(t("confirm_weekdayToken"))) return;
  3653. }
  3654. }
  3655. if (targetLane.persistedLane !== false) {
  3656. setDepartureEditBusy(true);
  3657. try {
  3658. const preview = await previewTruckLaneRenameClient({
  3659. truckLanceCode: targetLane.originalTruckLanceCode?.trim() || oldCode,
  3660. storeId: floor,
  3661. remark: targetLane.remark ?? null,
  3662. });
  3663. const handlers =
  3664. preview.handlerNames.length > 0
  3665. ? preview.handlerNames.slice(0, 8).join("、")
  3666. : t("rename_noHandlers");
  3667. const ok = window.confirm(
  3668. t("confirm_renameLane", {
  3669. from: targetLane.originalTruckLanceCode?.trim() || oldCode,
  3670. to: newCode,
  3671. unfinished: preview.unfinishedCount,
  3672. completed: preview.completedCount,
  3673. handlers,
  3674. schedules: preview.openScheduleCount,
  3675. }),
  3676. );
  3677. if (!ok) return;
  3678. } catch (e: any) {
  3679. window.alert(
  3680. renameErrorText(e?.message ?? String(e), t) || t("renameErr_preview"),
  3681. );
  3682. return;
  3683. } finally {
  3684. setDepartureEditBusy(false);
  3685. }
  3686. }
  3687. }
  3688. const newId = nameChanged
  3689. ? encodeLaneId(newCode, targetLane.remark ?? null)
  3690. : targetLane.id;
  3691. const nextLanes = lanesRef.current.map((lane) => {
  3692. if (lane.id !== targetLane.id) return lane;
  3693. const timeChanged = toTimeInputValue(lane.startTime) !== departureEditDraft;
  3694. return {
  3695. ...lane,
  3696. id: newId,
  3697. truckLanceCode: nameChanged ? newCode : lane.truckLanceCode,
  3698. originalTruckLanceCode: nameChanged
  3699. ? lane.originalTruckLanceCode ?? oldCode
  3700. : lane.originalTruckLanceCode,
  3701. originalStartTime: timeChanged
  3702. ? lane.originalStartTime ?? lane.startTime
  3703. : lane.originalStartTime,
  3704. startTime: backendTime,
  3705. shops: lane.shops.map((s) => ({ ...s, departureTime: backendTime })),
  3706. };
  3707. });
  3708. const timeChanged =
  3709. toTimeInputValue(targetLane.startTime) !== departureEditDraft;
  3710. if (shopIds.length > 0 && timeChanged) {
  3711. const wr = computeTruckLaneWarnings(lanesToWarningInputRows(nextLanes));
  3712. if (wr.warnings.length > 0) {
  3713. const ok = window.confirm(
  3714. t("confirm_departureConflict", { count: wr.warnings.length }),
  3715. );
  3716. if (!ok) return;
  3717. }
  3718. }
  3719. lanesRef.current = nextLanes;
  3720. setLanes(nextLanes);
  3721. if (nameChanged) retargetLaneId(targetLane.id, newId, newCode);
  3722. if (shopIds.length > 0 && (nameChanged || toTimeInputValue(targetLane.startTime) !== departureEditDraft)) {
  3723. setDirtyMoves((prev) => {
  3724. const next = new Map(prev);
  3725. for (const id of shopIds) {
  3726. if (id > 0) next.set(id, newId);
  3727. }
  3728. return next;
  3729. });
  3730. }
  3731. setSaveResult(null);
  3732. closeDepartureEdit();
  3733. };
  3734. const closeSeqEdit = () => {
  3735. setSeqEditTarget(null);
  3736. };
  3737. const openSeqEdit = (lane: Lane, shop: ShopCard) => {
  3738. setSeqEditDraft(String(shop.loadingSequence ?? 0));
  3739. setSeqEditTarget({ laneId: lane.id, shopId: shop.id });
  3740. };
  3741. const applySeqEdit = () => {
  3742. if (!seqEditTarget) return;
  3743. const n = Number(seqEditDraft);
  3744. const seq = Number.isFinite(n) ? Math.trunc(n) : 0;
  3745. setLanes((prev) =>
  3746. prev.map((lane) =>
  3747. lane.id !== seqEditTarget.laneId
  3748. ? lane
  3749. : {
  3750. ...lane,
  3751. shops: lane.shops.map((s) =>
  3752. s.id === seqEditTarget.shopId
  3753. ? { ...s, loadingSequence: seq }
  3754. : s,
  3755. ),
  3756. },
  3757. ),
  3758. );
  3759. setDirtyMoves((prev) => {
  3760. const next = new Map(prev);
  3761. next.set(seqEditTarget.shopId, seqEditTarget.laneId);
  3762. return next;
  3763. });
  3764. setSaveResult(null);
  3765. closeSeqEdit();
  3766. };
  3767. const normalizeChangedLines = (diff: any) => {
  3768. const lines = (diff?.changed || []) as any[];
  3769. return lines
  3770. .map((line: any) => {
  3771. const truckRowId = Number(line?.truckRowId);
  3772. const shopCode = line?.shopCode != null ? String(line.shopCode) : null;
  3773. const changes = Array.isArray(line?.changes)
  3774. ? (line.changes as any[]).map((c) => ({
  3775. field: c?.field != null ? String(c.field) : "",
  3776. from: c?.from != null ? String(c.from) : null,
  3777. to: c?.to != null ? String(c.to) : null,
  3778. }))
  3779. : [];
  3780. return {
  3781. truckRowId,
  3782. shopCode,
  3783. changes: changes.filter((c) => c.field),
  3784. truckLanceCode:
  3785. line?.truckLanceCode != null ? String(line.truckLanceCode) : null,
  3786. remark: line?.remark != null ? String(line.remark) : null,
  3787. };
  3788. })
  3789. .filter((x) => Number.isFinite(x.truckRowId) && x.truckRowId > 0);
  3790. };
  3791. const loadVersionDiff = async (versionId: number, list: any[]) => {
  3792. const ticket = ++versionDiffReqSeq.current;
  3793. setDiffLoading(true);
  3794. setDiffError(null);
  3795. try {
  3796. const idx = list.findIndex((v) => Number(v?.id) === versionId);
  3797. const olderId =
  3798. idx >= 0 && idx < list.length - 1 ? Number(list[idx + 1]?.id) : null;
  3799. if (olderId == null || !Number.isFinite(olderId) || olderId <= 0) {
  3800. if (versionDiffReqSeq.current === ticket) {
  3801. setDiffLines([]);
  3802. setLogisticMasterDiffLines([]);
  3803. setChangedShopIds(new Set());
  3804. }
  3805. return;
  3806. }
  3807. const diff = await diffTruckLaneVersionsClient(olderId, versionId);
  3808. if (versionDiffReqSeq.current !== ticket) return;
  3809. const normalized = normalizeChangedLines(diff);
  3810. const logisticMaster = Array.isArray(diff?.logisticMasterChanges)
  3811. ? (diff.logisticMasterChanges as LogisticMasterDiffLine[])
  3812. : [];
  3813. if (versionDiffReqSeq.current !== ticket) return;
  3814. const ids = new Set<number>();
  3815. normalized.forEach((line) => {
  3816. const id = Number(line?.truckRowId);
  3817. if (Number.isFinite(id) && id > 0) ids.add(id);
  3818. });
  3819. setChangedShopIds(ids);
  3820. setDiffLines(normalized);
  3821. setLogisticMasterDiffLines(logisticMaster);
  3822. } catch (e: any) {
  3823. if (versionDiffReqSeq.current === ticket) {
  3824. setDiffError(e?.message ?? String(e) ?? t("diff_loadFail"));
  3825. setChangedShopIds(new Set());
  3826. setDiffLines([]);
  3827. setLogisticMasterDiffLines([]);
  3828. }
  3829. } finally {
  3830. if (versionDiffReqSeq.current === ticket) setDiffLoading(false);
  3831. }
  3832. };
  3833. const refreshLogVersions = useCallback(async () => {
  3834. try {
  3835. const list = await listTruckLaneVersionsClient();
  3836. const arr = Array.isArray(list) ? list : [];
  3837. setLogVersions(arr);
  3838. return arr;
  3839. } catch (e) {
  3840. console.warn("Failed to load truck lane versions:", e);
  3841. return [];
  3842. }
  3843. }, []);
  3844. useEffect(() => {
  3845. void refreshLogVersions();
  3846. }, [refreshLogVersions]);
  3847. const openLogDialog = async () => {
  3848. setLogDialogOpen(true);
  3849. setDiffError(null);
  3850. setChangedShopIds(new Set());
  3851. setSelectedLogVersionId(null);
  3852. setDiffLines([]);
  3853. setLoadingVersions(true);
  3854. try {
  3855. const arr = await refreshLogVersions();
  3856. void listTruckLaneRenameLogsClient()
  3857. .then((rows) => setRenameLogs(Array.isArray(rows) ? rows : []))
  3858. .catch(() => setRenameLogs([]));
  3859. const head = resolveHeadVersionId(arr);
  3860. setSelectedLogVersionId(head);
  3861. if (head != null) {
  3862. await loadVersionDiff(head, arr);
  3863. }
  3864. } finally {
  3865. setLoadingVersions(false);
  3866. }
  3867. };
  3868. const closeLogDialog = () => {
  3869. versionDiffReqSeq.current += 1;
  3870. setLogDialogOpen(false);
  3871. setDiffError(null);
  3872. setChangedShopIds(new Set());
  3873. setSelectedLogVersionId(null);
  3874. setDiffLines([]);
  3875. setLogisticMasterDiffLines([]);
  3876. setVersionNoteDrafts({});
  3877. setVersionNoteSaveError(null);
  3878. setSavingVersionNoteId(null);
  3879. };
  3880. const saveVersionNote = useCallback(
  3881. async (id: number, serverNote: string, currentValue: string) => {
  3882. const trimmed = currentValue.trim();
  3883. const prev = (serverNote ?? "").trim();
  3884. if (trimmed === prev) {
  3885. setVersionNoteDrafts((p) => {
  3886. if (p[id] === undefined) return p;
  3887. const n = { ...p };
  3888. delete n[id];
  3889. return n;
  3890. });
  3891. return;
  3892. }
  3893. setVersionNoteSaveError(null);
  3894. setSavingVersionNoteId(id);
  3895. try {
  3896. const res = await updateTruckLaneVersionNoteClient(id, {
  3897. note: trimmed === "" ? null : trimmed,
  3898. });
  3899. setLogVersions((list) =>
  3900. list.map((x) =>
  3901. Number(x?.id) === id ? { ...x, note: res.note } : x,
  3902. ),
  3903. );
  3904. setVersionNoteDrafts((p) => {
  3905. const n = { ...p };
  3906. delete n[id];
  3907. return n;
  3908. });
  3909. } catch (e: any) {
  3910. setVersionNoteSaveError({
  3911. id,
  3912. message: e?.message ?? String(e) ?? t("versionNote_saveFail"),
  3913. });
  3914. } finally {
  3915. setSavingVersionNoteId(null);
  3916. }
  3917. },
  3918. [t],
  3919. );
  3920. const restoreVersion = (versionId: number) => {
  3921. if (!Number.isFinite(versionId) || versionId <= 0) return;
  3922. if (pendingRestoreVersionId === versionId) {
  3923. setSaveResult({ ok: true, message: t("diff_restoreAlreadyPending") });
  3924. closeLogDialog();
  3925. return;
  3926. }
  3927. const hasOtherPending =
  3928. dirtyMoves.size > 0 ||
  3929. dirtyDeletes.size > 0 ||
  3930. pendingShopAdds.length > 0 ||
  3931. pendingNewLanes.length > 0 ||
  3932. pendingLogisticMasterAdds.length > 0 ||
  3933. pendingLogisticMasterEdits.size > 0 ||
  3934. pendingLogisticMasterDeletes.size > 0 ||
  3935. pendingImportMeta != null ||
  3936. getLaneLogisticChanges().length > 0 ||
  3937. Object.values(pendingEmptyDistrictsByLane).some(
  3938. (a) => (a?.length ?? 0) > 0,
  3939. );
  3940. if (hasOtherPending && !window.confirm(t("confirm_restoreDiscardsEdits")))
  3941. return;
  3942. if (!window.confirm(t("confirm_restoreKeepsTickets"))) return;
  3943. if (!hasOtherPending && pendingRestoreVersionId != null) {
  3944. laneDisplayOverlayRef.current.clear();
  3945. setPendingRestoreVersionId(versionId);
  3946. setSaveResult({
  3947. ok: true,
  3948. message: t("diff_restoreScheduled", { versionId }),
  3949. });
  3950. closeLogDialog();
  3951. return;
  3952. }
  3953. laneDisplayOverlayRef.current.clear();
  3954. const pendingLaneKeys = new Set(
  3955. pendingNewLanesRef.current.map((p) => p.laneKey),
  3956. );
  3957. pendingLaneKeys.forEach((id) => laneLogisticBaselineRef.current.delete(id));
  3958. setPendingNewLanes([]);
  3959. setLanes((prev) => {
  3960. const next = stripDraftShopRows(prev).filter(
  3961. (l) => !pendingLaneKeys.has(l.id),
  3962. );
  3963. lanesRef.current = next;
  3964. return next;
  3965. });
  3966. setPendingShopAdds([]);
  3967. setDirtyMoves(new Map());
  3968. clearDirtyDeletesState();
  3969. setPendingEmptyDistrictsByLane({});
  3970. closeDistrictEdit();
  3971. closeDepartureEdit();
  3972. setSeqEditTarget(null);
  3973. lanesNeedingRefreshOnSaveRef.current.clear();
  3974. for (const lane of lanesRef.current) {
  3975. laneLogisticBaselineRef.current.set(
  3976. lane.id,
  3977. lane.logisticId != null ? Number(lane.logisticId) : null,
  3978. );
  3979. }
  3980. setPendingRestoreVersionId(versionId);
  3981. setSaveResult({
  3982. ok: true,
  3983. message: t("diff_restoreScheduled", { versionId }),
  3984. });
  3985. closeLogDialog();
  3986. };
  3987. const handleExportVersionLogReportExcel = useCallback(async () => {
  3988. if (routeExcelExportLockRef.current) return;
  3989. if (hasUnsavedChanges) {
  3990. setDiffError(t("diff_export_blockedError"));
  3991. return;
  3992. }
  3993. routeExcelExportLockRef.current = true;
  3994. if (selectedLogVersionId == null) {
  3995. routeExcelExportLockRef.current = false;
  3996. return;
  3997. }
  3998. const idx = logVersions.findIndex(
  3999. (v) => Number(v?.id) === Number(selectedLogVersionId),
  4000. );
  4001. const olderId =
  4002. idx >= 0 && idx < logVersions.length - 1
  4003. ? Number(logVersions[idx + 1]?.id)
  4004. : null;
  4005. if (olderId == null || !Number.isFinite(olderId) || olderId <= 0) {
  4006. setDiffError(t("diff_noOlderCompare"));
  4007. routeExcelExportLockRef.current = false;
  4008. return;
  4009. }
  4010. setRouteExcelBusy(true);
  4011. setDiffError(null);
  4012. try {
  4013. const { base64, filename } =
  4014. await exportTruckLaneVersionReportExcelClient(
  4015. olderId,
  4016. selectedLogVersionId,
  4017. );
  4018. downloadBase64Xlsx(base64, filename);
  4019. } catch (e: any) {
  4020. setDiffError(e?.message ?? String(e) ?? t("err_export"));
  4021. } finally {
  4022. setRouteExcelBusy(false);
  4023. routeExcelExportLockRef.current = false;
  4024. }
  4025. }, [hasUnsavedChanges, logVersions, selectedLogVersionId, t]);
  4026. const renderLaneHeader = (lane: Lane) => {
  4027. const isDirty = lane.shops.some((s) => dirtyMoves.has(s.id));
  4028. return (
  4029. <Box
  4030. sx={{
  4031. px: 2,
  4032. py: 2,
  4033. bgcolor: "background.paper",
  4034. borderBottom: "1px solid",
  4035. borderColor: "divider",
  4036. position: "sticky",
  4037. top: 0,
  4038. zIndex: 2,
  4039. }}
  4040. >
  4041. <Stack
  4042. direction="row"
  4043. justifyContent="space-between"
  4044. alignItems="flex-start"
  4045. spacing={1.5}
  4046. >
  4047. <Stack spacing={1} sx={{ flex: 1, minWidth: 0, pr: 0.5 }}>
  4048. <Box>
  4049. <Typography
  4050. component="h3"
  4051. sx={{
  4052. m: 0,
  4053. fontWeight: 800,
  4054. color: "primary.main",
  4055. fontSize: "1rem",
  4056. lineHeight: 1.35,
  4057. letterSpacing: 0.01,
  4058. wordBreak: "break-word",
  4059. overflowWrap: "anywhere",
  4060. }}
  4061. >
  4062. {lane.truckLanceCode}
  4063. </Typography>
  4064. <Stack
  4065. direction="row"
  4066. alignItems="center"
  4067. spacing={0.75}
  4068. flexWrap="wrap"
  4069. useFlexGap
  4070. sx={{ mt: 0.75 }}
  4071. >
  4072. {lane.remark != null && String(lane.remark).trim() !== "" && (
  4073. <Chip
  4074. label={lane.remark}
  4075. size="small"
  4076. variant="outlined"
  4077. color="secondary"
  4078. sx={{
  4079. height: 22,
  4080. maxWidth: "100%",
  4081. "& .MuiChip-label": {
  4082. px: 1,
  4083. fontWeight: 700,
  4084. fontSize: "0.7rem",
  4085. whiteSpace: "normal",
  4086. lineHeight: 1.2,
  4087. },
  4088. }}
  4089. />
  4090. )}
  4091. {isDirty && (
  4092. <Typography
  4093. variant="caption"
  4094. sx={{ color: "warning.main", fontWeight: 700 }}
  4095. >
  4096. {t("Changed")}
  4097. </Typography>
  4098. )}
  4099. </Stack>
  4100. </Box>
  4101. <Stack spacing={0.5} sx={{ color: "text.secondary" }}>
  4102. <Stack direction="row" spacing={0.75} alignItems="center">
  4103. <Box
  4104. sx={{
  4105. display: "flex",
  4106. color: "text.secondary",
  4107. flexShrink: 0,
  4108. }}
  4109. >
  4110. <TruckIcon size={14} />
  4111. </Box>
  4112. <Typography variant="body2" sx={{ fontWeight: 600 }}>
  4113. {lane.logisticsCompany || t("Logistic")}
  4114. </Typography>
  4115. </Stack>
  4116. <Stack
  4117. direction="row"
  4118. spacing={0.75}
  4119. alignItems="center"
  4120. flexWrap="wrap"
  4121. useFlexGap
  4122. >
  4123. <Box sx={{ display: "flex", flexShrink: 0 }}>
  4124. <Users size={14} />
  4125. </Box>
  4126. <Typography variant="body2" component="span">
  4127. {lane.driver ? lane.driver : t("Driver")}
  4128. </Typography>
  4129. <Box
  4130. sx={{
  4131. display: "inline-flex",
  4132. alignItems: "center",
  4133. gap: 0.35,
  4134. ml: 0.5,
  4135. }}
  4136. >
  4137. <Phone size={14} />
  4138. <Typography
  4139. variant="caption"
  4140. component="span"
  4141. sx={{ color: "primary.main", fontWeight: 700 }}
  4142. >
  4143. {lane.phone || "—"}
  4144. </Typography>
  4145. </Box>
  4146. </Stack>
  4147. </Stack>
  4148. </Stack>
  4149. <Stack alignItems="flex-end" spacing={1} sx={{ flexShrink: 0 }}>
  4150. <Typography
  4151. variant="caption"
  4152. sx={{
  4153. fontFamily: "monospace",
  4154. px: 1,
  4155. py: 0.35,
  4156. border: "1px solid",
  4157. borderColor: "divider",
  4158. borderRadius: 1,
  4159. bgcolor: "grey.50",
  4160. display: "block",
  4161. textAlign: "center",
  4162. }}
  4163. >
  4164. {lane.plate || t("Plate")}
  4165. </Typography>
  4166. <Stack direction="row" alignItems="center" spacing={0.25}>
  4167. <Typography
  4168. variant="caption"
  4169. sx={{
  4170. fontWeight: 700,
  4171. bgcolor: "grey.100",
  4172. px: 1,
  4173. py: 0.35,
  4174. borderRadius: 1,
  4175. whiteSpace: "nowrap",
  4176. }}
  4177. >
  4178. {t("Departure")}: {lane.startTime || "-"}
  4179. </Typography>
  4180. <Tooltip
  4181. title={t("laneEditTooltip")}
  4182. >
  4183. <span>
  4184. <IconButton
  4185. size="small"
  4186. onClick={(e) => {
  4187. e.stopPropagation();
  4188. openDepartureEdit(lane);
  4189. }}
  4190. disabled={loading}
  4191. aria-label={t("laneEditTooltip")}
  4192. >
  4193. <Pencil size={14} />
  4194. </IconButton>
  4195. </span>
  4196. </Tooltip>
  4197. </Stack>
  4198. <Typography
  4199. variant="caption"
  4200. sx={{ color: "text.secondary", fontWeight: 600 }}
  4201. >
  4202. {t("Shops")}: {lane.shops.length}
  4203. </Typography>
  4204. </Stack>
  4205. </Stack>
  4206. </Box>
  4207. );
  4208. };
  4209. return (
  4210. <Box
  4211. sx={{
  4212. flex: 1,
  4213. minHeight: 0,
  4214. height: "100%",
  4215. width: "100%",
  4216. display: "flex",
  4217. flexDirection: "column",
  4218. bgcolor: "grey.50",
  4219. overflow: "hidden",
  4220. }}
  4221. >
  4222. {/* Header (match your reference code structure) */}
  4223. <Box
  4224. sx={{
  4225. bgcolor: "background.paper",
  4226. borderBottom: "1px solid",
  4227. borderColor: "divider",
  4228. px: 2,
  4229. py: 1.5,
  4230. position: "sticky",
  4231. top: 0,
  4232. zIndex: 10,
  4233. }}
  4234. >
  4235. <Stack
  4236. direction="row"
  4237. justifyContent="space-between"
  4238. alignItems="center"
  4239. spacing={2}
  4240. >
  4241. <Box>
  4242. <Typography variant="h6" sx={{ fontWeight: 800 }}>
  4243. {t("pageTitle")}
  4244. </Typography>
  4245. <Typography variant="caption" color="text.secondary">
  4246. {t("Current version")}: {displayedVersionLabel}
  4247. </Typography>
  4248. </Box>
  4249. <Stack
  4250. direction="row"
  4251. spacing={1}
  4252. alignItems="center"
  4253. useFlexGap
  4254. flexWrap="wrap"
  4255. justifyContent="flex-end"
  4256. sx={{ minWidth: 0, rowGap: 0.5 }}
  4257. >
  4258. <input
  4259. ref={importRouteFileInputRef}
  4260. type="file"
  4261. accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
  4262. style={{ display: "none" }}
  4263. onChange={handleImportRouteExcelChange}
  4264. />
  4265. <ButtonGroup
  4266. variant="outlined"
  4267. size="small"
  4268. sx={{
  4269. flexShrink: 0,
  4270. "& .MuiButtonGroup-grouped": {
  4271. minWidth: 0,
  4272. px: 1.5,
  4273. fontWeight: 700,
  4274. },
  4275. }}
  4276. >
  4277. <Button
  4278. startIcon={<Upload size={16} />}
  4279. disabled={loading || routeExcelBusy}
  4280. onClick={() => importRouteFileInputRef.current?.click()}
  4281. >
  4282. {t("importRoutes")}
  4283. </Button>
  4284. <Button
  4285. startIcon={<Download size={16} />}
  4286. disabled={loading || routeExcelBusy}
  4287. onClick={() => void handleExportSelectedLanesExcel()}
  4288. >
  4289. {t("exportRoutes")}
  4290. </Button>
  4291. </ButtonGroup>
  4292. <Button
  4293. variant="outlined"
  4294. size="small"
  4295. startIcon={<FileText size={16} />}
  4296. disabled={loading || routeExcelBusy}
  4297. onClick={() => void handleExportRouteReportExcel()}
  4298. >
  4299. {t("routeReport")}
  4300. </Button>
  4301. <ButtonGroup
  4302. variant="outlined"
  4303. size="small"
  4304. sx={{
  4305. flexShrink: 0,
  4306. "& .MuiButtonGroup-grouped": {
  4307. minWidth: 0,
  4308. px: 1.5,
  4309. fontWeight: 700,
  4310. },
  4311. }}
  4312. >
  4313. <Button
  4314. startIcon={<Clock size={16} />}
  4315. disabled={loading || lanes.length === 0}
  4316. onClick={() => setScheduleModalOpen(true)}
  4317. >
  4318. {t("btn_scheduleChange")}
  4319. </Button>
  4320. <Tooltip
  4321. title={
  4322. failedScheduleCount > 0
  4323. ? t("schedule_log_failed_hint", { count: failedScheduleCount })
  4324. : ""
  4325. }
  4326. >
  4327. <Button
  4328. startIcon={
  4329. <Badge
  4330. color="error"
  4331. badgeContent={failedScheduleCount}
  4332. invisible={failedScheduleCount === 0}
  4333. sx={{
  4334. "& .MuiBadge-badge": {
  4335. fontSize: 10,
  4336. height: 16,
  4337. minWidth: 16,
  4338. padding: "0 4px",
  4339. },
  4340. }}
  4341. >
  4342. <FileText size={16} />
  4343. </Badge>
  4344. }
  4345. disabled={loading}
  4346. {...(failedScheduleCount > 0 ? { color: "error" as const } : {})}
  4347. onClick={() => setScheduleHistoryOpen(true)}
  4348. >
  4349. {t("btn_scheduleHistory")}
  4350. </Button>
  4351. </Tooltip>
  4352. </ButtonGroup>
  4353. <Tooltip
  4354. title={
  4355. laneWarnCount > 0
  4356. ? t("mtmsRouteWarn_tooltipHas", { count: laneWarnCount })
  4357. : t("mtmsRouteWarn_tooltipNone")
  4358. }
  4359. >
  4360. <Box
  4361. component="span"
  4362. sx={{ flexShrink: 0, display: "inline-flex" }}
  4363. >
  4364. <IconButton
  4365. size="small"
  4366. onClick={() => setLaneWarnDrawerOpen(true)}
  4367. aria-label={t("mtmsRouteWarn_title")}
  4368. sx={{
  4369. color:
  4370. laneWarnCount > 0 ? "warning.main" : "text.secondary",
  4371. }}
  4372. >
  4373. <Badge
  4374. color="error"
  4375. badgeContent={laneWarnCount}
  4376. invisible={laneWarnCount === 0}
  4377. >
  4378. <Bell size={20} />
  4379. </Badge>
  4380. </IconButton>
  4381. </Box>
  4382. </Tooltip>
  4383. <Divider flexItem orientation="vertical" />
  4384. <Button
  4385. variant="contained"
  4386. size="small"
  4387. startIcon={<Save size={16} />}
  4388. onClick={handleSave}
  4389. disabled={saving || !hasUnsavedChanges}
  4390. title={
  4391. saving || hasUnsavedChanges
  4392. ? undefined
  4393. : t("saveDisabledTooltip")
  4394. }
  4395. >
  4396. {saving ? t("Submitting...") : t("saveChanges")}
  4397. </Button>
  4398. <Button
  4399. variant="outlined"
  4400. size="small"
  4401. startIcon={<X size={16} />}
  4402. onClick={handleCancel}
  4403. disabled={saving}
  4404. >
  4405. {t("cancel")}
  4406. </Button>
  4407. </Stack>
  4408. </Stack>
  4409. {saveResult && (
  4410. <Alert sx={{ mt: 1 }} severity={saveResult.ok ? "success" : "error"}>
  4411. {saveResult.message}
  4412. </Alert>
  4413. )}
  4414. </Box>
  4415. <Drawer
  4416. anchor="right"
  4417. open={laneWarnDrawerOpen}
  4418. onClose={() => setLaneWarnDrawerOpen(false)}
  4419. PaperProps={{
  4420. sx: {
  4421. width: { xs: "100%", sm: 440 },
  4422. p: 0,
  4423. display: "flex",
  4424. flexDirection: "column",
  4425. maxHeight: "100vh",
  4426. },
  4427. }}
  4428. >
  4429. <Box sx={{ p: 2, borderBottom: 1, borderColor: "divider" }}>
  4430. <Stack
  4431. direction="row"
  4432. alignItems="center"
  4433. justifyContent="space-between"
  4434. spacing={1}
  4435. >
  4436. <Typography variant="h6" sx={{ fontWeight: 800 }}>
  4437. {t("mtmsRouteWarn_title")}
  4438. </Typography>
  4439. <IconButton
  4440. size="small"
  4441. aria-label={t("drawerClose")}
  4442. onClick={() => setLaneWarnDrawerOpen(false)}
  4443. >
  4444. <X size={18} />
  4445. </IconButton>
  4446. </Stack>
  4447. <Stack direction="row" spacing={1} sx={{ mt: 1.5 }} flexWrap="wrap">
  4448. <Button
  4449. size="small"
  4450. variant="outlined"
  4451. disabled={loading}
  4452. onClick={() => void loadLanes()}
  4453. >
  4454. {loading ? t("mtmsRouteWarn_refreshing") : t("mtmsRouteWarn_refresh")}
  4455. </Button>
  4456. <Button
  4457. size="small"
  4458. variant="outlined"
  4459. disabled={laneWarningsMemo.warnings.length === 0}
  4460. onClick={async () => {
  4461. const text = formatLaneWarningsClipboard(
  4462. laneWarningsMemo.warnings,
  4463. t,
  4464. );
  4465. try {
  4466. await navigator.clipboard.writeText(text);
  4467. } catch {
  4468. console.warn("clipboard write failed");
  4469. }
  4470. }}
  4471. >
  4472. {t("mtmsRouteWarn_copyAll")}
  4473. </Button>
  4474. </Stack>
  4475. </Box>
  4476. <Box sx={{ p: 1.5, overflow: "auto", flex: 1, minHeight: 0 }}>
  4477. {laneWarningsMemo.weekdayParseFailures.length > 0 && (
  4478. <Alert severity="info" sx={{ mb: 1 }}>
  4479. {t("mtmsRouteWarn_parseHint", {
  4480. count: laneWarningsMemo.weekdayParseFailures.length,
  4481. })}
  4482. </Alert>
  4483. )}
  4484. {laneWarningsMemo.warnings.length === 0 ? (
  4485. <Typography variant="body2" color="text.secondary">
  4486. {t("mtmsRouteWarn_empty")}
  4487. </Typography>
  4488. ) : (
  4489. laneWarningsMemo.warnings.map((w, i) => {
  4490. const shopHeadline = [w.shopCode, w.shopDisplayName]
  4491. .map((s) => String(s ?? "").trim())
  4492. .filter(Boolean)
  4493. .join(" ");
  4494. const expanded = laneWarnExpandedIdx === i;
  4495. return (
  4496. <Card
  4497. key={`${w.rule}-${w.shopCode}-${w.triggerValue}-${i}`}
  4498. variant="outlined"
  4499. sx={{ mb: 1 }}
  4500. >
  4501. <CardContent
  4502. sx={{ py: 1, px: 1.5, "&:last-child": { pb: 1 } }}
  4503. >
  4504. <Stack
  4505. direction="row"
  4506. alignItems="flex-start"
  4507. spacing={0.5}
  4508. >
  4509. <Box
  4510. role="button"
  4511. tabIndex={0}
  4512. onClick={() => selectLanesFromWarning(w)}
  4513. onKeyDown={(e) => {
  4514. if (e.key === "Enter" || e.key === " ") {
  4515. e.preventDefault();
  4516. selectLanesFromWarning(w);
  4517. }
  4518. }}
  4519. sx={{ flex: 1, minWidth: 0, cursor: "pointer" }}
  4520. >
  4521. <Typography variant="subtitle1" fontWeight={800}>
  4522. {t("mtmsRouteWarn_shop")}: {shopHeadline || "—"}
  4523. </Typography>
  4524. <Typography variant="body2" color="text.secondary">
  4525. {formatWarningSummary(w, t)}
  4526. </Typography>
  4527. </Box>
  4528. <IconButton
  4529. size="small"
  4530. aria-label={expanded ? t("warnCollapse") : t("warnExpand")}
  4531. onClick={(e) => {
  4532. e.stopPropagation();
  4533. setLaneWarnExpandedIdx((prev) =>
  4534. prev === i ? null : i,
  4535. );
  4536. }}
  4537. >
  4538. <ExpandMoreIcon
  4539. sx={{
  4540. transform: expanded
  4541. ? "rotate(180deg)"
  4542. : "none",
  4543. transition: "transform 0.2s",
  4544. }}
  4545. />
  4546. </IconButton>
  4547. </Stack>
  4548. </CardContent>
  4549. <Collapse in={expanded} timeout="auto" unmountOnExit>
  4550. <Box sx={{ px: 1.5, pb: 1.5 }}>
  4551. <Stack spacing={1}>
  4552. {w.lanes.map((L) => (
  4553. <Paper
  4554. key={L.laneKey}
  4555. variant="outlined"
  4556. sx={{ p: 1 }}
  4557. >
  4558. <Typography
  4559. variant="body2"
  4560. sx={{ fontWeight: 600 }}
  4561. >
  4562. {L.truckLanceCode}
  4563. {L.laneRemark ? ` · ${L.laneRemark}` : ""}
  4564. </Typography>
  4565. <Typography variant="caption" display="block">
  4566. {formatLaneWarningDetail(L, t)}
  4567. </Typography>
  4568. </Paper>
  4569. ))}
  4570. </Stack>
  4571. </Box>
  4572. </Collapse>
  4573. </Card>
  4574. );
  4575. })
  4576. )}
  4577. </Box>
  4578. </Drawer>
  4579. <Snackbar
  4580. open={laneWarnSnackbar != null}
  4581. autoHideDuration={9000}
  4582. onClose={() => setLaneWarnSnackbar(null)}
  4583. anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
  4584. >
  4585. <Alert
  4586. onClose={() => setLaneWarnSnackbar(null)}
  4587. severity="warning"
  4588. variant="filled"
  4589. sx={{ width: "100%" }}
  4590. >
  4591. {laneWarnSnackbar}
  4592. </Alert>
  4593. </Snackbar>
  4594. <Box sx={{ flex: 1, minHeight: 0, display: "flex" }}>
  4595. {scheduleModalOpen ? (
  4596. <ScheduleChangeModal
  4597. open
  4598. onClose={() => setScheduleModalOpen(false)}
  4599. lanes={scheduleLaneOptions}
  4600. shops={scheduleShopRows}
  4601. allShopsMaster={allShopsMaster}
  4602. pendingTruckRowIds={pendingScheduleShopIds}
  4603. onConfirmManual={handleScheduleConfirmManual}
  4604. onAfterScheduleChange={async () => {
  4605. await refreshScheduleIndicators();
  4606. }}
  4607. />
  4608. ) : null}
  4609. {scheduleHistoryOpen ? (
  4610. <ScheduleTaskHistoryModal
  4611. open
  4612. onClose={() => setScheduleHistoryOpen(false)}
  4613. lanes={scheduleLaneOptions}
  4614. onAfterChange={async () => {
  4615. await refreshScheduleIndicators();
  4616. await loadLanes();
  4617. }}
  4618. />
  4619. ) : null}
  4620. <Dialog
  4621. open={logDialogOpen}
  4622. onClose={closeLogDialog}
  4623. maxWidth="lg"
  4624. fullWidth
  4625. PaperProps={{ sx: { height: "min(85vh, 880px)", maxHeight: "90vh" } }}
  4626. >
  4627. <DialogTitle
  4628. sx={{
  4629. display: "flex",
  4630. alignItems: "center",
  4631. justifyContent: "space-between",
  4632. py: 1.5,
  4633. pl: 2,
  4634. pr: 1,
  4635. }}
  4636. >
  4637. <Typography variant="h6" sx={{ fontWeight: 800, textAlign: "left" }}>
  4638. {t("versionLogDialogTitle")}
  4639. </Typography>
  4640. <IconButton
  4641. aria-label={t("drawerClose")}
  4642. onClick={closeLogDialog}
  4643. size="small"
  4644. disabled={saving}
  4645. >
  4646. <X size={20} />
  4647. </IconButton>
  4648. </DialogTitle>
  4649. <DialogContent
  4650. dividers
  4651. sx={{
  4652. p: 0,
  4653. display: "flex",
  4654. flexDirection: "column",
  4655. minHeight: 0,
  4656. }}
  4657. >
  4658. <Stack direction="row" sx={{ flex: 1, minHeight: 0 }}>
  4659. {/* 左:版本列表 */}
  4660. <Box
  4661. sx={{
  4662. width: "50%",
  4663. borderRight: 1,
  4664. borderColor: "divider",
  4665. bgcolor: "grey.50",
  4666. display: "flex",
  4667. flexDirection: "column",
  4668. minHeight: 0,
  4669. }}
  4670. >
  4671. <Box
  4672. sx={{
  4673. px: 2,
  4674. py: 1.5,
  4675. borderBottom: 1,
  4676. borderColor: "divider",
  4677. bgcolor: "background.paper",
  4678. }}
  4679. >
  4680. <Stack
  4681. direction="row"
  4682. alignItems="center"
  4683. justifyContent="space-between"
  4684. spacing={1}
  4685. >
  4686. <Stack direction="row" alignItems="baseline" spacing={1}>
  4687. <Typography
  4688. variant="overline"
  4689. sx={{ fontWeight: 800, color: "text.secondary" }}
  4690. >
  4691. {t("version_ui_historyTitle")}
  4692. </Typography>
  4693. {!loadingVersions && logVersions.length > 0 && (
  4694. <Typography variant="caption" color="text.secondary">
  4695. {filteredLogVersions.length}/{logVersions.length}
  4696. </Typography>
  4697. )}
  4698. </Stack>
  4699. <IconButton
  4700. size="small"
  4701. onClick={(e) => setVersionFilterAnchor(e.currentTarget)}
  4702. disabled={loadingVersions || logVersions.length === 0}
  4703. aria-label={t("version_ui_filterAria")}
  4704. >
  4705. <FilterListIcon
  4706. fontSize="small"
  4707. color={versionFilterActive ? "primary" : "inherit"}
  4708. />
  4709. </IconButton>
  4710. </Stack>
  4711. </Box>
  4712. {renameLogs.length > 0 && (
  4713. <Box
  4714. sx={{
  4715. px: 2,
  4716. py: 1,
  4717. borderBottom: 1,
  4718. borderColor: "divider",
  4719. bgcolor: "background.paper",
  4720. }}
  4721. >
  4722. <Typography variant="overline" sx={{ fontWeight: 800, color: "text.secondary" }}>
  4723. {t("renameLog_title")}
  4724. </Typography>
  4725. {renameLogs.slice(0, 8).map((row) => (
  4726. <Typography key={row.id} variant="caption" sx={{ display: "block" }}>
  4727. {t("renameLog_line", {
  4728. when: row.created ?? "",
  4729. who: row.createdBy ?? "",
  4730. from: row.oldCode,
  4731. to: row.newCode,
  4732. floor: row.storeId,
  4733. tickets: row.ticketCount,
  4734. })}
  4735. </Typography>
  4736. ))}
  4737. </Box>
  4738. )}
  4739. <Box sx={{ flex: 1, overflow: "auto", p: 2 }}>
  4740. {loadingVersions ? (
  4741. <Box
  4742. sx={{ display: "flex", justifyContent: "center", py: 4 }}
  4743. >
  4744. <CircularProgress size={28} />
  4745. </Box>
  4746. ) : (
  4747. <List
  4748. disablePadding
  4749. component="div"
  4750. role="list"
  4751. aria-label={t("version_ui_listAria")}
  4752. >
  4753. {filteredLogVersions.map((v) => {
  4754. const id = Number(v?.id);
  4755. const created = String(v?.created || "");
  4756. const { date, time } = splitVersionCreated(created);
  4757. const note = v?.note != null ? String(v.note) : "";
  4758. const isSel = selectedLogVersionId === id;
  4759. const isHead =
  4760. headVersionId != null && id === headVersionId;
  4761. return (
  4762. <ListItemButton
  4763. key={id}
  4764. selected={isSel}
  4765. onClick={() => {
  4766. setSelectedLogVersionId(id);
  4767. void loadVersionDiff(id, logVersions);
  4768. }}
  4769. sx={{
  4770. p: 2,
  4771. mb: 1.5,
  4772. flexDirection: "column",
  4773. alignItems: "stretch",
  4774. border: 2,
  4775. borderColor: isSel
  4776. ? "primary.main"
  4777. : "transparent",
  4778. bgcolor: "background.paper",
  4779. borderRadius: 2,
  4780. boxShadow: isSel ? 2 : 1,
  4781. outline: isSel ? "4px solid" : "none",
  4782. outlineColor: isSel
  4783. ? "primary.light"
  4784. : "transparent",
  4785. transition:
  4786. "box-shadow 0.15s, border-color 0.15s",
  4787. "&.Mui-selected": {
  4788. bgcolor: "background.paper",
  4789. },
  4790. "&:hover": {
  4791. borderColor: isSel ? "primary.main" : "divider",
  4792. },
  4793. }}
  4794. >
  4795. <Stack
  4796. direction="row"
  4797. justifyContent="space-between"
  4798. alignItems="flex-start"
  4799. sx={{ mb: 1 }}
  4800. >
  4801. <Typography
  4802. variant="caption"
  4803. sx={{
  4804. fontWeight: 800,
  4805. color: "primary.main",
  4806. letterSpacing: 0.5,
  4807. }}
  4808. >
  4809. {date}
  4810. {time ? ` ${time}` : ""}
  4811. </Typography>
  4812. {isHead && (
  4813. <Typography
  4814. component="span"
  4815. variant="caption"
  4816. sx={{
  4817. px: 1,
  4818. py: 0.25,
  4819. bgcolor: "success.main",
  4820. color: "success.contrastText",
  4821. fontWeight: 800,
  4822. borderRadius: 1,
  4823. }}
  4824. >
  4825. {t("version_ui_snapshotBadge")}
  4826. </Typography>
  4827. )}
  4828. </Stack>
  4829. <Typography
  4830. variant="subtitle2"
  4831. sx={{ fontWeight: 900, mb: 0.5 }}
  4832. >
  4833. {t("version_ui_id", { id })}
  4834. </Typography>
  4835. {(() => {
  4836. const actor = resolveVersionActor(v ?? {});
  4837. return actor ? (
  4838. <Typography
  4839. variant="caption"
  4840. color="text.secondary"
  4841. sx={{ display: "block", mb: 0.5 }}
  4842. >
  4843. {t("version_ui_editedBy", {
  4844. name: actor,
  4845. })}
  4846. </Typography>
  4847. ) : null;
  4848. })()}
  4849. <Box
  4850. onMouseDown={(e) => e.stopPropagation()}
  4851. onClick={(e) => e.stopPropagation()}
  4852. sx={{ mb: 1 }}
  4853. >
  4854. <TextField
  4855. size="small"
  4856. fullWidth
  4857. multiline
  4858. maxRows={3}
  4859. placeholder={t("version_note_placeholder")}
  4860. disabled={savingVersionNoteId === id}
  4861. value={
  4862. Object.prototype.hasOwnProperty.call(
  4863. versionNoteDrafts,
  4864. id,
  4865. )
  4866. ? versionNoteDrafts[id] ?? ""
  4867. : note
  4868. }
  4869. onChange={(e) =>
  4870. setVersionNoteDrafts((p) => ({
  4871. ...p,
  4872. [id]: e.target.value,
  4873. }))
  4874. }
  4875. onBlur={(e) => {
  4876. void saveVersionNote(
  4877. id,
  4878. note,
  4879. e.target.value,
  4880. );
  4881. }}
  4882. inputProps={{ maxLength: 500 }}
  4883. helperText={
  4884. savingVersionNoteId === id
  4885. ? t("version_note_saving")
  4886. : versionNoteSaveError?.id === id
  4887. ? versionNoteSaveError.message
  4888. : ""
  4889. }
  4890. FormHelperTextProps={{
  4891. sx:
  4892. versionNoteSaveError?.id === id
  4893. ? { mt: 0.25, color: "error.main" }
  4894. : { mt: 0.25 },
  4895. }}
  4896. onFocus={() => setVersionNoteSaveError(null)}
  4897. sx={{
  4898. "& .MuiInputBase-input": {
  4899. fontSize: "0.8125rem",
  4900. fontStyle: "italic",
  4901. },
  4902. }}
  4903. />
  4904. </Box>
  4905. </ListItemButton>
  4906. );
  4907. })}
  4908. {!loadingVersions &&
  4909. logVersions.length > 0 &&
  4910. filteredLogVersions.length === 0 && (
  4911. <Typography variant="body2" color="text.secondary">
  4912. {t("version_empty_filtered")}
  4913. </Typography>
  4914. )}
  4915. {logVersions.length === 0 && (
  4916. <Typography variant="body2" color="text.secondary">
  4917. {t("version_empty_list")}
  4918. </Typography>
  4919. )}
  4920. </List>
  4921. )}
  4922. </Box>
  4923. <Popover
  4924. open={Boolean(versionFilterAnchor)}
  4925. anchorEl={versionFilterAnchor}
  4926. onClose={() => setVersionFilterAnchor(null)}
  4927. anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
  4928. transformOrigin={{ vertical: "top", horizontal: "right" }}
  4929. >
  4930. <Box sx={{ p: 2, width: 340 }}>
  4931. <Stack spacing={1.5}>
  4932. <TextField
  4933. size="small"
  4934. label={t("version_search_label")}
  4935. placeholder={t("version_search_placeholder")}
  4936. value={versionFilterQuery}
  4937. onChange={(e) => setVersionFilterQuery(e.target.value)}
  4938. autoFocus
  4939. />
  4940. <TextField
  4941. size="small"
  4942. label={t("version_date_label")}
  4943. type="date"
  4944. value={versionFilterDate}
  4945. onChange={(e) => setVersionFilterDate(e.target.value)}
  4946. InputLabelProps={{ shrink: true }}
  4947. fullWidth
  4948. />
  4949. <Stack direction="row" justifyContent="space-between">
  4950. <Button
  4951. size="small"
  4952. onClick={() => {
  4953. setVersionFilterQuery("");
  4954. setVersionFilterDate("");
  4955. }}
  4956. disabled={!versionFilterActive}
  4957. >
  4958. {t("filter_clear")}
  4959. </Button>
  4960. <Button
  4961. size="small"
  4962. variant="contained"
  4963. onClick={() => setVersionFilterAnchor(null)}
  4964. >
  4965. {t("filter_apply")}
  4966. </Button>
  4967. </Stack>
  4968. </Stack>
  4969. </Box>
  4970. </Popover>
  4971. </Box>
  4972. {/* 右:異動詳情 */}
  4973. <Box
  4974. sx={{
  4975. width: "50%",
  4976. display: "flex",
  4977. flexDirection: "column",
  4978. minHeight: 0,
  4979. bgcolor: "background.paper",
  4980. }}
  4981. >
  4982. <Box
  4983. sx={{
  4984. flex: 1,
  4985. overflow: "auto",
  4986. p: 2,
  4987. display: "flex",
  4988. flexDirection: "column",
  4989. }}
  4990. >
  4991. {diffError && (
  4992. <Alert severity="error" sx={{ mb: 2 }}>
  4993. {diffError}
  4994. </Alert>
  4995. )}
  4996. {selectedLogVersionId == null && !loadingVersions && (
  4997. <Stack
  4998. alignItems="center"
  4999. justifyContent="center"
  5000. sx={{ flex: 1, color: "text.disabled", py: 6 }}
  5001. >
  5002. <History size={48} strokeWidth={1} opacity={0.25} />
  5003. <Typography variant="body2" sx={{ mt: 1 }}>
  5004. {t("diff_clickLeft")}
  5005. </Typography>
  5006. </Stack>
  5007. )}
  5008. {selectedLogVersionId != null && (
  5009. <>
  5010. {(() => {
  5011. const idx = logVersions.findIndex(
  5012. (v) => Number(v?.id) === selectedLogVersionId,
  5013. );
  5014. const hasOlder =
  5015. idx >= 0 && idx < logVersions.length - 1;
  5016. const sel = logVersions[idx];
  5017. const note = sel?.note != null ? String(sel.note) : "";
  5018. const editor = sel ? resolveVersionActor(sel) : null;
  5019. return (
  5020. <>
  5021. {!hasOlder && !diffLoading && (
  5022. <Alert severity="info" sx={{ mb: 2 }}>
  5023. {t("diff_oldestSnapshot")}
  5024. </Alert>
  5025. )}
  5026. <Paper
  5027. variant="outlined"
  5028. sx={{
  5029. p: 2,
  5030. mb: 2,
  5031. bgcolor: (theme) =>
  5032. alpha(theme.palette.primary.main, 0.08),
  5033. borderColor: "primary.light",
  5034. flexShrink: 0,
  5035. }}
  5036. >
  5037. <Stack
  5038. direction="row"
  5039. spacing={1}
  5040. alignItems="center"
  5041. sx={{ mb: 1, color: "primary.dark" }}
  5042. >
  5043. <Info size={18} />
  5044. <Typography
  5045. variant="subtitle2"
  5046. sx={{ fontWeight: 800 }}
  5047. >
  5048. {t("diff_summary_title")}
  5049. </Typography>
  5050. <Box sx={{ flex: 1 }} />
  5051. <Tooltip
  5052. title={
  5053. hasUnsavedChanges
  5054. ? t("diff_export_blockedTooltip")
  5055. : ""
  5056. }
  5057. >
  5058. <span>
  5059. <Button
  5060. variant="outlined"
  5061. size="small"
  5062. startIcon={<FileText size={16} />}
  5063. disabled={
  5064. saving ||
  5065. routeExcelBusy ||
  5066. hasUnsavedChanges ||
  5067. selectedLogVersionId == null ||
  5068. logVersions.length < 2
  5069. }
  5070. onClick={() =>
  5071. void handleExportVersionLogReportExcel()
  5072. }
  5073. >
  5074. {t("diff_export_reportBtn")}
  5075. </Button>
  5076. </span>
  5077. </Tooltip>
  5078. </Stack>
  5079. {editor != null && (
  5080. <Typography
  5081. variant="caption"
  5082. color="text.secondary"
  5083. sx={{ display: "block", mb: 1 }}
  5084. >
  5085. {t("version_ui_editedBy", { name: editor })}
  5086. </Typography>
  5087. )}
  5088. <Typography
  5089. variant="body2"
  5090. sx={{ color: "text.primary", mb: 2 }}
  5091. >
  5092. {note || "—"}
  5093. </Typography>
  5094. <Stack direction="row" spacing={1}>
  5095. <Box
  5096. flex={1}
  5097. sx={{
  5098. bgcolor: "background.paper",
  5099. borderRadius: 1,
  5100. p: 1,
  5101. textAlign: "center",
  5102. border: 1,
  5103. borderColor: "divider",
  5104. }}
  5105. >
  5106. <Typography
  5107. variant="caption"
  5108. color="text.secondary"
  5109. >
  5110. {t("diff_summary_added")}
  5111. </Typography>
  5112. <Typography
  5113. variant="h6"
  5114. sx={{
  5115. fontWeight: 900,
  5116. color: "success.main",
  5117. }}
  5118. >
  5119. {diffLoading
  5120. ? t("diff_loadingEllipsis")
  5121. : versionRowSummary.added}
  5122. </Typography>
  5123. </Box>
  5124. <Box
  5125. flex={1}
  5126. sx={{
  5127. bgcolor: "background.paper",
  5128. borderRadius: 1,
  5129. p: 1,
  5130. textAlign: "center",
  5131. border: 1,
  5132. borderColor: "divider",
  5133. }}
  5134. >
  5135. <Typography
  5136. variant="caption"
  5137. color="text.secondary"
  5138. >
  5139. {t("diff_summary_moved")}
  5140. </Typography>
  5141. <Typography
  5142. variant="h6"
  5143. sx={{
  5144. fontWeight: 900,
  5145. color: "warning.main",
  5146. }}
  5147. >
  5148. {diffLoading
  5149. ? t("diff_loadingEllipsis")
  5150. : versionRowSummary.moved}
  5151. </Typography>
  5152. </Box>
  5153. <Box
  5154. flex={1}
  5155. sx={{
  5156. bgcolor: "background.paper",
  5157. borderRadius: 1,
  5158. p: 1,
  5159. textAlign: "center",
  5160. border: 1,
  5161. borderColor: "divider",
  5162. }}
  5163. >
  5164. <Typography
  5165. variant="caption"
  5166. color="text.secondary"
  5167. >
  5168. {t("diff_summary_deleted")}
  5169. </Typography>
  5170. <Typography
  5171. variant="h6"
  5172. sx={{
  5173. fontWeight: 900,
  5174. color: "error.main",
  5175. }}
  5176. >
  5177. {diffLoading
  5178. ? t("diff_loadingEllipsis")
  5179. : versionRowSummary.deleted}
  5180. </Typography>
  5181. </Box>
  5182. <Box
  5183. flex={1}
  5184. sx={{
  5185. bgcolor: "background.paper",
  5186. borderRadius: 1,
  5187. p: 1,
  5188. textAlign: "center",
  5189. border: 1,
  5190. borderColor: "divider",
  5191. }}
  5192. >
  5193. <Typography
  5194. variant="caption"
  5195. color="text.secondary"
  5196. >
  5197. {t("diff_summary_fieldChange")}
  5198. </Typography>
  5199. <Typography
  5200. variant="h6"
  5201. sx={{
  5202. fontWeight: 900,
  5203. color: "text.secondary",
  5204. }}
  5205. >
  5206. {diffLoading
  5207. ? t("diff_loadingEllipsis")
  5208. : versionRowSummary.fieldChanges}
  5209. </Typography>
  5210. </Box>
  5211. </Stack>
  5212. {hasOlder && stagedLogEntriesView.length > 0 && (
  5213. <Typography
  5214. variant="caption"
  5215. color="text.secondary"
  5216. sx={{ display: "block", mt: 1 }}
  5217. >
  5218. {t("diff_staged_boardPendingLine", {
  5219. count: stagedLogEntriesView.length,
  5220. })}
  5221. </Typography>
  5222. )}
  5223. </Paper>
  5224. <Stack
  5225. direction="row"
  5226. alignItems="baseline"
  5227. justifyContent="space-between"
  5228. spacing={1}
  5229. sx={{ mb: 1.5, flexShrink: 0 }}
  5230. >
  5231. <Typography
  5232. variant="overline"
  5233. sx={{
  5234. fontWeight: 800,
  5235. color: "text.secondary",
  5236. }}
  5237. >
  5238. {t("diff_shopList_title")}
  5239. </Typography>
  5240. {changedShopIds.size > 0 && (
  5241. <Typography
  5242. variant="caption"
  5243. color="text.secondary"
  5244. >
  5245. {t("diff_markedCount", {
  5246. count: changedShopIds.size,
  5247. })}
  5248. </Typography>
  5249. )}
  5250. </Stack>
  5251. <Box
  5252. sx={{ flex: 1, minHeight: 0, overflow: "auto" }}
  5253. >
  5254. <Stack spacing={1.5} sx={{ pb: 2 }}>
  5255. {stagedLogEntriesView.length > 0 && (
  5256. <>
  5257. <Typography
  5258. variant="overline"
  5259. sx={{
  5260. fontWeight: 800,
  5261. color: "warning.dark",
  5262. display: "block",
  5263. }}
  5264. >
  5265. {t("diff_staged_section_title")}
  5266. </Typography>
  5267. <Typography
  5268. variant="caption"
  5269. color="text.secondary"
  5270. sx={{ display: "block", mb: 0.5 }}
  5271. >
  5272. {t("diff_staged_section_subtitle")}
  5273. </Typography>
  5274. <Stack spacing={1.25}>
  5275. {stagedLogEntriesView.map((entry) => {
  5276. if (entry.kind === "restore") {
  5277. return (
  5278. <Alert
  5279. key={entry.key}
  5280. severity="info"
  5281. variant="outlined"
  5282. sx={{ py: 0.75 }}
  5283. >
  5284. {t("diff_staged_restoreScheduled", {
  5285. versionId: entry.versionId,
  5286. })}
  5287. </Alert>
  5288. );
  5289. }
  5290. if (entry.kind === "text") {
  5291. return (
  5292. <Paper
  5293. key={entry.key}
  5294. variant="outlined"
  5295. sx={{
  5296. p: 1.25,
  5297. bgcolor: "grey.50",
  5298. borderColor: "warning.light",
  5299. }}
  5300. >
  5301. <Stack
  5302. direction="row"
  5303. alignItems="center"
  5304. spacing={1}
  5305. flexWrap="wrap"
  5306. useFlexGap
  5307. >
  5308. <Chip
  5309. size="small"
  5310. label={t("diff_staged_tag_unsaved")}
  5311. color="warning"
  5312. />
  5313. <Typography variant="body2">
  5314. {t(
  5315. entry.titleKey as
  5316. | "diff_staged_deleteUnknown"
  5317. | "diff_staged_newLane"
  5318. | "diff_staged_laneLogistic"
  5319. | "diff_staged_emptyDistricts"
  5320. | "diff_staged_shopPendingOnLane"
  5321. | "diff_staged_shopDistrictOnly"
  5322. | "diff_staged_pendingLogisticMaster"
  5323. | "diff_staged_editLogisticMaster"
  5324. | "diff_staged_deleteLogisticMaster"
  5325. | "diff_staged_importPending",
  5326. entry.titleParams as Record<
  5327. string,
  5328. string | number
  5329. >,
  5330. )}
  5331. </Typography>
  5332. </Stack>
  5333. </Paper>
  5334. );
  5335. }
  5336. const row = entry.row;
  5337. const { headline, detail } =
  5338. resolveVersionLogShopHeadline(
  5339. row,
  5340. shopNameByCodeMap,
  5341. );
  5342. return (
  5343. <Paper
  5344. key={entry.key}
  5345. variant="outlined"
  5346. sx={{
  5347. p: 1.25,
  5348. bgcolor: "grey.50",
  5349. borderColor: "warning.light",
  5350. }}
  5351. >
  5352. <Stack
  5353. direction="row"
  5354. spacing={1}
  5355. alignItems="flex-start"
  5356. >
  5357. <Chip
  5358. size="small"
  5359. label={t("diff_staged_tag_unsaved")}
  5360. color="warning"
  5361. sx={{ mt: 0.25, flexShrink: 0 }}
  5362. />
  5363. <Box sx={{ flex: 1, minWidth: 0 }}>
  5364. <Typography
  5365. variant="body2"
  5366. sx={{ fontWeight: 800 }}
  5367. >
  5368. {headline}
  5369. </Typography>
  5370. {detail != null && detail !== "" && (
  5371. <Typography
  5372. variant="caption"
  5373. color="text.secondary"
  5374. sx={{ display: "block", mt: 0.25 }}
  5375. >
  5376. {detail}
  5377. </Typography>
  5378. )}
  5379. <Typography
  5380. variant="caption"
  5381. sx={{
  5382. fontFamily: "monospace",
  5383. display: "block",
  5384. mt: 0.5,
  5385. }}
  5386. >
  5387. {row.shopCode || "—"}
  5388. </Typography>
  5389. </Box>
  5390. </Stack>
  5391. </Paper>
  5392. );
  5393. })}
  5394. </Stack>
  5395. <Divider sx={{ my: 1.5 }} />
  5396. </>
  5397. )}
  5398. {diffLoading && (
  5399. <Box
  5400. sx={{
  5401. display: "flex",
  5402. justifyContent: "center",
  5403. py: 4,
  5404. }}
  5405. >
  5406. <CircularProgress size={24} />
  5407. </Box>
  5408. )}
  5409. {!diffLoading && (
  5410. <>
  5411. {logisticMasterDiffLines.length > 0 && (
  5412. <>
  5413. <Typography
  5414. variant="overline"
  5415. sx={{
  5416. fontWeight: 800,
  5417. color: "text.secondary",
  5418. }}
  5419. >
  5420. {t("diff_logisticMaster_section")}
  5421. </Typography>
  5422. {logisticMasterDiffLines.map((lm) => (
  5423. <Paper
  5424. key={`lm-${lm.logisticId}`}
  5425. variant="outlined"
  5426. sx={{
  5427. p: 1.25,
  5428. bgcolor: "grey.50",
  5429. borderColor:
  5430. lm.type === "ADDED"
  5431. ? "success.light"
  5432. : "divider",
  5433. }}
  5434. >
  5435. <Stack
  5436. direction="row"
  5437. spacing={1}
  5438. alignItems="center"
  5439. flexWrap="wrap"
  5440. useFlexGap
  5441. >
  5442. <Chip
  5443. size="small"
  5444. label={
  5445. lm.type === "ADDED"
  5446. ? t("diff_logisticMaster_added")
  5447. : t("diff_logisticMaster_edited")
  5448. }
  5449. color={
  5450. lm.type === "ADDED"
  5451. ? "success"
  5452. : "default"
  5453. }
  5454. />
  5455. <Typography variant="body2">
  5456. {lm.changeText ||
  5457. `${lm.logisticName}(${lm.carPlate})`}
  5458. </Typography>
  5459. </Stack>
  5460. </Paper>
  5461. ))}
  5462. </>
  5463. )}
  5464. {hasOlder &&
  5465. versionShopRows.length === 0 &&
  5466. logisticMasterDiffLines.length === 0 &&
  5467. stagedLogEntriesView.length === 0 &&
  5468. !diffError && (
  5469. <Alert severity="success">
  5470. {t("diff_noDiffFromPrev")}
  5471. </Alert>
  5472. )}
  5473. {hasOlder &&
  5474. versionShopRows.length === 0 &&
  5475. logisticMasterDiffLines.length === 0 &&
  5476. stagedLogEntriesView.length > 0 &&
  5477. !diffError && (
  5478. <Alert severity="info">
  5479. {t("diff_noShopDiffHasBoardStaged")}
  5480. </Alert>
  5481. )}
  5482. {versionShopRows.map((row, ri) => {
  5483. const { headline, detail } =
  5484. resolveVersionLogShopHeadline(
  5485. row,
  5486. shopNameByCodeMap,
  5487. );
  5488. const laneLabelForFields =
  5489. resolveVersionLogLaneLabel(row);
  5490. return (
  5491. <Paper
  5492. key={`${row.truckRowId}-${ri}`}
  5493. variant="outlined"
  5494. sx={{
  5495. p: 1.5,
  5496. display: "flex",
  5497. gap: 1.5,
  5498. alignItems: "stretch",
  5499. bgcolor: "grey.50",
  5500. borderColor: "divider",
  5501. }}
  5502. >
  5503. <Box
  5504. sx={{
  5505. width: 6,
  5506. alignSelf: "stretch",
  5507. borderRadius: 1,
  5508. bgcolor:
  5509. row.type === "moved"
  5510. ? "warning.main"
  5511. : row.type === "added"
  5512. ? "success.main"
  5513. : row.type === "deleted"
  5514. ? "error.main"
  5515. : "grey.400",
  5516. }}
  5517. />
  5518. <Box sx={{ flex: 1, minWidth: 0 }}>
  5519. <Stack
  5520. direction="row"
  5521. justifyContent="space-between"
  5522. alignItems="flex-start"
  5523. spacing={1}
  5524. >
  5525. <Box sx={{ minWidth: 0 }}>
  5526. <Typography
  5527. variant="body2"
  5528. sx={{ fontWeight: 800 }}
  5529. >
  5530. {headline}
  5531. </Typography>
  5532. {detail != null &&
  5533. detail !== "" && (
  5534. <Typography
  5535. variant="caption"
  5536. color="text.secondary"
  5537. sx={{
  5538. display: "block",
  5539. mt: 0.25,
  5540. }}
  5541. >
  5542. {detail}
  5543. </Typography>
  5544. )}
  5545. </Box>
  5546. <Typography
  5547. variant="caption"
  5548. sx={{
  5549. fontFamily: "monospace",
  5550. bgcolor: "background.paper",
  5551. px: 0.5,
  5552. borderRadius: 0.5,
  5553. flexShrink: 0,
  5554. }}
  5555. >
  5556. {row.shopCode || "—"}
  5557. </Typography>
  5558. </Stack>
  5559. <Stack
  5560. direction="row"
  5561. alignItems="center"
  5562. spacing={0.5}
  5563. sx={{ mt: 0.75 }}
  5564. flexWrap="wrap"
  5565. >
  5566. {row.type === "moved" && (
  5567. <>
  5568. <Typography
  5569. variant="caption"
  5570. sx={{
  5571. px: 0.75,
  5572. py: 0.25,
  5573. bgcolor: "grey.200",
  5574. borderRadius: 1,
  5575. }}
  5576. >
  5577. {t("diff_moveFrom", {
  5578. lane: row.fromLane ?? t("emDash"),
  5579. })}
  5580. </Typography>
  5581. <ArrowRight size={14} />
  5582. <Typography
  5583. variant="caption"
  5584. sx={{
  5585. px: 0.75,
  5586. py: 0.25,
  5587. bgcolor: "primary.light",
  5588. color: "primary.dark",
  5589. fontWeight: 800,
  5590. }}
  5591. >
  5592. {t("diff_moveTo", {
  5593. lane: row.toLane ?? t("emDash"),
  5594. })}
  5595. </Typography>
  5596. </>
  5597. )}
  5598. {row.type === "added" && (
  5599. <Typography
  5600. variant="caption"
  5601. sx={{
  5602. px: 0.75,
  5603. py: 0.25,
  5604. bgcolor: "success.light",
  5605. color: "success.dark",
  5606. fontWeight: 800,
  5607. }}
  5608. >
  5609. {t("diff_addedToLane", {
  5610. lane: row.toLane ?? t("emDash"),
  5611. })}
  5612. </Typography>
  5613. )}
  5614. {row.type === "deleted" && (
  5615. <Typography
  5616. variant="caption"
  5617. sx={{
  5618. px: 0.75,
  5619. py: 0.25,
  5620. bgcolor: "error.light",
  5621. color: "error.dark",
  5622. fontWeight: 800,
  5623. }}
  5624. >
  5625. {t("diff_removedFromLane", {
  5626. lane: row.fromLane ?? t("emDash"),
  5627. })}
  5628. </Typography>
  5629. )}
  5630. {row.fieldEdits != null &&
  5631. row.fieldEdits.length > 0 && (
  5632. <Box
  5633. sx={{
  5634. width: "100%",
  5635. flexBasis: "100%",
  5636. mt: 0.5,
  5637. }}
  5638. >
  5639. <Stack spacing={0.35}>
  5640. {row.fieldEdits.map(
  5641. (fe, fei) => {
  5642. const isLogistic =
  5643. fe.label ===
  5644. "versionLogField_logisticId";
  5645. const resolveLogisticDisplay =
  5646. (raw: string) => {
  5647. const s = String(
  5648. raw ?? "",
  5649. ).trim();
  5650. if (
  5651. s === "" ||
  5652. s === "—" ||
  5653. s === "未分配" ||
  5654. s === "未分配物流商"
  5655. )
  5656. return t(
  5657. "diffLogistic_unassigned",
  5658. );
  5659. const n = Number(s);
  5660. if (
  5661. Number.isFinite(n)
  5662. ) {
  5663. return (
  5664. logisticNameById.get(
  5665. n,
  5666. ) ?? s
  5667. );
  5668. }
  5669. return s;
  5670. };
  5671. const from = isLogistic
  5672. ? resolveLogisticDisplay(
  5673. fe.from,
  5674. )
  5675. : fe.from;
  5676. const to = isLogistic
  5677. ? resolveLogisticDisplay(
  5678. fe.to,
  5679. )
  5680. : fe.to;
  5681. const isLoadingSeq =
  5682. fe.label ===
  5683. VERSION_LOG_LOADING_SEQUENCE_LABEL ||
  5684. fe.label ===
  5685. "loadingSequence";
  5686. const showLaneOnSeq =
  5687. isLoadingSeq &&
  5688. laneLabelForFields !=
  5689. null &&
  5690. laneLabelForFields !==
  5691. "";
  5692. return (
  5693. <Typography
  5694. key={`${fe.label}-${fei}`}
  5695. variant="caption"
  5696. color="text.secondary"
  5697. sx={{
  5698. display: "block",
  5699. }}
  5700. >
  5701. <Box
  5702. component="span"
  5703. sx={{
  5704. fontWeight: 700,
  5705. color:
  5706. "text.primary",
  5707. }}
  5708. >
  5709. {formatDiffFieldLabel(
  5710. fe.label,
  5711. t,
  5712. )}
  5713. </Box>
  5714. {showLaneOnSeq && (
  5715. <Box
  5716. component="span"
  5717. sx={{
  5718. fontWeight: 600,
  5719. color:
  5720. "text.secondary",
  5721. }}
  5722. >
  5723. {" "}
  5724. (
  5725. {t(
  5726. "diff_onLane",
  5727. {
  5728. lane: laneLabelForFields,
  5729. },
  5730. )}
  5731. )
  5732. </Box>
  5733. )}
  5734. {":"}
  5735. {from} → {to}
  5736. </Typography>
  5737. );
  5738. },
  5739. )}
  5740. </Stack>
  5741. </Box>
  5742. )}
  5743. {row.type === "edited" &&
  5744. (!row.fieldEdits ||
  5745. row.fieldEdits.length ===
  5746. 0) && (
  5747. <Typography
  5748. variant="caption"
  5749. color="text.secondary"
  5750. >
  5751. {t("diff_editedCaption")}
  5752. </Typography>
  5753. )}
  5754. </Stack>
  5755. </Box>
  5756. </Paper>
  5757. );
  5758. })}
  5759. </>
  5760. )}
  5761. </Stack>
  5762. </Box>
  5763. <Box sx={{ pt: 1, flexShrink: 0 }}>
  5764. <Button
  5765. fullWidth
  5766. variant={
  5767. headVersionId != null &&
  5768. selectedLogVersionId === headVersionId
  5769. ? "outlined"
  5770. : "contained"
  5771. }
  5772. color="primary"
  5773. disabled={
  5774. saving || selectedLogVersionId == null
  5775. }
  5776. startIcon={<RotateCcw size={18} />}
  5777. sx={{ py: 1.5, fontWeight: 800 }}
  5778. onClick={() =>
  5779. selectedLogVersionId != null &&
  5780. restoreVersion(selectedLogVersionId)
  5781. }
  5782. >
  5783. {headVersionId != null &&
  5784. selectedLogVersionId === headVersionId
  5785. ? t("diff_restoreToHead")
  5786. : t("diff_restoreToSelected")}
  5787. </Button>
  5788. </Box>
  5789. </>
  5790. );
  5791. })()}
  5792. </>
  5793. )}
  5794. </Box>
  5795. </Box>
  5796. </Stack>
  5797. </DialogContent>
  5798. <DialogActions sx={{ px: 2, py: 1 }}>
  5799. <Button onClick={closeLogDialog} disabled={saving}>
  5800. {t("dialog_close")}
  5801. </Button>
  5802. </DialogActions>
  5803. </Dialog>
  5804. <Dialog
  5805. open={addShopDialogOpen}
  5806. onClose={closeAddShopDialog}
  5807. maxWidth="sm"
  5808. fullWidth
  5809. >
  5810. <DialogTitle>
  5811. {t("addShop_dialogTitle")}{" "}
  5812. {(() => {
  5813. const lane = addShopLaneId
  5814. ? lanes.find((l) => l.id === addShopLaneId)
  5815. : null;
  5816. if (!lane) return "";
  5817. return `「${lane.truckLanceCode}${
  5818. lane.remark != null && String(lane.remark).trim() !== ""
  5819. ? ` · ${lane.remark}`
  5820. : ""
  5821. }」`;
  5822. })()}
  5823. </DialogTitle>
  5824. <DialogContent dividers>
  5825. <Stack spacing={2} sx={{ pt: 1 }}>
  5826. <Autocomplete
  5827. options={addShopCandidates}
  5828. getOptionLabel={(o) => `${o.name} (${o.code})`}
  5829. isOptionEqualToValue={(a, b) => a.id === b.id}
  5830. value={addShopPick}
  5831. onChange={(_e, v) => setAddShopPick(v)}
  5832. renderInput={(params) => (
  5833. <TextField
  5834. {...params}
  5835. label={t("shop_autocomplete_label")}
  5836. placeholder={t("shop_autocomplete_ph")}
  5837. />
  5838. )}
  5839. noOptionsText={
  5840. allShopsMaster.length === 0
  5841. ? t("shop_autocomplete_loading")
  5842. : t("shop_autocomplete_noOptions")
  5843. }
  5844. />
  5845. </Stack>
  5846. </DialogContent>
  5847. <DialogActions>
  5848. <Button onClick={closeAddShopDialog}>{t("cancel")}</Button>
  5849. <Button
  5850. onClick={() => submitAddShop()}
  5851. variant="contained"
  5852. disabled={!addShopPick}
  5853. >
  5854. {t("addShop_confirm")}
  5855. </Button>
  5856. </DialogActions>
  5857. </Dialog>
  5858. <Dialog
  5859. open={districtEditOpen}
  5860. onClose={closeDistrictEdit}
  5861. maxWidth="xs"
  5862. fullWidth
  5863. >
  5864. <DialogTitle>
  5865. {districtEditCtx?.mode === "add"
  5866. ? t("district_dialog_add")
  5867. : t("district_dialog_edit")}
  5868. </DialogTitle>
  5869. <DialogContent dividers>
  5870. <TextField
  5871. autoFocus
  5872. margin="dense"
  5873. label={t("district_name_label")}
  5874. fullWidth
  5875. value={districtEditDraft}
  5876. onChange={(e) => {
  5877. setDistrictEditDraft(e.target.value);
  5878. setDistrictEditError(null);
  5879. }}
  5880. error={Boolean(districtEditError)}
  5881. InputLabelProps={{ shrink: true }}
  5882. />
  5883. </DialogContent>
  5884. <DialogActions>
  5885. <Button onClick={closeDistrictEdit}>{t("cancel")}</Button>
  5886. <Button variant="contained" onClick={() => applyDistrictEdit()}>
  5887. {t("btn_apply")}
  5888. </Button>
  5889. </DialogActions>
  5890. </Dialog>
  5891. <Dialog
  5892. open={departureEditLaneId != null}
  5893. onClose={closeDepartureEdit}
  5894. maxWidth="xs"
  5895. fullWidth
  5896. >
  5897. <DialogTitle>{t("departureDialog_title")}</DialogTitle>
  5898. <DialogContent>
  5899. <TextField
  5900. margin="dense"
  5901. fullWidth
  5902. label={t("departureDialog_nameLabel")}
  5903. value={departureEditNameDraft}
  5904. onChange={(e) => setDepartureEditNameDraft(e.target.value)}
  5905. inputProps={{ maxLength: 50 }}
  5906. sx={{ mt: 1 }}
  5907. />
  5908. {departureEditLaneId &&
  5909. normalizeStoreId(
  5910. lanes.find((l) => l.id === departureEditLaneId)?.storeId,
  5911. ) === "4F" &&
  5912. String(
  5913. lanes.find((l) => l.id === departureEditLaneId)?.remark ?? "",
  5914. ).trim() !== "" && (
  5915. <Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 0.5 }}>
  5916. {t("departureDialog_remarkReadonly")}:
  5917. {String(
  5918. lanes.find((l) => l.id === departureEditLaneId)?.remark ?? "",
  5919. ).trim()}
  5920. </Typography>
  5921. )}
  5922. <TextField
  5923. margin="dense"
  5924. fullWidth
  5925. type="time"
  5926. label={t("seq_edit_departureLabel")}
  5927. value={departureEditDraft}
  5928. onChange={(e) => setDepartureEditDraft(e.target.value)}
  5929. InputLabelProps={{ shrink: true }}
  5930. sx={{ mt: 1 }}
  5931. />
  5932. <Typography
  5933. variant="caption"
  5934. color="text.secondary"
  5935. sx={{ mt: 1, display: "block" }}
  5936. >
  5937. {t("departureDialog_hint")}
  5938. </Typography>
  5939. </DialogContent>
  5940. <DialogActions>
  5941. <Button onClick={closeDepartureEdit} disabled={departureEditBusy}>
  5942. {t("cancel")}
  5943. </Button>
  5944. <Button
  5945. variant="contained"
  5946. onClick={() => {
  5947. void applyDepartureEdit();
  5948. }}
  5949. disabled={departureEditBusy}
  5950. >
  5951. {t("filter_apply")}
  5952. </Button>
  5953. </DialogActions>
  5954. </Dialog>
  5955. <Dialog
  5956. open={seqEditTarget != null}
  5957. onClose={closeSeqEdit}
  5958. maxWidth="xs"
  5959. fullWidth
  5960. >
  5961. <DialogTitle>{t("seqDialog_title")}</DialogTitle>
  5962. <DialogContent>
  5963. <TextField
  5964. margin="dense"
  5965. fullWidth
  5966. type="number"
  5967. label={t("seq_edit_seqLabel")}
  5968. value={seqEditDraft}
  5969. onChange={(e) => setSeqEditDraft(e.target.value)}
  5970. inputProps={{ step: 1 }}
  5971. sx={{ mt: 1 }}
  5972. />
  5973. </DialogContent>
  5974. <DialogActions>
  5975. <Button onClick={closeSeqEdit}>{t("cancel")}</Button>
  5976. <Button variant="contained" onClick={applySeqEdit}>
  5977. {t("filter_apply")}
  5978. </Button>
  5979. </DialogActions>
  5980. </Dialog>
  5981. <Dialog
  5982. open={addRouteDialogOpen}
  5983. onClose={() => {
  5984. if (!addRouteSubmitting) closeAddRouteDialog();
  5985. }}
  5986. maxWidth="sm"
  5987. fullWidth
  5988. PaperProps={{ sx: { borderRadius: 3 } }}
  5989. >
  5990. <DialogTitle
  5991. sx={{
  5992. display: "flex",
  5993. alignItems: "center",
  5994. justifyContent: "space-between",
  5995. pr: 1,
  5996. py: 2,
  5997. borderBottom: 1,
  5998. borderColor: "divider",
  5999. bgcolor: "grey.50",
  6000. }}
  6001. >
  6002. <Stack direction="row" spacing={1.5} alignItems="center">
  6003. <Box
  6004. sx={{
  6005. bgcolor: "primary.main",
  6006. p: 1,
  6007. borderRadius: 1.5,
  6008. display: "inline-flex",
  6009. }}
  6010. >
  6011. <Plus size={20} color="white" />
  6012. </Box>
  6013. <Typography variant="h6" sx={{ fontWeight: 800 }}>
  6014. {t("addRoute_dialogTitle")}
  6015. </Typography>
  6016. </Stack>
  6017. <IconButton
  6018. onClick={closeAddRouteDialog}
  6019. size="small"
  6020. disabled={addRouteSubmitting}
  6021. aria-label={t("drawerClose")}
  6022. >
  6023. <X size={20} />
  6024. </IconButton>
  6025. </DialogTitle>
  6026. <DialogContent dividers>
  6027. {addRouteError && (
  6028. <Alert severity="error" sx={{ mb: 2 }}>
  6029. {addRouteError}
  6030. </Alert>
  6031. )}
  6032. <Box
  6033. sx={{
  6034. display: "grid",
  6035. gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
  6036. gap: 2,
  6037. alignItems: "start",
  6038. // small Outlined 預設約 40px,整體略加高 ≈5px
  6039. "& .MuiOutlinedInput-root": { minHeight: 45 },
  6040. }}
  6041. >
  6042. <TextField
  6043. size="small"
  6044. fullWidth
  6045. required
  6046. label={t("route_new_code_label")}
  6047. value={newRouteForm.truckLanceCode}
  6048. onChange={(e) =>
  6049. setNewRouteForm((p) => ({
  6050. ...p,
  6051. truckLanceCode: e.target.value,
  6052. }))
  6053. }
  6054. sx={{
  6055. "& .MuiInputBase-input": {
  6056. fontWeight: 800,
  6057. color: "primary.main",
  6058. },
  6059. }}
  6060. InputProps={{
  6061. startAdornment: (
  6062. <InputAdornment position="start">
  6063. <TruckIcon size={18} />
  6064. </InputAdornment>
  6065. ),
  6066. }}
  6067. />
  6068. <TextField
  6069. size="small"
  6070. fullWidth
  6071. required
  6072. label={t("route_new_time_label")}
  6073. type="time"
  6074. value={newRouteForm.startTime}
  6075. onChange={(e) =>
  6076. setNewRouteForm((p) => ({ ...p, startTime: e.target.value }))
  6077. }
  6078. InputLabelProps={{ shrink: true }}
  6079. InputProps={{
  6080. startAdornment: (
  6081. <InputAdornment position="start">
  6082. <Clock size={18} />
  6083. </InputAdornment>
  6084. ),
  6085. }}
  6086. />
  6087. <FormControl
  6088. size="small"
  6089. fullWidth
  6090. sx={{ gridColumn: { xs: "1", sm: "1 / -1" } }}
  6091. >
  6092. <InputLabel id="new-route-logistic-label">{t("route_new_logistic_label")}</InputLabel>
  6093. <Select
  6094. labelId="new-route-logistic-label"
  6095. label={t("route_new_logistic_label")}
  6096. value={
  6097. newRouteForm.logisticId == null
  6098. ? ""
  6099. : String(newRouteForm.logisticId)
  6100. }
  6101. onChange={(e) => {
  6102. const v = e.target.value;
  6103. setNewRouteForm((p) => ({
  6104. ...p,
  6105. logisticId:
  6106. v === "" ? null : Number.parseInt(String(v), 10),
  6107. }));
  6108. }}
  6109. >
  6110. <MenuItem value="">{t("route_logisticUnspecified")}</MenuItem>
  6111. {logisticRowsSortedForSelect.map((row) => (
  6112. <MenuItem key={row.id} value={String(row.id)}>
  6113. {row.logisticName}
  6114. </MenuItem>
  6115. ))}
  6116. </Select>
  6117. </FormControl>
  6118. <FormControl
  6119. size="small"
  6120. fullWidth
  6121. sx={{ gridColumn: { xs: "1", sm: "1 / -1" } }}
  6122. >
  6123. <InputLabel>{t("route_new_store_label")}</InputLabel>
  6124. <Select
  6125. label={t("route_new_store_label")}
  6126. value={newRouteForm.storeId}
  6127. onChange={(e) =>
  6128. setNewRouteForm((p) => ({
  6129. ...p,
  6130. storeId: e.target.value as "2F" | "4F",
  6131. }))
  6132. }
  6133. >
  6134. <MenuItem value="2F">2F</MenuItem>
  6135. <MenuItem value="4F">4F</MenuItem>
  6136. </Select>
  6137. </FormControl>
  6138. {newRouteForm.storeId === "4F" && (
  6139. <TextField
  6140. size="small"
  6141. fullWidth
  6142. sx={{ gridColumn: { xs: "1", sm: "1 / -1" } }}
  6143. label={t("route_new_remark_label")}
  6144. value={newRouteForm.remark}
  6145. onChange={(e) =>
  6146. setNewRouteForm((p) => ({ ...p, remark: e.target.value }))
  6147. }
  6148. />
  6149. )}
  6150. </Box>
  6151. </DialogContent>
  6152. <DialogActions
  6153. sx={{
  6154. px: 3,
  6155. py: 2,
  6156. bgcolor: "grey.50",
  6157. borderTop: 1,
  6158. borderColor: "divider",
  6159. gap: 1.5,
  6160. justifyContent: "flex-end",
  6161. flexWrap: "nowrap",
  6162. }}
  6163. >
  6164. <Button
  6165. variant="outlined"
  6166. onClick={closeAddRouteDialog}
  6167. disabled={addRouteSubmitting}
  6168. >
  6169. {t("btn_cancelBack")}
  6170. </Button>
  6171. <Button
  6172. variant="contained"
  6173. disabled={
  6174. addRouteSubmitting ||
  6175. !String(newRouteForm.truckLanceCode || "").trim() ||
  6176. !String(newRouteForm.startTime || "").trim()
  6177. }
  6178. onClick={() => void submitAddRoute()}
  6179. >
  6180. {addRouteSubmitting
  6181. ? t("addRoute_submitting")
  6182. : t("addRoute_confirm")}
  6183. </Button>
  6184. </DialogActions>
  6185. </Dialog>
  6186. <Dialog
  6187. open={addLogisticOpen}
  6188. onClose={() => {
  6189. if (!addLogisticSubmitting) setAddLogisticOpen(false);
  6190. }}
  6191. maxWidth="sm"
  6192. fullWidth
  6193. PaperProps={{ sx: { borderRadius: 3 } }}
  6194. >
  6195. <DialogTitle sx={{ fontWeight: 800 }}>{t("dialog_addLogisticsTitle")}</DialogTitle>
  6196. <DialogContent dividers>
  6197. {addLogisticError && (
  6198. <Alert severity="error" sx={{ mb: 2 }}>
  6199. {addLogisticError}
  6200. </Alert>
  6201. )}
  6202. <Stack spacing={2} sx={{ pt: 0.5 }}>
  6203. <TextField
  6204. size="small"
  6205. fullWidth
  6206. required
  6207. label={t("logistic_companyName")}
  6208. value={addLogisticForm.logisticName}
  6209. onChange={(e) =>
  6210. setAddLogisticForm((p) => ({
  6211. ...p,
  6212. logisticName: e.target.value,
  6213. }))
  6214. }
  6215. InputProps={{
  6216. startAdornment: (
  6217. <InputAdornment position="start">
  6218. <Building2 size={18} />
  6219. </InputAdornment>
  6220. ),
  6221. }}
  6222. />
  6223. <TextField
  6224. size="small"
  6225. fullWidth
  6226. required
  6227. label={t("logistic_plate")}
  6228. value={addLogisticForm.carPlate}
  6229. onChange={(e) =>
  6230. setAddLogisticForm((p) => ({
  6231. ...p,
  6232. carPlate: e.target.value,
  6233. }))
  6234. }
  6235. InputProps={{
  6236. startAdornment: (
  6237. <InputAdornment position="start">
  6238. <CreditCard size={18} />
  6239. </InputAdornment>
  6240. ),
  6241. }}
  6242. />
  6243. <TextField
  6244. size="small"
  6245. fullWidth
  6246. required
  6247. label={t("logistic_driver")}
  6248. value={addLogisticForm.driverName}
  6249. onChange={(e) =>
  6250. setAddLogisticForm((p) => ({
  6251. ...p,
  6252. driverName: e.target.value,
  6253. }))
  6254. }
  6255. InputProps={{
  6256. startAdornment: (
  6257. <InputAdornment position="start">
  6258. <Users size={18} />
  6259. </InputAdornment>
  6260. ),
  6261. }}
  6262. />
  6263. <TextField
  6264. size="small"
  6265. fullWidth
  6266. required
  6267. label={t("logistic_phone")}
  6268. value={addLogisticForm.driverPhone}
  6269. onChange={(e) =>
  6270. setAddLogisticForm((p) => ({
  6271. ...p,
  6272. driverPhone: e.target.value,
  6273. }))
  6274. }
  6275. InputProps={{
  6276. startAdornment: (
  6277. <InputAdornment position="start">
  6278. <Phone size={18} />
  6279. </InputAdornment>
  6280. ),
  6281. }}
  6282. />
  6283. </Stack>
  6284. </DialogContent>
  6285. <DialogActions sx={{ px: 3, py: 2 }}>
  6286. <Button
  6287. onClick={() => setAddLogisticOpen(false)}
  6288. disabled={addLogisticSubmitting}
  6289. >
  6290. {t("cancel")}
  6291. </Button>
  6292. <Button
  6293. variant="contained"
  6294. disabled={addLogisticSubmitting}
  6295. onClick={() => void submitAddLogistic()}
  6296. >
  6297. {addLogisticSubmitting
  6298. ? t("Submitting...")
  6299. : t("logistic_btn_save")}
  6300. </Button>
  6301. </DialogActions>
  6302. </Dialog>
  6303. <Dialog
  6304. open={editLogisticOpen}
  6305. onClose={() => {
  6306. if (!editLogisticSubmitting) {
  6307. setEditLogisticOpen(false);
  6308. setEditLogisticError(null);
  6309. }
  6310. }}
  6311. maxWidth="sm"
  6312. fullWidth
  6313. PaperProps={{ sx: { borderRadius: 3 } }}
  6314. >
  6315. <DialogTitle sx={{ fontWeight: 800 }}>{t("dialog_editLogisticsTitle")}</DialogTitle>
  6316. <DialogContent dividers>
  6317. {editLogisticError && (
  6318. <Alert severity="error" sx={{ mb: 2 }}>
  6319. {editLogisticError}
  6320. </Alert>
  6321. )}
  6322. <Stack spacing={2} sx={{ pt: 0.5 }}>
  6323. <TextField
  6324. size="small"
  6325. fullWidth
  6326. required
  6327. label={t("logistic_companyName")}
  6328. value={editLogisticForm.logisticName}
  6329. onChange={(e) =>
  6330. setEditLogisticForm((p) => ({
  6331. ...p,
  6332. logisticName: e.target.value,
  6333. }))
  6334. }
  6335. InputProps={{
  6336. startAdornment: (
  6337. <InputAdornment position="start">
  6338. <Building2 size={18} />
  6339. </InputAdornment>
  6340. ),
  6341. }}
  6342. />
  6343. <TextField
  6344. size="small"
  6345. fullWidth
  6346. required
  6347. label={t("logistic_plate")}
  6348. value={editLogisticForm.carPlate}
  6349. onChange={(e) =>
  6350. setEditLogisticForm((p) => ({
  6351. ...p,
  6352. carPlate: e.target.value,
  6353. }))
  6354. }
  6355. InputProps={{
  6356. startAdornment: (
  6357. <InputAdornment position="start">
  6358. <CreditCard size={18} />
  6359. </InputAdornment>
  6360. ),
  6361. }}
  6362. />
  6363. <TextField
  6364. size="small"
  6365. fullWidth
  6366. required
  6367. label={t("logistic_driver")}
  6368. value={editLogisticForm.driverName}
  6369. onChange={(e) =>
  6370. setEditLogisticForm((p) => ({
  6371. ...p,
  6372. driverName: e.target.value,
  6373. }))
  6374. }
  6375. InputProps={{
  6376. startAdornment: (
  6377. <InputAdornment position="start">
  6378. <Users size={18} />
  6379. </InputAdornment>
  6380. ),
  6381. }}
  6382. />
  6383. <TextField
  6384. size="small"
  6385. fullWidth
  6386. required
  6387. label={t("logistic_phone")}
  6388. value={editLogisticForm.driverPhone}
  6389. onChange={(e) =>
  6390. setEditLogisticForm((p) => ({
  6391. ...p,
  6392. driverPhone: e.target.value,
  6393. }))
  6394. }
  6395. InputProps={{
  6396. startAdornment: (
  6397. <InputAdornment position="start">
  6398. <Phone size={18} />
  6399. </InputAdornment>
  6400. ),
  6401. }}
  6402. />
  6403. </Stack>
  6404. </DialogContent>
  6405. <DialogActions sx={{ px: 3, py: 2 }}>
  6406. <Button
  6407. onClick={() => {
  6408. setEditLogisticOpen(false);
  6409. setEditLogisticError(null);
  6410. }}
  6411. disabled={editLogisticSubmitting}
  6412. >
  6413. {t("cancel")}
  6414. </Button>
  6415. <Button
  6416. variant="contained"
  6417. disabled={editLogisticSubmitting}
  6418. onClick={() => void submitEditLogistic()}
  6419. >
  6420. {editLogisticSubmitting
  6421. ? t("Submitting...")
  6422. : t("logistic_btn_apply")}
  6423. </Button>
  6424. </DialogActions>
  6425. </Dialog>
  6426. {/* Sidebar */}
  6427. <Box
  6428. sx={{
  6429. width: 320,
  6430. flexShrink: 0,
  6431. minHeight: 0,
  6432. bgcolor: "background.paper",
  6433. borderRight: "1px solid",
  6434. borderColor: "divider",
  6435. p: 2,
  6436. overflow: "auto",
  6437. }}
  6438. >
  6439. <Stack
  6440. direction="row"
  6441. spacing={0.5}
  6442. sx={{
  6443. bgcolor: "grey.100",
  6444. p: 0.5,
  6445. borderRadius: 2,
  6446. mb: 2,
  6447. }}
  6448. >
  6449. <Button
  6450. fullWidth
  6451. size="small"
  6452. variant={routeBoardTab === "board" ? "contained" : "text"}
  6453. color={routeBoardTab === "board" ? "primary" : "inherit"}
  6454. onClick={() => setRouteBoardTab("board")}
  6455. startIcon={<LayoutDashboard size={14} />}
  6456. sx={{
  6457. py: 1,
  6458. fontSize: "0.75rem",
  6459. fontWeight: 800,
  6460. textTransform: "none",
  6461. }}
  6462. >
  6463. {t("tabBoard")}
  6464. </Button>
  6465. <Button
  6466. fullWidth
  6467. size="small"
  6468. variant={routeBoardTab === "logistics" ? "contained" : "text"}
  6469. color={routeBoardTab === "logistics" ? "primary" : "inherit"}
  6470. onClick={() => setRouteBoardTab("logistics")}
  6471. startIcon={<Building2 size={14} />}
  6472. sx={{
  6473. py: 1,
  6474. fontSize: "0.75rem",
  6475. fontWeight: 800,
  6476. textTransform: "none",
  6477. }}
  6478. >
  6479. {t("tabLogistics")}
  6480. </Button>
  6481. </Stack>
  6482. {(() => {
  6483. const laneIds = visibleLaneOptions.map((l) => l.id);
  6484. const total = laneIds.length;
  6485. const selectedVisible = laneIds.filter((id) =>
  6486. selectedLaneIds.includes(id),
  6487. ).length;
  6488. const filterActive =
  6489. laneFilter.floor !== "all" ||
  6490. String(laneFilter.query || "").trim() !== "";
  6491. return (
  6492. <Box sx={{ mb: routeBoardTab === "logistics" ? 2 : 0 }}>
  6493. <Typography
  6494. variant="overline"
  6495. sx={{ fontWeight: 800, color: "text.secondary" }}
  6496. >
  6497. {t("lane_selectTitle")}
  6498. </Typography>
  6499. <Select
  6500. multiple
  6501. size="small"
  6502. value={selectedLaneIds}
  6503. displayEmpty
  6504. fullWidth
  6505. renderValue={(selected) => {
  6506. const arr = selected as string[];
  6507. if (arr.length === 0) {
  6508. return (
  6509. <Box component="span" sx={{ color: "text.secondary" }}>
  6510. {t("lane_selectedNone")}
  6511. </Box>
  6512. );
  6513. }
  6514. if (filterActive)
  6515. return t("lane_selectedCount", {
  6516. count: selectedVisible,
  6517. });
  6518. return t("lane_selectedCount", { count: arr.length });
  6519. }}
  6520. onChange={(e) => {
  6521. const value = e.target.value as unknown as string[];
  6522. setSelectedLaneIds(value);
  6523. }}
  6524. MenuProps={{
  6525. autoFocus: false,
  6526. MenuListProps: { autoFocus: false, dense: false },
  6527. PaperProps: {
  6528. sx: {
  6529. maxHeight: 420,
  6530. minWidth: 360,
  6531. maxWidth: "min(100vw - 32px, 480px)",
  6532. },
  6533. },
  6534. }}
  6535. sx={{ mt: 1 }}
  6536. >
  6537. <ListSubheader
  6538. sx={{
  6539. px: 1.5,
  6540. py: 1,
  6541. lineHeight: 1.2,
  6542. position: "sticky",
  6543. top: 0,
  6544. zIndex: 2,
  6545. bgcolor: "background.paper",
  6546. borderBottom: 1,
  6547. borderColor: "divider",
  6548. }}
  6549. onClick={(e) => e.stopPropagation()}
  6550. >
  6551. <Stack direction="row" spacing={0.5} alignItems="center">
  6552. <TextField
  6553. size="small"
  6554. fullWidth
  6555. placeholder={t("lane_searchPh")}
  6556. value={laneFilter.query}
  6557. onChange={(e) =>
  6558. setLaneFilter((prev) => ({
  6559. ...prev,
  6560. query: e.target.value,
  6561. }))
  6562. }
  6563. onKeyDown={(e) => e.stopPropagation()}
  6564. onClick={(e) => e.stopPropagation()}
  6565. onMouseDown={(e) => e.stopPropagation()}
  6566. InputProps={{
  6567. startAdornment: (
  6568. <Box
  6569. sx={{
  6570. mr: 0.5,
  6571. display: "inline-flex",
  6572. color: "text.disabled",
  6573. }}
  6574. >
  6575. <Search size={16} />
  6576. </Box>
  6577. ),
  6578. }}
  6579. sx={{
  6580. flex: 1,
  6581. minWidth: 0,
  6582. "& .MuiOutlinedInput-root": {
  6583. borderRadius: 2,
  6584. bgcolor: "grey.50",
  6585. },
  6586. "& .MuiInputBase-input": {
  6587. textAlign: "left",
  6588. fontSize: "0.8125rem",
  6589. color: "text.secondary",
  6590. py: 0.75,
  6591. },
  6592. "& .MuiInputBase-input::placeholder": {
  6593. opacity: 1,
  6594. color: "text.disabled",
  6595. textAlign: "left",
  6596. },
  6597. }}
  6598. />
  6599. <Tooltip title={t("floor_label")}>
  6600. <span>
  6601. <IconButton
  6602. size="small"
  6603. aria-label={t("floor_label")}
  6604. disabled={(lanes || []).length === 0}
  6605. color={filterActive ? "primary" : "default"}
  6606. onMouseDown={(e) => e.stopPropagation()}
  6607. onClick={(e) => {
  6608. e.stopPropagation();
  6609. setLaneFilterAnchor(e.currentTarget);
  6610. }}
  6611. sx={{
  6612. flexShrink: 0,
  6613. width: 32,
  6614. height: 32,
  6615. }}
  6616. >
  6617. <FilterListIcon fontSize="small" />
  6618. </IconButton>
  6619. </span>
  6620. </Tooltip>
  6621. <Button
  6622. size="small"
  6623. variant="text"
  6624. disabled={total === 0}
  6625. onMouseDown={(e) => e.stopPropagation()}
  6626. onClick={(e) => {
  6627. e.stopPropagation();
  6628. setSelectedLaneIds((prev) => {
  6629. const set = new Set(prev);
  6630. if (laneIds.every((id) => set.has(id))) {
  6631. laneIds.forEach((id) => set.delete(id));
  6632. } else {
  6633. laneIds.forEach((id) => set.add(id));
  6634. }
  6635. return Array.from(set);
  6636. });
  6637. }}
  6638. sx={{
  6639. flexShrink: 0,
  6640. minWidth: "auto",
  6641. px: 1,
  6642. py: 0.25,
  6643. }}
  6644. >
  6645. {t("lane_selectAll")}
  6646. </Button>
  6647. </Stack>
  6648. </ListSubheader>
  6649. {visibleLaneOptions.map((lane) => {
  6650. const checked = selectedLaneIds.includes(lane.id);
  6651. const rem =
  6652. lane.remark != null &&
  6653. String(lane.remark).trim() !== ""
  6654. ? String(lane.remark).trim()
  6655. : null;
  6656. const driverPlate =
  6657. (lane.driver || "—") +
  6658. (lane.plate ? ` · ${lane.plate}` : "");
  6659. const secondaryLine = rem
  6660. ? `${rem} · ${driverPlate}`
  6661. : driverPlate;
  6662. return (
  6663. <MenuItem
  6664. key={lane.id}
  6665. value={lane.id}
  6666. sx={{
  6667. alignItems: "flex-start",
  6668. py: 1,
  6669. gap: 1,
  6670. }}
  6671. >
  6672. <Checkbox
  6673. checked={checked}
  6674. size="small"
  6675. sx={{ p: 0.5, mt: 0.15 }}
  6676. />
  6677. <ListItemText
  6678. primary={lane.truckLanceCode}
  6679. secondary={secondaryLine}
  6680. primaryTypographyProps={{
  6681. sx: {
  6682. fontWeight: 800,
  6683. fontSize: "0.9rem",
  6684. wordBreak: "break-word",
  6685. overflowWrap: "anywhere",
  6686. },
  6687. }}
  6688. secondaryTypographyProps={{
  6689. sx: {
  6690. fontSize: "0.72rem",
  6691. color: "text.secondary",
  6692. mt: 0.25,
  6693. },
  6694. }}
  6695. />
  6696. </MenuItem>
  6697. );
  6698. })}
  6699. </Select>
  6700. <Popover
  6701. open={Boolean(laneFilterAnchor)}
  6702. anchorEl={laneFilterAnchor}
  6703. onClose={() => setLaneFilterAnchor(null)}
  6704. anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
  6705. transformOrigin={{ vertical: "top", horizontal: "left" }}
  6706. >
  6707. <Box sx={{ p: 2, width: 260 }}>
  6708. <Stack spacing={1.5}>
  6709. <FormControl size="small" fullWidth>
  6710. <InputLabel>{t("floor_label")}</InputLabel>
  6711. <Select
  6712. label={t("floor_label")}
  6713. value={laneFilter.floor}
  6714. onChange={(e) =>
  6715. setLaneFilter((prev) => ({
  6716. ...prev,
  6717. floor: e.target.value as any,
  6718. }))
  6719. }
  6720. >
  6721. <MenuItem value="all">{t("floor_all")}</MenuItem>
  6722. <MenuItem value="2F">2F</MenuItem>
  6723. <MenuItem value="4F">4F</MenuItem>
  6724. </Select>
  6725. </FormControl>
  6726. <Stack
  6727. direction="row"
  6728. spacing={1}
  6729. justifyContent="flex-end"
  6730. >
  6731. <Button
  6732. size="small"
  6733. onClick={() =>
  6734. setLaneFilter((prev) => ({
  6735. ...prev,
  6736. floor: "all",
  6737. }))
  6738. }
  6739. >
  6740. {t("filter_clear")}
  6741. </Button>
  6742. <Button
  6743. size="small"
  6744. variant="contained"
  6745. onClick={() => setLaneFilterAnchor(null)}
  6746. >
  6747. {t("filter_apply")}
  6748. </Button>
  6749. </Stack>
  6750. </Stack>
  6751. </Box>
  6752. </Popover>
  6753. {routeBoardTab === "board" && (
  6754. <Button
  6755. variant="outlined"
  6756. size="small"
  6757. fullWidth
  6758. startIcon={<Plus size={16} />}
  6759. disabled={loading || addRouteSubmitting}
  6760. sx={{ mt: 1 }}
  6761. onClick={openAddRouteDialog}
  6762. >
  6763. {t("btn_addLane")}
  6764. </Button>
  6765. )}
  6766. </Box>
  6767. );
  6768. })()}
  6769. {routeBoardTab === "logistics" && (
  6770. <Box sx={{ mb: 2 }}>
  6771. <Typography
  6772. variant="overline"
  6773. sx={{ fontWeight: 800, color: "text.secondary" }}
  6774. >
  6775. {t("quickIndex")}
  6776. </Typography>
  6777. <Button
  6778. fullWidth
  6779. size="small"
  6780. variant="outlined"
  6781. color="primary"
  6782. startIcon={<Plus size={16} />}
  6783. sx={{ mt: 1, fontWeight: 800, textTransform: "none" }}
  6784. onClick={() => {
  6785. setAddLogisticError(null);
  6786. setAddLogisticOpen(true);
  6787. }}
  6788. >
  6789. {t("btn_addLogistics")}
  6790. </Button>
  6791. <Stack spacing={1} sx={{ mt: 1 }}>
  6792. {lanesByLogisticsCompany.map(([company, list]) => {
  6793. const logisticMaster = resolveLogisticMasterRow(
  6794. company,
  6795. list,
  6796. logisticRowsEffective,
  6797. );
  6798. const canManage =
  6799. company !== "未分配物流商" && logisticMaster != null;
  6800. const masterId =
  6801. logisticMaster != null
  6802. ? Number(logisticMaster.id)
  6803. : null;
  6804. const isPendingDelete =
  6805. masterId != null &&
  6806. Number.isFinite(masterId) &&
  6807. masterId > 0 &&
  6808. pendingLogisticMasterDeletes.has(masterId);
  6809. const canDelete =
  6810. canManage && list.length === 0 && !isPendingDelete;
  6811. return (
  6812. <Box
  6813. key={
  6814. logisticMaster
  6815. ? `log-${logisticMaster.id}`
  6816. : `co-${company}`
  6817. }
  6818. sx={{
  6819. p: 1,
  6820. borderRadius: 1,
  6821. bgcolor: "grey.50",
  6822. border: "1px solid",
  6823. borderColor: isPendingDelete
  6824. ? "warning.light"
  6825. : "divider",
  6826. opacity: isPendingDelete ? 0.65 : 1,
  6827. }}
  6828. >
  6829. <Stack
  6830. direction="row"
  6831. alignItems="center"
  6832. spacing={0.75}
  6833. sx={{ minHeight: 28 }}
  6834. >
  6835. <Typography
  6836. variant="caption"
  6837. sx={{
  6838. fontWeight: 700,
  6839. flex: 1,
  6840. minWidth: 0,
  6841. overflow: "hidden",
  6842. textOverflow: "ellipsis",
  6843. whiteSpace: "nowrap",
  6844. lineHeight: 1.25,
  6845. }}
  6846. >
  6847. {company}
  6848. </Typography>
  6849. {canManage && (
  6850. <Stack
  6851. direction="row"
  6852. alignItems="center"
  6853. spacing={0}
  6854. sx={{ flexShrink: 0 }}
  6855. >
  6856. <Tooltip title={t("tooltip_editLogisticsDb")}>
  6857. <IconButton
  6858. size="small"
  6859. aria-label={t("aria_editLogistics")}
  6860. onClick={() =>
  6861. openEditLogistic(logisticMaster)
  6862. }
  6863. sx={{
  6864. width: 28,
  6865. height: 28,
  6866. p: 0,
  6867. }}
  6868. >
  6869. <Pencil size={14} />
  6870. </IconButton>
  6871. </Tooltip>
  6872. <Tooltip
  6873. title={
  6874. list.length > 0
  6875. ? t("err_logisticDeleteHasLanes", {
  6876. count: list.length,
  6877. })
  6878. : t("tooltip_deleteLogistics")
  6879. }
  6880. >
  6881. <Box
  6882. component="span"
  6883. sx={{
  6884. display: "inline-flex",
  6885. alignItems: "center",
  6886. }}
  6887. >
  6888. <IconButton
  6889. size="small"
  6890. aria-label={t("aria_deleteLogistics")}
  6891. disabled={!canDelete}
  6892. onClick={() =>
  6893. stageDeleteLogistic(
  6894. logisticMaster,
  6895. company,
  6896. )
  6897. }
  6898. sx={{
  6899. width: 28,
  6900. height: 28,
  6901. p: 0,
  6902. }}
  6903. >
  6904. <Trash2 size={14} />
  6905. </IconButton>
  6906. </Box>
  6907. </Tooltip>
  6908. </Stack>
  6909. )}
  6910. <Chip
  6911. label={t("lane_companyChip", { count: list.length })}
  6912. size="small"
  6913. sx={{
  6914. fontWeight: 800,
  6915. height: 22,
  6916. fontSize: "0.65rem",
  6917. flexShrink: 0,
  6918. "& .MuiChip-label": {
  6919. px: 0.75,
  6920. lineHeight: 1.2,
  6921. },
  6922. }}
  6923. />
  6924. </Stack>
  6925. {isPendingDelete && (
  6926. <Typography
  6927. variant="caption"
  6928. color="warning.dark"
  6929. sx={{ display: "block", mt: 0.5, fontWeight: 700 }}
  6930. >
  6931. {t("diff_staged_tag_unsaved")} ·{" "}
  6932. {t("diff_staged_deleteLogisticMaster", {
  6933. name: company,
  6934. })}
  6935. </Typography>
  6936. )}
  6937. </Box>
  6938. );
  6939. })}
  6940. {lanesByLogisticsCompany.length === 0 && (
  6941. <Typography variant="caption" color="text.secondary">
  6942. {t("logistics_sidebarEmpty")}
  6943. </Typography>
  6944. )}
  6945. </Stack>
  6946. </Box>
  6947. )}
  6948. {routeBoardTab !== "logistics" && (
  6949. <>
  6950. <Typography
  6951. variant="overline"
  6952. sx={{
  6953. fontWeight: 800,
  6954. color: "text.secondary",
  6955. mt: 3,
  6956. display: "block",
  6957. }}
  6958. >
  6959. {t("tools_title")}
  6960. </Typography>
  6961. <Stack spacing={1} sx={{ mt: 1 }}>
  6962. <TextField
  6963. size="small"
  6964. placeholder={t("shop_searchPh")}
  6965. value={searchTerm}
  6966. onChange={(e) => setSearchTerm(e.target.value)}
  6967. InputProps={{
  6968. startAdornment: (
  6969. <Box
  6970. sx={{
  6971. mr: 1,
  6972. display: "inline-flex",
  6973. alignItems: "center",
  6974. color: "text.secondary",
  6975. }}
  6976. >
  6977. <Search size={16} />
  6978. </Box>
  6979. ),
  6980. sx: {
  6981. alignItems: "center",
  6982. },
  6983. }}
  6984. sx={{
  6985. "& .MuiInputBase-root": {
  6986. alignItems: "center",
  6987. },
  6988. "& .MuiInputBase-input": {
  6989. textAlign: "left",
  6990. py: 0.75,
  6991. },
  6992. "& .MuiInputBase-input::placeholder": {
  6993. opacity: 1,
  6994. color: "text.disabled",
  6995. textAlign: "left",
  6996. },
  6997. }}
  6998. />
  6999. <Button
  7000. variant="outlined"
  7001. size="small"
  7002. startIcon={<History size={16} />}
  7003. onClick={openLogDialog}
  7004. disabled={loading || lanes.length === 0}
  7005. >
  7006. {t("btn_openVersionLog")}
  7007. </Button>
  7008. <Button
  7009. variant="outlined"
  7010. size="small"
  7011. onClick={loadLanes}
  7012. disabled={loading}
  7013. >
  7014. {loading ? t("btn_loading") : t("btn_refresh")}
  7015. </Button>
  7016. </Stack>
  7017. </>
  7018. )}
  7019. </Box>
  7020. {/* Board */}
  7021. <Box
  7022. sx={{
  7023. flex: 1,
  7024. minWidth: 0,
  7025. minHeight: 0,
  7026. p: 2,
  7027. overflow: "auto",
  7028. bgcolor: "grey.100",
  7029. }}
  7030. >
  7031. {error && (
  7032. <Alert severity="error" sx={{ mb: 2 }}>
  7033. {error}
  7034. </Alert>
  7035. )}
  7036. {loading ? (
  7037. <Box sx={{ display: "flex", justifyContent: "center", py: 6 }}>
  7038. <CircularProgress />
  7039. </Box>
  7040. ) : routeBoardTab === "logistics" ? (
  7041. <Box sx={{ width: "100%", maxWidth: 1400, mx: "auto" }}>
  7042. <Typography variant="h5" sx={{ fontWeight: 900, mb: 3 }}>
  7043. {t("logistics_overviewTitle")}
  7044. </Typography>
  7045. <Box
  7046. sx={{
  7047. display: "grid",
  7048. gridTemplateColumns: {
  7049. xs: "1fr",
  7050. md: "repeat(2, 1fr)",
  7051. xl: "repeat(3, 1fr)",
  7052. },
  7053. gap: 3,
  7054. }}
  7055. >
  7056. {lanesByLogisticsCompany.map(([company, companyLanes]) => {
  7057. const colStats = summarizeLogisticsColumnStats(companyLanes);
  7058. const columnHasDirtyLogistics = companyLanes.some((lane) =>
  7059. dirtyLaneLogisticIds.has(lane.id),
  7060. );
  7061. const logisticMaster = resolveLogisticMasterRow(
  7062. company,
  7063. companyLanes,
  7064. logisticRowsEffective,
  7065. );
  7066. return (
  7067. <Card
  7068. key={
  7069. logisticMaster
  7070. ? `lid:${logisticMaster.id}`
  7071. : `co:${company}`
  7072. }
  7073. variant="outlined"
  7074. onDragOver={(e) => {
  7075. e.preventDefault();
  7076. e.dataTransfer.dropEffect = "move";
  7077. if (logisticsLaneDragIdRef.current) {
  7078. setLogisticsDropHoverCompany(company);
  7079. }
  7080. }}
  7081. onDrop={(e) => {
  7082. e.preventDefault();
  7083. handleLogisticsDropOnCompany(company, companyLanes);
  7084. }}
  7085. sx={{
  7086. borderRadius: 3,
  7087. overflow: "hidden",
  7088. display: "flex",
  7089. flexDirection: "column",
  7090. transition: "border-color 0.15s, outline-color 0.15s",
  7091. borderColor: columnHasDirtyLogistics
  7092. ? "warning.main"
  7093. : "divider",
  7094. outline:
  7095. logisticsDropHoverCompany === company
  7096. ? "2px solid"
  7097. : "1px solid transparent",
  7098. outlineColor:
  7099. logisticsDropHoverCompany === company
  7100. ? "primary.main"
  7101. : "transparent",
  7102. }}
  7103. >
  7104. <Box
  7105. sx={{
  7106. p: 2,
  7107. borderBottom: 1,
  7108. borderColor: "divider",
  7109. bgcolor: columnHasDirtyLogistics
  7110. ? "warning.50"
  7111. : "grey.50",
  7112. display: "flex",
  7113. alignItems: "flex-start",
  7114. justifyContent: "space-between",
  7115. gap: 1,
  7116. }}
  7117. >
  7118. <Stack
  7119. direction="row"
  7120. spacing={1.5}
  7121. alignItems="flex-start"
  7122. sx={{ minWidth: 0, flex: 1 }}
  7123. >
  7124. <Box
  7125. sx={{
  7126. p: 1,
  7127. borderRadius: 1,
  7128. bgcolor: "primary.50",
  7129. color: "primary.main",
  7130. display: "flex",
  7131. flexShrink: 0,
  7132. }}
  7133. >
  7134. <Building2 size={20} />
  7135. </Box>
  7136. <Box sx={{ minWidth: 0 }}>
  7137. <Stack
  7138. direction="row"
  7139. alignItems="center"
  7140. spacing={0.75}
  7141. flexWrap="wrap"
  7142. useFlexGap
  7143. >
  7144. <Typography
  7145. sx={{
  7146. fontWeight: 900,
  7147. fontSize: "1.1rem",
  7148. flex: 1,
  7149. minWidth: 0,
  7150. lineHeight: 1.25,
  7151. }}
  7152. >
  7153. {company}
  7154. </Typography>
  7155. {columnHasDirtyLogistics && (
  7156. <Chip
  7157. size="small"
  7158. color="warning"
  7159. label={t("logistics_dirtyColumnBadge")}
  7160. sx={{ height: 22, fontWeight: 800 }}
  7161. />
  7162. )}
  7163. </Stack>
  7164. {logisticMaster && (
  7165. <Stack
  7166. spacing={0.5}
  7167. sx={{ mt: 0.75, alignSelf: "stretch", color: "text.secondary" }}
  7168. >
  7169. <Stack
  7170. direction="row"
  7171. spacing={0.75}
  7172. alignItems="center"
  7173. sx={{ alignSelf: "stretch" }}
  7174. >
  7175. <Box
  7176. sx={{
  7177. display: "flex",
  7178. alignItems: "center",
  7179. flexShrink: 0,
  7180. color: "inherit",
  7181. }}
  7182. >
  7183. <Users size={13} />
  7184. </Box>
  7185. <Typography
  7186. variant="body2"
  7187. sx={{
  7188. fontWeight: 500,
  7189. fontSize: "0.8125rem",
  7190. lineHeight: 1.35,
  7191. color: "inherit",
  7192. }}
  7193. >
  7194. {String(
  7195. logisticMaster.driverName ?? "",
  7196. ).trim() || "—"}
  7197. </Typography>
  7198. </Stack>
  7199. <Stack
  7200. direction="row"
  7201. spacing={0.75}
  7202. alignItems="center"
  7203. sx={{ alignSelf: "stretch" }}
  7204. >
  7205. <Box
  7206. sx={{
  7207. display: "flex",
  7208. alignItems: "center",
  7209. flexShrink: 0,
  7210. color: "inherit",
  7211. }}
  7212. >
  7213. <Phone size={13} />
  7214. </Box>
  7215. <Typography
  7216. variant="body2"
  7217. sx={{
  7218. fontWeight: 500,
  7219. fontSize: "0.8125rem",
  7220. lineHeight: 1.35,
  7221. color: "inherit",
  7222. }}
  7223. >
  7224. {logisticMaster.driverNumber != null &&
  7225. Number.isFinite(logisticMaster.driverNumber)
  7226. ? String(logisticMaster.driverNumber)
  7227. : "—"}
  7228. </Typography>
  7229. </Stack>
  7230. <Stack
  7231. direction="row"
  7232. spacing={0.75}
  7233. alignItems="center"
  7234. sx={{ alignSelf: "stretch" }}
  7235. >
  7236. <Box
  7237. sx={{
  7238. display: "flex",
  7239. alignItems: "center",
  7240. flexShrink: 0,
  7241. color: "inherit",
  7242. }}
  7243. >
  7244. <CarFront size={13} />
  7245. </Box>
  7246. <Typography
  7247. variant="body2"
  7248. sx={{
  7249. fontWeight: 500,
  7250. fontSize: "0.8125rem",
  7251. lineHeight: 1.35,
  7252. color: "inherit",
  7253. }}
  7254. >
  7255. {String(
  7256. logisticMaster.carPlate ?? "",
  7257. ).trim() || "—"}
  7258. </Typography>
  7259. </Stack>
  7260. </Stack>
  7261. )}
  7262. <Stack
  7263. direction="row"
  7264. flexWrap="wrap"
  7265. useFlexGap
  7266. spacing={0.75}
  7267. sx={{
  7268. mt: logisticMaster ? 1.5 : 1,
  7269. pt: logisticMaster ? 1.5 : 0,
  7270. borderTop: logisticMaster ? 1 : 0,
  7271. borderColor: "divider",
  7272. alignItems: "center",
  7273. }}
  7274. >
  7275. <Chip
  7276. size="small"
  7277. variant="outlined"
  7278. color="primary"
  7279. label={t("logistics_colLaneCount", {
  7280. count: colStats.laneCount,
  7281. })}
  7282. sx={{ fontWeight: 700, height: 24 }}
  7283. />
  7284. <Chip
  7285. size="small"
  7286. variant="outlined"
  7287. label={t("logistics_colShopCount", {
  7288. count: colStats.shopCount,
  7289. })}
  7290. sx={{ fontWeight: 700, height: 24 }}
  7291. />
  7292. <Chip
  7293. size="small"
  7294. label={`2F · ${colStats.count2F}`}
  7295. sx={{
  7296. height: 24,
  7297. fontWeight: 700,
  7298. bgcolor: "action.hover",
  7299. }}
  7300. />
  7301. <Chip
  7302. size="small"
  7303. label={`4F · ${colStats.count4F}`}
  7304. sx={{
  7305. height: 24,
  7306. fontWeight: 700,
  7307. bgcolor: "action.hover",
  7308. }}
  7309. />
  7310. </Stack>
  7311. </Box>
  7312. </Stack>
  7313. <ChevronRight
  7314. size={20}
  7315. color="var(--mui-palette-text-disabled)"
  7316. style={{ flexShrink: 0 }}
  7317. />
  7318. </Box>
  7319. <Stack spacing={1.5} sx={{ p: 2, flex: 1 }}>
  7320. {companyLanes.map((lane) => {
  7321. const logisticDirty = dirtyLaneLogisticIds.has(
  7322. lane.id,
  7323. );
  7324. return (
  7325. <Paper
  7326. key={lane.id}
  7327. component="div"
  7328. draggable
  7329. onDragStart={(e) => {
  7330. logisticsLaneDragIdRef.current = lane.id;
  7331. e.dataTransfer.effectAllowed = "move";
  7332. e.dataTransfer.setData(
  7333. "application/x-fpsms-lane-id",
  7334. lane.id,
  7335. );
  7336. }}
  7337. onDragEnd={() => {
  7338. logisticsLaneDragIdRef.current = null;
  7339. setLogisticsDropHoverCompany(null);
  7340. }}
  7341. variant="outlined"
  7342. sx={{
  7343. p: 1.5,
  7344. borderRadius: 2,
  7345. display: "flex",
  7346. flexDirection: "column",
  7347. gap: 1,
  7348. bgcolor: logisticDirty
  7349. ? "warning.50"
  7350. : "grey.50",
  7351. borderColor: logisticDirty
  7352. ? "warning.main"
  7353. : "divider",
  7354. boxShadow: logisticDirty
  7355. ? "0 0 0 2px rgba(245, 124, 0, 0.18)"
  7356. : "none",
  7357. minHeight: 88,
  7358. cursor: "grab",
  7359. overflow: "hidden",
  7360. "&:hover": {
  7361. bgcolor: logisticDirty
  7362. ? "warning.50"
  7363. : "action.hover",
  7364. borderColor: logisticDirty
  7365. ? "warning.dark"
  7366. : "primary.light",
  7367. },
  7368. }}
  7369. >
  7370. <Stack
  7371. direction="row"
  7372. alignItems="flex-start"
  7373. justifyContent="space-between"
  7374. spacing={1}
  7375. sx={{ minWidth: 0 }}
  7376. >
  7377. <Box sx={{ flex: 1, minWidth: 0 }}>
  7378. <Typography
  7379. sx={{
  7380. fontWeight: 900,
  7381. fontStyle: "italic",
  7382. color: "primary.main",
  7383. fontSize: "1.05rem",
  7384. lineHeight: 1.3,
  7385. wordBreak: "break-word",
  7386. overflowWrap: "anywhere",
  7387. }}
  7388. >
  7389. {lane.truckLanceCode}
  7390. {lane.remark != null &&
  7391. String(lane.remark).trim() !== "" && (
  7392. <Typography
  7393. component="span"
  7394. variant="caption"
  7395. color="secondary"
  7396. sx={{ ml: 0.5, fontStyle: "normal" }}
  7397. >
  7398. ·{lane.remark}
  7399. </Typography>
  7400. )}
  7401. </Typography>
  7402. {logisticDirty && (
  7403. <Chip
  7404. size="small"
  7405. color="warning"
  7406. label={t("logistics_dirtyLaneBadge")}
  7407. sx={{
  7408. mt: 0.75,
  7409. height: 22,
  7410. fontWeight: 800,
  7411. }}
  7412. />
  7413. )}
  7414. {(lane.driver || lane.plate) && (
  7415. <Stack
  7416. direction="row"
  7417. spacing={0.75}
  7418. alignItems="center"
  7419. flexWrap="wrap"
  7420. sx={{ mt: 0.5 }}
  7421. >
  7422. {!!lane.driver && (
  7423. <Typography
  7424. variant="caption"
  7425. sx={{ fontWeight: 700 }}
  7426. >
  7427. {lane.driver}
  7428. </Typography>
  7429. )}
  7430. {lane.plate && (
  7431. <Chip
  7432. label={lane.plate}
  7433. size="small"
  7434. variant="outlined"
  7435. sx={{
  7436. fontSize: "0.65rem",
  7437. height: 22,
  7438. maxWidth: "100%",
  7439. }}
  7440. />
  7441. )}
  7442. </Stack>
  7443. )}
  7444. </Box>
  7445. <Stack
  7446. direction="row"
  7447. spacing={0.25}
  7448. alignItems="flex-start"
  7449. sx={{ flexShrink: 0, pt: 0.25 }}
  7450. >
  7451. <GripVertical
  7452. size={18}
  7453. aria-hidden
  7454. color="var(--mui-palette-text-secondary)"
  7455. />
  7456. <Tooltip title={t("tooltip_openLaneBoard")}>
  7457. <IconButton
  7458. size="small"
  7459. onClick={() => {
  7460. setRouteBoardTab("board");
  7461. setSelectedLaneIds([lane.id]);
  7462. }}
  7463. aria-label={t("aria_openLaneBoard")}
  7464. >
  7465. <LayoutDashboard size={18} />
  7466. </IconButton>
  7467. </Tooltip>
  7468. </Stack>
  7469. </Stack>
  7470. <Stack
  7471. direction="row"
  7472. alignItems="center"
  7473. spacing={2}
  7474. sx={{
  7475. flexWrap: "nowrap",
  7476. pt: 0.75,
  7477. mt: "auto",
  7478. borderTop: 1,
  7479. borderColor: "divider",
  7480. typography: "caption",
  7481. color: "text.secondary",
  7482. }}
  7483. >
  7484. <Box
  7485. component="span"
  7486. sx={{
  7487. display: "inline-flex",
  7488. alignItems: "center",
  7489. gap: 0.5,
  7490. whiteSpace: "nowrap",
  7491. flexShrink: 0,
  7492. }}
  7493. >
  7494. <Clock size={12} aria-hidden />
  7495. {lane.startTime || "—"}
  7496. </Box>
  7497. <Box
  7498. component="span"
  7499. sx={{
  7500. display: "inline-flex",
  7501. alignItems: "center",
  7502. gap: 0.5,
  7503. whiteSpace: "nowrap",
  7504. flexShrink: 0,
  7505. }}
  7506. >
  7507. <MapPin size={12} aria-hidden />
  7508. {t("lane_shopCountInline", {
  7509. count: lane.shops.length,
  7510. })}
  7511. </Box>
  7512. </Stack>
  7513. </Paper>
  7514. );
  7515. })}
  7516. </Stack>
  7517. </Card>
  7518. );
  7519. })}
  7520. </Box>
  7521. </Box>
  7522. ) : (
  7523. <Stack
  7524. direction="row"
  7525. spacing={2}
  7526. alignItems="flex-start"
  7527. sx={{ width: "max-content" }}
  7528. >
  7529. {filteredLanes
  7530. .filter((lane) =>
  7531. lanesMatchingFloorOnly.some((v) => v.id === lane.id),
  7532. )
  7533. .map((lane) => {
  7534. const districtSections = buildLaneDistrictSections(
  7535. lane.shops,
  7536. pendingEmptyDistrictsByLane[lane.id],
  7537. );
  7538. return (
  7539. <Card
  7540. key={lane.id}
  7541. data-lane-id={lane.id}
  7542. onDragOver={(e) => {
  7543. handleDragOver(e);
  7544. if (!draggedRef.current) return;
  7545. const beforeShopId = getBeforeShopIdByPointer(
  7546. lane.id,
  7547. e.clientY,
  7548. );
  7549. setDropIndicator((prev) => {
  7550. if (
  7551. prev?.laneId === lane.id &&
  7552. prev.beforeShopId === beforeShopId
  7553. )
  7554. return prev;
  7555. return { laneId: lane.id, beforeShopId };
  7556. });
  7557. }}
  7558. onDrop={(e) => {
  7559. e.preventDefault();
  7560. e.stopPropagation();
  7561. handleDropToLane(lane.id);
  7562. }}
  7563. sx={{
  7564. width: 360,
  7565. flexShrink: 0,
  7566. borderTop: "4px solid",
  7567. borderTopColor: "primary.main",
  7568. overflow: "hidden",
  7569. }}
  7570. >
  7571. {renderLaneHeader(lane)}
  7572. <Box
  7573. sx={{
  7574. p: 1.5,
  7575. maxHeight: "calc(100vh - 220px)",
  7576. overflow: "auto",
  7577. }}
  7578. >
  7579. {districtSections.map(
  7580. ({ district, shops, isPendingEmpty }) => (
  7581. <Box
  7582. key={`${lane.id}::${district}`}
  7583. onDragOver={(e) => {
  7584. e.preventDefault();
  7585. if (!draggedRef.current) return;
  7586. setDropIndicator({
  7587. laneId: lane.id,
  7588. beforeShopId: null,
  7589. });
  7590. }}
  7591. onDrop={(e) => {
  7592. e.preventDefault();
  7593. e.stopPropagation();
  7594. handleDropToPosition(lane.id, null, district);
  7595. }}
  7596. sx={{ mb: 2 }}
  7597. >
  7598. <Stack
  7599. direction="row"
  7600. spacing={0.5}
  7601. alignItems="center"
  7602. sx={{ px: 0.5, mb: 1, minWidth: 0 }}
  7603. >
  7604. <MapPin size={14} />
  7605. <Typography
  7606. variant="caption"
  7607. sx={{
  7608. fontWeight: 900,
  7609. color: "text.secondary",
  7610. minWidth: 0,
  7611. }}
  7612. noWrap
  7613. >
  7614. {district}
  7615. </Typography>
  7616. <Box
  7617. sx={{
  7618. flex: 1,
  7619. height: 1,
  7620. bgcolor: "grey.200",
  7621. mx: 0.5,
  7622. }}
  7623. />
  7624. <Typography
  7625. variant="caption"
  7626. color="text.secondary"
  7627. >
  7628. {shops.length}
  7629. </Typography>
  7630. <Tooltip title={t("tooltip_editDistrict")}>
  7631. <span>
  7632. <IconButton
  7633. size="small"
  7634. sx={{ p: 0.25 }}
  7635. onMouseDown={(e) => e.stopPropagation()}
  7636. onClick={(e) => {
  7637. e.stopPropagation();
  7638. openDistrictRename(lane.id, district);
  7639. }}
  7640. disabled={loading}
  7641. aria-label={t("aria_editDistrict")}
  7642. >
  7643. <Pencil size={14} />
  7644. </IconButton>
  7645. </span>
  7646. </Tooltip>
  7647. {isPendingEmpty && (
  7648. <Tooltip title={t("tooltip_removeEmptyDistrict")}>
  7649. <span>
  7650. <IconButton
  7651. size="small"
  7652. sx={{ p: 0.25 }}
  7653. onMouseDown={(e) => e.stopPropagation()}
  7654. onClick={(e) => {
  7655. e.stopPropagation();
  7656. removePendingEmptyDistrict(
  7657. lane.id,
  7658. district,
  7659. );
  7660. }}
  7661. disabled={loading}
  7662. aria-label={t("aria_removeEmptyDistrict")}
  7663. >
  7664. <X size={14} />
  7665. </IconButton>
  7666. </span>
  7667. </Tooltip>
  7668. )}
  7669. </Stack>
  7670. <Stack spacing={1}>
  7671. {shops.map((shop) => {
  7672. const changed = dirtyMoves.has(shop.id);
  7673. const isScheduledMove =
  7674. shop.id > 0 && scheduledShopIdSet.has(shop.id);
  7675. const isScheduledLater =
  7676. shop.id > 0 &&
  7677. !isScheduledMove &&
  7678. pendingScheduleShopIds.has(shop.id);
  7679. const isFailedScheduledMove =
  7680. shop.id > 0 &&
  7681. failedScheduleShopIds.has(shop.id);
  7682. const showInsertLine =
  7683. dropIndicator != null &&
  7684. dropIndicator.laneId === lane.id &&
  7685. dropIndicator.beforeShopId === shop.id;
  7686. return (
  7687. <Card
  7688. key={shop.id}
  7689. variant="outlined"
  7690. data-shop-id={shop.id}
  7691. draggable={shop.id > 0 && !isScheduledMove}
  7692. onDragStart={() =>
  7693. handleDragStart(shop.id, lane.id)
  7694. }
  7695. onDragEnd={() => clearDragState()}
  7696. onDragOver={(e) => {
  7697. e.preventDefault();
  7698. e.stopPropagation();
  7699. if (draggedRef.current)
  7700. setDropIndicator({
  7701. laneId: lane.id,
  7702. beforeShopId: shop.id,
  7703. });
  7704. }}
  7705. onDrop={(e) => {
  7706. e.preventDefault();
  7707. e.stopPropagation();
  7708. handleDropToPosition(
  7709. lane.id,
  7710. shop.id,
  7711. district,
  7712. );
  7713. }}
  7714. sx={{
  7715. cursor:
  7716. shop.id > 0 && !isScheduledMove
  7717. ? "grab"
  7718. : "default",
  7719. opacity: isScheduledMove ? 0.55 : 1,
  7720. borderColor: changed
  7721. ? "warning.main"
  7722. : shop.id < 0
  7723. ? "warning.light"
  7724. : changedShopIds.has(shop.id)
  7725. ? "info.main"
  7726. : "divider",
  7727. bgcolor: isScheduledMove
  7728. ? "action.hover"
  7729. : changed
  7730. ? "warning.50"
  7731. : shop.id < 0
  7732. ? "grey.100"
  7733. : changedShopIds.has(shop.id)
  7734. ? "info.50"
  7735. : "background.paper",
  7736. "&:active":
  7737. shop.id > 0
  7738. ? { cursor: "grabbing" }
  7739. : undefined,
  7740. position: "relative",
  7741. }}
  7742. >
  7743. {isFailedScheduledMove && (
  7744. <Tooltip
  7745. title={t("schedule_history_status_failed")}
  7746. >
  7747. <Box
  7748. sx={{
  7749. position: "absolute",
  7750. top: 0,
  7751. right: 0,
  7752. p: 0.75,
  7753. bgcolor: "error.main",
  7754. color: "error.contrastText",
  7755. borderBottomLeftRadius: 8,
  7756. display: "flex",
  7757. alignItems: "center",
  7758. justifyContent: "center",
  7759. zIndex: 2,
  7760. }}
  7761. >
  7762. <AlertTriangle size={12} />
  7763. </Box>
  7764. </Tooltip>
  7765. )}
  7766. {isScheduledMove && !isFailedScheduledMove && (
  7767. <Tooltip title={t("schedule_shop_locked")}>
  7768. <Box
  7769. sx={{
  7770. position: "absolute",
  7771. top: 0,
  7772. right: 0,
  7773. p: 0.75,
  7774. bgcolor: "warning.main",
  7775. color: "warning.contrastText",
  7776. borderBottomLeftRadius: 8,
  7777. display: "flex",
  7778. alignItems: "center",
  7779. justifyContent: "center",
  7780. zIndex: 1,
  7781. }}
  7782. >
  7783. <Clock size={12} />
  7784. </Box>
  7785. </Tooltip>
  7786. )}
  7787. {isScheduledLater &&
  7788. !isFailedScheduledMove && (
  7789. <Tooltip
  7790. title={t("schedule_shop_scheduled")}
  7791. >
  7792. <Box
  7793. sx={{
  7794. position: "absolute",
  7795. top: 0,
  7796. right: 0,
  7797. p: 0.75,
  7798. bgcolor: "info.light",
  7799. color: "info.contrastText",
  7800. borderBottomLeftRadius: 8,
  7801. display: "flex",
  7802. alignItems: "center",
  7803. justifyContent: "center",
  7804. zIndex: 1,
  7805. }}
  7806. >
  7807. <Clock size={12} />
  7808. </Box>
  7809. </Tooltip>
  7810. )}
  7811. {showInsertLine && (
  7812. <Box
  7813. sx={{
  7814. position: "absolute",
  7815. top: -1,
  7816. left: 0,
  7817. right: 0,
  7818. height: 4,
  7819. bgcolor: "primary.main",
  7820. borderRadius: 1,
  7821. }}
  7822. />
  7823. )}
  7824. <CardContent
  7825. sx={{
  7826. py: 1.25,
  7827. "&:last-child": { pb: 1.25 },
  7828. }}
  7829. >
  7830. <Stack
  7831. direction="row"
  7832. justifyContent="space-between"
  7833. alignItems="flex-start"
  7834. spacing={1}
  7835. >
  7836. <Box sx={{ minWidth: 0 }}>
  7837. <Typography
  7838. variant="subtitle2"
  7839. sx={{ fontWeight: 900 }}
  7840. noWrap
  7841. >
  7842. {(() => {
  7843. const codeLower = String(
  7844. shop.shopCode || "",
  7845. )
  7846. .trim()
  7847. .toLowerCase();
  7848. const realName =
  7849. shopNameByCodeMap.get(
  7850. codeLower,
  7851. );
  7852. return realName &&
  7853. String(realName).trim() !== ""
  7854. ? realName
  7855. : shop.branchName || "-";
  7856. })()}
  7857. </Typography>
  7858. <Typography
  7859. variant="caption"
  7860. color="text.secondary"
  7861. >
  7862. {formatShopCardSubtitle(shop)}
  7863. </Typography>
  7864. <Stack
  7865. direction="row"
  7866. alignItems="center"
  7867. spacing={0.5}
  7868. >
  7869. <Typography
  7870. variant="caption"
  7871. sx={{
  7872. display: "block",
  7873. color: "text.secondary",
  7874. }}
  7875. >
  7876. Seq: {shop.loadingSequence ?? "-"}
  7877. </Typography>
  7878. <Tooltip title={t("tooltip_editSeq")}>
  7879. <span>
  7880. <IconButton
  7881. size="small"
  7882. sx={{ p: 0.25 }}
  7883. onMouseDown={(e) =>
  7884. e.stopPropagation()
  7885. }
  7886. onClick={(e) => {
  7887. e.stopPropagation();
  7888. openSeqEdit(lane, shop);
  7889. }}
  7890. disabled={loading}
  7891. aria-label={t("aria_editSeq")}
  7892. >
  7893. <Pencil size={12} />
  7894. </IconButton>
  7895. </span>
  7896. </Tooltip>
  7897. </Stack>
  7898. </Box>
  7899. <Stack
  7900. direction="row"
  7901. spacing={0.5}
  7902. alignItems="center"
  7903. >
  7904. <Tooltip
  7905. title={
  7906. isScheduledMove
  7907. ? t("schedule_shop_locked")
  7908. : t("tooltip_removeFromLane")
  7909. }
  7910. >
  7911. <span>
  7912. <IconButton
  7913. size="small"
  7914. onMouseDown={(e) =>
  7915. e.stopPropagation()
  7916. }
  7917. onClick={(e) => {
  7918. e.stopPropagation();
  7919. void handleDeleteTruckRow(
  7920. shop.id,
  7921. );
  7922. }}
  7923. disabled={
  7924. loading ||
  7925. dirtyDeletes.has(shop.id) ||
  7926. isScheduledMove
  7927. }
  7928. >
  7929. <Trash2 size={16} />
  7930. </IconButton>
  7931. </span>
  7932. </Tooltip>
  7933. </Stack>
  7934. </Stack>
  7935. </CardContent>
  7936. </Card>
  7937. );
  7938. })}
  7939. </Stack>
  7940. </Box>
  7941. ),
  7942. )}
  7943. <Button
  7944. size="small"
  7945. variant="text"
  7946. startIcon={<Plus size={14} />}
  7947. onClick={() => openDistrictAdd(lane.id)}
  7948. disabled={loading}
  7949. sx={{ alignSelf: "flex-start", mb: 1 }}
  7950. >
  7951. {t("btn_addDistrict")}
  7952. </Button>
  7953. {dropIndicator != null &&
  7954. dropIndicator.laneId === lane.id &&
  7955. dropIndicator.beforeShopId == null &&
  7956. lane.shops.length > 0 && (
  7957. <Box
  7958. sx={{
  7959. mt: 1,
  7960. height: 8,
  7961. borderRadius: 1,
  7962. bgcolor: "primary.main",
  7963. opacity: 0.35,
  7964. }}
  7965. />
  7966. )}
  7967. {districtSections.length === 0 && (
  7968. <Box
  7969. sx={{
  7970. border: "2px dashed",
  7971. borderColor: "grey.300",
  7972. borderRadius: 2,
  7973. height: 120,
  7974. display: "flex",
  7975. alignItems: "center",
  7976. justifyContent: "center",
  7977. color: "text.secondary",
  7978. }}
  7979. >
  7980. <Stack alignItems="center" spacing={1}>
  7981. <TruckIcon size={28} />
  7982. <Typography variant="caption">
  7983. {t("empty_lane_noShops")}
  7984. </Typography>
  7985. </Stack>
  7986. </Box>
  7987. )}
  7988. </Box>
  7989. <Box
  7990. sx={{
  7991. p: 1.25,
  7992. borderTop: "1px solid",
  7993. borderColor: "divider",
  7994. bgcolor: "grey.50",
  7995. }}
  7996. >
  7997. <Stack direction="row" spacing={1}>
  7998. <Button
  7999. fullWidth
  8000. size="small"
  8001. variant="outlined"
  8002. startIcon={<Plus size={16} />}
  8003. onClick={() => openAddShopDialog(lane.id)}
  8004. disabled={loading}
  8005. >
  8006. {t("btn_addShopToLane")}
  8007. </Button>
  8008. <Tooltip
  8009. title={
  8010. lane.shops.some(
  8011. (s) =>
  8012. s.id > 0 && scheduledShopIdSet.has(s.id),
  8013. )
  8014. ? t("schedule_shop_locked")
  8015. : t("tooltip_clearLaneShops")
  8016. }
  8017. >
  8018. <span>
  8019. <IconButton
  8020. size="small"
  8021. onClick={() => handleClearLaneShops(lane)}
  8022. disabled={
  8023. loading ||
  8024. lane.shops.length === 0 ||
  8025. lane.shops.some(
  8026. (s) =>
  8027. s.id > 0 &&
  8028. scheduledShopIdSet.has(s.id),
  8029. )
  8030. }
  8031. >
  8032. <Trash2 size={16} />
  8033. </IconButton>
  8034. </span>
  8035. </Tooltip>
  8036. </Stack>
  8037. </Box>
  8038. </Card>
  8039. );
  8040. })}
  8041. <Card
  8042. variant="outlined"
  8043. sx={{
  8044. width: 56,
  8045. height: 56,
  8046. flexShrink: 0,
  8047. display: "flex",
  8048. alignItems: "center",
  8049. justifyContent: "center",
  8050. bgcolor: "background.paper",
  8051. }}
  8052. >
  8053. <Tooltip title={t("tooltip_pickLane")}>
  8054. <span>
  8055. <IconButton
  8056. aria-label={t("aria_pickLane")}
  8057. onClick={(e) => setBoardQuickPickAnchorEl(e.currentTarget)}
  8058. disabled={loading || lanesMatchingFloorOnly.length === 0}
  8059. >
  8060. <Plus size={22} />
  8061. </IconButton>
  8062. </span>
  8063. </Tooltip>
  8064. </Card>
  8065. <Popover
  8066. open={boardQuickPickAnchorEl != null}
  8067. anchorEl={boardQuickPickAnchorEl}
  8068. onClose={() => {
  8069. setBoardQuickPickAnchorEl(null);
  8070. setBoardQuickPickSearch("");
  8071. }}
  8072. anchorOrigin={{ vertical: "center", horizontal: "right" }}
  8073. transformOrigin={{ vertical: "center", horizontal: "left" }}
  8074. slotProps={{
  8075. paper: {
  8076. sx: {
  8077. width: "min(100vw - 32px, 360px)",
  8078. maxHeight: 420,
  8079. overflow: "hidden",
  8080. display: "flex",
  8081. flexDirection: "column",
  8082. },
  8083. },
  8084. }}
  8085. >
  8086. <Box sx={{ px: 1.5, py: 1, borderBottom: 1, borderColor: "divider" }}>
  8087. <TextField
  8088. size="small"
  8089. fullWidth
  8090. placeholder={t("lane_searchPh")}
  8091. value={boardQuickPickSearch}
  8092. onChange={(e) => setBoardQuickPickSearch(e.target.value)}
  8093. sx={{
  8094. "& .MuiOutlinedInput-root": {
  8095. borderRadius: 2,
  8096. bgcolor: "grey.50",
  8097. },
  8098. "& .MuiInputBase-input": {
  8099. textAlign: "left",
  8100. fontSize: "0.8125rem",
  8101. color: "text.secondary",
  8102. py: 0.75,
  8103. },
  8104. "& .MuiInputBase-input::placeholder": {
  8105. opacity: 1,
  8106. color: "text.disabled",
  8107. },
  8108. }}
  8109. InputProps={{
  8110. startAdornment: (
  8111. <Box
  8112. sx={{
  8113. mr: 0.5,
  8114. display: "inline-flex",
  8115. color: "text.disabled",
  8116. }}
  8117. >
  8118. <Search size={16} />
  8119. </Box>
  8120. ),
  8121. }}
  8122. inputProps={{ "aria-label": t("aria_searchLanes") }}
  8123. onKeyDown={(e) => e.stopPropagation()}
  8124. />
  8125. </Box>
  8126. <List dense sx={{ overflow: "auto", py: 0, flex: 1, minHeight: 0 }}>
  8127. {boardQuickPickFilteredLanes.length === 0 ? (
  8128. <Box sx={{ px: 2, py: 2 }}>
  8129. <Typography variant="body2" color="text.secondary">
  8130. {lanesMatchingFloorOnly.length === 0
  8131. ? t("quickPick_noLanes")
  8132. : t("quickPick_noKeyword")}
  8133. </Typography>
  8134. </Box>
  8135. ) : (
  8136. boardQuickPickFilteredLanes.map((lane) => {
  8137. const rem =
  8138. lane.remark != null &&
  8139. String(lane.remark).trim() !== ""
  8140. ? String(lane.remark).trim()
  8141. : null;
  8142. const picked = selectedLaneIds.includes(lane.id);
  8143. return (
  8144. <ListItemButton
  8145. key={lane.id}
  8146. selected={picked}
  8147. onClick={() => applyBoardQuickPickLane(lane.id)}
  8148. sx={{ alignItems: "flex-start", py: 1 }}
  8149. >
  8150. <ListItemText
  8151. primary={lane.truckLanceCode}
  8152. secondary={rem ?? undefined}
  8153. primaryTypographyProps={{
  8154. sx: { fontWeight: 800, fontSize: "0.9rem" },
  8155. }}
  8156. secondaryTypographyProps={{
  8157. sx: { fontSize: "0.72rem" },
  8158. }}
  8159. />
  8160. </ListItemButton>
  8161. );
  8162. })
  8163. )}
  8164. </List>
  8165. </Popover>
  8166. </Stack>
  8167. )}
  8168. </Box>
  8169. </Box>
  8170. </Box>
  8171. );
  8172. };
  8173. export default RouteBoard;