Bläddra i källkod

Merge branch 'production' into bomUpdateTest

bomUpdateTest
CANCERYS\kw093 6 timmar sedan
förälder
incheckning
a3493b9249
20 ändrade filer med 1095 tillägg och 568 borttagningar
  1. +1
    -1
      src/app/api/jo/actions.ts
  2. +18
    -1
      src/authorities.ts
  3. +1
    -1
      src/components/AppBar/Profile.tsx
  4. +17
    -35
      src/components/DoWorkbench/DoWorkbenchTabs.tsx
  5. +2
    -2
      src/components/InventorySearch/InventoryLotLineTable.tsx
  6. +6
    -5
      src/components/NavigationContent/JobOrderFgStockInNavAlerts.tsx
  7. +10
    -14
      src/components/PoDetail/PoDetail.tsx
  8. +11
    -16
      src/components/PoDetail/QcStockInModal.tsx
  9. +409
    -112
      src/components/ProductionProcess/JobOrderOpsTable.tsx
  10. +26
    -22
      src/components/ProductionProcess/ProductionProcessDetail.tsx
  11. +1
    -1
      src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx
  12. +336
    -237
      src/components/ProductionProcess/ProductionProcessList.tsx
  13. +7
    -59
      src/components/ProductionProcess/ProductionProcessPage.tsx
  14. +13
    -22
      src/components/Qc/QcStockInModal.tsx
  15. +149
    -0
      src/components/common/PrinterSelect.tsx
  16. +62
    -34
      src/hooks/useJobOrderFgStockInAlerts.ts
  17. +8
    -0
      src/i18n/en/productionProcess.json
  18. +2
    -0
      src/i18n/en/purchaseOrder.json
  19. +14
    -6
      src/i18n/zh/productionProcess.json
  20. +2
    -0
      src/i18n/zh/purchaseOrder.json

+ 1
- 1
src/app/api/jo/actions.ts Visa fil

@@ -905,7 +905,7 @@ export const fetchAllJoborderProductProcessInfo = cache(async (type?: string | n
);
});

/** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.0 | 2026-08-03 */
/** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.5 | 2026-08-06 */
export const fetchJoborderProductProcessesPage = cache(async (params: {
/** Job order / process date(YYYY-MM-DD) */
date?: string | null;


+ 18
- 1
src/authorities.ts Visa fil

@@ -19,5 +19,22 @@ export const AUTH = {
JOB_PICK: "JOB_PICK",
JOB_MAT: "JOB_MAT",
JOB_PROD: "JOB_PROD",
/**
* FP-MTMS Version Checklist | Functions Ref. No. 51 | v1.0.0 | 2026-08-05
* 工單 生產流程 完成工單
*/
PRODUCT_PROCESS: "PRODUCT_PROCESS",
REPORT_MGMT: "REPORT_MGMT",
} as const;
} as const;

/**
* FP-MTMS Version Checklist | Functions Ref. No. 51 | v1.0.0 | 2026-08-05
* Match session ability codes (exact, trimmed).
*/
export function hasAbility(
abilities: string[] | undefined | null,
required: string,
): boolean {
const code = required.trim();
return (abilities ?? []).some((a) => String(a).trim() === code);
}

+ 1
- 1
src/components/AppBar/Profile.tsx Visa fil

@@ -45,7 +45,7 @@ const Profile: React.FC<Props> = ({ avatarImageSrc, profileName }) => {
disablePadding: false,
sx: { py: 0, minWidth: 180 },
}}
PaperProps={{ variant: "outlined" }}
PaperProps={{ variant: "outlined", elevation: 0 }}
>
<Typography sx={{ px: 2, py: 1.5, fontWeight: 600, color: "text.secondary", fontSize: "0.875rem" }}>
{profileName}


+ 17
- 35
src/components/DoWorkbench/DoWorkbenchTabs.tsx Visa fil

@@ -1,14 +1,12 @@
"use client";

import {
Autocomplete,
Badge,
Box,
Button,
CircularProgress,
Tab,
Tabs,
TextField,
Tooltip,
Typography,
} from "@mui/material";
@@ -20,6 +18,7 @@ import GoodPickExecutionWorkbenchRecord from "./GoodPickExecutionWorkbenchRecord
import { useTranslation } from "react-i18next";
import WorkbenchTicketReleaseTableTab from "./WorkbenchTicketReleaseTable";
import { Stack } from "@mui/system";
import PrinterSelect from "@/components/common/PrinterSelect";
import Swal from "sweetalert2";
import { printDNWorkbench } from "@/app/api/do/actions";
import {
@@ -251,41 +250,21 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom
mb: 1,
}}
>
<Typography variant="body2" sx={{ minWidth: "fit-content" }}>
{t("A4 Printer")}:
</Typography>
<Autocomplete
options={a4Printers}
getOptionLabel={(option) => option.name || option.label || option.code || `Printer ${option.id}`}
<PrinterSelect
label={t("A4 Printer")}
printers={a4Printers}
value={a4Printer}
onChange={(_, newValue) => setA4Printer(newValue)}
sx={{ minWidth: 200 }}
size="small"
renderInput={(params) => (
<TextField
{...params}
placeholder={t("A4 Printer")}
inputProps={{ ...params.inputProps, readOnly: true }}
/>
)}
onChange={setA4Printer}
placeholder={t("A4 Printer")}
minWidth={200}
/>
<Typography variant="body2" sx={{ minWidth: "fit-content" }}>
{t("Label Printer")}:
</Typography>
<Autocomplete
options={labelPrinters}
getOptionLabel={(option) => option.name || option.label || option.code || `Printer ${option.id}`}
<PrinterSelect
label={t("Label Printer")}
printers={labelPrinters}
value={labelPrinter}
onChange={(_, newValue) => setLabelPrinter(newValue)}
sx={{ minWidth: 200 }}
size="small"
renderInput={(params) => (
<TextField
{...params}
placeholder={t("Label Printer")}
inputProps={{ ...params.inputProps, readOnly: true }}
/>
)}
onChange={setLabelPrinter}
placeholder={t("Label Printer")}
minWidth={200}
/>
<Button
variant="contained"
@@ -420,7 +399,10 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom
);
};

/** FP-MTMS Version Checklist | Functions Ref. No. 20 | v1.0.0 | 2026-07-19 */
/**
* FP-MTMS Version Checklist | Functions Ref. No. 20 | v1.0.0 | 2026-07-19
* FP-MTMS Version Checklist | Functions Ref. No. 55 | v1.0.0 | 2026-08-06
*/
const DoWorkbenchTabs: React.FC<Props> = (props) => (
<Suspense
fallback={


+ 2
- 2
src/components/InventorySearch/InventoryLotLineTable.tsx Visa fil

@@ -59,6 +59,7 @@ interface Props {
onStockAdjustmentSuccess?: () => void | Promise<void>;
}

/** FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.5 | 2026-08-05 */
const InventoryLotLineTable: React.FC<Props> = ({
inventoryLotLines, pagingController, setPagingController, totalCount, inventory,
filterLotNo,
@@ -68,8 +69,7 @@ const InventoryLotLineTable: React.FC<Props> = ({
const { t } = useTranslation(["inventory"]);
const { data: session } = useSession();
const abilities = session?.user?.abilities ?? [];
const canStockAdjust =
abilities.includes(AUTH.ADMIN) || abilities.includes(AUTH.INVENTORY_ADJUST);
const canStockAdjust = abilities.includes(AUTH.INVENTORY_ADJUST);
const PRINT_PRINTER_ID_KEY = 'inventoryLotLinePrintPrinterId';
const { setIsUploading } = useUploadContext();
const [stockTransferModalOpen, setStockTransferModalOpen] = useState(false);


+ 6
- 5
src/components/NavigationContent/JobOrderFgStockInNavAlerts.tsx Visa fil

@@ -1,6 +1,6 @@
"use client";

import { useJobOrderFgStockInAlerts } from "@/hooks/useJobOrderFgStockInAlerts";
import { useJobOrderFgStockInAlerts, PRODUCTION_LOOKBACK_DAYS } from "@/hooks/useJobOrderFgStockInAlerts";
import type { JobOrderFgAlertItem } from "@/hooks/useJobOrderFgStockInAlerts";
import CloseIcon from "@mui/icons-material/Close";
import FactCheckIcon from "@mui/icons-material/FactCheck";
@@ -139,6 +139,7 @@ type Props = {
enabled: boolean;
};

/** FP-MTMS Version Checklist | Functions Ref. No. 52 | v1.0.0 | 2026-08-05 */
const JobOrderFgStockInNavAlerts: React.FC<Props> = ({ enabled }) => {
const { qcItems, putAwayItems, count, loading, reload } = useJobOrderFgStockInAlerts(enabled);
const [open, setOpen] = useState(false);
@@ -152,8 +153,8 @@ const JobOrderFgStockInNavAlerts: React.FC<Props> = ({ enabled }) => {
<Tooltip
title={
count > 0
? `點擊查看:待 QC ${qcItems.length}、待上架 ${putAwayItems.length}(今日/昨日產程、完成QC工單列表資格)`
: "今日/昨日無待 QC/待上架提醒"
? `點擊查看:待 QC ${qcItems.length}、待上架 ${putAwayItems.length}(與「待 QC 上架」tab 相同:今日往前 ${PRODUCTION_LOOKBACK_DAYS} 天)`
: `今日往前 ${PRODUCTION_LOOKBACK_DAYS} 天內無待 QC/待上架提醒`
}
placement="right"
>
@@ -202,8 +203,8 @@ const JobOrderFgStockInNavAlerts: React.FC<Props> = ({ enabled }) => {
)}
</Stack>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
僅含<strong>產程日期為今日或昨日</strong>的工單,且與「完成QC工單」相同條件(該工單<strong>所有工序行</strong>均為
Completed/Pass、有成品入庫且未完成/未拒絕)。待 QC:尚未進入已收貨;待上架:已收貨或部分完成入庫。
與產程頁<strong>「待 QC 上架」</strong>相同資料來源:搜索日為<strong>今日</strong>、往前
<strong> {PRODUCTION_LOOKBACK_DAYS} </strong>天產程;工序全完成、入庫未完成/未拒絕。待 QC:尚未進入已收貨;待上架:已收貨或部分完成入庫。
</Typography>
<IconButton
aria-label="關閉"


+ 10
- 14
src/components/PoDetail/PoDetail.tsx Visa fil

@@ -31,7 +31,6 @@ import {
CardContent,
Radio,
alpha,
Autocomplete,
Dialog,
DialogActions,
DialogContent,
@@ -39,6 +38,7 @@ import {
} from "@mui/material";
import { useTranslation } from "react-i18next";
import { submitDialogWithWarning } from "../Swal/CustomAlerts";
import PrinterSelect from "@/components/common/PrinterSelect";
// import InputDataGrid, { TableRow } from "../InputDataGrid/InputDataGrid";
import {
GridColDef,
@@ -252,6 +252,7 @@ interface PolInputResult {
dnQty: string,
}

/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.0 | 2026-08-06 */
const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
const cameras = useContext(CameraContext);
const { data: session } = useSession();
@@ -1070,19 +1071,14 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
<Card sx={{ display: "block", flex: 1 }}>
<CardContent component={Stack} spacing={2}>
<Typography variant="h6">列印</Typography>
<Autocomplete
disableClearable
options={labelPrinters}
value={selectedPrinter}
onChange={(_event, value) => setSelectedPrinter(value)}
renderInput={(params) => (
<TextField
{...params}
variant="outlined"
label={t("Printer")}
fullWidth
/>
)}
<PrinterSelect
label={t("Label Printer")}
printers={labelPrinters}
value={selectedPrinter ?? null}
onChange={(p) => setSelectedPrinter(p ?? undefined)}
placeholder={t("Label Printer")}
fullWidth
disabled={labelPrinters.length <= 0}
/>
<TextField
variant="outlined"


+ 11
- 16
src/components/PoDetail/QcStockInModal.tsx Visa fil

@@ -1,7 +1,6 @@
"use client";
import { QcItemWithChecks, QcData } from "@/app/api/qc";
import {
Autocomplete,
Box,
Button,
Divider,
@@ -29,6 +28,7 @@ import dayjs from "dayjs";
import { fetchPoQrcode } from "@/app/api/pdf/actions";
import { downloadFile } from "@/app/utils/commonUtil";
import { PrinterCombo } from "@/app/api/settings/printer";
import PrinterSelect from "@/components/common/PrinterSelect";
import { EscalationResult } from "@/app/api/escalation";
import { SessionWithTokens } from "@/config/authConfig";
import { GridRowModesModel } from "@mui/x-data-grid";
@@ -71,6 +71,7 @@ interface CommonProps extends Omit<ModalProps, "children"> {
interface Props extends CommonProps {
// itemDetail: StockInLine & { qcResult?: PurchaseQcResult[] } & { escResult?: EscalationResult[] };
}
/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.0 | 2026-08-06 */
const PoQcStockInModalVer2: React.FC<Props> = ({
open,
onClose,
@@ -658,22 +659,16 @@ const PoQcStockInModalVer2: React.FC<Props> = ({
</Grid>
{tabIndex == 1 && (
<Stack direction="row" justifyContent="flex-end" gap={1} sx={{m:3, mt:"auto"}}>
<Autocomplete
disableClearable
options={printerCombo}
defaultValue={selectedPrinter}
onChange={(event, value) => {
setSelectedPrinter(value)
<Stack direction="row" justifyContent="flex-end" alignItems="center" gap={1} sx={{m:3, mt:"auto"}}>
<PrinterSelect
label={t("Label Printer")}
printers={printerCombo || []}
value={selectedPrinter ?? null}
onChange={(p) => {
if (p) setSelectedPrinter(p);
}}
renderInput={(params) => (
<TextField
{...params}
variant="outlined"
label={t("Printer")}
sx={{ width: 300}}
/>
)}
placeholder={t("Label Printer")}
minWidth={300}
/>
<TextField
variant="outlined"


+ 409
- 112
src/components/ProductionProcess/JobOrderOpsTable.tsx Visa fil

@@ -24,6 +24,8 @@ import {
TablePagination,
TableRow,
Typography,
Avatar,
Tooltip,
} from "@mui/material";
import { useTranslation } from "react-i18next";
import { useSession } from "next-auth/react";
@@ -32,7 +34,8 @@ 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 { AUTH, hasAbility } from "@/authorities";
import PrinterSelect from "@/components/common/PrinterSelect";
import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil";
import {
AllJoborderProductProcessInfoResponse,
@@ -41,6 +44,8 @@ import {
fetchJos,
fetchProductProcessesByJobOrderId,
setJobOrderHidden,
printFGStockInLabel,
PrintFGStockInLabelRequest,
} from "@/app/api/jo/actions";
import { JobOrder } from "@/app/api/jo";
import QcStockInModal from "@/components/Qc/QcStockInModal";
@@ -48,16 +53,28 @@ import { StockInLineInput } from "@/app/api/stockIn";
import type { PrinterCombo } from "@/app/api/settings/printer";

type PrimaryTab =
| "all"
| "needs_action"
| "pending"
| "processing"
| "carried_over"
| "pending_qc"
| "putawayed"
| "issue";
type PendingSubTab = "all" | "picked_not_started" | "not_picked_not_started";
type ProcessingSubTab = "all" | "picked_started" | "not_picked_started";
/** Shared small tabs for 需處理 / 待生產 / 生產中. */
type ProductionSubTab = "all" | "not_picked" | "picked";
type IssueSubTab = "stop" | "cancel";

const NOT_PICKED_BUCKETS = new Set([
"not_picked_not_started",
"not_picked_started",
]);
const PICKED_BUCKETS = new Set(["picked_not_started", "picked_started"]);
const PENDING_BUCKETS = new Set([
"not_picked_not_started",
"picked_not_started",
]);
const PROCESSING_BUCKETS = new Set(["picked_started", "not_picked_started"]);

type OpsRow = {
key: string;
jobOrderId: number;
@@ -78,9 +95,20 @@ type OpsRow = {
sourceProcess?: AllJoborderProductProcessInfoResponse;
};

/** Same as ProductionProcessList: unfinished from (searchDate - 4) .. searchDate. */
const PRODUCTION_LOOKBACK_DAYS = 4;
/** Cards / page size for ops table fetch. */
const FETCH_SIZE = 200;

function isCarriedOver(row: OpsRow, searchDay: Dayjs): boolean {
if (!row.productionDate || !dayjs(row.productionDate).isValid()) return false;
return dayjs(row.productionDate).startOf("day").isBefore(searchDay);
}

function tabLabel(text: string, count: number): string {
return count > 0 ? `${text} (${count})` : text;
}

function isPausedProcess(p: AllJoborderProductProcessInfoResponse): boolean {
return (p.lines ?? []).some(
(l) => String(l.status ?? "").trim().toLowerCase() === "paused",
@@ -162,11 +190,40 @@ function dedupeByJobOrder(rows: OpsRow[]): OpsRow[] {
return Array.from(map.values());
}

/** Prefer pending_qc over production when same JO appears in off-plan merge. */
function dedupeOffPlanRows(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.rowKind !== "pending_qc" && row.rowKind === "pending_qc") {
map.set(row.jobOrderId, row);
continue;
}
if (!existing.isPaused && row.isPaused) {
map.set(row.jobOrderId, row);
}
}
return Array.from(map.values());
}

function isRowPutawayCompleted(row: OpsRow): boolean {
return (
String(row.sourceProcess?.stockInLineStatus ?? "")
.trim()
.toLowerCase() === "completed"
);
}

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

/** FP-MTMS Version Checklist | Functions Ref. No. 47 | v1.0.4 | 2026-08-06 */
const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({
onSelectProcess,
printerCombo = [],
@@ -175,14 +232,43 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({
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);
/** 取消工單:僅 ADMIN */
const canCancel = hasAbility(abilities, AUTH.ADMIN);
/** 完成工單:僅 PRODUCT_PROCESS(工單 生產流程 完成工單) */
const canComplete = hasAbility(abilities, AUTH.PRODUCT_PROCESS);

const labelPrinterCombo = useMemo(
() => (printerCombo || []).filter((p) => p.type === "Label"),
[printerCombo],
);
const printerOptions = useMemo(
() => (labelPrinterCombo.length > 0 ? labelPrinterCombo : printerCombo || []),
[labelPrinterCombo, printerCombo],
);
const [selectedPrinter, setSelectedPrinter] = useState<PrinterCombo | null>(
() => printerOptions[0] ?? null,
);
const printInFlightRef = useRef(false);
const [printingSilId, setPrintingSilId] = useState<number | null>(null);

useEffect(() => {
if (!printerOptions.length) {
setSelectedPrinter(null);
return;
}
setSelectedPrinter((prev) => {
if (prev && printerOptions.some((p) => p.id === prev.id)) return prev;
return printerOptions[0];
});
}, [printerOptions]);

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 [primaryTab, setPrimaryTab] = useState<PrimaryTab>("needs_action");
const [productionSub, setProductionSub] = useState<ProductionSubTab>("all");
const [issueSub, setIssueSub] = useState<IssueSubTab>("stop");

const searchDay = useMemo(() => queryDate.startOf("day"), [queryDate]);

const [productionRows, setProductionRows] = useState<OpsRow[]>([]);
const [pendingQcRows, setPendingQcRows] = useState<OpsRow[]>([]);
const [putawayedRows, setPutawayedRows] = useState<OpsRow[]>([]);
@@ -202,10 +288,9 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({

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,
lookbackDays: PRODUCTION_LOOKBACK_DAYS,
bucket: "all",
qcReady: false,
page: 0,
@@ -213,8 +298,7 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({
});
const rows = (data?.content ?? [])
.map((p) => toProductionRow(p, "production"))
.filter((r): r is OpsRow => r != null)
.filter((r) => !r.productionDate || r.productionDate === dayStr);
.filter((r): r is OpsRow => r != null);
setProductionRows(dedupeByJobOrder(rows));
}, [queryDate]);

@@ -223,7 +307,7 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({
try {
const data = await fetchJoborderProductProcessesPage({
date: dayStr,
lookbackDays: 0,
lookbackDays: PRODUCTION_LOOKBACK_DAYS,
qcReady: true,
includePutaway: true,
putawayStatus: "notCompleted",
@@ -232,8 +316,7 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({
});
const rows = (data?.content ?? [])
.map((p) => toProductionRow(p, "pending_qc"))
.filter((r): r is OpsRow => r != null)
.filter((r) => !r.productionDate || r.productionDate === dayStr);
.filter((r): r is OpsRow => r != null);
setPendingQcRows(dedupeByJobOrder(rows));
} catch (e) {
console.error("loadPendingQc failed", e);
@@ -254,8 +337,7 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({
});
const rows = (data?.content ?? [])
.map((p) => toProductionRow(p, "putawayed"))
.filter((r): r is OpsRow => r != null)
.filter((r) => !r.productionDate || r.productionDate === dayStr);
.filter((r): r is OpsRow => r != null);
setPutawayedRows(dedupeByJobOrder(rows));
} catch (e) {
console.error("loadPutawayed failed", e);
@@ -268,8 +350,8 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({
try {
const res = await fetchJos({
code: "",
planStart: dayStr,
planStartTo: dayStr,
planStart: `${dayStr}T00:00`,
planStartTo: `${dayStr}T23:59:59`,
joSearchStatus: "cancel",
pageNum: 0,
pageSize: FETCH_SIZE,
@@ -302,65 +384,108 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({

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

useEffect(() => {
setProductionSub("all");
}, [primaryTab]);

const offPlanRows = useMemo(() => {
const fromProd = productionRows.filter(
(r) => isCarriedOver(r, searchDay) && !isRowPutawayCompleted(r),
);
const fromQc = pendingQcRows.filter(
(r) => isCarriedOver(r, searchDay) && !isRowPutawayCompleted(r),
);
return dedupeOffPlanRows([...fromProd, ...fromQc]);
}, [productionRows, pendingQcRows, searchDay]);

const applyProductionSub = useCallback((list: OpsRow[], sub: ProductionSubTab) => {
if (sub === "not_picked") {
return list.filter((r) => NOT_PICKED_BUCKETS.has(String(r.pickProcessBucket ?? "")));
}
if (sub === "picked") {
return list.filter((r) => PICKED_BUCKETS.has(String(r.pickProcessBucket ?? "")));
}
return list;
}, []);

const filteredRows = useMemo(() => {
if (primaryTab === "pending_qc") return pendingQcRows;
if (primaryTab === "putawayed") return putawayedRows;
if (primaryTab === "pending_qc") return pendingQcRows;
if (primaryTab === "carried_over") return offPlanRows;
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";
});
list = list.filter((r) => PENDING_BUCKETS.has(String(r.pickProcessBucket ?? "")));
} 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";
});
list = list.filter((r) => PROCESSING_BUCKETS.has(String(r.pickProcessBucket ?? "")));
} else if (primaryTab === "issue" && issueSub === "stop") {
list = list.filter((r) => r.isPaused);
return list.filter((r) => r.isPaused);
}

if (
primaryTab === "needs_action" ||
primaryTab === "pending" ||
primaryTab === "processing"
) {
list = applyProductionSub(list, productionSub);
}
return list;
}, [
primaryTab,
pendingSub,
processingSub,
productionSub,
issueSub,
productionRows,
pendingQcRows,
putawayedRows,
cancelledRows,
offPlanRows,
applyProductionSub,
]);

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 pendingRows = productionRows.filter((r) =>
PENDING_BUCKETS.has(String(r.pickProcessBucket ?? "")),
);
const processingRows = productionRows.filter((r) =>
PROCESSING_BUCKETS.has(String(r.pickProcessBucket ?? "")),
);
const stop = productionRows.filter((r) => r.isPaused).length;

const scopeForSub =
primaryTab === "pending"
? pendingRows
: primaryTab === "processing"
? processingRows
: productionRows;

return {
all: productionRows.length,
pending,
processing,
needsAction: productionRows.length,
pending: pendingRows.length,
processing: processingRows.length,
carriedOver: offPlanRows.length,
pendingQc: pendingQcRows.length,
putawayed: putawayedRows.length,
stop,
cancel: cancelledRows.length,
subAll: scopeForSub.length,
subNotPicked: scopeForSub.filter((r) =>
NOT_PICKED_BUCKETS.has(String(r.pickProcessBucket ?? "")),
).length,
subPicked: scopeForSub.filter((r) =>
PICKED_BUCKETS.has(String(r.pickProcessBucket ?? "")),
).length,
};
}, [productionRows, pendingQcRows, putawayedRows, cancelledRows]);
}, [
productionRows,
pendingQcRows,
putawayedRows,
cancelledRows,
offPlanRows,
primaryTab,
]);

const paginatedRows = useMemo(() => {
const start = page * pageSize;
@@ -403,7 +528,7 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({

const handleComplete = useCallback(
(row: OpsRow) => {
if (!canManage || row.isCancelled) return;
if (!canComplete || row.isCancelled) return;
openConfirm(t("Confirm to update this Job Order?"), async () => {
if (actionInFlightRef.current) return;
actionInFlightRef.current = true;
@@ -428,12 +553,12 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({
}
});
},
[canManage, openConfirm, t, loadData],
[canComplete, openConfirm, t, loadData],
);

const handleCancel = useCallback(
(row: OpsRow) => {
if (!canManage || row.isCancelled) return;
if (!canCancel || row.isCancelled) return;
openConfirm(t("Cancel job order confirm message"), async () => {
if (actionInFlightRef.current) return;
actionInFlightRef.current = true;
@@ -447,7 +572,7 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({
}
});
},
[canManage, openConfirm, t, loadData],
[canCancel, openConfirm, t, loadData],
);

const onConfirm = useCallback(async () => {
@@ -469,6 +594,18 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({
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");
if (primaryTab === "carried_over") {
switch (row.pickProcessBucket) {
case "not_picked_not_started":
case "picked_not_started":
return t("pending");
case "picked_started":
case "not_picked_started":
return t("processing");
default:
break;
}
}
switch (row.pickProcessBucket) {
case "not_picked_not_started":
case "not_picked_started":
@@ -477,14 +614,65 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({
case "picked_started":
return t("Picked");
default:
// Prefer explicit key over t("completed") → purchaseOrder「已上架」
if (String(row.statusLabel).toLowerCase() === "completed") {
return t("Completed");
}
return row.statusLabel || "-";
}
};

const showManageActions =
primaryTab !== "pending_qc" &&
primaryTab !== "putawayed" &&
!(primaryTab === "issue" && issueSub === "cancel");
const showManageActionsForRow = (row: OpsRow) => {
if (row.isCancelled || row.rowKind === "putawayed") return false;
if (row.rowKind === "pending_qc") return false;
if (primaryTab === "pending_qc" || primaryTab === "putawayed") return false;
if (primaryTab === "issue" && issueSub === "cancel") return false;
return true;
};

const showQcActionForRow = (row: OpsRow) =>
(primaryTab === "pending_qc" ||
primaryTab === "putawayed" ||
(primaryTab === "carried_over" && row.rowKind === "pending_qc")) &&
row.stockInLineId != null;

const showPrintLabelForRow = (row: OpsRow) =>
(primaryTab === "putawayed" || row.rowKind === "putawayed") &&
row.stockInLineId != null;

const handlePrintLabel = useCallback(
async (row: OpsRow) => {
if (printInFlightRef.current) return;
if (!selectedPrinter) {
alert(t("Please select a label printer"));
return;
}
if (!row.stockInLineId) {
alert(t("Invalid Stock In Line Id"));
return;
}
printInFlightRef.current = true;
setPrintingSilId(row.stockInLineId);
try {
const data: PrintFGStockInLabelRequest = {
stockInLineId: row.stockInLineId,
printerId: selectedPrinter.id,
printQty: 1,
};
await printFGStockInLabel(data);
alert(t("Print job sent successfully"));
} catch (error: unknown) {
console.error("Error printing:", error);
const msg =
error instanceof Error ? error.message : String(error ?? "Unknown error");
alert(t(`Print failed: ${msg}`));
} finally {
setPrintingSilId(null);
printInFlightRef.current = false;
}
},
[selectedPrinter, t],
);

return (
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-hk">
@@ -510,48 +698,87 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({
</Button>
</Stack>

<Tabs
value={primaryTab}
onChange={(_, v: PrimaryTab) => setPrimaryTab(v)}
variant="scrollable"
scrollButtons="auto"
sx={{ mb: 1, borderBottom: 1, borderColor: "divider" }}
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
spacing={2}
sx={{ mb: 1, flexWrap: "wrap", rowGap: 1 }}
>
<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 }}
value={primaryTab}
onChange={(_, v: PrimaryTab) => setPrimaryTab(v)}
variant="scrollable"
scrollButtons="auto"
sx={{
flex: 1,
minWidth: 0,
borderBottom: 1,
borderColor: "divider",
"& .MuiTabs-flexContainer": {
columnGap: 0.5,
},
"& .MuiTab-root": {
minWidth: "auto",
px: 1,
},
}}
>
<Tab value="all" label={t("All")} />
<Tab value="picked_not_started" label={t("Picked")} />
<Tab value="not_picked_not_started" label={t("Not picked")} />
<Tab
value="needs_action"
label={tabLabel(t("Needs action"), counts.needsAction)}
/>
<Tab
value="pending"
label={tabLabel(t("pending"), counts.pending)}
/>
<Tab
value="processing"
label={tabLabel(t("Processing"), counts.processing)}
/>
<Tab
value="pending_qc"
label={tabLabel(t("Waiting QC Put Away"), counts.pendingQc)}
/>
<Tab
value="putawayed"
label={tabLabel(t("Put Awayed"), counts.putawayed)}
/>
<Tab
value="issue"
label={tabLabel(t("Issue"), counts.stop + counts.cancel)}
/>
<Tab
value="carried_over"
label={tabLabel(t("Off-plan unfinished"), counts.carriedOver)}
/>
</Tabs>
)}
<PrinterSelect
label={t("Label Printer")}
printers={printerOptions}
value={selectedPrinter}
onChange={setSelectedPrinter}
placeholder={t("Label Printer")}
/>
</Stack>

{primaryTab === "processing" && (
{(
["needs_action", "pending", "processing"] as PrimaryTab[]
).includes(primaryTab) && (
<Tabs
value={processingSub}
onChange={(_, v: ProcessingSubTab) => setProcessingSub(v)}
value={productionSub}
onChange={(_, v: ProductionSubTab) => setProductionSub(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")} />
<Tab value="all" label={tabLabel(t("All"), counts.subAll)} />
<Tab
value="not_picked"
label={tabLabel(t("Not picked"), counts.subNotPicked)}
/>
<Tab
value="picked"
label={tabLabel(t("Picked"), counts.subPicked)}
/>
</Tabs>
)}

@@ -561,8 +788,14 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({
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})`} />
<Tab
value="stop"
label={tabLabel(t("Stop (paused)"), counts.stop)}
/>
<Tab
value="cancel"
label={tabLabel(t("Cancelled"), counts.cancel)}
/>
</Tabs>
)}

@@ -576,8 +809,8 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({
<Table size="small" stickyHeader sx={{ minWidth: 800 }}>
<TableHead>
<TableRow>
<TableCell>{t("Job Order")}</TableCell>
<TableCell>{t("Item")}</TableCell>
<TableCell>{t("Job Order Code")}</TableCell>
<TableCell>{t("Item Code")}</TableCell>
<TableCell align="right">{t("Required Qty")}</TableCell>
<TableCell>{t("Production Date")}</TableCell>
<TableCell>{t("Status")}</TableCell>
@@ -596,24 +829,78 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({
) : (
paginatedRows.map((row) => {
const busy = rowBusyIds.has(row.jobOrderId);
const carriedOver = isCarriedOver(row, searchDay);
const carriedCellSx = carriedOver
? {
bgcolor: "warning.light",
borderBottomColor: "warning.main",
}
: undefined;
return (
<TableRow key={row.key} hover>
<TableCell>{row.jobOrderCode}</TableCell>
<TableCell>
<TableRow
key={row.key}
hover={!carriedOver}
sx={
carriedOver
? {
bgcolor: "warning.light",
"&:hover": {
bgcolor: "warning.light",
},
"& > .MuiTableCell-root": {
bgcolor: "warning.light",
},
"&:hover > .MuiTableCell-root": {
bgcolor: "warning.light",
},
}
: undefined
}
>
<TableCell sx={carriedCellSx}>
<Stack
direction="row"
alignItems="center"
spacing={0.75}
>
<Typography variant="body2" component="span">
{row.jobOrderCode}
</Typography>
{carriedOver ? (
<Tooltip title={t("Carried over from past day")}>
<Avatar
aria-label={t("Carried over from past day")}
sx={{
width: 24,
height: 24,
flexShrink: 0,
bgcolor: "error.main",
color: "error.contrastText",
fontSize: "0.9rem",
fontWeight: 800,
}}
>
!
</Avatar>
</Tooltip>
) : null}
</Stack>
</TableCell>
<TableCell sx={carriedCellSx}>
<Typography variant="body2">
{[row.itemCode, row.itemName].filter(Boolean).join(" ")}
</Typography>
</TableCell>
<TableCell align="right">
<TableCell align="right" sx={carriedCellSx}>
{row.requiredQty}
{row.uom ? ` ${row.uom}` : ""}
</TableCell>
<TableCell>
<TableCell sx={carriedCellSx}>
{row.productionDate && dayjs(row.productionDate).isValid()
? dayjs(row.productionDate).format(OUTPUT_DATE_FORMAT)
: "-"}
</TableCell>
<TableCell>
<TableCell sx={carriedCellSx}>
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
{row.isPaused && (
<Chip size="small" color="warning" label={t("Stop (paused)")} />
@@ -625,7 +912,7 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({
/>
</Stack>
</TableCell>
<TableCell align="center">
<TableCell align="center" sx={carriedCellSx}>
<Stack
direction="row"
spacing={1}
@@ -640,29 +927,39 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({
>
{t("View Details")}
</Button>
{primaryTab === "pending_qc" && row.stockInLineId != null && (
{showQcActionForRow(row) && (
<Button
size="small"
variant="contained"
onClick={() => handleOpenQcModal(row)}
>
{t("view stockin")}
{primaryTab === "putawayed" ||
row.rowKind === "putawayed"
? t("Put Away Detail")
: t("view stockin")}
</Button>
)}
{primaryTab === "putawayed" && row.stockInLineId != null && (
{showPrintLabelForRow(row) && (
<Button
size="small"
variant="contained"
onClick={() => handleOpenQcModal(row)}
color="secondary"
disabled={
!selectedPrinter ||
printingSilId === row.stockInLineId
}
onClick={() => void handlePrintLabel(row)}
>
{t("Put Away Detail")}
{printingSilId === row.stockInLineId
? t("Printing")
: t("Print Label")}
</Button>
)}
{showManageActions && !row.isCancelled && (
{showManageActionsForRow(row) && (
<Button
size="small"
variant="contained"
disabled={!canManage || busy}
disabled={!canComplete || busy}
onClick={() => handleComplete(row)}
startIcon={
busy ? (
@@ -673,12 +970,12 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({
{t("Update Job Order")}
</Button>
)}
{showManageActions && !row.isCancelled && (
{showManageActionsForRow(row) && (
<Button
size="small"
variant="outlined"
color="warning"
disabled={!canManage || busy}
disabled={!canCancel || busy}
onClick={() => handleCancel(row)}
>
{t("Cancel Job Order")}


+ 26
- 22
src/components/ProductionProcess/ProductionProcessDetail.tsx Visa fil

@@ -33,6 +33,7 @@ import { Operator, Machine } from "@/app/api/jo";
import { useQrCodeScannerContext } from '../QrCodeScannerProvider/QrCodeScannerProvider';
import { useSession } from "next-auth/react";
import { SessionWithTokens } from "@/config/authConfig";
import { AUTH, hasAbility } from "@/authorities";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import dayjs from "dayjs";
@@ -65,15 +66,19 @@ interface ProductProcessDetailProps {
fromJosave?: boolean;
}

/** FP-MTMS Version Checklist | Functions Ref. No. 50 | v1.0.0 | 2026-08-05 */
const ProductionProcessDetail: React.FC<ProductProcessDetailProps> = ({
jobOrderId,
onBack,
fromJosave,
}) => {
console.log(" ProductionProcessDetail RENDER", { jobOrderId, fromJosave });
// console.log(" ProductionProcessDetail RENDER", { jobOrderId, fromJosave });
const { t } = useTranslation(["productionProcess", "common"]);
const { data: session } = useSession() as { data: SessionWithTokens | null };
const abilities = session?.abilities ?? session?.user?.abilities ?? [];
/** 「已完成」(Just Pass):僅 ADMIN */
const canAdminPass = hasAbility(abilities, AUTH.ADMIN);
const currentUserId = session?.id ? parseInt(session.id) : undefined;
const { values: qrValues, startScan, stopScan, resetScan } = useQrCodeScannerContext();
const [showOutputPage, setShowOutputPage] = useState(false);
@@ -140,10 +145,10 @@ const fetchProcessDetailRef = useRef<() => Promise<void>>();
}, [onBack]);
// 获取 process 和 lines 数据
const fetchProcessDetail = useCallback(async () => {
console.log(" fetchProcessDetail CALLED", { jobOrderId, timestamp: new Date().toISOString() });
// console.log(" fetchProcessDetail CALLED", { jobOrderId, timestamp: new Date().toISOString() });
setLoading(true);
try {
console.log(` Loading process detail for JobOrderId: ${jobOrderId}`);
// console.log(` Loading process detail for JobOrderId: ${jobOrderId}`);
const processesWithLines = await fetchProductProcessesByJobOrderId(jobOrderId);
@@ -157,8 +162,8 @@ const fetchProcessDetailRef = useRef<() => Promise<void>>();
const lines = currentProcess.productProcessLines || [];
setLines(lines);
linesRef.current = lines;
console.log(" Process data loaded:", currentProcess);
console.log(" Lines loaded:", lines);
// console.log(" Process data loaded:", currentProcess);
// console.log(" Lines loaded:", lines);
} catch (error) {
console.error(" Error loading process detail:", error);
onBackRef.current();
@@ -168,12 +173,12 @@ const fetchProcessDetailRef = useRef<() => Promise<void>>();
}, [jobOrderId]);
const handleOpenTimeDialog = useCallback((lineId: number) => {
console.log("🔓 handleOpenTimeDialog CALLED", { lineId, timestamp: new Date().toISOString() });
//console.log("🔓 handleOpenTimeDialog CALLED", { lineId, timestamp: new Date().toISOString() });
// 直接使用 linesRef.current,避免触发 setLines
const line = linesRef.current.find(l => l.id === lineId);
if (line) {
console.log(" Found line:", line);
// console.log(" Found line:", line);
setEditingLineId(lineId);
setTimeValues({
durationInMinutes: line.durationInMinutes || 0,
@@ -181,7 +186,7 @@ const fetchProcessDetailRef = useRef<() => Promise<void>>();
postProdTimeInMinutes: line.postProdTimeInMinutes || 0,
});
setOpenTimeDialog(true);
console.log(" Dialog opened");
// console.log(" Dialog opened");
} else {
console.warn(" Line not found:", lineId);
}
@@ -190,7 +195,7 @@ const fetchProcessDetailRef = useRef<() => Promise<void>>();
fetchProcessDetailRef.current = fetchProcessDetail;
}, [fetchProcessDetail]);
const handleCloseTimeDialog = useCallback(() => {
console.log("🔒 handleCloseTimeDialog CALLED", { timestamp: new Date().toISOString() });
//console.log("🔒 handleCloseTimeDialog CALLED", { timestamp: new Date().toISOString() });
setOpenTimeDialog(false);
setEditingLineId(null);
setTimeValues({
@@ -198,11 +203,11 @@ const fetchProcessDetailRef = useRef<() => Promise<void>>();
prepTimeInMinutes: 0,
postProdTimeInMinutes: 0,
});
console.log(" Dialog closed");
//console.log(" Dialog closed");
}, []);
const handleConfirmTimeUpdate = useCallback(async () => {
console.log("💾 handleConfirmTimeUpdate CALLED", { editingLineId, timeValues, timestamp: new Date().toISOString() });
//console.log("💾 handleConfirmTimeUpdate CALLED", { editingLineId, timeValues, timestamp: new Date().toISOString() });
if (!editingLineId) return;
try {
@@ -223,10 +228,7 @@ const fetchProcessDetailRef = useRef<() => Promise<void>>();
}, [editingLineId, timeValues, fetchProcessDetail, handleCloseTimeDialog, t]);
useEffect(() => {
console.log("🔄 useEffect [jobOrderId] TRIGGERED", {
jobOrderId,
timestamp: new Date().toISOString()
});
//console.log("🔄 useEffect [jobOrderId] TRIGGERED", { jobOrderId,timestamp: new Date().toISOString()});
if (fetchProcessDetailRef.current) {
fetchProcessDetailRef.current();
}
@@ -234,25 +236,26 @@ const fetchProcessDetailRef = useRef<() => Promise<void>>();

// 添加监听 openTimeDialog 变化的 useEffect
useEffect(() => {
console.log(" openTimeDialog changed:", { openTimeDialog, timestamp: new Date().toISOString() });
//console.log(" openTimeDialog changed:", { openTimeDialog, timestamp: new Date().toISOString() });
}, [openTimeDialog]);

// 添加监听 timeValues 变化的 useEffect
useEffect(() => {
console.log(" timeValues changed:", { timeValues, timestamp: new Date().toISOString() });
//console.log(" timeValues changed:", { timeValues, timestamp: new Date().toISOString() });
}, [timeValues]);

// 添加监听 lines 变化的 useEffect
useEffect(() => {
console.log(" lines changed:", { count: lines.length, lines, timestamp: new Date().toISOString() });
//console.log(" lines changed:", { count: lines.length, lines, timestamp: new Date().toISOString() });
}, [lines]);

// 添加监听 editingLineId 变化的 useEffect
useEffect(() => {
console.log(" editingLineId changed:", { editingLineId, timestamp: new Date().toISOString() });
//console.log(" editingLineId changed:", { editingLineId, timestamp: new Date().toISOString() });
}, [editingLineId]);

const handlePassLine = useCallback(async (lineId: number) => {
if (!canAdminPass) return;
try {
await passProductProcessLine(lineId);
// 刷新数据
@@ -261,12 +264,13 @@ const fetchProcessDetailRef = useRef<() => Promise<void>>();
console.error("Error passing line:", error);
alert(t("Failed to pass line. Please try again."));
}
}, [fetchProcessDetail, t]);
}, [canAdminPass, fetchProcessDetail, t]);

const openPassConfirm = useCallback((lineId: number) => {
if (!canAdminPass) return;
setPassConfirmLineId(lineId);
setPassConfirmOpen(true);
}, []);
}, [canAdminPass]);

const closePassConfirm = useCallback(() => {
setPassConfirmOpen(false);
@@ -662,7 +666,7 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => {
const isPaused = statusLower === 'paused';
const isPending = statusLower === 'pending' || status === '';
const isPass = statusLower === 'pass';
const isPassDisabled = isCompleted || isPass;
const isPassDisabled = isCompleted || isPass || !canAdminPass;
return (
<TableRow key={line.id}>
<TableCell>


+ 1
- 1
src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx Visa fil

@@ -53,7 +53,7 @@ interface ProductProcessJobOrderDetailProps {
initialTabIndex?: number;
}

/** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.0 | 2026-08-03 */
/** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.5 | 2026-08-06 */
const ProductionProcessJobOrderDetail: React.FC<ProductProcessJobOrderDetailProps> = ({
jobOrderId,
onBack,


+ 336
- 237
src/components/ProductionProcess/ProductionProcessList.tsx Visa fil

@@ -22,7 +22,6 @@ import {
DialogActions,
Tabs,
Tab,
Badge,
Tooltip,
IconButton,
Avatar,
@@ -37,7 +36,8 @@ 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 { AUTH } from "@/authorities";
import { AUTH, hasAbility } from "@/authorities";
import PrinterSelect from "@/components/common/PrinterSelect";


import {
@@ -48,6 +48,8 @@ import {
assignJobOrderPickOrder,
fetchJoborderProductProcessesPage,
JobOrderProductProcessBucketCounts,
printFGStockInLabel,
PrintFGStockInLabelRequest,
} from "@/app/api/jo/actions";
import { StockInLineInput } from "@/app/api/stockIn";
import { PrinterCombo } from "@/app/api/settings/printer";
@@ -56,6 +58,7 @@ export type ProductionProcessListTab =
| "needs_action"
| "pending"
| "processing"
| "carried_over"
| "pending_qc"
| "putawayed";

@@ -68,7 +71,7 @@ export type ProductionProcessListPersistedState = {
selectedItemCodes: string[];
/**
* Unified list tabs:
* needs_action (= pending+processing) | pending | processing | pending_qc | putawayed
* needs_action | pending | processing | carried_over | pending_qc | putawayed
* Legacy: all → needs_action; fine pick buckets remapped to pending/processing.
*/
pickBucket: ProductionProcessListTab | string;
@@ -136,7 +139,14 @@ export function createDefaultProductionProcessListPersistedState(): ProductionPr

function normalizeListTab(raw: string | undefined | null): ProductionProcessListTab {
const v = (raw || "needs_action").trim();
if (v === "pending" || v === "processing" || v === "pending_qc" || v === "putawayed" || v === "needs_action") {
if (
v === "pending" ||
v === "processing" ||
v === "carried_over" ||
v === "pending_qc" ||
v === "putawayed" ||
v === "needs_action"
) {
return v;
}
if (v === "all") return "needs_action";
@@ -145,7 +155,30 @@ function normalizeListTab(raw: string | undefined | null): ProductionProcessList
return "needs_action";
}

/** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.0 | 2026-08-03 */
function isProcessCarriedOver(
p: AllJoborderProductProcessInfoResponse,
searchDay: ReturnType<typeof dayjs> | null,
): boolean {
if (!searchDay || !p.date || !dayjs(p.date).isValid()) return false;
return dayjs(p.date).startOf("day").isBefore(searchDay);
}

function isPutawayCompleted(
p: AllJoborderProductProcessInfoResponse,
): boolean {
return String(p.stockInLineStatus ?? "").trim().toLowerCase() === "completed";
}

/** Waiting QC put-away (has SIL, not completed/rejected). */
function isWaitingQcPutAway(
p: AllJoborderProductProcessInfoResponse,
): boolean {
if (p.stockInLineId == null) return false;
const s = String(p.stockInLineStatus ?? "").trim().toLowerCase();
return s !== "completed" && s !== "rejected";
}

/** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.5 | 2026-08-06 */
const ProductProcessList: React.FC<ProductProcessListProps> = ({
onSelectProcess,
printerCombo,
@@ -154,11 +187,19 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({
listPersistedState,
onListPersistedStateChange,
}) => {
const { t } = useTranslation( ["common", "productionProcess","purchaseOrder","dashboard"]);
const { t } = useTranslation( ["common", "productionProcess","purchaseOrder","dashboard","jo"]);
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 [productionCache, setProductionCache] = useState<
AllJoborderProductProcessInfoResponse[]
>([]);
const [pendingQcCache, setPendingQcCache] = useState<
AllJoborderProductProcessInfoResponse[]
>([]);
const [putawayedCache, setPutawayedCache] = useState<
AllJoborderProductProcessInfoResponse[]
>([]);
const [bucketCounts, setBucketCounts] =
useState<JobOrderProductProcessBucketCounts>(EMPTY_BUCKET_COUNTS);
const [pendingQcCount, setPendingQcCount] = useState(0);
@@ -168,24 +209,43 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({
const [modalInfo, setModalInfo] = useState<StockInLineInput>();
const currentUserId = session?.id ? parseInt(session.id) : undefined;
const abilities = session?.abilities ?? session?.user?.abilities ?? [];
// 依照 DB `authority.authority = 'ADMIN'` 的逻辑:僅 abilities 明確包含 ADMIN 才能操作
const canManageUpdateJo = abilities.some((a) => a.trim() === AUTH.ADMIN);
/** 完成工單:僅 PRODUCT_PROCESS(工單 生產流程 完成工單);不含 ADMIN 以免群組 ADMIN 繞過勾選 */
const canManageUpdateJo = hasAbility(abilities, AUTH.PRODUCT_PROCESS);
type ProcessFilter = "all" | "drink" | "Powder_Mixture" | "other";

const listTab = normalizeListTab(listPersistedState.pickBucket);
const isProductionTab =
listTab === "needs_action" || listTab === "pending" || listTab === "processing";
const qcReady = listTab === "pending_qc" || listTab === "putawayed";
const putawayStatus =
listTab === "putawayed"
? "completed"
: listTab === "pending_qc"
? "notCompleted"
: null;
const includePutaway = qcReady ? true : null;
/** Production unfinished tabs: carry-over + pick buckets. Pending QC: carry-over only. */
const isCarriedOverTab = listTab === "carried_over";
/** Production unfinished tabs + pending QC + off-plan: carry-over window. */
const enableCarryOver =
!disableDateFilter && (isProductionTab || listTab === "pending_qc");
!disableDateFilter &&
(isProductionTab || listTab === "pending_qc" || isCarriedOverTab);

const labelPrinterCombo = useMemo(
() => (printerCombo || []).filter((p) => p.type === "Label"),
[printerCombo],
);
const printerOptions = useMemo(
() => (labelPrinterCombo.length > 0 ? labelPrinterCombo : printerCombo || []),
[labelPrinterCombo, printerCombo],
);
const [selectedPrinter, setSelectedPrinter] = useState<PrinterCombo | null>(
() => printerOptions[0] ?? null,
);
const printInFlightRef = useRef(false);
const [printingSilId, setPrintingSilId] = useState<number | null>(null);

useEffect(() => {
if (!printerOptions.length) {
setSelectedPrinter(null);
return;
}
setSelectedPrinter((prev) => {
if (prev && printerOptions.some((p) => p.id === prev.id)) return prev;
return printerOptions[0];
});
}, [printerOptions]);

const appliedSearch = useMemo(
() => ({
@@ -208,67 +268,12 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({
[appliedSearch.date],
);

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>();
const isDone = (status: unknown) => {
const s = String(status ?? "").trim().toLowerCase();
return s === "completed" || s === "pass";
};
byJobOrder.forEach((jobOrderProcesses, jobOrderId) => {
const hasStockInLine = jobOrderProcesses.some((p) => p.stockInLineId != null);
const packingProcesses = jobOrderProcesses.filter(
(p) => String((p as any).code ?? "").trim() === "包裝"
);
const nonPackingProcesses = jobOrderProcesses.filter(
(p) => String((p as any).code ?? "").trim() !== "包裝"
);
const allNonPackingDone =
nonPackingProcesses.length === 0 ||
nonPackingProcesses.every((p) => {
const lines = p.lines ?? [];
return lines.length > 0 && lines.every((l) => isDone(l.status));
});
const hasOnePackingDone =
packingProcesses.length > 0 &&
packingProcesses.some((p) => {
const lines = p.lines ?? [];
return lines.some((l) => isDone(l.status));
});
const packingOk = packingProcesses.length === 0 ? true : hasOnePackingDone;
result.set(jobOrderId, hasStockInLine && allNonPackingDone && packingOk);
});

return result;
}, [processes]);
const handleAssignPickOrder = useCallback(async (pickOrderId: number, jobOrderId?: number, productProcessId?: number) => {
if (!currentUserId) {
alert(t("Unable to get user ID"));
@@ -349,110 +354,146 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({
}));
}, [disableDateFilter, onListPersistedStateChange]);

const fetchProcesses = useCallback(async () => {
/** Load all tab datasets once per search; tab switches use cache only. */
const loadAllCaches = useCallback(async () => {
setLoading(true);
try {
const typeParam = filter === "all" ? undefined : filter;
// Production tabs share one fetch (bucket=all); pending/processing filter client-side.
const data = await fetchJoborderProductProcessesPage({
const base = {
date: disableDateFilter ? undefined : appliedSearch.date,
itemCode: appliedSearch.itemCode,
jobOrderCode: appliedSearch.jobOrderCode,
qcReady,
includePutaway,
putawayStatus,
type: typeParam,
lookbackDays: enableCarryOver ? PRODUCTION_LOOKBACK_DAYS : undefined,
bucket: isProductionTab ? "all" : undefined,
page: 0,
size: FETCH_SIZE,
});

setProcesses(data?.content || []);
setTotalJobOrders(data?.totalJobOrders || 0);
if (isProductionTab && data?.bucketCounts) {
setBucketCounts(data.bucketCounts);
}
if (qcReady && putawayStatus === "notCompleted") {
setPendingQcCount(data?.totalJobOrders || 0);
}
if (qcReady && putawayStatus === "completed") {
setPutawayedCount(data?.totalJobOrders || 0);
}
setCarriedOverCount(data?.carriedOverCount ?? 0);
};
const lookback = disableDateFilter ? undefined : PRODUCTION_LOOKBACK_DAYS;

const [prod, pendingQc, putawayed] = await Promise.all([
fetchJoborderProductProcessesPage({
...base,
qcReady: false,
lookbackDays: lookback,
bucket: "all",
}),
fetchJoborderProductProcessesPage({
...base,
qcReady: true,
includePutaway: true,
putawayStatus: "notCompleted",
lookbackDays: lookback,
}),
fetchJoborderProductProcessesPage({
...base,
qcReady: true,
includePutaway: true,
putawayStatus: "completed",
}),
]);

const prodContent = (prod?.content || []).filter((p) => !isPutawayCompleted(p));
const qcContent = (pendingQc?.content || []).filter((p) => !isPutawayCompleted(p));
const putawayContent = putawayed?.content || [];

setProductionCache(prodContent);
setPendingQcCache(qcContent);
setPutawayedCache(putawayContent);
if (prod?.bucketCounts) setBucketCounts(prod.bucketCounts);
else setBucketCounts(EMPTY_BUCKET_COUNTS);
setPendingQcCount(pendingQc?.totalJobOrders || 0);
setPutawayedCount(putawayed?.totalJobOrders || 0);
setCarriedOverCount(prod?.carriedOverCount ?? 0);
} catch (e) {
console.error(e);
setProcesses([]);
setTotalJobOrders(0);
if (isProductionTab) setBucketCounts(EMPTY_BUCKET_COUNTS);
setProductionCache([]);
setPendingQcCache([]);
setPutawayedCache([]);
setBucketCounts(EMPTY_BUCKET_COUNTS);
setPendingQcCount(0);
setPutawayedCount(0);
setCarriedOverCount(0);
} finally {
setLoading(false);
}
}, [
appliedSearch,
disableDateFilter,
filter,
qcReady,
includePutaway,
putawayStatus,
enableCarryOver,
isProductionTab,
]);
}, [appliedSearch, disableDateFilter, filter]);

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

const offPlanRows = useMemo(() => {
if (!searchDay) return [] as AllJoborderProductProcessInfoResponse[];
const merged = [...pendingQcCache, ...productionCache].filter((p) =>
isProcessCarriedOver(p, searchDay),
);
const byJo = new Map<number, AllJoborderProductProcessInfoResponse>();
for (const p of merged) {
if (p.jobOrderId == null) continue;
const existing = byJo.get(p.jobOrderId);
if (!existing) {
byJo.set(p.jobOrderId, p);
} else if (existing.stockInLineId == null && p.stockInLineId != null) {
byJo.set(p.jobOrderId, p);
}
}
return Array.from(byJo.values());
}, [productionCache, pendingQcCache, searchDay]);

/** Keep production + QC tab badges fresh even when not on that tab. */
useEffect(() => {
let cancelled = false;
const typeParam = filter === "all" ? undefined : filter;
const base = {
date: disableDateFilter ? undefined : appliedSearch.date,
itemCode: appliedSearch.itemCode,
jobOrderCode: appliedSearch.jobOrderCode,
type: typeParam,
page: 0,
size: 1,
};
/** Active tab rows from cache — no refetch on tab switch. */
const tabProcesses = useMemo(() => {
if (listTab === "pending_qc") return pendingQcCache;
if (listTab === "putawayed") return putawayedCache;
if (listTab === "carried_over") return offPlanRows;
return productionCache;
}, [listTab, productionCache, pendingQcCache, putawayedCache, offPlanRows]);

(async () => {
try {
const [prod, pendingQc, putawayed] = await Promise.all([
fetchJoborderProductProcessesPage({
...base,
qcReady: false,
lookbackDays: disableDateFilter ? undefined : PRODUCTION_LOOKBACK_DAYS,
bucket: "all",
}),
fetchJoborderProductProcessesPage({
...base,
qcReady: true,
includePutaway: true,
putawayStatus: "notCompleted",
lookbackDays: disableDateFilter ? undefined : PRODUCTION_LOOKBACK_DAYS,
}),
fetchJoborderProductProcessesPage({
...base,
qcReady: true,
includePutaway: true,
putawayStatus: "completed",
}),
]);
if (cancelled) return;
if (prod?.bucketCounts) setBucketCounts(prod.bucketCounts);
setPendingQcCount(pendingQc?.totalJobOrders || 0);
setPutawayedCount(putawayed?.totalJobOrders || 0);
} catch (e) {
console.error(e);
}
})();
// QC 的业务判定:同一个 jobOrder 下,所有 productProcess 的所有 lines 都必须是 Completed/Pass
const jobOrderQcReadyById = useMemo(() => {
const byJobOrder = new Map<number, AllJoborderProductProcessInfoResponse[]>();
for (const p of tabProcesses) {
if (p.jobOrderId == null) continue;
const arr = byJobOrder.get(p.jobOrderId) ?? [];
arr.push(p);
byJobOrder.set(p.jobOrderId, arr);
}

return () => {
cancelled = true;
const result = new Map<number, boolean>();
const isDone = (status: unknown) => {
const s = String(status ?? "").trim().toLowerCase();
return s === "completed" || s === "pass";
};
}, [appliedSearch, disableDateFilter, filter]);

byJobOrder.forEach((jobOrderProcesses, jobOrderId) => {
const hasStockInLine = jobOrderProcesses.some((p) => p.stockInLineId != null);

const packingProcesses = jobOrderProcesses.filter(
(p) => String((p as any).code ?? "").trim() === "包裝",
);
const nonPackingProcesses = jobOrderProcesses.filter(
(p) => String((p as any).code ?? "").trim() !== "包裝",
);

const allNonPackingDone =
nonPackingProcesses.length === 0 ||
nonPackingProcesses.every((p) => {
const lines = p.lines ?? [];
return lines.length > 0 && lines.every((l) => isDone(l.status));
});

const hasOnePackingDone =
packingProcesses.length > 0 &&
packingProcesses.some((p) => {
const lines = p.lines ?? [];
return lines.some((l) => isDone(l.status));
});

const packingOk = packingProcesses.length === 0 ? true : hasOnePackingDone;

result.set(jobOrderId, hasStockInLine && allNonPackingDone && packingOk);
});

return result;
}, [tabProcesses]);

const handleListTabChange = useCallback(
(_: React.SyntheticEvent, value: string) => {
@@ -472,8 +513,10 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({
bucketCounts.pickedStarted + bucketCounts.notPickedStarted;
const needsActionCount = pendingCount + processingCount;

const offPlanTabCount = offPlanRows.length;

const filteredProcesses = useMemo(() => {
let list = processes;
let list = tabProcesses;
if (listTab === "pending") {
list = list.filter((p) =>
PENDING_FINE_BUCKETS.has(String(p.pickProcessBucket ?? "")),
@@ -485,15 +528,21 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({
}
if (selectedItemCodes.length === 0) return list;
return list.filter((p) => selectedItemCodes.includes(p.itemCode));
}, [processes, selectedItemCodes, listTab]);

const displayTotalJobOrders = isProductionTab
? listTab === "pending"
? pendingCount
: listTab === "processing"
? processingCount
: totalJobOrders || needsActionCount
: totalJobOrders;
}, [tabProcesses, selectedItemCodes, listTab]);

const displayTotalJobOrders = isCarriedOverTab
? filteredProcesses.length
: isProductionTab
? listTab === "pending"
? pendingCount
: listTab === "processing"
? processingCount
: needsActionCount
: listTab === "pending_qc"
? pendingQcCount
: listTab === "putawayed"
? putawayedCount
: filteredProcesses.length;

const displayCarriedOverCount = useMemo(() => {
if (!enableCarryOver || !searchDay) return 0;
@@ -578,38 +627,8 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({
}, [loading, filteredProcesses, totalPages]); // eslint-disable-line react-hooks/exhaustive-deps

const renderBucketTabLabel = useCallback(
(labelKey: string, count: number) => (
<Tooltip title={count > 0 ? t(labelKey) + `: ${count}` : t(labelKey)}>
<Box component="span" sx={{ display: "inline-flex", alignItems: "center" }}>
<Badge
color="error"
variant="standard"
badgeContent={count > 99 ? "99+" : count}
invisible={count === 0}
sx={{
"& .MuiBadge-badge": {
fontWeight: 800,
fontSize: "0.7rem",
minWidth: 18,
height: 18,
lineHeight: "18px",
px: 0.5,
right: -8,
top: 2,
},
}}
>
<Typography
component="span"
variant="inherit"
sx={{ pr: count > 0 ? 1 : 0 }}
>
{t(labelKey)}
</Typography>
</Badge>
</Box>
</Tooltip>
),
(labelKey: string, count: number) =>
count > 0 ? `${t(labelKey)} (${count})` : t(labelKey),
[t],
);
const handleUpdateJo = useCallback(async (process: AllJoborderProductProcessInfoResponse) => {
@@ -640,14 +659,14 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({
// await updateJo({ id: process.jobOrderId, status: "completed" });
// 4) 刷新列表
await fetchProcesses();
await loadAllCaches();
} catch (e) {
console.error(e);
alert(t("An error has occurred. Please try again later."));
} finally {
setLoading(false);
}
}, [t, fetchProcesses, canManageUpdateJo]);
}, [t, loadAllCaches, canManageUpdateJo]);

const openConfirm = useCallback((message: string, action: () => Promise<void>) => {
setConfirmMessage(message);
@@ -672,23 +691,51 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({
}
}, [pendingConfirmAction, closeConfirm]);
const closeNewModal = useCallback(() => {
// const response = updateJo({ id: 1, status: "storing" });
setOpenModal(false); // Close the modal first
// fetchProcesses();
// setTimeout(() => {
// }, 300); // Add a delay to avoid immediate re-trigger of useEffect
}, [fetchProcesses]);
setOpenModal(false);
}, []);

const handlePrintLabel = useCallback(
async (process: AllJoborderProductProcessInfoResponse) => {
if (printInFlightRef.current) return;
if (!selectedPrinter) {
alert(t("Please select a label printer"));
return;
}
if (!process.stockInLineId) {
alert(t("Invalid Stock In Line Id"));
return;
}
printInFlightRef.current = true;
setPrintingSilId(process.stockInLineId);
try {
const data: PrintFGStockInLabelRequest = {
stockInLineId: process.stockInLineId,
printerId: selectedPrinter.id,
printQty: 1,
};
await printFGStockInLabel(data);
alert(t("Print job sent successfully"));
} catch (error: any) {
console.error("Error printing:", error);
alert(t(`Print failed: ${error?.message || "Unknown error"}`));
} finally {
setPrintingSilId(null);
printInFlightRef.current = false;
}
},
[selectedPrinter, t],
);

const searchedItemOptions = useMemo(
() =>
Array.from(
new Map(
processes
[...productionCache, ...pendingQcCache, ...putawayedCache]
.filter((p) => !!p.itemCode)
.map((p) => [p.itemCode, { itemCode: p.itemCode, itemName: p.itemName }]),
).values(),
),
[processes],
[productionCache, pendingQcCache, putawayedCache],
);

/** Reset 用 ±3 天;preFilled 用目前已套用的條件(與列表查詢一致) */
@@ -799,40 +846,45 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({
{selectedItemCodes.length > 0 ? ` | ${t("Filtered")}: ${filteredProcesses.length}` : ""}
</Typography>

<Tabs
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
spacing={2}
sx={{ mb: 2, flexWrap: "wrap", rowGap: 1 }}
>
<Tabs
value={listTab}
onChange={handleListTabChange}
variant="scrollable"
scrollButtons="auto"
sx={{
mb: 2,
flex: 1,
minWidth: 0,
borderBottom: 1,
borderColor: "divider",
"& .MuiTabs-flexContainer": {
columnGap: 2,
columnGap: 0.5,
rowGap: 1,
},
"& .MuiTab-root": {
overflow: "visible",
minWidth: "auto",
px: 2,
px: 1,
},
}}
>
<Tab
value="needs_action"
label={renderBucketTabLabel("Needs action", needsActionCount)}
sx={{ pr: needsActionCount > 0 ? 4 : 2 }}
/>
<Tab
value="pending"
label={renderBucketTabLabel("pending", pendingCount)}
sx={{ pr: pendingCount > 0 ? 4 : 2 }}
/>
<Tab
value="processing"
label={renderBucketTabLabel("Processing", processingCount)}
sx={{ pr: processingCount > 0 ? 4 : 2 }}
/>
<Tab
value="pending_qc"
@@ -840,14 +892,27 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({
"Waiting QC Put Away",
pendingQcCount,
)}
sx={{ pr: pendingQcCount > 0 ? 4 : 2 }}
/>
<Tab
value="putawayed"
label={renderBucketTabLabel("Put Awayed", putawayedCount)}
sx={{ pr: putawayedCount > 0 ? 4 : 2 }}
/>
<Tab
value="carried_over"
label={renderBucketTabLabel(
"Off-plan unfinished",
offPlanTabCount,
)}
/>
</Tabs>
<PrinterSelect
label={t("Label Printer")}
printers={printerOptions}
value={selectedPrinter}
onChange={setSelectedPrinter}
placeholder={t("Label Printer")}
/>
</Stack>

<Box
sx={{
@@ -904,21 +969,35 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({
const statusLower = status.toLowerCase();
const displayStatus =
statusLower === "in_progress" ? "processing" : status;
const chipLabel = qcReady
? putawayStatus === "completed"
? t("Put Awayed")
: t("Waiting QC Put Away")
: t(displayStatus);
const statusColor = qcReady
? putawayStatus === "completed"
? "success"
: "warning"
: statusLower === "completed"
? "success"
: statusLower === "in_progress" ||
statusLower === "processing"
const bucket = String(process.pickProcessBucket ?? "");
const waitingQc = isWaitingQcPutAway(process);

// Avoid t("completed") → purchaseOrder「已上架」; use explicit keys.
let chipLabel: string;
let statusColor: "success" | "warning" | "primary" | "default";
if (listTab === "putawayed") {
chipLabel = t("Put Awayed");
statusColor = "success";
} else if (listTab === "pending_qc" || (isCarriedOverTab && waitingQc)) {
chipLabel = t("Waiting QC Put Away");
statusColor = "warning";
} else if (isCarriedOverTab && PENDING_FINE_BUCKETS.has(bucket)) {
chipLabel = t("pending");
statusColor = "default";
} else if (isCarriedOverTab && PROCESSING_FINE_BUCKETS.has(bucket)) {
chipLabel = t("processing");
statusColor = "primary";
} else if (statusLower === "completed") {
chipLabel = t("Completed");
statusColor = "success";
} else {
chipLabel = t(displayStatus);
statusColor =
statusLower === "in_progress" ||
statusLower === "processing"
? "primary"
: "default";
}

const jobOrderCode =
(process as any).jobOrderCode ??
@@ -932,12 +1011,14 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({
const joDay = process.date
? dayjs(process.date).startOf("day")
: null;
const isCarriedOver = Boolean(
enableCarryOver &&
searchDay?.isValid() &&
joDay?.isValid() &&
joDay.isBefore(searchDay),
);
const isCarriedOver =
isCarriedOverTab ||
Boolean(
enableCarryOver &&
searchDay?.isValid() &&
joDay?.isValid() &&
joDay.isBefore(searchDay),
);

const bomDescription = process.bomDescription
? String(process.bomDescription).trim()
@@ -1178,6 +1259,24 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({
{t("view stockin")}
</Button>
)}

{listTab === "putawayed" &&
process.stockInLineId != null && (
<Button
variant="contained"
size="small"
color="secondary"
disabled={
!selectedPrinter ||
printingSilId === process.stockInLineId
}
onClick={() => void handlePrintLabel(process)}
>
{printingSilId === process.stockInLineId
? t("Printing")
: t("Print Label")}
</Button>
)}
</Stack>

<Typography


+ 7
- 59
src/components/ProductionProcess/ProductionProcessPage.tsx Visa fil

@@ -2,7 +2,7 @@
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 { Box, Tabs, Tab } from "@mui/material";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import QcStockInModal from "@/components/Qc/QcStockInModal";
import ProductionProcessList, {
@@ -24,7 +24,10 @@ interface ProductionProcessPageProps {

const STORAGE_KEY = 'productionProcess_selectedMatchingStock';

/** FP-MTMS Version Checklist | Functions Ref. No. 26 | v1.0.0 | 2026-07-20 */
/**
* FP-MTMS Version Checklist | Functions Ref. No. 26 | v1.0.0 | 2026-07-20
* FP-MTMS Version Checklist | Functions Ref. No. 47 | v1.0.4 | 2026-08-06
*/
const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCombo }) => {
const { t } = useTranslation(["common", "productionProcess"]);
const [selectedProcessId, setSelectedProcessId] = useState<number | null>(null);
@@ -46,10 +49,6 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo
const [linkQcOpen, setLinkQcOpen] = useState(false);
const [linkQcSilId, setLinkQcSilId] = useState<number | null>(null);

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

useEffect(() => {
if (typeof window !== 'undefined') {
try {
@@ -120,9 +119,6 @@ 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
@@ -148,54 +144,6 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo
return (
<>
<Box>
{showPrinterBar && (
<Box sx={{
p: 1,
borderBottom: '1px solid #e0e0e0',
minHeight: 'auto',
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
gap: 2,
flexWrap: 'wrap',
}}>
<Stack
direction="row"
spacing={2}
sx={{
alignItems: 'center',
flexWrap: 'wrap',
rowGap: 1,
}}
>
<Typography variant="body2" sx={{ minWidth: 'fit-content', mr: 1.5 }}>
{t("Select Printer")}:
</Typography>
<Autocomplete
disableClearable
options={printerCombo || []}
getOptionLabel={(option) =>
option.name || option.label || option.code || `Printer ${option.id}`
}
value={selectedPrinter || undefined}
onChange={(_, newValue) => setSelectedPrinter(newValue)}
sx={{ minWidth: 200 }}
size="small"
renderInput={(params) => (
<TextField
{...params}
placeholder={t("Printer")}
inputProps={{
...params.inputProps,
readOnly: true,
}}
/>
)}
/>
</Stack>
</Box>
)}

<Tabs value={tabIndex} onChange={handleTabChange} sx={{ mb: 2 }}>
<Tab label={t("Production Process")} />
<Tab label={t("Job Order Ops Table")} />
@@ -207,7 +155,7 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo

{tabIndex === 0 && (
<ProductionProcessList
printerCombo={selectedPrinter ? [selectedPrinter] : printerCombo}
printerCombo={printerCombo}
listPersistedState={productionListState}
onListPersistedStateChange={setProductionListState}
onSelectProcess={(jobOrderId) => {
@@ -228,7 +176,7 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo

{tabIndex === 1 && (
<JobOrderOpsTable
printerCombo={selectedPrinter ? [selectedPrinter] : printerCombo}
printerCombo={printerCombo}
onSelectProcess={(jobOrderId) => {
if (jobOrderId != null) setSelectedProcessId(jobOrderId);
}}


+ 13
- 22
src/components/Qc/QcStockInModal.tsx Visa fil

@@ -1,7 +1,6 @@
"use client";
import { QcItemWithChecks, QcData } from "@/app/api/qc";
import {
Autocomplete,
Box,
Button,
Divider,
@@ -29,6 +28,7 @@ import dayjs from "dayjs";
import { fetchPoQrcode } from "@/app/api/pdf/actions";
import { downloadFile } from "@/app/utils/commonUtil";
import { PrinterCombo } from "@/app/api/settings/printer";
import PrinterSelect from "@/components/common/PrinterSelect";
import { EscalationResult } from "@/app/api/escalation";
import { SessionWithTokens } from "@/config/authConfig";
import { GridRowModesModel } from "@mui/x-data-grid";
@@ -72,6 +72,7 @@ interface Props extends CommonProps {
// itemDetail: StockInLine & { qcResult?: PurchaseQcResult[] } & { escResult?: EscalationResult[] };
}

/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.0 | 2026-08-06 */
const QcStockInModal: React.FC<Props> = ({
open,
onClose,
@@ -840,27 +841,17 @@ const printQrcode = useCallback(
</Grid>
{tabIndex == 1 && (
<Stack direction="row" justifyContent="flex-end" gap={1} sx={{m:3, mt:"auto"}}>
<Autocomplete
disableClearable
options={labelPrinterCombo}
getOptionLabel={(option) =>
option.name || option.label || option.code || `Printer ${option.id}`
}
value={selectedPrinter}
onChange={(_, newValue) => {
if (newValue) setSelectedPrinter(newValue);
}}
renderInput={(params) => (
<TextField
{...params}
variant="outlined"
label={t("Printer")}
sx={{ width: 300 }}
inputProps={{ ...params.inputProps, readOnly: true }}
/>
)}
/>
<Stack direction="row" justifyContent="flex-end" alignItems="center" gap={1} sx={{m:3, mt:"auto"}}>
<PrinterSelect
label={t("Label Printer")}
printers={labelPrinterCombo}
value={selectedPrinter ?? null}
onChange={(p) => {
if (p) setSelectedPrinter(p);
}}
placeholder={t("Label Printer")}
minWidth={300}
/>
<TextField
variant="outlined"
label={t("Print Qty")}


+ 149
- 0
src/components/common/PrinterSelect.tsx Visa fil

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

import React, { useMemo } from "react";
import {
FormControl,
MenuItem,
Select,
SelectChangeEvent,
Stack,
Typography,
} from "@mui/material";
import type { PrinterCombo } from "@/app/api/settings/printer";

/**
* FP-MTMS Version Checklist | Functions Ref. No. 54 | v1.0.0 | 2026-08-06
* Format display label for a printer combo option.
*/
export function formatPrinterOptionLabel(p: PrinterCombo): string {
return p.name || p.label || p.code || `Printer ${p.id}`;
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 54 | v1.0.0 | 2026-08-06
* Filter printers by type (e.g. Label / A4).
*/
export function filterPrintersByType(
printers: PrinterCombo[] | null | undefined,
typeFilter?: string | string[],
fallbackToAll = false,
): PrinterCombo[] {
const list = printers ?? [];
if (!typeFilter) return list;
const types = (Array.isArray(typeFilter) ? typeFilter : [typeFilter]).map(
(t) => t.trim().toLowerCase(),
);
const filtered = list.filter((p) =>
types.includes(String(p.type ?? "").trim().toLowerCase()),
);
if (filtered.length > 0) return filtered;
return fallbackToAll ? list : filtered;
}

export type PrinterSelectProps = {
/** Outer label text (required), e.g. 「列印機」「標籤打印機」 */
label: React.ReactNode;
printers: PrinterCombo[];
value: PrinterCombo | null | undefined;
onChange: (printer: PrinterCombo | null) => void;
/** Filter by printer.type, e.g. "Label" | "A4" */
typeFilter?: string | string[];
/** If type filter yields none, show all printers (default false) */
fallbackToAll?: boolean;
/** Shown inside Select when nothing selected */
placeholder?: string;
minWidth?: number;
fullWidth?: boolean;
disabled?: boolean;
size?: "small" | "medium";
/** Append ":" after label when label is a string (default true) */
appendColon?: boolean;
};

/**
* FP-MTMS Version Checklist | Functions Ref. No. 54 | v1.0.0 | 2026-08-06
* Shared printer picker: outer label + Select (selected value vertically centered, no floating label).
*/
const PrinterSelect: React.FC<PrinterSelectProps> = ({
label,
printers,
value,
onChange,
typeFilter,
fallbackToAll = false,
placeholder,
minWidth = 220,
fullWidth = false,
disabled = false,
size = "small",
appendColon = true,
}) => {
const options = useMemo(
() => filterPrintersByType(printers, typeFilter, fallbackToAll),
[printers, typeFilter, fallbackToAll],
);

const labelNode =
typeof label === "string" && appendColon && !label.trim().endsWith(":")
? `${label}:`
: label;

const height = size === "small" ? 40 : 48;

return (
<Stack
direction="row"
alignItems="center"
spacing={1}
sx={{ flexShrink: 0, width: fullWidth ? "100%" : undefined }}
>
<Typography variant="body2" sx={{ whiteSpace: "nowrap", minWidth: "fit-content" }}>
{labelNode}
</Typography>
<FormControl
size={size}
fullWidth={fullWidth}
sx={{ minWidth: fullWidth ? undefined : minWidth, flex: fullWidth ? 1 : undefined }}
disabled={disabled || options.length === 0}
> <Select
displayEmpty
value={value?.id != null ? String(value.id) : ""}
onChange={(e: SelectChangeEvent) => {
const id = Number(e.target.value);
onChange(options.find((p) => p.id === id) ?? null);
}}
renderValue={(selected: string) => {
if (!selected) {
return (
<Typography variant="body2" color="text.secondary" component="span">
{placeholder ?? (typeof label === "string" ? label.replace(/:$/, "") : "")}
</Typography>
);
}
const p = options.find((x) => String(x.id) === selected);
return p ? formatPrinterOptionLabel(p) : selected;
}}
sx={{
height,
"& .MuiSelect-select": {
display: "flex",
alignItems: "center",
py: 0,
lineHeight: `${height}px`,
height,
boxSizing: "border-box",
},
}}
>
{options.map((p) => (
<MenuItem key={p.id} value={String(p.id)}>
{formatPrinterOptionLabel(p)}
</MenuItem>
))}
</Select>
</FormControl>
</Stack>
);
};

export default PrinterSelect;

+ 62
- 34
src/hooks/useJobOrderFgStockInAlerts.ts Visa fil

@@ -1,12 +1,16 @@
"use client";

import { clientAuthFetch } from "@/app/utils/clientAuthFetch";
import { NEXT_PUBLIC_API_URL } from "@/config/api";
import {
AllJoborderProductProcessInfoResponse,
fetchJoborderProductProcessesPage,
} from "@/app/api/jo/actions";
import dayjs from "dayjs";
import { useCallback, useEffect, useState } from "react";

const POLL_MS = 60_000;

const ALERTS_URL = `${NEXT_PUBLIC_API_URL}/product-process/Demo/Process/alerts/fg-qc-putaway`;
/** Same as ProductionProcessList pending_qc tab. */
export const PRODUCTION_LOOKBACK_DAYS = 4;
const FETCH_SIZE = 200;

export type JobOrderFgAlertItem = {
stockInLineId: number;
@@ -19,35 +23,55 @@ export type JobOrderFgAlertItem = {
lotNo: string | null;
};

function parseRow(o: Record<string, unknown>): JobOrderFgAlertItem {
return {
stockInLineId: Number(o.stockInLineId ?? o.stockinLineId ?? 0),
jobOrderId: Number(o.jobOrderId ?? o.joborderid ?? 0),
jobOrderCode: o.jobOrderCode != null ? String(o.jobOrderCode) : null,
itemNo: o.itemNo != null ? String(o.itemNo) : null,
itemName: o.itemName != null ? String(o.itemName) : null,
status: o.status != null ? String(o.status) : null,
processDate: o.processDate != null ? String(o.processDate) : null,
lotNo: o.lotNo != null ? String(o.lotNo) : null,
};
function isPutawayCompleted(p: AllJoborderProductProcessInfoResponse): boolean {
return String(p.stockInLineStatus ?? "").trim().toLowerCase() === "completed";
}

function parsePayload(raw: unknown): { qc: JobOrderFgAlertItem[]; putAway: JobOrderFgAlertItem[] } {
if (!raw || typeof raw !== "object") return { qc: [], putAway: [] };
const p = raw as Record<string, unknown>;
const qcRaw = p.qc;
const putAwayRaw = p.putAway;
function toAlertItem(p: AllJoborderProductProcessInfoResponse): JobOrderFgAlertItem | null {
if (!p.stockInLineId || !p.jobOrderId) return null;
return {
qc: Array.isArray(qcRaw) ? qcRaw.map((r) => parseRow(r as Record<string, unknown>)) : [],
putAway: Array.isArray(putAwayRaw)
? putAwayRaw.map((r) => parseRow(r as Record<string, unknown>))
: [],
stockInLineId: p.stockInLineId,
jobOrderId: p.jobOrderId,
jobOrderCode: p.jobOrderCode ?? null,
itemNo: p.itemCode ?? null,
itemName: p.itemName ?? null,
status: p.stockInLineStatus ?? null,
processDate: p.date ?? null,
lotNo: p.lotNo ?? null,
};
}

function splitPendingQcRows(content: AllJoborderProductProcessInfoResponse[]): {
qc: JobOrderFgAlertItem[];
putAway: JobOrderFgAlertItem[];
} {
const qc: JobOrderFgAlertItem[] = [];
const putAway: JobOrderFgAlertItem[] = [];

for (const p of content) {
if (isPutawayCompleted(p)) continue;
const item = toAlertItem(p);
if (!item) continue;
const statusNorm = String(p.stockInLineStatus ?? "").trim().toLowerCase();
if (statusNorm === "received" || statusNorm === "partially_completed") {
putAway.push(item);
} else {
qc.push(item);
}
}

const sortByDate = (a: JobOrderFgAlertItem, b: JobOrderFgAlertItem) =>
(b.processDate ?? "").localeCompare(a.processDate ?? "");

qc.sort(sortByDate);
putAway.sort(sortByDate);
return { qc, putAway };
}

/**
* 與「完成QC工單」相同資格 + 產程日期為今日或昨日;分待 QC / 待上架。
* 與產程「待 QC 上架」tab 相同 API/日期(今日、lookback 4 天);分待 QC / 待上架。
*/
/** FP-MTMS Version Checklist | Functions Ref. No. 52 | v1.0.0 | 2026-08-05 */
export function useJobOrderFgStockInAlerts(enabled: boolean) {
const [qcItems, setQcItems] = useState<JobOrderFgAlertItem[]>([]);
const [putAwayItems, setPutAwayItems] = useState<JobOrderFgAlertItem[]>([]);
@@ -61,15 +85,19 @@ export function useJobOrderFgStockInAlerts(enabled: boolean) {
}
setLoading(true);
try {
const res = await clientAuthFetch(ALERTS_URL);
if (!res.ok) {
setQcItems([]);
setPutAwayItems([]);
return;
}
const data = parsePayload(await res.json());
setQcItems(data.qc);
setPutAwayItems(data.putAway);
const dateStr = dayjs().format("YYYY-MM-DD");
const data = await fetchJoborderProductProcessesPage({
date: dateStr,
lookbackDays: PRODUCTION_LOOKBACK_DAYS,
qcReady: true,
includePutaway: true,
putawayStatus: "notCompleted",
page: 0,
size: FETCH_SIZE,
});
const { qc, putAway } = splitPendingQcRows(data?.content ?? []);
setQcItems(qc);
setPutAwayItems(putAway);
} catch {
setQcItems([]);
setPutAwayItems([]);


+ 8
- 0
src/i18n/en/productionProcess.json Visa fil

@@ -139,6 +139,13 @@
"Prep Time (Minutes)": "Prep Time (Minutes)",
"Previous page": "Previous page",
"Printer": "Printer",
"Label Printer": "Label Printer",
"A4 Printer": "A4 Printer",
"Print Label": "Print Label",
"Printing": "Printing",
"Print job sent successfully": "Print job sent successfully",
"Please select a printer": "Please select a printer",
"Please select a label printer": "Please select a label printer",
"Process": "Process",
"Process & Equipment": "Process & Equipment",
"Process Description": "Process Description",
@@ -215,6 +222,7 @@
"Total job orders": "Total job orders",
"Including carried over": "Including carried over",
"Carried over from past day": "Carried over from past day",
"Off-plan unfinished": "Off-plan unfinished",
"All unfinished": "Needs action",
"Needs action": "Needs action",
"Not picked · Not started": "Not picked · Not started",


+ 2
- 0
src/i18n/en/purchaseOrder.json Visa fil

@@ -151,6 +151,8 @@
"Found": "Found",
"escalation processing": "Escalation Processing",
"Printer": "Printer",
"Label Printer": "Label Printer",
"A4 Printer": "A4 Printer",
"Printing": "Printing",
"rejectQty": "Reject Qty",
"QC decision is required": "QC decision is required",


+ 14
- 6
src/i18n/zh/productionProcess.json Visa fil

@@ -83,7 +83,7 @@
"Job Order No.": "工單編號",
"Job Order and Product": "工單及貨品",
"Issue": "異常",
"Job Order Ops Table": "查看工單流程情況",
"Job Order Ops Table": "工單生產流程",
"Pending (picked)": "已提料",
"Pending (not picked)": "未提料",
"Processing (picked)": "已提料",
@@ -98,7 +98,7 @@
"Actions": "操作",
"Job Order Production Process": "工單生產流程",
"Job Process Status Dashboard": "儀表板 - 工單狀態",
"Drink Production Qty Dashboard": "儀表板 - 飲料生產數",
"Drink Production Qty Dashboard": "儀表板 - 飲料生產數",
"Expand job order details": "展開工單明細",
"Collapse job order details": "收合工單明細",
"Goods Name": "貨品名稱",
@@ -144,6 +144,13 @@
"Prep Time (Minutes)": "準備時間(分鐘)",
"Previous page": "上一頁",
"Printer": "列印機",
"Label Printer": "標籤打印機",
"A4 Printer": "A4 打印機",
"Print Label": "列印標籤",
"Printing": "列印中",
"Print job sent successfully": "列印作業已成功送出",
"Please select a printer": "請選擇打印機",
"Please select a label printer": "請選擇標籤打印機",
"Process": "工序",
"Process & Equipment": "工序與設備",
"Process Description": "工序說明",
@@ -154,7 +161,7 @@
"Processing Time": "生產時間",
"Processing Time (mins)": "步驟時間(分鐘)",
"Product process status": "生產流程狀態",
"Production Date": "生產日期",
"Production Date": "預計生產日期",
"Production Equipment Status Dashboard": "儀表板 - 生產設備最新狀態",
"Production Output Data": "生產輸出數據",
"Production Output Data Entry": "生產輸出數據輸入",
@@ -164,7 +171,7 @@
"Production Process Line Remark": "工藝明細",
"Production Process Steps": "生產流程步驟",
"Production Time Remaining": "生產剩餘時間",
"Production date": "生產日期",
"Production date": "預計生產日期",
"Put Awayed Job Orders": "已上架工單",
"Qty": "數量",
"Quality Check": "品質檢查",
@@ -218,8 +225,9 @@
"Total Time": "總時間",
"Total finished QC job orders": "總完成QC工單數量",
"Total job orders": "總工單數量",
"Including carried over": "含過去轉來",
"Carried over from past day": "過去轉來的工單",
"Including carried over": "含未按規劃完成工單",
"Carried over from past day": "未按規劃完成工單的工單",
"Off-plan unfinished": "未按規劃完成工單",
"All unfinished": "需處理",
"Needs action": "需處理",
"Not picked · Not started": "未提料 · 未開工",


+ 2
- 0
src/i18n/zh/purchaseOrder.json Visa fil

@@ -151,6 +151,8 @@
"Found": "已找到",
"escalation processing": "處理上報記錄",
"Printer": "列印機",
"Label Printer": "標籤打印機",
"A4 Printer": "A4 打印機",
"Printing": "列印中",
"rejectQty": "拒絕數量",
"QC decision is required": "請決定品檢結果",


Laddar…
Avbryt
Spara