|
|
|
@@ -15,15 +15,18 @@ import { |
|
|
|
Tooltip, |
|
|
|
Typography, |
|
|
|
} 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/locale/zh-hk"; |
|
|
|
import * as XLSX from "xlsx-js-style"; |
|
|
|
import { |
|
|
|
CompletedDoPickOrderResponse, |
|
|
|
fetchCompletedDoPickOrdersAll, |
|
|
|
fetchCompletedDoPickOrdersWorkbenchAll, |
|
|
|
} from "@/app/api/pickOrder/actions"; |
|
|
|
import SafeApexCharts from "@/components/charts/SafeApexCharts"; |
|
|
|
import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; |
|
|
|
import { useTranslation } from "react-i18next"; |
|
|
|
|
|
|
|
type FloorFilter = "all" | "2/F" | "4/F"; |
|
|
|
@@ -37,6 +40,17 @@ type DailySummaryRow = { |
|
|
|
total: number; |
|
|
|
}; |
|
|
|
|
|
|
|
type ShopDailyRow = DailySummaryRow & { |
|
|
|
shopCode: string; |
|
|
|
shopName: string; |
|
|
|
}; |
|
|
|
|
|
|
|
type ShopQtyRow = { |
|
|
|
code: string; |
|
|
|
name: string; |
|
|
|
qty: number; |
|
|
|
}; |
|
|
|
|
|
|
|
type BreakdownRow = { |
|
|
|
key: string; |
|
|
|
label: string; |
|
|
|
@@ -50,6 +64,9 @@ type Props = { |
|
|
|
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 }; |
|
|
|
|
|
|
|
@@ -61,7 +78,7 @@ function normalizeTruckLane(raw: string | null | undefined): string { |
|
|
|
function shortLaneLabel(raw: string | null | undefined): string { |
|
|
|
const value = normalizeTruckLane(raw); |
|
|
|
const stripped = value |
|
|
|
.replace(/^(車線)[-–—]?\s*/u, "") |
|
|
|
.replace(/^(車線)[-–—]?\s*/, "") |
|
|
|
.replace(/^(truck)\s*[-–—]?\s*/i, "") |
|
|
|
.trim(); |
|
|
|
return stripped || value; |
|
|
|
@@ -77,6 +94,77 @@ function shopGroup(raw: string | null | undefined): string { |
|
|
|
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(); |
|
|
|
@@ -110,10 +198,8 @@ function recordMatchesFilters( |
|
|
|
} |
|
|
|
|
|
|
|
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 (shop !== ALL) return "lane"; |
|
|
|
return "lane"; |
|
|
|
} |
|
|
|
|
|
|
|
@@ -239,13 +325,18 @@ const FinishedGoodCartonDashboardTab: React.FC<Props> = ({ mode = "normal" }) => |
|
|
|
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 (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; |
|
|
|
}, [shopOptions, shop, t]); |
|
|
|
}, [shopOptions, shopFilterValue, t]); |
|
|
|
|
|
|
|
const filteredRecords = useMemo( |
|
|
|
() => records.filter((record) => recordMatchesFilters(record, floor, lane, shop)), |
|
|
|
@@ -330,12 +421,13 @@ const FinishedGoodCartonDashboardTab: React.FC<Props> = ({ mode = "normal" }) => |
|
|
|
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; |
|
|
|
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; |
|
|
|
grouped.set(key, current); |
|
|
|
}); |
|
|
|
@@ -372,19 +464,27 @@ const FinishedGoodCartonDashboardTab: React.FC<Props> = ({ mode = "normal" }) => |
|
|
|
} |
|
|
|
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], |
|
|
|
); |
|
|
|
|
|
|
|
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; |
|
|
|
@@ -409,111 +509,6 @@ const FinishedGoodCartonDashboardTab: React.FC<Props> = ({ mode = "normal" }) => |
|
|
|
}, [rows]); |
|
|
|
|
|
|
|
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( |
|
|
|
( |
|
|
|
@@ -523,8 +518,8 @@ const FinishedGoodCartonDashboardTab: React.FC<Props> = ({ mode = "normal" }) => |
|
|
|
selectedFloor: FloorFilter, |
|
|
|
selectedLane: string, |
|
|
|
selectedShop: string, |
|
|
|
): DailySummaryRow[] => { |
|
|
|
const summaryMap = new Map<string, DailySummaryRow>(); |
|
|
|
): ShopDailyRow[] => { |
|
|
|
const summaryMap = new Map<string, ShopDailyRow>(); |
|
|
|
const start = startDate.startOf("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 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(dayKey) ?? { |
|
|
|
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(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 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"] = [ |
|
|
|
{ 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 = { |
|
|
|
@@ -620,29 +632,32 @@ const FinishedGoodCartonDashboardTab: React.FC<Props> = ({ mode = "normal" }) => |
|
|
|
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 }); |
|
|
|
if (worksheet[headerCell]) worksheet[headerCell].s = headerStyle; |
|
|
|
} |
|
|
|
|
|
|
|
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 }); |
|
|
|
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) { |
|
|
|
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; |
|
|
|
const summaryTitleAddr = XLSX.utils.encode_cell({ r: summaryTitleRow, c: 0 }); |
|
|
|
if (worksheet[summaryTitleAddr]) worksheet[summaryTitleAddr].s = summaryTitleStyle; |
|
|
|
applyProjectExcelFont(worksheet); |
|
|
|
}, []); |
|
|
|
|
|
|
|
const addReportSheet = useCallback( |
|
|
|
@@ -650,26 +665,37 @@ const FinishedGoodCartonDashboardTab: React.FC<Props> = ({ mode = "normal" }) => |
|
|
|
workbook: XLSX.WorkBook, |
|
|
|
sheetName: string, |
|
|
|
reportTitle: string, |
|
|
|
dailyRows: DailySummaryRow[], |
|
|
|
dailyRows: ShopDailyRow[], |
|
|
|
) => { |
|
|
|
const reportSummary = calcSummary(dailyRows); |
|
|
|
const blank = ["", "", "", "", "", "", ""]; |
|
|
|
const aoa: (string | number)[][] = [ |
|
|
|
[reportTitle, "", "", "", ""], |
|
|
|
["", "", "", "", ""], |
|
|
|
[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.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); |
|
|
|
@@ -684,25 +710,24 @@ const FinishedGoodCartonDashboardTab: React.FC<Props> = ({ mode = "normal" }) => |
|
|
|
workbook: XLSX.WorkBook, |
|
|
|
sheetName: string, |
|
|
|
reportTitle: string, |
|
|
|
categoryLabel: string, |
|
|
|
slices: BreakdownRow[], |
|
|
|
shops: ShopQtyRow[], |
|
|
|
totalQty: 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; |
|
|
|
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); |
|
|
|
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 = { |
|
|
|
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; |
|
|
|
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 }); |
|
|
|
if (worksheet[headerCell]) worksheet[headerCell].s = headerStyle; |
|
|
|
} |
|
|
|
slices.forEach((_, index) => { |
|
|
|
shops.forEach((_, 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[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 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[totalValueAddr]) worksheet[totalValueAddr].s = numberStyle; |
|
|
|
applyProjectExcelFont(worksheet); |
|
|
|
|
|
|
|
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 floorLabel = floor === ALL ? t("All floors") : floor; |
|
|
|
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 reportTitle = t("FG carton qty filtered title", { |
|
|
|
floor: floorLabel, |
|
|
|
@@ -852,12 +881,6 @@ const FinishedGoodCartonDashboardTab: React.FC<Props> = ({ mode = "normal" }) => |
|
|
|
shop: shopLabel, |
|
|
|
date: dateLabel, |
|
|
|
}); |
|
|
|
const categoryLabel = |
|
|
|
breakdownDimension === "shop" |
|
|
|
? t("Shop Code") |
|
|
|
: breakdownDimension === "floor" |
|
|
|
? t("Floor") |
|
|
|
: t("Lane"); |
|
|
|
|
|
|
|
const dailyRows = buildDailyRowsFromRecords( |
|
|
|
records, |
|
|
|
@@ -868,15 +891,17 @@ const FinishedGoodCartonDashboardTab: React.FC<Props> = ({ mode = "normal" }) => |
|
|
|
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"), |
|
|
|
breakdownCaption, |
|
|
|
categoryLabel, |
|
|
|
breakdownRows, |
|
|
|
t("Cartons by shop"), |
|
|
|
shopRows, |
|
|
|
filteredTotal, |
|
|
|
); |
|
|
|
|
|
|
|
@@ -899,9 +924,6 @@ const FinishedGoodCartonDashboardTab: React.FC<Props> = ({ mode = "normal" }) => |
|
|
|
lane, |
|
|
|
shop, |
|
|
|
records, |
|
|
|
breakdownDimension, |
|
|
|
breakdownCaption, |
|
|
|
breakdownRows, |
|
|
|
buildDailyRowsFromRecords, |
|
|
|
addReportSheet, |
|
|
|
addBreakdownSheet, |
|
|
|
@@ -982,20 +1004,29 @@ const FinishedGoodCartonDashboardTab: React.FC<Props> = ({ mode = "normal" }) => |
|
|
|
<SearchableFilterSelect |
|
|
|
label={t("Shop Code")} |
|
|
|
options={shopFilterOptions} |
|
|
|
value={shop} |
|
|
|
value={shopFilterValue} |
|
|
|
onChange={setShop} |
|
|
|
/> |
|
|
|
</Grid> |
|
|
|
<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> |
|
|
|
|
|
|
|
@@ -1038,93 +1069,134 @@ const FinishedGoodCartonDashboardTab: React.FC<Props> = ({ mode = "normal" }) => |
|
|
|
</Paper> |
|
|
|
|
|
|
|
<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 ? ( |
|
|
|
<Typography sx={{ py: 3 }} color="text.secondary"> |
|
|
|
{t("No data available")} |
|
|
|
</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={{ |
|
|
|
height: CHART_ROW_H, |
|
|
|
height: "100%", |
|
|
|
display: "flex", |
|
|
|
alignItems: "center", |
|
|
|
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={{ |
|
|
|
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 |
|
|
|
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> |
|
|
|
)} |
|
|
|
</Paper> |
|
|
|
|