ledger lotQtyBefore/After + inventoryLotLineId 成品出倉員工問題報告 rep-016 Truck X 2F/4F suggestion Just Complete / isIssueJustComplete Workbench 略過 classic confirmLotSubstitution 停 JO planStart/renumber 生產流程 lookback + tabs/QC chip/Release→Cancel FP-MTMS code comments Report 店鋪訂單補貨記錄 工單生產流程 UI 再重構 工單提料列表顯示加強production
| @@ -31,6 +31,7 @@ import { | |||
| } from './semiFGProductionAnalysisApi'; | |||
| import { generateGrnReportExcel } from './grnReportApi'; | |||
| import { generateBomShopSyncReportExcel } from './bomShopSyncReportApi'; | |||
| import { generateShopOrderReplenishmentReportExcel } from './shopOrderReplenishmentReportApi'; | |||
| import { | |||
| FEATURE_USAGE, | |||
| FEATURE_USAGE_ACTION, | |||
| @@ -42,6 +43,7 @@ interface ItemCodeWithName { | |||
| name: string; | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 39 | v1.0.0 | 2026-08-03 */ | |||
| export default function ReportPage() { | |||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | |||
| const includeGrnFinancialColumns = | |||
| @@ -267,12 +269,24 @@ export default function ReportPage() { | |||
| ); | |||
| } else if (currentReport.id === 'rep-015') { | |||
| await generateBomShopSyncReportExcel(criteria, currentReport.title); | |||
| } else if (currentReport.id === 'rep-017') { | |||
| await generateShopOrderReplenishmentReportExcel(criteria, currentReport.title); | |||
| } else { | |||
| // Backend returns actual .xlsx bytes for this Excel endpoint. | |||
| const queryParams = | |||
| let queryParams = | |||
| currentReport.id === 'rep-012' | |||
| ? buildRep012QueryString() | |||
| : new URLSearchParams(criteria).toString(); | |||
| // rep-016: single-day UI — mirror dateStart to dateEnd for backend API. | |||
| if (currentReport.id === 'rep-016') { | |||
| const p = new URLSearchParams(criteria); | |||
| const day = (criteria.dateStart || '').trim(); | |||
| if (day) { | |||
| p.set('dateStart', day); | |||
| p.set('dateEnd', day); | |||
| } | |||
| queryParams = p.toString(); | |||
| } | |||
| const excelUrl = `${currentReport.apiEndpoint}-excel?${queryParams}`; | |||
| const response = await clientAuthFetch(excelUrl, { | |||
| @@ -25,7 +25,7 @@ export const REPORT_CATEGORIES: ReportCategoryConfig[] = [ | |||
| headerBg: "#b3d4f0", | |||
| bodyBg: "#eef5fc", | |||
| accent: "#1565c0", | |||
| reportIds: ["rep-004", "rep-014", "rep-008", "rep-009", "rep-013"], | |||
| reportIds: ["rep-004", "rep-014", "rep-008", "rep-009", "rep-013", "rep-016", "rep-017"], | |||
| }, | |||
| { | |||
| id: "production", | |||
| @@ -0,0 +1,161 @@ | |||
| "use client"; | |||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||
| import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | |||
| import { exportChartToXlsx } from "@/app/(main)/chart/_components/exportChartToXlsx"; | |||
| export interface ShopOrderReplenishmentReportRow { | |||
| shopNo?: string; | |||
| shopName?: string; | |||
| shopOrderDate?: string; | |||
| shopOrderNo?: string; | |||
| itemNo?: string; | |||
| itemName?: string; | |||
| firstOrderQty?: number | string; | |||
| firstOrderActualPickQty?: number | string; | |||
| reorderQty?: number | string; | |||
| reorderDate?: string; | |||
| reason?: string; | |||
| actualDeliveredQty?: number | string; | |||
| deliveredDate?: string; | |||
| [key: string]: unknown; | |||
| } | |||
| export interface ShopOrderReplenishmentReportResponse { | |||
| rows: ShopOrderReplenishmentReportRow[]; | |||
| } | |||
| const SHEET_NAME = "店鋪訂單補貨記錄"; | |||
| const NO_DATA_NOTE = | |||
| "(篩選範圍內無資料 / No records in the selected range)"; | |||
| function emptySheetRow(note: string = NO_DATA_NOTE): Record<string, unknown> { | |||
| return { | |||
| "Shop No. / 店鋪編號": note, | |||
| "Shop Name / 店鋪名稱": "", | |||
| "Shop Order Date / 店鋪訂單日期": "", | |||
| "Shop Order No. / 店鋪訂單編號": "", | |||
| "Item No. / 貨品編號": "", | |||
| "Item Name / 貨品名稱": "", | |||
| "First Order Qty / 原訂單數量": "", | |||
| "First Order Actual Pick Qty / 原單實際提料數量": "", | |||
| "Reorder Qty / 補貨數量": "", | |||
| "Reorder Date / 補貨日期": "", | |||
| "Reason / 補貨原因": "", | |||
| "Actual Delivered Qty / 實際補貨數量": "", | |||
| "Delivered Date / 送貨日期": "", | |||
| }; | |||
| } | |||
| function formatReason(reason: string | undefined): string { | |||
| switch ((reason ?? "").trim()) { | |||
| case "quality_issue": | |||
| return "質素問題"; | |||
| case "out_of_stock": | |||
| return "缺貨"; | |||
| case "other": | |||
| return "其他"; | |||
| default: | |||
| return reason ?? ""; | |||
| } | |||
| } | |||
| function formatDateCell(value: unknown): string { | |||
| if (value == null || value === "") return ""; | |||
| if (typeof value === "number" && Number.isFinite(value)) { | |||
| // Jackson may serialize java.sql.Date as epoch millis or seconds | |||
| const ms = value > 1e12 ? value : value * 1000; | |||
| const d = new Date(ms); | |||
| if (Number.isNaN(d.getTime())) return String(value); | |||
| return d.toISOString().slice(0, 10); | |||
| } | |||
| const s = String(value).trim(); | |||
| if (/^\d{10,13}$/.test(s)) { | |||
| const n = Number(s); | |||
| const ms = s.length >= 13 ? n : n * 1000; | |||
| const d = new Date(ms); | |||
| if (!Number.isNaN(d.getTime())) return d.toISOString().slice(0, 10); | |||
| } | |||
| // JDBC / string date: "2026-08-01" or "2026-08-01 00:00:00.0" | |||
| return s.length >= 10 ? s.slice(0, 10) : s; | |||
| } | |||
| function formatQty(value: unknown): string | number { | |||
| if (value === null || value === undefined || value === "") return ""; | |||
| const n = typeof value === "number" ? value : Number(value); | |||
| if (!Number.isFinite(n)) return String(value); | |||
| return n; | |||
| } | |||
| function toExcelRow(r: ShopOrderReplenishmentReportRow): Record<string, unknown> { | |||
| const base = emptySheetRow(""); | |||
| return { | |||
| ...base, | |||
| "Shop No. / 店鋪編號": r.shopNo ?? "", | |||
| "Shop Name / 店鋪名稱": r.shopName ?? "", | |||
| "Shop Order Date / 店鋪訂單日期": formatDateCell(r.shopOrderDate), | |||
| "Shop Order No. / 店鋪訂單編號": r.shopOrderNo ?? "", | |||
| "Item No. / 貨品編號": r.itemNo ?? "", | |||
| "Item Name / 貨品名稱": r.itemName ?? "", | |||
| "First Order Qty / 原訂單數量": formatQty(r.firstOrderQty), | |||
| "First Order Actual Pick Qty / 原單實際提料數量": formatQty(r.firstOrderActualPickQty), | |||
| "Reorder Qty / 補貨數量": formatQty(r.reorderQty), | |||
| "Reorder Date / 補貨日期": formatDateCell(r.reorderDate), | |||
| "Reason / 補貨原因": formatReason(r.reason), | |||
| "Actual Delivered Qty / 實際補貨數量": formatQty(r.actualDeliveredQty), | |||
| "Delivered Date / 送貨日期": formatDateCell(r.deliveredDate), | |||
| }; | |||
| } | |||
| export async function fetchShopOrderReplenishmentReportData( | |||
| criteria: Record<string, string>, | |||
| ): Promise<ShopOrderReplenishmentReportRow[]> { | |||
| const queryParams = new URLSearchParams(criteria).toString(); | |||
| const url = `${NEXT_PUBLIC_API_URL}/report/shop-order-replenishment?${queryParams}`; | |||
| const response = await clientAuthFetch(url, { | |||
| method: "GET", | |||
| headers: { Accept: "application/json" }, | |||
| }); | |||
| if (response.status === 401 || response.status === 403) | |||
| throw new Error("Unauthorized"); | |||
| if (!response.ok) | |||
| throw new Error(`HTTP error! status: ${response.status}`); | |||
| const data = (await response.json()) as | |||
| | ShopOrderReplenishmentReportResponse | |||
| | ShopOrderReplenishmentReportRow[]; | |||
| if (Array.isArray(data)) return data; | |||
| return data.rows ?? []; | |||
| } | |||
| /** | |||
| * Generate and download Shop Orders Replenishment Records as Excel. | |||
| */ | |||
| export async function generateShopOrderReplenishmentReportExcel( | |||
| criteria: Record<string, string>, | |||
| reportTitle: string = "店鋪訂單補貨記錄", | |||
| ): Promise<void> { | |||
| const rows = await fetchShopOrderReplenishmentReportData(criteria); | |||
| const excelRows = | |||
| rows.length > 0 ? rows.map(toExcelRow) : [emptySheetRow()]; | |||
| const dateCandidates = [ | |||
| criteria.reorderDateStart, | |||
| criteria.reorderDateEnd, | |||
| criteria.shopOrderDateStart, | |||
| criteria.shopOrderDateEnd, | |||
| criteria.deliveredDateStart, | |||
| criteria.deliveredDateEnd, | |||
| ].filter((v) => (v ?? "").trim().length > 0); | |||
| const datePart = | |||
| dateCandidates.length > 0 | |||
| ? dateCandidates.join("_") | |||
| : new Date().toISOString().slice(0, 10); | |||
| const safeDatePart = datePart.replace(/[^\d\-_/]/g, ""); | |||
| const filename = `${reportTitle}_${safeDatePart}`; | |||
| exportChartToXlsx(excelRows, filename, SHEET_NAME); | |||
| } | |||
| @@ -94,6 +94,8 @@ export type WorkbenchScanPickBody = { | |||
| excludeWarehouseCodes?: string[] | null; | |||
| /** Optional decimal string or number serialized by JSON */ | |||
| qty?: number | string | null; | |||
| /** Just Complete button (no QR) — DO user-audit flag */ | |||
| justComplete?: boolean | null; | |||
| userId: number; | |||
| }; | |||
| @@ -144,6 +146,7 @@ export async function workbenchScanPick( | |||
| ...(storeId !== undefined ? { storeId } : {}), | |||
| ...(excludeWarehouseCodes !== undefined ? { excludeWarehouseCodes } : {}), | |||
| ...(qty !== undefined ? { qty } : {}), | |||
| ...(body.justComplete === true ? { justComplete: true } : {}), | |||
| userId: body.userId, | |||
| }), | |||
| headers: { "Content-Type": "application/json" }, | |||
| @@ -368,6 +368,8 @@ export interface AllJoborderProductProcessInfoResponse { | |||
| itemCode: string; | |||
| itemName: string; | |||
| bomDescription?: string | null; | |||
| /** BOM.type (e.g. drink / Powder_Mixture / other). */ | |||
| bomType?: string | null; | |||
| lotNo: string; | |||
| requiredQty: number; | |||
| jobOrderId: number; | |||
| @@ -381,6 +383,26 @@ export interface AllJoborderProductProcessInfoResponse { | |||
| productProcessLineCount: number; | |||
| FinishedProductProcessLineCount: number; | |||
| lines: ProductProcessInfoResponse[]; | |||
| isPicked?: boolean | null; | |||
| /** Fine-grained pick/process bucket from backend. */ | |||
| pickProcessBucket?: ProductionProcessFinePickBucket | null; | |||
| } | |||
| /** Fine-grained buckets returned per row. */ | |||
| export type ProductionProcessFinePickBucket = | |||
| | "not_picked_not_started" | |||
| | "picked_not_started" | |||
| | "picked_started" | |||
| | "not_picked_started"; | |||
| /** Merged tab filter: pending = not started; processing = started. */ | |||
| export type ProductionProcessPickBucket = "pending" | "processing" | ProductionProcessFinePickBucket; | |||
| export interface JobOrderProductProcessBucketCounts { | |||
| notPickedNotStarted: number; | |||
| pickedNotStarted: number; | |||
| pickedStarted: number; | |||
| notPickedStarted: number; | |||
| } | |||
| export interface JobOrderProductProcessPageResponse { | |||
| @@ -388,6 +410,9 @@ export interface JobOrderProductProcessPageResponse { | |||
| totalJobOrders: number; | |||
| page: number; | |||
| size: number; | |||
| bucketCounts?: JobOrderProductProcessBucketCounts | null; | |||
| searchDate?: string | null; | |||
| carriedOverCount?: number | null; | |||
| } | |||
| export interface ProductProcessInfoResponse { | |||
| id: number; | |||
| @@ -558,7 +583,10 @@ export interface AllJoPickOrderResponse { | |||
| jobOrderType: string | null; | |||
| itemId: number; | |||
| itemName: string; | |||
| itemCode?: string | null; | |||
| bomDescription?: string | null; | |||
| /** BOM.type (e.g. drink / Powder_Mixture / other). */ | |||
| bomType?: string | null; | |||
| lotNo: string | null; | |||
| planStart?: string | number[] | null; | |||
| reqQty: number; | |||
| @@ -877,8 +905,9 @@ export const fetchAllJoborderProductProcessInfo = cache(async (type?: string | n | |||
| ); | |||
| }); | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.0 | 2026-08-03 */ | |||
| export const fetchJoborderProductProcessesPage = cache(async (params: { | |||
| /** Job order planStart 區間起(YYYY-MM-DD,含當日) */ | |||
| /** Job order / process date(YYYY-MM-DD) */ | |||
| date?: string | null; | |||
| itemCode?: string | null; | |||
| jobOrderCode?: string | null; | |||
| @@ -888,6 +917,10 @@ export const fetchJoborderProductProcessesPage = cache(async (params: { | |||
| includePutaway?: boolean | null; | |||
| /** all | completed | notCompleted */ | |||
| putawayStatus?: string | null; | |||
| /** Production list carry-over window (days before date). */ | |||
| lookbackDays?: number | null; | |||
| /** Pick/process tab filter when lookbackDays is set. */ | |||
| bucket?: ProductionProcessPickBucket | "all" | null; | |||
| page?: number; | |||
| size?: number; | |||
| }) => { | |||
| @@ -900,6 +933,8 @@ export const fetchJoborderProductProcessesPage = cache(async (params: { | |||
| includePutaway, | |||
| putawayStatus, | |||
| type, | |||
| lookbackDays, | |||
| bucket, | |||
| page = 0, | |||
| size = 50, | |||
| } = params; | |||
| @@ -917,6 +952,10 @@ export const fetchJoborderProductProcessesPage = cache(async (params: { | |||
| queryParts.push(`includePutaway=${includePutaway}`); | |||
| } | |||
| if (putawayStatus) queryParts.push(`putawayStatus=${encodeURIComponent(putawayStatus)}`); | |||
| if (lookbackDays !== undefined && lookbackDays !== null) { | |||
| queryParts.push(`lookbackDays=${lookbackDays}`); | |||
| } | |||
| if (bucket) queryParts.push(`bucket=${encodeURIComponent(bucket)}`); | |||
| queryParts.push(`page=${page}`); | |||
| queryParts.push(`size=${size}`); | |||
| @@ -551,7 +551,7 @@ function saveIssuePickedMap(doPickOrderId: number, map: Record<number, number>) | |||
| } | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 29 | v1.0.1 | 2026-07-22 */ | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 29 | v1.0.2 | 2026-08-03 */ | |||
| const WorkbenchGoodPickExecutionDetail: React.FC<Props> = ({ | |||
| filterArgs, | |||
| onSwitchToRecordTab, | |||
| @@ -3274,6 +3274,7 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe | |||
| ? { stockInLineId: canonicalLotForSol.stockInLineId } | |||
| : {}), | |||
| qty: qtyToSend, | |||
| justComplete: true, | |||
| storeId: fgPickOrders?.[0]?.storeId ?? null, | |||
| userId: currentUserId ?? 1, | |||
| }); | |||
| @@ -172,12 +172,12 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||
| pagingController: typeof defaultPagingController, | |||
| lotNo: string, | |||
| ) => { | |||
| console.log('%c Action Type 1.', 'color:red', actionType); | |||
| //console.log('%c Action Type 1.', 'color:red', actionType); | |||
| // Avoid loading data again | |||
| if (actionType === 'paging' && pagingController === defaultPagingController) { | |||
| return; | |||
| } | |||
| console.log('%c Action Type 2.', 'color:blue', actionType); | |||
| // console.log('%c Action Type 2.', 'color:blue', actionType); | |||
| const params: SearchInventory = { | |||
| code: query?.itemCode ?? '', | |||
| @@ -415,7 +415,7 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||
| scanUiMode, | |||
| ]); | |||
| console.log('', 'color: #666', inventoriesPagingController); | |||
| //console.log('', 'color: #666', inventoriesPagingController); | |||
| const handleOpenOpeningInventoryModal = useCallback(() => { | |||
| setOpeningSelectedItem(null); | |||
| @@ -1,11 +1,9 @@ | |||
| "use client"; | |||
| import React, { useCallback, useEffect, useMemo, useState } from "react"; | |||
| import React, { useCallback, useEffect, useState } from "react"; | |||
| import { | |||
| Box, | |||
| Button, | |||
| Card, | |||
| CardContent, | |||
| CardActions, | |||
| Stack, | |||
| Typography, | |||
| Chip, | |||
| @@ -26,6 +24,17 @@ interface Props { | |||
| printerCombo: PrinterCombo[]; | |||
| } | |||
| const chipSx = { | |||
| flexShrink: 0, | |||
| height: 28, | |||
| borderRadius: "14px", | |||
| "& .MuiChip-label": { | |||
| typography: "body2", | |||
| px: 1.25, | |||
| lineHeight: 1.2, | |||
| }, | |||
| } as const; | |||
| /** Jo workbench: same list + detail flow as Jodetail `JoPickOrderList`, detail uses `JoWorkbench/newJobPickExecution`. */ | |||
| const JoPickOrderList: React.FC<Props> = ({ printerCombo }) => { | |||
| const { t } = useTranslation(["common", "jo"]); | |||
| @@ -44,10 +53,9 @@ const JoPickOrderList: React.FC<Props> = ({ printerCombo }) => { | |||
| { label: t("Item Name"), paramName: "itemName", type: "text" }, | |||
| { | |||
| label: t("Job Order Type"), | |||
| paramName: "BOM Description", | |||
| paramName: "bomDescription", | |||
| type: "select-labelled", | |||
| options: [ | |||
| //{ label: t("All"), value: "All" }, | |||
| { label: t("FG"), value: "FG" }, | |||
| { label: t("WIP"), value: "WIP" }, | |||
| ], | |||
| @@ -58,7 +66,6 @@ const JoPickOrderList: React.FC<Props> = ({ printerCombo }) => { | |||
| paramName: "bomType", | |||
| type: "select-labelled", | |||
| options: [ | |||
| //{ label: t("All"), value: "All" }, | |||
| { label: t("Drink"), value: "drink" }, | |||
| { label: t("Powder Mixture"), value: "Powder_Mixture" }, | |||
| { label: t("Other"), value: "other" }, | |||
| @@ -69,7 +76,6 @@ const JoPickOrderList: React.FC<Props> = ({ printerCombo }) => { | |||
| paramName: "floor", | |||
| type: "select-labelled", | |||
| options: [ | |||
| //{ label: t("All"), value: "ALL" }, | |||
| { label: "2F", value: "2F" }, | |||
| { label: "3F", value: "3F" }, | |||
| { label: "4F", value: "4F" }, | |||
| @@ -179,8 +185,8 @@ const JoPickOrderList: React.FC<Props> = ({ printerCombo }) => { | |||
| <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> | |||
| {t("Total pick orders")}: {pickOrders.length} | |||
| </Typography> | |||
| <Grid container spacing={2}> | |||
| <Grid container spacing={2} alignItems="stretch"> | |||
| {pickOrders.map((pickOrder) => { | |||
| const status = String(pickOrder.jobOrderStatus || ""); | |||
| const statusLower = status.toLowerCase(); | |||
| @@ -190,126 +196,216 @@ const JoPickOrderList: React.FC<Props> = ({ printerCombo }) => { | |||
| : statusLower === "pending" || statusLower === "processing" | |||
| ? "primary" | |||
| : "default"; | |||
| const finishedCount = pickOrder.finishedPickOLineCount ?? 0; | |||
| const bomDescription = pickOrder.bomDescription | |||
| ? String(pickOrder.bomDescription).trim() | |||
| : ""; | |||
| const bomType = pickOrder.bomType | |||
| ? String(pickOrder.bomType).trim() | |||
| : ""; | |||
| return ( | |||
| <Grid key={pickOrder.id} item xs={12} sm={6} md={4}> | |||
| <Grid | |||
| key={pickOrder.id} | |||
| item | |||
| xs={12} | |||
| sm={6} | |||
| md={4} | |||
| sx={{ display: "flex" }} | |||
| > | |||
| <Card | |||
| sx={{ | |||
| minHeight: 180, | |||
| maxHeight: 280, | |||
| width: "100%", | |||
| height: "100%", | |||
| display: "flex", | |||
| flexDirection: "column", | |||
| border: "1px solid", | |||
| borderColor: "divider", | |||
| borderRadius: 2, | |||
| boxShadow: "none", | |||
| }} | |||
| > | |||
| <CardContent | |||
| <Box | |||
| sx={{ | |||
| pb: 1, | |||
| p: 2, | |||
| flexGrow: 1, | |||
| overflow: "auto", | |||
| display: "flex", | |||
| flexDirection: "column", | |||
| minHeight: 0, | |||
| }} | |||
| > | |||
| <Stack direction="row" justifyContent="space-between" alignItems="center"> | |||
| <Box sx={{ minWidth: 0 }}> | |||
| <Typography variant="subtitle1"> | |||
| {t("Job Order")}: {pickOrder.jobOrderCode || "-"} | |||
| </Typography> | |||
| </Box> | |||
| <Chip size="small" label={t(status)} color={statusColor as any} /> | |||
| </Stack> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("Lot No")}: {pickOrder.lotNo || "-"} | |||
| </Typography> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("Pick Order")}: {pickOrder.pickOrderCode || "-"} | |||
| </Typography> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("Item Name")}: {pickOrder.itemName} | |||
| {pickOrder.bomDescription ? ` (${t(pickOrder.bomDescription)})` : ""} | |||
| </Typography> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("Required Qty")}: {pickOrder.reqQty} ({pickOrder.uomName}) | |||
| <Typography | |||
| variant="body1" | |||
| color="text.primary" | |||
| fontWeight={600} | |||
| title={ | |||
| [pickOrder.itemCode, pickOrder.itemName] | |||
| .filter(Boolean) | |||
| .join(" ") || undefined | |||
| } | |||
| sx={{ | |||
| display: "-webkit-box", | |||
| WebkitLineClamp: 2, | |||
| WebkitBoxOrient: "vertical", | |||
| overflow: "hidden", | |||
| lineHeight: 1.35, | |||
| }} | |||
| > | |||
| {[pickOrder.itemCode, pickOrder.itemName] | |||
| .filter(Boolean) | |||
| .join(" ") || "-"} | |||
| </Typography> | |||
| {selectedFloor === "ALL" ? ( | |||
| <> | |||
| {pickOrder.floorPickCounts?.map(({ floor, finishedCount, totalCount }) => ( | |||
| <Typography | |||
| key={floor} | |||
| variant="body2" | |||
| color="text.secondary" | |||
| component="span" | |||
| sx={{ mr: 1 }} | |||
| > | |||
| {floor}: {finishedCount}/{totalCount} | |||
| </Typography> | |||
| ))} | |||
| {!!pickOrder.noLotPickCount && ( | |||
| <Typography | |||
| key="NO_LOT" | |||
| variant="body2" | |||
| color="text.secondary" | |||
| component="span" | |||
| sx={{ mr: 1 }} | |||
| > | |||
| {t("No Lot")}: {pickOrder.noLotPickCount.finishedCount}/{pickOrder.noLotPickCount.totalCount} | |||
| </Typography> | |||
| <Stack | |||
| direction="row" | |||
| alignItems="center" | |||
| spacing={0.75} | |||
| flexWrap="nowrap" | |||
| useFlexGap | |||
| sx={{ | |||
| mt: 0.75, | |||
| minWidth: 0, | |||
| overflowX: "auto", | |||
| scrollbarWidth: "none", | |||
| "&::-webkit-scrollbar": { display: "none" }, | |||
| }} | |||
| > | |||
| {bomDescription ? ( | |||
| <Chip | |||
| size="small" | |||
| label={t(bomDescription)} | |||
| variant="outlined" | |||
| sx={chipSx} | |||
| /> | |||
| ) : null} | |||
| {bomType ? ( | |||
| <Chip | |||
| size="small" | |||
| label={t(bomType)} | |||
| color="info" | |||
| variant="outlined" | |||
| sx={chipSx} | |||
| /> | |||
| ) : null} | |||
| <Chip | |||
| size="small" | |||
| label={t(status)} | |||
| color={statusColor as any} | |||
| sx={chipSx} | |||
| /> | |||
| </Stack> | |||
| <Stack | |||
| spacing={0.35} | |||
| sx={{ mt: 0.75, color: "text.secondary" }} | |||
| > | |||
| <Typography variant="body2"> | |||
| {t("Pick Order")}: {pickOrder.pickOrderCode || "-"} | |||
| </Typography> | |||
| <Typography variant="body2"> | |||
| {t("Required Qty")}: {pickOrder.reqQty} ({pickOrder.uomName}) | |||
| </Typography> | |||
| <Box> | |||
| {selectedFloor === "ALL" ? ( | |||
| <> | |||
| {pickOrder.floorPickCounts?.map( | |||
| ({ floor, finishedCount, totalCount }) => ( | |||
| <Typography | |||
| key={floor} | |||
| variant="body2" | |||
| color="text.secondary" | |||
| component="span" | |||
| sx={{ mr: 1 }} | |||
| > | |||
| {floor}: {finishedCount}/{totalCount} | |||
| </Typography> | |||
| ), | |||
| )} | |||
| {!!pickOrder.noLotPickCount && ( | |||
| <Typography | |||
| key="NO_LOT" | |||
| variant="body2" | |||
| color="text.secondary" | |||
| component="span" | |||
| sx={{ mr: 1 }} | |||
| > | |||
| {t("No Lot")}:{" "} | |||
| {pickOrder.noLotPickCount.finishedCount}/ | |||
| {pickOrder.noLotPickCount.totalCount} | |||
| </Typography> | |||
| )} | |||
| </> | |||
| ) : selectedFloor === "NO_LOT" ? ( | |||
| !!pickOrder.noLotPickCount && ( | |||
| <Typography | |||
| key="NO_LOT" | |||
| variant="body2" | |||
| color="text.secondary" | |||
| component="span" | |||
| sx={{ mr: 1 }} | |||
| > | |||
| {t("No Lot")}:{" "} | |||
| {pickOrder.noLotPickCount.finishedCount}/ | |||
| {pickOrder.noLotPickCount.totalCount} | |||
| </Typography> | |||
| ) | |||
| ) : ( | |||
| pickOrder.floorPickCounts | |||
| ?.filter((c) => c.floor === selectedFloor) | |||
| .map(({ floor, finishedCount, totalCount }) => ( | |||
| <Typography | |||
| key={floor} | |||
| variant="body2" | |||
| color="text.secondary" | |||
| component="span" | |||
| sx={{ mr: 1 }} | |||
| > | |||
| {floor}: {finishedCount}/{totalCount} | |||
| </Typography> | |||
| )) | |||
| )} | |||
| </> | |||
| ) : selectedFloor === "NO_LOT" ? ( | |||
| !!pickOrder.noLotPickCount && ( | |||
| <Typography | |||
| key="NO_LOT" | |||
| variant="body2" | |||
| color="text.secondary" | |||
| component="span" | |||
| sx={{ mr: 1 }} | |||
| > | |||
| {t("No Lot")}: {pickOrder.noLotPickCount.finishedCount}/{pickOrder.noLotPickCount.totalCount} | |||
| </Typography> | |||
| ) | |||
| ) : ( | |||
| pickOrder.floorPickCounts | |||
| ?.filter((c) => c.floor === selectedFloor) | |||
| .map(({ floor, finishedCount, totalCount }) => ( | |||
| <Typography | |||
| key={floor} | |||
| variant="body2" | |||
| color="text.secondary" | |||
| component="span" | |||
| sx={{ mr: 1 }} | |||
| > | |||
| {floor}: {finishedCount}/{totalCount} | |||
| </Box> | |||
| {typeof pickOrder.suggestedFailCount === "number" && | |||
| pickOrder.suggestedFailCount > 0 && ( | |||
| <Typography variant="body2" color="error"> | |||
| {t("Suggested Fail")}: {pickOrder.suggestedFailCount} | |||
| </Typography> | |||
| )) | |||
| )} | |||
| {typeof pickOrder.suggestedFailCount === "number" && pickOrder.suggestedFailCount > 0 && ( | |||
| <Typography variant="body2" color="error" sx={{ mt: 0.5 }}> | |||
| {t("Suggested Fail")}: {pickOrder.suggestedFailCount} | |||
| </Typography> | |||
| )} | |||
| {statusLower !== "pending" && finishedCount > 0 && ( | |||
| <Box sx={{ mt: 1 }}> | |||
| <Typography variant="body2" fontWeight={600}> | |||
| )} | |||
| {statusLower !== "pending" && finishedCount > 0 && ( | |||
| <Typography variant="body2" fontWeight={600} color="text.primary"> | |||
| {t("Finished lines")}: {finishedCount} | |||
| </Typography> | |||
| </Box> | |||
| )} | |||
| </CardContent> | |||
| <CardActions sx={{ pt: 0.5 }}> | |||
| <Button | |||
| variant="contained" | |||
| size="small" | |||
| onClick={() => { | |||
| setSelectedPickOrderId(pickOrder.pickOrderId ?? undefined); | |||
| setSelectedJobOrderId(pickOrder.jobOrderId ?? undefined); | |||
| }} | |||
| )} | |||
| </Stack> | |||
| <Stack sx={{ mt: "auto", pt: 1.5 }}> | |||
| <Button | |||
| variant="contained" | |||
| size="small" | |||
| sx={{ alignSelf: "flex-start" }} | |||
| onClick={() => { | |||
| setSelectedPickOrderId(pickOrder.pickOrderId ?? undefined); | |||
| setSelectedJobOrderId(pickOrder.jobOrderId ?? undefined); | |||
| }} | |||
| > | |||
| {t("View Details")} | |||
| </Button> | |||
| </Stack> | |||
| <Typography | |||
| variant="caption" | |||
| color="text.secondary" | |||
| sx={{ mt: 1.25 }} | |||
| > | |||
| {t("View Details")} | |||
| </Button> | |||
| <Box sx={{ flex: 1 }} /> | |||
| </CardActions> | |||
| {pickOrder.jobOrderCode || "-"} | |||
| {" · "} | |||
| {t("Lot No")}: {pickOrder.lotNo || "-"} | |||
| </Typography> | |||
| </Box> | |||
| </Card> | |||
| </Grid> | |||
| ); | |||
| @@ -618,6 +618,7 @@ const QrCodeModal: React.FC<{ | |||
| ); | |||
| }; | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 43 | v1.0.0 | 2026-08-03 */ | |||
| const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCombo = [] }) => { | |||
| const workbenchMode = true; | |||
| const { t } = useTranslation("jo"); | |||
| @@ -1799,88 +1800,92 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCo | |||
| selectedLotForQr.suggestedPickLotId || selectedLotForQr.lotId; | |||
| let switchedToUnavailable = false; | |||
| // noLot / missing suggestedPickLotId 场景:没有 originalSuggestedPickLotId,改用 updateStockOutLineStatusByQRCodeAndLotNo | |||
| if (!originalSuggestedPickLotId) { | |||
| if (!selectedLotForQr?.stockOutLineId) { | |||
| throw new Error("Missing stockOutLineId for noLot line"); | |||
| } | |||
| console.log( | |||
| "🔄 [LOT CONFIRM] No originalSuggestedPickLotId, using updateStockOutLineStatusByQRCodeAndLotNo...", | |||
| ); | |||
| const res = await updateStockOutLineStatusByQRCodeAndLotNo({ | |||
| pickOrderLineId: selectedLotForQr.pickOrderLineId, | |||
| inventoryLotNo: effectiveScannedLot.lotNo || "", | |||
| stockInLineId: effectiveScannedLot?.stockInLineId ?? null, | |||
| stockOutLineId: selectedLotForQr.stockOutLineId, | |||
| itemId: selectedLotForQr.itemId, | |||
| status: "checked", | |||
| }); | |||
| console.log( | |||
| "✅ [LOT CONFIRM] updateStockOutLineStatusByQRCodeAndLotNo result:", | |||
| res, | |||
| ); | |||
| switchedToUnavailable = res?.code === "BOUND_UNAVAILABLE"; | |||
| const ok = | |||
| res?.code === "checked" || | |||
| res?.code === "SUCCESS" || | |||
| switchedToUnavailable; | |||
| if (!ok) { | |||
| const errMsg = | |||
| res?.code === "LOT_UNAVAILABLE" | |||
| ? tPick( | |||
| "The scanned lot inventory line is unavailable. Cannot switch or bind; pick line was not updated.", | |||
| ) | |||
| : res?.message || | |||
| tPick( | |||
| "Lot switch failed; pick line was not marked as checked.", | |||
| ); | |||
| setQrScanError(true); | |||
| setQrScanSuccess(false); | |||
| setQrScanErrorMsg(errMsg); | |||
| return; | |||
| } | |||
| } else { | |||
| // Call confirmLotSubstitution to update the suggested lot | |||
| console.log("🔄 [LOT CONFIRM] Calling confirmLotSubstitution..."); | |||
| const substitutionResult = await confirmLotSubstitution({ | |||
| pickOrderLineId: selectedLotForQr.pickOrderLineId, | |||
| stockOutLineId: selectedLotForQr.stockOutLineId, | |||
| originalSuggestedPickLotId, | |||
| newInventoryLotNo: effectiveScannedLot.lotNo || "", | |||
| // ✅ required by LotSubstitutionConfirmRequest | |||
| newStockInLineId: effectiveScannedLot?.stockInLineId ?? null, | |||
| }); | |||
| console.log( | |||
| "✅ [LOT CONFIRM] Lot substitution result:", | |||
| substitutionResult, | |||
| ); | |||
| // Workbench no-hold: skip classic bind/switch (holdQty). Switch + pick via scan-pick only. | |||
| // Non-workbench: keep confirmLotSubstitution / QR bind (moves hold). | |||
| if (!workbenchMode) { | |||
| // noLot / missing suggestedPickLotId 场景:没有 originalSuggestedPickLotId,改用 updateStockOutLineStatusByQRCodeAndLotNo | |||
| if (!originalSuggestedPickLotId) { | |||
| if (!selectedLotForQr?.stockOutLineId) { | |||
| throw new Error("Missing stockOutLineId for noLot line"); | |||
| } | |||
| console.log( | |||
| "🔄 [LOT CONFIRM] No originalSuggestedPickLotId, using updateStockOutLineStatusByQRCodeAndLotNo...", | |||
| ); | |||
| const res = await updateStockOutLineStatusByQRCodeAndLotNo({ | |||
| pickOrderLineId: selectedLotForQr.pickOrderLineId, | |||
| inventoryLotNo: effectiveScannedLot.lotNo || "", | |||
| stockInLineId: effectiveScannedLot?.stockInLineId ?? null, | |||
| stockOutLineId: selectedLotForQr.stockOutLineId, | |||
| itemId: selectedLotForQr.itemId, | |||
| status: "checked", | |||
| }); | |||
| console.log( | |||
| "✅ [LOT CONFIRM] updateStockOutLineStatusByQRCodeAndLotNo result:", | |||
| res, | |||
| ); | |||
| switchedToUnavailable = res?.code === "BOUND_UNAVAILABLE"; | |||
| const ok = | |||
| res?.code === "checked" || | |||
| res?.code === "SUCCESS" || | |||
| switchedToUnavailable; | |||
| if (!ok) { | |||
| const errMsg = | |||
| res?.code === "LOT_UNAVAILABLE" | |||
| ? tPick( | |||
| "The scanned lot inventory line is unavailable. Cannot switch or bind; pick line was not updated.", | |||
| ) | |||
| : res?.message || | |||
| tPick( | |||
| "Lot switch failed; pick line was not marked as checked.", | |||
| ); | |||
| setQrScanError(true); | |||
| setQrScanSuccess(false); | |||
| setQrScanErrorMsg(errMsg); | |||
| return; | |||
| } | |||
| } else { | |||
| // Call confirmLotSubstitution to update the suggested lot | |||
| console.log("🔄 [LOT CONFIRM] Calling confirmLotSubstitution..."); | |||
| const substitutionResult = await confirmLotSubstitution({ | |||
| pickOrderLineId: selectedLotForQr.pickOrderLineId, | |||
| stockOutLineId: selectedLotForQr.stockOutLineId, | |||
| originalSuggestedPickLotId, | |||
| newInventoryLotNo: effectiveScannedLot.lotNo || "", | |||
| // ✅ required by LotSubstitutionConfirmRequest | |||
| newStockInLineId: effectiveScannedLot?.stockInLineId ?? null, | |||
| }); | |||
| // ✅ CRITICAL: substitution failed => DO NOT mark original stockOutLine as checked. | |||
| // Keep modal open so user can cancel/rescan. | |||
| switchedToUnavailable = | |||
| substitutionResult?.code === "SUCCESS_UNAVAILABLE" || | |||
| substitutionResult?.code === "BOUND_UNAVAILABLE"; | |||
| if ( | |||
| !substitutionResult || | |||
| (substitutionResult.code !== "SUCCESS" && !switchedToUnavailable) | |||
| ) { | |||
| console.error( | |||
| "❌ [LOT CONFIRM] Lot substitution failed. Will NOT update stockOutLine status.", | |||
| console.log( | |||
| "✅ [LOT CONFIRM] Lot substitution result:", | |||
| substitutionResult, | |||
| ); | |||
| const errMsg = | |||
| substitutionResult?.code === "LOT_UNAVAILABLE" | |||
| ? tPick( | |||
| "The scanned lot inventory line is unavailable. Cannot switch or bind; pick line was not updated.", | |||
| ) | |||
| : substitutionResult?.message || | |||
| `换批失败:stockInLineId ${ | |||
| effectiveScannedLot?.stockInLineId ?? "" | |||
| } 不存在或无法匹配`; | |||
| setQrScanError(true); | |||
| setQrScanSuccess(false); | |||
| setQrScanErrorMsg(errMsg); | |||
| return; | |||
| // ✅ CRITICAL: substitution failed => DO NOT mark original stockOutLine as checked. | |||
| // Keep modal open so user can cancel/rescan. | |||
| switchedToUnavailable = | |||
| substitutionResult?.code === "SUCCESS_UNAVAILABLE" || | |||
| substitutionResult?.code === "BOUND_UNAVAILABLE"; | |||
| if ( | |||
| !substitutionResult || | |||
| (substitutionResult.code !== "SUCCESS" && !switchedToUnavailable) | |||
| ) { | |||
| console.error( | |||
| "❌ [LOT CONFIRM] Lot substitution failed. Will NOT update stockOutLine status.", | |||
| ); | |||
| const errMsg = | |||
| substitutionResult?.code === "LOT_UNAVAILABLE" | |||
| ? tPick( | |||
| "The scanned lot inventory line is unavailable. Cannot switch or bind; pick line was not updated.", | |||
| ) | |||
| : substitutionResult?.message || | |||
| `换批失败:stockInLineId ${ | |||
| effectiveScannedLot?.stockInLineId ?? "" | |||
| } 不存在或无法匹配`; | |||
| setQrScanError(true); | |||
| setQrScanSuccess(false); | |||
| setQrScanErrorMsg(errMsg); | |||
| return; | |||
| } | |||
| } | |||
| } | |||
| @@ -30,7 +30,6 @@ import { SessionWithTokens } from "@/config/authConfig"; | |||
| import { | |||
| fetchConsumableWorkbenchPickOrderLotsHierarchical, | |||
| reloadConsumableWorkbenchPickOrderLotsHierarchical, | |||
| confirmLotSubstitution, | |||
| suggestPickOrderWorkbenchV2, | |||
| } from "@/app/api/pickOrder/actions"; | |||
| import { workbenchScanPick } from "@/app/api/doworkbench/actions"; | |||
| @@ -229,15 +228,6 @@ const isCheckedStatus = (status: string | undefined): boolean => | |||
| const isRejectedStatus = (status: string | undefined): boolean => | |||
| String(status || "").toLowerCase() === "rejected"; | |||
| const isNonBlockingSwitchLotReject = (code: unknown, message: unknown): boolean => { | |||
| const c = String(code || "").toUpperCase(); | |||
| const m = String(message || ""); | |||
| if (c === "SUCCESS_UNAVAILABLE" || c === "BOUND_UNAVAILABLE") return true; | |||
| if (/^Reject switch lot:/i.test(m)) return true; | |||
| if (/available\s*=\s*\d+(\.\d+)?\s*<\s*required\s*=\s*\d+(\.\d+)?/i.test(m)) return true; | |||
| return false; | |||
| }; | |||
| function safeDisplayTargetDate(targetDate: string | number[]): string { | |||
| try { | |||
| if (Array.isArray(targetDate) && targetDate.length >= 3) { | |||
| @@ -356,7 +346,7 @@ function mapHierarchicalToPickOrders(data: unknown): PickOrderTopRow[] { | |||
| }); | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 28 | v1.0.1 | 2026-07-22 */ | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 28 | v1.0.2 | 2026-08-03 */ | |||
| const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| const { t } = useTranslation("pickOrder"); | |||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | |||
| @@ -962,6 +952,7 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| } | |||
| }, []); | |||
| // Workbench no-hold: skip confirmLotSubstitution (classic hold move). Switch + pick via scan-pick only. | |||
| const handleLotConfirmation = useCallback( | |||
| async (overrideScanned?: ConfirmLotState, overrideExpected?: ConfirmLotState) => { | |||
| const expected = overrideExpected ?? expectedLotData; | |||
| @@ -972,56 +963,25 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| setError(""); | |||
| setMessage(""); | |||
| try { | |||
| const originalSuggestedPickLotId = Number(expected.row.suggestedPickLotId || 0); | |||
| let switchedToUnavailable = false; | |||
| if (originalSuggestedPickLotId > 0) { | |||
| const res = await confirmLotSubstitution({ | |||
| pickOrderLineId: expected.row.pickOrderLineId, | |||
| stockOutLineId: expected.row.stockOutLineId, | |||
| originalSuggestedPickLotId, | |||
| newInventoryLotNo: scanned.lotNo, | |||
| newStockInLineId: Number(scanned.stockInLineId ?? 0), | |||
| }); | |||
| switchedToUnavailable = res.code === "SUCCESS_UNAVAILABLE" || res.code === "BOUND_UNAVAILABLE"; | |||
| const nonBlockingReject = isNonBlockingSwitchLotReject(res.code, res.message); | |||
| if (res.code !== "SUCCESS" && !switchedToUnavailable && !nonBlockingReject) { | |||
| const msg = (res.message as string) || t("Lot switch failed"); | |||
| setLotConfirmationError(msg); | |||
| setError(msg); | |||
| startTransition(() => { | |||
| setQrScanError(true); | |||
| setQrScanSuccess(false); | |||
| setQrScanErrorMsg(msg); | |||
| }); | |||
| return; | |||
| } | |||
| if (nonBlockingReject && !switchedToUnavailable) { | |||
| const warnMsg = (res.message as string) || t("Lot switch rejected. Continue with scan-pick."); | |||
| setMessage(warnMsg); | |||
| } | |||
| } | |||
| if (!switchedToUnavailable) { | |||
| const res = await workbenchScanPick({ | |||
| stockOutLineId: expected.row.stockOutLineId, | |||
| lotNo: scanned.lotNo, | |||
| ...(Number.isFinite(Number(scanned.stockInLineId)) && Number(scanned.stockInLineId) > 0 | |||
| ? { stockInLineId: Number(scanned.stockInLineId) } | |||
| : {}), | |||
| ...workbenchScanPickQtyFromLot(expected.row), | |||
| userId, | |||
| const res = await workbenchScanPick({ | |||
| stockOutLineId: expected.row.stockOutLineId, | |||
| lotNo: scanned.lotNo, | |||
| ...(Number.isFinite(Number(scanned.stockInLineId)) && Number(scanned.stockInLineId) > 0 | |||
| ? { stockInLineId: Number(scanned.stockInLineId) } | |||
| : {}), | |||
| ...workbenchScanPickQtyFromLot(expected.row), | |||
| userId, | |||
| }); | |||
| if (res.code !== "SUCCESS") { | |||
| const msg = (res.message as string) || t("Workbench scan-pick failed."); | |||
| setLotConfirmationError(msg); | |||
| setError(msg); | |||
| startTransition(() => { | |||
| setQrScanError(true); | |||
| setQrScanSuccess(false); | |||
| setQrScanErrorMsg(msg); | |||
| }); | |||
| if (res.code !== "SUCCESS") { | |||
| const msg = (res.message as string) || t("Workbench scan-pick failed."); | |||
| setLotConfirmationError(msg); | |||
| setError(msg); | |||
| startTransition(() => { | |||
| setQrScanError(true); | |||
| setQrScanSuccess(false); | |||
| setQrScanErrorMsg(msg); | |||
| }); | |||
| return; | |||
| } | |||
| return; | |||
| } | |||
| setMessage(t("Scan pick success")); | |||
| startTransition(() => { | |||
| @@ -0,0 +1,750 @@ | |||
| "use client"; | |||
| import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |||
| import { | |||
| Box, | |||
| Button, | |||
| Card, | |||
| CardContent, | |||
| Chip, | |||
| CircularProgress, | |||
| Dialog, | |||
| DialogActions, | |||
| DialogContent, | |||
| DialogTitle, | |||
| Paper, | |||
| Stack, | |||
| Tab, | |||
| Tabs, | |||
| Table, | |||
| TableBody, | |||
| TableCell, | |||
| TableContainer, | |||
| TableHead, | |||
| TablePagination, | |||
| TableRow, | |||
| Typography, | |||
| } from "@mui/material"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { useSession } from "next-auth/react"; | |||
| import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | |||
| import { DatePicker } from "@mui/x-date-pickers/DatePicker"; | |||
| import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; | |||
| import dayjs, { Dayjs } from "dayjs"; | |||
| import { SessionWithTokens } from "@/config/authConfig"; | |||
| import { AUTH } from "@/authorities"; | |||
| import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | |||
| import { | |||
| AllJoborderProductProcessInfoResponse, | |||
| completeProductProcessLine, | |||
| fetchJoborderProductProcessesPage, | |||
| fetchJos, | |||
| fetchProductProcessesByJobOrderId, | |||
| setJobOrderHidden, | |||
| } from "@/app/api/jo/actions"; | |||
| import { JobOrder } from "@/app/api/jo"; | |||
| import QcStockInModal from "@/components/Qc/QcStockInModal"; | |||
| import { StockInLineInput } from "@/app/api/stockIn"; | |||
| import type { PrinterCombo } from "@/app/api/settings/printer"; | |||
| type PrimaryTab = | |||
| | "all" | |||
| | "pending" | |||
| | "processing" | |||
| | "pending_qc" | |||
| | "putawayed" | |||
| | "issue"; | |||
| type PendingSubTab = "all" | "picked_not_started" | "not_picked_not_started"; | |||
| type ProcessingSubTab = "all" | "picked_started" | "not_picked_started"; | |||
| type IssueSubTab = "stop" | "cancel"; | |||
| type OpsRow = { | |||
| key: string; | |||
| jobOrderId: number; | |||
| jobOrderCode: string; | |||
| itemCode: string; | |||
| itemName: string; | |||
| requiredQty: number; | |||
| uom: string; | |||
| productionDate: string; | |||
| statusLabel: string; | |||
| pickProcessBucket?: string | null; | |||
| isPaused: boolean; | |||
| isCancelled: boolean; | |||
| /** pending_qc | putawayed | production | cancelled */ | |||
| rowKind: "production" | "pending_qc" | "putawayed" | "cancelled"; | |||
| stockInLineId?: number | null; | |||
| /** Present for production rows; used by complete-JO. */ | |||
| sourceProcess?: AllJoborderProductProcessInfoResponse; | |||
| }; | |||
| /** Cards / page size for ops table fetch. */ | |||
| const FETCH_SIZE = 200; | |||
| function isPausedProcess(p: AllJoborderProductProcessInfoResponse): boolean { | |||
| return (p.lines ?? []).some( | |||
| (l) => String(l.status ?? "").trim().toLowerCase() === "paused", | |||
| ); | |||
| } | |||
| function toProductionRow( | |||
| p: AllJoborderProductProcessInfoResponse, | |||
| rowKind: OpsRow["rowKind"] = "production", | |||
| ): OpsRow | null { | |||
| if (!p.jobOrderId) return null; | |||
| return { | |||
| key: `pp-${rowKind}-${p.jobOrderId}-${p.id}`, | |||
| jobOrderId: p.jobOrderId, | |||
| jobOrderCode: p.jobOrderCode || "-", | |||
| itemCode: p.itemCode || "-", | |||
| itemName: p.itemName || "-", | |||
| requiredQty: p.requiredQty ?? 0, | |||
| uom: p.uom || "", | |||
| productionDate: p.date || "", | |||
| statusLabel: | |||
| rowKind === "pending_qc" | |||
| ? "pending_qc" | |||
| : rowKind === "putawayed" | |||
| ? "putawayed" | |||
| : p.pickProcessBucket || p.status || "-", | |||
| pickProcessBucket: p.pickProcessBucket, | |||
| isPaused: isPausedProcess(p), | |||
| isCancelled: false, | |||
| rowKind, | |||
| stockInLineId: p.stockInLineId ?? null, | |||
| sourceProcess: p, | |||
| }; | |||
| } | |||
| function toCancelledRow(jo: JobOrder): OpsRow { | |||
| const planStart = Array.isArray(jo.planStart) | |||
| ? dayjs( | |||
| new Date( | |||
| jo.planStart[0], | |||
| (jo.planStart[1] ?? 1) - 1, | |||
| jo.planStart[2] ?? 1, | |||
| ), | |||
| ).format("YYYY-MM-DD") | |||
| : jo.planStart | |||
| ? dayjs(jo.planStart as unknown as string).format("YYYY-MM-DD") | |||
| : ""; | |||
| return { | |||
| key: `jo-${jo.id}`, | |||
| jobOrderId: jo.id, | |||
| jobOrderCode: jo.code || "-", | |||
| itemCode: jo.item?.code || "-", | |||
| itemName: jo.itemName || jo.item?.name || "-", | |||
| requiredQty: jo.reqQty ?? 0, | |||
| uom: "", | |||
| productionDate: planStart, | |||
| statusLabel: "cancelled", | |||
| pickProcessBucket: null, | |||
| isPaused: false, | |||
| isCancelled: true, | |||
| rowKind: "cancelled", | |||
| stockInLineId: null, | |||
| }; | |||
| } | |||
| /** Deduplicate by jobOrderId (prefer paused / higher priority). */ | |||
| function dedupeByJobOrder(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.isPaused && row.isPaused) { | |||
| map.set(row.jobOrderId, row); | |||
| } | |||
| } | |||
| return Array.from(map.values()); | |||
| } | |||
| interface JobOrderOpsTableProps { | |||
| onSelectProcess?: (jobOrderId: number) => void; | |||
| printerCombo?: PrinterCombo[]; | |||
| } | |||
| const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | |||
| onSelectProcess, | |||
| printerCombo = [], | |||
| }) => { | |||
| const { t } = useTranslation(["productionProcess", "common"]); | |||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | |||
| const sessionToken = session as SessionWithTokens | null; | |||
| const abilities = session?.abilities ?? session?.user?.abilities ?? []; | |||
| const canManage = abilities.some((a) => a.trim() === AUTH.ADMIN); | |||
| 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 [issueSub, setIssueSub] = useState<IssueSubTab>("stop"); | |||
| const [productionRows, setProductionRows] = useState<OpsRow[]>([]); | |||
| const [pendingQcRows, setPendingQcRows] = useState<OpsRow[]>([]); | |||
| const [putawayedRows, setPutawayedRows] = useState<OpsRow[]>([]); | |||
| const [cancelledRows, setCancelledRows] = useState<OpsRow[]>([]); | |||
| const [loading, setLoading] = useState(false); | |||
| const [page, setPage] = useState(0); | |||
| const [pageSize, setPageSize] = useState(10); | |||
| const [confirmOpen, setConfirmOpen] = useState(false); | |||
| const [confirmMessage, setConfirmMessage] = useState(""); | |||
| const [confirmLoading, setConfirmLoading] = useState(false); | |||
| const [pendingAction, setPendingAction] = useState<null | (() => Promise<void>)>(null); | |||
| const actionInFlightRef = useRef(false); | |||
| const [rowBusyIds, setRowBusyIds] = useState<Set<number>>(new Set()); | |||
| const [qcModalOpen, setQcModalOpen] = useState(false); | |||
| const [qcModalInfo, setQcModalInfo] = useState<StockInLineInput | undefined>(); | |||
| const loadProduction = useCallback(async () => { | |||
| const dayStr = queryDate.format("YYYY-MM-DD"); | |||
| // lookbackDays=0 → exact search date only (still enables pickProcessBucket on backend) | |||
| const data = await fetchJoborderProductProcessesPage({ | |||
| date: dayStr, | |||
| lookbackDays: 0, | |||
| bucket: "all", | |||
| qcReady: false, | |||
| page: 0, | |||
| size: FETCH_SIZE, | |||
| }); | |||
| const rows = (data?.content ?? []) | |||
| .map((p) => toProductionRow(p, "production")) | |||
| .filter((r): r is OpsRow => r != null) | |||
| .filter((r) => !r.productionDate || r.productionDate === dayStr); | |||
| setProductionRows(dedupeByJobOrder(rows)); | |||
| }, [queryDate]); | |||
| const loadPendingQc = useCallback(async () => { | |||
| const dayStr = queryDate.format("YYYY-MM-DD"); | |||
| try { | |||
| const data = await fetchJoborderProductProcessesPage({ | |||
| date: dayStr, | |||
| lookbackDays: 0, | |||
| qcReady: true, | |||
| includePutaway: true, | |||
| putawayStatus: "notCompleted", | |||
| page: 0, | |||
| size: FETCH_SIZE, | |||
| }); | |||
| const rows = (data?.content ?? []) | |||
| .map((p) => toProductionRow(p, "pending_qc")) | |||
| .filter((r): r is OpsRow => r != null) | |||
| .filter((r) => !r.productionDate || r.productionDate === dayStr); | |||
| setPendingQcRows(dedupeByJobOrder(rows)); | |||
| } catch (e) { | |||
| console.error("loadPendingQc failed", e); | |||
| setPendingQcRows([]); | |||
| } | |||
| }, [queryDate]); | |||
| const loadPutawayed = useCallback(async () => { | |||
| const dayStr = queryDate.format("YYYY-MM-DD"); | |||
| try { | |||
| const data = await fetchJoborderProductProcessesPage({ | |||
| date: dayStr, | |||
| qcReady: true, | |||
| includePutaway: true, | |||
| putawayStatus: "completed", | |||
| page: 0, | |||
| size: FETCH_SIZE, | |||
| }); | |||
| const rows = (data?.content ?? []) | |||
| .map((p) => toProductionRow(p, "putawayed")) | |||
| .filter((r): r is OpsRow => r != null) | |||
| .filter((r) => !r.productionDate || r.productionDate === dayStr); | |||
| setPutawayedRows(dedupeByJobOrder(rows)); | |||
| } catch (e) { | |||
| console.error("loadPutawayed failed", e); | |||
| setPutawayedRows([]); | |||
| } | |||
| }, [queryDate]); | |||
| const loadCancelled = useCallback(async () => { | |||
| const dayStr = queryDate.format("YYYY-MM-DD"); | |||
| try { | |||
| const res = await fetchJos({ | |||
| code: "", | |||
| planStart: dayStr, | |||
| planStartTo: dayStr, | |||
| joSearchStatus: "cancel", | |||
| pageNum: 0, | |||
| pageSize: FETCH_SIZE, | |||
| }); | |||
| setCancelledRows((res?.records ?? []).map(toCancelledRow)); | |||
| } catch (e) { | |||
| console.error("loadCancelled failed", e); | |||
| setCancelledRows([]); | |||
| } | |||
| }, [queryDate]); | |||
| const loadData = useCallback(async () => { | |||
| setLoading(true); | |||
| const results = await Promise.allSettled([ | |||
| loadProduction(), | |||
| loadPendingQc(), | |||
| loadPutawayed(), | |||
| loadCancelled(), | |||
| ]); | |||
| if (results[0].status === "rejected") { | |||
| console.error(results[0].reason); | |||
| setProductionRows([]); | |||
| } | |||
| setLoading(false); | |||
| }, [loadProduction, loadPendingQc, loadPutawayed, loadCancelled]); | |||
| useEffect(() => { | |||
| void loadData(); | |||
| }, [loadData]); | |||
| useEffect(() => { | |||
| setPage(0); | |||
| }, [primaryTab, pendingSub, processingSub, issueSub, queryDate]); | |||
| const filteredRows = useMemo(() => { | |||
| if (primaryTab === "pending_qc") return pendingQcRows; | |||
| if (primaryTab === "putawayed") return putawayedRows; | |||
| if (primaryTab === "issue" && issueSub === "cancel") return cancelledRows; | |||
| let list = productionRows; | |||
| if (primaryTab === "pending") { | |||
| list = list.filter((r) => { | |||
| const b = r.pickProcessBucket; | |||
| if (pendingSub === "picked_not_started") return b === "picked_not_started"; | |||
| if (pendingSub === "not_picked_not_started") return b === "not_picked_not_started"; | |||
| return b === "picked_not_started" || b === "not_picked_not_started"; | |||
| }); | |||
| } 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"; | |||
| }); | |||
| } else if (primaryTab === "issue" && issueSub === "stop") { | |||
| list = list.filter((r) => r.isPaused); | |||
| } | |||
| return list; | |||
| }, [ | |||
| primaryTab, | |||
| pendingSub, | |||
| processingSub, | |||
| issueSub, | |||
| productionRows, | |||
| pendingQcRows, | |||
| putawayedRows, | |||
| cancelledRows, | |||
| ]); | |||
| 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 stop = productionRows.filter((r) => r.isPaused).length; | |||
| return { | |||
| all: productionRows.length, | |||
| pending, | |||
| processing, | |||
| pendingQc: pendingQcRows.length, | |||
| putawayed: putawayedRows.length, | |||
| stop, | |||
| cancel: cancelledRows.length, | |||
| }; | |||
| }, [productionRows, pendingQcRows, putawayedRows, cancelledRows]); | |||
| const paginatedRows = useMemo(() => { | |||
| const start = page * pageSize; | |||
| return filteredRows.slice(start, start + pageSize); | |||
| }, [filteredRows, page, pageSize]); | |||
| const openConfirm = useCallback((message: string, action: () => Promise<void>) => { | |||
| setConfirmMessage(message); | |||
| setPendingAction(() => action); | |||
| setConfirmOpen(true); | |||
| }, []); | |||
| const closeConfirm = useCallback(() => { | |||
| if (confirmLoading) return; | |||
| setConfirmOpen(false); | |||
| setPendingAction(null); | |||
| setConfirmMessage(""); | |||
| }, [confirmLoading]); | |||
| const markBusy = (id: number, busy: boolean) => { | |||
| setRowBusyIds((prev) => { | |||
| const next = new Set(prev); | |||
| if (busy) next.add(id); | |||
| else next.delete(id); | |||
| return next; | |||
| }); | |||
| }; | |||
| const handleOpenQcModal = useCallback( | |||
| (row: OpsRow) => { | |||
| if (!row.stockInLineId) { | |||
| alert(t("Invalid Stock In Line Id")); | |||
| return; | |||
| } | |||
| setQcModalInfo({ id: row.stockInLineId }); | |||
| setQcModalOpen(true); | |||
| }, | |||
| [t], | |||
| ); | |||
| const handleComplete = useCallback( | |||
| (row: OpsRow) => { | |||
| if (!canManage || row.isCancelled) return; | |||
| openConfirm(t("Confirm to update this Job Order?"), async () => { | |||
| if (actionInFlightRef.current) return; | |||
| actionInFlightRef.current = true; | |||
| markBusy(row.jobOrderId, true); | |||
| try { | |||
| const processes = await fetchProductProcessesByJobOrderId(row.jobOrderId); | |||
| const lineIds = (processes ?? []) | |||
| .flatMap((p) => (p as { productProcessLines?: { id?: number }[] }).productProcessLines ?? []) | |||
| .map((l) => l.id) | |||
| .filter((id): id is number => !!id); | |||
| for (const lineId of lineIds) { | |||
| try { | |||
| await completeProductProcessLine(lineId); | |||
| } catch (e) { | |||
| console.error("completeProductProcessLine failed", lineId, e); | |||
| } | |||
| } | |||
| await loadData(); | |||
| } finally { | |||
| markBusy(row.jobOrderId, false); | |||
| actionInFlightRef.current = false; | |||
| } | |||
| }); | |||
| }, | |||
| [canManage, openConfirm, t, loadData], | |||
| ); | |||
| const handleCancel = useCallback( | |||
| (row: OpsRow) => { | |||
| if (!canManage || row.isCancelled) return; | |||
| openConfirm(t("Cancel job order confirm message"), async () => { | |||
| if (actionInFlightRef.current) return; | |||
| actionInFlightRef.current = true; | |||
| markBusy(row.jobOrderId, true); | |||
| try { | |||
| await setJobOrderHidden(row.jobOrderId, true); | |||
| await loadData(); | |||
| } finally { | |||
| markBusy(row.jobOrderId, false); | |||
| actionInFlightRef.current = false; | |||
| } | |||
| }); | |||
| }, | |||
| [canManage, openConfirm, t, loadData], | |||
| ); | |||
| const onConfirm = useCallback(async () => { | |||
| if (!pendingAction) return; | |||
| setConfirmLoading(true); | |||
| try { | |||
| await pendingAction(); | |||
| } catch (e) { | |||
| console.error(e); | |||
| } finally { | |||
| setConfirmLoading(false); | |||
| setConfirmOpen(false); | |||
| setPendingAction(null); | |||
| setConfirmMessage(""); | |||
| } | |||
| }, [pendingAction]); | |||
| const bucketLabel = (row: OpsRow) => { | |||
| if (row.rowKind === "pending_qc") return t("Waiting QC Put Away"); | |||
| if (row.rowKind === "putawayed") return t("Put Awayed"); | |||
| if (row.isCancelled) return t("Cancelled"); | |||
| switch (row.pickProcessBucket) { | |||
| case "not_picked_not_started": | |||
| case "not_picked_started": | |||
| return t("Not picked"); | |||
| case "picked_not_started": | |||
| case "picked_started": | |||
| return t("Picked"); | |||
| default: | |||
| return row.statusLabel || "-"; | |||
| } | |||
| }; | |||
| const showManageActions = | |||
| primaryTab !== "pending_qc" && | |||
| primaryTab !== "putawayed" && | |||
| !(primaryTab === "issue" && issueSub === "cancel"); | |||
| return ( | |||
| <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-hk"> | |||
| <Card sx={{ mb: 2 }}> | |||
| <CardContent> | |||
| <Typography variant="h5" sx={{ fontWeight: 600, mb: 2 }}> | |||
| {t("Job Order Ops Table")} | |||
| </Typography> | |||
| <Stack | |||
| direction="row" | |||
| spacing={2} | |||
| sx={{ mb: 2, flexWrap: "wrap", alignItems: "center" }} | |||
| > | |||
| <DatePicker | |||
| label={t("Search date")} | |||
| value={queryDate} | |||
| onChange={(v) => v && setQueryDate(v)} | |||
| slotProps={{ textField: { size: "small", sx: { minWidth: 180 } } }} | |||
| /> | |||
| <Button variant="outlined" size="small" onClick={() => void loadData()}> | |||
| {t("Reload data")} | |||
| </Button> | |||
| </Stack> | |||
| <Tabs | |||
| value={primaryTab} | |||
| onChange={(_, v: PrimaryTab) => setPrimaryTab(v)} | |||
| variant="scrollable" | |||
| scrollButtons="auto" | |||
| sx={{ mb: 1, borderBottom: 1, borderColor: "divider" }} | |||
| > | |||
| <Tab value="all" label={`${t("All")} (${counts.all})`} /> | |||
| <Tab value="pending" label={`${t("pending")} (${counts.pending})`} /> | |||
| <Tab value="processing" label={`${t("Processing")} (${counts.processing})`} /> | |||
| <Tab | |||
| value="pending_qc" | |||
| label={`${t("Waiting QC Put Away")} (${counts.pendingQc})`} | |||
| /> | |||
| <Tab value="putawayed" label={`${t("Put Awayed")} (${counts.putawayed})`} /> | |||
| <Tab | |||
| value="issue" | |||
| label={`${t("Issue")} (${counts.stop + counts.cancel})`} | |||
| /> | |||
| </Tabs> | |||
| {primaryTab === "pending" && ( | |||
| <Tabs | |||
| value={pendingSub} | |||
| onChange={(_, v: PendingSubTab) => setPendingSub(v)} | |||
| sx={{ mb: 2 }} | |||
| > | |||
| <Tab value="all" label={t("All")} /> | |||
| <Tab value="picked_not_started" label={t("Picked")} /> | |||
| <Tab value="not_picked_not_started" label={t("Not picked")} /> | |||
| </Tabs> | |||
| )} | |||
| {primaryTab === "processing" && ( | |||
| <Tabs | |||
| value={processingSub} | |||
| onChange={(_, v: ProcessingSubTab) => setProcessingSub(v)} | |||
| sx={{ mb: 2 }} | |||
| > | |||
| <Tab value="all" label={t("All")} /> | |||
| <Tab value="picked_started" label={t("Picked")} /> | |||
| <Tab value="not_picked_started" label={t("Not picked")} /> | |||
| </Tabs> | |||
| )} | |||
| {primaryTab === "issue" && ( | |||
| <Tabs | |||
| value={issueSub} | |||
| onChange={(_, v: IssueSubTab) => setIssueSub(v)} | |||
| sx={{ mb: 2 }} | |||
| > | |||
| <Tab value="stop" label={`${t("Stop (paused)")} (${counts.stop})`} /> | |||
| <Tab value="cancel" label={`${t("Cancelled")} (${counts.cancel})`} /> | |||
| </Tabs> | |||
| )} | |||
| {loading ? ( | |||
| <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}> | |||
| <CircularProgress /> | |||
| </Box> | |||
| ) : ( | |||
| <> | |||
| <TableContainer component={Paper} sx={{ maxHeight: 480, overflow: "auto" }}> | |||
| <Table size="small" stickyHeader sx={{ minWidth: 800 }}> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell>{t("Job Order")}</TableCell> | |||
| <TableCell>{t("Item")}</TableCell> | |||
| <TableCell align="right">{t("Required Qty")}</TableCell> | |||
| <TableCell>{t("Production Date")}</TableCell> | |||
| <TableCell>{t("Status")}</TableCell> | |||
| <TableCell align="center" sx={{ minWidth: 280 }}> | |||
| {t("Actions")} | |||
| </TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {paginatedRows.length === 0 ? ( | |||
| <TableRow> | |||
| <TableCell colSpan={6} align="center"> | |||
| {t("No data available")} | |||
| </TableCell> | |||
| </TableRow> | |||
| ) : ( | |||
| paginatedRows.map((row) => { | |||
| const busy = rowBusyIds.has(row.jobOrderId); | |||
| return ( | |||
| <TableRow key={row.key} hover> | |||
| <TableCell>{row.jobOrderCode}</TableCell> | |||
| <TableCell> | |||
| <Typography variant="body2"> | |||
| {[row.itemCode, row.itemName].filter(Boolean).join(" ")} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| {row.requiredQty} | |||
| {row.uom ? ` ${row.uom}` : ""} | |||
| </TableCell> | |||
| <TableCell> | |||
| {row.productionDate && dayjs(row.productionDate).isValid() | |||
| ? dayjs(row.productionDate).format(OUTPUT_DATE_FORMAT) | |||
| : "-"} | |||
| </TableCell> | |||
| <TableCell> | |||
| <Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap> | |||
| {row.isPaused && ( | |||
| <Chip size="small" color="warning" label={t("Stop (paused)")} /> | |||
| )} | |||
| <Chip | |||
| size="small" | |||
| variant="outlined" | |||
| label={bucketLabel(row)} | |||
| /> | |||
| </Stack> | |||
| </TableCell> | |||
| <TableCell align="center"> | |||
| <Stack | |||
| direction="row" | |||
| spacing={1} | |||
| justifyContent="center" | |||
| flexWrap="wrap" | |||
| useFlexGap | |||
| > | |||
| <Button | |||
| size="small" | |||
| variant="outlined" | |||
| onClick={() => onSelectProcess?.(row.jobOrderId)} | |||
| > | |||
| {t("View Details")} | |||
| </Button> | |||
| {primaryTab === "pending_qc" && row.stockInLineId != null && ( | |||
| <Button | |||
| size="small" | |||
| variant="contained" | |||
| onClick={() => handleOpenQcModal(row)} | |||
| > | |||
| {t("view stockin")} | |||
| </Button> | |||
| )} | |||
| {primaryTab === "putawayed" && row.stockInLineId != null && ( | |||
| <Button | |||
| size="small" | |||
| variant="contained" | |||
| onClick={() => handleOpenQcModal(row)} | |||
| > | |||
| {t("Put Away Detail")} | |||
| </Button> | |||
| )} | |||
| {showManageActions && !row.isCancelled && ( | |||
| <Button | |||
| size="small" | |||
| variant="contained" | |||
| disabled={!canManage || busy} | |||
| onClick={() => handleComplete(row)} | |||
| startIcon={ | |||
| busy ? ( | |||
| <CircularProgress size={14} color="inherit" /> | |||
| ) : undefined | |||
| } | |||
| > | |||
| {t("Update Job Order")} | |||
| </Button> | |||
| )} | |||
| {showManageActions && !row.isCancelled && ( | |||
| <Button | |||
| size="small" | |||
| variant="outlined" | |||
| color="warning" | |||
| disabled={!canManage || busy} | |||
| onClick={() => handleCancel(row)} | |||
| > | |||
| {t("Cancel Job Order")} | |||
| </Button> | |||
| )} | |||
| </Stack> | |||
| </TableCell> | |||
| </TableRow> | |||
| ); | |||
| }) | |||
| )} | |||
| </TableBody> | |||
| </Table> | |||
| </TableContainer> | |||
| <TablePagination | |||
| component="div" | |||
| count={filteredRows.length} | |||
| page={page} | |||
| onPageChange={(_, p) => setPage(p)} | |||
| rowsPerPage={pageSize} | |||
| onRowsPerPageChange={(e) => { | |||
| setPageSize(parseInt(e.target.value, 10)); | |||
| setPage(0); | |||
| }} | |||
| rowsPerPageOptions={[5, 10, 25, 50]} | |||
| labelRowsPerPage={t("Rows per page")} | |||
| /> | |||
| </> | |||
| )} | |||
| </CardContent> | |||
| </Card> | |||
| <Dialog open={confirmOpen} onClose={closeConfirm} maxWidth="xs" fullWidth> | |||
| <DialogTitle>{t("Confirm")}</DialogTitle> | |||
| <DialogContent> | |||
| <Typography variant="body2">{confirmMessage}</Typography> | |||
| </DialogContent> | |||
| <DialogActions> | |||
| <Button onClick={closeConfirm} disabled={confirmLoading}> | |||
| {t("Cancel")} | |||
| </Button> | |||
| <Button | |||
| variant="contained" | |||
| onClick={() => void onConfirm()} | |||
| disabled={confirmLoading || !pendingAction} | |||
| > | |||
| {confirmLoading ? t("Processing...") : t("Confirm")} | |||
| </Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| <QcStockInModal | |||
| session={sessionToken} | |||
| open={qcModalOpen} | |||
| onClose={() => { | |||
| setQcModalOpen(false); | |||
| setQcModalInfo(undefined); | |||
| }} | |||
| inputDetail={qcModalInfo} | |||
| printerCombo={printerCombo} | |||
| warehouse={[]} | |||
| printSource="productionProcess" | |||
| uiMode="default" | |||
| /> | |||
| </LocalizationProvider> | |||
| ); | |||
| }; | |||
| export default JobOrderOpsTable; | |||
| @@ -1,5 +1,5 @@ | |||
| "use client"; | |||
| import React, { useCallback, useEffect, useState, useMemo } from "react"; | |||
| import React, { useCallback, useEffect, useRef, useState, useMemo } from "react"; | |||
| import { | |||
| Box, | |||
| Button, | |||
| @@ -23,7 +23,7 @@ import { | |||
| } from "@mui/material"; | |||
| import ArrowBackIcon from '@mui/icons-material/ArrowBack'; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { fetchProductProcessesByJobOrderId ,deleteJobOrder, setJobOrderHidden, updateProductProcessPriority, updateJoPlanStart,updateJoReqQty,newProductProcessLine,JobOrderLineInfo} from "@/app/api/jo/actions"; | |||
| import { fetchProductProcessesByJobOrderId , setJobOrderHidden, updateProductProcessPriority, updateJoPlanStart,updateJoReqQty,newProductProcessLine,JobOrderLineInfo} from "@/app/api/jo/actions"; | |||
| import ProductionProcessDetail from "./ProductionProcessDetail"; | |||
| import { BomCombo } from "@/app/api/bom"; | |||
| import { fetchBomCombo } from "@/app/api/bom/index"; | |||
| @@ -36,7 +36,7 @@ import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutli | |||
| import DoDisturbAltRoundedIcon from '@mui/icons-material/DoDisturbAltRounded'; | |||
| import { fetchInventories } from "@/app/api/inventory/actions"; | |||
| import { InventoryResult } from "@/app/api/inventory"; | |||
| import { releaseJo, startJo } from "@/app/api/jo/actions"; | |||
| import { releaseJoForWorkbench } from "@/app/api/jo/workbenchActions"; | |||
| import JobPickExecutionsecondscan from "../Jodetail/JobPickExecutionsecondscan"; | |||
| import ProcessSummaryHeader from "./ProcessSummaryHeader"; | |||
| import EditIcon from "@mui/icons-material/Edit"; | |||
| @@ -53,6 +53,7 @@ interface ProductProcessJobOrderDetailProps { | |||
| initialTabIndex?: number; | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.0 | 2026-08-03 */ | |||
| const ProductionProcessJobOrderDetail: React.FC<ProductProcessJobOrderDetailProps> = ({ | |||
| jobOrderId, | |||
| onBack, | |||
| @@ -276,25 +277,13 @@ const isPutAwayed = useMemo( | |||
| () => (processData?.jobOrderStatus ?? "").toLowerCase() === "completed", | |||
| [processData?.jobOrderStatus] | |||
| ); | |||
| const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); | |||
| const [cancelConfirmOpen, setCancelConfirmOpen] = useState(false); | |||
| const [deleteLoading, setDeleteLoading] = useState(false); | |||
| const [cancelLoading, setCancelLoading] = useState(false); | |||
| const handleConfirmDeleteJobOrder = useCallback(async () => { | |||
| setDeleteLoading(true); | |||
| try { | |||
| const response = await deleteJobOrder(jobOrderId); | |||
| if (response) { | |||
| setDeleteConfirmOpen(false); | |||
| onBack(); | |||
| } | |||
| } finally { | |||
| setDeleteLoading(false); | |||
| } | |||
| }, [jobOrderId, onBack]); | |||
| const cancelInFlightRef = useRef(false); | |||
| const handleConfirmCancelJobOrder = useCallback(async () => { | |||
| if (cancelInFlightRef.current) return; | |||
| cancelInFlightRef.current = true; | |||
| setCancelLoading(true); | |||
| try { | |||
| await setJobOrderHidden(jobOrderId, true); | |||
| @@ -302,17 +291,27 @@ const handleConfirmCancelJobOrder = useCallback(async () => { | |||
| onBack(); | |||
| } finally { | |||
| setCancelLoading(false); | |||
| cancelInFlightRef.current = false; | |||
| } | |||
| }, [jobOrderId, onBack]); | |||
| const handleRelease = useCallback(async ( jobOrderId: number) => { | |||
| // TODO: 替换为实际的 release 调用 | |||
| console.log("Release clicked for jobOrderId:", jobOrderId); | |||
| const response = await releaseJo({ id: jobOrderId }) | |||
| if (response) { | |||
| //setProcessData(response.entity); | |||
| await fetchData(); | |||
| const releaseInFlightRef = useRef(false); | |||
| const [isReleasing, setIsReleasing] = useState(false); | |||
| const handleRelease = useCallback(async (jobOrderId: number) => { | |||
| if (releaseInFlightRef.current) return; | |||
| releaseInFlightRef.current = true; | |||
| setIsReleasing(true); | |||
| try { | |||
| // Workbench no-hold release: defer SPL/SOL/hold until first pick assign | |||
| const response = await releaseJoForWorkbench({ id: jobOrderId }); | |||
| if (response) { | |||
| await fetchData(); | |||
| } | |||
| } finally { | |||
| setIsReleasing(false); | |||
| releaseInFlightRef.current = false; | |||
| } | |||
| }, [jobOrderId]); | |||
| }, [fetchData]); | |||
| const handleTabChange = useCallback<NonNullable<TabsProps["onChange"]>>( | |||
| (_e, newValue) => { | |||
| setTabIndex(newValue); | |||
| @@ -722,21 +721,12 @@ const handleRelease = useCallback(async ( jobOrderId: number) => { | |||
| <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}> | |||
| {t("Lines with insufficient stock: ")}<strong style={{ color: "red" }}>{stockCounts.insufficient}</strong> | |||
| </Typography> | |||
| {fromJosave && jobOrderPlanning && ( | |||
| <Button | |||
| variant="contained" | |||
| color="error" | |||
| onClick={() => setDeleteConfirmOpen(true)} | |||
| > | |||
| {t("Delete Job Order")} | |||
| </Button> | |||
| )} | |||
| {fromJosave && !jobOrderPlanning && ( | |||
| {fromJosave && ( | |||
| <Button | |||
| variant="contained" | |||
| color="warning" | |||
| onClick={() => setCancelConfirmOpen(true)} | |||
| disabled={isPutAwayed} | |||
| disabled={isPutAwayed || cancelLoading} | |||
| > | |||
| {t("Cancel Job Order")} | |||
| </Button> | |||
| @@ -746,8 +736,8 @@ const handleRelease = useCallback(async ( jobOrderId: number) => { | |||
| variant="contained" | |||
| color="primary" | |||
| onClick={() => handleRelease(jobOrderId)} | |||
| //disabled={stockCounts.insufficient > 0 || processData?.jobOrderStatus !== "planning"} | |||
| disabled={processData?.jobOrderStatus !== "planning"} | |||
| disabled={processData?.jobOrderStatus !== "planning" || isReleasing} | |||
| startIcon={isReleasing ? <CircularProgress size={16} color="inherit" /> : undefined} | |||
| > | |||
| {t("Release")} | |||
| </Button> | |||
| @@ -984,19 +974,6 @@ const handleRelease = useCallback(async ( jobOrderId: number) => { | |||
| </DialogActions> | |||
| </Dialog> | |||
| <Dialog open={deleteConfirmOpen} onClose={() => !deleteLoading && setDeleteConfirmOpen(false)} maxWidth="xs" fullWidth> | |||
| <DialogTitle>{t("Confirm delete job order")}</DialogTitle> | |||
| <DialogContent> | |||
| <Typography variant="body2">{t("Delete job order confirm message")}</Typography> | |||
| </DialogContent> | |||
| <DialogActions> | |||
| <Button onClick={() => setDeleteConfirmOpen(false)} disabled={deleteLoading}>{t("Cancel")}</Button> | |||
| <Button variant="contained" color="error" onClick={() => void handleConfirmDeleteJobOrder()} disabled={deleteLoading}> | |||
| {deleteLoading ? <CircularProgress size={20} /> : t("Delete Job Order")} | |||
| </Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| <Dialog open={cancelConfirmOpen} onClose={() => !cancelLoading && setCancelConfirmOpen(false)} maxWidth="xs" fullWidth> | |||
| <DialogTitle>{t("Confirm cancel job order")}</DialogTitle> | |||
| <DialogContent> | |||
| @@ -8,13 +8,13 @@ import QcStockInModal from "@/components/Qc/QcStockInModal"; | |||
| import ProductionProcessList, { | |||
| createDefaultProductionProcessListPersistedState, | |||
| } from "@/components/ProductionProcess/ProductionProcessList"; | |||
| import ProductionProcessDetail from "@/components/ProductionProcess/ProductionProcessDetail"; | |||
| import ProductionProcessJobOrderDetail from "@/components/ProductionProcess/ProductionProcessJobOrderDetail"; | |||
| import JobPickExecutionsecondscan from "@/components/Jodetail/JobPickExecutionsecondscan"; | |||
| import JobProcessStatus from "@/components/ProductionProcess/JobProcessStatus"; | |||
| import OperatorKpiDashboard from "@/components/ProductionProcess/OperatorKpiDashboard"; | |||
| import EquipmentStatusDashboard from "@/components/ProductionProcess/EquipmentStatusDashboard"; | |||
| import DrinkProductionQtyDashboard from "@/components/ProductionProcess/DrinkProductionQtyDashboard"; | |||
| import JobOrderOpsTable from "@/components/ProductionProcess/JobOrderOpsTable"; | |||
| import type { PrinterCombo } from "@/app/api/settings/printer"; | |||
| import { useTranslation } from "react-i18next"; | |||
| @@ -26,25 +26,18 @@ const STORAGE_KEY = 'productionProcess_selectedMatchingStock'; | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 26 | v1.0.0 | 2026-07-20 */ | |||
| const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCombo }) => { | |||
| const { t } = useTranslation(["common"]); | |||
| const { t } = useTranslation(["common", "productionProcess"]); | |||
| const [selectedProcessId, setSelectedProcessId] = useState<number | null>(null); | |||
| const [selectedMatchingStock, setSelectedMatchingStock] = useState<{ | |||
| jobOrderId: number; | |||
| productProcessId: number; | |||
| pickOrderId: number; | |||
| } | null>(null); | |||
| /** 0 = Production Process list; 1 = JO ops table; 2..5 = dashboards */ | |||
| const [tabIndex, setTabIndex] = useState(0); | |||
| /** 列表搜索/分頁:保留在切換工單詳情時,返回後仍為同一條件 */ | |||
| const [productionListState, setProductionListState] = useState(() => ({ | |||
| ...createDefaultProductionProcessListPersistedState(), | |||
| // date: "", | |||
| })); | |||
| const [waitingPutawayListState, setWaitingPutawayListState] = useState( | |||
| createDefaultProductionProcessListPersistedState, | |||
| ); | |||
| const [putawayedListState, setPutawayedListState] = useState( | |||
| createDefaultProductionProcessListPersistedState, | |||
| ); | |||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | |||
| const sessionToken = session as SessionWithTokens | null; | |||
| const searchParams = useSearchParams(); | |||
| @@ -53,22 +46,18 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||
| const [linkQcOpen, setLinkQcOpen] = useState(false); | |||
| const [linkQcSilId, setLinkQcSilId] = useState<number | null>(null); | |||
| // Add printer selection state | |||
| const [selectedPrinter, setSelectedPrinter] = useState<PrinterCombo | null>( | |||
| printerCombo && printerCombo.length > 0 ? printerCombo[0] : null | |||
| ); | |||
| // 从 sessionStorage 恢复状态(仅在客户端) | |||
| useEffect(() => { | |||
| if (typeof window !== 'undefined') { | |||
| try { | |||
| const saved = sessionStorage.getItem(STORAGE_KEY); | |||
| if (saved) { | |||
| const parsed = JSON.parse(saved); | |||
| // 验证数据有效性 | |||
| if (parsed && typeof parsed.jobOrderId === 'number' && typeof parsed.productProcessId === 'number') { | |||
| setSelectedMatchingStock(parsed); | |||
| console.log(" Restored selectedMatchingStock from sessionStorage:", parsed); | |||
| } | |||
| } | |||
| } catch (error) { | |||
| @@ -78,19 +67,16 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||
| } | |||
| }, []); | |||
| // 保存状态到 sessionStorage | |||
| useEffect(() => { | |||
| if (typeof window !== 'undefined') { | |||
| if (selectedMatchingStock) { | |||
| sessionStorage.setItem(STORAGE_KEY, JSON.stringify(selectedMatchingStock)); | |||
| console.log(" Saved selectedMatchingStock to sessionStorage:", selectedMatchingStock); | |||
| } else { | |||
| sessionStorage.removeItem(STORAGE_KEY); | |||
| } | |||
| } | |||
| }, [selectedMatchingStock]); | |||
| // 处理返回列表时清除存储 | |||
| const handleBackFromSecondScan = useCallback(() => { | |||
| setSelectedMatchingStock(null); | |||
| if (typeof window !== 'undefined') { | |||
| @@ -104,7 +90,7 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||
| const openStockInLineIdQ = searchParams.get("openStockInLineId"); | |||
| /** Deep link from nav alert: /productionProcess?openStockInLineId=… → 「完成QC工單」tab + FG QC modal */ | |||
| /** Deep link: /productionProcess?openStockInLineId=… → list tab pending_qc + FG QC modal */ | |||
| useEffect(() => { | |||
| if (!openStockInLineIdQ) { | |||
| setLinkQcOpen(false); | |||
| @@ -115,7 +101,12 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||
| if (!Number.isFinite(id) || id <= 0) return; | |||
| setSelectedProcessId(null); | |||
| setSelectedMatchingStock(null); | |||
| setTabIndex(1); | |||
| setTabIndex(0); | |||
| setProductionListState((prev) => ({ | |||
| ...prev, | |||
| pickBucket: "pending_qc", | |||
| page: 0, | |||
| })); | |||
| setLinkQcSilId(id); | |||
| setLinkQcOpen(true); | |||
| }, [openStockInLineIdQ]); | |||
| @@ -129,6 +120,9 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||
| router.replace(q ? `${pathname}?${q}` : pathname, { scroll: false }); | |||
| }, [pathname, router, searchParams]); | |||
| const listTab = String(productionListState.pickBucket || "needs_action"); | |||
| const showPrinterBar = tabIndex === 0 && listTab === "pending_qc"; | |||
| if (selectedMatchingStock) { | |||
| return ( | |||
| <JobPickExecutionsecondscan | |||
| @@ -154,8 +148,7 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||
| return ( | |||
| <> | |||
| <Box> | |||
| {/* Header section with printer selection */} | |||
| {tabIndex === 1 && ( | |||
| {showPrinterBar && ( | |||
| <Box sx={{ | |||
| p: 1, | |||
| borderBottom: '1px solid #e0e0e0', | |||
| @@ -205,8 +198,7 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||
| <Tabs value={tabIndex} onChange={handleTabChange} sx={{ mb: 2 }}> | |||
| <Tab label={t("Production Process")} /> | |||
| <Tab label={t("Waiting QC Put Away Job Orders")} /> | |||
| <Tab label={t("Put Awayed Job Orders")} /> | |||
| <Tab label={t("Job Order Ops Table")} /> | |||
| <Tab label={t("Job Process Status Dashboard")} /> | |||
| <Tab label={t("Operator KPI Dashboard")} /> | |||
| <Tab label={t("Production Equipment Status Dashboard")} /> | |||
| @@ -215,9 +207,7 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||
| {tabIndex === 0 && ( | |||
| <ProductionProcessList | |||
| printerCombo={printerCombo} | |||
| qcReady={false} | |||
| disableDateFilter={false} | |||
| printerCombo={selectedPrinter ? [selectedPrinter] : printerCombo} | |||
| listPersistedState={productionListState} | |||
| onListPersistedStateChange={setProductionListState} | |||
| onSelectProcess={(jobOrderId) => { | |||
| @@ -237,62 +227,24 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||
| )} | |||
| {tabIndex === 1 && ( | |||
| <ProductionProcessList | |||
| printerCombo={printerCombo} | |||
| qcReady={true} | |||
| includePutaway={true} | |||
| putawayStatus="notCompleted" | |||
| listPersistedState={waitingPutawayListState} | |||
| onListPersistedStateChange={setWaitingPutawayListState} | |||
| <JobOrderOpsTable | |||
| printerCombo={selectedPrinter ? [selectedPrinter] : printerCombo} | |||
| onSelectProcess={(jobOrderId) => { | |||
| const id = jobOrderId ?? null; | |||
| if (id !== null) { | |||
| setSelectedProcessId(id); | |||
| } | |||
| }} | |||
| onSelectMatchingStock={(jobOrderId, productProcessId, pickOrderId) => { | |||
| setSelectedMatchingStock({ | |||
| jobOrderId: jobOrderId || 0, | |||
| productProcessId: productProcessId || 0, | |||
| pickOrderId: pickOrderId || 0, | |||
| }); | |||
| if (jobOrderId != null) setSelectedProcessId(jobOrderId); | |||
| }} | |||
| /> | |||
| )} | |||
| {tabIndex === 2 && ( | |||
| <ProductionProcessList | |||
| printerCombo={printerCombo} | |||
| qcReady={true} | |||
| includePutaway={true} | |||
| putawayStatus="completed" | |||
| listPersistedState={putawayedListState} | |||
| onListPersistedStateChange={setPutawayedListState} | |||
| onSelectProcess={(jobOrderId) => { | |||
| const id = jobOrderId ?? null; | |||
| if (id !== null) { | |||
| setSelectedProcessId(id); | |||
| } | |||
| }} | |||
| onSelectMatchingStock={(jobOrderId, productProcessId, pickOrderId) => { | |||
| setSelectedMatchingStock({ | |||
| jobOrderId: jobOrderId || 0, | |||
| productProcessId: productProcessId || 0, | |||
| pickOrderId: pickOrderId || 0, | |||
| }); | |||
| }} | |||
| /> | |||
| )} | |||
| {tabIndex === 3 && ( | |||
| <JobProcessStatus /> | |||
| )} | |||
| {tabIndex === 4 && ( | |||
| {tabIndex === 3 && ( | |||
| <OperatorKpiDashboard /> | |||
| )} | |||
| {tabIndex === 5 && ( | |||
| {tabIndex === 4 && ( | |||
| <EquipmentStatusDashboard /> | |||
| )} | |||
| {tabIndex === 6 && ( | |||
| {tabIndex === 5 && ( | |||
| <DrinkProductionQtyDashboard /> | |||
| )} | |||
| </Box> | |||
| @@ -310,4 +262,4 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||
| ); | |||
| }; | |||
| export default ProductionProcessPage; | |||
| export default ProductionProcessPage; | |||
| @@ -1,3 +1,4 @@ | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 39 | v1.0.0 | 2026-08-03 */ | |||
| export type FieldType = 'date' | 'text' | 'select' | 'number' | 'checkbox'; | |||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||
| @@ -224,7 +225,7 @@ export const REPORTS: ReportDefinition[] = [ | |||
| { label: "出貨日期:由 Last Out Date Start", name: "lastOutDateStart", type: "date", required: false }, | |||
| { label: "出貨日期:至 Last Out Date End", name: "lastOutDateEnd", type: "date", required: false }, | |||
| { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false}, | |||
| { label: "提料人 Handler", name: "handler", type: "select", required: false, | |||
| { label: "提料員 Handler", name: "handler", type: "select", required: false, | |||
| multiple: true, | |||
| dynamicOptions: true, | |||
| dynamicOptionsEndpoint: `${NEXT_PUBLIC_API_URL}/report/fg-stock-out-traceability-handlers`, | |||
| @@ -336,4 +337,50 @@ export const REPORTS: ReportDefinition[] = [ | |||
| }, | |||
| ], | |||
| }, | |||
| { | |||
| id: "rep-016", | |||
| title: "成品出倉揀貨合規報告", | |||
| apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-do-user-pick-audit`, | |||
| responseType: "excel", | |||
| fields: [ | |||
| { label: "日期 Date", name: "dateStart", type: "date", required: true }, | |||
| { | |||
| label: "提料人 Handler", | |||
| name: "handler", | |||
| type: "select", | |||
| required: false, | |||
| multiple: true, | |||
| dynamicOptions: true, | |||
| dynamicOptionsEndpoint: `${NEXT_PUBLIC_API_URL}/report/do-user-pick-audit-handlers`, | |||
| options: [], | |||
| }, | |||
| { label: "提票號碼", name: "ticketNo", type: "text", required: false }, | |||
| { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false }, | |||
| { | |||
| label: "樓層", | |||
| name: "storeId", | |||
| type: "select", | |||
| required: false, | |||
| options: [ | |||
| { label: "2F", value: "2F" }, | |||
| { label: "4F", value: "4F" }, | |||
| ], | |||
| }, | |||
| ], | |||
| }, | |||
| { | |||
| id: "rep-017", | |||
| title: "店鋪訂單補貨記錄", | |||
| apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/shop-order-replenishment`, | |||
| responseType: "excel", | |||
| fields: [ | |||
| // { label: "補貨日期:由 Reorder Date Start", name: "reorderDateStart", type: "date", required: false }, | |||
| //{ label: "補貨日期:至 Reorder Date End", name: "reorderDateEnd", type: "date", required: false }, | |||
| { label: "店鋪訂單日期:由 Shop Order Date Start", name: "shopOrderDateStart", type: "date", required: false }, | |||
| { label: "店鋪訂單日期:至 Shop Order Date End", name: "shopOrderDateEnd", type: "date", required: false }, | |||
| //{ label: "送貨日期:由 Delivered Date Start", name: "deliveredDateStart", type: "date", required: false }, | |||
| //{ label: "送貨日期:至 Delivered Date End", name: "deliveredDateEnd", type: "date", required: false }, | |||
| { label: "店鋪編號 Shop Code", name: "shopCode", type: "text", required: false, placeholder: "e.g. S001" }, | |||
| ], | |||
| }, | |||
| ] | |||
| @@ -62,7 +62,7 @@ | |||
| "Not Started": "Not Started", | |||
| "cancelled": "Cancelled", | |||
| "in_progress": "In Progress", | |||
| "pending": "Pending", | |||
| "pending": "Awaiting production", | |||
| "stopped": "Stopped", | |||
| "Invalid Job Order Id": "Invalid Job Order Id", | |||
| "Invalid Stock In Line Id": "Invalid Stock In Line Id", | |||
| @@ -76,6 +76,20 @@ | |||
| "Job Order Info": "Job Order Info", | |||
| "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)": "Picked", | |||
| "Pending (not picked)": "Not picked", | |||
| "Processing (picked)": "Picked", | |||
| "Processing (not picked)": "Not picked", | |||
| "Picked": "Picked", | |||
| "Not picked": "Not picked", | |||
| "Stop (paused)": "Stop (paused)", | |||
| "Cancelled": "Cancelled", | |||
| "Reload data": "Reload data", | |||
| "Rows per page": "Rows per page", | |||
| "Processing...": "Processing...", | |||
| "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", | |||
| @@ -198,6 +212,16 @@ | |||
| "Total Time": "Total Time", | |||
| "Total finished QC job orders": "Total finished QC job orders", | |||
| "Total job orders": "Total job orders", | |||
| "Including carried over": "Including carried over", | |||
| "Carried over from past day": "Carried over from past day", | |||
| "All unfinished": "Needs action", | |||
| "Needs action": "Needs action", | |||
| "Not picked · Not started": "Not picked · Not started", | |||
| "Picked · Not started": "Picked · Not started", | |||
| "Picked · In production": "Picked · In production", | |||
| "Not picked · In production": "Not picked · In production", | |||
| "Powder_Mixture": "Powder Mixture", | |||
| "Processing": "Processing", | |||
| "Total lines: ": "Total lines: ", | |||
| "Type": "Type", | |||
| "Unable to get user ID": "Unable to get user ID", | |||
| @@ -212,6 +236,9 @@ | |||
| "View Details": "View Details", | |||
| "Wait Time": "Wait Time", | |||
| "Waiting QC Put Away Job Orders": "Waiting QC Put Away Job Orders", | |||
| "Waiting QC Put Away": "Waiting QC Put Away", | |||
| "Put Awayed": "Put Awayed", | |||
| "Put Away Detail": "Put Away Detail", | |||
| "all": "All", | |||
| "drink": "Drink", | |||
| "id": "ID", | |||
| @@ -190,7 +190,7 @@ | |||
| "jobOrder": "工單", | |||
| "deliveryOrder": "送貨單", | |||
| "deliveryNoteCode": "送貨單據號 (DN)", | |||
| "ticketNo": "票號", | |||
| "ticketNo": "提票號碼", | |||
| "variance": "差異", | |||
| "before": "帳面數量", | |||
| "after": "核准數量", | |||
| @@ -62,7 +62,7 @@ | |||
| "Not Started": "未開始", | |||
| "cancelled": "已取消", | |||
| "in_progress": "進行中", | |||
| "pending": "待處理", | |||
| "pending": "待生產", | |||
| "stopped": "已停止", | |||
| "Invalid Job Order Id": "無效工單編號", | |||
| "Invalid Stock In Line Id": "無效庫存行ID", | |||
| @@ -76,6 +76,20 @@ | |||
| "Job Order Info": "工單信息", | |||
| "Job Order No.": "工單編號", | |||
| "Job Order and Product": "工單及貨品", | |||
| "Issue": "異常", | |||
| "Job Order Ops Table": "查看工單流程情況", | |||
| "Pending (picked)": "已提料", | |||
| "Pending (not picked)": "未提料", | |||
| "Processing (picked)": "已提料", | |||
| "Processing (not picked)": "未提料", | |||
| "Picked": "已提料", | |||
| "Not picked": "未提料", | |||
| "Stop (paused)": "暫停中", | |||
| "Cancelled": "已取消", | |||
| "Reload data": "重新載入", | |||
| "Rows per page": "每頁列數", | |||
| "Processing...": "處理中...", | |||
| "Actions": "操作", | |||
| "Job Order Production Process": "工單生產流程", | |||
| "Job Process Status Dashboard": "儀表板 - 工單狀態", | |||
| "Drink Production Qty Dashboard": "儀表板 - 飲料生產量數", | |||
| @@ -198,6 +212,16 @@ | |||
| "Total Time": "總時間", | |||
| "Total finished QC job orders": "總完成QC工單數量", | |||
| "Total job orders": "總工單數量", | |||
| "Including carried over": "含過去轉來", | |||
| "Carried over from past day": "過去轉來的工單", | |||
| "All unfinished": "需處理", | |||
| "Needs action": "需處理", | |||
| "Not picked · Not started": "未提料 · 未開工", | |||
| "Picked · Not started": "已提料 · 未開工", | |||
| "Picked · In production": "已提料 · 未完成生產", | |||
| "Not picked · In production": "未提料 · 未完成生產", | |||
| "Powder_Mixture": "箱料粉", | |||
| "Processing": "生產中", | |||
| "Total lines: ": "總數量:", | |||
| "Type": "類型", | |||
| "Unable to get user ID": "無法獲取用戶ID", | |||
| @@ -212,6 +236,9 @@ | |||
| "View Details": "查看詳情", | |||
| "Wait Time": "等待時間", | |||
| "Waiting QC Put Away Job Orders": "待QC上架工單", | |||
| "Waiting QC Put Away": "待QC上架", | |||
| "Put Awayed": "已上架", | |||
| "Put Away Detail": "上架詳情", | |||
| "all": "全部", | |||
| "drink": "飲料", | |||
| "id": "ID", | |||