| @@ -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: { | export const fetchJoborderProductProcessesPage = cache(async (params: { | ||||
| /** Job order / process date(YYYY-MM-DD) */ | /** Job order / process date(YYYY-MM-DD) */ | ||||
| date?: string | null; | date?: string | null; | ||||
| @@ -19,5 +19,22 @@ export const AUTH = { | |||||
| JOB_PICK: "JOB_PICK", | JOB_PICK: "JOB_PICK", | ||||
| JOB_MAT: "JOB_MAT", | JOB_MAT: "JOB_MAT", | ||||
| JOB_PROD: "JOB_PROD", | 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", | 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"; | "use client"; | ||||
| import { useJobOrderFgStockInAlerts } from "@/hooks/useJobOrderFgStockInAlerts"; | |||||
| import { useJobOrderFgStockInAlerts, PRODUCTION_LOOKBACK_DAYS } from "@/hooks/useJobOrderFgStockInAlerts"; | |||||
| import type { JobOrderFgAlertItem } from "@/hooks/useJobOrderFgStockInAlerts"; | import type { JobOrderFgAlertItem } from "@/hooks/useJobOrderFgStockInAlerts"; | ||||
| import CloseIcon from "@mui/icons-material/Close"; | import CloseIcon from "@mui/icons-material/Close"; | ||||
| import FactCheckIcon from "@mui/icons-material/FactCheck"; | import FactCheckIcon from "@mui/icons-material/FactCheck"; | ||||
| @@ -139,6 +139,7 @@ type Props = { | |||||
| enabled: boolean; | enabled: boolean; | ||||
| }; | }; | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 52 | v1.0.0 | 2026-08-05 */ | |||||
| const JobOrderFgStockInNavAlerts: React.FC<Props> = ({ enabled }) => { | const JobOrderFgStockInNavAlerts: React.FC<Props> = ({ enabled }) => { | ||||
| const { qcItems, putAwayItems, count, loading, reload } = useJobOrderFgStockInAlerts(enabled); | const { qcItems, putAwayItems, count, loading, reload } = useJobOrderFgStockInAlerts(enabled); | ||||
| const [open, setOpen] = useState(false); | const [open, setOpen] = useState(false); | ||||
| @@ -152,8 +153,8 @@ const JobOrderFgStockInNavAlerts: React.FC<Props> = ({ enabled }) => { | |||||
| <Tooltip | <Tooltip | ||||
| title={ | title={ | ||||
| count > 0 | 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" | placement="right" | ||||
| > | > | ||||
| @@ -202,8 +203,8 @@ const JobOrderFgStockInNavAlerts: React.FC<Props> = ({ enabled }) => { | |||||
| )} | )} | ||||
| </Stack> | </Stack> | ||||
| <Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}> | <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> | </Typography> | ||||
| <IconButton | <IconButton | ||||
| aria-label="關閉" | aria-label="關閉" | ||||
| @@ -24,6 +24,8 @@ import { | |||||
| TablePagination, | TablePagination, | ||||
| TableRow, | TableRow, | ||||
| Typography, | Typography, | ||||
| Avatar, | |||||
| Tooltip, | |||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { useSession } from "next-auth/react"; | 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 { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; | ||||
| import dayjs, { Dayjs } from "dayjs"; | import dayjs, { Dayjs } from "dayjs"; | ||||
| import { SessionWithTokens } from "@/config/authConfig"; | import { SessionWithTokens } from "@/config/authConfig"; | ||||
| import { AUTH } from "@/authorities"; | |||||
| import { AUTH, hasAbility } from "@/authorities"; | |||||
| import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | ||||
| import { | import { | ||||
| AllJoborderProductProcessInfoResponse, | AllJoborderProductProcessInfoResponse, | ||||
| @@ -48,16 +50,28 @@ import { StockInLineInput } from "@/app/api/stockIn"; | |||||
| import type { PrinterCombo } from "@/app/api/settings/printer"; | import type { PrinterCombo } from "@/app/api/settings/printer"; | ||||
| type PrimaryTab = | type PrimaryTab = | ||||
| | "all" | |||||
| | "needs_action" | |||||
| | "pending" | | "pending" | ||||
| | "processing" | | "processing" | ||||
| | "carried_over" | |||||
| | "pending_qc" | | "pending_qc" | ||||
| | "putawayed" | | "putawayed" | ||||
| | "issue"; | | "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"; | 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 = { | type OpsRow = { | ||||
| key: string; | key: string; | ||||
| jobOrderId: number; | jobOrderId: number; | ||||
| @@ -78,9 +92,20 @@ type OpsRow = { | |||||
| sourceProcess?: AllJoborderProductProcessInfoResponse; | sourceProcess?: AllJoborderProductProcessInfoResponse; | ||||
| }; | }; | ||||
| /** Same as ProductionProcessList: unfinished from (searchDate - 4) .. searchDate. */ | |||||
| const PRODUCTION_LOOKBACK_DAYS = 4; | |||||
| /** Cards / page size for ops table fetch. */ | /** Cards / page size for ops table fetch. */ | ||||
| const FETCH_SIZE = 200; | 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 { | function isPausedProcess(p: AllJoborderProductProcessInfoResponse): boolean { | ||||
| return (p.lines ?? []).some( | return (p.lines ?? []).some( | ||||
| (l) => String(l.status ?? "").trim().toLowerCase() === "paused", | (l) => String(l.status ?? "").trim().toLowerCase() === "paused", | ||||
| @@ -162,12 +187,40 @@ function dedupeByJobOrder(rows: OpsRow[]): OpsRow[] { | |||||
| return Array.from(map.values()); | 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 { | interface JobOrderOpsTableProps { | ||||
| onSelectProcess?: (jobOrderId: number) => void; | onSelectProcess?: (jobOrderId: number) => void; | ||||
| printerCombo?: PrinterCombo[]; | 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> = ({ | const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | ||||
| onSelectProcess, | onSelectProcess, | ||||
| printerCombo = [], | printerCombo = [], | ||||
| @@ -176,14 +229,18 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | |||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | const { data: session } = useSession() as { data: SessionWithTokens | null }; | ||||
| const sessionToken = session as SessionWithTokens | null; | const sessionToken = session as SessionWithTokens | null; | ||||
| const abilities = session?.abilities ?? session?.user?.abilities ?? []; | 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 [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 [issueSub, setIssueSub] = useState<IssueSubTab>("stop"); | ||||
| const searchDay = useMemo(() => queryDate.startOf("day"), [queryDate]); | |||||
| const [productionRows, setProductionRows] = useState<OpsRow[]>([]); | const [productionRows, setProductionRows] = useState<OpsRow[]>([]); | ||||
| const [pendingQcRows, setPendingQcRows] = useState<OpsRow[]>([]); | const [pendingQcRows, setPendingQcRows] = useState<OpsRow[]>([]); | ||||
| const [putawayedRows, setPutawayedRows] = useState<OpsRow[]>([]); | const [putawayedRows, setPutawayedRows] = useState<OpsRow[]>([]); | ||||
| @@ -203,10 +260,9 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | |||||
| const loadProduction = useCallback(async () => { | const loadProduction = useCallback(async () => { | ||||
| const dayStr = queryDate.format("YYYY-MM-DD"); | const dayStr = queryDate.format("YYYY-MM-DD"); | ||||
| // lookbackDays=0 → exact search date only (still enables pickProcessBucket on backend) | |||||
| const data = await fetchJoborderProductProcessesPage({ | const data = await fetchJoborderProductProcessesPage({ | ||||
| date: dayStr, | date: dayStr, | ||||
| lookbackDays: 0, | |||||
| lookbackDays: PRODUCTION_LOOKBACK_DAYS, | |||||
| bucket: "all", | bucket: "all", | ||||
| qcReady: false, | qcReady: false, | ||||
| page: 0, | page: 0, | ||||
| @@ -214,8 +270,7 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | |||||
| }); | }); | ||||
| const rows = (data?.content ?? []) | const rows = (data?.content ?? []) | ||||
| .map((p) => toProductionRow(p, "production")) | .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)); | setProductionRows(dedupeByJobOrder(rows)); | ||||
| }, [queryDate]); | }, [queryDate]); | ||||
| @@ -224,7 +279,7 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | |||||
| try { | try { | ||||
| const data = await fetchJoborderProductProcessesPage({ | const data = await fetchJoborderProductProcessesPage({ | ||||
| date: dayStr, | date: dayStr, | ||||
| lookbackDays: 0, | |||||
| lookbackDays: PRODUCTION_LOOKBACK_DAYS, | |||||
| qcReady: true, | qcReady: true, | ||||
| includePutaway: true, | includePutaway: true, | ||||
| putawayStatus: "notCompleted", | putawayStatus: "notCompleted", | ||||
| @@ -233,8 +288,7 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | |||||
| }); | }); | ||||
| const rows = (data?.content ?? []) | const rows = (data?.content ?? []) | ||||
| .map((p) => toProductionRow(p, "pending_qc")) | .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)); | setPendingQcRows(dedupeByJobOrder(rows)); | ||||
| } catch (e) { | } catch (e) { | ||||
| console.error("loadPendingQc failed", e); | console.error("loadPendingQc failed", e); | ||||
| @@ -255,8 +309,7 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | |||||
| }); | }); | ||||
| const rows = (data?.content ?? []) | const rows = (data?.content ?? []) | ||||
| .map((p) => toProductionRow(p, "putawayed")) | .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)); | setPutawayedRows(dedupeByJobOrder(rows)); | ||||
| } catch (e) { | } catch (e) { | ||||
| console.error("loadPutawayed failed", e); | console.error("loadPutawayed failed", e); | ||||
| @@ -303,65 +356,108 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | |||||
| useEffect(() => { | useEffect(() => { | ||||
| setPage(0); | 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(() => { | const filteredRows = useMemo(() => { | ||||
| if (primaryTab === "pending_qc") return pendingQcRows; | |||||
| if (primaryTab === "putawayed") return putawayedRows; | if (primaryTab === "putawayed") return putawayedRows; | ||||
| if (primaryTab === "pending_qc") return pendingQcRows; | |||||
| if (primaryTab === "carried_over") return offPlanRows; | |||||
| if (primaryTab === "issue" && issueSub === "cancel") return cancelledRows; | if (primaryTab === "issue" && issueSub === "cancel") return cancelledRows; | ||||
| let list = productionRows; | let list = productionRows; | ||||
| if (primaryTab === "pending") { | 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") { | } 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") { | } 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; | return list; | ||||
| }, [ | }, [ | ||||
| primaryTab, | primaryTab, | ||||
| pendingSub, | |||||
| processingSub, | |||||
| productionSub, | |||||
| issueSub, | issueSub, | ||||
| productionRows, | productionRows, | ||||
| pendingQcRows, | pendingQcRows, | ||||
| putawayedRows, | putawayedRows, | ||||
| cancelledRows, | cancelledRows, | ||||
| offPlanRows, | |||||
| applyProductionSub, | |||||
| ]); | ]); | ||||
| const counts = useMemo(() => { | 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 stop = productionRows.filter((r) => r.isPaused).length; | ||||
| const scopeForSub = | |||||
| primaryTab === "pending" | |||||
| ? pendingRows | |||||
| : primaryTab === "processing" | |||||
| ? processingRows | |||||
| : productionRows; | |||||
| return { | return { | ||||
| all: productionRows.length, | |||||
| pending, | |||||
| processing, | |||||
| needsAction: productionRows.length, | |||||
| pending: pendingRows.length, | |||||
| processing: processingRows.length, | |||||
| carriedOver: offPlanRows.length, | |||||
| pendingQc: pendingQcRows.length, | pendingQc: pendingQcRows.length, | ||||
| putawayed: putawayedRows.length, | putawayed: putawayedRows.length, | ||||
| stop, | stop, | ||||
| cancel: cancelledRows.length, | 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 paginatedRows = useMemo(() => { | ||||
| const start = page * pageSize; | const start = page * pageSize; | ||||
| @@ -404,7 +500,7 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | |||||
| const handleComplete = useCallback( | const handleComplete = useCallback( | ||||
| (row: OpsRow) => { | (row: OpsRow) => { | ||||
| if (!canManage || row.isCancelled) return; | |||||
| if (!canComplete || row.isCancelled) return; | |||||
| openConfirm(t("Confirm to update this Job Order?"), async () => { | openConfirm(t("Confirm to update this Job Order?"), async () => { | ||||
| if (actionInFlightRef.current) return; | if (actionInFlightRef.current) return; | ||||
| actionInFlightRef.current = true; | actionInFlightRef.current = true; | ||||
| @@ -429,12 +525,12 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | |||||
| } | } | ||||
| }); | }); | ||||
| }, | }, | ||||
| [canManage, openConfirm, t, loadData], | |||||
| [canComplete, openConfirm, t, loadData], | |||||
| ); | ); | ||||
| const handleCancel = useCallback( | const handleCancel = useCallback( | ||||
| (row: OpsRow) => { | (row: OpsRow) => { | ||||
| if (!canManage || row.isCancelled) return; | |||||
| if (!canCancel || row.isCancelled) return; | |||||
| openConfirm(t("Cancel job order confirm message"), async () => { | openConfirm(t("Cancel job order confirm message"), async () => { | ||||
| if (actionInFlightRef.current) return; | if (actionInFlightRef.current) return; | ||||
| actionInFlightRef.current = true; | actionInFlightRef.current = true; | ||||
| @@ -448,7 +544,7 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | |||||
| } | } | ||||
| }); | }); | ||||
| }, | }, | ||||
| [canManage, openConfirm, t, loadData], | |||||
| [canCancel, openConfirm, t, loadData], | |||||
| ); | ); | ||||
| const onConfirm = useCallback(async () => { | 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 === "pending_qc") return t("Waiting QC Put Away"); | ||||
| if (row.rowKind === "putawayed") return t("Put Awayed"); | if (row.rowKind === "putawayed") return t("Put Awayed"); | ||||
| if (row.isCancelled) return t("Cancelled"); | 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) { | switch (row.pickProcessBucket) { | ||||
| case "not_picked_not_started": | case "not_picked_not_started": | ||||
| case "not_picked_started": | case "not_picked_started": | ||||
| @@ -478,14 +586,27 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | |||||
| case "picked_started": | case "picked_started": | ||||
| return t("Picked"); | return t("Picked"); | ||||
| default: | default: | ||||
| // Prefer explicit key over t("completed") → purchaseOrder「已上架」 | |||||
| if (String(row.statusLabel).toLowerCase() === "completed") { | |||||
| return t("Completed"); | |||||
| } | |||||
| return row.statusLabel || "-"; | 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 ( | return ( | ||||
| <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-hk"> | <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-hk"> | ||||
| @@ -519,72 +640,52 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | |||||
| sx={{ mb: 1, borderBottom: 1, borderColor: "divider" }} | sx={{ mb: 1, borderBottom: 1, borderColor: "divider" }} | ||||
| > | > | ||||
| <Tab | <Tab | ||||
| value="all" | |||||
| label={counts.all > 0 ? `${t("All")} (${counts.all})` : t("All")} | |||||
| value="needs_action" | |||||
| label={tabLabel(t("Needs action"), counts.needsAction)} | |||||
| /> | /> | ||||
| <Tab | <Tab | ||||
| value="pending" | value="pending" | ||||
| label={ | |||||
| counts.pending > 0 | |||||
| ? `${t("pending")} (${counts.pending})` | |||||
| : t("pending") | |||||
| } | |||||
| label={tabLabel(t("pending"), counts.pending)} | |||||
| /> | /> | ||||
| <Tab | <Tab | ||||
| value="processing" | 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 | <Tab | ||||
| value="pending_qc" | 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 | <Tab | ||||
| value="putawayed" | value="putawayed" | ||||
| label={ | |||||
| counts.putawayed > 0 | |||||
| ? `${t("Put Awayed")} (${counts.putawayed})` | |||||
| : t("Put Awayed") | |||||
| } | |||||
| label={tabLabel(t("Put Awayed"), counts.putawayed)} | |||||
| /> | /> | ||||
| <Tab | <Tab | ||||
| value="issue" | 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> | </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 | <Tabs | ||||
| value={processingSub} | |||||
| onChange={(_, v: ProcessingSubTab) => setProcessingSub(v)} | |||||
| value={productionSub} | |||||
| onChange={(_, v: ProductionSubTab) => setProductionSub(v)} | |||||
| sx={{ mb: 2 }} | 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> | </Tabs> | ||||
| )} | )} | ||||
| @@ -596,19 +697,11 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | |||||
| > | > | ||||
| <Tab | <Tab | ||||
| value="stop" | value="stop" | ||||
| label={ | |||||
| counts.stop > 0 | |||||
| ? `${t("Stop (paused)")} (${counts.stop})` | |||||
| : t("Stop (paused)") | |||||
| } | |||||
| label={tabLabel(t("Stop (paused)"), counts.stop)} | |||||
| /> | /> | ||||
| <Tab | <Tab | ||||
| value="cancel" | value="cancel" | ||||
| label={ | |||||
| counts.cancel > 0 | |||||
| ? `${t("Cancelled")} (${counts.cancel})` | |||||
| : t("Cancelled") | |||||
| } | |||||
| label={tabLabel(t("Cancelled"), counts.cancel)} | |||||
| /> | /> | ||||
| </Tabs> | </Tabs> | ||||
| )} | )} | ||||
| @@ -643,24 +736,78 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | |||||
| ) : ( | ) : ( | ||||
| paginatedRows.map((row) => { | paginatedRows.map((row) => { | ||||
| const busy = rowBusyIds.has(row.jobOrderId); | const busy = rowBusyIds.has(row.jobOrderId); | ||||
| const carriedOver = isCarriedOver(row, searchDay); | |||||
| const carriedCellSx = carriedOver | |||||
| ? { | |||||
| bgcolor: "warning.light", | |||||
| borderBottomColor: "warning.main", | |||||
| } | |||||
| : undefined; | |||||
| return ( | 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"> | <Typography variant="body2"> | ||||
| {[row.itemCode, row.itemName].filter(Boolean).join(" ")} | {[row.itemCode, row.itemName].filter(Boolean).join(" ")} | ||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell align="right"> | |||||
| <TableCell align="right" sx={carriedCellSx}> | |||||
| {row.requiredQty} | {row.requiredQty} | ||||
| {row.uom ? ` ${row.uom}` : ""} | {row.uom ? ` ${row.uom}` : ""} | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell> | |||||
| <TableCell sx={carriedCellSx}> | |||||
| {row.productionDate && dayjs(row.productionDate).isValid() | {row.productionDate && dayjs(row.productionDate).isValid() | ||||
| ? dayjs(row.productionDate).format(OUTPUT_DATE_FORMAT) | ? dayjs(row.productionDate).format(OUTPUT_DATE_FORMAT) | ||||
| : "-"} | : "-"} | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell> | |||||
| <TableCell sx={carriedCellSx}> | |||||
| <Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap> | <Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap> | ||||
| {row.isPaused && ( | {row.isPaused && ( | ||||
| <Chip size="small" color="warning" label={t("Stop (paused)")} /> | <Chip size="small" color="warning" label={t("Stop (paused)")} /> | ||||
| @@ -672,7 +819,7 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | |||||
| /> | /> | ||||
| </Stack> | </Stack> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell align="center"> | |||||
| <TableCell align="center" sx={carriedCellSx}> | |||||
| <Stack | <Stack | ||||
| direction="row" | direction="row" | ||||
| spacing={1} | spacing={1} | ||||
| @@ -687,29 +834,23 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | |||||
| > | > | ||||
| {t("View Details")} | {t("View Details")} | ||||
| </Button> | </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 | <Button | ||||
| size="small" | size="small" | ||||
| variant="contained" | variant="contained" | ||||
| onClick={() => handleOpenQcModal(row)} | onClick={() => handleOpenQcModal(row)} | ||||
| > | > | ||||
| {t("Put Away Detail")} | |||||
| {primaryTab === "putawayed" || | |||||
| row.rowKind === "putawayed" | |||||
| ? t("Put Away Detail") | |||||
| : t("view stockin")} | |||||
| </Button> | </Button> | ||||
| )} | )} | ||||
| {showManageActions && !row.isCancelled && ( | |||||
| {showManageActionsForRow(row) && ( | |||||
| <Button | <Button | ||||
| size="small" | size="small" | ||||
| variant="contained" | variant="contained" | ||||
| disabled={!canManage || busy} | |||||
| disabled={!canComplete || busy} | |||||
| onClick={() => handleComplete(row)} | onClick={() => handleComplete(row)} | ||||
| startIcon={ | startIcon={ | ||||
| busy ? ( | busy ? ( | ||||
| @@ -720,12 +861,12 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | |||||
| {t("Update Job Order")} | {t("Update Job Order")} | ||||
| </Button> | </Button> | ||||
| )} | )} | ||||
| {showManageActions && !row.isCancelled && ( | |||||
| {showManageActionsForRow(row) && ( | |||||
| <Button | <Button | ||||
| size="small" | size="small" | ||||
| variant="outlined" | variant="outlined" | ||||
| color="warning" | color="warning" | ||||
| disabled={!canManage || busy} | |||||
| disabled={!canCancel || busy} | |||||
| onClick={() => handleCancel(row)} | onClick={() => handleCancel(row)} | ||||
| > | > | ||||
| {t("Cancel Job Order")} | {t("Cancel Job Order")} | ||||
| @@ -33,6 +33,7 @@ import { Operator, Machine } from "@/app/api/jo"; | |||||
| import { useQrCodeScannerContext } from '../QrCodeScannerProvider/QrCodeScannerProvider'; | import { useQrCodeScannerContext } from '../QrCodeScannerProvider/QrCodeScannerProvider'; | ||||
| import { useSession } from "next-auth/react"; | import { useSession } from "next-auth/react"; | ||||
| import { SessionWithTokens } from "@/config/authConfig"; | import { SessionWithTokens } from "@/config/authConfig"; | ||||
| import { AUTH, hasAbility } from "@/authorities"; | |||||
| import PlayArrowIcon from "@mui/icons-material/PlayArrow"; | import PlayArrowIcon from "@mui/icons-material/PlayArrow"; | ||||
| import CheckCircleIcon from "@mui/icons-material/CheckCircle"; | import CheckCircleIcon from "@mui/icons-material/CheckCircle"; | ||||
| import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
| @@ -65,6 +66,7 @@ interface ProductProcessDetailProps { | |||||
| fromJosave?: boolean; | fromJosave?: boolean; | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 50 | v1.0.0 | 2026-08-05 */ | |||||
| const ProductionProcessDetail: React.FC<ProductProcessDetailProps> = ({ | const ProductionProcessDetail: React.FC<ProductProcessDetailProps> = ({ | ||||
| jobOrderId, | jobOrderId, | ||||
| onBack, | onBack, | ||||
| @@ -74,6 +76,9 @@ const ProductionProcessDetail: React.FC<ProductProcessDetailProps> = ({ | |||||
| const { t } = useTranslation(["productionProcess", "common"]); | const { t } = useTranslation(["productionProcess", "common"]); | ||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | 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 currentUserId = session?.id ? parseInt(session.id) : undefined; | ||||
| const { values: qrValues, startScan, stopScan, resetScan } = useQrCodeScannerContext(); | const { values: qrValues, startScan, stopScan, resetScan } = useQrCodeScannerContext(); | ||||
| const [showOutputPage, setShowOutputPage] = useState(false); | const [showOutputPage, setShowOutputPage] = useState(false); | ||||
| @@ -253,6 +258,7 @@ const fetchProcessDetailRef = useRef<() => Promise<void>>(); | |||||
| }, [editingLineId]); | }, [editingLineId]); | ||||
| const handlePassLine = useCallback(async (lineId: number) => { | const handlePassLine = useCallback(async (lineId: number) => { | ||||
| if (!canAdminPass) return; | |||||
| try { | try { | ||||
| await passProductProcessLine(lineId); | await passProductProcessLine(lineId); | ||||
| // 刷新数据 | // 刷新数据 | ||||
| @@ -261,12 +267,13 @@ const fetchProcessDetailRef = useRef<() => Promise<void>>(); | |||||
| console.error("Error passing line:", error); | console.error("Error passing line:", error); | ||||
| alert(t("Failed to pass line. Please try again.")); | alert(t("Failed to pass line. Please try again.")); | ||||
| } | } | ||||
| }, [fetchProcessDetail, t]); | |||||
| }, [canAdminPass, fetchProcessDetail, t]); | |||||
| const openPassConfirm = useCallback((lineId: number) => { | const openPassConfirm = useCallback((lineId: number) => { | ||||
| if (!canAdminPass) return; | |||||
| setPassConfirmLineId(lineId); | setPassConfirmLineId(lineId); | ||||
| setPassConfirmOpen(true); | setPassConfirmOpen(true); | ||||
| }, []); | |||||
| }, [canAdminPass]); | |||||
| const closePassConfirm = useCallback(() => { | const closePassConfirm = useCallback(() => { | ||||
| setPassConfirmOpen(false); | setPassConfirmOpen(false); | ||||
| @@ -662,7 +669,7 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => { | |||||
| const isPaused = statusLower === 'paused'; | const isPaused = statusLower === 'paused'; | ||||
| const isPending = statusLower === 'pending' || status === ''; | const isPending = statusLower === 'pending' || status === ''; | ||||
| const isPass = statusLower === 'pass'; | const isPass = statusLower === 'pass'; | ||||
| const isPassDisabled = isCompleted || isPass; | |||||
| const isPassDisabled = isCompleted || isPass || !canAdminPass; | |||||
| return ( | return ( | ||||
| <TableRow key={line.id}> | <TableRow key={line.id}> | ||||
| <TableCell> | <TableCell> | ||||
| @@ -53,7 +53,7 @@ interface ProductProcessJobOrderDetailProps { | |||||
| initialTabIndex?: number; | 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> = ({ | const ProductionProcessJobOrderDetail: React.FC<ProductProcessJobOrderDetailProps> = ({ | ||||
| jobOrderId, | jobOrderId, | ||||
| onBack, | onBack, | ||||
| @@ -36,7 +36,7 @@ import { SessionWithTokens } from "@/config/authConfig"; | |||||
| import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
| import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | ||||
| import SearchBox, { Criterion } from "@/components/SearchBox/SearchBox"; | import SearchBox, { Criterion } from "@/components/SearchBox/SearchBox"; | ||||
| import { AUTH } from "@/authorities"; | |||||
| import { AUTH, hasAbility } from "@/authorities"; | |||||
| import { | import { | ||||
| @@ -55,6 +55,7 @@ export type ProductionProcessListTab = | |||||
| | "needs_action" | | "needs_action" | ||||
| | "pending" | | "pending" | ||||
| | "processing" | | "processing" | ||||
| | "carried_over" | |||||
| | "pending_qc" | | "pending_qc" | ||||
| | "putawayed"; | | "putawayed"; | ||||
| @@ -67,7 +68,7 @@ export type ProductionProcessListPersistedState = { | |||||
| selectedItemCodes: string[]; | selectedItemCodes: string[]; | ||||
| /** | /** | ||||
| * Unified list tabs: | * 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. | * Legacy: all → needs_action; fine pick buckets remapped to pending/processing. | ||||
| */ | */ | ||||
| pickBucket: ProductionProcessListTab | string; | pickBucket: ProductionProcessListTab | string; | ||||
| @@ -135,7 +136,14 @@ export function createDefaultProductionProcessListPersistedState(): ProductionPr | |||||
| function normalizeListTab(raw: string | undefined | null): ProductionProcessListTab { | function normalizeListTab(raw: string | undefined | null): ProductionProcessListTab { | ||||
| const v = (raw || "needs_action").trim(); | 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; | return v; | ||||
| } | } | ||||
| if (v === "all") return "needs_action"; | if (v === "all") return "needs_action"; | ||||
| @@ -144,7 +152,30 @@ function normalizeListTab(raw: string | undefined | null): ProductionProcessList | |||||
| return "needs_action"; | 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> = ({ | const ProductProcessList: React.FC<ProductProcessListProps> = ({ | ||||
| onSelectProcess, | onSelectProcess, | ||||
| printerCombo, | printerCombo, | ||||
| @@ -158,6 +189,12 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||||
| const sessionToken = session as SessionWithTokens | null; | const sessionToken = session as SessionWithTokens | null; | ||||
| const [loading, setLoading] = useState(false); | const [loading, setLoading] = useState(false); | ||||
| const [processes, setProcesses] = useState<AllJoborderProductProcessInfoResponse[]>([]); | const [processes, setProcesses] = useState<AllJoborderProductProcessInfoResponse[]>([]); | ||||
| const [productionCache, setProductionCache] = useState< | |||||
| AllJoborderProductProcessInfoResponse[] | |||||
| >([]); | |||||
| const [pendingQcCache, setPendingQcCache] = useState< | |||||
| AllJoborderProductProcessInfoResponse[] | |||||
| >([]); | |||||
| const [bucketCounts, setBucketCounts] = | const [bucketCounts, setBucketCounts] = | ||||
| useState<JobOrderProductProcessBucketCounts>(EMPTY_BUCKET_COUNTS); | useState<JobOrderProductProcessBucketCounts>(EMPTY_BUCKET_COUNTS); | ||||
| const [pendingQcCount, setPendingQcCount] = useState(0); | const [pendingQcCount, setPendingQcCount] = useState(0); | ||||
| @@ -167,13 +204,14 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||||
| const [modalInfo, setModalInfo] = useState<StockInLineInput>(); | const [modalInfo, setModalInfo] = useState<StockInLineInput>(); | ||||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | const currentUserId = session?.id ? parseInt(session.id) : undefined; | ||||
| const abilities = session?.abilities ?? session?.user?.abilities ?? []; | 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"; | type ProcessFilter = "all" | "drink" | "Powder_Mixture" | "other"; | ||||
| const listTab = normalizeListTab(listPersistedState.pickBucket); | const listTab = normalizeListTab(listPersistedState.pickBucket); | ||||
| const isProductionTab = | const isProductionTab = | ||||
| listTab === "needs_action" || listTab === "pending" || listTab === "processing"; | listTab === "needs_action" || listTab === "pending" || listTab === "processing"; | ||||
| const isCarriedOverTab = listTab === "carried_over"; | |||||
| const qcReady = listTab === "pending_qc" || listTab === "putawayed"; | const qcReady = listTab === "pending_qc" || listTab === "putawayed"; | ||||
| const putawayStatus = | const putawayStatus = | ||||
| listTab === "putawayed" | listTab === "putawayed" | ||||
| @@ -182,9 +220,10 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||||
| ? "notCompleted" | ? "notCompleted" | ||||
| : null; | : null; | ||||
| const includePutaway = qcReady ? true : 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 = | const enableCarryOver = | ||||
| !disableDateFilter && (isProductionTab || listTab === "pending_qc"); | |||||
| !disableDateFilter && | |||||
| (isProductionTab || listTab === "pending_qc" || isCarriedOverTab); | |||||
| const appliedSearch = useMemo( | const appliedSearch = useMemo( | ||||
| () => ({ | () => ({ | ||||
| @@ -352,27 +391,80 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||||
| setLoading(true); | setLoading(true); | ||||
| try { | try { | ||||
| const typeParam = filter === "all" ? undefined : filter; | 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, | date: disableDateFilter ? undefined : appliedSearch.date, | ||||
| itemCode: appliedSearch.itemCode, | itemCode: appliedSearch.itemCode, | ||||
| jobOrderCode: appliedSearch.jobOrderCode, | 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, | qcReady, | ||||
| includePutaway, | includePutaway, | ||||
| putawayStatus, | putawayStatus, | ||||
| type: typeParam, | |||||
| lookbackDays: enableCarryOver ? PRODUCTION_LOOKBACK_DAYS : undefined, | lookbackDays: enableCarryOver ? PRODUCTION_LOOKBACK_DAYS : undefined, | ||||
| bucket: isProductionTab ? "all" : undefined, | bucket: isProductionTab ? "all" : undefined, | ||||
| page: 0, | |||||
| size: FETCH_SIZE, | |||||
| }); | }); | ||||
| setProcesses(data?.content || []); | |||||
| const content = data?.content || []; | |||||
| setProcesses(content); | |||||
| setTotalJobOrders(data?.totalJobOrders || 0); | 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") { | if (qcReady && putawayStatus === "notCompleted") { | ||||
| setPendingQcCache(content); | |||||
| setPendingQcCount(data?.totalJobOrders || 0); | setPendingQcCount(data?.totalJobOrders || 0); | ||||
| } | } | ||||
| if (qcReady && putawayStatus === "completed") { | if (qcReady && putawayStatus === "completed") { | ||||
| @@ -397,6 +489,7 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||||
| putawayStatus, | putawayStatus, | ||||
| enableCarryOver, | enableCarryOver, | ||||
| isProductionTab, | isProductionTab, | ||||
| isCarriedOverTab, | |||||
| ]); | ]); | ||||
| useEffect(() => { | useEffect(() => { | ||||
| @@ -413,7 +506,7 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||||
| jobOrderCode: appliedSearch.jobOrderCode, | jobOrderCode: appliedSearch.jobOrderCode, | ||||
| type: typeParam, | type: typeParam, | ||||
| page: 0, | page: 0, | ||||
| size: 1, | |||||
| size: FETCH_SIZE, | |||||
| }; | }; | ||||
| (async () => { | (async () => { | ||||
| @@ -434,6 +527,7 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||||
| }), | }), | ||||
| fetchJoborderProductProcessesPage({ | fetchJoborderProductProcessesPage({ | ||||
| ...base, | ...base, | ||||
| size: 1, | |||||
| qcReady: true, | qcReady: true, | ||||
| includePutaway: true, | includePutaway: true, | ||||
| putawayStatus: "completed", | putawayStatus: "completed", | ||||
| @@ -441,8 +535,17 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||||
| ]); | ]); | ||||
| if (cancelled) return; | if (cancelled) return; | ||||
| if (prod?.bucketCounts) setBucketCounts(prod.bucketCounts); | 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); | setPendingQcCount(pendingQc?.totalJobOrders || 0); | ||||
| setPutawayedCount(putawayed?.totalJobOrders || 0); | setPutawayedCount(putawayed?.totalJobOrders || 0); | ||||
| if (prod?.carriedOverCount != null) setCarriedOverCount(prod.carriedOverCount); | |||||
| } catch (e) { | } catch (e) { | ||||
| console.error(e); | console.error(e); | ||||
| } | } | ||||
| @@ -471,6 +574,30 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||||
| bucketCounts.pickedStarted + bucketCounts.notPickedStarted; | bucketCounts.pickedStarted + bucketCounts.notPickedStarted; | ||||
| const needsActionCount = pendingCount + processingCount; | 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(() => { | const filteredProcesses = useMemo(() => { | ||||
| let list = processes; | let list = processes; | ||||
| if (listTab === "pending") { | if (listTab === "pending") { | ||||
| @@ -482,17 +609,20 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||||
| PROCESSING_FINE_BUCKETS.has(String(p.pickProcessBucket ?? "")), | PROCESSING_FINE_BUCKETS.has(String(p.pickProcessBucket ?? "")), | ||||
| ); | ); | ||||
| } | } | ||||
| // carried_over: fetch already filtered to off-plan only | |||||
| if (selectedItemCodes.length === 0) return list; | if (selectedItemCodes.length === 0) return list; | ||||
| return list.filter((p) => selectedItemCodes.includes(p.itemCode)); | return list.filter((p) => selectedItemCodes.includes(p.itemCode)); | ||||
| }, [processes, selectedItemCodes, listTab]); | }, [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(() => { | const displayCarriedOverCount = useMemo(() => { | ||||
| if (!enableCarryOver || !searchDay) return 0; | if (!enableCarryOver || !searchDay) return 0; | ||||
| @@ -800,6 +930,13 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||||
| value="processing" | value="processing" | ||||
| label={renderBucketTabLabel("Processing", processingCount)} | label={renderBucketTabLabel("Processing", processingCount)} | ||||
| /> | /> | ||||
| <Tab | |||||
| value="carried_over" | |||||
| label={renderBucketTabLabel( | |||||
| "Off-plan unfinished", | |||||
| offPlanTabCount, | |||||
| )} | |||||
| /> | |||||
| <Tab | <Tab | ||||
| value="pending_qc" | value="pending_qc" | ||||
| label={renderBucketTabLabel( | label={renderBucketTabLabel( | ||||
| @@ -868,21 +1005,42 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||||
| const statusLower = status.toLowerCase(); | const statusLower = status.toLowerCase(); | ||||
| const displayStatus = | const displayStatus = | ||||
| statusLower === "in_progress" ? "processing" : status; | 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" | ? "primary" | ||||
| : "default"; | : "default"; | ||||
| } | |||||
| const jobOrderCode = | const jobOrderCode = | ||||
| (process as any).jobOrderCode ?? | (process as any).jobOrderCode ?? | ||||
| @@ -896,12 +1054,14 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||||
| const joDay = process.date | const joDay = process.date | ||||
| ? dayjs(process.date).startOf("day") | ? dayjs(process.date).startOf("day") | ||||
| : null; | : 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 | const bomDescription = process.bomDescription | ||||
| ? String(process.bomDescription).trim() | ? String(process.bomDescription).trim() | ||||
| @@ -24,7 +24,10 @@ interface ProductionProcessPageProps { | |||||
| const STORAGE_KEY = 'productionProcess_selectedMatchingStock'; | 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 ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCombo }) => { | ||||
| const { t } = useTranslation(["common", "productionProcess"]); | const { t } = useTranslation(["common", "productionProcess"]); | ||||
| const [selectedProcessId, setSelectedProcessId] = useState<number | null>(null); | const [selectedProcessId, setSelectedProcessId] = useState<number | null>(null); | ||||
| @@ -1,12 +1,16 @@ | |||||
| "use client"; | "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"; | import { useCallback, useEffect, useState } from "react"; | ||||
| const POLL_MS = 60_000; | 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 = { | export type JobOrderFgAlertItem = { | ||||
| stockInLineId: number; | stockInLineId: number; | ||||
| @@ -19,35 +23,55 @@ export type JobOrderFgAlertItem = { | |||||
| lotNo: string | null; | 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 { | 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) { | export function useJobOrderFgStockInAlerts(enabled: boolean) { | ||||
| const [qcItems, setQcItems] = useState<JobOrderFgAlertItem[]>([]); | const [qcItems, setQcItems] = useState<JobOrderFgAlertItem[]>([]); | ||||
| const [putAwayItems, setPutAwayItems] = useState<JobOrderFgAlertItem[]>([]); | const [putAwayItems, setPutAwayItems] = useState<JobOrderFgAlertItem[]>([]); | ||||
| @@ -61,15 +85,19 @@ export function useJobOrderFgStockInAlerts(enabled: boolean) { | |||||
| } | } | ||||
| setLoading(true); | setLoading(true); | ||||
| try { | 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 { | } catch { | ||||
| setQcItems([]); | setQcItems([]); | ||||
| setPutAwayItems([]); | setPutAwayItems([]); | ||||
| @@ -214,6 +214,7 @@ | |||||
| "Total job orders": "Total job orders", | "Total job orders": "Total job orders", | ||||
| "Including carried over": "Including carried over", | "Including carried over": "Including carried over", | ||||
| "Carried over from past day": "Carried over from past day", | "Carried over from past day": "Carried over from past day", | ||||
| "Off-plan unfinished": "Off-plan unfinished", | |||||
| "All unfinished": "Needs action", | "All unfinished": "Needs action", | ||||
| "Needs action": "Needs action", | "Needs action": "Needs action", | ||||
| "Not picked · Not started": "Not picked · Not started", | "Not picked · Not started": "Not picked · Not started", | ||||
| @@ -77,7 +77,7 @@ | |||||
| "Job Order No.": "工單編號", | "Job Order No.": "工單編號", | ||||
| "Job Order and Product": "工單及貨品", | "Job Order and Product": "工單及貨品", | ||||
| "Issue": "異常", | "Issue": "異常", | ||||
| "Job Order Ops Table": "工單流程", | |||||
| "Job Order Ops Table": "工單生產流程", | |||||
| "Pending (picked)": "已提料", | "Pending (picked)": "已提料", | ||||
| "Pending (not picked)": "未提料", | "Pending (not picked)": "未提料", | ||||
| "Processing (picked)": "已提料", | "Processing (picked)": "已提料", | ||||
| @@ -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": "未按規劃完成工單", | |||||
| "All unfinished": "需處理", | "All unfinished": "需處理", | ||||
| "Needs action": "需處理", | "Needs action": "需處理", | ||||
| "Not picked · Not started": "未提料 · 未開工", | "Not picked · Not started": "未提料 · 未開工", | ||||