diff --git a/package.json b/package.json index e033a11..ccd8e66 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/(main)/axios/AxiosProvider.tsx b/src/app/(main)/axios/AxiosProvider.tsx index 9de6002..e2f5f2f 100644 --- a/src/app/(main)/axios/AxiosProvider.tsx +++ b/src/app/(main)/axios/AxiosProvider.tsx @@ -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) { diff --git a/src/app/(main)/chart/_components/EXCEL_EXPORT_STANDARD.md b/src/app/(main)/chart/_components/EXCEL_EXPORT_STANDARD.md new file mode 100644 index 0000000..80fb140 --- /dev/null +++ b/src/app/(main)/chart/_components/EXCEL_EXPORT_STANDARD.md @@ -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[]`** (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).* diff --git a/src/app/(main)/chart/_components/exportChartToXlsx.ts b/src/app/(main)/chart/_components/exportChartToXlsx.ts index 92c733d..b147fae 100644 --- a/src/app/(main)/chart/_components/exportChartToXlsx.ts +++ b/src/app/(main)/chart/_components/exportChartToXlsx.ts @@ -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(); + 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(); + 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[] }; + +/** + * 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; + 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`); +} diff --git a/src/app/(main)/report/GRN_REPORT_BACKEND_SPEC.md b/src/app/(main)/report/GRN_REPORT_BACKEND_SPEC.md index fb47305..3d52075 100644 --- a/src/app/(main)/report/GRN_REPORT_BACKEND_SPEC.md +++ b/src/app/(main)/report/GRN_REPORT_BACKEND_SPEC.md @@ -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. diff --git a/src/app/(main)/report/grnReportApi.ts b/src/app/(main)/report/grnReportApi.ts index d0c9fc0..92d674c 100644 --- a/src/app/(main)/report/grnReportApi.ts +++ b/src/app/(main)/report/grnReportApi.ts @@ -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 -): Promise { +): 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 { - return { +function toExcelRow( + r: GrnReportRow, + includeFinancialColumns: boolean +): Record { + const base: Record = { "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[] { + if ( + !listed || + (listed.currencyTotals.length === 0 && + listed.byPurchaseOrder.length === 0) + ) { + return [ + { + "Note / 備註": + "(篩選範圍內無已完成之 PO 行) / No completed PO lines in the selected range", + }, + ]; + } + const out: Record[] = []; + 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, - reportTitle: string = "PO 入倉記錄" + reportTitle: string = "PO 入倉記錄", + /** Only users with ADMIN authority should pass true (must match backend). */ + includeFinancialColumns: boolean = false ): Promise { - 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[] }, + { name: GRN_SHEET_LISTED_PO, rows: sheet2 as Record[] }, + ], + filename + ); + } else { + exportChartToXlsx(excelRows as Record[], filename, GRN_SHEET_DETAIL); + } } diff --git a/src/app/(main)/report/page.tsx b/src/app/(main)/report/page.tsx index e99c2f3..e77f043 100644 --- a/src/app/(main)/report/page.tsx +++ b/src/app/(main)/report/page.tsx @@ -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(''); const [criteria, setCriteria] = useState>({}); 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(); diff --git a/src/app/utils/authToken.ts b/src/app/utils/authToken.ts new file mode 100644 index 0000000..7dc3a48 --- /dev/null +++ b/src/app/utils/authToken.ts @@ -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 | 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; + } 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; +} diff --git a/src/app/utils/clientAuthFetch.ts b/src/app/utils/clientAuthFetch.ts index 1bc8462..67ed99a 100644 --- a/src/app/utils/clientAuthFetch.ts +++ b/src/app/utils/clientAuthFetch.ts @@ -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; } }