FPSMS-frontend
Non puoi selezionare più di 25 argomenti Gli argomenti devono iniziare con una lettera o un numero, possono includere trattini ('-') e possono essere lunghi fino a 35 caratteri.
 
 

1213 righe
41 KiB

  1. "use client";
  2. import { useCallback, useEffect, useMemo, useRef, useState } from "react";
  3. import {
  4. Alert,
  5. Autocomplete,
  6. Box,
  7. Button,
  8. CircularProgress,
  9. Grid,
  10. MenuItem,
  11. Paper,
  12. Stack,
  13. TextField,
  14. Tooltip,
  15. Typography,
  16. } from "@mui/material";
  17. import ArrowBackIcon from "@mui/icons-material/ArrowBack";
  18. import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers";
  19. import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
  20. import dayjs from "dayjs";
  21. import "dayjs/locale/zh-hk";
  22. import * as XLSX from "xlsx-js-style";
  23. import {
  24. CompletedDoPickOrderResponse,
  25. fetchCompletedDoPickOrdersAll,
  26. fetchCompletedDoPickOrdersWorkbenchAll,
  27. } from "@/app/api/pickOrder/actions";
  28. import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil";
  29. import { useTranslation } from "react-i18next";
  30. type FloorFilter = "all" | "2/F" | "4/F";
  31. type BreakdownDimension = "lane" | "shop" | "floor";
  32. type DailySummaryRow = {
  33. date: string;
  34. floor2F: number;
  35. floor4F: number;
  36. truckX: number;
  37. total: number;
  38. };
  39. type ShopDailyRow = DailySummaryRow & {
  40. shopCode: string;
  41. shopName: string;
  42. };
  43. type ShopQtyRow = {
  44. code: string;
  45. name: string;
  46. qty: number;
  47. };
  48. type BreakdownRow = {
  49. key: string;
  50. label: string;
  51. qty: number;
  52. };
  53. type Props = {
  54. mode?: "normal" | "workbench";
  55. };
  56. const TRUCK_X_LANE = "車線-X";
  57. const ALL = "all";
  58. const CHART_ROW_H = 38;
  59. /** Jasper / report standard used across FPSMS Excel output. */
  60. const EXCEL_FONT_NAME = "微軟正黑體";
  61. const DAILY_SHEET_LAST_COL = 6;
  62. type FilterOption = { value: string; label: string };
  63. function normalizeTruckLane(raw: string | null | undefined): string {
  64. const value = String(raw ?? "").trim();
  65. return value || TRUCK_X_LANE;
  66. }
  67. function shortLaneLabel(raw: string | null | undefined): string {
  68. const value = normalizeTruckLane(raw);
  69. const stripped = value
  70. .replace(/^(車線)[-–—]?\s*/, "")
  71. .replace(/^(truck)\s*[-–—]?\s*/i, "")
  72. .trim();
  73. return stripped || value;
  74. }
  75. function normalizeShopCode(raw: string | null | undefined): string {
  76. return String(raw ?? "").trim();
  77. }
  78. function shopGroup(raw: string | null | undefined): string {
  79. const code = normalizeShopCode(raw).toUpperCase();
  80. if (!code) return "";
  81. return code.slice(0, 2);
  82. }
  83. function digitsWithoutLeadingZeros(digits: string): string {
  84. const stripped = digits.replace(/^0+/, "");
  85. return stripped || (digits ? "0" : "");
  86. }
  87. /** Shop names often already start with the code, sometimes zero-padded (`HP23` vs `HP023`). */
  88. function shopNameWithoutCode(code: string, name: string | null | undefined): string {
  89. const shopName = String(name ?? "").trim();
  90. if (!shopName) return "";
  91. const normalized = normalizeShopCode(code).toUpperCase();
  92. const codeParts = normalized.match(/^([A-Z]+)(\d*)$/);
  93. const nameParts = shopName.match(/^([A-Za-z]+)(\d*)(?:\s*[-–—]\s*|\s+)([\s\S]*)$/);
  94. if (codeParts && nameParts) {
  95. const sameLetters = nameParts[1].toUpperCase() === codeParts[1];
  96. const sameDigits =
  97. !nameParts[2] ||
  98. digitsWithoutLeadingZeros(nameParts[2]) === digitsWithoutLeadingZeros(codeParts[2]);
  99. if (sameLetters && sameDigits) return nameParts[3].trim();
  100. }
  101. if (normalized && shopName.toUpperCase().startsWith(normalized)) {
  102. return shopName.slice(normalized.length).replace(/^[\s\-–—]+/, "").trim();
  103. }
  104. return shopName;
  105. }
  106. function formatShopLabel(code: string, name: string | null | undefined): string {
  107. const normalized = normalizeShopCode(code).toUpperCase();
  108. const displayName = shopNameWithoutCode(normalized, name);
  109. if (!normalized) return displayName;
  110. if (!displayName || displayName.toUpperCase() === normalized) return normalized;
  111. return `${normalized} ${displayName}`;
  112. }
  113. function applyProjectExcelFont(worksheet: XLSX.WorkSheet) {
  114. if (!worksheet["!ref"]) return;
  115. const range = XLSX.utils.decode_range(worksheet["!ref"]);
  116. for (let r = range.s.r; r <= range.e.r; r += 1) {
  117. for (let c = range.s.c; c <= range.e.c; c += 1) {
  118. const addr = XLSX.utils.encode_cell({ r, c });
  119. const cell = worksheet[addr];
  120. if (!cell) continue;
  121. const style = (cell.s ?? {}) as XLSX.CellStyle;
  122. const font = style.font ?? {};
  123. cell.s = {
  124. ...style,
  125. font: {
  126. ...font,
  127. name: EXCEL_FONT_NAME,
  128. sz: font.sz ?? 11,
  129. },
  130. };
  131. }
  132. }
  133. }
  134. function buildShopQtyRows(source: CompletedDoPickOrderResponse[]): ShopQtyRow[] {
  135. const grouped = new Map<string, ShopQtyRow>();
  136. source.forEach((record) => {
  137. const code = normalizeShopCode(record.shopCode).toUpperCase();
  138. if (!code) return;
  139. const name = shopNameWithoutCode(code, record.shopName);
  140. const current = grouped.get(code) ?? { code, name, qty: 0 };
  141. if (!current.name && name) current.name = name;
  142. current.qty += Number(record.numberOfCartons ?? 0);
  143. grouped.set(code, current);
  144. });
  145. return Array.from(grouped.values()).sort(
  146. (a, b) => b.qty - a.qty || a.code.localeCompare(b.code, "zh-Hant"),
  147. );
  148. }
  149. function isShopGroupValue(shop: string): boolean {
  150. if (shop === ALL) return false;
  151. const code = normalizeShopCode(shop).toUpperCase();
  152. return code.length > 0 && code === shopGroup(code);
  153. }
  154. function recordMatchesShop(
  155. recordShop: string | null | undefined,
  156. shop: string,
  157. ): boolean {
  158. if (shop === ALL) return true;
  159. const code = normalizeShopCode(recordShop).toUpperCase();
  160. if (!code) return false;
  161. const selected = normalizeShopCode(shop).toUpperCase();
  162. if (isShopGroupValue(selected)) {
  163. return shopGroup(code) === selected;
  164. }
  165. return code === selected;
  166. }
  167. function recordMatchesFilters(
  168. record: CompletedDoPickOrderResponse,
  169. floor: FloorFilter,
  170. lane: string,
  171. shop: string,
  172. ): boolean {
  173. if (floor !== ALL && record.storeId !== floor) return false;
  174. if (lane !== ALL && normalizeTruckLane(record.truckLanceCode) !== lane) return false;
  175. if (!recordMatchesShop(record.shopCode, shop)) return false;
  176. return true;
  177. }
  178. function resolveBreakdownDimension(lane: string, shop: string): BreakdownDimension {
  179. if (shop !== ALL) return "shop";
  180. if (lane !== ALL) return "shop";
  181. return "lane";
  182. }
  183. type SearchableFilterProps = {
  184. label: string;
  185. options: FilterOption[];
  186. value: string;
  187. onChange: (next: string) => void;
  188. };
  189. function SearchableFilterSelect({ label, options, value, onChange }: SearchableFilterProps) {
  190. const selectable = options.filter((option) => option.value !== ALL);
  191. const selected = selectable.find((option) => option.value === value) ?? null;
  192. const placeholder = options.find((option) => option.value === ALL)?.label ?? "";
  193. return (
  194. <Autocomplete
  195. options={selectable}
  196. value={selected}
  197. onChange={(_, option) => onChange(option?.value ?? ALL)}
  198. getOptionLabel={(option) => option?.label ?? ""}
  199. isOptionEqualToValue={(a, b) => a?.value === b?.value}
  200. selectOnFocus
  201. autoHighlight
  202. handleHomeEndKeys
  203. autoComplete
  204. includeInputInList
  205. size="small"
  206. sx={{ width: "100%" }}
  207. renderInput={(params) => (
  208. <TextField {...params} label={label} placeholder={placeholder} />
  209. )}
  210. />
  211. );
  212. }
  213. /** FP-MTMS Version Checklist | Functions Ref. No. 84 | v1.0.0 | 2026-09-22 */
  214. const FinishedGoodCartonDashboardTab: React.FC<Props> = ({ mode = "normal" }) => {
  215. const { t, i18n } = useTranslation();
  216. const numberLocale = i18n.language?.startsWith("zh") ? "zh-HK" : "en-US";
  217. const [floor, setFloor] = useState<FloorFilter>(ALL);
  218. const [lane, setLane] = useState<string>(ALL);
  219. const [shop, setShop] = useState<string>(ALL);
  220. const [date, setDate] = useState<string>(dayjs().format("YYYY-MM-DD"));
  221. const [loading, setLoading] = useState(false);
  222. const [isExporting, setIsExporting] = useState(false);
  223. const [isExportingFiltered, setIsExportingFiltered] = useState(false);
  224. const exportInFlightRef = useRef(false);
  225. const filteredExportInFlightRef = useRef(false);
  226. const [error, setError] = useState<string>("");
  227. const [records, setRecords] = useState<CompletedDoPickOrderResponse[]>([]);
  228. const todayDate = dayjs().format("YYYY-MM-DD");
  229. const filtersAreActive =
  230. floor !== ALL || lane !== ALL || shop !== ALL || date !== todayDate;
  231. const resetFilters = useCallback(() => {
  232. setFloor(ALL);
  233. setLane(ALL);
  234. setShop(ALL);
  235. setDate(dayjs().format("YYYY-MM-DD"));
  236. }, []);
  237. const loadData = useCallback(async () => {
  238. setLoading(true);
  239. setError("");
  240. try {
  241. const data =
  242. mode === "workbench"
  243. ? await fetchCompletedDoPickOrdersWorkbenchAll(
  244. date ? { targetDate: date } : undefined,
  245. )
  246. : await fetchCompletedDoPickOrdersAll(
  247. date ? { targetDate: date } : undefined,
  248. );
  249. setRecords(data);
  250. } catch (err) {
  251. console.error("Failed to load finished good carton dashboard data", err);
  252. setError(t("Failed to load FG carton quantity. Please try again later."));
  253. setRecords([]);
  254. } finally {
  255. setLoading(false);
  256. }
  257. }, [date, mode, t]);
  258. useEffect(() => {
  259. loadData();
  260. }, [loadData]);
  261. const laneOptions = useMemo(() => {
  262. const byRaw = new Map<string, string>();
  263. records.forEach((record) => {
  264. if (floor !== ALL && record.storeId !== floor) return;
  265. if (!recordMatchesShop(record.shopCode, shop)) return;
  266. const raw = normalizeTruckLane(record.truckLanceCode);
  267. if (!byRaw.has(raw)) byRaw.set(raw, shortLaneLabel(raw));
  268. });
  269. return Array.from(byRaw.entries())
  270. .map(([value, label]) => ({ value, label }))
  271. .sort((a, b) => {
  272. if (a.value === TRUCK_X_LANE) return 1;
  273. if (b.value === TRUCK_X_LANE) return -1;
  274. return a.label.localeCompare(b.label, "zh-Hant");
  275. });
  276. }, [records, floor, shop]);
  277. const shopOptions = useMemo(() => {
  278. const groups = new Set<string>();
  279. records.forEach((record) => {
  280. if (floor !== ALL && record.storeId !== floor) return;
  281. if (lane !== ALL && normalizeTruckLane(record.truckLanceCode) !== lane) return;
  282. const group = shopGroup(record.shopCode);
  283. if (group) groups.add(group);
  284. });
  285. return Array.from(groups)
  286. .sort((a, b) => a.localeCompare(b, "zh-Hant"))
  287. .map((group) => ({ value: group, label: group }));
  288. }, [records, floor, lane]);
  289. const laneFilterOptions = useMemo<FilterOption[]>(() => {
  290. const options: FilterOption[] = [{ value: ALL, label: t("All lanes") }, ...laneOptions];
  291. if (lane !== ALL && !options.some((option) => option.value === lane)) {
  292. options.splice(1, 0, { value: lane, label: shortLaneLabel(lane) });
  293. }
  294. return options;
  295. }, [laneOptions, lane, t]);
  296. const shopFilterValue = shop === ALL ? ALL : shopGroup(shop);
  297. const shopFilterOptions = useMemo<FilterOption[]>(() => {
  298. const options: FilterOption[] = [{ value: ALL, label: t("All shops") }, ...shopOptions];
  299. if (
  300. shopFilterValue !== ALL &&
  301. !options.some((option) => option.value === shopFilterValue)
  302. ) {
  303. options.splice(1, 0, { value: shopFilterValue, label: shopFilterValue });
  304. }
  305. return options;
  306. }, [shopOptions, shopFilterValue, t]);
  307. const filteredRecords = useMemo(
  308. () => records.filter((record) => recordMatchesFilters(record, floor, lane, shop)),
  309. [records, floor, lane, shop],
  310. );
  311. const rows = useMemo<DailySummaryRow[]>(() => {
  312. const summary = new Map<string, DailySummaryRow>();
  313. filteredRecords.forEach((record) => {
  314. const day = dayjs(record.deliveryDate).isValid()
  315. ? dayjs(record.deliveryDate).format("YYYY-MM-DD")
  316. : "-";
  317. const cartonQty = Number(record.numberOfCartons ?? 0);
  318. const current = summary.get(day) ?? {
  319. date: day,
  320. floor2F: 0,
  321. floor4F: 0,
  322. truckX: 0,
  323. total: 0,
  324. };
  325. if (record.storeId === "2/F") {
  326. current.floor2F += cartonQty;
  327. }
  328. if (record.storeId === "4/F") {
  329. current.floor4F += cartonQty;
  330. }
  331. if (normalizeTruckLane(record.truckLanceCode) === TRUCK_X_LANE) {
  332. current.truckX += cartonQty;
  333. }
  334. current.total += cartonQty;
  335. summary.set(day, current);
  336. });
  337. return Array.from(summary.values()).sort((a, b) => b.date.localeCompare(a.date));
  338. }, [filteredRecords]);
  339. const breakdownDimension = resolveBreakdownDimension(lane, shop);
  340. const breakdownRows = useMemo<BreakdownRow[]>(() => {
  341. const toSorted = (slices: BreakdownRow[]) =>
  342. slices.sort((a, b) => b.qty - a.qty || a.label.localeCompare(b.label, "zh-Hant"));
  343. if (breakdownDimension === "floor") {
  344. let floor2F = 0;
  345. let floor4F = 0;
  346. let truckX = 0;
  347. filteredRecords.forEach((record) => {
  348. const cartonQty = Number(record.numberOfCartons ?? 0);
  349. if (record.storeId === "2/F") floor2F += cartonQty;
  350. if (record.storeId === "4/F") floor4F += cartonQty;
  351. if (normalizeTruckLane(record.truckLanceCode) === TRUCK_X_LANE) truckX += cartonQty;
  352. });
  353. return toSorted(
  354. [
  355. { key: "2/F", label: "2/F", qty: floor2F },
  356. { key: "4/F", label: "4/F", qty: floor4F },
  357. { key: TRUCK_X_LANE, label: shortLaneLabel(TRUCK_X_LANE), qty: truckX },
  358. ].filter((row) => {
  359. if (row.qty > 0) return true;
  360. if (row.key === "2/F" || row.key === "4/F") return floor === row.key;
  361. return lane === TRUCK_X_LANE && row.key === TRUCK_X_LANE;
  362. }),
  363. );
  364. }
  365. const grouped = new Map<string, BreakdownRow>();
  366. filteredRecords.forEach((record) => {
  367. const cartonQty = Number(record.numberOfCartons ?? 0);
  368. if (breakdownDimension === "lane") {
  369. const key = normalizeTruckLane(record.truckLanceCode);
  370. const current = grouped.get(key) ?? {
  371. key,
  372. label: shortLaneLabel(key),
  373. qty: 0,
  374. };
  375. current.qty += cartonQty;
  376. grouped.set(key, current);
  377. return;
  378. }
  379. const code = normalizeShopCode(record.shopCode).toUpperCase();
  380. const name = String(record.shopName ?? "").trim();
  381. const key = shop !== ALL ? code : shopGroup(record.shopCode);
  382. if (!key) return;
  383. const label = shop !== ALL ? formatShopLabel(code, name) : key;
  384. const current = grouped.get(key) ?? { key, label, qty: 0 };
  385. if (shop !== ALL && name) current.label = formatShopLabel(code, name);
  386. current.qty += cartonQty;
  387. grouped.set(key, current);
  388. });
  389. return toSorted(
  390. Array.from(grouped.values()).filter((row) => {
  391. if (row.qty > 0) return true;
  392. if (breakdownDimension === "shop") return shop !== ALL && row.key === shop;
  393. return lane !== ALL && row.key === lane;
  394. }),
  395. );
  396. }, [breakdownDimension, filteredRecords, floor, lane, shop]);
  397. const breakdownCaption =
  398. breakdownDimension === "shop"
  399. ? shop !== ALL
  400. ? t("Cartons by shop")
  401. : t("Cartons by shop group")
  402. : breakdownDimension === "floor"
  403. ? t("Cartons by floor")
  404. : t("Cartons by lane");
  405. const applyBreakdownClick = useCallback(
  406. (row: BreakdownRow) => {
  407. if (breakdownDimension === "lane") {
  408. setLane((prev) => (prev === row.key ? ALL : row.key));
  409. return;
  410. }
  411. if (breakdownDimension === "shop") {
  412. setShop((prev) => {
  413. if (prev === row.key) {
  414. const group = shopGroup(row.key);
  415. return group && group !== row.key ? group : ALL;
  416. }
  417. return row.key;
  418. });
  419. }
  420. },
  421. [breakdownDimension],
  422. );
  423. const viewingSpecificShop = shop !== ALL && !isShopGroupValue(shop);
  424. const chartClicksEnabled = !viewingSpecificShop;
  425. const chartCanGoBack = lane !== ALL || shop !== ALL;
  426. const goBackChartLevel = useCallback(() => {
  427. if (shop !== ALL && !isShopGroupValue(shop)) {
  428. setShop(shopGroup(shop) || ALL);
  429. return;
  430. }
  431. if (shop !== ALL) {
  432. setShop(ALL);
  433. return;
  434. }
  435. if (lane !== ALL) setLane(ALL);
  436. }, [lane, shop]);
  437. const isBreakdownRowActive = useCallback(
  438. (row: BreakdownRow) => {
  439. if (breakdownDimension === "lane") return lane === row.key;
  440. if (breakdownDimension === "shop") return shop === row.key;
  441. if (row.key === "2/F" || row.key === "4/F") return floor === row.key;
  442. return lane === TRUCK_X_LANE && row.key === TRUCK_X_LANE;
  443. },
  444. [breakdownDimension, floor, lane, shop],
  445. );
  446. const summary = useMemo(() => {
  447. return rows.reduce(
  448. (acc, row) => {
  449. acc.floor2F += row.floor2F;
  450. acc.floor4F += row.floor4F;
  451. acc.truckX += row.truckX;
  452. acc.total += row.total;
  453. return acc;
  454. },
  455. { floor2F: 0, floor4F: 0, truckX: 0, total: 0 },
  456. );
  457. }, [rows]);
  458. const chartMaxQty = Math.max(1, ...breakdownRows.map((row) => row.qty));
  459. const buildDailyRowsFromRecords = useCallback(
  460. (
  461. sourceRecords: CompletedDoPickOrderResponse[],
  462. startDate: dayjs.Dayjs,
  463. endDate: dayjs.Dayjs,
  464. selectedFloor: FloorFilter,
  465. selectedLane: string,
  466. selectedShop: string,
  467. ): ShopDailyRow[] => {
  468. const summaryMap = new Map<string, ShopDailyRow>();
  469. const start = startDate.startOf("day");
  470. const end = endDate.endOf("day");
  471. sourceRecords.forEach((record) => {
  472. if (!recordMatchesFilters(record, selectedFloor, selectedLane, selectedShop)) {
  473. return;
  474. }
  475. const deliveryDay = dayjs(record.deliveryDate, ["YYYY-MM-DD", "YYYYMMDD"], true);
  476. if (!deliveryDay.isValid() || deliveryDay.isBefore(start) || deliveryDay.isAfter(end)) {
  477. return;
  478. }
  479. const dayKey = deliveryDay.format("YYYY-MM-DD");
  480. const shopCode = normalizeShopCode(record.shopCode).toUpperCase() || "-";
  481. const shopName = shopNameWithoutCode(shopCode, record.shopName);
  482. const mapKey = `${dayKey}|${shopCode}`;
  483. const cartonQty = Number(record.numberOfCartons ?? 0);
  484. const current = summaryMap.get(mapKey) ?? {
  485. date: dayKey,
  486. shopCode,
  487. shopName,
  488. floor2F: 0,
  489. floor4F: 0,
  490. truckX: 0,
  491. total: 0,
  492. };
  493. if (!current.shopName && shopName) current.shopName = shopName;
  494. if (record.storeId === "2/F") current.floor2F += cartonQty;
  495. if (record.storeId === "4/F") current.floor4F += cartonQty;
  496. if (normalizeTruckLane(record.truckLanceCode) === TRUCK_X_LANE) current.truckX += cartonQty;
  497. current.total += cartonQty;
  498. summaryMap.set(mapKey, current);
  499. });
  500. return Array.from(summaryMap.values()).sort(
  501. (a, b) =>
  502. a.date.localeCompare(b.date) || a.shopCode.localeCompare(b.shopCode, "zh-Hant"),
  503. );
  504. },
  505. [],
  506. );
  507. const calcSummary = useCallback((dailyRows: DailySummaryRow[]) => {
  508. return dailyRows.reduce(
  509. (acc, row) => {
  510. acc.floor2F += row.floor2F;
  511. acc.floor4F += row.floor4F;
  512. acc.truckX += row.truckX;
  513. acc.total += row.total;
  514. return acc;
  515. },
  516. { floor2F: 0, floor4F: 0, truckX: 0, total: 0 },
  517. );
  518. }, []);
  519. const styleWorksheet = useCallback((worksheet: XLSX.WorkSheet, dataRowsCount: number) => {
  520. const summaryTitleRow = 4 + dataRowsCount;
  521. const summaryStartRow = 5 + dataRowsCount;
  522. worksheet["!cols"] = [
  523. { wch: 14 },
  524. { wch: 14 },
  525. { wch: 28 },
  526. { wch: 16 },
  527. { wch: 16 },
  528. { wch: 18 },
  529. { wch: 14 },
  530. ];
  531. worksheet["!merges"] = [
  532. { s: { r: 0, c: 0 }, e: { r: 0, c: DAILY_SHEET_LAST_COL } },
  533. { s: { r: summaryTitleRow, c: 0 }, e: { r: summaryTitleRow, c: DAILY_SHEET_LAST_COL } },
  534. ];
  535. const titleStyle = {
  536. font: { bold: true, sz: 14, color: { rgb: "1F2D3D" } },
  537. alignment: { horizontal: "center", vertical: "center" },
  538. fill: { fgColor: { rgb: "EAF3FF" } },
  539. };
  540. const headerStyle = {
  541. font: { bold: true, color: { rgb: "FFFFFF" } },
  542. fill: { fgColor: { rgb: "1976D2" } },
  543. alignment: { horizontal: "center", vertical: "center" },
  544. border: {
  545. top: { style: "thin", color: { rgb: "B0BEC5" } },
  546. bottom: { style: "thin", color: { rgb: "B0BEC5" } },
  547. left: { style: "thin", color: { rgb: "B0BEC5" } },
  548. right: { style: "thin", color: { rgb: "B0BEC5" } },
  549. },
  550. };
  551. const cellStyle = {
  552. alignment: { vertical: "center" },
  553. border: {
  554. top: { style: "thin", color: { rgb: "D0D7DE" } },
  555. bottom: { style: "thin", color: { rgb: "D0D7DE" } },
  556. left: { style: "thin", color: { rgb: "D0D7DE" } },
  557. right: { style: "thin", color: { rgb: "D0D7DE" } },
  558. },
  559. };
  560. const numberStyle = {
  561. ...cellStyle,
  562. alignment: { horizontal: "right", vertical: "center" },
  563. numFmt: "#,##0",
  564. };
  565. const summaryTitleStyle = {
  566. font: { bold: true, color: { rgb: "1F2D3D" } },
  567. fill: { fgColor: { rgb: "F1F8E9" } },
  568. alignment: { horizontal: "left", vertical: "center" },
  569. };
  570. for (let c = 0; c <= DAILY_SHEET_LAST_COL; c += 1) {
  571. const headerCell = XLSX.utils.encode_cell({ r: 2, c });
  572. if (worksheet[headerCell]) worksheet[headerCell].s = headerStyle;
  573. }
  574. for (let r = 3; r < 3 + dataRowsCount; r += 1) {
  575. for (let c = 0; c <= DAILY_SHEET_LAST_COL; c += 1) {
  576. const addr = XLSX.utils.encode_cell({ r, c });
  577. if (!worksheet[addr]) continue;
  578. worksheet[addr].s = c < 3 ? cellStyle : numberStyle;
  579. }
  580. }
  581. for (let r = summaryStartRow; r <= summaryStartRow + 3; r += 1) {
  582. for (let c = 0; c <= DAILY_SHEET_LAST_COL; c += 1) {
  583. const addr = XLSX.utils.encode_cell({ r, c });
  584. const cell = worksheet[addr];
  585. if (!cell) continue;
  586. cell.s = typeof cell.v === "number" ? numberStyle : cellStyle;
  587. }
  588. }
  589. if (worksheet["A1"]) worksheet["A1"].s = titleStyle;
  590. const summaryTitleAddr = XLSX.utils.encode_cell({ r: summaryTitleRow, c: 0 });
  591. if (worksheet[summaryTitleAddr]) worksheet[summaryTitleAddr].s = summaryTitleStyle;
  592. applyProjectExcelFont(worksheet);
  593. }, []);
  594. const addReportSheet = useCallback(
  595. (
  596. workbook: XLSX.WorkBook,
  597. sheetName: string,
  598. reportTitle: string,
  599. dailyRows: ShopDailyRow[],
  600. ) => {
  601. const reportSummary = calcSummary(dailyRows);
  602. const blank = ["", "", "", "", "", "", ""];
  603. const aoa: (string | number)[][] = [
  604. [reportTitle, ...blank.slice(1)],
  605. [...blank],
  606. [
  607. t("Date"),
  608. t("Shop code"),
  609. t("Shop Name"),
  610. t("2/F carton qty"),
  611. t("4/F carton qty"),
  612. t("Truck X carton qty"),
  613. t("Total carton qty"),
  614. ],
  615. ...dailyRows.map((row) => [
  616. row.date,
  617. row.shopCode,
  618. row.shopName,
  619. row.floor2F,
  620. row.floor4F,
  621. row.truckX,
  622. row.total,
  623. ]),
  624. [...blank],
  625. [t("Summary"), ...blank.slice(1)],
  626. [t("2/F carton qty"), "", "", reportSummary.floor2F, "", "", ""],
  627. [t("4/F carton qty"), "", "", "", reportSummary.floor4F, "", ""],
  628. [t("Truck X carton qty"), "", "", "", "", reportSummary.truckX, ""],
  629. [t("Total carton qty"), "", "", "", "", "", reportSummary.total],
  630. ];
  631. const worksheet = XLSX.utils.aoa_to_sheet(aoa);
  632. styleWorksheet(worksheet, dailyRows.length);
  633. XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
  634. },
  635. [calcSummary, styleWorksheet, t],
  636. );
  637. const addBreakdownSheet = useCallback(
  638. (
  639. workbook: XLSX.WorkBook,
  640. sheetName: string,
  641. reportTitle: string,
  642. shops: ShopQtyRow[],
  643. totalQty: number,
  644. ) => {
  645. const aoa: (string | number)[][] = [
  646. [reportTitle, "", "", ""],
  647. ["", "", "", ""],
  648. [t("Shop code"), t("Shop Name"), t("Cartons"), t("Share")],
  649. ...shops.map((row) => {
  650. const share = totalQty > 0 ? (row.qty / totalQty) * 100 : 0;
  651. return [row.code, row.name, row.qty, `${share.toFixed(1)}%`];
  652. }),
  653. ["", "", "", ""],
  654. [t("Total carton qty"), "", totalQty, ""],
  655. ];
  656. const worksheet = XLSX.utils.aoa_to_sheet(aoa);
  657. worksheet["!cols"] = [{ wch: 14 }, { wch: 28 }, { wch: 14 }, { wch: 12 }];
  658. worksheet["!merges"] = [{ s: { r: 0, c: 0 }, e: { r: 0, c: 3 } }];
  659. const titleStyle = {
  660. font: { bold: true, sz: 14, color: { rgb: "1F2D3D" } },
  661. alignment: { horizontal: "center", vertical: "center" },
  662. fill: { fgColor: { rgb: "EAF3FF" } },
  663. };
  664. const headerStyle = {
  665. font: { bold: true, color: { rgb: "FFFFFF" } },
  666. fill: { fgColor: { rgb: "1976D2" } },
  667. alignment: { horizontal: "center", vertical: "center" },
  668. };
  669. const cellStyle = {
  670. alignment: { vertical: "center" },
  671. border: {
  672. top: { style: "thin", color: { rgb: "D0D7DE" } },
  673. bottom: { style: "thin", color: { rgb: "D0D7DE" } },
  674. left: { style: "thin", color: { rgb: "D0D7DE" } },
  675. right: { style: "thin", color: { rgb: "D0D7DE" } },
  676. },
  677. };
  678. const numberStyle = {
  679. ...cellStyle,
  680. alignment: { horizontal: "right", vertical: "center" },
  681. numFmt: "#,##0",
  682. };
  683. if (worksheet["A1"]) worksheet["A1"].s = titleStyle;
  684. for (let c = 0; c <= 3; c += 1) {
  685. const headerCell = XLSX.utils.encode_cell({ r: 2, c });
  686. if (worksheet[headerCell]) worksheet[headerCell].s = headerStyle;
  687. }
  688. shops.forEach((_, index) => {
  689. const r = 3 + index;
  690. const codeAddr = XLSX.utils.encode_cell({ r, c: 0 });
  691. const nameAddr = XLSX.utils.encode_cell({ r, c: 1 });
  692. const qtyAddr = XLSX.utils.encode_cell({ r, c: 2 });
  693. const shareAddr = XLSX.utils.encode_cell({ r, c: 3 });
  694. if (worksheet[codeAddr]) worksheet[codeAddr].s = cellStyle;
  695. if (worksheet[nameAddr]) worksheet[nameAddr].s = cellStyle;
  696. if (worksheet[qtyAddr]) worksheet[qtyAddr].s = numberStyle;
  697. if (worksheet[shareAddr]) worksheet[shareAddr].s = cellStyle;
  698. });
  699. const totalRow = 4 + shops.length;
  700. const totalLabelAddr = XLSX.utils.encode_cell({ r: totalRow, c: 0 });
  701. const totalValueAddr = XLSX.utils.encode_cell({ r: totalRow, c: 2 });
  702. if (worksheet[totalLabelAddr]) worksheet[totalLabelAddr].s = cellStyle;
  703. if (worksheet[totalValueAddr]) worksheet[totalValueAddr].s = numberStyle;
  704. applyProjectExcelFont(worksheet);
  705. XLSX.utils.book_append_sheet(workbook, worksheet, sheetName.slice(0, 31));
  706. },
  707. [t],
  708. );
  709. /** FP-MTMS Version Checklist | Functions Ref. No. 86 | v1.0.0 | 2026-09-22 */
  710. const handleDownloadExcel = useCallback(async () => {
  711. if (exportInFlightRef.current || filteredExportInFlightRef.current) return;
  712. exportInFlightRef.current = true;
  713. setIsExporting(true);
  714. try {
  715. const allRecords =
  716. mode === "workbench"
  717. ? await fetchCompletedDoPickOrdersWorkbenchAll()
  718. : await fetchCompletedDoPickOrdersAll();
  719. const baseDate = dayjs(date || undefined).isValid() ? dayjs(date) : dayjs();
  720. const floorLabel = floor === ALL ? t("All floors") : floor;
  721. const laneLabel = lane === ALL ? t("All lanes") : lane;
  722. const shopLabel = shop === ALL ? t("All shops") : shop;
  723. const dateLabel = baseDate.format("YYYY-MM-DD");
  724. const monthPeriod = i18n.language?.startsWith("zh")
  725. ? baseDate.format("YYYY年MM月")
  726. : baseDate.format("YYYY-MM");
  727. const yearPeriod = i18n.language?.startsWith("zh")
  728. ? baseDate.format("YYYY年")
  729. : baseDate.format("YYYY");
  730. const last7Rows = buildDailyRowsFromRecords(
  731. allRecords,
  732. baseDate.subtract(6, "day"),
  733. baseDate,
  734. floor,
  735. lane,
  736. shop,
  737. );
  738. const monthRows = buildDailyRowsFromRecords(
  739. allRecords,
  740. baseDate.startOf("month"),
  741. baseDate.endOf("month"),
  742. floor,
  743. lane,
  744. shop,
  745. );
  746. const yearRows = buildDailyRowsFromRecords(
  747. allRecords,
  748. baseDate.startOf("year"),
  749. baseDate.endOf("year"),
  750. floor,
  751. lane,
  752. shop,
  753. );
  754. const workbook = XLSX.utils.book_new();
  755. addReportSheet(
  756. workbook,
  757. t("Last 7 days"),
  758. t("FG carton qty last 7 days title", { floor: floorLabel, date: dateLabel }),
  759. last7Rows,
  760. );
  761. addReportSheet(
  762. workbook,
  763. t("This month"),
  764. t("FG carton qty this month title", { floor: floorLabel, period: monthPeriod }),
  765. monthRows,
  766. );
  767. addReportSheet(
  768. workbook,
  769. t("This year"),
  770. t("FG carton qty this year title", { floor: floorLabel, period: yearPeriod }),
  771. yearRows,
  772. );
  773. const fileBits = [
  774. "FG_carton_qty",
  775. floorLabel.replace("/", ""),
  776. lane === ALL ? "" : laneLabel,
  777. shop === ALL ? "" : shopLabel,
  778. dateLabel,
  779. ].filter(Boolean);
  780. XLSX.writeFile(workbook, `${fileBits.join("_")}.xlsx`);
  781. } finally {
  782. setIsExporting(false);
  783. exportInFlightRef.current = false;
  784. }
  785. }, [mode, date, floor, lane, shop, buildDailyRowsFromRecords, addReportSheet, t, i18n.language]);
  786. const handleDownloadFilteredExcel = useCallback(async () => {
  787. if (filteredExportInFlightRef.current || exportInFlightRef.current) return;
  788. filteredExportInFlightRef.current = true;
  789. setIsExportingFiltered(true);
  790. try {
  791. const baseDate = dayjs(date || undefined).isValid() ? dayjs(date) : dayjs();
  792. const floorLabel = floor === ALL ? t("All floors") : floor;
  793. const laneLabel = lane === ALL ? t("All lanes") : shortLaneLabel(lane);
  794. const shopLabel =
  795. shop === ALL ? t("All shops") : isShopGroupValue(shop) ? shop : formatShopLabel(shop, "");
  796. const dateLabel = baseDate.format("YYYY-MM-DD");
  797. const reportTitle = t("FG carton qty filtered title", {
  798. floor: floorLabel,
  799. lane: laneLabel,
  800. shop: shopLabel,
  801. date: dateLabel,
  802. });
  803. const dailyRows = buildDailyRowsFromRecords(
  804. records,
  805. baseDate.startOf("day"),
  806. baseDate.endOf("day"),
  807. floor,
  808. lane,
  809. shop,
  810. );
  811. const filteredTotal = calcSummary(dailyRows).total;
  812. const shopRows = buildShopQtyRows(
  813. records.filter((record) => recordMatchesFilters(record, floor, lane, shop)),
  814. );
  815. const workbook = XLSX.utils.book_new();
  816. addReportSheet(workbook, t("Filtered"), reportTitle, dailyRows);
  817. addBreakdownSheet(
  818. workbook,
  819. t("Breakdown"),
  820. t("Cartons by shop"),
  821. shopRows,
  822. filteredTotal,
  823. );
  824. const fileBits = [
  825. "FG_carton_qty_filtered",
  826. floorLabel.replace("/", ""),
  827. lane === ALL ? "" : laneLabel,
  828. shop === ALL ? "" : shopLabel,
  829. dateLabel,
  830. ].filter(Boolean);
  831. XLSX.writeFile(workbook, `${fileBits.join("_")}.xlsx`);
  832. } finally {
  833. setIsExportingFiltered(false);
  834. filteredExportInFlightRef.current = false;
  835. }
  836. }, [
  837. date,
  838. floor,
  839. lane,
  840. shop,
  841. records,
  842. buildDailyRowsFromRecords,
  843. addReportSheet,
  844. addBreakdownSheet,
  845. calcSummary,
  846. t,
  847. ]);
  848. const isAnyExporting = isExporting || isExportingFiltered;
  849. return (
  850. <Box sx={{ width: "100%" }}>
  851. <Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 2 }}>
  852. <Typography variant="h6">{t("FG Carton Qty")}</Typography>
  853. <Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap>
  854. <Button
  855. variant="outlined"
  856. onClick={resetFilters}
  857. disabled={loading || !filtersAreActive}
  858. >
  859. {t("Reset filters")}
  860. </Button>
  861. <Tooltip title={t("Download this view Excel hint")}>
  862. <span>
  863. <Button
  864. variant="outlined"
  865. onClick={handleDownloadFilteredExcel}
  866. disabled={loading || isAnyExporting}
  867. >
  868. {isExportingFiltered ? t("Exporting...") : t("Download this view Excel")}
  869. </Button>
  870. </span>
  871. </Tooltip>
  872. <Tooltip title={t("Download period Excel hint")}>
  873. <span>
  874. <Button
  875. variant="contained"
  876. onClick={handleDownloadExcel}
  877. disabled={loading || isAnyExporting}
  878. >
  879. {isExporting ? t("Exporting...") : t("Download period Excel")}
  880. </Button>
  881. </span>
  882. </Tooltip>
  883. </Stack>
  884. </Stack>
  885. {error && (
  886. <Alert severity="error" sx={{ mb: 2 }}>
  887. {error}
  888. </Alert>
  889. )}
  890. <Stack spacing={2}>
  891. <Grid container spacing={1.5}>
  892. <Grid item xs={12} sm={6} md={3}>
  893. <TextField
  894. select
  895. fullWidth
  896. size="small"
  897. label={t("Floor")}
  898. value={floor}
  899. onChange={(event) => setFloor(event.target.value as FloorFilter)}
  900. >
  901. <MenuItem value={ALL}>{t("All")}</MenuItem>
  902. <MenuItem value="2/F">2/F</MenuItem>
  903. <MenuItem value="4/F">4/F</MenuItem>
  904. </TextField>
  905. </Grid>
  906. <Grid item xs={12} sm={6} md={3}>
  907. <SearchableFilterSelect
  908. label={t("Lane")}
  909. options={laneFilterOptions}
  910. value={lane}
  911. onChange={setLane}
  912. />
  913. </Grid>
  914. <Grid item xs={12} sm={6} md={3}>
  915. <SearchableFilterSelect
  916. label={t("Shop Code")}
  917. options={shopFilterOptions}
  918. value={shopFilterValue}
  919. onChange={setShop}
  920. />
  921. </Grid>
  922. <Grid item xs={12} sm={6} md={3}>
  923. <LocalizationProvider
  924. dateAdapter={AdapterDayjs}
  925. adapterLocale={i18n.language?.startsWith("zh") ? "zh-hk" : "en"}
  926. >
  927. <DatePicker
  928. label={t("Date")}
  929. format={OUTPUT_DATE_FORMAT}
  930. value={dayjs(date).isValid() ? dayjs(date) : null}
  931. onChange={(newValue) => {
  932. if (newValue && dayjs(newValue).isValid()) {
  933. setDate(dayjs(newValue).format(OUTPUT_DATE_FORMAT));
  934. }
  935. }}
  936. slotProps={{
  937. textField: { size: "small", fullWidth: true },
  938. }}
  939. />
  940. </LocalizationProvider>
  941. </Grid>
  942. </Grid>
  943. {loading ? (
  944. <Box sx={{ py: 6, display: "flex", justifyContent: "center" }}>
  945. <CircularProgress />
  946. </Box>
  947. ) : (
  948. <Stack spacing={1.5}>
  949. <Paper sx={{ px: 1, py: 1 }}>
  950. <Grid container>
  951. {[
  952. { label: t("2/F carton qty"), value: summary.floor2F },
  953. { label: t("4/F carton qty"), value: summary.floor4F },
  954. { label: t("Truck X carton qty"), value: summary.truckX },
  955. { label: t("Total carton qty"), value: summary.total },
  956. ].map((kpi, index) => (
  957. <Grid
  958. item
  959. xs={6}
  960. md={3}
  961. key={kpi.label}
  962. sx={{
  963. px: 1.5,
  964. py: 0.5,
  965. borderRight: { md: index < 3 ? "1px solid" : "none" },
  966. borderBottom: { xs: index < 2 ? "1px solid" : "none", md: "none" },
  967. borderColor: { xs: "divider", md: "divider" },
  968. }}
  969. >
  970. <Typography variant="caption" color="text.secondary">
  971. {kpi.label}
  972. </Typography>
  973. <Typography variant="h6" sx={{ fontVariantNumeric: "tabular-nums", lineHeight: 1.3 }}>
  974. {kpi.value.toLocaleString(numberLocale)}
  975. </Typography>
  976. </Grid>
  977. ))}
  978. </Grid>
  979. </Paper>
  980. <Paper sx={{ p: 1.5 }}>
  981. <Stack
  982. direction="row"
  983. alignItems="center"
  984. justifyContent="space-between"
  985. spacing={1}
  986. sx={{ mb: 1 }}
  987. >
  988. <Box sx={{ minWidth: 0 }}>
  989. <Typography variant="subtitle2" color="text.secondary">
  990. {breakdownCaption}
  991. </Typography>
  992. {chartClicksEnabled && (
  993. <Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>
  994. {t("Click a row to filter")}
  995. </Typography>
  996. )}
  997. </Box>
  998. <Button
  999. size="small"
  1000. variant="outlined"
  1001. startIcon={<ArrowBackIcon />}
  1002. onClick={goBackChartLevel}
  1003. disabled={!chartCanGoBack}
  1004. >
  1005. {t("Back to previous level")}
  1006. </Button>
  1007. </Stack>
  1008. {breakdownRows.length === 0 ? (
  1009. <Typography sx={{ py: 3 }} color="text.secondary">
  1010. {t("No data available")}
  1011. </Typography>
  1012. ) : (
  1013. <Box>
  1014. {breakdownRows.map((row) => {
  1015. const active = isBreakdownRowActive(row);
  1016. const share = summary.total > 0 ? (row.qty / summary.total) * 100 : 0;
  1017. const barWidth =
  1018. row.qty <= 0 ? "0%" : `${(row.qty / chartMaxQty) * 100}%`;
  1019. return (
  1020. <Box
  1021. key={row.key}
  1022. role={chartClicksEnabled ? "button" : undefined}
  1023. tabIndex={chartClicksEnabled ? 0 : undefined}
  1024. onClick={() => {
  1025. if (chartClicksEnabled) applyBreakdownClick(row);
  1026. }}
  1027. onKeyDown={(event) => {
  1028. if (!chartClicksEnabled) return;
  1029. if (event.key === "Enter" || event.key === " ") {
  1030. event.preventDefault();
  1031. applyBreakdownClick(row);
  1032. }
  1033. }}
  1034. title={`${row.label}: ${row.qty.toLocaleString(numberLocale)} (${share.toFixed(1)}%)`}
  1035. sx={{
  1036. height: CHART_ROW_H,
  1037. display: "grid",
  1038. gridTemplateColumns: {
  1039. xs: "132px minmax(0, 1fr) 84px",
  1040. sm: "200px minmax(0, 1fr) 84px",
  1041. md: "280px minmax(0, 1fr) 84px",
  1042. },
  1043. columnGap: 1,
  1044. alignItems: "center",
  1045. minWidth: 0,
  1046. cursor: chartClicksEnabled ? "pointer" : "default",
  1047. borderRadius: 1,
  1048. px: 0.5,
  1049. bgcolor: active ? "action.selected" : "transparent",
  1050. "&:hover": chartClicksEnabled ? { bgcolor: "action.hover" } : undefined,
  1051. }}
  1052. >
  1053. <Typography
  1054. component="span"
  1055. noWrap
  1056. title={row.label}
  1057. sx={{
  1058. minWidth: 0,
  1059. width: "100%",
  1060. fontWeight: 700,
  1061. fontSize: 13,
  1062. lineHeight: `${CHART_ROW_H}px`,
  1063. color: "text.primary",
  1064. textAlign: "left",
  1065. }}
  1066. >
  1067. {row.label}
  1068. </Typography>
  1069. <Box
  1070. sx={{
  1071. height: "100%",
  1072. display: "flex",
  1073. alignItems: "center",
  1074. minWidth: 0,
  1075. }}
  1076. >
  1077. <Box
  1078. sx={{
  1079. height: 22,
  1080. width: barWidth,
  1081. minWidth: row.qty > 0 ? 4 : 0,
  1082. bgcolor: "#1976d2",
  1083. borderRadius: "4px",
  1084. }}
  1085. />
  1086. </Box>
  1087. <Box
  1088. sx={{
  1089. height: 24,
  1090. display: "flex",
  1091. alignItems: "center",
  1092. justifyContent: "center",
  1093. px: 0.75,
  1094. borderRadius: 0.5,
  1095. bgcolor: "#fff",
  1096. border: "1px solid #90a4ae",
  1097. color: "#102a43",
  1098. fontWeight: 800,
  1099. fontSize: 15,
  1100. lineHeight: 1,
  1101. boxShadow: "0 1px 3px rgba(0,0,0,0.22)",
  1102. }}
  1103. >
  1104. {row.qty.toLocaleString(numberLocale)}
  1105. </Box>
  1106. </Box>
  1107. );
  1108. })}
  1109. </Box>
  1110. )}
  1111. </Paper>
  1112. </Stack>
  1113. )}
  1114. </Stack>
  1115. </Box>
  1116. );
  1117. };
  1118. export default FinishedGoodCartonDashboardTab;