Selaa lähdekoodia

Merge remote-tracking branch 'origin/MergeProblem1' into MergeProblem1

MergeProblem1
B.E.N.S.O.N 17 tuntia sitten
vanhempi
commit
6250eb2a4a
32 muutettua tiedostoa jossa 1880 lisäystä ja 487 poistoa
  1. +2
    -1
      package.json
  2. +47
    -0
      src/app/(main)/axios/AxiosProvider.tsx
  3. +161
    -0
      src/app/(main)/chart/_components/EXCEL_EXPORT_STANDARD.md
  4. +147
    -11
      src/app/(main)/chart/_components/exportChartToXlsx.ts
  5. +2
    -2
      src/app/(main)/ps/page.tsx
  6. +13
    -2
      src/app/(main)/report/GRN_REPORT_BACKEND_SPEC.md
  7. +153
    -17
      src/app/(main)/report/grnReportApi.ts
  8. +12
    -1
      src/app/(main)/report/page.tsx
  9. +2
    -0
      src/app/api/do/actions.tsx
  10. +64
    -0
      src/app/api/jo/actions.ts
  11. +56
    -1
      src/app/api/pickOrder/actions.ts
  12. +41
    -0
      src/app/utils/authToken.ts
  13. +2
    -2
      src/app/utils/clientAuthFetch.ts
  14. +380
    -300
      src/components/FinishedGoodSearch/FGPickOrderTicketReleaseTable.tsx
  15. +24
    -4
      src/components/FinishedGoodSearch/FinishedGoodSearch.tsx
  16. +81
    -39
      src/components/FinishedGoodSearch/GoodPickExecutionRecord.tsx
  17. +83
    -6
      src/components/JoSearch/JoSearch.tsx
  18. +1
    -0
      src/components/JoSearch/JoSearchWrapper.tsx
  19. +5
    -2
      src/components/PoDetail/PoDetail.tsx
  20. +16
    -8
      src/components/PoDetail/PoInputGrid.tsx
  21. +2
    -2
      src/components/PoDetail/PutAwayForm.tsx
  22. +15
    -10
      src/components/PoSearch/PoSearch.tsx
  23. +9
    -2
      src/components/ProductionProcess/OverallTimeRemainingCard.tsx
  24. +55
    -3
      src/components/ProductionProcess/ProductionProcessDetail.tsx
  25. +82
    -18
      src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx
  26. +336
    -48
      src/components/ProductionProcess/ProductionProcessList.tsx
  27. +31
    -4
      src/components/ProductionProcess/ProductionProcessPage.tsx
  28. +16
    -3
      src/components/SearchBox/SearchBox.tsx
  29. +17
    -0
      src/i18n/zh/common.json
  30. +8
    -0
      src/i18n/zh/jo.json
  31. +3
    -0
      src/i18n/zh/pickOrder.json
  32. +14
    -1
      src/i18n/zh/ticketReleaseTable.json

+ 2
- 1
package.json Näytä tiedosto

@@ -66,7 +66,8 @@
"reactstrap": "^9.2.2",
"styled-components": "^6.1.8",
"sweetalert2": "^11.10.3",
"xlsx": "^0.18.5"
"xlsx": "^0.18.5",
"xlsx-js-style": "^1.2.0"
},
"devDependencies": {
"@types/lodash": "^4.14.202",


+ 47
- 0
src/app/(main)/axios/AxiosProvider.tsx Näytä tiedosto

@@ -3,6 +3,12 @@
"use client";

import React, { createContext, useContext, useEffect, useState, useCallback } from "react";
import { getSession } from "next-auth/react";
import { SessionWithTokens } from "@/config/authConfig";
import {
isBackendJwtExpired,
LOGIN_SESSION_EXPIRED_HREF,
} from "@/app/utils/authToken";
import axiosInstance, { SetupAxiosInterceptors } from "./axiosInstance";

const AxiosContext = createContext(axiosInstance);
@@ -29,6 +35,47 @@ export const AxiosProvider: React.FC<{ children: React.ReactNode }> = ({ childre
}
}, []);

/**
* Detect expired/missing backend JWT before user actions (e.g. /report search).
* Sync accessToken from next-auth session into localStorage if missing, then
* redirect to login when the Bearer token is absent or past `exp`.
*/
useEffect(() => {
if (!isHydrated || typeof window === "undefined") return;

let cancelled = false;
(async () => {
try {
let token = localStorage.getItem("accessToken")?.trim() ?? "";

if (!token) {
const session = (await getSession()) as SessionWithTokens | null;
if (cancelled) return;
if (session?.accessToken) {
token = session.accessToken;
localStorage.setItem("accessToken", token);
setAccessToken(token);
}
}

if (!token) {
window.location.href = LOGIN_SESSION_EXPIRED_HREF;
return;
}

if (isBackendJwtExpired(token)) {
window.location.href = LOGIN_SESSION_EXPIRED_HREF;
}
} catch (e) {
console.warn("Auth token check failed", e);
}
})();

return () => {
cancelled = true;
};
}, [isHydrated]);

// Apply token + interceptors
useEffect(() => {
if (accessToken) {


+ 161
- 0
src/app/(main)/chart/_components/EXCEL_EXPORT_STANDARD.md Näytä tiedosto

@@ -0,0 +1,161 @@
# Excel export standard (FPSMS frontend)

This document defines how **client-side** `.xlsx` exports should look and behave. **Implementation:** `exportChartToXlsx.ts` and `exportMultiSheetToXlsx()` — use these helpers for new reports so styling stays consistent.

## Scope (important)

| Export path | Follows this `.md`? |
|-------------|---------------------|
| **Next.js** builds the file via `exportChartToXlsx` / `exportMultiSheetToXlsx` (e.g. **PO 入倉記錄** / `rep-014`) | **Yes** — rules are enforced in code. |
| **Backend** returns ready-made `.xlsx` or Excel bytes (JasperReports, Apache POI, etc.; most `print-*` report endpoints) | **No — not automatically.** That code does **not** use this TypeScript module. To match the same **look** (grey headers, number formats, alignment), implement equivalent styling in Java/Kotlin or Jasper templates. See the backend companion doc below. |

**Backend companion (visual parity):**
`FPSMS-backend/docs/EXCEL_EXPORT_STANDARD.md` — same *rules*, for POI/Jasper implementers.

---

## 1. Library

| Item | Value |
|------|--------|
| Package | **`xlsx-js-style`** (not the plain `xlsx` community build) |
| Reason | Plain SheetJS **does not persist** cell styles (`fill`, `alignment`, `numFmt`) in the written file. `xlsx-js-style` is a compatible fork that **does**. |

---

## 2. Data shape

- Rows are **`Record<string, unknown>[]`** (array of plain objects).
- **First object’s keys** become the **header row** (column titles). Every row should use the **same keys** in the same order for a rectangular sheet.
- Prefer **real JavaScript `number`** values for amounts where possible; the exporter will apply number formats. Strings that look like numbers (e.g. `"1,234.56"`) are parsed for money columns.

---

## 3. Processing order (per sheet)

After `json_to_sheet(rows)`:

1. **`!cols`** — column width heuristic (see §4).
2. **`applyHeaderRowStyle`** — header row styling (see §5).
3. **`applyMoneyColumnNumberFormats`** — money columns only, data rows (see §6).
4. **`applyNumericColumnRightAlign`** — money + quantity columns, **all rows including header** (see §7).

---

## 4. Column width (`!cols`)

- For each column: `wch = max(12, headerText.length + 4)`.
- Adjust if a report needs fixed widths; default keeps bilingual headers readable.

---

## 5. Header row style (row 0)

Applied to **every** header cell first; numeric columns get alignment overridden in step 4.

| Property | Value |
|----------|--------|
| Font | Bold, black `rgb: "000000"` |
| Fill | Solid, `fgColor: "D9D9D9"` (light grey) |
| Alignment (default) | Horizontal **center**, vertical **center**, `wrapText: true` |

---

## 6. Money / amount columns — number format

**Detection:** header label matches (case-insensitive):

```text
金額 | 單價 | Amount | Unit Price | Total Amount
```

(Also matches bilingual headers that contain these fragments, e.g. `Amount / 金額`, `Unit Price / 單價`, `Total Amount / 金額`.)

**Rules:**

- Applies to **data rows only** (not row 0).
- Excel format string: **`#,##0.00`** (thousands separator + 2 decimals). Stored on the cell as `z`.
- Cell type `t: "n"` for numeric values.
- If the cell is a **string**, commas are stripped and the value is parsed to a number when possible.

**Naming new reports:** use header text that matches the patterns above so columns pick up formatting automatically.

---

## 7. Quantity columns — alignment only

**Detection:** header label matches:

```text
Qty | 數量 | Demand
```

(Covers e.g. `Qty / 數量`, `Demand Qty / 訂單數量`.)

- No default thousands format (quantities may have up to 4 decimals in app code).
- These columns are **right-aligned** together with money columns (see §8).

---

## 8. Alignment — numeric columns (header + data)

**Detection:** union of **money** (§6) and **quantity** (§7) header patterns.

| Alignment | Value |
|-----------|--------|
| Horizontal | **`right`** |
| Vertical | **`center`** |
| `wrapText` | Preserved / defaulted to `true` where applicable |

Existing style objects are **merged** (fill, font from header styling are kept).

---

## 9. Multi-sheet workbook

| Rule | Detail |
|------|--------|
| API | `exportMultiSheetToXlsx({ name, rows }[], filename)` |
| Sheet name length | Truncated to **31** characters (Excel limit) |
| Each sheet | Same pipeline as §3 if `rows.length > 0` |

---

## 10. Empty sheets

If `rows.length === 0`, a minimal sheet is written; no header styling pipeline runs.

---

## 11. Reports using this standard today

| Feature | Location |
|---------|----------|
| Chart export | Uses `exportChartToXlsx` |
| GRN / PO 入倉記錄 (`rep-014`) | `src/app/(main)/report/grnReportApi.ts` — builds row objects, calls `exportChartToXlsx` / `exportMultiSheetToXlsx` |

Most other reports on `/report` download Excel/PDF **generated on the server** (Jasper, etc.). Those **do not** run this TypeScript pipeline; use **`FPSMS-backend/docs/EXCEL_EXPORT_STANDARD.md`** if you want the same visual rules there.

---

## 12. Checklist for new Excel exports

1. Import from **`xlsx-js-style`** only if building sheets manually; otherwise call **`exportChartToXlsx`** or **`exportMultiSheetToXlsx`**.
2. Use **stable header strings** that match §6 / §7 if the column is amount or quantity.
3. Pass **numbers** for amounts when possible.
4. If you need a **new** category (e.g. “Rate”, “折扣”), extend the regex constants in `exportChartToXlsx.ts` and **update this document**.
5. Keep filenames and sheet names user-readable; remember the **31-character** sheet limit.

---

## 13. Related files

| File | Role |
|------|------|
| `exportChartToXlsx.ts` | Single-sheet export + styling pipeline |
| `grnReportApi.ts` | Example: bilingual headers, money values, multi-sheet GRN report |
| `FPSMS-backend/docs/EXCEL_EXPORT_STANDARD.md` | Backend Excel (POI/Jasper) — same *rules*, separate code |

---

*Last aligned with implementation in `exportChartToXlsx.ts` (header fill `#D9D9D9`, money format `#,##0.00`, right-align numeric columns).*

+ 147
- 11
src/app/(main)/chart/_components/exportChartToXlsx.ts Näytä tiedosto

@@ -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`);
}

+ 2
- 2
src/app/(main)/ps/page.tsx Näytä tiedosto

@@ -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,


+ 13
- 2
src/app/(main)/report/GRN_REPORT_BACKEND_SPEC.md Näytä tiedosto

@@ -33,8 +33,11 @@ The frontend **GRN/入倉明細報告** report calls the following endpoint. The
"stockUomDesc": "KG",
"productLotNo": "LOT-001",
"expiryDate": "2026-03-01",
"supplierCode": "P06",
"supplier": "Supplier Name",
"status": "completed"
"status": "completed",
"grnCode": "PPP004GRN26030298",
"grnId": 7854617
}
]
}
@@ -56,4 +59,12 @@ Or a direct array:
- `itemCode` when provided.
- Return one row per GRN line with at least: **PO/delivery note no.**, **itemCode**, **itemName**, **qty** (e.g. `acceptedQty`), **uom**, and optionally receipt date, lot, expiry, supplier, status.

Frontend builds the Excel from this JSON and downloads it with columns: PO No., Delivery Note No., Receipt Date, Item Code, Item Name, Qty, Demand Qty, UOM, Product Lot No., Expiry Date, Supplier, Status.
Frontend builds the Excel from this JSON. Columns include: PO No., Delivery Note No., Receipt Date, Item Code, Item Name, Qty, Demand Qty, UOM, Supplier Lot No. 供應商批次, Expiry Date, Supplier Code, Supplier, 入倉狀態, **GRN Code** (`m18_goods_receipt_note_log.grn_code`), **GRN Id** (`m18_record_id`).

## Frontend Excel styling (shared standard)

Header colours, number formats (`#,##0.00` for amounts), and column alignment are defined in:

**[`../chart/_components/EXCEL_EXPORT_STANDARD.md`](../chart/_components/EXCEL_EXPORT_STANDARD.md)**

Use that document when adding or changing Excel exports so formatting stays consistent.

+ 153
- 17
src/app/(main)/report/grnReportApi.ts Näytä tiedosto

@@ -2,7 +2,10 @@

import { NEXT_PUBLIC_API_URL } from "@/config/api";
import { clientAuthFetch } from "@/app/utils/clientAuthFetch";
import { exportChartToXlsx } from "@/app/(main)/chart/_components/exportChartToXlsx";
import {
exportChartToXlsx,
exportMultiSheetToXlsx,
} from "@/app/(main)/chart/_components/exportChartToXlsx";

export interface GrnReportRow {
poCode?: string;
@@ -21,12 +24,38 @@ export interface GrnReportRow {
supplierCode?: string;
supplier?: string;
status?: string;
grnId?: number | string;
/** PO line unit price (purchase_order_line.up) */
unitPrice?: number;
/** unitPrice × acceptedQty */
lineAmount?: number;
/** PO currency code (currency.code) */
currencyCode?: string;
/** M18 AN document code from m18_goods_receipt_note_log.grn_code */
grnCode?: string;
/** M18 record id (m18_record_id) */
grnId?: number | string;
[key: string]: unknown;
}

/** Sheet "已上架PO金額": totals grouped by receipt date + currency / PO (ADMIN-only data from API). */
export interface ListedPoAmounts {
currencyTotals: {
receiptDate?: string;
currencyCode?: string;
totalAmount?: number;
}[];
byPurchaseOrder: {
receiptDate?: string;
poCode?: string;
currencyCode?: string;
totalAmount?: number;
grnCodes?: string;
}[];
}

export interface GrnReportResponse {
rows: GrnReportRow[];
listedPoAmounts?: ListedPoAmounts;
}

/**
@@ -35,7 +64,7 @@ export interface GrnReportResponse {
*/
export async function fetchGrnReportData(
criteria: Record<string, string>
): Promise<GrnReportRow[]> {
): Promise<{ rows: GrnReportRow[]; listedPoAmounts?: ListedPoAmounts }> {
const queryParams = new URLSearchParams(criteria).toString();
const url = `${NEXT_PUBLIC_API_URL}/report/grn-report?${queryParams}`;

@@ -50,39 +79,134 @@ export async function fetchGrnReportData(
throw new Error(`HTTP error! status: ${response.status}`);

const data = (await response.json()) as GrnReportResponse | GrnReportRow[];
const rows = Array.isArray(data) ? data : (data as GrnReportResponse).rows ?? [];
return rows;
if (Array.isArray(data)) {
return { rows: data };
}
const body = data as GrnReportResponse;
return {
rows: body.rows ?? [],
listedPoAmounts: body.listedPoAmounts,
};
}

/** Coerce API JSON (number or numeric string) to a finite number. */
function coerceToFiniteNumber(value: unknown): number | null {
if (value === null || value === undefined) return null;
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") {
const t = value.trim();
if (t === "") return null;
const n = Number(t);
return Number.isFinite(n) ? n : null;
}
return null;
}

/**
* Cell value for money columns: numeric when possible so Excel export can apply `#,##0.00` (see exportChartToXlsx).
*/
function moneyCellValue(v: unknown): number | string {
const n = coerceToFiniteNumber(v);
if (n === null) return "";
return n;
}

/** Thousands separator for quantities (up to 4 decimal places, trims trailing zeros). */
const formatQty = (n: number | undefined | null): string => {
if (n === undefined || n === null || Number.isNaN(Number(n))) return "";
return new Intl.NumberFormat("en-US", {
minimumFractionDigits: 0,
maximumFractionDigits: 4,
}).format(Number(n));
};

/** Excel column headers (bilingual) for GRN report */
function toExcelRow(r: GrnReportRow): Record<string, string | number | undefined> {
return {
function toExcelRow(
r: GrnReportRow,
includeFinancialColumns: boolean
): Record<string, string | number | undefined> {
const base: Record<string, string | number | undefined> = {
"PO No. / 訂單編號": r.poCode ?? "",
"Supplier Code / 供應商編號": r.supplierCode ?? "",
"Delivery Note No. / 送貨單編號": r.deliveryNoteNo ?? "",
"Receipt Date / 收貨日期": r.receiptDate ?? "",
"Item Code / 物料編號": r.itemCode ?? "",
"Item Name / 物料名稱": r.itemName ?? "",
"Qty / 數量": r.acceptedQty ?? r.receivedQty ?? "",
"Demand Qty / 訂單數量": r.demandQty ?? "",
"Qty / 數量": formatQty(
r.acceptedQty ?? r.receivedQty ?? undefined
),
"Demand Qty / 訂單數量": formatQty(r.demandQty),
"UOM / 單位": r.uom ?? r.purchaseUomDesc ?? r.stockUomDesc ?? "",
"Product Lot No. / 批次": r.productLotNo ?? "",
"Supplier Lot No. 供應商批次": r.productLotNo ?? "",
"Expiry Date / 到期日": r.expiryDate ?? "",
"Supplier Code / 供應商編號": r.supplierCode ?? "",
"Supplier / 供應商": r.supplier ?? "",
"Status / 狀態": r.status ?? "",
"GRN Id / M18 單號": r.grnId ?? "",
"入倉狀態": r.status ?? "",
};
if (includeFinancialColumns) {
base["Unit Price / 單價"] = moneyCellValue(r.unitPrice);
base["Currency / 貨幣"] = r.currencyCode ?? "";
base["Amount / 金額"] = moneyCellValue(r.lineAmount);
}
base["GRN Code / M18 入倉單號"] = r.grnCode ?? "";
base["GRN Id / M18 記錄編號"] = r.grnId ?? "";
return base;
}

const GRN_SHEET_DETAIL = "PO入倉記錄";
const GRN_SHEET_LISTED_PO = "已上架PO金額";

/** Rows for sheet "已上架PO金額" (ADMIN-only; do not add this sheet for other users). */
function buildListedPoAmountSheetRows(
listed: ListedPoAmounts | undefined
): Record<string, string | number | undefined>[] {
if (
!listed ||
(listed.currencyTotals.length === 0 &&
listed.byPurchaseOrder.length === 0)
) {
return [
{
"Note / 備註":
"(篩選範圍內無已完成之 PO 行) / No completed PO lines in the selected range",
},
];
}
const out: Record<string, string | number | undefined>[] = [];
for (const c of listed.currencyTotals) {
out.push({
"Category / 類別": "貨幣小計 / Currency total",
"Receipt Date / 收貨日期": c.receiptDate ?? "",
"PO No. / 訂單編號": "",
"Currency / 貨幣": c.currencyCode ?? "",
"Total Amount / 金額": moneyCellValue(c.totalAmount),
"GRN Code(s) / M18 入倉單號": "",
});
}
for (const p of listed.byPurchaseOrder) {
out.push({
"Category / 類別": "訂單 / PO",
"Receipt Date / 收貨日期": p.receiptDate ?? "",
"PO No. / 訂單編號": p.poCode ?? "",
"Currency / 貨幣": p.currencyCode ?? "",
"Total Amount / 金額": moneyCellValue(p.totalAmount),
"GRN Code(s) / M18 入倉單號": p.grnCodes ?? "",
});
}
return out;
}

/**
* Generate and download GRN report as Excel.
* Sheet "已上架PO金額" is included only when `includeFinancialColumns` is true (ADMIN).
*/
export async function generateGrnReportExcel(
criteria: Record<string, string>,
reportTitle: string = "PO 入倉記錄"
reportTitle: string = "PO 入倉記錄",
/** Only users with ADMIN authority should pass true (must match backend). */
includeFinancialColumns: boolean = false
): Promise<void> {
const rows = await fetchGrnReportData(criteria);
const excelRows = rows.map(toExcelRow);
const { rows, listedPoAmounts } = await fetchGrnReportData(criteria);
const excelRows = rows.map((r) => toExcelRow(r, includeFinancialColumns));
const start = criteria.receiptDateStart;
const end = criteria.receiptDateEnd;
let datePart: string;
@@ -95,5 +219,17 @@ export async function generateGrnReportExcel(
}
const safeDatePart = datePart.replace(/[^\d\-_/]/g, "");
const filename = `${reportTitle}_${safeDatePart}`;
exportChartToXlsx(excelRows, filename, "GRN");

if (includeFinancialColumns) {
const sheet2 = buildListedPoAmountSheetRows(listedPoAmounts);
exportMultiSheetToXlsx(
[
{ name: GRN_SHEET_DETAIL, rows: excelRows as Record<string, unknown>[] },
{ name: GRN_SHEET_LISTED_PO, rows: sheet2 as Record<string, unknown>[] },
],
filename
);
} else {
exportChartToXlsx(excelRows as Record<string, unknown>[], filename, GRN_SHEET_DETAIL);
}
}

+ 12
- 1
src/app/(main)/report/page.tsx Näytä tiedosto

@@ -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();


+ 2
- 0
src/app/api/do/actions.tsx Näytä tiedosto

@@ -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 {


+ 64
- 0
src/app/api/jo/actions.ts Näytä tiedosto

@@ -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>(


+ 56
- 1
src/app/api/pickOrder/actions.ts Näytä tiedosto

@@ -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}`,


+ 41
- 0
src/app/utils/authToken.ts Näytä tiedosto

@@ -0,0 +1,41 @@
/**
* Client-side helpers for the backend JWT stored in localStorage (`accessToken`).
* Used to detect expiry before API calls so pages like /report can redirect early.
*/

/** Must match clientAuthFetch redirect target */
export const LOGIN_SESSION_EXPIRED_HREF = "/login?session=expired";

/**
* Decode JWT payload (no signature verification — UX only; server still validates).
*/
export function decodeJwtPayload(
token: string
): Record<string, unknown> | null {
try {
const parts = token.split(".");
if (parts.length < 2) return null;
const payload = parts[1];
const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
const padded = base64.padEnd(
base64.length + ((4 - (base64.length % 4)) % 4),
"="
);
const binary = atob(padded);
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
const json = new TextDecoder().decode(bytes);
return JSON.parse(json) as Record<string, unknown>;
} catch {
return null;
}
}

/**
* True if `exp` claim is reached or passed. False if no `exp` (let API decide).
*/
export function isBackendJwtExpired(token: string): boolean {
const payload = decodeJwtPayload(token);
const exp = payload?.exp;
if (typeof exp !== "number") return false;
return Date.now() / 1000 >= exp;
}

+ 2
- 2
src/app/utils/clientAuthFetch.ts Näytä tiedosto

@@ -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;
}
}



+ 380
- 300
src/components/FinishedGoodSearch/FGPickOrderTicketReleaseTable.tsx Näytä tiedosto

@@ -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;

+ 24
- 4
src/components/FinishedGoodSearch/FinishedGoodSearch.tsx Näytä tiedosto

@@ -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>
);


+ 81
- 39
src/components/FinishedGoodSearch/GoodPickExecutionRecord.tsx Näytä tiedosto

@@ -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>

{/* 列表 */}


+ 83
- 6
src/components/JoSearch/JoSearch.tsx Näytä tiedosto

@@ -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>
</>
}


+ 1
- 0
src/components/JoSearch/JoSearchWrapper.tsx Näytä tiedosto

@@ -18,6 +18,7 @@ const JoSearchWrapper: React.FC & SubComponents = async () => {
itemName: "",
planStart: `${todayStr}T00:00`,
planStartTo: `${todayStr}T23:59:59`,
joSearchStatus: "all",

}



+ 5
- 2
src/components/PoDetail/PoDetail.tsx Näytä tiedosto

@@ -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);


+ 16
- 8
src/components/PoDetail/PoInputGrid.tsx Näytä tiedosto

@@ -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);


+ 2
- 2
src/components/PoDetail/PutAwayForm.tsx Näytä tiedosto

@@ -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>


+ 15
- 10
src/components/PoSearch/PoSearch.tsx Näytä tiedosto

@@ -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,


+ 9
- 2
src/components/ProductionProcess/OverallTimeRemainingCard.tsx Näytä tiedosto

@@ -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;


+ 55
- 3
src/components/ProductionProcess/ProductionProcessDetail.tsx Näytä tiedosto

@@ -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>
);
};


+ 82
- 18
src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx Näytä tiedosto

@@ -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>


+ 336
- 48
src/components/ProductionProcess/ProductionProcessList.tsx Näytä tiedosto

@@ -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>


+ 31
- 4
src/components/ProductionProcess/ProductionProcessPage.tsx Näytä tiedosto

@@ -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 && (


+ 16
- 3
src/components/SearchBox/SearchBox.tsx Näytä tiedosto

@@ -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
- 0
src/i18n/zh/common.json Näytä tiedosto

@@ -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)": "收尾時間",


+ 8
- 0
src/i18n/zh/jo.json Näytä tiedosto

@@ -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": "已開始工序",


+ 3
- 0
src/i18n/zh/pickOrder.json Näytä tiedosto

@@ -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",


+ 14
- 1
src/i18n/zh/ticketReleaseTable.json Näytä tiedosto

@@ -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": "取消"
}

Ladataan…
Peruuta
Tallenna