| @@ -66,7 +66,8 @@ | |||
| "reactstrap": "^9.2.2", | |||
| "styled-components": "^6.1.8", | |||
| "sweetalert2": "^11.10.3", | |||
| "xlsx": "^0.18.5" | |||
| "xlsx": "^0.18.5", | |||
| "xlsx-js-style": "^1.2.0" | |||
| }, | |||
| "devDependencies": { | |||
| "@types/lodash": "^4.14.202", | |||
| @@ -3,6 +3,12 @@ | |||
| "use client"; | |||
| import React, { createContext, useContext, useEffect, useState, useCallback } from "react"; | |||
| import { getSession } from "next-auth/react"; | |||
| import { SessionWithTokens } from "@/config/authConfig"; | |||
| import { | |||
| isBackendJwtExpired, | |||
| LOGIN_SESSION_EXPIRED_HREF, | |||
| } from "@/app/utils/authToken"; | |||
| import axiosInstance, { SetupAxiosInterceptors } from "./axiosInstance"; | |||
| const AxiosContext = createContext(axiosInstance); | |||
| @@ -29,6 +35,47 @@ export const AxiosProvider: React.FC<{ children: React.ReactNode }> = ({ childre | |||
| } | |||
| }, []); | |||
| /** | |||
| * Detect expired/missing backend JWT before user actions (e.g. /report search). | |||
| * Sync accessToken from next-auth session into localStorage if missing, then | |||
| * redirect to login when the Bearer token is absent or past `exp`. | |||
| */ | |||
| useEffect(() => { | |||
| if (!isHydrated || typeof window === "undefined") return; | |||
| let cancelled = false; | |||
| (async () => { | |||
| try { | |||
| let token = localStorage.getItem("accessToken")?.trim() ?? ""; | |||
| if (!token) { | |||
| const session = (await getSession()) as SessionWithTokens | null; | |||
| if (cancelled) return; | |||
| if (session?.accessToken) { | |||
| token = session.accessToken; | |||
| localStorage.setItem("accessToken", token); | |||
| setAccessToken(token); | |||
| } | |||
| } | |||
| if (!token) { | |||
| window.location.href = LOGIN_SESSION_EXPIRED_HREF; | |||
| return; | |||
| } | |||
| if (isBackendJwtExpired(token)) { | |||
| window.location.href = LOGIN_SESSION_EXPIRED_HREF; | |||
| } | |||
| } catch (e) { | |||
| console.warn("Auth token check failed", e); | |||
| } | |||
| })(); | |||
| return () => { | |||
| cancelled = true; | |||
| }; | |||
| }, [isHydrated]); | |||
| // Apply token + interceptors | |||
| useEffect(() => { | |||
| if (accessToken) { | |||
| @@ -0,0 +1,161 @@ | |||
| # Excel export standard (FPSMS frontend) | |||
| This document defines how **client-side** `.xlsx` exports should look and behave. **Implementation:** `exportChartToXlsx.ts` and `exportMultiSheetToXlsx()` — use these helpers for new reports so styling stays consistent. | |||
| ## Scope (important) | |||
| | Export path | Follows this `.md`? | | |||
| |-------------|---------------------| | |||
| | **Next.js** builds the file via `exportChartToXlsx` / `exportMultiSheetToXlsx` (e.g. **PO 入倉記錄** / `rep-014`) | **Yes** — rules are enforced in code. | | |||
| | **Backend** returns ready-made `.xlsx` or Excel bytes (JasperReports, Apache POI, etc.; most `print-*` report endpoints) | **No — not automatically.** That code does **not** use this TypeScript module. To match the same **look** (grey headers, number formats, alignment), implement equivalent styling in Java/Kotlin or Jasper templates. See the backend companion doc below. | | |||
| **Backend companion (visual parity):** | |||
| `FPSMS-backend/docs/EXCEL_EXPORT_STANDARD.md` — same *rules*, for POI/Jasper implementers. | |||
| --- | |||
| ## 1. Library | |||
| | Item | Value | | |||
| |------|--------| | |||
| | Package | **`xlsx-js-style`** (not the plain `xlsx` community build) | | |||
| | Reason | Plain SheetJS **does not persist** cell styles (`fill`, `alignment`, `numFmt`) in the written file. `xlsx-js-style` is a compatible fork that **does**. | | |||
| --- | |||
| ## 2. Data shape | |||
| - Rows are **`Record<string, unknown>[]`** (array of plain objects). | |||
| - **First object’s keys** become the **header row** (column titles). Every row should use the **same keys** in the same order for a rectangular sheet. | |||
| - Prefer **real JavaScript `number`** values for amounts where possible; the exporter will apply number formats. Strings that look like numbers (e.g. `"1,234.56"`) are parsed for money columns. | |||
| --- | |||
| ## 3. Processing order (per sheet) | |||
| After `json_to_sheet(rows)`: | |||
| 1. **`!cols`** — column width heuristic (see §4). | |||
| 2. **`applyHeaderRowStyle`** — header row styling (see §5). | |||
| 3. **`applyMoneyColumnNumberFormats`** — money columns only, data rows (see §6). | |||
| 4. **`applyNumericColumnRightAlign`** — money + quantity columns, **all rows including header** (see §7). | |||
| --- | |||
| ## 4. Column width (`!cols`) | |||
| - For each column: `wch = max(12, headerText.length + 4)`. | |||
| - Adjust if a report needs fixed widths; default keeps bilingual headers readable. | |||
| --- | |||
| ## 5. Header row style (row 0) | |||
| Applied to **every** header cell first; numeric columns get alignment overridden in step 4. | |||
| | Property | Value | | |||
| |----------|--------| | |||
| | Font | Bold, black `rgb: "000000"` | | |||
| | Fill | Solid, `fgColor: "D9D9D9"` (light grey) | | |||
| | Alignment (default) | Horizontal **center**, vertical **center**, `wrapText: true` | | |||
| --- | |||
| ## 6. Money / amount columns — number format | |||
| **Detection:** header label matches (case-insensitive): | |||
| ```text | |||
| 金額 | 單價 | Amount | Unit Price | Total Amount | |||
| ``` | |||
| (Also matches bilingual headers that contain these fragments, e.g. `Amount / 金額`, `Unit Price / 單價`, `Total Amount / 金額`.) | |||
| **Rules:** | |||
| - Applies to **data rows only** (not row 0). | |||
| - Excel format string: **`#,##0.00`** (thousands separator + 2 decimals). Stored on the cell as `z`. | |||
| - Cell type `t: "n"` for numeric values. | |||
| - If the cell is a **string**, commas are stripped and the value is parsed to a number when possible. | |||
| **Naming new reports:** use header text that matches the patterns above so columns pick up formatting automatically. | |||
| --- | |||
| ## 7. Quantity columns — alignment only | |||
| **Detection:** header label matches: | |||
| ```text | |||
| Qty | 數量 | Demand | |||
| ``` | |||
| (Covers e.g. `Qty / 數量`, `Demand Qty / 訂單數量`.) | |||
| - No default thousands format (quantities may have up to 4 decimals in app code). | |||
| - These columns are **right-aligned** together with money columns (see §8). | |||
| --- | |||
| ## 8. Alignment — numeric columns (header + data) | |||
| **Detection:** union of **money** (§6) and **quantity** (§7) header patterns. | |||
| | Alignment | Value | | |||
| |-----------|--------| | |||
| | Horizontal | **`right`** | | |||
| | Vertical | **`center`** | | |||
| | `wrapText` | Preserved / defaulted to `true` where applicable | | |||
| Existing style objects are **merged** (fill, font from header styling are kept). | |||
| --- | |||
| ## 9. Multi-sheet workbook | |||
| | Rule | Detail | | |||
| |------|--------| | |||
| | API | `exportMultiSheetToXlsx({ name, rows }[], filename)` | | |||
| | Sheet name length | Truncated to **31** characters (Excel limit) | | |||
| | Each sheet | Same pipeline as §3 if `rows.length > 0` | | |||
| --- | |||
| ## 10. Empty sheets | |||
| If `rows.length === 0`, a minimal sheet is written; no header styling pipeline runs. | |||
| --- | |||
| ## 11. Reports using this standard today | |||
| | Feature | Location | | |||
| |---------|----------| | |||
| | Chart export | Uses `exportChartToXlsx` | | |||
| | GRN / PO 入倉記錄 (`rep-014`) | `src/app/(main)/report/grnReportApi.ts` — builds row objects, calls `exportChartToXlsx` / `exportMultiSheetToXlsx` | | |||
| Most other reports on `/report` download Excel/PDF **generated on the server** (Jasper, etc.). Those **do not** run this TypeScript pipeline; use **`FPSMS-backend/docs/EXCEL_EXPORT_STANDARD.md`** if you want the same visual rules there. | |||
| --- | |||
| ## 12. Checklist for new Excel exports | |||
| 1. Import from **`xlsx-js-style`** only if building sheets manually; otherwise call **`exportChartToXlsx`** or **`exportMultiSheetToXlsx`**. | |||
| 2. Use **stable header strings** that match §6 / §7 if the column is amount or quantity. | |||
| 3. Pass **numbers** for amounts when possible. | |||
| 4. If you need a **new** category (e.g. “Rate”, “折扣”), extend the regex constants in `exportChartToXlsx.ts` and **update this document**. | |||
| 5. Keep filenames and sheet names user-readable; remember the **31-character** sheet limit. | |||
| --- | |||
| ## 13. Related files | |||
| | File | Role | | |||
| |------|------| | |||
| | `exportChartToXlsx.ts` | Single-sheet export + styling pipeline | | |||
| | `grnReportApi.ts` | Example: bilingual headers, money values, multi-sheet GRN report | | |||
| | `FPSMS-backend/docs/EXCEL_EXPORT_STANDARD.md` | Backend Excel (POI/Jasper) — same *rules*, separate code | | |||
| --- | |||
| *Last aligned with implementation in `exportChartToXlsx.ts` (header fill `#D9D9D9`, money format `#,##0.00`, right-align numeric columns).* | |||
| @@ -1,4 +1,114 @@ | |||
| import * as XLSX from "xlsx"; | |||
| /** | |||
| * Client-side .xlsx export with shared styling (headers, money format, alignment). | |||
| * @see ./EXCEL_EXPORT_STANDARD.md — conventions for new Excel exports | |||
| */ | |||
| import * as XLSX from "xlsx-js-style"; | |||
| /** Light grey header background + bold text (exported by xlsx-js-style). */ | |||
| const HEADER_CELL_STYLE: XLSX.CellStyle = { | |||
| font: { bold: true, color: { rgb: "000000" } }, | |||
| fill: { | |||
| patternType: "solid", | |||
| fgColor: { rgb: "D9D9D9" }, | |||
| }, | |||
| alignment: { vertical: "center", horizontal: "center", wrapText: true }, | |||
| }; | |||
| function applyHeaderRowStyle( | |||
| ws: XLSX.WorkSheet, | |||
| columnCount: number | |||
| ): void { | |||
| for (let colIdx = 0; colIdx < columnCount; colIdx++) { | |||
| const cellRef = XLSX.utils.encode_cell({ r: 0, c: colIdx }); | |||
| const cell = ws[cellRef]; | |||
| if (cell) { | |||
| cell.s = { ...HEADER_CELL_STYLE }; | |||
| } | |||
| } | |||
| } | |||
| /** Headers for money columns (GRN report & similar): thousands separator in Excel. */ | |||
| const MONEY_COLUMN_HEADER = | |||
| /金額|單價|Amount|Unit Price|Total Amount|Total Amount \/ 金額|Amount \/ 金額|Unit Price \/ 單價/i; | |||
| /** Quantity / numeric columns (right-align with amounts). */ | |||
| const QTY_COLUMN_HEADER = /Qty|數量|Demand/i; | |||
| function isNumericDataColumnHeader(h: string): boolean { | |||
| return MONEY_COLUMN_HEADER.test(h) || QTY_COLUMN_HEADER.test(h); | |||
| } | |||
| /** | |||
| * Apply Excel number format `#,##0.00` to money columns so values show with comma separators. | |||
| * Handles numeric cells and pre-formatted strings like "1,234.56". | |||
| */ | |||
| function applyMoneyColumnNumberFormats(ws: XLSX.WorkSheet, headerLabels: string[]): void { | |||
| const moneyColIdx = new Set<number>(); | |||
| headerLabels.forEach((h, c) => { | |||
| if (MONEY_COLUMN_HEADER.test(h)) moneyColIdx.add(c); | |||
| }); | |||
| if (moneyColIdx.size === 0 || !ws["!ref"]) return; | |||
| const range = XLSX.utils.decode_range(ws["!ref"]); | |||
| for (let r = range.s.r + 1; r <= range.e.r; r++) { | |||
| moneyColIdx.forEach((c) => { | |||
| const cellRef = XLSX.utils.encode_cell({ r, c }); | |||
| const cell = ws[cellRef]; | |||
| if (!cell) return; | |||
| if (typeof cell.v === "number" && Number.isFinite(cell.v)) { | |||
| cell.t = "n"; | |||
| cell.z = "#,##0.00"; | |||
| return; | |||
| } | |||
| if (typeof cell.v === "string") { | |||
| const cleaned = cell.v.replace(/,/g, "").trim(); | |||
| if (cleaned === "") return; | |||
| const n = Number.parseFloat(cleaned); | |||
| if (!Number.isNaN(n)) { | |||
| cell.v = n; | |||
| cell.t = "n"; | |||
| cell.z = "#,##0.00"; | |||
| } | |||
| } | |||
| }); | |||
| } | |||
| } | |||
| /** Right-align amount / quantity column headers and data (merge with existing header fill). */ | |||
| function applyNumericColumnRightAlign( | |||
| ws: XLSX.WorkSheet, | |||
| headerLabels: string[] | |||
| ): void { | |||
| const numericColIdx = new Set<number>(); | |||
| headerLabels.forEach((h, c) => { | |||
| if (isNumericDataColumnHeader(h)) numericColIdx.add(c); | |||
| }); | |||
| if (numericColIdx.size === 0 || !ws["!ref"]) return; | |||
| const range = XLSX.utils.decode_range(ws["!ref"]); | |||
| for (let r = range.s.r; r <= range.e.r; r++) { | |||
| numericColIdx.forEach((c) => { | |||
| const cellRef = XLSX.utils.encode_cell({ r, c }); | |||
| const cell = ws[cellRef]; | |||
| if (!cell) return; | |||
| const prev = (cell.s || {}) as XLSX.CellStyle; | |||
| cell.s = { | |||
| ...prev, | |||
| font: prev.font, | |||
| fill: prev.fill, | |||
| border: prev.border, | |||
| numFmt: prev.numFmt, | |||
| alignment: { | |||
| ...prev.alignment, | |||
| horizontal: "right", | |||
| vertical: "center", | |||
| wrapText: prev.alignment?.wrapText ?? true, | |||
| }, | |||
| }; | |||
| }); | |||
| } | |||
| } | |||
| /** | |||
| * Export an array of row objects to a .xlsx file and trigger download. | |||
| @@ -28,19 +138,45 @@ export function exportChartToXlsx( | |||
| wch: Math.max(12, h.length + 4), | |||
| })); | |||
| // Make header row look like a header (bold). | |||
| header.forEach((_, colIdx) => { | |||
| const cellRef = XLSX.utils.encode_cell({ r: 0, c: colIdx }); | |||
| const cell = ws[cellRef]; | |||
| if (cell) { | |||
| cell.s = { | |||
| font: { bold: true }, | |||
| }; | |||
| } | |||
| }); | |||
| applyHeaderRowStyle(ws, header.length); | |||
| applyMoneyColumnNumberFormats(ws, header); | |||
| applyNumericColumnRightAlign(ws, header); | |||
| } | |||
| const wb = XLSX.utils.book_new(); | |||
| XLSX.utils.book_append_sheet(wb, ws, sheetName); | |||
| XLSX.writeFile(wb, `${filename}.xlsx`); | |||
| } | |||
| export type MultiSheetSpec = { name: string; rows: Record<string, unknown>[] }; | |||
| /** | |||
| * Export multiple worksheets in one .xlsx file. | |||
| * Sheet names are truncated to 31 characters (Excel limit). | |||
| */ | |||
| export function exportMultiSheetToXlsx( | |||
| sheets: MultiSheetSpec[], | |||
| filename: string | |||
| ): void { | |||
| const wb = XLSX.utils.book_new(); | |||
| for (const { name, rows } of sheets) { | |||
| const safeName = name.slice(0, 31); | |||
| let ws: ReturnType<typeof XLSX.utils.json_to_sheet>; | |||
| if (rows.length === 0) { | |||
| ws = XLSX.utils.aoa_to_sheet([[]]); | |||
| } else { | |||
| ws = XLSX.utils.json_to_sheet(rows); | |||
| const header = Object.keys(rows[0] ?? {}); | |||
| if (header.length > 0) { | |||
| ws["!cols"] = header.map((h) => ({ | |||
| wch: Math.max(12, h.length + 4), | |||
| })); | |||
| applyHeaderRowStyle(ws, header.length); | |||
| applyMoneyColumnNumberFormats(ws, header); | |||
| applyNumericColumnRightAlign(ws, header); | |||
| } | |||
| } | |||
| XLSX.utils.book_append_sheet(wb, ws, safeName); | |||
| } | |||
| XLSX.writeFile(wb, `${filename}.xlsx`); | |||
| } | |||
| @@ -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, | |||
| @@ -33,8 +33,11 @@ The frontend **GRN/入倉明細報告** report calls the following endpoint. The | |||
| "stockUomDesc": "KG", | |||
| "productLotNo": "LOT-001", | |||
| "expiryDate": "2026-03-01", | |||
| "supplierCode": "P06", | |||
| "supplier": "Supplier Name", | |||
| "status": "completed" | |||
| "status": "completed", | |||
| "grnCode": "PPP004GRN26030298", | |||
| "grnId": 7854617 | |||
| } | |||
| ] | |||
| } | |||
| @@ -56,4 +59,12 @@ Or a direct array: | |||
| - `itemCode` when provided. | |||
| - Return one row per GRN line with at least: **PO/delivery note no.**, **itemCode**, **itemName**, **qty** (e.g. `acceptedQty`), **uom**, and optionally receipt date, lot, expiry, supplier, status. | |||
| Frontend builds the Excel from this JSON and downloads it with columns: PO No., Delivery Note No., Receipt Date, Item Code, Item Name, Qty, Demand Qty, UOM, Product Lot No., Expiry Date, Supplier, Status. | |||
| Frontend builds the Excel from this JSON. Columns include: PO No., Delivery Note No., Receipt Date, Item Code, Item Name, Qty, Demand Qty, UOM, Supplier Lot No. 供應商批次, Expiry Date, Supplier Code, Supplier, 入倉狀態, **GRN Code** (`m18_goods_receipt_note_log.grn_code`), **GRN Id** (`m18_record_id`). | |||
| ## Frontend Excel styling (shared standard) | |||
| Header colours, number formats (`#,##0.00` for amounts), and column alignment are defined in: | |||
| **[`../chart/_components/EXCEL_EXPORT_STANDARD.md`](../chart/_components/EXCEL_EXPORT_STANDARD.md)** | |||
| Use that document when adding or changing Excel exports so formatting stays consistent. | |||
| @@ -2,7 +2,10 @@ | |||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||
| import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | |||
| import { exportChartToXlsx } from "@/app/(main)/chart/_components/exportChartToXlsx"; | |||
| import { | |||
| exportChartToXlsx, | |||
| exportMultiSheetToXlsx, | |||
| } from "@/app/(main)/chart/_components/exportChartToXlsx"; | |||
| export interface GrnReportRow { | |||
| poCode?: string; | |||
| @@ -21,12 +24,38 @@ export interface GrnReportRow { | |||
| supplierCode?: string; | |||
| supplier?: string; | |||
| status?: string; | |||
| grnId?: number | string; | |||
| /** PO line unit price (purchase_order_line.up) */ | |||
| unitPrice?: number; | |||
| /** unitPrice × acceptedQty */ | |||
| lineAmount?: number; | |||
| /** PO currency code (currency.code) */ | |||
| currencyCode?: string; | |||
| /** M18 AN document code from m18_goods_receipt_note_log.grn_code */ | |||
| grnCode?: string; | |||
| /** M18 record id (m18_record_id) */ | |||
| grnId?: number | string; | |||
| [key: string]: unknown; | |||
| } | |||
| /** Sheet "已上架PO金額": totals grouped by receipt date + currency / PO (ADMIN-only data from API). */ | |||
| export interface ListedPoAmounts { | |||
| currencyTotals: { | |||
| receiptDate?: string; | |||
| currencyCode?: string; | |||
| totalAmount?: number; | |||
| }[]; | |||
| byPurchaseOrder: { | |||
| receiptDate?: string; | |||
| poCode?: string; | |||
| currencyCode?: string; | |||
| totalAmount?: number; | |||
| grnCodes?: string; | |||
| }[]; | |||
| } | |||
| export interface GrnReportResponse { | |||
| rows: GrnReportRow[]; | |||
| listedPoAmounts?: ListedPoAmounts; | |||
| } | |||
| /** | |||
| @@ -35,7 +64,7 @@ export interface GrnReportResponse { | |||
| */ | |||
| export async function fetchGrnReportData( | |||
| criteria: Record<string, string> | |||
| ): Promise<GrnReportRow[]> { | |||
| ): Promise<{ rows: GrnReportRow[]; listedPoAmounts?: ListedPoAmounts }> { | |||
| const queryParams = new URLSearchParams(criteria).toString(); | |||
| const url = `${NEXT_PUBLIC_API_URL}/report/grn-report?${queryParams}`; | |||
| @@ -50,39 +79,134 @@ export async function fetchGrnReportData( | |||
| throw new Error(`HTTP error! status: ${response.status}`); | |||
| const data = (await response.json()) as GrnReportResponse | GrnReportRow[]; | |||
| const rows = Array.isArray(data) ? data : (data as GrnReportResponse).rows ?? []; | |||
| return rows; | |||
| if (Array.isArray(data)) { | |||
| return { rows: data }; | |||
| } | |||
| const body = data as GrnReportResponse; | |||
| return { | |||
| rows: body.rows ?? [], | |||
| listedPoAmounts: body.listedPoAmounts, | |||
| }; | |||
| } | |||
| /** Coerce API JSON (number or numeric string) to a finite number. */ | |||
| function coerceToFiniteNumber(value: unknown): number | null { | |||
| if (value === null || value === undefined) return null; | |||
| if (typeof value === "number" && Number.isFinite(value)) return value; | |||
| if (typeof value === "string") { | |||
| const t = value.trim(); | |||
| if (t === "") return null; | |||
| const n = Number(t); | |||
| return Number.isFinite(n) ? n : null; | |||
| } | |||
| return null; | |||
| } | |||
| /** | |||
| * Cell value for money columns: numeric when possible so Excel export can apply `#,##0.00` (see exportChartToXlsx). | |||
| */ | |||
| function moneyCellValue(v: unknown): number | string { | |||
| const n = coerceToFiniteNumber(v); | |||
| if (n === null) return ""; | |||
| return n; | |||
| } | |||
| /** Thousands separator for quantities (up to 4 decimal places, trims trailing zeros). */ | |||
| const formatQty = (n: number | undefined | null): string => { | |||
| if (n === undefined || n === null || Number.isNaN(Number(n))) return ""; | |||
| return new Intl.NumberFormat("en-US", { | |||
| minimumFractionDigits: 0, | |||
| maximumFractionDigits: 4, | |||
| }).format(Number(n)); | |||
| }; | |||
| /** Excel column headers (bilingual) for GRN report */ | |||
| function toExcelRow(r: GrnReportRow): Record<string, string | number | undefined> { | |||
| return { | |||
| function toExcelRow( | |||
| r: GrnReportRow, | |||
| includeFinancialColumns: boolean | |||
| ): Record<string, string | number | undefined> { | |||
| const base: Record<string, string | number | undefined> = { | |||
| "PO No. / 訂單編號": r.poCode ?? "", | |||
| "Supplier Code / 供應商編號": r.supplierCode ?? "", | |||
| "Delivery Note No. / 送貨單編號": r.deliveryNoteNo ?? "", | |||
| "Receipt Date / 收貨日期": r.receiptDate ?? "", | |||
| "Item Code / 物料編號": r.itemCode ?? "", | |||
| "Item Name / 物料名稱": r.itemName ?? "", | |||
| "Qty / 數量": r.acceptedQty ?? r.receivedQty ?? "", | |||
| "Demand Qty / 訂單數量": r.demandQty ?? "", | |||
| "Qty / 數量": formatQty( | |||
| r.acceptedQty ?? r.receivedQty ?? undefined | |||
| ), | |||
| "Demand Qty / 訂單數量": formatQty(r.demandQty), | |||
| "UOM / 單位": r.uom ?? r.purchaseUomDesc ?? r.stockUomDesc ?? "", | |||
| "Product Lot No. / 批次": r.productLotNo ?? "", | |||
| "Supplier Lot No. 供應商批次": r.productLotNo ?? "", | |||
| "Expiry Date / 到期日": r.expiryDate ?? "", | |||
| "Supplier Code / 供應商編號": r.supplierCode ?? "", | |||
| "Supplier / 供應商": r.supplier ?? "", | |||
| "Status / 狀態": r.status ?? "", | |||
| "GRN Id / M18 單號": r.grnId ?? "", | |||
| "入倉狀態": r.status ?? "", | |||
| }; | |||
| if (includeFinancialColumns) { | |||
| base["Unit Price / 單價"] = moneyCellValue(r.unitPrice); | |||
| base["Currency / 貨幣"] = r.currencyCode ?? ""; | |||
| base["Amount / 金額"] = moneyCellValue(r.lineAmount); | |||
| } | |||
| base["GRN Code / M18 入倉單號"] = r.grnCode ?? ""; | |||
| base["GRN Id / M18 記錄編號"] = r.grnId ?? ""; | |||
| return base; | |||
| } | |||
| const GRN_SHEET_DETAIL = "PO入倉記錄"; | |||
| const GRN_SHEET_LISTED_PO = "已上架PO金額"; | |||
| /** Rows for sheet "已上架PO金額" (ADMIN-only; do not add this sheet for other users). */ | |||
| function buildListedPoAmountSheetRows( | |||
| listed: ListedPoAmounts | undefined | |||
| ): Record<string, string | number | undefined>[] { | |||
| if ( | |||
| !listed || | |||
| (listed.currencyTotals.length === 0 && | |||
| listed.byPurchaseOrder.length === 0) | |||
| ) { | |||
| return [ | |||
| { | |||
| "Note / 備註": | |||
| "(篩選範圍內無已完成之 PO 行) / No completed PO lines in the selected range", | |||
| }, | |||
| ]; | |||
| } | |||
| const out: Record<string, string | number | undefined>[] = []; | |||
| for (const c of listed.currencyTotals) { | |||
| out.push({ | |||
| "Category / 類別": "貨幣小計 / Currency total", | |||
| "Receipt Date / 收貨日期": c.receiptDate ?? "", | |||
| "PO No. / 訂單編號": "", | |||
| "Currency / 貨幣": c.currencyCode ?? "", | |||
| "Total Amount / 金額": moneyCellValue(c.totalAmount), | |||
| "GRN Code(s) / M18 入倉單號": "", | |||
| }); | |||
| } | |||
| for (const p of listed.byPurchaseOrder) { | |||
| out.push({ | |||
| "Category / 類別": "訂單 / PO", | |||
| "Receipt Date / 收貨日期": p.receiptDate ?? "", | |||
| "PO No. / 訂單編號": p.poCode ?? "", | |||
| "Currency / 貨幣": p.currencyCode ?? "", | |||
| "Total Amount / 金額": moneyCellValue(p.totalAmount), | |||
| "GRN Code(s) / M18 入倉單號": p.grnCodes ?? "", | |||
| }); | |||
| } | |||
| return out; | |||
| } | |||
| /** | |||
| * Generate and download GRN report as Excel. | |||
| * Sheet "已上架PO金額" is included only when `includeFinancialColumns` is true (ADMIN). | |||
| */ | |||
| export async function generateGrnReportExcel( | |||
| criteria: Record<string, string>, | |||
| reportTitle: string = "PO 入倉記錄" | |||
| reportTitle: string = "PO 入倉記錄", | |||
| /** Only users with ADMIN authority should pass true (must match backend). */ | |||
| includeFinancialColumns: boolean = false | |||
| ): Promise<void> { | |||
| const rows = await fetchGrnReportData(criteria); | |||
| const excelRows = rows.map(toExcelRow); | |||
| const { rows, listedPoAmounts } = await fetchGrnReportData(criteria); | |||
| const excelRows = rows.map((r) => toExcelRow(r, includeFinancialColumns)); | |||
| const start = criteria.receiptDateStart; | |||
| const end = criteria.receiptDateEnd; | |||
| let datePart: string; | |||
| @@ -95,5 +219,17 @@ export async function generateGrnReportExcel( | |||
| } | |||
| const safeDatePart = datePart.replace(/[^\d\-_/]/g, ""); | |||
| const filename = `${reportTitle}_${safeDatePart}`; | |||
| exportChartToXlsx(excelRows, filename, "GRN"); | |||
| if (includeFinancialColumns) { | |||
| const sheet2 = buildListedPoAmountSheetRows(listedPoAmounts); | |||
| exportMultiSheetToXlsx( | |||
| [ | |||
| { name: GRN_SHEET_DETAIL, rows: excelRows as Record<string, unknown>[] }, | |||
| { name: GRN_SHEET_LISTED_PO, rows: sheet2 as Record<string, unknown>[] }, | |||
| ], | |||
| filename | |||
| ); | |||
| } else { | |||
| exportChartToXlsx(excelRows as Record<string, unknown>[], filename, GRN_SHEET_DETAIL); | |||
| } | |||
| } | |||
| @@ -1,6 +1,9 @@ | |||
| "use client"; | |||
| import React, { useState, useMemo, useEffect } from 'react'; | |||
| import { useSession } from "next-auth/react"; | |||
| import { SessionWithTokens } from "@/config/authConfig"; | |||
| import { AUTH } from "@/authorities"; | |||
| import { | |||
| Box, | |||
| Card, | |||
| @@ -31,6 +34,10 @@ interface ItemCodeWithName { | |||
| } | |||
| export default function ReportPage() { | |||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | |||
| const includeGrnFinancialColumns = | |||
| session?.abilities?.includes(AUTH.ADMIN) ?? false; | |||
| const [selectedReportId, setSelectedReportId] = useState<string>(''); | |||
| const [criteria, setCriteria] = useState<Record<string, string>>({}); | |||
| const [loading, setLoading] = useState(false); | |||
| @@ -174,7 +181,11 @@ export default function ReportPage() { | |||
| setLoading(true); | |||
| try { | |||
| if (currentReport.id === 'rep-014') { | |||
| await generateGrnReportExcel(criteria, currentReport.title); | |||
| await generateGrnReportExcel( | |||
| criteria, | |||
| currentReport.title, | |||
| includeGrnFinancialColumns | |||
| ); | |||
| } else { | |||
| // Backend returns actual .xlsx bytes for this Excel endpoint. | |||
| const queryParams = new URLSearchParams(criteria).toString(); | |||
| @@ -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 { | |||
| @@ -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<any>(`${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<JobTypeResponse[]>( | |||
| `${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<JobOrderProductProcessPageResponse>( | |||
| `${BASE_API_URL}/product-process/Demo/Process/search${query}`, | |||
| { | |||
| method: "GET", | |||
| next: { tags: ["productProcessSearch"] }, | |||
| } | |||
| ); | |||
| }); | |||
| /* | |||
| export const updateProductProcessLineQty = async (request: UpdateProductProcessLineQtyRequest) => { | |||
| return serverFetchJson<UpdateProductProcessLineQtyResponse>( | |||
| @@ -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<CompletedDoPickOrderResponse[]> => { | |||
| 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<CompletedDoPickOrderResponse[]>(url, { | |||
| method: "GET", | |||
| }); | |||
| return response; | |||
| }; | |||
| /** 強制完成進行中的 do_pick_order(僅改狀態並歸檔,不調整揀貨數量) */ | |||
| export const forceCompleteDoPickOrder = async ( | |||
| doPickOrderId: number, | |||
| ): Promise<PostPickOrderResponse> => { | |||
| return serverFetchJson<PostPickOrderResponse>( | |||
| `${BASE_API_URL}/doPickOrder/force-complete/${doPickOrderId}`, | |||
| { method: "POST", headers: { "Content-Type": "application/json" } }, | |||
| ); | |||
| }; | |||
| /** 撤銷使用者領取,可再次分配 */ | |||
| export const revertDoPickOrderAssignment = async ( | |||
| doPickOrderId: number, | |||
| ): Promise<PostPickOrderResponse> => { | |||
| return serverFetchJson<PostPickOrderResponse>( | |||
| `${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<UpdateDoPickOrderHideStatusRequest>( | |||
| `${BASE_API_URL}/pickOrder/update-hide-status/${pickOrderId}?hide=${hide}`, | |||
| @@ -0,0 +1,41 @@ | |||
| /** | |||
| * Client-side helpers for the backend JWT stored in localStorage (`accessToken`). | |||
| * Used to detect expiry before API calls so pages like /report can redirect early. | |||
| */ | |||
| /** Must match clientAuthFetch redirect target */ | |||
| export const LOGIN_SESSION_EXPIRED_HREF = "/login?session=expired"; | |||
| /** | |||
| * Decode JWT payload (no signature verification — UX only; server still validates). | |||
| */ | |||
| export function decodeJwtPayload( | |||
| token: string | |||
| ): Record<string, unknown> | null { | |||
| try { | |||
| const parts = token.split("."); | |||
| if (parts.length < 2) return null; | |||
| const payload = parts[1]; | |||
| const base64 = payload.replace(/-/g, "+").replace(/_/g, "/"); | |||
| const padded = base64.padEnd( | |||
| base64.length + ((4 - (base64.length % 4)) % 4), | |||
| "=" | |||
| ); | |||
| const binary = atob(padded); | |||
| const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0)); | |||
| const json = new TextDecoder().decode(bytes); | |||
| return JSON.parse(json) as Record<string, unknown>; | |||
| } catch { | |||
| return null; | |||
| } | |||
| } | |||
| /** | |||
| * True if `exp` claim is reached or passed. False if no `exp` (let API decide). | |||
| */ | |||
| export function isBackendJwtExpired(token: string): boolean { | |||
| const payload = decodeJwtPayload(token); | |||
| const exp = payload?.exp; | |||
| if (typeof exp !== "number") return false; | |||
| return Date.now() / 1000 >= exp; | |||
| } | |||
| @@ -1,6 +1,6 @@ | |||
| "use client"; | |||
| const LOGIN_REDIRECT = "/login?session=expired"; | |||
| import { LOGIN_SESSION_EXPIRED_HREF } from "@/app/utils/authToken"; | |||
| /** | |||
| * Client-side fetch that adds Bearer token from localStorage and redirects | |||
| @@ -23,7 +23,7 @@ export async function clientAuthFetch( | |||
| if (response.status === 401 || response.status === 403) { | |||
| if (typeof window !== "undefined") { | |||
| console.warn(`Auth error ${response.status} → redirecting to login`); | |||
| window.location.href = LOGIN_REDIRECT; | |||
| window.location.href = LOGIN_SESSION_EXPIRED_HREF; | |||
| } | |||
| } | |||
| @@ -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<string>("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>(() => dayjs()); | |||
| const [selectedFloor, setSelectedFloor] = useState<string>(""); | |||
| const [selectedStatus, setSelectedStatus] = useState<string>("released"); | |||
| @@ -41,89 +79,77 @@ const FGPickOrderTicketReleaseTable: React.FC = () => { | |||
| }); | |||
| const [now, setNow] = useState(dayjs()); | |||
| const [lastDataRefreshTime, setLastDataRefreshTime] = useState<dayjs.Dayjs | null>(null); | |||
| const formatTime = (timeData: any): string => { | |||
| if (!timeData) return ''; | |||
| const [lastDataRefreshTime, setLastDataRefreshTime] = useState<dayjs.Dayjs | null>(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 ( | |||
| <Card sx={{ mb: 2 }}> | |||
| <CardContent> | |||
| {/* Title */} | |||
| <Typography variant="h5" sx={{ fontWeight: 600, mb: 2 }}> | |||
| {t("Ticket Release Table")} | |||
| </Typography> | |||
| {/* Dropdown Menus */} | |||
| <Stack direction="row" spacing={2} sx={{ mb: 3, flexWrap: 'wrap' }}> | |||
| <FormControl sx={{ minWidth: 250 }} size="small"> | |||
| <InputLabel id="date-select-label">{t("Select Date")}</InputLabel> | |||
| <Select | |||
| labelId="date-select-label" | |||
| id="date-select" | |||
| value={selectedDate} | |||
| label={t("Select Date")} | |||
| onChange={(e) => setSelectedDate(e.target.value)} | |||
| > | |||
| <MenuItem value="today"> | |||
| {t("Today")} ({getDateLabel(0)}) | |||
| </MenuItem> | |||
| <MenuItem value="tomorrow"> | |||
| {t("Tomorrow")} ({getDateLabel(1)}) | |||
| </MenuItem> | |||
| <MenuItem value="dayAfterTomorrow"> | |||
| {t("Day After Tomorrow")} ({getDateLabel(2)}) | |||
| </MenuItem> | |||
| </Select> | |||
| </FormControl> | |||
| <FormControl sx={{ minWidth: 150 }} size="small"> | |||
| <InputLabel | |||
| id="floor-select-label" | |||
| shrink={true} | |||
| > | |||
| {t("Floor")} | |||
| </InputLabel> | |||
| <Select | |||
| labelId="floor-select-label" | |||
| id="floor-select" | |||
| value={selectedFloor} | |||
| label={t("Floor")} | |||
| onChange={(e) => setSelectedFloor(e.target.value)} | |||
| displayEmpty | |||
| > | |||
| <MenuItem value="">{t("All Floors")}</MenuItem> | |||
| <MenuItem value="2/F">2/F</MenuItem> | |||
| <MenuItem value="4/F">4/F</MenuItem> | |||
| </Select> | |||
| </FormControl> | |||
| <FormControl sx={{ minWidth: 150 }} size="small"> | |||
| <InputLabel | |||
| id="status-select-label" | |||
| shrink={true} | |||
| > | |||
| {t("Status")} | |||
| </InputLabel> | |||
| <Select | |||
| labelId="status-select-label" | |||
| id="status-select" | |||
| value={selectedStatus} | |||
| label={t("Status")} | |||
| onChange={(e) => setSelectedStatus(e.target.value)} | |||
| displayEmpty | |||
| > | |||
| <MenuItem value="">{t("All Statuses")}</MenuItem> | |||
| <MenuItem value="pending">{t("pending")}</MenuItem> | |||
| <MenuItem value="released">{t("released")}</MenuItem> | |||
| <MenuItem value="completed">{t("completed")}</MenuItem> | |||
| </Select> | |||
| </FormControl> | |||
| <Box sx={{ flexGrow: 1 }} /> | |||
| <Stack direction="row" spacing={2} sx={{ flexShrink: 0, alignSelf: 'center' }}> | |||
| <Typography variant="body2" sx={{ color: 'text.secondary' }}> | |||
| {t("Now")}: {now.format('HH:mm')} | |||
| </Typography> | |||
| <Typography variant="body2" sx={{ color: 'text.secondary' }}> | |||
| {t("Auto-refresh every 5 minutes")} | {t("Last updated")}: {lastDataRefreshTime ? lastDataRefreshTime.format('HH:mm:ss') : '--:--:--'} | |||
| </Typography> | |||
| <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-hk"> | |||
| <Card sx={{ mb: 2 }}> | |||
| <CardContent> | |||
| <Typography variant="h5" sx={{ fontWeight: 600, mb: 2 }}> | |||
| {t("Ticket Release Table")} | |||
| </Typography> | |||
| <Stack direction="row" spacing={2} sx={{ mb: 3, flexWrap: "wrap", alignItems: "center" }}> | |||
| <DatePicker | |||
| label={t("Target Date")} | |||
| value={queryDate} | |||
| onChange={(v) => v && setQueryDate(v)} | |||
| slotProps={{ textField: { size: "small", sx: { minWidth: 180 } } }} | |||
| /> | |||
| <Button variant="outlined" size="small" onClick={() => void loadData()}> | |||
| {t("Reload data")} | |||
| </Button> | |||
| <FormControl sx={{ minWidth: 150 }} size="small"> | |||
| <InputLabel id="floor-select-label" shrink> | |||
| {t("Floor")} | |||
| </InputLabel> | |||
| <Select | |||
| labelId="floor-select-label" | |||
| value={selectedFloor} | |||
| label={t("Floor")} | |||
| onChange={(e) => setSelectedFloor(e.target.value)} | |||
| displayEmpty | |||
| > | |||
| <MenuItem value="">{t("All Floors")}</MenuItem> | |||
| <MenuItem value="2/F">2/F</MenuItem> | |||
| <MenuItem value="4/F">4/F</MenuItem> | |||
| </Select> | |||
| </FormControl> | |||
| <FormControl sx={{ minWidth: 150 }} size="small"> | |||
| <InputLabel id="status-select-label" shrink> | |||
| {t("Status")} | |||
| </InputLabel> | |||
| <Select | |||
| labelId="status-select-label" | |||
| value={selectedStatus} | |||
| label={t("Status")} | |||
| onChange={(e) => setSelectedStatus(e.target.value)} | |||
| displayEmpty | |||
| > | |||
| <MenuItem value="">{t("All Statuses")}</MenuItem> | |||
| <MenuItem value="pending">{t("pending")}</MenuItem> | |||
| <MenuItem value="released">{t("released")}</MenuItem> | |||
| <MenuItem value="completed">{t("completed")}</MenuItem> | |||
| </Select> | |||
| </FormControl> | |||
| <Box sx={{ flexGrow: 1 }} /> | |||
| <Stack direction="row" spacing={2} sx={{ flexShrink: 0, alignSelf: "center" }}> | |||
| <Typography variant="body2" sx={{ color: "text.secondary" }}> | |||
| {t("Now")}: {now.format("HH:mm")} | |||
| </Typography> | |||
| <Typography variant="body2" sx={{ color: "text.secondary" }}> | |||
| {t("Auto-refresh every 5 minutes")} | {t("Last updated")}:{" "} | |||
| {lastDataRefreshTime ? lastDataRefreshTime.format("HH:mm:ss") : "--:--:--"} | |||
| </Typography> | |||
| </Stack> | |||
| </Stack> | |||
| </Stack> | |||
| <Box sx={{ mt: 2 }}> | |||
| {loading ? ( | |||
| <Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}> | |||
| <CircularProgress /> | |||
| </Box> | |||
| ) : ( | |||
| <> | |||
| <TableContainer component={Paper} sx={{ maxHeight: 440, overflow: 'auto' }}> | |||
| <Table size="small" sx={{ minWidth: 650 }}> | |||
| <TableHead> | |||
| <TableRow sx={{ position: 'sticky', top: 0, zIndex: 1, backgroundColor: 'grey.100' }}> | |||
| <TableCell>{t("Store ID")}</TableCell> | |||
| <TableCell>{t("Required Delivery Date")}</TableCell> | |||
| <TableCell> | |||
| <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}> | |||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | |||
| {t("Truck Information")} | |||
| </Typography> | |||
| <Typography variant="caption" sx={{ color: 'text.secondary' }}> | |||
| {t("Truck Lane Code")} - {t("Departure Time")} | |||
| </Typography> | |||
| </Box> | |||
| </TableCell> | |||
| {/*<TableCell>{t("Truck Departure Time")}</TableCell> | |||
| <TableCell>{t("Truck Lane Code")}</TableCell>*/} | |||
| <TableCell sx={{ minWidth: 200, width: '20%' }}>{t("Shop Name")}</TableCell> | |||
| <TableCell align="right">{t("Loading Sequence")}</TableCell> | |||
| {/*<TableCell>{t("Delivery Order Code(s)")}</TableCell> | |||
| <TableCell>{t("Pick Order Code(s)")}</TableCell> | |||
| <TableCell>{t("Ticket Number")}</TableCell> | |||
| <TableCell>{t("Ticket Release Time")}</TableCell> | |||
| <TableCell>{t("Ticket Complete Date Time")}</TableCell> | |||
| <TableCell>{t("Ticket Status")}</TableCell>*/} | |||
| <TableCell> | |||
| <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}> | |||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | |||
| {t("Ticket Information")} | |||
| </Typography> | |||
| <Typography variant="caption" sx={{ color: 'text.secondary' }}> | |||
| {t("Ticket No.")} ({t("Status")}) | |||
| </Typography> | |||
| <Typography variant="caption" sx={{ color: 'text.secondary' }}> | |||
| {t("Released Time")} - {t("Completed Time")} | |||
| </Typography> | |||
| </Box> | |||
| </TableCell> | |||
| <TableCell>{t("Handler Name")}</TableCell> | |||
| <TableCell align="right" sx={{ minWidth: 100, width: '8%', whiteSpace: 'nowrap' }}>{t("Number of FG Items (Order Item(s) Count)")}</TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {paginatedData.length === 0 ? ( | |||
| <TableRow> | |||
| <TableCell colSpan={12} align="center"> | |||
| {t("No data available")} | |||
| <Box sx={{ mt: 2 }}> | |||
| {loading ? ( | |||
| <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}> | |||
| <CircularProgress /> | |||
| </Box> | |||
| ) : ( | |||
| <> | |||
| <TableContainer component={Paper} sx={{ maxHeight: 440, overflow: "auto" }}> | |||
| <Table size="small" sx={{ minWidth: 650 }}> | |||
| <TableHead> | |||
| <TableRow | |||
| sx={{ | |||
| position: "sticky", | |||
| top: 0, | |||
| zIndex: 1, | |||
| backgroundColor: "grey.100", | |||
| }} | |||
| > | |||
| <TableCell>{t("Store ID")}</TableCell> | |||
| <TableCell>{t("Required Delivery Date")}</TableCell> | |||
| <TableCell> | |||
| <Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}> | |||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | |||
| {t("Truck Information")} | |||
| </Typography> | |||
| <Typography variant="caption" sx={{ color: "text.secondary" }}> | |||
| {t("Truck Lane Code")} - {t("Departure Time")} | |||
| </Typography> | |||
| </Box> | |||
| </TableCell> | |||
| <TableCell sx={{ minWidth: 200, width: "20%" }}>{t("Shop Name")}</TableCell> | |||
| <TableCell align="right">{t("Loading Sequence")}</TableCell> | |||
| <TableCell> | |||
| <Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}> | |||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | |||
| {t("Ticket Information")} | |||
| </Typography> | |||
| <Typography variant="caption" sx={{ color: "text.secondary" }}> | |||
| {t("Ticket No.")} ({t("Status")}) | |||
| </Typography> | |||
| <Typography variant="caption" sx={{ color: "text.secondary" }}> | |||
| {t("Released Time")} - {t("Completed Time")} | |||
| </Typography> | |||
| </Box> | |||
| </TableCell> | |||
| <TableCell>{t("Handler Name")}</TableCell> | |||
| <TableCell align="right" sx={{ minWidth: 100, width: "8%", whiteSpace: "nowrap" }}> | |||
| {t("Number of FG Items (Order Item(s) Count)")} | |||
| </TableCell> | |||
| <TableCell align="center" sx={{ minWidth: 200 }}> | |||
| {t("Actions")} | |||
| </TableCell> | |||
| </TableRow> | |||
| ) : ( | |||
| paginatedData.map((row) => { | |||
| return ( | |||
| <TableRow key={row.id}> | |||
| <TableCell>{row.storeId || '-'}</TableCell> | |||
| </TableHead> | |||
| <TableBody> | |||
| {paginatedData.length === 0 ? ( | |||
| <TableRow> | |||
| <TableCell colSpan={9} align="center"> | |||
| {t("No data available")} | |||
| </TableCell> | |||
| </TableRow> | |||
| ) : ( | |||
| paginatedData.map((row) => ( | |||
| <TableRow key={`${row.id}-${row.ticketNo}-${row.requiredDeliveryDate}`}> | |||
| <TableCell>{row.storeId || "-"}</TableCell> | |||
| <TableCell> | |||
| {row.requiredDeliveryDate | |||
| ? dayjs(row.requiredDeliveryDate).format('YYYY-MM-DD') | |||
| : '-'} | |||
| ? dayjs(row.requiredDeliveryDate).format("YYYY-MM-DD") | |||
| : "-"} | |||
| </TableCell> | |||
| <TableCell> | |||
| <Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', alignItems: 'center' }}> | |||
| {row.truckLanceCode && ( | |||
| <Chip | |||
| label={row.truckLanceCode} | |||
| size="small" | |||
| color="primary" | |||
| /> | |||
| )} | |||
| {row.truckDepartureTime && ( | |||
| <Chip | |||
| label={formatTime(row.truckDepartureTime)} | |||
| size="small" | |||
| color="secondary" | |||
| /> | |||
| )} | |||
| {!row.truckLanceCode && !row.truckDepartureTime && ( | |||
| <Typography variant="body2" sx={{ color: 'text.secondary' }}> | |||
| - | |||
| </Typography> | |||
| )} | |||
| </Box> | |||
| </TableCell> | |||
| <TableCell> | |||
| <Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap", alignItems: "center" }}> | |||
| {row.truckLanceCode && ( | |||
| <Chip label={row.truckLanceCode} size="small" color="primary" /> | |||
| )} | |||
| {row.truckDepartureTime && ( | |||
| <Chip label={formatTime(row.truckDepartureTime)} size="small" color="secondary" /> | |||
| )} | |||
| {!row.truckLanceCode && !row.truckDepartureTime && ( | |||
| <Typography variant="body2" sx={{ color: "text.secondary" }}> | |||
| - | |||
| </Typography> | |||
| )} | |||
| </Box> | |||
| </TableCell> | |||
| <TableCell sx={{ minWidth: 200, width: '20%' }}>{row.shopName || '-'}</TableCell> | |||
| <TableCell align="right">{row.loadingSequence || '-'}</TableCell> | |||
| {/*<TableCell>{row.deliveryOrderCode || '-'}</TableCell> | |||
| <TableCell align="right">{row.pickOrderCode || '-'}</TableCell> | |||
| <TableCell>{row.ticketNo || '-'}</TableCell> | |||
| <TableCell> | |||
| {row.ticketReleaseTime | |||
| ? dayjs(row.ticketReleaseTime).format('YYYY-MM-DD HH:mm:ss') | |||
| : '-'} | |||
| </TableCell> | |||
| <TableCell> | |||
| {row.ticketCompleteDateTime | |||
| ? dayjs(row.ticketCompleteDateTime).format('YYYY-MM-DD HH:mm:ss') | |||
| : '-'} | |||
| </TableCell> | |||
| <TableCell>{row.ticketStatus || '-'}</TableCell>*/} | |||
| <TableCell sx={{ minWidth: 200, width: "20%" }}>{row.shopName || "-"}</TableCell> | |||
| <TableCell align="right">{row.loadingSequence ?? "-"}</TableCell> | |||
| <TableCell> | |||
| <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}> | |||
| <Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}> | |||
| <Typography variant="body2"> | |||
| {row.ticketNo || '-'} ({row.ticketStatus ? t(row.ticketStatus.toLowerCase()) : '-'}) | |||
| {row.ticketNo || "-"} ({row.ticketStatus ? t(row.ticketStatus.toLowerCase()) : "-"}) | |||
| </Typography> | |||
| <Typography variant="body2"> | |||
| {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"); | |||
| })() | |||
| : '-'} | |||
| : "-"} | |||
| </Typography> | |||
| <Typography variant="body2"> | |||
| {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"); | |||
| })() | |||
| : '-'} | |||
| : "-"} | |||
| </Typography> | |||
| </Box> | |||
| </TableCell> | |||
| <TableCell align="right" sx={{ minWidth: 100, width: '8%' }}>{row.handlerName ?? 0}</TableCell> | |||
| <TableCell align="right" sx={{ minWidth: 100, width: '8%' }}>{row.numberOfFGItems ?? 0}</TableCell> | |||
| <TableCell>{row.handlerName ?? "-"}</TableCell> | |||
| <TableCell align="right" sx={{ minWidth: 100, width: "8%" }}> | |||
| {row.numberOfFGItems ?? 0} | |||
| </TableCell> | |||
| <TableCell align="center"> | |||
| {showDoPickOpsButtons(row) ? ( | |||
| <Stack direction="row" spacing={1} justifyContent="center" flexWrap="wrap" useFlexGap> | |||
| <Tooltip title={opsTooltip}> | |||
| <span> | |||
| <Button | |||
| size="small" | |||
| variant="outlined" | |||
| color="warning" | |||
| disabled={!canManageDoPickOps} | |||
| onClick={() => void handleRevert(row)} | |||
| > | |||
| {t("Revert assignment")} | |||
| </Button> | |||
| </span> | |||
| </Tooltip> | |||
| <Tooltip title={opsTooltip}> | |||
| <span> | |||
| <Button | |||
| size="small" | |||
| variant="outlined" | |||
| color="primary" | |||
| disabled={!canManageDoPickOps} | |||
| onClick={() => void handleForceComplete(row)} | |||
| > | |||
| {t("Force complete DO")} | |||
| </Button> | |||
| </span> | |||
| </Tooltip> | |||
| </Stack> | |||
| ) : ( | |||
| <Typography variant="caption" color="text.secondary"> | |||
| — | |||
| </Typography> | |||
| )} | |||
| </TableCell> | |||
| </TableRow> | |||
| ); | |||
| }) | |||
| )} | |||
| </TableBody> | |||
| </Table> | |||
| </TableContainer> | |||
| {filteredData.length > 0 && ( | |||
| <TablePagination | |||
| component="div" | |||
| count={filteredData.length} | |||
| page={paginationController.pageNum} | |||
| rowsPerPage={paginationController.pageSize} | |||
| onPageChange={handlePageChange} | |||
| onRowsPerPageChange={handlePageSizeChange} | |||
| rowsPerPageOptions={[5, 10, 15]} | |||
| labelRowsPerPage={t("Rows per page")} | |||
| /> | |||
| )} | |||
| </> | |||
| )} | |||
| </Box> | |||
| </CardContent> | |||
| </Card> | |||
| )) | |||
| )} | |||
| </TableBody> | |||
| </Table> | |||
| </TableContainer> | |||
| {filteredData.length > 0 && ( | |||
| <TablePagination | |||
| component="div" | |||
| count={filteredData.length} | |||
| page={paginationController.pageNum} | |||
| rowsPerPage={paginationController.pageSize} | |||
| onPageChange={handlePageChange} | |||
| onRowsPerPageChange={handlePageSizeChange} | |||
| rowsPerPageOptions={[5, 10, 15]} | |||
| labelRowsPerPage={t("Rows per page")} | |||
| /> | |||
| )} | |||
| </> | |||
| )} | |||
| </Box> | |||
| </CardContent> | |||
| </Card> | |||
| </LocalizationProvider> | |||
| ); | |||
| }; | |||
| export default FGPickOrderTicketReleaseTable; | |||
| export default FGPickOrderTicketReleaseTable; | |||
| @@ -315,8 +315,8 @@ const [selectedPrinterForDraft, setSelectedPrinterForDraft] = useState<PrinterCo | |||
| // ✅ 新增:处理标签页切换时的打印按钮状态重置 | |||
| useEffect(() => { | |||
| // 当切换到标签页 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 ( | |||
| <Tab label={t("Finished Good Detail")} iconPosition="end" /> | |||
| <Tab label={t("Finished Good Record")} iconPosition="end" /> | |||
| <Tab label={t("Ticket Release Table")} iconPosition="end" /> | |||
| <Tab label={t("Finished Good Record (All)")} iconPosition="end" /> | |||
| </Tabs> | |||
| </Box> | |||
| @@ -742,8 +743,27 @@ const handleAssignByLane = useCallback(async ( | |||
| onRefreshReleasedOrderCount={fetchReleasedOrderCount} | |||
| /> | |||
| ) } | |||
| {tabIndex === 2 && <GoodPickExecutionRecord filterArgs={filterArgs} printerCombo={printerCombo} a4Printer={selectedPrinterForAllDraft} labelPrinter={selectedPrinterForDraft} />} | |||
| {tabIndex === 3 && <FGPickOrderTicketReleaseTable/>} | |||
| {tabIndex === 2 && ( | |||
| <GoodPickExecutionRecord | |||
| filterArgs={filterArgs} | |||
| printerCombo={printerCombo} | |||
| a4Printer={selectedPrinterForAllDraft} | |||
| labelPrinter={selectedPrinterForDraft} | |||
| recordTabIndex={2} | |||
| listScope="mine" | |||
| /> | |||
| )} | |||
| {tabIndex === 3 && <FGPickOrderTicketReleaseTable />} | |||
| {tabIndex === 4 && ( | |||
| <GoodPickExecutionRecord | |||
| filterArgs={filterArgs} | |||
| printerCombo={printerCombo} | |||
| a4Printer={selectedPrinterForAllDraft} | |||
| labelPrinter={selectedPrinterForDraft} | |||
| recordTabIndex={4} | |||
| listScope="all" | |||
| /> | |||
| )} | |||
| </Box> | |||
| </Box> | |||
| ); | |||
| @@ -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<Props> = ({ filterArgs, printerCombo, a4Printer, labelPrinter }) => { | |||
| const GoodPickExecutionRecord: React.FC<Props> = ({ | |||
| 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<Props> = ({ filterArgs, printerCombo, a4 | |||
| const [showDetailView, setShowDetailView] = useState(false); | |||
| const [detailLotData, setDetailLotData] = useState<any[]>([]); | |||
| // 新增:搜索状态 | |||
| const [searchQuery, setSearchQuery] = useState<Record<string, any>>({}); | |||
| // 新增:搜索状态(預設為今日) | |||
| const [searchQuery, setSearchQuery] = useState<Record<string, any>>(() => ({ | |||
| targetDate: dayjs().format("YYYY-MM-DD"), | |||
| })); | |||
| const [filteredDoPickOrders, setFilteredDoPickOrders] = useState<CompletedDoPickOrderResponse[]>([]); | |||
| // 新增:分页状态 | |||
| @@ -353,14 +367,17 @@ const GoodPickExecutionRecord: React.FC<Props> = ({ 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<Props> = ({ 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<string, any>) => { | |||
| @@ -389,7 +411,7 @@ const GoodPickExecutionRecord: React.FC<Props> = ({ 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<Props> = ({ 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<Props> = ({ filterArgs, printerCombo, a4 | |||
| return filteredDoPickOrders.slice(startIndex, endIndex); | |||
| }, [filteredDoPickOrders, paginationController]); | |||
| // 搜索条件 | |||
| const searchCriteria: Criterion<any>[] = [ | |||
| { | |||
| 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<any>[] = 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) { | |||
| <Box> | |||
| {/* 结果统计 */} | |||
| <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> | |||
| {t("Completed DO pick orders: ")} {filteredDoPickOrders.length} | |||
| {t("Search date")}: {searchDateDisplay} | {t("Completed DO pick orders: ")}{" "} | |||
| {filteredDoPickOrders.length} | |||
| </Typography> | |||
| {/* 列表 */} | |||
| @@ -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<Omit<JobOrder, "id">>; | |||
| type SearchParamNames = keyof SearchQuery; | |||
| type SearchParamNames = "code" | "itemName" | "planStart" | "planStartTo" | "jobTypeName" | "joSearchStatus"; | |||
| const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobTypes }) => { | |||
| const { t } = useTranslation("jo"); | |||
| @@ -58,6 +57,9 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||
| const [checkboxIds, setCheckboxIds] = useState<(string | number)[]>([]); | |||
| const [releasingJoIds, setReleasingJoIds] = useState<Set<number>>(new Set()); | |||
| const [isBatchReleasing, setIsBatchReleasing] = useState(false); | |||
| const [cancelConfirmJoId, setCancelConfirmJoId] = useState<number | null>(null); | |||
| const [cancelSubmitting, setCancelSubmitting] = useState(false); | |||
| const [cancelingJoIds, setCancelingJoIds] = useState<Set<number>>(new Set()); | |||
| // 合并后的统一编辑 Dialog 状态 | |||
| const [openEditDialog, setOpenEditDialog] = useState(false); | |||
| @@ -160,6 +162,19 @@ const JoSearch: React.FC<Props> = ({ 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<BomCombo | null> => { | |||
| @@ -288,6 +303,29 @@ const JoSearch: React.FC<Props> = ({ 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<Props> = ({ 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 ( | |||
| <Stack direction="row" spacing={1} alignItems="center"> | |||
| <Button | |||
| @@ -458,6 +498,7 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||
| > | |||
| {t("View")} | |||
| </Button> | |||
| {isPlanning ? ( | |||
| <Button | |||
| type="button" | |||
| variant="contained" | |||
| @@ -472,11 +513,27 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||
| > | |||
| {t("Release")} | |||
| </Button> | |||
| ) : ( | |||
| <Button | |||
| type="button" | |||
| variant="contained" | |||
| color="warning" | |||
| disabled={isPutAwayed || isCancelingRow || isBatchReleasing || cancelSubmitting} | |||
| sx={{ minWidth: 120 }} | |||
| onClick={(e) => { | |||
| e.stopPropagation(); | |||
| setCancelConfirmJoId(row.id); | |||
| }} | |||
| startIcon={isCancelingRow ? <CircularProgress size={16} color="inherit" /> : undefined} | |||
| > | |||
| {t("Cancel Job Order")} | |||
| </Button> | |||
| )} | |||
| </Stack> | |||
| ) | |||
| } | |||
| }, | |||
| ], [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<Props> = ({ 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<Props> = ({ 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<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||
| </Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| <Dialog | |||
| open={cancelConfirmJoId !== null} | |||
| onClose={() => !cancelSubmitting && setCancelConfirmJoId(null)} | |||
| maxWidth="xs" | |||
| fullWidth | |||
| > | |||
| <DialogTitle>{t("Confirm cancel job order")}</DialogTitle> | |||
| <DialogContent> | |||
| <Typography variant="body2">{t("Cancel job order confirm message")}</Typography> | |||
| </DialogContent> | |||
| <DialogActions> | |||
| <Button onClick={() => setCancelConfirmJoId(null)} disabled={cancelSubmitting}>{t("Cancel")}</Button> | |||
| <Button variant="contained" color="warning" onClick={() => void handleConfirmCancelJobOrder()} disabled={cancelSubmitting}> | |||
| {cancelSubmitting ? <CircularProgress size={20} /> : t("Cancel Job Order")} | |||
| </Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| </> | |||
| } | |||
| @@ -18,6 +18,7 @@ const JoSearchWrapper: React.FC & SubComponents = async () => { | |||
| itemName: "", | |||
| planStart: `${todayStr}T00:00`, | |||
| planStartTo: `${todayStr}T23:59:59`, | |||
| joSearchStatus: "all", | |||
| } | |||
| @@ -416,14 +416,17 @@ const PoDetail: React.FC<Props> = ({ 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); | |||
| @@ -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); | |||
| @@ -395,7 +395,7 @@ const PutAwayForm: React.FC<Props> = ({ itemDetail, warehouse=[], disabled, sugg | |||
| <TextField | |||
| label={t("acceptedPutawayQty")} // TODO: fix it back to acceptedQty after db is fixed | |||
| fullWidth | |||
| value={itemDetail.acceptedQty ?? itemDetail.demandQty} | |||
| value={itemDetail.qty ?? itemDetail.purchaseAcceptedQty ?? itemDetail.acceptedQty ?? itemDetail.demandQty} | |||
| disabled | |||
| /> | |||
| </Grid> | |||
| @@ -403,7 +403,7 @@ const PutAwayForm: React.FC<Props> = ({ itemDetail, warehouse=[], disabled, sugg | |||
| <TextField | |||
| label={t("uom")} | |||
| fullWidth | |||
| value={itemDetail.uom?.udfudesc} | |||
| value={itemDetail.purchaseUomDesc ?? itemDetail.uom?.udfudesc} | |||
| disabled | |||
| /> | |||
| </Grid> | |||
| @@ -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<Props> = ({ | |||
| }; | |||
| setAutoSyncStatus(null); | |||
| const res = await fetchPoListClient(params); | |||
| const cleanedQuery: Record<string, string> = {}; | |||
| 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<Props> = ({ | |||
| if (syncOk) { | |||
| setAutoSyncStatus("成功找到PO"); | |||
| // Re-fetch /po/list directly from client to avoid cached server action results. | |||
| const cleanedQuery: Record<string, string> = {}; | |||
| 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, | |||
| @@ -22,7 +22,14 @@ const OverallTimeRemainingCard: React.FC<OverallTimeRemainingCardProps> = ({ | |||
| 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<OverallTimeRemainingCardProps> = ({ | |||
| 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; | |||
| @@ -110,6 +110,11 @@ const fetchProcessDetailRef = useRef<() => Promise<void>>(); | |||
| postProdTimeInMinutes: 0, | |||
| }); | |||
| // Pass confirmation dialog (avoid accidental Pass) | |||
| const [passConfirmOpen, setPassConfirmOpen] = useState(false); | |||
| const [passConfirmLineId, setPassConfirmLineId] = useState<number | null>(null); | |||
| const [passConfirmLoading, setPassConfirmLoading] = useState(false); | |||
| const [outputData, setOutputData] = useState({ | |||
| byproductName: "", | |||
| byproductQty: "", | |||
| @@ -257,6 +262,29 @@ const fetchProcessDetailRef = useRef<() => Promise<void>>(); | |||
| 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) => { | |||
| </Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| <Dialog | |||
| open={passConfirmOpen} | |||
| onClose={closePassConfirm} | |||
| maxWidth="xs" | |||
| fullWidth | |||
| > | |||
| <DialogTitle>{t("Confirm")}</DialogTitle> | |||
| <DialogContent> | |||
| <Typography variant="body2">{t("Confirm to Pass this Process?")}</Typography> | |||
| </DialogContent> | |||
| <DialogActions> | |||
| <Button onClick={closePassConfirm} disabled={passConfirmLoading}> | |||
| {t("Cancel")} | |||
| </Button> | |||
| <Button | |||
| variant="contained" | |||
| onClick={confirmPassLine} | |||
| disabled={passConfirmLoading || passConfirmLineId == null} | |||
| > | |||
| {passConfirmLoading ? t("Processing...") : t("Confirm")} | |||
| </Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| </Box> | |||
| ); | |||
| }; | |||
| @@ -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<ProductProcessJobOrderDetailProps> = ({ | |||
| jobOrderId, | |||
| onBack, | |||
| fromJosave, | |||
| initialTabIndex = 0, | |||
| }) => { | |||
| const { t } = useTranslation(); | |||
| const [loading, setLoading] = useState(false); | |||
| const [processData, setProcessData] = useState<any>(null); | |||
| const [jobOrderLines, setJobOrderLines] = useState<JobOrderLineInfo[]>([]); | |||
| const [inventoryData, setInventoryData] = useState<InventoryResult[]>([]); | |||
| const [tabIndex, setTabIndex] = useState(0); | |||
| const [tabIndex, setTabIndex] = useState(initialTabIndex); | |||
| const [selectedProcessId, setSelectedProcessId] = useState<number | null>(null); | |||
| const [operationPriority, setOperationPriority] = useState<number>(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) => { | |||
| <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}> | |||
| {t("Lines with insufficient stock: ")}<strong style={{ color: "red" }}>{stockCounts.insufficient}</strong> | |||
| </Typography> | |||
| {fromJosave && ( | |||
| {fromJosave && jobOrderPlanning && ( | |||
| <Button | |||
| variant="contained" | |||
| color="error" | |||
| onClick={() => handleDeleteJobOrder(jobOrderId)} | |||
| disabled={processData?.jobOrderStatus !== "planning"} | |||
| > | |||
| {t("Delete Job Order")} | |||
| </Button> | |||
| variant="contained" | |||
| color="error" | |||
| onClick={() => setDeleteConfirmOpen(true)} | |||
| > | |||
| {t("Delete Job Order")} | |||
| </Button> | |||
| )} | |||
| {fromJosave && !jobOrderPlanning && ( | |||
| <Button | |||
| variant="contained" | |||
| color="warning" | |||
| onClick={() => setCancelConfirmOpen(true)} | |||
| disabled={isPutAwayed} | |||
| > | |||
| {t("Cancel Job Order")} | |||
| </Button> | |||
| )} | |||
| {fromJosave && ( | |||
| <Button | |||
| @@ -779,7 +817,7 @@ const handleRelease = useCallback(async ( jobOrderId: number) => { | |||
| setTabIndex(0); | |||
| }} | |||
| fromJosave={fromJosave} | |||
| fromJosave={Boolean(fromJosave && !isPutAwayed)} | |||
| /> | |||
| )} | |||
| {tabIndex === 3 && <ProductionProcessesLineRemarkTableContent />} | |||
| @@ -926,6 +964,32 @@ const handleRelease = useCallback(async ( jobOrderId: number) => { | |||
| </DialogActions> | |||
| </Dialog> | |||
| <Dialog open={deleteConfirmOpen} onClose={() => !deleteLoading && setDeleteConfirmOpen(false)} maxWidth="xs" fullWidth> | |||
| <DialogTitle>{t("Confirm delete job order")}</DialogTitle> | |||
| <DialogContent> | |||
| <Typography variant="body2">{t("Delete job order confirm message")}</Typography> | |||
| </DialogContent> | |||
| <DialogActions> | |||
| <Button onClick={() => setDeleteConfirmOpen(false)} disabled={deleteLoading}>{t("Cancel")}</Button> | |||
| <Button variant="contained" color="error" onClick={() => void handleConfirmDeleteJobOrder()} disabled={deleteLoading}> | |||
| {deleteLoading ? <CircularProgress size={20} /> : t("Delete Job Order")} | |||
| </Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| <Dialog open={cancelConfirmOpen} onClose={() => !cancelLoading && setCancelConfirmOpen(false)} maxWidth="xs" fullWidth> | |||
| <DialogTitle>{t("Confirm cancel job order")}</DialogTitle> | |||
| <DialogContent> | |||
| <Typography variant="body2">{t("Cancel job order confirm message")}</Typography> | |||
| </DialogContent> | |||
| <DialogActions> | |||
| <Button onClick={() => setCancelConfirmOpen(false)} disabled={cancelLoading}>{t("Cancel")}</Button> | |||
| <Button variant="contained" color="warning" onClick={() => void handleConfirmCancelJobOrder()} disabled={cancelLoading}> | |||
| {cancelLoading ? <CircularProgress size={20} /> : t("Cancel Job Order")} | |||
| </Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| </Box> | |||
| </Box> | |||
| @@ -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<ProductionProcessListPersistedState> | |||
| >; | |||
| } | |||
| 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<ProductProcessListProps> = ({ onSelectProcess, printerCombo ,onSelectMatchingStock}) => { | |||
| const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| 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<AllJoborderProductProcessInfoResponse[]>([]); | |||
| const [page, setPage] = useState(0); | |||
| const [openModal, setOpenModal] = useState<boolean>(false); | |||
| const [modalInfo, setModalInfo] = useState<StockInLineInput>(); | |||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | |||
| type ProcessFilter = "all" | "drink" | "other"; | |||
| const [filter, setFilter] = useState<ProcessFilter>("all"); | |||
| const [suggestedLocationCode, setSuggestedLocationCode] = useState<string | null>(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<null | (() => Promise<void>)>(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<number, AllJoborderProductProcessInfoResponse[]>(); | |||
| 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<number, boolean>(); | |||
| 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<ProductProcessListProps> = ({ onSelectProcess | |||
| setOpenModal(true); | |||
| }, [t]); | |||
| const handleApplySearch = useCallback( | |||
| (inputs: Record<SearchParam | `${SearchParam}To`, string>) => { | |||
| 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<ProductProcessListProps> = ({ onSelectProcess | |||
| setLoading(false); | |||
| } | |||
| }, [t, fetchProcesses]); | |||
| const openConfirm = useCallback((message: string, action: () => Promise<void>) => { | |||
| 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<ProductProcessListProps> = ({ 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<SearchParam>[] = 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<string[]>) => { | |||
| const nextValue = e.target.value; | |||
| const codes = typeof nextValue === "string" ? nextValue.split(",") : nextValue; | |||
| onListPersistedStateChange((prev) => ({ ...prev, selectedItemCodes: codes })); | |||
| }, | |||
| [onListPersistedStateChange], | |||
| ); | |||
| return ( | |||
| <Box> | |||
| @@ -180,37 +419,48 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess | |||
| </Box> | |||
| ) : ( | |||
| <Box> | |||
| <Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap', mb: 2 }}> | |||
| <Button | |||
| variant={filter === 'all' ? 'contained' : 'outlined'} | |||
| size="small" | |||
| onClick={() => setFilter('all')} | |||
| > | |||
| {t("All")} | |||
| </Button> | |||
| <Button | |||
| variant={filter === 'drink' ? 'contained' : 'outlined'} | |||
| size="small" | |||
| onClick={() => setFilter('drink')} | |||
| > | |||
| {t("Drink")} | |||
| </Button> | |||
| <Button | |||
| variant={filter === 'other' ? 'contained' : 'outlined'} | |||
| size="small" | |||
| onClick={() => setFilter('other')} | |||
| > | |||
| {t("Other")} | |||
| </Button> | |||
| </Box> | |||
| <SearchBox<SearchParam> | |||
| key={searchBoxKey} | |||
| criteria={searchCriteria} | |||
| onSearch={handleApplySearch} | |||
| onReset={handleResetSearch} | |||
| extraActions={ | |||
| <FormControl size="small" sx={{ minWidth: 260 }}> | |||
| <InputLabel>{t("Searched Item")}</InputLabel> | |||
| <Select | |||
| multiple | |||
| value={selectedItemCodes} | |||
| label={t("Item Code")} | |||
| renderValue={(selected) => | |||
| (selected as string[]).length === 0 ? t("All") : (selected as string[]).join(", ") | |||
| } | |||
| onChange={handleSelectedItemCodesChange} | |||
| > | |||
| {searchedItemOptions.map((item) => ( | |||
| <MenuItem key={item.itemCode} value={item.itemCode}> | |||
| <Checkbox checked={selectedItemCodes.includes(item.itemCode)} /> | |||
| <ListItemText primary={[item.itemCode, item.itemName].filter(Boolean).join(" - ")} /> | |||
| </MenuItem> | |||
| ))} | |||
| </Select> | |||
| </FormControl> | |||
| } | |||
| /> | |||
| <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> | |||
| {t("Total processes")}: {processes.length} | |||
| </Typography> | |||
| {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}` : ""} | |||
| </Typography> | |||
| <Grid container spacing={2}> | |||
| {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<ProductProcessListProps> = ({ 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 ( | |||
| <Grid key={process.id} item xs={12} sm={6} md={4}> | |||
| <Card | |||
| @@ -264,7 +519,7 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess | |||
| </Typography> | |||
| </Box> | |||
| <Chip size="small" label={t(status)} color={statusColor as any} /> | |||
| <Chip size="small" label={t(displayStatus)} color={statusColor as any} /> | |||
| </Stack> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("Lot No")}: {process.lotNo ?? "-"} | |||
| @@ -330,13 +585,26 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess | |||
| > | |||
| {t("Matching Stock")} | |||
| </Button> | |||
| {statusLower !== "completed" && ( | |||
| <Button variant="contained" size="small" onClick={() => handleUpdateJo(process)}> | |||
| <Button | |||
| variant="contained" | |||
| size="small" | |||
| onClick={() => | |||
| openConfirm( | |||
| t("Confirm to update this Job Order?"), | |||
| async () => { | |||
| await handleUpdateJo(process); | |||
| } | |||
| ) | |||
| } | |||
| > | |||
| {t("Update Job Order")} | |||
| </Button> | |||
| )} | |||
| {statusLower === "completed" && ( | |||
| <Button variant="contained" size="small" onClick={() => handleViewStockIn(process)}> | |||
| {canQc && ( | |||
| <Button variant="contained" size="small" onClick={() => handleViewStockIn(process)}> | |||
| {t("view stockin")} | |||
| </Button> | |||
| )} | |||
| @@ -358,14 +626,34 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess | |||
| printSource="productionProcess" | |||
| uiMode="default" | |||
| /> | |||
| {processes.length > 0 && ( | |||
| <Dialog open={confirmOpen} onClose={closeConfirm} maxWidth="xs" fullWidth> | |||
| <DialogTitle>{t("Confirm")}</DialogTitle> | |||
| <DialogContent> | |||
| <Typography variant="body2">{confirmMessage}</Typography> | |||
| </DialogContent> | |||
| <DialogActions> | |||
| <Button onClick={closeConfirm} disabled={confirmLoading}> | |||
| {t("Cancel")} | |||
| </Button> | |||
| <Button | |||
| variant="contained" | |||
| onClick={onConfirm} | |||
| disabled={confirmLoading || !pendingConfirmAction} | |||
| > | |||
| {confirmLoading ? t("Processing...") : t("Confirm")} | |||
| </Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| {totalJobOrders > 0 && ( | |||
| <TablePagination | |||
| component="div" | |||
| count={processes.length} | |||
| count={totalJobOrders} | |||
| page={page} | |||
| rowsPerPage={PER_PAGE} | |||
| onPageChange={(e, p) => setPage(p)} | |||
| rowsPerPageOptions={[PER_PAGE]} | |||
| rowsPerPage={PAGE_SIZE} | |||
| onPageChange={(e, p) => | |||
| onListPersistedStateChange((prev) => ({ ...prev, page: p })) | |||
| } | |||
| rowsPerPageOptions={[PAGE_SIZE]} | |||
| /> | |||
| )} | |||
| </Box> | |||
| @@ -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<ProductionProcessPageProps> = ({ 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<ProductionProcessPageProps> = ({ printerCo | |||
| return ( | |||
| <ProductionProcessJobOrderDetail | |||
| jobOrderId={selectedProcessId} | |||
| initialTabIndex={2} | |||
| onBack={() => setSelectedProcessId(null)} | |||
| /> | |||
| ); | |||
| @@ -179,6 +188,9 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||
| {tabIndex === 0 && ( | |||
| <ProductionProcessList | |||
| printerCombo={printerCombo} | |||
| qcReady={false} | |||
| listPersistedState={productionListState} | |||
| onListPersistedStateChange={setProductionListState} | |||
| onSelectProcess={(jobOrderId) => { | |||
| const id = jobOrderId ?? null; | |||
| if (id !== null) { | |||
| @@ -196,9 +208,24 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||
| )} | |||
| {tabIndex === 1 && ( | |||
| <FinishedQcJobOrderList | |||
| <ProductionProcessList | |||
| printerCombo={printerCombo} | |||
| selectedPrinter={selectedPrinter} | |||
| qcReady={true} | |||
| listPersistedState={finishedQcListState} | |||
| onListPersistedStateChange={setFinishedQcListState} | |||
| onSelectProcess={(jobOrderId) => { | |||
| 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 && ( | |||
| @@ -40,6 +40,8 @@ interface BaseCriterion<T extends string> { | |||
| 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<T extends string>({ | |||
| 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<T extends string>({ | |||
| {} as Record<T | `${T}To`, string>, | |||
| ); | |||
| return {...defaultInputs, ...preFilledCriteria} | |||
| }, [defaultInputs]) | |||
| }, [defaultInputs, criteria]) | |||
| const [inputs, setInputs] = useState(preFilledInputs); | |||
| const [isReset, setIsReset] = useState(false); | |||
| @@ -272,7 +280,7 @@ function SearchBox<T extends string>({ | |||
| }, []); | |||
| const handleReset = () => { | |||
| setInputs(defaultInputs); | |||
| setInputs(preFilledInputs); | |||
| onReset?.(); | |||
| setIsReset(!isReset); | |||
| }; | |||
| @@ -553,6 +561,11 @@ function SearchBox<T extends string>({ | |||
| label={t(c.label)} | |||
| onChange={makeDateChangeHandler(c.paramName)} | |||
| disabled={disabled} | |||
| value={ | |||
| inputs[c.paramName] && dayjs(inputs[c.paramName]).isValid() | |||
| ? dayjs(inputs[c.paramName]) | |||
| : null | |||
| } | |||
| /> | |||
| </FormControl> | |||
| </Box> | |||
| @@ -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)": "收尾時間", | |||
| @@ -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": "已開始工序", | |||
| @@ -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", | |||
| @@ -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": "取消" | |||
| } | |||