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)/ps/page.tsx b/src/app/(main)/ps/page.tsx index 6c72bc6..81bb48e 100644 --- a/src/app/(main)/ps/page.tsx +++ b/src/app/(main)/ps/page.tsx @@ -210,8 +210,8 @@ export default function ProductionSchedulePage() { } }; - const fromDateDefault = dayjs().subtract(29, "day").format("YYYY-MM-DD"); - const toDateDefault = dayjs().format("YYYY-MM-DD"); + const fromDateDefault = dayjs().subtract(6, "day").format("YYYY-MM-DD"); + const toDateDefault = dayjs().add(1, "day").format("YYYY-MM-DD"); const fetchItemDailyOut = async (force: boolean = false) => { // Avoid starting a new fetch while an import is in progress, 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 9ac6d2b..56b8c86 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/api/do/actions.tsx b/src/app/api/do/actions.tsx index f7fc806..dd9c16b 100644 --- a/src/app/api/do/actions.tsx +++ b/src/app/api/do/actions.tsx @@ -134,6 +134,8 @@ export interface getTicketReleaseTable { requiredDeliveryDate: string | null; handlerName: string | null; numberOfFGItems: number; + /** 進行中 do_pick_order 為 true,才可呼叫 force-complete / revert-assignment(id 為 do_pick_order 主鍵) */ + isActiveDoPickOrder?: boolean; } export interface TruckScheduleDashboardItem { diff --git a/src/app/api/jo/actions.ts b/src/app/api/jo/actions.ts index f3f10b2..97cbe0e 100644 --- a/src/app/api/jo/actions.ts +++ b/src/app/api/jo/actions.ts @@ -29,6 +29,7 @@ export interface SearchJoResultRequest extends Pageable { planStart?: string; planStartTo?: string; jobTypeName?: string; + joSearchStatus?: string; } export interface productProcessLineQtyRequest { @@ -356,6 +357,13 @@ export interface AllJoborderProductProcessInfoResponse { FinishedProductProcessLineCount: number; lines: ProductProcessInfoResponse[]; } + +export interface JobOrderProductProcessPageResponse { + content: AllJoborderProductProcessInfoResponse[]; + totalJobOrders: number; + page: number; + size: number; +} export interface ProductProcessInfoResponse { id: number; operatorId?: number; @@ -665,6 +673,16 @@ export const deleteJobOrder=cache(async (jobOrderId: number) => { } ); }); + +export const setJobOrderHidden = cache(async (jobOrderId: number, hidden: boolean) => { + const response = await serverFetchJson(`${BASE_API_URL}/jo/set-hidden`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: jobOrderId, hidden }), + }); + revalidateTag("jos"); + return response; +}); export const fetchAllJobTypes = cache(async () => { return serverFetchJson( `${BASE_API_URL}/jo/jobTypes`, @@ -771,6 +789,52 @@ export const fetchAllJoborderProductProcessInfo = cache(async (isDrink?: boolean ); }); +export const fetchJoborderProductProcessesPage = cache(async (params: { + /** Job order planStart 區間起(YYYY-MM-DD,含當日) */ + date?: string | null; + itemCode?: string | null; + jobOrderCode?: string | null; + bomIds?: number[] | null; + qcReady?: boolean | null; + isDrink?: boolean | null; + page?: number; + size?: number; +}) => { + const { + date, + itemCode, + jobOrderCode, + bomIds, + qcReady, + isDrink, + page = 0, + size = 50, + } = params; + + const queryParts: string[] = []; + if (date) { + queryParts.push(`date=${encodeURIComponent(date)}`); + } + if (itemCode) queryParts.push(`itemCode=${encodeURIComponent(itemCode)}`); + if (jobOrderCode) queryParts.push(`jobOrderCode=${encodeURIComponent(jobOrderCode)}`); + if (bomIds && bomIds.length > 0) queryParts.push(`bomIds=${bomIds.join(",")}`); + if (qcReady !== undefined && qcReady !== null) queryParts.push(`qcReady=${qcReady}`); + if (isDrink !== undefined && isDrink !== null) queryParts.push(`isDrink=${isDrink}`); + + queryParts.push(`page=${page}`); + queryParts.push(`size=${size}`); + + const query = queryParts.length > 0 ? `?${queryParts.join("&")}` : ""; + + return serverFetchJson( + `${BASE_API_URL}/product-process/Demo/Process/search${query}`, + { + method: "GET", + next: { tags: ["productProcessSearch"] }, + } + ); +}); + /* export const updateProductProcessLineQty = async (request: UpdateProductProcessLineQtyRequest) => { return serverFetchJson( diff --git a/src/app/api/pickOrder/actions.ts b/src/app/api/pickOrder/actions.ts index 44287e6..5811294 100644 --- a/src/app/api/pickOrder/actions.ts +++ b/src/app/api/pickOrder/actions.ts @@ -377,6 +377,8 @@ export interface CompletedDoPickOrderSearchParams { targetDate?: string; shopName?: string; deliveryNoteCode?: string; + /** 卡車/車道(後端 truckLanceCode 模糊匹配) */ + truckLanceCode?: string; } export interface PickExecutionIssue { id: number; @@ -670,7 +672,10 @@ export const fetchCompletedDoPickOrders = async ( if (searchParams?.targetDate) { params.append('targetDate', searchParams.targetDate); } - + if (searchParams?.truckLanceCode) { + params.append("truckLanceCode", searchParams.truckLanceCode); + } + const queryString = params.toString(); const url = `${BASE_API_URL}/pickOrder/completed-do-pick-orders/${userId}${queryString ? `?${queryString}` : ''}`; @@ -680,6 +685,56 @@ export const fetchCompletedDoPickOrders = async ( return response; }; + +/** 全部已完成 DO 提貨記錄(不限經手人),需後端 `/completed-do-pick-orders-all` */ +export const fetchCompletedDoPickOrdersAll = async ( + searchParams?: CompletedDoPickOrderSearchParams +): Promise => { + const params = new URLSearchParams(); + + if (searchParams?.deliveryNoteCode) { + params.append("deliveryNoteCode", searchParams.deliveryNoteCode); + } + if (searchParams?.shopName) { + params.append("shopName", searchParams.shopName); + } + if (searchParams?.targetDate) { + params.append("targetDate", searchParams.targetDate); + } + if (searchParams?.truckLanceCode) { + params.append("truckLanceCode", searchParams.truckLanceCode); + } + + const queryString = params.toString(); + const url = `${BASE_API_URL}/pickOrder/completed-do-pick-orders-all${queryString ? `?${queryString}` : ""}`; + + const response = await serverFetchJson(url, { + method: "GET", + }); + + return response; +}; + +/** 強制完成進行中的 do_pick_order(僅改狀態並歸檔,不調整揀貨數量) */ +export const forceCompleteDoPickOrder = async ( + doPickOrderId: number, +): Promise => { + return serverFetchJson( + `${BASE_API_URL}/doPickOrder/force-complete/${doPickOrderId}`, + { method: "POST", headers: { "Content-Type": "application/json" } }, + ); +}; + +/** 撤銷使用者領取,可再次分配 */ +export const revertDoPickOrderAssignment = async ( + doPickOrderId: number, +): Promise => { + return serverFetchJson( + `${BASE_API_URL}/doPickOrder/revert-assignment/${doPickOrderId}`, + { method: "POST", headers: { "Content-Type": "application/json" } }, + ); +}; + export const updatePickOrderHideStatus = async (pickOrderId: number, hide: boolean) => { const response = await serverFetchJson( `${BASE_API_URL}/pickOrder/update-hide-status/${pickOrderId}?hide=${hide}`, 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; } } diff --git a/src/components/FinishedGoodSearch/FGPickOrderTicketReleaseTable.tsx b/src/components/FinishedGoodSearch/FGPickOrderTicketReleaseTable.tsx index 1fad172..041851b 100644 --- a/src/components/FinishedGoodSearch/FGPickOrderTicketReleaseTable.tsx +++ b/src/components/FinishedGoodSearch/FGPickOrderTicketReleaseTable.tsx @@ -1,6 +1,14 @@ "use client"; -import React, { useState, useEffect, useCallback, useMemo } from 'react'; +/** + * 權限說明(與全站一致): + * - 登入後 JWT / session 帶有 `abilities: string[]`(見 config/authConfig、authorities.ts)。 + * - 導航「Finished Good Order」等使用 `requiredAbility: [AUTH.STOCK_FG, AUTH.ADMIN]`。 + * - 本表「撤銷領取 / 強制完成」僅允許具 **ADMIN** 能力者操作(專案內以 ADMIN 作為管理員層級權限)。 + * - 一般使用者可進入本頁與檢視列表;按鈕會 disabled 並以 Tooltip 提示。 + */ + +import React, { useState, useEffect, useCallback, useMemo } from "react"; import { Box, Typography, @@ -20,16 +28,46 @@ import { Paper, CircularProgress, TablePagination, - Chip -} from '@mui/material'; -import { useTranslation } from 'react-i18next'; -import dayjs from 'dayjs'; -import { arrayToDayjs } from '@/app/utils/formatUtil'; -import { fetchTicketReleaseTable, getTicketReleaseTable } from '@/app/api/do/actions'; + Chip, + Button, + Tooltip, +} from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { useSession } from "next-auth/react"; +import dayjs, { Dayjs } from "dayjs"; +import { arrayToDayjs } from "@/app/utils/formatUtil"; +import { fetchTicketReleaseTable, getTicketReleaseTable } from "@/app/api/do/actions"; +import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import { DatePicker } from "@mui/x-date-pickers/DatePicker"; +import { + forceCompleteDoPickOrder, + revertDoPickOrderAssignment, +} from "@/app/api/pickOrder/actions"; +import Swal from "sweetalert2"; +import { AUTH } from "@/authorities"; +import { SessionWithTokens } from "@/config/authConfig"; + +function isCompletedStatus(status: string | null | undefined): boolean { + return (status ?? "").toLowerCase() === "completed"; +} + +/** 已領取(有負責人)的進行中單據才可撤銷或強制完成;未領取不可強制完成 */ +function showDoPickOpsButtons(row: getTicketReleaseTable): boolean { + return ( + row.isActiveDoPickOrder === true && + !isCompletedStatus(row.ticketStatus) && + row.handledBy != null + ); +} const FGPickOrderTicketReleaseTable: React.FC = () => { const { t } = useTranslation("ticketReleaseTable"); - const [selectedDate, setSelectedDate] = useState("today"); + const { data: session } = useSession() as { data: SessionWithTokens | null }; + const abilities = session?.abilities ?? session?.user?.abilities ?? []; + const canManageDoPickOps = abilities.includes(AUTH.ADMIN); + + const [queryDate, setQueryDate] = useState(() => dayjs()); const [selectedFloor, setSelectedFloor] = useState(""); const [selectedStatus, setSelectedStatus] = useState("released"); @@ -41,89 +79,77 @@ const FGPickOrderTicketReleaseTable: React.FC = () => { }); const [now, setNow] = useState(dayjs()); -const [lastDataRefreshTime, setLastDataRefreshTime] = useState(null); - const formatTime = (timeData: any): string => { - if (!timeData) return ''; - + const [lastDataRefreshTime, setLastDataRefreshTime] = useState(null); + + const formatTime = (timeData: unknown): string => { + if (!timeData) return ""; + let hour: number; let minute: number; - - if (typeof timeData === 'string') { - const parts = timeData.split(':'); + if (typeof timeData === "string") { + const parts = timeData.split(":"); hour = parseInt(parts[0], 10); - minute = parseInt(parts[1] || '0', 10); + minute = parseInt(parts[1] || "0", 10); } else if (Array.isArray(timeData)) { - hour = timeData[0] || 0; minute = timeData[1] || 0; - } - else { - return ''; + } else { + return ""; } - - const formattedHour = hour.toString().padStart(2, '0'); - const formattedMinute = minute.toString().padStart(2, '0'); - return `${formattedHour}:${formattedMinute}`; - }; - const getDateLabel = (offset: number) => { - return dayjs().add(offset, 'day').format('YYYY-MM-DD'); - }; - const getDateRange = () => { - const today = dayjs().format('YYYY-MM-DD'); - const dayAfterTomorrow = dayjs().add(2, 'day').format('YYYY-MM-DD'); - return { startDate: today, endDate: dayAfterTomorrow }; + const formattedHour = hour.toString().padStart(2, "0"); + const formattedMinute = minute.toString().padStart(2, "0"); + return `${formattedHour}:${formattedMinute}`; }; -const loadData = useCallback(async () => { - setLoading(true); - try { - const { startDate, endDate } = getDateRange(); - const result = await fetchTicketReleaseTable(startDate, endDate); - setData(result); - setLastDataRefreshTime(dayjs()); - } catch (error) { - console.error('Error fetching ticket release table:', error); - } finally { - setLoading(false); - } -}, []); - -useEffect(() => { - loadData(); - const id = setInterval(loadData, 5 * 60 * 1000); - return () => clearInterval(id); -}, [loadData]); - - const filteredData = data.filter((item) => { - // Filter by floor if selected - if (selectedFloor && item.storeId !== selectedFloor) { - return false; + const loadData = useCallback(async () => { + setLoading(true); + try { + const dayStr = queryDate.format("YYYY-MM-DD"); + const result = await fetchTicketReleaseTable(dayStr, dayStr); + setData(result); + setLastDataRefreshTime(dayjs()); + } catch (error) { + console.error("Error fetching ticket release table:", error); + } finally { + setLoading(false); } + }, [queryDate]); - // Filter by date if selected - if (selectedDate && item.requiredDeliveryDate) { - const itemDate = dayjs(item.requiredDeliveryDate).format('YYYY-MM-DD'); - const targetDate = getDateLabel( - selectedDate === "today" ? 0 : selectedDate === "tomorrow" ? 1 : 2 - ); - if (itemDate !== targetDate) { - return false; - } - } + useEffect(() => { + loadData(); + const id = setInterval(loadData, 5 * 60 * 1000); + return () => clearInterval(id); + }, [loadData]); - // Filter by status if selected - if (selectedStatus && item.ticketStatus?.toLowerCase() !== selectedStatus.toLowerCase()) { - return false; - } + useEffect(() => { + const tick = setInterval(() => setNow(dayjs()), 30 * 1000); + return () => clearInterval(tick); + }, []); + const dayStr = queryDate.format("YYYY-MM-DD"); - return true; - },[data, selectedDate, selectedFloor, selectedStatus]); + const filteredData = useMemo(() => { + return data.filter((item) => { + if (selectedFloor && item.storeId !== selectedFloor) { + return false; + } + if (item.requiredDeliveryDate) { + const itemDate = dayjs(item.requiredDeliveryDate).format("YYYY-MM-DD"); + if (itemDate !== dayStr) { + return false; + } + } + if (selectedStatus && item.ticketStatus?.toLowerCase() !== selectedStatus.toLowerCase()) { + return false; + } + return true; + }); + }, [data, dayStr, selectedFloor, selectedStatus]); - const handlePageChange = useCallback((event: unknown, newPage: number) => { - setPaginationController(prev => ({ + const handlePageChange = useCallback((event: unknown, newPage: number) => { + setPaginationController((prev) => ({ ...prev, pageNum: newPage, })); @@ -144,265 +170,319 @@ useEffect(() => { }, [filteredData, paginationController]); useEffect(() => { - setPaginationController(prev => ({ ...prev, pageNum: 0 })); - }, [selectedDate, selectedFloor, selectedStatus]); + setPaginationController((prev) => ({ ...prev, pageNum: 0 })); + }, [queryDate, selectedFloor, selectedStatus]); + + const handleRevert = async (row: getTicketReleaseTable) => { + if (!canManageDoPickOps) return; + const r = await Swal.fire({ + title: t("Confirm revert assignment"), + text: t("Revert assignment hint"), + icon: "warning", + showCancelButton: true, + confirmButtonText: t("Confirm"), + cancelButtonText: t("Cancel"), + }); + if (!r.isConfirmed) return; + try { + const res = await revertDoPickOrderAssignment(row.id); + if (res.code === "SUCCESS") { + await Swal.fire({ icon: "success", text: t("Operation succeeded"), timer: 1500, showConfirmButton: false }); + await loadData(); + } else { + await Swal.fire({ icon: "error", title: res.code ?? "", text: res.message ?? "" }); + } + } catch (e) { + console.error(e); + await Swal.fire({ icon: "error", text: String(e) }); + } + }; + + const handleForceComplete = async (row: getTicketReleaseTable) => { + if (!canManageDoPickOps) return; + const r = await Swal.fire({ + title: t("Confirm force complete"), + text: t("Force complete hint"), + icon: "warning", + showCancelButton: true, + confirmButtonText: t("Confirm"), + cancelButtonText: t("Cancel"), + }); + if (!r.isConfirmed) return; + try { + const res = await forceCompleteDoPickOrder(row.id); + if (res.code === "SUCCESS") { + await Swal.fire({ icon: "success", text: t("Operation succeeded"), timer: 1500, showConfirmButton: false }); + await loadData(); + } else { + await Swal.fire({ icon: "error", title: res.code ?? "", text: res.message ?? "" }); + } + } catch (e) { + console.error(e); + await Swal.fire({ icon: "error", text: String(e) }); + } + }; + + const opsTooltip = !canManageDoPickOps ? t("Manager only hint") : ""; return ( - - - {/* Title */} - - {t("Ticket Release Table")} - - - {/* Dropdown Menus */} - - - {t("Select Date")} - - - - - - {t("Floor")} - - - - - - - {t("Status")} - - - - - - - - {t("Now")}: {now.format('HH:mm')} - - - {t("Auto-refresh every 5 minutes")} | {t("Last updated")}: {lastDataRefreshTime ? lastDataRefreshTime.format('HH:mm:ss') : '--:--:--'} - + + + + + {t("Ticket Release Table")} + + + + v && setQueryDate(v)} + slotProps={{ textField: { size: "small", sx: { minWidth: 180 } } }} + /> + + + + + {t("Floor")} + + + + + + + {t("Status")} + + + + + + + + {t("Now")}: {now.format("HH:mm")} + + + {t("Auto-refresh every 5 minutes")} | {t("Last updated")}:{" "} + {lastDataRefreshTime ? lastDataRefreshTime.format("HH:mm:ss") : "--:--:--"} + + - - - - {loading ? ( - - - - ) : ( - <> - - - - - {t("Store ID")} - {t("Required Delivery Date")} - - - - {t("Truck Information")} - - - {t("Truck Lane Code")} - {t("Departure Time")} - - - - {/*{t("Truck Departure Time")} - {t("Truck Lane Code")}*/} - {t("Shop Name")} - {t("Loading Sequence")} - {/*{t("Delivery Order Code(s)")} - {t("Pick Order Code(s)")} - {t("Ticket Number")} - {t("Ticket Release Time")} - {t("Ticket Complete Date Time")} - {t("Ticket Status")}*/} - - - - {t("Ticket Information")} - - - {t("Ticket No.")} ({t("Status")}) - - - {t("Released Time")} - {t("Completed Time")} - - - - {t("Handler Name")} - {t("Number of FG Items (Order Item(s) Count)")} - - - - {paginatedData.length === 0 ? ( - - - {t("No data available")} + + + {loading ? ( + + + + ) : ( + <> + +
+ + + {t("Store ID")} + {t("Required Delivery Date")} + + + + {t("Truck Information")} + + + {t("Truck Lane Code")} - {t("Departure Time")} + + + + {t("Shop Name")} + {t("Loading Sequence")} + + + + {t("Ticket Information")} + + + {t("Ticket No.")} ({t("Status")}) + + + {t("Released Time")} - {t("Completed Time")} + + + + {t("Handler Name")} + + {t("Number of FG Items (Order Item(s) Count)")} + + + {t("Actions")} - ) : ( - paginatedData.map((row) => { - return ( - - {row.storeId || '-'} + + + {paginatedData.length === 0 ? ( + + + {t("No data available")} + + + ) : ( + paginatedData.map((row) => ( + + {row.storeId || "-"} {row.requiredDeliveryDate - ? dayjs(row.requiredDeliveryDate).format('YYYY-MM-DD') - : '-'} + ? dayjs(row.requiredDeliveryDate).format("YYYY-MM-DD") + : "-"} - - - {row.truckLanceCode && ( - - )} - {row.truckDepartureTime && ( - - )} - {!row.truckLanceCode && !row.truckDepartureTime && ( - - - - - )} - - + + + {row.truckLanceCode && ( + + )} + {row.truckDepartureTime && ( + + )} + {!row.truckLanceCode && !row.truckDepartureTime && ( + + - + + )} + + - {row.shopName || '-'} - {row.loadingSequence || '-'} - {/*{row.deliveryOrderCode || '-'} - {row.pickOrderCode || '-'} - {row.ticketNo || '-'} - - {row.ticketReleaseTime - ? dayjs(row.ticketReleaseTime).format('YYYY-MM-DD HH:mm:ss') - : '-'} - - - {row.ticketCompleteDateTime - ? dayjs(row.ticketCompleteDateTime).format('YYYY-MM-DD HH:mm:ss') - : '-'} - - {row.ticketStatus || '-'}*/} + {row.shopName || "-"} + {row.loadingSequence ?? "-"} - + - {row.ticketNo || '-'} ({row.ticketStatus ? t(row.ticketStatus.toLowerCase()) : '-'}) + {row.ticketNo || "-"} ({row.ticketStatus ? t(row.ticketStatus.toLowerCase()) : "-"}) - {t("Released Time")}: {row.ticketReleaseTime + {t("Released Time")}:{" "} + {row.ticketReleaseTime ? (() => { if (Array.isArray(row.ticketReleaseTime)) { - return arrayToDayjs(row.ticketReleaseTime, true).format('HH:mm'); + return arrayToDayjs(row.ticketReleaseTime, true).format("HH:mm"); } - const parsedDate = dayjs(row.ticketReleaseTime, 'YYYYMMDDHHmmss'); + const parsedDate = dayjs(row.ticketReleaseTime, "YYYYMMDDHHmmss"); if (!parsedDate.isValid()) { - return dayjs(row.ticketReleaseTime).format('HH:mm'); + return dayjs(row.ticketReleaseTime).format("HH:mm"); } - return parsedDate.format('HH:mm'); + return parsedDate.format("HH:mm"); })() - : '-'} + : "-"} - {t("Completed Time")}: {row.ticketCompleteDateTime + {t("Completed Time")}:{" "} + {row.ticketCompleteDateTime ? (() => { if (Array.isArray(row.ticketCompleteDateTime)) { - return arrayToDayjs(row.ticketCompleteDateTime, true).format('HH:mm'); + return arrayToDayjs(row.ticketCompleteDateTime, true).format("HH:mm"); } - const parsedDate = dayjs(row.ticketCompleteDateTime, 'YYYYMMDDHHmmss'); + const parsedDate = dayjs(row.ticketCompleteDateTime, "YYYYMMDDHHmmss"); if (!parsedDate.isValid()) { - return dayjs(row.ticketCompleteDateTime).format('HH:mm'); + return dayjs(row.ticketCompleteDateTime).format("HH:mm"); } - return parsedDate.format('HH:mm'); + return parsedDate.format("HH:mm"); })() - : '-'} + : "-"} - {row.handlerName ?? 0} - {row.numberOfFGItems ?? 0} + {row.handlerName ?? "-"} + + {row.numberOfFGItems ?? 0} + + + {showDoPickOpsButtons(row) ? ( + + + + + + + + + + + + + ) : ( + + — + + )} + - ); - }) - )} - -
-
- {filteredData.length > 0 && ( - - )} - - )} -
-
-
+ )) + )} + + + + {filteredData.length > 0 && ( + + )} + + )} + + + + ); }; -export default FGPickOrderTicketReleaseTable; \ No newline at end of file +export default FGPickOrderTicketReleaseTable; diff --git a/src/components/FinishedGoodSearch/FinishedGoodSearch.tsx b/src/components/FinishedGoodSearch/FinishedGoodSearch.tsx index 2465887..8ae9faa 100644 --- a/src/components/FinishedGoodSearch/FinishedGoodSearch.tsx +++ b/src/components/FinishedGoodSearch/FinishedGoodSearch.tsx @@ -315,8 +315,8 @@ const [selectedPrinterForDraft, setSelectedPrinterForDraft] = useState { - // 当切换到标签页 2 (GoodPickExecutionRecord) 时,重置打印按钮状态 - if (tabIndex === 2) { + // 当切换到成品提貨記錄標籤時,重置打印按钮状态 + if (tabIndex === 2 || tabIndex === 4) { setPrintButtonsEnabled(false); console.log("Reset print buttons for Pick Execution Record tab"); } @@ -709,6 +709,7 @@ const handleAssignByLane = useCallback(async ( + @@ -742,8 +743,27 @@ const handleAssignByLane = useCallback(async ( onRefreshReleasedOrderCount={fetchReleasedOrderCount} /> ) } - {tabIndex === 2 && } - {tabIndex === 3 && } + {tabIndex === 2 && ( + + )} + {tabIndex === 3 && } + {tabIndex === 4 && ( + + )} ); diff --git a/src/components/FinishedGoodSearch/GoodPickExecutionRecord.tsx b/src/components/FinishedGoodSearch/GoodPickExecutionRecord.tsx index be57310..225bab7 100644 --- a/src/components/FinishedGoodSearch/GoodPickExecutionRecord.tsx +++ b/src/components/FinishedGoodSearch/GoodPickExecutionRecord.tsx @@ -44,6 +44,7 @@ import { PickOrderCompletionResponse, checkAndCompletePickOrderByConsoCode, fetchCompletedDoPickOrders, + fetchCompletedDoPickOrdersAll, CompletedDoPickOrderResponse, CompletedDoPickOrderSearchParams, fetchLotDetailsByDoPickOrderRecordId @@ -74,6 +75,10 @@ interface Props { printerCombo: PrinterCombo[]; a4Printer: PrinterCombo | null; // A4 打印机(DN 用) labelPrinter: PrinterCombo | null; + /** 與 FinishedGoodSearch 標籤索引一致,用於 pickOrderCompletionStatus 事件 */ + recordTabIndex?: number; + /** mine:僅本人經手的完成記錄;all:全部人員的完成記錄(新分頁) */ + listScope?: "mine" | "all"; } @@ -87,7 +92,14 @@ interface PickOrderData { lots: any[]; } -const GoodPickExecutionRecord: React.FC = ({ filterArgs, printerCombo, a4Printer, labelPrinter }) => { +const GoodPickExecutionRecord: React.FC = ({ + filterArgs, + printerCombo, + a4Printer, + labelPrinter, + recordTabIndex = 2, + listScope = "mine", +}) => { const { t } = useTranslation("pickOrder"); const router = useRouter(); const { data: session } = useSession() as { data: SessionWithTokens | null }; @@ -103,8 +115,10 @@ const GoodPickExecutionRecord: React.FC = ({ filterArgs, printerCombo, a4 const [showDetailView, setShowDetailView] = useState(false); const [detailLotData, setDetailLotData] = useState([]); - // 新增:搜索状态 - const [searchQuery, setSearchQuery] = useState>({}); + // 新增:搜索状态(預設為今日) + const [searchQuery, setSearchQuery] = useState>(() => ({ + targetDate: dayjs().format("YYYY-MM-DD"), + })); const [filteredDoPickOrders, setFilteredDoPickOrders] = useState([]); // 新增:分页状态 @@ -353,14 +367,17 @@ const GoodPickExecutionRecord: React.FC = ({ filterArgs, printerCombo, a4 // 修改:使用新的 API 获取已完成的 DO Pick Orders const fetchCompletedDoPickOrdersData = useCallback(async (searchParams?: CompletedDoPickOrderSearchParams) => { - if (!currentUserId) return; - + if (listScope === "mine" && !currentUserId) return; + setCompletedDoPickOrdersLoading(true); try { console.log("🔍 Fetching completed DO pick orders with params:", searchParams); - - const completedDoPickOrders = await fetchCompletedDoPickOrders(currentUserId, searchParams); - + + const completedDoPickOrders = + listScope === "all" + ? await fetchCompletedDoPickOrdersAll(searchParams) + : await fetchCompletedDoPickOrders(currentUserId!, searchParams); + setCompletedDoPickOrders(completedDoPickOrders); setFilteredDoPickOrders(completedDoPickOrders); console.log(" Fetched completed DO pick orders:", completedDoPickOrders); @@ -371,14 +388,19 @@ const GoodPickExecutionRecord: React.FC = ({ filterArgs, printerCombo, a4 } finally { setCompletedDoPickOrdersLoading(false); } - }, [currentUserId]); + }, [currentUserId, listScope]); - // 初始化时获取数据 + // 初始化时获取数据(預設依「今日」篩選) useEffect(() => { - if (currentUserId) { - fetchCompletedDoPickOrdersData(); + const todayParams: CompletedDoPickOrderSearchParams = { + targetDate: dayjs().format("YYYY-MM-DD"), + }; + if (listScope === "all") { + fetchCompletedDoPickOrdersData(todayParams); + } else if (currentUserId) { + fetchCompletedDoPickOrdersData(todayParams); } - }, [currentUserId, fetchCompletedDoPickOrdersData]); + }, [currentUserId, listScope, fetchCompletedDoPickOrdersData]); // 修改:搜索功能使用新的 API const handleSearch = useCallback((query: Record) => { @@ -389,7 +411,7 @@ const GoodPickExecutionRecord: React.FC = ({ filterArgs, printerCombo, a4 targetDate: query.targetDate || undefined, shopName: query.shopName || undefined, deliveryNoteCode: query.deliveryNoteCode || undefined, - //ticketNo: query.ticketNo || undefined, + truckLanceCode: query.truckLanceCode || undefined, }; // 使用新的 API 进行搜索 @@ -398,8 +420,9 @@ const GoodPickExecutionRecord: React.FC = ({ filterArgs, printerCombo, a4 // 修复:重命名函数避免重复声明 const handleSearchReset = useCallback(() => { - setSearchQuery({}); - fetchCompletedDoPickOrdersData(); // 重新获取所有数据 + const today = dayjs().format("YYYY-MM-DD"); + setSearchQuery({ targetDate: today }); + fetchCompletedDoPickOrdersData({ targetDate: today }); }, [fetchCompletedDoPickOrdersData]); // 分页功能 @@ -425,24 +448,42 @@ const GoodPickExecutionRecord: React.FC = ({ filterArgs, printerCombo, a4 return filteredDoPickOrders.slice(startIndex, endIndex); }, [filteredDoPickOrders, paginationController]); - // 搜索条件 - const searchCriteria: Criterion[] = [ - { - label: t("Delivery Note Code"), - paramName: "deliveryNoteCode", - type: "text", - }, - { - label: t("Shop Name"), - paramName: "shopName", - type: "text", - }, - { - label: t("Target Date"), - paramName: "targetDate", - type: "date", + // 搜索条件(目標日期預設為今日) + const searchCriteria: Criterion[] = useMemo( + () => [ + { + label: t("Delivery Note Code"), + paramName: "deliveryNoteCode", + type: "text", + }, + { + label: t("Shop Name"), + paramName: "shopName", + type: "text", + }, + { + label: t("Truck Lance Code"), + paramName: "truckLanceCode", + type: "text", + }, + { + label: t("Target Date"), + paramName: "targetDate", + type: "date", + defaultValue: dayjs().format("YYYY-MM-DD"), + }, + ], + [t], + ); + + const searchDateDisplay = useMemo(() => { + const raw = searchQuery.targetDate; + if (raw && String(raw).trim() !== "") { + const d = dayjs(raw); + return d.isValid() ? d.format(OUTPUT_DATE_FORMAT) : t("All dates"); } - ]; + return t("All dates"); + }, [searchQuery.targetDate, t]); const handleDetailClick = useCallback(async (doPickOrder: CompletedDoPickOrderResponse) => { setSelectedDoPickOrder(doPickOrder); @@ -540,7 +581,7 @@ setDetailLotData(flatLotData); window.dispatchEvent(new CustomEvent('pickOrderCompletionStatus', { detail: { allLotsCompleted: allCompleted, - tabIndex: 2 + tabIndex: recordTabIndex } })); @@ -551,11 +592,11 @@ setDetailLotData(flatLotData); window.dispatchEvent(new CustomEvent('pickOrderCompletionStatus', { detail: { allLotsCompleted: false, - tabIndex: 2 + tabIndex: recordTabIndex } })); } - }, []); + }, [recordTabIndex]); // 返回列表视图 @@ -568,10 +609,10 @@ setDetailLotData(flatLotData); window.dispatchEvent(new CustomEvent('pickOrderCompletionStatus', { detail: { allLotsCompleted: false, - tabIndex: 2 + tabIndex: recordTabIndex } })); - }, []); + }, [recordTabIndex]); // 如果显示详情视图,渲染类似 GoodPickExecution 的表格 @@ -723,7 +764,8 @@ if (showDetailView && selectedDoPickOrder) { {/* 结果统计 */} - {t("Completed DO pick orders: ")} {filteredDoPickOrders.length} + {t("Search date")}: {searchDateDisplay} | {t("Completed DO pick orders: ")}{" "} + {filteredDoPickOrders.length} {/* 列表 */} diff --git a/src/components/JoSearch/JoSearch.tsx b/src/components/JoSearch/JoSearch.tsx index ed34f30..580887f 100644 --- a/src/components/JoSearch/JoSearch.tsx +++ b/src/components/JoSearch/JoSearch.tsx @@ -1,5 +1,5 @@ "use client" -import { SearchJoResultRequest, fetchJos, releaseJo, updateJo, updateProductProcessPriority, updateJoReqQty } from "@/app/api/jo/actions"; +import { SearchJoResultRequest, fetchJos, releaseJo, setJobOrderHidden, updateJo, updateProductProcessPriority, updateJoReqQty } from "@/app/api/jo/actions"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Criterion } from "../SearchBox"; @@ -39,8 +39,7 @@ interface Props { jobTypes: JobTypeResponse[]; } -type SearchQuery = Partial>; -type SearchParamNames = keyof SearchQuery; +type SearchParamNames = "code" | "itemName" | "planStart" | "planStartTo" | "jobTypeName" | "joSearchStatus"; const JoSearch: React.FC = ({ defaultInputs, bomCombo, printerCombo, jobTypes }) => { const { t } = useTranslation("jo"); @@ -58,6 +57,9 @@ const JoSearch: React.FC = ({ defaultInputs, bomCombo, printerCombo, jobT const [checkboxIds, setCheckboxIds] = useState<(string | number)[]>([]); const [releasingJoIds, setReleasingJoIds] = useState>(new Set()); const [isBatchReleasing, setIsBatchReleasing] = useState(false); + const [cancelConfirmJoId, setCancelConfirmJoId] = useState(null); + const [cancelSubmitting, setCancelSubmitting] = useState(false); + const [cancelingJoIds, setCancelingJoIds] = useState>(new Set()); // 合并后的统一编辑 Dialog 状态 const [openEditDialog, setOpenEditDialog] = useState(false); @@ -160,6 +162,19 @@ const JoSearch: React.FC = ({ defaultInputs, bomCombo, printerCombo, jobT type: "select", options: jobTypes.map(jt => jt.name) }, + { + label: t("Status"), + paramName: "joSearchStatus", + type: "select-labelled", + options: [ + { label: t("Pending"), value: "pending" }, + { label: t("Packaging"), value: "packaging" }, + { label: t("Processing"), value: "processing" }, + { label: t("Storing"), value: "storing" }, + { label: t("Put Awayed"), value: "putAwayed" }, + { label: t("cancel"), value: "cancel" }, + ], + }, ], [t, jobTypes]) const fetchBomForJo = useCallback(async (jo: JobOrder): Promise => { @@ -288,6 +303,29 @@ const JoSearch: React.FC = ({ defaultInputs, bomCombo, printerCombo, jobT }); } }, [inputs, pagingController, t, newPageFetch]); + + const handleConfirmCancelJobOrder = useCallback(async () => { + if (cancelConfirmJoId == null) return; + const id = cancelConfirmJoId; + setCancelSubmitting(true); + setCancelingJoIds((prev) => new Set(prev).add(id)); + try { + await setJobOrderHidden(id, true); + msg(t("update success")); + setCancelConfirmJoId(null); + await newPageFetch(pagingController, inputs); + } catch (error) { + console.error("Error cancelling job order:", error); + msg(t("update failed")); + } finally { + setCancelSubmitting(false); + setCancelingJoIds((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); + } + }, [cancelConfirmJoId, newPageFetch, pagingController, inputs, t]); const selectedPlanningJoIds = useMemo(() => { const selectedIds = new Set(checkboxIds.map((id) => Number(id))); @@ -444,6 +482,8 @@ const JoSearch: React.FC = ({ defaultInputs, bomCombo, printerCombo, jobT renderCell: (row) => { const isPlanning = isPlanningJo(row); const isReleasing = releasingJoIds.has(row.id) || isBatchReleasing; + const isCancelingRow = cancelingJoIds.has(row.id); + const isPutAwayed = row.stockInLineStatus?.toLowerCase() === "completed"; return ( + {isPlanning ? ( + ) : ( + + )} ) } }, - ], [t, inventoryData, detailedJos, handleOpenEditDialog, handleReleaseJo, isPlanningJo, releasingJoIds, isBatchReleasing] + ], [t, inventoryData, detailedJos, handleOpenEditDialog, handleReleaseJo, isPlanningJo, releasingJoIds, isBatchReleasing, cancelingJoIds, cancelSubmitting] ) const handleUpdateReqQty = useCallback(async (jobOrderId: number, newReqQty: number) => { @@ -622,7 +679,8 @@ const JoSearch: React.FC = ({ defaultInputs, bomCombo, printerCombo, jobT ...query, planStart: query.planStart ? `${query.planStart}T00:00` : query.planStart, planStartTo: query.planStartTo ? `${query.planStartTo}T23:59:59` : query.planStartTo, - jobTypeName: query.jobTypeName && query.jobTypeName !== "All" ? query.jobTypeName : "" + jobTypeName: query.jobTypeName && query.jobTypeName !== "All" ? query.jobTypeName : "", + joSearchStatus: query.joSearchStatus && query.joSearchStatus !== "All" ? query.joSearchStatus : "all", }; setInputs({ @@ -630,7 +688,8 @@ const JoSearch: React.FC = ({ defaultInputs, bomCombo, printerCombo, jobT itemName: transformedQuery.itemName, planStart: transformedQuery.planStart, planStartTo: transformedQuery.planStartTo, - jobTypeName: transformedQuery.jobTypeName + jobTypeName: transformedQuery.jobTypeName, + joSearchStatus: transformedQuery.joSearchStatus }); setPagingController(defaultPagingController); @@ -839,6 +898,24 @@ const JoSearch: React.FC = ({ defaultInputs, bomCombo, printerCombo, jobT + + !cancelSubmitting && setCancelConfirmJoId(null)} + maxWidth="xs" + fullWidth + > + {t("Confirm cancel job order")} + + {t("Cancel job order confirm message")} + + + + + + } diff --git a/src/components/JoSearch/JoSearchWrapper.tsx b/src/components/JoSearch/JoSearchWrapper.tsx index 68e894b..e420886 100644 --- a/src/components/JoSearch/JoSearchWrapper.tsx +++ b/src/components/JoSearch/JoSearchWrapper.tsx @@ -18,6 +18,7 @@ const JoSearchWrapper: React.FC & SubComponents = async () => { itemName: "", planStart: `${todayStr}T00:00`, planStartTo: `${todayStr}T23:59:59`, + joSearchStatus: "all", } diff --git a/src/components/PoDetail/PoDetail.tsx b/src/components/PoDetail/PoDetail.tsx index a7fdb26..7ef386e 100644 --- a/src/components/PoDetail/PoDetail.tsx +++ b/src/components/PoDetail/PoDetail.tsx @@ -416,14 +416,17 @@ const PoDetail: React.FC = ({ po, warehouse, printerCombo }) => { }, []); useEffect(() => { - if (processedQty === row.qty) { + // `processedQty` comes from putAwayLines (stock unit). + // After the fix, `row.qty` is qtyM18 (M18 unit), so compare using stockUom demand. + const targetStockQty = Number(row.stockUom?.stockQty ?? row.qty ?? 0); + if (targetStockQty > 0 && processedQty >= targetStockQty) { setCurrStatus("completed".toUpperCase()); } else if (processedQty > 0) { setCurrStatus("receiving".toUpperCase()); } else { setCurrStatus("pending".toUpperCase()); } - }, [processedQty, row.qty]); + }, [processedQty, row.qty, row.stockUom?.stockQty]); const handleRowSelect = () => { // setSelectedRowId(row.id); diff --git a/src/components/PoDetail/PoInputGrid.tsx b/src/components/PoDetail/PoInputGrid.tsx index bccde84..f2bae05 100644 --- a/src/components/PoDetail/PoInputGrid.tsx +++ b/src/components/PoDetail/PoInputGrid.tsx @@ -153,7 +153,8 @@ function PoInputGrid({ const [btnIsLoading, setBtnIsLoading] = useState(false); const [currQty, setCurrQty] = useState(() => { const total = entries.reduce( - (acc, curr) => acc + (curr.acceptedQty || 0), + // remaining qty (M18 unit) + (acc, curr) => acc + (curr.purchaseAcceptedQty || 0), 0, ); return total; @@ -231,7 +232,8 @@ function PoInputGrid({ itemName: params.row.itemName, // purchaseOrderId: params.row.purchaseOrderId, purchaseOrderLineId: params.row.purchaseOrderLineId, - acceptedQty: params.row.acceptedQty, + // For PO-origin, backend expects M18 qty and converts it to stock qty. + acceptedQty: params.row.purchaseAcceptedQty ?? params.row.acceptedQty, }; const res = await createStockInLine(postData); console.log(res); @@ -516,7 +518,7 @@ function PoInputGrid({ // // flex: 0.6, // }, { - field: "acceptedQty", + field: "purchaseAcceptedQty", headerName: t("acceptedQty"), // flex: 0.5, width: 125, @@ -524,7 +526,7 @@ function PoInputGrid({ // editable: true, // replace with tooltip + content renderCell: (params) => { - const qty = params.row.purchaseAcceptedQty ?? params.row.acceptedQty ?? 0; + const qty = params.row.purchaseAcceptedQty ?? 0; return integerFormatter.format(qty); } }, @@ -818,7 +820,8 @@ function PoInputGrid({ purchaseOrderLineId: itemDetail.id, itemNo: itemDetail.itemNo, itemName: itemDetail.itemName, - acceptedQty: itemDetail.qty - currQty, // this bug + // User inputs qty in M18 unit; backend will convert to stock unit on create. + purchaseAcceptedQty: itemDetail.qty - currQty, uom: itemDetail.uom, status: "draft", }; @@ -840,8 +843,13 @@ function PoInputGrid({ const error: StockInLineEntryError = {}; console.log(newRow); console.log(currQty); - if (newRow.acceptedQty && newRow.acceptedQty > itemDetail.qty) { - error["acceptedQty"] = t("qty cannot be greater than remaining qty"); + if ( + newRow.purchaseAcceptedQty && + newRow.purchaseAcceptedQty > itemDetail.qty + ) { + error["purchaseAcceptedQty"] = t( + "qty cannot be greater than remaining qty", + ); } return Object.keys(error).length > 0 ? error : undefined; }, @@ -872,7 +880,7 @@ function PoInputGrid({ setEntries(newEntries); //update remaining qty const total = newEntries.reduce( - (acc, curr) => acc + (curr.acceptedQty || 0), + (acc, curr) => acc + (curr.purchaseAcceptedQty || 0), 0, ); setCurrQty(total); diff --git a/src/components/PoDetail/PutAwayForm.tsx b/src/components/PoDetail/PutAwayForm.tsx index 732b9db..0aa74b4 100644 --- a/src/components/PoDetail/PutAwayForm.tsx +++ b/src/components/PoDetail/PutAwayForm.tsx @@ -395,7 +395,7 @@ const PutAwayForm: React.FC = ({ itemDetail, warehouse=[], disabled, sugg @@ -403,7 +403,7 @@ const PutAwayForm: React.FC = ({ itemDetail, warehouse=[], disabled, sugg diff --git a/src/components/PoSearch/PoSearch.tsx b/src/components/PoSearch/PoSearch.tsx index ca95ebe..770b306 100644 --- a/src/components/PoSearch/PoSearch.tsx +++ b/src/components/PoSearch/PoSearch.tsx @@ -13,7 +13,7 @@ import { WarehouseResult } from "@/app/api/warehouse"; import NotificationIcon from "@mui/icons-material/NotificationImportant"; import { useSession } from "next-auth/react"; import { defaultPagingController } from "../SearchResults/SearchResults"; -import { fetchPoListClient, testing } from "@/app/api/po/actions"; +import { testing } from "@/app/api/po/actions"; import dayjs from "dayjs"; import { arrayToDateString, dayjsToDateString } from "@/app/utils/formatUtil"; import arraySupport from "dayjs/plugin/arraySupport"; @@ -289,7 +289,20 @@ const PoSearch: React.FC = ({ }; setAutoSyncStatus(null); - const res = await fetchPoListClient(params); + const cleanedQuery: Record = {}; + Object.entries(params).forEach(([k, v]) => { + if (v === undefined || v === null) return; + if (typeof v === "string" && (v as string).trim() === "") return; + cleanedQuery[k] = String(v); + }); + const baseListResp = await clientAuthFetch( + `${NEXT_PUBLIC_API_URL}/po/list?${new URLSearchParams(cleanedQuery).toString()}`, + { method: "GET" }, + ); + if (!baseListResp.ok) { + throw new Error(`PO list fetch failed: ${baseListResp.status}`); + } + const res = await baseListResp.json(); if (!res) return; if (res.records && res.records.length > 0) { @@ -340,14 +353,6 @@ const PoSearch: React.FC = ({ if (syncOk) { setAutoSyncStatus("成功找到PO"); - // Re-fetch /po/list directly from client to avoid cached server action results. - const cleanedQuery: Record = {}; - Object.entries(params).forEach(([k, v]) => { - if (v === undefined || v === null) return; - if (typeof v === "string" && v.trim() === "") return; - cleanedQuery[k] = String(v); - }); - const listResp = await clientAuthFetch( `${NEXT_PUBLIC_API_URL}/po/list?${new URLSearchParams( cleanedQuery, diff --git a/src/components/ProductionProcess/OverallTimeRemainingCard.tsx b/src/components/ProductionProcess/OverallTimeRemainingCard.tsx index 7b06f68..9d9261d 100644 --- a/src/components/ProductionProcess/OverallTimeRemainingCard.tsx +++ b/src/components/ProductionProcess/OverallTimeRemainingCard.tsx @@ -22,7 +22,14 @@ const OverallTimeRemainingCard: React.FC = ({ console.log("🕐 OverallTimeRemainingCard - processData?.startTime type:", typeof processData?.startTime); console.log("🕐 OverallTimeRemainingCard - processData?.startTime isArray:", Array.isArray(processData?.startTime)); - if (!processData?.startTime) { + const jobOrderStatus = String((processData as any)?.jobOrderStatus ?? "").trim().toLowerCase(); + const shouldStopCount = + jobOrderStatus === "storing" || + jobOrderStatus === "completed" || + jobOrderStatus === "pendingqc" || + jobOrderStatus === "pending_qc"; + + if (shouldStopCount || !processData?.startTime) { console.log("❌ OverallTimeRemainingCard - No startTime found"); setOverallRemainingTime(null); setIsOverTime(false); @@ -176,7 +183,7 @@ const OverallTimeRemainingCard: React.FC = ({ update(); const timer = setInterval(update, 1000); return () => clearInterval(timer); - }, [processData?.startTime, processData?.productProcessLines]); + }, [processData?.startTime, processData?.productProcessLines, (processData as any)?.jobOrderStatus]); if (!processData?.startTime || overallRemainingTime === null) { return null; diff --git a/src/components/ProductionProcess/ProductionProcessDetail.tsx b/src/components/ProductionProcess/ProductionProcessDetail.tsx index 6080e52..5e28a49 100644 --- a/src/components/ProductionProcess/ProductionProcessDetail.tsx +++ b/src/components/ProductionProcess/ProductionProcessDetail.tsx @@ -110,6 +110,11 @@ const fetchProcessDetailRef = useRef<() => Promise>(); postProdTimeInMinutes: 0, }); + // Pass confirmation dialog (avoid accidental Pass) + const [passConfirmOpen, setPassConfirmOpen] = useState(false); + const [passConfirmLineId, setPassConfirmLineId] = useState(null); + const [passConfirmLoading, setPassConfirmLoading] = useState(false); + const [outputData, setOutputData] = useState({ byproductName: "", byproductQty: "", @@ -257,6 +262,29 @@ const fetchProcessDetailRef = useRef<() => Promise>(); alert(t("Failed to pass line. Please try again.")); } }, [fetchProcessDetail, t]); + + const openPassConfirm = useCallback((lineId: number) => { + setPassConfirmLineId(lineId); + setPassConfirmOpen(true); + }, []); + + const closePassConfirm = useCallback(() => { + setPassConfirmOpen(false); + setPassConfirmLineId(null); + setPassConfirmLoading(false); + }, []); + + const confirmPassLine = useCallback(async () => { + if (!passConfirmLineId) return; + setPassConfirmLoading(true); + try { + await handlePassLine(passConfirmLineId); + closePassConfirm(); + } catch { + // handlePassLine 已经处理 alert,这里兜底收起弹窗 + closePassConfirm(); + } + }, [passConfirmLineId, handlePassLine, closePassConfirm]); const handleCreateNewLine = useCallback(async (lineId: number) => { try { await newProductProcessLine(lineId); @@ -765,7 +793,7 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => { variant="outlined" size="small" color="success" - onClick={() => handlePassLine(line.id)} + onClick={() => openPassConfirm(line.id)} disabled={isPassDisabled} > {t("Pass")} @@ -790,7 +818,7 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => { variant="outlined" size="small" color="success" - onClick={() => handlePassLine(line.id)} + onClick={() => openPassConfirm(line.id)} disabled={isPassDisabled} > {t("Pass")} @@ -813,7 +841,7 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => { variant="outlined" size="small" color="success" - onClick={() => handlePassLine(line.id)} + onClick={() => openPassConfirm(line.id)} disabled={isPassDisabled} > {t("Pass")} @@ -996,6 +1024,30 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => { + + + {t("Confirm")} + + {t("Confirm to Pass this Process?")} + + + + + + ); }; diff --git a/src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx b/src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx index 86cc938..ddd0852 100644 --- a/src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx +++ b/src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx @@ -23,7 +23,7 @@ import { } from "@mui/material"; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import { useTranslation } from "react-i18next"; -import { fetchProductProcessesByJobOrderId ,deleteJobOrder, updateProductProcessPriority, updateJoPlanStart,updateJoReqQty,newProductProcessLine,JobOrderLineInfo} from "@/app/api/jo/actions"; +import { fetchProductProcessesByJobOrderId ,deleteJobOrder, setJobOrderHidden, updateProductProcessPriority, updateJoPlanStart,updateJoReqQty,newProductProcessLine,JobOrderLineInfo} from "@/app/api/jo/actions"; import ProductionProcessDetail from "./ProductionProcessDetail"; import { BomCombo } from "@/app/api/bom"; import { fetchBomCombo } from "@/app/api/bom/index"; @@ -50,19 +50,21 @@ interface ProductProcessJobOrderDetailProps { jobOrderId: number; onBack: () => void; fromJosave?: boolean; + initialTabIndex?: number; } const ProductionProcessJobOrderDetail: React.FC = ({ jobOrderId, onBack, fromJosave, + initialTabIndex = 0, }) => { const { t } = useTranslation(); const [loading, setLoading] = useState(false); const [processData, setProcessData] = useState(null); const [jobOrderLines, setJobOrderLines] = useState([]); const [inventoryData, setInventoryData] = useState([]); - const [tabIndex, setTabIndex] = useState(0); + const [tabIndex, setTabIndex] = useState(initialTabIndex); const [selectedProcessId, setSelectedProcessId] = useState(null); const [operationPriority, setOperationPriority] = useState(50); const [openOperationPriorityDialog, setOpenOperationPriorityDialog] = useState(false); @@ -263,15 +265,42 @@ const stockCounts = useMemo(() => { insufficient: total - sufficient, }; }, [jobOrderLines, inventoryData]); -const status = processData?.status?.toLowerCase?.() ?? ""; -const handleDeleteJobOrder = useCallback(async ( jobOrderId: number) => { - const response = await deleteJobOrder(jobOrderId) - if (response) { - //setProcessData(response.entity); - //await fetchData(); +const jobOrderPlanning = useMemo( + () => (processData?.jobOrderStatus ?? "").toLowerCase() === "planning", + [processData?.jobOrderStatus] +); +const isPutAwayed = useMemo( + () => (processData?.jobOrderStatus ?? "").toLowerCase() === "completed", + [processData?.jobOrderStatus] +); +const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); +const [cancelConfirmOpen, setCancelConfirmOpen] = useState(false); +const [deleteLoading, setDeleteLoading] = useState(false); +const [cancelLoading, setCancelLoading] = useState(false); + +const handleConfirmDeleteJobOrder = useCallback(async () => { + setDeleteLoading(true); + try { + const response = await deleteJobOrder(jobOrderId); + if (response) { + setDeleteConfirmOpen(false); + onBack(); + } + } finally { + setDeleteLoading(false); + } +}, [jobOrderId, onBack]); + +const handleConfirmCancelJobOrder = useCallback(async () => { + setCancelLoading(true); + try { + await setJobOrderHidden(jobOrderId, true); + setCancelConfirmOpen(false); onBack(); + } finally { + setCancelLoading(false); } -}, [jobOrderId]); +}, [jobOrderId, onBack]); const handleRelease = useCallback(async ( jobOrderId: number) => { // TODO: 替换为实际的 release 调用 console.log("Release clicked for jobOrderId:", jobOrderId); @@ -673,15 +702,24 @@ const handleRelease = useCallback(async ( jobOrderId: number) => { {t("Lines with insufficient stock: ")}{stockCounts.insufficient} - {fromJosave && ( + {fromJosave && jobOrderPlanning && ( + variant="contained" + color="error" + onClick={() => setDeleteConfirmOpen(true)} + > + {t("Delete Job Order")} + + )} + {fromJosave && !jobOrderPlanning && ( + )} {fromJosave && ( + + + + + !cancelLoading && setCancelConfirmOpen(false)} maxWidth="xs" fullWidth> + {t("Confirm cancel job order")} + + {t("Cancel job order confirm message")} + + + + + + + diff --git a/src/components/ProductionProcess/ProductionProcessList.tsx b/src/components/ProductionProcess/ProductionProcessList.tsx index 1eea577..ab5bf35 100644 --- a/src/components/ProductionProcess/ProductionProcessList.tsx +++ b/src/components/ProductionProcess/ProductionProcessList.tsx @@ -1,5 +1,5 @@ "use client"; -import React, { useCallback, useEffect, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Box, Button, @@ -12,6 +12,17 @@ import { CircularProgress, TablePagination, Grid, + FormControl, + InputLabel, + Select, + MenuItem, + Checkbox, + ListItemText, + SelectChangeEvent, + Dialog, + DialogTitle, + DialogContent, + DialogActions, } from "@mui/material"; import { useTranslation } from "react-i18next"; import { fetchItemForPutAway } from "@/app/api/stockIn/actions"; @@ -20,41 +31,137 @@ import { useSession } from "next-auth/react"; import { SessionWithTokens } from "@/config/authConfig"; import dayjs from "dayjs"; import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; +import SearchBox, { Criterion } from "@/components/SearchBox/SearchBox"; import { - fetchAllJoborderProductProcessInfo, AllJoborderProductProcessInfoResponse, updateJo, fetchProductProcessesByJobOrderId, completeProductProcessLine, - assignJobOrderPickOrder + assignJobOrderPickOrder, + fetchJoborderProductProcessesPage } from "@/app/api/jo/actions"; import { StockInLineInput } from "@/app/api/stockIn"; import { PrinterCombo } from "@/app/api/settings/printer"; import JobPickExecutionsecondscan from "../Jodetail/JobPickExecutionsecondscan"; +export type ProductionProcessListPersistedState = { + date: string; + itemCode: string | null; + jobOrderCode: string | null; + filter: "all" | "drink" | "other"; + page: number; + selectedItemCodes: string[]; +}; + interface ProductProcessListProps { onSelectProcess: (jobOrderId: number|undefined, productProcessId: number|undefined) => void; onSelectMatchingStock: (jobOrderId: number|undefined, productProcessId: number|undefined,pickOrderId: number|undefined) => void; printerCombo: PrinterCombo[]; + qcReady: boolean; + listPersistedState: ProductionProcessListPersistedState; + onListPersistedStateChange: React.Dispatch< + React.SetStateAction + >; } +export type SearchParam = "date" | "itemCode" | "jobOrderCode" | "processType"; +const PAGE_SIZE = 50; -const PER_PAGE = 6; +/** 預設依 JobOrder.planStart 搜尋:今天往前 3 天~往後 3 天(含當日) */ +function defaultPlanStartRange() { + return { + from: dayjs().subtract(0, "day").format("YYYY-MM-DD"), + to: dayjs().add(0, "day").format("YYYY-MM-DD"), + }; +} + +export function createDefaultProductionProcessListPersistedState(): ProductionProcessListPersistedState { + return { + date: dayjs().format("YYYY-MM-DD"), + itemCode: null, + jobOrderCode: null, + filter: "all", + page: 0, + selectedItemCodes: [], + }; +} -const ProductProcessList: React.FC = ({ onSelectProcess, printerCombo ,onSelectMatchingStock}) => { +const ProductProcessList: React.FC = ({ + onSelectProcess, + printerCombo, + onSelectMatchingStock, + qcReady, + listPersistedState, + onListPersistedStateChange, +}) => { const { t } = useTranslation( ["common", "production","purchaseOrder","dashboard"]); const { data: session } = useSession() as { data: SessionWithTokens | null }; const sessionToken = session as SessionWithTokens | null; const [loading, setLoading] = useState(false); const [processes, setProcesses] = useState([]); - const [page, setPage] = useState(0); const [openModal, setOpenModal] = useState(false); const [modalInfo, setModalInfo] = useState(); const currentUserId = session?.id ? parseInt(session.id) : undefined; type ProcessFilter = "all" | "drink" | "other"; - const [filter, setFilter] = useState("all"); const [suggestedLocationCode, setSuggestedLocationCode] = useState(null); + + const appliedSearch = useMemo( + () => ({ + date: listPersistedState.date, + itemCode: listPersistedState.itemCode, + jobOrderCode: listPersistedState.jobOrderCode, + }), + [ + listPersistedState.date, + listPersistedState.itemCode, + listPersistedState.jobOrderCode, + ], + ); + const filter = listPersistedState.filter; + const page = listPersistedState.page; + const selectedItemCodes = listPersistedState.selectedItemCodes; + + const [totalJobOrders, setTotalJobOrders] = useState(0); + + // Generic confirm dialog for actions (update job order / etc.) + const [confirmOpen, setConfirmOpen] = useState(false); + const [confirmMessage, setConfirmMessage] = useState(""); + const [confirmLoading, setConfirmLoading] = useState(false); + const [pendingConfirmAction, setPendingConfirmAction] = useState Promise)>(null); + + // QC 的业务判定:同一个 jobOrder 下,所有 productProcess 的所有 lines 都必须是 Completed/Pass + // 才允许打开 QcStockInModal(避免仅某个 productProcess 完成就提前出现 view stockin)。 + const jobOrderQcReadyById = useMemo(() => { + const lineDone = (status: unknown) => { + const s = String(status ?? "").trim().toLowerCase(); + return s === "completed" || s === "pass"; + }; + + const byJobOrder = new Map(); + for (const p of processes) { + if (p.jobOrderId == null) continue; + const arr = byJobOrder.get(p.jobOrderId) ?? []; + arr.push(p); + byJobOrder.set(p.jobOrderId, arr); + } + + const result = new Map(); + byJobOrder.forEach((jobOrderProcesses, jobOrderId) => { + const hasStockInLine = jobOrderProcesses.some((p) => p.stockInLineId != null); + const allLinesDone = + jobOrderProcesses.length > 0 && + jobOrderProcesses.every((p) => { + const lines = p.lines ?? []; + // 没有 lines 的情况认为未完成,避免误放行 + return lines.length > 0 && lines.every((l) => lineDone(l.status)); + }); + + result.set(jobOrderId, hasStockInLine && allLinesDone); + }); + + return result; + }, [processes]); const handleAssignPickOrder = useCallback(async (pickOrderId: number, jobOrderId?: number, productProcessId?: number) => { if (!currentUserId) { alert(t("Unable to get user ID")); @@ -106,22 +213,63 @@ const ProductProcessList: React.FC = ({ onSelectProcess setOpenModal(true); }, [t]); + const handleApplySearch = useCallback( + (inputs: Record) => { + const selectedProcessType = (inputs.processType || "all") as ProcessFilter; + const fallback = defaultPlanStartRange(); + const selectedDate = (inputs.date || "").trim() || fallback.from; + onListPersistedStateChange((prev) => ({ + ...prev, + filter: selectedProcessType, + date: selectedDate, + itemCode: inputs.itemCode?.trim() ? inputs.itemCode.trim() : null, + jobOrderCode: inputs.jobOrderCode?.trim() ? inputs.jobOrderCode.trim() : null, + selectedItemCodes: [], + page: 0, + })); + }, + [onListPersistedStateChange], + ); + + const handleResetSearch = useCallback(() => { + const r = defaultPlanStartRange(); + onListPersistedStateChange((prev) => ({ + ...prev, + filter: "all", + date: r.from, + itemCode: null, + jobOrderCode: null, + selectedItemCodes: [], + page: 0, + })); + }, [onListPersistedStateChange]); + const fetchProcesses = useCallback(async () => { setLoading(true); try { const isDrinkParam = filter === "all" ? undefined : filter === "drink" ? true : false; - - const data = await fetchAllJoborderProductProcessInfo(isDrinkParam); - setProcesses(data || []); - setPage(0); + + const data = await fetchJoborderProductProcessesPage({ + date: appliedSearch.date, + itemCode: appliedSearch.itemCode, + jobOrderCode: appliedSearch.jobOrderCode, + qcReady, + isDrink: isDrinkParam, + page, + size: PAGE_SIZE, + }); + + setProcesses(data?.content || []); + setTotalJobOrders(data?.totalJobOrders || 0); } catch (e) { console.error(e); setProcesses([]); + setTotalJobOrders(0); } finally { setLoading(false); } - }, [filter]); + }, [listPersistedState, qcReady]); useEffect(() => { fetchProcesses(); @@ -161,6 +309,29 @@ const ProductProcessList: React.FC = ({ onSelectProcess setLoading(false); } }, [t, fetchProcesses]); + + const openConfirm = useCallback((message: string, action: () => Promise) => { + setConfirmMessage(message); + setPendingConfirmAction(() => action); + setConfirmOpen(true); + }, []); + + const closeConfirm = useCallback(() => { + setConfirmOpen(false); + setPendingConfirmAction(null); + setConfirmMessage(""); + setConfirmLoading(false); + }, []); + + const onConfirm = useCallback(async () => { + if (!pendingConfirmAction) return; + setConfirmLoading(true); + try { + await pendingConfirmAction(); + } finally { + closeConfirm(); + } + }, [pendingConfirmAction, closeConfirm]); const closeNewModal = useCallback(() => { // const response = updateJo({ id: 1, status: "storing" }); setOpenModal(false); // Close the modal first @@ -169,8 +340,76 @@ const ProductProcessList: React.FC = ({ onSelectProcess // }, 300); // Add a delay to avoid immediate re-trigger of useEffect }, [fetchProcesses]); - const startIdx = page * PER_PAGE; - const paged = processes.slice(startIdx, startIdx + PER_PAGE); + const searchedItemOptions = useMemo( + () => + Array.from( + new Map( + processes + .filter((p) => !!p.itemCode) + .map((p) => [p.itemCode, { itemCode: p.itemCode, itemName: p.itemName }]), + ).values(), + ), + [processes], + ); + + const paged = useMemo(() => { + if (selectedItemCodes.length === 0) return processes; + return processes.filter((p) => selectedItemCodes.includes(p.itemCode)); + }, [processes, selectedItemCodes]); + + /** Reset 用 ±3 天;preFilled 用目前已套用的條件(與列表查詢一致) */ + const searchCriteria: Criterion[] = useMemo(() => { + const r = defaultPlanStartRange(); + return [ + { + type: "date", + label: t("Search date"), + paramName: "date", + defaultValue: appliedSearch.date, + preFilledValue: appliedSearch.date, + }, + { + type: "text", + label: "Item Code", + paramName: "itemCode", + preFilledValue: appliedSearch.itemCode ?? "", + }, + { + type: "text", + label: "Job Order Code", + paramName: "jobOrderCode", + preFilledValue: appliedSearch.jobOrderCode ?? "", + }, + { + type: "select", + label: "Type", + paramName: "processType", + options: ["all", "drink", "other"], + preFilledValue: filter, + }, + ]; + }, [appliedSearch, filter, t]); + + /** SearchBox 內部 state 只在掛載時讀 preFilled;套用搜尋後需 remount 才會與 appliedSearch 一致 */ + const searchBoxKey = useMemo( + () => + [ + appliedSearch.date, + appliedSearch.itemCode ?? "", + appliedSearch.jobOrderCode ?? "", + filter, + ].join("|"), + [appliedSearch, filter], + ); + + const handleSelectedItemCodesChange = useCallback( + (e: SelectChangeEvent) => { + const nextValue = e.target.value; + const codes = typeof nextValue === "string" ? nextValue.split(",") : nextValue; + onListPersistedStateChange((prev) => ({ ...prev, selectedItemCodes: codes })); + }, + [onListPersistedStateChange], + ); return ( @@ -180,37 +419,48 @@ const ProductProcessList: React.FC = ({ onSelectProcess ) : ( - - - - - + + key={searchBoxKey} + criteria={searchCriteria} + onSearch={handleApplySearch} + onReset={handleResetSearch} + extraActions={ + + {t("Searched Item")} + + + } + /> - {t("Total processes")}: {processes.length} - + {t("Search date") /* 或在 zh/common.json 加鍵,例如「搜尋日期」 */}:{" "} + {appliedSearch.date && dayjs(appliedSearch.date).isValid() + ? dayjs(appliedSearch.date).format(OUTPUT_DATE_FORMAT) + : "-"} + {" | "} + {t("Total job orders")}: {totalJobOrders} + {selectedItemCodes.length > 0 ? ` | ${t("Filtered")}: ${paged.length}` : ""} + {paged.map((process) => { const status = String(process.status || ""); const statusLower = status.toLowerCase(); + const displayStatus = statusLower === "in_progress" ? "processing" : status; const statusColor = statusLower === "completed" ? "success" @@ -238,6 +488,11 @@ const ProductProcessList: React.FC = ({ onSelectProcess .filter(l => String(l.status ?? "").trim() !== "") .filter(l => String(l.status).toLowerCase() === "in_progress"); + const canQc = + process.jobOrderId != null && + process.stockInLineId != null && + jobOrderQcReadyById.get(process.jobOrderId) === true; + return ( = ({ onSelectProcess - + {t("Lot No")}: {process.lotNo ?? "-"} @@ -330,13 +585,26 @@ const ProductProcessList: React.FC = ({ onSelectProcess > {t("Matching Stock")} + {statusLower !== "completed" && ( - )} - {statusLower === "completed" && ( - )} @@ -358,14 +626,34 @@ const ProductProcessList: React.FC = ({ onSelectProcess printSource="productionProcess" uiMode="default" /> - {processes.length > 0 && ( + + {t("Confirm")} + + {confirmMessage} + + + + + + + {totalJobOrders > 0 && ( setPage(p)} - rowsPerPageOptions={[PER_PAGE]} + rowsPerPage={PAGE_SIZE} + onPageChange={(e, p) => + onListPersistedStateChange((prev) => ({ ...prev, page: p })) + } + rowsPerPageOptions={[PAGE_SIZE]} /> )} diff --git a/src/components/ProductionProcess/ProductionProcessPage.tsx b/src/components/ProductionProcess/ProductionProcessPage.tsx index 02e611c..ef34f7c 100644 --- a/src/components/ProductionProcess/ProductionProcessPage.tsx +++ b/src/components/ProductionProcess/ProductionProcessPage.tsx @@ -3,11 +3,12 @@ import React, { useState, useEffect, useCallback } from "react"; import { useSession } from "next-auth/react"; import { SessionWithTokens } from "@/config/authConfig"; import { Box, Tabs, Tab, Stack, Typography, Autocomplete, TextField } from "@mui/material"; -import ProductionProcessList from "@/components/ProductionProcess/ProductionProcessList"; +import ProductionProcessList, { + createDefaultProductionProcessListPersistedState, +} from "@/components/ProductionProcess/ProductionProcessList"; import ProductionProcessDetail from "@/components/ProductionProcess/ProductionProcessDetail"; import ProductionProcessJobOrderDetail from "@/components/ProductionProcess/ProductionProcessJobOrderDetail"; import JobPickExecutionsecondscan from "@/components/Jodetail/JobPickExecutionsecondscan"; -import FinishedQcJobOrderList from "@/components/ProductionProcess/FinishedQcJobOrderList"; import JobProcessStatus from "@/components/ProductionProcess/JobProcessStatus"; import OperatorKpiDashboard from "@/components/ProductionProcess/OperatorKpiDashboard"; import EquipmentStatusDashboard from "@/components/ProductionProcess/EquipmentStatusDashboard"; @@ -44,6 +45,13 @@ const ProductionProcessPage: React.FC = ({ printerCo pickOrderId: number; } | null>(null); const [tabIndex, setTabIndex] = useState(0); + /** 列表搜尋/分頁:保留在切換工單詳情時,返回後仍為同一條件 */ + const [productionListState, setProductionListState] = useState( + createDefaultProductionProcessListPersistedState, + ); + const [finishedQcListState, setFinishedQcListState] = useState( + createDefaultProductionProcessListPersistedState, + ); const { data: session } = useSession() as { data: SessionWithTokens | null }; const currentUserId = session?.id ? parseInt(session.id) : undefined; @@ -112,6 +120,7 @@ const ProductionProcessPage: React.FC = ({ printerCo return ( setSelectedProcessId(null)} /> ); @@ -179,6 +188,9 @@ const ProductionProcessPage: React.FC = ({ printerCo {tabIndex === 0 && ( { const id = jobOrderId ?? null; if (id !== null) { @@ -196,9 +208,24 @@ const ProductionProcessPage: React.FC = ({ printerCo )} {tabIndex === 1 && ( - { + const id = jobOrderId ?? null; + if (id !== null) { + setSelectedProcessId(id); + } + }} + onSelectMatchingStock={(jobOrderId, productProcessId, pickOrderId) => { + setSelectedMatchingStock({ + jobOrderId: jobOrderId || 0, + productProcessId: productProcessId || 0, + pickOrderId: pickOrderId || 0, + }); + }} /> )} {tabIndex === 2 && ( diff --git a/src/components/SearchBox/SearchBox.tsx b/src/components/SearchBox/SearchBox.tsx index ccd10c5..959ec2e 100644 --- a/src/components/SearchBox/SearchBox.tsx +++ b/src/components/SearchBox/SearchBox.tsx @@ -40,6 +40,8 @@ interface BaseCriterion { paramName2?: T; // options?: T[] | string[]; defaultValue?: string; + /** 與 `defaultValue` 配對,用於 dateRange / datetimeRange 重置時的結束值 */ + defaultValueTo?: string; preFilledValue?: string | { from?: string; to?: string }; filterObj?: T; handleSelectionChange?: (selectedOptions: T[]) => void; @@ -159,7 +161,13 @@ function SearchBox({ tempCriteria = { ...tempCriteria, [c.paramName]: c.defaultValue ?? "", - [`${c.paramName}To`]: "", + [`${c.paramName}To`]: c.defaultValueTo ?? "", + }; + } + if (c.type === "date") { + tempCriteria = { + ...tempCriteria, + [c.paramName]: c.defaultValue ?? "", }; } return tempCriteria; @@ -188,7 +196,7 @@ function SearchBox({ {} as Record, ); return {...defaultInputs, ...preFilledCriteria} - }, [defaultInputs]) + }, [defaultInputs, criteria]) const [inputs, setInputs] = useState(preFilledInputs); const [isReset, setIsReset] = useState(false); @@ -272,7 +280,7 @@ function SearchBox({ }, []); const handleReset = () => { - setInputs(defaultInputs); + setInputs(preFilledInputs); onReset?.(); setIsReset(!isReset); }; @@ -553,6 +561,11 @@ function SearchBox({ label={t(c.label)} onChange={makeDateChangeHandler(c.paramName)} disabled={disabled} + value={ + inputs[c.paramName] && dayjs(inputs[c.paramName]).isValid() + ? dayjs(inputs[c.paramName]) + : null + } /> diff --git a/src/i18n/zh/common.json b/src/i18n/zh/common.json index 8580cc8..82c6bfc 100644 --- a/src/i18n/zh/common.json +++ b/src/i18n/zh/common.json @@ -17,8 +17,25 @@ "Process & Equipment": "製程與設備", "Sequence": "順序", "Process Name": "製程名稱", + "Plan start (from)": "開始日期(從)", + "Plan start (to)": "開始日期(至)", "Process Description": "說明", + "Search date": "搜索日期", + "Confirm to Pass this Process?": "確認要通過此工序嗎?", "Equipment Name": "設備", + "Confirm to update this Job Order?": "確認要完成此工單嗎?", + "Cancel Job Order": "取消工單", + "Confirm delete job order": "確認刪除工單", + "Delete job order confirm message": "確定要刪除此工單嗎?此操作無法復原。", + "Confirm cancel job order": "確認取消工單", + "Cancel job order confirm message": "確定要取消此工單嗎?工單將從列表中隱藏。", + "all": "全部", + "Bom Uom": "BOM 單位", + "Searched Item": "已搜索物料", + "drink": "飲料", + "other": "其他", + "Total job orders": "總工單數量", + "Filtered": "已過濾", "Duration (Minutes)": "時間(分)", "Prep Time (Minutes)": "準備時間", "Post Prod Time (Minutes)": "收尾時間", diff --git a/src/i18n/zh/jo.json b/src/i18n/zh/jo.json index 23e8fa5..730bad0 100644 --- a/src/i18n/zh/jo.json +++ b/src/i18n/zh/jo.json @@ -25,6 +25,7 @@ "UoM": "銷售單位", "Select Another Bag Lot":"選擇另一個包裝袋", "No": "沒有", + "Packaging":"提料中", "Overall Time Remaining": "總剩餘時間", "User not found with staffNo:": "用戶不存在", "Time Remaining": "剩餘時間", @@ -41,9 +42,16 @@ "Lot No.": "批號", "Pass": "通過", "Delete Job Order": "刪除工單", + "Cancel Job Order": "取消工單", + "Confirm delete job order": "確認刪除工單", + "Delete job order confirm message": "確定要刪除此工單嗎?此操作無法復原。", + "Confirm cancel job order": "確認取消工單", + "Cancel job order confirm message": "確定要取消此工單嗎?工單將從列表中隱藏。", "Bom": "半成品/成品編號", "Release": "放單", "Pending": "待掃碼", + "Put Awayed": "已上架", + "cancel": "已取消", "Pending for pick": "待提料", "Planning": "計劃中", "Processing": "已開始工序", diff --git a/src/i18n/zh/pickOrder.json b/src/i18n/zh/pickOrder.json index cb6d98c..f6c307e 100644 --- a/src/i18n/zh/pickOrder.json +++ b/src/i18n/zh/pickOrder.json @@ -350,6 +350,9 @@ "Enter the number of cartons:": "請輸入總箱數", "Finished Good Detail": "成品提貨詳情", "Finished Good Record": "成品提貨記錄", + "Finished Good Record (All)": "成品提貨記錄(全部)", + "All dates": "全部日期", + "Search date": "搜索日期", "Hide Completed: OFF": "完成: OFF", "Hide Completed: ON": "完成: ON", "Number must be at least 1": "數量至少為1", diff --git a/src/i18n/zh/ticketReleaseTable.json b/src/i18n/zh/ticketReleaseTable.json index 9a981ae..fd7c8af 100644 --- a/src/i18n/zh/ticketReleaseTable.json +++ b/src/i18n/zh/ticketReleaseTable.json @@ -31,5 +31,18 @@ "pending": "待撳單", "released": "提貨中", "completed": "已完成", - "All Statuses": "所有提貨狀態" + "All Statuses": "所有提貨狀態", + "Target Date": "目標日期", + "Reload data": "重新載入", + "Manager only hint": "僅管理員(ADMIN 權限)可使用", + "Actions": "操作", + "Revert assignment": "撤銷領取", + "Force complete DO": "強制完成提貨單", + "Confirm revert assignment": "確認撤銷領取?", + "Revert assignment hint": "將清空負責人,單據回到待分配,其他人可再領取。", + "Confirm force complete": "確認強制完成?", + "Force complete hint": "僅將狀態標為完成並歸檔,不修改已揀數量;適用於已全部提交但系統未完成的情況。", + "Operation succeeded": "操作成功", + "Confirm": "確認", + "Cancel": "取消" } \ No newline at end of file