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.
 
 

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