| @@ -17,7 +17,7 @@ import OutboundOutlinedIcon from "@mui/icons-material/OutboundOutlined"; | |||
| import BarChartOutlinedIcon from "@mui/icons-material/BarChartOutlined"; | |||
| import PieChartOutlineOutlinedIcon from "@mui/icons-material/PieChartOutlineOutlined"; | |||
| import type { SvgIconComponent } from "@mui/icons-material"; | |||
| import { REPORTS } from "@/config/reportConfig"; | |||
| import { DEMO_UNAVAILABLE_REPORT_IDS, REPORTS } from "@/config/reportConfig"; | |||
| import { REPORT_CATEGORIES, type ReportCategoryConfig } from "./reportCategories"; | |||
| import { useReportLabels } from "./reportI18n"; | |||
| @@ -52,12 +52,16 @@ function ReportCard({ | |||
| title, | |||
| accent, | |||
| selected, | |||
| unavailable, | |||
| unavailableLabel, | |||
| onClick, | |||
| }: { | |||
| reportId: string; | |||
| title: string; | |||
| accent: string; | |||
| selected: boolean; | |||
| unavailable: boolean; | |||
| unavailableLabel: string; | |||
| onClick: () => void; | |||
| }) { | |||
| const Icon = REPORT_ICON_MAP[reportId] ?? Inventory2OutlinedIcon; | |||
| @@ -66,7 +70,9 @@ function ReportCard({ | |||
| <Box | |||
| component="button" | |||
| type="button" | |||
| onClick={onClick} | |||
| disabled={unavailable} | |||
| onClick={unavailable ? undefined : onClick} | |||
| aria-disabled={unavailable} | |||
| sx={{ | |||
| display: "flex", | |||
| alignItems: "center", | |||
| @@ -74,17 +80,22 @@ function ReportCard({ | |||
| width: "100%", | |||
| p: 1.5, | |||
| textAlign: "left", | |||
| cursor: "pointer", | |||
| cursor: unavailable ? "not-allowed" : "pointer", | |||
| border: "1px solid", | |||
| borderColor: selected ? accent : "divider", | |||
| borderColor: unavailable ? "action.disabledBackground" : selected ? accent : "divider", | |||
| borderRadius: 1.5, | |||
| bgcolor: "background.paper", | |||
| boxShadow: selected ? 2 : "0 1px 3px rgba(0,0,0,0.06)", | |||
| bgcolor: unavailable ? "action.hover" : "background.paper", | |||
| color: unavailable ? "text.disabled" : "text.primary", | |||
| boxShadow: !unavailable && selected ? 2 : "0 1px 3px rgba(0,0,0,0.06)", | |||
| opacity: unavailable ? 0.72 : 1, | |||
| filter: unavailable ? "grayscale(1)" : "none", | |||
| transition: "border-color 0.15s, box-shadow 0.15s", | |||
| "&:hover": { | |||
| borderColor: accent, | |||
| boxShadow: 2, | |||
| }, | |||
| "&:hover": unavailable | |||
| ? {} | |||
| : { | |||
| borderColor: accent, | |||
| boxShadow: 2, | |||
| }, | |||
| }} | |||
| > | |||
| <Box | |||
| @@ -95,16 +106,23 @@ function ReportCard({ | |||
| width: 40, | |||
| height: 40, | |||
| borderRadius: 1, | |||
| bgcolor: `${accent}18`, | |||
| color: accent, | |||
| bgcolor: unavailable ? "action.disabledBackground" : `${accent}18`, | |||
| color: unavailable ? "text.disabled" : accent, | |||
| flexShrink: 0, | |||
| }} | |||
| > | |||
| <Icon fontSize="small" /> | |||
| </Box> | |||
| <Typography variant="body2" fontWeight={selected ? 600 : 400} sx={{ lineHeight: 1.35 }}> | |||
| {title} | |||
| </Typography> | |||
| <Box sx={{ minWidth: 0 }}> | |||
| <Typography variant="body2" fontWeight={selected && !unavailable ? 600 : 400} sx={{ lineHeight: 1.35 }}> | |||
| {title} | |||
| </Typography> | |||
| {unavailable && ( | |||
| <Typography variant="caption" sx={{ display: "block", lineHeight: 1.3, color: "text.secondary" }}> | |||
| {unavailableLabel} | |||
| </Typography> | |||
| )} | |||
| </Box> | |||
| </Box> | |||
| ); | |||
| } | |||
| @@ -118,7 +136,7 @@ function CategoryColumn({ | |||
| selectedReportId: string; | |||
| onSelectReport: (reportId: string) => void; | |||
| }) { | |||
| const { reportTitle, categoryTitle } = useReportLabels(); | |||
| const { t, reportTitle, categoryTitle } = useReportLabels(); | |||
| const reports = category.reportIds | |||
| .map((id) => reportById[id]) | |||
| .filter(Boolean); | |||
| @@ -161,6 +179,8 @@ function CategoryColumn({ | |||
| title={reportTitle(report)} | |||
| accent={category.accent} | |||
| selected={selectedReportId === report.id} | |||
| unavailable={DEMO_UNAVAILABLE_REPORT_IDS.has(report.id)} | |||
| unavailableLabel={t("unavailableInDemo")} | |||
| onClick={() => onSelectReport(report.id)} | |||
| /> | |||
| </Grid> | |||
| @@ -205,7 +205,7 @@ function toMaterialExcelRow( | |||
| export async function generateBomShopSyncReportExcel( | |||
| criteria: Record<string, string>, | |||
| reportTitle: string = "M18 BOM Shop 同步記錄", | |||
| reportTitle: string = "BOM Shop 同步記錄", | |||
| t?: TFunction, | |||
| ): Promise<void> { | |||
| const L = bomSyncLabels(t); | |||
| @@ -33,12 +33,6 @@ export interface GrnReportRow { | |||
| lineAmount?: number; | |||
| /** PO currency code (currency.code) */ | |||
| currencyCode?: string; | |||
| /** M18 AN document code from m18_goods_receipt_note_log.grn_code */ | |||
| grnCode?: string; | |||
| /** M18 record id (m18_record_id) */ | |||
| grnId?: number | string; | |||
| /** From purchase_order.m18CreatedUId; e.g. "2569 (legato)" */ | |||
| poM18CreatorDisplay?: string; | |||
| [key: string]: unknown; | |||
| } | |||
| @@ -54,7 +48,6 @@ export interface ListedPoAmounts { | |||
| poCode?: string; | |||
| currencyCode?: string; | |||
| totalAmount?: number; | |||
| grnCodes?: string; | |||
| }[]; | |||
| } | |||
| @@ -148,13 +141,9 @@ function grnLabels(t?: TFunction) { | |||
| unitPrice: tx(t, "excel.grn.unitPrice", "單價"), | |||
| currency: tx(t, "excel.grn.currency", "貨幣"), | |||
| amount: tx(t, "excel.grn.amount", "金額"), | |||
| grnCode: tx(t, "excel.grn.grnCode", "M18 入倉單號"), | |||
| grnId: tx(t, "excel.grn.grnId", "M18 記錄編號"), | |||
| poCreator: tx(t, "excel.grn.poCreator", "PO建立者(M18)"), | |||
| note: tx(t, "excel.grn.note", "備註"), | |||
| category: tx(t, "excel.grn.category", "類別"), | |||
| totalAmount: tx(t, "excel.grn.totalAmount", "金額"), | |||
| grnCodes: tx(t, "excel.grn.grnCodes", "M18 入倉單號"), | |||
| noCompletedPo: tx(t, "excel.grn.noCompletedPo", "(篩選範圍內無已完成之 PO 行)"), | |||
| categoryCurrencyTotal: tx(t, "excel.grn.categoryCurrencyTotal", "貨幣小計"), | |||
| categoryPo: tx(t, "excel.grn.categoryPo", "訂單"), | |||
| @@ -187,9 +176,6 @@ function toExcelRow( | |||
| base[L.currency] = r.currencyCode ?? ""; | |||
| base[L.amount] = moneyCellValue(r.lineAmount); | |||
| } | |||
| base[L.grnCode] = r.grnCode ?? ""; | |||
| base[L.grnId] = r.grnId ?? ""; | |||
| base[L.poCreator] = r.poM18CreatorDisplay ?? ""; | |||
| return base; | |||
| } | |||
| @@ -213,7 +199,6 @@ function buildListedPoAmountSheetRows( | |||
| [L.poNo]: "", | |||
| [L.currency]: c.currencyCode ?? "", | |||
| [L.totalAmount]: moneyCellValue(c.totalAmount), | |||
| [L.grnCodes]: "", | |||
| }); | |||
| } | |||
| for (const p of listed.byPurchaseOrder) { | |||
| @@ -223,7 +208,6 @@ function buildListedPoAmountSheetRows( | |||
| [L.poNo]: p.poCode ?? "", | |||
| [L.currency]: p.currencyCode ?? "", | |||
| [L.totalAmount]: moneyCellValue(p.totalAmount), | |||
| [L.grnCodes]: p.grnCodes ?? "", | |||
| }); | |||
| } | |||
| return out; | |||
| @@ -25,7 +25,7 @@ import { | |||
| } from '@mui/material'; | |||
| import DownloadIcon from '@mui/icons-material/Download'; | |||
| import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; | |||
| import { REPORTS } from '@/config/reportConfig'; | |||
| import { DEMO_UNAVAILABLE_REPORT_IDS, REPORTS } from '@/config/reportConfig'; | |||
| import { mergePastedItemCodes, buildItemCodePasteRequestBody, parseItemCodeTokens } from './parseItemCodeTokens'; | |||
| import { NEXT_PUBLIC_API_URL } from '@/config/api'; | |||
| import { clientAuthFetch } from '@/app/utils/clientAuthFetch'; | |||
| @@ -152,6 +152,7 @@ export default function ReportPage() { | |||
| [selectedReportId]); | |||
| const handleSelectReport = (reportId: string) => { | |||
| if (DEMO_UNAVAILABLE_REPORT_IDS.has(reportId)) return; | |||
| if (reportId === selectedReportId) return; | |||
| setSelectedReportId(reportId); | |||
| setFieldErrors({}); | |||
| @@ -452,6 +453,7 @@ export default function ReportPage() { | |||
| const executeExcelReport = async () => { | |||
| if (!currentReport) return; | |||
| if (DEMO_UNAVAILABLE_REPORT_IDS.has(currentReport.id)) return; | |||
| if (excelInFlightRef.current) return; | |||
| excelInFlightRef.current = true; | |||
| setLoading(true); | |||
| @@ -33,6 +33,6 @@ export const REPORT_CATEGORIES: ReportCategoryConfig[] = [ | |||
| headerBg: "#f5d4a8", | |||
| bodyBg: "#fdf6ec", | |||
| accent: "#e65100", | |||
| reportIds: ["rep-006", "rep-005"], | |||
| reportIds: ["rep-006", "rep-005", "rep-015"], | |||
| }, | |||
| ]; | |||
| @@ -37,8 +37,6 @@ import { | |||
| type LaserBag2AutoSendReport, | |||
| type LaserLastReceiveSuccess, | |||
| } from "@/app/api/laserPrint/actions"; | |||
| import * as XLSX from "xlsx"; | |||
| interface TabPanelProps { | |||
| children?: React.ReactNode; | |||
| index: number; | |||
| @@ -175,49 +173,6 @@ export default function TestingPage() { | |||
| }; | |||
| }, [tabValue]); | |||
| const handleDownloadGrnPreviewXlsx = async () => { | |||
| try { | |||
| const response = await clientAuthFetch( | |||
| `${NEXT_PUBLIC_API_URL}/report/grn-preview-m18?receiptDate=${encodeURIComponent( | |||
| grnPreviewReceiptDate, | |||
| )}`, | |||
| { method: "GET" }, | |||
| ); | |||
| if (response.status === 401 || response.status === 403) return; | |||
| if (!response.ok) throw new Error(`Download failed: ${response.status}`); | |||
| const data = await response.json(); | |||
| const rows = Array.isArray(data?.rows) ? data.rows : []; | |||
| const ws = XLSX.utils.json_to_sheet(rows); | |||
| const wb = XLSX.utils.book_new(); | |||
| XLSX.utils.book_append_sheet(wb, ws, "GRN Preview"); | |||
| const xlsxArrayBuffer = XLSX.write(wb, { | |||
| bookType: "xlsx", | |||
| type: "array", | |||
| }); | |||
| const blob = new Blob([xlsxArrayBuffer], { | |||
| type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", | |||
| }); | |||
| const url = window.URL.createObjectURL(blob); | |||
| const link = document.createElement("a"); | |||
| link.href = url; | |||
| link.setAttribute( | |||
| "download", | |||
| `grn-preview-m18-${grnPreviewReceiptDate}.xlsx`, | |||
| ); | |||
| document.body.appendChild(link); | |||
| link.click(); | |||
| link.remove(); | |||
| window.URL.revokeObjectURL(url); | |||
| } catch (e) { | |||
| console.error("GRN Preview XLSX Download Error:", e); | |||
| alert("GRN Preview XLSX download failed. Check console/network."); | |||
| } | |||
| }; | |||
| const downloadBlob = (blob: Blob, filename: string) => { | |||
| const url = window.URL.createObjectURL(blob); | |||
| const link = document.createElement("a"); | |||
| @@ -524,33 +479,35 @@ export default function TestingPage() { | |||
| </Tabs> | |||
| <TabPanel value={tabValue} index={0}> | |||
| <Section title="1. GRN Preview (M18)"> | |||
| <Stack | |||
| direction="row" | |||
| spacing={2} | |||
| sx={{ mb: 2, alignItems: "center" }} | |||
| > | |||
| <TextField | |||
| size="small" | |||
| label="Receipt Date" | |||
| type="date" | |||
| value={grnPreviewReceiptDate} | |||
| onChange={(e) => setGrnPreviewReceiptDate(e.target.value)} | |||
| InputLabelProps={{ shrink: true }} | |||
| /> | |||
| <Button | |||
| variant="contained" | |||
| color="success" | |||
| size="medium" | |||
| startIcon={<FileDownload />} | |||
| onClick={handleDownloadGrnPreviewXlsx} | |||
| <Section title="1. GRN Preview"> | |||
| <Box sx={{ opacity: 0.55, filter: "grayscale(1)", pointerEvents: "none" }}> | |||
| <Stack | |||
| direction="row" | |||
| spacing={2} | |||
| sx={{ mb: 2, alignItems: "center" }} | |||
| > | |||
| Download GRN Preview XLSX | |||
| </Button> | |||
| </Stack> | |||
| <Typography variant="body2" color="textSecondary"> | |||
| Backend endpoint:{" "} | |||
| <code>/report/grn-preview-m18?receiptDate=YYYY-MM-DD</code> | |||
| <TextField | |||
| size="small" | |||
| label="Receipt Date" | |||
| type="date" | |||
| value={grnPreviewReceiptDate} | |||
| onChange={(e) => setGrnPreviewReceiptDate(e.target.value)} | |||
| InputLabelProps={{ shrink: true }} | |||
| disabled | |||
| /> | |||
| <Button | |||
| variant="contained" | |||
| color="success" | |||
| size="medium" | |||
| startIcon={<FileDownload />} | |||
| disabled | |||
| > | |||
| Download GRN Preview XLSX | |||
| </Button> | |||
| </Stack> | |||
| </Box> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| Unavailable in demo mode | |||
| </Typography> | |||
| </Section> | |||
| </TabPanel> | |||
| @@ -33,6 +33,9 @@ export interface ReportField { | |||
| export type ReportResponseType = 'pdf' | 'excel'; | |||
| /** Live-system reports. The demo page shows them greyed out. */ | |||
| export const DEMO_UNAVAILABLE_REPORT_IDS = new Set(["rep-015"]); | |||
| export interface ReportDefinition { | |||
| id: string; | |||
| title: string; | |||
| @@ -459,7 +462,7 @@ export const REPORTS: ReportDefinition[] = [ | |||
| }, | |||
| { | |||
| id: "rep-015", | |||
| title: "M18 BOM Shop 同步記錄", | |||
| title: "BOM Shop 同步記錄", | |||
| apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/bom-shop-sync-history`, | |||
| responseType: "excel", | |||
| fields: [ | |||
| @@ -15,6 +15,7 @@ | |||
| "noDataFoundTitle": "No Data Found / 查無資料", | |||
| "noDataFoundHint": "No inventory records match your search. Please try adjusting the filters.", | |||
| "ok": "OK", | |||
| "unavailableInDemo": "Unavailable in demo mode", | |||
| "missingRequired": "Missing required fields:\n- {{fields}}", | |||
| "requiredField": "This is a required field", | |||
| "dateNotBeforeToday": "Date cannot be before today:\n- {{fields}}", | |||
| @@ -231,7 +232,7 @@ | |||
| } | |||
| }, | |||
| "rep-015": { | |||
| "title": "M18 BOM Shop Sync History", | |||
| "title": "BOM Shop Sync History", | |||
| "fields": { | |||
| "syncDateStart": "Sync Date Start", | |||
| "syncDateEnd": "Sync Date End", | |||
| @@ -332,13 +333,9 @@ | |||
| "unitPrice": "Unit Price", | |||
| "currency": "Currency", | |||
| "amount": "Amount", | |||
| "grnCode": "GRN Code / M18 Receipt No.", | |||
| "grnId": "GRN Id / M18 Record Id", | |||
| "poCreator": "PO creator (M18)", | |||
| "note": "Note", | |||
| "category": "Category", | |||
| "totalAmount": "Total Amount", | |||
| "grnCodes": "GRN Code(s) / M18 Receipt No." | |||
| "totalAmount": "Total Amount" | |||
| }, | |||
| "bomSync": { | |||
| "sheetSync": "BOM Sync Log", | |||
| @@ -15,6 +15,7 @@ | |||
| "noDataFoundTitle": "查無資料", | |||
| "noDataFoundHint": "找不到符合您搜尋條件的庫存資料,請嘗試調整篩選條件。", | |||
| "ok": "確定", | |||
| "unavailableInDemo": "示範模式無法使用", | |||
| "missingRequired": "缺少必填條件:\n- {{fields}}", | |||
| "requiredField": "此為必填欄位", | |||
| "dateNotBeforeToday": "日期不可早於今天:\n- {{fields}}", | |||
| @@ -231,7 +232,7 @@ | |||
| } | |||
| }, | |||
| "rep-015": { | |||
| "title": "M18 BOM Shop 同步記錄", | |||
| "title": "BOM Shop 同步記錄", | |||
| "fields": { | |||
| "syncDateStart": "同步日期:由", | |||
| "syncDateEnd": "同步日期:至", | |||
| @@ -332,13 +333,9 @@ | |||
| "unitPrice": "單價", | |||
| "currency": "貨幣", | |||
| "amount": "金額", | |||
| "grnCode": "M18 入倉單號", | |||
| "grnId": "M18 記錄編號", | |||
| "poCreator": "PO建立者(M18)", | |||
| "note": "備註", | |||
| "category": "類別", | |||
| "totalAmount": "金額", | |||
| "grnCodes": "M18 入倉單號" | |||
| "totalAmount": "金額" | |||
| }, | |||
| "bomSync": { | |||
| "sheetSync": "BOM同步記錄", | |||