| @@ -66,7 +66,8 @@ | |||
| "reactstrap": "^9.2.2", | |||
| "styled-components": "^6.1.8", | |||
| "sweetalert2": "^11.10.3", | |||
| "xlsx": "^0.18.5" | |||
| "xlsx": "^0.18.5", | |||
| "xlsx-js-style": "^1.2.0" | |||
| }, | |||
| "devDependencies": { | |||
| "@types/lodash": "^4.14.202", | |||
| @@ -3,6 +3,12 @@ | |||
| "use client"; | |||
| import React, { createContext, useContext, useEffect, useState, useCallback } from "react"; | |||
| import { getSession } from "next-auth/react"; | |||
| import { SessionWithTokens } from "@/config/authConfig"; | |||
| import { | |||
| isBackendJwtExpired, | |||
| LOGIN_SESSION_EXPIRED_HREF, | |||
| } from "@/app/utils/authToken"; | |||
| import axiosInstance, { SetupAxiosInterceptors } from "./axiosInstance"; | |||
| const AxiosContext = createContext(axiosInstance); | |||
| @@ -29,6 +35,47 @@ export const AxiosProvider: React.FC<{ children: React.ReactNode }> = ({ childre | |||
| } | |||
| }, []); | |||
| /** | |||
| * Detect expired/missing backend JWT before user actions (e.g. /report search). | |||
| * Sync accessToken from next-auth session into localStorage if missing, then | |||
| * redirect to login when the Bearer token is absent or past `exp`. | |||
| */ | |||
| useEffect(() => { | |||
| if (!isHydrated || typeof window === "undefined") return; | |||
| let cancelled = false; | |||
| (async () => { | |||
| try { | |||
| let token = localStorage.getItem("accessToken")?.trim() ?? ""; | |||
| if (!token) { | |||
| const session = (await getSession()) as SessionWithTokens | null; | |||
| if (cancelled) return; | |||
| if (session?.accessToken) { | |||
| token = session.accessToken; | |||
| localStorage.setItem("accessToken", token); | |||
| setAccessToken(token); | |||
| } | |||
| } | |||
| if (!token) { | |||
| window.location.href = LOGIN_SESSION_EXPIRED_HREF; | |||
| return; | |||
| } | |||
| if (isBackendJwtExpired(token)) { | |||
| window.location.href = LOGIN_SESSION_EXPIRED_HREF; | |||
| } | |||
| } catch (e) { | |||
| console.warn("Auth token check failed", e); | |||
| } | |||
| })(); | |||
| return () => { | |||
| cancelled = true; | |||
| }; | |||
| }, [isHydrated]); | |||
| // Apply token + interceptors | |||
| useEffect(() => { | |||
| if (accessToken) { | |||
| @@ -0,0 +1,161 @@ | |||
| # Excel export standard (FPSMS frontend) | |||
| This document defines how **client-side** `.xlsx` exports should look and behave. **Implementation:** `exportChartToXlsx.ts` and `exportMultiSheetToXlsx()` — use these helpers for new reports so styling stays consistent. | |||
| ## Scope (important) | |||
| | Export path | Follows this `.md`? | | |||
| |-------------|---------------------| | |||
| | **Next.js** builds the file via `exportChartToXlsx` / `exportMultiSheetToXlsx` (e.g. **PO 入倉記錄** / `rep-014`) | **Yes** — rules are enforced in code. | | |||
| | **Backend** returns ready-made `.xlsx` or Excel bytes (JasperReports, Apache POI, etc.; most `print-*` report endpoints) | **No — not automatically.** That code does **not** use this TypeScript module. To match the same **look** (grey headers, number formats, alignment), implement equivalent styling in Java/Kotlin or Jasper templates. See the backend companion doc below. | | |||
| **Backend companion (visual parity):** | |||
| `FPSMS-backend/docs/EXCEL_EXPORT_STANDARD.md` — same *rules*, for POI/Jasper implementers. | |||
| --- | |||
| ## 1. Library | |||
| | Item | Value | | |||
| |------|--------| | |||
| | Package | **`xlsx-js-style`** (not the plain `xlsx` community build) | | |||
| | Reason | Plain SheetJS **does not persist** cell styles (`fill`, `alignment`, `numFmt`) in the written file. `xlsx-js-style` is a compatible fork that **does**. | | |||
| --- | |||
| ## 2. Data shape | |||
| - Rows are **`Record<string, unknown>[]`** (array of plain objects). | |||
| - **First object’s keys** become the **header row** (column titles). Every row should use the **same keys** in the same order for a rectangular sheet. | |||
| - Prefer **real JavaScript `number`** values for amounts where possible; the exporter will apply number formats. Strings that look like numbers (e.g. `"1,234.56"`) are parsed for money columns. | |||
| --- | |||
| ## 3. Processing order (per sheet) | |||
| After `json_to_sheet(rows)`: | |||
| 1. **`!cols`** — column width heuristic (see §4). | |||
| 2. **`applyHeaderRowStyle`** — header row styling (see §5). | |||
| 3. **`applyMoneyColumnNumberFormats`** — money columns only, data rows (see §6). | |||
| 4. **`applyNumericColumnRightAlign`** — money + quantity columns, **all rows including header** (see §7). | |||
| --- | |||
| ## 4. Column width (`!cols`) | |||
| - For each column: `wch = max(12, headerText.length + 4)`. | |||
| - Adjust if a report needs fixed widths; default keeps bilingual headers readable. | |||
| --- | |||
| ## 5. Header row style (row 0) | |||
| Applied to **every** header cell first; numeric columns get alignment overridden in step 4. | |||
| | Property | Value | | |||
| |----------|--------| | |||
| | Font | Bold, black `rgb: "000000"` | | |||
| | Fill | Solid, `fgColor: "D9D9D9"` (light grey) | | |||
| | Alignment (default) | Horizontal **center**, vertical **center**, `wrapText: true` | | |||
| --- | |||
| ## 6. Money / amount columns — number format | |||
| **Detection:** header label matches (case-insensitive): | |||
| ```text | |||
| 金額 | 單價 | Amount | Unit Price | Total Amount | |||
| ``` | |||
| (Also matches bilingual headers that contain these fragments, e.g. `Amount / 金額`, `Unit Price / 單價`, `Total Amount / 金額`.) | |||
| **Rules:** | |||
| - Applies to **data rows only** (not row 0). | |||
| - Excel format string: **`#,##0.00`** (thousands separator + 2 decimals). Stored on the cell as `z`. | |||
| - Cell type `t: "n"` for numeric values. | |||
| - If the cell is a **string**, commas are stripped and the value is parsed to a number when possible. | |||
| **Naming new reports:** use header text that matches the patterns above so columns pick up formatting automatically. | |||
| --- | |||
| ## 7. Quantity columns — alignment only | |||
| **Detection:** header label matches: | |||
| ```text | |||
| Qty | 數量 | Demand | |||
| ``` | |||
| (Covers e.g. `Qty / 數量`, `Demand Qty / 訂單數量`.) | |||
| - No default thousands format (quantities may have up to 4 decimals in app code). | |||
| - These columns are **right-aligned** together with money columns (see §8). | |||
| --- | |||
| ## 8. Alignment — numeric columns (header + data) | |||
| **Detection:** union of **money** (§6) and **quantity** (§7) header patterns. | |||
| | Alignment | Value | | |||
| |-----------|--------| | |||
| | Horizontal | **`right`** | | |||
| | Vertical | **`center`** | | |||
| | `wrapText` | Preserved / defaulted to `true` where applicable | | |||
| Existing style objects are **merged** (fill, font from header styling are kept). | |||
| --- | |||
| ## 9. Multi-sheet workbook | |||
| | Rule | Detail | | |||
| |------|--------| | |||
| | API | `exportMultiSheetToXlsx({ name, rows }[], filename)` | | |||
| | Sheet name length | Truncated to **31** characters (Excel limit) | | |||
| | Each sheet | Same pipeline as §3 if `rows.length > 0` | | |||
| --- | |||
| ## 10. Empty sheets | |||
| If `rows.length === 0`, a minimal sheet is written; no header styling pipeline runs. | |||
| --- | |||
| ## 11. Reports using this standard today | |||
| | Feature | Location | | |||
| |---------|----------| | |||
| | Chart export | Uses `exportChartToXlsx` | | |||
| | GRN / PO 入倉記錄 (`rep-014`) | `src/app/(main)/report/grnReportApi.ts` — builds row objects, calls `exportChartToXlsx` / `exportMultiSheetToXlsx` | | |||
| Most other reports on `/report` download Excel/PDF **generated on the server** (Jasper, etc.). Those **do not** run this TypeScript pipeline; use **`FPSMS-backend/docs/EXCEL_EXPORT_STANDARD.md`** if you want the same visual rules there. | |||
| --- | |||
| ## 12. Checklist for new Excel exports | |||
| 1. Import from **`xlsx-js-style`** only if building sheets manually; otherwise call **`exportChartToXlsx`** or **`exportMultiSheetToXlsx`**. | |||
| 2. Use **stable header strings** that match §6 / §7 if the column is amount or quantity. | |||
| 3. Pass **numbers** for amounts when possible. | |||
| 4. If you need a **new** category (e.g. “Rate”, “折扣”), extend the regex constants in `exportChartToXlsx.ts` and **update this document**. | |||
| 5. Keep filenames and sheet names user-readable; remember the **31-character** sheet limit. | |||
| --- | |||
| ## 13. Related files | |||
| | File | Role | | |||
| |------|------| | |||
| | `exportChartToXlsx.ts` | Single-sheet export + styling pipeline | | |||
| | `grnReportApi.ts` | Example: bilingual headers, money values, multi-sheet GRN report | | |||
| | `FPSMS-backend/docs/EXCEL_EXPORT_STANDARD.md` | Backend Excel (POI/Jasper) — same *rules*, separate code | | |||
| --- | |||
| *Last aligned with implementation in `exportChartToXlsx.ts` (header fill `#D9D9D9`, money format `#,##0.00`, right-align numeric columns).* | |||
| @@ -1,4 +1,114 @@ | |||
| import * as XLSX from "xlsx"; | |||
| /** | |||
| * Client-side .xlsx export with shared styling (headers, money format, alignment). | |||
| * @see ./EXCEL_EXPORT_STANDARD.md — conventions for new Excel exports | |||
| */ | |||
| import * as XLSX from "xlsx-js-style"; | |||
| /** Light grey header background + bold text (exported by xlsx-js-style). */ | |||
| const HEADER_CELL_STYLE: XLSX.CellStyle = { | |||
| font: { bold: true, color: { rgb: "000000" } }, | |||
| fill: { | |||
| patternType: "solid", | |||
| fgColor: { rgb: "D9D9D9" }, | |||
| }, | |||
| alignment: { vertical: "center", horizontal: "center", wrapText: true }, | |||
| }; | |||
| function applyHeaderRowStyle( | |||
| ws: XLSX.WorkSheet, | |||
| columnCount: number | |||
| ): void { | |||
| for (let colIdx = 0; colIdx < columnCount; colIdx++) { | |||
| const cellRef = XLSX.utils.encode_cell({ r: 0, c: colIdx }); | |||
| const cell = ws[cellRef]; | |||
| if (cell) { | |||
| cell.s = { ...HEADER_CELL_STYLE }; | |||
| } | |||
| } | |||
| } | |||
| /** Headers for money columns (GRN report & similar): thousands separator in Excel. */ | |||
| const MONEY_COLUMN_HEADER = | |||
| /金額|單價|Amount|Unit Price|Total Amount|Total Amount \/ 金額|Amount \/ 金額|Unit Price \/ 單價/i; | |||
| /** Quantity / numeric columns (right-align with amounts). */ | |||
| const QTY_COLUMN_HEADER = /Qty|數量|Demand/i; | |||
| function isNumericDataColumnHeader(h: string): boolean { | |||
| return MONEY_COLUMN_HEADER.test(h) || QTY_COLUMN_HEADER.test(h); | |||
| } | |||
| /** | |||
| * Apply Excel number format `#,##0.00` to money columns so values show with comma separators. | |||
| * Handles numeric cells and pre-formatted strings like "1,234.56". | |||
| */ | |||
| function applyMoneyColumnNumberFormats(ws: XLSX.WorkSheet, headerLabels: string[]): void { | |||
| const moneyColIdx = new Set<number>(); | |||
| headerLabels.forEach((h, c) => { | |||
| if (MONEY_COLUMN_HEADER.test(h)) moneyColIdx.add(c); | |||
| }); | |||
| if (moneyColIdx.size === 0 || !ws["!ref"]) return; | |||
| const range = XLSX.utils.decode_range(ws["!ref"]); | |||
| for (let r = range.s.r + 1; r <= range.e.r; r++) { | |||
| moneyColIdx.forEach((c) => { | |||
| const cellRef = XLSX.utils.encode_cell({ r, c }); | |||
| const cell = ws[cellRef]; | |||
| if (!cell) return; | |||
| if (typeof cell.v === "number" && Number.isFinite(cell.v)) { | |||
| cell.t = "n"; | |||
| cell.z = "#,##0.00"; | |||
| return; | |||
| } | |||
| if (typeof cell.v === "string") { | |||
| const cleaned = cell.v.replace(/,/g, "").trim(); | |||
| if (cleaned === "") return; | |||
| const n = Number.parseFloat(cleaned); | |||
| if (!Number.isNaN(n)) { | |||
| cell.v = n; | |||
| cell.t = "n"; | |||
| cell.z = "#,##0.00"; | |||
| } | |||
| } | |||
| }); | |||
| } | |||
| } | |||
| /** Right-align amount / quantity column headers and data (merge with existing header fill). */ | |||
| function applyNumericColumnRightAlign( | |||
| ws: XLSX.WorkSheet, | |||
| headerLabels: string[] | |||
| ): void { | |||
| const numericColIdx = new Set<number>(); | |||
| headerLabels.forEach((h, c) => { | |||
| if (isNumericDataColumnHeader(h)) numericColIdx.add(c); | |||
| }); | |||
| if (numericColIdx.size === 0 || !ws["!ref"]) return; | |||
| const range = XLSX.utils.decode_range(ws["!ref"]); | |||
| for (let r = range.s.r; r <= range.e.r; r++) { | |||
| numericColIdx.forEach((c) => { | |||
| const cellRef = XLSX.utils.encode_cell({ r, c }); | |||
| const cell = ws[cellRef]; | |||
| if (!cell) return; | |||
| const prev = (cell.s || {}) as XLSX.CellStyle; | |||
| cell.s = { | |||
| ...prev, | |||
| font: prev.font, | |||
| fill: prev.fill, | |||
| border: prev.border, | |||
| numFmt: prev.numFmt, | |||
| alignment: { | |||
| ...prev.alignment, | |||
| horizontal: "right", | |||
| vertical: "center", | |||
| wrapText: prev.alignment?.wrapText ?? true, | |||
| }, | |||
| }; | |||
| }); | |||
| } | |||
| } | |||
| /** | |||
| * Export an array of row objects to a .xlsx file and trigger download. | |||
| @@ -28,19 +138,45 @@ export function exportChartToXlsx( | |||
| 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 }, | |||
| }; | |||
| } | |||
| }); | |||
| applyHeaderRowStyle(ws, header.length); | |||
| applyMoneyColumnNumberFormats(ws, header); | |||
| applyNumericColumnRightAlign(ws, header); | |||
| } | |||
| const wb = XLSX.utils.book_new(); | |||
| XLSX.utils.book_append_sheet(wb, ws, sheetName); | |||
| XLSX.writeFile(wb, `${filename}.xlsx`); | |||
| } | |||
| export type MultiSheetSpec = { name: string; rows: Record<string, unknown>[] }; | |||
| /** | |||
| * Export multiple worksheets in one .xlsx file. | |||
| * Sheet names are truncated to 31 characters (Excel limit). | |||
| */ | |||
| export function exportMultiSheetToXlsx( | |||
| sheets: MultiSheetSpec[], | |||
| filename: string | |||
| ): void { | |||
| const wb = XLSX.utils.book_new(); | |||
| for (const { name, rows } of sheets) { | |||
| const safeName = name.slice(0, 31); | |||
| let ws: ReturnType<typeof XLSX.utils.json_to_sheet>; | |||
| if (rows.length === 0) { | |||
| ws = XLSX.utils.aoa_to_sheet([[]]); | |||
| } else { | |||
| ws = XLSX.utils.json_to_sheet(rows); | |||
| const header = Object.keys(rows[0] ?? {}); | |||
| if (header.length > 0) { | |||
| ws["!cols"] = header.map((h) => ({ | |||
| wch: Math.max(12, h.length + 4), | |||
| })); | |||
| applyHeaderRowStyle(ws, header.length); | |||
| applyMoneyColumnNumberFormats(ws, header); | |||
| applyNumericColumnRightAlign(ws, header); | |||
| } | |||
| } | |||
| XLSX.utils.book_append_sheet(wb, ws, safeName); | |||
| } | |||
| XLSX.writeFile(wb, `${filename}.xlsx`); | |||
| } | |||
| @@ -33,8 +33,11 @@ The frontend **GRN/入倉明細報告** report calls the following endpoint. The | |||
| "stockUomDesc": "KG", | |||
| "productLotNo": "LOT-001", | |||
| "expiryDate": "2026-03-01", | |||
| "supplierCode": "P06", | |||
| "supplier": "Supplier Name", | |||
| "status": "completed" | |||
| "status": "completed", | |||
| "grnCode": "PPP004GRN26030298", | |||
| "grnId": 7854617 | |||
| } | |||
| ] | |||
| } | |||
| @@ -56,4 +59,12 @@ Or a direct array: | |||
| - `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. | |||
| Frontend builds the Excel from this JSON. Columns include: PO No., Delivery Note No., Receipt Date, Item Code, Item Name, Qty, Demand Qty, UOM, Supplier Lot No. 供應商批次, Expiry Date, Supplier Code, Supplier, 入倉狀態, **GRN Code** (`m18_goods_receipt_note_log.grn_code`), **GRN Id** (`m18_record_id`). | |||
| ## Frontend Excel styling (shared standard) | |||
| Header colours, number formats (`#,##0.00` for amounts), and column alignment are defined in: | |||
| **[`../chart/_components/EXCEL_EXPORT_STANDARD.md`](../chart/_components/EXCEL_EXPORT_STANDARD.md)** | |||
| Use that document when adding or changing Excel exports so formatting stays consistent. | |||
| @@ -2,7 +2,10 @@ | |||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||
| import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | |||
| import { exportChartToXlsx } from "@/app/(main)/chart/_components/exportChartToXlsx"; | |||
| import { | |||
| exportChartToXlsx, | |||
| exportMultiSheetToXlsx, | |||
| } from "@/app/(main)/chart/_components/exportChartToXlsx"; | |||
| export interface GrnReportRow { | |||
| poCode?: string; | |||
| @@ -21,12 +24,38 @@ export interface GrnReportRow { | |||
| supplierCode?: string; | |||
| supplier?: string; | |||
| status?: string; | |||
| grnId?: number | string; | |||
| /** PO line unit price (purchase_order_line.up) */ | |||
| unitPrice?: number; | |||
| /** unitPrice × acceptedQty */ | |||
| 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; | |||
| [key: string]: unknown; | |||
| } | |||
| /** Sheet "已上架PO金額": totals grouped by receipt date + currency / PO (ADMIN-only data from API). */ | |||
| export interface ListedPoAmounts { | |||
| currencyTotals: { | |||
| receiptDate?: string; | |||
| currencyCode?: string; | |||
| totalAmount?: number; | |||
| }[]; | |||
| byPurchaseOrder: { | |||
| receiptDate?: string; | |||
| poCode?: string; | |||
| currencyCode?: string; | |||
| totalAmount?: number; | |||
| grnCodes?: string; | |||
| }[]; | |||
| } | |||
| export interface GrnReportResponse { | |||
| rows: GrnReportRow[]; | |||
| listedPoAmounts?: ListedPoAmounts; | |||
| } | |||
| /** | |||
| @@ -35,7 +64,7 @@ export interface GrnReportResponse { | |||
| */ | |||
| export async function fetchGrnReportData( | |||
| criteria: Record<string, string> | |||
| ): Promise<GrnReportRow[]> { | |||
| ): Promise<{ rows: GrnReportRow[]; listedPoAmounts?: ListedPoAmounts }> { | |||
| const queryParams = new URLSearchParams(criteria).toString(); | |||
| const url = `${NEXT_PUBLIC_API_URL}/report/grn-report?${queryParams}`; | |||
| @@ -50,39 +79,134 @@ export async function fetchGrnReportData( | |||
| 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; | |||
| if (Array.isArray(data)) { | |||
| return { rows: data }; | |||
| } | |||
| const body = data as GrnReportResponse; | |||
| return { | |||
| rows: body.rows ?? [], | |||
| listedPoAmounts: body.listedPoAmounts, | |||
| }; | |||
| } | |||
| /** Coerce API JSON (number or numeric string) to a finite number. */ | |||
| function coerceToFiniteNumber(value: unknown): number | null { | |||
| if (value === null || value === undefined) return null; | |||
| if (typeof value === "number" && Number.isFinite(value)) return value; | |||
| if (typeof value === "string") { | |||
| const t = value.trim(); | |||
| if (t === "") return null; | |||
| const n = Number(t); | |||
| return Number.isFinite(n) ? n : null; | |||
| } | |||
| return null; | |||
| } | |||
| /** | |||
| * Cell value for money columns: numeric when possible so Excel export can apply `#,##0.00` (see exportChartToXlsx). | |||
| */ | |||
| function moneyCellValue(v: unknown): number | string { | |||
| const n = coerceToFiniteNumber(v); | |||
| if (n === null) return ""; | |||
| return n; | |||
| } | |||
| /** Thousands separator for quantities (up to 4 decimal places, trims trailing zeros). */ | |||
| const formatQty = (n: number | undefined | null): string => { | |||
| if (n === undefined || n === null || Number.isNaN(Number(n))) return ""; | |||
| return new Intl.NumberFormat("en-US", { | |||
| minimumFractionDigits: 0, | |||
| maximumFractionDigits: 4, | |||
| }).format(Number(n)); | |||
| }; | |||
| /** Excel column headers (bilingual) for GRN report */ | |||
| function toExcelRow(r: GrnReportRow): Record<string, string | number | undefined> { | |||
| return { | |||
| function toExcelRow( | |||
| r: GrnReportRow, | |||
| includeFinancialColumns: boolean | |||
| ): Record<string, string | number | undefined> { | |||
| const base: Record<string, string | number | undefined> = { | |||
| "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 ?? "", | |||
| "Qty / 數量": formatQty( | |||
| r.acceptedQty ?? r.receivedQty ?? undefined | |||
| ), | |||
| "Demand Qty / 訂單數量": formatQty(r.demandQty), | |||
| "UOM / 單位": r.uom ?? r.purchaseUomDesc ?? r.stockUomDesc ?? "", | |||
| "Product Lot No. / 批次": r.productLotNo ?? "", | |||
| "Supplier Lot No. 供應商批次": r.productLotNo ?? "", | |||
| "Expiry Date / 到期日": r.expiryDate ?? "", | |||
| "Supplier Code / 供應商編號": r.supplierCode ?? "", | |||
| "Supplier / 供應商": r.supplier ?? "", | |||
| "Status / 狀態": r.status ?? "", | |||
| "GRN Id / M18 單號": r.grnId ?? "", | |||
| "入倉狀態": r.status ?? "", | |||
| }; | |||
| if (includeFinancialColumns) { | |||
| base["Unit Price / 單價"] = moneyCellValue(r.unitPrice); | |||
| base["Currency / 貨幣"] = r.currencyCode ?? ""; | |||
| base["Amount / 金額"] = moneyCellValue(r.lineAmount); | |||
| } | |||
| base["GRN Code / M18 入倉單號"] = r.grnCode ?? ""; | |||
| base["GRN Id / M18 記錄編號"] = r.grnId ?? ""; | |||
| return base; | |||
| } | |||
| const GRN_SHEET_DETAIL = "PO入倉記錄"; | |||
| const GRN_SHEET_LISTED_PO = "已上架PO金額"; | |||
| /** Rows for sheet "已上架PO金額" (ADMIN-only; do not add this sheet for other users). */ | |||
| function buildListedPoAmountSheetRows( | |||
| listed: ListedPoAmounts | undefined | |||
| ): Record<string, string | number | undefined>[] { | |||
| if ( | |||
| !listed || | |||
| (listed.currencyTotals.length === 0 && | |||
| listed.byPurchaseOrder.length === 0) | |||
| ) { | |||
| return [ | |||
| { | |||
| "Note / 備註": | |||
| "(篩選範圍內無已完成之 PO 行) / No completed PO lines in the selected range", | |||
| }, | |||
| ]; | |||
| } | |||
| const out: Record<string, string | number | undefined>[] = []; | |||
| for (const c of listed.currencyTotals) { | |||
| out.push({ | |||
| "Category / 類別": "貨幣小計 / Currency total", | |||
| "Receipt Date / 收貨日期": c.receiptDate ?? "", | |||
| "PO No. / 訂單編號": "", | |||
| "Currency / 貨幣": c.currencyCode ?? "", | |||
| "Total Amount / 金額": moneyCellValue(c.totalAmount), | |||
| "GRN Code(s) / M18 入倉單號": "", | |||
| }); | |||
| } | |||
| for (const p of listed.byPurchaseOrder) { | |||
| out.push({ | |||
| "Category / 類別": "訂單 / PO", | |||
| "Receipt Date / 收貨日期": p.receiptDate ?? "", | |||
| "PO No. / 訂單編號": p.poCode ?? "", | |||
| "Currency / 貨幣": p.currencyCode ?? "", | |||
| "Total Amount / 金額": moneyCellValue(p.totalAmount), | |||
| "GRN Code(s) / M18 入倉單號": p.grnCodes ?? "", | |||
| }); | |||
| } | |||
| return out; | |||
| } | |||
| /** | |||
| * Generate and download GRN report as Excel. | |||
| * Sheet "已上架PO金額" is included only when `includeFinancialColumns` is true (ADMIN). | |||
| */ | |||
| export async function generateGrnReportExcel( | |||
| criteria: Record<string, string>, | |||
| reportTitle: string = "PO 入倉記錄" | |||
| reportTitle: string = "PO 入倉記錄", | |||
| /** Only users with ADMIN authority should pass true (must match backend). */ | |||
| includeFinancialColumns: boolean = false | |||
| ): Promise<void> { | |||
| const rows = await fetchGrnReportData(criteria); | |||
| const excelRows = rows.map(toExcelRow); | |||
| const { rows, listedPoAmounts } = await fetchGrnReportData(criteria); | |||
| const excelRows = rows.map((r) => toExcelRow(r, includeFinancialColumns)); | |||
| const start = criteria.receiptDateStart; | |||
| const end = criteria.receiptDateEnd; | |||
| let datePart: string; | |||
| @@ -95,5 +219,17 @@ export async function generateGrnReportExcel( | |||
| } | |||
| const safeDatePart = datePart.replace(/[^\d\-_/]/g, ""); | |||
| const filename = `${reportTitle}_${safeDatePart}`; | |||
| exportChartToXlsx(excelRows, filename, "GRN"); | |||
| if (includeFinancialColumns) { | |||
| const sheet2 = buildListedPoAmountSheetRows(listedPoAmounts); | |||
| exportMultiSheetToXlsx( | |||
| [ | |||
| { name: GRN_SHEET_DETAIL, rows: excelRows as Record<string, unknown>[] }, | |||
| { name: GRN_SHEET_LISTED_PO, rows: sheet2 as Record<string, unknown>[] }, | |||
| ], | |||
| filename | |||
| ); | |||
| } else { | |||
| exportChartToXlsx(excelRows as Record<string, unknown>[], filename, GRN_SHEET_DETAIL); | |||
| } | |||
| } | |||
| @@ -1,6 +1,9 @@ | |||
| "use client"; | |||
| import React, { useState, useMemo, useEffect } from 'react'; | |||
| import { useSession } from "next-auth/react"; | |||
| import { SessionWithTokens } from "@/config/authConfig"; | |||
| import { AUTH } from "@/authorities"; | |||
| import { | |||
| Box, | |||
| Card, | |||
| @@ -31,6 +34,10 @@ interface ItemCodeWithName { | |||
| } | |||
| export default function ReportPage() { | |||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | |||
| const includeGrnFinancialColumns = | |||
| session?.abilities?.includes(AUTH.ADMIN) ?? false; | |||
| const [selectedReportId, setSelectedReportId] = useState<string>(''); | |||
| const [criteria, setCriteria] = useState<Record<string, string>>({}); | |||
| const [loading, setLoading] = useState(false); | |||
| @@ -174,7 +181,11 @@ export default function ReportPage() { | |||
| setLoading(true); | |||
| try { | |||
| if (currentReport.id === 'rep-014') { | |||
| await generateGrnReportExcel(criteria, currentReport.title); | |||
| await generateGrnReportExcel( | |||
| criteria, | |||
| currentReport.title, | |||
| includeGrnFinancialColumns | |||
| ); | |||
| } else { | |||
| // Backend returns actual .xlsx bytes for this Excel endpoint. | |||
| const queryParams = new URLSearchParams(criteria).toString(); | |||
| @@ -0,0 +1,41 @@ | |||
| /** | |||
| * Client-side helpers for the backend JWT stored in localStorage (`accessToken`). | |||
| * Used to detect expiry before API calls so pages like /report can redirect early. | |||
| */ | |||
| /** Must match clientAuthFetch redirect target */ | |||
| export const LOGIN_SESSION_EXPIRED_HREF = "/login?session=expired"; | |||
| /** | |||
| * Decode JWT payload (no signature verification — UX only; server still validates). | |||
| */ | |||
| export function decodeJwtPayload( | |||
| token: string | |||
| ): Record<string, unknown> | null { | |||
| try { | |||
| const parts = token.split("."); | |||
| if (parts.length < 2) return null; | |||
| const payload = parts[1]; | |||
| const base64 = payload.replace(/-/g, "+").replace(/_/g, "/"); | |||
| const padded = base64.padEnd( | |||
| base64.length + ((4 - (base64.length % 4)) % 4), | |||
| "=" | |||
| ); | |||
| const binary = atob(padded); | |||
| const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0)); | |||
| const json = new TextDecoder().decode(bytes); | |||
| return JSON.parse(json) as Record<string, unknown>; | |||
| } catch { | |||
| return null; | |||
| } | |||
| } | |||
| /** | |||
| * True if `exp` claim is reached or passed. False if no `exp` (let API decide). | |||
| */ | |||
| export function isBackendJwtExpired(token: string): boolean { | |||
| const payload = decodeJwtPayload(token); | |||
| const exp = payload?.exp; | |||
| if (typeof exp !== "number") return false; | |||
| return Date.now() / 1000 >= exp; | |||
| } | |||
| @@ -1,6 +1,6 @@ | |||
| "use client"; | |||
| const LOGIN_REDIRECT = "/login?session=expired"; | |||
| import { LOGIN_SESSION_EXPIRED_HREF } from "@/app/utils/authToken"; | |||
| /** | |||
| * Client-side fetch that adds Bearer token from localStorage and redirects | |||
| @@ -23,7 +23,7 @@ export async function clientAuthFetch( | |||
| if (response.status === 401 || response.status === 403) { | |||
| if (typeof window !== "undefined") { | |||
| console.warn(`Auth error ${response.status} → redirecting to login`); | |||
| window.location.href = LOGIN_REDIRECT; | |||
| window.location.href = LOGIN_SESSION_EXPIRED_HREF; | |||
| } | |||
| } | |||