| @@ -66,7 +66,8 @@ | |||||
| "reactstrap": "^9.2.2", | "reactstrap": "^9.2.2", | ||||
| "styled-components": "^6.1.8", | "styled-components": "^6.1.8", | ||||
| "sweetalert2": "^11.10.3", | "sweetalert2": "^11.10.3", | ||||
| "xlsx": "^0.18.5" | |||||
| "xlsx": "^0.18.5", | |||||
| "xlsx-js-style": "^1.2.0" | |||||
| }, | }, | ||||
| "devDependencies": { | "devDependencies": { | ||||
| "@types/lodash": "^4.14.202", | "@types/lodash": "^4.14.202", | ||||
| @@ -3,6 +3,12 @@ | |||||
| "use client"; | "use client"; | ||||
| import React, { createContext, useContext, useEffect, useState, useCallback } from "react"; | 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"; | import axiosInstance, { SetupAxiosInterceptors } from "./axiosInstance"; | ||||
| const AxiosContext = createContext(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 | // Apply token + interceptors | ||||
| useEffect(() => { | useEffect(() => { | ||||
| if (accessToken) { | 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. | * 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), | 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(); | 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`); | ||||
| } | } | ||||
| 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", | "stockUomDesc": "KG", | ||||
| "productLotNo": "LOT-001", | "productLotNo": "LOT-001", | ||||
| "expiryDate": "2026-03-01", | "expiryDate": "2026-03-01", | ||||
| "supplierCode": "P06", | |||||
| "supplier": "Supplier Name", | "supplier": "Supplier Name", | ||||
| "status": "completed" | |||||
| "status": "completed", | |||||
| "grnCode": "PPP004GRN26030298", | |||||
| "grnId": 7854617 | |||||
| } | } | ||||
| ] | ] | ||||
| } | } | ||||
| @@ -56,4 +59,12 @@ Or a direct array: | |||||
| - `itemCode` 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. | - 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 { NEXT_PUBLIC_API_URL } from "@/config/api"; | ||||
| import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | 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 { | export interface GrnReportRow { | ||||
| poCode?: string; | poCode?: string; | ||||
| @@ -21,12 +24,38 @@ export interface GrnReportRow { | |||||
| supplierCode?: string; | supplierCode?: string; | ||||
| supplier?: string; | supplier?: string; | ||||
| status?: 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; | [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 { | export interface GrnReportResponse { | ||||
| rows: GrnReportRow[]; | rows: GrnReportRow[]; | ||||
| listedPoAmounts?: ListedPoAmounts; | |||||
| } | } | ||||
| /** | /** | ||||
| @@ -35,7 +64,7 @@ export interface GrnReportResponse { | |||||
| */ | */ | ||||
| export async function fetchGrnReportData( | export async function fetchGrnReportData( | ||||
| criteria: Record<string, string> | criteria: Record<string, string> | ||||
| ): Promise<GrnReportRow[]> { | |||||
| ): Promise<{ rows: GrnReportRow[]; listedPoAmounts?: ListedPoAmounts }> { | |||||
| const queryParams = new URLSearchParams(criteria).toString(); | const queryParams = new URLSearchParams(criteria).toString(); | ||||
| const url = `${NEXT_PUBLIC_API_URL}/report/grn-report?${queryParams}`; | 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}`); | throw new Error(`HTTP error! status: ${response.status}`); | ||||
| const data = (await response.json()) as GrnReportResponse | GrnReportRow[]; | 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 */ | /** 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 ?? "", | "PO No. / 訂單編號": r.poCode ?? "", | ||||
| "Supplier Code / 供應商編號": r.supplierCode ?? "", | |||||
| "Delivery Note No. / 送貨單編號": r.deliveryNoteNo ?? "", | "Delivery Note No. / 送貨單編號": r.deliveryNoteNo ?? "", | ||||
| "Receipt Date / 收貨日期": r.receiptDate ?? "", | "Receipt Date / 收貨日期": r.receiptDate ?? "", | ||||
| "Item Code / 物料編號": r.itemCode ?? "", | "Item Code / 物料編號": r.itemCode ?? "", | ||||
| "Item Name / 物料名稱": r.itemName ?? "", | "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 ?? "", | "UOM / 單位": r.uom ?? r.purchaseUomDesc ?? r.stockUomDesc ?? "", | ||||
| "Product Lot No. / 批次": r.productLotNo ?? "", | |||||
| "Supplier Lot No. 供應商批次": r.productLotNo ?? "", | |||||
| "Expiry Date / 到期日": r.expiryDate ?? "", | "Expiry Date / 到期日": r.expiryDate ?? "", | ||||
| "Supplier Code / 供應商編號": r.supplierCode ?? "", | |||||
| "Supplier / 供應商": r.supplier ?? "", | "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. | * Generate and download GRN report as Excel. | ||||
| * Sheet "已上架PO金額" is included only when `includeFinancialColumns` is true (ADMIN). | |||||
| */ | */ | ||||
| export async function generateGrnReportExcel( | export async function generateGrnReportExcel( | ||||
| criteria: Record<string, string>, | 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> { | ): 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 start = criteria.receiptDateStart; | ||||
| const end = criteria.receiptDateEnd; | const end = criteria.receiptDateEnd; | ||||
| let datePart: string; | let datePart: string; | ||||
| @@ -95,5 +219,17 @@ export async function generateGrnReportExcel( | |||||
| } | } | ||||
| const safeDatePart = datePart.replace(/[^\d\-_/]/g, ""); | const safeDatePart = datePart.replace(/[^\d\-_/]/g, ""); | ||||
| const filename = `${reportTitle}_${safeDatePart}`; | 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"; | "use client"; | ||||
| import React, { useState, useMemo, useEffect } from 'react'; | import React, { useState, useMemo, useEffect } from 'react'; | ||||
| import { useSession } from "next-auth/react"; | |||||
| import { SessionWithTokens } from "@/config/authConfig"; | |||||
| import { AUTH } from "@/authorities"; | |||||
| import { | import { | ||||
| Box, | Box, | ||||
| Card, | Card, | ||||
| @@ -31,6 +34,10 @@ interface ItemCodeWithName { | |||||
| } | } | ||||
| export default function ReportPage() { | 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 [selectedReportId, setSelectedReportId] = useState<string>(''); | ||||
| const [criteria, setCriteria] = useState<Record<string, string>>({}); | const [criteria, setCriteria] = useState<Record<string, string>>({}); | ||||
| const [loading, setLoading] = useState(false); | const [loading, setLoading] = useState(false); | ||||
| @@ -174,7 +181,11 @@ export default function ReportPage() { | |||||
| setLoading(true); | setLoading(true); | ||||
| try { | try { | ||||
| if (currentReport.id === 'rep-014') { | if (currentReport.id === 'rep-014') { | ||||
| await generateGrnReportExcel(criteria, currentReport.title); | |||||
| await generateGrnReportExcel( | |||||
| criteria, | |||||
| currentReport.title, | |||||
| includeGrnFinancialColumns | |||||
| ); | |||||
| } else { | } else { | ||||
| // Backend returns actual .xlsx bytes for this Excel endpoint. | // Backend returns actual .xlsx bytes for this Excel endpoint. | ||||
| const queryParams = new URLSearchParams(criteria).toString(); | 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"; | "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 | * 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 (response.status === 401 || response.status === 403) { | ||||
| if (typeof window !== "undefined") { | if (typeof window !== "undefined") { | ||||
| console.warn(`Auth error ${response.status} → redirecting to login`); | console.warn(`Auth error ${response.status} → redirecting to login`); | ||||
| window.location.href = LOGIN_REDIRECT; | |||||
| window.location.href = LOGIN_SESSION_EXPIRED_HREF; | |||||
| } | } | ||||
| } | } | ||||