| @@ -905,7 +905,7 @@ export const fetchAllJoborderProductProcessInfo = cache(async (type?: string | n | |||
| ); | |||
| }); | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.0 | 2026-08-03 */ | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.5 | 2026-08-06 */ | |||
| export const fetchJoborderProductProcessesPage = cache(async (params: { | |||
| /** Job order / process date(YYYY-MM-DD) */ | |||
| date?: string | null; | |||
| @@ -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); | |||
| } | |||
| @@ -45,7 +45,7 @@ const Profile: React.FC<Props> = ({ avatarImageSrc, profileName }) => { | |||
| disablePadding: false, | |||
| sx: { py: 0, minWidth: 180 }, | |||
| }} | |||
| PaperProps={{ variant: "outlined" }} | |||
| PaperProps={{ variant: "outlined", elevation: 0 }} | |||
| > | |||
| <Typography sx={{ px: 2, py: 1.5, fontWeight: 600, color: "text.secondary", fontSize: "0.875rem" }}> | |||
| {profileName} | |||
| @@ -1,14 +1,12 @@ | |||
| "use client"; | |||
| import { | |||
| Autocomplete, | |||
| Badge, | |||
| Box, | |||
| Button, | |||
| CircularProgress, | |||
| Tab, | |||
| Tabs, | |||
| TextField, | |||
| Tooltip, | |||
| Typography, | |||
| } from "@mui/material"; | |||
| @@ -20,6 +18,7 @@ import GoodPickExecutionWorkbenchRecord from "./GoodPickExecutionWorkbenchRecord | |||
| import { useTranslation } from "react-i18next"; | |||
| import WorkbenchTicketReleaseTableTab from "./WorkbenchTicketReleaseTable"; | |||
| import { Stack } from "@mui/system"; | |||
| import PrinterSelect from "@/components/common/PrinterSelect"; | |||
| import Swal from "sweetalert2"; | |||
| import { printDNWorkbench } from "@/app/api/do/actions"; | |||
| import { | |||
| @@ -251,41 +250,21 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom | |||
| mb: 1, | |||
| }} | |||
| > | |||
| <Typography variant="body2" sx={{ minWidth: "fit-content" }}> | |||
| {t("A4 Printer")}: | |||
| </Typography> | |||
| <Autocomplete | |||
| options={a4Printers} | |||
| getOptionLabel={(option) => option.name || option.label || option.code || `Printer ${option.id}`} | |||
| <PrinterSelect | |||
| label={t("A4 Printer")} | |||
| printers={a4Printers} | |||
| value={a4Printer} | |||
| onChange={(_, newValue) => setA4Printer(newValue)} | |||
| sx={{ minWidth: 200 }} | |||
| size="small" | |||
| renderInput={(params) => ( | |||
| <TextField | |||
| {...params} | |||
| placeholder={t("A4 Printer")} | |||
| inputProps={{ ...params.inputProps, readOnly: true }} | |||
| /> | |||
| )} | |||
| onChange={setA4Printer} | |||
| placeholder={t("A4 Printer")} | |||
| minWidth={200} | |||
| /> | |||
| <Typography variant="body2" sx={{ minWidth: "fit-content" }}> | |||
| {t("Label Printer")}: | |||
| </Typography> | |||
| <Autocomplete | |||
| options={labelPrinters} | |||
| getOptionLabel={(option) => option.name || option.label || option.code || `Printer ${option.id}`} | |||
| <PrinterSelect | |||
| label={t("Label Printer")} | |||
| printers={labelPrinters} | |||
| value={labelPrinter} | |||
| onChange={(_, newValue) => setLabelPrinter(newValue)} | |||
| sx={{ minWidth: 200 }} | |||
| size="small" | |||
| renderInput={(params) => ( | |||
| <TextField | |||
| {...params} | |||
| placeholder={t("Label Printer")} | |||
| inputProps={{ ...params.inputProps, readOnly: true }} | |||
| /> | |||
| )} | |||
| onChange={setLabelPrinter} | |||
| placeholder={t("Label Printer")} | |||
| minWidth={200} | |||
| /> | |||
| <Button | |||
| variant="contained" | |||
| @@ -420,7 +399,10 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom | |||
| ); | |||
| }; | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 20 | v1.0.0 | 2026-07-19 */ | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 20 | v1.0.0 | 2026-07-19 | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 55 | v1.0.0 | 2026-08-06 | |||
| */ | |||
| const DoWorkbenchTabs: React.FC<Props> = (props) => ( | |||
| <Suspense | |||
| fallback={ | |||
| @@ -59,6 +59,7 @@ interface Props { | |||
| onStockAdjustmentSuccess?: () => void | Promise<void>; | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.5 | 2026-08-05 */ | |||
| const InventoryLotLineTable: React.FC<Props> = ({ | |||
| inventoryLotLines, pagingController, setPagingController, totalCount, inventory, | |||
| filterLotNo, | |||
| @@ -68,8 +69,7 @@ const InventoryLotLineTable: React.FC<Props> = ({ | |||
| const { t } = useTranslation(["inventory"]); | |||
| const { data: session } = useSession(); | |||
| const abilities = session?.user?.abilities ?? []; | |||
| const canStockAdjust = | |||
| abilities.includes(AUTH.ADMIN) || abilities.includes(AUTH.INVENTORY_ADJUST); | |||
| const canStockAdjust = abilities.includes(AUTH.INVENTORY_ADJUST); | |||
| const PRINT_PRINTER_ID_KEY = 'inventoryLotLinePrintPrinterId'; | |||
| const { setIsUploading } = useUploadContext(); | |||
| const [stockTransferModalOpen, setStockTransferModalOpen] = useState(false); | |||
| @@ -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="關閉" | |||
| @@ -31,7 +31,6 @@ import { | |||
| CardContent, | |||
| Radio, | |||
| alpha, | |||
| Autocomplete, | |||
| Dialog, | |||
| DialogActions, | |||
| DialogContent, | |||
| @@ -39,6 +38,7 @@ import { | |||
| } from "@mui/material"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { submitDialogWithWarning } from "../Swal/CustomAlerts"; | |||
| import PrinterSelect from "@/components/common/PrinterSelect"; | |||
| // import InputDataGrid, { TableRow } from "../InputDataGrid/InputDataGrid"; | |||
| import { | |||
| GridColDef, | |||
| @@ -252,6 +252,7 @@ interface PolInputResult { | |||
| dnQty: string, | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.0 | 2026-08-06 */ | |||
| const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||
| const cameras = useContext(CameraContext); | |||
| const { data: session } = useSession(); | |||
| @@ -1070,19 +1071,14 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||
| <Card sx={{ display: "block", flex: 1 }}> | |||
| <CardContent component={Stack} spacing={2}> | |||
| <Typography variant="h6">列印</Typography> | |||
| <Autocomplete | |||
| disableClearable | |||
| options={labelPrinters} | |||
| value={selectedPrinter} | |||
| onChange={(_event, value) => setSelectedPrinter(value)} | |||
| renderInput={(params) => ( | |||
| <TextField | |||
| {...params} | |||
| variant="outlined" | |||
| label={t("Printer")} | |||
| fullWidth | |||
| /> | |||
| )} | |||
| <PrinterSelect | |||
| label={t("Label Printer")} | |||
| printers={labelPrinters} | |||
| value={selectedPrinter ?? null} | |||
| onChange={(p) => setSelectedPrinter(p ?? undefined)} | |||
| placeholder={t("Label Printer")} | |||
| fullWidth | |||
| disabled={labelPrinters.length <= 0} | |||
| /> | |||
| <TextField | |||
| variant="outlined" | |||
| @@ -1,7 +1,6 @@ | |||
| "use client"; | |||
| import { QcItemWithChecks, QcData } from "@/app/api/qc"; | |||
| import { | |||
| Autocomplete, | |||
| Box, | |||
| Button, | |||
| Divider, | |||
| @@ -29,6 +28,7 @@ import dayjs from "dayjs"; | |||
| import { fetchPoQrcode } from "@/app/api/pdf/actions"; | |||
| import { downloadFile } from "@/app/utils/commonUtil"; | |||
| import { PrinterCombo } from "@/app/api/settings/printer"; | |||
| import PrinterSelect from "@/components/common/PrinterSelect"; | |||
| import { EscalationResult } from "@/app/api/escalation"; | |||
| import { SessionWithTokens } from "@/config/authConfig"; | |||
| import { GridRowModesModel } from "@mui/x-data-grid"; | |||
| @@ -71,6 +71,7 @@ interface CommonProps extends Omit<ModalProps, "children"> { | |||
| interface Props extends CommonProps { | |||
| // itemDetail: StockInLine & { qcResult?: PurchaseQcResult[] } & { escResult?: EscalationResult[] }; | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.0 | 2026-08-06 */ | |||
| const PoQcStockInModalVer2: React.FC<Props> = ({ | |||
| open, | |||
| onClose, | |||
| @@ -658,22 +659,16 @@ const PoQcStockInModalVer2: React.FC<Props> = ({ | |||
| </Grid> | |||
| {tabIndex == 1 && ( | |||
| <Stack direction="row" justifyContent="flex-end" gap={1} sx={{m:3, mt:"auto"}}> | |||
| <Autocomplete | |||
| disableClearable | |||
| options={printerCombo} | |||
| defaultValue={selectedPrinter} | |||
| onChange={(event, value) => { | |||
| setSelectedPrinter(value) | |||
| <Stack direction="row" justifyContent="flex-end" alignItems="center" gap={1} sx={{m:3, mt:"auto"}}> | |||
| <PrinterSelect | |||
| label={t("Label Printer")} | |||
| printers={printerCombo || []} | |||
| value={selectedPrinter ?? null} | |||
| onChange={(p) => { | |||
| if (p) setSelectedPrinter(p); | |||
| }} | |||
| renderInput={(params) => ( | |||
| <TextField | |||
| {...params} | |||
| variant="outlined" | |||
| label={t("Printer")} | |||
| sx={{ width: 300}} | |||
| /> | |||
| )} | |||
| placeholder={t("Label Printer")} | |||
| minWidth={300} | |||
| /> | |||
| <TextField | |||
| variant="outlined" | |||
| @@ -24,6 +24,8 @@ import { | |||
| TablePagination, | |||
| TableRow, | |||
| Typography, | |||
| Avatar, | |||
| Tooltip, | |||
| } from "@mui/material"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { useSession } from "next-auth/react"; | |||
| @@ -32,7 +34,8 @@ import { DatePicker } from "@mui/x-date-pickers/DatePicker"; | |||
| import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; | |||
| import dayjs, { Dayjs } from "dayjs"; | |||
| import { SessionWithTokens } from "@/config/authConfig"; | |||
| import { AUTH } from "@/authorities"; | |||
| import { AUTH, hasAbility } from "@/authorities"; | |||
| import PrinterSelect from "@/components/common/PrinterSelect"; | |||
| import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | |||
| import { | |||
| AllJoborderProductProcessInfoResponse, | |||
| @@ -41,6 +44,8 @@ import { | |||
| fetchJos, | |||
| fetchProductProcessesByJobOrderId, | |||
| setJobOrderHidden, | |||
| printFGStockInLabel, | |||
| PrintFGStockInLabelRequest, | |||
| } from "@/app/api/jo/actions"; | |||
| import { JobOrder } from "@/app/api/jo"; | |||
| import QcStockInModal from "@/components/Qc/QcStockInModal"; | |||
| @@ -48,16 +53,28 @@ import { StockInLineInput } from "@/app/api/stockIn"; | |||
| import type { PrinterCombo } from "@/app/api/settings/printer"; | |||
| type PrimaryTab = | |||
| | "all" | |||
| | "needs_action" | |||
| | "pending" | |||
| | "processing" | |||
| | "carried_over" | |||
| | "pending_qc" | |||
| | "putawayed" | |||
| | "issue"; | |||
| type PendingSubTab = "all" | "picked_not_started" | "not_picked_not_started"; | |||
| type ProcessingSubTab = "all" | "picked_started" | "not_picked_started"; | |||
| /** Shared small tabs for 需處理 / 待生產 / 生產中. */ | |||
| type ProductionSubTab = "all" | "not_picked" | "picked"; | |||
| type IssueSubTab = "stop" | "cancel"; | |||
| const NOT_PICKED_BUCKETS = new Set([ | |||
| "not_picked_not_started", | |||
| "not_picked_started", | |||
| ]); | |||
| const PICKED_BUCKETS = new Set(["picked_not_started", "picked_started"]); | |||
| const PENDING_BUCKETS = new Set([ | |||
| "not_picked_not_started", | |||
| "picked_not_started", | |||
| ]); | |||
| const PROCESSING_BUCKETS = new Set(["picked_started", "not_picked_started"]); | |||
| type OpsRow = { | |||
| key: string; | |||
| jobOrderId: number; | |||
| @@ -78,9 +95,20 @@ type OpsRow = { | |||
| sourceProcess?: AllJoborderProductProcessInfoResponse; | |||
| }; | |||
| /** Same as ProductionProcessList: unfinished from (searchDate - 4) .. searchDate. */ | |||
| const PRODUCTION_LOOKBACK_DAYS = 4; | |||
| /** Cards / page size for ops table fetch. */ | |||
| const FETCH_SIZE = 200; | |||
| function isCarriedOver(row: OpsRow, searchDay: Dayjs): boolean { | |||
| if (!row.productionDate || !dayjs(row.productionDate).isValid()) return false; | |||
| return dayjs(row.productionDate).startOf("day").isBefore(searchDay); | |||
| } | |||
| function tabLabel(text: string, count: number): string { | |||
| return count > 0 ? `${text} (${count})` : text; | |||
| } | |||
| function isPausedProcess(p: AllJoborderProductProcessInfoResponse): boolean { | |||
| return (p.lines ?? []).some( | |||
| (l) => String(l.status ?? "").trim().toLowerCase() === "paused", | |||
| @@ -162,11 +190,40 @@ function dedupeByJobOrder(rows: OpsRow[]): OpsRow[] { | |||
| return Array.from(map.values()); | |||
| } | |||
| /** Prefer pending_qc over production when same JO appears in off-plan merge. */ | |||
| function dedupeOffPlanRows(rows: OpsRow[]): OpsRow[] { | |||
| const map = new Map<number, OpsRow>(); | |||
| for (const row of rows) { | |||
| const existing = map.get(row.jobOrderId); | |||
| if (!existing) { | |||
| map.set(row.jobOrderId, row); | |||
| continue; | |||
| } | |||
| if (existing.rowKind !== "pending_qc" && row.rowKind === "pending_qc") { | |||
| map.set(row.jobOrderId, row); | |||
| continue; | |||
| } | |||
| if (!existing.isPaused && row.isPaused) { | |||
| map.set(row.jobOrderId, row); | |||
| } | |||
| } | |||
| return Array.from(map.values()); | |||
| } | |||
| function isRowPutawayCompleted(row: OpsRow): boolean { | |||
| return ( | |||
| String(row.sourceProcess?.stockInLineStatus ?? "") | |||
| .trim() | |||
| .toLowerCase() === "completed" | |||
| ); | |||
| } | |||
| interface JobOrderOpsTableProps { | |||
| onSelectProcess?: (jobOrderId: number) => void; | |||
| printerCombo?: PrinterCombo[]; | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 47 | v1.0.4 | 2026-08-06 */ | |||
| const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | |||
| onSelectProcess, | |||
| printerCombo = [], | |||
| @@ -175,14 +232,43 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | |||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | |||
| const sessionToken = session as SessionWithTokens | null; | |||
| const abilities = session?.abilities ?? session?.user?.abilities ?? []; | |||
| const canManage = abilities.some((a) => a.trim() === AUTH.ADMIN); | |||
| /** 取消工單:僅 ADMIN */ | |||
| const canCancel = hasAbility(abilities, AUTH.ADMIN); | |||
| /** 完成工單:僅 PRODUCT_PROCESS(工單 生產流程 完成工單) */ | |||
| const canComplete = hasAbility(abilities, AUTH.PRODUCT_PROCESS); | |||
| const labelPrinterCombo = useMemo( | |||
| () => (printerCombo || []).filter((p) => p.type === "Label"), | |||
| [printerCombo], | |||
| ); | |||
| const printerOptions = useMemo( | |||
| () => (labelPrinterCombo.length > 0 ? labelPrinterCombo : printerCombo || []), | |||
| [labelPrinterCombo, printerCombo], | |||
| ); | |||
| const [selectedPrinter, setSelectedPrinter] = useState<PrinterCombo | null>( | |||
| () => printerOptions[0] ?? null, | |||
| ); | |||
| const printInFlightRef = useRef(false); | |||
| const [printingSilId, setPrintingSilId] = useState<number | null>(null); | |||
| useEffect(() => { | |||
| if (!printerOptions.length) { | |||
| setSelectedPrinter(null); | |||
| return; | |||
| } | |||
| setSelectedPrinter((prev) => { | |||
| if (prev && printerOptions.some((p) => p.id === prev.id)) return prev; | |||
| return printerOptions[0]; | |||
| }); | |||
| }, [printerOptions]); | |||
| const [queryDate, setQueryDate] = useState<Dayjs>(() => dayjs()); | |||
| const [primaryTab, setPrimaryTab] = useState<PrimaryTab>("all"); | |||
| const [pendingSub, setPendingSub] = useState<PendingSubTab>("all"); | |||
| const [processingSub, setProcessingSub] = useState<ProcessingSubTab>("all"); | |||
| const [primaryTab, setPrimaryTab] = useState<PrimaryTab>("needs_action"); | |||
| const [productionSub, setProductionSub] = useState<ProductionSubTab>("all"); | |||
| const [issueSub, setIssueSub] = useState<IssueSubTab>("stop"); | |||
| const searchDay = useMemo(() => queryDate.startOf("day"), [queryDate]); | |||
| const [productionRows, setProductionRows] = useState<OpsRow[]>([]); | |||
| const [pendingQcRows, setPendingQcRows] = useState<OpsRow[]>([]); | |||
| const [putawayedRows, setPutawayedRows] = useState<OpsRow[]>([]); | |||
| @@ -202,10 +288,9 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | |||
| const loadProduction = useCallback(async () => { | |||
| const dayStr = queryDate.format("YYYY-MM-DD"); | |||
| // lookbackDays=0 → exact search date only (still enables pickProcessBucket on backend) | |||
| const data = await fetchJoborderProductProcessesPage({ | |||
| date: dayStr, | |||
| lookbackDays: 0, | |||
| lookbackDays: PRODUCTION_LOOKBACK_DAYS, | |||
| bucket: "all", | |||
| qcReady: false, | |||
| page: 0, | |||
| @@ -213,8 +298,7 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | |||
| }); | |||
| const rows = (data?.content ?? []) | |||
| .map((p) => toProductionRow(p, "production")) | |||
| .filter((r): r is OpsRow => r != null) | |||
| .filter((r) => !r.productionDate || r.productionDate === dayStr); | |||
| .filter((r): r is OpsRow => r != null); | |||
| setProductionRows(dedupeByJobOrder(rows)); | |||
| }, [queryDate]); | |||
| @@ -223,7 +307,7 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | |||
| try { | |||
| const data = await fetchJoborderProductProcessesPage({ | |||
| date: dayStr, | |||
| lookbackDays: 0, | |||
| lookbackDays: PRODUCTION_LOOKBACK_DAYS, | |||
| qcReady: true, | |||
| includePutaway: true, | |||
| putawayStatus: "notCompleted", | |||
| @@ -232,8 +316,7 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | |||
| }); | |||
| const rows = (data?.content ?? []) | |||
| .map((p) => toProductionRow(p, "pending_qc")) | |||
| .filter((r): r is OpsRow => r != null) | |||
| .filter((r) => !r.productionDate || r.productionDate === dayStr); | |||
| .filter((r): r is OpsRow => r != null); | |||
| setPendingQcRows(dedupeByJobOrder(rows)); | |||
| } catch (e) { | |||
| console.error("loadPendingQc failed", e); | |||
| @@ -254,8 +337,7 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | |||
| }); | |||
| const rows = (data?.content ?? []) | |||
| .map((p) => toProductionRow(p, "putawayed")) | |||
| .filter((r): r is OpsRow => r != null) | |||
| .filter((r) => !r.productionDate || r.productionDate === dayStr); | |||
| .filter((r): r is OpsRow => r != null); | |||
| setPutawayedRows(dedupeByJobOrder(rows)); | |||
| } catch (e) { | |||
| console.error("loadPutawayed failed", e); | |||
| @@ -268,8 +350,8 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | |||
| try { | |||
| const res = await fetchJos({ | |||
| code: "", | |||
| planStart: dayStr, | |||
| planStartTo: dayStr, | |||
| planStart: `${dayStr}T00:00`, | |||
| planStartTo: `${dayStr}T23:59:59`, | |||
| joSearchStatus: "cancel", | |||
| pageNum: 0, | |||
| pageSize: FETCH_SIZE, | |||
| @@ -302,65 +384,108 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | |||
| useEffect(() => { | |||
| setPage(0); | |||
| }, [primaryTab, pendingSub, processingSub, issueSub, queryDate]); | |||
| }, [primaryTab, productionSub, issueSub, queryDate]); | |||
| useEffect(() => { | |||
| setProductionSub("all"); | |||
| }, [primaryTab]); | |||
| const offPlanRows = useMemo(() => { | |||
| const fromProd = productionRows.filter( | |||
| (r) => isCarriedOver(r, searchDay) && !isRowPutawayCompleted(r), | |||
| ); | |||
| const fromQc = pendingQcRows.filter( | |||
| (r) => isCarriedOver(r, searchDay) && !isRowPutawayCompleted(r), | |||
| ); | |||
| return dedupeOffPlanRows([...fromProd, ...fromQc]); | |||
| }, [productionRows, pendingQcRows, searchDay]); | |||
| const applyProductionSub = useCallback((list: OpsRow[], sub: ProductionSubTab) => { | |||
| if (sub === "not_picked") { | |||
| return list.filter((r) => NOT_PICKED_BUCKETS.has(String(r.pickProcessBucket ?? ""))); | |||
| } | |||
| if (sub === "picked") { | |||
| return list.filter((r) => PICKED_BUCKETS.has(String(r.pickProcessBucket ?? ""))); | |||
| } | |||
| return list; | |||
| }, []); | |||
| const filteredRows = useMemo(() => { | |||
| if (primaryTab === "pending_qc") return pendingQcRows; | |||
| if (primaryTab === "putawayed") return putawayedRows; | |||
| if (primaryTab === "pending_qc") return pendingQcRows; | |||
| if (primaryTab === "carried_over") return offPlanRows; | |||
| if (primaryTab === "issue" && issueSub === "cancel") return cancelledRows; | |||
| let list = productionRows; | |||
| if (primaryTab === "pending") { | |||
| list = list.filter((r) => { | |||
| const b = r.pickProcessBucket; | |||
| if (pendingSub === "picked_not_started") return b === "picked_not_started"; | |||
| if (pendingSub === "not_picked_not_started") return b === "not_picked_not_started"; | |||
| return b === "picked_not_started" || b === "not_picked_not_started"; | |||
| }); | |||
| list = list.filter((r) => PENDING_BUCKETS.has(String(r.pickProcessBucket ?? ""))); | |||
| } else if (primaryTab === "processing") { | |||
| list = list.filter((r) => { | |||
| const b = r.pickProcessBucket; | |||
| if (processingSub === "picked_started") return b === "picked_started"; | |||
| if (processingSub === "not_picked_started") return b === "not_picked_started"; | |||
| return b === "picked_started" || b === "not_picked_started"; | |||
| }); | |||
| list = list.filter((r) => PROCESSING_BUCKETS.has(String(r.pickProcessBucket ?? ""))); | |||
| } else if (primaryTab === "issue" && issueSub === "stop") { | |||
| list = list.filter((r) => r.isPaused); | |||
| return list.filter((r) => r.isPaused); | |||
| } | |||
| if ( | |||
| primaryTab === "needs_action" || | |||
| primaryTab === "pending" || | |||
| primaryTab === "processing" | |||
| ) { | |||
| list = applyProductionSub(list, productionSub); | |||
| } | |||
| return list; | |||
| }, [ | |||
| primaryTab, | |||
| pendingSub, | |||
| processingSub, | |||
| productionSub, | |||
| issueSub, | |||
| productionRows, | |||
| pendingQcRows, | |||
| putawayedRows, | |||
| cancelledRows, | |||
| offPlanRows, | |||
| applyProductionSub, | |||
| ]); | |||
| const counts = useMemo(() => { | |||
| const pending = productionRows.filter( | |||
| (r) => | |||
| r.pickProcessBucket === "picked_not_started" || | |||
| r.pickProcessBucket === "not_picked_not_started", | |||
| ).length; | |||
| const processing = productionRows.filter( | |||
| (r) => | |||
| r.pickProcessBucket === "picked_started" || | |||
| r.pickProcessBucket === "not_picked_started", | |||
| ).length; | |||
| const pendingRows = productionRows.filter((r) => | |||
| PENDING_BUCKETS.has(String(r.pickProcessBucket ?? "")), | |||
| ); | |||
| const processingRows = productionRows.filter((r) => | |||
| PROCESSING_BUCKETS.has(String(r.pickProcessBucket ?? "")), | |||
| ); | |||
| const stop = productionRows.filter((r) => r.isPaused).length; | |||
| const scopeForSub = | |||
| primaryTab === "pending" | |||
| ? pendingRows | |||
| : primaryTab === "processing" | |||
| ? processingRows | |||
| : productionRows; | |||
| return { | |||
| all: productionRows.length, | |||
| pending, | |||
| processing, | |||
| needsAction: productionRows.length, | |||
| pending: pendingRows.length, | |||
| processing: processingRows.length, | |||
| carriedOver: offPlanRows.length, | |||
| pendingQc: pendingQcRows.length, | |||
| putawayed: putawayedRows.length, | |||
| stop, | |||
| cancel: cancelledRows.length, | |||
| subAll: scopeForSub.length, | |||
| subNotPicked: scopeForSub.filter((r) => | |||
| NOT_PICKED_BUCKETS.has(String(r.pickProcessBucket ?? "")), | |||
| ).length, | |||
| subPicked: scopeForSub.filter((r) => | |||
| PICKED_BUCKETS.has(String(r.pickProcessBucket ?? "")), | |||
| ).length, | |||
| }; | |||
| }, [productionRows, pendingQcRows, putawayedRows, cancelledRows]); | |||
| }, [ | |||
| productionRows, | |||
| pendingQcRows, | |||
| putawayedRows, | |||
| cancelledRows, | |||
| offPlanRows, | |||
| primaryTab, | |||
| ]); | |||
| const paginatedRows = useMemo(() => { | |||
| const start = page * pageSize; | |||
| @@ -403,7 +528,7 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | |||
| const handleComplete = useCallback( | |||
| (row: OpsRow) => { | |||
| if (!canManage || row.isCancelled) return; | |||
| if (!canComplete || row.isCancelled) return; | |||
| openConfirm(t("Confirm to update this Job Order?"), async () => { | |||
| if (actionInFlightRef.current) return; | |||
| actionInFlightRef.current = true; | |||
| @@ -428,12 +553,12 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | |||
| } | |||
| }); | |||
| }, | |||
| [canManage, openConfirm, t, loadData], | |||
| [canComplete, openConfirm, t, loadData], | |||
| ); | |||
| const handleCancel = useCallback( | |||
| (row: OpsRow) => { | |||
| if (!canManage || row.isCancelled) return; | |||
| if (!canCancel || row.isCancelled) return; | |||
| openConfirm(t("Cancel job order confirm message"), async () => { | |||
| if (actionInFlightRef.current) return; | |||
| actionInFlightRef.current = true; | |||
| @@ -447,7 +572,7 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | |||
| } | |||
| }); | |||
| }, | |||
| [canManage, openConfirm, t, loadData], | |||
| [canCancel, openConfirm, t, loadData], | |||
| ); | |||
| const onConfirm = useCallback(async () => { | |||
| @@ -469,6 +594,18 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | |||
| if (row.rowKind === "pending_qc") return t("Waiting QC Put Away"); | |||
| if (row.rowKind === "putawayed") return t("Put Awayed"); | |||
| if (row.isCancelled) return t("Cancelled"); | |||
| if (primaryTab === "carried_over") { | |||
| switch (row.pickProcessBucket) { | |||
| case "not_picked_not_started": | |||
| case "picked_not_started": | |||
| return t("pending"); | |||
| case "picked_started": | |||
| case "not_picked_started": | |||
| return t("processing"); | |||
| default: | |||
| break; | |||
| } | |||
| } | |||
| switch (row.pickProcessBucket) { | |||
| case "not_picked_not_started": | |||
| case "not_picked_started": | |||
| @@ -477,14 +614,65 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | |||
| case "picked_started": | |||
| return t("Picked"); | |||
| default: | |||
| // Prefer explicit key over t("completed") → purchaseOrder「已上架」 | |||
| if (String(row.statusLabel).toLowerCase() === "completed") { | |||
| return t("Completed"); | |||
| } | |||
| return row.statusLabel || "-"; | |||
| } | |||
| }; | |||
| const showManageActions = | |||
| primaryTab !== "pending_qc" && | |||
| primaryTab !== "putawayed" && | |||
| !(primaryTab === "issue" && issueSub === "cancel"); | |||
| const showManageActionsForRow = (row: OpsRow) => { | |||
| if (row.isCancelled || row.rowKind === "putawayed") return false; | |||
| if (row.rowKind === "pending_qc") return false; | |||
| if (primaryTab === "pending_qc" || primaryTab === "putawayed") return false; | |||
| if (primaryTab === "issue" && issueSub === "cancel") return false; | |||
| return true; | |||
| }; | |||
| const showQcActionForRow = (row: OpsRow) => | |||
| (primaryTab === "pending_qc" || | |||
| primaryTab === "putawayed" || | |||
| (primaryTab === "carried_over" && row.rowKind === "pending_qc")) && | |||
| row.stockInLineId != null; | |||
| const showPrintLabelForRow = (row: OpsRow) => | |||
| (primaryTab === "putawayed" || row.rowKind === "putawayed") && | |||
| row.stockInLineId != null; | |||
| const handlePrintLabel = useCallback( | |||
| async (row: OpsRow) => { | |||
| if (printInFlightRef.current) return; | |||
| if (!selectedPrinter) { | |||
| alert(t("Please select a label printer")); | |||
| return; | |||
| } | |||
| if (!row.stockInLineId) { | |||
| alert(t("Invalid Stock In Line Id")); | |||
| return; | |||
| } | |||
| printInFlightRef.current = true; | |||
| setPrintingSilId(row.stockInLineId); | |||
| try { | |||
| const data: PrintFGStockInLabelRequest = { | |||
| stockInLineId: row.stockInLineId, | |||
| printerId: selectedPrinter.id, | |||
| printQty: 1, | |||
| }; | |||
| await printFGStockInLabel(data); | |||
| alert(t("Print job sent successfully")); | |||
| } catch (error: unknown) { | |||
| console.error("Error printing:", error); | |||
| const msg = | |||
| error instanceof Error ? error.message : String(error ?? "Unknown error"); | |||
| alert(t(`Print failed: ${msg}`)); | |||
| } finally { | |||
| setPrintingSilId(null); | |||
| printInFlightRef.current = false; | |||
| } | |||
| }, | |||
| [selectedPrinter, t], | |||
| ); | |||
| return ( | |||
| <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-hk"> | |||
| @@ -510,48 +698,87 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | |||
| </Button> | |||
| </Stack> | |||
| <Tabs | |||
| value={primaryTab} | |||
| onChange={(_, v: PrimaryTab) => setPrimaryTab(v)} | |||
| variant="scrollable" | |||
| scrollButtons="auto" | |||
| sx={{ mb: 1, borderBottom: 1, borderColor: "divider" }} | |||
| <Stack | |||
| direction="row" | |||
| alignItems="center" | |||
| justifyContent="space-between" | |||
| spacing={2} | |||
| sx={{ mb: 1, flexWrap: "wrap", rowGap: 1 }} | |||
| > | |||
| <Tab value="all" label={`${t("All")} (${counts.all})`} /> | |||
| <Tab value="pending" label={`${t("pending")} (${counts.pending})`} /> | |||
| <Tab value="processing" label={`${t("Processing")} (${counts.processing})`} /> | |||
| <Tab | |||
| value="pending_qc" | |||
| label={`${t("Waiting QC Put Away")} (${counts.pendingQc})`} | |||
| /> | |||
| <Tab value="putawayed" label={`${t("Put Awayed")} (${counts.putawayed})`} /> | |||
| <Tab | |||
| value="issue" | |||
| label={`${t("Issue")} (${counts.stop + counts.cancel})`} | |||
| /> | |||
| </Tabs> | |||
| {primaryTab === "pending" && ( | |||
| <Tabs | |||
| value={pendingSub} | |||
| onChange={(_, v: PendingSubTab) => setPendingSub(v)} | |||
| sx={{ mb: 2 }} | |||
| value={primaryTab} | |||
| onChange={(_, v: PrimaryTab) => setPrimaryTab(v)} | |||
| variant="scrollable" | |||
| scrollButtons="auto" | |||
| sx={{ | |||
| flex: 1, | |||
| minWidth: 0, | |||
| borderBottom: 1, | |||
| borderColor: "divider", | |||
| "& .MuiTabs-flexContainer": { | |||
| columnGap: 0.5, | |||
| }, | |||
| "& .MuiTab-root": { | |||
| minWidth: "auto", | |||
| px: 1, | |||
| }, | |||
| }} | |||
| > | |||
| <Tab value="all" label={t("All")} /> | |||
| <Tab value="picked_not_started" label={t("Picked")} /> | |||
| <Tab value="not_picked_not_started" label={t("Not picked")} /> | |||
| <Tab | |||
| value="needs_action" | |||
| label={tabLabel(t("Needs action"), counts.needsAction)} | |||
| /> | |||
| <Tab | |||
| value="pending" | |||
| label={tabLabel(t("pending"), counts.pending)} | |||
| /> | |||
| <Tab | |||
| value="processing" | |||
| label={tabLabel(t("Processing"), counts.processing)} | |||
| /> | |||
| <Tab | |||
| value="pending_qc" | |||
| label={tabLabel(t("Waiting QC Put Away"), counts.pendingQc)} | |||
| /> | |||
| <Tab | |||
| value="putawayed" | |||
| label={tabLabel(t("Put Awayed"), counts.putawayed)} | |||
| /> | |||
| <Tab | |||
| value="issue" | |||
| label={tabLabel(t("Issue"), counts.stop + counts.cancel)} | |||
| /> | |||
| <Tab | |||
| value="carried_over" | |||
| label={tabLabel(t("Off-plan unfinished"), counts.carriedOver)} | |||
| /> | |||
| </Tabs> | |||
| )} | |||
| <PrinterSelect | |||
| label={t("Label Printer")} | |||
| printers={printerOptions} | |||
| value={selectedPrinter} | |||
| onChange={setSelectedPrinter} | |||
| placeholder={t("Label Printer")} | |||
| /> | |||
| </Stack> | |||
| {primaryTab === "processing" && ( | |||
| {( | |||
| ["needs_action", "pending", "processing"] as PrimaryTab[] | |||
| ).includes(primaryTab) && ( | |||
| <Tabs | |||
| value={processingSub} | |||
| onChange={(_, v: ProcessingSubTab) => setProcessingSub(v)} | |||
| value={productionSub} | |||
| onChange={(_, v: ProductionSubTab) => setProductionSub(v)} | |||
| sx={{ mb: 2 }} | |||
| > | |||
| <Tab value="all" label={t("All")} /> | |||
| <Tab value="picked_started" label={t("Picked")} /> | |||
| <Tab value="not_picked_started" label={t("Not picked")} /> | |||
| <Tab value="all" label={tabLabel(t("All"), counts.subAll)} /> | |||
| <Tab | |||
| value="not_picked" | |||
| label={tabLabel(t("Not picked"), counts.subNotPicked)} | |||
| /> | |||
| <Tab | |||
| value="picked" | |||
| label={tabLabel(t("Picked"), counts.subPicked)} | |||
| /> | |||
| </Tabs> | |||
| )} | |||
| @@ -561,8 +788,14 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | |||
| onChange={(_, v: IssueSubTab) => setIssueSub(v)} | |||
| sx={{ mb: 2 }} | |||
| > | |||
| <Tab value="stop" label={`${t("Stop (paused)")} (${counts.stop})`} /> | |||
| <Tab value="cancel" label={`${t("Cancelled")} (${counts.cancel})`} /> | |||
| <Tab | |||
| value="stop" | |||
| label={tabLabel(t("Stop (paused)"), counts.stop)} | |||
| /> | |||
| <Tab | |||
| value="cancel" | |||
| label={tabLabel(t("Cancelled"), counts.cancel)} | |||
| /> | |||
| </Tabs> | |||
| )} | |||
| @@ -576,8 +809,8 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | |||
| <Table size="small" stickyHeader sx={{ minWidth: 800 }}> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell>{t("Job Order")}</TableCell> | |||
| <TableCell>{t("Item")}</TableCell> | |||
| <TableCell>{t("Job Order Code")}</TableCell> | |||
| <TableCell>{t("Item Code")}</TableCell> | |||
| <TableCell align="right">{t("Required Qty")}</TableCell> | |||
| <TableCell>{t("Production Date")}</TableCell> | |||
| <TableCell>{t("Status")}</TableCell> | |||
| @@ -596,24 +829,78 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | |||
| ) : ( | |||
| paginatedRows.map((row) => { | |||
| const busy = rowBusyIds.has(row.jobOrderId); | |||
| const carriedOver = isCarriedOver(row, searchDay); | |||
| const carriedCellSx = carriedOver | |||
| ? { | |||
| bgcolor: "warning.light", | |||
| borderBottomColor: "warning.main", | |||
| } | |||
| : undefined; | |||
| return ( | |||
| <TableRow key={row.key} hover> | |||
| <TableCell>{row.jobOrderCode}</TableCell> | |||
| <TableCell> | |||
| <TableRow | |||
| key={row.key} | |||
| hover={!carriedOver} | |||
| sx={ | |||
| carriedOver | |||
| ? { | |||
| bgcolor: "warning.light", | |||
| "&:hover": { | |||
| bgcolor: "warning.light", | |||
| }, | |||
| "& > .MuiTableCell-root": { | |||
| bgcolor: "warning.light", | |||
| }, | |||
| "&:hover > .MuiTableCell-root": { | |||
| bgcolor: "warning.light", | |||
| }, | |||
| } | |||
| : undefined | |||
| } | |||
| > | |||
| <TableCell sx={carriedCellSx}> | |||
| <Stack | |||
| direction="row" | |||
| alignItems="center" | |||
| spacing={0.75} | |||
| > | |||
| <Typography variant="body2" component="span"> | |||
| {row.jobOrderCode} | |||
| </Typography> | |||
| {carriedOver ? ( | |||
| <Tooltip title={t("Carried over from past day")}> | |||
| <Avatar | |||
| aria-label={t("Carried over from past day")} | |||
| sx={{ | |||
| width: 24, | |||
| height: 24, | |||
| flexShrink: 0, | |||
| bgcolor: "error.main", | |||
| color: "error.contrastText", | |||
| fontSize: "0.9rem", | |||
| fontWeight: 800, | |||
| }} | |||
| > | |||
| ! | |||
| </Avatar> | |||
| </Tooltip> | |||
| ) : null} | |||
| </Stack> | |||
| </TableCell> | |||
| <TableCell sx={carriedCellSx}> | |||
| <Typography variant="body2"> | |||
| {[row.itemCode, row.itemName].filter(Boolean).join(" ")} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| <TableCell align="right" sx={carriedCellSx}> | |||
| {row.requiredQty} | |||
| {row.uom ? ` ${row.uom}` : ""} | |||
| </TableCell> | |||
| <TableCell> | |||
| <TableCell sx={carriedCellSx}> | |||
| {row.productionDate && dayjs(row.productionDate).isValid() | |||
| ? dayjs(row.productionDate).format(OUTPUT_DATE_FORMAT) | |||
| : "-"} | |||
| </TableCell> | |||
| <TableCell> | |||
| <TableCell sx={carriedCellSx}> | |||
| <Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap> | |||
| {row.isPaused && ( | |||
| <Chip size="small" color="warning" label={t("Stop (paused)")} /> | |||
| @@ -625,7 +912,7 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | |||
| /> | |||
| </Stack> | |||
| </TableCell> | |||
| <TableCell align="center"> | |||
| <TableCell align="center" sx={carriedCellSx}> | |||
| <Stack | |||
| direction="row" | |||
| spacing={1} | |||
| @@ -640,29 +927,39 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | |||
| > | |||
| {t("View Details")} | |||
| </Button> | |||
| {primaryTab === "pending_qc" && row.stockInLineId != null && ( | |||
| {showQcActionForRow(row) && ( | |||
| <Button | |||
| size="small" | |||
| variant="contained" | |||
| onClick={() => handleOpenQcModal(row)} | |||
| > | |||
| {t("view stockin")} | |||
| {primaryTab === "putawayed" || | |||
| row.rowKind === "putawayed" | |||
| ? t("Put Away Detail") | |||
| : t("view stockin")} | |||
| </Button> | |||
| )} | |||
| {primaryTab === "putawayed" && row.stockInLineId != null && ( | |||
| {showPrintLabelForRow(row) && ( | |||
| <Button | |||
| size="small" | |||
| variant="contained" | |||
| onClick={() => handleOpenQcModal(row)} | |||
| color="secondary" | |||
| disabled={ | |||
| !selectedPrinter || | |||
| printingSilId === row.stockInLineId | |||
| } | |||
| onClick={() => void handlePrintLabel(row)} | |||
| > | |||
| {t("Put Away Detail")} | |||
| {printingSilId === row.stockInLineId | |||
| ? t("Printing") | |||
| : t("Print Label")} | |||
| </Button> | |||
| )} | |||
| {showManageActions && !row.isCancelled && ( | |||
| {showManageActionsForRow(row) && ( | |||
| <Button | |||
| size="small" | |||
| variant="contained" | |||
| disabled={!canManage || busy} | |||
| disabled={!canComplete || busy} | |||
| onClick={() => handleComplete(row)} | |||
| startIcon={ | |||
| busy ? ( | |||
| @@ -673,12 +970,12 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | |||
| {t("Update Job Order")} | |||
| </Button> | |||
| )} | |||
| {showManageActions && !row.isCancelled && ( | |||
| {showManageActionsForRow(row) && ( | |||
| <Button | |||
| size="small" | |||
| variant="outlined" | |||
| color="warning" | |||
| disabled={!canManage || busy} | |||
| disabled={!canCancel || busy} | |||
| onClick={() => handleCancel(row)} | |||
| > | |||
| {t("Cancel Job Order")} | |||
| @@ -33,6 +33,7 @@ import { Operator, Machine } from "@/app/api/jo"; | |||
| import { useQrCodeScannerContext } from '../QrCodeScannerProvider/QrCodeScannerProvider'; | |||
| import { useSession } from "next-auth/react"; | |||
| import { SessionWithTokens } from "@/config/authConfig"; | |||
| import { AUTH, hasAbility } from "@/authorities"; | |||
| import PlayArrowIcon from "@mui/icons-material/PlayArrow"; | |||
| import CheckCircleIcon from "@mui/icons-material/CheckCircle"; | |||
| import dayjs from "dayjs"; | |||
| @@ -65,15 +66,19 @@ interface ProductProcessDetailProps { | |||
| fromJosave?: boolean; | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 50 | v1.0.0 | 2026-08-05 */ | |||
| const ProductionProcessDetail: React.FC<ProductProcessDetailProps> = ({ | |||
| jobOrderId, | |||
| onBack, | |||
| fromJosave, | |||
| }) => { | |||
| console.log(" ProductionProcessDetail RENDER", { jobOrderId, fromJosave }); | |||
| // console.log(" ProductionProcessDetail RENDER", { jobOrderId, fromJosave }); | |||
| const { t } = useTranslation(["productionProcess", "common"]); | |||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | |||
| const abilities = session?.abilities ?? session?.user?.abilities ?? []; | |||
| /** 「已完成」(Just Pass):僅 ADMIN */ | |||
| const canAdminPass = hasAbility(abilities, AUTH.ADMIN); | |||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | |||
| const { values: qrValues, startScan, stopScan, resetScan } = useQrCodeScannerContext(); | |||
| const [showOutputPage, setShowOutputPage] = useState(false); | |||
| @@ -140,10 +145,10 @@ const fetchProcessDetailRef = useRef<() => Promise<void>>(); | |||
| }, [onBack]); | |||
| // 获取 process 和 lines 数据 | |||
| const fetchProcessDetail = useCallback(async () => { | |||
| console.log(" fetchProcessDetail CALLED", { jobOrderId, timestamp: new Date().toISOString() }); | |||
| // console.log(" fetchProcessDetail CALLED", { jobOrderId, timestamp: new Date().toISOString() }); | |||
| setLoading(true); | |||
| try { | |||
| console.log(` Loading process detail for JobOrderId: ${jobOrderId}`); | |||
| // console.log(` Loading process detail for JobOrderId: ${jobOrderId}`); | |||
| const processesWithLines = await fetchProductProcessesByJobOrderId(jobOrderId); | |||
| @@ -157,8 +162,8 @@ const fetchProcessDetailRef = useRef<() => Promise<void>>(); | |||
| const lines = currentProcess.productProcessLines || []; | |||
| setLines(lines); | |||
| linesRef.current = lines; | |||
| console.log(" Process data loaded:", currentProcess); | |||
| console.log(" Lines loaded:", lines); | |||
| // console.log(" Process data loaded:", currentProcess); | |||
| // console.log(" Lines loaded:", lines); | |||
| } catch (error) { | |||
| console.error(" Error loading process detail:", error); | |||
| onBackRef.current(); | |||
| @@ -168,12 +173,12 @@ const fetchProcessDetailRef = useRef<() => Promise<void>>(); | |||
| }, [jobOrderId]); | |||
| const handleOpenTimeDialog = useCallback((lineId: number) => { | |||
| console.log("🔓 handleOpenTimeDialog CALLED", { lineId, timestamp: new Date().toISOString() }); | |||
| //console.log("🔓 handleOpenTimeDialog CALLED", { lineId, timestamp: new Date().toISOString() }); | |||
| // 直接使用 linesRef.current,避免触发 setLines | |||
| const line = linesRef.current.find(l => l.id === lineId); | |||
| if (line) { | |||
| console.log(" Found line:", line); | |||
| // console.log(" Found line:", line); | |||
| setEditingLineId(lineId); | |||
| setTimeValues({ | |||
| durationInMinutes: line.durationInMinutes || 0, | |||
| @@ -181,7 +186,7 @@ const fetchProcessDetailRef = useRef<() => Promise<void>>(); | |||
| postProdTimeInMinutes: line.postProdTimeInMinutes || 0, | |||
| }); | |||
| setOpenTimeDialog(true); | |||
| console.log(" Dialog opened"); | |||
| // console.log(" Dialog opened"); | |||
| } else { | |||
| console.warn(" Line not found:", lineId); | |||
| } | |||
| @@ -190,7 +195,7 @@ const fetchProcessDetailRef = useRef<() => Promise<void>>(); | |||
| fetchProcessDetailRef.current = fetchProcessDetail; | |||
| }, [fetchProcessDetail]); | |||
| const handleCloseTimeDialog = useCallback(() => { | |||
| console.log("🔒 handleCloseTimeDialog CALLED", { timestamp: new Date().toISOString() }); | |||
| //console.log("🔒 handleCloseTimeDialog CALLED", { timestamp: new Date().toISOString() }); | |||
| setOpenTimeDialog(false); | |||
| setEditingLineId(null); | |||
| setTimeValues({ | |||
| @@ -198,11 +203,11 @@ const fetchProcessDetailRef = useRef<() => Promise<void>>(); | |||
| prepTimeInMinutes: 0, | |||
| postProdTimeInMinutes: 0, | |||
| }); | |||
| console.log(" Dialog closed"); | |||
| //console.log(" Dialog closed"); | |||
| }, []); | |||
| const handleConfirmTimeUpdate = useCallback(async () => { | |||
| console.log("💾 handleConfirmTimeUpdate CALLED", { editingLineId, timeValues, timestamp: new Date().toISOString() }); | |||
| //console.log("💾 handleConfirmTimeUpdate CALLED", { editingLineId, timeValues, timestamp: new Date().toISOString() }); | |||
| if (!editingLineId) return; | |||
| try { | |||
| @@ -223,10 +228,7 @@ const fetchProcessDetailRef = useRef<() => Promise<void>>(); | |||
| }, [editingLineId, timeValues, fetchProcessDetail, handleCloseTimeDialog, t]); | |||
| useEffect(() => { | |||
| console.log("🔄 useEffect [jobOrderId] TRIGGERED", { | |||
| jobOrderId, | |||
| timestamp: new Date().toISOString() | |||
| }); | |||
| //console.log("🔄 useEffect [jobOrderId] TRIGGERED", { jobOrderId,timestamp: new Date().toISOString()}); | |||
| if (fetchProcessDetailRef.current) { | |||
| fetchProcessDetailRef.current(); | |||
| } | |||
| @@ -234,25 +236,26 @@ const fetchProcessDetailRef = useRef<() => Promise<void>>(); | |||
| // 添加监听 openTimeDialog 变化的 useEffect | |||
| useEffect(() => { | |||
| console.log(" openTimeDialog changed:", { openTimeDialog, timestamp: new Date().toISOString() }); | |||
| //console.log(" openTimeDialog changed:", { openTimeDialog, timestamp: new Date().toISOString() }); | |||
| }, [openTimeDialog]); | |||
| // 添加监听 timeValues 变化的 useEffect | |||
| useEffect(() => { | |||
| console.log(" timeValues changed:", { timeValues, timestamp: new Date().toISOString() }); | |||
| //console.log(" timeValues changed:", { timeValues, timestamp: new Date().toISOString() }); | |||
| }, [timeValues]); | |||
| // 添加监听 lines 变化的 useEffect | |||
| useEffect(() => { | |||
| console.log(" lines changed:", { count: lines.length, lines, timestamp: new Date().toISOString() }); | |||
| //console.log(" lines changed:", { count: lines.length, lines, timestamp: new Date().toISOString() }); | |||
| }, [lines]); | |||
| // 添加监听 editingLineId 变化的 useEffect | |||
| useEffect(() => { | |||
| console.log(" editingLineId changed:", { editingLineId, timestamp: new Date().toISOString() }); | |||
| //console.log(" editingLineId changed:", { editingLineId, timestamp: new Date().toISOString() }); | |||
| }, [editingLineId]); | |||
| const handlePassLine = useCallback(async (lineId: number) => { | |||
| if (!canAdminPass) return; | |||
| try { | |||
| await passProductProcessLine(lineId); | |||
| // 刷新数据 | |||
| @@ -261,12 +264,13 @@ const fetchProcessDetailRef = useRef<() => Promise<void>>(); | |||
| console.error("Error passing line:", error); | |||
| alert(t("Failed to pass line. Please try again.")); | |||
| } | |||
| }, [fetchProcessDetail, t]); | |||
| }, [canAdminPass, fetchProcessDetail, t]); | |||
| const openPassConfirm = useCallback((lineId: number) => { | |||
| if (!canAdminPass) return; | |||
| setPassConfirmLineId(lineId); | |||
| setPassConfirmOpen(true); | |||
| }, []); | |||
| }, [canAdminPass]); | |||
| const closePassConfirm = useCallback(() => { | |||
| setPassConfirmOpen(false); | |||
| @@ -662,7 +666,7 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => { | |||
| const isPaused = statusLower === 'paused'; | |||
| const isPending = statusLower === 'pending' || status === ''; | |||
| const isPass = statusLower === 'pass'; | |||
| const isPassDisabled = isCompleted || isPass; | |||
| const isPassDisabled = isCompleted || isPass || !canAdminPass; | |||
| return ( | |||
| <TableRow key={line.id}> | |||
| <TableCell> | |||
| @@ -53,7 +53,7 @@ interface ProductProcessJobOrderDetailProps { | |||
| initialTabIndex?: number; | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.0 | 2026-08-03 */ | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.5 | 2026-08-06 */ | |||
| const ProductionProcessJobOrderDetail: React.FC<ProductProcessJobOrderDetailProps> = ({ | |||
| jobOrderId, | |||
| onBack, | |||
| @@ -22,7 +22,6 @@ import { | |||
| DialogActions, | |||
| Tabs, | |||
| Tab, | |||
| Badge, | |||
| Tooltip, | |||
| IconButton, | |||
| Avatar, | |||
| @@ -37,7 +36,8 @@ import { SessionWithTokens } from "@/config/authConfig"; | |||
| import dayjs from "dayjs"; | |||
| import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | |||
| import SearchBox, { Criterion } from "@/components/SearchBox/SearchBox"; | |||
| import { AUTH } from "@/authorities"; | |||
| import { AUTH, hasAbility } from "@/authorities"; | |||
| import PrinterSelect from "@/components/common/PrinterSelect"; | |||
| import { | |||
| @@ -48,6 +48,8 @@ import { | |||
| assignJobOrderPickOrder, | |||
| fetchJoborderProductProcessesPage, | |||
| JobOrderProductProcessBucketCounts, | |||
| printFGStockInLabel, | |||
| PrintFGStockInLabelRequest, | |||
| } from "@/app/api/jo/actions"; | |||
| import { StockInLineInput } from "@/app/api/stockIn"; | |||
| import { PrinterCombo } from "@/app/api/settings/printer"; | |||
| @@ -56,6 +58,7 @@ export type ProductionProcessListTab = | |||
| | "needs_action" | |||
| | "pending" | |||
| | "processing" | |||
| | "carried_over" | |||
| | "pending_qc" | |||
| | "putawayed"; | |||
| @@ -68,7 +71,7 @@ export type ProductionProcessListPersistedState = { | |||
| selectedItemCodes: string[]; | |||
| /** | |||
| * Unified list tabs: | |||
| * needs_action (= pending+processing) | pending | processing | pending_qc | putawayed | |||
| * needs_action | pending | processing | carried_over | pending_qc | putawayed | |||
| * Legacy: all → needs_action; fine pick buckets remapped to pending/processing. | |||
| */ | |||
| pickBucket: ProductionProcessListTab | string; | |||
| @@ -136,7 +139,14 @@ export function createDefaultProductionProcessListPersistedState(): ProductionPr | |||
| function normalizeListTab(raw: string | undefined | null): ProductionProcessListTab { | |||
| const v = (raw || "needs_action").trim(); | |||
| if (v === "pending" || v === "processing" || v === "pending_qc" || v === "putawayed" || v === "needs_action") { | |||
| if ( | |||
| v === "pending" || | |||
| v === "processing" || | |||
| v === "carried_over" || | |||
| v === "pending_qc" || | |||
| v === "putawayed" || | |||
| v === "needs_action" | |||
| ) { | |||
| return v; | |||
| } | |||
| if (v === "all") return "needs_action"; | |||
| @@ -145,7 +155,30 @@ function normalizeListTab(raw: string | undefined | null): ProductionProcessList | |||
| return "needs_action"; | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.0 | 2026-08-03 */ | |||
| function isProcessCarriedOver( | |||
| p: AllJoborderProductProcessInfoResponse, | |||
| searchDay: ReturnType<typeof dayjs> | null, | |||
| ): boolean { | |||
| if (!searchDay || !p.date || !dayjs(p.date).isValid()) return false; | |||
| return dayjs(p.date).startOf("day").isBefore(searchDay); | |||
| } | |||
| function isPutawayCompleted( | |||
| p: AllJoborderProductProcessInfoResponse, | |||
| ): boolean { | |||
| return String(p.stockInLineStatus ?? "").trim().toLowerCase() === "completed"; | |||
| } | |||
| /** Waiting QC put-away (has SIL, not completed/rejected). */ | |||
| function isWaitingQcPutAway( | |||
| p: AllJoborderProductProcessInfoResponse, | |||
| ): boolean { | |||
| if (p.stockInLineId == null) return false; | |||
| const s = String(p.stockInLineStatus ?? "").trim().toLowerCase(); | |||
| return s !== "completed" && s !== "rejected"; | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.5 | 2026-08-06 */ | |||
| const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| onSelectProcess, | |||
| printerCombo, | |||
| @@ -154,11 +187,19 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| listPersistedState, | |||
| onListPersistedStateChange, | |||
| }) => { | |||
| const { t } = useTranslation( ["common", "productionProcess","purchaseOrder","dashboard"]); | |||
| const { t } = useTranslation( ["common", "productionProcess","purchaseOrder","dashboard","jo"]); | |||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | |||
| const sessionToken = session as SessionWithTokens | null; | |||
| const [loading, setLoading] = useState(false); | |||
| const [processes, setProcesses] = useState<AllJoborderProductProcessInfoResponse[]>([]); | |||
| const [productionCache, setProductionCache] = useState< | |||
| AllJoborderProductProcessInfoResponse[] | |||
| >([]); | |||
| const [pendingQcCache, setPendingQcCache] = useState< | |||
| AllJoborderProductProcessInfoResponse[] | |||
| >([]); | |||
| const [putawayedCache, setPutawayedCache] = useState< | |||
| AllJoborderProductProcessInfoResponse[] | |||
| >([]); | |||
| const [bucketCounts, setBucketCounts] = | |||
| useState<JobOrderProductProcessBucketCounts>(EMPTY_BUCKET_COUNTS); | |||
| const [pendingQcCount, setPendingQcCount] = useState(0); | |||
| @@ -168,24 +209,43 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| const [modalInfo, setModalInfo] = useState<StockInLineInput>(); | |||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | |||
| const abilities = session?.abilities ?? session?.user?.abilities ?? []; | |||
| // 依照 DB `authority.authority = 'ADMIN'` 的逻辑:僅 abilities 明確包含 ADMIN 才能操作 | |||
| const canManageUpdateJo = abilities.some((a) => a.trim() === AUTH.ADMIN); | |||
| /** 完成工單:僅 PRODUCT_PROCESS(工單 生產流程 完成工單);不含 ADMIN 以免群組 ADMIN 繞過勾選 */ | |||
| const canManageUpdateJo = hasAbility(abilities, AUTH.PRODUCT_PROCESS); | |||
| type ProcessFilter = "all" | "drink" | "Powder_Mixture" | "other"; | |||
| const listTab = normalizeListTab(listPersistedState.pickBucket); | |||
| const isProductionTab = | |||
| listTab === "needs_action" || listTab === "pending" || listTab === "processing"; | |||
| const qcReady = listTab === "pending_qc" || listTab === "putawayed"; | |||
| const putawayStatus = | |||
| listTab === "putawayed" | |||
| ? "completed" | |||
| : listTab === "pending_qc" | |||
| ? "notCompleted" | |||
| : null; | |||
| const includePutaway = qcReady ? true : null; | |||
| /** Production unfinished tabs: carry-over + pick buckets. Pending QC: carry-over only. */ | |||
| const isCarriedOverTab = listTab === "carried_over"; | |||
| /** Production unfinished tabs + pending QC + off-plan: carry-over window. */ | |||
| const enableCarryOver = | |||
| !disableDateFilter && (isProductionTab || listTab === "pending_qc"); | |||
| !disableDateFilter && | |||
| (isProductionTab || listTab === "pending_qc" || isCarriedOverTab); | |||
| const labelPrinterCombo = useMemo( | |||
| () => (printerCombo || []).filter((p) => p.type === "Label"), | |||
| [printerCombo], | |||
| ); | |||
| const printerOptions = useMemo( | |||
| () => (labelPrinterCombo.length > 0 ? labelPrinterCombo : printerCombo || []), | |||
| [labelPrinterCombo, printerCombo], | |||
| ); | |||
| const [selectedPrinter, setSelectedPrinter] = useState<PrinterCombo | null>( | |||
| () => printerOptions[0] ?? null, | |||
| ); | |||
| const printInFlightRef = useRef(false); | |||
| const [printingSilId, setPrintingSilId] = useState<number | null>(null); | |||
| useEffect(() => { | |||
| if (!printerOptions.length) { | |||
| setSelectedPrinter(null); | |||
| return; | |||
| } | |||
| setSelectedPrinter((prev) => { | |||
| if (prev && printerOptions.some((p) => p.id === prev.id)) return prev; | |||
| return printerOptions[0]; | |||
| }); | |||
| }, [printerOptions]); | |||
| const appliedSearch = useMemo( | |||
| () => ({ | |||
| @@ -208,67 +268,12 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| [appliedSearch.date], | |||
| ); | |||
| const [totalJobOrders, setTotalJobOrders] = useState(0); | |||
| // Generic confirm dialog for actions (update job order / etc.) | |||
| const [confirmOpen, setConfirmOpen] = useState(false); | |||
| const [confirmMessage, setConfirmMessage] = useState(""); | |||
| const [confirmLoading, setConfirmLoading] = useState(false); | |||
| const [pendingConfirmAction, setPendingConfirmAction] = useState<null | (() => Promise<void>)>(null); | |||
| // QC 的业务判定:同一个 jobOrder 下,所有 productProcess 的所有 lines 都必须是 Completed/Pass | |||
| // 才允许打开 QcStockInModal(避免仅某个 productProcess 完成就提前出现 view stockin)。 | |||
| const jobOrderQcReadyById = useMemo(() => { | |||
| const lineDone = (status: unknown) => { | |||
| const s = String(status ?? "").trim().toLowerCase(); | |||
| return s === "completed" || s === "pass"; | |||
| }; | |||
| const byJobOrder = new Map<number, AllJoborderProductProcessInfoResponse[]>(); | |||
| for (const p of processes) { | |||
| if (p.jobOrderId == null) continue; | |||
| const arr = byJobOrder.get(p.jobOrderId) ?? []; | |||
| arr.push(p); | |||
| byJobOrder.set(p.jobOrderId, arr); | |||
| } | |||
| const result = new Map<number, boolean>(); | |||
| const isDone = (status: unknown) => { | |||
| const s = String(status ?? "").trim().toLowerCase(); | |||
| return s === "completed" || s === "pass"; | |||
| }; | |||
| byJobOrder.forEach((jobOrderProcesses, jobOrderId) => { | |||
| const hasStockInLine = jobOrderProcesses.some((p) => p.stockInLineId != null); | |||
| const packingProcesses = jobOrderProcesses.filter( | |||
| (p) => String((p as any).code ?? "").trim() === "包裝" | |||
| ); | |||
| const nonPackingProcesses = jobOrderProcesses.filter( | |||
| (p) => String((p as any).code ?? "").trim() !== "包裝" | |||
| ); | |||
| const allNonPackingDone = | |||
| nonPackingProcesses.length === 0 || | |||
| nonPackingProcesses.every((p) => { | |||
| const lines = p.lines ?? []; | |||
| return lines.length > 0 && lines.every((l) => isDone(l.status)); | |||
| }); | |||
| const hasOnePackingDone = | |||
| packingProcesses.length > 0 && | |||
| packingProcesses.some((p) => { | |||
| const lines = p.lines ?? []; | |||
| return lines.some((l) => isDone(l.status)); | |||
| }); | |||
| const packingOk = packingProcesses.length === 0 ? true : hasOnePackingDone; | |||
| result.set(jobOrderId, hasStockInLine && allNonPackingDone && packingOk); | |||
| }); | |||
| return result; | |||
| }, [processes]); | |||
| const handleAssignPickOrder = useCallback(async (pickOrderId: number, jobOrderId?: number, productProcessId?: number) => { | |||
| if (!currentUserId) { | |||
| alert(t("Unable to get user ID")); | |||
| @@ -349,110 +354,146 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| })); | |||
| }, [disableDateFilter, onListPersistedStateChange]); | |||
| const fetchProcesses = useCallback(async () => { | |||
| /** Load all tab datasets once per search; tab switches use cache only. */ | |||
| const loadAllCaches = useCallback(async () => { | |||
| setLoading(true); | |||
| try { | |||
| const typeParam = filter === "all" ? undefined : filter; | |||
| // Production tabs share one fetch (bucket=all); pending/processing filter client-side. | |||
| const data = await fetchJoborderProductProcessesPage({ | |||
| const base = { | |||
| date: disableDateFilter ? undefined : appliedSearch.date, | |||
| itemCode: appliedSearch.itemCode, | |||
| jobOrderCode: appliedSearch.jobOrderCode, | |||
| qcReady, | |||
| includePutaway, | |||
| putawayStatus, | |||
| type: typeParam, | |||
| lookbackDays: enableCarryOver ? PRODUCTION_LOOKBACK_DAYS : undefined, | |||
| bucket: isProductionTab ? "all" : undefined, | |||
| page: 0, | |||
| size: FETCH_SIZE, | |||
| }); | |||
| setProcesses(data?.content || []); | |||
| setTotalJobOrders(data?.totalJobOrders || 0); | |||
| if (isProductionTab && data?.bucketCounts) { | |||
| setBucketCounts(data.bucketCounts); | |||
| } | |||
| if (qcReady && putawayStatus === "notCompleted") { | |||
| setPendingQcCount(data?.totalJobOrders || 0); | |||
| } | |||
| if (qcReady && putawayStatus === "completed") { | |||
| setPutawayedCount(data?.totalJobOrders || 0); | |||
| } | |||
| setCarriedOverCount(data?.carriedOverCount ?? 0); | |||
| }; | |||
| const lookback = disableDateFilter ? undefined : PRODUCTION_LOOKBACK_DAYS; | |||
| const [prod, pendingQc, putawayed] = await Promise.all([ | |||
| fetchJoborderProductProcessesPage({ | |||
| ...base, | |||
| qcReady: false, | |||
| lookbackDays: lookback, | |||
| bucket: "all", | |||
| }), | |||
| fetchJoborderProductProcessesPage({ | |||
| ...base, | |||
| qcReady: true, | |||
| includePutaway: true, | |||
| putawayStatus: "notCompleted", | |||
| lookbackDays: lookback, | |||
| }), | |||
| fetchJoborderProductProcessesPage({ | |||
| ...base, | |||
| qcReady: true, | |||
| includePutaway: true, | |||
| putawayStatus: "completed", | |||
| }), | |||
| ]); | |||
| const prodContent = (prod?.content || []).filter((p) => !isPutawayCompleted(p)); | |||
| const qcContent = (pendingQc?.content || []).filter((p) => !isPutawayCompleted(p)); | |||
| const putawayContent = putawayed?.content || []; | |||
| setProductionCache(prodContent); | |||
| setPendingQcCache(qcContent); | |||
| setPutawayedCache(putawayContent); | |||
| if (prod?.bucketCounts) setBucketCounts(prod.bucketCounts); | |||
| else setBucketCounts(EMPTY_BUCKET_COUNTS); | |||
| setPendingQcCount(pendingQc?.totalJobOrders || 0); | |||
| setPutawayedCount(putawayed?.totalJobOrders || 0); | |||
| setCarriedOverCount(prod?.carriedOverCount ?? 0); | |||
| } catch (e) { | |||
| console.error(e); | |||
| setProcesses([]); | |||
| setTotalJobOrders(0); | |||
| if (isProductionTab) setBucketCounts(EMPTY_BUCKET_COUNTS); | |||
| setProductionCache([]); | |||
| setPendingQcCache([]); | |||
| setPutawayedCache([]); | |||
| setBucketCounts(EMPTY_BUCKET_COUNTS); | |||
| setPendingQcCount(0); | |||
| setPutawayedCount(0); | |||
| setCarriedOverCount(0); | |||
| } finally { | |||
| setLoading(false); | |||
| } | |||
| }, [ | |||
| appliedSearch, | |||
| disableDateFilter, | |||
| filter, | |||
| qcReady, | |||
| includePutaway, | |||
| putawayStatus, | |||
| enableCarryOver, | |||
| isProductionTab, | |||
| ]); | |||
| }, [appliedSearch, disableDateFilter, filter]); | |||
| useEffect(() => { | |||
| fetchProcesses(); | |||
| }, [fetchProcesses]); | |||
| void loadAllCaches(); | |||
| }, [loadAllCaches]); | |||
| const offPlanRows = useMemo(() => { | |||
| if (!searchDay) return [] as AllJoborderProductProcessInfoResponse[]; | |||
| const merged = [...pendingQcCache, ...productionCache].filter((p) => | |||
| isProcessCarriedOver(p, searchDay), | |||
| ); | |||
| const byJo = new Map<number, AllJoborderProductProcessInfoResponse>(); | |||
| for (const p of merged) { | |||
| if (p.jobOrderId == null) continue; | |||
| const existing = byJo.get(p.jobOrderId); | |||
| if (!existing) { | |||
| byJo.set(p.jobOrderId, p); | |||
| } else if (existing.stockInLineId == null && p.stockInLineId != null) { | |||
| byJo.set(p.jobOrderId, p); | |||
| } | |||
| } | |||
| return Array.from(byJo.values()); | |||
| }, [productionCache, pendingQcCache, searchDay]); | |||
| /** Keep production + QC tab badges fresh even when not on that tab. */ | |||
| useEffect(() => { | |||
| let cancelled = false; | |||
| const typeParam = filter === "all" ? undefined : filter; | |||
| const base = { | |||
| date: disableDateFilter ? undefined : appliedSearch.date, | |||
| itemCode: appliedSearch.itemCode, | |||
| jobOrderCode: appliedSearch.jobOrderCode, | |||
| type: typeParam, | |||
| page: 0, | |||
| size: 1, | |||
| }; | |||
| /** Active tab rows from cache — no refetch on tab switch. */ | |||
| const tabProcesses = useMemo(() => { | |||
| if (listTab === "pending_qc") return pendingQcCache; | |||
| if (listTab === "putawayed") return putawayedCache; | |||
| if (listTab === "carried_over") return offPlanRows; | |||
| return productionCache; | |||
| }, [listTab, productionCache, pendingQcCache, putawayedCache, offPlanRows]); | |||
| (async () => { | |||
| try { | |||
| const [prod, pendingQc, putawayed] = await Promise.all([ | |||
| fetchJoborderProductProcessesPage({ | |||
| ...base, | |||
| qcReady: false, | |||
| lookbackDays: disableDateFilter ? undefined : PRODUCTION_LOOKBACK_DAYS, | |||
| bucket: "all", | |||
| }), | |||
| fetchJoborderProductProcessesPage({ | |||
| ...base, | |||
| qcReady: true, | |||
| includePutaway: true, | |||
| putawayStatus: "notCompleted", | |||
| lookbackDays: disableDateFilter ? undefined : PRODUCTION_LOOKBACK_DAYS, | |||
| }), | |||
| fetchJoborderProductProcessesPage({ | |||
| ...base, | |||
| qcReady: true, | |||
| includePutaway: true, | |||
| putawayStatus: "completed", | |||
| }), | |||
| ]); | |||
| if (cancelled) return; | |||
| if (prod?.bucketCounts) setBucketCounts(prod.bucketCounts); | |||
| setPendingQcCount(pendingQc?.totalJobOrders || 0); | |||
| setPutawayedCount(putawayed?.totalJobOrders || 0); | |||
| } catch (e) { | |||
| console.error(e); | |||
| } | |||
| })(); | |||
| // QC 的业务判定:同一个 jobOrder 下,所有 productProcess 的所有 lines 都必须是 Completed/Pass | |||
| const jobOrderQcReadyById = useMemo(() => { | |||
| const byJobOrder = new Map<number, AllJoborderProductProcessInfoResponse[]>(); | |||
| for (const p of tabProcesses) { | |||
| if (p.jobOrderId == null) continue; | |||
| const arr = byJobOrder.get(p.jobOrderId) ?? []; | |||
| arr.push(p); | |||
| byJobOrder.set(p.jobOrderId, arr); | |||
| } | |||
| return () => { | |||
| cancelled = true; | |||
| const result = new Map<number, boolean>(); | |||
| const isDone = (status: unknown) => { | |||
| const s = String(status ?? "").trim().toLowerCase(); | |||
| return s === "completed" || s === "pass"; | |||
| }; | |||
| }, [appliedSearch, disableDateFilter, filter]); | |||
| byJobOrder.forEach((jobOrderProcesses, jobOrderId) => { | |||
| const hasStockInLine = jobOrderProcesses.some((p) => p.stockInLineId != null); | |||
| const packingProcesses = jobOrderProcesses.filter( | |||
| (p) => String((p as any).code ?? "").trim() === "包裝", | |||
| ); | |||
| const nonPackingProcesses = jobOrderProcesses.filter( | |||
| (p) => String((p as any).code ?? "").trim() !== "包裝", | |||
| ); | |||
| const allNonPackingDone = | |||
| nonPackingProcesses.length === 0 || | |||
| nonPackingProcesses.every((p) => { | |||
| const lines = p.lines ?? []; | |||
| return lines.length > 0 && lines.every((l) => isDone(l.status)); | |||
| }); | |||
| const hasOnePackingDone = | |||
| packingProcesses.length > 0 && | |||
| packingProcesses.some((p) => { | |||
| const lines = p.lines ?? []; | |||
| return lines.some((l) => isDone(l.status)); | |||
| }); | |||
| const packingOk = packingProcesses.length === 0 ? true : hasOnePackingDone; | |||
| result.set(jobOrderId, hasStockInLine && allNonPackingDone && packingOk); | |||
| }); | |||
| return result; | |||
| }, [tabProcesses]); | |||
| const handleListTabChange = useCallback( | |||
| (_: React.SyntheticEvent, value: string) => { | |||
| @@ -472,8 +513,10 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| bucketCounts.pickedStarted + bucketCounts.notPickedStarted; | |||
| const needsActionCount = pendingCount + processingCount; | |||
| const offPlanTabCount = offPlanRows.length; | |||
| const filteredProcesses = useMemo(() => { | |||
| let list = processes; | |||
| let list = tabProcesses; | |||
| if (listTab === "pending") { | |||
| list = list.filter((p) => | |||
| PENDING_FINE_BUCKETS.has(String(p.pickProcessBucket ?? "")), | |||
| @@ -485,15 +528,21 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| } | |||
| if (selectedItemCodes.length === 0) return list; | |||
| return list.filter((p) => selectedItemCodes.includes(p.itemCode)); | |||
| }, [processes, selectedItemCodes, listTab]); | |||
| const displayTotalJobOrders = isProductionTab | |||
| ? listTab === "pending" | |||
| ? pendingCount | |||
| : listTab === "processing" | |||
| ? processingCount | |||
| : totalJobOrders || needsActionCount | |||
| : totalJobOrders; | |||
| }, [tabProcesses, selectedItemCodes, listTab]); | |||
| const displayTotalJobOrders = isCarriedOverTab | |||
| ? filteredProcesses.length | |||
| : isProductionTab | |||
| ? listTab === "pending" | |||
| ? pendingCount | |||
| : listTab === "processing" | |||
| ? processingCount | |||
| : needsActionCount | |||
| : listTab === "pending_qc" | |||
| ? pendingQcCount | |||
| : listTab === "putawayed" | |||
| ? putawayedCount | |||
| : filteredProcesses.length; | |||
| const displayCarriedOverCount = useMemo(() => { | |||
| if (!enableCarryOver || !searchDay) return 0; | |||
| @@ -578,38 +627,8 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| }, [loading, filteredProcesses, totalPages]); // eslint-disable-line react-hooks/exhaustive-deps | |||
| const renderBucketTabLabel = useCallback( | |||
| (labelKey: string, count: number) => ( | |||
| <Tooltip title={count > 0 ? t(labelKey) + `: ${count}` : t(labelKey)}> | |||
| <Box component="span" sx={{ display: "inline-flex", alignItems: "center" }}> | |||
| <Badge | |||
| color="error" | |||
| variant="standard" | |||
| badgeContent={count > 99 ? "99+" : count} | |||
| invisible={count === 0} | |||
| sx={{ | |||
| "& .MuiBadge-badge": { | |||
| fontWeight: 800, | |||
| fontSize: "0.7rem", | |||
| minWidth: 18, | |||
| height: 18, | |||
| lineHeight: "18px", | |||
| px: 0.5, | |||
| right: -8, | |||
| top: 2, | |||
| }, | |||
| }} | |||
| > | |||
| <Typography | |||
| component="span" | |||
| variant="inherit" | |||
| sx={{ pr: count > 0 ? 1 : 0 }} | |||
| > | |||
| {t(labelKey)} | |||
| </Typography> | |||
| </Badge> | |||
| </Box> | |||
| </Tooltip> | |||
| ), | |||
| (labelKey: string, count: number) => | |||
| count > 0 ? `${t(labelKey)} (${count})` : t(labelKey), | |||
| [t], | |||
| ); | |||
| const handleUpdateJo = useCallback(async (process: AllJoborderProductProcessInfoResponse) => { | |||
| @@ -640,14 +659,14 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| // await updateJo({ id: process.jobOrderId, status: "completed" }); | |||
| // 4) 刷新列表 | |||
| await fetchProcesses(); | |||
| await loadAllCaches(); | |||
| } catch (e) { | |||
| console.error(e); | |||
| alert(t("An error has occurred. Please try again later.")); | |||
| } finally { | |||
| setLoading(false); | |||
| } | |||
| }, [t, fetchProcesses, canManageUpdateJo]); | |||
| }, [t, loadAllCaches, canManageUpdateJo]); | |||
| const openConfirm = useCallback((message: string, action: () => Promise<void>) => { | |||
| setConfirmMessage(message); | |||
| @@ -672,23 +691,51 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| } | |||
| }, [pendingConfirmAction, closeConfirm]); | |||
| const closeNewModal = useCallback(() => { | |||
| // const response = updateJo({ id: 1, status: "storing" }); | |||
| setOpenModal(false); // Close the modal first | |||
| // fetchProcesses(); | |||
| // setTimeout(() => { | |||
| // }, 300); // Add a delay to avoid immediate re-trigger of useEffect | |||
| }, [fetchProcesses]); | |||
| setOpenModal(false); | |||
| }, []); | |||
| const handlePrintLabel = useCallback( | |||
| async (process: AllJoborderProductProcessInfoResponse) => { | |||
| if (printInFlightRef.current) return; | |||
| if (!selectedPrinter) { | |||
| alert(t("Please select a label printer")); | |||
| return; | |||
| } | |||
| if (!process.stockInLineId) { | |||
| alert(t("Invalid Stock In Line Id")); | |||
| return; | |||
| } | |||
| printInFlightRef.current = true; | |||
| setPrintingSilId(process.stockInLineId); | |||
| try { | |||
| const data: PrintFGStockInLabelRequest = { | |||
| stockInLineId: process.stockInLineId, | |||
| printerId: selectedPrinter.id, | |||
| printQty: 1, | |||
| }; | |||
| await printFGStockInLabel(data); | |||
| alert(t("Print job sent successfully")); | |||
| } catch (error: any) { | |||
| console.error("Error printing:", error); | |||
| alert(t(`Print failed: ${error?.message || "Unknown error"}`)); | |||
| } finally { | |||
| setPrintingSilId(null); | |||
| printInFlightRef.current = false; | |||
| } | |||
| }, | |||
| [selectedPrinter, t], | |||
| ); | |||
| const searchedItemOptions = useMemo( | |||
| () => | |||
| Array.from( | |||
| new Map( | |||
| processes | |||
| [...productionCache, ...pendingQcCache, ...putawayedCache] | |||
| .filter((p) => !!p.itemCode) | |||
| .map((p) => [p.itemCode, { itemCode: p.itemCode, itemName: p.itemName }]), | |||
| ).values(), | |||
| ), | |||
| [processes], | |||
| [productionCache, pendingQcCache, putawayedCache], | |||
| ); | |||
| /** Reset 用 ±3 天;preFilled 用目前已套用的條件(與列表查詢一致) */ | |||
| @@ -799,40 +846,45 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| {selectedItemCodes.length > 0 ? ` | ${t("Filtered")}: ${filteredProcesses.length}` : ""} | |||
| </Typography> | |||
| <Tabs | |||
| <Stack | |||
| direction="row" | |||
| alignItems="center" | |||
| justifyContent="space-between" | |||
| spacing={2} | |||
| sx={{ mb: 2, flexWrap: "wrap", rowGap: 1 }} | |||
| > | |||
| <Tabs | |||
| value={listTab} | |||
| onChange={handleListTabChange} | |||
| variant="scrollable" | |||
| scrollButtons="auto" | |||
| sx={{ | |||
| mb: 2, | |||
| flex: 1, | |||
| minWidth: 0, | |||
| borderBottom: 1, | |||
| borderColor: "divider", | |||
| "& .MuiTabs-flexContainer": { | |||
| columnGap: 2, | |||
| columnGap: 0.5, | |||
| rowGap: 1, | |||
| }, | |||
| "& .MuiTab-root": { | |||
| overflow: "visible", | |||
| minWidth: "auto", | |||
| px: 2, | |||
| px: 1, | |||
| }, | |||
| }} | |||
| > | |||
| <Tab | |||
| value="needs_action" | |||
| label={renderBucketTabLabel("Needs action", needsActionCount)} | |||
| sx={{ pr: needsActionCount > 0 ? 4 : 2 }} | |||
| /> | |||
| <Tab | |||
| value="pending" | |||
| label={renderBucketTabLabel("pending", pendingCount)} | |||
| sx={{ pr: pendingCount > 0 ? 4 : 2 }} | |||
| /> | |||
| <Tab | |||
| value="processing" | |||
| label={renderBucketTabLabel("Processing", processingCount)} | |||
| sx={{ pr: processingCount > 0 ? 4 : 2 }} | |||
| /> | |||
| <Tab | |||
| value="pending_qc" | |||
| @@ -840,14 +892,27 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| "Waiting QC Put Away", | |||
| pendingQcCount, | |||
| )} | |||
| sx={{ pr: pendingQcCount > 0 ? 4 : 2 }} | |||
| /> | |||
| <Tab | |||
| value="putawayed" | |||
| label={renderBucketTabLabel("Put Awayed", putawayedCount)} | |||
| sx={{ pr: putawayedCount > 0 ? 4 : 2 }} | |||
| /> | |||
| <Tab | |||
| value="carried_over" | |||
| label={renderBucketTabLabel( | |||
| "Off-plan unfinished", | |||
| offPlanTabCount, | |||
| )} | |||
| /> | |||
| </Tabs> | |||
| <PrinterSelect | |||
| label={t("Label Printer")} | |||
| printers={printerOptions} | |||
| value={selectedPrinter} | |||
| onChange={setSelectedPrinter} | |||
| placeholder={t("Label Printer")} | |||
| /> | |||
| </Stack> | |||
| <Box | |||
| sx={{ | |||
| @@ -904,21 +969,35 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| const statusLower = status.toLowerCase(); | |||
| const displayStatus = | |||
| statusLower === "in_progress" ? "processing" : status; | |||
| const chipLabel = qcReady | |||
| ? putawayStatus === "completed" | |||
| ? t("Put Awayed") | |||
| : t("Waiting QC Put Away") | |||
| : t(displayStatus); | |||
| const statusColor = qcReady | |||
| ? putawayStatus === "completed" | |||
| ? "success" | |||
| : "warning" | |||
| : statusLower === "completed" | |||
| ? "success" | |||
| : statusLower === "in_progress" || | |||
| statusLower === "processing" | |||
| const bucket = String(process.pickProcessBucket ?? ""); | |||
| const waitingQc = isWaitingQcPutAway(process); | |||
| // Avoid t("completed") → purchaseOrder「已上架」; use explicit keys. | |||
| let chipLabel: string; | |||
| let statusColor: "success" | "warning" | "primary" | "default"; | |||
| if (listTab === "putawayed") { | |||
| chipLabel = t("Put Awayed"); | |||
| statusColor = "success"; | |||
| } else if (listTab === "pending_qc" || (isCarriedOverTab && waitingQc)) { | |||
| chipLabel = t("Waiting QC Put Away"); | |||
| statusColor = "warning"; | |||
| } else if (isCarriedOverTab && PENDING_FINE_BUCKETS.has(bucket)) { | |||
| chipLabel = t("pending"); | |||
| statusColor = "default"; | |||
| } else if (isCarriedOverTab && PROCESSING_FINE_BUCKETS.has(bucket)) { | |||
| chipLabel = t("processing"); | |||
| statusColor = "primary"; | |||
| } else if (statusLower === "completed") { | |||
| chipLabel = t("Completed"); | |||
| statusColor = "success"; | |||
| } else { | |||
| chipLabel = t(displayStatus); | |||
| statusColor = | |||
| statusLower === "in_progress" || | |||
| statusLower === "processing" | |||
| ? "primary" | |||
| : "default"; | |||
| } | |||
| const jobOrderCode = | |||
| (process as any).jobOrderCode ?? | |||
| @@ -932,12 +1011,14 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| const joDay = process.date | |||
| ? dayjs(process.date).startOf("day") | |||
| : null; | |||
| const isCarriedOver = Boolean( | |||
| enableCarryOver && | |||
| searchDay?.isValid() && | |||
| joDay?.isValid() && | |||
| joDay.isBefore(searchDay), | |||
| ); | |||
| const isCarriedOver = | |||
| isCarriedOverTab || | |||
| Boolean( | |||
| enableCarryOver && | |||
| searchDay?.isValid() && | |||
| joDay?.isValid() && | |||
| joDay.isBefore(searchDay), | |||
| ); | |||
| const bomDescription = process.bomDescription | |||
| ? String(process.bomDescription).trim() | |||
| @@ -1178,6 +1259,24 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| {t("view stockin")} | |||
| </Button> | |||
| )} | |||
| {listTab === "putawayed" && | |||
| process.stockInLineId != null && ( | |||
| <Button | |||
| variant="contained" | |||
| size="small" | |||
| color="secondary" | |||
| disabled={ | |||
| !selectedPrinter || | |||
| printingSilId === process.stockInLineId | |||
| } | |||
| onClick={() => void handlePrintLabel(process)} | |||
| > | |||
| {printingSilId === process.stockInLineId | |||
| ? t("Printing") | |||
| : t("Print Label")} | |||
| </Button> | |||
| )} | |||
| </Stack> | |||
| <Typography | |||
| @@ -2,7 +2,7 @@ | |||
| import React, { useState, useEffect, useCallback } from "react"; | |||
| import { useSession } from "next-auth/react"; | |||
| import { SessionWithTokens } from "@/config/authConfig"; | |||
| import { Box, Tabs, Tab, Stack, Typography, Autocomplete, TextField } from "@mui/material"; | |||
| import { Box, Tabs, Tab } from "@mui/material"; | |||
| import { usePathname, useRouter, useSearchParams } from "next/navigation"; | |||
| import QcStockInModal from "@/components/Qc/QcStockInModal"; | |||
| import ProductionProcessList, { | |||
| @@ -24,7 +24,10 @@ interface ProductionProcessPageProps { | |||
| const STORAGE_KEY = 'productionProcess_selectedMatchingStock'; | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 26 | v1.0.0 | 2026-07-20 */ | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 26 | v1.0.0 | 2026-07-20 | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 47 | v1.0.4 | 2026-08-06 | |||
| */ | |||
| const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCombo }) => { | |||
| const { t } = useTranslation(["common", "productionProcess"]); | |||
| const [selectedProcessId, setSelectedProcessId] = useState<number | null>(null); | |||
| @@ -46,10 +49,6 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||
| const [linkQcOpen, setLinkQcOpen] = useState(false); | |||
| const [linkQcSilId, setLinkQcSilId] = useState<number | null>(null); | |||
| const [selectedPrinter, setSelectedPrinter] = useState<PrinterCombo | null>( | |||
| printerCombo && printerCombo.length > 0 ? printerCombo[0] : null | |||
| ); | |||
| useEffect(() => { | |||
| if (typeof window !== 'undefined') { | |||
| try { | |||
| @@ -120,9 +119,6 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||
| router.replace(q ? `${pathname}?${q}` : pathname, { scroll: false }); | |||
| }, [pathname, router, searchParams]); | |||
| const listTab = String(productionListState.pickBucket || "needs_action"); | |||
| const showPrinterBar = tabIndex === 0 && listTab === "pending_qc"; | |||
| if (selectedMatchingStock) { | |||
| return ( | |||
| <JobPickExecutionsecondscan | |||
| @@ -148,54 +144,6 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||
| return ( | |||
| <> | |||
| <Box> | |||
| {showPrinterBar && ( | |||
| <Box sx={{ | |||
| p: 1, | |||
| borderBottom: '1px solid #e0e0e0', | |||
| minHeight: 'auto', | |||
| display: 'flex', | |||
| alignItems: 'center', | |||
| justifyContent: 'flex-end', | |||
| gap: 2, | |||
| flexWrap: 'wrap', | |||
| }}> | |||
| <Stack | |||
| direction="row" | |||
| spacing={2} | |||
| sx={{ | |||
| alignItems: 'center', | |||
| flexWrap: 'wrap', | |||
| rowGap: 1, | |||
| }} | |||
| > | |||
| <Typography variant="body2" sx={{ minWidth: 'fit-content', mr: 1.5 }}> | |||
| {t("Select Printer")}: | |||
| </Typography> | |||
| <Autocomplete | |||
| disableClearable | |||
| options={printerCombo || []} | |||
| getOptionLabel={(option) => | |||
| option.name || option.label || option.code || `Printer ${option.id}` | |||
| } | |||
| value={selectedPrinter || undefined} | |||
| onChange={(_, newValue) => setSelectedPrinter(newValue)} | |||
| sx={{ minWidth: 200 }} | |||
| size="small" | |||
| renderInput={(params) => ( | |||
| <TextField | |||
| {...params} | |||
| placeholder={t("Printer")} | |||
| inputProps={{ | |||
| ...params.inputProps, | |||
| readOnly: true, | |||
| }} | |||
| /> | |||
| )} | |||
| /> | |||
| </Stack> | |||
| </Box> | |||
| )} | |||
| <Tabs value={tabIndex} onChange={handleTabChange} sx={{ mb: 2 }}> | |||
| <Tab label={t("Production Process")} /> | |||
| <Tab label={t("Job Order Ops Table")} /> | |||
| @@ -207,7 +155,7 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||
| {tabIndex === 0 && ( | |||
| <ProductionProcessList | |||
| printerCombo={selectedPrinter ? [selectedPrinter] : printerCombo} | |||
| printerCombo={printerCombo} | |||
| listPersistedState={productionListState} | |||
| onListPersistedStateChange={setProductionListState} | |||
| onSelectProcess={(jobOrderId) => { | |||
| @@ -228,7 +176,7 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||
| {tabIndex === 1 && ( | |||
| <JobOrderOpsTable | |||
| printerCombo={selectedPrinter ? [selectedPrinter] : printerCombo} | |||
| printerCombo={printerCombo} | |||
| onSelectProcess={(jobOrderId) => { | |||
| if (jobOrderId != null) setSelectedProcessId(jobOrderId); | |||
| }} | |||
| @@ -1,7 +1,6 @@ | |||
| "use client"; | |||
| import { QcItemWithChecks, QcData } from "@/app/api/qc"; | |||
| import { | |||
| Autocomplete, | |||
| Box, | |||
| Button, | |||
| Divider, | |||
| @@ -29,6 +28,7 @@ import dayjs from "dayjs"; | |||
| import { fetchPoQrcode } from "@/app/api/pdf/actions"; | |||
| import { downloadFile } from "@/app/utils/commonUtil"; | |||
| import { PrinterCombo } from "@/app/api/settings/printer"; | |||
| import PrinterSelect from "@/components/common/PrinterSelect"; | |||
| import { EscalationResult } from "@/app/api/escalation"; | |||
| import { SessionWithTokens } from "@/config/authConfig"; | |||
| import { GridRowModesModel } from "@mui/x-data-grid"; | |||
| @@ -72,6 +72,7 @@ interface Props extends CommonProps { | |||
| // itemDetail: StockInLine & { qcResult?: PurchaseQcResult[] } & { escResult?: EscalationResult[] }; | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.0 | 2026-08-06 */ | |||
| const QcStockInModal: React.FC<Props> = ({ | |||
| open, | |||
| onClose, | |||
| @@ -840,27 +841,17 @@ const printQrcode = useCallback( | |||
| </Grid> | |||
| {tabIndex == 1 && ( | |||
| <Stack direction="row" justifyContent="flex-end" gap={1} sx={{m:3, mt:"auto"}}> | |||
| <Autocomplete | |||
| disableClearable | |||
| options={labelPrinterCombo} | |||
| getOptionLabel={(option) => | |||
| option.name || option.label || option.code || `Printer ${option.id}` | |||
| } | |||
| value={selectedPrinter} | |||
| onChange={(_, newValue) => { | |||
| if (newValue) setSelectedPrinter(newValue); | |||
| }} | |||
| renderInput={(params) => ( | |||
| <TextField | |||
| {...params} | |||
| variant="outlined" | |||
| label={t("Printer")} | |||
| sx={{ width: 300 }} | |||
| inputProps={{ ...params.inputProps, readOnly: true }} | |||
| /> | |||
| )} | |||
| /> | |||
| <Stack direction="row" justifyContent="flex-end" alignItems="center" gap={1} sx={{m:3, mt:"auto"}}> | |||
| <PrinterSelect | |||
| label={t("Label Printer")} | |||
| printers={labelPrinterCombo} | |||
| value={selectedPrinter ?? null} | |||
| onChange={(p) => { | |||
| if (p) setSelectedPrinter(p); | |||
| }} | |||
| placeholder={t("Label Printer")} | |||
| minWidth={300} | |||
| /> | |||
| <TextField | |||
| variant="outlined" | |||
| label={t("Print Qty")} | |||
| @@ -0,0 +1,149 @@ | |||
| "use client"; | |||
| import React, { useMemo } from "react"; | |||
| import { | |||
| FormControl, | |||
| MenuItem, | |||
| Select, | |||
| SelectChangeEvent, | |||
| Stack, | |||
| Typography, | |||
| } from "@mui/material"; | |||
| import type { PrinterCombo } from "@/app/api/settings/printer"; | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 54 | v1.0.0 | 2026-08-06 | |||
| * Format display label for a printer combo option. | |||
| */ | |||
| export function formatPrinterOptionLabel(p: PrinterCombo): string { | |||
| return p.name || p.label || p.code || `Printer ${p.id}`; | |||
| } | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 54 | v1.0.0 | 2026-08-06 | |||
| * Filter printers by type (e.g. Label / A4). | |||
| */ | |||
| export function filterPrintersByType( | |||
| printers: PrinterCombo[] | null | undefined, | |||
| typeFilter?: string | string[], | |||
| fallbackToAll = false, | |||
| ): PrinterCombo[] { | |||
| const list = printers ?? []; | |||
| if (!typeFilter) return list; | |||
| const types = (Array.isArray(typeFilter) ? typeFilter : [typeFilter]).map( | |||
| (t) => t.trim().toLowerCase(), | |||
| ); | |||
| const filtered = list.filter((p) => | |||
| types.includes(String(p.type ?? "").trim().toLowerCase()), | |||
| ); | |||
| if (filtered.length > 0) return filtered; | |||
| return fallbackToAll ? list : filtered; | |||
| } | |||
| export type PrinterSelectProps = { | |||
| /** Outer label text (required), e.g. 「列印機」「標籤打印機」 */ | |||
| label: React.ReactNode; | |||
| printers: PrinterCombo[]; | |||
| value: PrinterCombo | null | undefined; | |||
| onChange: (printer: PrinterCombo | null) => void; | |||
| /** Filter by printer.type, e.g. "Label" | "A4" */ | |||
| typeFilter?: string | string[]; | |||
| /** If type filter yields none, show all printers (default false) */ | |||
| fallbackToAll?: boolean; | |||
| /** Shown inside Select when nothing selected */ | |||
| placeholder?: string; | |||
| minWidth?: number; | |||
| fullWidth?: boolean; | |||
| disabled?: boolean; | |||
| size?: "small" | "medium"; | |||
| /** Append ":" after label when label is a string (default true) */ | |||
| appendColon?: boolean; | |||
| }; | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 54 | v1.0.0 | 2026-08-06 | |||
| * Shared printer picker: outer label + Select (selected value vertically centered, no floating label). | |||
| */ | |||
| const PrinterSelect: React.FC<PrinterSelectProps> = ({ | |||
| label, | |||
| printers, | |||
| value, | |||
| onChange, | |||
| typeFilter, | |||
| fallbackToAll = false, | |||
| placeholder, | |||
| minWidth = 220, | |||
| fullWidth = false, | |||
| disabled = false, | |||
| size = "small", | |||
| appendColon = true, | |||
| }) => { | |||
| const options = useMemo( | |||
| () => filterPrintersByType(printers, typeFilter, fallbackToAll), | |||
| [printers, typeFilter, fallbackToAll], | |||
| ); | |||
| const labelNode = | |||
| typeof label === "string" && appendColon && !label.trim().endsWith(":") | |||
| ? `${label}:` | |||
| : label; | |||
| const height = size === "small" ? 40 : 48; | |||
| return ( | |||
| <Stack | |||
| direction="row" | |||
| alignItems="center" | |||
| spacing={1} | |||
| sx={{ flexShrink: 0, width: fullWidth ? "100%" : undefined }} | |||
| > | |||
| <Typography variant="body2" sx={{ whiteSpace: "nowrap", minWidth: "fit-content" }}> | |||
| {labelNode} | |||
| </Typography> | |||
| <FormControl | |||
| size={size} | |||
| fullWidth={fullWidth} | |||
| sx={{ minWidth: fullWidth ? undefined : minWidth, flex: fullWidth ? 1 : undefined }} | |||
| disabled={disabled || options.length === 0} | |||
| > <Select | |||
| displayEmpty | |||
| value={value?.id != null ? String(value.id) : ""} | |||
| onChange={(e: SelectChangeEvent) => { | |||
| const id = Number(e.target.value); | |||
| onChange(options.find((p) => p.id === id) ?? null); | |||
| }} | |||
| renderValue={(selected: string) => { | |||
| if (!selected) { | |||
| return ( | |||
| <Typography variant="body2" color="text.secondary" component="span"> | |||
| {placeholder ?? (typeof label === "string" ? label.replace(/:$/, "") : "")} | |||
| </Typography> | |||
| ); | |||
| } | |||
| const p = options.find((x) => String(x.id) === selected); | |||
| return p ? formatPrinterOptionLabel(p) : selected; | |||
| }} | |||
| sx={{ | |||
| height, | |||
| "& .MuiSelect-select": { | |||
| display: "flex", | |||
| alignItems: "center", | |||
| py: 0, | |||
| lineHeight: `${height}px`, | |||
| height, | |||
| boxSizing: "border-box", | |||
| }, | |||
| }} | |||
| > | |||
| {options.map((p) => ( | |||
| <MenuItem key={p.id} value={String(p.id)}> | |||
| {formatPrinterOptionLabel(p)} | |||
| </MenuItem> | |||
| ))} | |||
| </Select> | |||
| </FormControl> | |||
| </Stack> | |||
| ); | |||
| }; | |||
| export default PrinterSelect; | |||
| @@ -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([]); | |||
| @@ -139,6 +139,13 @@ | |||
| "Prep Time (Minutes)": "Prep Time (Minutes)", | |||
| "Previous page": "Previous page", | |||
| "Printer": "Printer", | |||
| "Label Printer": "Label Printer", | |||
| "A4 Printer": "A4 Printer", | |||
| "Print Label": "Print Label", | |||
| "Printing": "Printing", | |||
| "Print job sent successfully": "Print job sent successfully", | |||
| "Please select a printer": "Please select a printer", | |||
| "Please select a label printer": "Please select a label printer", | |||
| "Process": "Process", | |||
| "Process & Equipment": "Process & Equipment", | |||
| "Process Description": "Process Description", | |||
| @@ -215,6 +222,7 @@ | |||
| "Total job orders": "Total job orders", | |||
| "Including carried over": "Including carried over", | |||
| "Carried over from past day": "Carried over from past day", | |||
| "Off-plan unfinished": "Off-plan unfinished", | |||
| "All unfinished": "Needs action", | |||
| "Needs action": "Needs action", | |||
| "Not picked · Not started": "Not picked · Not started", | |||
| @@ -151,6 +151,8 @@ | |||
| "Found": "Found", | |||
| "escalation processing": "Escalation Processing", | |||
| "Printer": "Printer", | |||
| "Label Printer": "Label Printer", | |||
| "A4 Printer": "A4 Printer", | |||
| "Printing": "Printing", | |||
| "rejectQty": "Reject Qty", | |||
| "QC decision is required": "QC decision is required", | |||
| @@ -83,7 +83,7 @@ | |||
| "Job Order No.": "工單編號", | |||
| "Job Order and Product": "工單及貨品", | |||
| "Issue": "異常", | |||
| "Job Order Ops Table": "查看工單流程情況", | |||
| "Job Order Ops Table": "工單生產流程", | |||
| "Pending (picked)": "已提料", | |||
| "Pending (not picked)": "未提料", | |||
| "Processing (picked)": "已提料", | |||
| @@ -98,7 +98,7 @@ | |||
| "Actions": "操作", | |||
| "Job Order Production Process": "工單生產流程", | |||
| "Job Process Status Dashboard": "儀表板 - 工單狀態", | |||
| "Drink Production Qty Dashboard": "儀表板 - 飲料生產量數", | |||
| "Drink Production Qty Dashboard": "儀表板 - 飲料生產數量", | |||
| "Expand job order details": "展開工單明細", | |||
| "Collapse job order details": "收合工單明細", | |||
| "Goods Name": "貨品名稱", | |||
| @@ -144,6 +144,13 @@ | |||
| "Prep Time (Minutes)": "準備時間(分鐘)", | |||
| "Previous page": "上一頁", | |||
| "Printer": "列印機", | |||
| "Label Printer": "標籤打印機", | |||
| "A4 Printer": "A4 打印機", | |||
| "Print Label": "列印標籤", | |||
| "Printing": "列印中", | |||
| "Print job sent successfully": "列印作業已成功送出", | |||
| "Please select a printer": "請選擇打印機", | |||
| "Please select a label printer": "請選擇標籤打印機", | |||
| "Process": "工序", | |||
| "Process & Equipment": "工序與設備", | |||
| "Process Description": "工序說明", | |||
| @@ -154,7 +161,7 @@ | |||
| "Processing Time": "生產時間", | |||
| "Processing Time (mins)": "步驟時間(分鐘)", | |||
| "Product process status": "生產流程狀態", | |||
| "Production Date": "生產日期", | |||
| "Production Date": "預計生產日期", | |||
| "Production Equipment Status Dashboard": "儀表板 - 生產設備最新狀態", | |||
| "Production Output Data": "生產輸出數據", | |||
| "Production Output Data Entry": "生產輸出數據輸入", | |||
| @@ -164,7 +171,7 @@ | |||
| "Production Process Line Remark": "工藝明細", | |||
| "Production Process Steps": "生產流程步驟", | |||
| "Production Time Remaining": "生產剩餘時間", | |||
| "Production date": "生產日期", | |||
| "Production date": "預計生產日期", | |||
| "Put Awayed Job Orders": "已上架工單", | |||
| "Qty": "數量", | |||
| "Quality Check": "品質檢查", | |||
| @@ -218,8 +225,9 @@ | |||
| "Total Time": "總時間", | |||
| "Total finished QC job orders": "總完成QC工單數量", | |||
| "Total job orders": "總工單數量", | |||
| "Including carried over": "含過去轉來", | |||
| "Carried over from past day": "過去轉來的工單", | |||
| "Including carried over": "含未按規劃完成工單", | |||
| "Carried over from past day": "未按規劃完成工單的工單", | |||
| "Off-plan unfinished": "未按規劃完成工單", | |||
| "All unfinished": "需處理", | |||
| "Needs action": "需處理", | |||
| "Not picked · Not started": "未提料 · 未開工", | |||
| @@ -151,6 +151,8 @@ | |||
| "Found": "已找到", | |||
| "escalation processing": "處理上報記錄", | |||
| "Printer": "列印機", | |||
| "Label Printer": "標籤打印機", | |||
| "A4 Printer": "A4 打印機", | |||
| "Printing": "列印中", | |||
| "rejectQty": "拒絕數量", | |||
| "QC decision is required": "請決定品檢結果", | |||