FPSMS-frontend
Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.
 
 

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