|
- "use client";
-
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
- import {
- Alert,
- Autocomplete,
- Box,
- Button,
- CircularProgress,
- Grid,
- MenuItem,
- Paper,
- Stack,
- TextField,
- Tooltip,
- Typography,
- } from "@mui/material";
- import ArrowBackIcon from "@mui/icons-material/ArrowBack";
- import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers";
- import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
- import dayjs from "dayjs";
- import "dayjs/locale/zh-hk";
- import * as XLSX from "xlsx-js-style";
- import {
- CompletedDoPickOrderResponse,
- fetchCompletedDoPickOrdersAll,
- fetchCompletedDoPickOrdersWorkbenchAll,
- } from "@/app/api/pickOrder/actions";
- import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil";
- import { useTranslation } from "react-i18next";
-
- type FloorFilter = "all" | "2/F" | "4/F";
- type BreakdownDimension = "lane" | "shop" | "floor";
-
- type DailySummaryRow = {
- date: string;
- floor2F: number;
- floor4F: number;
- truckX: number;
- total: number;
- };
-
- type ShopDailyRow = DailySummaryRow & {
- shopCode: string;
- shopName: string;
- };
-
- type ShopQtyRow = {
- code: string;
- name: string;
- qty: number;
- };
-
- type BreakdownRow = {
- key: string;
- label: string;
- qty: number;
- };
-
- type Props = {
- mode?: "normal" | "workbench";
- };
-
- const TRUCK_X_LANE = "車線-X";
- const ALL = "all";
- const CHART_ROW_H = 38;
- /** Jasper / report standard used across FPSMS Excel output. */
- const EXCEL_FONT_NAME = "微軟正黑體";
- const DAILY_SHEET_LAST_COL = 6;
-
- type FilterOption = { value: string; label: string };
-
- function normalizeTruckLane(raw: string | null | undefined): string {
- const value = String(raw ?? "").trim();
- return value || TRUCK_X_LANE;
- }
-
- function shortLaneLabel(raw: string | null | undefined): string {
- const value = normalizeTruckLane(raw);
- const stripped = value
- .replace(/^(車線)[-–—]?\s*/, "")
- .replace(/^(truck)\s*[-–—]?\s*/i, "")
- .trim();
- return stripped || value;
- }
-
- function normalizeShopCode(raw: string | null | undefined): string {
- return String(raw ?? "").trim();
- }
-
- function shopGroup(raw: string | null | undefined): string {
- const code = normalizeShopCode(raw).toUpperCase();
- if (!code) return "";
- return code.slice(0, 2);
- }
-
- function digitsWithoutLeadingZeros(digits: string): string {
- const stripped = digits.replace(/^0+/, "");
- return stripped || (digits ? "0" : "");
- }
-
- /** Shop names often already start with the code, sometimes zero-padded (`HP23` vs `HP023`). */
- function shopNameWithoutCode(code: string, name: string | null | undefined): string {
- const shopName = String(name ?? "").trim();
- if (!shopName) return "";
- const normalized = normalizeShopCode(code).toUpperCase();
- const codeParts = normalized.match(/^([A-Z]+)(\d*)$/);
- const nameParts = shopName.match(/^([A-Za-z]+)(\d*)(?:\s*[-–—]\s*|\s+)([\s\S]*)$/);
- if (codeParts && nameParts) {
- const sameLetters = nameParts[1].toUpperCase() === codeParts[1];
- const sameDigits =
- !nameParts[2] ||
- digitsWithoutLeadingZeros(nameParts[2]) === digitsWithoutLeadingZeros(codeParts[2]);
- if (sameLetters && sameDigits) return nameParts[3].trim();
- }
- if (normalized && shopName.toUpperCase().startsWith(normalized)) {
- return shopName.slice(normalized.length).replace(/^[\s\-–—]+/, "").trim();
- }
- return shopName;
- }
-
- function formatShopLabel(code: string, name: string | null | undefined): string {
- const normalized = normalizeShopCode(code).toUpperCase();
- const displayName = shopNameWithoutCode(normalized, name);
- if (!normalized) return displayName;
- if (!displayName || displayName.toUpperCase() === normalized) return normalized;
- return `${normalized} ${displayName}`;
- }
-
- function applyProjectExcelFont(worksheet: XLSX.WorkSheet) {
- if (!worksheet["!ref"]) return;
- const range = XLSX.utils.decode_range(worksheet["!ref"]);
- for (let r = range.s.r; r <= range.e.r; r += 1) {
- for (let c = range.s.c; c <= range.e.c; c += 1) {
- const addr = XLSX.utils.encode_cell({ r, c });
- const cell = worksheet[addr];
- if (!cell) continue;
- const style = (cell.s ?? {}) as XLSX.CellStyle;
- const font = style.font ?? {};
- cell.s = {
- ...style,
- font: {
- ...font,
- name: EXCEL_FONT_NAME,
- sz: font.sz ?? 11,
- },
- };
- }
- }
- }
-
- function buildShopQtyRows(source: CompletedDoPickOrderResponse[]): ShopQtyRow[] {
- const grouped = new Map<string, ShopQtyRow>();
- source.forEach((record) => {
- const code = normalizeShopCode(record.shopCode).toUpperCase();
- if (!code) return;
- const name = shopNameWithoutCode(code, record.shopName);
- const current = grouped.get(code) ?? { code, name, qty: 0 };
- if (!current.name && name) current.name = name;
- current.qty += Number(record.numberOfCartons ?? 0);
- grouped.set(code, current);
- });
- return Array.from(grouped.values()).sort(
- (a, b) => b.qty - a.qty || a.code.localeCompare(b.code, "zh-Hant"),
- );
- }
-
- function isShopGroupValue(shop: string): boolean {
- if (shop === ALL) return false;
- const code = normalizeShopCode(shop).toUpperCase();
- return code.length > 0 && code === shopGroup(code);
- }
-
- function recordMatchesShop(
- recordShop: string | null | undefined,
- shop: string,
- ): boolean {
- if (shop === ALL) return true;
- const code = normalizeShopCode(recordShop).toUpperCase();
- if (!code) return false;
- const selected = normalizeShopCode(shop).toUpperCase();
- if (isShopGroupValue(selected)) {
- return shopGroup(code) === selected;
- }
- return code === selected;
- }
-
- function recordMatchesFilters(
- record: CompletedDoPickOrderResponse,
- floor: FloorFilter,
- lane: string,
- shop: string,
- ): boolean {
- if (floor !== ALL && record.storeId !== floor) return false;
- if (lane !== ALL && normalizeTruckLane(record.truckLanceCode) !== lane) return false;
- if (!recordMatchesShop(record.shopCode, shop)) return false;
- return true;
- }
-
- function resolveBreakdownDimension(lane: string, shop: string): BreakdownDimension {
- if (shop !== ALL) return "shop";
- if (lane !== ALL) return "shop";
- return "lane";
- }
-
- type SearchableFilterProps = {
- label: string;
- options: FilterOption[];
- value: string;
- onChange: (next: string) => void;
- };
-
- function SearchableFilterSelect({ label, options, value, onChange }: SearchableFilterProps) {
- const selectable = options.filter((option) => option.value !== ALL);
- const selected = selectable.find((option) => option.value === value) ?? null;
- const placeholder = options.find((option) => option.value === ALL)?.label ?? "";
-
- return (
- <Autocomplete
- options={selectable}
- value={selected}
- onChange={(_, option) => onChange(option?.value ?? ALL)}
- getOptionLabel={(option) => option?.label ?? ""}
- isOptionEqualToValue={(a, b) => a?.value === b?.value}
- selectOnFocus
- autoHighlight
- handleHomeEndKeys
- autoComplete
- includeInputInList
- size="small"
- sx={{ width: "100%" }}
- renderInput={(params) => (
- <TextField {...params} label={label} placeholder={placeholder} />
- )}
- />
- );
- }
-
- const FinishedGoodCartonDashboardTab: React.FC<Props> = ({ mode = "normal" }) => {
- const { t, i18n } = useTranslation();
- const numberLocale = i18n.language?.startsWith("zh") ? "zh-HK" : "en-US";
- const [floor, setFloor] = useState<FloorFilter>(ALL);
- const [lane, setLane] = useState<string>(ALL);
- const [shop, setShop] = useState<string>(ALL);
- const [date, setDate] = useState<string>(dayjs().format("YYYY-MM-DD"));
- const [loading, setLoading] = useState(false);
- const [isExporting, setIsExporting] = useState(false);
- const [isExportingFiltered, setIsExportingFiltered] = useState(false);
- const exportInFlightRef = useRef(false);
- const filteredExportInFlightRef = useRef(false);
- const [error, setError] = useState<string>("");
- const [records, setRecords] = useState<CompletedDoPickOrderResponse[]>([]);
- const todayDate = dayjs().format("YYYY-MM-DD");
- const filtersAreActive =
- floor !== ALL || lane !== ALL || shop !== ALL || date !== todayDate;
-
- const resetFilters = useCallback(() => {
- setFloor(ALL);
- setLane(ALL);
- setShop(ALL);
- setDate(dayjs().format("YYYY-MM-DD"));
- }, []);
-
- const loadData = useCallback(async () => {
- setLoading(true);
- setError("");
- try {
- const data =
- mode === "workbench"
- ? await fetchCompletedDoPickOrdersWorkbenchAll(
- date ? { targetDate: date } : undefined,
- )
- : await fetchCompletedDoPickOrdersAll(
- date ? { targetDate: date } : undefined,
- );
- setRecords(data);
- } catch (err) {
- console.error("Failed to load finished good carton dashboard data", err);
- setError(t("Failed to load FG carton quantity. Please try again later."));
- setRecords([]);
- } finally {
- setLoading(false);
- }
- }, [date, mode, t]);
-
- useEffect(() => {
- loadData();
- }, [loadData]);
-
- const laneOptions = useMemo(() => {
- const byRaw = new Map<string, string>();
- records.forEach((record) => {
- if (floor !== ALL && record.storeId !== floor) return;
- if (!recordMatchesShop(record.shopCode, shop)) return;
- const raw = normalizeTruckLane(record.truckLanceCode);
- if (!byRaw.has(raw)) byRaw.set(raw, shortLaneLabel(raw));
- });
- return Array.from(byRaw.entries())
- .map(([value, label]) => ({ value, label }))
- .sort((a, b) => {
- if (a.value === TRUCK_X_LANE) return 1;
- if (b.value === TRUCK_X_LANE) return -1;
- return a.label.localeCompare(b.label, "zh-Hant");
- });
- }, [records, floor, shop]);
-
- const shopOptions = useMemo(() => {
- const groups = new Set<string>();
- records.forEach((record) => {
- if (floor !== ALL && record.storeId !== floor) return;
- if (lane !== ALL && normalizeTruckLane(record.truckLanceCode) !== lane) return;
- const group = shopGroup(record.shopCode);
- if (group) groups.add(group);
- });
- return Array.from(groups)
- .sort((a, b) => a.localeCompare(b, "zh-Hant"))
- .map((group) => ({ value: group, label: group }));
- }, [records, floor, lane]);
-
- const laneFilterOptions = useMemo<FilterOption[]>(() => {
- const options: FilterOption[] = [{ value: ALL, label: t("All lanes") }, ...laneOptions];
- if (lane !== ALL && !options.some((option) => option.value === lane)) {
- options.splice(1, 0, { value: lane, label: shortLaneLabel(lane) });
- }
- return options;
- }, [laneOptions, lane, t]);
-
- const shopFilterValue = shop === ALL ? ALL : shopGroup(shop);
-
- const shopFilterOptions = useMemo<FilterOption[]>(() => {
- const options: FilterOption[] = [{ value: ALL, label: t("All shops") }, ...shopOptions];
- if (
- shopFilterValue !== ALL &&
- !options.some((option) => option.value === shopFilterValue)
- ) {
- options.splice(1, 0, { value: shopFilterValue, label: shopFilterValue });
- }
- return options;
- }, [shopOptions, shopFilterValue, t]);
-
- const filteredRecords = useMemo(
- () => records.filter((record) => recordMatchesFilters(record, floor, lane, shop)),
- [records, floor, lane, shop],
- );
-
- const rows = useMemo<DailySummaryRow[]>(() => {
- const summary = new Map<string, DailySummaryRow>();
-
- filteredRecords.forEach((record) => {
- const day = dayjs(record.deliveryDate).isValid()
- ? dayjs(record.deliveryDate).format("YYYY-MM-DD")
- : "-";
- const cartonQty = Number(record.numberOfCartons ?? 0);
-
- const current = summary.get(day) ?? {
- date: day,
- floor2F: 0,
- floor4F: 0,
- truckX: 0,
- total: 0,
- };
-
- if (record.storeId === "2/F") {
- current.floor2F += cartonQty;
- }
- if (record.storeId === "4/F") {
- current.floor4F += cartonQty;
- }
- if (normalizeTruckLane(record.truckLanceCode) === TRUCK_X_LANE) {
- current.truckX += cartonQty;
- }
-
- current.total += cartonQty;
- summary.set(day, current);
- });
-
- return Array.from(summary.values()).sort((a, b) => b.date.localeCompare(a.date));
- }, [filteredRecords]);
-
- const breakdownDimension = resolveBreakdownDimension(lane, shop);
-
- const breakdownRows = useMemo<BreakdownRow[]>(() => {
- const toSorted = (slices: BreakdownRow[]) =>
- slices.sort((a, b) => b.qty - a.qty || a.label.localeCompare(b.label, "zh-Hant"));
-
- if (breakdownDimension === "floor") {
- let floor2F = 0;
- let floor4F = 0;
- let truckX = 0;
- filteredRecords.forEach((record) => {
- const cartonQty = Number(record.numberOfCartons ?? 0);
- if (record.storeId === "2/F") floor2F += cartonQty;
- if (record.storeId === "4/F") floor4F += cartonQty;
- if (normalizeTruckLane(record.truckLanceCode) === TRUCK_X_LANE) truckX += cartonQty;
- });
- return toSorted(
- [
- { key: "2/F", label: "2/F", qty: floor2F },
- { key: "4/F", label: "4/F", qty: floor4F },
- { key: TRUCK_X_LANE, label: shortLaneLabel(TRUCK_X_LANE), qty: truckX },
- ].filter((row) => {
- if (row.qty > 0) return true;
- if (row.key === "2/F" || row.key === "4/F") return floor === row.key;
- return lane === TRUCK_X_LANE && row.key === TRUCK_X_LANE;
- }),
- );
- }
-
- const grouped = new Map<string, BreakdownRow>();
- filteredRecords.forEach((record) => {
- const cartonQty = Number(record.numberOfCartons ?? 0);
- if (breakdownDimension === "lane") {
- const key = normalizeTruckLane(record.truckLanceCode);
- const current = grouped.get(key) ?? {
- key,
- label: shortLaneLabel(key),
- qty: 0,
- };
- current.qty += cartonQty;
- grouped.set(key, current);
- return;
- }
-
- const code = normalizeShopCode(record.shopCode).toUpperCase();
- const name = String(record.shopName ?? "").trim();
- const key = shop !== ALL ? code : shopGroup(record.shopCode);
- if (!key) return;
- const label = shop !== ALL ? formatShopLabel(code, name) : key;
- const current = grouped.get(key) ?? { key, label, qty: 0 };
- if (shop !== ALL && name) current.label = formatShopLabel(code, name);
- current.qty += cartonQty;
- grouped.set(key, current);
- });
-
- return toSorted(
- Array.from(grouped.values()).filter((row) => {
- if (row.qty > 0) return true;
- if (breakdownDimension === "shop") return shop !== ALL && row.key === shop;
- return lane !== ALL && row.key === lane;
- }),
- );
- }, [breakdownDimension, filteredRecords, floor, lane, shop]);
-
- const breakdownCaption =
- breakdownDimension === "shop"
- ? shop !== ALL
- ? t("Cartons by shop")
- : t("Cartons by shop group")
- : breakdownDimension === "floor"
- ? t("Cartons by floor")
- : t("Cartons by lane");
-
- const applyBreakdownClick = useCallback(
- (row: BreakdownRow) => {
- if (breakdownDimension === "lane") {
- setLane((prev) => (prev === row.key ? ALL : row.key));
- return;
- }
- if (breakdownDimension === "shop") {
- setShop((prev) => {
- if (prev === row.key) {
- const group = shopGroup(row.key);
- return group && group !== row.key ? group : ALL;
- }
- return row.key;
- });
- }
- },
- [breakdownDimension],
- );
-
- const viewingSpecificShop = shop !== ALL && !isShopGroupValue(shop);
- const chartClicksEnabled = !viewingSpecificShop;
- const chartCanGoBack = lane !== ALL || shop !== ALL;
-
- const goBackChartLevel = useCallback(() => {
- if (shop !== ALL && !isShopGroupValue(shop)) {
- setShop(shopGroup(shop) || ALL);
- return;
- }
- if (shop !== ALL) {
- setShop(ALL);
- return;
- }
- if (lane !== ALL) setLane(ALL);
- }, [lane, shop]);
-
- const isBreakdownRowActive = useCallback(
- (row: BreakdownRow) => {
- if (breakdownDimension === "lane") return lane === row.key;
- if (breakdownDimension === "shop") return shop === row.key;
- if (row.key === "2/F" || row.key === "4/F") return floor === row.key;
- return lane === TRUCK_X_LANE && row.key === TRUCK_X_LANE;
- },
- [breakdownDimension, floor, lane, shop],
- );
-
- const summary = useMemo(() => {
- return rows.reduce(
- (acc, row) => {
- acc.floor2F += row.floor2F;
- acc.floor4F += row.floor4F;
- acc.truckX += row.truckX;
- acc.total += row.total;
- return acc;
- },
- { floor2F: 0, floor4F: 0, truckX: 0, total: 0 },
- );
- }, [rows]);
-
- const chartMaxQty = Math.max(1, ...breakdownRows.map((row) => row.qty));
-
- const buildDailyRowsFromRecords = useCallback(
- (
- sourceRecords: CompletedDoPickOrderResponse[],
- startDate: dayjs.Dayjs,
- endDate: dayjs.Dayjs,
- selectedFloor: FloorFilter,
- selectedLane: string,
- selectedShop: string,
- ): ShopDailyRow[] => {
- const summaryMap = new Map<string, ShopDailyRow>();
- const start = startDate.startOf("day");
- const end = endDate.endOf("day");
-
- sourceRecords.forEach((record) => {
- if (!recordMatchesFilters(record, selectedFloor, selectedLane, selectedShop)) {
- return;
- }
-
- const deliveryDay = dayjs(record.deliveryDate, ["YYYY-MM-DD", "YYYYMMDD"], true);
- if (!deliveryDay.isValid() || deliveryDay.isBefore(start) || deliveryDay.isAfter(end)) {
- return;
- }
-
- const dayKey = deliveryDay.format("YYYY-MM-DD");
- const shopCode = normalizeShopCode(record.shopCode).toUpperCase() || "-";
- const shopName = shopNameWithoutCode(shopCode, record.shopName);
- const mapKey = `${dayKey}|${shopCode}`;
- const cartonQty = Number(record.numberOfCartons ?? 0);
- const current = summaryMap.get(mapKey) ?? {
- date: dayKey,
- shopCode,
- shopName,
- floor2F: 0,
- floor4F: 0,
- truckX: 0,
- total: 0,
- };
- if (!current.shopName && shopName) current.shopName = shopName;
-
- if (record.storeId === "2/F") current.floor2F += cartonQty;
- if (record.storeId === "4/F") current.floor4F += cartonQty;
- if (normalizeTruckLane(record.truckLanceCode) === TRUCK_X_LANE) current.truckX += cartonQty;
-
- current.total += cartonQty;
- summaryMap.set(mapKey, current);
- });
-
- return Array.from(summaryMap.values()).sort(
- (a, b) =>
- a.date.localeCompare(b.date) || a.shopCode.localeCompare(b.shopCode, "zh-Hant"),
- );
- },
- [],
- );
-
- const calcSummary = useCallback((dailyRows: DailySummaryRow[]) => {
- return dailyRows.reduce(
- (acc, row) => {
- acc.floor2F += row.floor2F;
- acc.floor4F += row.floor4F;
- acc.truckX += row.truckX;
- acc.total += row.total;
- return acc;
- },
- { floor2F: 0, floor4F: 0, truckX: 0, total: 0 },
- );
- }, []);
-
- const styleWorksheet = useCallback((worksheet: XLSX.WorkSheet, dataRowsCount: number) => {
- const summaryTitleRow = 4 + dataRowsCount;
- const summaryStartRow = 5 + dataRowsCount;
-
- worksheet["!cols"] = [
- { wch: 14 },
- { wch: 14 },
- { wch: 28 },
- { wch: 16 },
- { wch: 16 },
- { wch: 18 },
- { wch: 14 },
- ];
- worksheet["!merges"] = [
- { s: { r: 0, c: 0 }, e: { r: 0, c: DAILY_SHEET_LAST_COL } },
- { s: { r: summaryTitleRow, c: 0 }, e: { r: summaryTitleRow, c: DAILY_SHEET_LAST_COL } },
- ];
-
- const titleStyle = {
- font: { bold: true, sz: 14, color: { rgb: "1F2D3D" } },
- alignment: { horizontal: "center", vertical: "center" },
- fill: { fgColor: { rgb: "EAF3FF" } },
- };
- const headerStyle = {
- font: { bold: true, color: { rgb: "FFFFFF" } },
- fill: { fgColor: { rgb: "1976D2" } },
- alignment: { horizontal: "center", vertical: "center" },
- border: {
- top: { style: "thin", color: { rgb: "B0BEC5" } },
- bottom: { style: "thin", color: { rgb: "B0BEC5" } },
- left: { style: "thin", color: { rgb: "B0BEC5" } },
- right: { style: "thin", color: { rgb: "B0BEC5" } },
- },
- };
- const cellStyle = {
- alignment: { vertical: "center" },
- border: {
- top: { style: "thin", color: { rgb: "D0D7DE" } },
- bottom: { style: "thin", color: { rgb: "D0D7DE" } },
- left: { style: "thin", color: { rgb: "D0D7DE" } },
- right: { style: "thin", color: { rgb: "D0D7DE" } },
- },
- };
- const numberStyle = {
- ...cellStyle,
- alignment: { horizontal: "right", vertical: "center" },
- numFmt: "#,##0",
- };
- const summaryTitleStyle = {
- font: { bold: true, color: { rgb: "1F2D3D" } },
- fill: { fgColor: { rgb: "F1F8E9" } },
- alignment: { horizontal: "left", vertical: "center" },
- };
-
- for (let c = 0; c <= DAILY_SHEET_LAST_COL; c += 1) {
- const headerCell = XLSX.utils.encode_cell({ r: 2, c });
- if (worksheet[headerCell]) worksheet[headerCell].s = headerStyle;
- }
-
- for (let r = 3; r < 3 + dataRowsCount; r += 1) {
- for (let c = 0; c <= DAILY_SHEET_LAST_COL; c += 1) {
- const addr = XLSX.utils.encode_cell({ r, c });
- if (!worksheet[addr]) continue;
- worksheet[addr].s = c < 3 ? cellStyle : numberStyle;
- }
- }
-
- for (let r = summaryStartRow; r <= summaryStartRow + 3; r += 1) {
- for (let c = 0; c <= DAILY_SHEET_LAST_COL; c += 1) {
- const addr = XLSX.utils.encode_cell({ r, c });
- const cell = worksheet[addr];
- if (!cell) continue;
- cell.s = typeof cell.v === "number" ? numberStyle : cellStyle;
- }
- }
-
- if (worksheet["A1"]) worksheet["A1"].s = titleStyle;
- const summaryTitleAddr = XLSX.utils.encode_cell({ r: summaryTitleRow, c: 0 });
- if (worksheet[summaryTitleAddr]) worksheet[summaryTitleAddr].s = summaryTitleStyle;
- applyProjectExcelFont(worksheet);
- }, []);
-
- const addReportSheet = useCallback(
- (
- workbook: XLSX.WorkBook,
- sheetName: string,
- reportTitle: string,
- dailyRows: ShopDailyRow[],
- ) => {
- const reportSummary = calcSummary(dailyRows);
- const blank = ["", "", "", "", "", "", ""];
- const aoa: (string | number)[][] = [
- [reportTitle, ...blank.slice(1)],
- [...blank],
- [
- t("Date"),
- t("Shop code"),
- t("Shop Name"),
- t("2/F carton qty"),
- t("4/F carton qty"),
- t("Truck X carton qty"),
- t("Total carton qty"),
- ],
- ...dailyRows.map((row) => [
- row.date,
- row.shopCode,
- row.shopName,
- row.floor2F,
- row.floor4F,
- row.truckX,
- row.total,
- ]),
- [...blank],
- [t("Summary"), ...blank.slice(1)],
- [t("2/F carton qty"), "", "", reportSummary.floor2F, "", "", ""],
- [t("4/F carton qty"), "", "", "", reportSummary.floor4F, "", ""],
- [t("Truck X carton qty"), "", "", "", "", reportSummary.truckX, ""],
- [t("Total carton qty"), "", "", "", "", "", reportSummary.total],
- ];
-
- const worksheet = XLSX.utils.aoa_to_sheet(aoa);
- styleWorksheet(worksheet, dailyRows.length);
- XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
- },
- [calcSummary, styleWorksheet, t],
- );
-
- const addBreakdownSheet = useCallback(
- (
- workbook: XLSX.WorkBook,
- sheetName: string,
- reportTitle: string,
- shops: ShopQtyRow[],
- totalQty: number,
- ) => {
- const aoa: (string | number)[][] = [
- [reportTitle, "", "", ""],
- ["", "", "", ""],
- [t("Shop code"), t("Shop Name"), t("Cartons"), t("Share")],
- ...shops.map((row) => {
- const share = totalQty > 0 ? (row.qty / totalQty) * 100 : 0;
- return [row.code, row.name, row.qty, `${share.toFixed(1)}%`];
- }),
- ["", "", "", ""],
- [t("Total carton qty"), "", totalQty, ""],
- ];
-
- const worksheet = XLSX.utils.aoa_to_sheet(aoa);
- worksheet["!cols"] = [{ wch: 14 }, { wch: 28 }, { wch: 14 }, { wch: 12 }];
- worksheet["!merges"] = [{ s: { r: 0, c: 0 }, e: { r: 0, c: 3 } }];
-
- const titleStyle = {
- font: { bold: true, sz: 14, color: { rgb: "1F2D3D" } },
- alignment: { horizontal: "center", vertical: "center" },
- fill: { fgColor: { rgb: "EAF3FF" } },
- };
- const headerStyle = {
- font: { bold: true, color: { rgb: "FFFFFF" } },
- fill: { fgColor: { rgb: "1976D2" } },
- alignment: { horizontal: "center", vertical: "center" },
- };
- const cellStyle = {
- alignment: { vertical: "center" },
- border: {
- top: { style: "thin", color: { rgb: "D0D7DE" } },
- bottom: { style: "thin", color: { rgb: "D0D7DE" } },
- left: { style: "thin", color: { rgb: "D0D7DE" } },
- right: { style: "thin", color: { rgb: "D0D7DE" } },
- },
- };
- const numberStyle = {
- ...cellStyle,
- alignment: { horizontal: "right", vertical: "center" },
- numFmt: "#,##0",
- };
-
- if (worksheet["A1"]) worksheet["A1"].s = titleStyle;
- for (let c = 0; c <= 3; c += 1) {
- const headerCell = XLSX.utils.encode_cell({ r: 2, c });
- if (worksheet[headerCell]) worksheet[headerCell].s = headerStyle;
- }
- shops.forEach((_, index) => {
- const r = 3 + index;
- const codeAddr = XLSX.utils.encode_cell({ r, c: 0 });
- const nameAddr = XLSX.utils.encode_cell({ r, c: 1 });
- const qtyAddr = XLSX.utils.encode_cell({ r, c: 2 });
- const shareAddr = XLSX.utils.encode_cell({ r, c: 3 });
- if (worksheet[codeAddr]) worksheet[codeAddr].s = cellStyle;
- if (worksheet[nameAddr]) worksheet[nameAddr].s = cellStyle;
- if (worksheet[qtyAddr]) worksheet[qtyAddr].s = numberStyle;
- if (worksheet[shareAddr]) worksheet[shareAddr].s = cellStyle;
- });
- const totalRow = 4 + shops.length;
- const totalLabelAddr = XLSX.utils.encode_cell({ r: totalRow, c: 0 });
- const totalValueAddr = XLSX.utils.encode_cell({ r: totalRow, c: 2 });
- if (worksheet[totalLabelAddr]) worksheet[totalLabelAddr].s = cellStyle;
- if (worksheet[totalValueAddr]) worksheet[totalValueAddr].s = numberStyle;
- applyProjectExcelFont(worksheet);
-
- XLSX.utils.book_append_sheet(workbook, worksheet, sheetName.slice(0, 31));
- },
- [t],
- );
-
- const handleDownloadExcel = useCallback(async () => {
- if (exportInFlightRef.current || filteredExportInFlightRef.current) return;
- exportInFlightRef.current = true;
- setIsExporting(true);
- try {
- const allRecords =
- mode === "workbench"
- ? await fetchCompletedDoPickOrdersWorkbenchAll()
- : await fetchCompletedDoPickOrdersAll();
-
- const baseDate = dayjs(date || undefined).isValid() ? dayjs(date) : dayjs();
- const floorLabel = floor === ALL ? t("All floors") : floor;
- const laneLabel = lane === ALL ? t("All lanes") : lane;
- const shopLabel = shop === ALL ? t("All shops") : shop;
- const dateLabel = baseDate.format("YYYY-MM-DD");
- const monthPeriod = i18n.language?.startsWith("zh")
- ? baseDate.format("YYYY年MM月")
- : baseDate.format("YYYY-MM");
- const yearPeriod = i18n.language?.startsWith("zh")
- ? baseDate.format("YYYY年")
- : baseDate.format("YYYY");
-
- const last7Rows = buildDailyRowsFromRecords(
- allRecords,
- baseDate.subtract(6, "day"),
- baseDate,
- floor,
- lane,
- shop,
- );
- const monthRows = buildDailyRowsFromRecords(
- allRecords,
- baseDate.startOf("month"),
- baseDate.endOf("month"),
- floor,
- lane,
- shop,
- );
- const yearRows = buildDailyRowsFromRecords(
- allRecords,
- baseDate.startOf("year"),
- baseDate.endOf("year"),
- floor,
- lane,
- shop,
- );
-
- const workbook = XLSX.utils.book_new();
- addReportSheet(
- workbook,
- t("Last 7 days"),
- t("FG carton qty last 7 days title", { floor: floorLabel, date: dateLabel }),
- last7Rows,
- );
- addReportSheet(
- workbook,
- t("This month"),
- t("FG carton qty this month title", { floor: floorLabel, period: monthPeriod }),
- monthRows,
- );
- addReportSheet(
- workbook,
- t("This year"),
- t("FG carton qty this year title", { floor: floorLabel, period: yearPeriod }),
- yearRows,
- );
-
- const fileBits = [
- "FG_carton_qty",
- floorLabel.replace("/", ""),
- lane === ALL ? "" : laneLabel,
- shop === ALL ? "" : shopLabel,
- dateLabel,
- ].filter(Boolean);
-
- XLSX.writeFile(workbook, `${fileBits.join("_")}.xlsx`);
- } finally {
- setIsExporting(false);
- exportInFlightRef.current = false;
- }
- }, [mode, date, floor, lane, shop, buildDailyRowsFromRecords, addReportSheet, t, i18n.language]);
-
- const handleDownloadFilteredExcel = useCallback(async () => {
- if (filteredExportInFlightRef.current || exportInFlightRef.current) return;
- filteredExportInFlightRef.current = true;
- setIsExportingFiltered(true);
- try {
- const baseDate = dayjs(date || undefined).isValid() ? dayjs(date) : dayjs();
- const floorLabel = floor === ALL ? t("All floors") : floor;
- const laneLabel = lane === ALL ? t("All lanes") : shortLaneLabel(lane);
- const shopLabel =
- shop === ALL ? t("All shops") : isShopGroupValue(shop) ? shop : formatShopLabel(shop, "");
- const dateLabel = baseDate.format("YYYY-MM-DD");
- const reportTitle = t("FG carton qty filtered title", {
- floor: floorLabel,
- lane: laneLabel,
- shop: shopLabel,
- date: dateLabel,
- });
-
- const dailyRows = buildDailyRowsFromRecords(
- records,
- baseDate.startOf("day"),
- baseDate.endOf("day"),
- floor,
- lane,
- shop,
- );
- const filteredTotal = calcSummary(dailyRows).total;
- const shopRows = buildShopQtyRows(
- records.filter((record) => recordMatchesFilters(record, floor, lane, shop)),
- );
-
- const workbook = XLSX.utils.book_new();
- addReportSheet(workbook, t("Filtered"), reportTitle, dailyRows);
- addBreakdownSheet(
- workbook,
- t("Breakdown"),
- t("Cartons by shop"),
- shopRows,
- filteredTotal,
- );
-
- const fileBits = [
- "FG_carton_qty_filtered",
- floorLabel.replace("/", ""),
- lane === ALL ? "" : laneLabel,
- shop === ALL ? "" : shopLabel,
- dateLabel,
- ].filter(Boolean);
-
- XLSX.writeFile(workbook, `${fileBits.join("_")}.xlsx`);
- } finally {
- setIsExportingFiltered(false);
- filteredExportInFlightRef.current = false;
- }
- }, [
- date,
- floor,
- lane,
- shop,
- records,
- buildDailyRowsFromRecords,
- addReportSheet,
- addBreakdownSheet,
- calcSummary,
- t,
- ]);
-
- const isAnyExporting = isExporting || isExportingFiltered;
-
- return (
- <Box sx={{ width: "100%" }}>
- <Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 2 }}>
- <Typography variant="h6">{t("FG Carton Qty")}</Typography>
- <Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap>
- <Button
- variant="outlined"
- onClick={resetFilters}
- disabled={loading || !filtersAreActive}
- >
- {t("Reset filters")}
- </Button>
- <Tooltip title={t("Download this view Excel hint")}>
- <span>
- <Button
- variant="outlined"
- onClick={handleDownloadFilteredExcel}
- disabled={loading || isAnyExporting}
- >
- {isExportingFiltered ? t("Exporting...") : t("Download this view Excel")}
- </Button>
- </span>
- </Tooltip>
- <Tooltip title={t("Download period Excel hint")}>
- <span>
- <Button
- variant="contained"
- onClick={handleDownloadExcel}
- disabled={loading || isAnyExporting}
- >
- {isExporting ? t("Exporting...") : t("Download period Excel")}
- </Button>
- </span>
- </Tooltip>
- </Stack>
- </Stack>
-
- {error && (
- <Alert severity="error" sx={{ mb: 2 }}>
- {error}
- </Alert>
- )}
-
- <Stack spacing={2}>
- <Grid container spacing={1.5}>
- <Grid item xs={12} sm={6} md={3}>
- <TextField
- select
- fullWidth
- size="small"
- label={t("Floor")}
- value={floor}
- onChange={(event) => setFloor(event.target.value as FloorFilter)}
- >
- <MenuItem value={ALL}>{t("All")}</MenuItem>
- <MenuItem value="2/F">2/F</MenuItem>
- <MenuItem value="4/F">4/F</MenuItem>
- </TextField>
- </Grid>
- <Grid item xs={12} sm={6} md={3}>
- <SearchableFilterSelect
- label={t("Lane")}
- options={laneFilterOptions}
- value={lane}
- onChange={setLane}
- />
- </Grid>
- <Grid item xs={12} sm={6} md={3}>
- <SearchableFilterSelect
- label={t("Shop Code")}
- options={shopFilterOptions}
- value={shopFilterValue}
- onChange={setShop}
- />
- </Grid>
- <Grid item xs={12} sm={6} md={3}>
- <LocalizationProvider
- dateAdapter={AdapterDayjs}
- adapterLocale={i18n.language?.startsWith("zh") ? "zh-hk" : "en"}
- >
- <DatePicker
- label={t("Date")}
- format={OUTPUT_DATE_FORMAT}
- value={dayjs(date).isValid() ? dayjs(date) : null}
- onChange={(newValue) => {
- if (newValue && dayjs(newValue).isValid()) {
- setDate(dayjs(newValue).format(OUTPUT_DATE_FORMAT));
- }
- }}
- slotProps={{
- textField: { size: "small", fullWidth: true },
- }}
- />
- </LocalizationProvider>
- </Grid>
- </Grid>
-
- {loading ? (
- <Box sx={{ py: 6, display: "flex", justifyContent: "center" }}>
- <CircularProgress />
- </Box>
- ) : (
- <Stack spacing={1.5}>
- <Paper sx={{ px: 1, py: 1 }}>
- <Grid container>
- {[
- { label: t("2/F carton qty"), value: summary.floor2F },
- { label: t("4/F carton qty"), value: summary.floor4F },
- { label: t("Truck X carton qty"), value: summary.truckX },
- { label: t("Total carton qty"), value: summary.total },
- ].map((kpi, index) => (
- <Grid
- item
- xs={6}
- md={3}
- key={kpi.label}
- sx={{
- px: 1.5,
- py: 0.5,
- borderRight: { md: index < 3 ? "1px solid" : "none" },
- borderBottom: { xs: index < 2 ? "1px solid" : "none", md: "none" },
- borderColor: { xs: "divider", md: "divider" },
- }}
- >
- <Typography variant="caption" color="text.secondary">
- {kpi.label}
- </Typography>
- <Typography variant="h6" sx={{ fontVariantNumeric: "tabular-nums", lineHeight: 1.3 }}>
- {kpi.value.toLocaleString(numberLocale)}
- </Typography>
- </Grid>
- ))}
- </Grid>
- </Paper>
-
- <Paper sx={{ p: 1.5 }}>
- <Stack
- direction="row"
- alignItems="center"
- justifyContent="space-between"
- spacing={1}
- sx={{ mb: 1 }}
- >
- <Box sx={{ minWidth: 0 }}>
- <Typography variant="subtitle2" color="text.secondary">
- {breakdownCaption}
- </Typography>
- {chartClicksEnabled && (
- <Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>
- {t("Click a row to filter")}
- </Typography>
- )}
- </Box>
- <Button
- size="small"
- variant="outlined"
- startIcon={<ArrowBackIcon />}
- onClick={goBackChartLevel}
- disabled={!chartCanGoBack}
- >
- {t("Back to previous level")}
- </Button>
- </Stack>
- {breakdownRows.length === 0 ? (
- <Typography sx={{ py: 3 }} color="text.secondary">
- {t("No data available")}
- </Typography>
- ) : (
- <Box>
- {breakdownRows.map((row) => {
- const active = isBreakdownRowActive(row);
- const share = summary.total > 0 ? (row.qty / summary.total) * 100 : 0;
- const barWidth =
- row.qty <= 0 ? "0%" : `${(row.qty / chartMaxQty) * 100}%`;
- return (
- <Box
- key={row.key}
- role={chartClicksEnabled ? "button" : undefined}
- tabIndex={chartClicksEnabled ? 0 : undefined}
- onClick={() => {
- if (chartClicksEnabled) applyBreakdownClick(row);
- }}
- onKeyDown={(event) => {
- if (!chartClicksEnabled) return;
- if (event.key === "Enter" || event.key === " ") {
- event.preventDefault();
- applyBreakdownClick(row);
- }
- }}
- title={`${row.label}: ${row.qty.toLocaleString(numberLocale)} (${share.toFixed(1)}%)`}
- sx={{
- height: CHART_ROW_H,
- display: "grid",
- gridTemplateColumns: {
- xs: "132px minmax(0, 1fr) 84px",
- sm: "200px minmax(0, 1fr) 84px",
- md: "280px minmax(0, 1fr) 84px",
- },
- columnGap: 1,
- alignItems: "center",
- minWidth: 0,
- cursor: chartClicksEnabled ? "pointer" : "default",
- borderRadius: 1,
- px: 0.5,
- bgcolor: active ? "action.selected" : "transparent",
- "&:hover": chartClicksEnabled ? { bgcolor: "action.hover" } : undefined,
- }}
- >
- <Typography
- component="span"
- noWrap
- title={row.label}
- sx={{
- minWidth: 0,
- width: "100%",
- fontWeight: 700,
- fontSize: 13,
- lineHeight: `${CHART_ROW_H}px`,
- color: "text.primary",
- textAlign: "left",
- }}
- >
- {row.label}
- </Typography>
- <Box
- sx={{
- height: "100%",
- display: "flex",
- alignItems: "center",
- minWidth: 0,
- }}
- >
- <Box
- sx={{
- height: 22,
- width: barWidth,
- minWidth: row.qty > 0 ? 4 : 0,
- bgcolor: "#1976d2",
- borderRadius: "4px",
- }}
- />
- </Box>
- <Box
- sx={{
- height: 24,
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- px: 0.75,
- borderRadius: 0.5,
- bgcolor: "#fff",
- border: "1px solid #90a4ae",
- color: "#102a43",
- fontWeight: 800,
- fontSize: 15,
- lineHeight: 1,
- boxShadow: "0 1px 3px rgba(0,0,0,0.22)",
- }}
- >
- {row.qty.toLocaleString(numberLocale)}
- </Box>
- </Box>
- );
- })}
- </Box>
- )}
- </Paper>
- </Stack>
- )}
- </Stack>
- </Box>
- );
- };
-
- export default FinishedGoodCartonDashboardTab;
|