Просмотр исходного кода

[Fix] Amend few visual issues

do_workbench_fix
Harry Groves 6 часов назад
Родитель
Сommit
759df0b379
6 измененных файлов: 356 добавлений и 276 удалений
  1. +6
    -6
      src/components/DoWorkbench/DoWorkbenchTabs.tsx
  2. +338
    -266
      src/components/FinishedGoodSearch/FinishedGoodCartonDashboardTab.tsx
  3. +3
    -1
      src/i18n/en/doWorkbench.json
  4. +3
    -1
      src/i18n/en/pickOrder.json
  5. +3
    -1
      src/i18n/zh/doWorkbench.json
  6. +3
    -1
      src/i18n/zh/pickOrder.json

+ 6
- 6
src/components/DoWorkbench/DoWorkbenchTabs.tsx Просмотреть файл

@@ -282,7 +282,7 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom
sx={{ sx={{
width: "100%", width: "100%",
maxWidth: "100%", maxWidth: "100%",
minHeight: 56,
minHeight: 48,
borderBottom: 1, borderBottom: 1,
borderColor: "divider", borderColor: "divider",
"& .MuiTabs-flexContainer": { "& .MuiTabs-flexContainer": {
@@ -291,12 +291,12 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom
/* 否則 Tab 內 overflow:hidden 會把 Badge 數字裁成紅點 */ /* 否則 Tab 內 overflow:hidden 會把 Badge 數字裁成紅點 */
"& .MuiTab-root": { "& .MuiTab-root": {
overflow: "visible", overflow: "visible",
minHeight: 56,
minHeight: 48,
minWidth: 72, minWidth: 72,
maxWidth: 120,
px: 1,
maxWidth: "none",
px: 1.5,
py: 0.75, py: 0.75,
whiteSpace: "normal",
whiteSpace: "nowrap",
lineHeight: 1.2, lineHeight: 1.2,
textAlign: "center", textAlign: "center",
}, },
@@ -342,7 +342,7 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom
variant="inherit" variant="inherit"
sx={{ sx={{
pr: etraIncompleteDopoCount > 0 ? 1 : 0, pr: etraIncompleteDopoCount > 0 ? 1 : 0,
whiteSpace: "normal",
whiteSpace: "nowrap",
lineHeight: 1.2, lineHeight: 1.2,
textAlign: "center", textAlign: "center",
}} }}


+ 338
- 266
src/components/FinishedGoodSearch/FinishedGoodCartonDashboardTab.tsx Просмотреть файл

@@ -15,15 +15,18 @@ import {
Tooltip, Tooltip,
Typography, Typography,
} from "@mui/material"; } from "@mui/material";
import type { ApexOptions } from "apexcharts";
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 from "dayjs";
import "dayjs/locale/zh-hk";
import * as XLSX from "xlsx-js-style"; import * as XLSX from "xlsx-js-style";
import { import {
CompletedDoPickOrderResponse, CompletedDoPickOrderResponse,
fetchCompletedDoPickOrdersAll, fetchCompletedDoPickOrdersAll,
fetchCompletedDoPickOrdersWorkbenchAll, fetchCompletedDoPickOrdersWorkbenchAll,
} from "@/app/api/pickOrder/actions"; } from "@/app/api/pickOrder/actions";
import SafeApexCharts from "@/components/charts/SafeApexCharts";
import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";


type FloorFilter = "all" | "2/F" | "4/F"; type FloorFilter = "all" | "2/F" | "4/F";
@@ -37,6 +40,17 @@ type DailySummaryRow = {
total: number; total: number;
}; };


type ShopDailyRow = DailySummaryRow & {
shopCode: string;
shopName: string;
};

type ShopQtyRow = {
code: string;
name: string;
qty: number;
};

type BreakdownRow = { type BreakdownRow = {
key: string; key: string;
label: string; label: string;
@@ -50,6 +64,9 @@ type Props = {
const TRUCK_X_LANE = "車線-X"; const TRUCK_X_LANE = "車線-X";
const ALL = "all"; const ALL = "all";
const CHART_ROW_H = 38; 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 }; type FilterOption = { value: string; label: string };


@@ -61,7 +78,7 @@ function normalizeTruckLane(raw: string | null | undefined): string {
function shortLaneLabel(raw: string | null | undefined): string { function shortLaneLabel(raw: string | null | undefined): string {
const value = normalizeTruckLane(raw); const value = normalizeTruckLane(raw);
const stripped = value const stripped = value
.replace(/^(車線)[-–—]?\s*/u, "")
.replace(/^(車線)[-–—]?\s*/, "")
.replace(/^(truck)\s*[-–—]?\s*/i, "") .replace(/^(truck)\s*[-–—]?\s*/i, "")
.trim(); .trim();
return stripped || value; return stripped || value;
@@ -77,6 +94,77 @@ function shopGroup(raw: string | null | undefined): string {
return code.slice(0, 2); 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 { function isShopGroupValue(shop: string): boolean {
if (shop === ALL) return false; if (shop === ALL) return false;
const code = normalizeShopCode(shop).toUpperCase(); const code = normalizeShopCode(shop).toUpperCase();
@@ -110,10 +198,8 @@ function recordMatchesFilters(
} }


function resolveBreakdownDimension(lane: string, shop: string): BreakdownDimension { function resolveBreakdownDimension(lane: string, shop: string): BreakdownDimension {
if (isShopGroupValue(shop)) return "shop";
if (shop !== ALL && lane !== ALL) return "floor";
if (shop !== ALL) return "shop";
if (lane !== ALL) return "shop"; if (lane !== ALL) return "shop";
if (shop !== ALL) return "lane";
return "lane"; return "lane";
} }


@@ -239,13 +325,18 @@ const FinishedGoodCartonDashboardTab: React.FC<Props> = ({ mode = "normal" }) =>
return options; return options;
}, [laneOptions, lane, t]); }, [laneOptions, lane, t]);


const shopFilterValue = shop === ALL ? ALL : shopGroup(shop);

const shopFilterOptions = useMemo<FilterOption[]>(() => { const shopFilterOptions = useMemo<FilterOption[]>(() => {
const options: FilterOption[] = [{ value: ALL, label: t("All shops") }, ...shopOptions]; const options: FilterOption[] = [{ value: ALL, label: t("All shops") }, ...shopOptions];
if (shop !== ALL && !options.some((option) => option.value === shop)) {
options.splice(1, 0, { value: shop, label: shop });
if (
shopFilterValue !== ALL &&
!options.some((option) => option.value === shopFilterValue)
) {
options.splice(1, 0, { value: shopFilterValue, label: shopFilterValue });
} }
return options; return options;
}, [shopOptions, shop, t]);
}, [shopOptions, shopFilterValue, t]);


const filteredRecords = useMemo( const filteredRecords = useMemo(
() => records.filter((record) => recordMatchesFilters(record, floor, lane, shop)), () => records.filter((record) => recordMatchesFilters(record, floor, lane, shop)),
@@ -330,12 +421,13 @@ const FinishedGoodCartonDashboardTab: React.FC<Props> = ({ mode = "normal" }) =>
return; return;
} }


const key =
shop !== ALL
? normalizeShopCode(record.shopCode).toUpperCase()
: shopGroup(record.shopCode);
const code = normalizeShopCode(record.shopCode).toUpperCase();
const name = String(record.shopName ?? "").trim();
const key = shop !== ALL ? code : shopGroup(record.shopCode);
if (!key) return; if (!key) return;
const current = grouped.get(key) ?? { key, label: key, qty: 0 };
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; current.qty += cartonQty;
grouped.set(key, current); grouped.set(key, current);
}); });
@@ -372,19 +464,27 @@ const FinishedGoodCartonDashboardTab: React.FC<Props> = ({ mode = "normal" }) =>
} }
return row.key; return row.key;
}); });
return;
}
if (row.key === "2/F" || row.key === "4/F") {
setFloor((prev) => (prev === row.key ? ALL : row.key));
return;
}
if (row.key === TRUCK_X_LANE) {
setLane((prev) => (prev === TRUCK_X_LANE ? ALL : TRUCK_X_LANE));
} }
}, },
[breakdownDimension], [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( const isBreakdownRowActive = useCallback(
(row: BreakdownRow) => { (row: BreakdownRow) => {
if (breakdownDimension === "lane") return lane === row.key; if (breakdownDimension === "lane") return lane === row.key;
@@ -409,111 +509,6 @@ const FinishedGoodCartonDashboardTab: React.FC<Props> = ({ mode = "normal" }) =>
}, [rows]); }, [rows]);


const chartMaxQty = Math.max(1, ...breakdownRows.map((row) => row.qty)); const chartMaxQty = Math.max(1, ...breakdownRows.map((row) => row.qty));
const chartHeight = Math.max(CHART_ROW_H, breakdownRows.length * CHART_ROW_H);

const chartOptions = useMemo<ApexOptions>(
() => ({
chart: {
type: "bar",
toolbar: { show: false },
sparkline: { enabled: false },
parentHeightOffset: 0,
offsetY: 0,
animations: {
enabled: true,
easing: "easeinout",
speed: 450,
animateGradually: { enabled: true, delay: 40 },
},
selection: { enabled: false },
zoom: { enabled: false },
},
states: {
hover: { filter: { type: "none" } },
active: { filter: { type: "none" } },
},
colors: ["#1976d2"],
dataLabels: {
enabled: true,
formatter: (val) => Number(val || 0).toLocaleString(numberLocale),
offsetX: 12,
textAnchor: "start",
style: {
fontSize: "15px",
fontWeight: 800,
colors: ["#ffffff"],
},
background: {
enabled: true,
foreColor: "#102a43",
padding: 6,
borderRadius: 4,
opacity: 1,
borderWidth: 1,
borderColor: "#90a4ae",
dropShadow: {
enabled: true,
color: "#000000",
top: 1,
left: 0,
blur: 3,
opacity: 0.22,
},
},
},
stroke: { show: true, width: 1, colors: ["transparent"] },
plotOptions: {
bar: {
horizontal: true,
borderRadius: 4,
barHeight: 22,
dataLabels: {
position: "top",
},
},
},
grid: {
show: true,
borderColor: "#eceff1",
padding: { top: -8, bottom: -8, left: 4, right: 64 },
xaxis: { lines: { show: true } },
yaxis: { lines: { show: false } },
},
xaxis: {
categories: breakdownRows.map((row) => row.label),
labels: { show: false },
axisBorder: { show: false },
axisTicks: { show: false },
min: 0,
max: Math.max(chartMaxQty * 1.4, chartMaxQty + 12),
},
yaxis: {
labels: { show: false },
},
tooltip: {
x: {
formatter: (_val, opts) =>
breakdownRows[opts?.dataPointIndex ?? -1]?.label ?? String(_val ?? ""),
},
y: {
formatter: (val, opts) => {
const qty = Number(val || 0);
const share = summary.total > 0 ? (qty / summary.total) * 100 : 0;
const label = breakdownRows[opts?.dataPointIndex ?? -1]?.label ?? "";
return `${label}: ${qty.toLocaleString(numberLocale)} (${share.toFixed(1)}%)`;
},
},
},
legend: { show: false },
noData: { text: t("No chart data") },
}),
[breakdownRows, chartMaxQty, numberLocale, summary.total, t],
);

const chartSeries = useMemo(
() => [{ name: t("Cartons"), data: breakdownRows.map((row) => row.qty) }],
[breakdownRows, t],
);


const buildDailyRowsFromRecords = useCallback( const buildDailyRowsFromRecords = useCallback(
( (
@@ -523,8 +518,8 @@ const FinishedGoodCartonDashboardTab: React.FC<Props> = ({ mode = "normal" }) =>
selectedFloor: FloorFilter, selectedFloor: FloorFilter,
selectedLane: string, selectedLane: string,
selectedShop: string, selectedShop: string,
): DailySummaryRow[] => {
const summaryMap = new Map<string, DailySummaryRow>();
): ShopDailyRow[] => {
const summaryMap = new Map<string, ShopDailyRow>();
const start = startDate.startOf("day"); const start = startDate.startOf("day");
const end = endDate.endOf("day"); const end = endDate.endOf("day");


@@ -539,24 +534,33 @@ const FinishedGoodCartonDashboardTab: React.FC<Props> = ({ mode = "normal" }) =>
} }


const dayKey = deliveryDay.format("YYYY-MM-DD"); 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 cartonQty = Number(record.numberOfCartons ?? 0);
const current = summaryMap.get(dayKey) ?? {
const current = summaryMap.get(mapKey) ?? {
date: dayKey, date: dayKey,
shopCode,
shopName,
floor2F: 0, floor2F: 0,
floor4F: 0, floor4F: 0,
truckX: 0, truckX: 0,
total: 0, total: 0,
}; };
if (!current.shopName && shopName) current.shopName = shopName;


if (record.storeId === "2/F") current.floor2F += cartonQty; if (record.storeId === "2/F") current.floor2F += cartonQty;
if (record.storeId === "4/F") current.floor4F += cartonQty; if (record.storeId === "4/F") current.floor4F += cartonQty;
if (normalizeTruckLane(record.truckLanceCode) === TRUCK_X_LANE) current.truckX += cartonQty; if (normalizeTruckLane(record.truckLanceCode) === TRUCK_X_LANE) current.truckX += cartonQty;


current.total += cartonQty; current.total += cartonQty;
summaryMap.set(dayKey, current);
summaryMap.set(mapKey, current);
}); });


return Array.from(summaryMap.values()).sort((a, b) => a.date.localeCompare(b.date));
return Array.from(summaryMap.values()).sort(
(a, b) =>
a.date.localeCompare(b.date) || a.shopCode.localeCompare(b.shopCode, "zh-Hant"),
);
}, },
[], [],
); );
@@ -578,10 +582,18 @@ const FinishedGoodCartonDashboardTab: React.FC<Props> = ({ mode = "normal" }) =>
const summaryTitleRow = 4 + dataRowsCount; const summaryTitleRow = 4 + dataRowsCount;
const summaryStartRow = 5 + dataRowsCount; const summaryStartRow = 5 + dataRowsCount;


worksheet["!cols"] = [{ wch: 16 }, { wch: 14 }, { wch: 14 }, { wch: 16 }, { wch: 14 }];
worksheet["!cols"] = [
{ wch: 14 },
{ wch: 14 },
{ wch: 28 },
{ wch: 16 },
{ wch: 16 },
{ wch: 18 },
{ wch: 14 },
];
worksheet["!merges"] = [ worksheet["!merges"] = [
{ s: { r: 0, c: 0 }, e: { r: 0, c: 4 } },
{ s: { r: summaryTitleRow, c: 0 }, e: { r: summaryTitleRow, c: 4 } },
{ 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 = { const titleStyle = {
@@ -620,29 +632,32 @@ const FinishedGoodCartonDashboardTab: React.FC<Props> = ({ mode = "normal" }) =>
alignment: { horizontal: "left", vertical: "center" }, alignment: { horizontal: "left", vertical: "center" },
}; };


for (let c = 0; c <= 4; c += 1) {
for (let c = 0; c <= DAILY_SHEET_LAST_COL; c += 1) {
const headerCell = XLSX.utils.encode_cell({ r: 2, c }); const headerCell = XLSX.utils.encode_cell({ r: 2, c });
if (worksheet[headerCell]) worksheet[headerCell].s = headerStyle; if (worksheet[headerCell]) worksheet[headerCell].s = headerStyle;
} }


for (let r = 3; r < 3 + dataRowsCount; r += 1) { for (let r = 3; r < 3 + dataRowsCount; r += 1) {
for (let c = 0; c <= 4; c += 1) {
for (let c = 0; c <= DAILY_SHEET_LAST_COL; c += 1) {
const addr = XLSX.utils.encode_cell({ r, c }); const addr = XLSX.utils.encode_cell({ r, c });
if (!worksheet[addr]) continue; if (!worksheet[addr]) continue;
worksheet[addr].s = c === 0 ? cellStyle : numberStyle;
worksheet[addr].s = c < 3 ? cellStyle : numberStyle;
} }
} }


for (let r = summaryStartRow; r <= summaryStartRow + 3; r += 1) { for (let r = summaryStartRow; r <= summaryStartRow + 3; r += 1) {
const labelAddr = XLSX.utils.encode_cell({ r, c: 0 });
const valueAddr = XLSX.utils.encode_cell({ r, c: 1 });
if (worksheet[labelAddr]) worksheet[labelAddr].s = cellStyle;
if (worksheet[valueAddr]) worksheet[valueAddr].s = numberStyle;
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; if (worksheet["A1"]) worksheet["A1"].s = titleStyle;
const summaryTitleAddr = XLSX.utils.encode_cell({ r: summaryTitleRow, c: 0 }); const summaryTitleAddr = XLSX.utils.encode_cell({ r: summaryTitleRow, c: 0 });
if (worksheet[summaryTitleAddr]) worksheet[summaryTitleAddr].s = summaryTitleStyle; if (worksheet[summaryTitleAddr]) worksheet[summaryTitleAddr].s = summaryTitleStyle;
applyProjectExcelFont(worksheet);
}, []); }, []);


const addReportSheet = useCallback( const addReportSheet = useCallback(
@@ -650,26 +665,37 @@ const FinishedGoodCartonDashboardTab: React.FC<Props> = ({ mode = "normal" }) =>
workbook: XLSX.WorkBook, workbook: XLSX.WorkBook,
sheetName: string, sheetName: string,
reportTitle: string, reportTitle: string,
dailyRows: DailySummaryRow[],
dailyRows: ShopDailyRow[],
) => { ) => {
const reportSummary = calcSummary(dailyRows); const reportSummary = calcSummary(dailyRows);
const blank = ["", "", "", "", "", "", ""];
const aoa: (string | number)[][] = [ const aoa: (string | number)[][] = [
[reportTitle, "", "", "", ""],
["", "", "", "", ""],
[reportTitle, ...blank.slice(1)],
[...blank],
[ [
t("Date"), t("Date"),
t("Shop code"),
t("Shop Name"),
t("2/F carton qty"), t("2/F carton qty"),
t("4/F carton qty"), t("4/F carton qty"),
t("Truck X carton qty"), t("Truck X carton qty"),
t("Total carton qty"), t("Total carton qty"),
], ],
...dailyRows.map((row) => [row.date, row.floor2F, row.floor4F, row.truckX, row.total]),
["", "", "", "", ""],
[t("Summary"), "", "", "", ""],
[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, "", "", ""],
...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); const worksheet = XLSX.utils.aoa_to_sheet(aoa);
@@ -684,25 +710,24 @@ const FinishedGoodCartonDashboardTab: React.FC<Props> = ({ mode = "normal" }) =>
workbook: XLSX.WorkBook, workbook: XLSX.WorkBook,
sheetName: string, sheetName: string,
reportTitle: string, reportTitle: string,
categoryLabel: string,
slices: BreakdownRow[],
shops: ShopQtyRow[],
totalQty: number, totalQty: number,
) => { ) => {
const aoa: (string | number)[][] = [ const aoa: (string | number)[][] = [
[reportTitle, "", ""],
["", "", ""],
[categoryLabel, t("Cartons"), t("Share")],
...slices.map((row) => {
[reportTitle, "", "", ""],
["", "", "", ""],
[t("Shop code"), t("Shop Name"), t("Cartons"), t("Share")],
...shops.map((row) => {
const share = totalQty > 0 ? (row.qty / totalQty) * 100 : 0; const share = totalQty > 0 ? (row.qty / totalQty) * 100 : 0;
return [row.label, row.qty, `${share.toFixed(1)}%`];
return [row.code, row.name, row.qty, `${share.toFixed(1)}%`];
}), }),
["", "", ""],
[t("Total carton qty"), totalQty, ""],
["", "", "", ""],
[t("Total carton qty"), "", totalQty, ""],
]; ];


const worksheet = XLSX.utils.aoa_to_sheet(aoa); const worksheet = XLSX.utils.aoa_to_sheet(aoa);
worksheet["!cols"] = [{ wch: 28 }, { wch: 14 }, { wch: 12 }];
worksheet["!merges"] = [{ s: { r: 0, c: 0 }, e: { r: 0, c: 2 } }];
worksheet["!cols"] = [{ wch: 14 }, { wch: 28 }, { wch: 14 }, { wch: 12 }];
worksheet["!merges"] = [{ s: { r: 0, c: 0 }, e: { r: 0, c: 3 } }];


const titleStyle = { const titleStyle = {
font: { bold: true, sz: 14, color: { rgb: "1F2D3D" } }, font: { bold: true, sz: 14, color: { rgb: "1F2D3D" } },
@@ -730,24 +755,27 @@ const FinishedGoodCartonDashboardTab: React.FC<Props> = ({ mode = "normal" }) =>
}; };


if (worksheet["A1"]) worksheet["A1"].s = titleStyle; if (worksheet["A1"]) worksheet["A1"].s = titleStyle;
for (let c = 0; c <= 2; c += 1) {
for (let c = 0; c <= 3; c += 1) {
const headerCell = XLSX.utils.encode_cell({ r: 2, c }); const headerCell = XLSX.utils.encode_cell({ r: 2, c });
if (worksheet[headerCell]) worksheet[headerCell].s = headerStyle; if (worksheet[headerCell]) worksheet[headerCell].s = headerStyle;
} }
slices.forEach((_, index) => {
shops.forEach((_, index) => {
const r = 3 + index; const r = 3 + index;
const labelAddr = XLSX.utils.encode_cell({ r, c: 0 });
const qtyAddr = XLSX.utils.encode_cell({ r, c: 1 });
const shareAddr = XLSX.utils.encode_cell({ r, c: 2 });
if (worksheet[labelAddr]) worksheet[labelAddr].s = cellStyle;
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[qtyAddr]) worksheet[qtyAddr].s = numberStyle;
if (worksheet[shareAddr]) worksheet[shareAddr].s = cellStyle; if (worksheet[shareAddr]) worksheet[shareAddr].s = cellStyle;
}); });
const totalRow = 4 + slices.length;
const totalRow = 4 + shops.length;
const totalLabelAddr = XLSX.utils.encode_cell({ r: totalRow, c: 0 }); const totalLabelAddr = XLSX.utils.encode_cell({ r: totalRow, c: 0 });
const totalValueAddr = XLSX.utils.encode_cell({ r: totalRow, c: 1 });
const totalValueAddr = XLSX.utils.encode_cell({ r: totalRow, c: 2 });
if (worksheet[totalLabelAddr]) worksheet[totalLabelAddr].s = cellStyle; if (worksheet[totalLabelAddr]) worksheet[totalLabelAddr].s = cellStyle;
if (worksheet[totalValueAddr]) worksheet[totalValueAddr].s = numberStyle; if (worksheet[totalValueAddr]) worksheet[totalValueAddr].s = numberStyle;
applyProjectExcelFont(worksheet);


XLSX.utils.book_append_sheet(workbook, worksheet, sheetName.slice(0, 31)); XLSX.utils.book_append_sheet(workbook, worksheet, sheetName.slice(0, 31));
}, },
@@ -844,7 +872,8 @@ const FinishedGoodCartonDashboardTab: React.FC<Props> = ({ mode = "normal" }) =>
const baseDate = dayjs(date || undefined).isValid() ? dayjs(date) : dayjs(); const baseDate = dayjs(date || undefined).isValid() ? dayjs(date) : dayjs();
const floorLabel = floor === ALL ? t("All floors") : floor; const floorLabel = floor === ALL ? t("All floors") : floor;
const laneLabel = lane === ALL ? t("All lanes") : shortLaneLabel(lane); const laneLabel = lane === ALL ? t("All lanes") : shortLaneLabel(lane);
const shopLabel = shop === ALL ? t("All shops") : shop;
const shopLabel =
shop === ALL ? t("All shops") : isShopGroupValue(shop) ? shop : formatShopLabel(shop, "");
const dateLabel = baseDate.format("YYYY-MM-DD"); const dateLabel = baseDate.format("YYYY-MM-DD");
const reportTitle = t("FG carton qty filtered title", { const reportTitle = t("FG carton qty filtered title", {
floor: floorLabel, floor: floorLabel,
@@ -852,12 +881,6 @@ const FinishedGoodCartonDashboardTab: React.FC<Props> = ({ mode = "normal" }) =>
shop: shopLabel, shop: shopLabel,
date: dateLabel, date: dateLabel,
}); });
const categoryLabel =
breakdownDimension === "shop"
? t("Shop Code")
: breakdownDimension === "floor"
? t("Floor")
: t("Lane");


const dailyRows = buildDailyRowsFromRecords( const dailyRows = buildDailyRowsFromRecords(
records, records,
@@ -868,15 +891,17 @@ const FinishedGoodCartonDashboardTab: React.FC<Props> = ({ mode = "normal" }) =>
shop, shop,
); );
const filteredTotal = calcSummary(dailyRows).total; const filteredTotal = calcSummary(dailyRows).total;
const shopRows = buildShopQtyRows(
records.filter((record) => recordMatchesFilters(record, floor, lane, shop)),
);


const workbook = XLSX.utils.book_new(); const workbook = XLSX.utils.book_new();
addReportSheet(workbook, t("Filtered"), reportTitle, dailyRows); addReportSheet(workbook, t("Filtered"), reportTitle, dailyRows);
addBreakdownSheet( addBreakdownSheet(
workbook, workbook,
t("Breakdown"), t("Breakdown"),
breakdownCaption,
categoryLabel,
breakdownRows,
t("Cartons by shop"),
shopRows,
filteredTotal, filteredTotal,
); );


@@ -899,9 +924,6 @@ const FinishedGoodCartonDashboardTab: React.FC<Props> = ({ mode = "normal" }) =>
lane, lane,
shop, shop,
records, records,
breakdownDimension,
breakdownCaption,
breakdownRows,
buildDailyRowsFromRecords, buildDailyRowsFromRecords,
addReportSheet, addReportSheet,
addBreakdownSheet, addBreakdownSheet,
@@ -982,20 +1004,29 @@ const FinishedGoodCartonDashboardTab: React.FC<Props> = ({ mode = "normal" }) =>
<SearchableFilterSelect <SearchableFilterSelect
label={t("Shop Code")} label={t("Shop Code")}
options={shopFilterOptions} options={shopFilterOptions}
value={shop}
value={shopFilterValue}
onChange={setShop} onChange={setShop}
/> />
</Grid> </Grid>
<Grid item xs={12} sm={6} md={3}> <Grid item xs={12} sm={6} md={3}>
<TextField
fullWidth
size="small"
label={t("Date")}
type="date"
value={date}
InputLabelProps={{ shrink: true }}
onChange={(event) => setDate(event.target.value)}
/>
<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>
</Grid> </Grid>


@@ -1038,93 +1069,134 @@ const FinishedGoodCartonDashboardTab: React.FC<Props> = ({ mode = "normal" }) =>
</Paper> </Paper>


<Paper sx={{ p: 1.5 }}> <Paper sx={{ p: 1.5 }}>
<Typography variant="subtitle2" color="text.secondary">
{breakdownCaption}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mb: 1 }}>
{t("Click a row to filter")}
</Typography>
<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 ? ( {breakdownRows.length === 0 ? (
<Typography sx={{ py: 3 }} color="text.secondary"> <Typography sx={{ py: 3 }} color="text.secondary">
{t("No data available")} {t("No data available")}
</Typography> </Typography>
) : ( ) : (
<Box sx={{ display: "flex", alignItems: "stretch" }}>
<Box
sx={{
width: { xs: 112, sm: 168, md: 220 },
flexShrink: 0,
pr: 1.5,
}}
>
{breakdownRows.map((row) => {
const active = isBreakdownRowActive(row);
return (
<Box
key={row.key}
role="button"
tabIndex={0}
onClick={() => applyBreakdownClick(row)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
applyBreakdownClick(row);
}
<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={{ sx={{
height: CHART_ROW_H,
height: "100%",
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
minWidth: 0, minWidth: 0,
cursor: "pointer",
borderRadius: 1,
px: 0.5,
bgcolor: active ? "action.selected" : "transparent",
"&:hover": { bgcolor: "action.hover" },
}} }}
> >
<Typography
component="span"
title={row.label}
noWrap
<Box
sx={{ sx={{
fontWeight: 700,
fontSize: 13,
color: "text.primary",
minWidth: 0,
height: 22,
width: barWidth,
minWidth: row.qty > 0 ? 4 : 0,
bgcolor: "#1976d2",
borderRadius: "4px",
}} }}
>
{row.label}
</Typography>
/>
</Box> </Box>
);
})}
</Box>
<Box
sx={{
flex: 1,
minWidth: 0,
cursor: "pointer",
"& .apexcharts-bar-area, & .apexcharts-series path": {
cursor: "pointer",
},
}}
onClick={(event) => {
const rect = event.currentTarget.getBoundingClientRect();
const index = Math.floor((event.clientY - rect.top) / CHART_ROW_H);
const row = breakdownRows[index];
if (row) applyBreakdownClick(row);
}}
>
<SafeApexCharts
type="bar"
height={chartHeight}
options={chartOptions}
series={chartSeries}
allowZeroSeries
chartRevision={`${floor}-${lane}-${shop}-${date}-${breakdownDimension}-${breakdownRows.length}`}
/>
</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> </Box>
)} )}
</Paper> </Paper>


+ 3
- 1
src/i18n/en/doWorkbench.json Просмотреть файл

@@ -61,6 +61,8 @@
"2/F or 4/F": "2/F or 4/F", "2/F or 4/F": "2/F or 4/F",
"Lane": "Lane", "Lane": "Lane",
"Shop Code": "Shop group", "Shop Code": "Shop group",
"Shop code": "Shop code",
"Back to previous level": "Back",
"All lanes": "All lanes", "All lanes": "All lanes",
"All shops": "All shop groups", "All shops": "All shop groups",
"Cartons by lane": "Cartons by lane", "Cartons by lane": "Cartons by lane",
@@ -75,7 +77,7 @@
"Date": "Date", "Date": "Date",
"Download Excel": "Download Excel", "Download Excel": "Download Excel",
"Download this view Excel": "Download Excel (Selected)", "Download this view Excel": "Download Excel (Selected)",
"Download period Excel": "Download Excel (All)",
"Download period Excel": "Download Excel (Complete)",
"Download this view Excel hint": "Excel of the current floor, lane, shop, and date, plus the chart breakdown.", "Download this view Excel hint": "Excel of the current floor, lane, shop, and date, plus the chart breakdown.",
"Download period Excel hint": "Excel with last 7 days, this month, and this year. Not limited to the current view.", "Download period Excel hint": "Excel with last 7 days, this month, and this year. Not limited to the current view.",
"Filtered": "Filtered", "Filtered": "Filtered",


+ 3
- 1
src/i18n/en/pickOrder.json Просмотреть файл

@@ -625,7 +625,7 @@
"Exporting...": "Exporting...", "Exporting...": "Exporting...",
"Download Excel": "Download Excel", "Download Excel": "Download Excel",
"Download this view Excel": "Download Excel (Selected)", "Download this view Excel": "Download Excel (Selected)",
"Download period Excel": "Download Excel (All)",
"Download period Excel": "Download Excel (Complete)",
"Download this view Excel hint": "Excel of the current floor, lane, shop, and date, plus the chart breakdown.", "Download this view Excel hint": "Excel of the current floor, lane, shop, and date, plus the chart breakdown.",
"Download period Excel hint": "Excel with last 7 days, this month, and this year. Not limited to the current view.", "Download period Excel hint": "Excel with last 7 days, this month, and this year. Not limited to the current view.",
"Filtered": "Filtered", "Filtered": "Filtered",
@@ -635,6 +635,8 @@
"2/F or 4/F": "2/F or 4/F", "2/F or 4/F": "2/F or 4/F",
"Lane": "Lane", "Lane": "Lane",
"Shop Code": "Shop group", "Shop Code": "Shop group",
"Shop code": "Shop code",
"Back to previous level": "Back",
"All lanes": "All lanes", "All lanes": "All lanes",
"All shops": "All shop groups", "All shops": "All shop groups",
"Cartons by lane": "Cartons by lane", "Cartons by lane": "Cartons by lane",


+ 3
- 1
src/i18n/zh/doWorkbench.json Просмотреть файл

@@ -56,6 +56,8 @@
"2/F or 4/F": "2/F 或 4/F", "2/F or 4/F": "2/F 或 4/F",
"Lane": "車線", "Lane": "車線",
"Shop Code": "店鋪組別", "Shop Code": "店鋪組別",
"Shop code": "店鋪編號",
"Back to previous level": "返回上一層",
"All lanes": "全部車線", "All lanes": "全部車線",
"All shops": "全部店鋪組別", "All shops": "全部店鋪組別",
"Cartons by lane": "按車線統計箱數", "Cartons by lane": "按車線統計箱數",
@@ -70,7 +72,7 @@
"Date": "日期", "Date": "日期",
"Download Excel": "下載 Excel", "Download Excel": "下載 Excel",
"Download this view Excel": "下載 Excel(已選)", "Download this view Excel": "下載 Excel(已選)",
"Download period Excel": "下載 Excel(全部)",
"Download period Excel": "下載 Excel(完整)",
"Download this view Excel hint": "匯出目前樓層、車線、店鋪組別、日期的箱數,以及圖表分項。", "Download this view Excel hint": "匯出目前樓層、車線、店鋪組別、日期的箱數,以及圖表分項。",
"Download period Excel hint": "匯出近7天、本月、本年的箱數,不限目前篩選畫面。", "Download period Excel hint": "匯出近7天、本月、本年的箱數,不限目前篩選畫面。",
"Filtered": "篩選", "Filtered": "篩選",


+ 3
- 1
src/i18n/zh/pickOrder.json Просмотреть файл

@@ -637,7 +637,7 @@
"Exporting...": "匯出中...", "Exporting...": "匯出中...",
"Download Excel": "下載 Excel", "Download Excel": "下載 Excel",
"Download this view Excel": "下載 Excel(已選)", "Download this view Excel": "下載 Excel(已選)",
"Download period Excel": "下載 Excel(全部)",
"Download period Excel": "下載 Excel(完整)",
"Download this view Excel hint": "匯出目前樓層、車線、店鋪組別、日期的箱數,以及圖表分項。", "Download this view Excel hint": "匯出目前樓層、車線、店鋪組別、日期的箱數,以及圖表分項。",
"Download period Excel hint": "匯出近7天、本月、本年的箱數,不限目前篩選畫面。", "Download period Excel hint": "匯出近7天、本月、本年的箱數,不限目前篩選畫面。",
"Filtered": "篩選", "Filtered": "篩選",
@@ -647,6 +647,8 @@
"2/F or 4/F": "2/F 或 4/F", "2/F or 4/F": "2/F 或 4/F",
"Lane": "車線", "Lane": "車線",
"Shop Code": "店鋪組別", "Shop Code": "店鋪組別",
"Shop code": "店鋪編號",
"Back to previous level": "返回上一層",
"All lanes": "全部車線", "All lanes": "全部車線",
"All shops": "全部店鋪組別", "All shops": "全部店鋪組別",
"Cartons by lane": "按車線統計箱數", "Cartons by lane": "按車線統計箱數",


Загрузка…
Отмена
Сохранить