CANCERYS\kw093 7 часов назад
Родитель
Сommit
ab31d6a74c
9 измененных файлов: 578 добавлений и 34 удалений
  1. +2
    -1
      package.json
  2. +47
    -0
      src/app/(main)/axios/AxiosProvider.tsx
  3. +161
    -0
      src/app/(main)/chart/_components/EXCEL_EXPORT_STANDARD.md
  4. +147
    -11
      src/app/(main)/chart/_components/exportChartToXlsx.ts
  5. +13
    -2
      src/app/(main)/report/GRN_REPORT_BACKEND_SPEC.md
  6. +153
    -17
      src/app/(main)/report/grnReportApi.ts
  7. +12
    -1
      src/app/(main)/report/page.tsx
  8. +41
    -0
      src/app/utils/authToken.ts
  9. +2
    -2
      src/app/utils/clientAuthFetch.ts

+ 2
- 1
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",


+ 47
- 0
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) {


+ 161
- 0
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<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).*

+ 147
- 11
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<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`);
}

+ 13
- 2
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.

+ 153
- 17
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<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);
}
}

+ 12
- 1
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<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();


+ 41
- 0
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<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;
}

+ 2
- 2
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;
}
}



Загрузка…
Отмена
Сохранить