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