| @@ -19,6 +19,27 @@ export function exportChartToXlsx( | |||
| return; | |||
| } | |||
| 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(); | |||
| XLSX.utils.book_append_sheet(wb, ws, sheetName); | |||
| 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, | |||
| fetchSemiFGItemCodesWithCategory | |||
| } from './semiFGProductionAnalysisApi'; | |||
| import { generateGrnReportExcel } from './grnReportApi'; | |||
| interface ItemCodeWithName { | |||
| code: string; | |||
| @@ -144,9 +145,30 @@ export default function ReportPage() { | |||
| } | |||
| // 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} | |||
| reportTitle={currentReport.title} | |||
| /> | |||
| ) : currentReport.responseType === 'excel' ? ( | |||
| <Button | |||
| variant="contained" | |||
| size="large" | |||
| startIcon={<PrintIcon />} | |||
| onClick={handlePrint} | |||
| disabled={loading} | |||
| sx={{ px: 4 }} | |||
| > | |||
| {loading ? "生成報告..." : "匯出 Excel"} | |||
| </Button> | |||
| ) : ( | |||
| <Button | |||
| variant="contained" | |||
| @@ -16,10 +16,14 @@ export interface ReportField { | |||
| allowInput?: boolean; // Allow user to input custom values (for select types) | |||
| } | |||
| export type ReportResponseType = 'pdf' | 'excel'; | |||
| export interface ReportDefinition { | |||
| id: string; | |||
| title: string; | |||
| apiEndpoint: string; | |||
| /** When 'excel', report page fetches JSON and builds .xlsx for download. Default 'pdf'. */ | |||
| responseType?: ReportResponseType; | |||
| 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", | |||
| title: "成品/半成品生產分析報告", | |||