diff --git a/src/app/(main)/chart/_components/exportChartToXlsx.ts b/src/app/(main)/chart/_components/exportChartToXlsx.ts index 7e7f74e..92c733d 100644 --- a/src/app/(main)/chart/_components/exportChartToXlsx.ts +++ b/src/app/(main)/chart/_components/exportChartToXlsx.ts @@ -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`); diff --git a/src/app/(main)/report/GRN_REPORT_BACKEND_SPEC.md b/src/app/(main)/report/GRN_REPORT_BACKEND_SPEC.md new file mode 100644 index 0000000..fb47305 --- /dev/null +++ b/src/app/(main)/report/GRN_REPORT_BACKEND_SPEC.md @@ -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. diff --git a/src/app/(main)/report/grnReportApi.ts b/src/app/(main)/report/grnReportApi.ts new file mode 100644 index 0000000..d0c9fc0 --- /dev/null +++ b/src/app/(main)/report/grnReportApi.ts @@ -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 +): Promise { + 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 { + 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, + reportTitle: string = "PO 入倉記錄" +): Promise { + 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"); +} diff --git a/src/app/(main)/report/page.tsx b/src/app/(main)/report/page.tsx index f170845..2c21c9e 100644 --- a/src/app/(main)/report/page.tsx +++ b/src/app/(main)/report/page.tsx @@ -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' ? ( + ) : (