| @@ -19,6 +19,27 @@ export function exportChartToXlsx( | |||||
| return; | return; | ||||
| } | } | ||||
| const ws = XLSX.utils.json_to_sheet(rows); | const ws = XLSX.utils.json_to_sheet(rows); | ||||
| // Auto-set column widths based on header length (simple heuristic). | |||||
| const header = Object.keys(rows[0] ?? {}); | |||||
| if (header.length > 0) { | |||||
| ws["!cols"] = header.map((h) => ({ | |||||
| // Basic width: header length + padding, minimum 12 | |||||
| wch: Math.max(12, h.length + 4), | |||||
| })); | |||||
| // Make header row look like a header (bold). | |||||
| header.forEach((_, colIdx) => { | |||||
| const cellRef = XLSX.utils.encode_cell({ r: 0, c: colIdx }); | |||||
| const cell = ws[cellRef]; | |||||
| if (cell) { | |||||
| cell.s = { | |||||
| font: { bold: true }, | |||||
| }; | |||||
| } | |||||
| }); | |||||
| } | |||||
| const wb = XLSX.utils.book_new(); | const wb = XLSX.utils.book_new(); | ||||
| XLSX.utils.book_append_sheet(wb, ws, sheetName); | XLSX.utils.book_append_sheet(wb, ws, sheetName); | ||||
| XLSX.writeFile(wb, `${filename}.xlsx`); | XLSX.writeFile(wb, `${filename}.xlsx`); | ||||
| @@ -0,0 +1,59 @@ | |||||
| # GRN Report – Backend API Spec | |||||
| The frontend **GRN/入倉明細報告** report calls the following endpoint. The backend must implement it to return JSON (not PDF). | |||||
| ## Endpoint | |||||
| - **Method:** `GET` | |||||
| - **Path:** `/report/grn-report` | |||||
| - **Query parameters (all optional):** | |||||
| - `receiptDateStart` – date (e.g. `yyyy-MM-dd`), filter receipt date from | |||||
| - `receiptDateEnd` – date (e.g. `yyyy-MM-dd`), filter receipt date to | |||||
| - `itemCode` – string, filter by item code (partial match if desired) | |||||
| ## Response | |||||
| - **Content-Type:** `application/json` | |||||
| - **Body:** Either an array of row objects, or an object with a `rows` array: | |||||
| ```json | |||||
| { | |||||
| "rows": [ | |||||
| { | |||||
| "poCode": "PO-2025-001", | |||||
| "deliveryNoteNo": "DN-12345", | |||||
| "receiptDate": "2025-03-15", | |||||
| "itemCode": "MAT-001", | |||||
| "itemName": "Raw Material A", | |||||
| "acceptedQty": 100, | |||||
| "receivedQty": 100, | |||||
| "demandQty": 120, | |||||
| "uom": "KG", | |||||
| "purchaseUomDesc": "Kilogram", | |||||
| "stockUomDesc": "KG", | |||||
| "productLotNo": "LOT-001", | |||||
| "expiryDate": "2026-03-01", | |||||
| "supplier": "Supplier Name", | |||||
| "status": "completed" | |||||
| } | |||||
| ] | |||||
| } | |||||
| ``` | |||||
| Or a direct array: | |||||
| ```json | |||||
| [ | |||||
| { "poCode": "PO-2025-001", "deliveryNoteNo": "DN-12345", ... } | |||||
| ] | |||||
| ``` | |||||
| ## Suggested backend implementation | |||||
| - Use data that “generates the GRN” (Goods Received Note): e.g. **stock-in lines** (or equivalent) linked to **PO** and **delivery note**. | |||||
| - Filter by: | |||||
| - `receiptDate` (or equivalent) between `receiptDateStart` and `receiptDateEnd` when provided. | |||||
| - `itemCode` when provided. | |||||
| - Return one row per GRN line with at least: **PO/delivery note no.**, **itemCode**, **itemName**, **qty** (e.g. `acceptedQty`), **uom**, and optionally receipt date, lot, expiry, supplier, status. | |||||
| Frontend builds the Excel from this JSON and downloads it with columns: PO No., Delivery Note No., Receipt Date, Item Code, Item Name, Qty, Demand Qty, UOM, Product Lot No., Expiry Date, Supplier, Status. | |||||
| @@ -0,0 +1,99 @@ | |||||
| "use client"; | |||||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||||
| import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | |||||
| import { exportChartToXlsx } from "@/app/(main)/chart/_components/exportChartToXlsx"; | |||||
| export interface GrnReportRow { | |||||
| poCode?: string; | |||||
| deliveryNoteNo?: string; | |||||
| receiptDate?: string; | |||||
| itemCode?: string; | |||||
| itemName?: string; | |||||
| acceptedQty?: number; | |||||
| receivedQty?: number; | |||||
| demandQty?: number; | |||||
| uom?: string; | |||||
| purchaseUomDesc?: string; | |||||
| stockUomDesc?: string; | |||||
| productLotNo?: string; | |||||
| expiryDate?: string; | |||||
| supplierCode?: string; | |||||
| supplier?: string; | |||||
| status?: string; | |||||
| grnId?: number | string; | |||||
| [key: string]: unknown; | |||||
| } | |||||
| export interface GrnReportResponse { | |||||
| rows: GrnReportRow[]; | |||||
| } | |||||
| /** | |||||
| * Fetch GRN (Goods Received Note) report data by date range. | |||||
| * Backend: GET /report/grn-report?receiptDateStart=&receiptDateEnd=&itemCode= | |||||
| */ | |||||
| export async function fetchGrnReportData( | |||||
| criteria: Record<string, string> | |||||
| ): Promise<GrnReportRow[]> { | |||||
| const queryParams = new URLSearchParams(criteria).toString(); | |||||
| const url = `${NEXT_PUBLIC_API_URL}/report/grn-report?${queryParams}`; | |||||
| const response = await clientAuthFetch(url, { | |||||
| method: "GET", | |||||
| headers: { Accept: "application/json" }, | |||||
| }); | |||||
| if (response.status === 401 || response.status === 403) | |||||
| throw new Error("Unauthorized"); | |||||
| if (!response.ok) | |||||
| throw new Error(`HTTP error! status: ${response.status}`); | |||||
| const data = (await response.json()) as GrnReportResponse | GrnReportRow[]; | |||||
| const rows = Array.isArray(data) ? data : (data as GrnReportResponse).rows ?? []; | |||||
| return rows; | |||||
| } | |||||
| /** Excel column headers (bilingual) for GRN report */ | |||||
| function toExcelRow(r: GrnReportRow): Record<string, string | number | undefined> { | |||||
| return { | |||||
| "PO No. / 訂單編號": r.poCode ?? "", | |||||
| "Supplier Code / 供應商編號": r.supplierCode ?? "", | |||||
| "Delivery Note No. / 送貨單編號": r.deliveryNoteNo ?? "", | |||||
| "Receipt Date / 收貨日期": r.receiptDate ?? "", | |||||
| "Item Code / 物料編號": r.itemCode ?? "", | |||||
| "Item Name / 物料名稱": r.itemName ?? "", | |||||
| "Qty / 數量": r.acceptedQty ?? r.receivedQty ?? "", | |||||
| "Demand Qty / 訂單數量": r.demandQty ?? "", | |||||
| "UOM / 單位": r.uom ?? r.purchaseUomDesc ?? r.stockUomDesc ?? "", | |||||
| "Product Lot No. / 批次": r.productLotNo ?? "", | |||||
| "Expiry Date / 到期日": r.expiryDate ?? "", | |||||
| "Supplier / 供應商": r.supplier ?? "", | |||||
| "Status / 狀態": r.status ?? "", | |||||
| "GRN Id / M18 單號": r.grnId ?? "", | |||||
| }; | |||||
| } | |||||
| /** | |||||
| * Generate and download GRN report as Excel. | |||||
| */ | |||||
| export async function generateGrnReportExcel( | |||||
| criteria: Record<string, string>, | |||||
| reportTitle: string = "PO 入倉記錄" | |||||
| ): Promise<void> { | |||||
| const rows = await fetchGrnReportData(criteria); | |||||
| const excelRows = rows.map(toExcelRow); | |||||
| const start = criteria.receiptDateStart; | |||||
| const end = criteria.receiptDateEnd; | |||||
| let datePart: string; | |||||
| if (start && end && start === end) { | |||||
| datePart = start; | |||||
| } else if (start || end) { | |||||
| datePart = `${start || ""}_to_${end || ""}`; | |||||
| } else { | |||||
| datePart = new Date().toISOString().slice(0, 10); | |||||
| } | |||||
| const safeDatePart = datePart.replace(/[^\d\-_/]/g, ""); | |||||
| const filename = `${reportTitle}_${safeDatePart}`; | |||||
| exportChartToXlsx(excelRows, filename, "GRN"); | |||||
| } | |||||
| @@ -23,6 +23,7 @@ import { | |||||
| fetchSemiFGItemCodes, | fetchSemiFGItemCodes, | ||||
| fetchSemiFGItemCodesWithCategory | fetchSemiFGItemCodesWithCategory | ||||
| } from './semiFGProductionAnalysisApi'; | } from './semiFGProductionAnalysisApi'; | ||||
| import { generateGrnReportExcel } from './grnReportApi'; | |||||
| interface ItemCodeWithName { | interface ItemCodeWithName { | ||||
| code: string; | code: string; | ||||
| @@ -144,9 +145,30 @@ export default function ReportPage() { | |||||
| } | } | ||||
| // For rep-005, the print logic is handled by SemiFGProductionAnalysisReport component | // For rep-005, the print logic is handled by SemiFGProductionAnalysisReport component | ||||
| // For other reports, execute print directly | |||||
| if (currentReport.id !== 'rep-005') { | |||||
| await executePrint(); | |||||
| if (currentReport.id === 'rep-005') return; | |||||
| // For Excel reports (e.g. GRN), fetch JSON and download as .xlsx | |||||
| if (currentReport.responseType === 'excel') { | |||||
| await executeExcelReport(); | |||||
| return; | |||||
| } | |||||
| await executePrint(); | |||||
| }; | |||||
| const executeExcelReport = async () => { | |||||
| if (!currentReport) return; | |||||
| setLoading(true); | |||||
| try { | |||||
| if (currentReport.id === 'rep-014') { | |||||
| await generateGrnReportExcel(criteria, currentReport.title); | |||||
| } | |||||
| setShowConfirmDialog(false); | |||||
| } catch (error) { | |||||
| console.error("Failed to generate Excel report:", error); | |||||
| alert("An error occurred while generating the report. Please try again."); | |||||
| } finally { | |||||
| setLoading(false); | |||||
| } | } | ||||
| }; | }; | ||||
| @@ -425,6 +447,17 @@ export default function ReportPage() { | |||||
| setLoading={setLoading} | setLoading={setLoading} | ||||
| reportTitle={currentReport.title} | reportTitle={currentReport.title} | ||||
| /> | /> | ||||
| ) : currentReport.responseType === 'excel' ? ( | |||||
| <Button | |||||
| variant="contained" | |||||
| size="large" | |||||
| startIcon={<PrintIcon />} | |||||
| onClick={handlePrint} | |||||
| disabled={loading} | |||||
| sx={{ px: 4 }} | |||||
| > | |||||
| {loading ? "生成報告..." : "匯出 Excel"} | |||||
| </Button> | |||||
| ) : ( | ) : ( | ||||
| <Button | <Button | ||||
| variant="contained" | variant="contained" | ||||
| @@ -16,10 +16,14 @@ export interface ReportField { | |||||
| allowInput?: boolean; // Allow user to input custom values (for select types) | allowInput?: boolean; // Allow user to input custom values (for select types) | ||||
| } | } | ||||
| export type ReportResponseType = 'pdf' | 'excel'; | |||||
| export interface ReportDefinition { | export interface ReportDefinition { | ||||
| id: string; | id: string; | ||||
| title: string; | title: string; | ||||
| apiEndpoint: string; | apiEndpoint: string; | ||||
| /** When 'excel', report page fetches JSON and builds .xlsx for download. Default 'pdf'. */ | |||||
| responseType?: ReportResponseType; | |||||
| fields: ReportField[]; | fields: ReportField[]; | ||||
| } | } | ||||
| @@ -186,6 +190,17 @@ export const REPORTS: ReportDefinition[] = [ | |||||
| ] | ] | ||||
| }, | }, | ||||
| { | |||||
| id: "rep-014", | |||||
| title: "PO 入倉記錄", | |||||
| apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/grn-report`, | |||||
| responseType: "excel", | |||||
| fields: [ | |||||
| { label: "收貨日期:由 Receipt Date Start", name: "receiptDateStart", type: "date", required: false }, | |||||
| { label: "收貨日期:至 Receipt Date End", name: "receiptDateEnd", type: "date", required: false }, | |||||
| { label: "物料編號 Item Code", name: "itemCode", type: "text", required: false }, | |||||
| ], | |||||
| }, | |||||
| { | { | ||||
| id: "rep-005", | id: "rep-005", | ||||
| title: "成品/半成品生產分析報告", | title: "成品/半成品生產分析報告", | ||||