diff --git a/src/app/api/jo/actions.ts b/src/app/api/jo/actions.ts index 00b634a..d24f4be 100644 --- a/src/app/api/jo/actions.ts +++ b/src/app/api/jo/actions.ts @@ -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.3 | 2026-08-05 */ export const fetchJoborderProductProcessesPage = cache(async (params: { /** Job order / process date(YYYY-MM-DD) */ date?: string | null; diff --git a/src/authorities.ts b/src/authorities.ts index 1107afd..08e222e 100644 --- a/src/authorities.ts +++ b/src/authorities.ts @@ -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; \ No newline at end of file +} 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); +} \ No newline at end of file diff --git a/src/components/NavigationContent/JobOrderFgStockInNavAlerts.tsx b/src/components/NavigationContent/JobOrderFgStockInNavAlerts.tsx index b463e82..c3c4b28 100644 --- a/src/components/NavigationContent/JobOrderFgStockInNavAlerts.tsx +++ b/src/components/NavigationContent/JobOrderFgStockInNavAlerts.tsx @@ -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 = ({ enabled }) => { const { qcItems, putAwayItems, count, loading, reload } = useJobOrderFgStockInAlerts(enabled); const [open, setOpen] = useState(false); @@ -152,8 +153,8 @@ const JobOrderFgStockInNavAlerts: React.FC = ({ enabled }) => { 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 = ({ enabled }) => { )} - 僅含產程日期為今日或昨日的工單,且與「完成QC工單」相同條件(該工單所有工序行均為 - Completed/Pass、有成品入庫且未完成/未拒絕)。待 QC:尚未進入已收貨;待上架:已收貨或部分完成入庫。 + 與產程頁「待 QC 上架」相同資料來源:搜索日為今日、往前 + {PRODUCTION_LOOKBACK_DAYS} 天產程;工序全完成、入庫未完成/未拒絕。待 QC:尚未進入已收貨;待上架:已收貨或部分完成入庫。 0 ? `${text} (${count})` : text; +} + function isPausedProcess(p: AllJoborderProductProcessInfoResponse): boolean { return (p.lines ?? []).some( (l) => String(l.status ?? "").trim().toLowerCase() === "paused", @@ -162,12 +187,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(); + 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.1 | 2026-08-05 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 47 | v1.0.2 | 2026-08-05 */ const JobOrderOpsTable: React.FC = ({ onSelectProcess, printerCombo = [], @@ -176,14 +229,18 @@ const JobOrderOpsTable: React.FC = ({ 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 [queryDate, setQueryDate] = useState(() => dayjs()); - const [primaryTab, setPrimaryTab] = useState("all"); - const [pendingSub, setPendingSub] = useState("all"); - const [processingSub, setProcessingSub] = useState("all"); + const [primaryTab, setPrimaryTab] = useState("needs_action"); + const [productionSub, setProductionSub] = useState("all"); const [issueSub, setIssueSub] = useState("stop"); + const searchDay = useMemo(() => queryDate.startOf("day"), [queryDate]); + const [productionRows, setProductionRows] = useState([]); const [pendingQcRows, setPendingQcRows] = useState([]); const [putawayedRows, setPutawayedRows] = useState([]); @@ -203,10 +260,9 @@ const JobOrderOpsTable: React.FC = ({ 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, @@ -214,8 +270,7 @@ const JobOrderOpsTable: React.FC = ({ }); 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]); @@ -224,7 +279,7 @@ const JobOrderOpsTable: React.FC = ({ try { const data = await fetchJoborderProductProcessesPage({ date: dayStr, - lookbackDays: 0, + lookbackDays: PRODUCTION_LOOKBACK_DAYS, qcReady: true, includePutaway: true, putawayStatus: "notCompleted", @@ -233,8 +288,7 @@ const JobOrderOpsTable: React.FC = ({ }); 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); @@ -255,8 +309,7 @@ const JobOrderOpsTable: React.FC = ({ }); 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); @@ -303,65 +356,108 @@ const JobOrderOpsTable: React.FC = ({ 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; @@ -404,7 +500,7 @@ const JobOrderOpsTable: React.FC = ({ 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; @@ -429,12 +525,12 @@ const JobOrderOpsTable: React.FC = ({ } }); }, - [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; @@ -448,7 +544,7 @@ const JobOrderOpsTable: React.FC = ({ } }); }, - [canManage, openConfirm, t, loadData], + [canCancel, openConfirm, t, loadData], ); const onConfirm = useCallback(async () => { @@ -470,6 +566,18 @@ const JobOrderOpsTable: React.FC = ({ 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": @@ -478,14 +586,27 @@ const JobOrderOpsTable: React.FC = ({ 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; return ( @@ -519,72 +640,52 @@ const JobOrderOpsTable: React.FC = ({ sx={{ mb: 1, borderBottom: 1, borderColor: "divider" }} > 0 ? `${t("All")} (${counts.all})` : t("All")} + value="needs_action" + label={tabLabel(t("Needs action"), counts.needsAction)} /> 0 - ? `${t("pending")} (${counts.pending})` - : t("pending") - } + label={tabLabel(t("pending"), counts.pending)} /> 0 - ? `${t("Processing")} (${counts.processing})` - : t("Processing") - } + label={tabLabel(t("Processing"), counts.processing)} + /> + 0 - ? `${t("Waiting QC Put Away")} (${counts.pendingQc})` - : t("Waiting QC Put Away") - } + label={tabLabel(t("Waiting QC Put Away"), counts.pendingQc)} /> 0 - ? `${t("Put Awayed")} (${counts.putawayed})` - : t("Put Awayed") - } + label={tabLabel(t("Put Awayed"), counts.putawayed)} /> 0 - ? `${t("Issue")} (${counts.stop + counts.cancel})` - : t("Issue") - } + label={tabLabel(t("Issue"), counts.stop + counts.cancel)} /> - {primaryTab === "pending" && ( - setPendingSub(v)} - sx={{ mb: 2 }} - > - - - - - )} - - {primaryTab === "processing" && ( + {( + ["needs_action", "pending", "processing"] as PrimaryTab[] + ).includes(primaryTab) && ( setProcessingSub(v)} + value={productionSub} + onChange={(_, v: ProductionSubTab) => setProductionSub(v)} sx={{ mb: 2 }} > - - - + + + )} @@ -596,19 +697,11 @@ const JobOrderOpsTable: React.FC = ({ > 0 - ? `${t("Stop (paused)")} (${counts.stop})` - : t("Stop (paused)") - } + label={tabLabel(t("Stop (paused)"), counts.stop)} /> 0 - ? `${t("Cancelled")} (${counts.cancel})` - : t("Cancelled") - } + label={tabLabel(t("Cancelled"), counts.cancel)} /> )} @@ -643,24 +736,78 @@ const JobOrderOpsTable: React.FC = ({ ) : ( 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 ( - - {row.jobOrderCode} - + .MuiTableCell-root": { + bgcolor: "warning.light", + }, + "&:hover > .MuiTableCell-root": { + bgcolor: "warning.light", + }, + } + : undefined + } + > + + + + {row.jobOrderCode} + + {carriedOver ? ( + + + ! + + + ) : null} + + + {[row.itemCode, row.itemName].filter(Boolean).join(" ")} - + {row.requiredQty} {row.uom ? ` ${row.uom}` : ""} - + {row.productionDate && dayjs(row.productionDate).isValid() ? dayjs(row.productionDate).format(OUTPUT_DATE_FORMAT) : "-"} - + {row.isPaused && ( @@ -672,7 +819,7 @@ const JobOrderOpsTable: React.FC = ({ /> - + = ({ > {t("View Details")} - {primaryTab === "pending_qc" && row.stockInLineId != null && ( - - )} - {primaryTab === "putawayed" && row.stockInLineId != null && ( + {showQcActionForRow(row) && ( )} - {showManageActions && !row.isCancelled && ( + {showManageActionsForRow(row) && ( )} - {showManageActions && !row.isCancelled && ( + {showManageActionsForRow(row) && (