| @@ -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; | |||
| @@ -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,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="關閉" | |||
| @@ -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,7 @@ 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 { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | |||
| import { | |||
| AllJoborderProductProcessInfoResponse, | |||
| @@ -48,16 +50,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 +92,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,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<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.1 | 2026-08-05 */ | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 47 | v1.0.2 | 2026-08-05 */ | |||
| const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | |||
| onSelectProcess, | |||
| printerCombo = [], | |||
| @@ -176,14 +229,18 @@ 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 [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[]>([]); | |||
| @@ -203,10 +260,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, | |||
| @@ -214,8 +270,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]); | |||
| @@ -224,7 +279,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", | |||
| @@ -233,8 +288,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); | |||
| @@ -255,8 +309,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); | |||
| @@ -303,65 +356,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; | |||
| @@ -404,7 +500,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; | |||
| @@ -429,12 +525,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; | |||
| @@ -448,7 +544,7 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | |||
| } | |||
| }); | |||
| }, | |||
| [canManage, openConfirm, t, loadData], | |||
| [canCancel, openConfirm, t, loadData], | |||
| ); | |||
| const onConfirm = useCallback(async () => { | |||
| @@ -470,6 +566,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": | |||
| @@ -478,14 +586,27 @@ 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; | |||
| return ( | |||
| <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-hk"> | |||
| @@ -519,72 +640,52 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | |||
| sx={{ mb: 1, borderBottom: 1, borderColor: "divider" }} | |||
| > | |||
| <Tab | |||
| value="all" | |||
| label={counts.all > 0 ? `${t("All")} (${counts.all})` : t("All")} | |||
| value="needs_action" | |||
| label={tabLabel(t("Needs action"), counts.needsAction)} | |||
| /> | |||
| <Tab | |||
| value="pending" | |||
| label={ | |||
| counts.pending > 0 | |||
| ? `${t("pending")} (${counts.pending})` | |||
| : t("pending") | |||
| } | |||
| label={tabLabel(t("pending"), counts.pending)} | |||
| /> | |||
| <Tab | |||
| value="processing" | |||
| label={ | |||
| counts.processing > 0 | |||
| ? `${t("Processing")} (${counts.processing})` | |||
| : t("Processing") | |||
| } | |||
| label={tabLabel(t("Processing"), counts.processing)} | |||
| /> | |||
| <Tab | |||
| value="carried_over" | |||
| label={tabLabel(t("Off-plan unfinished"), counts.carriedOver)} | |||
| /> | |||
| <Tab | |||
| value="pending_qc" | |||
| label={ | |||
| counts.pendingQc > 0 | |||
| ? `${t("Waiting QC Put Away")} (${counts.pendingQc})` | |||
| : t("Waiting QC Put Away") | |||
| } | |||
| label={tabLabel(t("Waiting QC Put Away"), counts.pendingQc)} | |||
| /> | |||
| <Tab | |||
| value="putawayed" | |||
| label={ | |||
| counts.putawayed > 0 | |||
| ? `${t("Put Awayed")} (${counts.putawayed})` | |||
| : t("Put Awayed") | |||
| } | |||
| label={tabLabel(t("Put Awayed"), counts.putawayed)} | |||
| /> | |||
| <Tab | |||
| value="issue" | |||
| label={ | |||
| counts.stop + counts.cancel > 0 | |||
| ? `${t("Issue")} (${counts.stop + counts.cancel})` | |||
| : t("Issue") | |||
| } | |||
| label={tabLabel(t("Issue"), counts.stop + counts.cancel)} | |||
| /> | |||
| </Tabs> | |||
| {primaryTab === "pending" && ( | |||
| <Tabs | |||
| value={pendingSub} | |||
| onChange={(_, v: PendingSubTab) => setPendingSub(v)} | |||
| sx={{ mb: 2 }} | |||
| > | |||
| <Tab value="all" label={t("All")} /> | |||
| <Tab value="picked_not_started" label={t("Picked")} /> | |||
| <Tab value="not_picked_not_started" label={t("Not picked")} /> | |||
| </Tabs> | |||
| )} | |||
| {primaryTab === "processing" && ( | |||
| {( | |||
| ["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> | |||
| )} | |||
| @@ -596,19 +697,11 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | |||
| > | |||
| <Tab | |||
| value="stop" | |||
| label={ | |||
| counts.stop > 0 | |||
| ? `${t("Stop (paused)")} (${counts.stop})` | |||
| : t("Stop (paused)") | |||
| } | |||
| label={tabLabel(t("Stop (paused)"), counts.stop)} | |||
| /> | |||
| <Tab | |||
| value="cancel" | |||
| label={ | |||
| counts.cancel > 0 | |||
| ? `${t("Cancelled")} (${counts.cancel})` | |||
| : t("Cancelled") | |||
| } | |||
| label={tabLabel(t("Cancelled"), counts.cancel)} | |||
| /> | |||
| </Tabs> | |||
| )} | |||
| @@ -643,24 +736,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)")} /> | |||
| @@ -672,7 +819,7 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | |||
| /> | |||
| </Stack> | |||
| </TableCell> | |||
| <TableCell align="center"> | |||
| <TableCell align="center" sx={carriedCellSx}> | |||
| <Stack | |||
| direction="row" | |||
| spacing={1} | |||
| @@ -687,29 +834,23 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | |||
| > | |||
| {t("View Details")} | |||
| </Button> | |||
| {primaryTab === "pending_qc" && row.stockInLineId != null && ( | |||
| <Button | |||
| size="small" | |||
| variant="contained" | |||
| onClick={() => handleOpenQcModal(row)} | |||
| > | |||
| {t("view stockin")} | |||
| </Button> | |||
| )} | |||
| {primaryTab === "putawayed" && row.stockInLineId != null && ( | |||
| {showQcActionForRow(row) && ( | |||
| <Button | |||
| size="small" | |||
| variant="contained" | |||
| onClick={() => handleOpenQcModal(row)} | |||
| > | |||
| {t("Put Away Detail")} | |||
| {primaryTab === "putawayed" || | |||
| row.rowKind === "putawayed" | |||
| ? t("Put Away Detail") | |||
| : t("view stockin")} | |||
| </Button> | |||
| )} | |||
| {showManageActions && !row.isCancelled && ( | |||
| {showManageActionsForRow(row) && ( | |||
| <Button | |||
| size="small" | |||
| variant="contained" | |||
| disabled={!canManage || busy} | |||
| disabled={!canComplete || busy} | |||
| onClick={() => handleComplete(row)} | |||
| startIcon={ | |||
| busy ? ( | |||
| @@ -720,12 +861,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")} | |||
| @@ -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,6 +66,7 @@ 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, | |||
| @@ -74,6 +76,9 @@ const ProductionProcessDetail: React.FC<ProductProcessDetailProps> = ({ | |||
| 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); | |||
| @@ -253,6 +258,7 @@ const fetchProcessDetailRef = useRef<() => Promise<void>>(); | |||
| }, [editingLineId]); | |||
| const handlePassLine = useCallback(async (lineId: number) => { | |||
| if (!canAdminPass) return; | |||
| try { | |||
| await passProductProcessLine(lineId); | |||
| // 刷新数据 | |||
| @@ -261,12 +267,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 +669,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> | |||
| @@ -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.3 | 2026-08-05 */ | |||
| const ProductionProcessJobOrderDetail: React.FC<ProductProcessJobOrderDetailProps> = ({ | |||
| jobOrderId, | |||
| onBack, | |||
| @@ -36,7 +36,7 @@ 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 { | |||
| @@ -55,6 +55,7 @@ export type ProductionProcessListTab = | |||
| | "needs_action" | |||
| | "pending" | |||
| | "processing" | |||
| | "carried_over" | |||
| | "pending_qc" | |||
| | "putawayed"; | |||
| @@ -67,7 +68,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; | |||
| @@ -135,7 +136,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"; | |||
| @@ -144,7 +152,30 @@ function normalizeListTab(raw: string | undefined | null): ProductionProcessList | |||
| return "needs_action"; | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.2 | 2026-08-05 */ | |||
| 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.3 | 2026-08-05 */ | |||
| const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| onSelectProcess, | |||
| printerCombo, | |||
| @@ -158,6 +189,12 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| 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 [bucketCounts, setBucketCounts] = | |||
| useState<JobOrderProductProcessBucketCounts>(EMPTY_BUCKET_COUNTS); | |||
| const [pendingQcCount, setPendingQcCount] = useState(0); | |||
| @@ -167,13 +204,14 @@ 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 isCarriedOverTab = listTab === "carried_over"; | |||
| const qcReady = listTab === "pending_qc" || listTab === "putawayed"; | |||
| const putawayStatus = | |||
| listTab === "putawayed" | |||
| @@ -182,9 +220,10 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| ? "notCompleted" | |||
| : null; | |||
| const includePutaway = qcReady ? true : null; | |||
| /** Production unfinished tabs: carry-over + pick buckets. Pending QC: carry-over only. */ | |||
| /** Production unfinished tabs + pending QC + off-plan: carry-over window. */ | |||
| const enableCarryOver = | |||
| !disableDateFilter && (isProductionTab || listTab === "pending_qc"); | |||
| !disableDateFilter && | |||
| (isProductionTab || listTab === "pending_qc" || isCarriedOverTab); | |||
| const appliedSearch = useMemo( | |||
| () => ({ | |||
| @@ -352,27 +391,80 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| 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, | |||
| type: typeParam, | |||
| page: 0, | |||
| size: FETCH_SIZE, | |||
| }; | |||
| if (isCarriedOverTab) { | |||
| const [prod, pendingQc] = await Promise.all([ | |||
| fetchJoborderProductProcessesPage({ | |||
| ...base, | |||
| qcReady: false, | |||
| lookbackDays: enableCarryOver ? PRODUCTION_LOOKBACK_DAYS : undefined, | |||
| bucket: "all", | |||
| }), | |||
| fetchJoborderProductProcessesPage({ | |||
| ...base, | |||
| qcReady: true, | |||
| includePutaway: true, | |||
| putawayStatus: "notCompleted", | |||
| lookbackDays: enableCarryOver ? PRODUCTION_LOOKBACK_DAYS : undefined, | |||
| }), | |||
| ]); | |||
| const prodContent = (prod?.content || []).filter((p) => !isPutawayCompleted(p)); | |||
| const qcContent = (pendingQc?.content || []).filter((p) => !isPutawayCompleted(p)); | |||
| setProductionCache(prodContent); | |||
| setPendingQcCache(qcContent); | |||
| if (prod?.bucketCounts) setBucketCounts(prod.bucketCounts); | |||
| setPendingQcCount(pendingQc?.totalJobOrders || 0); | |||
| setCarriedOverCount(prod?.carriedOverCount ?? 0); | |||
| const search = appliedSearch.date | |||
| ? dayjs(appliedSearch.date).startOf("day") | |||
| : null; | |||
| const merged = [...qcContent, ...prodContent].filter((p) => | |||
| isProcessCarriedOver(p, search), | |||
| ); | |||
| // Dedupe by jobOrderId (prefer waiting-QC row when it has stockInLineId) | |||
| 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); | |||
| } | |||
| } | |||
| const content = Array.from(byJo.values()); | |||
| setProcesses(content); | |||
| setTotalJobOrders(content.length); | |||
| return; | |||
| } | |||
| // Production tabs share one fetch (bucket=all); pending/processing filter client-side. | |||
| const data = await fetchJoborderProductProcessesPage({ | |||
| ...base, | |||
| qcReady, | |||
| includePutaway, | |||
| putawayStatus, | |||
| type: typeParam, | |||
| lookbackDays: enableCarryOver ? PRODUCTION_LOOKBACK_DAYS : undefined, | |||
| bucket: isProductionTab ? "all" : undefined, | |||
| page: 0, | |||
| size: FETCH_SIZE, | |||
| }); | |||
| setProcesses(data?.content || []); | |||
| const content = data?.content || []; | |||
| setProcesses(content); | |||
| setTotalJobOrders(data?.totalJobOrders || 0); | |||
| if (isProductionTab && data?.bucketCounts) { | |||
| setBucketCounts(data.bucketCounts); | |||
| if (isProductionTab) { | |||
| setProductionCache(content); | |||
| if (data?.bucketCounts) setBucketCounts(data.bucketCounts); | |||
| } | |||
| if (qcReady && putawayStatus === "notCompleted") { | |||
| setPendingQcCache(content); | |||
| setPendingQcCount(data?.totalJobOrders || 0); | |||
| } | |||
| if (qcReady && putawayStatus === "completed") { | |||
| @@ -397,6 +489,7 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| putawayStatus, | |||
| enableCarryOver, | |||
| isProductionTab, | |||
| isCarriedOverTab, | |||
| ]); | |||
| useEffect(() => { | |||
| @@ -413,7 +506,7 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| jobOrderCode: appliedSearch.jobOrderCode, | |||
| type: typeParam, | |||
| page: 0, | |||
| size: 1, | |||
| size: FETCH_SIZE, | |||
| }; | |||
| (async () => { | |||
| @@ -434,6 +527,7 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| }), | |||
| fetchJoborderProductProcessesPage({ | |||
| ...base, | |||
| size: 1, | |||
| qcReady: true, | |||
| includePutaway: true, | |||
| putawayStatus: "completed", | |||
| @@ -441,8 +535,17 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| ]); | |||
| if (cancelled) return; | |||
| if (prod?.bucketCounts) setBucketCounts(prod.bucketCounts); | |||
| if (prod?.content) { | |||
| setProductionCache(prod.content.filter((p) => !isPutawayCompleted(p))); | |||
| } | |||
| if (pendingQc?.content) { | |||
| setPendingQcCache( | |||
| pendingQc.content.filter((p) => !isPutawayCompleted(p)), | |||
| ); | |||
| } | |||
| setPendingQcCount(pendingQc?.totalJobOrders || 0); | |||
| setPutawayedCount(putawayed?.totalJobOrders || 0); | |||
| if (prod?.carriedOverCount != null) setCarriedOverCount(prod.carriedOverCount); | |||
| } catch (e) { | |||
| console.error(e); | |||
| } | |||
| @@ -471,6 +574,30 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| bucketCounts.pickedStarted + bucketCounts.notPickedStarted; | |||
| const needsActionCount = pendingCount + processingCount; | |||
| const offPlanTabCount = useMemo(() => { | |||
| if (!searchDay) return 0; | |||
| const ids = new Set<number>(); | |||
| for (const p of productionCache) { | |||
| if ( | |||
| !isPutawayCompleted(p) && | |||
| isProcessCarriedOver(p, searchDay) && | |||
| p.jobOrderId != null | |||
| ) { | |||
| ids.add(p.jobOrderId); | |||
| } | |||
| } | |||
| for (const p of pendingQcCache) { | |||
| if ( | |||
| !isPutawayCompleted(p) && | |||
| isProcessCarriedOver(p, searchDay) && | |||
| p.jobOrderId != null | |||
| ) { | |||
| ids.add(p.jobOrderId); | |||
| } | |||
| } | |||
| return ids.size; | |||
| }, [productionCache, pendingQcCache, searchDay]); | |||
| const filteredProcesses = useMemo(() => { | |||
| let list = processes; | |||
| if (listTab === "pending") { | |||
| @@ -482,17 +609,20 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| PROCESSING_FINE_BUCKETS.has(String(p.pickProcessBucket ?? "")), | |||
| ); | |||
| } | |||
| // carried_over: fetch already filtered to off-plan only | |||
| 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; | |||
| const displayTotalJobOrders = isCarriedOverTab | |||
| ? filteredProcesses.length | |||
| : isProductionTab | |||
| ? listTab === "pending" | |||
| ? pendingCount | |||
| : listTab === "processing" | |||
| ? processingCount | |||
| : totalJobOrders || needsActionCount | |||
| : totalJobOrders; | |||
| const displayCarriedOverCount = useMemo(() => { | |||
| if (!enableCarryOver || !searchDay) return 0; | |||
| @@ -800,6 +930,13 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| value="processing" | |||
| label={renderBucketTabLabel("Processing", processingCount)} | |||
| /> | |||
| <Tab | |||
| value="carried_over" | |||
| label={renderBucketTabLabel( | |||
| "Off-plan unfinished", | |||
| offPlanTabCount, | |||
| )} | |||
| /> | |||
| <Tab | |||
| value="pending_qc" | |||
| label={renderBucketTabLabel( | |||
| @@ -868,21 +1005,42 @@ 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 (qcReady) { | |||
| chipLabel = | |||
| putawayStatus === "completed" | |||
| ? t("Put Awayed") | |||
| : t("Waiting QC Put Away"); | |||
| statusColor = | |||
| putawayStatus === "completed" ? "success" : "warning"; | |||
| } 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 ?? | |||
| @@ -896,12 +1054,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() | |||
| @@ -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.2 | 2026-08-05 | |||
| */ | |||
| const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCombo }) => { | |||
| const { t } = useTranslation(["common", "productionProcess"]); | |||
| const [selectedProcessId, setSelectedProcessId] = useState<number | null>(null); | |||
| @@ -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([]); | |||
| @@ -214,6 +214,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", | |||
| @@ -77,7 +77,7 @@ | |||
| "Job Order No.": "工單編號", | |||
| "Job Order and Product": "工單及貨品", | |||
| "Issue": "異常", | |||
| "Job Order Ops Table": "工單流程", | |||
| "Job Order Ops Table": "工單生產流程", | |||
| "Pending (picked)": "已提料", | |||
| "Pending (not picked)": "未提料", | |||
| "Processing (picked)": "已提料", | |||
| @@ -214,6 +214,7 @@ | |||
| "Total job orders": "總工單數量", | |||
| "Including carried over": "含未按規劃完成工單", | |||
| "Carried over from past day": "未按規劃完成工單的工單", | |||
| "Off-plan unfinished": "未按規劃完成工單", | |||
| "All unfinished": "需處理", | |||
| "Needs action": "需處理", | |||
| "Not picked · Not started": "未提料 · 未開工", | |||