Parcourir la source

actualPickLotLineId + COALESCE 讀取

ledger lotQtyBefore/After + inventoryLotLineId
成品出倉員工問題報告 rep-016
Truck X 2F/4F suggestion
Just Complete / isIssueJustComplete
Workbench 略過 classic confirmLotSubstitution
停 JO planStart/renumber
生產流程 lookback + tabs/QC chip/Release→Cancel
FP-MTMS code comments
Report 店鋪訂單補貨記錄
工單生產流程 UI 再重構
工單提料列表顯示加強
production
CANCERYS\kw093 il y a 23 heures
Parent
révision
7d8f781016
18 fichiers modifiés avec 2191 ajouts et 582 suppressions
  1. +15
    -1
      src/app/(main)/report/page.tsx
  2. +1
    -1
      src/app/(main)/report/reportCategories.ts
  3. +161
    -0
      src/app/(main)/report/shopOrderReplenishmentReportApi.ts
  4. +3
    -0
      src/app/api/doworkbench/actions.ts
  5. +40
    -1
      src/app/api/jo/actions.ts
  6. +2
    -1
      src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx
  7. +3
    -3
      src/components/InventorySearch/InventorySearch.tsx
  8. +207
    -111
      src/components/JoWorkbench/JoPickOrderList.tsx
  9. +85
    -80
      src/components/JoWorkbench/newJobPickExecution.tsx
  10. +20
    -60
      src/components/PickOrderSearch/WorkbenchPickExecution.tsx
  11. +750
    -0
      src/components/ProductionProcess/JobOrderOpsTable.tsx
  12. +29
    -52
      src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx
  13. +747
    -197
      src/components/ProductionProcess/ProductionProcessList.tsx
  14. +23
    -71
      src/components/ProductionProcess/ProductionProcessPage.tsx
  15. +48
    -1
      src/config/reportConfig.ts
  16. +28
    -1
      src/i18n/en/productionProcess.json
  17. +1
    -1
      src/i18n/zh/itemTracing.json
  18. +28
    -1
      src/i18n/zh/productionProcess.json

+ 15
- 1
src/app/(main)/report/page.tsx Voir le fichier

@@ -31,6 +31,7 @@ import {
} from './semiFGProductionAnalysisApi';
import { generateGrnReportExcel } from './grnReportApi';
import { generateBomShopSyncReportExcel } from './bomShopSyncReportApi';
import { generateShopOrderReplenishmentReportExcel } from './shopOrderReplenishmentReportApi';
import {
FEATURE_USAGE,
FEATURE_USAGE_ACTION,
@@ -42,6 +43,7 @@ interface ItemCodeWithName {
name: string;
}

/** FP-MTMS Version Checklist | Functions Ref. No. 39 | v1.0.0 | 2026-08-03 */
export default function ReportPage() {
const { data: session } = useSession() as { data: SessionWithTokens | null };
const includeGrnFinancialColumns =
@@ -267,12 +269,24 @@ export default function ReportPage() {
);
} else if (currentReport.id === 'rep-015') {
await generateBomShopSyncReportExcel(criteria, currentReport.title);
} else if (currentReport.id === 'rep-017') {
await generateShopOrderReplenishmentReportExcel(criteria, currentReport.title);
} else {
// Backend returns actual .xlsx bytes for this Excel endpoint.
const queryParams =
let queryParams =
currentReport.id === 'rep-012'
? buildRep012QueryString()
: new URLSearchParams(criteria).toString();
// rep-016: single-day UI — mirror dateStart to dateEnd for backend API.
if (currentReport.id === 'rep-016') {
const p = new URLSearchParams(criteria);
const day = (criteria.dateStart || '').trim();
if (day) {
p.set('dateStart', day);
p.set('dateEnd', day);
}
queryParams = p.toString();
}
const excelUrl = `${currentReport.apiEndpoint}-excel?${queryParams}`;

const response = await clientAuthFetch(excelUrl, {


+ 1
- 1
src/app/(main)/report/reportCategories.ts Voir le fichier

@@ -25,7 +25,7 @@ export const REPORT_CATEGORIES: ReportCategoryConfig[] = [
headerBg: "#b3d4f0",
bodyBg: "#eef5fc",
accent: "#1565c0",
reportIds: ["rep-004", "rep-014", "rep-008", "rep-009", "rep-013"],
reportIds: ["rep-004", "rep-014", "rep-008", "rep-009", "rep-013", "rep-016", "rep-017"],
},
{
id: "production",


+ 161
- 0
src/app/(main)/report/shopOrderReplenishmentReportApi.ts Voir le fichier

@@ -0,0 +1,161 @@
"use client";

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

export interface ShopOrderReplenishmentReportRow {
shopNo?: string;
shopName?: string;
shopOrderDate?: string;
shopOrderNo?: string;
itemNo?: string;
itemName?: string;
firstOrderQty?: number | string;
firstOrderActualPickQty?: number | string;
reorderQty?: number | string;
reorderDate?: string;
reason?: string;
actualDeliveredQty?: number | string;
deliveredDate?: string;
[key: string]: unknown;
}

export interface ShopOrderReplenishmentReportResponse {
rows: ShopOrderReplenishmentReportRow[];
}

const SHEET_NAME = "店鋪訂單補貨記錄";

const NO_DATA_NOTE =
"(篩選範圍內無資料 / No records in the selected range)";

function emptySheetRow(note: string = NO_DATA_NOTE): Record<string, unknown> {
return {
"Shop No. / 店鋪編號": note,
"Shop Name / 店鋪名稱": "",
"Shop Order Date / 店鋪訂單日期": "",
"Shop Order No. / 店鋪訂單編號": "",
"Item No. / 貨品編號": "",
"Item Name / 貨品名稱": "",
"First Order Qty / 原訂單數量": "",
"First Order Actual Pick Qty / 原單實際提料數量": "",
"Reorder Qty / 補貨數量": "",
"Reorder Date / 補貨日期": "",
"Reason / 補貨原因": "",
"Actual Delivered Qty / 實際補貨數量": "",
"Delivered Date / 送貨日期": "",
};
}

function formatReason(reason: string | undefined): string {
switch ((reason ?? "").trim()) {
case "quality_issue":
return "質素問題";
case "out_of_stock":
return "缺貨";
case "other":
return "其他";
default:
return reason ?? "";
}
}

function formatDateCell(value: unknown): string {
if (value == null || value === "") return "";
if (typeof value === "number" && Number.isFinite(value)) {
// Jackson may serialize java.sql.Date as epoch millis or seconds
const ms = value > 1e12 ? value : value * 1000;
const d = new Date(ms);
if (Number.isNaN(d.getTime())) return String(value);
return d.toISOString().slice(0, 10);
}
const s = String(value).trim();
if (/^\d{10,13}$/.test(s)) {
const n = Number(s);
const ms = s.length >= 13 ? n : n * 1000;
const d = new Date(ms);
if (!Number.isNaN(d.getTime())) return d.toISOString().slice(0, 10);
}
// JDBC / string date: "2026-08-01" or "2026-08-01 00:00:00.0"
return s.length >= 10 ? s.slice(0, 10) : s;
}

function formatQty(value: unknown): string | number {
if (value === null || value === undefined || value === "") return "";
const n = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(n)) return String(value);
return n;
}

function toExcelRow(r: ShopOrderReplenishmentReportRow): Record<string, unknown> {
const base = emptySheetRow("");
return {
...base,
"Shop No. / 店鋪編號": r.shopNo ?? "",
"Shop Name / 店鋪名稱": r.shopName ?? "",
"Shop Order Date / 店鋪訂單日期": formatDateCell(r.shopOrderDate),
"Shop Order No. / 店鋪訂單編號": r.shopOrderNo ?? "",
"Item No. / 貨品編號": r.itemNo ?? "",
"Item Name / 貨品名稱": r.itemName ?? "",
"First Order Qty / 原訂單數量": formatQty(r.firstOrderQty),
"First Order Actual Pick Qty / 原單實際提料數量": formatQty(r.firstOrderActualPickQty),
"Reorder Qty / 補貨數量": formatQty(r.reorderQty),
"Reorder Date / 補貨日期": formatDateCell(r.reorderDate),
"Reason / 補貨原因": formatReason(r.reason),
"Actual Delivered Qty / 實際補貨數量": formatQty(r.actualDeliveredQty),
"Delivered Date / 送貨日期": formatDateCell(r.deliveredDate),
};
}

export async function fetchShopOrderReplenishmentReportData(
criteria: Record<string, string>,
): Promise<ShopOrderReplenishmentReportRow[]> {
const queryParams = new URLSearchParams(criteria).toString();
const url = `${NEXT_PUBLIC_API_URL}/report/shop-order-replenishment?${queryParams}`;

const response = await clientAuthFetch(url, {
method: "GET",
headers: { Accept: "application/json" },
});

if (response.status === 401 || response.status === 403)
throw new Error("Unauthorized");
if (!response.ok)
throw new Error(`HTTP error! status: ${response.status}`);

const data = (await response.json()) as
| ShopOrderReplenishmentReportResponse
| ShopOrderReplenishmentReportRow[];
if (Array.isArray(data)) return data;
return data.rows ?? [];
}

/**
* Generate and download Shop Orders Replenishment Records as Excel.
*/
export async function generateShopOrderReplenishmentReportExcel(
criteria: Record<string, string>,
reportTitle: string = "店鋪訂單補貨記錄",
): Promise<void> {
const rows = await fetchShopOrderReplenishmentReportData(criteria);
const excelRows =
rows.length > 0 ? rows.map(toExcelRow) : [emptySheetRow()];

const dateCandidates = [
criteria.reorderDateStart,
criteria.reorderDateEnd,
criteria.shopOrderDateStart,
criteria.shopOrderDateEnd,
criteria.deliveredDateStart,
criteria.deliveredDateEnd,
].filter((v) => (v ?? "").trim().length > 0);
const datePart =
dateCandidates.length > 0
? dateCandidates.join("_")
: new Date().toISOString().slice(0, 10);
const safeDatePart = datePart.replace(/[^\d\-_/]/g, "");
const filename = `${reportTitle}_${safeDatePart}`;

exportChartToXlsx(excelRows, filename, SHEET_NAME);
}

+ 3
- 0
src/app/api/doworkbench/actions.ts Voir le fichier

@@ -94,6 +94,8 @@ export type WorkbenchScanPickBody = {
excludeWarehouseCodes?: string[] | null;
/** Optional decimal string or number serialized by JSON */
qty?: number | string | null;
/** Just Complete button (no QR) — DO user-audit flag */
justComplete?: boolean | null;
userId: number;
};

@@ -144,6 +146,7 @@ export async function workbenchScanPick(
...(storeId !== undefined ? { storeId } : {}),
...(excludeWarehouseCodes !== undefined ? { excludeWarehouseCodes } : {}),
...(qty !== undefined ? { qty } : {}),
...(body.justComplete === true ? { justComplete: true } : {}),
userId: body.userId,
}),
headers: { "Content-Type": "application/json" },


+ 40
- 1
src/app/api/jo/actions.ts Voir le fichier

@@ -368,6 +368,8 @@ export interface AllJoborderProductProcessInfoResponse {
itemCode: string;
itemName: string;
bomDescription?: string | null;
/** BOM.type (e.g. drink / Powder_Mixture / other). */
bomType?: string | null;
lotNo: string;
requiredQty: number;
jobOrderId: number;
@@ -381,6 +383,26 @@ export interface AllJoborderProductProcessInfoResponse {
productProcessLineCount: number;
FinishedProductProcessLineCount: number;
lines: ProductProcessInfoResponse[];
isPicked?: boolean | null;
/** Fine-grained pick/process bucket from backend. */
pickProcessBucket?: ProductionProcessFinePickBucket | null;
}

/** Fine-grained buckets returned per row. */
export type ProductionProcessFinePickBucket =
| "not_picked_not_started"
| "picked_not_started"
| "picked_started"
| "not_picked_started";

/** Merged tab filter: pending = not started; processing = started. */
export type ProductionProcessPickBucket = "pending" | "processing" | ProductionProcessFinePickBucket;

export interface JobOrderProductProcessBucketCounts {
notPickedNotStarted: number;
pickedNotStarted: number;
pickedStarted: number;
notPickedStarted: number;
}

export interface JobOrderProductProcessPageResponse {
@@ -388,6 +410,9 @@ export interface JobOrderProductProcessPageResponse {
totalJobOrders: number;
page: number;
size: number;
bucketCounts?: JobOrderProductProcessBucketCounts | null;
searchDate?: string | null;
carriedOverCount?: number | null;
}
export interface ProductProcessInfoResponse {
id: number;
@@ -558,7 +583,10 @@ export interface AllJoPickOrderResponse {
jobOrderType: string | null;
itemId: number;
itemName: string;
itemCode?: string | null;
bomDescription?: string | null;
/** BOM.type (e.g. drink / Powder_Mixture / other). */
bomType?: string | null;
lotNo: string | null;
planStart?: string | number[] | null;
reqQty: number;
@@ -877,8 +905,9 @@ export const fetchAllJoborderProductProcessInfo = cache(async (type?: string | n
);
});

/** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.0 | 2026-08-03 */
export const fetchJoborderProductProcessesPage = cache(async (params: {
/** Job order planStart 區間起(YYYY-MM-DD,含當日) */
/** Job order / process date(YYYY-MM-DD) */
date?: string | null;
itemCode?: string | null;
jobOrderCode?: string | null;
@@ -888,6 +917,10 @@ export const fetchJoborderProductProcessesPage = cache(async (params: {
includePutaway?: boolean | null;
/** all | completed | notCompleted */
putawayStatus?: string | null;
/** Production list carry-over window (days before date). */
lookbackDays?: number | null;
/** Pick/process tab filter when lookbackDays is set. */
bucket?: ProductionProcessPickBucket | "all" | null;
page?: number;
size?: number;
}) => {
@@ -900,6 +933,8 @@ export const fetchJoborderProductProcessesPage = cache(async (params: {
includePutaway,
putawayStatus,
type,
lookbackDays,
bucket,
page = 0,
size = 50,
} = params;
@@ -917,6 +952,10 @@ export const fetchJoborderProductProcessesPage = cache(async (params: {
queryParts.push(`includePutaway=${includePutaway}`);
}
if (putawayStatus) queryParts.push(`putawayStatus=${encodeURIComponent(putawayStatus)}`);
if (lookbackDays !== undefined && lookbackDays !== null) {
queryParts.push(`lookbackDays=${lookbackDays}`);
}
if (bucket) queryParts.push(`bucket=${encodeURIComponent(bucket)}`);
queryParts.push(`page=${page}`);
queryParts.push(`size=${size}`);



+ 2
- 1
src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx Voir le fichier

@@ -551,7 +551,7 @@ function saveIssuePickedMap(doPickOrderId: number, map: Record<number, number>)
}
}

/** FP-MTMS Version Checklist | Functions Ref. No. 29 | v1.0.1 | 2026-07-22 */
/** FP-MTMS Version Checklist | Functions Ref. No. 29 | v1.0.2 | 2026-08-03 */
const WorkbenchGoodPickExecutionDetail: React.FC<Props> = ({
filterArgs,
onSwitchToRecordTab,
@@ -3274,6 +3274,7 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe
? { stockInLineId: canonicalLotForSol.stockInLineId }
: {}),
qty: qtyToSend,
justComplete: true,
storeId: fgPickOrders?.[0]?.storeId ?? null,
userId: currentUserId ?? 1,
});


+ 3
- 3
src/components/InventorySearch/InventorySearch.tsx Voir le fichier

@@ -172,12 +172,12 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => {
pagingController: typeof defaultPagingController,
lotNo: string,
) => {
console.log('%c Action Type 1.', 'color:red', actionType);
//console.log('%c Action Type 1.', 'color:red', actionType);
// Avoid loading data again
if (actionType === 'paging' && pagingController === defaultPagingController) {
return;
}
console.log('%c Action Type 2.', 'color:blue', actionType);
// console.log('%c Action Type 2.', 'color:blue', actionType);

const params: SearchInventory = {
code: query?.itemCode ?? '',
@@ -415,7 +415,7 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => {
scanUiMode,
]);

console.log('', 'color: #666', inventoriesPagingController);
//console.log('', 'color: #666', inventoriesPagingController);

const handleOpenOpeningInventoryModal = useCallback(() => {
setOpeningSelectedItem(null);


+ 207
- 111
src/components/JoWorkbench/JoPickOrderList.tsx Voir le fichier

@@ -1,11 +1,9 @@
"use client";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useState } from "react";
import {
Box,
Button,
Card,
CardContent,
CardActions,
Stack,
Typography,
Chip,
@@ -26,6 +24,17 @@ interface Props {
printerCombo: PrinterCombo[];
}

const chipSx = {
flexShrink: 0,
height: 28,
borderRadius: "14px",
"& .MuiChip-label": {
typography: "body2",
px: 1.25,
lineHeight: 1.2,
},
} as const;

/** Jo workbench: same list + detail flow as Jodetail `JoPickOrderList`, detail uses `JoWorkbench/newJobPickExecution`. */
const JoPickOrderList: React.FC<Props> = ({ printerCombo }) => {
const { t } = useTranslation(["common", "jo"]);
@@ -44,10 +53,9 @@ const JoPickOrderList: React.FC<Props> = ({ printerCombo }) => {
{ label: t("Item Name"), paramName: "itemName", type: "text" },
{
label: t("Job Order Type"),
paramName: "BOM Description",
paramName: "bomDescription",
type: "select-labelled",
options: [
//{ label: t("All"), value: "All" },
{ label: t("FG"), value: "FG" },
{ label: t("WIP"), value: "WIP" },
],
@@ -58,7 +66,6 @@ const JoPickOrderList: React.FC<Props> = ({ printerCombo }) => {
paramName: "bomType",
type: "select-labelled",
options: [
//{ label: t("All"), value: "All" },
{ label: t("Drink"), value: "drink" },
{ label: t("Powder Mixture"), value: "Powder_Mixture" },
{ label: t("Other"), value: "other" },
@@ -69,7 +76,6 @@ const JoPickOrderList: React.FC<Props> = ({ printerCombo }) => {
paramName: "floor",
type: "select-labelled",
options: [
//{ label: t("All"), value: "ALL" },
{ label: "2F", value: "2F" },
{ label: "3F", value: "3F" },
{ label: "4F", value: "4F" },
@@ -179,8 +185,8 @@ const JoPickOrderList: React.FC<Props> = ({ printerCombo }) => {
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{t("Total pick orders")}: {pickOrders.length}
</Typography>
<Grid container spacing={2}>
<Grid container spacing={2} alignItems="stretch">
{pickOrders.map((pickOrder) => {
const status = String(pickOrder.jobOrderStatus || "");
const statusLower = status.toLowerCase();
@@ -190,126 +196,216 @@ const JoPickOrderList: React.FC<Props> = ({ printerCombo }) => {
: statusLower === "pending" || statusLower === "processing"
? "primary"
: "default";
const finishedCount = pickOrder.finishedPickOLineCount ?? 0;
const bomDescription = pickOrder.bomDescription
? String(pickOrder.bomDescription).trim()
: "";
const bomType = pickOrder.bomType
? String(pickOrder.bomType).trim()
: "";

return (
<Grid key={pickOrder.id} item xs={12} sm={6} md={4}>
<Grid
key={pickOrder.id}
item
xs={12}
sm={6}
md={4}
sx={{ display: "flex" }}
>
<Card
sx={{
minHeight: 180,
maxHeight: 280,
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
border: "1px solid",
borderColor: "divider",
borderRadius: 2,
boxShadow: "none",
}}
>
<CardContent
<Box
sx={{
pb: 1,
p: 2,
flexGrow: 1,
overflow: "auto",
display: "flex",
flexDirection: "column",
minHeight: 0,
}}
>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Box sx={{ minWidth: 0 }}>
<Typography variant="subtitle1">
{t("Job Order")}: {pickOrder.jobOrderCode || "-"}
</Typography>
</Box>
<Chip size="small" label={t(status)} color={statusColor as any} />
</Stack>
<Typography variant="body2" color="text.secondary">
{t("Lot No")}: {pickOrder.lotNo || "-"}
</Typography>
<Typography variant="body2" color="text.secondary">
{t("Pick Order")}: {pickOrder.pickOrderCode || "-"}
</Typography>
<Typography variant="body2" color="text.secondary">
{t("Item Name")}: {pickOrder.itemName}
{pickOrder.bomDescription ? ` (${t(pickOrder.bomDescription)})` : ""}
</Typography>
<Typography variant="body2" color="text.secondary">
{t("Required Qty")}: {pickOrder.reqQty} ({pickOrder.uomName})
<Typography
variant="body1"
color="text.primary"
fontWeight={600}
title={
[pickOrder.itemCode, pickOrder.itemName]
.filter(Boolean)
.join(" ") || undefined
}
sx={{
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
lineHeight: 1.35,
}}
>
{[pickOrder.itemCode, pickOrder.itemName]
.filter(Boolean)
.join(" ") || "-"}
</Typography>
{selectedFloor === "ALL" ? (
<>
{pickOrder.floorPickCounts?.map(({ floor, finishedCount, totalCount }) => (
<Typography
key={floor}
variant="body2"
color="text.secondary"
component="span"
sx={{ mr: 1 }}
>
{floor}: {finishedCount}/{totalCount}
</Typography>
))}
{!!pickOrder.noLotPickCount && (
<Typography
key="NO_LOT"
variant="body2"
color="text.secondary"
component="span"
sx={{ mr: 1 }}
>
{t("No Lot")}: {pickOrder.noLotPickCount.finishedCount}/{pickOrder.noLotPickCount.totalCount}
</Typography>

<Stack
direction="row"
alignItems="center"
spacing={0.75}
flexWrap="nowrap"
useFlexGap
sx={{
mt: 0.75,
minWidth: 0,
overflowX: "auto",
scrollbarWidth: "none",
"&::-webkit-scrollbar": { display: "none" },
}}
>
{bomDescription ? (
<Chip
size="small"
label={t(bomDescription)}
variant="outlined"
sx={chipSx}
/>
) : null}
{bomType ? (
<Chip
size="small"
label={t(bomType)}
color="info"
variant="outlined"
sx={chipSx}
/>
) : null}
<Chip
size="small"
label={t(status)}
color={statusColor as any}
sx={chipSx}
/>
</Stack>

<Stack
spacing={0.35}
sx={{ mt: 0.75, color: "text.secondary" }}
>
<Typography variant="body2">
{t("Pick Order")}: {pickOrder.pickOrderCode || "-"}
</Typography>
<Typography variant="body2">
{t("Required Qty")}: {pickOrder.reqQty} ({pickOrder.uomName})
</Typography>

<Box>
{selectedFloor === "ALL" ? (
<>
{pickOrder.floorPickCounts?.map(
({ floor, finishedCount, totalCount }) => (
<Typography
key={floor}
variant="body2"
color="text.secondary"
component="span"
sx={{ mr: 1 }}
>
{floor}: {finishedCount}/{totalCount}
</Typography>
),
)}
{!!pickOrder.noLotPickCount && (
<Typography
key="NO_LOT"
variant="body2"
color="text.secondary"
component="span"
sx={{ mr: 1 }}
>
{t("No Lot")}:{" "}
{pickOrder.noLotPickCount.finishedCount}/
{pickOrder.noLotPickCount.totalCount}
</Typography>
)}
</>
) : selectedFloor === "NO_LOT" ? (
!!pickOrder.noLotPickCount && (
<Typography
key="NO_LOT"
variant="body2"
color="text.secondary"
component="span"
sx={{ mr: 1 }}
>
{t("No Lot")}:{" "}
{pickOrder.noLotPickCount.finishedCount}/
{pickOrder.noLotPickCount.totalCount}
</Typography>
)
) : (
pickOrder.floorPickCounts
?.filter((c) => c.floor === selectedFloor)
.map(({ floor, finishedCount, totalCount }) => (
<Typography
key={floor}
variant="body2"
color="text.secondary"
component="span"
sx={{ mr: 1 }}
>
{floor}: {finishedCount}/{totalCount}
</Typography>
))
)}
</>
) : selectedFloor === "NO_LOT" ? (
!!pickOrder.noLotPickCount && (
<Typography
key="NO_LOT"
variant="body2"
color="text.secondary"
component="span"
sx={{ mr: 1 }}
>
{t("No Lot")}: {pickOrder.noLotPickCount.finishedCount}/{pickOrder.noLotPickCount.totalCount}
</Typography>
)
) : (
pickOrder.floorPickCounts
?.filter((c) => c.floor === selectedFloor)
.map(({ floor, finishedCount, totalCount }) => (
<Typography
key={floor}
variant="body2"
color="text.secondary"
component="span"
sx={{ mr: 1 }}
>
{floor}: {finishedCount}/{totalCount}
</Box>

{typeof pickOrder.suggestedFailCount === "number" &&
pickOrder.suggestedFailCount > 0 && (
<Typography variant="body2" color="error">
{t("Suggested Fail")}: {pickOrder.suggestedFailCount}
</Typography>
))
)}
{typeof pickOrder.suggestedFailCount === "number" && pickOrder.suggestedFailCount > 0 && (
<Typography variant="body2" color="error" sx={{ mt: 0.5 }}>
{t("Suggested Fail")}: {pickOrder.suggestedFailCount}
</Typography>
)}
{statusLower !== "pending" && finishedCount > 0 && (
<Box sx={{ mt: 1 }}>
<Typography variant="body2" fontWeight={600}>
)}

{statusLower !== "pending" && finishedCount > 0 && (
<Typography variant="body2" fontWeight={600} color="text.primary">
{t("Finished lines")}: {finishedCount}
</Typography>
</Box>
)}
</CardContent>
<CardActions sx={{ pt: 0.5 }}>
<Button
variant="contained"
size="small"
onClick={() => {
setSelectedPickOrderId(pickOrder.pickOrderId ?? undefined);
setSelectedJobOrderId(pickOrder.jobOrderId ?? undefined);
}}
)}
</Stack>

<Stack sx={{ mt: "auto", pt: 1.5 }}>
<Button
variant="contained"
size="small"
sx={{ alignSelf: "flex-start" }}
onClick={() => {
setSelectedPickOrderId(pickOrder.pickOrderId ?? undefined);
setSelectedJobOrderId(pickOrder.jobOrderId ?? undefined);
}}
>
{t("View Details")}
</Button>
</Stack>

<Typography
variant="caption"
color="text.secondary"
sx={{ mt: 1.25 }}
>
{t("View Details")}
</Button>
<Box sx={{ flex: 1 }} />
</CardActions>
{pickOrder.jobOrderCode || "-"}
{" · "}
{t("Lot No")}: {pickOrder.lotNo || "-"}
</Typography>
</Box>
</Card>
</Grid>
);


+ 85
- 80
src/components/JoWorkbench/newJobPickExecution.tsx Voir le fichier

@@ -618,6 +618,7 @@ const QrCodeModal: React.FC<{
);
};

/** FP-MTMS Version Checklist | Functions Ref. No. 43 | v1.0.0 | 2026-08-03 */
const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCombo = [] }) => {
const workbenchMode = true;
const { t } = useTranslation("jo");
@@ -1799,88 +1800,92 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCo
selectedLotForQr.suggestedPickLotId || selectedLotForQr.lotId;

let switchedToUnavailable = false;
// noLot / missing suggestedPickLotId 场景:没有 originalSuggestedPickLotId,改用 updateStockOutLineStatusByQRCodeAndLotNo
if (!originalSuggestedPickLotId) {
if (!selectedLotForQr?.stockOutLineId) {
throw new Error("Missing stockOutLineId for noLot line");
}
console.log(
"🔄 [LOT CONFIRM] No originalSuggestedPickLotId, using updateStockOutLineStatusByQRCodeAndLotNo...",
);
const res = await updateStockOutLineStatusByQRCodeAndLotNo({
pickOrderLineId: selectedLotForQr.pickOrderLineId,
inventoryLotNo: effectiveScannedLot.lotNo || "",
stockInLineId: effectiveScannedLot?.stockInLineId ?? null,
stockOutLineId: selectedLotForQr.stockOutLineId,
itemId: selectedLotForQr.itemId,
status: "checked",
});
console.log(
"✅ [LOT CONFIRM] updateStockOutLineStatusByQRCodeAndLotNo result:",
res,
);
switchedToUnavailable = res?.code === "BOUND_UNAVAILABLE";
const ok =
res?.code === "checked" ||
res?.code === "SUCCESS" ||
switchedToUnavailable;
if (!ok) {
const errMsg =
res?.code === "LOT_UNAVAILABLE"
? tPick(
"The scanned lot inventory line is unavailable. Cannot switch or bind; pick line was not updated.",
)
: res?.message ||
tPick(
"Lot switch failed; pick line was not marked as checked.",
);
setQrScanError(true);
setQrScanSuccess(false);
setQrScanErrorMsg(errMsg);
return;
}
} else {
// Call confirmLotSubstitution to update the suggested lot
console.log("🔄 [LOT CONFIRM] Calling confirmLotSubstitution...");
const substitutionResult = await confirmLotSubstitution({
pickOrderLineId: selectedLotForQr.pickOrderLineId,
stockOutLineId: selectedLotForQr.stockOutLineId,
originalSuggestedPickLotId,
newInventoryLotNo: effectiveScannedLot.lotNo || "",
// ✅ required by LotSubstitutionConfirmRequest
newStockInLineId: effectiveScannedLot?.stockInLineId ?? null,
});

console.log(
"✅ [LOT CONFIRM] Lot substitution result:",
substitutionResult,
);
// Workbench no-hold: skip classic bind/switch (holdQty). Switch + pick via scan-pick only.
// Non-workbench: keep confirmLotSubstitution / QR bind (moves hold).
if (!workbenchMode) {
// noLot / missing suggestedPickLotId 场景:没有 originalSuggestedPickLotId,改用 updateStockOutLineStatusByQRCodeAndLotNo
if (!originalSuggestedPickLotId) {
if (!selectedLotForQr?.stockOutLineId) {
throw new Error("Missing stockOutLineId for noLot line");
}
console.log(
"🔄 [LOT CONFIRM] No originalSuggestedPickLotId, using updateStockOutLineStatusByQRCodeAndLotNo...",
);
const res = await updateStockOutLineStatusByQRCodeAndLotNo({
pickOrderLineId: selectedLotForQr.pickOrderLineId,
inventoryLotNo: effectiveScannedLot.lotNo || "",
stockInLineId: effectiveScannedLot?.stockInLineId ?? null,
stockOutLineId: selectedLotForQr.stockOutLineId,
itemId: selectedLotForQr.itemId,
status: "checked",
});
console.log(
"✅ [LOT CONFIRM] updateStockOutLineStatusByQRCodeAndLotNo result:",
res,
);
switchedToUnavailable = res?.code === "BOUND_UNAVAILABLE";
const ok =
res?.code === "checked" ||
res?.code === "SUCCESS" ||
switchedToUnavailable;
if (!ok) {
const errMsg =
res?.code === "LOT_UNAVAILABLE"
? tPick(
"The scanned lot inventory line is unavailable. Cannot switch or bind; pick line was not updated.",
)
: res?.message ||
tPick(
"Lot switch failed; pick line was not marked as checked.",
);
setQrScanError(true);
setQrScanSuccess(false);
setQrScanErrorMsg(errMsg);
return;
}
} else {
// Call confirmLotSubstitution to update the suggested lot
console.log("🔄 [LOT CONFIRM] Calling confirmLotSubstitution...");
const substitutionResult = await confirmLotSubstitution({
pickOrderLineId: selectedLotForQr.pickOrderLineId,
stockOutLineId: selectedLotForQr.stockOutLineId,
originalSuggestedPickLotId,
newInventoryLotNo: effectiveScannedLot.lotNo || "",
// ✅ required by LotSubstitutionConfirmRequest
newStockInLineId: effectiveScannedLot?.stockInLineId ?? null,
});

// ✅ CRITICAL: substitution failed => DO NOT mark original stockOutLine as checked.
// Keep modal open so user can cancel/rescan.
switchedToUnavailable =
substitutionResult?.code === "SUCCESS_UNAVAILABLE" ||
substitutionResult?.code === "BOUND_UNAVAILABLE";
if (
!substitutionResult ||
(substitutionResult.code !== "SUCCESS" && !switchedToUnavailable)
) {
console.error(
"❌ [LOT CONFIRM] Lot substitution failed. Will NOT update stockOutLine status.",
console.log(
"✅ [LOT CONFIRM] Lot substitution result:",
substitutionResult,
);
const errMsg =
substitutionResult?.code === "LOT_UNAVAILABLE"
? tPick(
"The scanned lot inventory line is unavailable. Cannot switch or bind; pick line was not updated.",
)
: substitutionResult?.message ||
`换批失败:stockInLineId ${
effectiveScannedLot?.stockInLineId ?? ""
} 不存在或无法匹配`;
setQrScanError(true);
setQrScanSuccess(false);
setQrScanErrorMsg(errMsg);
return;

// ✅ CRITICAL: substitution failed => DO NOT mark original stockOutLine as checked.
// Keep modal open so user can cancel/rescan.
switchedToUnavailable =
substitutionResult?.code === "SUCCESS_UNAVAILABLE" ||
substitutionResult?.code === "BOUND_UNAVAILABLE";
if (
!substitutionResult ||
(substitutionResult.code !== "SUCCESS" && !switchedToUnavailable)
) {
console.error(
"❌ [LOT CONFIRM] Lot substitution failed. Will NOT update stockOutLine status.",
);
const errMsg =
substitutionResult?.code === "LOT_UNAVAILABLE"
? tPick(
"The scanned lot inventory line is unavailable. Cannot switch or bind; pick line was not updated.",
)
: substitutionResult?.message ||
`换批失败:stockInLineId ${
effectiveScannedLot?.stockInLineId ?? ""
} 不存在或无法匹配`;
setQrScanError(true);
setQrScanSuccess(false);
setQrScanErrorMsg(errMsg);
return;
}
}
}



+ 20
- 60
src/components/PickOrderSearch/WorkbenchPickExecution.tsx Voir le fichier

@@ -30,7 +30,6 @@ import { SessionWithTokens } from "@/config/authConfig";
import {
fetchConsumableWorkbenchPickOrderLotsHierarchical,
reloadConsumableWorkbenchPickOrderLotsHierarchical,
confirmLotSubstitution,
suggestPickOrderWorkbenchV2,
} from "@/app/api/pickOrder/actions";
import { workbenchScanPick } from "@/app/api/doworkbench/actions";
@@ -229,15 +228,6 @@ const isCheckedStatus = (status: string | undefined): boolean =>
const isRejectedStatus = (status: string | undefined): boolean =>
String(status || "").toLowerCase() === "rejected";

const isNonBlockingSwitchLotReject = (code: unknown, message: unknown): boolean => {
const c = String(code || "").toUpperCase();
const m = String(message || "");
if (c === "SUCCESS_UNAVAILABLE" || c === "BOUND_UNAVAILABLE") return true;
if (/^Reject switch lot:/i.test(m)) return true;
if (/available\s*=\s*\d+(\.\d+)?\s*<\s*required\s*=\s*\d+(\.\d+)?/i.test(m)) return true;
return false;
};

function safeDisplayTargetDate(targetDate: string | number[]): string {
try {
if (Array.isArray(targetDate) && targetDate.length >= 3) {
@@ -356,7 +346,7 @@ function mapHierarchicalToPickOrders(data: unknown): PickOrderTopRow[] {
});
}

/** FP-MTMS Version Checklist | Functions Ref. No. 28 | v1.0.1 | 2026-07-22 */
/** FP-MTMS Version Checklist | Functions Ref. No. 28 | v1.0.2 | 2026-08-03 */
const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => {
const { t } = useTranslation("pickOrder");
const { data: session } = useSession() as { data: SessionWithTokens | null };
@@ -962,6 +952,7 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => {
}
}, []);

// Workbench no-hold: skip confirmLotSubstitution (classic hold move). Switch + pick via scan-pick only.
const handleLotConfirmation = useCallback(
async (overrideScanned?: ConfirmLotState, overrideExpected?: ConfirmLotState) => {
const expected = overrideExpected ?? expectedLotData;
@@ -972,56 +963,25 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => {
setError("");
setMessage("");
try {
const originalSuggestedPickLotId = Number(expected.row.suggestedPickLotId || 0);
let switchedToUnavailable = false;
if (originalSuggestedPickLotId > 0) {
const res = await confirmLotSubstitution({
pickOrderLineId: expected.row.pickOrderLineId,
stockOutLineId: expected.row.stockOutLineId,
originalSuggestedPickLotId,
newInventoryLotNo: scanned.lotNo,
newStockInLineId: Number(scanned.stockInLineId ?? 0),
});
switchedToUnavailable = res.code === "SUCCESS_UNAVAILABLE" || res.code === "BOUND_UNAVAILABLE";
const nonBlockingReject = isNonBlockingSwitchLotReject(res.code, res.message);
if (res.code !== "SUCCESS" && !switchedToUnavailable && !nonBlockingReject) {
const msg = (res.message as string) || t("Lot switch failed");
setLotConfirmationError(msg);
setError(msg);
startTransition(() => {
setQrScanError(true);
setQrScanSuccess(false);
setQrScanErrorMsg(msg);
});
return;
}
if (nonBlockingReject && !switchedToUnavailable) {
const warnMsg = (res.message as string) || t("Lot switch rejected. Continue with scan-pick.");
setMessage(warnMsg);
}
}

if (!switchedToUnavailable) {
const res = await workbenchScanPick({
stockOutLineId: expected.row.stockOutLineId,
lotNo: scanned.lotNo,
...(Number.isFinite(Number(scanned.stockInLineId)) && Number(scanned.stockInLineId) > 0
? { stockInLineId: Number(scanned.stockInLineId) }
: {}),
...workbenchScanPickQtyFromLot(expected.row),
userId,
const res = await workbenchScanPick({
stockOutLineId: expected.row.stockOutLineId,
lotNo: scanned.lotNo,
...(Number.isFinite(Number(scanned.stockInLineId)) && Number(scanned.stockInLineId) > 0
? { stockInLineId: Number(scanned.stockInLineId) }
: {}),
...workbenchScanPickQtyFromLot(expected.row),
userId,
});
if (res.code !== "SUCCESS") {
const msg = (res.message as string) || t("Workbench scan-pick failed.");
setLotConfirmationError(msg);
setError(msg);
startTransition(() => {
setQrScanError(true);
setQrScanSuccess(false);
setQrScanErrorMsg(msg);
});
if (res.code !== "SUCCESS") {
const msg = (res.message as string) || t("Workbench scan-pick failed.");
setLotConfirmationError(msg);
setError(msg);
startTransition(() => {
setQrScanError(true);
setQrScanSuccess(false);
setQrScanErrorMsg(msg);
});
return;
}
return;
}
setMessage(t("Scan pick success"));
startTransition(() => {


+ 750
- 0
src/components/ProductionProcess/JobOrderOpsTable.tsx Voir le fichier

@@ -0,0 +1,750 @@
"use client";

import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Box,
Button,
Card,
CardContent,
Chip,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Paper,
Stack,
Tab,
Tabs,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TablePagination,
TableRow,
Typography,
} from "@mui/material";
import { useTranslation } from "react-i18next";
import { useSession } from "next-auth/react";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import dayjs, { Dayjs } from "dayjs";
import { SessionWithTokens } from "@/config/authConfig";
import { AUTH } from "@/authorities";
import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil";
import {
AllJoborderProductProcessInfoResponse,
completeProductProcessLine,
fetchJoborderProductProcessesPage,
fetchJos,
fetchProductProcessesByJobOrderId,
setJobOrderHidden,
} from "@/app/api/jo/actions";
import { JobOrder } from "@/app/api/jo";
import QcStockInModal from "@/components/Qc/QcStockInModal";
import { StockInLineInput } from "@/app/api/stockIn";
import type { PrinterCombo } from "@/app/api/settings/printer";

type PrimaryTab =
| "all"
| "pending"
| "processing"
| "pending_qc"
| "putawayed"
| "issue";
type PendingSubTab = "all" | "picked_not_started" | "not_picked_not_started";
type ProcessingSubTab = "all" | "picked_started" | "not_picked_started";
type IssueSubTab = "stop" | "cancel";

type OpsRow = {
key: string;
jobOrderId: number;
jobOrderCode: string;
itemCode: string;
itemName: string;
requiredQty: number;
uom: string;
productionDate: string;
statusLabel: string;
pickProcessBucket?: string | null;
isPaused: boolean;
isCancelled: boolean;
/** pending_qc | putawayed | production | cancelled */
rowKind: "production" | "pending_qc" | "putawayed" | "cancelled";
stockInLineId?: number | null;
/** Present for production rows; used by complete-JO. */
sourceProcess?: AllJoborderProductProcessInfoResponse;
};

/** Cards / page size for ops table fetch. */
const FETCH_SIZE = 200;

function isPausedProcess(p: AllJoborderProductProcessInfoResponse): boolean {
return (p.lines ?? []).some(
(l) => String(l.status ?? "").trim().toLowerCase() === "paused",
);
}

function toProductionRow(
p: AllJoborderProductProcessInfoResponse,
rowKind: OpsRow["rowKind"] = "production",
): OpsRow | null {
if (!p.jobOrderId) return null;
return {
key: `pp-${rowKind}-${p.jobOrderId}-${p.id}`,
jobOrderId: p.jobOrderId,
jobOrderCode: p.jobOrderCode || "-",
itemCode: p.itemCode || "-",
itemName: p.itemName || "-",
requiredQty: p.requiredQty ?? 0,
uom: p.uom || "",
productionDate: p.date || "",
statusLabel:
rowKind === "pending_qc"
? "pending_qc"
: rowKind === "putawayed"
? "putawayed"
: p.pickProcessBucket || p.status || "-",
pickProcessBucket: p.pickProcessBucket,
isPaused: isPausedProcess(p),
isCancelled: false,
rowKind,
stockInLineId: p.stockInLineId ?? null,
sourceProcess: p,
};
}

function toCancelledRow(jo: JobOrder): OpsRow {
const planStart = Array.isArray(jo.planStart)
? dayjs(
new Date(
jo.planStart[0],
(jo.planStart[1] ?? 1) - 1,
jo.planStart[2] ?? 1,
),
).format("YYYY-MM-DD")
: jo.planStart
? dayjs(jo.planStart as unknown as string).format("YYYY-MM-DD")
: "";
return {
key: `jo-${jo.id}`,
jobOrderId: jo.id,
jobOrderCode: jo.code || "-",
itemCode: jo.item?.code || "-",
itemName: jo.itemName || jo.item?.name || "-",
requiredQty: jo.reqQty ?? 0,
uom: "",
productionDate: planStart,
statusLabel: "cancelled",
pickProcessBucket: null,
isPaused: false,
isCancelled: true,
rowKind: "cancelled",
stockInLineId: null,
};
}

/** Deduplicate by jobOrderId (prefer paused / higher priority). */
function dedupeByJobOrder(rows: OpsRow[]): OpsRow[] {
const map = new Map<number, OpsRow>();
for (const row of rows) {
const existing = map.get(row.jobOrderId);
if (!existing) {
map.set(row.jobOrderId, row);
continue;
}
if (!existing.isPaused && row.isPaused) {
map.set(row.jobOrderId, row);
}
}
return Array.from(map.values());
}

interface JobOrderOpsTableProps {
onSelectProcess?: (jobOrderId: number) => void;
printerCombo?: PrinterCombo[];
}

const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({
onSelectProcess,
printerCombo = [],
}) => {
const { t } = useTranslation(["productionProcess", "common"]);
const { data: session } = useSession() as { data: SessionWithTokens | null };
const sessionToken = session as SessionWithTokens | null;
const abilities = session?.abilities ?? session?.user?.abilities ?? [];
const canManage = abilities.some((a) => a.trim() === AUTH.ADMIN);

const [queryDate, setQueryDate] = useState<Dayjs>(() => dayjs());
const [primaryTab, setPrimaryTab] = useState<PrimaryTab>("all");
const [pendingSub, setPendingSub] = useState<PendingSubTab>("all");
const [processingSub, setProcessingSub] = useState<ProcessingSubTab>("all");
const [issueSub, setIssueSub] = useState<IssueSubTab>("stop");

const [productionRows, setProductionRows] = useState<OpsRow[]>([]);
const [pendingQcRows, setPendingQcRows] = useState<OpsRow[]>([]);
const [putawayedRows, setPutawayedRows] = useState<OpsRow[]>([]);
const [cancelledRows, setCancelledRows] = useState<OpsRow[]>([]);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(10);

const [confirmOpen, setConfirmOpen] = useState(false);
const [confirmMessage, setConfirmMessage] = useState("");
const [confirmLoading, setConfirmLoading] = useState(false);
const [pendingAction, setPendingAction] = useState<null | (() => Promise<void>)>(null);
const actionInFlightRef = useRef(false);
const [rowBusyIds, setRowBusyIds] = useState<Set<number>>(new Set());
const [qcModalOpen, setQcModalOpen] = useState(false);
const [qcModalInfo, setQcModalInfo] = useState<StockInLineInput | undefined>();

const loadProduction = useCallback(async () => {
const dayStr = queryDate.format("YYYY-MM-DD");
// lookbackDays=0 → exact search date only (still enables pickProcessBucket on backend)
const data = await fetchJoborderProductProcessesPage({
date: dayStr,
lookbackDays: 0,
bucket: "all",
qcReady: false,
page: 0,
size: FETCH_SIZE,
});
const rows = (data?.content ?? [])
.map((p) => toProductionRow(p, "production"))
.filter((r): r is OpsRow => r != null)
.filter((r) => !r.productionDate || r.productionDate === dayStr);
setProductionRows(dedupeByJobOrder(rows));
}, [queryDate]);

const loadPendingQc = useCallback(async () => {
const dayStr = queryDate.format("YYYY-MM-DD");
try {
const data = await fetchJoborderProductProcessesPage({
date: dayStr,
lookbackDays: 0,
qcReady: true,
includePutaway: true,
putawayStatus: "notCompleted",
page: 0,
size: FETCH_SIZE,
});
const rows = (data?.content ?? [])
.map((p) => toProductionRow(p, "pending_qc"))
.filter((r): r is OpsRow => r != null)
.filter((r) => !r.productionDate || r.productionDate === dayStr);
setPendingQcRows(dedupeByJobOrder(rows));
} catch (e) {
console.error("loadPendingQc failed", e);
setPendingQcRows([]);
}
}, [queryDate]);

const loadPutawayed = useCallback(async () => {
const dayStr = queryDate.format("YYYY-MM-DD");
try {
const data = await fetchJoborderProductProcessesPage({
date: dayStr,
qcReady: true,
includePutaway: true,
putawayStatus: "completed",
page: 0,
size: FETCH_SIZE,
});
const rows = (data?.content ?? [])
.map((p) => toProductionRow(p, "putawayed"))
.filter((r): r is OpsRow => r != null)
.filter((r) => !r.productionDate || r.productionDate === dayStr);
setPutawayedRows(dedupeByJobOrder(rows));
} catch (e) {
console.error("loadPutawayed failed", e);
setPutawayedRows([]);
}
}, [queryDate]);

const loadCancelled = useCallback(async () => {
const dayStr = queryDate.format("YYYY-MM-DD");
try {
const res = await fetchJos({
code: "",
planStart: dayStr,
planStartTo: dayStr,
joSearchStatus: "cancel",
pageNum: 0,
pageSize: FETCH_SIZE,
});
setCancelledRows((res?.records ?? []).map(toCancelledRow));
} catch (e) {
console.error("loadCancelled failed", e);
setCancelledRows([]);
}
}, [queryDate]);

const loadData = useCallback(async () => {
setLoading(true);
const results = await Promise.allSettled([
loadProduction(),
loadPendingQc(),
loadPutawayed(),
loadCancelled(),
]);
if (results[0].status === "rejected") {
console.error(results[0].reason);
setProductionRows([]);
}
setLoading(false);
}, [loadProduction, loadPendingQc, loadPutawayed, loadCancelled]);

useEffect(() => {
void loadData();
}, [loadData]);

useEffect(() => {
setPage(0);
}, [primaryTab, pendingSub, processingSub, issueSub, queryDate]);

const filteredRows = useMemo(() => {
if (primaryTab === "pending_qc") return pendingQcRows;
if (primaryTab === "putawayed") return putawayedRows;
if (primaryTab === "issue" && issueSub === "cancel") return cancelledRows;

let list = productionRows;
if (primaryTab === "pending") {
list = list.filter((r) => {
const b = r.pickProcessBucket;
if (pendingSub === "picked_not_started") return b === "picked_not_started";
if (pendingSub === "not_picked_not_started") return b === "not_picked_not_started";
return b === "picked_not_started" || b === "not_picked_not_started";
});
} else if (primaryTab === "processing") {
list = list.filter((r) => {
const b = r.pickProcessBucket;
if (processingSub === "picked_started") return b === "picked_started";
if (processingSub === "not_picked_started") return b === "not_picked_started";
return b === "picked_started" || b === "not_picked_started";
});
} else if (primaryTab === "issue" && issueSub === "stop") {
list = list.filter((r) => r.isPaused);
}
return list;
}, [
primaryTab,
pendingSub,
processingSub,
issueSub,
productionRows,
pendingQcRows,
putawayedRows,
cancelledRows,
]);

const counts = useMemo(() => {
const pending = productionRows.filter(
(r) =>
r.pickProcessBucket === "picked_not_started" ||
r.pickProcessBucket === "not_picked_not_started",
).length;
const processing = productionRows.filter(
(r) =>
r.pickProcessBucket === "picked_started" ||
r.pickProcessBucket === "not_picked_started",
).length;
const stop = productionRows.filter((r) => r.isPaused).length;
return {
all: productionRows.length,
pending,
processing,
pendingQc: pendingQcRows.length,
putawayed: putawayedRows.length,
stop,
cancel: cancelledRows.length,
};
}, [productionRows, pendingQcRows, putawayedRows, cancelledRows]);

const paginatedRows = useMemo(() => {
const start = page * pageSize;
return filteredRows.slice(start, start + pageSize);
}, [filteredRows, page, pageSize]);

const openConfirm = useCallback((message: string, action: () => Promise<void>) => {
setConfirmMessage(message);
setPendingAction(() => action);
setConfirmOpen(true);
}, []);

const closeConfirm = useCallback(() => {
if (confirmLoading) return;
setConfirmOpen(false);
setPendingAction(null);
setConfirmMessage("");
}, [confirmLoading]);

const markBusy = (id: number, busy: boolean) => {
setRowBusyIds((prev) => {
const next = new Set(prev);
if (busy) next.add(id);
else next.delete(id);
return next;
});
};

const handleOpenQcModal = useCallback(
(row: OpsRow) => {
if (!row.stockInLineId) {
alert(t("Invalid Stock In Line Id"));
return;
}
setQcModalInfo({ id: row.stockInLineId });
setQcModalOpen(true);
},
[t],
);

const handleComplete = useCallback(
(row: OpsRow) => {
if (!canManage || row.isCancelled) return;
openConfirm(t("Confirm to update this Job Order?"), async () => {
if (actionInFlightRef.current) return;
actionInFlightRef.current = true;
markBusy(row.jobOrderId, true);
try {
const processes = await fetchProductProcessesByJobOrderId(row.jobOrderId);
const lineIds = (processes ?? [])
.flatMap((p) => (p as { productProcessLines?: { id?: number }[] }).productProcessLines ?? [])
.map((l) => l.id)
.filter((id): id is number => !!id);
for (const lineId of lineIds) {
try {
await completeProductProcessLine(lineId);
} catch (e) {
console.error("completeProductProcessLine failed", lineId, e);
}
}
await loadData();
} finally {
markBusy(row.jobOrderId, false);
actionInFlightRef.current = false;
}
});
},
[canManage, openConfirm, t, loadData],
);

const handleCancel = useCallback(
(row: OpsRow) => {
if (!canManage || row.isCancelled) return;
openConfirm(t("Cancel job order confirm message"), async () => {
if (actionInFlightRef.current) return;
actionInFlightRef.current = true;
markBusy(row.jobOrderId, true);
try {
await setJobOrderHidden(row.jobOrderId, true);
await loadData();
} finally {
markBusy(row.jobOrderId, false);
actionInFlightRef.current = false;
}
});
},
[canManage, openConfirm, t, loadData],
);

const onConfirm = useCallback(async () => {
if (!pendingAction) return;
setConfirmLoading(true);
try {
await pendingAction();
} catch (e) {
console.error(e);
} finally {
setConfirmLoading(false);
setConfirmOpen(false);
setPendingAction(null);
setConfirmMessage("");
}
}, [pendingAction]);

const bucketLabel = (row: OpsRow) => {
if (row.rowKind === "pending_qc") return t("Waiting QC Put Away");
if (row.rowKind === "putawayed") return t("Put Awayed");
if (row.isCancelled) return t("Cancelled");
switch (row.pickProcessBucket) {
case "not_picked_not_started":
case "not_picked_started":
return t("Not picked");
case "picked_not_started":
case "picked_started":
return t("Picked");
default:
return row.statusLabel || "-";
}
};

const showManageActions =
primaryTab !== "pending_qc" &&
primaryTab !== "putawayed" &&
!(primaryTab === "issue" && issueSub === "cancel");

return (
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-hk">
<Card sx={{ mb: 2 }}>
<CardContent>
<Typography variant="h5" sx={{ fontWeight: 600, mb: 2 }}>
{t("Job Order Ops Table")}
</Typography>

<Stack
direction="row"
spacing={2}
sx={{ mb: 2, flexWrap: "wrap", alignItems: "center" }}
>
<DatePicker
label={t("Search 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>
</Stack>

<Tabs
value={primaryTab}
onChange={(_, v: PrimaryTab) => setPrimaryTab(v)}
variant="scrollable"
scrollButtons="auto"
sx={{ mb: 1, borderBottom: 1, borderColor: "divider" }}
>
<Tab value="all" label={`${t("All")} (${counts.all})`} />
<Tab value="pending" label={`${t("pending")} (${counts.pending})`} />
<Tab value="processing" label={`${t("Processing")} (${counts.processing})`} />
<Tab
value="pending_qc"
label={`${t("Waiting QC Put Away")} (${counts.pendingQc})`}
/>
<Tab value="putawayed" label={`${t("Put Awayed")} (${counts.putawayed})`} />
<Tab
value="issue"
label={`${t("Issue")} (${counts.stop + counts.cancel})`}
/>
</Tabs>

{primaryTab === "pending" && (
<Tabs
value={pendingSub}
onChange={(_, v: PendingSubTab) => setPendingSub(v)}
sx={{ mb: 2 }}
>
<Tab value="all" label={t("All")} />
<Tab value="picked_not_started" label={t("Picked")} />
<Tab value="not_picked_not_started" label={t("Not picked")} />
</Tabs>
)}

{primaryTab === "processing" && (
<Tabs
value={processingSub}
onChange={(_, v: ProcessingSubTab) => setProcessingSub(v)}
sx={{ mb: 2 }}
>
<Tab value="all" label={t("All")} />
<Tab value="picked_started" label={t("Picked")} />
<Tab value="not_picked_started" label={t("Not picked")} />
</Tabs>
)}

{primaryTab === "issue" && (
<Tabs
value={issueSub}
onChange={(_, v: IssueSubTab) => setIssueSub(v)}
sx={{ mb: 2 }}
>
<Tab value="stop" label={`${t("Stop (paused)")} (${counts.stop})`} />
<Tab value="cancel" label={`${t("Cancelled")} (${counts.cancel})`} />
</Tabs>
)}

{loading ? (
<Box sx={{ display: "flex", justifyContent: "center", p: 3 }}>
<CircularProgress />
</Box>
) : (
<>
<TableContainer component={Paper} sx={{ maxHeight: 480, overflow: "auto" }}>
<Table size="small" stickyHeader sx={{ minWidth: 800 }}>
<TableHead>
<TableRow>
<TableCell>{t("Job Order")}</TableCell>
<TableCell>{t("Item")}</TableCell>
<TableCell align="right">{t("Required Qty")}</TableCell>
<TableCell>{t("Production Date")}</TableCell>
<TableCell>{t("Status")}</TableCell>
<TableCell align="center" sx={{ minWidth: 280 }}>
{t("Actions")}
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{paginatedRows.length === 0 ? (
<TableRow>
<TableCell colSpan={6} align="center">
{t("No data available")}
</TableCell>
</TableRow>
) : (
paginatedRows.map((row) => {
const busy = rowBusyIds.has(row.jobOrderId);
return (
<TableRow key={row.key} hover>
<TableCell>{row.jobOrderCode}</TableCell>
<TableCell>
<Typography variant="body2">
{[row.itemCode, row.itemName].filter(Boolean).join(" ")}
</Typography>
</TableCell>
<TableCell align="right">
{row.requiredQty}
{row.uom ? ` ${row.uom}` : ""}
</TableCell>
<TableCell>
{row.productionDate && dayjs(row.productionDate).isValid()
? dayjs(row.productionDate).format(OUTPUT_DATE_FORMAT)
: "-"}
</TableCell>
<TableCell>
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
{row.isPaused && (
<Chip size="small" color="warning" label={t("Stop (paused)")} />
)}
<Chip
size="small"
variant="outlined"
label={bucketLabel(row)}
/>
</Stack>
</TableCell>
<TableCell align="center">
<Stack
direction="row"
spacing={1}
justifyContent="center"
flexWrap="wrap"
useFlexGap
>
<Button
size="small"
variant="outlined"
onClick={() => onSelectProcess?.(row.jobOrderId)}
>
{t("View Details")}
</Button>
{primaryTab === "pending_qc" && row.stockInLineId != null && (
<Button
size="small"
variant="contained"
onClick={() => handleOpenQcModal(row)}
>
{t("view stockin")}
</Button>
)}
{primaryTab === "putawayed" && row.stockInLineId != null && (
<Button
size="small"
variant="contained"
onClick={() => handleOpenQcModal(row)}
>
{t("Put Away Detail")}
</Button>
)}
{showManageActions && !row.isCancelled && (
<Button
size="small"
variant="contained"
disabled={!canManage || busy}
onClick={() => handleComplete(row)}
startIcon={
busy ? (
<CircularProgress size={14} color="inherit" />
) : undefined
}
>
{t("Update Job Order")}
</Button>
)}
{showManageActions && !row.isCancelled && (
<Button
size="small"
variant="outlined"
color="warning"
disabled={!canManage || busy}
onClick={() => handleCancel(row)}
>
{t("Cancel Job Order")}
</Button>
)}
</Stack>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={filteredRows.length}
page={page}
onPageChange={(_, p) => setPage(p)}
rowsPerPage={pageSize}
onRowsPerPageChange={(e) => {
setPageSize(parseInt(e.target.value, 10));
setPage(0);
}}
rowsPerPageOptions={[5, 10, 25, 50]}
labelRowsPerPage={t("Rows per page")}
/>
</>
)}
</CardContent>
</Card>

<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={() => void onConfirm()}
disabled={confirmLoading || !pendingAction}
>
{confirmLoading ? t("Processing...") : t("Confirm")}
</Button>
</DialogActions>
</Dialog>

<QcStockInModal
session={sessionToken}
open={qcModalOpen}
onClose={() => {
setQcModalOpen(false);
setQcModalInfo(undefined);
}}
inputDetail={qcModalInfo}
printerCombo={printerCombo}
warehouse={[]}
printSource="productionProcess"
uiMode="default"
/>
</LocalizationProvider>
);
};

export default JobOrderOpsTable;

+ 29
- 52
src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx Voir le fichier

@@ -1,5 +1,5 @@
"use client";
import React, { useCallback, useEffect, useState, useMemo } from "react";
import React, { useCallback, useEffect, useRef, useState, useMemo } from "react";
import {
Box,
Button,
@@ -23,7 +23,7 @@ import {
} from "@mui/material";
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { useTranslation } from "react-i18next";
import { fetchProductProcessesByJobOrderId ,deleteJobOrder, setJobOrderHidden, updateProductProcessPriority, updateJoPlanStart,updateJoReqQty,newProductProcessLine,JobOrderLineInfo} from "@/app/api/jo/actions";
import { fetchProductProcessesByJobOrderId , 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";
@@ -36,7 +36,7 @@ import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutli
import DoDisturbAltRoundedIcon from '@mui/icons-material/DoDisturbAltRounded';
import { fetchInventories } from "@/app/api/inventory/actions";
import { InventoryResult } from "@/app/api/inventory";
import { releaseJo, startJo } from "@/app/api/jo/actions";
import { releaseJoForWorkbench } from "@/app/api/jo/workbenchActions";
import JobPickExecutionsecondscan from "../Jodetail/JobPickExecutionsecondscan";
import ProcessSummaryHeader from "./ProcessSummaryHeader";
import EditIcon from "@mui/icons-material/Edit";
@@ -53,6 +53,7 @@ interface ProductProcessJobOrderDetailProps {
initialTabIndex?: number;
}

/** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.0 | 2026-08-03 */
const ProductionProcessJobOrderDetail: React.FC<ProductProcessJobOrderDetailProps> = ({
jobOrderId,
onBack,
@@ -276,25 +277,13 @@ 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 cancelInFlightRef = useRef(false);

const handleConfirmCancelJobOrder = useCallback(async () => {
if (cancelInFlightRef.current) return;
cancelInFlightRef.current = true;
setCancelLoading(true);
try {
await setJobOrderHidden(jobOrderId, true);
@@ -302,17 +291,27 @@ const handleConfirmCancelJobOrder = useCallback(async () => {
onBack();
} finally {
setCancelLoading(false);
cancelInFlightRef.current = false;
}
}, [jobOrderId, onBack]);
const handleRelease = useCallback(async ( jobOrderId: number) => {
// TODO: 替换为实际的 release 调用
console.log("Release clicked for jobOrderId:", jobOrderId);
const response = await releaseJo({ id: jobOrderId })
if (response) {
//setProcessData(response.entity);
await fetchData();
const releaseInFlightRef = useRef(false);
const [isReleasing, setIsReleasing] = useState(false);

const handleRelease = useCallback(async (jobOrderId: number) => {
if (releaseInFlightRef.current) return;
releaseInFlightRef.current = true;
setIsReleasing(true);
try {
// Workbench no-hold release: defer SPL/SOL/hold until first pick assign
const response = await releaseJoForWorkbench({ id: jobOrderId });
if (response) {
await fetchData();
}
} finally {
setIsReleasing(false);
releaseInFlightRef.current = false;
}
}, [jobOrderId]);
}, [fetchData]);
const handleTabChange = useCallback<NonNullable<TabsProps["onChange"]>>(
(_e, newValue) => {
setTabIndex(newValue);
@@ -722,21 +721,12 @@ 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 && jobOrderPlanning && (
<Button
variant="contained"
color="error"
onClick={() => setDeleteConfirmOpen(true)}
>
{t("Delete Job Order")}
</Button>
)}
{fromJosave && !jobOrderPlanning && (
{fromJosave && (
<Button
variant="contained"
color="warning"
onClick={() => setCancelConfirmOpen(true)}
disabled={isPutAwayed}
disabled={isPutAwayed || cancelLoading}
>
{t("Cancel Job Order")}
</Button>
@@ -746,8 +736,8 @@ const handleRelease = useCallback(async ( jobOrderId: number) => {
variant="contained"
color="primary"
onClick={() => handleRelease(jobOrderId)}
//disabled={stockCounts.insufficient > 0 || processData?.jobOrderStatus !== "planning"}
disabled={processData?.jobOrderStatus !== "planning"}
disabled={processData?.jobOrderStatus !== "planning" || isReleasing}
startIcon={isReleasing ? <CircularProgress size={16} color="inherit" /> : undefined}
>
{t("Release")}
</Button>
@@ -984,19 +974,6 @@ 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>


+ 747
- 197
src/components/ProductionProcess/ProductionProcessList.tsx
Fichier diff supprimé car celui-ci est trop grand
Voir le fichier


+ 23
- 71
src/components/ProductionProcess/ProductionProcessPage.tsx Voir le fichier

@@ -8,13 +8,13 @@ import QcStockInModal from "@/components/Qc/QcStockInModal";
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 JobProcessStatus from "@/components/ProductionProcess/JobProcessStatus";
import OperatorKpiDashboard from "@/components/ProductionProcess/OperatorKpiDashboard";
import EquipmentStatusDashboard from "@/components/ProductionProcess/EquipmentStatusDashboard";
import DrinkProductionQtyDashboard from "@/components/ProductionProcess/DrinkProductionQtyDashboard";
import JobOrderOpsTable from "@/components/ProductionProcess/JobOrderOpsTable";
import type { PrinterCombo } from "@/app/api/settings/printer";
import { useTranslation } from "react-i18next";

@@ -26,25 +26,18 @@ const STORAGE_KEY = 'productionProcess_selectedMatchingStock';

/** FP-MTMS Version Checklist | Functions Ref. No. 26 | v1.0.0 | 2026-07-20 */
const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCombo }) => {
const { t } = useTranslation(["common"]);
const { t } = useTranslation(["common", "productionProcess"]);
const [selectedProcessId, setSelectedProcessId] = useState<number | null>(null);
const [selectedMatchingStock, setSelectedMatchingStock] = useState<{
jobOrderId: number;
productProcessId: number;
pickOrderId: number;
} | null>(null);
/** 0 = Production Process list; 1 = JO ops table; 2..5 = dashboards */
const [tabIndex, setTabIndex] = useState(0);
/** 列表搜索/分頁:保留在切換工單詳情時,返回後仍為同一條件 */
const [productionListState, setProductionListState] = useState(() => ({
...createDefaultProductionProcessListPersistedState(),
// date: "",
}));
const [waitingPutawayListState, setWaitingPutawayListState] = useState(
createDefaultProductionProcessListPersistedState,
);
const [putawayedListState, setPutawayedListState] = useState(
createDefaultProductionProcessListPersistedState,
);
const { data: session } = useSession() as { data: SessionWithTokens | null };
const sessionToken = session as SessionWithTokens | null;
const searchParams = useSearchParams();
@@ -53,22 +46,18 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo
const [linkQcOpen, setLinkQcOpen] = useState(false);
const [linkQcSilId, setLinkQcSilId] = useState<number | null>(null);

// Add printer selection state
const [selectedPrinter, setSelectedPrinter] = useState<PrinterCombo | null>(
printerCombo && printerCombo.length > 0 ? printerCombo[0] : null
);

// 从 sessionStorage 恢复状态(仅在客户端)
useEffect(() => {
if (typeof window !== 'undefined') {
try {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
// 验证数据有效性
if (parsed && typeof parsed.jobOrderId === 'number' && typeof parsed.productProcessId === 'number') {
setSelectedMatchingStock(parsed);
console.log(" Restored selectedMatchingStock from sessionStorage:", parsed);
}
}
} catch (error) {
@@ -78,19 +67,16 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo
}
}, []);

// 保存状态到 sessionStorage
useEffect(() => {
if (typeof window !== 'undefined') {
if (selectedMatchingStock) {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(selectedMatchingStock));
console.log(" Saved selectedMatchingStock to sessionStorage:", selectedMatchingStock);
} else {
sessionStorage.removeItem(STORAGE_KEY);
}
}
}, [selectedMatchingStock]);

// 处理返回列表时清除存储
const handleBackFromSecondScan = useCallback(() => {
setSelectedMatchingStock(null);
if (typeof window !== 'undefined') {
@@ -104,7 +90,7 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo

const openStockInLineIdQ = searchParams.get("openStockInLineId");

/** Deep link from nav alert: /productionProcess?openStockInLineId=… → 「完成QC工單」tab + FG QC modal */
/** Deep link: /productionProcess?openStockInLineId=… → list tab pending_qc + FG QC modal */
useEffect(() => {
if (!openStockInLineIdQ) {
setLinkQcOpen(false);
@@ -115,7 +101,12 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo
if (!Number.isFinite(id) || id <= 0) return;
setSelectedProcessId(null);
setSelectedMatchingStock(null);
setTabIndex(1);
setTabIndex(0);
setProductionListState((prev) => ({
...prev,
pickBucket: "pending_qc",
page: 0,
}));
setLinkQcSilId(id);
setLinkQcOpen(true);
}, [openStockInLineIdQ]);
@@ -129,6 +120,9 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo
router.replace(q ? `${pathname}?${q}` : pathname, { scroll: false });
}, [pathname, router, searchParams]);

const listTab = String(productionListState.pickBucket || "needs_action");
const showPrinterBar = tabIndex === 0 && listTab === "pending_qc";

if (selectedMatchingStock) {
return (
<JobPickExecutionsecondscan
@@ -154,8 +148,7 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo
return (
<>
<Box>
{/* Header section with printer selection */}
{tabIndex === 1 && (
{showPrinterBar && (
<Box sx={{
p: 1,
borderBottom: '1px solid #e0e0e0',
@@ -205,8 +198,7 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo

<Tabs value={tabIndex} onChange={handleTabChange} sx={{ mb: 2 }}>
<Tab label={t("Production Process")} />
<Tab label={t("Waiting QC Put Away Job Orders")} />
<Tab label={t("Put Awayed Job Orders")} />
<Tab label={t("Job Order Ops Table")} />
<Tab label={t("Job Process Status Dashboard")} />
<Tab label={t("Operator KPI Dashboard")} />
<Tab label={t("Production Equipment Status Dashboard")} />
@@ -215,9 +207,7 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo

{tabIndex === 0 && (
<ProductionProcessList
printerCombo={printerCombo}
qcReady={false}
disableDateFilter={false}
printerCombo={selectedPrinter ? [selectedPrinter] : printerCombo}
listPersistedState={productionListState}
onListPersistedStateChange={setProductionListState}
onSelectProcess={(jobOrderId) => {
@@ -237,62 +227,24 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo
)}

{tabIndex === 1 && (
<ProductionProcessList
printerCombo={printerCombo}
qcReady={true}
includePutaway={true}
putawayStatus="notCompleted"
listPersistedState={waitingPutawayListState}
onListPersistedStateChange={setWaitingPutawayListState}
<JobOrderOpsTable
printerCombo={selectedPrinter ? [selectedPrinter] : printerCombo}
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,
});
if (jobOrderId != null) setSelectedProcessId(jobOrderId);
}}
/>
)}

{tabIndex === 2 && (
<ProductionProcessList
printerCombo={printerCombo}
qcReady={true}
includePutaway={true}
putawayStatus="completed"
listPersistedState={putawayedListState}
onListPersistedStateChange={setPutawayedListState}
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 === 3 && (
<JobProcessStatus />
)}
{tabIndex === 4 && (
{tabIndex === 3 && (
<OperatorKpiDashboard />
)}
{tabIndex === 5 && (
{tabIndex === 4 && (
<EquipmentStatusDashboard />
)}
{tabIndex === 6 && (
{tabIndex === 5 && (
<DrinkProductionQtyDashboard />
)}
</Box>
@@ -310,4 +262,4 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo
);
};

export default ProductionProcessPage;
export default ProductionProcessPage;

+ 48
- 1
src/config/reportConfig.ts Voir le fichier

@@ -1,3 +1,4 @@
/** FP-MTMS Version Checklist | Functions Ref. No. 39 | v1.0.0 | 2026-08-03 */
export type FieldType = 'date' | 'text' | 'select' | 'number' | 'checkbox';

import { NEXT_PUBLIC_API_URL } from "@/config/api";
@@ -224,7 +225,7 @@ export const REPORTS: ReportDefinition[] = [
{ label: "出貨日期:由 Last Out Date Start", name: "lastOutDateStart", type: "date", required: false },
{ label: "出貨日期:至 Last Out Date End", name: "lastOutDateEnd", type: "date", required: false },
{ label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false},
{ label: "提料 Handler", name: "handler", type: "select", required: false,
{ label: "提料 Handler", name: "handler", type: "select", required: false,
multiple: true,
dynamicOptions: true,
dynamicOptionsEndpoint: `${NEXT_PUBLIC_API_URL}/report/fg-stock-out-traceability-handlers`,
@@ -336,4 +337,50 @@ export const REPORTS: ReportDefinition[] = [
},
],
},
{
id: "rep-016",
title: "成品出倉揀貨合規報告",
apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-do-user-pick-audit`,
responseType: "excel",
fields: [
{ label: "日期 Date", name: "dateStart", type: "date", required: true },
{
label: "提料人 Handler",
name: "handler",
type: "select",
required: false,
multiple: true,
dynamicOptions: true,
dynamicOptionsEndpoint: `${NEXT_PUBLIC_API_URL}/report/do-user-pick-audit-handlers`,
options: [],
},
{ label: "提票號碼", name: "ticketNo", type: "text", required: false },
{ label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false },
{
label: "樓層",
name: "storeId",
type: "select",
required: false,
options: [
{ label: "2F", value: "2F" },
{ label: "4F", value: "4F" },
],
},
],
},
{
id: "rep-017",
title: "店鋪訂單補貨記錄",
apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/shop-order-replenishment`,
responseType: "excel",
fields: [
// { label: "補貨日期:由 Reorder Date Start", name: "reorderDateStart", type: "date", required: false },
//{ label: "補貨日期:至 Reorder Date End", name: "reorderDateEnd", type: "date", required: false },
{ label: "店鋪訂單日期:由 Shop Order Date Start", name: "shopOrderDateStart", type: "date", required: false },
{ label: "店鋪訂單日期:至 Shop Order Date End", name: "shopOrderDateEnd", type: "date", required: false },
//{ label: "送貨日期:由 Delivered Date Start", name: "deliveredDateStart", type: "date", required: false },
//{ label: "送貨日期:至 Delivered Date End", name: "deliveredDateEnd", type: "date", required: false },
{ label: "店鋪編號 Shop Code", name: "shopCode", type: "text", required: false, placeholder: "e.g. S001" },
],
},
]

+ 28
- 1
src/i18n/en/productionProcess.json Voir le fichier

@@ -62,7 +62,7 @@
"Not Started": "Not Started",
"cancelled": "Cancelled",
"in_progress": "In Progress",
"pending": "Pending",
"pending": "Awaiting production",
"stopped": "Stopped",
"Invalid Job Order Id": "Invalid Job Order Id",
"Invalid Stock In Line Id": "Invalid Stock In Line Id",
@@ -76,6 +76,20 @@
"Job Order Info": "Job Order Info",
"Job Order No.": "Job Order No.",
"Job Order and Product": "Job Order and Product",
"Issue": "Issue",
"Job Order Ops Table": "Job Order Ops Table",
"Pending (picked)": "Picked",
"Pending (not picked)": "Not picked",
"Processing (picked)": "Picked",
"Processing (not picked)": "Not picked",
"Picked": "Picked",
"Not picked": "Not picked",
"Stop (paused)": "Stop (paused)",
"Cancelled": "Cancelled",
"Reload data": "Reload data",
"Rows per page": "Rows per page",
"Processing...": "Processing...",
"Actions": "Actions",
"Job Order Production Process": "Job Order Production Process",
"Job Process Status Dashboard": "Job Process Status Dashboard",
"Drink Production Qty Dashboard": "Drink Production Qty Dashboard",
@@ -198,6 +212,16 @@
"Total Time": "Total Time",
"Total finished QC job orders": "Total finished QC job orders",
"Total job orders": "Total job orders",
"Including carried over": "Including carried over",
"Carried over from past day": "Carried over from past day",
"All unfinished": "Needs action",
"Needs action": "Needs action",
"Not picked · Not started": "Not picked · Not started",
"Picked · Not started": "Picked · Not started",
"Picked · In production": "Picked · In production",
"Not picked · In production": "Not picked · In production",
"Powder_Mixture": "Powder Mixture",
"Processing": "Processing",
"Total lines: ": "Total lines: ",
"Type": "Type",
"Unable to get user ID": "Unable to get user ID",
@@ -212,6 +236,9 @@
"View Details": "View Details",
"Wait Time": "Wait Time",
"Waiting QC Put Away Job Orders": "Waiting QC Put Away Job Orders",
"Waiting QC Put Away": "Waiting QC Put Away",
"Put Awayed": "Put Awayed",
"Put Away Detail": "Put Away Detail",
"all": "All",
"drink": "Drink",
"id": "ID",


+ 1
- 1
src/i18n/zh/itemTracing.json Voir le fichier

@@ -190,7 +190,7 @@
"jobOrder": "工單",
"deliveryOrder": "送貨單",
"deliveryNoteCode": "送貨單據號 (DN)",
"ticketNo": "票號",
"ticketNo": "票號",
"variance": "差異",
"before": "帳面數量",
"after": "核准數量",


+ 28
- 1
src/i18n/zh/productionProcess.json Voir le fichier

@@ -62,7 +62,7 @@
"Not Started": "未開始",
"cancelled": "已取消",
"in_progress": "進行中",
"pending": "待處理",
"pending": "待生產",
"stopped": "已停止",
"Invalid Job Order Id": "無效工單編號",
"Invalid Stock In Line Id": "無效庫存行ID",
@@ -76,6 +76,20 @@
"Job Order Info": "工單信息",
"Job Order No.": "工單編號",
"Job Order and Product": "工單及貨品",
"Issue": "異常",
"Job Order Ops Table": "查看工單流程情況",
"Pending (picked)": "已提料",
"Pending (not picked)": "未提料",
"Processing (picked)": "已提料",
"Processing (not picked)": "未提料",
"Picked": "已提料",
"Not picked": "未提料",
"Stop (paused)": "暫停中",
"Cancelled": "已取消",
"Reload data": "重新載入",
"Rows per page": "每頁列數",
"Processing...": "處理中...",
"Actions": "操作",
"Job Order Production Process": "工單生產流程",
"Job Process Status Dashboard": "儀表板 - 工單狀態",
"Drink Production Qty Dashboard": "儀表板 - 飲料生產量數",
@@ -198,6 +212,16 @@
"Total Time": "總時間",
"Total finished QC job orders": "總完成QC工單數量",
"Total job orders": "總工單數量",
"Including carried over": "含過去轉來",
"Carried over from past day": "過去轉來的工單",
"All unfinished": "需處理",
"Needs action": "需處理",
"Not picked · Not started": "未提料 · 未開工",
"Picked · Not started": "已提料 · 未開工",
"Picked · In production": "已提料 · 未完成生產",
"Not picked · In production": "未提料 · 未完成生產",
"Powder_Mixture": "箱料粉",
"Processing": "生產中",
"Total lines: ": "總數量:",
"Type": "類型",
"Unable to get user ID": "無法獲取用戶ID",
@@ -212,6 +236,9 @@
"View Details": "查看詳情",
"Wait Time": "等待時間",
"Waiting QC Put Away Job Orders": "待QC上架工單",
"Waiting QC Put Away": "待QC上架",
"Put Awayed": "已上架",
"Put Away Detail": "上架詳情",
"all": "全部",
"drink": "飲料",
"id": "ID",


Chargement…
Annuler
Enregistrer