| @@ -7,7 +7,9 @@ | |||
| "build": "next build", | |||
| "start": "set NODE_OPTIONS=--inspect --max-old-space-size=6144&& next start", | |||
| "lint": "next lint", | |||
| "type-check": "tsc --noEmit" | |||
| "type-check": "tsc --noEmit", | |||
| "test": "vitest run", | |||
| "test:watch": "vitest" | |||
| }, | |||
| "dependencies": { | |||
| "@emotion/cache": "^11.11.0", | |||
| @@ -40,6 +42,7 @@ | |||
| "@tiptap/react": "^2.14.0", | |||
| "@tiptap/starter-kit": "^2.14.0", | |||
| "@unly/universal-language-detector": "^2.0.3", | |||
| "@xyflow/react": "^12.11.2", | |||
| "apexcharts": "^3.45.2", | |||
| "axios": "^1.9.0", | |||
| "dayjs": "^1.11.10", | |||
| @@ -67,7 +70,8 @@ | |||
| "styled-components": "^6.1.8", | |||
| "sweetalert2": "^11.10.3", | |||
| "xlsx": "^0.18.5", | |||
| "xlsx-js-style": "^1.2.0" | |||
| "xlsx-js-style": "^1.2.0", | |||
| "zod": "^3.25.76" | |||
| }, | |||
| "devDependencies": { | |||
| "@types/lodash": "^4.14.202", | |||
| @@ -85,6 +89,7 @@ | |||
| "postcss": "^8.4.33", | |||
| "prettier": "3.1.1", | |||
| "tailwindcss": "^3.4.1", | |||
| "typescript": "^5" | |||
| "typescript": "^5", | |||
| "vitest": "^3.2.4" | |||
| } | |||
| } | |||
| @@ -0,0 +1,44 @@ | |||
| import { I18nProvider, getServerI18n } from "@/i18n"; | |||
| import ItemTracing from "@/components/ItemTracing"; | |||
| import { AUTH } from "@/authorities"; | |||
| import { authOptions } from "@/config/authConfig"; | |||
| import { Stack, Typography } from "@mui/material"; | |||
| import { Metadata } from "next"; | |||
| import { getServerSession } from "next-auth"; | |||
| import { redirect } from "next/navigation"; | |||
| import { Suspense } from "react"; | |||
| import ItemTracingLoading from "@/components/ItemTracing/ItemTracingLoading"; | |||
| export const metadata: Metadata = { | |||
| title: "Item Tracing", | |||
| }; | |||
| const canAccess = (abilities: string[]) => | |||
| abilities.includes(AUTH.ITEM_TRACING); | |||
| const ItemTracingPage: React.FC = async () => { | |||
| const session = await getServerSession(authOptions); | |||
| const abilities = session?.user?.abilities ?? []; | |||
| if (!canAccess(abilities)) { | |||
| redirect("/dashboard"); | |||
| } | |||
| const { t } = await getServerI18n("itemTracing"); | |||
| return ( | |||
| <> | |||
| <Stack direction="row" justifyContent="space-between" flexWrap="wrap" rowGap={2}> | |||
| <Typography variant="h4" marginInlineEnd={2}> | |||
| {t("title")} | |||
| </Typography> | |||
| </Stack> | |||
| <I18nProvider namespaces={["itemTracing", "navigation", "common"]}> | |||
| <Suspense fallback={<ItemTracingLoading />}> | |||
| <ItemTracing /> | |||
| </Suspense> | |||
| </I18nProvider> | |||
| </> | |||
| ); | |||
| }; | |||
| export default ItemTracingPage; | |||
| @@ -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); | |||
| } | |||
| @@ -15,7 +15,7 @@ const ItemPriceSetting: React.FC = async () => { | |||
| <> | |||
| <PageTitleBar title={t("Price Inquiry")} className="mb-4" /> | |||
| <I18nProvider namespaces={["itemPrice","navigation","common","inventory","importExcel"]}> | |||
| <I18nProvider namespaces={["itemPrice","navigation","common","importExcel"]}> | |||
| <Suspense fallback={<ItemPriceSearch.Loading />}> | |||
| <ItemPriceSearch /> | |||
| </Suspense> | |||
| @@ -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" }, | |||
| @@ -242,13 +245,16 @@ export async function fetchWorkbenchReleasedDoPickOrdersForSelection( | |||
| shopName?: string, | |||
| storeId?: string, | |||
| truck?: string, | |||
| releaseType?: string | |||
| releaseType?: string, | |||
| /** Optional `2F`/`4F`: Truck X split by DO supplier preferred floor (storeId stays null). */ | |||
| floor?: string | |||
| ): Promise<ReleasedDoPickOrderListItem[]> { | |||
| const params = new URLSearchParams(); | |||
| if (shopName?.trim()) params.append("shopName", shopName.trim()); | |||
| if (storeId?.trim()) params.append("storeId", storeId.trim()); | |||
| if (truck?.trim()) params.append("truck", truck.trim()); | |||
| if (releaseType?.trim()) params.append("releaseType", releaseType.trim()); | |||
| if (floor?.trim()) params.append("floor", floor.trim().replace("/", "").toUpperCase()); | |||
| const query = params.toString(); | |||
| const url = `${BASE_API_URL}/doPickOrder/workbench/released${query ? `?${query}` : ""}`; | |||
| const response = await serverFetchJson<ReleasedDoPickOrderListItem[]>(url, { method: "GET" }); | |||
| @@ -261,7 +267,9 @@ export async function fetchWorkbenchReleasedDoPickOrdersForSelectionToday( | |||
| storeId?: string, | |||
| truck?: string, | |||
| requiredDeliveryDate?: string, | |||
| releaseType?: string | |||
| releaseType?: string, | |||
| /** Optional `2F`/`4F`: Truck X split by DO supplier preferred floor (storeId stays null). */ | |||
| floor?: string | |||
| ): Promise<ReleasedDoPickOrderListItem[]> { | |||
| const params = new URLSearchParams(); | |||
| if (shopName?.trim()) params.append("shopName", shopName.trim()); | |||
| @@ -269,6 +277,7 @@ export async function fetchWorkbenchReleasedDoPickOrdersForSelectionToday( | |||
| if (truck?.trim()) params.append("truck", truck.trim()); | |||
| if (requiredDeliveryDate?.trim()) params.append("requiredDate", requiredDeliveryDate.trim()); | |||
| if (releaseType?.trim()) params.append("releaseType", releaseType.trim()); | |||
| if (floor?.trim()) params.append("floor", floor.trim().replace("/", "").toUpperCase()); | |||
| const query = params.toString(); | |||
| const url = `${BASE_API_URL}/doPickOrder/workbench/released-today${query ? `?${query}` : ""}`; | |||
| const response = await serverFetchJson<ReleasedDoPickOrderListItem[]>(url, { method: "GET" }); | |||
| @@ -417,6 +426,8 @@ export async function fetchWorkbenchCompletedLotDetails( | |||
| export type WorkbenchScanPayload = { | |||
| itemId: number; | |||
| stockInLineId: number; | |||
| /** POL UomConversion id — when set, sameItemLots only include matching lot UOM */ | |||
| uomId?: number | null; | |||
| }; | |||
| export async function fetchWorkbenchPrinters() { | |||
| return serverFetchJson<any[]>(`${BASE_API_URL}/printers`, { | |||
| @@ -433,9 +444,16 @@ export async function analyzeWorkbenchQrCode(payload: WorkbenchScanPayload) { | |||
| }); | |||
| } | |||
| export async function fetchWorkbenchAvailableLotsByItem(itemId: number) { | |||
| export async function fetchWorkbenchAvailableLotsByItem( | |||
| itemId: number, | |||
| uomId?: number | null, | |||
| ) { | |||
| const qs = | |||
| uomId != null && Number.isFinite(Number(uomId)) && Number(uomId) > 0 | |||
| ? `?uomId=${Number(uomId)}` | |||
| : ""; | |||
| return serverFetchJson<any>( | |||
| `${BASE_API_URL}/inventoryLotLine/workbench/available-lots-by-item/${itemId}`, | |||
| `${BASE_API_URL}/inventoryLotLine/workbench/available-lots-by-item/${itemId}${qs}`, | |||
| { | |||
| method: "GET", | |||
| cache: "no-store", | |||
| @@ -23,6 +23,7 @@ export interface LotLineInfo { | |||
| lotNo: string; | |||
| remainingQty: number; | |||
| uom: string; | |||
| uomId: number; | |||
| } | |||
| export interface SearchInventoryLotLine extends Pageable { | |||
| @@ -160,13 +161,32 @@ async function fetchInventoriesImpl(data: SearchInventory) { | |||
| ); | |||
| } | |||
| async function fetchInventoriesLatestImpl(data: SearchInventory) { | |||
| const queryStr = convertObjToURLSearchParams(data); | |||
| return serverFetchJson<InventoryResultByPage>( | |||
| `${BASE_API_URL}/inventory/searchLatest/getRecordByPage?${queryStr}`, | |||
| { next: { tags: ["inventories"] } }, | |||
| ); | |||
| } | |||
| export const fetchInventories = cache(fetchInventoriesImpl); | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 18 | v1.0.0 | 2026-07-17 | |||
| * Inventory search page: latest inventory row per item (no baseUnit/uomId filter). | |||
| */ | |||
| export const fetchInventoriesLatest = cache(fetchInventoriesLatestImpl); | |||
| /** Bypass React cache() after mutations so lists show fresh qty. */ | |||
| export async function fetchInventoriesFresh(data: SearchInventory) { | |||
| return fetchInventoriesImpl(data); | |||
| } | |||
| /** Bypass React cache() for inventory search page latest-inventory search. */ | |||
| export async function fetchInventoriesLatestFresh(data: SearchInventory) { | |||
| return fetchInventoriesLatestImpl(data); | |||
| } | |||
| async function fetchInventoryLotLinesImpl(data: SearchInventoryLotLine) { | |||
| const queryStr = convertObjToURLSearchParams(data); | |||
| return serverFetchJson<InventoryLotLineResultByPage>( | |||
| @@ -0,0 +1,24 @@ | |||
| "use server"; | |||
| import { BASE_API_URL } from "@/config/api"; | |||
| import { convertObjToURLSearchParams } from "@/app/utils/commonUtil"; | |||
| import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||
| import { ItemLotTraceParams, ItemLotTraceResponse } from "."; | |||
| import { parseItemLotTraceResponse } from "./schema"; | |||
| export const fetchItemLotTrace = async ( | |||
| params: ItemLotTraceParams, | |||
| ): Promise<ItemLotTraceResponse> => { | |||
| const query = convertObjToURLSearchParams(params as Record<string, unknown>); | |||
| const json = await serverFetchJson<unknown>( | |||
| `${BASE_API_URL}/inventoryLotLine/trace?${query}`, | |||
| { method: "GET" }, | |||
| ); | |||
| return parseItemLotTraceResponse(json) as unknown as ItemLotTraceResponse; | |||
| }; | |||
| /** | |||
| * Backend also exposes GET /inventoryLotLine/trace/location/{inventoryLotId} for | |||
| * lazy location blocks. The main /trace payload already includes locationBlocks, | |||
| * so the UI uses that eager path and does not call a separate location fetch. | |||
| */ | |||
| @@ -0,0 +1,496 @@ | |||
| export interface ItemLotTraceLotInfo { | |||
| inventoryLotId: number; | |||
| lotNo: string; | |||
| itemId: number; | |||
| itemCode: string; | |||
| itemName: string; | |||
| expiryDate: string | null; | |||
| productionDate: string | null; | |||
| stockInDate: string | null; | |||
| uom: string; | |||
| primaryStockInLineId: number | null; | |||
| } | |||
| export interface ItemLotTraceWarehouseLine { | |||
| inventoryLotLineId: number; | |||
| warehouseCode: string; | |||
| inQty: number; | |||
| outQty: number; | |||
| issueQty: number; | |||
| status: string; | |||
| availableQty: number; | |||
| } | |||
| export interface ItemLotTraceAlternateLocation { | |||
| inventoryLotId: number; | |||
| stockInLineId: number | null; | |||
| inventoryLotLineId: number; | |||
| warehouseCode: string; | |||
| availableQty: number; | |||
| } | |||
| /** | |||
| * Per-location lifecycle trace block for one alternate inventory_lot | |||
| * sharing the same lotNo+itemId. Contains only location-scoped dimensions. | |||
| * Lot-level dimensions (BOM, JO prelude, production, byproducts, lot relations) | |||
| * are NOT included — they're at the top level of ItemLotTraceResponse. | |||
| */ | |||
| export interface ItemLotTraceLocationBlock { | |||
| inventoryLotId: number; | |||
| lotNo: string; | |||
| itemCode: string; | |||
| itemName?: string; | |||
| uom?: string; | |||
| expiryDate?: string | null; | |||
| productionDate?: string | null; | |||
| stockInDate?: string | null; | |||
| stockInLineId: number | null; | |||
| warehouseLines: ItemLotTraceWarehouseLine[]; | |||
| origins: ItemLotTraceOrigin[]; | |||
| purchaseEvents?: ItemLotTracePurchaseEvent[]; | |||
| qcResults: ItemLotTraceQcResult[]; | |||
| putawayEvents: ItemLotTracePutawayEvent[]; | |||
| movements: ItemLotTraceMovement[]; | |||
| outboundUsage: ItemLotTraceOutboundUsage[]; | |||
| stockTakeEvents: ItemLotTraceStockTakeEvent[]; | |||
| adjustments: ItemLotTraceAdjustment[]; | |||
| transfers: ItemLotTraceTransfer[]; | |||
| openMovements: ItemLotTraceOpenMovement[]; | |||
| failEvents: ItemLotTraceFailEvent[]; | |||
| doDeliveries: ItemLotTraceDoDelivery[]; | |||
| replenishmentEvents?: ItemLotTraceReplenishmentEvent[]; | |||
| returnEvents: ItemLotTraceReturnEvent[]; | |||
| } | |||
| export interface ItemLotTraceReplenishmentEvent { | |||
| replenishmentId: number; | |||
| replenishmentCode: string; | |||
| sourceDoCode: string; | |||
| sourceDoId: number | null; | |||
| shopCode: string; | |||
| shopName: string; | |||
| itemNo: string; | |||
| itemName: string; | |||
| reason: string; | |||
| replenishQty: number; | |||
| handler: string; | |||
| timestamp: string | null; | |||
| pickOrderLineId: number | null; | |||
| stockOutLineId: number | null; | |||
| } | |||
| export interface ItemLotTraceOrigin { | |||
| stockInLineId: number; | |||
| type: string; | |||
| refCode: string; | |||
| refId: number | null; | |||
| supplierCode: string; | |||
| supplierName: string; | |||
| dnNo: string; | |||
| receiptDate: string | null; | |||
| acceptedQty: number; | |||
| status: string; | |||
| } | |||
| export interface ItemLotTracePurchaseEvent { | |||
| purchaseOrderLineId: number; | |||
| purchaseOrderId: number | null; | |||
| purchaseOrderCode: string; | |||
| itemCode: string; | |||
| itemName: string; | |||
| orderQty: number; | |||
| putAwayQty: number; | |||
| purchaseUnit: string; | |||
| supplierCode: string; | |||
| supplierName: string; | |||
| orderDate: string | null; | |||
| status: string; | |||
| } | |||
| export interface ItemLotTraceQcResult { | |||
| stockInLineId: number; | |||
| qcPassed: boolean; | |||
| failQty: number; | |||
| acceptedQty: number; | |||
| remarks: string; | |||
| handledBy: string; | |||
| created: string | null; | |||
| qcItemCode?: string; | |||
| qcItemName?: string; | |||
| qcItemDescription?: string; | |||
| qcType?: string; | |||
| } | |||
| export interface ItemLotTraceMovement { | |||
| direction: string; | |||
| qty: number; | |||
| movementType: string; | |||
| refType: string; | |||
| refCode: string; | |||
| refId: number | null; | |||
| warehouseCode: string; | |||
| handledBy: string; | |||
| timestamp: string | null; | |||
| remarks: string; | |||
| pickConsoCode?: string; | |||
| outboundTicketNo?: string; | |||
| deliveryOrderPickOrderId?: number | null; | |||
| relationshipId?: number | null; | |||
| doOutboundIsExtra?: boolean; | |||
| doOutboundIsReplenish?: boolean; | |||
| doOutboundQtyChanged?: boolean; | |||
| processingStatus?: string; | |||
| matchStatus?: string; | |||
| } | |||
| export interface ItemLotTracePutawayEvent { | |||
| inventoryLotLineId: number; | |||
| stockInLineId: number | null; | |||
| warehouseCode: string; | |||
| qty: number; | |||
| handledBy: string; | |||
| timestamp: string | null; | |||
| refType: string; | |||
| refCode: string; | |||
| refId: number | null; | |||
| status?: string; | |||
| } | |||
| export interface ItemLotTraceOutboundUsage { | |||
| stockOutLineId: number; | |||
| qty: number; | |||
| pickOrderCode: string; | |||
| pickOrderId: number | null; | |||
| consoCode: string; | |||
| jobOrderCode: string; | |||
| jobOrderId: number | null; | |||
| deliveryOrderCode: string; | |||
| handler: string; | |||
| timestamp: string | null; | |||
| usageType: string; | |||
| } | |||
| export interface ItemLotTraceStockTakeRecordDetail { | |||
| stockTakeLineId: number; | |||
| bookQty: number; | |||
| pickerFirstQty?: number | null; | |||
| pickerFirstBadQty?: number | null; | |||
| pickerSecondQty?: number | null; | |||
| pickerSecondBadQty?: number | null; | |||
| approverQty?: number | null; | |||
| approverBadQty?: number | null; | |||
| stockTakerName?: string | null; | |||
| approverName?: string | null; | |||
| stockTakeStartTime?: string | null; | |||
| stockTakeEndTime?: string | null; | |||
| approverTime?: string | null; | |||
| recordStatus?: string | null; | |||
| lastSelect?: number | null; | |||
| varianceQty?: number | null; | |||
| } | |||
| export interface ItemLotTraceStockTakeEvent { | |||
| stockTakeCode: string; | |||
| lotNo?: string; | |||
| itemCode?: string; | |||
| itemName?: string; | |||
| warehouseCode?: string; | |||
| stockTakeSection?: string; | |||
| stockTakeRoundId?: number | null; | |||
| stockTakeRoundName?: string; | |||
| varianceQty: number; | |||
| beforeQty: number; | |||
| afterQty?: number | null; | |||
| approver: string; | |||
| timestamp: string | null; | |||
| recordDetail?: ItemLotTraceStockTakeRecordDetail | null; | |||
| } | |||
| export interface ItemLotTraceAdjustment { | |||
| adjustmentType: string; | |||
| direction: string; | |||
| qty: number; | |||
| reason: string; | |||
| handledBy: string; | |||
| timestamp: string | null; | |||
| refCode: string; | |||
| warehouseCode?: string; | |||
| } | |||
| export interface ItemLotTraceTransfer { | |||
| fromWarehouse: string; | |||
| toWarehouse: string; | |||
| qty: number; | |||
| transferCode: string; | |||
| timestamp: string | null; | |||
| } | |||
| export interface ItemLotTraceBomDownstream { | |||
| jobOrderCode: string; | |||
| jobOrderId: number | null; | |||
| finishedItemCode: string; | |||
| finishedLotNo: string; | |||
| finishedStockInLineId: number | null; | |||
| fgQty: number; | |||
| fgUom?: string; | |||
| materialQtyUsed: number; | |||
| } | |||
| export interface ItemLotTraceBomUpstream { | |||
| jobOrderCode: string; | |||
| jobOrderId: number | null; | |||
| materialItemCode: string; | |||
| materialLotNo: string; | |||
| materialStockInLineId: number | null; | |||
| materialQty: number; | |||
| bomQtyPerUnit: number | null; | |||
| } | |||
| export interface ItemLotTraceBomRecipeLine { | |||
| materialItemCode: string; | |||
| materialItemName: string; | |||
| qtyPerUnit: number; | |||
| uom: string; | |||
| bomProcessId?: number | null; | |||
| bomProcessSeqNo?: number | null; | |||
| assignedStepName?: string; | |||
| } | |||
| export interface ItemLotTraceStepMaterialRef { | |||
| materialItemCode: string; | |||
| materialItemName: string; | |||
| qtyPerUnit: number; | |||
| uom: string; | |||
| } | |||
| export interface ItemLotTraceBomTrace { | |||
| direction: string; | |||
| downstream: ItemLotTraceBomDownstream[]; | |||
| upstream: ItemLotTraceBomUpstream[]; | |||
| bomRecipe: ItemLotTraceBomRecipeLine[]; | |||
| } | |||
| export interface ItemLotTraceJoContext { | |||
| jobOrderId: number; | |||
| jobOrderCode: string; | |||
| planStart: string | null; | |||
| /** Earliest jo_pick_order.created for this JO. */ | |||
| createdAt?: string | null; | |||
| reqQty: number; | |||
| status: string; | |||
| } | |||
| export interface ItemLotTraceJoPickLine { | |||
| pickOrderLineId: number; | |||
| itemCode: string; | |||
| itemName: string; | |||
| requiredQty: number; | |||
| pickedQty: number; | |||
| status: string; | |||
| } | |||
| export interface ItemLotTraceJoPickOrder { | |||
| pickOrderId: number; | |||
| pickOrderCode: string; | |||
| consoCode: string; | |||
| status: string; | |||
| targetDate: string | null; | |||
| completeDate: string | null; | |||
| releasedDate: string | null; | |||
| lines: ItemLotTraceJoPickLine[]; | |||
| } | |||
| export interface ItemLotTraceMaterialInput { | |||
| jobOrderCode: string; | |||
| jobOrderId: number | null; | |||
| materialItemCode: string; | |||
| materialItemName: string; | |||
| materialLotNo: string; | |||
| materialStockInLineId: number | null; | |||
| materialInventoryLotId: number | null; | |||
| materialQty: number; | |||
| materialUom?: string; | |||
| bomQtyPerUnit: number | null; | |||
| bomProcessId?: number | null; | |||
| bomProcessSeqNo?: number | null; | |||
| assignedStepName?: string; | |||
| pickOrderCode: string; | |||
| pickOrderId: number | null; | |||
| consoCode: string; | |||
| pickedAt: string | null; | |||
| processingStatus?: string; | |||
| matchStatus?: string; | |||
| stockInOrigin: ItemLotTraceOrigin | null; | |||
| purchaseEvents?: ItemLotTracePurchaseEvent[]; | |||
| qcResults: ItemLotTraceQcResult[]; | |||
| putawayEvents: ItemLotTracePutawayEvent[]; | |||
| /** When this material was produced by another JO, its upstream pick/prelude chain. */ | |||
| nestedJoPrelude?: ItemLotTraceJoPrelude | null; | |||
| /** Production steps from the JO that produced this material lot. */ | |||
| productionSteps?: ItemLotTraceProductionStep[]; | |||
| } | |||
| export interface ItemLotTraceJoPrelude { | |||
| jobOrder: ItemLotTraceJoContext; | |||
| pickOrders: ItemLotTraceJoPickOrder[]; | |||
| materialInputs: ItemLotTraceMaterialInput[]; | |||
| } | |||
| export interface ItemLotTraceProductionStep { | |||
| processLineId: number; | |||
| stepName: string; | |||
| description: string; | |||
| equipmentCode: string; | |||
| equipmentName: string; | |||
| operatorName: string; | |||
| startTime: string | null; | |||
| endTime: string | null; | |||
| outputQty: number; | |||
| scrapQty: number; | |||
| defectQty: number; | |||
| status: string; | |||
| seqNo: number | null; | |||
| bomProcessId?: number | null; | |||
| stepMaterials?: ItemLotTraceStepMaterialRef[]; | |||
| } | |||
| /** Sibling byproduct lot from the same job order (independent item/lot, traceable). */ | |||
| export interface ItemLotTraceByproductLot { | |||
| inventoryLotId: number | null; | |||
| stockInLineId: number | null; | |||
| lotNo: string; | |||
| itemCode: string; | |||
| itemName: string; | |||
| qty: number; | |||
| uom: string; | |||
| processStepName: string; | |||
| producedAt: string | null; | |||
| jobOrderCode: string; | |||
| jobOrderId: number | null; | |||
| } | |||
| export interface ItemLotTraceOpenMovement { | |||
| stockInLineId: number; | |||
| qty: number; | |||
| warehouseCode: string; | |||
| refCode: string; | |||
| handledBy: string; | |||
| timestamp: string | null; | |||
| remarks: string; | |||
| } | |||
| export interface ItemLotTraceFailEvent { | |||
| failId: number; | |||
| failType: string; | |||
| category: string; | |||
| qty: number; | |||
| handlerName: string; | |||
| recordDate: string | null; | |||
| pickOrderCode: string; | |||
| pickOrderId: number | null; | |||
| } | |||
| export interface ItemLotTraceDoDelivery { | |||
| stockOutLineId: number; | |||
| qty: number; | |||
| pickOrderCode: string; | |||
| pickOrderId: number | null; | |||
| consoCode: string; | |||
| ticketNo?: string; | |||
| deliveryOrderCode: string; | |||
| deliveryOrderId: number | null; | |||
| shopCode?: string; | |||
| shopName?: string; | |||
| handler: string; | |||
| timestamp: string | null; | |||
| releaseType?: string | null; | |||
| deliveryOrderPickOrderId?: number | null; | |||
| relationshipId?: number | null; | |||
| deliveryNoteCode?: string; | |||
| doPickOrderRecordId?: number | null; | |||
| isExtra?: boolean; | |||
| isReplenish?: boolean; | |||
| /** True when pick_order_line.qty ≠ SUM(stock_out_line.qty) for that line (改數). */ | |||
| qtyChanged?: boolean; | |||
| } | |||
| export interface ItemLotTraceReturnEvent { | |||
| stockOutLineId: number; | |||
| qty: number; | |||
| refCode: string; | |||
| movementType: string; | |||
| warehouseCode: string; | |||
| handler: string; | |||
| timestamp: string | null; | |||
| remarks: string; | |||
| } | |||
| export interface ItemLotTraceLotRelation { | |||
| relationType: string; | |||
| inventoryLotId: number | null; | |||
| lotNo: string; | |||
| itemCode: string; | |||
| itemName: string; | |||
| qty: number; | |||
| productLotNo: string; | |||
| timestamp: string | null; | |||
| } | |||
| export interface ItemLotTraceGraphEdge { | |||
| fromKey: string; | |||
| toKey: string; | |||
| kind?: string; | |||
| } | |||
| export interface ItemLotTraceGraphNodeRef { | |||
| key: string; | |||
| kind: string; | |||
| scopeKey?: string | null; | |||
| refCode?: string | null; | |||
| traceLotNo?: string | null; | |||
| traceItemCode?: string | null; | |||
| /** Warehouse code for multi-location visual differentiation. */ | |||
| warehouseCode?: string | null; | |||
| } | |||
| export interface ItemLotTraceGraph { | |||
| nodes: ItemLotTraceGraphNodeRef[]; | |||
| edges: ItemLotTraceGraphEdge[]; | |||
| } | |||
| export interface ItemLotTraceResponse { | |||
| lot: ItemLotTraceLotInfo; | |||
| warehouseLines: ItemLotTraceWarehouseLine[]; | |||
| origins: ItemLotTraceOrigin[]; | |||
| purchaseEvents?: ItemLotTracePurchaseEvent[]; | |||
| qcResults: ItemLotTraceQcResult[]; | |||
| putawayEvents: ItemLotTracePutawayEvent[]; | |||
| movements: ItemLotTraceMovement[]; | |||
| outboundUsage: ItemLotTraceOutboundUsage[]; | |||
| stockTakeEvents: ItemLotTraceStockTakeEvent[]; | |||
| adjustments: ItemLotTraceAdjustment[]; | |||
| transfers: ItemLotTraceTransfer[]; | |||
| bomTrace: ItemLotTraceBomTrace; | |||
| joPrelude: ItemLotTraceJoPrelude | null; | |||
| productionSteps: ItemLotTraceProductionStep[]; | |||
| byproductLots: ItemLotTraceByproductLot[]; | |||
| openMovements: ItemLotTraceOpenMovement[]; | |||
| failEvents: ItemLotTraceFailEvent[]; | |||
| doDeliveries: ItemLotTraceDoDelivery[]; | |||
| replenishmentEvents?: ItemLotTraceReplenishmentEvent[]; | |||
| returnEvents: ItemLotTraceReturnEvent[]; | |||
| lotRelations: ItemLotTraceLotRelation[]; | |||
| alternateLocations: ItemLotTraceAlternateLocation[]; | |||
| /** Per-location full trace blocks for alternate inventory_lot rows sharing same lotNo+itemId. | |||
| * Empty when there is only one location for this lot. */ | |||
| locationBlocks?: ItemLotTraceLocationBlock[]; | |||
| traceGraph?: ItemLotTraceGraph | null; | |||
| } | |||
| export interface ItemLotTraceParams { | |||
| stockInLineId?: number; | |||
| inventoryLotLineId?: number; | |||
| lotNo?: string; | |||
| itemId?: number; | |||
| itemCode?: string; | |||
| } | |||
| @@ -0,0 +1,211 @@ | |||
| import { z } from "zod"; | |||
| const nullableStr = z.string().nullable().optional(); | |||
| const nullableNum = z.number().nullable().optional(); | |||
| const ItemLotTraceOriginSchema = z | |||
| .object({ | |||
| stockInLineId: z.number(), | |||
| type: z.string(), | |||
| refCode: z.string(), | |||
| refId: nullableNum, | |||
| supplierCode: z.string(), | |||
| supplierName: z.string(), | |||
| dnNo: z.string(), | |||
| receiptDate: nullableStr, | |||
| acceptedQty: z.number(), | |||
| status: z.string(), | |||
| }) | |||
| .passthrough(); | |||
| const ItemLotTraceQcResultSchema = z | |||
| .object({ | |||
| stockInLineId: z.number(), | |||
| qcPassed: z.boolean(), | |||
| failQty: z.number(), | |||
| acceptedQty: z.number(), | |||
| remarks: z.string(), | |||
| handledBy: z.string(), | |||
| created: nullableStr, | |||
| }) | |||
| .passthrough(); | |||
| const ItemLotTracePutawayEventSchema = z | |||
| .object({ | |||
| inventoryLotLineId: z.number(), | |||
| stockInLineId: nullableNum, | |||
| warehouseCode: z.string(), | |||
| qty: z.number(), | |||
| handledBy: z.string(), | |||
| timestamp: nullableStr, | |||
| refType: z.string(), | |||
| refCode: z.string(), | |||
| refId: nullableNum, | |||
| status: z.string().optional(), | |||
| }) | |||
| .passthrough(); | |||
| const ItemLotTraceProductionStepSchema = z | |||
| .object({ | |||
| processLineId: z.number(), | |||
| stepName: z.string(), | |||
| description: z.string(), | |||
| equipmentCode: z.string(), | |||
| equipmentName: z.string(), | |||
| operatorName: z.string(), | |||
| startTime: nullableStr, | |||
| endTime: nullableStr, | |||
| outputQty: z.number(), | |||
| scrapQty: z.number(), | |||
| defectQty: z.number(), | |||
| status: z.string(), | |||
| seqNo: nullableNum, | |||
| bomProcessId: nullableNum, | |||
| }) | |||
| .passthrough(); | |||
| const ItemLotTraceMaterialInputSchema: z.ZodType<Record<string, unknown>> = z.lazy(() => | |||
| z | |||
| .object({ | |||
| jobOrderCode: z.string(), | |||
| jobOrderId: nullableNum, | |||
| materialItemCode: z.string(), | |||
| materialItemName: z.string(), | |||
| materialLotNo: z.string(), | |||
| materialStockInLineId: nullableNum, | |||
| materialInventoryLotId: nullableNum, | |||
| materialQty: z.number(), | |||
| materialUom: z.string().optional(), | |||
| bomQtyPerUnit: nullableNum, | |||
| bomProcessId: nullableNum, | |||
| bomProcessSeqNo: nullableNum, | |||
| assignedStepName: z.string().optional(), | |||
| pickOrderCode: z.string(), | |||
| pickOrderId: nullableNum, | |||
| consoCode: z.string(), | |||
| pickedAt: nullableStr, | |||
| stockInOrigin: ItemLotTraceOriginSchema.nullable().optional(), | |||
| purchaseEvents: z.array(z.record(z.unknown())).optional(), | |||
| qcResults: z.array(ItemLotTraceQcResultSchema), | |||
| putawayEvents: z.array(ItemLotTracePutawayEventSchema).optional(), | |||
| nestedJoPrelude: ItemLotTraceJoPreludeSchema.nullable().optional(), | |||
| productionSteps: z.array(ItemLotTraceProductionStepSchema).optional(), | |||
| }) | |||
| .passthrough(), | |||
| ); | |||
| const ItemLotTraceJoPreludeSchema: z.ZodType<Record<string, unknown>> = z.lazy(() => | |||
| z | |||
| .object({ | |||
| jobOrder: z | |||
| .object({ | |||
| jobOrderId: z.number(), | |||
| jobOrderCode: z.string(), | |||
| planStart: nullableStr, | |||
| createdAt: nullableStr.optional(), | |||
| reqQty: z.number(), | |||
| status: z.string(), | |||
| }) | |||
| .passthrough(), | |||
| pickOrders: z.array(z.record(z.unknown())), | |||
| materialInputs: z.array(ItemLotTraceMaterialInputSchema), | |||
| }) | |||
| .passthrough(), | |||
| ); | |||
| const ItemLotTraceGraphEdgeSchema = z | |||
| .object({ | |||
| fromKey: z.string(), | |||
| toKey: z.string(), | |||
| kind: z.string(), | |||
| }) | |||
| .passthrough(); | |||
| const ItemLotTraceGraphNodeRefSchema = z | |||
| .object({ | |||
| key: z.string(), | |||
| kind: z.string(), | |||
| scopeKey: nullableStr, | |||
| refCode: nullableStr, | |||
| traceLotNo: nullableStr, | |||
| traceItemCode: nullableStr, | |||
| warehouseCode: nullableStr, | |||
| }) | |||
| .passthrough(); | |||
| const ItemLotTraceGraphSchema = z | |||
| .object({ | |||
| nodes: z.array(ItemLotTraceGraphNodeRefSchema), | |||
| edges: z.array(ItemLotTraceGraphEdgeSchema), | |||
| }) | |||
| .passthrough() | |||
| .nullable() | |||
| .optional(); | |||
| export const ItemLotTraceResponseSchema = z | |||
| .object({ | |||
| lot: z | |||
| .object({ | |||
| inventoryLotId: z.number(), | |||
| lotNo: z.string(), | |||
| itemId: z.number(), | |||
| itemCode: z.string(), | |||
| itemName: z.string(), | |||
| expiryDate: nullableStr, | |||
| productionDate: nullableStr, | |||
| stockInDate: nullableStr, | |||
| uom: z.string(), | |||
| primaryStockInLineId: nullableNum, | |||
| }) | |||
| .passthrough(), | |||
| warehouseLines: z.array(z.record(z.unknown())), | |||
| origins: z.array(ItemLotTraceOriginSchema), | |||
| purchaseEvents: z.array(z.record(z.unknown())).optional(), | |||
| qcResults: z.array(ItemLotTraceQcResultSchema), | |||
| putawayEvents: z.array(ItemLotTracePutawayEventSchema), | |||
| movements: z.array(z.record(z.unknown())), | |||
| outboundUsage: z.array(z.record(z.unknown())), | |||
| stockTakeEvents: z.array(z.record(z.unknown())), | |||
| adjustments: z.array(z.record(z.unknown())), | |||
| transfers: z.array(z.record(z.unknown())), | |||
| bomTrace: z.record(z.unknown()), | |||
| joPrelude: ItemLotTraceJoPreludeSchema.nullable(), | |||
| productionSteps: z.array(ItemLotTraceProductionStepSchema), | |||
| byproductLots: z.array(z.record(z.unknown())), | |||
| openMovements: z.array(z.record(z.unknown())), | |||
| failEvents: z.array(z.record(z.unknown())), | |||
| doDeliveries: z.array(z.record(z.unknown())), | |||
| replenishmentEvents: z.array(z.record(z.unknown())).optional(), | |||
| returnEvents: z.array(z.record(z.unknown())), | |||
| lotRelations: z.array(z.record(z.unknown())), | |||
| alternateLocations: z.array(z.record(z.unknown())), | |||
| locationBlocks: z.array(z.record(z.unknown())).optional(), | |||
| traceGraph: ItemLotTraceGraphSchema, | |||
| }) | |||
| .passthrough(); | |||
| export type ItemLotTraceResponseParsed = z.infer<typeof ItemLotTraceResponseSchema>; | |||
| export const parseItemLotTraceResponse = (data: unknown): ItemLotTraceResponseParsed => | |||
| ItemLotTraceResponseSchema.parse(data); | |||
| /** Collect dot-path keys for contract snapshot tests. */ | |||
| export const collectObjectPaths = ( | |||
| obj: unknown, | |||
| prefix = "", | |||
| depth = 0, | |||
| maxDepth = 6, | |||
| ): string[] => { | |||
| if (obj == null || depth > maxDepth) return prefix ? [prefix] : []; | |||
| if (Array.isArray(obj)) { | |||
| if (obj.length === 0) return prefix ? [prefix] : []; | |||
| return collectObjectPaths(obj[0], prefix ? `${prefix}[]` : "[]", depth + 1, maxDepth); | |||
| } | |||
| if (typeof obj !== "object") return prefix ? [prefix] : []; | |||
| const paths: string[] = prefix ? [prefix] : []; | |||
| Object.keys(obj as Record<string, unknown>).sort().forEach((key) => { | |||
| const next = prefix ? `${prefix}.${key}` : key; | |||
| paths.push(...collectObjectPaths((obj as Record<string, unknown>)[key], next, depth + 1, maxDepth)); | |||
| }); | |||
| return paths; | |||
| }; | |||
| @@ -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}`); | |||
| @@ -1203,8 +1242,15 @@ export const fetchJobOrderPickOrdersrecords = async ( | |||
| ) => { | |||
| const params = new URLSearchParams(); | |||
| if (date && String(date).trim() !== "") { | |||
| params.set("date", String(date).trim()); | |||
| // Backend expects LocalDate (YYYY-MM-DD); strip any time component. | |||
| const dateOnly = (() => { | |||
| if (!date || String(date).trim() === "") return null; | |||
| const match = String(date).trim().match(/(\d{4}-\d{2}-\d{2})/); | |||
| return match ? match[1] : String(date).trim().slice(0, 10); | |||
| })(); | |||
| if (dateOnly) { | |||
| params.set("date", dateOnly); | |||
| } | |||
| if (status && String(status).trim() !== "" && String(status) !== "All") { | |||
| params.set("status", String(status).trim()); | |||
| @@ -1636,6 +1682,37 @@ export const fetchOperatorKpi = cache(async (date?: string) => { | |||
| }); | |||
| }); | |||
| // ===== Drink Production Qty Dashboard ===== | |||
| export interface DrinkProductionQtyJobOrderDetail { | |||
| jobOrderId: number; | |||
| jobOrderCode?: string | null; | |||
| productionDate?: string | null; | |||
| reqQty: number; | |||
| productionQty: number; | |||
| } | |||
| export interface DrinkProductionQtyResponse { | |||
| itemCode?: string | null; | |||
| itemName?: string | null; | |||
| uom?: string | null; | |||
| totalReqQty: number; | |||
| totalQty: number; | |||
| jobOrders?: DrinkProductionQtyJobOrderDetail[]; | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 26 | v1.0.0 | 2026-07-20 */ | |||
| export const fetchDrinkProductionQty = cache(async (date?: string) => { | |||
| const params = new URLSearchParams(); | |||
| if (date) params.set("date", date); | |||
| const qs = params.toString(); | |||
| const url = `${BASE_API_URL}/product-process/Demo/DrinkProductionQty${qs ? `?${qs}` : ""}`; | |||
| return serverFetchJson<DrinkProductionQtyResponse[]>(url, { | |||
| method: "GET", | |||
| next: { tags: ["drinkProductionQty"] }, | |||
| }); | |||
| }); | |||
| // ===== Equipment Status Dashboard ===== | |||
| export interface EquipmentStatusProcessInfo { | |||
| @@ -95,6 +95,30 @@ export interface ItemWithDetails { | |||
| uomDesc: string; | |||
| currentStockBalance: number; | |||
| } | |||
| export interface PickUomOption { | |||
| uomId: number; | |||
| uomCode: string; | |||
| uomDesc: string; | |||
| } | |||
| export interface AvailablePickUomsResponse { | |||
| itemId: number; | |||
| stockUom: PickUomOption | null; | |||
| options: PickUomOption[]; | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 35 | v1.0.1 | 2026-07-22 */ | |||
| export const fetchAvailablePickUoms = cache(async (itemId: number) => { | |||
| return serverFetchJson<AvailablePickUomsResponse>( | |||
| `${BASE_API_URL}/items/${itemId}/available-pick-uoms`, | |||
| { | |||
| method: "GET", | |||
| next: { tags: ["items"] }, | |||
| }, | |||
| ); | |||
| }); | |||
| export const fetchItemsWithDetails = cache(async (searchParams?: Record<string, any>) => { | |||
| if (searchParams) { | |||
| const queryString = new URLSearchParams(searchParams).toString(); | |||
| @@ -2,6 +2,9 @@ import { SessionWithTokens, authOptions } from "@/config/authConfig"; | |||
| import { getServerSession } from "next-auth"; | |||
| import { headers } from "next/headers"; | |||
| import { redirect } from "next/navigation"; | |||
| import { ServerFetchError } from "./serverFetchError"; | |||
| export { ServerFetchError }; | |||
| export interface BlobResponse { | |||
| filename: string; | |||
| @@ -21,16 +24,6 @@ export interface searchParamsProps { | |||
| searchParams: { [key: string]: string | string[] | undefined }; | |||
| } | |||
| export class ServerFetchError extends Error { | |||
| public readonly response: Response | undefined; | |||
| constructor(message?: string, response?: Response) { | |||
| super(message); | |||
| this.response = response; | |||
| Object.setPrototypeOf(this, ServerFetchError.prototype); | |||
| } | |||
| } | |||
| export async function serverFetchWithNoContent(...args: FetchParams) { | |||
| const response = await serverFetch(...args); | |||
| @@ -0,0 +1,21 @@ | |||
| /** Client-safe error type thrown by server fetch helpers. */ | |||
| export class ServerFetchError extends Error { | |||
| public readonly response: Response | undefined; | |||
| constructor(message?: string, response?: Response) { | |||
| super(message); | |||
| this.response = response; | |||
| Object.setPrototypeOf(this, ServerFetchError.prototype); | |||
| } | |||
| } | |||
| /** Works in client components after server actions (errors may lose prototype). */ | |||
| export const isNotFoundServerFetchError = (e: unknown): boolean => { | |||
| if (e instanceof ServerFetchError) { | |||
| return e.response?.status === 404; | |||
| } | |||
| if (e instanceof Error) { | |||
| return /\b404\b/.test(e.message); | |||
| } | |||
| return false; | |||
| }; | |||
| @@ -9,6 +9,7 @@ export const AUTH = { | |||
| ADMIN: "ADMIN", | |||
| STOCK: "STOCK", | |||
| INVENTORY_ADJUST: "INVENTORY_ADJUST", | |||
| ITEM_TRACING: "ITEM_TRACING", | |||
| PURCHASE: "PURCHASE", | |||
| STOCK_TAKE: "STOCK_TAKE", | |||
| STOCK_IN_BIND: "STOCK_IN_BIND", | |||
| @@ -47,6 +47,7 @@ const pathToLabelKey: { [path: string]: string } = { | |||
| "/scheduling/detailed": "nav.breadcrumb.schedulingDetailed", | |||
| "/scheduling/detailed/edit": "nav.breadcrumb.schedulingDetailedEdit", | |||
| "/inventory": "nav.breadcrumb.inventory", | |||
| "/itemTracing": "nav.breadcrumb.itemTracing", | |||
| "/settings/importTesting": "nav.breadcrumb.importTesting", | |||
| "/settings/m18ImportTesting": "nav.breadcrumb.importTesting", | |||
| "/do": "nav.deliveryOrder", | |||
| @@ -58,6 +58,7 @@ function TabPanel(props: { value: number; index: number; children: React.ReactNo | |||
| return <Box sx={{ pt: 2 }}>{children}</Box>; | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 20 | v1.0.0 | 2026-07-19 */ | |||
| const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCombo = [] }) => { | |||
| const searchParams = useSearchParams(); | |||
| const router = useRouter(); | |||
| @@ -74,6 +75,8 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom | |||
| urlTargetDateRaw && urlTargetDateRaw.trim() !== "" | |||
| ? decodeURIComponent(urlTargetDateRaw.trim()) | |||
| : null; | |||
| /** Item Tracing deep-link only; DO finish redirect must not set this. */ | |||
| const urlOpenDetail = searchParams.get("openDetail") === "1"; | |||
| const [tab, setTab] = React.useState<number>(defaultTabIndex); | |||
| const [lanePanelPrefs, setLanePanelPrefs] = React.useState<WorkbenchLanePanelPrefs>( | |||
| @@ -147,10 +150,11 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom | |||
| setTab(newTab); | |||
| const params = new URLSearchParams(searchParams.toString()); | |||
| params.set("tab", String(newTab)); | |||
| /* ticketNo / targetDate deep-link only for "Finished Good Record" (mine) */ | |||
| if (newTab !== 2) { | |||
| /* ticketNo / targetDate / openDetail deep-link for Finished Good Record tabs */ | |||
| if (newTab !== 2 && newTab !== 3) { | |||
| params.delete("ticketNo"); | |||
| params.delete("targetDate"); | |||
| params.delete("openDetail"); | |||
| } | |||
| const qs = params.toString(); | |||
| router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false }); | |||
| @@ -380,21 +384,26 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom | |||
| </TabPanel> | |||
| <TabPanel value={tab} index={2}> | |||
| <GoodPickExecutionWorkbenchRecord | |||
| key={`workbench-record-mine-${urlTicketNo ?? ""}-${urlTargetDate ?? ""}`} | |||
| key={`workbench-record-mine-${urlTicketNo ?? ""}-${urlTargetDate ?? ""}-${urlOpenDetail ? "1" : "0"}`} | |||
| printerCombo={printerCombo} | |||
| listScope="mine" | |||
| a4Printer={a4Printer} | |||
| labelPrinter={labelPrinter} | |||
| initialTicketNo={urlTicketNo} | |||
| initialTargetDate={urlTargetDate} | |||
| openDetail={urlOpenDetail} | |||
| /> | |||
| </TabPanel> | |||
| <TabPanel value={tab} index={3}> | |||
| <GoodPickExecutionWorkbenchRecord | |||
| key={`workbench-record-all-${urlTicketNo ?? ""}-${urlTargetDate ?? ""}-${urlOpenDetail ? "1" : "0"}`} | |||
| printerCombo={printerCombo} | |||
| listScope="all" | |||
| a4Printer={a4Printer} | |||
| labelPrinter={labelPrinter} | |||
| initialTicketNo={urlTicketNo} | |||
| initialTargetDate={urlTargetDate} | |||
| openDetail={urlOpenDetail} | |||
| /> | |||
| </TabPanel> | |||
| <TabPanel value={tab} index={4}> | |||
| @@ -411,6 +420,7 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom | |||
| ); | |||
| }; | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 20 | v1.0.0 | 2026-07-19 */ | |||
| const DoWorkbenchTabs: React.FC<Props> = (props) => ( | |||
| <Suspense | |||
| fallback={ | |||
| @@ -1,6 +1,6 @@ | |||
| "use client"; | |||
| import React, { useCallback, useEffect, useMemo, useState } from "react"; | |||
| import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |||
| import { | |||
| Accordion, | |||
| AccordionDetails, | |||
| @@ -48,8 +48,11 @@ type Props = { | |||
| labelPrinter: PrinterCombo | null; | |||
| initialTicketNo?: string | null; | |||
| initialTargetDate?: string | null; | |||
| /** When true (Item Tracing `openDetail=1`), auto-open matching ticket detail. */ | |||
| openDetail?: boolean; | |||
| }; | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 20 | v1.0.0 | 2026-07-19 */ | |||
| const GoodPickExecutionWorkbenchRecord: React.FC<Props> = ({ | |||
| printerCombo, | |||
| listScope = "mine", | |||
| @@ -57,6 +60,7 @@ const GoodPickExecutionWorkbenchRecord: React.FC<Props> = ({ | |||
| labelPrinter, | |||
| initialTicketNo, | |||
| initialTargetDate, | |||
| openDetail = false, | |||
| }) => { | |||
| const { t } = useTranslation("pickOrder"); | |||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | |||
| @@ -507,6 +511,17 @@ const GoodPickExecutionWorkbenchRecord: React.FC<Props> = ({ | |||
| [], | |||
| ); | |||
| const initialDetailOpenedRef = useRef(false); | |||
| useEffect(() => { | |||
| if (!openDetail) return; | |||
| if (initialDetailOpenedRef.current || loading || !initialTicketNo?.trim()) return; | |||
| const tn = initialTicketNo.trim(); | |||
| const match = records.find((r) => r.ticketNo?.trim() === tn); | |||
| if (!match) return; | |||
| initialDetailOpenedRef.current = true; | |||
| void handleDetailClick(match); | |||
| }, [openDetail, records, loading, initialTicketNo, handleDetailClick]); | |||
| const handleBackToList = useCallback(() => { | |||
| setShowDetailView(false); | |||
| setSelectedRecord(null); | |||
| @@ -40,6 +40,7 @@ interface Props { | |||
| type LaneSlot4F = { truckDepartureTime: string; lane: LaneBtn }; | |||
| type TruckGroup4F = { truckLanceCode: string; slots: (LaneSlot4F & { sequenceIndex: number })[] }; | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 36 | v1.0.0 | 2026-07-27 */ | |||
| const WorkbenchFloorLanePanel: React.FC<Props> = ({ | |||
| onPickOrderAssigned, | |||
| onSwitchToDetailTab, | |||
| @@ -85,7 +86,13 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ | |||
| const [modalReleaseTypeFilter, setModalReleaseTypeFilter] = useState<string | undefined>(undefined); | |||
| const [modalFilterRequiredDeliveryDate, setModalFilterRequiredDeliveryDate] = useState<string | undefined>(undefined); | |||
| const [modalInitialShopSearch, setModalInitialShopSearch] = useState<string | undefined>(undefined); | |||
| const defaultTruckCount = summary4F?.defaultTruckCount ?? 0; | |||
| const [modalTruckXFloor, setModalTruckXFloor] = useState<string | undefined>(undefined); | |||
| const ticketFloorApiKey = useMemo( | |||
| () => ticketFloor.replace("/", "").trim().toUpperCase(), | |||
| [ticketFloor], | |||
| ); | |||
| const defaultTruckCount = | |||
| (ticketFloor === "2/F" ? summary2F?.defaultTruckCount : summary4F?.defaultTruckCount) ?? 0; | |||
| const etraEnterInFlightRef = useRef(false); | |||
| const [etraMergeDialogOpen, setEtraMergeDialogOpen] = useState(false); | |||
| @@ -108,16 +115,25 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ | |||
| shopName?: string, | |||
| storeId?: string, | |||
| truck?: string, | |||
| releaseType?: string | |||
| ) => fetchWorkbenchReleasedDoPickOrdersForSelection(shopName, storeId, truck, releaseType), | |||
| releaseType?: string, | |||
| floor?: string | |||
| ) => fetchWorkbenchReleasedDoPickOrdersForSelection(shopName, storeId, truck, releaseType, floor), | |||
| loadToday: ( | |||
| shopName?: string, | |||
| storeId?: string, | |||
| truck?: string, | |||
| requiredDeliveryDate?: string, | |||
| releaseType?: string | |||
| releaseType?: string, | |||
| floor?: string | |||
| ) => | |||
| fetchWorkbenchReleasedDoPickOrdersForSelectionToday(shopName, storeId, truck, requiredDeliveryDate, releaseType), | |||
| fetchWorkbenchReleasedDoPickOrdersForSelectionToday( | |||
| shopName, | |||
| storeId, | |||
| truck, | |||
| requiredDeliveryDate, | |||
| releaseType, | |||
| floor | |||
| ), | |||
| assignByListItemId: assignByDeliveryOrderPickOrderId, | |||
| }), | |||
| [], | |||
| @@ -234,7 +250,14 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ | |||
| pendingRef.current += 1; | |||
| startFullTimer(); | |||
| try { | |||
| const list = await fetchWorkbenchReleasedDoPickOrdersForSelection(undefined, undefined, "車線-X"); | |||
| // storeId stays undefined (Truck X); floor splits by DO supplier preferred floor. | |||
| const list = await fetchWorkbenchReleasedDoPickOrdersForSelection( | |||
| undefined, | |||
| undefined, | |||
| "車線-X", | |||
| undefined, | |||
| ticketFloorApiKey | |||
| ); | |||
| setBeforeTodayTruckXCount(list.length); | |||
| } catch { | |||
| setBeforeTodayTruckXCount(0); | |||
| @@ -244,12 +267,13 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ | |||
| } | |||
| }; | |||
| void loadBeforeTodayTruckX(); | |||
| }, [inEtraUi]); | |||
| }, [inEtraUi, ticketFloorApiKey]); | |||
| const clearModalEtraContext = useCallback(() => { | |||
| setModalReleaseTypeFilter(undefined); | |||
| setModalFilterRequiredDeliveryDate(undefined); | |||
| setModalInitialShopSearch(undefined); | |||
| setModalTruckXFloor(undefined); | |||
| }, []); | |||
| const openEnterEtraView = useCallback(async () => { | |||
| @@ -538,6 +562,7 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ | |||
| setSelectedTruck("車線-X"); | |||
| setIsDefaultTruck(true); | |||
| setDefaultDateScope("today"); | |||
| setModalTruckXFloor(ticketFloorApiKey); | |||
| setModalOpen(true); | |||
| }} | |||
| > | |||
| @@ -638,10 +663,11 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ | |||
| variant="outlined" | |||
| onClick={() => { | |||
| clearModalEtraContext(); | |||
| setSelectedStore("4/F"); | |||
| setSelectedStore(""); | |||
| setSelectedTruck("車線-X"); | |||
| setIsDefaultTruck(true); | |||
| setDefaultDateScope("before"); | |||
| setModalTruckXFloor(ticketFloorApiKey); | |||
| setModalOpen(true); | |||
| }} | |||
| > | |||
| @@ -664,11 +690,17 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ | |||
| group.lanes.map((lane, li) => { | |||
| const sid = (lane.storeId ?? "").trim(); | |||
| const dep = (lane.truckDepartureTime ?? "").trim(); | |||
| const is4F = sid.replace(/\//g, "").toUpperCase() === "4F"; | |||
| const isTruckX = | |||
| (lane.truckLanceCode ?? "").trim() === "車線-X" || | |||
| (lane.truckLanceCode ?? "").trim() === "Truck X"; | |||
| const floorKey = sid.replace(/\//g, "").toUpperCase(); | |||
| const is4F = floorKey === "4F"; | |||
| const labelCore = | |||
| is4F && lane.loadingSequence != null | |||
| ? `${t("Loading sequence n", { n: lane.loadingSequence })} (${lane.unassigned}/${lane.total})` | |||
| : `${dep ? `${dep} ` : ""}${lane.truckLanceCode} (${lane.unassigned}/${lane.total})`; | |||
| isTruckX | |||
| ? `${t("車線-X")}${sid ? ` · ${sid}` : ""} (${lane.unassigned}/${lane.total})` | |||
| : is4F && lane.loadingSequence != null | |||
| ? `${t("Loading sequence n", { n: lane.loadingSequence })} (${lane.unassigned}/${lane.total})` | |||
| : `${dep ? `${dep} ` : ""}${lane.truckLanceCode} (${lane.unassigned}/${lane.total})`; | |||
| const handlerName = (lane.handlerName ?? "").trim(); | |||
| const shopCode = (group.shopCode ?? "").trim(); | |||
| const shopName = (group.shopName ?? "").trim(); | |||
| @@ -677,20 +709,29 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ | |||
| ? `${shopCode} · ${shopName}` | |||
| : shopName || shopCode || t("Shop"); | |||
| const laneSecondary = `${labelCore}${handlerName ? ` · ${handlerName}` : ""}`; | |||
| const tileKey = `${shopCode}|${shopName}|${lane.truckLanceCode}|${dep}|${lane.loadingSequence ?? ""}|${li}`; | |||
| const tileKey = `${shopCode}|${shopName}|${lane.truckLanceCode}|${dep}|${lane.loadingSequence ?? ""}|${sid}|${li}`; | |||
| return ( | |||
| <Grid item xs={12} sm={6} md={4} lg={3} key={tileKey}> | |||
| <Button | |||
| fullWidth | |||
| variant="outlined" | |||
| disabled={lane.unassigned === 0 || !sid} | |||
| disabled={lane.unassigned === 0 || (!isTruckX && !sid)} | |||
| onClick={() => { | |||
| setModalReleaseTypeFilter("isExtra"); | |||
| setModalFilterRequiredDeliveryDate(selectedDeliveryDateYmd); | |||
| setModalInitialShopSearch((group.shopName || group.shopCode || "").trim() || undefined); | |||
| setSelectedStore(sid); | |||
| setSelectedTruck(lane.truckLanceCode); | |||
| setIsDefaultTruck(false); | |||
| if (isTruckX) { | |||
| // Truck X: DB storeId stays null; use display floor + default-truck list path. | |||
| setSelectedStore(""); | |||
| setSelectedTruck("車線-X"); | |||
| setIsDefaultTruck(true); | |||
| setModalTruckXFloor(floorKey || undefined); | |||
| } else { | |||
| setSelectedStore(sid); | |||
| setSelectedTruck(lane.truckLanceCode); | |||
| setIsDefaultTruck(false); | |||
| setModalTruckXFloor(undefined); | |||
| } | |||
| setDefaultDateScope("today"); | |||
| setModalOpen(true); | |||
| }} | |||
| @@ -746,6 +787,7 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ | |||
| releaseTypeFilter={modalReleaseTypeFilter} | |||
| filterRequiredDeliveryDate={modalFilterRequiredDeliveryDate} | |||
| initialShopSearch={modalInitialShopSearch} | |||
| truckXFloor={modalTruckXFloor} | |||
| onClose={() => { | |||
| setModalOpen(false); | |||
| clearModalEtraContext(); | |||
| @@ -551,6 +551,7 @@ function saveIssuePickedMap(doPickOrderId: number, map: Record<number, number>) | |||
| } | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 29 | v1.0.2 | 2026-08-03 */ | |||
| const WorkbenchGoodPickExecutionDetail: React.FC<Props> = ({ | |||
| filterArgs, | |||
| onSwitchToRecordTab, | |||
| @@ -1076,6 +1077,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||
| itemId: line.item.id, | |||
| itemCode: line.item.code, | |||
| itemName: line.item.name, | |||
| uomId: line.item.uomId ?? null, | |||
| uomDesc: line.item.uomDesc, | |||
| uomShortDesc: line.item.uomShortDesc, | |||
| @@ -1147,6 +1149,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||
| itemId: line.item.id, | |||
| itemCode: line.item.code, | |||
| itemName: line.item.name, | |||
| uomId: line.item.uomId ?? null, | |||
| uomDesc: line.item.uomDesc, | |||
| uomShortDesc: line.item.uomShortDesc, | |||
| @@ -1220,13 +1223,21 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||
| }, [fetchAllCombinedLotData]); | |||
| const openWorkbenchLotLabelModalForLot = useCallback( | |||
| (lot: any, reminderText?: string | null) => { | |||
| ( | |||
| lot: any, | |||
| reminderText?: string | null, | |||
| options?: { markAsScanIssue?: boolean }, | |||
| ) => { | |||
| const itemId = Number(lot?.itemId); | |||
| const stockInLineId = Number(lot?.stockInLineId); | |||
| const solId = Number(lot?.stockOutLineId); | |||
| if (!Number.isFinite(itemId) || itemId <= 0 || !Number.isFinite(solId) || solId <= 0) { | |||
| return; | |||
| } | |||
| // 掃碼 issue 才標記:手動「批號二維碼」不傳 markAsScanIssue,批號不變紅 | |||
| if (options?.markAsScanIssue) { | |||
| rememberWorkbenchScanReject(solId, reminderText); | |||
| } | |||
| setWorkbenchLotLabelContextLot(lot); | |||
| if (Number.isFinite(stockInLineId) && stockInLineId > 0) { | |||
| setWorkbenchLotLabelInitialPayload({ itemId, stockInLineId }); | |||
| @@ -1238,7 +1249,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||
| setQrScanSuccess(false); | |||
| setWorkbenchLotLabelModalOpen(true); | |||
| }, | |||
| [], | |||
| [rememberWorkbenchScanReject], | |||
| ); | |||
| const shouldOpenWorkbenchLotLabelModalForFailure = useCallback( | |||
| @@ -1688,14 +1699,91 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||
| }); | |||
| return; | |||
| } | |||
| // ✅ OPTIMIZATION: Use cached active lots directly (no filtering needed) | |||
| const lookupStartTime = performance.now(); | |||
| const activeSuggestedLots = indexes.activeLotsByItemId.get(scannedItemId) || []; | |||
| let activeSuggestedLots = indexes.activeLotsByItemId.get(scannedItemId) || []; | |||
| // ✅ Also get all lots for this item (not just active ones) to allow lot switching even when all lots are rejected | |||
| const allLotsForItem = indexes.byItemId.get(scannedItemId) || []; | |||
| let allLotsForItem = indexes.byItemId.get(scannedItemId) || []; | |||
| const lookupTime = performance.now() - lookupStartTime; | |||
| console.log(` [PERF] Index lookup time: ${lookupTime.toFixed(2)}ms, found ${activeSuggestedLots.length} active lots, ${allLotsForItem.length} total lots`); | |||
| // Case A: reject only if scanned lot UOM matches none of this item's POL UOMs | |||
| // (same item may have multiple lines with different UOMs on one pick order) | |||
| let scannedUomId = 0; | |||
| const allowedUomIds = new Set( | |||
| allLotsForItem | |||
| .map((l: any) => Number(l?.uomId)) | |||
| .filter((id: number) => Number.isFinite(id) && id > 0), | |||
| ); | |||
| const hasLineUoms = allowedUomIds.size > 0; | |||
| try { | |||
| const lotDetail = await fetchLotDetail(scannedStockInLineId); | |||
| scannedUomId = Number(lotDetail?.uomId) || 0; | |||
| } catch (e) { | |||
| if (hasLineUoms) { | |||
| console.warn( | |||
| "[QR PROCESS] lot-detail UOM check failed; rejecting scan", | |||
| e, | |||
| ); | |||
| startTransition(() => { | |||
| setQrScanError(true); | |||
| setQrScanSuccess(false); | |||
| setQrScanErrorMsg( | |||
| t( | |||
| "This lot UOM does not match the pick line. Please scan another lot.", | |||
| ), | |||
| ); | |||
| }); | |||
| lastProcessedQrRef.current = ""; | |||
| processedQrCodesRef.current.delete(latestQr); | |||
| return; | |||
| } | |||
| } | |||
| if ( | |||
| hasLineUoms && | |||
| (!(scannedUomId > 0) || !allowedUomIds.has(scannedUomId)) | |||
| ) { | |||
| console.warn( | |||
| ` [QR PROCESS] UOM mismatch: allowed=[${Array.from(allowedUomIds).join(",")}], scannedLot=${scannedUomId}, stockInLineId=${scannedStockInLineId}`, | |||
| ); | |||
| startTransition(() => { | |||
| setQrScanError(true); | |||
| setQrScanSuccess(false); | |||
| setQrScanErrorMsg( | |||
| t( | |||
| "This lot UOM does not match the pick line. Please scan another lot.", | |||
| ), | |||
| ); | |||
| }); | |||
| lastProcessedQrRef.current = ""; | |||
| processedQrCodesRef.current.delete(latestQr); | |||
| return; | |||
| } | |||
| // Strict UOM filter — never fall back to other UOM lines | |||
| if (scannedUomId > 0 && hasLineUoms) { | |||
| allLotsForItem = allLotsForItem.filter( | |||
| (l: any) => Number(l?.uomId) === scannedUomId, | |||
| ); | |||
| activeSuggestedLots = activeSuggestedLots.filter( | |||
| (l: any) => Number(l?.uomId) === scannedUomId, | |||
| ); | |||
| if (allLotsForItem.length === 0) { | |||
| startTransition(() => { | |||
| setQrScanError(true); | |||
| setQrScanSuccess(false); | |||
| setQrScanErrorMsg( | |||
| t( | |||
| "This lot UOM does not match the pick line. Please scan another lot.", | |||
| ), | |||
| ); | |||
| }); | |||
| lastProcessedQrRef.current = ""; | |||
| processedQrCodesRef.current.delete(latestQr); | |||
| return; | |||
| } | |||
| } | |||
| // ✅ Check if scanned lot is rejected BEFORE checking activeSuggestedLots | |||
| // This allows users to scan other lots even when all suggested lots are rejected | |||
| @@ -1746,6 +1834,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||
| openWorkbenchLotLabelModalForLot( | |||
| scannedLot, | |||
| t("This lot is not available, please scan another lot."), | |||
| { markAsScanIssue: true }, | |||
| ); | |||
| return; | |||
| } | |||
| @@ -1761,6 +1850,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||
| openWorkbenchLotLabelModalForLot( | |||
| scannedLot, | |||
| `Lot is expired (expiry=${scannedLot.expiryDate || "-"})`, | |||
| { markAsScanIssue: true }, | |||
| ); | |||
| return; | |||
| } | |||
| @@ -1883,7 +1973,9 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||
| shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) && | |||
| expectedLot | |||
| ) { | |||
| openWorkbenchLotLabelModalForLot(expectedLot, failMsg); | |||
| openWorkbenchLotLabelModalForLot(expectedLot, failMsg, { | |||
| markAsScanIssue: true, | |||
| }); | |||
| return; | |||
| } | |||
| if (workbenchMode && expectedLot.stockOutLineId != null) { | |||
| @@ -2055,7 +2147,9 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||
| shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) && | |||
| expectedLot | |||
| ) { | |||
| openWorkbenchLotLabelModalForLot(expectedLot, failMsg); | |||
| openWorkbenchLotLabelModalForLot(expectedLot, failMsg, { | |||
| markAsScanIssue: true, | |||
| }); | |||
| return; | |||
| } | |||
| if (workbenchMode && expectedLot.stockOutLineId != null) { | |||
| @@ -2250,7 +2344,9 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||
| shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) && | |||
| exactMatch | |||
| ) { | |||
| openWorkbenchLotLabelModalForLot(exactMatch, failMsg); | |||
| openWorkbenchLotLabelModalForLot(exactMatch, failMsg, { | |||
| markAsScanIssue: true, | |||
| }); | |||
| return; | |||
| } | |||
| if (workbenchMode && exactMatch.stockOutLineId != null) { | |||
| @@ -2394,7 +2490,9 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||
| shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) && | |||
| expectedLot | |||
| ) { | |||
| openWorkbenchLotLabelModalForLot(expectedLot, failMsg); | |||
| openWorkbenchLotLabelModalForLot(expectedLot, failMsg, { | |||
| markAsScanIssue: true, | |||
| }); | |||
| return; | |||
| } | |||
| if (workbenchMode && expectedLot.stockOutLineId != null) { | |||
| @@ -2646,7 +2744,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||
| const qrDetectionStartTime = performance.now(); | |||
| console.log(` [QR DETECTION] Latest QR detected: ${latestQr?.substring(0, 50)}...`); | |||
| console.log(` [QR DETECTION] Detection time: ${new Date().toISOString()}`); | |||
| //console.log(` [QR DETECTION] Detection time: ${new Date().toISOString()}`); | |||
| console.log(` [QR DETECTION] Time since QR scanner set value: ${(qrDetectionStartTime - qrValuesChangeStartTime).toFixed(2)}ms`); | |||
| const qrPayload = parseWorkbenchScanQrPayload(latestQr); | |||
| @@ -3176,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, | |||
| }); | |||
| @@ -3457,7 +3556,7 @@ const handleStartScan = useCallback(() => { | |||
| resetScan(); | |||
| lastConsumedQrValuesLengthRef.current = 0; | |||
| }, [stopScan, resetScan]); | |||
| // ... existing code around line 1469 ... | |||
| const handlelotnull = useCallback(async (lot: any) => { | |||
| // 优先使用 stockouts 中的 id,如果没有则使用 stockOutLineId | |||
| const stockOutLineId = lot.stockOutLineId; | |||
| @@ -4074,10 +4173,21 @@ const handleSubmitAllScanned = useCallback(async () => { | |||
| <TableCell>{t("Item Code")}</TableCell> | |||
| <TableCell>{t("Item Name")}</TableCell> | |||
| <TableCell>{t("Route")}</TableCell> | |||
| <TableCell>{t("Suggest Lot No.")}</TableCell> | |||
| <TableCell align="right">{t("Lot Required Pick Qty")}</TableCell> | |||
| <TableCell align="center">{t("Scan Result")}</TableCell> | |||
| <TableCell | |||
| align="center" | |||
| sx={{ minWidth: 220, whiteSpace: "nowrap" }} | |||
| > | |||
| {`${t("Route")} / ${t("Suggest Lot No.")}`} | |||
| </TableCell> | |||
| <TableCell align="right" sx={{ whiteSpace: "nowrap", width: 88, px: 1 }}> | |||
| {t("Lot Required Pick Qty")} | |||
| </TableCell> | |||
| <TableCell | |||
| align="center" | |||
| sx={{ width: 88, minWidth: 88, px: 0.5, whiteSpace: "nowrap" }} | |||
| > | |||
| {t("Scan Result")} | |||
| </TableCell> | |||
| {/*<TableCell align="center">{t("Qty will submit")}</TableCell>*/} | |||
| <TableCell align="center">{t("Submit Required Pick Qty")}</TableCell> | |||
| </TableRow> | |||
| @@ -4085,7 +4195,7 @@ const handleSubmitAllScanned = useCallback(async () => { | |||
| <TableBody> | |||
| {paginatedData.length === 0 ? ( | |||
| <TableRow> | |||
| <TableCell colSpan={11} align="center"> | |||
| <TableCell colSpan={7} align="center"> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("No data available")} | |||
| </Typography> | |||
| @@ -4106,7 +4216,7 @@ paginatedData.map((row, index) => { | |||
| ? Number(fromPickRow) | |||
| : lockedSubmitQtyDisplay; | |||
| // 检查是否是 issue lot | |||
| const isIssueLot = lot.stockOutLineStatus === 'rejected' || !lot.lotNo; | |||
| const rejectDisplay = buildLotRejectDisplayMessage(lot, scanRejectMessageBySolId, t); | |||
| const solSt = String(lot.stockOutLineStatus || "").toLowerCase(); | |||
| @@ -4131,45 +4241,64 @@ paginatedData.map((row, index) => { | |||
| }} | |||
| > | |||
| <TableCell> | |||
| <Typography variant="body2" fontWeight="bold"> | |||
| <Typography variant="body1" fontWeight="bold"> | |||
| {row.isGroupFirst ? row.groupDisplayIndex : ""} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell>{row.isGroupFirst ? lot.itemCode : ""}</TableCell> | |||
| <TableCell> | |||
| {row.isGroupFirst ? lot.itemName + '(' + lot.stockUnit + ')' : ""} | |||
| <Typography variant="body1"> | |||
| {row.isGroupFirst ? lot.itemCode : ""} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell> | |||
| <Typography variant="body2"> | |||
| {lot.routerRoute || '-'} | |||
| <TableCell> | |||
| <Typography variant="body1"> | |||
| {row.isGroupFirst ? lot.itemName + '(' + lot.stockUnit + ')' : ""} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell> | |||
| <Stack direction="row" spacing={1} alignItems="flex-start"> | |||
| <Box sx={{ flex: 1, minWidth: 0 }}> | |||
| <TableCell align="center" sx={{ minWidth: 220 }}> | |||
| <Stack | |||
| direction="row" | |||
| spacing={1} | |||
| alignItems="center" | |||
| justifyContent="center" | |||
| sx={{ width: "100%" }} | |||
| > | |||
| <Box sx={{ textAlign: "center" }}> | |||
| <Typography variant="body2" sx={{ whiteSpace: "nowrap", textAlign: "center" }}> | |||
| {lot.routerRoute || "-"} | |||
| </Typography> | |||
| {(() => { | |||
| const hasLotNo = Boolean(lot.lotNo); | |||
| const isExpired = lot.lotAvailability === 'expired'; | |||
| const isUnavailable = isInventoryLotLineUnavailable(lot); | |||
| const isNoLotHint = !hasLotNo && !rejectDisplay; // 顯示「請檢查周圍…」那種 | |||
| const isIssueText = | |||
| Boolean(rejectDisplay) || !hasLotNo || isExpired || isUnavailable; | |||
| const textColor = | |||
| isNoLotHint | |||
| ? 'error.main' // 或 'text.primary':固定黑,不受 handled / unavailable 影響 | |||
| : rejectDisplay || isSolRejected || isUnavailable | |||
| ? 'error.main' | |||
| : isExpired | |||
| ? 'warning.main' | |||
| : 'inherit'; | |||
| const solStatus = String(lot.stockOutLineStatus ?? "").toLowerCase(); | |||
| const isComplete = | |||
| solStatus === "completed" || | |||
| solStatus === "checked" || | |||
| solStatus === "partially_completed" || | |||
| solStatus === "partially_complete"; | |||
| const textColor = | |||
| isNoLotHint | |||
| ? "error.main" | |||
| : rejectDisplay || isSolRejected || isUnavailable | |||
| ? "error.main" | |||
| : isExpired | |||
| ? "warning.main" | |||
| : isComplete | |||
| ? "success.main" | |||
| : "inherit"; | |||
| const lotOnly = | |||
| hasLotNo && !rejectDisplay && !isExpired && !isUnavailable; | |||
| return ( | |||
| <Typography | |||
| variant={isIssueText ? "body2" : "body1"} | |||
| variant="body2" | |||
| sx={{ | |||
| color: textColor, | |||
| whiteSpace: 'pre-wrap', | |||
| wordBreak: 'break-word', | |||
| whiteSpace: lotOnly ? "nowrap" : "pre-wrap", | |||
| wordBreak: lotOnly ? "normal" : "break-word", | |||
| textAlign: "center", | |||
| }} | |||
| > | |||
| {hasLotNo ? ( | |||
| @@ -4213,7 +4342,6 @@ paginatedData.map((row, index) => { | |||
| sx={{ | |||
| flexShrink: 0, | |||
| fontSize: "0.75rem", | |||
| py: 0.25, | |||
| minWidth: "auto", | |||
| px: 1, | |||
| whiteSpace: "nowrap", | |||
| @@ -4224,23 +4352,34 @@ paginatedData.map((row, index) => { | |||
| )} | |||
| </Stack> | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| {(() => { | |||
| const requiredQty = lot.requiredQty || 0; | |||
| return requiredQty.toLocaleString() + '(' + lot.uomShortDesc + ')'; | |||
| })()} | |||
| <TableCell align="right" sx={{ whiteSpace: "nowrap", width: 88, px: 1 }}> | |||
| <Typography variant="body1"> | |||
| {(() => { | |||
| const requiredQty = lot.requiredQty || 0; | |||
| return requiredQty.toLocaleString() + '(' + lot.uomShortDesc + ')'; | |||
| })()} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell align="center"> | |||
| <TableCell align="center" sx={{ width: 88, minWidth: 88, px: 0.5 }}> | |||
| {(() => { | |||
| const status = lot.stockOutLineStatus?.toLowerCase(); | |||
| const isRejected = status === 'rejected' || lot.lotAvailability === 'rejected'; | |||
| const isNoLot = !lot.lotNo; | |||
| const scanResultSlotSx = { | |||
| width: 42, | |||
| height: 42, | |||
| display: 'flex', | |||
| justifyContent: 'center', | |||
| alignItems: 'center', | |||
| mx: 'auto', | |||
| } as const; | |||
| // rejected lot:显示红色勾选(已扫描但被拒绝) | |||
| if (isRejected && !isNoLot) { | |||
| return ( | |||
| <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}> | |||
| <Box sx={scanResultSlotSx}> | |||
| <Checkbox | |||
| checked={true} | |||
| disabled={true} | |||
| @@ -4264,13 +4403,13 @@ paginatedData.map((row, index) => { | |||
| status !== "partially_completed" && | |||
| status !== "partially_complete" | |||
| ) { | |||
| return null; | |||
| return <Box sx={scanResultSlotSx} />; | |||
| } | |||
| // 正常 lot:已扫描(checked/partially_completed/completed) | |||
| if (!isNoLot && status !== 'pending' && status !== 'rejected') { | |||
| return ( | |||
| <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}> | |||
| <Box sx={scanResultSlotSx}> | |||
| <Checkbox | |||
| checked={true} | |||
| disabled={true} | |||
| @@ -4289,7 +4428,7 @@ paginatedData.map((row, index) => { | |||
| // noLot 且已完成/部分完成:显示红色勾选 | |||
| if (isNoLot && (status === 'partially_completed' || status === 'completed')) { | |||
| return ( | |||
| <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}> | |||
| <Box sx={scanResultSlotSx}> | |||
| <Checkbox | |||
| checked={true} | |||
| disabled={true} | |||
| @@ -4305,7 +4444,7 @@ paginatedData.map((row, index) => { | |||
| ); | |||
| } | |||
| return null; | |||
| return <Box sx={scanResultSlotSx} />; | |||
| })()} | |||
| </TableCell> | |||
| {/* | |||
| @@ -4391,11 +4530,10 @@ paginatedData.map((row, index) => { | |||
| <Stack direction="row" spacing={1} alignItems="center" justifyContent="center"> | |||
| {isRowPicked ? ( | |||
| <Typography | |||
| variant="body2" | |||
| variant="body1" | |||
| sx={{ | |||
| width: 96, | |||
| textAlign: "center", | |||
| fontSize: "1rem", | |||
| fontWeight: 500, | |||
| }} | |||
| > | |||
| @@ -4426,7 +4564,16 @@ paginatedData.map((row, index) => { | |||
| inputProps={{ min: 0, step: 1 }} | |||
| sx={{ | |||
| width: 96, | |||
| "& .MuiInputBase-input": { fontSize: "0.75rem", py: 0.5, textAlign: "center" }, | |||
| "& .MuiInputBase-input": { | |||
| typography: "body1", | |||
| py: 0.5, | |||
| textAlign: "center", | |||
| "&::-webkit-outer-spin-button, &::-webkit-inner-spin-button": { | |||
| WebkitAppearance: "none", | |||
| margin: 0, | |||
| }, | |||
| MozAppearance: "textfield", | |||
| }, | |||
| }} | |||
| /> | |||
| )} | |||
| @@ -4471,7 +4618,14 @@ paginatedData.map((row, index) => { | |||
| } | |||
| sx={{ fontSize: '0.7rem', py: 0.5, minHeight: '28px', minWidth: '60px' }} | |||
| sx={{ | |||
| fontSize: "0.7rem", | |||
| py: 0.5, | |||
| minHeight: "28px", | |||
| minWidth: "72px", | |||
| whiteSpace: "nowrap", | |||
| flexShrink: 0, | |||
| }} | |||
| > | |||
| {t("Just Completed")} | |||
| </Button> | |||
| @@ -4556,6 +4710,12 @@ paginatedData.map((row, index) => { | |||
| ).trim() || null | |||
| : null | |||
| } | |||
| expectedUomId={ | |||
| workbenchLotLabelContextLot != null && | |||
| Number(workbenchLotLabelContextLot.uomId) > 0 | |||
| ? Number(workbenchLotLabelContextLot.uomId) | |||
| : null | |||
| } | |||
| disableScanPick={workbenchLotLabelScanPickDisabled} | |||
| onWorkbenchScanPick={handleWorkbenchLotLabelScanPick} | |||
| submitQty={workbenchLotLabelSubmitQty} | |||
| @@ -63,6 +63,8 @@ type QrCodeAnalysisResponse = { | |||
| inventoryLotLineId: number; | |||
| warehouseCode?: string | null; | |||
| warehouseName?: string | null; | |||
| uom?: string | null; | |||
| uomId?: number | null; | |||
| } | null; | |||
| sameItemLots: Array<{ | |||
| lotNo: string; | |||
| @@ -70,6 +72,7 @@ type QrCodeAnalysisResponse = { | |||
| stockInLineId?: number | null; | |||
| availableQty: number; | |||
| uom: string; | |||
| uomId?: number | null; | |||
| warehouseCode?: string | null; | |||
| warehouseName?: string | null; | |||
| }>; | |||
| @@ -95,6 +98,8 @@ export interface WorkbenchLotLabelPrintModalProps { | |||
| /** 提貨台表格列上的可用量/單位(API 的 sameItemLots 不含掃描行,需補上才能顯示「目前這筆」) */ | |||
| triggerLotAvailableQty?: number | null; | |||
| triggerLotUom?: string | null; | |||
| /** POL UomConversion id — workbench lot list only returns matching UOM */ | |||
| expectedUomId?: number | null; | |||
| /** 此出庫行已掃碼/已完成時為 true,停用所有「掃碼提貨」(仍可列印標籤) */ | |||
| disableScanPick?: boolean; | |||
| /** | |||
| @@ -141,6 +146,7 @@ function isLabelPrinter(p: Printer): boolean { | |||
| return s.includes("label") && !s.includes("a4"); | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 30 | v1.0.1 | 2026-07-22 */ | |||
| const WorkbenchLotLabelPrintModal: React.FC<WorkbenchLotLabelPrintModalProps> = ({ | |||
| open, | |||
| onClose, | |||
| @@ -155,6 +161,7 @@ const WorkbenchLotLabelPrintModal: React.FC<WorkbenchLotLabelPrintModalProps> = | |||
| hideTriggeredLot = false, | |||
| triggerLotAvailableQty = null, | |||
| triggerLotUom = null, | |||
| expectedUomId = null, | |||
| disableScanPick = false, | |||
| onWorkbenchScanPick, | |||
| submitQty = null, | |||
| @@ -257,13 +264,22 @@ const WorkbenchLotLabelPrintModal: React.FC<WorkbenchLotLabelPrintModalProps> = | |||
| if (id != null) setSelectedPrinterId(id); | |||
| }, [open, printers, selectedPrinterId, pickDefaultPrinterId]); | |||
| const resolveExpectedUomId = useCallback((): number | null => { | |||
| const n = Number(expectedUomId); | |||
| return Number.isFinite(n) && n > 0 ? n : null; | |||
| }, [expectedUomId]); | |||
| const analyzePayload = useCallback( | |||
| async (payload: ScanPayload) => { | |||
| setLastPayload(payload); | |||
| setScanError(null); | |||
| setAnalysisLoading(true); | |||
| try { | |||
| const data = (await analyzeWorkbenchQrCode(payload)) as QrCodeAnalysisResponse; | |||
| const uomId = resolveExpectedUomId(); | |||
| const data = (await analyzeWorkbenchQrCode({ | |||
| ...payload, | |||
| ...(uomId != null ? { uomId } : {}), | |||
| })) as QrCodeAnalysisResponse; | |||
| setAnalysis(data); | |||
| setSnackbar({ | |||
| open: true, | |||
| @@ -277,7 +293,7 @@ const WorkbenchLotLabelPrintModal: React.FC<WorkbenchLotLabelPrintModalProps> = | |||
| setAnalysisLoading(false); | |||
| } | |||
| }, | |||
| [], | |||
| [resolveExpectedUomId], | |||
| ); | |||
| const analyzeByItem = useCallback( | |||
| @@ -290,7 +306,11 @@ const WorkbenchLotLabelPrintModal: React.FC<WorkbenchLotLabelPrintModalProps> = | |||
| setScanError(null); | |||
| setAnalysisLoading(true); | |||
| try { | |||
| const data = (await fetchWorkbenchAvailableLotsByItem(itemId)) as { | |||
| const uomId = resolveExpectedUomId(); | |||
| const data = (await fetchWorkbenchAvailableLotsByItem( | |||
| itemId, | |||
| uomId, | |||
| )) as { | |||
| itemId: number; | |||
| itemCode: string; | |||
| itemName: string; | |||
| @@ -315,7 +335,7 @@ const WorkbenchLotLabelPrintModal: React.FC<WorkbenchLotLabelPrintModalProps> = | |||
| setAnalysisLoading(false); | |||
| } | |||
| }, | |||
| [], | |||
| [resolveExpectedUomId], | |||
| ); | |||
| const handleAnalyze = useCallback(async () => { | |||
| @@ -381,30 +401,51 @@ const WorkbenchLotLabelPrintModal: React.FC<WorkbenchLotLabelPrintModalProps> = | |||
| Number.isFinite(tableQty) && tableQty >= 0 ? tableQty : 0; | |||
| const fromApi = Number(scannedRow?.availableQty ?? 0); | |||
| const scanned = analysis.scanned; | |||
| const scannedLot = scannedLotLineId | |||
| ? { | |||
| lotNo: scanned?.lotNo ?? "", | |||
| inventoryLotLineId: scannedLotLineId, | |||
| stockInLineId: Number(scanned?.stockInLineId ?? 0) || null, | |||
| availableQty: Math.max(fromApi, fromTable) as number, | |||
| uom: (scannedRow?.uom ?? triggerLotUom ?? "") as string, | |||
| warehouseCode: | |||
| scanned?.warehouseCode ?? scannedRow?.warehouseCode, | |||
| warehouseName: | |||
| scanned?.warehouseName ?? scannedRow?.warehouseName, | |||
| _scanned: true as const, | |||
| } | |||
| : null; | |||
| const expectUom = Number(expectedUomId); | |||
| const hasExpectUom = Number.isFinite(expectUom) && expectUom > 0; | |||
| const scannedUomId = Number(scanned?.uomId ?? scannedRow?.uomId ?? 0); | |||
| const scannedUomOk = | |||
| !hasExpectUom || | |||
| !Number.isFinite(scannedUomId) || | |||
| scannedUomId <= 0 || | |||
| scannedUomId === expectUom; | |||
| const scannedLot = | |||
| scannedLotLineId && scannedUomOk | |||
| ? { | |||
| lotNo: scanned?.lotNo ?? "", | |||
| inventoryLotLineId: scannedLotLineId, | |||
| stockInLineId: Number(scanned?.stockInLineId ?? 0) || null, | |||
| availableQty: Math.max(fromApi, fromTable) as number, | |||
| uom: (scanned?.uom ?? scannedRow?.uom ?? triggerLotUom ?? "") as string, | |||
| uomId: scannedUomId > 0 ? scannedUomId : null, | |||
| warehouseCode: | |||
| scanned?.warehouseCode ?? scannedRow?.warehouseCode, | |||
| warehouseName: | |||
| scanned?.warehouseName ?? scannedRow?.warehouseName, | |||
| _scanned: true as const, | |||
| } | |||
| : null; | |||
| const merged = [ | |||
| ...(!hideTriggeredLot && scannedLot ? [scannedLot] : []), | |||
| ...list | |||
| .filter((x) => x.inventoryLotLineId !== scannedLotLineId) | |||
| .filter((x) => { | |||
| if (!hasExpectUom) return true; | |||
| const id = Number(x.uomId); | |||
| return !Number.isFinite(id) || id <= 0 || id === expectUom; | |||
| }) | |||
| .map((x) => ({ ...x, _scanned: false as const })), | |||
| ]; | |||
| return merged; | |||
| }, [analysis, hideTriggeredLot, triggerLotAvailableQty, triggerLotUom]); | |||
| }, [ | |||
| analysis, | |||
| hideTriggeredLot, | |||
| triggerLotAvailableQty, | |||
| triggerLotUom, | |||
| expectedUomId, | |||
| ]); | |||
| const filteredLots = useMemo(() => { | |||
| const prefix = String(warehouseCodePrefixFilter ?? "").trim(); | |||
| @@ -5,13 +5,16 @@ import { | |||
| Box, | |||
| Card, | |||
| CardContent, | |||
| Checkbox, | |||
| Chip, | |||
| CircularProgress, | |||
| FormControl, | |||
| InputLabel, | |||
| ListItemText, | |||
| MenuItem, | |||
| Paper, | |||
| Select, | |||
| SelectChangeEvent, | |||
| Stack, | |||
| Table, | |||
| TableBody, | |||
| @@ -65,6 +68,14 @@ function showDoPickOpsButtons(row: WorkbenchTicketReleaseTable): boolean { | |||
| ); | |||
| } | |||
| const HANDLER_UNASSIGNED = "__UNASSIGNED__"; | |||
| const HANDLER_SELECT_ALL = "__ALL__"; | |||
| function handlerFilterKey(handlerName: string | null | undefined): string { | |||
| const name = handlerName?.trim() ?? ""; | |||
| return name || HANDLER_UNASSIGNED; | |||
| } | |||
| const WorkbenchTicketReleaseTableTab: React.FC = () => { | |||
| const { t } = useTranslation("ticketReleaseTable"); | |||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | |||
| @@ -73,6 +84,7 @@ const WorkbenchTicketReleaseTableTab: React.FC = () => { | |||
| const [queryDate, setQueryDate] = useState<Dayjs>(() => dayjs()); | |||
| const [selectedFloor, setSelectedFloor] = useState<string>(""); | |||
| const [selectedStatus, setSelectedStatus] = useState<string>("released"); | |||
| const [selectedHandlers, setSelectedHandlers] = useState<string[]>([]); | |||
| const [data, setData] = useState<WorkbenchTicketReleaseTable[]>([]); | |||
| const [loading, setLoading] = useState<boolean>(true); | |||
| const [now, setNow] = useState(dayjs()); | |||
| @@ -111,6 +123,28 @@ const WorkbenchTicketReleaseTableTab: React.FC = () => { | |||
| }, []); | |||
| const dayStr = queryDate.format("YYYY-MM-DD"); | |||
| const handlerOptions = useMemo(() => { | |||
| const names = new Set<string>(); | |||
| let hasUnassigned = false; | |||
| for (const item of data) { | |||
| if (selectedFloor && item.storeId !== selectedFloor) continue; | |||
| const name = item.handlerName?.trim(); | |||
| if (name) names.add(name); | |||
| else hasUnassigned = true; | |||
| } | |||
| const sorted = Array.from(names).sort((a, b) => a.localeCompare(b, "zh-Hant")); | |||
| return hasUnassigned ? [HANDLER_UNASSIGNED, ...sorted] : sorted; | |||
| }, [data, selectedFloor]); | |||
| useEffect(() => { | |||
| setSelectedHandlers((prev) => { | |||
| if (prev.length === 0) return prev; | |||
| const next = prev.filter((name) => handlerOptions.includes(name)); | |||
| return next.length === prev.length ? prev : next; | |||
| }); | |||
| }, [handlerOptions]); | |||
| const filteredData = useMemo(() => { | |||
| return data.filter((item) => { | |||
| if (selectedFloor && item.storeId !== selectedFloor) return false; | |||
| @@ -119,9 +153,27 @@ const WorkbenchTicketReleaseTableTab: React.FC = () => { | |||
| if (itemDate !== dayStr) return false; | |||
| } | |||
| if (selectedStatus && item.ticketStatus?.toLowerCase() !== selectedStatus.toLowerCase()) return false; | |||
| if (selectedHandlers.length > 0 && !selectedHandlers.includes(handlerFilterKey(item.handlerName))) { | |||
| return false; | |||
| } | |||
| return true; | |||
| }); | |||
| }, [data, dayStr, selectedFloor, selectedStatus]); | |||
| }, [data, dayStr, selectedFloor, selectedStatus, selectedHandlers]); | |||
| const allHandlersSelected = | |||
| handlerOptions.length > 0 && selectedHandlers.length === handlerOptions.length; | |||
| const handleHandlersChange = (event: SelectChangeEvent<string[]>) => { | |||
| const value = event.target.value; | |||
| const next = typeof value === "string" ? value.split(",") : value; | |||
| if (next.includes(HANDLER_SELECT_ALL)) { | |||
| setSelectedHandlers(allHandlersSelected ? [] : handlerOptions); | |||
| setPaginationController((prev) => ({ ...prev, pageNum: 0 })); | |||
| return; | |||
| } | |||
| setSelectedHandlers(next.filter((v) => v !== HANDLER_SELECT_ALL)); | |||
| setPaginationController((prev) => ({ ...prev, pageNum: 0 })); | |||
| }; | |||
| const paginatedData = useMemo(() => { | |||
| const startIndex = paginationController.pageNum * paginationController.pageSize; | |||
| @@ -247,6 +299,39 @@ const WorkbenchTicketReleaseTableTab: React.FC = () => { | |||
| <MenuItem value="completed">{t("completed")}</MenuItem> | |||
| </Select> | |||
| </FormControl> | |||
| <FormControl sx={{ minWidth: 200 }} size="small"> | |||
| <InputLabel id="workbench-handler-select-label" shrink> | |||
| {t("Handler Name")} | |||
| </InputLabel> | |||
| <Select | |||
| labelId="workbench-handler-select-label" | |||
| multiple | |||
| value={selectedHandlers} | |||
| label={t("Handler Name")} | |||
| onChange={handleHandlersChange} | |||
| displayEmpty | |||
| renderValue={(selected) => { | |||
| if (selected.length === 0) return t("All Handlers"); | |||
| return selected | |||
| .map((v) => (v === HANDLER_UNASSIGNED ? t("Unassigned") : v)) | |||
| .join(", "); | |||
| }} | |||
| > | |||
| <MenuItem value={HANDLER_SELECT_ALL}> | |||
| <Checkbox | |||
| checked={allHandlersSelected} | |||
| indeterminate={selectedHandlers.length > 0 && !allHandlersSelected} | |||
| /> | |||
| <ListItemText primary={t("Select All")} /> | |||
| </MenuItem> | |||
| {handlerOptions.map((name) => ( | |||
| <MenuItem key={name} value={name}> | |||
| <Checkbox checked={selectedHandlers.includes(name)} /> | |||
| <ListItemText primary={name === HANDLER_UNASSIGNED ? t("Unassigned") : name} /> | |||
| </MenuItem> | |||
| ))} | |||
| </Select> | |||
| </FormControl> | |||
| <Box sx={{ flexGrow: 1 }} /> | |||
| <Stack direction="row" spacing={2} sx={{ flexShrink: 0, alignSelf: "center" }}> | |||
| <Typography variant="body2" sx={{ color: "text.secondary" }} suppressHydrationWarning> | |||
| @@ -36,7 +36,9 @@ export type ReleasedDoPickListBridge = { | |||
| shopName?: string, | |||
| storeId?: string, | |||
| truck?: string, | |||
| releaseType?: string | |||
| releaseType?: string, | |||
| /** Optional `2F`/`4F` for Truck X supplier-floor split */ | |||
| floor?: string | |||
| ) => Promise<ReleasedDoPickOrderListItem[]>; | |||
| /** Optional 4th arg: workbench `requiredDeliveryDate` (YYYY-MM-DD) for default truck list; omit = calendar today. */ | |||
| loadToday: ( | |||
| @@ -44,7 +46,9 @@ export type ReleasedDoPickListBridge = { | |||
| storeId?: string, | |||
| truck?: string, | |||
| requiredDeliveryDate?: string, | |||
| releaseType?: string | |||
| releaseType?: string, | |||
| /** Optional `2F`/`4F` for Truck X supplier-floor split */ | |||
| floor?: string | |||
| ) => Promise<ReleasedDoPickOrderListItem[]>; | |||
| assignByListItemId: (userId: number, id: number) => Promise<PostPickOrderResponse>; | |||
| }; | |||
| @@ -70,8 +74,13 @@ interface Props { | |||
| * requiredDate instead of historical released (delivery date before calendar today). | |||
| */ | |||
| filterRequiredDeliveryDate?: string; | |||
| /** | |||
| * Truck X only: `2F`/`4F` (or `2/F`/`4/F`) — filter by DO supplier preferred floor; storeId stays null. | |||
| */ | |||
| truckXFloor?: string; | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 36 | v1.0.0 | 2026-07-27 */ | |||
| const ReleasedDoPickOrderSelectModal: React.FC<Props> = ({ | |||
| open, | |||
| onClose, | |||
| @@ -85,6 +94,7 @@ const ReleasedDoPickOrderSelectModal: React.FC<Props> = ({ | |||
| releaseTypeFilter, | |||
| initialShopSearch, | |||
| filterRequiredDeliveryDate, | |||
| truckXFloor, | |||
| }) => { | |||
| const { t } = useTranslation("pickOrder"); | |||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | |||
| @@ -102,21 +112,25 @@ const ReleasedDoPickOrderSelectModal: React.FC<Props> = ({ | |||
| const loadReleased = listBridge?.loadBeforeToday ?? fetchReleasedDoPickOrdersForSelection; | |||
| const loadTodayFn = listBridge?.loadToday ?? fetchReleasedDoPickOrdersForSelectionToday; | |||
| const floorArg = truckXFloor?.trim() || undefined; | |||
| const shopArg = shopSearch.trim() || undefined; | |||
| if (isDefaultTruck) { | |||
| if (defaultDateScopeProp === "today") { | |||
| data = await loadTodayFn( | |||
| undefined, | |||
| shopArg, | |||
| undefined, | |||
| "車線-X", | |||
| defaultTruckRequiredDeliveryDate?.trim() || undefined, | |||
| releaseTypeFilter?.trim() || undefined | |||
| releaseTypeFilter?.trim() || undefined, | |||
| floorArg | |||
| ); | |||
| } else { | |||
| data = await loadReleased( | |||
| undefined, | |||
| shopArg, | |||
| undefined, | |||
| "車線-X", | |||
| releaseTypeFilter?.trim() || undefined | |||
| releaseTypeFilter?.trim() || undefined, | |||
| floorArg | |||
| ); | |||
| } | |||
| } else if (filterRequiredDeliveryDate?.trim() && listBridge?.loadToday) { | |||
| @@ -143,7 +157,7 @@ const ReleasedDoPickOrderSelectModal: React.FC<Props> = ({ | |||
| } finally { | |||
| setLoading(false); | |||
| } | |||
| }, [open, shopSearch, storeId, truck, isDefaultTruck, defaultDateScopeProp, listBridge, defaultTruckRequiredDeliveryDate, releaseTypeFilter, filterRequiredDeliveryDate]); | |||
| }, [open, shopSearch, storeId, truck, isDefaultTruck, defaultDateScopeProp, listBridge, defaultTruckRequiredDeliveryDate, releaseTypeFilter, filterRequiredDeliveryDate, truckXFloor]); | |||
| useEffect(() => { | |||
| loadList(); | |||
| @@ -12,7 +12,7 @@ import { | |||
| analyzeQrCode, | |||
| SearchInventory, | |||
| SearchInventoryLotLine, | |||
| fetchInventories, | |||
| fetchInventoriesLatest, | |||
| fetchInventoryLotLines, | |||
| } from '@/app/api/inventory/actions'; | |||
| import { PrinterCombo } from '@/app/api/settings/printer'; | |||
| @@ -56,6 +56,7 @@ type SearchQuery = Partial< | |||
| >; | |||
| type SearchParamNames = keyof SearchQuery; | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 18 | v1.0.0 | 2026-07-17 */ | |||
| const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||
| const { t } = useTranslation(['inventory', 'common', 'item']); | |||
| @@ -171,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 ?? '', | |||
| @@ -187,7 +188,7 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||
| pageSize: pagingController.pageSize, | |||
| }; | |||
| const response = await fetchInventories(params); | |||
| const response = await fetchInventoriesLatest(params); | |||
| if (response) { | |||
| setInventoriesTotalCount(response.total); | |||
| @@ -199,7 +200,7 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||
| break; | |||
| case 'paging': | |||
| setFilteredInventories((fi) => | |||
| uniqBy([...fi, ...response.records], 'id'), | |||
| uniqBy([...fi, ...response.records], 'itemId'), | |||
| ); | |||
| } | |||
| } | |||
| @@ -414,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); | |||
| @@ -24,7 +24,7 @@ type ItemPriceSearchComponent = React.FC & { | |||
| type SearchParamNames = keyof SearchQuery; | |||
| const ItemPriceSearch: ItemPriceSearchComponent = () => { | |||
| const { t } = useTranslation(["itemPrice", "inventory", "common", "importExcel"]); | |||
| const { t } = useTranslation(["itemPrice", "common", "importExcel"]); | |||
| const [item, setItem] = useState<ItemsResult | null>(null); | |||
| const [isSearching, setIsSearching] = useState(false); | |||
| @@ -275,7 +275,7 @@ const ItemPriceSearch: ItemPriceSearchComponent = () => { | |||
| <Card variant="outlined" sx={{ borderRadius: 2, flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}> | |||
| <CardContent sx={{ py: 2, flex: 1, minHeight: 0, "&:last-child": { pb: 2 }, display: "flex", flexDirection: "column", alignItems: "flex-start", justifyContent: "flex-start" }}> | |||
| <Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1, flexShrink: 0 }}> | |||
| {t("Average unit price", { ns: "inventory" })} | |||
| {t("Average unit price")} | |||
| </Typography> | |||
| <Typography variant="h5" sx={{ width: "100%", flex: 1, display: "flex", alignItems: "center", justifyContent: "center", textAlign: "center", fontWeight: 500, color: "black", minHeight: 0 }}> | |||
| {avgPrice != null && avgPrice !== 0 | |||
| @@ -292,7 +292,7 @@ const ItemPriceSearch: ItemPriceSearchComponent = () => { | |||
| <Card variant="outlined" sx={{ borderRadius: 2, flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}> | |||
| <CardContent sx={{ py: 2, flex: 1, minHeight: 0, "&:last-child": { pb: 2 }, display: "flex", flexDirection: "column", alignItems: "flex-start", justifyContent: "flex-start" }}> | |||
| <Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1, flexShrink: 0 }}> | |||
| {t("Latest market unit price", { ns: "inventory" })} | |||
| {t("Latest market unit price")} | |||
| </Typography> | |||
| <Typography variant="h5" sx={{ width: "100%", textAlign: "center", fontWeight: 500, color: "black", flex: 1, minHeight: 0, display: "flex", alignItems: "center", justifyContent: "center" }}> | |||
| {item.latestMarketUnitPrice != null && Number(item.latestMarketUnitPrice) !== 0 | |||
| @@ -0,0 +1,151 @@ | |||
| "use client"; | |||
| import { Alert, Box, CircularProgress, Stack, Typography } from "@mui/material"; | |||
| import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |||
| import { useSearchParams } from "next/navigation"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { fetchItemLotTrace } from "@/app/api/itemTracing/actions"; | |||
| import { ItemLotTraceResponse } from "@/app/api/itemTracing"; | |||
| import { isNotFoundServerFetchError } from "@/app/utils/serverFetchError"; | |||
| import ItemTracingScanBar from "./ItemTracingScanBar"; | |||
| import ItemTracingSummary from "./ItemTracingSummary"; | |||
| import ItemTracingFlowGraph from "./ItemTracingFlowGraph"; | |||
| import ItemTracingSections from "./ItemTracingSections"; | |||
| import { | |||
| compileTraceGraph, | |||
| type CompiledTraceGraph, | |||
| } from "./compileTraceGraph"; | |||
| import { buildTraceGraphLabels } from "./traceGraphLabels"; | |||
| import { exportItemLotTraceXlsx } from "./exportItemLotTraceXlsx"; | |||
| export type WarehouseFocusRequest = { | |||
| inventoryLotId: number; | |||
| warehouseCode: string; | |||
| }; | |||
| const ItemTracing: React.FC = () => { | |||
| const { t } = useTranslation("itemTracing"); | |||
| const searchParams = useSearchParams(); | |||
| const initialTraceRan = useRef(false); | |||
| const [data, setData] = useState<ItemLotTraceResponse | null>(null); | |||
| const [loading, setLoading] = useState(false); | |||
| const [error, setError] = useState<string | null>(null); | |||
| const [focusWarehouse, setFocusWarehouse] = | |||
| useState<WarehouseFocusRequest | null>(null); | |||
| const runTrace = useCallback( | |||
| async (params: { | |||
| stockInLineId?: number; | |||
| inventoryLotLineId?: number; | |||
| lotNo?: string; | |||
| itemCode?: string; | |||
| }) => { | |||
| setLoading(true); | |||
| setError(null); | |||
| try { | |||
| const res = await fetchItemLotTrace(params); | |||
| setData(res); | |||
| setFocusWarehouse(null); | |||
| } catch (e) { | |||
| console.error(e); | |||
| if (isNotFoundServerFetchError(e)) { | |||
| setError(t("notFound")); | |||
| } else { | |||
| setError(t("traceError")); | |||
| } | |||
| setData(null); | |||
| } finally { | |||
| setLoading(false); | |||
| } | |||
| }, | |||
| [t], | |||
| ); | |||
| useEffect(() => { | |||
| if (initialTraceRan.current) return; | |||
| const stockInLineIdRaw = searchParams.get("stockInLineId"); | |||
| const lotNo = searchParams.get("lotNo")?.trim(); | |||
| const itemCode = searchParams.get("itemCode")?.trim(); | |||
| const stockInLineId = stockInLineIdRaw | |||
| ? Number(stockInLineIdRaw) | |||
| : undefined; | |||
| const hasTraceParams = | |||
| (stockInLineId != null && !Number.isNaN(stockInLineId)) || | |||
| Boolean(lotNo) || | |||
| Boolean(itemCode); | |||
| if (!hasTraceParams) return; | |||
| initialTraceRan.current = true; | |||
| void runTrace({ | |||
| stockInLineId: | |||
| stockInLineId != null && !Number.isNaN(stockInLineId) | |||
| ? stockInLineId | |||
| : undefined, | |||
| lotNo: lotNo || undefined, | |||
| itemCode: itemCode || undefined, | |||
| }); | |||
| }, [runTrace, searchParams]); | |||
| const compiledGraph = useMemo((): CompiledTraceGraph | null => { | |||
| if (!data) return null; | |||
| const labels = buildTraceGraphLabels(t, data.lot.uom); | |||
| return compileTraceGraph(data, labels); | |||
| }, [data, t]); | |||
| const handleExportExcel = useCallback(() => { | |||
| if (!data || !compiledGraph) return; | |||
| exportItemLotTraceXlsx(data, compiledGraph, t); | |||
| }, [data, compiledGraph, t]); | |||
| return ( | |||
| <Stack spacing={3} sx={{ mt: 2 }}> | |||
| <Typography variant="body1" color="text.secondary"> | |||
| {t("subtitle")} | |||
| </Typography> | |||
| <ItemTracingScanBar | |||
| onTrace={runTrace} | |||
| loading={loading} | |||
| lastLotNo={data?.lot.lotNo} | |||
| /> | |||
| {loading && ( | |||
| <Box sx={{ display: "flex", justifyContent: "center", py: 4 }}> | |||
| <CircularProgress /> | |||
| </Box> | |||
| )} | |||
| {error && !loading && <Alert severity="error">{error}</Alert>} | |||
| {!loading && !data && !error && ( | |||
| <Alert severity="info">{t("noResult")}</Alert> | |||
| )} | |||
| {data && !loading && ( | |||
| <> | |||
| <ItemTracingSummary | |||
| data={data} | |||
| onFocusWarehouse={(request) => setFocusWarehouse(request)} | |||
| onExportExcel={compiledGraph ? handleExportExcel : undefined} | |||
| /> | |||
| {compiledGraph && ( | |||
| <> | |||
| <ItemTracingFlowGraph | |||
| data={data} | |||
| compiledGraph={compiledGraph} | |||
| onTrace={runTrace} | |||
| focusWarehouse={focusWarehouse} | |||
| /> | |||
| <ItemTracingSections | |||
| data={data} | |||
| compiledGraph={compiledGraph} | |||
| onTrace={runTrace} | |||
| /> | |||
| </> | |||
| )} | |||
| </> | |||
| )} | |||
| </Stack> | |||
| ); | |||
| }; | |||
| export default ItemTracing; | |||
| @@ -0,0 +1,94 @@ | |||
| "use client"; | |||
| import Link from "next/link"; | |||
| import MuiLink from "@mui/material/Link"; | |||
| import { isWorkbenchTicketNo, normalizeTargetDateForLink } from "./traceDocLinkUtils"; | |||
| type DocLinkProps = { | |||
| kind: "jo" | "po" | "pick" | "workbench" | "jodetail"; | |||
| code: string; | |||
| id?: number | null; | |||
| consoCode?: string; | |||
| ticketNo?: string; | |||
| targetDate?: string; | |||
| /** Finished Good Record tab on /doworkbench (default: 3 = all). */ | |||
| workbenchTab?: number; | |||
| /** Completed JO pick record tab on /jodetail (default: 1 = complete records). */ | |||
| jodetailTab?: number; | |||
| /** Open in a new browser tab (used for links inside the flow graph). */ | |||
| openInNewTab?: boolean; | |||
| }; | |||
| const stopGraphEvent = (e: React.MouseEvent) => { | |||
| e.stopPropagation(); | |||
| }; | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 16 | v1.0.1 | 2026-07-19 */ | |||
| const ItemTracingDocLink: React.FC<DocLinkProps> = ({ | |||
| kind, | |||
| code, | |||
| id, | |||
| consoCode, | |||
| ticketNo, | |||
| targetDate, | |||
| workbenchTab = 3, | |||
| jodetailTab = 1, | |||
| openInNewTab = false, | |||
| }) => { | |||
| if (!code?.trim()) return <>—</>; | |||
| const linkTargetDate = normalizeTargetDateForLink(targetDate); | |||
| let href = "#"; | |||
| if (kind === "jo" && id) href = `/jo/edit?id=${id}`; | |||
| else if (kind === "po" && id) href = `/po/edit?id=${id}`; | |||
| else if (kind === "workbench" && ticketNo?.trim()) { | |||
| const params = new URLSearchParams(); | |||
| params.set("tab", String(workbenchTab)); | |||
| params.set("ticketNo", ticketNo.trim()); | |||
| if (linkTargetDate) params.set("targetDate", linkTargetDate); | |||
| // Only Item Tracing deep-links should auto-open detail (not DO finish redirect). | |||
| params.set("openDetail", "1"); | |||
| href = `/doworkbench?${params.toString()}`; | |||
| } else if (kind === "jodetail" && (consoCode || code)) { | |||
| const params = new URLSearchParams(); | |||
| params.set("tab", String(jodetailTab)); | |||
| // Prefer PI-* pick order code for jodetail search; consoCode may be PICK-*. | |||
| params.set("pickOrderCode", (code || consoCode || "").trim()); | |||
| if (linkTargetDate) params.set("targetDate", linkTargetDate); | |||
| params.set("openDetail", "1"); | |||
| href = `/jodetail?${params.toString()}`; | |||
| } else if (kind === "pick") { | |||
| // /pickOrder/detail?consoCode=… is retired — only link when we have a TI-* workbench ticket. | |||
| const workbenchTicket = [ticketNo, consoCode, code] | |||
| .map((v) => (v ?? "").trim()) | |||
| .find(isWorkbenchTicketNo); | |||
| if (workbenchTicket) { | |||
| const params = new URLSearchParams(); | |||
| params.set("tab", String(workbenchTab)); | |||
| params.set("ticketNo", workbenchTicket); | |||
| if (linkTargetDate) params.set("targetDate", linkTargetDate); | |||
| params.set("openDetail", "1"); | |||
| href = `/doworkbench?${params.toString()}`; | |||
| } else { | |||
| return <>{code}</>; | |||
| } | |||
| } else { | |||
| return <>{code}</>; | |||
| } | |||
| return ( | |||
| <MuiLink | |||
| component={Link} | |||
| href={href} | |||
| underline="hover" | |||
| {...(openInNewTab ? { target: "_blank", rel: "noopener noreferrer" } : {})} | |||
| onClick={openInNewTab ? stopGraphEvent : undefined} | |||
| onMouseDown={openInNewTab ? stopGraphEvent : undefined} | |||
| > | |||
| {code} | |||
| </MuiLink> | |||
| ); | |||
| }; | |||
| export default ItemTracingDocLink; | |||
| @@ -0,0 +1,797 @@ | |||
| "use client"; | |||
| import "@xyflow/react/dist/style.css"; | |||
| import { | |||
| Background, | |||
| BackgroundVariant, | |||
| Controls, | |||
| MiniMap, | |||
| MarkerType, | |||
| Panel, | |||
| ReactFlow, | |||
| ReactFlowProvider, | |||
| useEdgesState, | |||
| useNodesState, | |||
| useReactFlow, | |||
| type Node, | |||
| } from "@xyflow/react"; | |||
| import { | |||
| Box, | |||
| Chip, | |||
| IconButton, | |||
| Paper, | |||
| Popover, | |||
| Stack, | |||
| Tooltip, | |||
| Typography, | |||
| } from "@mui/material"; | |||
| import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; | |||
| import MapOutlinedIcon from "@mui/icons-material/MapOutlined"; | |||
| import RestartAltIcon from "@mui/icons-material/RestartAlt"; | |||
| import { useCallback, useEffect, useMemo, useState } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { ItemLotTraceResponse } from "@/app/api/itemTracing"; | |||
| import ItemTracingNodeDetailPanel from "./ItemTracingNodeDetailPanel"; | |||
| import ItemTracingFlowGraphSearch from "./ItemTracingFlowGraphSearch"; | |||
| import { | |||
| buildReactFlowGraph, | |||
| reactFlowGraphExtent, | |||
| type TraceFlowNodeData, | |||
| } from "./buildReactFlowGraph"; | |||
| import { traceFlowNodeTypes } from "./TraceFlowNodes"; | |||
| import { traceFlowEdgeTypes } from "./TraceFlowEdge"; | |||
| import { | |||
| VIEWPORT_HEIGHT, | |||
| FIT_VIEW_MIN_ZOOM, | |||
| FIT_VIEW_MAX_ZOOM, | |||
| DO_GROUP_HEADER, | |||
| } from "./traceFlowConstants"; | |||
| import { | |||
| minimapNodeColor, | |||
| phaseChipColor, | |||
| phaseLabelKey, | |||
| kindLabelKey, | |||
| } from "./traceFlowNodeUtils"; | |||
| import { searchTraceGraphNodes } from "./traceGraphSearch"; | |||
| import type { CompiledTraceGraph } from "./compileTraceGraph"; | |||
| import { TraceGraphNode } from "./buildTraceGraphNodes"; | |||
| import type { WarehouseFocusRequest } from "./ItemTracing"; | |||
| import type { TraceGraphPhase } from "./traceGraphLayout"; | |||
| type TraceParams = { | |||
| stockInLineId?: number; | |||
| lotNo?: string; | |||
| itemCode?: string; | |||
| }; | |||
| type Props = { | |||
| data: ItemLotTraceResponse; | |||
| compiledGraph: CompiledTraceGraph; | |||
| onTrace?: (params: TraceParams) => void; | |||
| focusWarehouse?: WarehouseFocusRequest | null; | |||
| }; | |||
| const DETAIL_PANEL_WIDTH = 280; | |||
| const LEGEND_ROWS: Array<{ key: string; tipKey: string }> = [ | |||
| { key: "flowLegendTime", tipKey: "flowLegendTimeTip" }, | |||
| { key: "flowLegendPhase", tipKey: "flowLegendPhaseTip" }, | |||
| { key: "flowLegendBranch", tipKey: "flowLegendBranchTip" }, | |||
| { key: "flowLegendArrow", tipKey: "flowLegendArrowTip" }, | |||
| { key: "flowLegendPrelude", tipKey: "flowLegendPreludeTip" }, | |||
| ]; | |||
| const ItemTracingFlowGraphInner: React.FC<Props> = ({ | |||
| data, | |||
| compiledGraph, | |||
| onTrace, | |||
| focusWarehouse, | |||
| }) => { | |||
| const { t } = useTranslation("itemTracing"); | |||
| const { fitView } = useReactFlow(); | |||
| const [selectedNode, setSelectedNode] = useState<TraceGraphNode | null>(null); | |||
| const [showMinimap, setShowMinimap] = useState(true); | |||
| const [searchQuery, setSearchQuery] = useState(""); | |||
| const [activeMatchIndex, setActiveMatchIndex] = useState(0); | |||
| const [legendAnchor, setLegendAnchor] = useState<HTMLElement | null>(null); | |||
| const [hiddenPhases, setHiddenPhases] = useState<Set<TraceGraphPhase>>( | |||
| () => new Set(), | |||
| ); | |||
| const [collapsedGroupIds, setCollapsedGroupIds] = useState<Set<string>>( | |||
| () => new Set(), | |||
| ); | |||
| const layout = compiledGraph.layout; | |||
| const hasJoPrelude = Boolean(data.joPrelude); | |||
| const phaseLabels = useMemo( | |||
| () => | |||
| Object.fromEntries( | |||
| layout.phaseOrder.map((phase) => [phase, t(phaseLabelKey(phase))]), | |||
| ), | |||
| [layout.phaseOrder, t], | |||
| ); | |||
| const graphElements = useMemo( | |||
| () => buildReactFlowGraph(layout, phaseLabels, compiledGraph.edgePairs), | |||
| [layout, phaseLabels, compiledGraph.edgePairs], | |||
| ); | |||
| const nodesWithCallbacks = useMemo( | |||
| () => | |||
| graphElements.nodes.map((node) => | |||
| node.type === "traceEvent" | |||
| ? { | |||
| ...node, | |||
| data: { ...node.data, onTrace }, | |||
| } | |||
| : node, | |||
| ), | |||
| [graphElements.nodes, onTrace], | |||
| ); | |||
| const [nodes, setNodes, onNodesChange] = useNodesState(nodesWithCallbacks); | |||
| const [edges, setEdges, onEdgesChange] = useEdgesState(graphElements.edges); | |||
| const kindLabel = useCallback( | |||
| (kind: TraceGraphNode["kind"]) => t(kindLabelKey(kind)), | |||
| [t], | |||
| ); | |||
| const phaseVisible = useCallback( | |||
| (phase: TraceGraphPhase | undefined) => | |||
| phase == null || hiddenPhases.size === 0 || !hiddenPhases.has(phase), | |||
| [hiddenPhases], | |||
| ); | |||
| const visibleLayoutNodes = useMemo( | |||
| () => layout.nodes.filter((n) => phaseVisible(n.phase)), | |||
| [layout.nodes, phaseVisible], | |||
| ); | |||
| const matchIds = useMemo( | |||
| () => searchTraceGraphNodes(visibleLayoutNodes, searchQuery, kindLabel), | |||
| [visibleLayoutNodes, searchQuery, kindLabel], | |||
| ); | |||
| const activeNodeId = | |||
| matchIds.length > 0 ? matchIds[activeMatchIndex % matchIds.length] : null; | |||
| const focusFitNodes = useMemo(() => { | |||
| if (!activeNodeId) return []; | |||
| const ids = new Set<string>([activeNodeId]); | |||
| const activeRf = graphElements.nodes.find((n) => n.id === activeNodeId); | |||
| if (activeRf?.parentId) ids.add(activeRf.parentId); | |||
| graphElements.edges.forEach((edge) => { | |||
| if (edge.source === activeNodeId) ids.add(edge.target); | |||
| if (edge.target === activeNodeId) ids.add(edge.source); | |||
| }); | |||
| return Array.from(ids, (id) => ({ id })); | |||
| }, [activeNodeId, graphElements.edges, graphElements.nodes]); | |||
| useEffect(() => { | |||
| setSearchQuery(""); | |||
| setActiveMatchIndex(0); | |||
| setSelectedNode(null); | |||
| setHiddenPhases(new Set()); | |||
| setCollapsedGroupIds(new Set()); | |||
| }, [data]); | |||
| useEffect(() => { | |||
| setActiveMatchIndex(0); | |||
| }, [searchQuery]); | |||
| const togglePhase = useCallback( | |||
| (phase: TraceGraphPhase) => { | |||
| setHiddenPhases((prev) => { | |||
| const next = new Set(prev); | |||
| if (next.has(phase)) next.delete(phase); | |||
| else next.add(phase); | |||
| // If every phase would be hidden, treat as "show all" | |||
| if (next.size >= layout.phaseOrder.length) return new Set(); | |||
| return next; | |||
| }); | |||
| }, | |||
| [layout.phaseOrder.length], | |||
| ); | |||
| const toggleGroupCollapse = useCallback((groupId: string) => { | |||
| setCollapsedGroupIds((prev) => { | |||
| const next = new Set(prev); | |||
| if (next.has(groupId)) next.delete(groupId); | |||
| else next.add(groupId); | |||
| return next; | |||
| }); | |||
| }, []); | |||
| const resetPhaseFilter = useCallback(() => setHiddenPhases(new Set()), []); | |||
| const displayedNodes = useMemo(() => { | |||
| const hasSearch = searchQuery.trim().length > 0; | |||
| const matchSet = new Set(matchIds); | |||
| const groupIdsWithChildMatch = new Set<string>(); | |||
| visibleLayoutNodes.forEach((n) => { | |||
| if (n.doGroupId && matchSet.has(n.id)) | |||
| groupIdsWithChildMatch.add(n.doGroupId); | |||
| }); | |||
| const visibleIds = new Set(visibleLayoutNodes.map((n) => n.id)); | |||
| layout.nodes.forEach((n) => { | |||
| if (n.doGroupId && visibleIds.has(n.id)) visibleIds.add(n.doGroupId); | |||
| }); | |||
| return nodes | |||
| .filter((node) => { | |||
| if (node.type === "phaseLabel" || node.type === "dateHeader") | |||
| return true; | |||
| if (node.type === "doGroup" || node.type === "pickGroup") { | |||
| return layout.nodes.some( | |||
| (n) => n.doGroupId === node.id && phaseVisible(n.phase), | |||
| ); | |||
| } | |||
| if (node.type === "traceEvent") { | |||
| const layoutNode = (node.data as TraceFlowNodeData | undefined) | |||
| ?.layoutNode; | |||
| if ( | |||
| layoutNode?.doGroupId && | |||
| collapsedGroupIds.has(layoutNode.doGroupId) | |||
| ) { | |||
| return false; | |||
| } | |||
| return phaseVisible(layoutNode?.phase); | |||
| } | |||
| return true; | |||
| }) | |||
| .map((node) => { | |||
| if ( | |||
| node.type !== "traceEvent" && | |||
| node.type !== "doGroup" && | |||
| node.type !== "pickGroup" | |||
| ) { | |||
| return node; | |||
| } | |||
| const isMatch = | |||
| matchSet.has(node.id) || | |||
| ((node.type === "doGroup" || node.type === "pickGroup") && | |||
| groupIdsWithChildMatch.has(node.id)); | |||
| const isFocused = node.id === activeNodeId; | |||
| const isSelected = selectedNode?.id === node.id; | |||
| const isGroup = node.type === "doGroup" || node.type === "pickGroup"; | |||
| const groupCollapsed = isGroup && collapsedGroupIds.has(node.id); | |||
| const collapsedH = DO_GROUP_HEADER; | |||
| return { | |||
| ...node, | |||
| ...(groupCollapsed | |||
| ? { | |||
| style: { ...node.style, height: collapsedH }, | |||
| height: collapsedH, | |||
| measured: { | |||
| width: node.measured?.width ?? node.width ?? 0, | |||
| height: collapsedH, | |||
| }, | |||
| } | |||
| : {}), | |||
| data: { | |||
| ...node.data, | |||
| searchActive: hasSearch, | |||
| searchMatch: isMatch, | |||
| searchFocused: isFocused, | |||
| nodeSelected: isSelected, | |||
| ...(isGroup | |||
| ? { | |||
| groupCollapsed, | |||
| onToggleGroupCollapse: () => toggleGroupCollapse(node.id), | |||
| } | |||
| : {}), | |||
| }, | |||
| }; | |||
| }); | |||
| }, [ | |||
| nodes, | |||
| searchQuery, | |||
| matchIds, | |||
| activeNodeId, | |||
| selectedNode?.id, | |||
| layout.nodes, | |||
| visibleLayoutNodes, | |||
| phaseVisible, | |||
| collapsedGroupIds, | |||
| toggleGroupCollapse, | |||
| ]); | |||
| const displayedEdges = useMemo(() => { | |||
| const visibleEventIds = new Set( | |||
| displayedNodes | |||
| .filter( | |||
| (n) => | |||
| n.type === "traceEvent" || | |||
| n.type === "doGroup" || | |||
| n.type === "pickGroup", | |||
| ) | |||
| .map((n) => n.id), | |||
| ); | |||
| const phaseFiltered = edges.filter( | |||
| (edge) => | |||
| visibleEventIds.has(edge.source) && visibleEventIds.has(edge.target), | |||
| ); | |||
| const hasSearch = searchQuery.trim().length > 0 && matchIds.length > 0; | |||
| const selectedId = selectedNode?.id ?? null; | |||
| const selectedGroupId = selectedNode?.doGroupId?.trim() || null; | |||
| const hasSelection = Boolean(selectedId); | |||
| if (!hasSearch && !hasSelection) return phaseFiltered; | |||
| const matchSet = hasSearch ? new Set(matchIds) : null; | |||
| const selectionIds = new Set<string>(); | |||
| if (selectedId) { | |||
| selectionIds.add(selectedId); | |||
| if (selectedGroupId) selectionIds.add(selectedGroupId); | |||
| } | |||
| const focusStroke = "#1565c0"; | |||
| const matchStroke = "#ef6c00"; | |||
| const selectStroke = "#2e7d32"; | |||
| return phaseFiltered.map((edge) => { | |||
| const sourceMatch = matchSet?.has(edge.source) ?? false; | |||
| const targetMatch = matchSet?.has(edge.target) ?? false; | |||
| const connectsHighlighted = | |||
| Boolean(matchSet) && sourceMatch && targetMatch; | |||
| const touchesFocus = Boolean( | |||
| activeNodeId && | |||
| (edge.source === activeNodeId || edge.target === activeNodeId), | |||
| ); | |||
| const touchesSelection = | |||
| hasSelection && | |||
| (selectionIds.has(edge.source) || selectionIds.has(edge.target)); | |||
| const shouldHighlight = | |||
| connectsHighlighted || touchesFocus || touchesSelection; | |||
| if (!shouldHighlight) { | |||
| return { | |||
| ...edge, | |||
| zIndex: 0, | |||
| style: { | |||
| ...edge.style, | |||
| stroke: "#bdbdbd", | |||
| strokeWidth: 1, | |||
| opacity: 0.18, | |||
| }, | |||
| labelStyle: { ...edge.labelStyle, opacity: 0.2 }, | |||
| markerEnd: { | |||
| type: MarkerType.ArrowClosed, | |||
| color: "#bdbdbd", | |||
| width: 14, | |||
| height: 14, | |||
| }, | |||
| }; | |||
| } | |||
| const stroke = touchesSelection | |||
| ? selectStroke | |||
| : touchesFocus | |||
| ? focusStroke | |||
| : matchStroke; | |||
| const strokeWidth = touchesSelection || touchesFocus ? 3 : 2.5; | |||
| const zIndex = touchesSelection ? 4 : touchesFocus ? 3 : 2; | |||
| return { | |||
| ...edge, | |||
| zIndex, | |||
| style: { | |||
| ...edge.style, | |||
| stroke, | |||
| strokeWidth, | |||
| opacity: 1, | |||
| }, | |||
| labelStyle: { | |||
| ...edge.labelStyle, | |||
| fill: touchesSelection | |||
| ? selectStroke | |||
| : touchesFocus | |||
| ? focusStroke | |||
| : "#e65100", | |||
| fontWeight: 700, | |||
| opacity: 1, | |||
| }, | |||
| labelBgStyle: { | |||
| ...edge.labelBgStyle, | |||
| fill: touchesSelection | |||
| ? "#e8f5e9" | |||
| : touchesFocus | |||
| ? "#e3f2fd" | |||
| : "#fff8e1", | |||
| fillOpacity: 1, | |||
| }, | |||
| markerEnd: { | |||
| type: MarkerType.ArrowClosed, | |||
| color: stroke, | |||
| width: touchesSelection || touchesFocus ? 18 : 16, | |||
| height: touchesSelection || touchesFocus ? 18 : 16, | |||
| }, | |||
| }; | |||
| }); | |||
| }, [ | |||
| edges, | |||
| searchQuery, | |||
| matchIds, | |||
| activeNodeId, | |||
| selectedNode, | |||
| displayedNodes, | |||
| ]); | |||
| useEffect(() => { | |||
| setNodes(nodesWithCallbacks); | |||
| setEdges(graphElements.edges); | |||
| const timer = window.setTimeout(() => { | |||
| fitView({ | |||
| padding: 0.04, | |||
| minZoom: FIT_VIEW_MIN_ZOOM, | |||
| maxZoom: FIT_VIEW_MAX_ZOOM, | |||
| duration: 200, | |||
| }); | |||
| }, 50); | |||
| return () => window.clearTimeout(timer); | |||
| }, [nodesWithCallbacks, graphElements.edges, setNodes, setEdges, fitView]); | |||
| useEffect(() => { | |||
| if (!activeNodeId || !searchQuery.trim()) return; | |||
| const timer = window.setTimeout(() => { | |||
| fitView({ | |||
| nodes: focusFitNodes, | |||
| padding: 0.55, | |||
| duration: 280, | |||
| maxZoom: 1.25, | |||
| }); | |||
| }, 60); | |||
| return () => window.clearTimeout(timer); | |||
| }, [activeNodeId, searchQuery, focusFitNodes, fitView]); | |||
| useEffect(() => { | |||
| if (!focusWarehouse) return; | |||
| const { inventoryLotId, warehouseCode } = focusWarehouse; | |||
| const locPrefix = `loc-${inventoryLotId}-`; | |||
| const wh = warehouseCode.trim().toUpperCase(); | |||
| const focusIds = layout.nodes | |||
| .filter((n) => { | |||
| if (n.id.startsWith(locPrefix)) return true; | |||
| if (inventoryLotId !== data.lot.inventoryLotId) return false; | |||
| if (n.id.startsWith("loc-")) return false; | |||
| if (!wh) return true; | |||
| return (n.warehouseCode ?? "").trim().toUpperCase() === wh; | |||
| }) | |||
| .map((n) => n.id); | |||
| if (focusIds.length === 0) return; | |||
| setSearchQuery(warehouseCode.trim() || String(inventoryLotId)); | |||
| setActiveMatchIndex(0); | |||
| const timer = window.setTimeout(() => { | |||
| fitView({ | |||
| nodes: focusIds.map((id) => ({ id })), | |||
| padding: 0.2, | |||
| duration: 320, | |||
| maxZoom: 1.1, | |||
| }); | |||
| }, 80); | |||
| return () => window.clearTimeout(timer); | |||
| }, [focusWarehouse, layout.nodes, data.lot.inventoryLotId, fitView]); | |||
| const handleSearchPrev = useCallback(() => { | |||
| if (matchIds.length === 0) return; | |||
| setActiveMatchIndex( | |||
| (prev) => (prev - 1 + matchIds.length) % matchIds.length, | |||
| ); | |||
| }, [matchIds.length]); | |||
| const handleSearchNext = useCallback(() => { | |||
| if (matchIds.length === 0) return; | |||
| setActiveMatchIndex((prev) => (prev + 1) % matchIds.length); | |||
| }, [matchIds.length]); | |||
| const handleSearchClear = useCallback(() => { | |||
| setSearchQuery(""); | |||
| setActiveMatchIndex(0); | |||
| }, []); | |||
| const onNodeClick = useCallback( | |||
| (_: React.MouseEvent, node: Node<TraceFlowNodeData>) => { | |||
| if (node.type === "traceEvent" && node.data?.layoutNode?.id) { | |||
| setSelectedNode(node.data.layoutNode); | |||
| } | |||
| }, | |||
| [], | |||
| ); | |||
| const translateExtent = useMemo( | |||
| () => reactFlowGraphExtent(layout, graphElements.graphHeight), | |||
| [layout, graphElements.graphHeight], | |||
| ); | |||
| if (!layout.nodes.length) { | |||
| return ( | |||
| <Paper variant="outlined" sx={{ p: 2 }}> | |||
| <Typography color="text.secondary">{t("noRecords")}</Typography> | |||
| </Paper> | |||
| ); | |||
| } | |||
| return ( | |||
| <Paper variant="outlined" sx={{ p: 2 }}> | |||
| <Stack | |||
| direction={{ xs: "column", sm: "row" }} | |||
| alignItems={{ sm: "center" }} | |||
| spacing={1} | |||
| sx={{ mb: 1 }} | |||
| > | |||
| <Typography variant="subtitle1" fontWeight={600} sx={{ flexGrow: 1 }}> | |||
| {t("flowGraph")} | |||
| </Typography> | |||
| <Tooltip title={t("flowLegendOpen")} arrow> | |||
| <IconButton | |||
| size="small" | |||
| aria-label={t("flowLegendOpen")} | |||
| onClick={(e) => setLegendAnchor(e.currentTarget)} | |||
| > | |||
| <InfoOutlinedIcon fontSize="small" /> | |||
| </IconButton> | |||
| </Tooltip> | |||
| </Stack> | |||
| <Typography | |||
| variant="caption" | |||
| color="text.secondary" | |||
| display="block" | |||
| sx={{ mb: 1 }} | |||
| > | |||
| {hasJoPrelude ? t("flowGraphHintJo") : t("flowGraphHint")} | |||
| {" · "} | |||
| {t("flowZoomPanHint")} | |||
| </Typography> | |||
| <Stack spacing={0.75} alignItems="center" sx={{ mb: 1.5 }}> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {t("flowPhaseFilter")} | |||
| </Typography> | |||
| <Stack | |||
| direction="row" | |||
| flexWrap="wrap" | |||
| gap={0.75} | |||
| alignItems="center" | |||
| justifyContent="center" | |||
| > | |||
| {layout.phaseOrder.map((phase) => { | |||
| const active = phaseVisible(phase); | |||
| const chipColor = phaseChipColor(phase); | |||
| return ( | |||
| <Chip | |||
| key={phase} | |||
| size="small" | |||
| label={t(phaseLabelKey(phase))} | |||
| color={chipColor} | |||
| variant={active ? "filled" : "outlined"} | |||
| onClick={() => togglePhase(phase)} | |||
| sx={{ | |||
| borderRadius: 1.5, | |||
| height: 28, | |||
| fontWeight: 600, | |||
| cursor: "pointer", | |||
| letterSpacing: 0.1, | |||
| opacity: active ? 1 : 0.8, | |||
| transition: "box-shadow 0.15s ease, opacity 0.15s ease, filter 0.15s ease", | |||
| "& .MuiChip-label": { px: 1.25 }, | |||
| "&:hover": { | |||
| opacity: 1, | |||
| boxShadow: 1, | |||
| filter: active ? "brightness(1.06)" : undefined, | |||
| }, | |||
| }} | |||
| /> | |||
| ); | |||
| })} | |||
| <Chip | |||
| size="small" | |||
| icon={<RestartAltIcon />} | |||
| label={t("flowPhaseFilterReset")} | |||
| color="error" | |||
| variant="filled" | |||
| disabled={hiddenPhases.size === 0} | |||
| onClick={hiddenPhases.size > 0 ? resetPhaseFilter : undefined} | |||
| sx={{ | |||
| borderRadius: 1.5, | |||
| height: 28, | |||
| fontWeight: 700, | |||
| cursor: hiddenPhases.size === 0 ? "default" : "pointer", | |||
| letterSpacing: 0.2, | |||
| "& .MuiChip-icon": { ml: 0.5, fontSize: 16 }, | |||
| "& .MuiChip-label": { px: 1.25 }, | |||
| "&:hover": | |||
| hiddenPhases.size === 0 | |||
| ? undefined | |||
| : { | |||
| boxShadow: 1, | |||
| filter: "brightness(1.08)", | |||
| }, | |||
| }} | |||
| /> | |||
| </Stack> | |||
| </Stack> | |||
| <Popover | |||
| open={Boolean(legendAnchor)} | |||
| anchorEl={legendAnchor} | |||
| onClose={() => setLegendAnchor(null)} | |||
| anchorOrigin={{ vertical: "bottom", horizontal: "right" }} | |||
| transformOrigin={{ vertical: "top", horizontal: "right" }} | |||
| > | |||
| <Box sx={{ p: 2, maxWidth: 360 }}> | |||
| <Typography variant="subtitle2" fontWeight={600} sx={{ mb: 1 }}> | |||
| {t("flowLegendTitle")} | |||
| </Typography> | |||
| <Stack spacing={1}> | |||
| {LEGEND_ROWS.map(({ key, tipKey }) => ( | |||
| <Box key={key}> | |||
| <Typography variant="body2" fontWeight={600}> | |||
| {t(key)} | |||
| </Typography> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {t(tipKey)} | |||
| </Typography> | |||
| </Box> | |||
| ))} | |||
| {hasJoPrelude && ( | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {t("flowGraphPathJo")} | |||
| </Typography> | |||
| )} | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {t("flowNodeDetailHint")} | |||
| </Typography> | |||
| </Stack> | |||
| </Box> | |||
| </Popover> | |||
| <Box | |||
| sx={{ | |||
| height: VIEWPORT_HEIGHT, | |||
| borderRadius: 1, | |||
| border: 1, | |||
| borderColor: "divider", | |||
| overflow: "hidden", | |||
| bgcolor: "action.hover", | |||
| display: "flex", | |||
| "& .react-flow__attribution": { display: "none" }, | |||
| }} | |||
| > | |||
| {selectedNode && ( | |||
| <Box | |||
| sx={{ width: DETAIL_PANEL_WIDTH, flexShrink: 0, height: "100%" }} | |||
| > | |||
| <ItemTracingNodeDetailPanel | |||
| node={selectedNode} | |||
| onClose={() => setSelectedNode(null)} | |||
| /> | |||
| </Box> | |||
| )} | |||
| <Box | |||
| sx={{ flex: 1, minWidth: 0, height: "100%", position: "relative" }} | |||
| > | |||
| <ReactFlow | |||
| nodes={displayedNodes} | |||
| edges={displayedEdges} | |||
| onNodesChange={onNodesChange} | |||
| onEdgesChange={onEdgesChange} | |||
| elevateEdgesOnSelect={false} | |||
| defaultEdgeOptions={{ zIndex: 0 }} | |||
| nodeTypes={traceFlowNodeTypes} | |||
| edgeTypes={traceFlowEdgeTypes} | |||
| onNodeClick={onNodeClick} | |||
| onPaneClick={() => setSelectedNode(null)} | |||
| nodesDraggable={false} | |||
| nodesConnectable={false} | |||
| elementsSelectable | |||
| panOnScroll | |||
| zoomOnScroll | |||
| minZoom={0.15} | |||
| maxZoom={2.5} | |||
| translateExtent={translateExtent} | |||
| proOptions={{ hideAttribution: true }} | |||
| style={{ width: "100%", height: "100%" }} | |||
| > | |||
| <Panel position="top-right"> | |||
| <ItemTracingFlowGraphSearch | |||
| query={searchQuery} | |||
| matchCount={matchIds.length} | |||
| activeIndex={activeMatchIndex} | |||
| onQueryChange={setSearchQuery} | |||
| onPrev={handleSearchPrev} | |||
| onNext={handleSearchNext} | |||
| onClear={handleSearchClear} | |||
| /> | |||
| </Panel> | |||
| <Background | |||
| variant={BackgroundVariant.Dots} | |||
| gap={16} | |||
| size={1} | |||
| color="#bdbdbd" | |||
| /> | |||
| <Panel position="bottom-left" style={{ left: 12, bottom: 12 }}> | |||
| <Stack spacing={0.5} alignItems="center"> | |||
| <Tooltip | |||
| title={ | |||
| showMinimap ? t("flowMinimapHide") : t("flowMinimapShow") | |||
| } | |||
| arrow | |||
| placement="right" | |||
| > | |||
| <IconButton | |||
| size="small" | |||
| onClick={() => setShowMinimap((v) => !v)} | |||
| aria-label={ | |||
| showMinimap ? t("flowMinimapHide") : t("flowMinimapShow") | |||
| } | |||
| sx={{ | |||
| bgcolor: "background.paper", | |||
| border: 1, | |||
| borderColor: "divider", | |||
| boxShadow: 1, | |||
| borderRadius: 1, | |||
| width: 28, | |||
| height: 28, | |||
| "&:hover": { bgcolor: "background.paper" }, | |||
| }} | |||
| > | |||
| <MapOutlinedIcon | |||
| fontSize="small" | |||
| color={showMinimap ? "primary" : "action"} | |||
| /> | |||
| </IconButton> | |||
| </Tooltip> | |||
| <Box | |||
| sx={{ | |||
| "& .react-flow__controls": { | |||
| position: "static", | |||
| margin: 0, | |||
| boxShadow: 1, | |||
| }, | |||
| }} | |||
| > | |||
| <Controls showInteractive={false} /> | |||
| </Box> | |||
| </Stack> | |||
| </Panel> | |||
| {showMinimap && ( | |||
| <MiniMap | |||
| nodeColor={(node) => { | |||
| if ( | |||
| node.type !== "traceEvent" && | |||
| node.type !== "doGroup" && | |||
| node.type !== "pickGroup" | |||
| ) { | |||
| return "#e0e0e0"; | |||
| } | |||
| const layoutNode = ( | |||
| node.data as TraceFlowNodeData | undefined | |||
| )?.layoutNode; | |||
| return layoutNode?.kind | |||
| ? minimapNodeColor(layoutNode.kind) | |||
| : "#e0e0e0"; | |||
| }} | |||
| pannable | |||
| zoomable | |||
| style={{ border: "1px solid #e0e0e0", borderRadius: 4 }} | |||
| /> | |||
| )} | |||
| </ReactFlow> | |||
| </Box> | |||
| </Box> | |||
| </Paper> | |||
| ); | |||
| }; | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 14 | v1.0.0 | 2026-07-17 */ | |||
| const ItemTracingFlowGraph: React.FC<Props> = (props) => ( | |||
| <ReactFlowProvider> | |||
| <ItemTracingFlowGraphInner {...props} /> | |||
| </ReactFlowProvider> | |||
| ); | |||
| export default ItemTracingFlowGraph; | |||
| @@ -0,0 +1,149 @@ | |||
| "use client"; | |||
| import ClearIcon from "@mui/icons-material/Clear"; | |||
| import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; | |||
| import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; | |||
| import SearchIcon from "@mui/icons-material/Search"; | |||
| import { | |||
| Box, | |||
| IconButton, | |||
| InputAdornment, | |||
| Paper, | |||
| TextField, | |||
| Tooltip, | |||
| Typography, | |||
| } from "@mui/material"; | |||
| import { useTranslation } from "react-i18next"; | |||
| type Props = { | |||
| query: string; | |||
| matchCount: number; | |||
| activeIndex: number; | |||
| onQueryChange: (value: string) => void; | |||
| onPrev: () => void; | |||
| onNext: () => void; | |||
| onClear: () => void; | |||
| }; | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 15 | v1.0.1 | 2026-07-20 */ | |||
| const ItemTracingFlowGraphSearch: React.FC<Props> = ({ | |||
| query, | |||
| matchCount, | |||
| activeIndex, | |||
| onQueryChange, | |||
| onPrev, | |||
| onNext, | |||
| onClear, | |||
| }) => { | |||
| const { t } = useTranslation("itemTracing"); | |||
| const trimmed = query.trim(); | |||
| const hasQuery = trimmed.length > 0; | |||
| const matchLabel = | |||
| matchCount > 0 | |||
| ? t("flowGraphSearchMatch", { current: activeIndex + 1, total: matchCount }) | |||
| : hasQuery | |||
| ? t("flowGraphSearchNoMatch") | |||
| : ""; | |||
| const handleKeyDown = (event: React.KeyboardEvent) => { | |||
| if (event.key === "Enter") { | |||
| event.preventDefault(); | |||
| if (event.shiftKey) onPrev(); | |||
| else onNext(); | |||
| } else if (event.key === "Escape") { | |||
| event.preventDefault(); | |||
| onClear(); | |||
| } | |||
| }; | |||
| return ( | |||
| <Paper | |||
| elevation={2} | |||
| sx={{ | |||
| p: 1, | |||
| minWidth: 280, | |||
| maxWidth: 360, | |||
| bgcolor: "background.paper", | |||
| }} | |||
| > | |||
| <TextField | |||
| size="small" | |||
| fullWidth | |||
| value={query} | |||
| placeholder={t("flowGraphSearchPlaceholder")} | |||
| onChange={(e) => onQueryChange(e.target.value)} | |||
| onKeyDown={handleKeyDown} | |||
| InputProps={{ | |||
| startAdornment: ( | |||
| <InputAdornment position="start" sx={{ mr: 0.5 }}> | |||
| <SearchIcon fontSize="small" sx={{ color: "text.secondary" }} /> | |||
| </InputAdornment> | |||
| ), | |||
| endAdornment: hasQuery ? ( | |||
| <InputAdornment position="end"> | |||
| <Tooltip title={t("flowGraphSearchClear")}> | |||
| <IconButton size="small" edge="end" onClick={onClear} aria-label={t("flowGraphSearchClear")}> | |||
| <ClearIcon fontSize="small" /> | |||
| </IconButton> | |||
| </Tooltip> | |||
| </InputAdornment> | |||
| ) : undefined, | |||
| }} | |||
| sx={{ | |||
| "& .MuiOutlinedInput-root": { | |||
| alignItems: "center", | |||
| }, | |||
| "& .MuiOutlinedInput-input": { | |||
| color: "text.secondary", | |||
| py: "8.5px", | |||
| lineHeight: 1.4375, | |||
| }, | |||
| "& .MuiOutlinedInput-input::placeholder": { | |||
| color: "text.disabled", | |||
| opacity: 1, | |||
| }, | |||
| "& .MuiInputAdornment-root": { | |||
| height: "100%", | |||
| maxHeight: "none", | |||
| alignItems: "center", | |||
| }, | |||
| }} | |||
| /> | |||
| {hasQuery && ( | |||
| <Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", mt: 0.75, gap: 1 }}> | |||
| <Typography variant="caption" color={matchCount > 0 ? "text.secondary" : "error"} noWrap> | |||
| {matchLabel} | |||
| </Typography> | |||
| <Box sx={{ display: "flex", flexShrink: 0 }}> | |||
| <Tooltip title={t("flowGraphSearchPrev")}> | |||
| <span> | |||
| <IconButton | |||
| size="small" | |||
| onClick={onPrev} | |||
| disabled={matchCount === 0} | |||
| aria-label={t("flowGraphSearchPrev")} | |||
| > | |||
| <KeyboardArrowUpIcon fontSize="small" /> | |||
| </IconButton> | |||
| </span> | |||
| </Tooltip> | |||
| <Tooltip title={t("flowGraphSearchNext")}> | |||
| <span> | |||
| <IconButton | |||
| size="small" | |||
| onClick={onNext} | |||
| disabled={matchCount === 0} | |||
| aria-label={t("flowGraphSearchNext")} | |||
| > | |||
| <KeyboardArrowDownIcon fontSize="small" /> | |||
| </IconButton> | |||
| </span> | |||
| </Tooltip> | |||
| </Box> | |||
| </Box> | |||
| )} | |||
| </Paper> | |||
| ); | |||
| }; | |||
| export default ItemTracingFlowGraphSearch; | |||
| @@ -0,0 +1,11 @@ | |||
| import { Skeleton, Stack } from "@mui/material"; | |||
| const ItemTracingLoading: React.FC = () => ( | |||
| <Stack spacing={2} sx={{ mt: 2 }}> | |||
| <Skeleton variant="rounded" height={120} /> | |||
| <Skeleton variant="rounded" height={200} /> | |||
| <Skeleton variant="rounded" height={320} /> | |||
| </Stack> | |||
| ); | |||
| export default ItemTracingLoading; | |||
| @@ -0,0 +1,791 @@ | |||
| "use client"; | |||
| import React, { useState } from "react"; | |||
| import { | |||
| Accordion, | |||
| AccordionDetails, | |||
| AccordionSummary, | |||
| Box, | |||
| Chip, | |||
| Stack, | |||
| TableContainer, | |||
| Typography, | |||
| } from "@mui/material"; | |||
| import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; | |||
| import Inventory2Icon from "@mui/icons-material/Inventory2"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { ItemLotTraceLocationBlock } from "@/app/api/itemTracing"; | |||
| import type { WarehouseFocusRequest } from "./ItemTracing"; | |||
| import { blockWarehouseCode } from "./buildLocationBlockGraphNodes"; | |||
| import { createTraceLabelTranslator } from "./traceLabelUtils"; | |||
| import { | |||
| resolveStockTakeAcceptedQty, | |||
| resolveStockTakeBookQty, | |||
| } from "./traceStockTakeUtils"; | |||
| import { | |||
| FilterableDataTable, | |||
| type FilterableColumnDef, | |||
| } from "./itemTracingTableFilters"; | |||
| interface Props { | |||
| locationBlocks: ItemLotTraceLocationBlock[]; | |||
| onFocusWarehouse?: (request: WarehouseFocusRequest) => void; | |||
| } | |||
| /** Compact sub-table for sections that may have many rows. */ | |||
| function SectionTable<T>({ | |||
| title, | |||
| rows, | |||
| columns, | |||
| getRowKey, | |||
| }: { | |||
| title: string; | |||
| rows: T[]; | |||
| columns: FilterableColumnDef<T>[]; | |||
| getRowKey: (row: T, index: number) => string; | |||
| }) { | |||
| const { t } = useTranslation("itemTracing"); | |||
| const [showAll, setShowAll] = useState(false); | |||
| return ( | |||
| <Box> | |||
| <Typography variant="subtitle2" gutterBottom> | |||
| {title} ({rows.length}) | |||
| </Typography> | |||
| <TableContainer> | |||
| <FilterableDataTable | |||
| rows={rows} | |||
| columns={columns} | |||
| getRowKey={getRowKey} | |||
| emptyLabel={t("noRecords")} | |||
| collapseAfter={10} | |||
| showAll={showAll} | |||
| onToggleShowAll={() => setShowAll((v) => !v)} | |||
| showAllLabel={(count) => t("locationsShowAll", { count })} | |||
| collapseLabel={(count) => t("locationsCollapse", { count })} | |||
| /> | |||
| </TableContainer> | |||
| </Box> | |||
| ); | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 13 | v1.0.0 | 2026-07-17 */ | |||
| const ItemTracingLocations: React.FC<Props> = ({ | |||
| locationBlocks, | |||
| onFocusWarehouse, | |||
| }) => { | |||
| const { t } = useTranslation("itemTracing"); | |||
| const tr = createTraceLabelTranslator(t); | |||
| if (!locationBlocks || locationBlocks.length === 0) return null; | |||
| return ( | |||
| <Stack spacing={1.5}> | |||
| <Stack direction="row" alignItems="center" spacing={1}> | |||
| <Inventory2Icon fontSize="small" color="primary" /> | |||
| <Typography variant="subtitle1" fontWeight={600}> | |||
| {t("locationHeader", { count: locationBlocks.length })} | |||
| </Typography> | |||
| <Chip | |||
| label={`${locationBlocks.length}`} | |||
| size="small" | |||
| color="primary" | |||
| variant="outlined" | |||
| /> | |||
| </Stack> | |||
| {locationBlocks.map((block) => { | |||
| const totalIn = block.warehouseLines.reduce( | |||
| (s, l) => s + (l.inQty || 0), | |||
| 0, | |||
| ); | |||
| const totalOut = block.warehouseLines.reduce( | |||
| (s, l) => s + (l.outQty || 0), | |||
| 0, | |||
| ); | |||
| const available = totalIn - totalOut; | |||
| const lastMovement = block.movements[0]; | |||
| const stockTakeVariance = block.stockTakeEvents.reduce( | |||
| (s, st) => s + (st.varianceQty || 0), | |||
| 0, | |||
| ); | |||
| return ( | |||
| <Accordion | |||
| key={block.inventoryLotId} | |||
| slotProps={{ transition: { unmountOnExit: true } }} | |||
| > | |||
| <AccordionSummary expandIcon={<ExpandMoreIcon />}> | |||
| <Stack | |||
| direction="row" | |||
| alignItems="center" | |||
| spacing={1.5} | |||
| flexWrap="wrap" | |||
| sx={{ width: "100%", gap: 0.5 }} | |||
| > | |||
| <Typography variant="body2" fontWeight={600}> | |||
| {block.warehouseLines | |||
| .map((l) => l.warehouseCode) | |||
| .filter(Boolean) | |||
| .join(" / ") || `Lot #${block.inventoryLotId}`} | |||
| </Typography> | |||
| {block.itemName && ( | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {block.itemCode} ??{block.itemName} | |||
| </Typography> | |||
| )} | |||
| <Chip | |||
| label={`${t("available")}: ${available}`} | |||
| size="small" | |||
| color={available > 0 ? "success" : "default"} | |||
| variant="outlined" | |||
| /> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {t("inQty")}: {totalIn} / {t("outQty")}: {totalOut} | |||
| </Typography> | |||
| {block.movements.length > 0 && ( | |||
| <Chip | |||
| label={`${block.movements.length} moves`} | |||
| size="small" | |||
| variant="outlined" | |||
| /> | |||
| )} | |||
| {lastMovement?.timestamp && ( | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {t("lastMove")}: {lastMovement.timestamp} | |||
| </Typography> | |||
| )} | |||
| {stockTakeVariance !== 0 && ( | |||
| <Chip | |||
| label={`? ${stockTakeVariance}`} | |||
| size="small" | |||
| color={stockTakeVariance !== 0 ? "warning" : "default"} | |||
| variant="outlined" | |||
| /> | |||
| )} | |||
| {onFocusWarehouse ? ( | |||
| <Chip | |||
| label={t("focusWarehouseInGraph")} | |||
| size="small" | |||
| color="primary" | |||
| variant="filled" | |||
| clickable | |||
| onClick={(e) => { | |||
| e.stopPropagation(); | |||
| onFocusWarehouse({ | |||
| inventoryLotId: block.inventoryLotId, | |||
| warehouseCode: blockWarehouseCode(block), | |||
| }); | |||
| }} | |||
| sx={{ ml: "auto" }} | |||
| /> | |||
| ) : null} | |||
| </Stack> | |||
| </AccordionSummary> | |||
| <AccordionDetails> | |||
| <Stack spacing={2}> | |||
| {block.warehouseLines.length > 0 && ( | |||
| <SectionTable | |||
| title={t("warehouseLines")} | |||
| rows={block.warehouseLines} | |||
| getRowKey={(wl) => String(wl.inventoryLotLineId)} | |||
| columns={[ | |||
| { | |||
| key: "warehouse", | |||
| label: t("warehouse"), | |||
| value: (wl) => wl.warehouseCode, | |||
| }, | |||
| { | |||
| key: "inQty", | |||
| label: t("inQty"), | |||
| align: "right", | |||
| value: (wl) => wl.inQty, | |||
| }, | |||
| { | |||
| key: "outQty", | |||
| label: t("outQty"), | |||
| align: "right", | |||
| value: (wl) => wl.outQty, | |||
| }, | |||
| { | |||
| key: "available", | |||
| label: t("available"), | |||
| align: "right", | |||
| value: (wl) => wl.availableQty, | |||
| }, | |||
| { | |||
| key: "status", | |||
| label: t("status"), | |||
| value: (wl) => tr.lotLineStatus(wl.status), | |||
| }, | |||
| ]} | |||
| /> | |||
| )} | |||
| {block.origins.length > 0 && ( | |||
| <SectionTable | |||
| title={t("origins")} | |||
| rows={block.origins} | |||
| getRowKey={(o) => String(o.stockInLineId)} | |||
| columns={[ | |||
| { | |||
| key: "type", | |||
| label: t("type"), | |||
| value: (o) => o.type, | |||
| cell: (o) => ( | |||
| <Chip | |||
| label={o.type} | |||
| size="small" | |||
| variant="outlined" | |||
| /> | |||
| ), | |||
| }, | |||
| { | |||
| key: "refCode", | |||
| label: t("refCode"), | |||
| value: (o) => o.refCode, | |||
| }, | |||
| { | |||
| key: "supplier", | |||
| label: t("supplier"), | |||
| value: (o) => o.supplierName || o.supplierCode, | |||
| }, | |||
| { | |||
| key: "qty", | |||
| label: t("qty"), | |||
| align: "right", | |||
| value: (o) => o.acceptedQty, | |||
| }, | |||
| { | |||
| key: "date", | |||
| label: t("date"), | |||
| value: (o) => o.receiptDate, | |||
| }, | |||
| ]} | |||
| /> | |||
| )} | |||
| {block.qcResults.length > 0 && ( | |||
| <SectionTable | |||
| title={t("tabQc")} | |||
| rows={block.qcResults} | |||
| getRowKey={(qc) => `${qc.stockInLineId}-${qc.created}`} | |||
| columns={[ | |||
| { | |||
| key: "qcPassed", | |||
| label: t("qcPassed"), | |||
| value: (qc) => | |||
| qc.qcPassed ? t("qcPassed") : t("qcFailed"), | |||
| cell: (qc) => ( | |||
| <Chip | |||
| label={qc.qcPassed ? t("qcPassed") : t("qcFailed")} | |||
| size="small" | |||
| color={qc.qcPassed ? "success" : "error"} | |||
| variant="outlined" | |||
| /> | |||
| ), | |||
| }, | |||
| { | |||
| key: "acceptedQty", | |||
| label: t("acceptedQty"), | |||
| align: "right", | |||
| value: (qc) => qc.acceptedQty, | |||
| }, | |||
| { | |||
| key: "failQty", | |||
| label: t("failQty"), | |||
| align: "right", | |||
| value: (qc) => qc.failQty, | |||
| }, | |||
| { | |||
| key: "qcType", | |||
| label: t("detailQcType"), | |||
| value: (qc) => qc.qcType, | |||
| }, | |||
| { | |||
| key: "handler", | |||
| label: t("handler"), | |||
| value: (qc) => qc.handledBy, | |||
| }, | |||
| { | |||
| key: "date", | |||
| label: t("date"), | |||
| value: (qc) => qc.created, | |||
| }, | |||
| ]} | |||
| /> | |||
| )} | |||
| {block.purchaseEvents && block.purchaseEvents.length > 0 && ( | |||
| <SectionTable | |||
| title={t("categoryPurchase")} | |||
| rows={block.purchaseEvents} | |||
| getRowKey={(pe) => String(pe.purchaseOrderLineId)} | |||
| columns={[ | |||
| { | |||
| key: "po", | |||
| label: t("detailPurchaseOrderNo"), | |||
| value: (pe) => pe.purchaseOrderCode, | |||
| }, | |||
| { | |||
| key: "supplier", | |||
| label: t("supplier"), | |||
| value: (pe) => pe.supplierName || pe.supplierCode, | |||
| }, | |||
| { | |||
| key: "orderQty", | |||
| label: t("detailOrderQty"), | |||
| align: "right", | |||
| value: (pe) => pe.orderQty, | |||
| }, | |||
| { | |||
| key: "putAwayQty", | |||
| label: t("detailPutAwayQty"), | |||
| align: "right", | |||
| value: (pe) => pe.putAwayQty, | |||
| }, | |||
| { | |||
| key: "date", | |||
| label: t("date"), | |||
| value: (pe) => pe.orderDate, | |||
| }, | |||
| ]} | |||
| /> | |||
| )} | |||
| {block.putawayEvents.length > 0 && ( | |||
| <SectionTable | |||
| title={t("nodePutaway")} | |||
| rows={block.putawayEvents} | |||
| getRowKey={(pa, idx) => `${pa.inventoryLotLineId}-${idx}`} | |||
| columns={[ | |||
| { | |||
| key: "warehouse", | |||
| label: t("warehouse"), | |||
| value: (pa) => pa.warehouseCode, | |||
| }, | |||
| { | |||
| key: "qty", | |||
| label: t("qty"), | |||
| align: "right", | |||
| value: (pa) => pa.qty, | |||
| }, | |||
| { | |||
| key: "handler", | |||
| label: t("handler"), | |||
| value: (pa) => pa.handledBy, | |||
| }, | |||
| { | |||
| key: "refCode", | |||
| label: t("refCode"), | |||
| value: (pa) => pa.refCode, | |||
| }, | |||
| { | |||
| key: "date", | |||
| label: t("date"), | |||
| value: (pa) => pa.timestamp, | |||
| }, | |||
| ]} | |||
| /> | |||
| )} | |||
| {block.movements.length > 0 && ( | |||
| <SectionTable | |||
| title={t("movements")} | |||
| rows={block.movements} | |||
| getRowKey={(m, idx) => `${m.refCode}-${m.timestamp}-${idx}`} | |||
| columns={[ | |||
| { | |||
| key: "direction", | |||
| label: t("direction"), | |||
| value: (m) => m.direction, | |||
| cell: (m) => ( | |||
| <Chip | |||
| label={m.direction} | |||
| size="small" | |||
| color={m.direction === "IN" ? "success" : "error"} | |||
| variant="outlined" | |||
| /> | |||
| ), | |||
| }, | |||
| { | |||
| key: "type", | |||
| label: t("type"), | |||
| value: (m) => m.refType, | |||
| }, | |||
| { | |||
| key: "refCode", | |||
| label: t("refCode"), | |||
| value: (m) => m.refCode, | |||
| }, | |||
| { | |||
| key: "qty", | |||
| label: t("qty"), | |||
| align: "right", | |||
| value: (m) => m.qty, | |||
| }, | |||
| { | |||
| key: "warehouse", | |||
| label: t("warehouse"), | |||
| value: (m) => m.warehouseCode, | |||
| }, | |||
| { | |||
| key: "date", | |||
| label: t("date"), | |||
| value: (m) => m.timestamp, | |||
| }, | |||
| ]} | |||
| /> | |||
| )} | |||
| {block.outboundUsage.length > 0 && ( | |||
| <SectionTable | |||
| title={t("outbound")} | |||
| rows={block.outboundUsage} | |||
| getRowKey={(o, idx) => `${o.stockOutLineId}-${idx}`} | |||
| columns={[ | |||
| { | |||
| key: "pickOrder", | |||
| label: t("pickOrder"), | |||
| value: (o) => o.pickOrderCode || o.deliveryOrderCode, | |||
| }, | |||
| { | |||
| key: "type", | |||
| label: t("type"), | |||
| value: (o) => tr.usageType(o.usageType), | |||
| cell: (o) => ( | |||
| <Chip | |||
| label={tr.usageType(o.usageType)} | |||
| size="small" | |||
| variant="outlined" | |||
| /> | |||
| ), | |||
| }, | |||
| { | |||
| key: "qty", | |||
| label: t("qty"), | |||
| align: "right", | |||
| value: (o) => o.qty, | |||
| }, | |||
| { | |||
| key: "handler", | |||
| label: t("handler"), | |||
| value: (o) => o.handler, | |||
| }, | |||
| { | |||
| key: "date", | |||
| label: t("date"), | |||
| value: (o) => o.timestamp, | |||
| }, | |||
| ]} | |||
| /> | |||
| )} | |||
| {block.stockTakeEvents.length > 0 && ( | |||
| <SectionTable | |||
| title={t("stockTake")} | |||
| rows={block.stockTakeEvents} | |||
| getRowKey={(st, idx) => `${st.stockTakeCode}-${idx}`} | |||
| columns={[ | |||
| { | |||
| key: "code", | |||
| label: t("stockTakeCode"), | |||
| value: (st) => st.stockTakeCode, | |||
| }, | |||
| { | |||
| key: "section", | |||
| label: t("section"), | |||
| value: (st) => st.stockTakeSection, | |||
| }, | |||
| { | |||
| key: "round", | |||
| label: t("round"), | |||
| value: (st) => st.stockTakeRoundName, | |||
| }, | |||
| { | |||
| key: "before", | |||
| label: t("before"), | |||
| align: "right", | |||
| value: (st) => | |||
| resolveStockTakeBookQty(st.recordDetail, st.beforeQty), | |||
| }, | |||
| { | |||
| key: "after", | |||
| label: t("after"), | |||
| align: "right", | |||
| value: (st) => | |||
| resolveStockTakeAcceptedQty( | |||
| st.recordDetail, | |||
| st.afterQty, | |||
| ), | |||
| }, | |||
| { | |||
| key: "variance", | |||
| label: t("variance"), | |||
| align: "right", | |||
| value: (st) => | |||
| st.recordDetail?.varianceQty != null | |||
| ? st.recordDetail.varianceQty | |||
| : st.varianceQty, | |||
| }, | |||
| { | |||
| key: "date", | |||
| label: t("date"), | |||
| value: (st) => st.timestamp, | |||
| }, | |||
| ]} | |||
| /> | |||
| )} | |||
| {block.adjustments.length > 0 && ( | |||
| <SectionTable | |||
| title={t("tabAdjustments")} | |||
| rows={block.adjustments} | |||
| getRowKey={(adj, idx) => | |||
| `${adj.refCode}-${adj.timestamp}-${idx}` | |||
| } | |||
| columns={[ | |||
| { | |||
| key: "type", | |||
| label: t("type"), | |||
| value: (adj) => adj.adjustmentType, | |||
| }, | |||
| { | |||
| key: "direction", | |||
| label: t("direction"), | |||
| value: (adj) => adj.direction, | |||
| cell: (adj) => ( | |||
| <Chip | |||
| label={adj.direction} | |||
| size="small" | |||
| color={adj.direction === "IN" ? "success" : "error"} | |||
| variant="outlined" | |||
| /> | |||
| ), | |||
| }, | |||
| { | |||
| key: "qty", | |||
| label: t("qty"), | |||
| align: "right", | |||
| value: (adj) => adj.qty, | |||
| }, | |||
| { | |||
| key: "remarks", | |||
| label: t("remarks"), | |||
| value: (adj) => adj.reason, | |||
| }, | |||
| { | |||
| key: "handler", | |||
| label: t("handler"), | |||
| value: (adj) => adj.handledBy, | |||
| }, | |||
| { | |||
| key: "date", | |||
| label: t("date"), | |||
| value: (adj) => adj.timestamp, | |||
| }, | |||
| ]} | |||
| /> | |||
| )} | |||
| {block.transfers.length > 0 && ( | |||
| <SectionTable | |||
| title={t("tabTransfers")} | |||
| rows={block.transfers} | |||
| getRowKey={(xfer, idx) => `${xfer.transferCode}-${idx}`} | |||
| columns={[ | |||
| { | |||
| key: "from", | |||
| label: t("from"), | |||
| value: (xfer) => xfer.fromWarehouse, | |||
| }, | |||
| { | |||
| key: "to", | |||
| label: t("to"), | |||
| value: (xfer) => xfer.toWarehouse, | |||
| }, | |||
| { | |||
| key: "qty", | |||
| label: t("qty"), | |||
| align: "right", | |||
| value: (xfer) => xfer.qty, | |||
| }, | |||
| { | |||
| key: "refCode", | |||
| label: t("refCode"), | |||
| value: (xfer) => xfer.transferCode, | |||
| }, | |||
| { | |||
| key: "date", | |||
| label: t("date"), | |||
| value: (xfer) => xfer.timestamp, | |||
| }, | |||
| ]} | |||
| /> | |||
| )} | |||
| {block.doDeliveries.length > 0 && ( | |||
| <SectionTable | |||
| title={t("deliveryOrder")} | |||
| rows={block.doDeliveries} | |||
| getRowKey={(dd, idx) => `${dd.stockOutLineId}-${idx}`} | |||
| columns={[ | |||
| { | |||
| key: "pickOrder", | |||
| label: t("pickOrder"), | |||
| value: (dd) => dd.pickOrderCode, | |||
| }, | |||
| { | |||
| key: "deliveryOrder", | |||
| label: t("deliveryOrder"), | |||
| value: (dd) => dd.deliveryOrderCode, | |||
| }, | |||
| { | |||
| key: "deliveryNoteCode", | |||
| label: t("deliveryNoteCode"), | |||
| value: (dd) => dd.deliveryNoteCode || "—", | |||
| }, | |||
| { | |||
| key: "ticketNo", | |||
| label: t("ticketNo"), | |||
| value: (dd) => dd.ticketNo || "—", | |||
| }, | |||
| { | |||
| key: "qty", | |||
| label: t("qty"), | |||
| align: "right", | |||
| value: (dd) => dd.qty, | |||
| }, | |||
| { | |||
| key: "handler", | |||
| label: t("handler"), | |||
| value: (dd) => dd.handler, | |||
| }, | |||
| { | |||
| key: "date", | |||
| label: t("date"), | |||
| value: (dd) => dd.timestamp, | |||
| }, | |||
| ]} | |||
| /> | |||
| )} | |||
| {block.failEvents.length > 0 && ( | |||
| <SectionTable | |||
| title={t("nodeFail")} | |||
| rows={block.failEvents} | |||
| getRowKey={(fe) => String(fe.failId)} | |||
| columns={[ | |||
| { | |||
| key: "category", | |||
| label: t("detailFailCategory"), | |||
| value: (fe) => `${fe.failType} / ${fe.category}`, | |||
| }, | |||
| { | |||
| key: "qty", | |||
| label: t("qty"), | |||
| align: "right", | |||
| value: (fe) => fe.qty, | |||
| }, | |||
| { | |||
| key: "pickOrder", | |||
| label: t("pickOrder"), | |||
| value: (fe) => fe.pickOrderCode, | |||
| }, | |||
| { | |||
| key: "handler", | |||
| label: t("handler"), | |||
| value: (fe) => fe.handlerName, | |||
| }, | |||
| { | |||
| key: "date", | |||
| label: t("date"), | |||
| value: (fe) => fe.recordDate, | |||
| }, | |||
| ]} | |||
| /> | |||
| )} | |||
| {block.returnEvents.length > 0 && ( | |||
| <SectionTable | |||
| title={t("nodeReturn")} | |||
| rows={block.returnEvents} | |||
| getRowKey={(re, idx) => `${re.stockOutLineId}-${idx}`} | |||
| columns={[ | |||
| { | |||
| key: "type", | |||
| label: t("type"), | |||
| value: (re) => re.movementType, | |||
| }, | |||
| { | |||
| key: "qty", | |||
| label: t("qty"), | |||
| align: "right", | |||
| value: (re) => re.qty, | |||
| }, | |||
| { | |||
| key: "warehouse", | |||
| label: t("warehouse"), | |||
| value: (re) => re.warehouseCode, | |||
| }, | |||
| { | |||
| key: "remarks", | |||
| label: t("remarks"), | |||
| value: (re) => re.remarks, | |||
| }, | |||
| { | |||
| key: "date", | |||
| label: t("date"), | |||
| value: (re) => re.timestamp, | |||
| }, | |||
| ]} | |||
| /> | |||
| )} | |||
| {block.openMovements.length > 0 && ( | |||
| <SectionTable | |||
| title={t("nodeOpen")} | |||
| rows={block.openMovements} | |||
| getRowKey={(om) => String(om.stockInLineId)} | |||
| columns={[ | |||
| { | |||
| key: "warehouse", | |||
| label: t("warehouse"), | |||
| value: (om) => om.warehouseCode, | |||
| }, | |||
| { | |||
| key: "qty", | |||
| label: t("qty"), | |||
| align: "right", | |||
| value: (om) => om.qty, | |||
| }, | |||
| { | |||
| key: "refCode", | |||
| label: t("refCode"), | |||
| value: (om) => om.refCode, | |||
| }, | |||
| { | |||
| key: "handler", | |||
| label: t("handler"), | |||
| value: (om) => om.handledBy, | |||
| }, | |||
| { | |||
| key: "date", | |||
| label: t("date"), | |||
| value: (om) => om.timestamp, | |||
| }, | |||
| ]} | |||
| /> | |||
| )} | |||
| </Stack> | |||
| </AccordionDetails> | |||
| </Accordion> | |||
| ); | |||
| })} | |||
| </Stack> | |||
| ); | |||
| }; | |||
| export default ItemTracingLocations; | |||
| @@ -0,0 +1,50 @@ | |||
| "use client"; | |||
| import Link from "next/link"; | |||
| import MuiLink, { type LinkProps as MuiLinkProps } from "@mui/material/Link"; | |||
| import { buildItemTracingHref } from "./traceNavigationUtils"; | |||
| type Props = { | |||
| label: string; | |||
| lotNo?: string; | |||
| itemCode?: string; | |||
| stockInLineId?: number; | |||
| /** Prevent React Flow node selection when the link sits on a graph card. */ | |||
| stopPropagation?: boolean; | |||
| variant?: MuiLinkProps["variant"]; | |||
| sx?: MuiLinkProps["sx"]; | |||
| }; | |||
| const stopGraphEvent = (e: React.MouseEvent) => { | |||
| e.stopPropagation(); | |||
| }; | |||
| const ItemTracingLotTraceLink: React.FC<Props> = ({ | |||
| label, | |||
| lotNo, | |||
| itemCode, | |||
| stockInLineId, | |||
| stopPropagation = false, | |||
| variant = "caption", | |||
| sx, | |||
| }) => { | |||
| if (!lotNo?.trim() && stockInLineId == null) return null; | |||
| return ( | |||
| <MuiLink | |||
| component={Link} | |||
| href={buildItemTracingHref({ lotNo, itemCode, stockInLineId })} | |||
| underline="hover" | |||
| target="_blank" | |||
| rel="noopener noreferrer" | |||
| variant={variant} | |||
| sx={sx} | |||
| onClick={stopPropagation ? stopGraphEvent : undefined} | |||
| onMouseDown={stopPropagation ? stopGraphEvent : undefined} | |||
| > | |||
| {label} | |||
| </MuiLink> | |||
| ); | |||
| }; | |||
| export default ItemTracingLotTraceLink; | |||
| @@ -0,0 +1,265 @@ | |||
| "use client"; | |||
| import { | |||
| Box, | |||
| Chip, | |||
| Divider, | |||
| IconButton, | |||
| List, | |||
| ListItem, | |||
| ListItemText, | |||
| Paper, | |||
| Stack, | |||
| Table, | |||
| TableBody, | |||
| TableCell, | |||
| TableRow, | |||
| Typography, | |||
| } from "@mui/material"; | |||
| import CloseIcon from "@mui/icons-material/Close"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { TraceGraphNode } from "./buildTraceGraphNodes"; | |||
| import ItemTracingDocLink from "./ItemTracingDocLink"; | |||
| import ItemTracingLotTraceLink from "./ItemTracingLotTraceLink"; | |||
| import ItemTracingStockTakeLifecycle from "./ItemTracingStockTakeLifecycle"; | |||
| import { formatQty } from "./traceQtyUtils"; | |||
| import { buildStockTakeLifecycleStages } from "./traceStockTakeUtils"; | |||
| type Props = { | |||
| node: TraceGraphNode; | |||
| onClose?: () => void; | |||
| }; | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 15 | v1.0.1 | 2026-07-20 */ | |||
| const ItemTracingNodeDetailPanel: React.FC<Props> = ({ node, onClose }) => { | |||
| const { t } = useTranslation("itemTracing"); | |||
| const hasDocLink = node.docLinkKind && node.refCode; | |||
| const lifecycleStages = | |||
| node.kind === "STOCK_TAKE" && node.stockTakeRecordDetail | |||
| ? buildStockTakeLifecycleStages(node.stockTakeRecordDetail) | |||
| : []; | |||
| const activeStageCount = lifecycleStages.filter((s) => s.isActive).length; | |||
| return ( | |||
| <Paper | |||
| elevation={0} | |||
| square | |||
| sx={{ | |||
| width: "100%", | |||
| height: "100%", | |||
| display: "flex", | |||
| flexDirection: "column", | |||
| bgcolor: "background.paper", | |||
| borderRight: 1, | |||
| borderColor: "divider", | |||
| overflow: "hidden", | |||
| }} | |||
| > | |||
| <Box | |||
| sx={{ | |||
| px: 1.5, | |||
| py: 1.25, | |||
| borderBottom: 1, | |||
| borderColor: "divider", | |||
| flexShrink: 0, | |||
| }} | |||
| > | |||
| <Stack direction="row" spacing={0.5} alignItems="flex-start" justifyContent="space-between"> | |||
| <Stack spacing={0.5} sx={{ minWidth: 0, flex: 1 }}> | |||
| <Typography variant="subtitle2" fontWeight={700}> | |||
| {t("nodeDetailTitle")} | |||
| </Typography> | |||
| {node.categoryLabel && ( | |||
| <Chip size="small" label={node.categoryLabel} color="primary" variant="outlined" /> | |||
| )} | |||
| </Stack> | |||
| {onClose && ( | |||
| <IconButton size="small" onClick={onClose} aria-label={t("nodeDetailClose")} sx={{ mt: -0.5 }}> | |||
| <CloseIcon fontSize="small" /> | |||
| </IconButton> | |||
| )} | |||
| </Stack> | |||
| </Box> | |||
| <Box sx={{ flex: 1, overflow: "auto", px: 1.5, py: 1.25 }}> | |||
| <Typography variant="subtitle2" fontWeight={600} gutterBottom> | |||
| {hasDocLink ? ( | |||
| <ItemTracingDocLink | |||
| kind={node.docLinkKind!} | |||
| code={node.refCode!} | |||
| id={node.refId} | |||
| consoCode={node.consoCode ?? node.refCode} | |||
| ticketNo={node.docLinkTicketNo} | |||
| targetDate={node.docLinkTargetDate} | |||
| openInNewTab | |||
| /> | |||
| ) : ( | |||
| node.title | |||
| )} | |||
| </Typography> | |||
| {node.subtitle && ( | |||
| <Typography variant="caption" color="text.secondary" display="block" gutterBottom> | |||
| {node.subtitle} | |||
| </Typography> | |||
| )} | |||
| <Table size="small" sx={{ mt: 0.5 }}> | |||
| <TableBody> | |||
| {node.details.map((row, i) => ( | |||
| <TableRow key={`${row.label}-${i}`}> | |||
| <TableCell | |||
| component="th" | |||
| scope="row" | |||
| sx={{ | |||
| width: "42%", | |||
| fontWeight: 600, | |||
| color: "text.secondary", | |||
| border: 0, | |||
| py: 0.5, | |||
| pl: 0, | |||
| fontSize: "0.75rem", | |||
| verticalAlign: "top", | |||
| }} | |||
| > | |||
| {row.label} | |||
| </TableCell> | |||
| <TableCell | |||
| sx={{ | |||
| border: 0, | |||
| py: 0.5, | |||
| pr: 0, | |||
| fontSize: "0.8125rem", | |||
| whiteSpace: "pre-line", | |||
| verticalAlign: "top", | |||
| }} | |||
| > | |||
| {row.variant === "qcCriteriaList" && row.qcCriteriaItems?.length ? ( | |||
| <List dense disablePadding sx={{ width: "100%" }}> | |||
| {row.qcCriteriaItems.map((item, idx) => ( | |||
| <ListItem | |||
| key={`${item.name}-${idx}`} | |||
| disableGutters | |||
| alignItems="flex-start" | |||
| sx={{ | |||
| py: 0.5, | |||
| borderBottom: | |||
| idx < row.qcCriteriaItems!.length - 1 ? "1px solid" : "none", | |||
| borderColor: "divider", | |||
| }} | |||
| > | |||
| <ListItemText | |||
| primary={ | |||
| <Stack | |||
| direction="row" | |||
| spacing={0.5} | |||
| alignItems="center" | |||
| flexWrap="wrap" | |||
| useFlexGap | |||
| > | |||
| <Typography variant="caption" fontWeight={600} component="span"> | |||
| {item.name} | |||
| </Typography> | |||
| <Chip | |||
| size="small" | |||
| label={item.passed ? t("qcPassed") : t("qcFailed")} | |||
| color={item.passed ? "success" : "error"} | |||
| variant="outlined" | |||
| sx={{ height: 20, "& .MuiChip-label": { px: 0.75, fontSize: "0.65rem" } }} | |||
| /> | |||
| {!item.passed && item.failQty != null && item.failQty > 0 && ( | |||
| <Typography variant="caption" color="error.main" component="span"> | |||
| {t("failQty")}: {formatQty(item.failQty, node.uom)} | |||
| </Typography> | |||
| )} | |||
| </Stack> | |||
| } | |||
| secondary={ | |||
| item.description ? ( | |||
| <Typography | |||
| variant="caption" | |||
| color="text.secondary" | |||
| component="span" | |||
| sx={{ display: "block", mt: 0.25, lineHeight: 1.4 }} | |||
| > | |||
| {item.description} | |||
| </Typography> | |||
| ) : null | |||
| } | |||
| /> | |||
| </ListItem> | |||
| ))} | |||
| </List> | |||
| ) : row.linkKind && row.linkCode ? ( | |||
| <ItemTracingDocLink | |||
| kind={row.linkKind} | |||
| code={row.linkCode} | |||
| id={row.linkId} | |||
| consoCode={row.consoCode ?? row.linkCode} | |||
| ticketNo={row.linkTicketNo} | |||
| targetDate={row.linkTargetDate} | |||
| openInNewTab | |||
| /> | |||
| ) : ( | |||
| <Typography | |||
| component="span" | |||
| sx={{ | |||
| fontSize: "inherit", | |||
| fontWeight: row.valueColor && row.valueColor !== "default" ? 700 : undefined, | |||
| color: | |||
| row.valueColor && row.valueColor !== "default" | |||
| ? `${row.valueColor}.main` | |||
| : undefined, | |||
| }} | |||
| > | |||
| {row.value || "—"} | |||
| </Typography> | |||
| )} | |||
| </TableCell> | |||
| </TableRow> | |||
| ))} | |||
| </TableBody> | |||
| </Table> | |||
| {node.traceLotNo ? ( | |||
| <Box sx={{ mt: 1.5 }}> | |||
| <ItemTracingLotTraceLink | |||
| label={ | |||
| node.kind === "BYPRODUCT" | |||
| ? t("traceByproductLot") | |||
| : node.kind === "REPACK" | |||
| ? t("traceRepackLot") | |||
| : t("traceMaterialLot") | |||
| } | |||
| lotNo={node.traceLotNo} | |||
| itemCode={node.traceItemCode} | |||
| variant="body2" | |||
| sx={{ display: "inline-block" }} | |||
| /> | |||
| </Box> | |||
| ) : null} | |||
| {lifecycleStages.length > 0 && ( | |||
| <> | |||
| <Divider sx={{ my: 1.5 }} /> | |||
| <Typography variant="subtitle2" fontWeight={700} gutterBottom> | |||
| {t("nodeStockTake")} · {t("stockTakeStageDetail", { count: activeStageCount })} | |||
| </Typography> | |||
| <ItemTracingStockTakeLifecycle stages={lifecycleStages} uom={node.uom} /> | |||
| </> | |||
| )} | |||
| {node.meta && ( | |||
| <> | |||
| <Divider sx={{ my: 1.5 }} /> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {node.meta} | |||
| </Typography> | |||
| </> | |||
| )} | |||
| </Box> | |||
| </Paper> | |||
| ); | |||
| }; | |||
| export default ItemTracingNodeDetailPanel; | |||
| @@ -0,0 +1,142 @@ | |||
| "use client"; | |||
| import { | |||
| Alert, | |||
| Button, | |||
| Chip, | |||
| Paper, | |||
| Stack, | |||
| TextField, | |||
| Typography, | |||
| } from "@mui/material"; | |||
| import QrCodeScannerIcon from "@mui/icons-material/QrCodeScanner"; | |||
| import SearchIcon from "@mui/icons-material/Search"; | |||
| import { useCallback, useEffect, useState } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { useQrCodeScannerContext } from "../QrCodeScannerProvider/QrCodeScannerProvider"; | |||
| type ScanBarProps = { | |||
| onTrace: (params: { | |||
| stockInLineId?: number; | |||
| lotNo?: string; | |||
| itemCode?: string; | |||
| }) => void; | |||
| loading: boolean; | |||
| lastLotNo?: string; | |||
| }; | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 12 | v1.0.0 | 2026-07-17 */ | |||
| const ItemTracingScanBar: React.FC<ScanBarProps> = ({ | |||
| onTrace, | |||
| loading, | |||
| lastLotNo, | |||
| }) => { | |||
| const { t } = useTranslation("itemTracing"); | |||
| const scanner = useQrCodeScannerContext(); | |||
| const [scanMode, setScanMode] = useState<"idle" | "wedge">("idle"); | |||
| const [itemCode, setItemCode] = useState(""); | |||
| const [lotNo, setLotNo] = useState(""); | |||
| const [scanError, setScanError] = useState<string | null>(null); | |||
| const startWedgeScan = useCallback(() => { | |||
| setScanError(null); | |||
| setScanMode("wedge"); | |||
| scanner.resetScan(); | |||
| scanner.startScan(); | |||
| }, [scanner]); | |||
| const stopWedgeScan = useCallback(() => { | |||
| scanner.stopScan(); | |||
| scanner.resetScan(); | |||
| setScanMode("idle"); | |||
| }, [scanner]); | |||
| useEffect(() => { | |||
| if (scanMode !== "wedge") return; | |||
| const itemId = scanner.result?.itemId; | |||
| const stockInLineId = scanner.result?.stockInLineId; | |||
| if (!itemId || !stockInLineId) return; | |||
| setScanError(null); | |||
| stopWedgeScan(); | |||
| onTrace({ stockInLineId: Number(stockInLineId) }); | |||
| }, [scanMode, scanner.result, onTrace, stopWedgeScan]); | |||
| const handleManualSearch = () => { | |||
| const code = itemCode.trim(); | |||
| const lot = lotNo.trim(); | |||
| if (!code || !lot) return; | |||
| onTrace({ itemCode: code, lotNo: lot }); | |||
| }; | |||
| return ( | |||
| <Paper variant="outlined" sx={{ p: 2 }}> | |||
| <Stack spacing={2}> | |||
| <Stack | |||
| direction={{ xs: "column", sm: "row" }} | |||
| spacing={1} | |||
| alignItems={{ sm: "center" }} | |||
| > | |||
| <Button | |||
| variant={scanMode === "wedge" ? "contained" : "outlined"} | |||
| startIcon={<QrCodeScannerIcon />} | |||
| onClick={scanMode === "wedge" ? stopWedgeScan : startWedgeScan} | |||
| disabled={loading} | |||
| > | |||
| {scanMode === "wedge" ? t("scanning") : t("scanAgain")} | |||
| </Button> | |||
| {lastLotNo && ( | |||
| <Chip | |||
| label={lastLotNo} | |||
| color="primary" | |||
| variant="outlined" | |||
| sx={{ ml: { sm: "auto" } }} | |||
| /> | |||
| )} | |||
| </Stack> | |||
| {scanError && ( | |||
| <Alert severity="warning" onClose={() => setScanError(null)}> | |||
| {scanError} | |||
| </Alert> | |||
| )} | |||
| {scanMode === "wedge" && ( | |||
| <Alert severity="info" onClose={stopWedgeScan}> | |||
| {t("scanReady")} | |||
| </Alert> | |||
| )} | |||
| <Typography variant="subtitle2">{t("manualSearch")}</Typography> | |||
| <Stack direction={{ xs: "column", sm: "row" }} spacing={1}> | |||
| <TextField | |||
| size="small" | |||
| label={t("itemCode")} | |||
| value={itemCode} | |||
| onChange={(e) => setItemCode(e.target.value)} | |||
| disabled={loading} | |||
| fullWidth | |||
| /> | |||
| <TextField | |||
| size="small" | |||
| label={t("lotNo")} | |||
| value={lotNo} | |||
| onChange={(e) => setLotNo(e.target.value)} | |||
| disabled={loading} | |||
| fullWidth | |||
| /> | |||
| <Button | |||
| variant="contained" | |||
| startIcon={<SearchIcon />} | |||
| onClick={handleManualSearch} | |||
| disabled={loading || !itemCode.trim() || !lotNo.trim()} | |||
| sx={{ minWidth: 120 }} | |||
| > | |||
| {loading ? t("searching") : t("search")} | |||
| </Button> | |||
| </Stack> | |||
| </Stack> | |||
| </Paper> | |||
| ); | |||
| }; | |||
| export default ItemTracingScanBar; | |||
| @@ -0,0 +1,910 @@ | |||
| "use client"; | |||
| import { | |||
| Box, | |||
| Chip, | |||
| Checkbox, | |||
| Link as MuiLink, | |||
| Paper, | |||
| Tab, | |||
| Tabs, | |||
| Typography, | |||
| } from "@mui/material"; | |||
| import { useMemo, useState } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { ItemLotTraceResponse } from "@/app/api/itemTracing"; | |||
| import ItemTracingDocLink from "./ItemTracingDocLink"; | |||
| import ItemTracingLotTraceLink from "./ItemTracingLotTraceLink"; | |||
| import { createTraceLabelTranslator, qcItemLabel } from "./traceLabelUtils"; | |||
| import { formatNum, formatQty } from "./traceQtyUtils"; | |||
| import { normalizeTargetDateForLink } from "./traceDocLinkUtils"; | |||
| import type { CompiledTraceGraph } from "./compileTraceGraph"; | |||
| import { buildTracePresentationRows } from "./tracePresentationAdapter"; | |||
| import { | |||
| hasMultipleLocations, | |||
| mergeScopedAdjustments, | |||
| mergeScopedOrigins, | |||
| mergeScopedOutboundUsage, | |||
| mergeScopedQcResults, | |||
| mergeScopedStockTakeEvents, | |||
| mergeScopedTransfers, | |||
| } from "./mergeLocationScopedData"; | |||
| import { | |||
| resolveStockTakeAcceptedQty, | |||
| resolveStockTakeBookQty, | |||
| } from "./traceStockTakeUtils"; | |||
| import { | |||
| FilterableDataTable, | |||
| type FilterableColumnDef, | |||
| } from "./itemTracingTableFilters"; | |||
| type TraceParams = { stockInLineId?: number; lotNo?: string; itemCode?: string }; | |||
| type Props = { | |||
| data: ItemLotTraceResponse; | |||
| compiledGraph: CompiledTraceGraph; | |||
| onTrace?: (params: TraceParams) => void; | |||
| }; | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 17 | v1.0.1 | 2026-07-20 */ | |||
| const ItemTracingSections: React.FC<Props> = ({ data, compiledGraph, onTrace }) => { | |||
| const { t } = useTranslation("itemTracing"); | |||
| const [tab, setTab] = useState(0); | |||
| const { bomTrace, joPrelude, lot } = data; | |||
| const tr = useMemo(() => createTraceLabelTranslator(t), [t]); | |||
| const presentationRows = useMemo( | |||
| () => buildTracePresentationRows(compiledGraph.nodes, data), | |||
| [compiledGraph.nodes, data], | |||
| ); | |||
| const multiLocation = hasMultipleLocations(data); | |||
| const mergedOrigins = useMemo(() => mergeScopedOrigins(data), [data]); | |||
| const mergedQc = useMemo(() => mergeScopedQcResults(data), [data]); | |||
| const mergedOutbound = useMemo(() => mergeScopedOutboundUsage(data), [data]); | |||
| const mergedStockTake = useMemo(() => mergeScopedStockTakeEvents(data), [data]); | |||
| const mergedAdjustments = useMemo(() => mergeScopedAdjustments(data), [data]); | |||
| const mergedTransfers = useMemo(() => mergeScopedTransfers(data), [data]); | |||
| const stockUom = lot.uom; | |||
| const tabKeys = useMemo(() => { | |||
| const keys = [ | |||
| "origins", | |||
| "qc", | |||
| "outbound", | |||
| "stockTake", | |||
| "adjustments", | |||
| "transfers", | |||
| "bom", | |||
| ] as const; | |||
| if (joPrelude) { | |||
| return [...keys, "joPick"] as const; | |||
| } | |||
| return keys; | |||
| }, [joPrelude]); | |||
| const activeTab = tabKeys[tab] ?? tabKeys[0]; | |||
| const bomDirectionLabel = | |||
| bomTrace.direction === "FINISHED_GOOD" | |||
| ? t("bomDirectionFG") | |||
| : bomTrace.direction === "MATERIAL" | |||
| ? t("bomDirectionMaterial") | |||
| : t("bomDirectionUnknown"); | |||
| const useJoMaterialInputs = joPrelude != null && joPrelude.materialInputs.length > 0; | |||
| const checkboxCellFromStatus = ( | |||
| status?: string | null, | |||
| opts?: { checkedWhen?: Array<string>; rejectedWhen?: Array<string> }, | |||
| ): React.ReactNode => { | |||
| const s = String(status || "").trim().toLowerCase(); | |||
| const checkedWhen = opts?.checkedWhen ?? []; | |||
| const rejectedWhen = opts?.rejectedWhen ?? []; | |||
| const isRejected = rejectedWhen.includes(s); | |||
| const isChecked = checkedWhen.includes(s); | |||
| if (isRejected) { | |||
| return ( | |||
| <Checkbox | |||
| checked | |||
| disabled | |||
| readOnly | |||
| size="small" | |||
| sx={{ color: "error.main", "&.Mui-checked": { color: "error.main" } }} | |||
| /> | |||
| ); | |||
| } | |||
| if (isChecked) { | |||
| return ( | |||
| <Checkbox | |||
| checked | |||
| disabled | |||
| readOnly | |||
| size="small" | |||
| sx={{ color: "success.main", "&.Mui-checked": { color: "success.main" } }} | |||
| /> | |||
| ); | |||
| } | |||
| return <Checkbox checked={false} disabled readOnly size="small" />; | |||
| }; | |||
| const originCols = useMemo((): FilterableColumnDef<(typeof mergedOrigins)[number]>[] => { | |||
| const cols: FilterableColumnDef<(typeof mergedOrigins)[number]>[] = []; | |||
| if (multiLocation) { | |||
| cols.push({ | |||
| key: "warehouse", | |||
| label: t("warehouse"), | |||
| value: (o) => o.scopeWarehouseCode, | |||
| }); | |||
| } | |||
| cols.push( | |||
| { | |||
| key: "type", | |||
| label: t("type"), | |||
| value: (o) => tr.refType(o.type), | |||
| }, | |||
| { | |||
| key: "ref", | |||
| label: t("ref"), | |||
| value: (o) => o.refCode || "—", | |||
| cell: (o) => | |||
| o.type === "PO" ? ( | |||
| <ItemTracingDocLink kind="po" code={o.refCode} id={o.refId} /> | |||
| ) : o.type === "JO" ? ( | |||
| <ItemTracingDocLink kind="jo" code={o.refCode} id={o.refId} /> | |||
| ) : ( | |||
| o.refCode || "—" | |||
| ), | |||
| }, | |||
| { | |||
| key: "supplier", | |||
| label: t("supplier"), | |||
| value: (o) => [o.supplierCode, o.supplierName].filter(Boolean).join(" "), | |||
| }, | |||
| { key: "dnNo", label: t("dnNo"), value: (o) => o.dnNo || "—" }, | |||
| { | |||
| key: "qty", | |||
| label: t("qty"), | |||
| align: "right", | |||
| value: (o) => formatQty(o.acceptedQty, stockUom), | |||
| }, | |||
| { | |||
| key: "status", | |||
| label: t("status"), | |||
| value: (o) => tr.stockInStatus(o.status), | |||
| }, | |||
| { | |||
| key: "timestamp", | |||
| label: t("timestamp"), | |||
| value: (o) => o.receiptDate ?? "—", | |||
| }, | |||
| ); | |||
| return cols; | |||
| }, [multiLocation, t, tr, stockUom]); | |||
| const qcCols = useMemo((): FilterableColumnDef<(typeof mergedQc)[number]>[] => { | |||
| const cols: FilterableColumnDef<(typeof mergedQc)[number]>[] = []; | |||
| if (multiLocation) { | |||
| cols.push({ | |||
| key: "warehouse", | |||
| label: t("warehouse"), | |||
| value: (q) => q.scopeWarehouseCode, | |||
| }); | |||
| } | |||
| cols.push( | |||
| { | |||
| key: "status", | |||
| label: t("status"), | |||
| value: (q) => (q.qcPassed ? t("qcPassed") : t("qcFailed")), | |||
| cell: (q) => ( | |||
| <Chip | |||
| size="small" | |||
| color={q.qcPassed ? "success" : "error"} | |||
| label={q.qcPassed ? t("qcPassed") : t("qcFailed")} | |||
| /> | |||
| ), | |||
| }, | |||
| { | |||
| key: "criteria", | |||
| label: t("detailQcCriteria"), | |||
| value: (q) => qcItemLabel(q) || "—", | |||
| }, | |||
| { | |||
| key: "acceptedQty", | |||
| label: t("acceptedQty"), | |||
| align: "right", | |||
| value: (q) => formatQty(q.acceptedQty, stockUom), | |||
| }, | |||
| { | |||
| key: "failQty", | |||
| label: t("failQty"), | |||
| align: "right", | |||
| value: (q) => formatQty(q.failQty, stockUom), | |||
| }, | |||
| { | |||
| key: "remarks", | |||
| label: t("remarks"), | |||
| value: (q) => q.remarks?.trim() || "", | |||
| }, | |||
| { | |||
| key: "handler", | |||
| label: t("handler"), | |||
| value: (q) => q.handledBy || "—", | |||
| }, | |||
| { | |||
| key: "timestamp", | |||
| label: t("timestamp"), | |||
| value: (q) => q.created ?? "—", | |||
| }, | |||
| ); | |||
| return cols; | |||
| }, [multiLocation, t, stockUom]); | |||
| const outboundCols = useMemo((): FilterableColumnDef<(typeof mergedOutbound)[number]>[] => { | |||
| const cols: FilterableColumnDef<(typeof mergedOutbound)[number]>[] = []; | |||
| if (multiLocation) { | |||
| cols.push({ | |||
| key: "warehouse", | |||
| label: t("warehouse"), | |||
| value: (u) => u.scopeWarehouseCode, | |||
| }); | |||
| } | |||
| cols.push( | |||
| { | |||
| key: "type", | |||
| label: t("type"), | |||
| value: (u) => tr.usageType(u.usageType), | |||
| }, | |||
| { | |||
| key: "qty", | |||
| label: t("qty"), | |||
| align: "right", | |||
| value: (u) => formatQty(u.qty, stockUom), | |||
| }, | |||
| { | |||
| key: "pickOrder", | |||
| label: t("pickOrder"), | |||
| value: (u) => u.pickOrderCode || u.consoCode || "—", | |||
| cell: (u) => ( | |||
| <ItemTracingDocLink | |||
| kind="pick" | |||
| code={u.pickOrderCode} | |||
| id={u.pickOrderId} | |||
| consoCode={u.consoCode} | |||
| /> | |||
| ), | |||
| }, | |||
| { | |||
| key: "jobOrder", | |||
| label: t("jobOrder"), | |||
| value: (u) => u.jobOrderCode || "—", | |||
| cell: (u) => ( | |||
| <ItemTracingDocLink kind="jo" code={u.jobOrderCode} id={u.jobOrderId} /> | |||
| ), | |||
| }, | |||
| { | |||
| key: "deliveryOrder", | |||
| label: t("deliveryOrder"), | |||
| value: (u) => u.deliveryOrderCode || "—", | |||
| }, | |||
| { | |||
| key: "handler", | |||
| label: t("handler"), | |||
| value: (u) => u.handler || "—", | |||
| }, | |||
| { | |||
| key: "timestamp", | |||
| label: t("timestamp"), | |||
| value: (u) => u.timestamp ?? "—", | |||
| }, | |||
| ); | |||
| return cols; | |||
| }, [multiLocation, t, tr, stockUom]); | |||
| const stockTakeCols = useMemo((): FilterableColumnDef<(typeof mergedStockTake)[number]>[] => { | |||
| const cols: FilterableColumnDef<(typeof mergedStockTake)[number]>[] = []; | |||
| if (multiLocation) { | |||
| cols.push({ | |||
| key: "warehouse", | |||
| label: t("warehouse"), | |||
| value: (e) => e.scopeWarehouseCode, | |||
| }); | |||
| } | |||
| cols.push( | |||
| { key: "ref", label: t("ref"), value: (e) => e.stockTakeCode }, | |||
| { | |||
| key: "round", | |||
| label: t("detailStockTakeRound"), | |||
| value: (e) => | |||
| e.stockTakeRoundName?.trim() || | |||
| (e.stockTakeRoundId != null ? `#${e.stockTakeRoundId}` : "—"), | |||
| }, | |||
| { | |||
| key: "section", | |||
| label: t("detailStockTakeSection"), | |||
| value: (e) => e.stockTakeSection?.trim() || "—", | |||
| }, | |||
| { | |||
| key: "location", | |||
| label: t("detailLocation"), | |||
| value: (e) => e.warehouseCode?.trim() || "—", | |||
| }, | |||
| { | |||
| key: "lotNo", | |||
| label: t("lotNo"), | |||
| value: (e) => e.lotNo?.trim() || data.lot.lotNo || "—", | |||
| }, | |||
| { | |||
| key: "before", | |||
| label: t("before"), | |||
| align: "right", | |||
| value: (e) => | |||
| formatQty( | |||
| resolveStockTakeBookQty(e.recordDetail, e.beforeQty), | |||
| stockUom, | |||
| ), | |||
| }, | |||
| { | |||
| key: "after", | |||
| label: t("after"), | |||
| align: "right", | |||
| value: (e) => | |||
| formatQty( | |||
| resolveStockTakeAcceptedQty(e.recordDetail, e.afterQty), | |||
| stockUom, | |||
| ), | |||
| }, | |||
| { | |||
| key: "variance", | |||
| label: t("variance"), | |||
| align: "right", | |||
| value: (e) => | |||
| formatQty( | |||
| e.recordDetail?.varianceQty != null | |||
| ? e.recordDetail.varianceQty | |||
| : e.varianceQty, | |||
| stockUom, | |||
| ), | |||
| }, | |||
| { | |||
| key: "approver", | |||
| label: t("approver"), | |||
| value: (e) => e.approver || "—", | |||
| }, | |||
| { | |||
| key: "timestamp", | |||
| label: t("timestamp"), | |||
| value: (e) => e.timestamp ?? "—", | |||
| }, | |||
| ); | |||
| return cols; | |||
| }, [multiLocation, t, stockUom, data.lot.lotNo]); | |||
| const adjustmentCols = useMemo((): FilterableColumnDef<(typeof mergedAdjustments)[number]>[] => { | |||
| const cols: FilterableColumnDef<(typeof mergedAdjustments)[number]>[] = []; | |||
| if (multiLocation) { | |||
| cols.push({ | |||
| key: "warehouse", | |||
| label: t("warehouse"), | |||
| value: (a) => a.scopeWarehouseCode, | |||
| }); | |||
| } | |||
| cols.push( | |||
| { | |||
| key: "type", | |||
| label: t("type"), | |||
| value: (a) => a.adjustmentType, | |||
| }, | |||
| { | |||
| key: "direction", | |||
| label: `${t("directionIn")}/${t("directionOut")}`, | |||
| value: (a) => a.direction, | |||
| }, | |||
| { | |||
| key: "qty", | |||
| label: t("qty"), | |||
| align: "right", | |||
| value: (a) => formatQty(a.qty, stockUom), | |||
| }, | |||
| { | |||
| key: "ref", | |||
| label: t("ref"), | |||
| value: (a) => a.refCode || "—", | |||
| }, | |||
| { | |||
| key: "remarks", | |||
| label: t("remarks"), | |||
| value: (a) => a.reason?.trim() || "", | |||
| }, | |||
| { | |||
| key: "handler", | |||
| label: t("handler"), | |||
| value: (a) => a.handledBy || "—", | |||
| }, | |||
| { | |||
| key: "timestamp", | |||
| label: t("timestamp"), | |||
| value: (a) => a.timestamp ?? "—", | |||
| }, | |||
| ); | |||
| return cols; | |||
| }, [multiLocation, t, stockUom]); | |||
| const transferCols = useMemo((): FilterableColumnDef<(typeof mergedTransfers)[number]>[] => { | |||
| const cols: FilterableColumnDef<(typeof mergedTransfers)[number]>[] = []; | |||
| if (multiLocation) { | |||
| cols.push({ | |||
| key: "warehouse", | |||
| label: t("warehouse"), | |||
| value: (row) => row.scopeWarehouseCode, | |||
| }); | |||
| } | |||
| cols.push( | |||
| { | |||
| key: "from", | |||
| label: t("from"), | |||
| value: (row) => row.fromWarehouse || "—", | |||
| }, | |||
| { | |||
| key: "to", | |||
| label: t("to"), | |||
| value: (row) => row.toWarehouse || "—", | |||
| }, | |||
| { | |||
| key: "qty", | |||
| label: t("qty"), | |||
| align: "right", | |||
| value: (row) => formatQty(row.qty, stockUom), | |||
| }, | |||
| { | |||
| key: "timestamp", | |||
| label: t("timestamp"), | |||
| value: (row) => row.timestamp ?? "—", | |||
| }, | |||
| ); | |||
| return cols; | |||
| }, [multiLocation, t, stockUom]); | |||
| type JoMaterialInput = NonNullable<typeof joPrelude>["materialInputs"][number]; | |||
| type BomUpstream = (typeof bomTrace.upstream)[number]; | |||
| const bomUpstreamJoCols = useMemo((): FilterableColumnDef<JoMaterialInput>[] => [ | |||
| { | |||
| key: "jobOrder", | |||
| label: t("jobOrder"), | |||
| value: (m) => m.jobOrderCode || "—", | |||
| cell: (m) => ( | |||
| <ItemTracingDocLink kind="jo" code={m.jobOrderCode} id={m.jobOrderId} /> | |||
| ), | |||
| }, | |||
| { key: "material", label: t("itemCode"), value: (m) => m.materialItemCode }, | |||
| { key: "materialLot", label: t("itemLot"), value: (m) => m.materialLotNo }, | |||
| { | |||
| key: "pickOrder", | |||
| label: t("pickOrder"), | |||
| value: (m) => m.pickOrderCode || m.consoCode || "—", | |||
| cell: (m) => | |||
| m.pickOrderCode ? ( | |||
| <ItemTracingDocLink | |||
| kind="pick" | |||
| code={m.pickOrderCode} | |||
| id={m.pickOrderId} | |||
| consoCode={m.consoCode} | |||
| /> | |||
| ) : ( | |||
| "—" | |||
| ), | |||
| }, | |||
| { | |||
| key: "pickedAt", | |||
| label: t("pickedAt"), | |||
| value: (m) => m.pickedAt ?? "—", | |||
| }, | |||
| { | |||
| key: "materialQty", | |||
| label: t("materialQty"), | |||
| align: "right", | |||
| value: (m) => formatNum(Number(m.materialQty)), | |||
| }, | |||
| { | |||
| key: "qtyPerUnit", | |||
| label: t("qtyPerUnit"), | |||
| align: "right", | |||
| value: (m) => | |||
| m.bomQtyPerUnit != null ? formatNum(Number(m.bomQtyPerUnit)) : "—", | |||
| }, | |||
| { | |||
| key: "uom", | |||
| label: t("uom"), | |||
| value: (m) => m.materialUom?.trim() || "", | |||
| }, | |||
| { | |||
| key: "processingStatus", | |||
| label: t("processingStatus"), | |||
| align: "center", | |||
| value: (m) => tr.processingStatus(m.processingStatus), | |||
| cell: (m) => | |||
| checkboxCellFromStatus(m.processingStatus, { | |||
| checkedWhen: ["completed"], | |||
| rejectedWhen: ["rejected"], | |||
| }), | |||
| }, | |||
| { | |||
| key: "matchStatus", | |||
| label: t("matchStatus"), | |||
| align: "center", | |||
| value: (m) => tr.matchStatus(m.matchStatus), | |||
| cell: (m) => checkboxCellFromStatus(m.matchStatus, { checkedWhen: ["completed"] }), | |||
| }, | |||
| { | |||
| key: "trace", | |||
| label: "", | |||
| value: () => "", | |||
| cell: (m) => | |||
| m.materialLotNo && onTrace ? ( | |||
| <MuiLink | |||
| component="button" | |||
| type="button" | |||
| variant="body2" | |||
| underline="hover" | |||
| onClick={() => | |||
| onTrace({ | |||
| lotNo: m.materialLotNo, | |||
| itemCode: m.materialItemCode, | |||
| }) | |||
| } | |||
| > | |||
| {t("traceMaterialLot")} | |||
| </MuiLink> | |||
| ) : ( | |||
| "—" | |||
| ), | |||
| }, | |||
| ], [t, tr, stockUom, onTrace]); | |||
| const bomUpstreamCols = useMemo((): FilterableColumnDef<BomUpstream>[] => [ | |||
| { | |||
| key: "jobOrder", | |||
| label: t("jobOrder"), | |||
| value: (u) => u.jobOrderCode || "—", | |||
| cell: (u) => ( | |||
| <ItemTracingDocLink kind="jo" code={u.jobOrderCode} id={u.jobOrderId} /> | |||
| ), | |||
| }, | |||
| { key: "material", label: t("itemCode"), value: (u) => u.materialItemCode }, | |||
| { key: "materialLot", label: t("itemLot"), value: (u) => u.materialLotNo }, | |||
| { | |||
| key: "materialQty", | |||
| label: t("materialQty"), | |||
| align: "right", | |||
| value: (u) => formatNum(Number(u.materialQty)), | |||
| }, | |||
| { | |||
| key: "qtyPerUnit", | |||
| label: t("qtyPerUnit"), | |||
| align: "right", | |||
| value: (u) => | |||
| u.bomQtyPerUnit != null ? formatNum(Number(u.bomQtyPerUnit)) : "—", | |||
| }, | |||
| { | |||
| key: "uom", | |||
| label: t("uom"), | |||
| value: () => stockUom, | |||
| }, | |||
| ], [t, stockUom]); | |||
| const bomDownstreamCols = useMemo((): FilterableColumnDef<(typeof bomTrace.downstream)[number]>[] => [ | |||
| { | |||
| key: "jobOrder", | |||
| label: t("jobOrder"), | |||
| value: (d) => d.jobOrderCode || "—", | |||
| cell: (d) => ( | |||
| <ItemTracingDocLink kind="jo" code={d.jobOrderCode} id={d.jobOrderId} /> | |||
| ), | |||
| }, | |||
| { key: "finishedItem", label: t("itemCode"), value: (d) => d.finishedItemCode }, | |||
| { key: "finishedLot", label: t("itemLot"), value: (d) => d.finishedLotNo }, | |||
| { | |||
| key: "fgQty", | |||
| label: t("fgQty"), | |||
| align: "right", | |||
| value: (d) => formatQty(d.fgQty, d.fgUom?.trim() || ""), | |||
| }, | |||
| { | |||
| key: "materialQty", | |||
| label: t("materialQty"), | |||
| align: "right", | |||
| value: (d) => formatQty(d.materialQtyUsed, stockUom), | |||
| }, | |||
| ], [t, stockUom]); | |||
| const bomRecipeCols = useMemo((): FilterableColumnDef<(typeof bomTrace.bomRecipe)[number]>[] => [ | |||
| { key: "material", label: t("itemCode"), value: (r) => r.materialItemCode }, | |||
| { | |||
| key: "materialName", | |||
| label: t("detailItemName"), | |||
| value: (r) => r.materialItemName, | |||
| }, | |||
| { | |||
| key: "qtyPerUnit", | |||
| label: t("qtyPerUnit"), | |||
| align: "right", | |||
| value: (r) => formatNum(Number(r.qtyPerUnit)), | |||
| }, | |||
| { key: "uom", label: t("uom"), value: (r) => r.uom }, | |||
| ], [t]); | |||
| type JoPickRow = (typeof presentationRows.joPicks)[number]; | |||
| const joPickCols = useMemo((): FilterableColumnDef<JoPickRow>[] => [ | |||
| { | |||
| key: "pickOrder", | |||
| label: t("pickOrder"), | |||
| value: (row) => row.pickOrderCode || row.consoCode || "—", | |||
| cell: (row) => | |||
| row.pickOrderCode ? ( | |||
| <ItemTracingDocLink | |||
| kind="jodetail" | |||
| code={row.pickOrderCode} | |||
| id={row.pickOrderId} | |||
| consoCode={row.consoCode || row.pickOrderCode} | |||
| targetDate={normalizeTargetDateForLink( | |||
| row.targetDate || row.pickedAt, | |||
| )} | |||
| openInNewTab | |||
| /> | |||
| ) : ( | |||
| "—" | |||
| ), | |||
| }, | |||
| { key: "material", label: t("Item"), value: (row) => row.materialItemCode }, | |||
| { | |||
| key: "lotNo", | |||
| label: t("lotNo"), | |||
| value: (row) => row.materialLotNo || "—", | |||
| cell: (row) => | |||
| row.materialLotNo ? ( | |||
| <ItemTracingLotTraceLink | |||
| label={row.materialLotNo} | |||
| lotNo={row.materialLotNo} | |||
| itemCode={row.materialItemCode} | |||
| variant="body2" | |||
| /> | |||
| ) : ( | |||
| "—" | |||
| ), | |||
| }, | |||
| { | |||
| key: "pickedQty", | |||
| label: t("pickedQty"), | |||
| align: "right", | |||
| value: (row) => formatQty(row.qty, row.uom || stockUom), | |||
| }, | |||
| { | |||
| key: "assignedStep", | |||
| label: t("detailAssignedStep"), | |||
| value: (row) => row.assignedStepName || "—", | |||
| }, | |||
| { | |||
| key: "timestamp", | |||
| label: t("timestamp"), | |||
| value: (row) => row.pickedAt ?? "—", | |||
| }, | |||
| ], [t, stockUom]); | |||
| type JoPickLine = NonNullable<typeof joPrelude>["pickOrders"][number]["lines"][number]; | |||
| const joPickLineCols = useMemo((): FilterableColumnDef<JoPickLine>[] => [ | |||
| { | |||
| key: "material", | |||
| label: t("Item"), | |||
| value: (line) => | |||
| line.itemName ? `${line.itemCode} · ${line.itemName}` : line.itemCode, | |||
| }, | |||
| { | |||
| key: "plannedQty", | |||
| label: t("requiredQty"), | |||
| align: "right", | |||
| value: (line) => formatQty(Number(line.requiredQty), stockUom), | |||
| }, | |||
| { | |||
| key: "pickedQty", | |||
| label: t("pickedQty"), | |||
| align: "right", | |||
| value: (line) => formatQty(Number(line.pickedQty), stockUom), | |||
| }, | |||
| { key: "status", label: t("status"), value: (line) => tr.pickStatus(line.status) }, | |||
| ], [t, tr, stockUom]); | |||
| return ( | |||
| <Paper variant="outlined"> | |||
| <Tabs | |||
| value={tab} | |||
| onChange={(_, v) => setTab(v)} | |||
| variant="scrollable" | |||
| scrollButtons="auto" | |||
| > | |||
| <Tab label={t("tabOrigins")} /> | |||
| <Tab label={t("tabQc")} /> | |||
| <Tab label={t("tabOutbound")} /> | |||
| <Tab label={t("tabStockTake")} /> | |||
| <Tab label={t("tabAdjustments")} /> | |||
| <Tab label={t("tabTransfers")} /> | |||
| <Tab label={t("tabBom")} /> | |||
| {joPrelude && <Tab label={t("tabJoPick")} />} | |||
| </Tabs> | |||
| <Box sx={{ p: 2, overflowX: "auto" }}> | |||
| {multiLocation && ( | |||
| <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> | |||
| {t("sectionsMultiLocationHint")} | |||
| </Typography> | |||
| )} | |||
| {activeTab === "origins" && ( | |||
| <FilterableDataTable | |||
| rows={mergedOrigins} | |||
| columns={originCols} | |||
| getRowKey={(o) => `${o.inventoryLotId}-${o.stockInLineId}`} | |||
| emptyLabel={t("noRecords")} | |||
| /> | |||
| )} | |||
| {activeTab === "qc" && ( | |||
| <FilterableDataTable | |||
| rows={mergedQc} | |||
| columns={qcCols} | |||
| getRowKey={(q) => q.rowKey} | |||
| emptyLabel={t("noRecords")} | |||
| /> | |||
| )} | |||
| {activeTab === "outbound" && ( | |||
| <FilterableDataTable | |||
| rows={mergedOutbound} | |||
| columns={outboundCols} | |||
| getRowKey={(u) => `${u.inventoryLotId}-${u.stockOutLineId}`} | |||
| emptyLabel={t("noRecords")} | |||
| /> | |||
| )} | |||
| {activeTab === "stockTake" && ( | |||
| <FilterableDataTable | |||
| rows={mergedStockTake} | |||
| columns={stockTakeCols} | |||
| getRowKey={(e) => e.rowKey} | |||
| emptyLabel={t("noRecords")} | |||
| /> | |||
| )} | |||
| {activeTab === "adjustments" && ( | |||
| <FilterableDataTable | |||
| rows={mergedAdjustments} | |||
| columns={adjustmentCols} | |||
| getRowKey={(a) => a.rowKey} | |||
| emptyLabel={t("noRecords")} | |||
| /> | |||
| )} | |||
| {activeTab === "transfers" && ( | |||
| <FilterableDataTable | |||
| rows={mergedTransfers} | |||
| columns={transferCols} | |||
| getRowKey={(row) => row.rowKey} | |||
| emptyLabel={t("noRecords")} | |||
| /> | |||
| )} | |||
| {activeTab === "bom" && ( | |||
| <Box> | |||
| <Typography variant="body2" color="text.secondary" gutterBottom> | |||
| {t("bomDirection")}: <strong>{bomDirectionLabel}</strong> | |||
| </Typography> | |||
| <Typography variant="subtitle2" sx={{ mt: 2, mb: 1 }}> | |||
| {t("bomUpstream")} | |||
| </Typography> | |||
| <Box sx={{ mb: 2 }}> | |||
| {useJoMaterialInputs ? ( | |||
| <FilterableDataTable | |||
| rows={joPrelude!.materialInputs} | |||
| columns={bomUpstreamJoCols} | |||
| getRowKey={(_, i) => `up-jo-${i}`} | |||
| emptyLabel={t("noRecords")} | |||
| /> | |||
| ) : ( | |||
| <FilterableDataTable | |||
| rows={bomTrace.upstream} | |||
| columns={bomUpstreamCols} | |||
| getRowKey={(_, i) => `up-${i}`} | |||
| emptyLabel={t("noRecords")} | |||
| /> | |||
| )} | |||
| </Box> | |||
| <Typography variant="subtitle2" sx={{ mb: 1 }}> | |||
| {t("bomDownstream")} | |||
| </Typography> | |||
| <Box sx={{ mb: 2 }}> | |||
| <FilterableDataTable | |||
| rows={bomTrace.downstream} | |||
| columns={bomDownstreamCols} | |||
| getRowKey={(_, i) => `down-${i}`} | |||
| emptyLabel={t("noRecords")} | |||
| /> | |||
| </Box> | |||
| <Typography variant="subtitle2" sx={{ mb: 1 }}> | |||
| {t("bomRecipe")} | |||
| </Typography> | |||
| <FilterableDataTable | |||
| rows={bomTrace.bomRecipe} | |||
| columns={bomRecipeCols} | |||
| getRowKey={(_, i) => `recipe-${i}`} | |||
| emptyLabel={t("noRecords")} | |||
| /> | |||
| </Box> | |||
| )} | |||
| {activeTab === "joPick" && joPrelude && ( | |||
| <Box> | |||
| {presentationRows.joPicks.length > 0 ? ( | |||
| <FilterableDataTable | |||
| rows={presentationRows.joPicks} | |||
| columns={joPickCols} | |||
| getRowKey={(row) => row.id} | |||
| emptyLabel={t("noRecords")} | |||
| /> | |||
| ) : joPrelude.pickOrders.length === 0 ? ( | |||
| <Typography color="text.secondary" variant="body2"> | |||
| {t("noRecords")} | |||
| </Typography> | |||
| ) : ( | |||
| joPrelude.pickOrders.map((po) => ( | |||
| <Box key={po.pickOrderId} sx={{ mb: 3 }}> | |||
| <Typography variant="subtitle2" gutterBottom> | |||
| <ItemTracingDocLink | |||
| kind="jodetail" | |||
| code={po.pickOrderCode} | |||
| id={po.pickOrderId} | |||
| consoCode={po.consoCode || po.pickOrderCode} | |||
| targetDate={normalizeTargetDateForLink(po.targetDate)} | |||
| openInNewTab | |||
| /> | |||
| {" · "} | |||
| <Chip | |||
| size="small" | |||
| label={tr.pickStatus(po.status)} | |||
| variant="outlined" | |||
| sx={{ ml: 0.5 }} | |||
| /> | |||
| </Typography> | |||
| <Typography | |||
| variant="caption" | |||
| color="text.secondary" | |||
| display="block" | |||
| sx={{ mb: 1 }} | |||
| > | |||
| {[ | |||
| po.targetDate && `${t("targetDate")}: ${po.targetDate}`, | |||
| po.completeDate && `${t("completeDate")}: ${po.completeDate}`, | |||
| ] | |||
| .filter(Boolean) | |||
| .join(" · ")} | |||
| </Typography> | |||
| <FilterableDataTable | |||
| rows={po.lines} | |||
| columns={joPickLineCols} | |||
| getRowKey={(line) => String(line.pickOrderLineId)} | |||
| emptyLabel={t("noRecords")} | |||
| /> | |||
| </Box> | |||
| )) | |||
| )} | |||
| </Box> | |||
| )} | |||
| </Box> | |||
| </Paper> | |||
| ); | |||
| }; | |||
| export default ItemTracingSections; | |||
| @@ -0,0 +1,100 @@ | |||
| "use client"; | |||
| import { Box, Typography } from "@mui/material"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import type { StockTakeLifecycleStage } from "./traceStockTakeUtils"; | |||
| import { formatQty } from "./traceQtyUtils"; | |||
| type Props = { | |||
| stages: StockTakeLifecycleStage[]; | |||
| uom?: string; | |||
| }; | |||
| /** Vertical timeline of stock-take progression (first count → approve). */ | |||
| const ItemTracingStockTakeLifecycle: React.FC<Props> = ({ stages, uom = "" }) => { | |||
| const { t } = useTranslation("itemTracing"); | |||
| if (!stages.length) return null; | |||
| return ( | |||
| <Box sx={{ display: "flex", flexDirection: "column" }}> | |||
| {stages.map((stage, idx) => { | |||
| const isLast = idx === stages.length - 1; | |||
| return ( | |||
| <Box key={stage.key} sx={{ display: "flex", gap: 1 }}> | |||
| <Box | |||
| sx={{ | |||
| display: "flex", | |||
| flexDirection: "column", | |||
| alignItems: "center", | |||
| width: 12, | |||
| flexShrink: 0, | |||
| }} | |||
| > | |||
| <Box | |||
| sx={{ | |||
| width: 10, | |||
| height: 10, | |||
| borderRadius: "50%", | |||
| bgcolor: stage.isActive ? "success.main" : "grey.400", | |||
| flexShrink: 0, | |||
| mt: 0.35, | |||
| zIndex: 1, | |||
| }} | |||
| /> | |||
| {!isLast && ( | |||
| <Box | |||
| sx={{ | |||
| width: 2, | |||
| flex: 1, | |||
| minHeight: 22, | |||
| bgcolor: | |||
| stage.isActive && stages[idx + 1]?.isActive ? "success.main" : "grey.300", | |||
| my: -0.5, | |||
| }} | |||
| /> | |||
| )} | |||
| </Box> | |||
| <Box sx={{ minWidth: 0, flex: 1, pb: isLast ? 0 : 1 }}> | |||
| <Typography | |||
| variant="caption" | |||
| fontWeight={600} | |||
| display="block" | |||
| noWrap | |||
| color={stage.isActive ? "text.primary" : "text.disabled"} | |||
| > | |||
| {t(stage.label)} | |||
| </Typography> | |||
| {stage.isActive ? ( | |||
| <Typography variant="caption" color="text.secondary" display="block"> | |||
| {[ | |||
| stage.qty != null | |||
| ? stage.key === "round-created" | |||
| ? `${t("stockTakeStageBookQty")}: ${formatQty(stage.qty, uom)}` | |||
| : formatQty(stage.qty, uom) | |||
| : null, | |||
| stage.key === "accepted" && stage.badQty != null | |||
| ? `${t("variance")}: ${formatQty(stage.badQty, uom)}` | |||
| : stage.badQty != null && stage.badQty > 0 | |||
| ? `${t("unqualifiedQty")}: ${formatQty(stage.badQty, uom)}` | |||
| : null, | |||
| stage.handler ?? null, | |||
| stage.timestamp ?? null, | |||
| ] | |||
| .filter(Boolean) | |||
| .join(" · ") || "—"} | |||
| </Typography> | |||
| ) : ( | |||
| <Typography variant="caption" color="text.disabled" display="block"> | |||
| — | |||
| </Typography> | |||
| )} | |||
| </Box> | |||
| </Box> | |||
| ); | |||
| })} | |||
| </Box> | |||
| ); | |||
| }; | |||
| export default ItemTracingStockTakeLifecycle; | |||
| @@ -0,0 +1,291 @@ | |||
| "use client"; | |||
| import { | |||
| Box, | |||
| Button, | |||
| Chip, | |||
| Grid, | |||
| Paper, | |||
| Stack, | |||
| Table, | |||
| TableBody, | |||
| TableCell, | |||
| TableHead, | |||
| TableRow, | |||
| Typography, | |||
| } from "@mui/material"; | |||
| import { useMemo } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { ItemLotTraceResponse } from "@/app/api/itemTracing"; | |||
| import ItemTracingDocLink from "./ItemTracingDocLink"; | |||
| import { createTraceLabelTranslator } from "./traceLabelUtils"; | |||
| import { formatQty } from "./traceQtyUtils"; | |||
| import type { WarehouseFocusRequest } from "./ItemTracing"; | |||
| type Props = { | |||
| data: ItemLotTraceResponse; | |||
| onFocusWarehouse?: (request: WarehouseFocusRequest) => void; | |||
| onExportExcel?: () => void; | |||
| }; | |||
| type SummaryWarehouseRow = { | |||
| key: string; | |||
| inventoryLotId: number; | |||
| warehouseCode: string; | |||
| inQty: number; | |||
| outQty: number; | |||
| availableQty: number; | |||
| status: string; | |||
| }; | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 13 | v1.0.0 | 2026-07-17 */ | |||
| const ItemTracingSummary: React.FC<Props> = ({ | |||
| data, | |||
| onFocusWarehouse, | |||
| onExportExcel, | |||
| }) => { | |||
| const { t } = useTranslation("itemTracing"); | |||
| const tr = useMemo(() => createTraceLabelTranslator(t), [t]); | |||
| const { lot, warehouseLines, joPrelude, alternateLocations, locationBlocks } = | |||
| data; | |||
| const combinedWarehouseRows = useMemo((): SummaryWarehouseRow[] => { | |||
| const primary = warehouseLines.map((w) => ({ | |||
| key: `primary-${w.inventoryLotLineId}`, | |||
| inventoryLotId: lot.inventoryLotId, | |||
| warehouseCode: w.warehouseCode, | |||
| inQty: w.inQty, | |||
| outQty: w.outQty, | |||
| availableQty: w.availableQty, | |||
| status: w.status, | |||
| })); | |||
| const fromBlocks = (locationBlocks ?? []).flatMap((block) => | |||
| block.warehouseLines.map((w) => ({ | |||
| key: `alt-${block.inventoryLotId}-${w.inventoryLotLineId}`, | |||
| inventoryLotId: block.inventoryLotId, | |||
| warehouseCode: w.warehouseCode, | |||
| inQty: w.inQty, | |||
| outQty: w.outQty, | |||
| availableQty: w.availableQty, | |||
| status: w.status, | |||
| })), | |||
| ); | |||
| if (fromBlocks.length > 0) { | |||
| return [...primary, ...fromBlocks]; | |||
| } | |||
| // Fallback when location blocks are absent but alternateLocations exist. | |||
| const fromAlts = alternateLocations.map((loc) => ({ | |||
| key: `alt-loc-${loc.inventoryLotId}-${loc.inventoryLotLineId}`, | |||
| inventoryLotId: loc.inventoryLotId, | |||
| warehouseCode: loc.warehouseCode, | |||
| inQty: 0, | |||
| outQty: 0, | |||
| availableQty: loc.availableQty, | |||
| status: "", | |||
| })); | |||
| return [...primary, ...fromAlts]; | |||
| }, [ | |||
| warehouseLines, | |||
| locationBlocks, | |||
| alternateLocations, | |||
| lot.inventoryLotId, | |||
| ]); | |||
| const totalAvailable = combinedWarehouseRows.reduce( | |||
| (s, w) => s + (w.availableQty ?? 0), | |||
| 0, | |||
| ); | |||
| return ( | |||
| <Paper variant="outlined" sx={{ p: 2.5 }}> | |||
| <Stack | |||
| direction="row" | |||
| alignItems="center" | |||
| justifyContent="space-between" | |||
| spacing={1} | |||
| > | |||
| <Typography variant="overline" color="text.secondary"> | |||
| {t("summary")} | |||
| </Typography> | |||
| {onExportExcel && ( | |||
| <Button | |||
| size="small" | |||
| variant="outlined" | |||
| onClick={onExportExcel} | |||
| title={t("exportExcelTooltip")} | |||
| > | |||
| {t("exportExcel")} | |||
| </Button> | |||
| )} | |||
| </Stack> | |||
| <Stack | |||
| direction={{ xs: "column", md: "row" }} | |||
| spacing={2} | |||
| alignItems={{ md: "center" }} | |||
| sx={{ mt: 1 }} | |||
| > | |||
| <Box sx={{ flex: 1 }}> | |||
| <Typography variant="h5" fontWeight={700}> | |||
| {lot.lotNo || "—"} | |||
| </Typography> | |||
| <Typography variant="subtitle1" color="text.secondary"> | |||
| {lot.itemCode} · {lot.itemName} | |||
| </Typography> | |||
| </Box> | |||
| <Chip | |||
| label={`${t("totalAvailable")}: ${formatQty( | |||
| totalAvailable, | |||
| lot.uom, | |||
| )}`} | |||
| color="primary" | |||
| variant="outlined" | |||
| /> | |||
| </Stack> | |||
| <Grid container spacing={2} sx={{ mt: 2 }}> | |||
| <Grid item xs={6} sm={3}> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {t("expiryDate")} | |||
| </Typography> | |||
| <Typography variant="body2">{lot.expiryDate ?? "—"}</Typography> | |||
| </Grid> | |||
| <Grid item xs={6} sm={3}> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {t("productionDate")} | |||
| </Typography> | |||
| <Typography variant="body2">{lot.productionDate ?? "—"}</Typography> | |||
| </Grid> | |||
| <Grid item xs={6} sm={3}> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {t("stockInDate")} | |||
| </Typography> | |||
| <Typography variant="body2">{lot.stockInDate ?? "—"}</Typography> | |||
| </Grid> | |||
| <Grid item xs={6} sm={3}> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {t("uom")} | |||
| </Typography> | |||
| <Typography variant="body2">{lot.uom || "—"}</Typography> | |||
| </Grid> | |||
| </Grid> | |||
| {joPrelude && ( | |||
| <Box sx={{ mt: 2, p: 1.5, bgcolor: "action.hover", borderRadius: 1 }}> | |||
| <Typography variant="subtitle2" gutterBottom> | |||
| {t("joContext")} | |||
| </Typography> | |||
| <Grid container spacing={1}> | |||
| <Grid item xs={6} sm={3}> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {t("jobOrder")} | |||
| </Typography> | |||
| <Typography variant="body2"> | |||
| {joPrelude.jobOrder.jobOrderId != null ? ( | |||
| <ItemTracingDocLink | |||
| kind="jo" | |||
| id={joPrelude.jobOrder.jobOrderId} | |||
| code={joPrelude.jobOrder.jobOrderCode} | |||
| /> | |||
| ) : ( | |||
| joPrelude.jobOrder.jobOrderCode || "—" | |||
| )} | |||
| </Typography> | |||
| </Grid> | |||
| <Grid item xs={6} sm={3}> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {t("status")} | |||
| </Typography> | |||
| <Typography variant="body2"> | |||
| {tr.joStatus(joPrelude.jobOrder.status)} | |||
| </Typography> | |||
| </Grid> | |||
| <Grid item xs={6} sm={3}> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {t("planStart")} | |||
| </Typography> | |||
| <Typography variant="body2"> | |||
| {joPrelude.jobOrder.planStart ?? "—"} | |||
| </Typography> | |||
| </Grid> | |||
| <Grid item xs={6} sm={3}> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {t("plannedQty")} | |||
| </Typography> | |||
| <Typography variant="body2"> | |||
| {formatQty(Number(joPrelude.jobOrder.reqQty), lot.uom)} | |||
| </Typography> | |||
| </Grid> | |||
| </Grid> | |||
| </Box> | |||
| )} | |||
| {combinedWarehouseRows.length > 0 && ( | |||
| <Box sx={{ mt: 3 }}> | |||
| <Table size="small" sx={{ tableLayout: "auto" }}> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell>{t("warehouse")}</TableCell> | |||
| <TableCell align="right">{t("inQty")}</TableCell> | |||
| <TableCell align="right">{t("outQty")}</TableCell> | |||
| <TableCell align="right">{t("available")}</TableCell> | |||
| <TableCell>{t("status")}</TableCell> | |||
| {onFocusWarehouse && ( | |||
| <TableCell align="right">{t("action")}</TableCell> | |||
| )} | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {combinedWarehouseRows.map((w) => ( | |||
| <TableRow key={w.key}> | |||
| <TableCell>{w.warehouseCode || "—"}</TableCell> | |||
| <TableCell align="right"> | |||
| {formatQty(w.inQty, lot.uom)} | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| {formatQty(w.outQty, lot.uom)} | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| {formatQty(w.availableQty, lot.uom)} | |||
| </TableCell> | |||
| <TableCell> | |||
| {w.status ? ( | |||
| <Chip | |||
| size="small" | |||
| label={tr.lotLineStatus(w.status)} | |||
| variant="outlined" | |||
| /> | |||
| ) : ( | |||
| "—" | |||
| )} | |||
| </TableCell> | |||
| {onFocusWarehouse && ( | |||
| <TableCell align="right"> | |||
| {w.warehouseCode?.trim() ? ( | |||
| <Button | |||
| size="small" | |||
| variant="outlined" | |||
| onClick={() => | |||
| onFocusWarehouse({ | |||
| inventoryLotId: w.inventoryLotId, | |||
| warehouseCode: w.warehouseCode, | |||
| }) | |||
| } | |||
| > | |||
| {t("focusWarehouseInGraph")} | |||
| </Button> | |||
| ) : ( | |||
| "—" | |||
| )} | |||
| </TableCell> | |||
| )} | |||
| </TableRow> | |||
| ))} | |||
| </TableBody> | |||
| </Table> | |||
| </Box> | |||
| )} | |||
| </Paper> | |||
| ); | |||
| }; | |||
| export default ItemTracingSummary; | |||
| @@ -0,0 +1,58 @@ | |||
| "use client"; | |||
| import { BaseEdge, getSmoothStepPath, type EdgeProps } from "@xyflow/react"; | |||
| import { buildTraceFlowCorridorPath } from "./traceFlowEdgeLayout"; | |||
| export type TraceFlowEdgeData = { | |||
| offset?: number; | |||
| pathMode?: "smooth" | "corridor"; | |||
| corridorY?: number; | |||
| corridorEntryOffset?: number; | |||
| corridorExitOffset?: number; | |||
| corridorBranchOffset?: number; | |||
| }; | |||
| export const TraceFlowEdge = ({ | |||
| id, | |||
| sourceX, | |||
| sourceY, | |||
| targetX, | |||
| targetY, | |||
| sourcePosition, | |||
| targetPosition, | |||
| style, | |||
| markerEnd, | |||
| data, | |||
| }: EdgeProps) => { | |||
| const edgeData = data as TraceFlowEdgeData | undefined; | |||
| const offset = edgeData?.offset ?? 0; | |||
| const path = | |||
| edgeData?.pathMode === "corridor" && edgeData.corridorY != null | |||
| ? buildTraceFlowCorridorPath( | |||
| sourceX, | |||
| sourceY, | |||
| targetX, | |||
| targetY, | |||
| edgeData.corridorY, | |||
| edgeData.corridorEntryOffset, | |||
| edgeData.corridorExitOffset, | |||
| edgeData.corridorBranchOffset, | |||
| ) | |||
| : getSmoothStepPath({ | |||
| sourceX, | |||
| sourceY, | |||
| sourcePosition, | |||
| targetX, | |||
| targetY, | |||
| targetPosition, | |||
| borderRadius: 8, | |||
| offset, | |||
| })[0]; | |||
| return <BaseEdge id={id} path={path} style={style} markerEnd={markerEnd} />; | |||
| }; | |||
| export const traceFlowEdgeTypes = { | |||
| traceFlow: TraceFlowEdge, | |||
| }; | |||
| @@ -0,0 +1,878 @@ | |||
| "use client"; | |||
| import { memo, useEffect, useState } from "react"; | |||
| import { | |||
| Handle, | |||
| Position, | |||
| useReactFlow, | |||
| useUpdateNodeInternals, | |||
| type Node, | |||
| type NodeProps, | |||
| } from "@xyflow/react"; | |||
| import { | |||
| Box, | |||
| Chip, | |||
| Divider, | |||
| IconButton, | |||
| Paper, | |||
| Tooltip, | |||
| Typography, | |||
| } from "@mui/material"; | |||
| import { alpha } from "@mui/material/styles"; | |||
| import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; | |||
| import ExpandLessIcon from "@mui/icons-material/ExpandLess"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import ItemTracingDocLink from "./ItemTracingDocLink"; | |||
| import ItemTracingLotTraceLink from "./ItemTracingLotTraceLink"; | |||
| import ItemTracingStockTakeLifecycle from "./ItemTracingStockTakeLifecycle"; | |||
| import { TraceFlowNodeData } from "./buildReactFlowGraph"; | |||
| import { NODE_HEIGHT, NODE_WIDTH, NODE_WIDTH_BRANCH, DO_GROUP_HEADER } from "./traceFlowConstants"; | |||
| import { traceFlowHandleTopPercent } from "./traceFlowEdgeLayout"; | |||
| import { kindColor, kindLabelKey } from "./traceFlowNodeUtils"; | |||
| import { formatQty, formatSignedQty } from "./traceQtyUtils"; | |||
| import { buildStockTakeLifecycleStages } from "./traceStockTakeUtils"; | |||
| import { pickStatusValueColor, resolveDoOutboundQtyColor } from "./traceLabelUtils"; | |||
| const PHASE_LABEL_INNER = 96; | |||
| /** Expanded stock-take stage panel (mt + maxHeight + padding) below the fixed card. */ | |||
| const STOCK_TAKE_LIFECYCLE_PANEL_EXTRA = 320; | |||
| const GroupCollapseButton = ({ | |||
| collapsed, | |||
| onToggle, | |||
| collapseLabel, | |||
| expandLabel, | |||
| }: { | |||
| collapsed: boolean; | |||
| onToggle?: () => void; | |||
| collapseLabel: string; | |||
| expandLabel: string; | |||
| }) => ( | |||
| <IconButton | |||
| size="small" | |||
| aria-label={collapsed ? expandLabel : collapseLabel} | |||
| aria-expanded={!collapsed} | |||
| onClick={(e) => { | |||
| e.stopPropagation(); | |||
| onToggle?.(); | |||
| }} | |||
| onMouseDown={(e) => e.stopPropagation()} | |||
| sx={{ | |||
| pointerEvents: "all", | |||
| p: 0.25, | |||
| ml: "auto", | |||
| flexShrink: 0, | |||
| color: "warning.dark", | |||
| }} | |||
| > | |||
| {collapsed ? ( | |||
| <ExpandMoreIcon fontSize="small" /> | |||
| ) : ( | |||
| <ExpandLessIcon fontSize="small" /> | |||
| )} | |||
| </IconButton> | |||
| ); | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 23 | v1.0.0 | 2026-07-20 */ | |||
| export const TraceFlowEventNode = memo(function TraceFlowEventNode({ | |||
| id, | |||
| data, | |||
| }: NodeProps<Node<TraceFlowNodeData>>) { | |||
| const { t } = useTranslation("itemTracing"); | |||
| const { setNodes } = useReactFlow(); | |||
| const updateNodeInternals = useUpdateNodeInternals(); | |||
| const node = data.layoutNode; | |||
| const color = kindColor(node.kind); | |||
| const isQc = node.kind === "QC" || node.kind === "MATERIAL_QC"; | |||
| const isStockTake = node.kind === "STOCK_TAKE"; | |||
| const hasLifecycle = isStockTake && node.stockTakeRecordDetail != null; | |||
| const [lifecycleExpanded, setLifecycleExpanded] = useState(false); | |||
| const lifecycleStages = hasLifecycle | |||
| ? buildStockTakeLifecycleStages(node.stockTakeRecordDetail) | |||
| : []; | |||
| const nodeWidth = data.compact ? NODE_WIDTH_BRANCH : NODE_WIDTH; | |||
| useEffect(() => { | |||
| if (!hasLifecycle) return; | |||
| const nextH = lifecycleExpanded | |||
| ? NODE_HEIGHT + STOCK_TAKE_LIFECYCLE_PANEL_EXTRA | |||
| : NODE_HEIGHT; | |||
| setNodes((nds) => | |||
| nds.map((n) => { | |||
| if (n.id !== id) return n; | |||
| return { | |||
| ...n, | |||
| height: nextH, | |||
| style: { ...(n.style ?? {}), width: nodeWidth, height: nextH }, | |||
| measured: { | |||
| width: n.measured?.width ?? n.width ?? nodeWidth, | |||
| height: nextH, | |||
| }, | |||
| }; | |||
| }), | |||
| ); | |||
| const raf = requestAnimationFrame(() => updateNodeInternals(id)); | |||
| return () => cancelAnimationFrame(raf); | |||
| }, [ | |||
| hasLifecycle, | |||
| lifecycleExpanded, | |||
| id, | |||
| nodeWidth, | |||
| setNodes, | |||
| updateNodeInternals, | |||
| ]); | |||
| const chipLabel = | |||
| node.kind === "PUTAWAY" && node.putawayStatusLabel?.trim() | |||
| ? node.putawayStatusLabel | |||
| : isQc && node.qcTypeLabel?.trim() | |||
| ? node.qcTypeLabel | |||
| : t(kindLabelKey(node.kind)); | |||
| const dateLabel = node.timestamp?.trim() ? node.timestamp : t("noTimestamp"); | |||
| const isDoOut = node.kind === "DO_OUT"; | |||
| const doOutKindChipSx = { | |||
| height: data.compact ? 18 : 20, | |||
| "& .MuiChip-label": { | |||
| px: 0.75, | |||
| fontSize: data.compact ? "0.65rem" : "0.7rem", | |||
| fontWeight: 600, | |||
| }, | |||
| } as const; | |||
| const stopCardClick = (e: React.MouseEvent) => { | |||
| e.stopPropagation(); | |||
| }; | |||
| const doOutboundKindChips = | |||
| isDoOut && (node.doOutboundIsExtra || node.doOutboundIsReplenish) ? ( | |||
| <> | |||
| {node.doOutboundIsExtra ? ( | |||
| <Chip | |||
| size="small" | |||
| label={t("doOutboundExtra")} | |||
| color="secondary" | |||
| sx={doOutKindChipSx} | |||
| onClick={stopCardClick} | |||
| onMouseDown={stopCardClick} | |||
| /> | |||
| ) : null} | |||
| {node.doOutboundIsReplenish ? ( | |||
| <Chip | |||
| size="small" | |||
| label={t("doOutboundReplenish")} | |||
| color="success" | |||
| sx={doOutKindChipSx} | |||
| onClick={stopCardClick} | |||
| onMouseDown={stopCardClick} | |||
| /> | |||
| ) : null} | |||
| </> | |||
| ) : null; | |||
| const titleContent = isDoOut ? ( | |||
| <Box | |||
| component="span" | |||
| sx={{ | |||
| display: "inline-flex", | |||
| flexWrap: "wrap", | |||
| alignItems: "center", | |||
| gap: 0.5, | |||
| maxWidth: "100%", | |||
| }} | |||
| > | |||
| {node.docLinkKind && node.refCode ? ( | |||
| <ItemTracingDocLink | |||
| kind={node.docLinkKind} | |||
| code={node.refCode} | |||
| id={node.refId} | |||
| consoCode={node.consoCode ?? node.refCode} | |||
| ticketNo={node.docLinkTicketNo} | |||
| targetDate={node.docLinkTargetDate} | |||
| openInNewTab | |||
| /> | |||
| ) : ( | |||
| node.refCode || "—" | |||
| )} | |||
| </Box> | |||
| ) : node.docLinkKind && node.refCode ? ( | |||
| <ItemTracingDocLink | |||
| kind={node.docLinkKind} | |||
| code={node.refCode} | |||
| id={node.refId} | |||
| consoCode={node.consoCode ?? node.refCode} | |||
| ticketNo={node.docLinkTicketNo} | |||
| targetDate={node.docLinkTargetDate} | |||
| openInNewTab | |||
| /> | |||
| ) : ( | |||
| node.title | |||
| ); | |||
| const incomingCount = data.incomingHandleCount ?? 0; | |||
| const outgoingCount = data.outgoingHandleCount ?? 0; | |||
| const searchActive = data.searchActive ?? false; | |||
| const searchMatch = data.searchMatch ?? false; | |||
| const searchFocused = data.searchFocused ?? false; | |||
| const nodeSelected = data.nodeSelected ?? false; | |||
| const hiddenHandleStyle = { opacity: 0, width: 8, height: 8, transform: "none" as const }; | |||
| const activeCount = lifecycleStages.filter((s) => s.isActive).length; | |||
| const card = ( | |||
| <Paper | |||
| variant="outlined" | |||
| sx={{ | |||
| width: nodeWidth, | |||
| minWidth: nodeWidth, | |||
| maxWidth: nodeWidth, | |||
| height: NODE_HEIGHT, | |||
| display: "flex", | |||
| flexDirection: "column", | |||
| overflow: "hidden", | |||
| position: "relative", | |||
| borderColor: searchFocused | |||
| ? "primary.main" | |||
| : nodeSelected | |||
| ? "primary.main" | |||
| : searchMatch | |||
| ? "warning.main" | |||
| : isQc | |||
| ? `${color}.main` | |||
| : undefined, | |||
| borderWidth: searchFocused || searchMatch || nodeSelected ? 2 : isQc ? 2 : 1, | |||
| opacity: searchActive && !searchMatch ? 0.35 : 1, | |||
| transition: "opacity 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease", | |||
| boxShadow: searchFocused ? 8 : nodeSelected ? 6 : searchMatch ? 4 : undefined, | |||
| cursor: "pointer", | |||
| "&:hover": { boxShadow: searchFocused || searchMatch || nodeSelected ? undefined : 6 }, | |||
| }} | |||
| > | |||
| {incomingCount > 0 && | |||
| Array.from({ length: incomingCount }, (_, i) => ( | |||
| <Handle | |||
| key={`in-${i}`} | |||
| id={`in-${i}`} | |||
| type="target" | |||
| position={Position.Left} | |||
| style={{ | |||
| ...hiddenHandleStyle, | |||
| top: traceFlowHandleTopPercent(i, incomingCount), | |||
| left: 0, | |||
| }} | |||
| /> | |||
| ))} | |||
| <Box sx={{ height: 4, bgcolor: `${color}.main`, flexShrink: 0 }} /> | |||
| <Box | |||
| sx={{ | |||
| p: data.compact ? 1 : 1.25, | |||
| pb: hasLifecycle ? (data.compact ? 0.5 : 0.75) : undefined, | |||
| flex: 1, | |||
| minHeight: 0, | |||
| overflow: "hidden", | |||
| }} | |||
| > | |||
| <Box | |||
| sx={{ | |||
| display: "flex", | |||
| alignItems: "center", | |||
| flexWrap: "wrap", | |||
| gap: 0.5, | |||
| mb: 0.75, | |||
| }} | |||
| > | |||
| {isQc && ( | |||
| <Box | |||
| sx={{ | |||
| width: 8, | |||
| height: 8, | |||
| bgcolor: `${color}.main`, | |||
| transform: "rotate(45deg)", | |||
| flexShrink: 0, | |||
| }} | |||
| /> | |||
| )} | |||
| <Chip size="small" label={chipLabel} color={color} /> | |||
| {doOutboundKindChips} | |||
| </Box> | |||
| <Box | |||
| sx={{ | |||
| lineHeight: 1.3, | |||
| fontSize: data.compact ? "0.8rem" : undefined, | |||
| fontWeight: 700, | |||
| overflow: "hidden", | |||
| overflowWrap: "anywhere", | |||
| wordBreak: "break-word", | |||
| ...(isDoOut | |||
| ? { display: "flex", flexWrap: "wrap", alignItems: "center", gap: 0.5 } | |||
| : { | |||
| display: "-webkit-box", | |||
| WebkitLineClamp: 2, | |||
| WebkitBoxOrient: "vertical", | |||
| }), | |||
| }} | |||
| title={typeof node.refCode === "string" && node.refCode.trim() ? node.refCode : node.title} | |||
| > | |||
| {titleContent} | |||
| </Box> | |||
| {node.subtitle?.trim() ? ( | |||
| <Typography | |||
| variant="caption" | |||
| color="text.secondary" | |||
| display="block" | |||
| title={node.subtitle} | |||
| sx={{ | |||
| mt: 0.5, | |||
| overflow: "hidden", | |||
| overflowWrap: "anywhere", | |||
| wordBreak: "break-word", | |||
| display: "-webkit-box", | |||
| WebkitLineClamp: 2, | |||
| WebkitBoxOrient: "vertical", | |||
| }} | |||
| > | |||
| {node.subtitle} | |||
| </Typography> | |||
| ) : null} | |||
| {node.kind === "MATERIAL_PICK" && node.traceItemCode?.trim() ? ( | |||
| <Typography | |||
| variant="body2" | |||
| fontWeight={600} | |||
| title={node.meta?.trim() || node.traceItemCode} | |||
| sx={{ | |||
| mt: 0.5, | |||
| fontSize: data.compact ? "0.8rem" : undefined, | |||
| overflow: "hidden", | |||
| overflowWrap: "anywhere", | |||
| wordBreak: "break-word", | |||
| display: "-webkit-box", | |||
| WebkitLineClamp: 2, | |||
| WebkitBoxOrient: "vertical", | |||
| }} | |||
| > | |||
| {t("Item")}: {node.meta?.trim() || node.traceItemCode} | |||
| </Typography> | |||
| ) : null} | |||
| {(node.qty != null || node.kind === "STOCK_TAKE") && ( | |||
| <Typography | |||
| variant="body2" | |||
| fontWeight={600} | |||
| title={ | |||
| node.kind === "ADJUSTMENT" | |||
| ? formatSignedQty(node.qty, node.adjustmentDirection, node.uom) | |||
| : formatQty(node.qty, node.uom) | |||
| } | |||
| sx={{ | |||
| mt: 0.5, | |||
| fontSize: data.compact ? "0.8rem" : undefined, | |||
| overflow: "hidden", | |||
| overflowWrap: "anywhere", | |||
| wordBreak: "break-word", | |||
| display: "-webkit-box", | |||
| WebkitLineClamp: 2, | |||
| WebkitBoxOrient: "vertical", | |||
| color: | |||
| node.kind === "DO_OUT" || | |||
| node.kind === "JO_OUT" || | |||
| node.kind === "MATERIAL_PICK" | |||
| ? resolveDoOutboundQtyColor( | |||
| node.qty, | |||
| node.kind === "DO_OUT" ? node.doOutboundQtyChanged : false, | |||
| ) | |||
| : undefined, | |||
| }} | |||
| > | |||
| {node.kind === "PURCHASE" | |||
| ? t("detailOrderQty") | |||
| : node.kind === "STOCK_TAKE" | |||
| ? t("after") | |||
| : node.kind === "ADJUSTMENT" | |||
| ? t("variance") | |||
| : node.kind === "QC" || node.kind === "MATERIAL_QC" || node.kind === "FAIL" | |||
| ? t("unqualifiedQty") | |||
| : node.kind === "PRODUCTION_STEP" | |||
| ? t("processOutputQty") | |||
| : t("qty")} | |||
| :{" "} | |||
| {node.kind === "ADJUSTMENT" | |||
| ? formatSignedQty(node.qty, node.adjustmentDirection, node.uom) | |||
| : formatQty(node.qty, node.uom)} | |||
| </Typography> | |||
| )} | |||
| {node.kind === "STOCK_TAKE" ? ( | |||
| <Typography | |||
| variant="body2" | |||
| fontWeight={600} | |||
| title={formatQty(node.stockTakeVarianceQty, node.uom)} | |||
| sx={{ | |||
| mt: 0.25, | |||
| fontSize: data.compact ? "0.8rem" : undefined, | |||
| overflow: "hidden", | |||
| overflowWrap: "anywhere", | |||
| wordBreak: "break-word", | |||
| display: "-webkit-box", | |||
| WebkitLineClamp: 2, | |||
| WebkitBoxOrient: "vertical", | |||
| color: | |||
| node.stockTakeVarianceQty != null && node.stockTakeVarianceQty > 0 | |||
| ? "success.main" | |||
| : node.stockTakeVarianceQty != null && node.stockTakeVarianceQty < 0 | |||
| ? "error.main" | |||
| : undefined, | |||
| }} | |||
| > | |||
| {t("variance")}: {formatQty(node.stockTakeVarianceQty, node.uom)} | |||
| </Typography> | |||
| ) : null} | |||
| {node.traceLotNo ? ( | |||
| <ItemTracingLotTraceLink | |||
| label={ | |||
| node.kind === "BYPRODUCT" | |||
| ? t("traceByproductLot") | |||
| : node.kind === "REPACK" | |||
| ? t("traceRepackLot") | |||
| : t("traceMaterialLot") | |||
| } | |||
| lotNo={node.traceLotNo} | |||
| itemCode={node.traceItemCode} | |||
| stopPropagation | |||
| sx={{ mt: 0.5, display: "inline-block" }} | |||
| /> | |||
| ) : null} | |||
| {node.warehouseCode?.trim() ? ( | |||
| <Typography | |||
| variant="caption" | |||
| color="text.secondary" | |||
| display="block" | |||
| title={node.warehouseCode} | |||
| sx={{ | |||
| mt: 0.5, | |||
| fontWeight: 600, | |||
| overflow: "hidden", | |||
| overflowWrap: "anywhere", | |||
| wordBreak: "break-word", | |||
| display: "-webkit-box", | |||
| WebkitLineClamp: 2, | |||
| WebkitBoxOrient: "vertical", | |||
| }} | |||
| > | |||
| {t("warehouse")}: {node.warehouseCode} | |||
| </Typography> | |||
| ) : null} | |||
| {(node.kind === "JO_OUT" || node.kind === "MATERIAL_PICK") && | |||
| node.processingStatusLabel?.trim() ? ( | |||
| <Typography | |||
| variant="caption" | |||
| color="text.secondary" | |||
| display="block" | |||
| title={node.processingStatusLabel} | |||
| sx={{ | |||
| mt: 0.5, | |||
| fontWeight: 600, | |||
| overflow: "hidden", | |||
| overflowWrap: "anywhere", | |||
| wordBreak: "break-word", | |||
| display: "-webkit-box", | |||
| WebkitLineClamp: 1, | |||
| WebkitBoxOrient: "vertical", | |||
| }} | |||
| > | |||
| {t("processingStatus")}:{" "} | |||
| <Box | |||
| component="span" | |||
| sx={{ | |||
| color: (() => { | |||
| const c = pickStatusValueColor(node.processingStatus); | |||
| return c === "default" ? "text.primary" : `${c}.main`; | |||
| })(), | |||
| fontWeight: 700, | |||
| }} | |||
| > | |||
| {node.processingStatusLabel} | |||
| </Box> | |||
| </Typography> | |||
| ) : null} | |||
| {(node.kind === "JO_OUT" || node.kind === "MATERIAL_PICK") && | |||
| node.matchStatusLabel?.trim() ? ( | |||
| <Typography | |||
| variant="caption" | |||
| color="text.secondary" | |||
| display="block" | |||
| title={node.matchStatusLabel} | |||
| sx={{ | |||
| mt: 0.5, | |||
| fontWeight: 600, | |||
| overflow: "hidden", | |||
| overflowWrap: "anywhere", | |||
| wordBreak: "break-word", | |||
| display: "-webkit-box", | |||
| WebkitLineClamp: 1, | |||
| WebkitBoxOrient: "vertical", | |||
| }} | |||
| > | |||
| {t("matchStatus")}:{" "} | |||
| <Box | |||
| component="span" | |||
| sx={{ | |||
| color: (() => { | |||
| const c = pickStatusValueColor(node.matchStatus); | |||
| return c === "default" ? "text.primary" : `${c}.main`; | |||
| })(), | |||
| fontWeight: 700, | |||
| }} | |||
| > | |||
| {node.matchStatusLabel} | |||
| </Box> | |||
| </Typography> | |||
| ) : null} | |||
| <Typography variant="caption" color="text.secondary" display="block" sx={{ mt: 0.75 }}> | |||
| {dateLabel} | |||
| </Typography> | |||
| </Box> | |||
| {hasLifecycle ? ( | |||
| <Box | |||
| sx={{ | |||
| flexShrink: 0, | |||
| borderTop: 1, | |||
| borderColor: "divider", | |||
| px: data.compact ? 0.75 : 1, | |||
| py: 0.25, | |||
| }} | |||
| > | |||
| <Box | |||
| role="button" | |||
| tabIndex={0} | |||
| aria-expanded={lifecycleExpanded} | |||
| aria-label={lifecycleExpanded ? t("stockTakeStageCollapse") : t("stockTakeStageExpand")} | |||
| onClick={(e) => { | |||
| e.stopPropagation(); | |||
| setLifecycleExpanded((v) => !v); | |||
| }} | |||
| onKeyDown={(e) => { | |||
| if (e.key === "Enter" || e.key === " ") { | |||
| e.preventDefault(); | |||
| e.stopPropagation(); | |||
| setLifecycleExpanded((v) => !v); | |||
| } | |||
| }} | |||
| sx={{ | |||
| display: "flex", | |||
| alignItems: "center", | |||
| justifyContent: "space-between", | |||
| gap: 0.5, | |||
| width: "100%", | |||
| py: 0.25, | |||
| px: 0.5, | |||
| borderRadius: 1, | |||
| cursor: "pointer", | |||
| userSelect: "none", | |||
| "&:hover": { bgcolor: "action.hover" }, | |||
| }} | |||
| > | |||
| <Typography variant="caption" fontWeight={700} color="secondary.main" noWrap> | |||
| {t("nodeStockTake")} · {t("stockTakeStageDetail", { count: activeCount })} | |||
| </Typography> | |||
| {lifecycleExpanded ? ( | |||
| <ExpandLessIcon fontSize="small" color="secondary" /> | |||
| ) : ( | |||
| <ExpandMoreIcon fontSize="small" color="secondary" /> | |||
| )} | |||
| </Box> | |||
| </Box> | |||
| ) : null} | |||
| {outgoingCount > 0 && | |||
| Array.from({ length: outgoingCount }, (_, i) => ( | |||
| <Handle | |||
| key={`out-${i}`} | |||
| id={`out-${i}`} | |||
| type="source" | |||
| position={Position.Right} | |||
| style={{ | |||
| ...hiddenHandleStyle, | |||
| top: traceFlowHandleTopPercent(i, outgoingCount), | |||
| right: 0, | |||
| }} | |||
| /> | |||
| ))} | |||
| </Paper> | |||
| ); | |||
| // Inline under the card so pan/zoom keeps the panel attached (Popper portals to body). | |||
| const lifecyclePanel = | |||
| hasLifecycle && lifecycleExpanded ? ( | |||
| <Paper | |||
| variant="outlined" | |||
| className="nowheel nodrag nopan" | |||
| onClick={(e) => e.stopPropagation()} | |||
| onMouseDown={(e) => e.stopPropagation()} | |||
| onWheel={(e) => e.stopPropagation()} | |||
| sx={{ | |||
| width: nodeWidth, | |||
| maxWidth: nodeWidth, | |||
| mt: 0.75, | |||
| p: 1.25, | |||
| boxShadow: 4, | |||
| borderColor: "secondary.main", | |||
| borderWidth: 1.5, | |||
| maxHeight: STOCK_TAKE_LIFECYCLE_PANEL_EXTRA - 24, | |||
| overflow: "auto", | |||
| overscrollBehavior: "contain", | |||
| bgcolor: "background.paper", | |||
| pointerEvents: "all", | |||
| }} | |||
| > | |||
| <Typography variant="caption" fontWeight={700} color="secondary.main" sx={{ mb: 1, display: "block" }}> | |||
| {t("nodeStockTake")} · {t("stockTakeStageDetail", { count: activeCount })} | |||
| </Typography> | |||
| <Divider sx={{ mb: 1 }} /> | |||
| <ItemTracingStockTakeLifecycle stages={lifecycleStages} uom={node.uom} /> | |||
| </Paper> | |||
| ) : null; | |||
| const wrapped = ( | |||
| <Box | |||
| sx={{ | |||
| width: nodeWidth, | |||
| height: lifecycleExpanded | |||
| ? NODE_HEIGHT + STOCK_TAKE_LIFECYCLE_PANEL_EXTRA | |||
| : NODE_HEIGHT, | |||
| overflow: "visible", | |||
| }} | |||
| > | |||
| {card} | |||
| {lifecyclePanel} | |||
| </Box> | |||
| ); | |||
| if (node.meta) { | |||
| return ( | |||
| <Tooltip title={node.meta} arrow placement="top"> | |||
| {wrapped} | |||
| </Tooltip> | |||
| ); | |||
| } | |||
| return wrapped; | |||
| }); | |||
| export const TraceFlowPhaseLabelNode = memo(function TraceFlowPhaseLabelNode({ | |||
| data, | |||
| }: NodeProps<Node<TraceFlowNodeData>>) { | |||
| return ( | |||
| <Box | |||
| sx={{ | |||
| width: PHASE_LABEL_INNER, | |||
| display: "flex", | |||
| alignItems: "center", | |||
| justifyContent: "center", | |||
| px: 0.75, | |||
| py: 1, | |||
| borderLeft: `4px solid ${data.phaseColor ?? "#757575"}`, | |||
| backgroundImage: `linear-gradient(${data.phaseColor ?? "#757575"}14, ${data.phaseColor ?? "#757575"}14)`, | |||
| borderRadius: 1, | |||
| }} | |||
| > | |||
| <Typography | |||
| variant="caption" | |||
| fontWeight={700} | |||
| sx={{ | |||
| writingMode: "vertical-rl", | |||
| textOrientation: "mixed", | |||
| color: data.phaseColor ?? "#757575", | |||
| letterSpacing: 0.5, | |||
| }} | |||
| > | |||
| {data.phaseLabel} | |||
| </Typography> | |||
| </Box> | |||
| ); | |||
| }); | |||
| export const TraceFlowDateHeaderNode = memo(function TraceFlowDateHeaderNode({ | |||
| data, | |||
| }: NodeProps<Node<TraceFlowNodeData>>) { | |||
| return ( | |||
| <Typography variant="caption" color="text.secondary" fontWeight={600} sx={{ width: 80, textAlign: "center" }}> | |||
| {data.dateLabel} | |||
| </Typography> | |||
| ); | |||
| }); | |||
| export const TraceFlowDoGroupNode = memo(function TraceFlowDoGroupNode({ | |||
| data, | |||
| }: NodeProps<Node<TraceFlowNodeData>>) { | |||
| const { t } = useTranslation("itemTracing"); | |||
| const node = data.layoutNode; | |||
| const incomingCount = data.incomingHandleCount ?? 0; | |||
| const searchActive = data.searchActive ?? false; | |||
| const searchMatch = data.searchMatch ?? false; | |||
| const searchFocused = data.searchFocused ?? false; | |||
| const collapsed = data.groupCollapsed === true; | |||
| const width = node.groupBoxWidth ?? NODE_WIDTH; | |||
| const height = collapsed ? DO_GROUP_HEADER : (node.groupBoxHeight ?? NODE_HEIGHT); | |||
| const hiddenHandleStyle = { opacity: 0, width: 8, height: 8, transform: "none" as const }; | |||
| return ( | |||
| <Box | |||
| sx={{ | |||
| width, | |||
| height, | |||
| border: 2, | |||
| borderStyle: "dashed", | |||
| borderColor: searchFocused | |||
| ? "primary.main" | |||
| : searchMatch | |||
| ? "warning.main" | |||
| : "warning.light", | |||
| borderRadius: 1, | |||
| bgcolor: (theme) => alpha(theme.palette.background.paper, 0.45), | |||
| opacity: searchActive && !searchMatch ? 0.35 : 1, | |||
| boxShadow: searchFocused ? 4 : searchMatch ? 2 : undefined, | |||
| position: "relative", | |||
| overflow: "hidden", | |||
| pointerEvents: "none", | |||
| }} | |||
| > | |||
| {incomingCount > 0 && | |||
| Array.from({ length: incomingCount }, (_, i) => ( | |||
| <Handle | |||
| key={`in-${i}`} | |||
| id={`in-${i}`} | |||
| type="target" | |||
| position={Position.Left} | |||
| style={{ | |||
| ...hiddenHandleStyle, | |||
| top: traceFlowHandleTopPercent(i, incomingCount), | |||
| left: 0, | |||
| pointerEvents: "all", | |||
| }} | |||
| /> | |||
| ))} | |||
| <Box | |||
| sx={{ | |||
| height: DO_GROUP_HEADER, | |||
| px: 1.25, | |||
| display: "flex", | |||
| alignItems: "center", | |||
| gap: 1, | |||
| overflow: "hidden", | |||
| bgcolor: (theme) => alpha(theme.palette.warning.light, 0.35), | |||
| borderBottom: collapsed ? 0 : 1, | |||
| borderColor: "warning.light", | |||
| }} | |||
| > | |||
| <Typography variant="caption" fontWeight={700} noWrap sx={{ color: "warning.dark", minWidth: 0 }}> | |||
| {node.title} | |||
| </Typography> | |||
| {node.groupTotalQty != null ? ( | |||
| <Typography variant="caption" fontWeight={600} noWrap sx={{ color: "warning.dark", flexShrink: 0 }}> | |||
| {t("flowDoGroupTotalQty", { | |||
| qtyLabel: formatQty(node.groupTotalQty, node.groupUom), | |||
| })} | |||
| </Typography> | |||
| ) : null} | |||
| <GroupCollapseButton | |||
| collapsed={collapsed} | |||
| onToggle={data.onToggleGroupCollapse} | |||
| collapseLabel={t("flowGroupCollapse")} | |||
| expandLabel={t("flowGroupExpand")} | |||
| /> | |||
| </Box> | |||
| </Box> | |||
| ); | |||
| }); | |||
| export const TraceFlowPickGroupNode = memo(function TraceFlowPickGroupNode({ | |||
| data, | |||
| }: NodeProps<Node<TraceFlowNodeData>>) { | |||
| const { t } = useTranslation("itemTracing"); | |||
| const node = data.layoutNode; | |||
| const incomingCount = data.incomingHandleCount ?? 0; | |||
| const outgoingCount = data.outgoingHandleCount ?? 0; | |||
| const searchActive = data.searchActive ?? false; | |||
| const searchMatch = data.searchMatch ?? false; | |||
| const searchFocused = data.searchFocused ?? false; | |||
| const collapsed = data.groupCollapsed === true; | |||
| const width = node.groupBoxWidth ?? NODE_WIDTH; | |||
| const height = collapsed ? DO_GROUP_HEADER : (node.groupBoxHeight ?? NODE_HEIGHT); | |||
| const hiddenHandleStyle = { opacity: 0, width: 8, height: 8, transform: "none" as const }; | |||
| return ( | |||
| <Box | |||
| sx={{ | |||
| width, | |||
| height, | |||
| border: 2, | |||
| borderStyle: "dashed", | |||
| borderColor: searchFocused | |||
| ? "primary.main" | |||
| : searchMatch | |||
| ? "warning.main" | |||
| : "warning.light", | |||
| borderRadius: 1, | |||
| bgcolor: (theme) => alpha(theme.palette.background.paper, 0.45), | |||
| opacity: searchActive && !searchMatch ? 0.35 : 1, | |||
| boxShadow: searchFocused ? 4 : searchMatch ? 2 : undefined, | |||
| position: "relative", | |||
| overflow: "hidden", | |||
| pointerEvents: "none", | |||
| }} | |||
| > | |||
| {incomingCount > 0 && | |||
| Array.from({ length: incomingCount }, (_, i) => ( | |||
| <Handle | |||
| key={`in-${i}`} | |||
| id={`in-${i}`} | |||
| type="target" | |||
| position={Position.Left} | |||
| style={{ | |||
| ...hiddenHandleStyle, | |||
| top: traceFlowHandleTopPercent(i, incomingCount), | |||
| left: 0, | |||
| pointerEvents: "all", | |||
| }} | |||
| /> | |||
| ))} | |||
| <Box | |||
| sx={{ | |||
| height: DO_GROUP_HEADER, | |||
| px: 1.25, | |||
| display: "flex", | |||
| alignItems: "center", | |||
| gap: 0.5, | |||
| bgcolor: (theme) => alpha(theme.palette.warning.light, 0.35), | |||
| borderBottom: collapsed ? 0 : 1, | |||
| borderColor: "warning.light", | |||
| }} | |||
| > | |||
| <Typography variant="caption" fontWeight={700} noWrap sx={{ flex: 1, color: "warning.dark", minWidth: 0 }}> | |||
| {node.title} | |||
| </Typography> | |||
| <GroupCollapseButton | |||
| collapsed={collapsed} | |||
| onToggle={data.onToggleGroupCollapse} | |||
| collapseLabel={t("flowGroupCollapse")} | |||
| expandLabel={t("flowGroupExpand")} | |||
| /> | |||
| </Box> | |||
| {outgoingCount > 0 && | |||
| Array.from({ length: outgoingCount }, (_, i) => ( | |||
| <Handle | |||
| key={`out-${i}`} | |||
| id={`out-${i}`} | |||
| type="source" | |||
| position={Position.Right} | |||
| style={{ | |||
| ...hiddenHandleStyle, | |||
| top: traceFlowHandleTopPercent(i, outgoingCount), | |||
| right: 0, | |||
| pointerEvents: "all", | |||
| }} | |||
| /> | |||
| ))} | |||
| </Box> | |||
| ); | |||
| }); | |||
| export const traceFlowNodeTypes = { | |||
| traceEvent: TraceFlowEventNode, | |||
| doGroup: TraceFlowDoGroupNode, | |||
| pickGroup: TraceFlowPickGroupNode, | |||
| phaseLabel: TraceFlowPhaseLabelNode, | |||
| dateHeader: TraceFlowDateHeaderNode, | |||
| }; | |||
| @@ -0,0 +1,278 @@ | |||
| import { ItemLotTraceResponse } from "@/app/api/itemTracing"; | |||
| import dayjs from "dayjs"; | |||
| import { | |||
| TraceGraphDetailLabels, | |||
| TraceGraphNode, | |||
| TraceGraphBuildScope, | |||
| scopePrefix, | |||
| withNodeScope, | |||
| } from "./buildTraceGraphNodes"; | |||
| import { formatQty } from "./traceQtyUtils"; | |||
| import { | |||
| formatDoOutboundKindLabel, | |||
| resolveDoOutboundChipFlags, | |||
| } from "./traceLabelUtils"; | |||
| import { resolveDoOutboundDocLink } from "./traceDocLinkUtils"; | |||
| import { field, fieldIf, detailsOf, parseSortKey } from "./traceNodeFactory"; | |||
| export interface ExtendedTraceGraphLabels extends TraceGraphDetailLabels { | |||
| nodeOpen: string; | |||
| nodeFail: string; | |||
| nodeDoOut: string; | |||
| nodeReplenishmentCreated: string; | |||
| nodeReturn: string; | |||
| nodeRepack: string; | |||
| traceRepackLot: string; | |||
| doOutboundExtra: string; | |||
| doOutboundReplenish: string; | |||
| detailDoOutboundKind: string; | |||
| } | |||
| const shopDisplayLabel = (shopCode?: string, shopName?: string): string => { | |||
| const code = shopCode?.trim(); | |||
| const name = shopName?.trim(); | |||
| if (code && name) return `${code} · ${name}`; | |||
| return name || code || ""; | |||
| }; | |||
| export const buildExtendedTraceGraphNodes = ( | |||
| data: ItemLotTraceResponse, | |||
| labels: ExtendedTraceGraphLabels, | |||
| scope?: TraceGraphBuildScope, | |||
| ): TraceGraphNode[] => { | |||
| const nodes: TraceGraphNode[] = []; | |||
| let seq = 0; | |||
| const stockUom = data.lot.uom; | |||
| const idPfx = scopePrefix(scope); | |||
| const lb = scope?.locationBlockKeys ?? false; | |||
| (data.openMovements ?? []).forEach((o, i) => { | |||
| nodes.push({ | |||
| id: lb ? `${idPfx}open-${i}-${o.stockInLineId}` : `${idPfx}open-${o.stockInLineId}`, | |||
| kind: "OPEN", | |||
| timestamp: o.timestamp, | |||
| sortKey: parseSortKey(o.timestamp, seq++), | |||
| title: labels.nodeOpen, | |||
| subtitle: [o.refCode, o.warehouseCode].filter(Boolean).join(" · ") || "—", | |||
| qty: o.qty, | |||
| uom: stockUom, | |||
| refCode: o.refCode, | |||
| categoryLabel: labels.categoryOpen, | |||
| details: detailsOf( | |||
| field(labels.detailType, labels.nodeOpen), | |||
| field(labels.detailInboundSiNo, o.refCode), | |||
| field(labels.detailWarehouse, o.warehouseCode), | |||
| field(labels.detailQty, formatQty(o.qty, stockUom)), | |||
| field(labels.detailHandler, o.handledBy), | |||
| field(labels.detailTime, o.timestamp), | |||
| fieldIf(labels.detailRemarks, o.remarks), | |||
| ), | |||
| }); | |||
| }); | |||
| (data.failEvents ?? []).forEach((f, i) => { | |||
| nodes.push({ | |||
| id: lb ? `${idPfx}fail-${i}-${f.failId}` : `${idPfx}fail-${f.failId}`, | |||
| kind: "FAIL", | |||
| timestamp: f.recordDate, | |||
| sortKey: parseSortKey(f.recordDate, seq++), | |||
| title: labels.nodeFail, | |||
| subtitle: [labels.tr.failType(f.failType), f.category].filter(Boolean).join(" · ") || "—", | |||
| qty: f.qty, | |||
| uom: stockUom, | |||
| refType: "JO_PICK", | |||
| refCode: f.pickOrderCode, | |||
| refId: f.pickOrderId, | |||
| docLinkKind: f.pickOrderCode ? "jodetail" : undefined, | |||
| consoCode: f.pickOrderCode, | |||
| docLinkTargetDate: f.recordDate?.trim() | |||
| ? dayjs(f.recordDate).isValid() | |||
| ? dayjs(f.recordDate).format("YYYY-MM-DD") | |||
| : f.recordDate.trim().slice(0, 10) | |||
| : undefined, | |||
| categoryLabel: labels.categoryQc, | |||
| details: detailsOf( | |||
| field(labels.detailType, labels.nodeFail), | |||
| field(labels.detailStatus, labels.tr.failType(f.failType)), | |||
| field(labels.pickOrder, f.pickOrderCode, { | |||
| linkKind: f.pickOrderCode ? "jodetail" : undefined, | |||
| linkCode: f.pickOrderCode, | |||
| linkId: f.pickOrderId, | |||
| consoCode: f.pickOrderCode, | |||
| linkTargetDate: f.recordDate?.trim() | |||
| ? dayjs(f.recordDate).isValid() | |||
| ? dayjs(f.recordDate).format("YYYY-MM-DD") | |||
| : f.recordDate.trim().slice(0, 10) | |||
| : undefined, | |||
| }), | |||
| field(labels.detailQty, formatQty(f.qty, stockUom)), | |||
| field(labels.detailHandler, f.handlerName), | |||
| field(labels.detailTime, f.recordDate), | |||
| fieldIf(labels.detailFailCategory, f.category), | |||
| ), | |||
| }); | |||
| }); | |||
| (data.replenishmentEvents ?? []).forEach((r) => { | |||
| const supplyTo = shopDisplayLabel(r.shopCode, r.shopName); | |||
| nodes.push({ | |||
| id: `${idPfx}replenish-${r.replenishmentId}`, | |||
| kind: "REPLENISHMENT_CREATED", | |||
| timestamp: r.timestamp, | |||
| sortKey: parseSortKey(r.timestamp, seq++), | |||
| title: labels.nodeReplenishmentCreated, | |||
| subtitle: r.sourceDoCode || r.replenishmentCode || "—", | |||
| qty: r.replenishQty, | |||
| uom: stockUom, | |||
| refType: "DO", | |||
| refCode: r.sourceDoCode, | |||
| refId: r.sourceDoId, | |||
| replenishmentStockOutLineId: r.stockOutLineId ?? undefined, | |||
| categoryLabel: labels.categoryOutbound, | |||
| details: detailsOf( | |||
| field(labels.detailType, labels.nodeReplenishmentCreated), | |||
| field(labels.deliveryOrder, r.sourceDoCode), | |||
| field(labels.detailSupplyTo, supplyTo || "—"), | |||
| field(labels.detailItemCode, r.itemNo), | |||
| field(labels.detailItemName, r.itemName), | |||
| field(labels.detailReplenishmentCode, r.replenishmentCode), | |||
| field(labels.detailQty, formatQty(r.replenishQty, stockUom)), | |||
| field(labels.detailReason, r.reason), | |||
| field(labels.detailHandler, r.handler), | |||
| field(labels.detailTime, r.timestamp), | |||
| ), | |||
| }); | |||
| }); | |||
| (data.doDeliveries ?? []).forEach((d, i) => { | |||
| const supplyTo = shopDisplayLabel(d.shopCode, d.shopName); | |||
| const kindLabels = { | |||
| extra: labels.doOutboundExtra, | |||
| replenish: labels.doOutboundReplenish, | |||
| }; | |||
| const chipFlags = resolveDoOutboundChipFlags({ | |||
| isExtra: d.isExtra, | |||
| isReplenish: d.isReplenish, | |||
| ticketNo: d.ticketNo, | |||
| consoCode: d.consoCode, | |||
| releaseType: d.releaseType, | |||
| deliveryOrderPickOrderId: d.deliveryOrderPickOrderId, | |||
| relationshipId: d.relationshipId, | |||
| }); | |||
| const outboundKindLabel = formatDoOutboundKindLabel( | |||
| chipFlags.isExtra, | |||
| chipFlags.isReplenish, | |||
| kindLabels, | |||
| ); | |||
| const doCode = d.deliveryOrderCode || "—"; | |||
| const docLink = resolveDoOutboundDocLink({ | |||
| pickOrderCode: d.pickOrderCode, | |||
| pickOrderId: d.pickOrderId, | |||
| deliveryOrderCode: d.deliveryOrderCode, | |||
| ticketNo: d.ticketNo, | |||
| consoCode: d.consoCode, | |||
| timestamp: d.timestamp, | |||
| deliveryOrderPickOrderId: d.deliveryOrderPickOrderId, | |||
| }); | |||
| nodes.push({ | |||
| id: lb ? `${idPfx}dodel-${i}-${d.stockOutLineId}` : `${idPfx}do-${d.stockOutLineId}`, | |||
| kind: "DO_OUT", | |||
| timestamp: d.timestamp, | |||
| sortKey: parseSortKey(d.timestamp, seq++), | |||
| title: `${labels.nodeDoOut} · ${doCode}`, | |||
| subtitle: "", | |||
| qty: d.qty, | |||
| uom: stockUom, | |||
| refType: "DO", | |||
| refCode: docLink.displayCode, | |||
| refId: d.deliveryOrderId ?? d.pickOrderId, | |||
| docLinkKind: docLink.kind, | |||
| docLinkTicketNo: docLink.ticketNo, | |||
| docLinkTargetDate: docLink.targetDate, | |||
| consoCode: docLink.consoCode || d.consoCode || d.pickOrderCode, | |||
| categoryLabel: labels.categoryOutbound, | |||
| doOutboundIsExtra: chipFlags.isExtra, | |||
| doOutboundIsReplenish: chipFlags.isReplenish, | |||
| doOutboundQtyChanged: d.qtyChanged === true, | |||
| outboundKindLabel, | |||
| warehouseCode: scope?.defaultWarehouseCode, | |||
| details: [ | |||
| field(labels.detailType, labels.nodeDoOut), | |||
| ...(outboundKindLabel | |||
| ? [field(labels.detailDoOutboundKind, outboundKindLabel)] | |||
| : []), | |||
| field(labels.deliveryOrder, d.deliveryOrderCode), | |||
| ...(d.deliveryNoteCode?.trim() | |||
| ? [field(labels.deliveryNoteCode, d.deliveryNoteCode.trim())] | |||
| : []), | |||
| ...(d.ticketNo?.trim() | |||
| ? [field(labels.ticketNo, d.ticketNo.trim())] | |||
| : []), | |||
| field(labels.detailSupplyTo, supplyTo || "—"), | |||
| field(labels.pickOrder, d.pickOrderCode, { | |||
| linkKind: docLink.kind, | |||
| linkCode: d.pickOrderCode, | |||
| linkId: d.pickOrderId, | |||
| consoCode: docLink.consoCode || d.consoCode || d.pickOrderCode, | |||
| linkTicketNo: docLink.ticketNo, | |||
| linkTargetDate: docLink.targetDate, | |||
| }), | |||
| field(labels.detailQty, formatQty(d.qty, stockUom)), | |||
| field(labels.detailHandler, d.handler), | |||
| field(labels.detailTime, d.timestamp), | |||
| ], | |||
| }); | |||
| }); | |||
| (data.returnEvents ?? []).forEach((r, i) => { | |||
| nodes.push({ | |||
| id: lb | |||
| ? `${idPfx}return-${i}-${r.stockOutLineId}` | |||
| : `${idPfx}return-${r.stockOutLineId}-${r.timestamp}`, | |||
| kind: "RETURN", | |||
| timestamp: r.timestamp, | |||
| sortKey: parseSortKey(r.timestamp, seq++), | |||
| title: labels.nodeReturn, | |||
| subtitle: [labels.tr.movementType(r.movementType), r.refCode].filter(Boolean).join(" · ") || "—", | |||
| qty: r.qty, | |||
| uom: stockUom, | |||
| refCode: r.refCode, | |||
| categoryLabel: labels.categoryOutbound, | |||
| details: detailsOf( | |||
| field(labels.detailType, labels.tr.movementType(r.movementType)), | |||
| field(labels.detailReturnRef, r.refCode), | |||
| field(labels.detailWarehouse, r.warehouseCode), | |||
| field(labels.detailQty, formatQty(r.qty, stockUom)), | |||
| field(labels.detailHandler, r.handler), | |||
| field(labels.detailTime, r.timestamp), | |||
| fieldIf(labels.detailRemarks, r.remarks), | |||
| ), | |||
| }); | |||
| }); | |||
| (data.lotRelations ?? []).forEach((rel, i) => { | |||
| nodes.push({ | |||
| id: `repack-${rel.inventoryLotId ?? i}-${rel.lotNo}`, | |||
| kind: "REPACK", | |||
| timestamp: rel.timestamp, | |||
| sortKey: parseSortKey(rel.timestamp, seq++), | |||
| title: `${rel.itemCode} · ${labels.nodeRepack}`, | |||
| subtitle: [rel.lotNo, rel.productLotNo].filter(Boolean).join(" · ") || rel.itemName, | |||
| qty: rel.qty, | |||
| uom: stockUom, | |||
| traceLotNo: rel.lotNo, | |||
| traceItemCode: rel.itemCode, | |||
| categoryLabel: labels.categoryTransfer, | |||
| details: [ | |||
| field(labels.detailType, labels.nodeRepack), | |||
| field(labels.detailMaterial, `${rel.itemCode} · ${rel.itemName}`), | |||
| field(labels.detailLot, rel.lotNo), | |||
| field(labels.detailQty, formatQty(rel.qty, stockUom)), | |||
| field(labels.detailProductLotNo, rel.productLotNo), | |||
| field(labels.detailTime, rel.timestamp), | |||
| ], | |||
| }); | |||
| }); | |||
| return nodes.map((n) => withNodeScope(n, scope)); | |||
| }; | |||
| @@ -0,0 +1,632 @@ | |||
| import { ItemLotTraceJoPrelude, ItemLotTraceMaterialInput } from "@/app/api/itemTracing"; | |||
| import { | |||
| TraceGraphDetailField, | |||
| TraceGraphDetailLabels, | |||
| TraceGraphNode, | |||
| } from "./buildTraceGraphNodes"; | |||
| import { | |||
| buildProductionGraphNodes, | |||
| ProductionGraphLabels, | |||
| } from "./buildProductionGraphNodes"; | |||
| import { | |||
| groupQcResultsBySession, | |||
| } from "./traceLabelUtils"; | |||
| import { formatQty, formatSignedQty } from "./traceQtyUtils"; | |||
| import { | |||
| createMaterialPickNode, | |||
| docLinkFromOriginType, | |||
| docLinkFromRefType, | |||
| field, | |||
| fieldIf, | |||
| detailsOf, | |||
| isJoProducedMaterial, | |||
| parseSortKey, | |||
| } from "./traceNodeFactory"; | |||
| import { buildPickOrderTargetDateMapFromPrelude } from "./traceDocLinkUtils"; | |||
| import { resolvePutawayPresentation, isTransferInboundPutaway } from "./tracePutawayUtils"; | |||
| export interface JoPreludeGraphLabels extends TraceGraphDetailLabels { | |||
| nodeMaterialIn: string; | |||
| nodeMaterialPick: string; | |||
| nodePurchase: string; | |||
| nodeReceipt: string; | |||
| nodeJoCreated: string; | |||
| pickOrder: string; | |||
| } | |||
| type LotPickRef = { | |||
| pickOrderCode: string; | |||
| pickOrderId: number | null; | |||
| consoCode: string; | |||
| }; | |||
| type PreludeBuildContext = { | |||
| seenPurchaseLines: Set<number>; | |||
| seenInboundLots: Set<number>; | |||
| seenReceiptLots: Set<number>; | |||
| seenQcKeys: Set<string>; | |||
| seenPutawayKeys: Set<string>; | |||
| pickOrderTargetDateMap: Map<string, string>; | |||
| seq: number; | |||
| }; | |||
| const materialLotKey = (itemCode: string, lotNo: string) => `${itemCode}::${lotNo}`; | |||
| const mergeLotPickMaps = ( | |||
| target: Map<string, LotPickRef[]>, | |||
| source: Map<string, LotPickRef[]>, | |||
| ): void => { | |||
| source.forEach((picks, key) => { | |||
| const list = target.get(key) ?? []; | |||
| picks.forEach((p) => { | |||
| if (!list.some((existing) => existing.pickOrderCode === p.pickOrderCode)) { | |||
| list.push(p); | |||
| } | |||
| }); | |||
| if (list.length) target.set(key, list); | |||
| }); | |||
| }; | |||
| const buildLotPickOrderMap = ( | |||
| materialInputs: ItemLotTraceJoPrelude["materialInputs"], | |||
| ): Map<string, LotPickRef[]> => { | |||
| const map = new Map<string, LotPickRef[]>(); | |||
| materialInputs.forEach((m) => { | |||
| const code = m.pickOrderCode?.trim(); | |||
| if (!code) return; | |||
| const key = materialLotKey(m.materialItemCode, m.materialLotNo); | |||
| const list = map.get(key) ?? []; | |||
| if (!list.some((p) => p.pickOrderCode === code)) { | |||
| list.push({ | |||
| pickOrderCode: code, | |||
| pickOrderId: m.pickOrderId, | |||
| consoCode: m.consoCode, | |||
| }); | |||
| } | |||
| map.set(key, list); | |||
| const nestedInputs = m.nestedJoPrelude?.materialInputs ?? []; | |||
| if (nestedInputs.length) { | |||
| mergeLotPickMaps(map, buildLotPickOrderMapRecursive(nestedInputs)); | |||
| } | |||
| }); | |||
| return map; | |||
| }; | |||
| const buildLotPickOrderMapRecursive = ( | |||
| materialInputs: ItemLotTraceMaterialInput[], | |||
| ): Map<string, LotPickRef[]> => buildLotPickOrderMap(materialInputs); | |||
| const pickOrdersForLot = ( | |||
| map: Map<string, LotPickRef[]>, | |||
| itemCode: string, | |||
| lotNo: string, | |||
| ): LotPickRef[] => map.get(materialLotKey(itemCode, lotNo)) ?? []; | |||
| const pickOrderCodesLabel = (picks: LotPickRef[]) => | |||
| picks.map((p) => p.pickOrderCode).join(" · "); | |||
| const appendPickToSubtitle = (base: string, picks: LotPickRef[], pickLabel: string): string => { | |||
| if (!picks.length) return base; | |||
| const suffix = `${pickLabel}: ${pickOrderCodesLabel(picks)}`; | |||
| return base ? `${base} · ${suffix}` : suffix; | |||
| }; | |||
| const pickOrderDetailFields = ( | |||
| picks: LotPickRef[], | |||
| pickLabel: string, | |||
| ): TraceGraphDetailField[] => | |||
| picks.map((p) => | |||
| field(pickLabel, p.pickOrderCode, { | |||
| linkKind: "pick", | |||
| linkCode: p.pickOrderCode, | |||
| linkId: p.pickOrderId, | |||
| consoCode: p.consoCode || p.pickOrderCode, | |||
| }), | |||
| ); | |||
| const nextSeq = (ctx: PreludeBuildContext): number => ctx.seq++; | |||
| const pickCtx = (ctx: PreludeBuildContext) => ({ | |||
| nextSeq: () => nextSeq(ctx), | |||
| pickOrderTargetDate: (pickOrderCode: string) => | |||
| ctx.pickOrderTargetDateMap.get(pickOrderCode?.trim() ?? ""), | |||
| }); | |||
| const buildMaterialProductionNodes = ( | |||
| m: ItemLotTraceMaterialInput, | |||
| labels: JoPreludeGraphLabels & Partial<ProductionGraphLabels>, | |||
| ctx: PreludeBuildContext, | |||
| ): TraceGraphNode[] => { | |||
| const steps = m.productionSteps ?? []; | |||
| if (!steps.length || !labels.nodeProductionStep || !labels.nodeScrap || !labels.nodeDefect) return []; | |||
| const matUom = m.materialUom?.trim() || ""; | |||
| return buildProductionGraphNodes(steps, [], labels as ProductionGraphLabels, matUom).map((node) => ({ | |||
| ...node, | |||
| id: `mat-prod-${m.materialLotNo}-${node.id}`, | |||
| traceLotNo: m.materialLotNo, | |||
| traceItemCode: m.materialItemCode, | |||
| subtitle: [m.materialItemCode, m.materialLotNo, node.subtitle].filter(Boolean).join(" · "), | |||
| })); | |||
| }; | |||
| const buildMaterialStockInPreludeNodes = ( | |||
| m: ItemLotTraceMaterialInput, | |||
| labels: JoPreludeGraphLabels, | |||
| lotPicks: Map<string, LotPickRef[]>, | |||
| ctx: PreludeBuildContext, | |||
| ): TraceGraphNode[] => { | |||
| const nodes: TraceGraphNode[] = []; | |||
| const picks = pickOrdersForLot(lotPicks, m.materialItemCode, m.materialLotNo); | |||
| const lotId = m.materialInventoryLotId; | |||
| const origin = m.stockInOrigin; | |||
| const matUom = m.materialUom?.trim() || ""; | |||
| (m.purchaseEvents ?? []).forEach((p) => { | |||
| if (ctx.seenPurchaseLines.has(p.purchaseOrderLineId)) return; | |||
| ctx.seenPurchaseLines.add(p.purchaseOrderLineId); | |||
| const purchaseUnit = p.purchaseUnit; | |||
| const supplierLabel = [p.supplierCode, p.supplierName].filter(Boolean).join(" "); | |||
| const subtitle = | |||
| [supplierLabel, p.itemCode, p.itemName].filter(Boolean).join(" · ") || m.materialItemCode; | |||
| const meta = [supplierLabel].filter(Boolean).join(" · "); | |||
| nodes.push({ | |||
| id: `mat-purchase-${p.purchaseOrderLineId}-${m.materialLotNo}`, | |||
| kind: "PURCHASE", | |||
| timestamp: p.orderDate, | |||
| sortKey: parseSortKey(p.orderDate, nextSeq(ctx)), | |||
| title: labels.nodePurchase, | |||
| subtitle: appendPickToSubtitle(subtitle, picks, labels.pickOrder), | |||
| qty: p.orderQty, | |||
| uom: purchaseUnit, | |||
| meta: meta || undefined, | |||
| refType: "PO", | |||
| refCode: p.purchaseOrderCode, | |||
| refId: p.purchaseOrderId, | |||
| docLinkKind: "po", | |||
| traceLotNo: m.materialLotNo, | |||
| traceItemCode: m.materialItemCode, | |||
| categoryLabel: labels.categoryPurchase, | |||
| details: [ | |||
| field(labels.detailItemCode, p.itemCode), | |||
| field(labels.detailItemName, p.itemName), | |||
| field(labels.detailSupplier, supplierLabel), | |||
| field(labels.detailOrderQty, formatQty(p.orderQty, purchaseUnit)), | |||
| field(labels.detailPurchaseUnit, p.purchaseUnit), | |||
| field(labels.detailPurchaseOrderNo, p.purchaseOrderCode, { | |||
| linkKind: "po", | |||
| linkCode: p.purchaseOrderCode, | |||
| linkId: p.purchaseOrderId, | |||
| }), | |||
| field(labels.detailTime, p.orderDate), | |||
| field(labels.detailLot, m.materialLotNo), | |||
| ...pickOrderDetailFields(picks, labels.pickOrder), | |||
| ], | |||
| }); | |||
| }); | |||
| if (origin && lotId != null) { | |||
| const originType = origin.type.trim().toUpperCase(); | |||
| const linkKind = docLinkFromOriginType(origin.type); | |||
| const subtitle = [ | |||
| m.materialLotNo, | |||
| [origin.supplierCode, origin.supplierName].filter(Boolean).join(" "), | |||
| ] | |||
| .filter(Boolean) | |||
| .join(" · "); | |||
| if (originType === "PO" && !ctx.seenReceiptLots.has(lotId)) { | |||
| ctx.seenReceiptLots.add(lotId); | |||
| nodes.push({ | |||
| id: `mat-receipt-${lotId}-${origin.stockInLineId}`, | |||
| kind: "RECEIPT", | |||
| timestamp: origin.receiptDate, | |||
| sortKey: parseSortKey(origin.receiptDate, nextSeq(ctx)), | |||
| title: labels.nodeReceipt, | |||
| subtitle: appendPickToSubtitle( | |||
| [origin.refCode, subtitle].filter(Boolean).join(" · ") || m.materialItemCode, | |||
| picks, | |||
| labels.pickOrder, | |||
| ), | |||
| qty: origin.acceptedQty, | |||
| uom: matUom, | |||
| refType: origin.type, | |||
| refCode: origin.refCode, | |||
| refId: origin.refId, | |||
| docLinkKind: linkKind, | |||
| traceLotNo: m.materialLotNo, | |||
| traceItemCode: m.materialItemCode, | |||
| categoryLabel: labels.categoryReceipt, | |||
| details: [ | |||
| field(labels.detailPurchaseOrderNo, origin.refCode, { | |||
| linkKind, | |||
| linkCode: origin.refCode, | |||
| linkId: origin.refId, | |||
| }), | |||
| field(labels.detailSupplier, [origin.supplierCode, origin.supplierName].filter(Boolean).join(" ")), | |||
| field(labels.detailItemCode, m.materialItemCode), | |||
| field(labels.detailLot, m.materialLotNo), | |||
| field(labels.detailQty, formatQty(origin.acceptedQty, matUom)), | |||
| field(labels.detailStatus, labels.tr.stockInStatus(origin.status)), | |||
| field(labels.detailTime, origin.receiptDate), | |||
| ...pickOrderDetailFields(picks, labels.pickOrder), | |||
| ], | |||
| }); | |||
| } else if ( | |||
| originType !== "PO" && | |||
| originType !== "ADJ" && | |||
| !ctx.seenInboundLots.has(lotId) | |||
| ) { | |||
| // Nested JO_CREATED already represents this JO output lot (with 提料 links). | |||
| const skipJoOriginBecauseNested = | |||
| originType === "JO" && Boolean(m.nestedJoPrelude?.jobOrder?.jobOrderCode?.trim()); | |||
| if (!skipJoOriginBecauseNested) { | |||
| // ADJ is emitted after putaways as ADJUSTMENT (FG-aligned: 上架 → 庫存調整). | |||
| ctx.seenInboundLots.add(lotId); | |||
| const isJoOrigin = originType === "JO"; | |||
| nodes.push({ | |||
| id: `mat-in-${lotId}-${origin.stockInLineId}`, | |||
| kind: isJoOrigin ? "JO_CREATED" : "MATERIAL_IN", | |||
| timestamp: origin.receiptDate, | |||
| sortKey: parseSortKey(origin.receiptDate, nextSeq(ctx)), | |||
| title: isJoOrigin | |||
| ? labels.nodeJoCreated | |||
| : `${labels.tr.refType(origin.type)} · ${labels.nodeMaterialIn}`, | |||
| subtitle: appendPickToSubtitle( | |||
| subtitle || m.materialItemCode, | |||
| picks, | |||
| labels.pickOrder, | |||
| ), | |||
| qty: origin.acceptedQty, | |||
| uom: matUom, | |||
| refType: origin.type, | |||
| refCode: origin.refCode, | |||
| refId: origin.refId, | |||
| docLinkKind: linkKind, | |||
| traceLotNo: m.materialLotNo, | |||
| traceItemCode: m.materialItemCode, | |||
| categoryLabel: isJoOrigin ? labels.categoryProduction : labels.categoryPurchase, | |||
| details: [ | |||
| field(labels.detailType, isJoOrigin ? labels.nodeJoCreated : labels.tr.refType(origin.type)), | |||
| field( | |||
| isJoOrigin | |||
| ? labels.jobOrder | |||
| : originType === "PO" | |||
| ? labels.detailPurchaseOrderNo | |||
| : labels.detailSourceDoc, | |||
| origin.refCode, | |||
| { | |||
| linkKind, | |||
| linkCode: origin.refCode, | |||
| linkId: origin.refId, | |||
| }), | |||
| field(labels.detailSupplier, [origin.supplierCode, origin.supplierName].filter(Boolean).join(" ")), | |||
| field(labels.detailItemCode, m.materialItemCode), | |||
| field(labels.detailLot, m.materialLotNo), | |||
| field(labels.detailQty, formatQty(origin.acceptedQty, matUom)), | |||
| field(labels.detailStatus, labels.tr.stockInStatus(origin.status)), | |||
| field(labels.detailTime, origin.receiptDate), | |||
| ...pickOrderDetailFields(picks, labels.pickOrder), | |||
| ], | |||
| }); | |||
| } | |||
| } | |||
| } | |||
| groupQcResultsBySession(m.qcResults).forEach((group) => { | |||
| const first = group[0]; | |||
| if (!first) return; | |||
| const key = `${first.stockInLineId}-${first.created}`; | |||
| if (ctx.seenQcKeys.has(key)) return; | |||
| ctx.seenQcKeys.add(key); | |||
| const allPassed = group.every((q) => q.qcPassed); | |||
| const totalFail = group.reduce((s, q) => s + (q.failQty ?? 0), 0); | |||
| const meta = [first.handledBy, ...group.map((q) => q.remarks).filter(Boolean), m.materialLotNo] | |||
| .filter(Boolean) | |||
| .join(" · "); | |||
| const qcTypeRaw = group.map((q) => q.qcType).find((t) => t?.trim()) ?? ""; | |||
| nodes.push({ | |||
| id: `mat-qc-${key}`, | |||
| kind: "MATERIAL_QC", | |||
| timestamp: first.created, | |||
| sortKey: parseSortKey(first.created, nextSeq(ctx)), | |||
| title: allPassed ? labels.nodeQcPass : labels.nodeQcFail, | |||
| subtitle: "", | |||
| qty: totalFail, | |||
| uom: matUom, | |||
| meta: meta || undefined, | |||
| traceLotNo: m.materialLotNo, | |||
| traceItemCode: m.materialItemCode, | |||
| categoryLabel: labels.categoryQc, | |||
| qcTypeLabel: labels.tr.qcType(qcTypeRaw), | |||
| details: detailsOf( | |||
| field(labels.detailItemCode, m.materialItemCode), | |||
| field(labels.detailLot, m.materialLotNo), | |||
| field(labels.detailStatus, labels.tr.qcPassed(allPassed)), | |||
| field(labels.detailQcType, labels.tr.qcType(qcTypeRaw)), | |||
| field(labels.detailQcCriteria, "", { | |||
| variant: "qcCriteriaList", | |||
| qcCriteriaItems: group.map((q) => ({ | |||
| name: q.qcItemName?.trim() || q.qcItemCode?.trim() || labels.detailQcUnknownItem, | |||
| description: q.qcItemDescription?.trim() || undefined, | |||
| passed: q.qcPassed, | |||
| failQty: q.failQty > 0 ? q.failQty : undefined, | |||
| })), | |||
| }), | |||
| field(labels.detailAcceptedQty, formatQty(first.acceptedQty, matUom)), | |||
| field(labels.detailFailQty, formatQty(totalFail, matUom)), | |||
| field(labels.detailHandler, first.handledBy), | |||
| field(labels.detailTime, first.created), | |||
| fieldIf(labels.detailRemarks, group.map((q) => q.remarks).filter(Boolean).join(";")), | |||
| ...pickOrderDetailFields(picks, labels.pickOrder), | |||
| ), | |||
| }); | |||
| }); | |||
| let lastMaterialPutawayWh = ""; | |||
| (m.putawayEvents ?? []).forEach((p) => { | |||
| const key = `${p.inventoryLotLineId}-${p.timestamp}`; | |||
| if (ctx.seenPutawayKeys.has(key)) return; | |||
| if (isTransferInboundPutaway(p.refType)) return; | |||
| ctx.seenPutawayKeys.add(key); | |||
| const linkKind = docLinkFromRefType(p.refType); | |||
| const pres = resolvePutawayPresentation( | |||
| labels.tr, | |||
| { | |||
| nodePutaway: labels.nodePutaway, | |||
| nodePutawayTransfer: labels.nodePutawayTransfer, | |||
| putawayTransferDetail: labels.putawayTransferDetail, | |||
| }, | |||
| p.status, | |||
| p.refType, | |||
| ); | |||
| const meta = [pres.chipLabel, p.handledBy, p.warehouseCode].filter(Boolean).join(" · "); | |||
| if (p.warehouseCode?.trim()) lastMaterialPutawayWh = p.warehouseCode.trim(); | |||
| nodes.push({ | |||
| id: `mat-putaway-${p.inventoryLotLineId}-${p.timestamp}`, | |||
| kind: "PUTAWAY", | |||
| timestamp: p.timestamp, | |||
| sortKey: parseSortKey(p.timestamp, nextSeq(ctx)), | |||
| title: pres.title, | |||
| subtitle: appendPickToSubtitle( | |||
| [pres.chipLabel, p.refCode, p.warehouseCode, m.materialLotNo].filter(Boolean).join(" · ") || | |||
| m.materialItemCode, | |||
| picks, | |||
| labels.pickOrder, | |||
| ), | |||
| qty: p.qty, | |||
| uom: matUom, | |||
| meta: meta || undefined, | |||
| refType: p.refType, | |||
| refCode: p.refCode, | |||
| refId: p.refId, | |||
| docLinkKind: linkKind, | |||
| putawayStatusLabel: pres.chipLabel, | |||
| putawayStatusDetail: pres.statusDetail, | |||
| warehouseCode: p.warehouseCode, | |||
| traceLotNo: m.materialLotNo, | |||
| traceItemCode: m.materialItemCode, | |||
| categoryLabel: labels.categoryPutaway, | |||
| details: [ | |||
| field(labels.detailStatus, pres.statusDetail), | |||
| field(labels.detailItemCode, m.materialItemCode), | |||
| field(labels.detailLot, m.materialLotNo), | |||
| field( | |||
| isTransferInboundPutaway(p.refType) | |||
| ? labels.detailInboundSiNo | |||
| : (p.refType ?? "").trim().toUpperCase() === "PO" | |||
| ? labels.detailPurchaseOrderNo | |||
| : (p.refType ?? "").trim().toUpperCase() === "JO" | |||
| ? labels.jobOrder | |||
| : labels.detailSourceDoc, | |||
| p.refCode, | |||
| { | |||
| linkKind, | |||
| linkCode: p.refCode, | |||
| linkId: p.refId, | |||
| }), | |||
| field(labels.detailPutawayBin, p.warehouseCode), | |||
| field(labels.detailQty, formatQty(p.qty, matUom)), | |||
| field(labels.detailHandler, p.handledBy), | |||
| field(labels.detailTime, p.timestamp), | |||
| ...pickOrderDetailFields(picks, labels.pickOrder), | |||
| ], | |||
| }); | |||
| }); | |||
| // ADJ stock-in: match FG — 上架 → 庫存調整 (入), not orphaned 「調整 · 原料入庫」. | |||
| if (origin && lotId != null) { | |||
| const originType = origin.type.trim().toUpperCase(); | |||
| if (originType === "ADJ" && !ctx.seenInboundLots.has(lotId)) { | |||
| ctx.seenInboundLots.add(lotId); | |||
| const adjWh = lastMaterialPutawayWh; | |||
| const meta = [labels.tr.stockInStatus(origin.status)].filter(Boolean).join(" · "); | |||
| // Place at/after related putaway so chronological X and 上架 → 調整 edges match FG. | |||
| const lastPutawayTs = (m.putawayEvents ?? []) | |||
| .map((p) => p.timestamp) | |||
| .filter((t): t is string => Boolean(t?.trim())) | |||
| .sort() | |||
| .at(-1); | |||
| const adjTimestamp = | |||
| lastPutawayTs && | |||
| parseSortKey(lastPutawayTs, 0) >= parseSortKey(origin.receiptDate, 0) | |||
| ? lastPutawayTs | |||
| : origin.receiptDate; | |||
| // Keep sortKey strictly after matching putaway so layout X stays 上架 → 調整 | |||
| // (same calendar time previously ordered adj left of putaway → back-arrow). | |||
| const putawaySort = lastPutawayTs ? parseSortKey(lastPutawayTs, 0) : null; | |||
| const adjSortBase = parseSortKey(adjTimestamp, nextSeq(ctx)); | |||
| nodes.push({ | |||
| id: `mat-adj-${lotId}-${origin.stockInLineId}`, | |||
| kind: "ADJUSTMENT", | |||
| timestamp: origin.receiptDate, | |||
| sortKey: | |||
| putawaySort != null && adjSortBase <= putawaySort ? putawaySort + 1 : adjSortBase, | |||
| title: `${labels.nodeAdjustment} (${labels.tr.direction("IN")})`, | |||
| subtitle: appendPickToSubtitle( | |||
| [labels.tr.adjustmentType("ADJ"), origin.refCode || m.materialLotNo] | |||
| .filter(Boolean) | |||
| .join(" · ") || m.materialItemCode, | |||
| picks, | |||
| labels.pickOrder, | |||
| ), | |||
| qty: origin.acceptedQty, | |||
| uom: matUom, | |||
| adjustmentDirection: "IN", | |||
| meta: meta || undefined, | |||
| refType: origin.type, | |||
| refCode: origin.refCode, | |||
| refId: origin.refId, | |||
| warehouseCode: adjWh || undefined, | |||
| traceLotNo: m.materialLotNo, | |||
| traceItemCode: m.materialItemCode, | |||
| categoryLabel: labels.categoryAdjustment, | |||
| details: detailsOf( | |||
| field(labels.detailVariance, formatSignedQty(origin.acceptedQty, "IN", matUom)), | |||
| field(labels.detailAdjustmentRef, origin.refCode), | |||
| field(labels.detailItemCode, m.materialItemCode), | |||
| field(labels.detailLot, m.materialLotNo), | |||
| ...(adjWh ? [field(labels.detailWarehouse, adjWh)] : []), | |||
| field(labels.detailStatus, labels.tr.stockInStatus(origin.status)), | |||
| field(labels.detailTime, origin.receiptDate), | |||
| ...pickOrderDetailFields(picks, labels.pickOrder), | |||
| ), | |||
| }); | |||
| } | |||
| } | |||
| return nodes; | |||
| }; | |||
| const buildNestedJoCreatedNode = ( | |||
| m: ItemLotTraceMaterialInput, | |||
| labels: JoPreludeGraphLabels, | |||
| picks: LotPickRef[], | |||
| ctx: PreludeBuildContext, | |||
| ): TraceGraphNode | null => { | |||
| const nested = m.nestedJoPrelude; | |||
| const jo = nested?.jobOrder; | |||
| if (!jo?.jobOrderCode?.trim()) return null; | |||
| const nestedInputs = nested?.materialInputs ?? []; | |||
| const nestedPickTs = nestedInputs | |||
| .map((input) => input.pickedAt) | |||
| .find((ts) => ts?.trim()); | |||
| const nestedProdTs = (m.productionSteps ?? []) | |||
| .map((step) => step.startTime) | |||
| .find((ts) => ts?.trim()); | |||
| const origin = m.stockInOrigin; | |||
| const ts = | |||
| jo.createdAt?.trim() || | |||
| jo.planStart?.trim() || | |||
| nestedPickTs?.trim() || | |||
| nestedProdTs?.trim() || | |||
| origin?.receiptDate?.trim() || | |||
| null; | |||
| const matUom = m.materialUom?.trim() || ""; | |||
| const qty = origin?.acceptedQty ?? jo.reqQty; | |||
| return { | |||
| id: `mat-jo-created-${jo.jobOrderId}-${m.materialLotNo}`, | |||
| kind: "JO_CREATED", | |||
| timestamp: ts, | |||
| sortKey: parseSortKey(ts, nextSeq(ctx)), | |||
| title: labels.nodeJoCreated, | |||
| subtitle: appendPickToSubtitle( | |||
| [m.materialLotNo, m.materialItemCode].filter(Boolean).join(" · ") || jo.jobOrderCode, | |||
| picks, | |||
| labels.pickOrder, | |||
| ), | |||
| qty, | |||
| uom: matUom, | |||
| refType: "JO", | |||
| refCode: jo.jobOrderCode, | |||
| refId: jo.jobOrderId, | |||
| docLinkKind: "jo", | |||
| traceLotNo: m.materialLotNo, | |||
| traceItemCode: m.materialItemCode, | |||
| categoryLabel: labels.categoryProduction, | |||
| details: [ | |||
| field(labels.detailType, labels.nodeJoCreated), | |||
| field(labels.jobOrder, jo.jobOrderCode, { | |||
| linkKind: "jo", | |||
| linkCode: jo.jobOrderCode, | |||
| linkId: jo.jobOrderId, | |||
| }), | |||
| field(labels.detailItemCode, m.materialItemCode), | |||
| field(labels.detailLot, m.materialLotNo), | |||
| field(labels.detailQty, formatQty(qty, matUom)), | |||
| field(labels.detailTime, ts), | |||
| ...pickOrderDetailFields(picks, labels.pickOrder), | |||
| ], | |||
| }; | |||
| }; | |||
| const buildMaterialInputChainNodes = ( | |||
| materialInputs: ItemLotTraceMaterialInput[], | |||
| labels: JoPreludeGraphLabels & Partial<ProductionGraphLabels>, | |||
| lotPicks: Map<string, LotPickRef[]>, | |||
| ctx: PreludeBuildContext, | |||
| ): TraceGraphNode[] => { | |||
| const nodes: TraceGraphNode[] = []; | |||
| materialInputs.forEach((m) => { | |||
| const joProduced = isJoProducedMaterial(m); | |||
| const nestedInputs = m.nestedJoPrelude?.materialInputs ?? []; | |||
| const picks = pickOrdersForLot(lotPicks, m.materialItemCode, m.materialLotNo); | |||
| if (joProduced && m.nestedJoPrelude) { | |||
| const nestedJoCreated = buildNestedJoCreatedNode(m, labels, picks, ctx); | |||
| if (nestedJoCreated) nodes.push(nestedJoCreated); | |||
| } | |||
| if (joProduced && nestedInputs.length > 0) { | |||
| const nestedLotPicks = buildLotPickOrderMap(nestedInputs); | |||
| nodes.push( | |||
| ...buildMaterialInputChainNodes( | |||
| nestedInputs, | |||
| labels, | |||
| nestedLotPicks, | |||
| ctx, | |||
| ), | |||
| ); | |||
| nestedInputs.forEach((nm, ni) => { | |||
| nodes.push(createMaterialPickNode(nm, ni, labels, pickCtx(ctx), m.materialLotNo)); | |||
| }); | |||
| } | |||
| if (joProduced && (m.productionSteps?.length ?? 0) > 0) { | |||
| nodes.push(...buildMaterialProductionNodes(m, labels, ctx)); | |||
| } | |||
| nodes.push(...buildMaterialStockInPreludeNodes(m, labels, lotPicks, ctx)); | |||
| }); | |||
| return nodes; | |||
| }; | |||
| export const buildJoPreludeGraphNodes = ( | |||
| joPrelude: ItemLotTraceJoPrelude, | |||
| labels: JoPreludeGraphLabels & Partial<ProductionGraphLabels>, | |||
| ): TraceGraphNode[] => { | |||
| const ctx: PreludeBuildContext = { | |||
| seenPurchaseLines: new Set(), | |||
| seenInboundLots: new Set(), | |||
| seenReceiptLots: new Set(), | |||
| seenQcKeys: new Set(), | |||
| seenPutawayKeys: new Set(), | |||
| pickOrderTargetDateMap: buildPickOrderTargetDateMapFromPrelude(joPrelude), | |||
| seq: 0, | |||
| }; | |||
| const lotPicks = buildLotPickOrderMap(joPrelude.materialInputs); | |||
| const nodes: TraceGraphNode[] = [ | |||
| ...buildMaterialInputChainNodes(joPrelude.materialInputs, labels, lotPicks, ctx), | |||
| ...joPrelude.materialInputs.map((m, i) => createMaterialPickNode(m, i, labels, pickCtx(ctx))), | |||
| ]; | |||
| return nodes.sort((a, b) => { | |||
| if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey; | |||
| return a.id.localeCompare(b.id); | |||
| }); | |||
| }; | |||
| @@ -0,0 +1,172 @@ | |||
| import { ItemLotTraceLocationBlock, ItemLotTraceResponse } from "@/app/api/itemTracing"; | |||
| import { | |||
| buildTraceGraphNodes, | |||
| TraceGraphBuildScope, | |||
| TraceGraphDetailLabels, | |||
| TraceGraphNode, | |||
| } from "./buildTraceGraphNodes"; | |||
| import { | |||
| buildExtendedTraceGraphNodes, | |||
| ExtendedTraceGraphLabels, | |||
| } from "./buildExtendedTraceGraphNodes"; | |||
| export const blockWarehouseCode = (block: ItemLotTraceLocationBlock): string => | |||
| block.warehouseLines | |||
| .map((w) => w.warehouseCode) | |||
| .filter(Boolean) | |||
| .join(" / ") || `Lot #${block.inventoryLotId}`; | |||
| const blockToTraceResponse = (block: ItemLotTraceLocationBlock): ItemLotTraceResponse => { | |||
| const uom = block.uom ?? ""; | |||
| return { | |||
| lot: { | |||
| inventoryLotId: block.inventoryLotId, | |||
| lotNo: block.lotNo, | |||
| itemId: 0, | |||
| itemCode: block.itemCode, | |||
| itemName: block.itemName ?? "", | |||
| expiryDate: block.expiryDate ?? null, | |||
| productionDate: block.productionDate ?? null, | |||
| stockInDate: block.stockInDate ?? null, | |||
| uom, | |||
| primaryStockInLineId: block.stockInLineId, | |||
| }, | |||
| warehouseLines: block.warehouseLines, | |||
| origins: block.origins, | |||
| purchaseEvents: block.purchaseEvents ?? [], | |||
| qcResults: block.qcResults ?? [], | |||
| putawayEvents: block.putawayEvents ?? [], | |||
| movements: block.movements ?? [], | |||
| outboundUsage: block.outboundUsage ?? [], | |||
| stockTakeEvents: block.stockTakeEvents ?? [], | |||
| adjustments: block.adjustments ?? [], | |||
| transfers: block.transfers ?? [], | |||
| bomTrace: { direction: "UNKNOWN", upstream: [], downstream: [], bomRecipe: [] }, | |||
| joPrelude: null, | |||
| productionSteps: [], | |||
| byproductLots: [], | |||
| openMovements: block.openMovements ?? [], | |||
| failEvents: block.failEvents ?? [], | |||
| doDeliveries: block.doDeliveries ?? [], | |||
| replenishmentEvents: block.replenishmentEvents ?? [], | |||
| returnEvents: block.returnEvents ?? [], | |||
| lotRelations: [], | |||
| alternateLocations: [], | |||
| locationBlocks: [], | |||
| traceGraph: null, | |||
| }; | |||
| }; | |||
| export const buildLocationBlockGraphNodes = ( | |||
| block: ItemLotTraceLocationBlock, | |||
| labels: TraceGraphDetailLabels & ExtendedTraceGraphLabels, | |||
| ): TraceGraphNode[] => { | |||
| const wh = blockWarehouseCode(block); | |||
| const scope: TraceGraphBuildScope = { | |||
| idPrefix: `loc-${block.inventoryLotId}-`, | |||
| locationBlockKeys: true, | |||
| inventoryLotId: block.inventoryLotId, | |||
| defaultWarehouseCode: wh, | |||
| mergedMultiLocation: true, | |||
| }; | |||
| const synthetic = blockToTraceResponse(block); | |||
| const fgNodes = buildTraceGraphNodes(synthetic, labels, scope); | |||
| const extNodes = | |||
| labels.nodeOpen && labels.nodeFail && labels.nodeDoOut | |||
| ? buildExtendedTraceGraphNodes(synthetic, labels, scope) | |||
| : []; | |||
| return [...fgNodes, ...extNodes].sort((a, b) => { | |||
| if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey; | |||
| return a.id.localeCompare(b.id); | |||
| }); | |||
| }; | |||
| export const buildAllLocationBlockGraphNodes = ( | |||
| blocks: ItemLotTraceLocationBlock[], | |||
| labels: TraceGraphDetailLabels & ExtendedTraceGraphLabels, | |||
| ): TraceGraphNode[] => | |||
| (blocks ?? []).flatMap((block) => buildLocationBlockGraphNodes(block, labels)); | |||
| @@ -0,0 +1,169 @@ | |||
| import { | |||
| ItemLotTraceByproductLot, | |||
| ItemLotTraceProductionStep, | |||
| } from "@/app/api/itemTracing"; | |||
| import { | |||
| TraceGraphDetailLabels, | |||
| TraceGraphNode, | |||
| } from "./buildTraceGraphNodes"; | |||
| import { formatQty } from "./traceQtyUtils"; | |||
| import { field, fieldIf, detailsOf, parseSortKey } from "./traceNodeFactory"; | |||
| export interface ProductionGraphLabels extends TraceGraphDetailLabels { | |||
| nodeProductionStep: string; | |||
| nodeByproduct: string; | |||
| nodeScrap: string; | |||
| nodeDefect: string; | |||
| detailProcessOutputQty: string; | |||
| detailProcessScrapQty: string; | |||
| detailProcessDefectQty: string; | |||
| } | |||
| const formatStepMaterials = ( | |||
| materials: ItemLotTraceProductionStep["stepMaterials"], | |||
| ): string => { | |||
| if (!materials?.length) return "—"; | |||
| return materials | |||
| .map((mat, i) => { | |||
| const label = [mat.materialItemCode, mat.materialItemName].filter(Boolean).join(" · "); | |||
| const qty = formatQty(mat.qtyPerUnit, mat.uom); | |||
| return `${i + 1}. ${label} (${qty})`; | |||
| }) | |||
| .join("\n"); | |||
| }; | |||
| export const buildProductionGraphNodes = ( | |||
| productionSteps: ItemLotTraceProductionStep[], | |||
| byproductLots: ItemLotTraceByproductLot[], | |||
| labels: ProductionGraphLabels, | |||
| lotUom: string, | |||
| ): TraceGraphNode[] => { | |||
| const nodes: TraceGraphNode[] = []; | |||
| let seq = 0; | |||
| productionSteps.forEach((step) => { | |||
| const ts = step.endTime ?? step.startTime; | |||
| const equip = [step.equipmentCode, step.equipmentName].filter(Boolean).join(" "); | |||
| const stepMaterialsLabel = formatStepMaterials(step.stepMaterials); | |||
| const materialCodesList = (step.stepMaterials ?? []) | |||
| .map((mat) => mat.materialItemCode?.trim()) | |||
| .filter((code): code is string => Boolean(code)); | |||
| const materialCodes = materialCodesList.join(" · "); | |||
| nodes.push({ | |||
| id: `prod-step-${step.processLineId}`, | |||
| kind: "PRODUCTION_STEP", | |||
| timestamp: ts, | |||
| sortKey: parseSortKey(ts, seq++), | |||
| title: step.stepName || labels.nodeProductionStep, | |||
| subtitle: [materialCodes, equip, labels.tr.productionStatus(step.status)] | |||
| .filter(Boolean) | |||
| .join(" · ") || "—", | |||
| qty: step.outputQty, | |||
| uom: lotUom, | |||
| meta: step.operatorName ? `${labels.detailHandler}: ${step.operatorName}` : undefined, | |||
| refType: "JO", | |||
| bomProcessId: step.bomProcessId, | |||
| bomProcessSeqNo: step.seqNo, | |||
| assignedStepName: step.stepName, | |||
| stepMaterialItemCodes: materialCodesList.length ? materialCodesList : undefined, | |||
| categoryLabel: labels.categoryProduction, | |||
| details: detailsOf( | |||
| field(labels.detailType, labels.nodeProductionStep), | |||
| field(labels.detailStatus, labels.tr.productionStatus(step.status)), | |||
| field(labels.detailProcessStep, step.stepName), | |||
| field(labels.detailStepMaterials, stepMaterialsLabel), | |||
| fieldIf(labels.detailProcessDescription, step.description), | |||
| field(labels.detailHandler, step.operatorName), | |||
| field(labels.detailTime, ts), | |||
| field(labels.detailProcessOutputQty, formatQty(step.outputQty, lotUom)), | |||
| field(labels.detailProcessScrapQty, formatQty(step.scrapQty, lotUom)), | |||
| field(labels.detailProcessDefectQty, formatQty(step.defectQty, lotUom)), | |||
| field(labels.detailEquipment, equip || "—"), | |||
| ), | |||
| }); | |||
| const lossQty = (step.scrapQty ?? 0) + (step.defectQty ?? 0); | |||
| if (lossQty > 0) { | |||
| if ((step.scrapQty ?? 0) > 0) { | |||
| nodes.push({ | |||
| id: `scrap-${step.processLineId}`, | |||
| kind: "SCRAP", | |||
| timestamp: ts, | |||
| sortKey: parseSortKey(ts, seq++) + 1, | |||
| title: labels.nodeScrap, | |||
| subtitle: step.stepName || "—", | |||
| qty: step.scrapQty, | |||
| uom: lotUom, | |||
| bomProcessId: step.bomProcessId, | |||
| bomProcessSeqNo: step.seqNo, | |||
| assignedStepName: step.stepName, | |||
| categoryLabel: labels.categoryProduction, | |||
| details: [ | |||
| field(labels.detailType, labels.nodeScrap), | |||
| field(labels.detailProcessStep, step.stepName), | |||
| field(labels.detailProcessScrapQty, formatQty(step.scrapQty, lotUom)), | |||
| field(labels.detailTime, ts), | |||
| ], | |||
| }); | |||
| } | |||
| if ((step.defectQty ?? 0) > 0) { | |||
| nodes.push({ | |||
| id: `defect-${step.processLineId}`, | |||
| kind: "DEFECT", | |||
| timestamp: ts, | |||
| sortKey: parseSortKey(ts, seq++) + 2, | |||
| title: labels.nodeDefect, | |||
| subtitle: step.stepName || "—", | |||
| qty: step.defectQty, | |||
| uom: lotUom, | |||
| bomProcessId: step.bomProcessId, | |||
| bomProcessSeqNo: step.seqNo, | |||
| assignedStepName: step.stepName, | |||
| categoryLabel: labels.categoryProduction, | |||
| details: [ | |||
| field(labels.detailType, labels.nodeDefect), | |||
| field(labels.detailProcessStep, step.stepName), | |||
| field(labels.detailProcessDefectQty, formatQty(step.defectQty, lotUom)), | |||
| field(labels.detailTime, ts), | |||
| ], | |||
| }); | |||
| } | |||
| } | |||
| }); | |||
| byproductLots.forEach((bp, i) => { | |||
| const subtitle = [bp.lotNo, bp.processStepName].filter(Boolean).join(" · ") || bp.itemName; | |||
| nodes.push({ | |||
| id: `byproduct-${bp.inventoryLotId ?? i}-${bp.lotNo}`, | |||
| kind: "BYPRODUCT", | |||
| timestamp: bp.producedAt, | |||
| sortKey: parseSortKey(bp.producedAt, seq++), | |||
| title: `${bp.itemCode} · ${labels.nodeByproduct}`, | |||
| subtitle, | |||
| qty: bp.qty, | |||
| uom: bp.uom, | |||
| refType: "JO", | |||
| refCode: bp.jobOrderCode, | |||
| refId: bp.jobOrderId, | |||
| docLinkKind: "jo", | |||
| traceLotNo: bp.lotNo, | |||
| traceItemCode: bp.itemCode, | |||
| categoryLabel: labels.categoryProduction, | |||
| details: [ | |||
| field(labels.detailType, labels.nodeByproduct), | |||
| field(labels.detailMaterial, `${bp.itemCode} · ${bp.itemName}`), | |||
| field(labels.detailLot, bp.lotNo), | |||
| field(labels.detailQty, formatQty(bp.qty, bp.uom)), | |||
| field(labels.detailTime, bp.producedAt), | |||
| field(labels.jobOrder, bp.jobOrderCode, { | |||
| linkKind: "jo", | |||
| linkCode: bp.jobOrderCode, | |||
| linkId: bp.jobOrderId, | |||
| }), | |||
| field(labels.detailProcessStep, bp.processStepName), | |||
| ], | |||
| }); | |||
| }); | |||
| return nodes; | |||
| }; | |||
| @@ -0,0 +1,323 @@ | |||
| import { MarkerType, type Edge, type Node } from "@xyflow/react"; | |||
| import { | |||
| COLUMN_WIDTH_MIN, | |||
| HEADER_HEIGHT, | |||
| LANE_GAP, | |||
| LANE_MIN_HEIGHT, | |||
| NODE_HEIGHT, | |||
| NODE_WIDTH, | |||
| NODE_WIDTH_BRANCH, | |||
| PHASE_LABEL_WIDTH, | |||
| } from "./traceFlowConstants"; | |||
| import { | |||
| CELL_PAD_X, | |||
| layoutCellMembers, | |||
| computeLaneTops, | |||
| } from "./traceFlowLayout"; | |||
| import { phaseAccent } from "./traceFlowNodeUtils"; | |||
| import { TraceGraphLayout, TraceGraphLayoutNode, buildTraceFlowEdgePairs } from "./traceGraphLayout"; | |||
| import type { TraceFlowEdgePair } from "./traceGraphSemantics"; | |||
| import { | |||
| assignTraceFlowEdgeRouting, | |||
| enrichTraceFlowCorridorRouting, | |||
| type TraceFlowNodeRect, | |||
| } from "./traceFlowEdgeLayout"; | |||
| import { | |||
| isDoGroupChild, | |||
| isFlowGroupContainer, | |||
| layoutDoGroupChildPlacements, | |||
| } from "./traceDoGroupLayout"; | |||
| export type TraceFlowNodeData = { | |||
| layoutNode: TraceGraphLayoutNode; | |||
| compact: boolean; | |||
| onTrace?: (params: { stockInLineId?: number; lotNo?: string; itemCode?: string }) => void; | |||
| phaseLabel?: string; | |||
| dateLabel?: string; | |||
| phaseColor?: string; | |||
| incomingHandleCount?: number; | |||
| outgoingHandleCount?: number; | |||
| searchActive?: boolean; | |||
| searchMatch?: boolean; | |||
| searchFocused?: boolean; | |||
| nodeSelected?: boolean; | |||
| /** DO / pick group header collapse. */ | |||
| groupCollapsed?: boolean; | |||
| onToggleGroupCollapse?: () => void; | |||
| }; | |||
| const cellKey = (laneIndex: number, column: number, stagger: number) => | |||
| `${laneIndex}:${column}:${stagger}`; | |||
| export const buildReactFlowGraph = ( | |||
| layout: TraceGraphLayout, | |||
| phaseLabels: Record<string, string>, | |||
| edgePairs?: TraceFlowEdgePair[], | |||
| ): { nodes: Node<TraceFlowNodeData>[]; edges: Edge[]; graphHeight: number } => { | |||
| const nodes: Node<TraceFlowNodeData>[] = []; | |||
| const topLevelNodes = layout.nodes.filter((n) => !isDoGroupChild(n)); | |||
| const cells = new Map<string, TraceGraphLayoutNode[]>(); | |||
| topLevelNodes.forEach((n) => { | |||
| const key = cellKey(n.laneIndex, n.column, n.dayPhaseStaggerIndex); | |||
| const list = cells.get(key) ?? []; | |||
| list.push(n); | |||
| cells.set(key, list); | |||
| }); | |||
| const cellLayouts = new Map<string, ReturnType<typeof layoutCellMembers>>(); | |||
| const { columnStarts, columnWidths, phaseSlotWidths, phaseSlotStarts } = layout.dayColumns; | |||
| cells.forEach((members, key) => { | |||
| const col = Number(key.split(":")[1]); | |||
| const stagger = members[0]?.dayPhaseStaggerIndex ?? 0; | |||
| const slotW = phaseSlotWidths[col]?.[stagger] ?? COLUMN_WIDTH_MIN; | |||
| cellLayouts.set(key, layoutCellMembers(members, slotW - CELL_PAD_X * 2)); | |||
| }); | |||
| const doChildrenByGroup = new Map<string, TraceGraphLayoutNode[]>(); | |||
| layout.nodes.forEach((n) => { | |||
| if (!n.doGroupId) return; | |||
| const list = doChildrenByGroup.get(n.doGroupId) ?? []; | |||
| list.push(n); | |||
| doChildrenByGroup.set(n.doGroupId, list); | |||
| }); | |||
| const childPlacementsByGroup = new Map< | |||
| string, | |||
| ReturnType<typeof layoutDoGroupChildPlacements> | |||
| >(); | |||
| doChildrenByGroup.forEach((children, groupId) => { | |||
| childPlacementsByGroup.set(groupId, layoutDoGroupChildPlacements(children)); | |||
| }); | |||
| const { laneTops, laneHeights, totalHeight } = computeLaneTops(layout.laneCount, cells); | |||
| layout.columnDates.forEach((date, col) => { | |||
| const dayW = columnWidths[col] ?? COLUMN_WIDTH_MIN; | |||
| const dayStart = columnStarts[col] ?? col * COLUMN_WIDTH_MIN; | |||
| nodes.push({ | |||
| id: `hdr-${col}`, | |||
| type: "dateHeader", | |||
| position: { | |||
| x: PHASE_LABEL_WIDTH + dayStart + dayW / 2 - 40, | |||
| y: 6, | |||
| }, | |||
| data: { layoutNode: {} as TraceGraphLayoutNode, compact: false, dateLabel: date }, | |||
| draggable: false, | |||
| selectable: false, | |||
| connectable: false, | |||
| focusable: false, | |||
| }); | |||
| }); | |||
| layout.phaseOrder.forEach((phase, laneIdx) => { | |||
| const laneH = laneHeights[laneIdx] ?? LANE_MIN_HEIGHT; | |||
| nodes.push({ | |||
| id: `phase-${phase}`, | |||
| type: "phaseLabel", | |||
| position: { | |||
| x: 8, | |||
| y: laneTops[laneIdx] + Math.max(8, (laneH - 80) / 2), | |||
| }, | |||
| data: { | |||
| layoutNode: {} as TraceGraphLayoutNode, | |||
| compact: false, | |||
| phaseLabel: phaseLabels[phase] ?? phase, | |||
| phaseColor: phaseAccent(phase), | |||
| }, | |||
| draggable: false, | |||
| selectable: false, | |||
| connectable: false, | |||
| focusable: false, | |||
| }); | |||
| }); | |||
| topLevelNodes.forEach((layoutNode) => { | |||
| const key = cellKey(layoutNode.laneIndex, layoutNode.column, layoutNode.dayPhaseStaggerIndex); | |||
| const placed = cellLayouts.get(key)?.get(layoutNode.id); | |||
| if (!placed) return; | |||
| const dayStart = columnStarts[layoutNode.column] ?? layoutNode.column * COLUMN_WIDTH_MIN; | |||
| const phaseSlotX = | |||
| phaseSlotStarts[layoutNode.column]?.[layoutNode.dayPhaseStaggerIndex] ?? 0; | |||
| const isGroup = isFlowGroupContainer(layoutNode); | |||
| const groupW = layoutNode.groupBoxWidth ?? NODE_WIDTH; | |||
| const groupH = layoutNode.groupBoxHeight ?? NODE_HEIGHT; | |||
| nodes.push({ | |||
| id: layoutNode.id, | |||
| type: layoutNode.kind === "PICK_GROUP" ? "pickGroup" : isGroup ? "doGroup" : "traceEvent", | |||
| position: { | |||
| x: PHASE_LABEL_WIDTH + dayStart + phaseSlotX + CELL_PAD_X + placed.x, | |||
| y: laneTops[layoutNode.laneIndex] + placed.y, | |||
| }, | |||
| data: { layoutNode, compact: placed.compact }, | |||
| draggable: false, | |||
| connectable: false, | |||
| selectable: !isGroup, | |||
| zIndex: isGroup ? 0 : 1, | |||
| style: isGroup ? { width: groupW, height: groupH } : undefined, | |||
| width: isGroup ? groupW : undefined, | |||
| height: isGroup ? groupH : undefined, | |||
| measured: { | |||
| width: isGroup ? groupW : placed.compact ? NODE_WIDTH_BRANCH : NODE_WIDTH, | |||
| height: isGroup ? groupH : NODE_HEIGHT, | |||
| }, | |||
| }); | |||
| }); | |||
| const parentPositions = new Map<string, { x: number; y: number }>(); | |||
| nodes.forEach((node) => { | |||
| if (node.type === "doGroup" || node.type === "pickGroup") { | |||
| parentPositions.set(node.id, node.position); | |||
| } | |||
| }); | |||
| doChildrenByGroup.forEach((children, groupId) => { | |||
| const placements = childPlacementsByGroup.get(groupId); | |||
| const parentPos = parentPositions.get(groupId); | |||
| if (!placements || !parentPos) return; | |||
| children.forEach((layoutNode) => { | |||
| const placed = placements.get(layoutNode.id); | |||
| if (!placed) return; | |||
| nodes.push({ | |||
| id: layoutNode.id, | |||
| type: "traceEvent", | |||
| position: { | |||
| x: parentPos.x + placed.x, | |||
| y: parentPos.y + placed.y, | |||
| }, | |||
| data: { layoutNode, compact: placed.compact }, | |||
| draggable: false, | |||
| connectable: false, | |||
| zIndex: 2, | |||
| width: NODE_WIDTH, | |||
| height: NODE_HEIGHT, | |||
| measured: { | |||
| width: NODE_WIDTH, | |||
| height: NODE_HEIGHT, | |||
| }, | |||
| }); | |||
| }); | |||
| }); | |||
| const flowPairs = edgePairs ?? buildTraceFlowEdgePairs(layout.nodes, layout.phaseOrder); | |||
| const { routed, incomingCount, outgoingCount } = assignTraceFlowEdgeRouting(flowPairs); | |||
| const nodeRects = new Map<string, TraceFlowNodeRect>(); | |||
| nodes.forEach((node) => { | |||
| if (node.type !== "traceEvent" && node.type !== "doGroup" && node.type !== "pickGroup") return; | |||
| const layoutNode = node.data.layoutNode; | |||
| if (layoutNode.doGroupId) return; | |||
| nodeRects.set(node.id, { | |||
| x: node.position.x, | |||
| y: node.position.y, | |||
| width: node.measured?.width ?? NODE_WIDTH, | |||
| height: node.measured?.height ?? NODE_HEIGHT, | |||
| laneIndex: layoutNode.laneIndex, | |||
| }); | |||
| }); | |||
| const corridorRouted = enrichTraceFlowCorridorRouting(routed, nodeRects, { | |||
| laneTops, | |||
| laneHeights, | |||
| laneGap: LANE_GAP, | |||
| }); | |||
| const nodesWithHandles = nodes.map((node) => { | |||
| if (node.type !== "traceEvent" && node.type !== "doGroup" && node.type !== "pickGroup") return node; | |||
| if (node.data.layoutNode.doGroupId) { | |||
| return { | |||
| ...node, | |||
| data: { | |||
| ...node.data, | |||
| incomingHandleCount: 0, | |||
| outgoingHandleCount: 0, | |||
| }, | |||
| }; | |||
| } | |||
| return { | |||
| ...node, | |||
| data: { | |||
| ...node.data, | |||
| incomingHandleCount: incomingCount.get(node.id) ?? 0, | |||
| outgoingHandleCount: outgoingCount.get(node.id) ?? 0, | |||
| }, | |||
| }; | |||
| }); | |||
| const nodeById = new Map(layout.nodes.map((n) => [n.id, n])); | |||
| const edges: Edge[] = corridorRouted.map( | |||
| ({ | |||
| fromId, | |||
| toId, | |||
| sourceHandle, | |||
| targetHandle, | |||
| offset, | |||
| pathMode, | |||
| corridorY, | |||
| corridorEntryOffset, | |||
| corridorExitOffset, | |||
| corridorBranchOffset, | |||
| }) => { | |||
| const targetNode = nodeById.get(toId); | |||
| const ref = targetNode?.refCode?.trim(); | |||
| const flowLabel = | |||
| targetNode?.kind === "DO_GROUP" && targetNode.groupMemberCount | |||
| ? String(targetNode.groupMemberCount) | |||
| : targetNode?.kind === "PICK_GROUP" && ref | |||
| ? ref | |||
| : targetNode?.kind === "MATERIAL_PICK" && ref | |||
| ? ref | |||
| : (targetNode?.kind === "OUT" || targetNode?.kind === "DO_OUT") && ref | |||
| ? ref | |||
| : undefined; | |||
| return { | |||
| id: `e-${fromId}-${toId}`, | |||
| source: fromId, | |||
| target: toId, | |||
| sourceHandle, | |||
| targetHandle, | |||
| type: "traceFlow", | |||
| data: { | |||
| offset, | |||
| pathMode, | |||
| corridorY, | |||
| corridorEntryOffset, | |||
| corridorExitOffset, | |||
| corridorBranchOffset, | |||
| }, | |||
| label: flowLabel, | |||
| labelStyle: { fontSize: 10, fontWeight: 600, fill: "#5d4037" }, | |||
| labelBgStyle: { fill: "#fff8e1", fillOpacity: 0.95 }, | |||
| labelBgPadding: [4, 6] as [number, number], | |||
| labelBgBorderRadius: 4, | |||
| style: { stroke: "#757575", strokeWidth: 1.5, opacity: 0.75 }, | |||
| markerEnd: { type: MarkerType.ArrowClosed, color: "#757575", width: 16, height: 16 }, | |||
| }; | |||
| }); | |||
| return { nodes: nodesWithHandles, edges, graphHeight: totalHeight }; | |||
| }; | |||
| export const reactFlowGraphExtent = ( | |||
| layout: TraceGraphLayout, | |||
| graphHeight?: number, | |||
| ): [[number, number], [number, number]] => { | |||
| const width = PHASE_LABEL_WIDTH + layout.dayColumns.timelineWidth + 32; | |||
| const height = graphHeight ?? HEADER_HEIGHT + layout.laneCount * LANE_MIN_HEIGHT + 32; | |||
| // Extra bottom room so expanded stock-take lifecycle panels stay pannable. | |||
| const padX = 48; | |||
| const padTop = 48; | |||
| const padBottom = 360; | |||
| return [ | |||
| [-padX, -padTop], | |||
| [width + padX, height + padBottom], | |||
| ]; | |||
| }; | |||
| export type ReactFlowGraphResult = ReturnType<typeof buildReactFlowGraph>; | |||
| @@ -0,0 +1,33 @@ | |||
| import { ItemLotTraceResponse } from "@/app/api/itemTracing"; | |||
| import { | |||
| buildTraceGraphLayout, | |||
| TraceGraphLayout, | |||
| TraceGraphLayoutNode, | |||
| } from "./traceGraphLayout"; | |||
| import { resolveTraceFlowEdgePairs } from "./traceGraphSemantics"; | |||
| import type { TraceGraphCompileLabels } from "./traceGraphLabels"; | |||
| export type TraceFlowEdgePair = { fromId: string; toId: string }; | |||
| export type CompiledTraceGraph = { | |||
| layout: TraceGraphLayout; | |||
| edgePairs: TraceFlowEdgePair[]; | |||
| nodes: TraceGraphLayoutNode[]; | |||
| }; | |||
| export const compileTraceGraph = ( | |||
| data: ItemLotTraceResponse, | |||
| labels: TraceGraphCompileLabels, | |||
| ): CompiledTraceGraph => { | |||
| const layout = buildTraceGraphLayout(data, labels); | |||
| const backendEdges = data.traceGraph?.edges?.map((e) => ({ | |||
| fromKey: e.fromKey, | |||
| toKey: e.toKey, | |||
| })); | |||
| const edgePairs = resolveTraceFlowEdgePairs(layout.nodes, layout.phaseOrder, backendEdges); | |||
| return { | |||
| layout, | |||
| edgePairs, | |||
| nodes: layout.nodes, | |||
| }; | |||
| }; | |||
| @@ -0,0 +1,905 @@ | |||
| import type { TFunction } from "i18next"; | |||
| import type { ItemLotTraceResponse } from "@/app/api/itemTracing"; | |||
| import { | |||
| exportMultiSheetToXlsx, | |||
| type MultiSheetSpec, | |||
| } from "@/app/(main)/chart/_components/exportChartToXlsx"; | |||
| import type { CompiledTraceGraph } from "./compileTraceGraph"; | |||
| import { | |||
| mergeScopedAdjustments, | |||
| mergeScopedDoDeliveries, | |||
| mergeScopedFailEvents, | |||
| mergeScopedOpenMovements, | |||
| mergeScopedOrigins, | |||
| mergeScopedOutboundUsage, | |||
| mergeScopedPurchaseEvents, | |||
| mergeScopedPutawayEvents, | |||
| mergeScopedQcResults, | |||
| mergeScopedReplenishmentEvents, | |||
| mergeScopedReturnEvents, | |||
| mergeScopedStockTakeEvents, | |||
| mergeScopedTransfers, | |||
| } from "./mergeLocationScopedData"; | |||
| import { | |||
| resolveStockTakeAcceptedQty, | |||
| resolveStockTakeBookQty, | |||
| } from "./traceStockTakeUtils"; | |||
| import { | |||
| createTraceLabelTranslator, | |||
| type TraceLabelTranslator, | |||
| } from "./traceLabelUtils"; | |||
| import { kindLabelKey, phaseLabelKey } from "./traceFlowNodeUtils"; | |||
| import { buildTracePresentationRows } from "./tracePresentationAdapter"; | |||
| import { isDoGroupChild } from "./traceDoGroupLayout"; | |||
| const NO_DATA = "(無資料 / No records)"; | |||
| const SHEET = { | |||
| summary: "摘要 Summary", | |||
| timeline: "生命週期時間軸 Timeline", | |||
| origins: "來源與採購 Origins", | |||
| qc: "品質檢驗 QC", | |||
| warehouse: "倉儲作業 Warehouse", | |||
| outbound: "出庫 Outbound", | |||
| stockTake: "盤點 Stock Take", | |||
| production: "生產與BOM Production", | |||
| } as const; | |||
| export type ItemLotTraceExportLabels = { | |||
| bomDirectionFg: string; | |||
| bomDirectionMaterial: string; | |||
| bomDirectionUnknown: string; | |||
| }; | |||
| const orDash = (v: string | number | null | undefined): string | number => { | |||
| if (v == null) return "—"; | |||
| if (typeof v === "string" && !v.trim()) return "—"; | |||
| return v; | |||
| }; | |||
| const bomDirectionLabel = ( | |||
| direction: string, | |||
| labels: ItemLotTraceExportLabels, | |||
| ): string => { | |||
| const d = direction.trim().toUpperCase(); | |||
| if (d === "FINISHED_GOOD") return labels.bomDirectionFg; | |||
| if (d === "MATERIAL") return labels.bomDirectionMaterial; | |||
| return labels.bomDirectionUnknown; | |||
| }; | |||
| const ensureRows = ( | |||
| rows: Record<string, unknown>[], | |||
| emptyTemplate: Record<string, unknown>, | |||
| ): Record<string, unknown>[] => { | |||
| if (rows.length > 0) return rows; | |||
| const firstKey = Object.keys(emptyTemplate)[0]; | |||
| return [{ ...emptyTemplate, ...(firstKey ? { [firstKey]: NO_DATA } : {}) }]; | |||
| }; | |||
| const todayYmd = (): string => { | |||
| const d = new Date(); | |||
| const y = d.getFullYear(); | |||
| const m = String(d.getMonth() + 1).padStart(2, "0"); | |||
| const day = String(d.getDate()).padStart(2, "0"); | |||
| return `${y}${m}${day}`; | |||
| }; | |||
| const safeFilenamePart = (s: string): string => | |||
| s.replace(/[\\/:*?"<>|]/g, "_").trim() || "unknown"; | |||
| export const buildItemLotTraceFilename = ( | |||
| data: ItemLotTraceResponse, | |||
| ): string => { | |||
| const item = safeFilenamePart(data.lot.itemCode || "item"); | |||
| const lot = safeFilenamePart(data.lot.lotNo || "lot"); | |||
| return `批號追溯_${item}_${lot}_${todayYmd()}`; | |||
| }; | |||
| const summaryEmpty = (): Record<string, unknown> => ({ | |||
| "Field / 欄位": "", | |||
| "Value / 值": "", | |||
| }); | |||
| const buildSummarySheet = ( | |||
| data: ItemLotTraceResponse, | |||
| labels: ItemLotTraceExportLabels, | |||
| exportAt: string, | |||
| ): Record<string, unknown>[] => { | |||
| const { lot, warehouseLines, joPrelude, alternateLocations, bomTrace } = data; | |||
| const totalAvailable = warehouseLines.reduce( | |||
| (s, w) => s + (w.availableQty ?? 0), | |||
| 0, | |||
| ); | |||
| const rows: Record<string, unknown>[] = [ | |||
| { "Field / 欄位": "Lot No. / 批號", "Value / 值": orDash(lot.lotNo) }, | |||
| { "Field / 欄位": "Item Code / 貨品編號", "Value / 值": orDash(lot.itemCode) }, | |||
| { "Field / 欄位": "Item Name / 品名", "Value / 值": orDash(lot.itemName) }, | |||
| { "Field / 欄位": "UOM / 單位", "Value / 值": orDash(lot.uom) }, | |||
| { "Field / 欄位": "Expiry / 效期", "Value / 值": orDash(lot.expiryDate) }, | |||
| { | |||
| "Field / 欄位": "Production Date / 生產日期", | |||
| "Value / 值": orDash(lot.productionDate), | |||
| }, | |||
| { | |||
| "Field / 欄位": "Stock In Date / 入庫日期", | |||
| "Value / 值": orDash(lot.stockInDate), | |||
| }, | |||
| { | |||
| "Field / 欄位": "Total Available / 總可用量", | |||
| "Value / 值": totalAvailable, | |||
| }, | |||
| { | |||
| "Field / 欄位": "BOM Direction / BOM 方向", | |||
| "Value / 值": bomDirectionLabel(bomTrace.direction, labels), | |||
| }, | |||
| ]; | |||
| if (joPrelude) { | |||
| const jo = joPrelude.jobOrder; | |||
| rows.push( | |||
| { | |||
| "Field / 欄位": "Job Order / 工單", | |||
| "Value / 值": orDash(jo.jobOrderCode), | |||
| }, | |||
| { | |||
| "Field / 欄位": "Plan Start / 計劃開工", | |||
| "Value / 值": orDash(jo.planStart), | |||
| }, | |||
| { "Field / 欄位": "Planned Qty / 計劃產量", "Value / 值": jo.reqQty }, | |||
| { | |||
| "Field / 欄位": "JO Status / 工單狀態", | |||
| "Value / 值": orDash(jo.status), | |||
| }, | |||
| ); | |||
| } | |||
| rows.push( | |||
| { "Field / 欄位": "Export Time / 匯出時間", "Value / 值": exportAt }, | |||
| { "Field / 欄位": "", "Value / 值": "" }, | |||
| { | |||
| "Field / 欄位": "— Warehouse Breakdown / 各倉庫存量 —", | |||
| "Value / 值": "", | |||
| }, | |||
| ); | |||
| warehouseLines.forEach((w) => { | |||
| rows.push({ | |||
| "Field / 欄位": `Warehouse / 倉位: ${w.warehouseCode}`, | |||
| "Value / 值": `In ${w.inQty} · Out ${w.outQty} · Available ${w.availableQty} · ${w.status}`, | |||
| }); | |||
| }); | |||
| if (alternateLocations.length > 0) { | |||
| rows.push( | |||
| { "Field / 欄位": "", "Value / 值": "" }, | |||
| { | |||
| "Field / 欄位": "— Alternate Locations / 其他倉位 —", | |||
| "Value / 值": "", | |||
| }, | |||
| ); | |||
| alternateLocations.forEach((loc) => { | |||
| rows.push({ | |||
| "Field / 欄位": `Inventory Lot ${loc.inventoryLotId}`, | |||
| "Value / 值": `${loc.warehouseCode} · Available ${loc.availableQty}`, | |||
| }); | |||
| }); | |||
| } | |||
| return rows.length > 0 ? rows : [summaryEmpty()]; | |||
| }; | |||
| const timelineEmpty = (): Record<string, unknown> => ({ | |||
| "Date/Time / 時間": "", | |||
| "Phase / 階段": "", | |||
| "Event Type / 事件類型": "", | |||
| "Doc No. / 單號": "", | |||
| "Ref Type / 來源類型": "", | |||
| "Ref Type Code": "", | |||
| "Qty / 數量": "", | |||
| "UOM / 單位": "", | |||
| "Warehouse / 倉位": "", | |||
| "Handler / 經手人": "", | |||
| "Lot / 批號": "", | |||
| "Remarks / 備註": "", | |||
| }); | |||
| const buildTimelineSheet = ( | |||
| compiledGraph: CompiledTraceGraph, | |||
| t: TFunction, | |||
| tr: TraceLabelTranslator, | |||
| ): Record<string, unknown>[] => { | |||
| const nodes = compiledGraph.layout.nodes | |||
| .filter( | |||
| (n) => | |||
| n.kind !== "DO_GROUP" && n.kind !== "PICK_GROUP" && !isDoGroupChild(n), | |||
| ) | |||
| .slice() | |||
| .sort((a, b) => { | |||
| const ta = a.timestamp ?? ""; | |||
| const tb = b.timestamp ?? ""; | |||
| if (ta !== tb) return ta.localeCompare(tb); | |||
| return a.sortKey - b.sortKey || a.sequenceIndex - b.sequenceIndex; | |||
| }); | |||
| const rows = nodes.map((n) => { | |||
| const handlerDetail = n.details.find( | |||
| (d) => | |||
| d.label.toLowerCase().includes("handler") || | |||
| d.label.includes("經手") || | |||
| d.label.includes("核准") || | |||
| d.label.includes("盤點"), | |||
| ); | |||
| return { | |||
| "Date/Time / 時間": orDash(n.timestamp), | |||
| "Phase / 階段": t(phaseLabelKey(n.phase)), | |||
| "Event Type / 事件類型": t(kindLabelKey(n.kind)), | |||
| "Doc No. / 單號": orDash(n.refCode), | |||
| "Ref Type / 來源類型": tr.refType(n.refType), | |||
| "Ref Type Code": orDash(n.refType), | |||
| "Qty / 數量": n.qty ?? "", | |||
| "UOM / 單位": orDash(n.uom), | |||
| "Warehouse / 倉位": orDash(n.warehouseCode), | |||
| "Handler / 經手人": orDash( | |||
| handlerDetail?.value ?? n.meta?.split(" · ")[0], | |||
| ), | |||
| "Lot / 批號": orDash(n.traceLotNo), | |||
| "Remarks / 備註": orDash(n.subtitle), | |||
| }; | |||
| }); | |||
| return ensureRows(rows, timelineEmpty()); | |||
| }; | |||
| const originsEmpty = (): Record<string, unknown> => ({ | |||
| "Section / 區塊": "", | |||
| "Warehouse / 倉位": "", | |||
| "Inventory Lot Id": "", | |||
| "Type / 類型": "", | |||
| "Type Code": "", | |||
| "Doc No. / 單號": "", | |||
| "Supplier Code / 供應商編號": "", | |||
| "Supplier / 供應商": "", | |||
| "DN No. / 送貨單號": "", | |||
| "Qty / 數量": "", | |||
| "Status / 狀態": "", | |||
| "Date / 日期": "", | |||
| }); | |||
| const buildOriginsSheet = ( | |||
| data: ItemLotTraceResponse, | |||
| tr: TraceLabelTranslator, | |||
| ): Record<string, unknown>[] => { | |||
| const originRows = mergeScopedOrigins(data).map((o) => ({ | |||
| "Section / 區塊": "Origin / 來源", | |||
| "Warehouse / 倉位": o.scopeWarehouseCode, | |||
| "Inventory Lot Id": o.inventoryLotId, | |||
| "Type / 類型": tr.refType(o.type), | |||
| "Type Code": orDash(o.type), | |||
| "Doc No. / 單號": orDash(o.refCode), | |||
| "Supplier Code / 供應商編號": orDash(o.supplierCode), | |||
| "Supplier / 供應商": orDash(o.supplierName), | |||
| "DN No. / 送貨單號": orDash(o.dnNo), | |||
| "Qty / 數量": o.acceptedQty, | |||
| "Status / 狀態": tr.stockInStatus(o.status), | |||
| "Date / 日期": orDash(o.receiptDate), | |||
| })); | |||
| const purchaseRows = mergeScopedPurchaseEvents(data).map((p) => ({ | |||
| "Section / 區塊": "Purchase / 採購", | |||
| "Warehouse / 倉位": p.scopeWarehouseCode, | |||
| "Inventory Lot Id": p.inventoryLotId, | |||
| "Type / 類型": tr.refType("PO"), | |||
| "Type Code": "PO", | |||
| "Doc No. / 單號": orDash(p.purchaseOrderCode), | |||
| "Supplier Code / 供應商編號": orDash(p.supplierCode), | |||
| "Supplier / 供應商": orDash(p.supplierName), | |||
| "DN No. / 送貨單號": "", | |||
| "Qty / 數量": p.orderQty, | |||
| "Status / 狀態": orDash(p.status), | |||
| "Date / 日期": orDash(p.orderDate), | |||
| })); | |||
| return ensureRows([...originRows, ...purchaseRows], originsEmpty()); | |||
| }; | |||
| const qcEmpty = (): Record<string, unknown> => ({ | |||
| "Warehouse / 倉位": "", | |||
| "Inventory Lot Id": "", | |||
| "QC Result / 結果": "", | |||
| "QC Type / 品檢類型": "", | |||
| "QC Type Code": "", | |||
| "QC Item Code / 品檢項編號": "", | |||
| "QC Item / 品檢項": "", | |||
| "Accepted Qty / 抽樣數量": "", | |||
| "Fail Qty / 不良數量": "", | |||
| "Remarks / 備註": "", | |||
| "Handler / 經手人": "", | |||
| "Date/Time / 時間": "", | |||
| "Stock In Line Id": "", | |||
| }); | |||
| const buildQcSheet = ( | |||
| data: ItemLotTraceResponse, | |||
| tr: TraceLabelTranslator, | |||
| ): Record<string, unknown>[] => { | |||
| const rows = mergeScopedQcResults(data).map((q) => ({ | |||
| "Warehouse / 倉位": q.scopeWarehouseCode, | |||
| "Inventory Lot Id": q.inventoryLotId, | |||
| "QC Result / 結果": tr.qcPassed(q.qcPassed), | |||
| "QC Type / 品檢類型": tr.qcType(q.qcType), | |||
| "QC Type Code": orDash(q.qcType), | |||
| "QC Item Code / 品檢項編號": orDash(q.qcItemCode), | |||
| "QC Item / 品檢項": orDash(q.qcItemName || q.qcItemDescription), | |||
| "Accepted Qty / 抽樣數量": q.acceptedQty, | |||
| "Fail Qty / 不良數量": q.failQty, | |||
| "Remarks / 備註": orDash(q.remarks), | |||
| "Handler / 經手人": orDash(q.handledBy), | |||
| "Date/Time / 時間": orDash(q.created), | |||
| "Stock In Line Id": q.stockInLineId, | |||
| })); | |||
| return ensureRows(rows, qcEmpty()); | |||
| }; | |||
| const warehouseEmpty = (): Record<string, unknown> => ({ | |||
| "Section / 區塊": "", | |||
| "Warehouse / 倉位": "", | |||
| "Inventory Lot Id": "", | |||
| "Direction / 方向": "", | |||
| "Type / 類型": "", | |||
| "Type Code": "", | |||
| "Doc No. / 單號": "", | |||
| "From / 來源倉": "", | |||
| "To / 目標倉": "", | |||
| "Qty / 數量": "", | |||
| "Handler / 經手人": "", | |||
| "Status / 狀態": "", | |||
| "Remarks / 備註": "", | |||
| "Date/Time / 時間": "", | |||
| }); | |||
| const buildWarehouseSheet = ( | |||
| data: ItemLotTraceResponse, | |||
| tr: TraceLabelTranslator, | |||
| ): Record<string, unknown>[] => { | |||
| const putaway = mergeScopedPutawayEvents(data).map((p) => ({ | |||
| "Section / 區塊": "Putaway / 上架", | |||
| "Warehouse / 倉位": p.scopeWarehouseCode, | |||
| "Inventory Lot Id": p.inventoryLotId, | |||
| "Direction / 方向": "", | |||
| "Type / 類型": tr.refType(p.refType), | |||
| "Type Code": orDash(p.refType), | |||
| "Doc No. / 單號": orDash(p.refCode), | |||
| "From / 來源倉": "", | |||
| "To / 目標倉": orDash(p.warehouseCode), | |||
| "Qty / 數量": p.qty, | |||
| "Handler / 經手人": orDash(p.handledBy), | |||
| "Status / 狀態": orDash(p.status), | |||
| "Remarks / 備註": "", | |||
| "Date/Time / 時間": orDash(p.timestamp), | |||
| })); | |||
| const transfers = mergeScopedTransfers(data).map((trRow) => ({ | |||
| "Section / 區塊": "Transfer / 轉倉", | |||
| "Warehouse / 倉位": trRow.scopeWarehouseCode, | |||
| "Inventory Lot Id": trRow.inventoryLotId, | |||
| "Direction / 方向": "", | |||
| "Type / 類型": tr.refType("TRANSFER"), | |||
| "Type Code": "TRANSFER", | |||
| "Doc No. / 單號": orDash(trRow.transferCode), | |||
| "From / 來源倉": orDash(trRow.fromWarehouse), | |||
| "To / 目標倉": orDash(trRow.toWarehouse), | |||
| "Qty / 數量": trRow.qty, | |||
| "Handler / 經手人": "", | |||
| "Status / 狀態": "", | |||
| "Remarks / 備註": "", | |||
| "Date/Time / 時間": orDash(trRow.timestamp), | |||
| })); | |||
| const adjustments = mergeScopedAdjustments(data).map((a) => ({ | |||
| "Section / 區塊": "Adjustment / 庫存調整", | |||
| "Warehouse / 倉位": a.scopeWarehouseCode, | |||
| "Inventory Lot Id": a.inventoryLotId, | |||
| "Direction / 方向": tr.direction(a.direction), | |||
| "Type / 類型": tr.adjustmentType(a.adjustmentType), | |||
| "Type Code": orDash(a.adjustmentType), | |||
| "Doc No. / 單號": orDash(a.refCode), | |||
| "From / 來源倉": "", | |||
| "To / 目標倉": orDash(a.warehouseCode), | |||
| "Qty / 數量": a.qty, | |||
| "Handler / 經手人": orDash(a.handledBy), | |||
| "Status / 狀態": "", | |||
| "Remarks / 備註": orDash(a.reason), | |||
| "Date/Time / 時間": orDash(a.timestamp), | |||
| })); | |||
| const openMoves = mergeScopedOpenMovements(data).map((o) => ({ | |||
| "Section / 區塊": "Opening Stock / 開倉入庫", | |||
| "Warehouse / 倉位": o.scopeWarehouseCode, | |||
| "Inventory Lot Id": o.inventoryLotId, | |||
| "Direction / 方向": tr.direction("IN"), | |||
| "Type / 類型": tr.refType("OPEN"), | |||
| "Type Code": "OPEN", | |||
| "Doc No. / 單號": orDash(o.refCode), | |||
| "From / 來源倉": "", | |||
| "To / 目標倉": orDash(o.warehouseCode), | |||
| "Qty / 數量": o.qty, | |||
| "Handler / 經手人": orDash(o.handledBy), | |||
| "Status / 狀態": "", | |||
| "Remarks / 備註": orDash(o.remarks), | |||
| "Date/Time / 時間": orDash(o.timestamp), | |||
| })); | |||
| return ensureRows( | |||
| [...putaway, ...transfers, ...adjustments, ...openMoves], | |||
| warehouseEmpty(), | |||
| ); | |||
| }; | |||
| const outboundEmpty = (): Record<string, unknown> => ({ | |||
| "Section / 區塊": "", | |||
| "Warehouse / 倉位": "", | |||
| "Inventory Lot Id": "", | |||
| "Usage Type / 類型": "", | |||
| "Usage Type Code": "", | |||
| "Doc No. / 單號": "", | |||
| "Pick Order / 提料單": "", | |||
| "Conso / 併單": "", | |||
| "Job Order / 工單": "", | |||
| "Delivery Order / 送貨單": "", | |||
| "Delivery Note / DN": "", | |||
| "Ticket / 票號": "", | |||
| "Shop / 門市": "", | |||
| "Qty / 數量": "", | |||
| "Handler / 經手人": "", | |||
| "Extra / 加單": "", | |||
| "Replenish / 補貨": "", | |||
| "Remarks / 備註": "", | |||
| "Date/Time / 時間": "", | |||
| }); | |||
| const buildOutboundSheet = ( | |||
| data: ItemLotTraceResponse, | |||
| tr: TraceLabelTranslator, | |||
| ): Record<string, unknown>[] => { | |||
| const usage = mergeScopedOutboundUsage(data).map((u) => ({ | |||
| "Section / 區塊": "Outbound Usage / 出庫使用", | |||
| "Warehouse / 倉位": u.scopeWarehouseCode, | |||
| "Inventory Lot Id": u.inventoryLotId, | |||
| "Usage Type / 類型": tr.usageType(u.usageType), | |||
| "Usage Type Code": orDash(u.usageType), | |||
| "Doc No. / 單號": orDash(u.deliveryOrderCode || u.pickOrderCode), | |||
| "Pick Order / 提料單": orDash(u.pickOrderCode), | |||
| "Conso / 併單": orDash(u.consoCode), | |||
| "Job Order / 工單": orDash(u.jobOrderCode), | |||
| "Delivery Order / 送貨單": orDash(u.deliveryOrderCode), | |||
| "Delivery Note / DN": "", | |||
| "Ticket / 票號": "", | |||
| "Shop / 門市": "", | |||
| "Qty / 數量": u.qty, | |||
| "Handler / 經手人": orDash(u.handler), | |||
| "Extra / 加單": "", | |||
| "Replenish / 補貨": "", | |||
| "Remarks / 備註": "", | |||
| "Date/Time / 時間": orDash(u.timestamp), | |||
| })); | |||
| const doRows = mergeScopedDoDeliveries(data).map((d) => ({ | |||
| "Section / 區塊": "DO Delivery / 成品出倉", | |||
| "Warehouse / 倉位": d.scopeWarehouseCode, | |||
| "Inventory Lot Id": d.inventoryLotId, | |||
| "Usage Type / 類型": tr.usageType("FG_DELIVERY"), | |||
| "Usage Type Code": "FG_DELIVERY", | |||
| "Doc No. / 單號": orDash(d.deliveryOrderCode), | |||
| "Pick Order / 提料單": orDash(d.pickOrderCode), | |||
| "Conso / 併單": orDash(d.consoCode), | |||
| "Job Order / 工單": "", | |||
| "Delivery Order / 送貨單": orDash(d.deliveryOrderCode), | |||
| "Delivery Note / DN": orDash(d.deliveryNoteCode), | |||
| "Ticket / 票號": orDash(d.ticketNo), | |||
| "Shop / 門市": [d.shopCode, d.shopName].filter(Boolean).join(" · ") || "—", | |||
| "Qty / 數量": d.qty, | |||
| "Handler / 經手人": orDash(d.handler), | |||
| "Extra / 加單": d.isExtra ? "Y" : "", | |||
| "Replenish / 補貨": d.isReplenish ? "Y" : "", | |||
| "Remarks / 備註": "", | |||
| "Date/Time / 時間": orDash(d.timestamp), | |||
| })); | |||
| const replenish = mergeScopedReplenishmentEvents(data).map((r) => ({ | |||
| "Section / 區塊": "Replenishment / 補貨", | |||
| "Warehouse / 倉位": r.scopeWarehouseCode, | |||
| "Inventory Lot Id": r.inventoryLotId, | |||
| "Usage Type / 類型": "Replenishment", | |||
| "Usage Type Code": "REPLENISH", | |||
| "Doc No. / 單號": orDash(r.replenishmentCode), | |||
| "Pick Order / 提料單": "", | |||
| "Conso / 併單": "", | |||
| "Job Order / 工單": "", | |||
| "Delivery Order / 送貨單": orDash(r.sourceDoCode), | |||
| "Delivery Note / DN": "", | |||
| "Ticket / 票號": "", | |||
| "Shop / 門市": [r.shopCode, r.shopName].filter(Boolean).join(" · ") || "—", | |||
| "Qty / 數量": r.replenishQty, | |||
| "Handler / 經手人": orDash(r.handler), | |||
| "Extra / 加單": "", | |||
| "Replenish / 補貨": "Y", | |||
| "Remarks / 備註": orDash(r.reason), | |||
| "Date/Time / 時間": orDash(r.timestamp), | |||
| })); | |||
| const returns = mergeScopedReturnEvents(data).map((r) => ({ | |||
| "Section / 區塊": "Return / 退貨", | |||
| "Warehouse / 倉位": r.scopeWarehouseCode, | |||
| "Inventory Lot Id": r.inventoryLotId, | |||
| "Usage Type / 類型": tr.movementType(r.movementType), | |||
| "Usage Type Code": orDash(r.movementType), | |||
| "Doc No. / 單號": orDash(r.refCode), | |||
| "Pick Order / 提料單": "", | |||
| "Conso / 併單": "", | |||
| "Job Order / 工單": "", | |||
| "Delivery Order / 送貨單": "", | |||
| "Delivery Note / DN": "", | |||
| "Ticket / 票號": "", | |||
| "Shop / 門市": "", | |||
| "Qty / 數量": r.qty, | |||
| "Handler / 經手人": orDash(r.handler), | |||
| "Extra / 加單": "", | |||
| "Replenish / 補貨": "", | |||
| "Remarks / 備註": orDash(r.remarks), | |||
| "Date/Time / 時間": orDash(r.timestamp), | |||
| })); | |||
| const fails = mergeScopedFailEvents(data).map((f) => ({ | |||
| "Section / 區塊": "Pick Failure / 揀貨異常", | |||
| "Warehouse / 倉位": f.scopeWarehouseCode, | |||
| "Inventory Lot Id": f.inventoryLotId, | |||
| "Usage Type / 類型": tr.failType(f.failType), | |||
| "Usage Type Code": orDash(f.failType), | |||
| "Doc No. / 單號": orDash(f.pickOrderCode), | |||
| "Pick Order / 提料單": orDash(f.pickOrderCode), | |||
| "Conso / 併單": "", | |||
| "Job Order / 工單": "", | |||
| "Delivery Order / 送貨單": "", | |||
| "Delivery Note / DN": "", | |||
| "Ticket / 票號": "", | |||
| "Shop / 門市": "", | |||
| "Qty / 數量": f.qty, | |||
| "Handler / 經手人": orDash(f.handlerName), | |||
| "Extra / 加單": "", | |||
| "Replenish / 補貨": "", | |||
| "Remarks / 備註": orDash(f.category), | |||
| "Date/Time / 時間": orDash(f.recordDate), | |||
| })); | |||
| return ensureRows( | |||
| [...usage, ...doRows, ...replenish, ...returns, ...fails], | |||
| outboundEmpty(), | |||
| ); | |||
| }; | |||
| const stockTakeEmpty = (): Record<string, unknown> => ({ | |||
| "Warehouse / 倉位": "", | |||
| "Inventory Lot Id": "", | |||
| "Stock Take Code / 盤點單號": "", | |||
| "Round / 輪次": "", | |||
| "Section / 區域": "", | |||
| "Lot / 批號": "", | |||
| "Book Qty / 帳面數量": "", | |||
| "Accepted Qty / 核准數量": "", | |||
| "Variance Qty / 差異數量": "", | |||
| "Approver / 核准人": "", | |||
| "Stock Taker / 初盤人": "", | |||
| "First Count Qty / 初盤數量": "", | |||
| "Second Count Qty / 複盤數量": "", | |||
| "Approver Count Qty / 覆盤數量": "", | |||
| "Record Status / 紀錄狀態": "", | |||
| "Date/Time / 時間": "", | |||
| }); | |||
| const buildStockTakeSheet = ( | |||
| data: ItemLotTraceResponse, | |||
| ): Record<string, unknown>[] => { | |||
| const rows = mergeScopedStockTakeEvents(data).map((e) => { | |||
| const d = e.recordDetail; | |||
| return { | |||
| "Warehouse / 倉位": e.scopeWarehouseCode, | |||
| "Inventory Lot Id": e.inventoryLotId, | |||
| "Stock Take Code / 盤點單號": orDash(e.stockTakeCode), | |||
| "Round / 輪次": orDash( | |||
| e.stockTakeRoundName ?? | |||
| (e.stockTakeRoundId != null ? `#${e.stockTakeRoundId}` : ""), | |||
| ), | |||
| "Section / 區域": orDash(e.stockTakeSection), | |||
| "Lot / 批號": orDash(e.lotNo), | |||
| "Book Qty / 帳面數量": resolveStockTakeBookQty(d, e.beforeQty) ?? "", | |||
| "Accepted Qty / 核准數量": | |||
| resolveStockTakeAcceptedQty(d, e.afterQty) ?? "", | |||
| "Variance Qty / 差異數量": | |||
| d?.varianceQty != null ? d.varianceQty : e.varianceQty, | |||
| "Approver / 核准人": orDash(d?.approverName || e.approver), | |||
| "Stock Taker / 初盤人": orDash(d?.stockTakerName), | |||
| "First Count Qty / 初盤數量": d?.pickerFirstQty ?? "", | |||
| "Second Count Qty / 複盤數量": d?.pickerSecondQty ?? "", | |||
| "Approver Count Qty / 覆盤數量": d?.approverQty ?? "", | |||
| "Record Status / 紀錄狀態": orDash(d?.recordStatus), | |||
| "Date/Time / 時間": orDash(e.timestamp), | |||
| }; | |||
| }); | |||
| return ensureRows(rows, stockTakeEmpty()); | |||
| }; | |||
| const productionEmpty = (): Record<string, unknown> => ({ | |||
| "Section / 區塊": "", | |||
| "Job Order / 工單": "", | |||
| "Doc No. / 單號": "", | |||
| "Item Code / 貨品編號": "", | |||
| "Lot / 批號": "", | |||
| "Item Name / 品名": "", | |||
| "Step / 步驟": "", | |||
| "Assigned Step / 對應製程": "", | |||
| "Qty / 數量": "", | |||
| "Scrap Qty / 損耗數量": "", | |||
| "Defect Qty / 不良數量": "", | |||
| "UOM / 單位": "", | |||
| "Operator / 操作員": "", | |||
| "Equipment / 設備": "", | |||
| "Status / 狀態": "", | |||
| "Date/Time / 時間": "", | |||
| }); | |||
| const buildProductionSheet = ( | |||
| data: ItemLotTraceResponse, | |||
| compiledGraph: CompiledTraceGraph, | |||
| tr: TraceLabelTranslator, | |||
| ): Record<string, unknown>[] => { | |||
| const rows: Record<string, unknown>[] = []; | |||
| const presentation = buildTracePresentationRows(compiledGraph.nodes, data); | |||
| const upstreamSource = data.joPrelude?.materialInputs?.length | |||
| ? data.joPrelude.materialInputs.map((m) => ({ | |||
| jobOrderCode: m.jobOrderCode, | |||
| materialItemCode: m.materialItemCode, | |||
| materialLotNo: m.materialLotNo, | |||
| materialQty: m.materialQty, | |||
| materialUom: m.materialUom, | |||
| assignedStepName: m.assignedStepName, | |||
| pickOrderCode: m.pickOrderCode, | |||
| pickedAt: m.pickedAt, | |||
| materialItemName: m.materialItemName, | |||
| })) | |||
| : data.bomTrace.upstream.map((u) => ({ | |||
| jobOrderCode: u.jobOrderCode, | |||
| materialItemCode: u.materialItemCode, | |||
| materialLotNo: u.materialLotNo, | |||
| materialQty: u.materialQty, | |||
| materialUom: "", | |||
| assignedStepName: "", | |||
| pickOrderCode: "", | |||
| pickedAt: null as string | null, | |||
| materialItemName: "", | |||
| })); | |||
| upstreamSource.forEach((m) => { | |||
| rows.push({ | |||
| "Section / 區塊": "BOM Upstream / BOM 上游", | |||
| "Job Order / 工單": orDash(m.jobOrderCode), | |||
| "Doc No. / 單號": orDash(m.pickOrderCode), | |||
| "Item Code / 貨品編號": orDash(m.materialItemCode), | |||
| "Lot / 批號": orDash(m.materialLotNo), | |||
| "Item Name / 品名": orDash(m.materialItemName), | |||
| "Step / 步驟": "", | |||
| "Assigned Step / 對應製程": orDash(m.assignedStepName), | |||
| "Qty / 數量": m.materialQty, | |||
| "Scrap Qty / 損耗數量": "", | |||
| "Defect Qty / 不良數量": "", | |||
| "UOM / 單位": orDash(m.materialUom), | |||
| "Operator / 操作員": "", | |||
| "Equipment / 設備": "", | |||
| "Status / 狀態": "", | |||
| "Date/Time / 時間": orDash(m.pickedAt), | |||
| }); | |||
| }); | |||
| data.bomTrace.downstream.forEach((d) => { | |||
| rows.push({ | |||
| "Section / 區塊": "BOM Downstream / BOM 下游", | |||
| "Job Order / 工單": orDash(d.jobOrderCode), | |||
| "Doc No. / 單號": "", | |||
| "Item Code / 貨品編號": orDash(d.finishedItemCode), | |||
| "Lot / 批號": orDash(d.finishedLotNo), | |||
| "Item Name / 品名": "", | |||
| "Step / 步驟": "", | |||
| "Assigned Step / 對應製程": "", | |||
| "Qty / 數量": d.fgQty, | |||
| "Scrap Qty / 損耗數量": "", | |||
| "Defect Qty / 不良數量": "", | |||
| "UOM / 單位": orDash(d.fgUom), | |||
| "Operator / 操作員": "", | |||
| "Equipment / 設備": "", | |||
| "Status / 狀態": "", | |||
| "Date/Time / 時間": "", | |||
| }); | |||
| }); | |||
| data.bomTrace.bomRecipe.forEach((r) => { | |||
| rows.push({ | |||
| "Section / 區塊": "BOM Recipe / BOM 配方", | |||
| "Job Order / 工單": "", | |||
| "Doc No. / 單號": "", | |||
| "Item Code / 貨品編號": orDash(r.materialItemCode), | |||
| "Lot / 批號": "", | |||
| "Item Name / 品名": orDash(r.materialItemName), | |||
| "Step / 步驟": "", | |||
| "Assigned Step / 對應製程": orDash(r.assignedStepName), | |||
| "Qty / 數量": r.qtyPerUnit, | |||
| "Scrap Qty / 損耗數量": "", | |||
| "Defect Qty / 不良數量": "", | |||
| "UOM / 單位": orDash(r.uom), | |||
| "Operator / 操作員": "", | |||
| "Equipment / 設備": "", | |||
| "Status / 狀態": "", | |||
| "Date/Time / 時間": "", | |||
| }); | |||
| }); | |||
| presentation.joPicks | |||
| .slice() | |||
| .sort((a, b) => (b.pickedAt ?? "").localeCompare(a.pickedAt ?? "")) | |||
| .forEach((p) => { | |||
| rows.push({ | |||
| "Section / 區塊": "JO Pick / 工單提料", | |||
| "Job Order / 工單": "", | |||
| "Doc No. / 單號": orDash(p.pickOrderCode), | |||
| "Item Code / 貨品編號": orDash(p.materialItemCode), | |||
| "Lot / 批號": orDash(p.materialLotNo), | |||
| "Item Name / 品名": "", | |||
| "Step / 步驟": "", | |||
| "Assigned Step / 對應製程": orDash(p.assignedStepName), | |||
| "Qty / 數量": p.qty, | |||
| "Scrap Qty / 損耗數量": "", | |||
| "Defect Qty / 不良數量": "", | |||
| "UOM / 單位": orDash(p.uom), | |||
| "Operator / 操作員": "", | |||
| "Equipment / 設備": "", | |||
| "Status / 狀態": "", | |||
| "Date/Time / 時間": orDash(p.pickedAt), | |||
| }); | |||
| }); | |||
| const productionRows = | |||
| data.productionSteps.length > 0 | |||
| ? data.productionSteps | |||
| .slice() | |||
| .sort((a, b) => (a.seqNo ?? 0) - (b.seqNo ?? 0)) | |||
| .map((s) => ({ | |||
| "Section / 區塊": "Production Step / 生產步驟", | |||
| "Job Order / 工單": orDash(data.joPrelude?.jobOrder.jobOrderCode), | |||
| "Doc No. / 單號": "", | |||
| "Item Code / 貨品編號": orDash(data.lot.itemCode), | |||
| "Lot / 批號": orDash(data.lot.lotNo), | |||
| "Item Name / 品名": orDash(data.lot.itemName), | |||
| "Step / 步驟": orDash(s.stepName), | |||
| "Assigned Step / 對應製程": orDash(s.description), | |||
| "Qty / 數量": s.outputQty, | |||
| "Scrap Qty / 損耗數量": s.scrapQty, | |||
| "Defect Qty / 不良數量": s.defectQty, | |||
| "UOM / 單位": orDash(data.lot.uom), | |||
| "Operator / 操作員": orDash(s.operatorName), | |||
| "Equipment / 設備": | |||
| [s.equipmentCode, s.equipmentName].filter(Boolean).join(" · ") || | |||
| "—", | |||
| "Status / 狀態": tr.productionStatus(s.status), | |||
| "Date/Time / 時間": orDash(s.endTime || s.startTime), | |||
| })) | |||
| : presentation.production | |||
| .slice() | |||
| .sort((a, b) => (a.seqNo ?? 0) - (b.seqNo ?? 0)) | |||
| .map((s) => ({ | |||
| "Section / 區塊": "Production Step / 生產步驟", | |||
| "Job Order / 工單": orDash(data.joPrelude?.jobOrder.jobOrderCode), | |||
| "Doc No. / 單號": "", | |||
| "Item Code / 貨品編號": orDash(data.lot.itemCode), | |||
| "Lot / 批號": orDash(s.traceLotNo || data.lot.lotNo), | |||
| "Item Name / 品名": orDash(data.lot.itemName), | |||
| "Step / 步驟": orDash(s.stepName), | |||
| "Assigned Step / 對應製程": orDash(s.description), | |||
| "Qty / 數量": s.outputQty, | |||
| "Scrap Qty / 損耗數量": s.scrapQty, | |||
| "Defect Qty / 不良數量": s.defectQty, | |||
| "UOM / 單位": orDash(data.lot.uom), | |||
| "Operator / 操作員": orDash(s.operatorName), | |||
| "Equipment / 設備": orDash(s.equipmentName || s.equipmentCode), | |||
| "Status / 狀態": orDash(s.status), | |||
| "Date/Time / 時間": orDash(s.endTime || s.startTime), | |||
| })); | |||
| rows.push(...productionRows); | |||
| data.byproductLots.forEach((b) => { | |||
| rows.push({ | |||
| "Section / 區塊": "Byproduct / 副產品", | |||
| "Job Order / 工單": orDash(b.jobOrderCode), | |||
| "Doc No. / 單號": "", | |||
| "Item Code / 貨品編號": orDash(b.itemCode), | |||
| "Lot / 批號": orDash(b.lotNo), | |||
| "Item Name / 品名": orDash(b.itemName), | |||
| "Step / 步驟": orDash(b.processStepName), | |||
| "Assigned Step / 對應製程": "", | |||
| "Qty / 數量": b.qty, | |||
| "Scrap Qty / 損耗數量": "", | |||
| "Defect Qty / 不良數量": "", | |||
| "UOM / 單位": orDash(b.uom), | |||
| "Operator / 操作員": "", | |||
| "Equipment / 設備": "", | |||
| "Status / 狀態": "", | |||
| "Date/Time / 時間": orDash(b.producedAt), | |||
| }); | |||
| }); | |||
| data.lotRelations.forEach((r) => { | |||
| rows.push({ | |||
| "Section / 區塊": "Lot Relation / 拆批關聯", | |||
| "Job Order / 工單": "", | |||
| "Doc No. / 單號": orDash(r.relationType), | |||
| "Item Code / 貨品編號": orDash(r.itemCode), | |||
| "Lot / 批號": orDash(r.lotNo), | |||
| "Item Name / 品名": orDash(r.itemName), | |||
| "Step / 步驟": "", | |||
| "Assigned Step / 對應製程": "", | |||
| "Qty / 數量": r.qty, | |||
| "Scrap Qty / 損耗數量": "", | |||
| "Defect Qty / 不良數量": "", | |||
| "UOM / 單位": "", | |||
| "Operator / 操作員": "", | |||
| "Equipment / 設備": "", | |||
| "Status / 狀態": orDash(r.productLotNo), | |||
| "Date/Time / 時間": orDash(r.timestamp), | |||
| }); | |||
| }); | |||
| return ensureRows(rows, productionEmpty()); | |||
| }; | |||
| /** Build the 8 classified sheets without triggering download (for tests). */ | |||
| export const buildItemLotTraceSheets = ( | |||
| data: ItemLotTraceResponse, | |||
| compiledGraph: CompiledTraceGraph, | |||
| t: TFunction, | |||
| exportLabels: ItemLotTraceExportLabels, | |||
| exportAt: string = new Date().toISOString().replace("T", " ").slice(0, 19), | |||
| ): MultiSheetSpec[] => { | |||
| const tr = createTraceLabelTranslator(t); | |||
| return [ | |||
| { | |||
| name: SHEET.summary, | |||
| rows: buildSummarySheet(data, exportLabels, exportAt), | |||
| }, | |||
| { name: SHEET.timeline, rows: buildTimelineSheet(compiledGraph, t, tr) }, | |||
| { name: SHEET.origins, rows: buildOriginsSheet(data, tr) }, | |||
| { name: SHEET.qc, rows: buildQcSheet(data, tr) }, | |||
| { name: SHEET.warehouse, rows: buildWarehouseSheet(data, tr) }, | |||
| { name: SHEET.outbound, rows: buildOutboundSheet(data, tr) }, | |||
| { name: SHEET.stockTake, rows: buildStockTakeSheet(data) }, | |||
| { | |||
| name: SHEET.production, | |||
| rows: buildProductionSheet(data, compiledGraph, tr), | |||
| }, | |||
| ]; | |||
| }; | |||
| export const ITEM_LOT_TRACE_SHEET_NAMES = Object.values(SHEET); | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 15 | v1.0.1 | 2026-07-20 */ | |||
| export const exportItemLotTraceXlsx = ( | |||
| data: ItemLotTraceResponse, | |||
| compiledGraph: CompiledTraceGraph, | |||
| t: TFunction, | |||
| ): void => { | |||
| const exportLabels: ItemLotTraceExportLabels = { | |||
| bomDirectionFg: t("bomDirectionFG"), | |||
| bomDirectionMaterial: t("bomDirectionMaterial"), | |||
| bomDirectionUnknown: t("bomDirectionUnknown"), | |||
| }; | |||
| const sheets = buildItemLotTraceSheets(data, compiledGraph, t, exportLabels); | |||
| exportMultiSheetToXlsx(sheets, buildItemLotTraceFilename(data)); | |||
| }; | |||
| @@ -0,0 +1,5 @@ | |||
| export { default } from "./ItemTracing"; | |||
| export { default as ItemTracingScanBar } from "./ItemTracingScanBar"; | |||
| export { default as ItemTracingSummary } from "./ItemTracingSummary"; | |||
| export { default as ItemTracingFlowGraph } from "./ItemTracingFlowGraph"; | |||
| export { default as ItemTracingSections } from "./ItemTracingSections"; | |||
| @@ -0,0 +1,113 @@ | |||
| "use client"; | |||
| import { | |||
| Table, | |||
| TableBody, | |||
| TableCell, | |||
| TableHead, | |||
| TableRow, | |||
| Typography, | |||
| } from "@mui/material"; | |||
| import { type ReactNode } from "react"; | |||
| export type FilterableColumnDef<T> = { | |||
| key: string; | |||
| label: string; | |||
| align?: "left" | "right" | "center"; | |||
| value: (row: T) => string | number | null | undefined; | |||
| cell?: (row: T) => ReactNode; | |||
| /** @deprecated Unused — filters removed. */ | |||
| filterable?: boolean; | |||
| }; | |||
| type FilterableDataTableProps<T> = { | |||
| rows: T[]; | |||
| columns: FilterableColumnDef<T>[]; | |||
| getRowKey: (row: T, index: number) => string; | |||
| emptyLabel: string; | |||
| size?: "small" | "medium"; | |||
| collapseAfter?: number; | |||
| showAll?: boolean; | |||
| onToggleShowAll?: () => void; | |||
| showAllLabel?: string | ((count: number) => string); | |||
| collapseLabel?: string | ((count: number) => string); | |||
| }; | |||
| const resolveCountLabel = ( | |||
| label: string | ((count: number) => string) | undefined, | |||
| count: number, | |||
| fallback: string, | |||
| ) => (typeof label === "function" ? label(count) : (label ?? fallback)); | |||
| /** Plain data table (column filters removed). */ | |||
| export function FilterableDataTable<T>({ | |||
| rows, | |||
| columns, | |||
| getRowKey, | |||
| emptyLabel, | |||
| size = "small", | |||
| collapseAfter, | |||
| showAll = false, | |||
| onToggleShowAll, | |||
| showAllLabel, | |||
| collapseLabel, | |||
| }: FilterableDataTableProps<T>) { | |||
| const collapsed = | |||
| collapseAfter != null && rows.length > collapseAfter && !showAll; | |||
| const visibleRows = collapsed ? rows.slice(0, collapseAfter) : rows; | |||
| return ( | |||
| <> | |||
| <Table size={size}> | |||
| <TableHead> | |||
| <TableRow> | |||
| {columns.map((col) => ( | |||
| <TableCell | |||
| key={col.key} | |||
| align={col.align} | |||
| sx={{ fontWeight: 700, whiteSpace: "nowrap" }} | |||
| > | |||
| {col.label} | |||
| </TableCell> | |||
| ))} | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {visibleRows.length === 0 ? ( | |||
| <TableRow> | |||
| <TableCell colSpan={columns.length}> | |||
| <Typography color="text.secondary" variant="body2"> | |||
| {emptyLabel} | |||
| </Typography> | |||
| </TableCell> | |||
| </TableRow> | |||
| ) : ( | |||
| visibleRows.map((row, index) => ( | |||
| <TableRow key={getRowKey(row, index)}> | |||
| {columns.map((col) => ( | |||
| <TableCell key={col.key} align={col.align}> | |||
| {col.cell ? col.cell(row) : (col.value(row) ?? "—")} | |||
| </TableCell> | |||
| ))} | |||
| </TableRow> | |||
| )) | |||
| )} | |||
| </TableBody> | |||
| </Table> | |||
| {collapseAfter != null && | |||
| rows.length > collapseAfter && | |||
| onToggleShowAll && ( | |||
| <Typography | |||
| variant="caption" | |||
| color="primary" | |||
| sx={{ cursor: "pointer", mt: 0.5, display: "inline-block" }} | |||
| onClick={onToggleShowAll} | |||
| > | |||
| {showAll | |||
| ? resolveCountLabel(collapseLabel, rows.length, `▲ ${rows.length}`) | |||
| : resolveCountLabel(showAllLabel, rows.length, `▼ ${rows.length}`)} | |||
| </Typography> | |||
| )} | |||
| </> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,316 @@ | |||
| import type { | |||
| ItemLotTraceLocationBlock, | |||
| ItemLotTraceResponse, | |||
| } from "@/app/api/itemTracing"; | |||
| import { blockWarehouseCode } from "./buildLocationBlockGraphNodes"; | |||
| export type ScopedRow<T> = T & { | |||
| scopeWarehouseCode: string; | |||
| inventoryLotId: number; | |||
| }; | |||
| export const primaryWarehouseLabel = (data: ItemLotTraceResponse): string => | |||
| data.warehouseLines | |||
| .map((w) => w.warehouseCode) | |||
| .filter(Boolean) | |||
| .join(" / ") || "—"; | |||
| const sortByTimestampDesc = <T extends { timestamp?: string | null }>( | |||
| rows: T[], | |||
| ): T[] => | |||
| [...rows].sort((a, b) => | |||
| (b.timestamp ?? "").localeCompare(a.timestamp ?? ""), | |||
| ); | |||
| const sortByReceiptDateDesc = <T extends { receiptDate?: string | null }>( | |||
| rows: T[], | |||
| ): T[] => | |||
| [...rows].sort((a, b) => | |||
| (b.receiptDate ?? "").localeCompare(a.receiptDate ?? ""), | |||
| ); | |||
| export const mergeScopedOrigins = (data: ItemLotTraceResponse) => { | |||
| const primaryWh = primaryWarehouseLabel(data); | |||
| const primary = data.origins.map((o) => ({ | |||
| ...o, | |||
| scopeWarehouseCode: primaryWh, | |||
| inventoryLotId: data.lot.inventoryLotId, | |||
| })); | |||
| const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => { | |||
| const wh = blockWarehouseCode(block); | |||
| return block.origins.map((o) => ({ | |||
| ...o, | |||
| scopeWarehouseCode: wh, | |||
| inventoryLotId: block.inventoryLotId, | |||
| })); | |||
| }); | |||
| return sortByReceiptDateDesc([...primary, ...fromBlocks]); | |||
| }; | |||
| export const mergeScopedQcResults = (data: ItemLotTraceResponse) => { | |||
| const primaryWh = primaryWarehouseLabel(data); | |||
| const primary = data.qcResults.map((q, i) => ({ | |||
| ...q, | |||
| scopeWarehouseCode: primaryWh, | |||
| inventoryLotId: data.lot.inventoryLotId, | |||
| rowKey: `primary-${q.stockInLineId}-${i}`, | |||
| })); | |||
| const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => { | |||
| const wh = blockWarehouseCode(block); | |||
| return block.qcResults.map((q, i) => ({ | |||
| ...q, | |||
| scopeWarehouseCode: wh, | |||
| inventoryLotId: block.inventoryLotId, | |||
| rowKey: `loc-${block.inventoryLotId}-${q.stockInLineId}-${i}`, | |||
| })); | |||
| }); | |||
| return [...primary, ...fromBlocks].sort((a, b) => | |||
| (b.created ?? "").localeCompare(a.created ?? ""), | |||
| ); | |||
| }; | |||
| export const mergeScopedOutboundUsage = (data: ItemLotTraceResponse) => { | |||
| const primaryWh = primaryWarehouseLabel(data); | |||
| const primary = data.outboundUsage.map((u) => ({ | |||
| ...u, | |||
| scopeWarehouseCode: primaryWh, | |||
| inventoryLotId: data.lot.inventoryLotId, | |||
| })); | |||
| const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => { | |||
| const wh = blockWarehouseCode(block); | |||
| return block.outboundUsage.map((u) => ({ | |||
| ...u, | |||
| scopeWarehouseCode: wh, | |||
| inventoryLotId: block.inventoryLotId, | |||
| })); | |||
| }); | |||
| return sortByTimestampDesc([...primary, ...fromBlocks]); | |||
| }; | |||
| export const mergeScopedStockTakeEvents = (data: ItemLotTraceResponse) => { | |||
| const primaryWh = primaryWarehouseLabel(data); | |||
| const primary = data.stockTakeEvents.map((e, i) => ({ | |||
| ...e, | |||
| scopeWarehouseCode: e.warehouseCode?.trim() || primaryWh, | |||
| inventoryLotId: data.lot.inventoryLotId, | |||
| rowKey: `primary-${e.stockTakeCode}-${i}`, | |||
| })); | |||
| const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => { | |||
| const wh = blockWarehouseCode(block); | |||
| return block.stockTakeEvents.map((e, i) => ({ | |||
| ...e, | |||
| scopeWarehouseCode: e.warehouseCode?.trim() || wh, | |||
| inventoryLotId: block.inventoryLotId, | |||
| rowKey: `loc-${block.inventoryLotId}-${e.stockTakeCode}-${i}`, | |||
| })); | |||
| }); | |||
| return sortByTimestampDesc([...primary, ...fromBlocks]); | |||
| }; | |||
| export const mergeScopedAdjustments = (data: ItemLotTraceResponse) => { | |||
| const primaryWh = primaryWarehouseLabel(data); | |||
| const primary = data.adjustments.map((a, i) => ({ | |||
| ...a, | |||
| scopeWarehouseCode: primaryWh, | |||
| inventoryLotId: data.lot.inventoryLotId, | |||
| rowKey: `primary-${a.refCode}-${i}`, | |||
| })); | |||
| const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => { | |||
| const wh = blockWarehouseCode(block); | |||
| return block.adjustments.map((a, i) => ({ | |||
| ...a, | |||
| scopeWarehouseCode: wh, | |||
| inventoryLotId: block.inventoryLotId, | |||
| rowKey: `loc-${block.inventoryLotId}-${a.refCode}-${i}`, | |||
| })); | |||
| }); | |||
| return sortByTimestampDesc([...primary, ...fromBlocks]); | |||
| }; | |||
| export const mergeScopedTransfers = (data: ItemLotTraceResponse) => { | |||
| const primaryWh = primaryWarehouseLabel(data); | |||
| const primary = data.transfers.map((tr, i) => ({ | |||
| ...tr, | |||
| scopeWarehouseCode: primaryWh, | |||
| inventoryLotId: data.lot.inventoryLotId, | |||
| rowKey: `primary-${tr.transferCode}-${i}`, | |||
| })); | |||
| const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => { | |||
| const wh = blockWarehouseCode(block); | |||
| return block.transfers.map((tr, i) => ({ | |||
| ...tr, | |||
| scopeWarehouseCode: wh, | |||
| inventoryLotId: block.inventoryLotId, | |||
| rowKey: `loc-${block.inventoryLotId}-${tr.transferCode}-${i}`, | |||
| })); | |||
| }); | |||
| return sortByTimestampDesc([...primary, ...fromBlocks]); | |||
| }; | |||
| export const mergeScopedMovements = (data: ItemLotTraceResponse) => { | |||
| const primaryWh = primaryWarehouseLabel(data); | |||
| const primary = data.movements.map((m, i) => ({ | |||
| ...m, | |||
| scopeWarehouseCode: m.warehouseCode?.trim() || primaryWh, | |||
| inventoryLotId: data.lot.inventoryLotId, | |||
| rowKey: `primary-mov-${i}-${m.refCode}`, | |||
| })); | |||
| const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => { | |||
| const wh = blockWarehouseCode(block); | |||
| return block.movements.map((m, i) => ({ | |||
| ...m, | |||
| scopeWarehouseCode: m.warehouseCode?.trim() || wh, | |||
| inventoryLotId: block.inventoryLotId, | |||
| rowKey: `loc-${block.inventoryLotId}-mov-${i}-${m.refCode}`, | |||
| })); | |||
| }); | |||
| return sortByTimestampDesc([...primary, ...fromBlocks]); | |||
| }; | |||
| export const mergeScopedPutawayEvents = (data: ItemLotTraceResponse) => { | |||
| const primaryWh = primaryWarehouseLabel(data); | |||
| const primary = data.putawayEvents.map((p, i) => ({ | |||
| ...p, | |||
| scopeWarehouseCode: p.warehouseCode?.trim() || primaryWh, | |||
| inventoryLotId: data.lot.inventoryLotId, | |||
| rowKey: `primary-putaway-${i}-${p.refCode}`, | |||
| })); | |||
| const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => { | |||
| const wh = blockWarehouseCode(block); | |||
| return block.putawayEvents.map((p, i) => ({ | |||
| ...p, | |||
| scopeWarehouseCode: p.warehouseCode?.trim() || wh, | |||
| inventoryLotId: block.inventoryLotId, | |||
| rowKey: `loc-${block.inventoryLotId}-putaway-${i}-${p.refCode}`, | |||
| })); | |||
| }); | |||
| return sortByTimestampDesc([...primary, ...fromBlocks]); | |||
| }; | |||
| export const mergeScopedPurchaseEvents = (data: ItemLotTraceResponse) => { | |||
| const primaryWh = primaryWarehouseLabel(data); | |||
| const primary = (data.purchaseEvents ?? []).map((p, i) => ({ | |||
| ...p, | |||
| scopeWarehouseCode: primaryWh, | |||
| inventoryLotId: data.lot.inventoryLotId, | |||
| rowKey: `primary-po-${i}-${p.purchaseOrderLineId}`, | |||
| })); | |||
| const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => { | |||
| const wh = blockWarehouseCode(block); | |||
| return (block.purchaseEvents ?? []).map((p, i) => ({ | |||
| ...p, | |||
| scopeWarehouseCode: wh, | |||
| inventoryLotId: block.inventoryLotId, | |||
| rowKey: `loc-${block.inventoryLotId}-po-${i}-${p.purchaseOrderLineId}`, | |||
| })); | |||
| }); | |||
| return [...primary, ...fromBlocks].sort((a, b) => | |||
| (b.orderDate ?? "").localeCompare(a.orderDate ?? ""), | |||
| ); | |||
| }; | |||
| export const mergeScopedDoDeliveries = (data: ItemLotTraceResponse) => { | |||
| const primaryWh = primaryWarehouseLabel(data); | |||
| const primary = data.doDeliveries.map((d, i) => ({ | |||
| ...d, | |||
| scopeWarehouseCode: primaryWh, | |||
| inventoryLotId: data.lot.inventoryLotId, | |||
| rowKey: `primary-do-${i}-${d.stockOutLineId}`, | |||
| })); | |||
| const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => { | |||
| const wh = blockWarehouseCode(block); | |||
| return block.doDeliveries.map((d, i) => ({ | |||
| ...d, | |||
| scopeWarehouseCode: wh, | |||
| inventoryLotId: block.inventoryLotId, | |||
| rowKey: `loc-${block.inventoryLotId}-do-${i}-${d.stockOutLineId}`, | |||
| })); | |||
| }); | |||
| return sortByTimestampDesc([...primary, ...fromBlocks]); | |||
| }; | |||
| export const mergeScopedFailEvents = (data: ItemLotTraceResponse) => { | |||
| const primaryWh = primaryWarehouseLabel(data); | |||
| const primary = data.failEvents.map((f, i) => ({ | |||
| ...f, | |||
| scopeWarehouseCode: primaryWh, | |||
| inventoryLotId: data.lot.inventoryLotId, | |||
| rowKey: `primary-fail-${i}-${f.failId}`, | |||
| timestamp: f.recordDate, | |||
| })); | |||
| const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => { | |||
| const wh = blockWarehouseCode(block); | |||
| return block.failEvents.map((f, i) => ({ | |||
| ...f, | |||
| scopeWarehouseCode: wh, | |||
| inventoryLotId: block.inventoryLotId, | |||
| rowKey: `loc-${block.inventoryLotId}-fail-${i}-${f.failId}`, | |||
| timestamp: f.recordDate, | |||
| })); | |||
| }); | |||
| return sortByTimestampDesc([...primary, ...fromBlocks]); | |||
| }; | |||
| export const mergeScopedReturnEvents = (data: ItemLotTraceResponse) => { | |||
| const primaryWh = primaryWarehouseLabel(data); | |||
| const primary = data.returnEvents.map((r, i) => ({ | |||
| ...r, | |||
| scopeWarehouseCode: r.warehouseCode?.trim() || primaryWh, | |||
| inventoryLotId: data.lot.inventoryLotId, | |||
| rowKey: `primary-return-${i}-${r.stockOutLineId}`, | |||
| })); | |||
| const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => { | |||
| const wh = blockWarehouseCode(block); | |||
| return block.returnEvents.map((r, i) => ({ | |||
| ...r, | |||
| scopeWarehouseCode: r.warehouseCode?.trim() || wh, | |||
| inventoryLotId: block.inventoryLotId, | |||
| rowKey: `loc-${block.inventoryLotId}-return-${i}-${r.stockOutLineId}`, | |||
| })); | |||
| }); | |||
| return sortByTimestampDesc([...primary, ...fromBlocks]); | |||
| }; | |||
| export const mergeScopedOpenMovements = (data: ItemLotTraceResponse) => { | |||
| const primaryWh = primaryWarehouseLabel(data); | |||
| const primary = data.openMovements.map((o, i) => ({ | |||
| ...o, | |||
| scopeWarehouseCode: o.warehouseCode?.trim() || primaryWh, | |||
| inventoryLotId: data.lot.inventoryLotId, | |||
| rowKey: `primary-open-${i}-${o.stockInLineId}`, | |||
| })); | |||
| const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => { | |||
| const wh = blockWarehouseCode(block); | |||
| return block.openMovements.map((o, i) => ({ | |||
| ...o, | |||
| scopeWarehouseCode: o.warehouseCode?.trim() || wh, | |||
| inventoryLotId: block.inventoryLotId, | |||
| rowKey: `loc-${block.inventoryLotId}-open-${i}-${o.stockInLineId}`, | |||
| })); | |||
| }); | |||
| return sortByTimestampDesc([...primary, ...fromBlocks]); | |||
| }; | |||
| export const mergeScopedReplenishmentEvents = (data: ItemLotTraceResponse) => { | |||
| const primaryWh = primaryWarehouseLabel(data); | |||
| const primary = (data.replenishmentEvents ?? []).map((r, i) => ({ | |||
| ...r, | |||
| scopeWarehouseCode: primaryWh, | |||
| inventoryLotId: data.lot.inventoryLotId, | |||
| rowKey: `primary-replenish-${i}-${r.replenishmentId}`, | |||
| })); | |||
| const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => { | |||
| const wh = blockWarehouseCode(block); | |||
| return (block.replenishmentEvents ?? []).map((r, i) => ({ | |||
| ...r, | |||
| scopeWarehouseCode: wh, | |||
| inventoryLotId: block.inventoryLotId, | |||
| rowKey: `loc-${block.inventoryLotId}-replenish-${i}-${r.replenishmentId}`, | |||
| })); | |||
| }); | |||
| return sortByTimestampDesc([...primary, ...fromBlocks]); | |||
| }; | |||
| export const hasMultipleLocations = (data: ItemLotTraceResponse): boolean => | |||
| (data.locationBlocks?.length ?? 0) > 0 || | |||
| (data.alternateLocations?.length ?? 0) > 0; | |||
| @@ -0,0 +1,274 @@ | |||
| import { | |||
| DO_GROUP_CHILD_WIDTH, | |||
| DO_GROUP_HEADER, | |||
| DO_GROUP_INNER_COLS_MAX, | |||
| DO_GROUP_MIN_SIZE, | |||
| DO_GROUP_PAD, | |||
| PICK_GROUP_MIN_SIZE, | |||
| NODE_GAP, | |||
| NODE_HEIGHT, | |||
| NODE_WIDTH, | |||
| } from "./traceFlowConstants"; | |||
| import { TraceGraphLayoutNode, sortNodesInPhase } from "./traceGraphLayout"; | |||
| import { TraceGraphNode } from "./buildTraceGraphNodes"; | |||
| export type DoGroupChildPlacement = { x: number; y: number; compact: boolean }; | |||
| /** Fewer columns for large groups — wider cards, no horizontal cramming. */ | |||
| const preferredDoGroupCols = (memberCount: number): number => { | |||
| if (memberCount <= 1) return 1; | |||
| if (memberCount >= 15) return 2; | |||
| if (memberCount >= 6) return 3; | |||
| return 2; | |||
| }; | |||
| const sumDoGroupQty = ( | |||
| children: TraceGraphLayoutNode[], | |||
| ): { totalQty: number; uom?: string } | null => { | |||
| let totalQty = 0; | |||
| let hasQty = false; | |||
| let uom: string | undefined; | |||
| children.forEach((child) => { | |||
| const qty = child.qty; | |||
| if (qty != null && Number.isFinite(Number(qty))) { | |||
| totalQty += Number(qty); | |||
| hasQty = true; | |||
| } | |||
| if (!uom && child.uom?.trim()) uom = child.uom.trim(); | |||
| }); | |||
| return hasQty ? { totalQty, uom } : null; | |||
| }; | |||
| export const computeDoGroupBoxLayout = ( | |||
| memberCount: number, | |||
| ): { width: number; height: number; cols: number } => { | |||
| const childW = DO_GROUP_CHILD_WIDTH; | |||
| const cols = Math.min(DO_GROUP_INNER_COLS_MAX, memberCount, preferredDoGroupCols(memberCount)); | |||
| const rows = Math.ceil(memberCount / cols); | |||
| const gridW = cols * childW + Math.max(0, cols - 1) * NODE_GAP; | |||
| const gridH = rows * NODE_HEIGHT + Math.max(0, rows - 1) * NODE_GAP; | |||
| return { | |||
| width: gridW + DO_GROUP_PAD * 2, | |||
| height: DO_GROUP_HEADER + gridH + DO_GROUP_PAD * 2, | |||
| cols, | |||
| }; | |||
| }; | |||
| export const layoutDoGroupChildPlacements = ( | |||
| children: TraceGraphLayoutNode[], | |||
| ): Map<string, DoGroupChildPlacement> => { | |||
| const sorted = [...children].sort(sortNodesInPhase); | |||
| const { cols } = computeDoGroupBoxLayout(sorted.length); | |||
| const childW = DO_GROUP_CHILD_WIDTH; | |||
| const result = new Map<string, DoGroupChildPlacement>(); | |||
| sorted.forEach((child, index) => { | |||
| const col = index % cols; | |||
| const row = Math.floor(index / cols); | |||
| result.set(child.id, { | |||
| x: DO_GROUP_PAD + col * (childW + NODE_GAP), | |||
| y: DO_GROUP_HEADER + DO_GROUP_PAD + row * (NODE_HEIGHT + NODE_GAP), | |||
| compact: false, | |||
| }); | |||
| }); | |||
| return result; | |||
| }; | |||
| export const applyDoOutboundGrouping = ( | |||
| nodes: TraceGraphLayoutNode[], | |||
| groupTitle: (count: number) => string, | |||
| minSize = DO_GROUP_MIN_SIZE, | |||
| ): TraceGraphLayoutNode[] => { | |||
| const doByColumnAndWarehouse = new Map<string, TraceGraphLayoutNode[]>(); | |||
| nodes.forEach((node) => { | |||
| if (node.kind !== "DO_OUT" || node.doGroupId) return; | |||
| const whKey = (node.warehouseCode ?? "").trim().toUpperCase() || "__none__"; | |||
| const key = `${node.column}::${whKey}`; | |||
| const list = doByColumnAndWarehouse.get(key) ?? []; | |||
| list.push(node); | |||
| doByColumnAndWarehouse.set(key, list); | |||
| }); | |||
| const groupNodes: TraceGraphLayoutNode[] = []; | |||
| const childGroupIds = new Map<string, string>(); | |||
| doByColumnAndWarehouse.forEach((columnNodes, key) => { | |||
| if (columnNodes.length < minSize) return; | |||
| const column = columnNodes[0]!.column; | |||
| const warehouseCode = columnNodes[0]!.warehouseCode?.trim() || undefined; | |||
| const whSlug = (warehouseCode ?? "none").replace(/[^a-zA-Z0-9_-]/g, "_"); | |||
| const groupId = `do-group-col-${column}-${whSlug}`; | |||
| const sorted = [...columnNodes].sort(sortNodesInPhase); | |||
| const { width, height } = computeDoGroupBoxLayout(sorted.length); | |||
| const minSort = sorted.reduce((min, n) => Math.min(min, n.sortKey), sorted[0]!.sortKey); | |||
| const qtySummary = sumDoGroupQty(sorted); | |||
| sorted.forEach((child) => childGroupIds.set(child.id, groupId)); | |||
| const groupNode: TraceGraphLayoutNode = { | |||
| id: groupId, | |||
| kind: "DO_GROUP", | |||
| timestamp: sorted[0]?.timestamp ?? null, | |||
| sortKey: minSort, | |||
| title: groupTitle(sorted.length), | |||
| subtitle: [sorted[0]?.dayKey, warehouseCode].filter(Boolean).join(" · ") || "", | |||
| categoryLabel: sorted[0]?.categoryLabel, | |||
| warehouseCode, | |||
| details: [], | |||
| phase: sorted[0]!.phase, | |||
| column, | |||
| dayKey: sorted[0]!.dayKey, | |||
| laneIndex: sorted[0]!.laneIndex, | |||
| sequenceIndex: sorted[0]!.sequenceIndex, | |||
| branchIndex: 0, | |||
| branchSize: 1, | |||
| dayPhaseStaggerIndex: sorted[0]!.dayPhaseStaggerIndex, | |||
| groupBoxWidth: width, | |||
| groupBoxHeight: height, | |||
| groupMemberCount: sorted.length, | |||
| groupTotalQty: qtySummary?.totalQty, | |||
| groupUom: qtySummary?.uom, | |||
| }; | |||
| groupNodes.push(groupNode); | |||
| }); | |||
| if (!groupNodes.length) return nodes; | |||
| return [ | |||
| ...nodes.map((node) => { | |||
| const groupId = childGroupIds.get(node.id); | |||
| if (!groupId) return node; | |||
| return { ...node, doGroupId: groupId }; | |||
| }), | |||
| ...groupNodes, | |||
| ].sort((a, b) => { | |||
| if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey; | |||
| return a.id.localeCompare(b.id); | |||
| }); | |||
| }; | |||
| export const applyMaterialPickGrouping = ( | |||
| nodes: TraceGraphLayoutNode[], | |||
| groupTitle: (pickOrderCode: string, count: number) => string, | |||
| minSize = PICK_GROUP_MIN_SIZE, | |||
| ): TraceGraphLayoutNode[] => { | |||
| const picksByKey = new Map<string, TraceGraphLayoutNode[]>(); | |||
| nodes.forEach((node) => { | |||
| if (node.kind !== "MATERIAL_PICK" || node.doGroupId) return; | |||
| const pickCode = node.refCode?.trim(); | |||
| if (!pickCode) return; | |||
| const key = `${node.column}::${pickCode}`; | |||
| const list = picksByKey.get(key) ?? []; | |||
| list.push(node); | |||
| picksByKey.set(key, list); | |||
| }); | |||
| const groupNodes: TraceGraphLayoutNode[] = []; | |||
| const childGroupIds = new Map<string, string>(); | |||
| picksByKey.forEach((columnNodes, key) => { | |||
| if (columnNodes.length < minSize) return; | |||
| const pickCode = key.split("::").slice(1).join("::"); | |||
| const groupId = `pick-group-${key.replace(/[^a-zA-Z0-9_-]/g, "_")}`; | |||
| const sorted = [...columnNodes].sort(sortNodesInPhase); | |||
| const { width, height } = computeDoGroupBoxLayout(sorted.length); | |||
| const minSort = sorted.reduce((min, n) => Math.min(min, n.sortKey), sorted[0]!.sortKey); | |||
| sorted.forEach((child) => childGroupIds.set(child.id, groupId)); | |||
| const groupNode: TraceGraphLayoutNode = { | |||
| id: groupId, | |||
| kind: "PICK_GROUP", | |||
| timestamp: sorted[0]?.timestamp ?? null, | |||
| sortKey: minSort, | |||
| title: groupTitle(pickCode, sorted.length), | |||
| subtitle: sorted[0]?.dayKey ?? "", | |||
| refCode: pickCode, | |||
| refId: sorted[0]?.refId, | |||
| docLinkKind: "pick", | |||
| consoCode: sorted[0]?.consoCode, | |||
| jobOrderCode: sorted[0]?.jobOrderCode, | |||
| categoryLabel: sorted[0]?.categoryLabel, | |||
| details: [], | |||
| phase: sorted[0]!.phase, | |||
| column: sorted[0]!.column, | |||
| dayKey: sorted[0]!.dayKey, | |||
| laneIndex: sorted[0]!.laneIndex, | |||
| sequenceIndex: sorted[0]!.sequenceIndex, | |||
| branchIndex: 0, | |||
| branchSize: 1, | |||
| dayPhaseStaggerIndex: sorted[0]!.dayPhaseStaggerIndex, | |||
| groupBoxWidth: width, | |||
| groupBoxHeight: height, | |||
| groupMemberCount: sorted.length, | |||
| }; | |||
| groupNodes.push(groupNode); | |||
| }); | |||
| if (!groupNodes.length) return nodes; | |||
| return [ | |||
| ...nodes.map((node) => { | |||
| const groupId = childGroupIds.get(node.id); | |||
| if (!groupId) return node; | |||
| return { ...node, doGroupId: groupId }; | |||
| }), | |||
| ...groupNodes, | |||
| ].sort((a, b) => { | |||
| if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey; | |||
| return a.id.localeCompare(b.id); | |||
| }); | |||
| }; | |||
| export const applyFlowNodeGrouping = ( | |||
| nodes: TraceGraphLayoutNode[], | |||
| labels: { | |||
| flowDoGroupTitle: (count: number) => string; | |||
| flowPickGroupTitle: (pickOrderCode: string, count: number) => string; | |||
| }, | |||
| ): TraceGraphLayoutNode[] => | |||
| applyMaterialPickGrouping( | |||
| applyDoOutboundGrouping(nodes, labels.flowDoGroupTitle), | |||
| labels.flowPickGroupTitle, | |||
| ); | |||
| export const regroupLayoutCells = ( | |||
| nodes: TraceGraphLayoutNode[], | |||
| ): TraceGraphLayoutNode[][] => { | |||
| const visible = nodes.filter((n) => !isDoGroupChild(n)); | |||
| const groups = new Map<string, TraceGraphLayoutNode[]>(); | |||
| visible.forEach((node) => { | |||
| const key = `${node.column}:${node.laneIndex}`; | |||
| const list = groups.get(key) ?? []; | |||
| list.push(node); | |||
| groups.set(key, list); | |||
| }); | |||
| groups.forEach((list) => { | |||
| list.sort(sortNodesInPhase); | |||
| const size = list.length; | |||
| list.forEach((node, idx) => { | |||
| node.branchIndex = idx; | |||
| node.branchSize = size; | |||
| }); | |||
| }); | |||
| return Array.from(groups.values()).sort((a, b) => { | |||
| const na = a[0]!; | |||
| const nb = b[0]!; | |||
| if (na.column !== nb.column) return na.column - nb.column; | |||
| return na.laneIndex - nb.laneIndex; | |||
| }); | |||
| }; | |||
| export const isFlowGroupContainer = (node: TraceGraphNode | TraceGraphLayoutNode): boolean => | |||
| node.kind === "DO_GROUP" || node.kind === "PICK_GROUP"; | |||
| export const isDoGroupChild = (node: TraceGraphNode | TraceGraphLayoutNode): boolean => | |||
| Boolean(node.doGroupId?.trim()); | |||
| @@ -0,0 +1,155 @@ | |||
| import dayjs from "dayjs"; | |||
| import { | |||
| ItemLotTraceJoPrelude, | |||
| ItemLotTraceResponse, | |||
| } from "@/app/api/itemTracing"; | |||
| export const isWorkbenchTicketNo = (ticketNo?: string | null): boolean => | |||
| (ticketNo ?? "").trim().toUpperCase().startsWith("TI-"); | |||
| /** Normalize any date/datetime string to YYYY-MM-DD for API / URL query params (LocalDate). */ | |||
| export const normalizeTargetDateForLink = ( | |||
| value?: string | null, | |||
| ): string | undefined => { | |||
| if (value == null) return undefined; | |||
| const raw = String(value).trim(); | |||
| if (!raw) return undefined; | |||
| // Prefer extracting YYYY-MM-DD prefix — dayjs/Date.parse is unreliable for | |||
| // "YYYY-MM-DD HH:mm:ss" in some browsers (e.g. Safari). | |||
| const match = raw.match(/(\d{4}-\d{2}-\d{2})/); | |||
| if (match) return match[1]; | |||
| const d = dayjs(raw); | |||
| return d.isValid() ? d.format("YYYY-MM-DD") : undefined; | |||
| }; | |||
| export const targetDateFromTraceTimestamp = ( | |||
| timestamp?: string | null, | |||
| ): string | undefined => normalizeTargetDateForLink(timestamp); | |||
| export type DoOutboundDocLink = { | |||
| kind: "workbench" | "pick" | undefined; | |||
| ticketNo?: string; | |||
| targetDate?: string; | |||
| /** Visible link label (DO code preferred on DO_OUT cards). */ | |||
| displayCode: string; | |||
| pickOrderId?: number | null; | |||
| consoCode?: string; | |||
| }; | |||
| export type JoPickDocLink = { | |||
| kind: "jodetail"; | |||
| pickOrderCode: string; | |||
| targetDate?: string; | |||
| displayCode: string; | |||
| pickOrderId?: number | null; | |||
| }; | |||
| const walkJoPreludePickOrders = ( | |||
| prelude: ItemLotTraceJoPrelude, | |||
| map: Map<string, string>, | |||
| ): void => { | |||
| prelude.pickOrders.forEach((po) => { | |||
| const code = po.pickOrderCode?.trim(); | |||
| const date = normalizeTargetDateForLink(po.targetDate); | |||
| if (code && date) map.set(code, date); | |||
| }); | |||
| prelude.materialInputs.forEach((m) => { | |||
| if (m.nestedJoPrelude) walkJoPreludePickOrders(m.nestedJoPrelude, map); | |||
| }); | |||
| }; | |||
| export const buildPickOrderTargetDateMapFromPrelude = ( | |||
| prelude: ItemLotTraceJoPrelude, | |||
| ): Map<string, string> => { | |||
| const map = new Map<string, string>(); | |||
| walkJoPreludePickOrders(prelude, map); | |||
| return map; | |||
| }; | |||
| export const buildPickOrderTargetDateMap = ( | |||
| data: ItemLotTraceResponse, | |||
| ): Map<string, string> => | |||
| data.joPrelude ? buildPickOrderTargetDateMapFromPrelude(data.joPrelude) : new Map(); | |||
| export const resolvePickOrderTargetDate = ( | |||
| map: Map<string, string>, | |||
| pickOrderCode?: string | null, | |||
| fallbackTimestamp?: string | null, | |||
| ): string | undefined => { | |||
| const code = pickOrderCode?.trim(); | |||
| if (code) { | |||
| const fromPrelude = normalizeTargetDateForLink(map.get(code)); | |||
| if (fromPrelude) return fromPrelude; | |||
| } | |||
| return targetDateFromTraceTimestamp(fallbackTimestamp); | |||
| }; | |||
| export const resolveJoPickDocLink = (input: { | |||
| pickOrderCode?: string; | |||
| pickOrderId?: number | null; | |||
| timestamp?: string | null; | |||
| targetDate?: string | null; | |||
| }): JoPickDocLink | null => { | |||
| const pickCode = input.pickOrderCode?.trim() || ""; | |||
| if (!pickCode) return null; | |||
| return { | |||
| kind: "jodetail", | |||
| pickOrderCode: pickCode, | |||
| targetDate: | |||
| normalizeTargetDateForLink(input.targetDate) || | |||
| targetDateFromTraceTimestamp(input.timestamp), | |||
| displayCode: pickCode, | |||
| pickOrderId: input.pickOrderId, | |||
| }; | |||
| }; | |||
| export const resolveDoOutboundDocLink = (input: { | |||
| pickOrderCode?: string; | |||
| pickOrderId?: number | null; | |||
| deliveryOrderCode?: string; | |||
| ticketNo?: string; | |||
| outboundTicketNo?: string; | |||
| consoCode?: string; | |||
| timestamp?: string | null; | |||
| /** Workbench path only when linked to delivery_order_pick_order. */ | |||
| deliveryOrderPickOrderId?: number | null; | |||
| }): DoOutboundDocLink => { | |||
| const ticket = (input.ticketNo || input.outboundTicketNo || "").trim(); | |||
| const conso = (input.consoCode || "").trim(); | |||
| const hasWorkbenchLink = | |||
| input.deliveryOrderPickOrderId != null && | |||
| Number.isFinite(Number(input.deliveryOrderPickOrderId)); | |||
| const workbenchTicket = hasWorkbenchLink | |||
| ? isWorkbenchTicketNo(ticket) | |||
| ? ticket | |||
| : isWorkbenchTicketNo(conso) | |||
| ? conso | |||
| : undefined | |||
| : undefined; | |||
| const targetDate = targetDateFromTraceTimestamp(input.timestamp); | |||
| const pickCode = input.pickOrderCode?.trim() || ""; | |||
| const doCode = input.deliveryOrderCode?.trim() || ""; | |||
| if (workbenchTicket) { | |||
| return { | |||
| kind: "workbench", | |||
| ticketNo: workbenchTicket, | |||
| targetDate, | |||
| displayCode: doCode || pickCode || workbenchTicket, | |||
| pickOrderId: input.pickOrderId, | |||
| consoCode: conso || pickCode, | |||
| }; | |||
| } | |||
| // Prefer 送貨單號 on the card; /pickOrder/detail is retired so no pick-only link. | |||
| // Legacy GoodPick TI-* without deliveryOrderPickOrderId must not deep-link to workbench. | |||
| if (doCode || pickCode) { | |||
| return { | |||
| kind: undefined, | |||
| displayCode: doCode || pickCode, | |||
| pickOrderId: input.pickOrderId, | |||
| consoCode: conso || pickCode, | |||
| targetDate, | |||
| }; | |||
| } | |||
| return { kind: undefined, displayCode: "—" }; | |||
| }; | |||
| @@ -0,0 +1,38 @@ | |||
| export const LANE_MIN_HEIGHT = 150; | |||
| export const LANE_GAP = 32; | |||
| /** Minimum width of one phase slot within a calendar day column. */ | |||
| export const COLUMN_WIDTH_MIN = 320; | |||
| /** @deprecated use COLUMN_WIDTH_MIN — kept for imports that expect COLUMN_WIDTH */ | |||
| export const COLUMN_WIDTH = COLUMN_WIDTH_MIN; | |||
| export const PHASE_LABEL_WIDTH = 112; | |||
| export const HEADER_HEIGHT = 40; | |||
| /** Trace event card width — sized for PO codes and supplier names. */ | |||
| export const NODE_WIDTH = 268; | |||
| export const NODE_WIDTH_BRANCH = 220; | |||
| /** Fixed trace event card height — layout spacing must match rendered Paper height. | |||
| * Sized for densest cards (MATERIAL_PICK / JO_OUT with item, statuses, timestamp). */ | |||
| export const NODE_HEIGHT = 280; | |||
| export const NODE_GAP = 16; | |||
| /** React Flow canvas height in the life-cycle graph panel. */ | |||
| export const VIEWPORT_HEIGHT = 720; | |||
| /** Initial fitView will not zoom out below this (keeps nodes readable). */ | |||
| export const FIT_VIEW_MIN_ZOOM = 0.55; | |||
| export const FIT_VIEW_MAX_ZOOM = 1.35; | |||
| /** | |||
| * Min DO_OUT nodes in one day column before collapsing into a group box. | |||
| * Higher than pick groups: individual DO tickets stay readable until the column | |||
| * gets dense (≥3 same day + warehouse). | |||
| */ | |||
| export const DO_GROUP_MIN_SIZE = 3; | |||
| /** | |||
| * Min MATERIAL_PICK lines sharing a pick order before collapsing into a group box. | |||
| * Intentional asymmetry vs DO_GROUP_MIN_SIZE: warehouse users think in pick-order | |||
| * batches, so even a single material line sits inside its pick-order box. | |||
| */ | |||
| export const PICK_GROUP_MIN_SIZE = 1; | |||
| export const DO_GROUP_PAD = 12; | |||
| export const DO_GROUP_HEADER = 32; | |||
| export const DO_GROUP_INNER_COLS_MAX = 3; | |||
| /** Card width used inside a DO group grid (full-size, not branch compact). */ | |||
| export const DO_GROUP_CHILD_WIDTH = NODE_WIDTH; | |||
| @@ -0,0 +1,395 @@ | |||
| export type TraceFlowPair = { fromId: string; toId: string }; | |||
| export type RoutedTraceFlowEdge = TraceFlowPair & { | |||
| sourceHandle: string; | |||
| targetHandle: string; | |||
| offset: number; | |||
| pathMode?: "smooth" | "corridor"; | |||
| corridorY?: number; | |||
| /** Added to React Flow sourceX for the first horizontal stub. */ | |||
| corridorExitOffset?: number; | |||
| /** Added to React Flow targetX for the vertical drop into the target. */ | |||
| corridorEntryOffset?: number; | |||
| /** | |||
| * Added to React Flow sourceX for the late branch point on the corridor. | |||
| * Shared-trunk fans travel together until this X, then fork to each target. | |||
| */ | |||
| corridorBranchOffset?: number; | |||
| }; | |||
| export type TraceFlowNodeRect = { | |||
| x: number; | |||
| y: number; | |||
| width: number; | |||
| height: number; | |||
| laneIndex: number; | |||
| }; | |||
| export type TraceFlowLaneLayout = { | |||
| laneTops: number[]; | |||
| laneHeights: number[]; | |||
| laneGap: number; | |||
| }; | |||
| const OFFSET_STEP = 18; | |||
| const CORRIDOR_STEP = 14; | |||
| const HORIZONTAL_STUB = 14; | |||
| const MIN_HORIZONTAL_SPAN = 24; | |||
| export const TRACE_FLOW_HANDLE_IN = "in"; | |||
| export const TRACE_FLOW_HANDLE_OUT = "out"; | |||
| const stableSortPairs = (list: TraceFlowPair[]): TraceFlowPair[] => | |||
| [...list].sort((a, b) => { | |||
| const keyA = `${a.fromId}->${a.toId}`; | |||
| const keyB = `${b.fromId}->${b.toId}`; | |||
| return keyA.localeCompare(keyB); | |||
| }); | |||
| const edgeKey = (fromId: string, toId: string) => `${fromId}->${toId}`; | |||
| const targetColumnKey = (rect: TraceFlowNodeRect) => | |||
| `${rect.laneIndex}:${Math.round(rect.x)}`; | |||
| const sourceColumnKey = (rect: TraceFlowNodeRect) => | |||
| `${rect.laneIndex}:${Math.round(rect.x)}`; | |||
| type CorridorMeta = { | |||
| corridorY: number; | |||
| corridorExitOffset: number; | |||
| corridorEntryOffset: number; | |||
| corridorBranchOffset?: number; | |||
| }; | |||
| /** Spread multiple edges from the same node via handles + smoothstep offset. */ | |||
| export const assignTraceFlowEdgeRouting = ( | |||
| pairs: TraceFlowPair[], | |||
| ): { | |||
| routed: RoutedTraceFlowEdge[]; | |||
| incomingCount: Map<string, number>; | |||
| outgoingCount: Map<string, number>; | |||
| } => { | |||
| const bySource = new Map<string, TraceFlowPair[]>(); | |||
| const byTarget = new Map<string, TraceFlowPair[]>(); | |||
| pairs.forEach((p) => { | |||
| const outList = bySource.get(p.fromId) ?? []; | |||
| outList.push(p); | |||
| bySource.set(p.fromId, outList); | |||
| const inList = byTarget.get(p.toId) ?? []; | |||
| inList.push(p); | |||
| byTarget.set(p.toId, inList); | |||
| }); | |||
| bySource.forEach((list, id) => bySource.set(id, stableSortPairs(list))); | |||
| byTarget.forEach((list, id) => byTarget.set(id, stableSortPairs(list))); | |||
| const incomingCount = new Map<string, number>(); | |||
| const outgoingCount = new Map<string, number>(); | |||
| byTarget.forEach((list, id) => incomingCount.set(id, list.length)); | |||
| // 1→N fans use a single source handle (shared trunk); report 1 outbound slot. | |||
| bySource.forEach((list, id) => outgoingCount.set(id, list.length > 1 ? 1 : list.length)); | |||
| const routed: RoutedTraceFlowEdge[] = pairs.map((p) => { | |||
| const outList = bySource.get(p.fromId) ?? [p]; | |||
| const inList = byTarget.get(p.toId) ?? [p]; | |||
| const sourceIndex = outList.findIndex((e) => e.fromId === p.fromId && e.toId === p.toId); | |||
| const targetIndex = inList.findIndex((e) => e.fromId === p.fromId && e.toId === p.toId); | |||
| // 1→N fans leave from one handle so the stroke starts as a single trunk. | |||
| const sourceHandle = | |||
| outList.length > 1 | |||
| ? `${TRACE_FLOW_HANDLE_OUT}-0` | |||
| : `${TRACE_FLOW_HANDLE_OUT}-${Math.max(0, sourceIndex)}`; | |||
| const targetHandle = `${TRACE_FLOW_HANDLE_IN}-${Math.max(0, targetIndex)}`; | |||
| let offset = 0; | |||
| if (outList.length > 1) { | |||
| offset = 0; | |||
| } else if (inList.length > 1) { | |||
| offset = (targetIndex - (inList.length - 1) / 2) * OFFSET_STEP; | |||
| } | |||
| return { ...p, sourceHandle, targetHandle, offset, pathMode: "smooth" }; | |||
| }); | |||
| return { routed, incomingCount, outgoingCount }; | |||
| }; | |||
| const baseCorridorY = ( | |||
| src: TraceFlowNodeRect, | |||
| srcLane: number, | |||
| tgtLane: number, | |||
| lanes: TraceFlowLaneLayout, | |||
| ): number => { | |||
| if (srcLane !== tgtLane) { | |||
| const srcLaneBottom = | |||
| (lanes.laneTops[srcLane] ?? src.y) + (lanes.laneHeights[srcLane] ?? 0); | |||
| return srcLaneBottom + lanes.laneGap * 0.42; | |||
| } | |||
| return src.y + src.height + 12; | |||
| }; | |||
| /** | |||
| * Route long horizontal spans through the lane gutter so edges do not cut across | |||
| * unrelated nodes in the same swimlane. | |||
| * 1→N source fans share one corridor trunk and only branch near targets. | |||
| */ | |||
| export const enrichTraceFlowCorridorRouting = ( | |||
| routed: RoutedTraceFlowEdge[], | |||
| rects: Map<string, TraceFlowNodeRect>, | |||
| lanes: TraceFlowLaneLayout, | |||
| ): RoutedTraceFlowEdge[] => { | |||
| const outDegree = new Map<string, number>(); | |||
| routed.forEach((edge) => { | |||
| outDegree.set(edge.fromId, (outDegree.get(edge.fromId) ?? 0) + 1); | |||
| }); | |||
| const corridorCandidates: RoutedTraceFlowEdge[] = []; | |||
| routed.forEach((edge) => { | |||
| const src = rects.get(edge.fromId); | |||
| const tgt = rects.get(edge.toId); | |||
| if (!src || !tgt) return; | |||
| const srcRight = src.x + src.width; | |||
| const crossLane = src.laneIndex !== tgt.laneIndex; | |||
| const longSpan = tgt.x > srcRight + MIN_HORIZONTAL_SPAN; | |||
| const sourceFan = (outDegree.get(edge.fromId) ?? 0) > 1; | |||
| if (crossLane || longSpan || sourceFan) { | |||
| corridorCandidates.push(edge); | |||
| } | |||
| }); | |||
| const corridorMetaByKey = new Map<string, CorridorMeta>(); | |||
| const groups = new Map<string, RoutedTraceFlowEdge[]>(); | |||
| corridorCandidates.forEach((edge) => { | |||
| const src = rects.get(edge.fromId); | |||
| const tgt = rects.get(edge.toId); | |||
| if (!src || !tgt) return; | |||
| const groupKey = `${src.laneIndex}->${tgt.laneIndex}`; | |||
| const list = groups.get(groupKey) ?? []; | |||
| list.push(edge); | |||
| groups.set(groupKey, list); | |||
| }); | |||
| groups.forEach((edges, groupKey) => { | |||
| const [srcLaneStr, tgtLaneStr] = groupKey.split("->"); | |||
| const srcLane = Number(srcLaneStr); | |||
| const tgtLane = Number(tgtLaneStr); | |||
| const byFrom = new Map<string, RoutedTraceFlowEdge[]>(); | |||
| edges.forEach((edge) => { | |||
| const list = byFrom.get(edge.fromId) ?? []; | |||
| list.push(edge); | |||
| byFrom.set(edge.fromId, list); | |||
| }); | |||
| // Distinct sources in this lane-pair get separated corridor Y bands. | |||
| const sourceIds = Array.from(byFrom.keys()).sort((a, b) => { | |||
| const ax = rects.get(a)?.x ?? 0; | |||
| const bx = rects.get(b)?.x ?? 0; | |||
| if (ax !== bx) return ax - bx; | |||
| const ay = rects.get(a)?.y ?? 0; | |||
| const by = rects.get(b)?.y ?? 0; | |||
| return ay - by || a.localeCompare(b); | |||
| }); | |||
| sourceIds.forEach((fromId, sourceIndex) => { | |||
| const fan = [...(byFrom.get(fromId) ?? [])].sort((a, b) => { | |||
| const aty = rects.get(a.toId)?.y ?? 0; | |||
| const bty = rects.get(b.toId)?.y ?? 0; | |||
| if (aty !== bty) return aty - bty; | |||
| const atx = rects.get(a.toId)?.x ?? 0; | |||
| const btx = rects.get(b.toId)?.x ?? 0; | |||
| return ( | |||
| atx - btx || | |||
| edgeKey(a.fromId, a.toId).localeCompare(edgeKey(b.fromId, b.toId)) | |||
| ); | |||
| }); | |||
| const src = rects.get(fromId); | |||
| if (!src || !fan.length) return; | |||
| const baseY = baseCorridorY(src, srcLane, tgtLane, lanes); | |||
| const corridorY = baseY + sourceIndex * CORRIDOR_STEP; | |||
| const sharedExit = HORIZONTAL_STUB; | |||
| const srcRight = src.x + src.width; | |||
| const isFan = fan.length > 1; | |||
| const entrySpread = isFan ? (fan.length - 1) * CORRIDOR_STEP : 0; | |||
| const fanMetas = fan.map((edge, fanIndex) => { | |||
| const tgt = rects.get(edge.toId); | |||
| const corridorEntryOffset = isFan | |||
| ? -(HORIZONTAL_STUB + entrySpread) + fanIndex * CORRIDOR_STEP | |||
| : -HORIZONTAL_STUB; | |||
| const entryXLayout = (tgt?.x ?? 0) + corridorEntryOffset; | |||
| return { edge, corridorEntryOffset, entryXLayout }; | |||
| }); | |||
| let sharedBranchOffset: number | undefined; | |||
| if (isFan && fanMetas.length) { | |||
| const minEntryX = Math.min(...fanMetas.map((m) => m.entryXLayout)); | |||
| sharedBranchOffset = minEntryX - srcRight; | |||
| } | |||
| fanMetas.forEach(({ edge, corridorEntryOffset }) => { | |||
| corridorMetaByKey.set(edgeKey(edge.fromId, edge.toId), { | |||
| corridorY, | |||
| corridorExitOffset: sharedExit, | |||
| corridorEntryOffset, | |||
| corridorBranchOffset: sharedBranchOffset, | |||
| }); | |||
| }); | |||
| }); | |||
| }); | |||
| // Separate vertical drops into the same target column (stacked nodes), | |||
| // without breaking shared-trunk exit / branch for same-source fans. | |||
| const byTargetColumn = new Map<string, RoutedTraceFlowEdge[]>(); | |||
| corridorCandidates.forEach((edge) => { | |||
| const tgt = rects.get(edge.toId); | |||
| if (!tgt) return; | |||
| const key = targetColumnKey(tgt); | |||
| const list = byTargetColumn.get(key) ?? []; | |||
| list.push(edge); | |||
| byTargetColumn.set(key, list); | |||
| }); | |||
| byTargetColumn.forEach((edges) => { | |||
| if (edges.length <= 1) return; | |||
| const sorted = [...edges].sort((a, b) => { | |||
| const aty = rects.get(a.toId)?.y ?? 0; | |||
| const bty = rects.get(b.toId)?.y ?? 0; | |||
| return aty - bty || edgeKey(a.fromId, a.toId).localeCompare(edgeKey(b.fromId, b.toId)); | |||
| }); | |||
| const spread = (sorted.length - 1) * CORRIDOR_STEP; | |||
| sorted.forEach((edge, index) => { | |||
| const key = edgeKey(edge.fromId, edge.toId); | |||
| const meta = corridorMetaByKey.get(key); | |||
| if (!meta) return; | |||
| // Same-source fans already have entry offsets relative to the fan. | |||
| if ((outDegree.get(edge.fromId) ?? 0) > 1) return; | |||
| corridorMetaByKey.set(key, { | |||
| ...meta, | |||
| corridorEntryOffset: -(HORIZONTAL_STUB + spread) + index * CORRIDOR_STEP, | |||
| }); | |||
| }); | |||
| }); | |||
| // Separate trunks when multiple distinct sources share a column (stacked nodes). | |||
| // Same-source fan edges keep a shared exit; different sources get staggered exits. | |||
| const bySourceColumn = new Map<string, string[]>(); | |||
| corridorCandidates.forEach((edge) => { | |||
| const src = rects.get(edge.fromId); | |||
| if (!src) return; | |||
| const key = sourceColumnKey(src); | |||
| const list = bySourceColumn.get(key) ?? []; | |||
| if (!list.includes(edge.fromId)) list.push(edge.fromId); | |||
| bySourceColumn.set(key, list); | |||
| }); | |||
| bySourceColumn.forEach((sourceIds) => { | |||
| if (sourceIds.length <= 1) return; | |||
| const sortedSources = [...sourceIds].sort((a, b) => { | |||
| const asy = rects.get(a)?.y ?? 0; | |||
| const bsy = rects.get(b)?.y ?? 0; | |||
| return asy - bsy || a.localeCompare(b); | |||
| }); | |||
| const spread = (sortedSources.length - 1) * CORRIDOR_STEP; | |||
| sortedSources.forEach((fromId, index) => { | |||
| const exitOffset = HORIZONTAL_STUB - spread / 2 + index * CORRIDOR_STEP; | |||
| corridorCandidates | |||
| .filter((e) => e.fromId === fromId) | |||
| .forEach((edge) => { | |||
| const key = edgeKey(edge.fromId, edge.toId); | |||
| const meta = corridorMetaByKey.get(key); | |||
| if (!meta) return; | |||
| corridorMetaByKey.set(key, { | |||
| ...meta, | |||
| corridorExitOffset: exitOffset, | |||
| }); | |||
| }); | |||
| }); | |||
| }); | |||
| return routed.map((edge) => { | |||
| const meta = corridorMetaByKey.get(edgeKey(edge.fromId, edge.toId)); | |||
| if (!meta) return edge; | |||
| return { | |||
| ...edge, | |||
| pathMode: "corridor", | |||
| corridorY: meta.corridorY, | |||
| corridorExitOffset: meta.corridorExitOffset, | |||
| corridorEntryOffset: meta.corridorEntryOffset, | |||
| corridorBranchOffset: meta.corridorBranchOffset, | |||
| // Shared trunk fans already leave from out-0 with zero smooth offset. | |||
| offset: (outDegree.get(edge.fromId) ?? 0) > 1 ? 0 : edge.offset, | |||
| sourceHandle: | |||
| (outDegree.get(edge.fromId) ?? 0) > 1 | |||
| ? `${TRACE_FLOW_HANDLE_OUT}-0` | |||
| : edge.sourceHandle, | |||
| }; | |||
| }); | |||
| }; | |||
| /** | |||
| * Orthogonal path: exit source → drop to corridor → travel → | |||
| * (optional late branch) → rise to target. | |||
| */ | |||
| export const buildTraceFlowCorridorPath = ( | |||
| sourceX: number, | |||
| sourceY: number, | |||
| targetX: number, | |||
| targetY: number, | |||
| corridorY: number, | |||
| corridorEntryOffset?: number, | |||
| corridorExitOffset?: number, | |||
| corridorBranchOffset?: number, | |||
| ): string => { | |||
| const exitX = sourceX + (corridorExitOffset ?? HORIZONTAL_STUB); | |||
| const entryX = targetX + (corridorEntryOffset ?? -HORIZONTAL_STUB); | |||
| const midY = corridorY; | |||
| const branchX = | |||
| corridorBranchOffset != null ? sourceX + corridorBranchOffset : entryX; | |||
| // Keep branch between exit and entry so the trunk never backtracks oddly. | |||
| const clampedBranchX = Math.min( | |||
| Math.max(branchX, Math.min(exitX, entryX)), | |||
| Math.max(exitX, entryX), | |||
| ); | |||
| if (Math.abs(clampedBranchX - entryX) < 0.5) { | |||
| return [ | |||
| `M ${sourceX} ${sourceY}`, | |||
| `L ${exitX} ${sourceY}`, | |||
| `L ${exitX} ${midY}`, | |||
| `L ${entryX} ${midY}`, | |||
| `L ${entryX} ${targetY}`, | |||
| `L ${targetX} ${targetY}`, | |||
| ].join(" "); | |||
| } | |||
| return [ | |||
| `M ${sourceX} ${sourceY}`, | |||
| `L ${exitX} ${sourceY}`, | |||
| `L ${exitX} ${midY}`, | |||
| `L ${clampedBranchX} ${midY}`, | |||
| `L ${entryX} ${midY}`, | |||
| `L ${entryX} ${targetY}`, | |||
| `L ${targetX} ${targetY}`, | |||
| ].join(" "); | |||
| }; | |||
| /** Vertical position (%) for handle index among `count` siblings on one node side. */ | |||
| export const traceFlowHandleTopPercent = (index: number, count: number): string => { | |||
| if (count <= 1) return "50%"; | |||
| return `${((index + 1) / (count + 1)) * 100}%`; | |||
| }; | |||
| @@ -0,0 +1,200 @@ | |||
| import { | |||
| COLUMN_WIDTH_MIN, | |||
| HEADER_HEIGHT, | |||
| LANE_GAP, | |||
| LANE_MIN_HEIGHT, | |||
| NODE_GAP, | |||
| NODE_HEIGHT, | |||
| NODE_WIDTH, | |||
| NODE_WIDTH_BRANCH, | |||
| } from "./traceFlowConstants"; | |||
| import { TraceGraphLayoutNode, sortNodesInPhase } from "./traceGraphLayout"; | |||
| import { isFlowGroupContainer } from "./traceDoGroupLayout"; | |||
| export const CELL_PAD_X = 10; | |||
| export const CELL_PAD_Y = 10; | |||
| export type CellPlacement = { x: number; y: number; compact: boolean }; | |||
| const horizWidth = (count: number, nodeW: number) => | |||
| count * nodeW + Math.max(0, count - 1) * NODE_GAP; | |||
| const defaultInnerCellWidth = () => COLUMN_WIDTH_MIN - CELL_PAD_X * 2; | |||
| const nodeLayoutSize = ( | |||
| node: TraceGraphLayoutNode, | |||
| compact = false, | |||
| ): { width: number; height: number } => { | |||
| if (isFlowGroupContainer(node)) { | |||
| return { | |||
| width: node.groupBoxWidth ?? NODE_WIDTH, | |||
| height: node.groupBoxHeight ?? NODE_HEIGHT, | |||
| }; | |||
| } | |||
| return { | |||
| width: compact ? NODE_WIDTH_BRANCH : NODE_WIDTH, | |||
| height: NODE_HEIGHT, | |||
| }; | |||
| }; | |||
| const stackedCellHeight = (members: TraceGraphLayoutNode[]): number => | |||
| members.reduce((sum, node, index) => { | |||
| const { height } = nodeLayoutSize(node); | |||
| return sum + height + (index > 0 ? NODE_GAP : 0); | |||
| }, 0); | |||
| /** Group boxes have variable height — never tile them horizontally with other cards. */ | |||
| const requiresStackedLayout = (members: TraceGraphLayoutNode[]): boolean => | |||
| members.length > 1 && members.some(isFlowGroupContainer); | |||
| const layoutStackedMembers = ( | |||
| sorted: TraceGraphLayoutNode[], | |||
| innerW: number, | |||
| compact: boolean, | |||
| ): Map<string, CellPlacement> => { | |||
| const result = new Map<string, CellPlacement>(); | |||
| let y = CELL_PAD_Y; | |||
| sorted.forEach((node) => { | |||
| const { width, height } = nodeLayoutSize(node, compact); | |||
| result.set(node.id, { | |||
| x: Math.max(0, (innerW - width) / 2), | |||
| y, | |||
| compact, | |||
| }); | |||
| y += height + NODE_GAP; | |||
| }); | |||
| return result; | |||
| }; | |||
| /** Minimum phase-slot width for a cell's top-level members (excludes DO group children). */ | |||
| export const cellWidthRequirement = (members: TraceGraphLayoutNode[]): number => { | |||
| const sorted = [...members].sort(sortNodesInPhase); | |||
| const n = sorted.length; | |||
| if (n === 0) return COLUMN_WIDTH_MIN; | |||
| if (n === 1 && isFlowGroupContainer(sorted[0]!)) { | |||
| return Math.max(COLUMN_WIDTH_MIN, (sorted[0]!.groupBoxWidth ?? NODE_WIDTH) + CELL_PAD_X * 2); | |||
| } | |||
| if (n === 1) return COLUMN_WIDTH_MIN; | |||
| if (requiresStackedLayout(sorted)) { | |||
| const maxW = Math.max(...sorted.map((node) => nodeLayoutSize(node).width)); | |||
| return Math.max(COLUMN_WIDTH_MIN, maxW + CELL_PAD_X * 2); | |||
| } | |||
| const innerMin = COLUMN_WIDTH_MIN - CELL_PAD_X * 2; | |||
| const branchInner = horizWidth(n, NODE_WIDTH_BRANCH); | |||
| const fullInner = horizWidth(n, NODE_WIDTH); | |||
| const contentInner = Math.max(branchInner, fullInner, innerMin); | |||
| return contentInner + CELL_PAD_X * 2; | |||
| }; | |||
| /** Lay out nodes sharing the same date column + phase lane (side-by-side or vertical stack). */ | |||
| export const layoutCellMembers = ( | |||
| members: TraceGraphLayoutNode[], | |||
| innerCellWidth = defaultInnerCellWidth(), | |||
| ): Map<string, CellPlacement> => { | |||
| const sorted = [...members].sort(sortNodesInPhase); | |||
| const result = new Map<string, CellPlacement>(); | |||
| const n = sorted.length; | |||
| if (n === 0) return result; | |||
| const innerW = innerCellWidth; | |||
| if (n === 1 && isFlowGroupContainer(sorted[0]!)) { | |||
| const w = sorted[0]!.groupBoxWidth ?? NODE_WIDTH; | |||
| result.set(sorted[0]!.id, { | |||
| x: Math.max(0, (innerW - w) / 2), | |||
| y: CELL_PAD_Y, | |||
| compact: false, | |||
| }); | |||
| return result; | |||
| } | |||
| if (n === 1) { | |||
| result.set(sorted[0].id, { | |||
| x: (innerW - NODE_WIDTH) / 2, | |||
| y: CELL_PAD_Y, | |||
| compact: false, | |||
| }); | |||
| return result; | |||
| } | |||
| if (requiresStackedLayout(sorted)) { | |||
| return layoutStackedMembers(sorted, innerW, false); | |||
| } | |||
| let horizontal = false; | |||
| let nodeW = NODE_WIDTH_BRANCH; | |||
| if (horizWidth(n, NODE_WIDTH_BRANCH) <= innerW) { | |||
| horizontal = true; | |||
| nodeW = NODE_WIDTH_BRANCH; | |||
| } else if (horizWidth(n, NODE_WIDTH) <= innerW) { | |||
| horizontal = true; | |||
| nodeW = NODE_WIDTH; | |||
| } | |||
| if (horizontal) { | |||
| const totalW = horizWidth(n, nodeW); | |||
| const startX = (innerW - totalW) / 2; | |||
| sorted.forEach((node, i) => { | |||
| result.set(node.id, { | |||
| x: startX + i * (nodeW + NODE_GAP), | |||
| y: CELL_PAD_Y, | |||
| compact: true, | |||
| }); | |||
| }); | |||
| return result; | |||
| } | |||
| return layoutStackedMembers(sorted, innerW, true); | |||
| }; | |||
| export const cellContentHeight = (members: TraceGraphLayoutNode[]): number => { | |||
| const sorted = [...members].sort(sortNodesInPhase); | |||
| const memberCount = sorted.length; | |||
| if (memberCount <= 0) return LANE_MIN_HEIGHT; | |||
| if (memberCount === 1) { | |||
| const sole = sorted[0]!; | |||
| if (isFlowGroupContainer(sole) && sole.groupBoxHeight) { | |||
| return CELL_PAD_Y * 2 + sole.groupBoxHeight; | |||
| } | |||
| return CELL_PAD_Y * 2 + NODE_HEIGHT; | |||
| } | |||
| if (requiresStackedLayout(sorted)) { | |||
| return CELL_PAD_Y * 2 + stackedCellHeight(sorted); | |||
| } | |||
| const innerW = defaultInnerCellWidth(); | |||
| const fitsHoriz = | |||
| horizWidth(memberCount, NODE_WIDTH_BRANCH) <= innerW || | |||
| horizWidth(memberCount, NODE_WIDTH) <= innerW; | |||
| if (fitsHoriz) return CELL_PAD_Y * 2 + NODE_HEIGHT; | |||
| return CELL_PAD_Y * 2 + stackedCellHeight(sorted); | |||
| }; | |||
| export const computeLaneTops = ( | |||
| laneCount: number, | |||
| cells: Map<string, TraceGraphLayoutNode[]>, | |||
| ): { laneTops: number[]; laneHeights: number[]; totalHeight: number } => { | |||
| const laneHeights = Array.from({ length: laneCount }, () => LANE_MIN_HEIGHT); | |||
| cells.forEach((members, key) => { | |||
| const laneIndex = Number(key.split(":")[0]); | |||
| const cellH = cellContentHeight(members); | |||
| laneHeights[laneIndex] = Math.max(laneHeights[laneIndex] ?? LANE_MIN_HEIGHT, cellH); | |||
| }); | |||
| const laneTops: number[] = []; | |||
| let y = HEADER_HEIGHT; | |||
| for (let i = 0; i < laneCount; i++) { | |||
| laneTops[i] = y; | |||
| y += laneHeights[i] ?? LANE_MIN_HEIGHT; | |||
| if (i < laneCount - 1) y += LANE_GAP; | |||
| } | |||
| return { laneTops, laneHeights, totalHeight: y + 16 }; | |||
| }; | |||
| @@ -0,0 +1,223 @@ | |||
| import { TraceGraphNodeKind } from "./buildTraceGraphNodes"; | |||
| import { TraceGraphPhase } from "./traceGraphLayout"; | |||
| /** | |||
| * Palette aligned with phase/category so kinds in different lanes do not all | |||
| * collapse onto the same chip color (outbound vs pick vs scrap vs fail). | |||
| */ | |||
| export const kindColor = ( | |||
| kind: TraceGraphNodeKind, | |||
| ): | |||
| | "success" | |||
| | "warning" | |||
| | "info" | |||
| | "secondary" | |||
| | "default" | |||
| | "primary" | |||
| | "error" => { | |||
| switch (kind) { | |||
| case "PURCHASE": | |||
| case "RECEIPT": | |||
| case "MATERIAL_IN": | |||
| case "OPEN": | |||
| case "IN": | |||
| return "success"; | |||
| case "QC": | |||
| case "MATERIAL_QC": | |||
| return "info"; | |||
| case "FAIL": | |||
| return "error"; | |||
| case "MATERIAL_PICK": | |||
| case "PICK_GROUP": | |||
| return "secondary"; | |||
| case "PRODUCTION_STEP": | |||
| case "JO_CREATED": | |||
| case "BYPRODUCT": | |||
| return "primary"; | |||
| case "SCRAP": | |||
| return "warning"; | |||
| case "DEFECT": | |||
| case "EXPIRED": | |||
| return "error"; | |||
| case "PUTAWAY": | |||
| case "TRANSFER": | |||
| case "REPACK": | |||
| return "primary"; | |||
| case "STOCK_TAKE": | |||
| return "secondary"; | |||
| case "ADJUSTMENT": | |||
| case "DEPLETED": | |||
| return "default"; | |||
| case "DO_OUT": | |||
| case "DO_GROUP": | |||
| case "REPLENISHMENT_CREATED": | |||
| case "OUT": | |||
| return "warning"; | |||
| case "JO_OUT": | |||
| case "PO_OUT": | |||
| return "secondary"; | |||
| case "RETURN": | |||
| return "error"; | |||
| default: | |||
| return "default"; | |||
| } | |||
| }; | |||
| export const kindLabelKey = (kind: TraceGraphNodeKind): string => { | |||
| switch (kind) { | |||
| case "MATERIAL_IN": | |||
| return "nodeMaterialIn"; | |||
| case "JO_CREATED": | |||
| return "nodeJoCreated"; | |||
| case "MATERIAL_QC": | |||
| return "tabQc"; | |||
| case "MATERIAL_PICK": | |||
| return "nodeMaterialPick"; | |||
| case "PRODUCTION_STEP": | |||
| return "nodeProductionStep"; | |||
| case "BYPRODUCT": | |||
| return "nodeByproduct"; | |||
| case "SCRAP": | |||
| return "nodeScrap"; | |||
| case "DEFECT": | |||
| return "nodeDefect"; | |||
| case "OPEN": | |||
| return "nodeOpen"; | |||
| case "FAIL": | |||
| return "nodeFail"; | |||
| case "DO_OUT": | |||
| return "nodeDoOut"; | |||
| case "REPLENISHMENT_CREATED": | |||
| return "nodeReplenishmentCreated"; | |||
| case "JO_OUT": | |||
| return "nodeJoOut"; | |||
| case "PO_OUT": | |||
| return "nodePoOut"; | |||
| case "DO_GROUP": | |||
| return "nodeDoGroup"; | |||
| case "PICK_GROUP": | |||
| return "nodePickGroup"; | |||
| case "RETURN": | |||
| return "nodeReturn"; | |||
| case "REPACK": | |||
| return "nodeRepack"; | |||
| case "PURCHASE": | |||
| return "nodePurchase"; | |||
| case "RECEIPT": | |||
| return "nodeReceipt"; | |||
| case "PUTAWAY": | |||
| return "nodePutaway"; | |||
| case "IN": | |||
| return "directionIn"; | |||
| case "OUT": | |||
| return "directionOut"; | |||
| case "QC": | |||
| return "tabQc"; | |||
| case "STOCK_TAKE": | |||
| return "nodeStockTake"; | |||
| case "ADJUSTMENT": | |||
| return "nodeAdjustment"; | |||
| case "TRANSFER": | |||
| return "nodeTransfer"; | |||
| case "EXPIRED": | |||
| return "nodeExpired"; | |||
| case "DEPLETED": | |||
| return "nodeDepleted"; | |||
| default: | |||
| return "type"; | |||
| } | |||
| }; | |||
| export const phaseLabelKey = (phase: TraceGraphPhase): string => { | |||
| switch (phase) { | |||
| case "MATERIAL_PICK": | |||
| return "phaseMaterialPick"; | |||
| case "PRODUCTION": | |||
| return "phaseProduction"; | |||
| case "PURCHASE": | |||
| return "phasePurchase"; | |||
| case "INBOUND": | |||
| return "phaseInbound"; | |||
| case "QC": | |||
| return "phaseQc"; | |||
| case "PUTAWAY": | |||
| return "phasePutaway"; | |||
| case "WAREHOUSE": | |||
| return "phaseWarehouse"; | |||
| case "OUTBOUND": | |||
| return "phaseOutbound"; | |||
| case "STOCK_TAKE": | |||
| return "phaseStockTake"; | |||
| default: | |||
| return "type"; | |||
| } | |||
| }; | |||
| export const phaseAccent = (phase: TraceGraphPhase): string => { | |||
| switch (phase) { | |||
| case "MATERIAL_PICK": | |||
| return "#f9a825"; | |||
| case "PRODUCTION": | |||
| return "#6a1b9a"; | |||
| case "PURCHASE": | |||
| return "#33691e"; | |||
| case "INBOUND": | |||
| return "#2e7d32"; | |||
| case "QC": | |||
| return "#0288d1"; | |||
| case "PUTAWAY": | |||
| return "#00897b"; | |||
| case "WAREHOUSE": | |||
| return "#7b1fa2"; | |||
| case "OUTBOUND": | |||
| return "#ed6c02"; | |||
| case "STOCK_TAKE": | |||
| return "#5c6bc0"; | |||
| default: | |||
| return "#757575"; | |||
| } | |||
| }; | |||
| /** MUI Chip color for a phase — matches the node chips typically shown in that lane. */ | |||
| export const phaseChipColor = ( | |||
| phase: TraceGraphPhase, | |||
| ): | |||
| | "success" | |||
| | "warning" | |||
| | "info" | |||
| | "secondary" | |||
| | "default" | |||
| | "primary" | |||
| | "error" => { | |||
| switch (phase) { | |||
| case "PURCHASE": | |||
| case "INBOUND": | |||
| return "success"; | |||
| case "QC": | |||
| return "info"; | |||
| case "PUTAWAY": | |||
| case "PRODUCTION": | |||
| case "WAREHOUSE": | |||
| return "primary"; | |||
| case "MATERIAL_PICK": | |||
| case "STOCK_TAKE": | |||
| return "secondary"; | |||
| case "OUTBOUND": | |||
| return "warning"; | |||
| default: | |||
| return "default"; | |||
| } | |||
| }; | |||
| export const minimapNodeColor = (kind: TraceGraphNodeKind): string => { | |||
| const map: Record<string, string> = { | |||
| success: "#2e7d32", | |||
| warning: "#ed6c02", | |||
| info: "#0288d1", | |||
| secondary: "#5c6bc0", | |||
| primary: "#7b1fa2", | |||
| default: "#9e9e9e", | |||
| error: "#d32f2f", | |||
| }; | |||
| return map[kindColor(kind)] ?? "#9e9e9e"; | |||
| }; | |||
| @@ -0,0 +1,291 @@ | |||
| import { TFunction } from "i18next"; | |||
| import { createTraceLabelTranslator } from "./traceLabelUtils"; | |||
| import { formatQty } from "./traceQtyUtils"; | |||
| import type { JoPreludeGraphLabels } from "./buildJoPreludeGraphNodes"; | |||
| import type { ProductionGraphLabels } from "./buildProductionGraphNodes"; | |||
| import type { ExtendedTraceGraphLabels } from "./buildExtendedTraceGraphNodes"; | |||
| import type { TraceGraphDetailLabels } from "./buildTraceGraphNodes"; | |||
| export type TraceGraphCompileLabels = TraceGraphDetailLabels & | |||
| JoPreludeGraphLabels & | |||
| ProductionGraphLabels & | |||
| ExtendedTraceGraphLabels & { | |||
| flowDoGroupTitle?: (count: number) => string; | |||
| flowPickGroupTitle?: (pickOrderCode: string, count: number) => string; | |||
| }; | |||
| export const buildTraceGraphLabels = ( | |||
| t: TFunction, | |||
| lotUom: string, | |||
| ): TraceGraphCompileLabels => { | |||
| const tr = createTraceLabelTranslator(t); | |||
| return { | |||
| tr, | |||
| nodeQcPass: t("nodeQcPass"), | |||
| nodeQcFail: t("nodeQcFail"), | |||
| nodeReceipt: t("nodeReceipt"), | |||
| nodePurchase: t("nodePurchase"), | |||
| nodePutaway: t("nodePutaway"), | |||
| nodePutawayTransfer: t("nodePutawayTransfer"), | |||
| putawayTransferDetail: t("putawayTransferInboundDetail"), | |||
| nodeStockTake: t("nodeStockTake"), | |||
| nodeAdjustment: t("nodeAdjustment"), | |||
| nodeTransfer: t("nodeTransfer"), | |||
| nodeMaterialIn: t("nodeMaterialIn"), | |||
| nodeJoCreated: t("nodeJoCreated"), | |||
| nodeMaterialPick: t("nodeMaterialPick"), | |||
| nodeProductionStep: t("nodeProductionStep"), | |||
| nodeByproduct: t("nodeByproduct"), | |||
| nodeScrap: t("nodeScrap"), | |||
| nodeDefect: t("nodeDefect"), | |||
| detailProcessOutputQty: t("processOutputQty"), | |||
| detailProcessScrapQty: t("processScrapQty"), | |||
| detailProcessDefectQty: t("processDefectQty"), | |||
| nodeOpen: t("nodeOpen"), | |||
| nodeFail: t("nodeFail"), | |||
| nodeDoOut: t("nodeDoOut"), | |||
| nodeReplenishmentCreated: t("nodeReplenishmentCreated"), | |||
| nodeJoOut: t("nodeJoOut"), | |||
| nodePoOut: t("nodePoOut"), | |||
| doOutboundExtra: t("doOutboundExtra"), | |||
| doOutboundReplenish: t("doOutboundReplenish"), | |||
| detailDoOutboundKind: t("detailDoOutboundKind"), | |||
| flowDoGroupTitle: (count: number) => t("flowDoGroupTitle", { count }), | |||
| flowPickGroupTitle: (pickOrderCode: string, count: number) => | |||
| t("flowPickGroupTitle", { pickOrderCode, count }), | |||
| nodeReturn: t("nodeReturn"), | |||
| nodeRepack: t("nodeRepack"), | |||
| traceRepackLot: t("traceRepackLot"), | |||
| nodeExpired: t("nodeExpired"), | |||
| nodeDepleted: t("nodeDepleted"), | |||
| directionIn: t("directionIn"), | |||
| directionOut: t("directionOut"), | |||
| formatQcSubtitle: (failQty: number, acceptedQty: number) => | |||
| `${t("failQty")}: ${formatQty(failQty, lotUom)} / ${t("acceptedQty")}: ${formatQty(acceptedQty, lotUom)}`, | |||
| detailQty: t("qty"), | |||
| detailTime: t("timestamp"), | |||
| detailHandler: t("handler"), | |||
| detailStockTaker: t("stockTaker"), | |||
| detailApprover: t("approver"), | |||
| detailWarehouse: t("from"), | |||
| detailRemarks: t("remarks"), | |||
| detailFailCategory: t("detailFailCategory"), | |||
| detailProcessDescription: t("detailProcessDescription"), | |||
| detailType: t("type"), | |||
| detailRef: t("ref"), | |||
| detailStockTakeCode: t("stockTakeCode"), | |||
| detailAdjustmentRef: t("detailAdjustmentRef"), | |||
| detailReplenishmentCode: t("detailReplenishmentCode"), | |||
| detailReason: t("detailReason"), | |||
| detailReturnRef: t("detailReturnRef"), | |||
| detailSourceDoc: t("detailSourceDoc"), | |||
| detailPurchaseOrderNo: t("detailPurchaseOrderNo"), | |||
| detailInboundRef: t("detailInboundRef"), | |||
| detailInboundSiNo: t("detailInboundSiNo"), | |||
| detailPutawayBin: t("detailPutawayBin"), | |||
| detailDirection: t("detailDirection"), | |||
| detailStatus: t("status"), | |||
| detailSupplier: t("supplier"), | |||
| detailMaterial: t("material"), | |||
| detailItemCode: t("detailItemCode"), | |||
| detailItemName: t("detailItemName"), | |||
| detailLot: t("lotNo"), | |||
| detailAcceptedQty: t("acceptedQty"), | |||
| detailFailQty: t("failQty"), | |||
| detailQcCriteria: t("detailQcCriteria"), | |||
| detailQcType: t("detailQcType"), | |||
| detailQcUnknownItem: t("detailQcUnknownItem"), | |||
| detailFrom: t("from"), | |||
| detailTo: t("to"), | |||
| detailVariance: t("variance"), | |||
| detailBefore: t("before"), | |||
| detailAfter: t("after"), | |||
| detailStockTakeFirstCount: t("stockTakeStageFirstCount"), | |||
| detailStockTakeSecondCount: t("stockTakeStageSecondCount"), | |||
| detailStockTakeApproverCount: t("stockTakeStageApproverCount"), | |||
| detailScrapQty: t("scrapQty"), | |||
| detailDefectQty: t("defectQty"), | |||
| detailEquipment: t("equipment"), | |||
| detailProcessStep: t("processStep"), | |||
| detailStepMaterials: t("detailStepMaterials"), | |||
| detailAssignedStep: t("detailAssignedStep"), | |||
| detailPickTargetDate: t("detailPickTargetDate"), | |||
| detailProductLotNo: t("productLotNo"), | |||
| deliveryOrder: t("deliveryOrder"), | |||
| deliveryNoteCode: t("deliveryNoteCode"), | |||
| ticketNo: t("ticketNo"), | |||
| pickOrder: t("pickOrder"), | |||
| jobOrder: t("jobOrder"), | |||
| expiryDate: t("expiryDate"), | |||
| totalAvailable: t("totalAvailable"), | |||
| itemCode: t("itemCode"), | |||
| detailOrderQty: t("detailOrderQty"), | |||
| detailPutAwayQty: t("detailPutAwayQty"), | |||
| detailPurchaseUnit: t("detailPurchaseUnit"), | |||
| detailSupplyTo: t("detailSupplyTo"), | |||
| detailStockTakeRound: t("detailStockTakeRound"), | |||
| detailStockTakeSection: t("detailStockTakeSection"), | |||
| detailLocation: t("detailLocation"), | |||
| categoryPurchase: t("categoryPurchase"), | |||
| categoryProduction: t("categoryProduction"), | |||
| categoryInbound: t("phaseInbound"), | |||
| categoryReceipt: t("categoryReceipt"), | |||
| categoryPutaway: t("phasePutaway"), | |||
| categoryTransfer: t("phaseWarehouse"), | |||
| categoryStockTake: t("phaseStockTake"), | |||
| categoryOpen: t("categoryOpen"), | |||
| categoryAdjustment: t("nodeAdjustment"), | |||
| categoryPick: t("phaseMaterialPick"), | |||
| categoryQc: t("phaseQc"), | |||
| categoryOutbound: t("phaseOutbound"), | |||
| categoryTerminal: t("categoryTerminal"), | |||
| processingStatus: t("processingStatus"), | |||
| matchStatus: t("matchStatus"), | |||
| }; | |||
| }; | |||
| /** Minimal labels for unit tests (no i18n). */ | |||
| export const createTestTraceGraphLabels = (): TraceGraphCompileLabels => { | |||
| const identity = (s: string | null | undefined) => s?.trim() || "—"; | |||
| const tr = { | |||
| refType: identity, | |||
| movementType: identity, | |||
| direction: (c: string | null | undefined) => | |||
| c?.toUpperCase() === "OUT" ? "Out" : c?.toUpperCase() === "IN" ? "In" : identity(c), | |||
| stockInStatus: identity, | |||
| adjustmentType: identity, | |||
| usageType: identity, | |||
| failType: identity, | |||
| productionStatus: identity, | |||
| pickStatus: identity, | |||
| joStatus: identity, | |||
| qcType: () => "IQC", | |||
| qcPassed: (p: boolean) => (p ? "Pass" : "Fail"), | |||
| putawayShelfStatus: (phase: "pending" | "completed") => | |||
| phase === "completed" ? "Put away done" : "Pending put-away", | |||
| processingStatus: identity, | |||
| matchStatus: identity, | |||
| lotLineStatus: identity, | |||
| }; | |||
| return { | |||
| tr, | |||
| nodeQcPass: "QC Pass", | |||
| nodeQcFail: "QC Fail", | |||
| nodeReceipt: "Receipt", | |||
| nodePurchase: "Purchase", | |||
| nodePutaway: "Putaway", | |||
| nodePutawayTransfer: "Transfer inbound", | |||
| putawayTransferDetail: "Inbound at target warehouse after transfer (auto-completed, not PO put-away queue)", | |||
| nodeStockTake: "Stock take", | |||
| nodeAdjustment: "Adjustment", | |||
| nodeTransfer: "Transfer", | |||
| nodeMaterialIn: "Material in", | |||
| nodeJoCreated: "JO created", | |||
| nodeMaterialPick: "Material pick", | |||
| nodeProductionStep: "Production step", | |||
| nodeByproduct: "Byproduct", | |||
| nodeScrap: "Scrap", | |||
| nodeDefect: "Defect", | |||
| detailProcessOutputQty: "Process output", | |||
| detailProcessScrapQty: "Scrap quantity", | |||
| detailProcessDefectQty: "Defect quantity", | |||
| nodeOpen: "Opening", | |||
| nodeFail: "Pick fail", | |||
| nodeDoOut: "DO PO", | |||
| nodeReplenishmentCreated: "Replenishment created", | |||
| nodeJoOut: "JO PO", | |||
| nodePoOut: "PO pick", | |||
| doOutboundExtra: "Add-on", | |||
| doOutboundReplenish: "Replenishment", | |||
| detailDoOutboundKind: "Outbound type", | |||
| flowDoGroupTitle: (count) => `DO × ${count}`, | |||
| flowPickGroupTitle: (code, count) => `Pick · ${code} × ${count}`, | |||
| nodeReturn: "Return", | |||
| nodeRepack: "Repack", | |||
| traceRepackLot: "Trace lot", | |||
| nodeExpired: "Expired", | |||
| nodeDepleted: "Depleted", | |||
| directionIn: "In", | |||
| directionOut: "Out", | |||
| formatQcSubtitle: (f, a) => `Fail ${f} / Accepted ${a}`, | |||
| detailQty: "Qty", | |||
| detailTime: "Time", | |||
| detailHandler: "Handler", | |||
| detailStockTaker: "First counter", | |||
| detailApprover: "Approver", | |||
| detailWarehouse: "Warehouse", | |||
| detailRemarks: "Remarks", | |||
| detailFailCategory: "Fail category", | |||
| detailProcessDescription: "Step description", | |||
| detailType: "Type", | |||
| detailRef: "Doc no.", | |||
| detailStockTakeCode: "Stock Take #", | |||
| detailAdjustmentRef: "Adjustment #", | |||
| detailReplenishmentCode: "Replenishment #", | |||
| detailReason: "Reason", | |||
| detailReturnRef: "Return #", | |||
| detailSourceDoc: "Source doc #", | |||
| detailPurchaseOrderNo: "PO no.", | |||
| detailInboundRef: "Inbound SI", | |||
| detailInboundSiNo: "Inbound SI no.", | |||
| detailPutawayBin: "Bin", | |||
| detailDirection: "Direction", | |||
| detailStatus: "Status", | |||
| detailSupplier: "Supplier", | |||
| detailMaterial: "Material", | |||
| detailItemCode: "Item code", | |||
| detailItemName: "Item name", | |||
| detailLot: "Lot", | |||
| detailAcceptedQty: "Accepted", | |||
| detailFailQty: "Fail", | |||
| detailQcCriteria: "Criteria", | |||
| detailQcType: "QC type", | |||
| detailQcUnknownItem: "Unknown", | |||
| detailFrom: "From", | |||
| detailTo: "To", | |||
| detailVariance: "Variance", | |||
| detailBefore: "Book qty", | |||
| detailAfter: "Accepted qty", | |||
| detailStockTakeFirstCount: "First count", | |||
| detailStockTakeSecondCount: "Re-count", | |||
| detailStockTakeApproverCount: "Approver count", | |||
| detailScrapQty: "Scrap", | |||
| detailDefectQty: "Defect", | |||
| detailEquipment: "Equipment", | |||
| detailProcessStep: "Step", | |||
| detailStepMaterials: "Step materials", | |||
| detailAssignedStep: "Assigned step", | |||
| detailPickTargetDate: "Target date", | |||
| detailProductLotNo: "Product lot", | |||
| deliveryOrder: "DO", | |||
| deliveryNoteCode: "DN", | |||
| ticketNo: "Ticket", | |||
| pickOrder: "Pick", | |||
| jobOrder: "Job order", | |||
| expiryDate: "Expiry", | |||
| totalAvailable: "Available", | |||
| itemCode: "Item code", | |||
| detailOrderQty: "Order qty", | |||
| detailPutAwayQty: "Putaway qty", | |||
| detailPurchaseUnit: "Purchase unit", | |||
| detailSupplyTo: "Supply to", | |||
| detailStockTakeRound: "Stock take round", | |||
| detailStockTakeSection: "Stock take section", | |||
| detailLocation: "Location", | |||
| categoryPurchase: "Purchase", | |||
| categoryProduction: "Production", | |||
| categoryInbound: "Inbound", | |||
| categoryReceipt: "Receipt", | |||
| categoryPutaway: "Putaway", | |||
| categoryTransfer: "Warehouse", | |||
| categoryStockTake: "Stock take", | |||
| categoryOpen: "Opening", | |||
| categoryAdjustment: "Adjustment", | |||
| categoryPick: "Pick", | |||
| categoryQc: "QC", | |||
| categoryOutbound: "Outbound", | |||
| categoryTerminal: "Terminal", | |||
| processingStatus: "Processing status", | |||
| matchStatus: "Match status", | |||
| }; | |||
| }; | |||
| @@ -0,0 +1,402 @@ | |||
| import dayjs from "dayjs"; | |||
| import { ItemLotTraceResponse } from "@/app/api/itemTracing"; | |||
| import { COLUMN_WIDTH_MIN } from "./traceFlowConstants"; | |||
| import { | |||
| buildTraceGraphNodes, | |||
| TraceGraphDetailLabels, | |||
| TraceGraphNode, | |||
| } from "./buildTraceGraphNodes"; | |||
| import { buildJoPreludeGraphNodes, JoPreludeGraphLabels } from "./buildJoPreludeGraphNodes"; | |||
| import { | |||
| buildProductionGraphNodes, | |||
| ProductionGraphLabels, | |||
| } from "./buildProductionGraphNodes"; | |||
| import { | |||
| buildExtendedTraceGraphNodes, | |||
| ExtendedTraceGraphLabels, | |||
| } from "./buildExtendedTraceGraphNodes"; | |||
| import { buildAllLocationBlockGraphNodes } from "./buildLocationBlockGraphNodes"; | |||
| import { applyFlowNodeGrouping, isDoGroupChild, regroupLayoutCells } from "./traceDoGroupLayout"; | |||
| import { cellWidthRequirement } from "./traceFlowLayout"; | |||
| import { | |||
| materialInputsHaveProduction, | |||
| phaseFromKind, | |||
| sortNodesInPhase, | |||
| sortPhasesByEarliestEvent, | |||
| } from "./traceGraphSemantics"; | |||
| export { | |||
| buildTraceFlowEdgePairs, | |||
| materialInputsHaveProduction, | |||
| phaseFromKind, | |||
| sortNodesInPhase, | |||
| sortPhasesByEarliestEvent, | |||
| } from "./traceGraphSemantics"; | |||
| export type PreProductionPhase = "MATERIAL_PICK"; | |||
| export type ProductionPhase = "PRODUCTION"; | |||
| export type FgTraceGraphPhase = | |||
| | "PURCHASE" | |||
| | "INBOUND" | |||
| | "QC" | |||
| | "PUTAWAY" | |||
| | "WAREHOUSE" | |||
| | "OUTBOUND" | |||
| | "STOCK_TAKE"; | |||
| /** @deprecated MATERIAL_QC merged into QC lane when prelude is shown */ | |||
| export type LegacyMaterialQcPhase = "MATERIAL_QC"; | |||
| export type TraceGraphPhase = PreProductionPhase | ProductionPhase | FgTraceGraphPhase; | |||
| export const PRE_PRODUCTION_PHASES: PreProductionPhase[] = ["MATERIAL_PICK"]; | |||
| export const FG_PHASE_ORDER: FgTraceGraphPhase[] = [ | |||
| "PURCHASE", | |||
| "INBOUND", | |||
| "QC", | |||
| "PUTAWAY", | |||
| "WAREHOUSE", | |||
| "STOCK_TAKE", | |||
| "OUTBOUND", | |||
| ]; | |||
| /** Prelude rows use one shared QC lane (原料 + 成品品檢). */ | |||
| export const PRELUDE_PHASE_ORDER = ( | |||
| hasProduction: boolean, | |||
| ): TraceGraphPhase[] => { | |||
| const phases: TraceGraphPhase[] = [ | |||
| "PURCHASE", | |||
| "INBOUND", | |||
| "QC", | |||
| "PUTAWAY", | |||
| "MATERIAL_PICK", | |||
| ]; | |||
| if (hasProduction) phases.push("PRODUCTION"); | |||
| phases.push("WAREHOUSE", "STOCK_TAKE", "OUTBOUND"); | |||
| return phases; | |||
| }; | |||
| export const getPhaseOrder = (hasPrelude: boolean, hasProduction: boolean): TraceGraphPhase[] => { | |||
| if (hasPrelude) return PRELUDE_PHASE_ORDER(hasProduction); | |||
| const phases: TraceGraphPhase[] = [...FG_PHASE_ORDER]; | |||
| if (hasProduction) phases.splice(1, 0, "PRODUCTION"); | |||
| return phases; | |||
| }; | |||
| /** @deprecated use layout.phaseOrder */ | |||
| export const PHASE_ORDER: TraceGraphPhase[] = FG_PHASE_ORDER; | |||
| /** @deprecated use layout.laneCount */ | |||
| export const LANE_COUNT = FG_PHASE_ORDER.length; | |||
| export interface TraceGraphLayoutNode extends TraceGraphNode { | |||
| phase: TraceGraphPhase; | |||
| column: number; | |||
| dayKey: string; | |||
| laneIndex: number; | |||
| sequenceIndex: number; | |||
| branchIndex: number; | |||
| branchSize: number; | |||
| /** Slot index within the day column (one dynamic phase slot per phase on that day). */ | |||
| dayPhaseStaggerIndex: number; | |||
| } | |||
| export interface DayColumnLayout { | |||
| columnStarts: number[]; | |||
| columnWidths: number[]; | |||
| /** Width of each phase slot within a calendar day (same order as phases on that day). */ | |||
| phaseSlotWidths: number[][]; | |||
| /** Start X offset of each phase slot within a calendar day. */ | |||
| phaseSlotStarts: number[][]; | |||
| timelineWidth: number; | |||
| } | |||
| export interface TraceGraphLayout { | |||
| nodes: TraceGraphLayoutNode[]; | |||
| columnCount: number; | |||
| columnDates: string[]; | |||
| dayColumns: DayColumnLayout; | |||
| cellGroups: TraceGraphLayoutNode[][]; | |||
| phaseOrder: TraceGraphPhase[]; | |||
| laneCount: number; | |||
| hasPrelude: boolean; | |||
| hasProduction: boolean; | |||
| } | |||
| export const laneIndexFromPhase = (phase: TraceGraphPhase, phaseOrder: TraceGraphPhase[]): number => | |||
| phaseOrder.indexOf(phase); | |||
| const cellKey = (column: number, laneIndex: number) => `${column}:${laneIndex}`; | |||
| export const dayKeyFromTimestamp = (timestamp: string | null): string => { | |||
| if (!timestamp?.trim()) return "—"; | |||
| const d = dayjs(timestamp); | |||
| if (!d.isValid()) return timestamp.slice(0, 10) || "—"; | |||
| return d.format("YYYY-MM-DD"); | |||
| }; | |||
| export const phasesOnDay = ( | |||
| nodes: TraceGraphLayoutNode[], | |||
| dayKey: string, | |||
| phaseOrder: TraceGraphPhase[], | |||
| ): TraceGraphPhase[] => { | |||
| const present = new Set<TraceGraphPhase>(); | |||
| nodes.forEach((n) => { | |||
| if (n.dayKey === dayKey) present.add(n.phase); | |||
| }); | |||
| return sortPhasesByEarliestEvent(nodes, Array.from(present), phaseOrder, dayKey); | |||
| }; | |||
| /** Widen each calendar day by chronological time slots (not phase-packed slots). */ | |||
| export const computeDayColumnLayout = ( | |||
| columnDates: string[], | |||
| nodes: TraceGraphLayoutNode[], | |||
| _phaseOrder: TraceGraphPhase[], | |||
| ): DayColumnLayout => { | |||
| const visible = nodes.filter((n) => !isDoGroupChild(n)); | |||
| const phaseSlotWidths: number[][] = []; | |||
| const phaseSlotStarts: number[][] = []; | |||
| const columnWidths: number[] = []; | |||
| columnDates.forEach((dayKey, colIndex) => { | |||
| const dayNodes = visible.filter((n) => n.column === colIndex && n.dayKey === dayKey); | |||
| const maxSlot = dayNodes.reduce((m, n) => Math.max(m, n.dayPhaseStaggerIndex), 0); | |||
| const slotCount = dayNodes.length === 0 ? 1 : maxSlot + 1; | |||
| const slotWidths: number[] = []; | |||
| for (let slot = 0; slot < slotCount; slot++) { | |||
| const members = dayNodes.filter((n) => n.dayPhaseStaggerIndex === slot); | |||
| slotWidths.push(cellWidthRequirement(members)); | |||
| } | |||
| const starts: number[] = []; | |||
| let dayWidth = 0; | |||
| slotWidths.forEach((w) => { | |||
| starts.push(dayWidth); | |||
| dayWidth += w; | |||
| }); | |||
| phaseSlotWidths.push(slotWidths); | |||
| phaseSlotStarts.push(starts); | |||
| columnWidths.push(Math.max(COLUMN_WIDTH_MIN, dayWidth)); | |||
| }); | |||
| const columnStarts: number[] = []; | |||
| let x = 0; | |||
| for (const w of columnWidths) { | |||
| columnStarts.push(x); | |||
| x += w; | |||
| } | |||
| return { columnStarts, columnWidths, phaseSlotWidths, phaseSlotStarts, timelineWidth: x }; | |||
| }; | |||
| /** | |||
| * Within each calendar day, assign left→right slots by event time (sortKey). | |||
| * Avoids packing an entire phase to the left of another phase when later events | |||
| * in the early phase happen after earlier events in a later phase. | |||
| */ | |||
| export const assignChronologicalDaySlots = (nodes: TraceGraphLayoutNode[]): void => { | |||
| const byDay = new Map<string, TraceGraphLayoutNode[]>(); | |||
| nodes.forEach((n) => { | |||
| if (isDoGroupChild(n)) return; | |||
| const list = byDay.get(n.dayKey) ?? []; | |||
| list.push(n); | |||
| byDay.set(n.dayKey, list); | |||
| }); | |||
| byDay.forEach((dayNodes) => { | |||
| dayNodes.sort((a, b) => { | |||
| if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey; | |||
| // Same timestamp: putaway before material ADJUSTMENT only (上架 → 庫存調整). | |||
| if ( | |||
| (a.kind === "PUTAWAY" || a.kind === "ADJUSTMENT") && | |||
| (b.kind === "PUTAWAY" || b.kind === "ADJUSTMENT") && | |||
| a.kind !== b.kind | |||
| ) { | |||
| return a.kind === "PUTAWAY" ? -1 : 1; | |||
| } | |||
| // 建立工單 before 工單提料 group / lines in the same pick phase. | |||
| const pickLaneOrder = (n: TraceGraphLayoutNode) => | |||
| n.kind === "JO_CREATED" ? 0 : n.kind === "PICK_GROUP" ? 1 : n.kind === "MATERIAL_PICK" ? 2 : 3; | |||
| const pickA = pickLaneOrder(a); | |||
| const pickB = pickLaneOrder(b); | |||
| if (pickA !== pickB) return pickA - pickB; | |||
| // Then canonical phase / lane order (e.g. QC before putaway). | |||
| if (a.laneIndex !== b.laneIndex) return a.laneIndex - b.laneIndex; | |||
| if (a.sequenceIndex !== b.sequenceIndex) return a.sequenceIndex - b.sequenceIndex; | |||
| return a.id.localeCompare(b.id); | |||
| }); | |||
| dayNodes.forEach((n, i) => { | |||
| n.dayPhaseStaggerIndex = i; | |||
| n.branchIndex = 0; | |||
| n.branchSize = 1; | |||
| }); | |||
| }); | |||
| // Group children share the container's horizontal time slot. | |||
| const parentById = new Map(nodes.map((n) => [n.id, n])); | |||
| nodes.forEach((n) => { | |||
| if (!n.doGroupId) return; | |||
| const parent = parentById.get(n.doGroupId); | |||
| if (parent) n.dayPhaseStaggerIndex = parent.dayPhaseStaggerIndex; | |||
| }); | |||
| }; | |||
| const assignColumns = ( | |||
| sorted: TraceGraphNode[], | |||
| phaseOrder: TraceGraphPhase[], | |||
| ): { columnDates: string[]; nodes: TraceGraphLayoutNode[]; cellGroups: TraceGraphLayoutNode[][] } => { | |||
| const dayOrder: string[] = []; | |||
| const columnByDayKey = new Map<string, number>(); | |||
| const withMeta: TraceGraphLayoutNode[] = sorted.map((node, sequenceIndex) => { | |||
| const dayKey = dayKeyFromTimestamp(node.timestamp); | |||
| let column = columnByDayKey.get(dayKey); | |||
| if (column === undefined) { | |||
| column = dayOrder.length; | |||
| dayOrder.push(dayKey); | |||
| columnByDayKey.set(dayKey, column); | |||
| } | |||
| const phase = phaseFromKind(node.kind, phaseOrder, node.traceLotNo, node.refType); | |||
| return { | |||
| ...node, | |||
| phase, | |||
| dayKey, | |||
| column, | |||
| laneIndex: laneIndexFromPhase(phase, phaseOrder), | |||
| sequenceIndex, | |||
| branchIndex: 0, | |||
| branchSize: 1, | |||
| dayPhaseStaggerIndex: 0, | |||
| }; | |||
| }); | |||
| // Provisional phase-based stagger (overwritten after grouping by chronological slots). | |||
| const staggerByDay = new Map<string, TraceGraphPhase[]>(); | |||
| withMeta.forEach((node) => { | |||
| if (!staggerByDay.has(node.dayKey)) { | |||
| staggerByDay.set(node.dayKey, phasesOnDay(withMeta, node.dayKey, phaseOrder)); | |||
| } | |||
| const ordered = staggerByDay.get(node.dayKey)!; | |||
| node.dayPhaseStaggerIndex = Math.max(0, ordered.indexOf(node.phase)); | |||
| }); | |||
| const groups = new Map<string, TraceGraphLayoutNode[]>(); | |||
| withMeta.forEach((node) => { | |||
| const key = cellKey(node.column, node.laneIndex); | |||
| const list = groups.get(key) ?? []; | |||
| list.push(node); | |||
| groups.set(key, list); | |||
| }); | |||
| groups.forEach((list) => { | |||
| list.sort(sortNodesInPhase); | |||
| const size = list.length; | |||
| list.forEach((node, idx) => { | |||
| node.branchIndex = idx; | |||
| node.branchSize = size; | |||
| }); | |||
| }); | |||
| const cellGroups = Array.from(groups.values()).sort((a, b) => { | |||
| const na = a[0]; | |||
| const nb = b[0]; | |||
| if (na.column !== nb.column) return na.column - nb.column; | |||
| return na.laneIndex - nb.laneIndex; | |||
| }); | |||
| return { columnDates: dayOrder, nodes: withMeta, cellGroups }; | |||
| }; | |||
| export const buildTraceGraphLayout = ( | |||
| data: ItemLotTraceResponse, | |||
| labels: TraceGraphDetailLabels & | |||
| Partial<JoPreludeGraphLabels> & | |||
| Partial<ProductionGraphLabels> & | |||
| Partial<ExtendedTraceGraphLabels> & { | |||
| flowDoGroupTitle?: (count: number) => string; | |||
| flowPickGroupTitle?: (pickOrderCode: string, count: number) => string; | |||
| }, | |||
| ): TraceGraphLayout => { | |||
| const hasPrelude = data.joPrelude != null; | |||
| const hasScrap = (data.productionSteps ?? []).some( | |||
| (s) => (s.scrapQty ?? 0) + (s.defectQty ?? 0) > 0, | |||
| ); | |||
| const hasMaterialProduction = | |||
| data.joPrelude != null && materialInputsHaveProduction(data.joPrelude.materialInputs); | |||
| const hasProduction = | |||
| (data.productionSteps?.length ?? 0) > 0 || | |||
| (data.byproductLots?.length ?? 0) > 0 || | |||
| hasScrap || | |||
| hasMaterialProduction; | |||
| const phaseOrder = getPhaseOrder(hasPrelude, hasProduction); | |||
| const mergedMultiLocation = (data.locationBlocks?.length ?? 0) > 0; | |||
| const primaryWh = | |||
| data.warehouseLines.map((w) => w.warehouseCode).filter(Boolean).join(" / ") || undefined; | |||
| const primaryScope = { | |||
| defaultWarehouseCode: primaryWh, | |||
| inventoryLotId: data.lot.inventoryLotId, | |||
| mergedMultiLocation, | |||
| }; | |||
| const fgNodes = buildTraceGraphNodes(data, labels, primaryScope); | |||
| const preludeNodes = | |||
| data.joPrelude && labels.nodeMaterialIn && labels.nodeMaterialPick && labels.nodePurchase | |||
| ? buildJoPreludeGraphNodes(data.joPrelude, labels as JoPreludeGraphLabels) | |||
| : []; | |||
| const productionNodes = | |||
| hasProduction && labels.nodeProductionStep && labels.nodeScrap && labels.nodeDefect | |||
| ? buildProductionGraphNodes( | |||
| data.productionSteps ?? [], | |||
| data.byproductLots ?? [], | |||
| labels as ProductionGraphLabels, | |||
| data.lot.uom, | |||
| ) | |||
| : []; | |||
| const extendedNodes = | |||
| labels.nodeOpen && labels.nodeFail && labels.nodeDoOut | |||
| ? buildExtendedTraceGraphNodes(data, labels as ExtendedTraceGraphLabels, primaryScope) | |||
| : []; | |||
| const locationNodes = | |||
| (data.locationBlocks?.length ?? 0) > 0 && | |||
| labels.nodeOpen && | |||
| labels.nodeFail && | |||
| labels.nodeDoOut | |||
| ? buildAllLocationBlockGraphNodes(data.locationBlocks ?? [], labels as ExtendedTraceGraphLabels) | |||
| : []; | |||
| const merged = [...preludeNodes, ...productionNodes, ...extendedNodes, ...fgNodes, ...locationNodes].sort((a, b) => { | |||
| if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey; | |||
| return a.id.localeCompare(b.id); | |||
| }); | |||
| const { columnDates, nodes: assigned, cellGroups: _initialCellGroups } = assignColumns( | |||
| merged, | |||
| phaseOrder, | |||
| ); | |||
| const groupTitleDo = | |||
| labels.flowDoGroupTitle ?? ((count: number) => `DO × ${count}`); | |||
| const groupTitlePick = | |||
| labels.flowPickGroupTitle ?? | |||
| ((pickOrderCode: string, count: number) => `${pickOrderCode} × ${count}`); | |||
| const nodes = applyFlowNodeGrouping(assigned, { | |||
| flowDoGroupTitle: groupTitleDo, | |||
| flowPickGroupTitle: groupTitlePick, | |||
| }); | |||
| assignChronologicalDaySlots(nodes); | |||
| const cellGroups = regroupLayoutCells(nodes); | |||
| const columnCount = Math.max(columnDates.length, 1); | |||
| const dayColumns = computeDayColumnLayout(columnDates, nodes, phaseOrder); | |||
| return { | |||
| nodes, | |||
| columnCount, | |||
| columnDates, | |||
| dayColumns, | |||
| cellGroups, | |||
| phaseOrder, | |||
| laneCount: phaseOrder.length, | |||
| hasPrelude, | |||
| hasProduction, | |||
| }; | |||
| }; | |||
| @@ -0,0 +1,47 @@ | |||
| import { TraceGraphNodeKind } from "./buildTraceGraphNodes"; | |||
| import { TraceGraphLayoutNode } from "./traceGraphLayout"; | |||
| const normalize = (value: string) => value.trim().toLowerCase(); | |||
| const nodeSearchHaystack = ( | |||
| node: TraceGraphLayoutNode, | |||
| kindLabel: string, | |||
| ): string => { | |||
| const parts: string[] = [ | |||
| node.title, | |||
| node.subtitle, | |||
| node.refCode ?? "", | |||
| node.traceLotNo ?? "", | |||
| node.traceItemCode ?? "", | |||
| node.consoCode ?? "", | |||
| node.meta ?? "", | |||
| node.timestamp ?? "", | |||
| node.categoryLabel ?? "", | |||
| node.refType ?? "", | |||
| node.warehouseCode ?? "", | |||
| node.transferFromWarehouse ?? "", | |||
| node.transferToWarehouse ?? "", | |||
| kindLabel, | |||
| node.qty != null ? String(node.qty) : "", | |||
| ...node.details.flatMap((field) => | |||
| [field.label, field.value, field.linkCode ?? ""].filter(Boolean), | |||
| ), | |||
| ]; | |||
| return normalize(parts.join(" ")); | |||
| }; | |||
| export const searchTraceGraphNodes = ( | |||
| nodes: TraceGraphLayoutNode[], | |||
| query: string, | |||
| kindLabel: (kind: TraceGraphNodeKind) => string, | |||
| ): string[] => { | |||
| const normalizedQuery = normalize(query); | |||
| if (!normalizedQuery) return []; | |||
| return nodes | |||
| .filter((node) => | |||
| nodeSearchHaystack(node, kindLabel(node.kind)).includes(normalizedQuery), | |||
| ) | |||
| .sort((a, b) => a.sortKey - b.sortKey || a.sequenceIndex - b.sequenceIndex) | |||
| .map((node) => node.id); | |||
| }; | |||
| @@ -0,0 +1,192 @@ | |||
| import { TFunction } from "i18next"; | |||
| import { ItemLotTraceQcResult } from "@/app/api/itemTracing"; | |||
| const normCode = (code: string | null | undefined) => (code ?? "").trim(); | |||
| const lookupCode = (t: TFunction, prefix: string, code: string | null | undefined): string => { | |||
| const raw = normCode(code); | |||
| if (!raw) return "—"; | |||
| const variants = [raw, raw.toUpperCase(), raw.toLowerCase()]; | |||
| for (const v of variants) { | |||
| const key = `${prefix}.${v}`; | |||
| const translated = t(key, { defaultValue: "" }); | |||
| if (translated && translated !== key) return translated; | |||
| } | |||
| return raw.replace(/_/g, " "); | |||
| }; | |||
| export type TraceLabelTranslator = { | |||
| refType: (code: string | null | undefined) => string; | |||
| movementType: (code: string | null | undefined) => string; | |||
| direction: (code: string | null | undefined) => string; | |||
| stockInStatus: (code: string | null | undefined) => string; | |||
| adjustmentType: (code: string | null | undefined) => string; | |||
| usageType: (code: string | null | undefined) => string; | |||
| failType: (code: string | null | undefined) => string; | |||
| productionStatus: (code: string | null | undefined) => string; | |||
| pickStatus: (code: string | null | undefined) => string; | |||
| processingStatus: (code: string | null | undefined) => string; | |||
| matchStatus: (code: string | null | undefined) => string; | |||
| lotLineStatus: (code: string | null | undefined) => string; | |||
| joStatus: (code: string | null | undefined) => string; | |||
| qcType: (code: string | null | undefined) => string; | |||
| qcPassed: (passed: boolean) => string; | |||
| putawayShelfStatus: (phase: "pending" | "completed") => string; | |||
| }; | |||
| export const createTraceLabelTranslator = (t: TFunction): TraceLabelTranslator => ({ | |||
| refType: (code) => lookupCode(t, "code.refType", code), | |||
| movementType: (code) => lookupCode(t, "code.movementType", code), | |||
| direction: (code) => { | |||
| const u = normCode(code).toUpperCase(); | |||
| if (u === "IN") return t("directionIn"); | |||
| if (u === "OUT") return t("directionOut"); | |||
| return lookupCode(t, "code.direction", code); | |||
| }, | |||
| stockInStatus: (code) => lookupCode(t, "code.stockInStatus", code), | |||
| adjustmentType: (code) => lookupCode(t, "code.adjustmentType", code), | |||
| usageType: (code) => lookupCode(t, "code.usageType", code), | |||
| failType: (code) => lookupCode(t, "code.failType", code), | |||
| productionStatus: (code) => lookupCode(t, "code.productionStatus", code), | |||
| pickStatus: (code) => lookupCode(t, "code.pickStatus", code), | |||
| processingStatus: (code) => lookupCode(t, "code.processingStatus", code), | |||
| matchStatus: (code) => lookupCode(t, "code.matchStatus", code), | |||
| lotLineStatus: (code) => lookupCode(t, "code.lotLineStatus", code), | |||
| joStatus: (code) => lookupCode(t, "code.joStatus", code), | |||
| qcType: (code) => { | |||
| const raw = normCode(code); | |||
| const u = raw.toUpperCase(); | |||
| if (!raw || u === "QC") return t("code.qcType.IQC"); | |||
| return lookupCode(t, "code.qcType", code); | |||
| }, | |||
| qcPassed: (passed) => (passed ? t("qcPassed") : t("qcFailed")), | |||
| putawayShelfStatus: (phase) => | |||
| phase === "completed" ? t("putawayStatusCompleted") : t("putawayStatusPending"), | |||
| }); | |||
| /** Color for 處理狀態 / 對料狀態 values on flow cards and detail panel. */ | |||
| export type PickStatusValueColor = "success" | "error" | "warning" | "info" | "default"; | |||
| export const pickStatusValueColor = ( | |||
| code: string | null | undefined, | |||
| ): PickStatusValueColor => { | |||
| switch (normCode(code).toLowerCase()) { | |||
| case "completed": | |||
| return "success"; | |||
| case "rejected": | |||
| return "error"; | |||
| case "scanned": | |||
| return "info"; | |||
| case "pending": | |||
| case "created": | |||
| return "warning"; | |||
| default: | |||
| return "default"; | |||
| } | |||
| }; | |||
| export const qcItemShortLabel = (q: ItemLotTraceQcResult): string => | |||
| q.qcItemName?.trim() || q.qcItemCode?.trim() || ""; | |||
| export const qcItemLabel = (q: ItemLotTraceQcResult): string => { | |||
| const name = qcItemShortLabel(q); | |||
| const desc = q.qcItemDescription?.trim(); | |||
| if (name && desc) return `${name}(${desc})`; | |||
| return name || desc || ""; | |||
| }; | |||
| export const groupQcResultsBySession = ( | |||
| results: ItemLotTraceQcResult[], | |||
| ): ItemLotTraceQcResult[][] => { | |||
| const map = new Map<string, ItemLotTraceQcResult[]>(); | |||
| results.forEach((q) => { | |||
| const key = `${q.stockInLineId}:${q.created ?? ""}`; | |||
| const list = map.get(key) ?? []; | |||
| list.push(q); | |||
| map.set(key, list); | |||
| }); | |||
| return Array.from(map.values()); | |||
| }; | |||
| export const buildQcCriteriaLines = ( | |||
| results: ItemLotTraceQcResult[], | |||
| tr: TraceLabelTranslator, | |||
| unknownItemLabel: string, | |||
| failQtyLabel: string, | |||
| ): string => { | |||
| if (results.length === 0) return "—"; | |||
| return results | |||
| .map((q) => { | |||
| const item = qcItemLabel(q) || unknownItemLabel; | |||
| const status = q.qcPassed | |||
| ? tr.qcPassed(true) | |||
| : `${tr.qcPassed(false)}${q.failQty > 0 ? `(${failQtyLabel}: ${q.failQty})` : ""}`; | |||
| return `${item}:${status}`; | |||
| }) | |||
| .join("\n"); | |||
| }; | |||
| export const buildQcSubtitle = ( | |||
| results: ItemLotTraceQcResult[], | |||
| formatQcSubtitle: (failQty: number, acceptedQty: number) => string, | |||
| extra?: string, | |||
| ): string => { | |||
| const first = results[0]; | |||
| const qtyLine = first ? formatQcSubtitle(first.failQty, first.acceptedQty) : ""; | |||
| const criteria = results | |||
| .map((q) => qcItemShortLabel(q)) | |||
| .filter(Boolean) | |||
| .slice(0, 3) | |||
| .join(" · "); | |||
| const parts = [extra, criteria, qtyLine].filter(Boolean); | |||
| return parts.join(" · ") || qtyLine || "—"; | |||
| }; | |||
| export type DoOutboundKindLabels = { | |||
| extra: string; | |||
| replenish: string; | |||
| }; | |||
| /** Detail label for 加單·補貨. */ | |||
| export const formatDoOutboundKindLabel = ( | |||
| isExtra?: boolean, | |||
| isReplenish?: boolean, | |||
| labels?: DoOutboundKindLabels, | |||
| ): string | undefined => { | |||
| if (!labels) return undefined; | |||
| const parts: string[] = []; | |||
| if (isExtra) parts.push(labels.extra); | |||
| if (isReplenish) parts.push(labels.replenish); | |||
| return parts.length > 0 ? parts.join(" · ") : undefined; | |||
| }; | |||
| import { | |||
| resolveTraceDoOutboundIsExtra, | |||
| type TraceDoOutboundExtraSource, | |||
| } from "@/utils/traceDoOutboundExtra"; | |||
| export type DoOutboundChipSource = TraceDoOutboundExtraSource & { | |||
| isReplenish?: boolean; | |||
| }; | |||
| /** Resolve chip flags from API fields, with ticket-type fallback (TI-E / TI-M rules). */ | |||
| export const resolveDoOutboundChipFlags = ( | |||
| source: DoOutboundChipSource, | |||
| ): { isExtra: boolean; isReplenish: boolean } => ({ | |||
| isExtra: resolveTraceDoOutboundIsExtra(source), | |||
| isReplenish: source.isReplenish === true, | |||
| }); | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 23 | v1.0.0 | 2026-07-20 | |||
| * Outbound qty color (DO_OUT / JO_OUT / MATERIAL_PICK): | |||
| * 改數 → orange; else qty > 0 → green; qty ≤ 0 → red. | |||
| */ | |||
| export const resolveDoOutboundQtyColor = ( | |||
| qty: number | null | undefined, | |||
| qtyChanged?: boolean, | |||
| ): "warning.main" | "success.main" | "error.main" | undefined => { | |||
| if (qtyChanged) return "warning.main"; | |||
| if (qty == null || !Number.isFinite(Number(qty))) return undefined; | |||
| return Number(qty) > 0 ? "success.main" : "error.main"; | |||
| }; | |||
| @@ -0,0 +1,14 @@ | |||
| export type TraceNavigateParams = { | |||
| stockInLineId?: number; | |||
| lotNo?: string; | |||
| itemCode?: string; | |||
| }; | |||
| export const buildItemTracingHref = (params: TraceNavigateParams): string => { | |||
| const q = new URLSearchParams(); | |||
| if (params.stockInLineId != null) q.set("stockInLineId", String(params.stockInLineId)); | |||
| if (params.lotNo?.trim()) q.set("lotNo", params.lotNo.trim()); | |||
| if (params.itemCode?.trim()) q.set("itemCode", params.itemCode.trim()); | |||
| const qs = q.toString(); | |||
| return qs ? `/itemTracing?${qs}` : "/itemTracing"; | |||
| }; | |||
| @@ -0,0 +1,213 @@ | |||
| import dayjs from "dayjs"; | |||
| import { ItemLotTraceMaterialInput } from "@/app/api/itemTracing"; | |||
| import { | |||
| TraceGraphDetailField, | |||
| TraceGraphDocLinkKind, | |||
| TraceGraphNode, | |||
| } from "./buildTraceGraphNodes"; | |||
| import { formatQty } from "./traceQtyUtils"; | |||
| import { pickStatusValueColor } from "./traceLabelUtils"; | |||
| import { normalizeTargetDateForLink } from "./traceDocLinkUtils"; | |||
| export type MaterialPickLabels = { | |||
| nodeMaterialPick: string; | |||
| categoryPick: string; | |||
| pickOrder: string; | |||
| jobOrder: string; | |||
| detailMaterial: string; | |||
| detailLot: string; | |||
| detailQty: string; | |||
| detailType: string; | |||
| detailAssignedStep: string; | |||
| detailPickTargetDate: string; | |||
| detailTime: string; | |||
| processingStatus: string; | |||
| matchStatus: string; | |||
| tr: { | |||
| processingStatus: (code: string | null | undefined) => string; | |||
| matchStatus: (code: string | null | undefined) => string; | |||
| }; | |||
| }; | |||
| export const pickStatusDetailFields = ( | |||
| labels: Pick<MaterialPickLabels, "processingStatus" | "matchStatus" | "tr">, | |||
| processingStatus?: string | null, | |||
| matchStatus?: string | null, | |||
| opts?: { always?: boolean }, | |||
| ): TraceGraphDetailField[] => { | |||
| const always = opts?.always === true; | |||
| const rows: TraceGraphDetailField[] = []; | |||
| if (always || processingStatus?.trim()) { | |||
| rows.push( | |||
| field(labels.processingStatus, labels.tr.processingStatus(processingStatus), { | |||
| valueColor: pickStatusValueColor(processingStatus), | |||
| }), | |||
| ); | |||
| } | |||
| if (always || matchStatus?.trim()) { | |||
| rows.push( | |||
| field(labels.matchStatus, labels.tr.matchStatus(matchStatus), { | |||
| valueColor: pickStatusValueColor(matchStatus), | |||
| }), | |||
| ); | |||
| } | |||
| return rows; | |||
| }; | |||
| export const pickStatusNodeLabels = ( | |||
| labels: Pick<MaterialPickLabels, "tr">, | |||
| processingStatus?: string | null, | |||
| matchStatus?: string | null, | |||
| opts?: { always?: boolean }, | |||
| ): Pick< | |||
| TraceGraphNode, | |||
| "processingStatusLabel" | "matchStatusLabel" | "processingStatus" | "matchStatus" | |||
| > => { | |||
| const always = opts?.always === true; | |||
| const showProcessing = always || Boolean(processingStatus?.trim()); | |||
| const showMatch = always || Boolean(matchStatus?.trim()); | |||
| return { | |||
| processingStatusLabel: showProcessing | |||
| ? labels.tr.processingStatus(processingStatus) | |||
| : undefined, | |||
| matchStatusLabel: showMatch ? labels.tr.matchStatus(matchStatus) : undefined, | |||
| processingStatus: showProcessing ? processingStatus?.trim() || undefined : undefined, | |||
| matchStatus: showMatch ? matchStatus?.trim() || undefined : undefined, | |||
| }; | |||
| }; | |||
| export const fmt = (v: string | null | undefined) => (v?.trim() ? v : "—"); | |||
| export const field = ( | |||
| label: string, | |||
| value: string | null | undefined, | |||
| extra?: Partial<TraceGraphDetailField>, | |||
| ): TraceGraphDetailField => ({ | |||
| label, | |||
| value: fmt(value), | |||
| ...extra, | |||
| }); | |||
| /** Omit detail row when value is blank (used for optional remarks, etc.). */ | |||
| export const fieldIf = ( | |||
| label: string, | |||
| value: string | number | null | undefined, | |||
| extra?: Partial<TraceGraphDetailField>, | |||
| ): TraceGraphDetailField | null => { | |||
| const raw = value == null ? "" : String(value).trim(); | |||
| if (!raw) return null; | |||
| return field(label, raw, extra); | |||
| }; | |||
| export const detailsOf = ( | |||
| ...rows: Array<TraceGraphDetailField | null | undefined | false> | |||
| ): TraceGraphDetailField[] => rows.filter((row): row is TraceGraphDetailField => Boolean(row)); | |||
| export const parseSortKey = (ts: string | null | undefined, seq: number): number => { | |||
| if (!ts?.trim()) return seq; | |||
| const d = dayjs(ts); | |||
| return d.isValid() ? d.valueOf() : seq; | |||
| }; | |||
| /** Expiry is date-only in master data; sort at end of that calendar day (23:59:59). */ | |||
| export const parseExpirySortKey = (expiry: string | null | undefined, seq: number): number => { | |||
| if (!expiry?.trim()) return seq; | |||
| const d = dayjs(expiry.trim().slice(0, 10)); | |||
| return d.isValid() ? d.endOf("day").valueOf() : seq; | |||
| }; | |||
| export const docLinkFromOriginType = (type: string): TraceGraphDocLinkKind | undefined => { | |||
| const t = type.toUpperCase(); | |||
| if (t === "PO") return "po"; | |||
| if (t === "JO") return "jo"; | |||
| return undefined; | |||
| }; | |||
| export const docLinkFromRefType = (refType: string): TraceGraphDocLinkKind | undefined => { | |||
| const t = refType.toUpperCase(); | |||
| if (t === "PO") return "po"; | |||
| if (t === "JO") return "jo"; | |||
| if (t === "PICK" || t === "DO") return "pick"; | |||
| if (t === "JO_PICK") return "jodetail"; | |||
| return undefined; | |||
| }; | |||
| /** Semi-finished / nested JO output used as a material for another JO. */ | |||
| export const isJoProducedMaterial = (m: ItemLotTraceMaterialInput): boolean => | |||
| (m.productionSteps?.length ?? 0) > 0 || | |||
| Boolean(m.nestedJoPrelude) || | |||
| m.stockInOrigin?.type?.trim().toUpperCase() === "JO"; | |||
| export type MaterialPickNodeContext = { | |||
| nextSeq: () => number; | |||
| pickOrderTargetDate?: (pickOrderCode: string) => string | undefined; | |||
| }; | |||
| export const createMaterialPickNode = ( | |||
| m: ItemLotTraceMaterialInput, | |||
| index: number, | |||
| labels: MaterialPickLabels, | |||
| ctx: MaterialPickNodeContext, | |||
| feedsProductionScopeLotNo?: string, | |||
| ): TraceGraphNode => { | |||
| const matUom = m.materialUom?.trim() || ""; | |||
| const pickTitle = m.pickOrderCode || labels.nodeMaterialPick; | |||
| const stepLabel = m.assignedStepName?.trim() | |||
| ? m.bomProcessSeqNo != null | |||
| ? `${m.bomProcessSeqNo}. ${m.assignedStepName}` | |||
| : m.assignedStepName | |||
| : ""; | |||
| const itemLabel = [m.materialItemCode, m.materialItemName].filter(Boolean).join(" · "); | |||
| const pickTargetDate = normalizeTargetDateForLink( | |||
| ctx.pickOrderTargetDate?.(m.pickOrderCode ?? ""), | |||
| ); | |||
| const pickedDate = normalizeTargetDateForLink(m.pickedAt); | |||
| const linkTargetDate = pickTargetDate || pickedDate; | |||
| return { | |||
| id: `mat-pick-${index}-${m.pickOrderId}-${m.materialLotNo}-${m.pickedAt}`, | |||
| kind: "MATERIAL_PICK", | |||
| timestamp: m.pickedAt, | |||
| sortKey: parseSortKey(m.pickedAt, ctx.nextSeq()), | |||
| title: pickTitle, | |||
| subtitle: [m.materialLotNo, stepLabel, m.consoCode].filter(Boolean).join(" · "), | |||
| qty: m.materialQty, | |||
| uom: matUom, | |||
| meta: itemLabel || undefined, | |||
| refCode: m.pickOrderCode, | |||
| refId: m.pickOrderId, | |||
| consoCode: m.consoCode, | |||
| jobOrderCode: m.jobOrderCode?.trim() || undefined, | |||
| docLinkKind: m.pickOrderCode ? "jodetail" : undefined, | |||
| docLinkTargetDate: linkTargetDate, | |||
| traceLotNo: m.materialLotNo, | |||
| traceItemCode: m.materialItemCode, | |||
| bomProcessId: m.bomProcessId, | |||
| bomProcessSeqNo: m.bomProcessSeqNo, | |||
| assignedStepName: m.assignedStepName, | |||
| feedsProductionScopeLotNo, | |||
| categoryLabel: labels.categoryPick, | |||
| details: [ | |||
| field(labels.pickOrder, m.pickOrderCode, { | |||
| linkKind: "jodetail", | |||
| linkCode: m.pickOrderCode, | |||
| linkId: m.pickOrderId, | |||
| consoCode: m.consoCode, | |||
| linkTargetDate, | |||
| }), | |||
| field(labels.detailPickTargetDate, pickTargetDate || pickedDate), | |||
| field(labels.detailMaterial, `${m.materialItemCode} · ${m.materialItemName}`), | |||
| field(labels.detailLot, m.materialLotNo), | |||
| field(labels.detailQty, formatQty(m.materialQty, matUom)), | |||
| field(labels.detailType, labels.nodeMaterialPick), | |||
| field(labels.detailAssignedStep, stepLabel || "—"), | |||
| field(labels.jobOrder, m.jobOrderCode, { | |||
| linkKind: "jo", | |||
| linkCode: m.jobOrderCode, | |||
| linkId: m.jobOrderId, | |||
| }), | |||
| ...pickStatusDetailFields(labels, m.processingStatus, m.matchStatus), | |||
| field(labels.detailTime, m.pickedAt), | |||
| ], | |||
| ...pickStatusNodeLabels(labels, m.processingStatus, m.matchStatus), | |||
| }; | |||
| }; | |||
| @@ -0,0 +1,200 @@ | |||
| import type { ItemLotTraceResponse } from "@/app/api/itemTracing"; | |||
| import type { TraceGraphLayoutNode } from "./traceGraphLayout"; | |||
| import type { TraceGraphNodeKind } from "./buildTraceGraphNodes"; | |||
| import { mergeScopedMovements } from "./mergeLocationScopedData"; | |||
| export type TraceMovementRow = { | |||
| id: string; | |||
| direction: string; | |||
| qty: number; | |||
| movementType: string; | |||
| refType: string; | |||
| refCode: string; | |||
| refId: number | null; | |||
| warehouseCode: string; | |||
| handledBy: string; | |||
| timestamp: string | null; | |||
| remarks: string; | |||
| }; | |||
| export type TraceJoPickRow = { | |||
| id: string; | |||
| pickOrderCode: string; | |||
| pickOrderId: number | null; | |||
| consoCode: string; | |||
| materialItemCode: string; | |||
| materialLotNo: string; | |||
| qty: number; | |||
| uom: string; | |||
| pickedAt: string | null; | |||
| bomProcessId: number | null; | |||
| assignedStepName: string; | |||
| /** YYYY-MM-DD for jodetail deep links. */ | |||
| targetDate?: string; | |||
| }; | |||
| export type TraceProductionRow = { | |||
| id: string; | |||
| stepName: string; | |||
| description: string; | |||
| equipmentCode: string; | |||
| equipmentName: string; | |||
| operatorName: string; | |||
| startTime: string | null; | |||
| endTime: string | null; | |||
| outputQty: number; | |||
| scrapQty: number; | |||
| defectQty: number; | |||
| status: string; | |||
| seqNo: number | null; | |||
| bomProcessId: number | null; | |||
| traceLotNo: string | null; | |||
| }; | |||
| const detailValue = (node: TraceGraphLayoutNode, labelIncludes: string): string => { | |||
| const row = node.details.find((d) => d.label.includes(labelIncludes)); | |||
| return row?.value?.trim() || "—"; | |||
| }; | |||
| const movementKinds: TraceGraphNodeKind[] = [ | |||
| "IN", | |||
| "OUT", | |||
| "RECEIPT", | |||
| "OPEN", | |||
| "RETURN", | |||
| "REPACK", | |||
| ]; | |||
| export const deriveMovementRowsFromGraph = ( | |||
| nodes: TraceGraphLayoutNode[], | |||
| data: ItemLotTraceResponse, | |||
| ): TraceMovementRow[] => { | |||
| const fromGraph = nodes | |||
| .filter((n) => movementKinds.includes(n.kind)) | |||
| .map((n) => ({ | |||
| id: n.id, | |||
| direction: n.kind === "OUT" || n.kind === "RETURN" ? "OUT" : "IN", | |||
| qty: n.qty ?? 0, | |||
| movementType: n.subtitle?.trim() || n.kind, | |||
| refType: n.refType?.trim() || "", | |||
| refCode: n.refCode?.trim() || "", | |||
| refId: n.refId ?? null, | |||
| warehouseCode: n.warehouseCode?.trim() || (detailValue(n, "Warehouse") !== "—" ? detailValue(n, "Warehouse") : n.meta?.split(" · ")[1] ?? ""), | |||
| handledBy: detailValue(n, "Handler") !== "—" ? detailValue(n, "Handler") : n.meta?.split(" · ")[0] ?? "", | |||
| timestamp: n.timestamp, | |||
| remarks: detailValue(n, "Remarks"), | |||
| })); | |||
| if (fromGraph.length > 0) return fromGraph; | |||
| return mergeScopedMovements(data).map((m) => ({ | |||
| id: m.rowKey, | |||
| direction: m.direction, | |||
| qty: m.qty, | |||
| movementType: m.movementType, | |||
| refType: m.refType, | |||
| refCode: m.refCode, | |||
| refId: m.refId, | |||
| warehouseCode: m.scopeWarehouseCode, | |||
| handledBy: m.handledBy, | |||
| timestamp: m.timestamp, | |||
| remarks: m.remarks, | |||
| })); | |||
| }; | |||
| export const deriveJoPickRowsFromGraph = ( | |||
| nodes: TraceGraphLayoutNode[], | |||
| fallbackInputs: NonNullable<ItemLotTraceResponse["joPrelude"]>["materialInputs"] | undefined, | |||
| ): TraceJoPickRow[] => { | |||
| const picks = nodes.filter((n) => n.kind === "MATERIAL_PICK"); | |||
| if (picks.length > 0) { | |||
| return picks.map((n) => ({ | |||
| id: n.id, | |||
| pickOrderCode: n.refCode?.trim() || n.title?.trim() || "", | |||
| pickOrderId: n.refId ?? null, | |||
| consoCode: n.consoCode?.trim() || "", | |||
| materialItemCode: n.traceItemCode?.trim() || "", | |||
| materialLotNo: n.traceLotNo?.trim() || "", | |||
| qty: n.qty ?? 0, | |||
| uom: n.uom?.trim() || "", | |||
| pickedAt: n.timestamp, | |||
| bomProcessId: n.bomProcessId ?? null, | |||
| assignedStepName: n.assignedStepName?.trim() || "", | |||
| targetDate: n.docLinkTargetDate?.trim() || undefined, | |||
| })); | |||
| } | |||
| return (fallbackInputs ?? []).map((m, i) => ({ | |||
| id: `pick-api-${i}-${m.pickOrderCode}-${m.materialLotNo}`, | |||
| pickOrderCode: m.pickOrderCode, | |||
| pickOrderId: m.pickOrderId, | |||
| consoCode: m.consoCode, | |||
| materialItemCode: m.materialItemCode, | |||
| materialLotNo: m.materialLotNo, | |||
| qty: m.materialQty, | |||
| uom: m.materialUom ?? "", | |||
| pickedAt: m.pickedAt, | |||
| bomProcessId: m.bomProcessId ?? null, | |||
| assignedStepName: m.assignedStepName ?? "", | |||
| targetDate: undefined, | |||
| })); | |||
| }; | |||
| export const deriveProductionRowsFromGraph = ( | |||
| nodes: TraceGraphLayoutNode[], | |||
| fallback: ItemLotTraceResponse["productionSteps"], | |||
| ): TraceProductionRow[] => { | |||
| const steps = nodes.filter((n) => n.kind === "PRODUCTION_STEP"); | |||
| if (steps.length > 0) { | |||
| return steps.map((n) => ({ | |||
| id: n.id, | |||
| stepName: n.title?.trim() || detailValue(n, "Step"), | |||
| description: detailValue(n, "Remarks"), | |||
| equipmentCode: "", | |||
| equipmentName: detailValue(n, "Equipment"), | |||
| operatorName: detailValue(n, "Handler"), | |||
| startTime: null, | |||
| endTime: n.timestamp, | |||
| outputQty: n.qty ?? 0, | |||
| scrapQty: 0, | |||
| defectQty: 0, | |||
| status: detailValue(n, "Status"), | |||
| seqNo: n.bomProcessSeqNo ?? null, | |||
| bomProcessId: n.bomProcessId ?? null, | |||
| traceLotNo: n.traceLotNo ?? null, | |||
| })); | |||
| } | |||
| return fallback.map((s) => ({ | |||
| id: `prod-api-${s.processLineId}`, | |||
| stepName: s.stepName, | |||
| description: s.description, | |||
| equipmentCode: s.equipmentCode, | |||
| equipmentName: s.equipmentName, | |||
| operatorName: s.operatorName, | |||
| startTime: s.startTime, | |||
| endTime: s.endTime, | |||
| outputQty: s.outputQty, | |||
| scrapQty: s.scrapQty, | |||
| defectQty: s.defectQty, | |||
| status: s.status, | |||
| seqNo: s.seqNo, | |||
| bomProcessId: s.bomProcessId ?? null, | |||
| traceLotNo: null, | |||
| })); | |||
| }; | |||
| export type TracePresentationRows = { | |||
| movements: TraceMovementRow[]; | |||
| joPicks: TraceJoPickRow[]; | |||
| production: TraceProductionRow[]; | |||
| }; | |||
| export const buildTracePresentationRows = ( | |||
| nodes: TraceGraphLayoutNode[], | |||
| data: ItemLotTraceResponse, | |||
| ): TracePresentationRows => ({ | |||
| movements: deriveMovementRowsFromGraph(nodes, data), | |||
| joPicks: deriveJoPickRowsFromGraph(nodes, data.joPrelude?.materialInputs), | |||
| production: deriveProductionRowsFromGraph(nodes, data.productionSteps), | |||
| }); | |||
| @@ -0,0 +1,174 @@ | |||
| import { TraceLabelTranslator } from "./traceLabelUtils"; | |||
| /** Matches PO receiving UI: only `completed` means 已上架. */ | |||
| const COMPLETED_PUTAWAY_STATUSES = new Set(["completed", "complete"]); | |||
| export const normRefType = (refType: string | null | undefined): string => | |||
| (refType ?? "").trim().toUpperCase(); | |||
| const normWhCode = (code: string | null | undefined): string => | |||
| (code ?? "").trim().toUpperCase(); | |||
| /** Match exact warehouse codes or floor vs bin (e.g. 4F ↔ 4F-W401). */ | |||
| export const warehouseCodesMatch = ( | |||
| a: string | null | undefined, | |||
| b: string | null | undefined, | |||
| ): boolean => { | |||
| const x = normWhCode(a); | |||
| const y = normWhCode(b); | |||
| if (!x || !y) return false; | |||
| if (x === y) return true; | |||
| return x.startsWith(`${y}-`) || y.startsWith(`${x}-`); | |||
| }; | |||
| /** Transfer destination stock-in is shown on the TRANSFER node, not as a separate putaway card. */ | |||
| export const isTransferInboundPutaway = (refType: string | null | undefined): boolean => | |||
| normRefType(refType) === "TRANSFER"; | |||
| export type TraceTransferMatchInput = { | |||
| fromWarehouse?: string | null; | |||
| toWarehouse?: string | null; | |||
| qty?: number | null; | |||
| transferCode?: string | null; | |||
| timestamp?: string | null; | |||
| }; | |||
| /** | |||
| * Putaway refCode is often SI-* while transferCode is TR-*; match by code, then to-warehouse + qty/time. | |||
| */ | |||
| export const matchTransferForInboundPutaway = ( | |||
| putaway: { | |||
| warehouseCode?: string | null; | |||
| qty?: number | null; | |||
| refCode?: string | null; | |||
| timestamp?: string | null; | |||
| }, | |||
| transfers: TraceTransferMatchInput[], | |||
| ): TraceTransferMatchInput | undefined => { | |||
| if (!transfers.length) return undefined; | |||
| const ref = (putaway.refCode ?? "").trim(); | |||
| if (ref) { | |||
| const byCode = transfers.find((tr) => (tr.transferCode ?? "").trim() === ref); | |||
| if (byCode) return byCode; | |||
| } | |||
| const toMatched = transfers.filter((tr) => | |||
| warehouseCodesMatch(tr.toWarehouse, putaway.warehouseCode), | |||
| ); | |||
| if (!toMatched.length) return undefined; | |||
| if (toMatched.length === 1) return toMatched[0]; | |||
| const putawayQty = putaway.qty; | |||
| const qtyMatched = | |||
| putawayQty != null | |||
| ? toMatched.filter((tr) => tr.qty != null && Number(tr.qty) === Number(putawayQty)) | |||
| : []; | |||
| const pool = qtyMatched.length > 0 ? qtyMatched : toMatched; | |||
| const putawayTs = Date.parse(putaway.timestamp ?? ""); | |||
| if (!Number.isNaN(putawayTs)) { | |||
| const ranked = pool | |||
| .map((tr) => { | |||
| const ts = Date.parse(tr.timestamp ?? ""); | |||
| return { tr, dist: Number.isNaN(ts) ? Number.POSITIVE_INFINITY : Math.abs(ts - putawayTs) }; | |||
| }) | |||
| .sort((a, b) => a.dist - b.dist); | |||
| if (ranked[0] && Number.isFinite(ranked[0].dist)) return ranked[0].tr; | |||
| } | |||
| return pool[0]; | |||
| }; | |||
| /** | |||
| * Emit a TRANSFER card for each transfer on the primary lot. | |||
| * Location-block scopes skip transfers to avoid duplicating the same TR-* rows. | |||
| */ | |||
| export const shouldEmitTransferForScope = (locationBlockKeys?: boolean): boolean => | |||
| !locationBlockKeys; | |||
| /** | |||
| * Transfer inbound putaways are represented by TRANSFER cards (one per TR-*). | |||
| * Keep 轉倉入庫 putaway cards off so multi-transfer lots do not collapse to one inbound. | |||
| */ | |||
| export const shouldShowTransferInboundPutawayCard = ( | |||
| _scopeWarehouse?: string | null, | |||
| _putawayWarehouse?: string | null, | |||
| _transfers?: Array<{ fromWarehouse?: string | null; toWarehouse?: string | null }>, | |||
| _mergedMultiLocation?: boolean, | |||
| ): boolean => false; | |||
| /** | |||
| * In a merged multi-warehouse graph, skip 已用完 when this location was emptied by transfer | |||
| * to another location still shown on the same graph. Remaining DO picks belong to the destination. | |||
| */ | |||
| export const shouldEmitDepletedForScope = ( | |||
| availableQty: number, | |||
| scopeWarehouse: string | null | undefined, | |||
| transfers: Array<{ fromWarehouse?: string | null; toWarehouse?: string | null }>, | |||
| mergedMultiLocation: boolean, | |||
| ): boolean => { | |||
| if (availableQty > 0) return false; | |||
| if (!mergedMultiLocation) return true; | |||
| const scope = scopeWarehouse?.trim(); | |||
| if (!scope) return true; | |||
| const emptiedByTransfer = transfers.some((tr) => | |||
| warehouseCodesMatch(scope, tr.fromWarehouse), | |||
| ); | |||
| return !emptiedByTransfer; | |||
| }; | |||
| export type PutawayPresentationLabels = { | |||
| nodePutaway: string; | |||
| nodePutawayTransfer: string; | |||
| putawayTransferDetail: string; | |||
| }; | |||
| export type PutawayPresentation = { | |||
| /** Chip on flow card (top badge). */ | |||
| chipLabel: string; | |||
| /** Status row in node detail panel. */ | |||
| statusDetail: string; | |||
| /** Card title (replaces generic「上架」for transfer inbound). */ | |||
| title: string; | |||
| /** Type row in node detail panel. */ | |||
| detailType: string; | |||
| }; | |||
| export const isPendingPutawayOriginStatus = (status: string | null | undefined): boolean => { | |||
| const normalized = (status ?? "").trim().toLowerCase(); | |||
| if (COMPLETED_PUTAWAY_STATUSES.has(normalized)) return false; | |||
| return true; | |||
| }; | |||
| export const resolvePutawayStatusLabel = ( | |||
| tr: TraceLabelTranslator, | |||
| status: string | null | undefined, | |||
| ): string => { | |||
| const normalized = (status ?? "").trim().toLowerCase(); | |||
| if (COMPLETED_PUTAWAY_STATUSES.has(normalized)) { | |||
| return tr.putawayShelfStatus("completed"); | |||
| } | |||
| return tr.putawayShelfStatus("pending"); | |||
| }; | |||
| export const resolvePutawayPresentation = ( | |||
| tr: TraceLabelTranslator, | |||
| labels: PutawayPresentationLabels, | |||
| status: string | null | undefined, | |||
| refType: string | null | undefined, | |||
| ): PutawayPresentation => { | |||
| if (normRefType(refType) === "TRANSFER") { | |||
| return { | |||
| chipLabel: labels.nodePutawayTransfer, | |||
| statusDetail: labels.putawayTransferDetail, | |||
| title: labels.nodePutawayTransfer, | |||
| detailType: labels.nodePutawayTransfer, | |||
| }; | |||
| } | |||
| const shelf = resolvePutawayStatusLabel(tr, status); | |||
| return { | |||
| chipLabel: shelf, | |||
| statusDetail: shelf, | |||
| title: labels.nodePutaway, | |||
| detailType: labels.nodePutaway, | |||
| }; | |||
| }; | |||
| @@ -0,0 +1,38 @@ | |||
| export const formatNum = (n: number) => | |||
| Number.isFinite(n) ? n.toLocaleString(undefined, { maximumFractionDigits: 4 }) : "—"; | |||
| /** Packaging / compound stock unit (e.g. 1包X25千克) — not a simple unit like 斤 or kg. */ | |||
| export const isPackagingUom = (uom?: string | null): boolean => { | |||
| const unit = uom?.trim(); | |||
| if (!unit) return false; | |||
| if (/^\d/.test(unit)) return true; | |||
| if (/[xX×]/.test(unit) && /\d/.test(unit)) return true; | |||
| return false; | |||
| }; | |||
| /** Join qty with UOM; packaging units use parentheses to avoid "6 1包…" ambiguity. */ | |||
| export const joinQtyUom = (num: string, unit: string): string => | |||
| isPackagingUom(unit) ? `${num}(${unit})` : `${num} ${unit}`; | |||
| /** Format a quantity with its unit when available. */ | |||
| export const formatQty = (qty: number | null | undefined, uom?: string | null): string => { | |||
| if (qty == null || !Number.isFinite(Number(qty))) return "—"; | |||
| const num = formatNum(Number(qty)); | |||
| const unit = uom?.trim(); | |||
| return unit ? joinQtyUom(num, unit) : num; | |||
| }; | |||
| /** Signed variance for stock adjustments: IN → +qty, OUT → −qty. */ | |||
| export const formatSignedQty = ( | |||
| qty: number | null | undefined, | |||
| direction: string | null | undefined, | |||
| uom?: string | null, | |||
| ): string => { | |||
| if (qty == null || !Number.isFinite(Number(qty))) return "—"; | |||
| const abs = Math.abs(Number(qty)); | |||
| const sign = (direction ?? "").trim().toUpperCase() === "OUT" ? "-" : "+"; | |||
| const num = formatNum(abs); | |||
| const unit = uom?.trim(); | |||
| const signed = `${sign}${num}`; | |||
| return unit ? joinQtyUom(signed, unit) : signed; | |||
| }; | |||
| @@ -0,0 +1,176 @@ | |||
| import type { ItemLotTraceStockTakeEvent, ItemLotTraceStockTakeRecordDetail } from "@/app/api/itemTracing"; | |||
| export const formatStockTakeRoundLabel = (event: ItemLotTraceStockTakeEvent): string => { | |||
| const name = event.stockTakeRoundName?.trim(); | |||
| if (name) return name; | |||
| if (event.stockTakeRoundId != null) return `#${event.stockTakeRoundId}`; | |||
| return ""; | |||
| }; | |||
| export const stockTakeEventSubtitle = (event: ItemLotTraceStockTakeEvent): string => { | |||
| const parts = [ | |||
| formatStockTakeRoundLabel(event), | |||
| event.stockTakeSection?.trim(), | |||
| event.warehouseCode?.trim(), | |||
| event.lotNo?.trim() || undefined, | |||
| ].filter(Boolean); | |||
| return parts.length > 0 ? parts.join(" · ") : event.stockTakeCode || "—"; | |||
| }; | |||
| /** Book (system) qty at count time — prefer record snapshot over line initialQty. */ | |||
| export const resolveStockTakeBookQty = ( | |||
| detail: ItemLotTraceStockTakeRecordDetail | null | undefined, | |||
| fallbackBeforeQty?: number | null, | |||
| ): number | null => { | |||
| if (detail?.bookQty != null && !Number.isNaN(Number(detail.bookQty))) { | |||
| return Number(detail.bookQty); | |||
| } | |||
| if (fallbackBeforeQty != null && !Number.isNaN(Number(fallbackBeforeQty))) { | |||
| return Number(fallbackBeforeQty); | |||
| } | |||
| return null; | |||
| }; | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 23 | v1.0.0 | 2026-07-20 | |||
| * Accepted physical qty used for variance / posting decision. | |||
| * Prefer lastSelect (1=first, 2=second, 3=approver), then fallback chain, then line finalQty. | |||
| * Incomplete rounds (no count yet) must not surface as 0 via COALESCE(finalQty, 0). | |||
| */ | |||
| export const resolveStockTakeAcceptedQty = ( | |||
| detail: ItemLotTraceStockTakeRecordDetail | null | undefined, | |||
| fallbackAfterQty?: number | null, | |||
| ): number | null => { | |||
| const pick = (n: number | null | undefined) => | |||
| n != null && !Number.isNaN(Number(n)) ? Number(n) : null; | |||
| if (detail) { | |||
| switch (detail.lastSelect) { | |||
| case 3: { | |||
| const q = pick(detail.approverQty); | |||
| if (q != null) return q; | |||
| break; | |||
| } | |||
| case 2: { | |||
| const q = pick(detail.pickerSecondQty); | |||
| if (q != null) return q; | |||
| break; | |||
| } | |||
| case 1: { | |||
| const q = pick(detail.pickerFirstQty); | |||
| if (q != null) return q; | |||
| break; | |||
| } | |||
| default: | |||
| break; | |||
| } | |||
| const fromApprover = pick(detail.approverQty); | |||
| if (fromApprover != null) return fromApprover; | |||
| const fromSecond = pick(detail.pickerSecondQty); | |||
| if (fromSecond != null) return fromSecond; | |||
| const fromFirst = pick(detail.pickerFirstQty); | |||
| if (fromFirst != null) return fromFirst; | |||
| const status = (detail.recordStatus ?? "").trim().toUpperCase(); | |||
| const accepted = | |||
| status === "ACCEPTED" || status === "COMPLETED" || status === "COMPLETE"; | |||
| if (!accepted) { | |||
| return null; | |||
| } | |||
| } | |||
| if (fallbackAfterQty != null && !Number.isNaN(Number(fallbackAfterQty))) { | |||
| return Number(fallbackAfterQty); | |||
| } | |||
| return null; | |||
| }; | |||
| export interface StockTakeLifecycleStage { | |||
| key: string; | |||
| label: string; | |||
| qty?: number | null; | |||
| badQty?: number | null; | |||
| handler?: string | null; | |||
| timestamp?: string | null; | |||
| isActive: boolean; | |||
| } | |||
| /** Build ordered lifecycle stages from stocktake record detail. */ | |||
| export const buildStockTakeLifecycleStages = ( | |||
| detail: ItemLotTraceStockTakeRecordDetail | null | undefined, | |||
| ): StockTakeLifecycleStage[] => { | |||
| if (!detail) return []; | |||
| const stages: StockTakeLifecycleStage[] = []; | |||
| // Stage 1: Round created — show book (帳面) qty for context | |||
| stages.push({ | |||
| key: "round-created", | |||
| label: "stockTakeStageCreated", | |||
| qty: detail.bookQty, | |||
| handler: null, | |||
| timestamp: detail.stockTakeStartTime ?? null, | |||
| isActive: true, | |||
| }); | |||
| // Stage 2: Picker first count | |||
| const hasFirstCount = | |||
| detail.pickerFirstQty != null || | |||
| detail.pickerFirstBadQty != null || | |||
| Boolean(detail.stockTakerName?.trim()); | |||
| stages.push({ | |||
| key: "first-count", | |||
| label: "stockTakeStageFirstCount", | |||
| qty: detail.pickerFirstQty, | |||
| badQty: detail.pickerFirstBadQty, | |||
| handler: detail.stockTakerName, | |||
| timestamp: detail.stockTakeStartTime ?? null, | |||
| isActive: hasFirstCount, | |||
| }); | |||
| // Stage 3: Picker second count (re-count) — only if present | |||
| const hasSecondCount = | |||
| detail.pickerSecondQty != null || detail.pickerSecondBadQty != null; | |||
| if (hasSecondCount) { | |||
| stages.push({ | |||
| key: "second-count", | |||
| label: "stockTakeStageSecondCount", | |||
| qty: detail.pickerSecondQty, | |||
| badQty: detail.pickerSecondBadQty, | |||
| handler: detail.stockTakerName, | |||
| timestamp: detail.stockTakeEndTime ?? null, | |||
| isActive: true, | |||
| }); | |||
| } | |||
| // Stage 4: Approver manual input — only when lastSelect === 3 (admin entered own count) | |||
| const hasApproverCount = | |||
| detail.approverQty != null || detail.approverBadQty != null; | |||
| if (detail.lastSelect === 3 && hasApproverCount) { | |||
| stages.push({ | |||
| key: "approver-count", | |||
| label: "stockTakeStageApproverCount", | |||
| qty: detail.approverQty, | |||
| badQty: detail.approverBadQty, | |||
| handler: detail.approverName, | |||
| timestamp: detail.approverTime ?? null, | |||
| isActive: true, | |||
| }); | |||
| } | |||
| // Stage 5: Accepted — show accepted physical qty; variance via badQty for secondary line | |||
| const isAccepted = | |||
| detail.recordStatus?.toUpperCase() === "ACCEPTED" || | |||
| detail.recordStatus?.toUpperCase() === "COMPLETED" || | |||
| detail.recordStatus?.toUpperCase() === "COMPLETE"; | |||
| const acceptedQty = resolveStockTakeAcceptedQty(detail, null); | |||
| stages.push({ | |||
| key: "accepted", | |||
| label: isAccepted ? "stockTakeStageAccepted" : "stockTakeStagePending", | |||
| qty: acceptedQty, | |||
| badQty: detail.varianceQty, | |||
| handler: detail.approverName, | |||
| timestamp: detail.approverTime ?? detail.stockTakeEndTime ?? null, | |||
| isActive: isAccepted || Boolean(detail.approverName?.trim()), | |||
| }); | |||
| return stages; | |||
| }; | |||
| @@ -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; | |||
| } | |||
| } | |||
| } | |||
| @@ -1,6 +1,7 @@ | |||
| "use client"; | |||
| import { PickOrderResult } from "@/app/api/pickOrder"; | |||
| import { useCallback, useEffect, useMemo, useState } from "react"; | |||
| import { useSearchParams } from "next/navigation"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import SearchBox, { Criterion } from "../SearchBox"; | |||
| import { | |||
| @@ -50,8 +51,25 @@ type SearchQuery = Partial< | |||
| type SearchParamNames = keyof SearchQuery; | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 21 | v1.0.0 | 2026-07-19 */ | |||
| const JodetailSearch: React.FC<Props> = ({ printerCombo }) => { | |||
| const { t } = useTranslation("jo"); | |||
| const searchParams = useSearchParams(); | |||
| const urlTabRaw = searchParams.get("tab"); | |||
| const urlPickOrderCode = searchParams.get("pickOrderCode")?.trim() || undefined; | |||
| const urlTargetDateRaw = searchParams.get("targetDate")?.trim() || undefined; | |||
| const urlTargetDate = (() => { | |||
| if (!urlTargetDateRaw) return undefined; | |||
| const match = urlTargetDateRaw.match(/(\d{4}-\d{2}-\d{2})/); | |||
| return match ? match[1] : undefined; | |||
| })(); | |||
| /** Item Tracing deep-link only; other navigations must not set this. */ | |||
| const urlOpenDetail = searchParams.get("openDetail") === "1"; | |||
| const urlTabIndex = useMemo(() => { | |||
| if (urlTabRaw == null) return undefined; | |||
| const n = parseInt(urlTabRaw, 10); | |||
| return !Number.isNaN(n) && n >= 0 && n <= 3 ? n : undefined; | |||
| }, [urlTabRaw]); | |||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | |||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | |||
| @@ -61,7 +79,7 @@ const JodetailSearch: React.FC<Props> = ({ printerCombo }) => { | |||
| //const [filteredPickOrders, setFilteredPickOrders] = useState(pickOrders); | |||
| const [filterArgs, setFilterArgs] = useState<Record<string, any>>({}); | |||
| const [searchQuery, setSearchQuery] = useState<Record<string, any>>({}); | |||
| const [tabIndex, setTabIndex] = useState(0); | |||
| const [tabIndex, setTabIndex] = useState(urlTabIndex ?? 0); | |||
| const [totalCount, setTotalCount] = useState<number>(); | |||
| const [isAssigning, setIsAssigning] = useState(false); | |||
| const [unassignedOrders, setUnassignedOrders] = useState<any[]>([]); | |||
| @@ -70,6 +88,10 @@ const JodetailSearch: React.FC<Props> = ({ printerCombo }) => { | |||
| const [hasDataTab0, setHasDataTab0] = useState(false); | |||
| const [hasDataTab1, setHasDataTab1] = useState(false); | |||
| const hasAnyAssignedData = hasDataTab0 || hasDataTab1; | |||
| useEffect(() => { | |||
| if (urlTabIndex != null) setTabIndex(urlTabIndex); | |||
| }, [urlTabIndex]); | |||
| // Add printer selection state | |||
| const [selectedPrinter, setSelectedPrinter] = useState<PrinterCombo | null>( | |||
| @@ -492,6 +514,9 @@ const JodetailSearch: React.FC<Props> = ({ printerCombo }) => { | |||
| printerCombo={printerCombo} | |||
| selectedPrinter={selectedPrinter} | |||
| printQty={printQty} | |||
| initialPickOrderCode={urlPickOrderCode} | |||
| initialTargetDate={urlTargetDate} | |||
| openDetail={urlOpenDetail} | |||
| /> | |||
| )} | |||
| {tabIndex === 2 && <MaterialPickStatusTable />} | |||
| @@ -58,6 +58,10 @@ interface Props { | |||
| printerCombo: PrinterCombo[]; | |||
| selectedPrinter?: PrinterCombo | null; | |||
| printQty?: number; | |||
| initialPickOrderCode?: string; | |||
| initialTargetDate?: string; | |||
| /** When true (Item Tracing `openDetail=1`), auto-open matching pick-order detail. */ | |||
| openDetail?: boolean; | |||
| } | |||
| // 修改:已完成的 Job Order Pick Order 接口 | |||
| @@ -112,11 +116,15 @@ interface LotDetail { | |||
| match_status: string | null; | |||
| } | |||
| const CompleteJobOrderRecord: React.FC<Props> = ({ | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 21 | v1.0.0 | 2026-07-19 */ | |||
| const CompleteJobOrderRecord: React.FC<Props> = ({ | |||
| filterArgs, | |||
| printerCombo, | |||
| selectedPrinter: selectedPrinterProp, | |||
| printQty: printQtyProp | |||
| printQty: printQtyProp, | |||
| initialPickOrderCode, | |||
| initialTargetDate, | |||
| openDetail = false, | |||
| }) => { | |||
| const { t } = useTranslation("jo"); | |||
| const router = useRouter(); | |||
| @@ -135,15 +143,23 @@ const CompleteJobOrderRecord: React.FC<Props> = ({ | |||
| const [detailLotDataLoading, setDetailLotDataLoading] = useState(false); | |||
| // 修改:搜索状态 | |||
| const [searchQuery, setSearchQuery] = useState<Record<string, any>>(() => ({ | |||
| completedDate: dayjs().format("YYYY-MM-DD"), | |||
| })); | |||
| const [searchQuery, setSearchQuery] = useState<Record<string, any>>(() => { | |||
| const raw = initialTargetDate?.trim() || ""; | |||
| const match = raw.match(/(\d{4}-\d{2}-\d{2})/); | |||
| return { | |||
| completedDate: match?.[1] || dayjs().format("YYYY-MM-DD"), | |||
| ...(initialPickOrderCode?.trim() | |||
| ? { pickOrderCode: initialPickOrderCode.trim() } | |||
| : {}), | |||
| }; | |||
| }); | |||
| const [filteredJobOrderPickOrders, setFilteredJobOrderPickOrders] = useState<CompletedJobOrderPickOrder[]>([]); | |||
| // Use props with fallback | |||
| const selectedPrinter = selectedPrinterProp ?? (printerCombo && printerCombo.length > 0 ? printerCombo[0] : null); | |||
| const printQty = printQtyProp ?? 1; | |||
| const pickRecordPrintInFlightRef = useRef(false); | |||
| const initialDetailOpenedRef = useRef(false); | |||
| // 修改:分页状态 | |||
| const [paginationController, setPaginationController] = useState({ | |||
| @@ -370,6 +386,27 @@ const CompleteJobOrderRecord: React.FC<Props> = ({ | |||
| }, [fetchLotDetailsData]); | |||
| useEffect(() => { | |||
| if (!openDetail) return; | |||
| if (initialDetailOpenedRef.current || completedJobOrderPickOrdersLoading) return; | |||
| const code = initialPickOrderCode?.trim(); | |||
| if (!code) return; | |||
| const match = completedJobOrderPickOrders.find( | |||
| (row) => | |||
| row.pickOrderCode?.trim() === code || | |||
| row.pickOrderConsoCode?.trim() === code, | |||
| ); | |||
| if (!match) return; | |||
| initialDetailOpenedRef.current = true; | |||
| void handleDetailClick(match); | |||
| }, [ | |||
| openDetail, | |||
| completedJobOrderPickOrders, | |||
| completedJobOrderPickOrdersLoading, | |||
| initialPickOrderCode, | |||
| handleDetailClick, | |||
| ]); | |||
| // 修改:返回列表视图 | |||
| const handleBackToList = useCallback(() => { | |||
| setShowDetailView(false); | |||
| @@ -86,7 +86,7 @@ const NavigationContent: React.FC = () => { | |||
| icon: <Storefront />, | |||
| labelKey: "nav.storeManagement", | |||
| path: "", | |||
| requiredAbility: [AUTH.PURCHASE, AUTH.STOCK, AUTH.STOCK_TAKE, AUTH.STOCK_FG, AUTH.STOCK_IN_BIND, AUTH.ADMIN], | |||
| requiredAbility: [AUTH.PURCHASE, AUTH.STOCK, AUTH.STOCK_TAKE, AUTH.STOCK_FG, AUTH.STOCK_IN_BIND, AUTH.ITEM_TRACING, AUTH.ADMIN], | |||
| children: [ | |||
| { | |||
| id: "nav.store.purchaseOrder", | |||
| @@ -109,6 +109,13 @@ const NavigationContent: React.FC = () => { | |||
| requiredAbility: [AUTH.STOCK, AUTH.ADMIN], | |||
| path: "/inventory", | |||
| }, | |||
| { | |||
| id: "nav.store.itemTracing", | |||
| icon: <QrCodeIcon />, | |||
| labelKey: "nav.store.itemTracing", | |||
| requiredAbility: [AUTH.ITEM_TRACING], | |||
| path: "/itemTracing", | |||
| }, | |||
| { | |||
| id: "nav.store.stockTake", | |||
| icon: <AssignmentTurnedIn />, | |||
| @@ -50,7 +50,9 @@ interface ItemRow { | |||
| id: string; | |||
| pickOrderId: number; | |||
| pickOrderCode: string; | |||
| pickOrderLineId?: number; | |||
| itemId: number; | |||
| uomId?: number; | |||
| itemCode: string; | |||
| itemName: string; | |||
| requiredQty: number; | |||
| @@ -85,6 +87,7 @@ const style = { | |||
| width: { xs: "100%", sm: "100%", md: "100%" }, | |||
| }; | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 31 | v1.0.1 | 2026-07-22 */ | |||
| const AssignAndRelease: React.FC<Props> = ({ filterArgs }) => { | |||
| const { t } = useTranslation("pickOrder"); | |||
| const { setIsUploading } = useUploadContext(); | |||
| @@ -167,11 +170,13 @@ const AssignAndRelease: React.FC<Props> = ({ filterArgs }) => { | |||
| // const filteredRecords = res.records.filter( | |||
| // (item: any) => (item.status || "").toLowerCase() === "pending" | |||
| //); | |||
| const itemRows: ItemRow[] = res.records.map((item: any) => ({ | |||
| id: item.id, | |||
| const itemRows: ItemRow[] = res.records.map((item: any, index: number) => ({ | |||
| id: item.id ?? `${item.pickOrderId}-${item.pickOrderLineId ?? item.itemId}-${index}`, | |||
| pickOrderId: item.pickOrderId, | |||
| pickOrderCode: item.pickOrderCode, | |||
| pickOrderLineId: item.pickOrderLineId, | |||
| itemId: item.itemId, | |||
| uomId: item.uomId, | |||
| itemCode: item.itemCode, | |||
| itemName: item.itemName, | |||
| requiredQty: item.requiredQty, | |||
| @@ -493,7 +498,7 @@ const AssignAndRelease: React.FC<Props> = ({ filterArgs }) => { | |||
| ) : ( | |||
| groupedItems.map((group) => ( | |||
| group.items.map((item, index) => ( | |||
| <TableRow key={item.id}> | |||
| <TableRow key={item.id || `${group.pickOrderId}-${item.pickOrderLineId ?? item.itemId}-${index}`}> | |||
| {/* Checkbox - 只在第一个项目显示,按 pick order 选择 */} | |||
| <TableCell> | |||
| {index === 0 ? ( | |||
| @@ -1,6 +1,5 @@ | |||
| import React, { useCallback } from 'react'; | |||
| import { | |||
| Box, | |||
| Typography, | |||
| Table, | |||
| TableBody, | |||
| @@ -43,15 +42,16 @@ interface Group { | |||
| interface CreatedItemsTableProps { | |||
| items: CreatedItem[]; | |||
| groups: Group[]; | |||
| onItemSelect: (itemId: number, checked: boolean) => void; | |||
| onQtyChange: (itemId: number, qty: number) => void; | |||
| onGroupChange: (itemId: number, groupId: string) => void; | |||
| onItemSelect: (itemId: number, checked: boolean, uomId?: number) => void; | |||
| onQtyChange: (itemId: number, qty: number, uomId?: number) => void; | |||
| onGroupChange: (itemId: number, groupId: string, uomId?: number) => void; | |||
| pageNum: number; | |||
| pageSize: number; | |||
| onPageChange: (event: unknown, newPage: number) => void; | |||
| onPageSizeChange: (event: React.ChangeEvent<HTMLInputElement>) => void; | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 31 | v1.0.1 | 2026-07-22 */ | |||
| const CreatedItemsTable: React.FC<CreatedItemsTableProps> = ({ | |||
| items, | |||
| groups, | |||
| @@ -65,15 +65,14 @@ const CreatedItemsTable: React.FC<CreatedItemsTableProps> = ({ | |||
| }) => { | |||
| const { t } = useTranslation("pickOrder"); | |||
| // Calculate pagination | |||
| const startIndex = (pageNum - 1) * pageSize; | |||
| const endIndex = startIndex + pageSize; | |||
| const paginatedItems = items.slice(startIndex, endIndex); | |||
| const handleQtyChange = useCallback((itemId: number, value: string) => { | |||
| const handleQtyChange = useCallback((itemId: number, uomId: number, value: string) => { | |||
| const numValue = Number(value); | |||
| if (!isNaN(numValue) && numValue >= 1) { | |||
| onQtyChange(itemId, numValue); | |||
| onQtyChange(itemId, numValue, uomId); | |||
| } | |||
| }, [onQtyChange]); | |||
| @@ -117,11 +116,11 @@ const CreatedItemsTable: React.FC<CreatedItemsTableProps> = ({ | |||
| </TableRow> | |||
| ) : ( | |||
| paginatedItems.map((item) => ( | |||
| <TableRow key={item.itemId}> | |||
| <TableRow key={`${item.itemId}_${item.uomId}`}> | |||
| <TableCell padding="checkbox"> | |||
| <Checkbox | |||
| checked={item.isSelected} | |||
| onChange={(e) => onItemSelect(item.itemId, e.target.checked)} | |||
| onChange={(e) => onItemSelect(item.itemId, e.target.checked, item.uomId)} | |||
| /> | |||
| </TableCell> | |||
| <TableCell> | |||
| @@ -134,7 +133,7 @@ const CreatedItemsTable: React.FC<CreatedItemsTableProps> = ({ | |||
| <FormControl size="small" sx={{ minWidth: 120 }}> | |||
| <Select | |||
| value={item.groupId?.toString() || ""} | |||
| onChange={(e) => onGroupChange(item.itemId, e.target.value)} | |||
| onChange={(e) => onGroupChange(item.itemId, e.target.value, item.uomId)} | |||
| displayEmpty | |||
| > | |||
| <MenuItem value=""> | |||
| @@ -157,14 +156,14 @@ const CreatedItemsTable: React.FC<CreatedItemsTableProps> = ({ | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| <Typography variant="body2">{item.uomDesc}</Typography> | |||
| <Typography variant="body2">{item.uomDesc || item.uom || "-"}</Typography> | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| <TextField | |||
| type="number" | |||
| size="small" | |||
| value={item.qty || ""} | |||
| onChange={(e) => handleQtyChange(item.itemId, e.target.value)} | |||
| onChange={(e) => handleQtyChange(item.itemId, item.uomId, e.target.value)} | |||
| inputProps={{ | |||
| min: 1, | |||
| step: 1, | |||
| @@ -208,4 +207,4 @@ const CreatedItemsTable: React.FC<CreatedItemsTableProps> = ({ | |||
| ); | |||
| }; | |||
| export default CreatedItemsTable; | |||
| export default CreatedItemsTable; | |||
| @@ -1,6 +1,5 @@ | |||
| import React, { useCallback } from 'react'; | |||
| import { | |||
| Box, | |||
| Typography, | |||
| Table, | |||
| TableBody, | |||
| @@ -22,8 +21,12 @@ import dayjs from 'dayjs'; | |||
| interface SearchItemWithQty { | |||
| id: number; | |||
| /** Unique search-row key: itemId + uomId */ | |||
| rowKey: string; | |||
| label: string; | |||
| qty: number | null; | |||
| uomId?: number; | |||
| uom?: string; | |||
| currentStockBalance?: number; | |||
| uomDesc?: string; | |||
| targetDate?: string | null; | |||
| @@ -40,17 +43,18 @@ interface SearchResultsTableProps { | |||
| items: SearchItemWithQty[]; | |||
| selectedItemIds: (string | number)[]; | |||
| groups: Group[]; | |||
| onItemSelect: (itemId: number, checked: boolean) => void; | |||
| onQtyChange: (itemId: number, qty: number | null) => void; | |||
| onQtyBlur: (itemId: number) => void; | |||
| onGroupChange: (itemId: number, groupId: string) => void; | |||
| isItemInCreated: (itemId: number) => boolean; | |||
| onItemSelect: (rowKey: string, checked: boolean) => void; | |||
| onQtyChange: (rowKey: string, qty: number | null) => void; | |||
| onQtyBlur: (rowKey: string) => void; | |||
| onGroupChange: (rowKey: string, groupId: string) => void; | |||
| isItemInCreated: (itemId: number, uomId?: number) => boolean; | |||
| pageNum: number; | |||
| pageSize: number; | |||
| onPageChange: (event: unknown, newPage: number) => void; | |||
| onPageSizeChange: (event: React.ChangeEvent<HTMLInputElement>) => void; | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 31 | v1.0.1 | 2026-07-22 */ | |||
| const SearchResultsTable: React.FC<SearchResultsTableProps> = ({ | |||
| items, | |||
| selectedItemIds, | |||
| @@ -67,16 +71,14 @@ const SearchResultsTable: React.FC<SearchResultsTableProps> = ({ | |||
| }) => { | |||
| const { t } = useTranslation("pickOrder"); | |||
| // Calculate pagination | |||
| const startIndex = (pageNum - 1) * pageSize; | |||
| const endIndex = startIndex + pageSize; | |||
| const paginatedResults = items.slice(startIndex, endIndex); | |||
| const handleQtyChange = useCallback((itemId: number, value: string) => { | |||
| // Only allow numbers | |||
| const handleQtyChange = useCallback((rowKey: string, value: string) => { | |||
| if (value === "" || /^\d*\.?\d+$/.test(value)) { | |||
| const numValue = value === "" ? null : Number(value); | |||
| onQtyChange(itemId, numValue); | |||
| onQtyChange(rowKey, numValue); | |||
| } | |||
| }, [onQtyChange]); | |||
| @@ -119,50 +121,45 @@ const SearchResultsTable: React.FC<SearchResultsTableProps> = ({ | |||
| </TableCell> | |||
| </TableRow> | |||
| ) : ( | |||
| paginatedResults.map((item) => ( | |||
| <TableRow key={item.id}> | |||
| paginatedResults.map((item) => { | |||
| const rowKey = item.rowKey || `${item.id}_${item.uomId || 0}`; | |||
| const inCreated = isItemInCreated(item.id, item.uomId); | |||
| return ( | |||
| <TableRow key={rowKey}> | |||
| <TableCell padding="checkbox"> | |||
| <Checkbox | |||
| checked={selectedItemIds.includes(item.id)} | |||
| onChange={(e) => onItemSelect(item.id, e.target.checked)} | |||
| disabled={isItemInCreated(item.id)} | |||
| checked={selectedItemIds.includes(rowKey) || inCreated} | |||
| onChange={(e) => onItemSelect(rowKey, e.target.checked)} | |||
| disabled={inCreated} | |||
| /> | |||
| </TableCell> | |||
| {/* Item */} | |||
| <TableCell> | |||
| <Box> | |||
| <Typography variant="body2"> | |||
| {item.label.split(' - ')[1] || item.label} | |||
| </Typography> | |||
| <Typography variant="caption" color="textSecondary"> | |||
| {item.label.split(' - ')[0] || ''} | |||
| </Typography> | |||
| </Box> | |||
| <Typography variant="body2"> | |||
| {item.label.split(' - ')[1] || item.label} | |||
| </Typography> | |||
| <Typography variant="caption" color="textSecondary"> | |||
| {item.label.split(' - ')[0] || ''} | |||
| </Typography> | |||
| </TableCell> | |||
| {/* Group */} | |||
| <TableCell> | |||
| <FormControl size="small" sx={{ minWidth: 120 }}> | |||
| <Select | |||
| <Select | |||
| value={item.groupId?.toString() || ""} | |||
| onChange={(e) => onGroupChange(item.id, e.target.value)} | |||
| onChange={(e) => onGroupChange(rowKey, e.target.value)} | |||
| displayEmpty | |||
| disabled={isItemInCreated(item.id)} | |||
| > | |||
| disabled={inCreated} | |||
| > | |||
| <MenuItem value=""> | |||
| <em>{t("No Group")}</em> | |||
| <em>{t("No Group")}</em> | |||
| </MenuItem> | |||
| {groups.map((group) => ( | |||
| <MenuItem key={group.id} value={group.id.toString()}> | |||
| <MenuItem key={group.id} value={group.id.toString()}> | |||
| {group.name} | |||
| </MenuItem> | |||
| </MenuItem> | |||
| ))} | |||
| </Select> | |||
| </Select> | |||
| </FormControl> | |||
| </TableCell> | |||
| {/* Current Stock */} | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| <Typography | |||
| variant="body2" | |||
| @@ -172,54 +169,42 @@ const SearchResultsTable: React.FC<SearchResultsTableProps> = ({ | |||
| {item.currentStockBalance?.toLocaleString()||0} | |||
| </Typography> | |||
| </TableCell> | |||
| {/* Stock Unit */} | |||
| <TableCell align="right"> | |||
| <Typography variant="body2"> | |||
| {item.uomDesc || "-"} | |||
| {item.uomDesc || item.uom || "-"} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| {/* Order Quantity */} | |||
| <TextField | |||
| type="number" | |||
| size="small" | |||
| value={item.qty || ""} | |||
| onChange={(e) => { | |||
| const value = e.target.value; | |||
| // Only allow numbers | |||
| if (value === "" || /^\d+$/.test(value)) { | |||
| const numValue = value === "" ? null : Number(value); | |||
| onQtyChange(item.id, numValue); | |||
| } | |||
| }} | |||
| onBlur={() => { | |||
| // Trigger auto-add check when user finishes input (clicks elsewhere) | |||
| onQtyBlur(item.id); // ← Change this to call onQtyBlur instead! | |||
| }} | |||
| inputProps={{ | |||
| <TextField | |||
| type="number" | |||
| size="small" | |||
| value={item.qty || ""} | |||
| onChange={(e) => handleQtyChange(rowKey, e.target.value)} | |||
| onBlur={() => onQtyBlur(rowKey)} | |||
| disabled={inCreated} | |||
| inputProps={{ | |||
| min: 1, | |||
| max: item.currentStockBalance || 0, | |||
| step: 1, | |||
| style: { textAlign: 'center' } | |||
| }} | |||
| sx={{ | |||
| }} | |||
| sx={{ | |||
| width: '80px', | |||
| '& .MuiInputBase-input': { | |||
| textAlign: 'center', | |||
| cursor: 'text' | |||
| textAlign: 'center', | |||
| cursor: 'text' | |||
| } | |||
| }} | |||
| disabled={isItemInCreated(item.id)} | |||
| }} | |||
| /> | |||
| </TableCell> | |||
| {/* Target Date */} | |||
| <TableCell align="right"> | |||
| <Typography variant="body2"> | |||
| {item.targetDate ? dayjs(item.targetDate).format(OUTPUT_DATE_FORMAT) : "-"} | |||
| </Typography> | |||
| </TableCell> | |||
| </TableRow> | |||
| )) | |||
| ); | |||
| }) | |||
| )} | |||
| </TableBody> | |||
| </Table> | |||
| @@ -242,4 +227,4 @@ const SearchResultsTable: React.FC<SearchResultsTableProps> = ({ | |||
| ); | |||
| }; | |||
| export default SearchResultsTable; | |||
| export default SearchResultsTable; | |||
| @@ -30,11 +30,11 @@ import { SessionWithTokens } from "@/config/authConfig"; | |||
| import { | |||
| fetchConsumableWorkbenchPickOrderLotsHierarchical, | |||
| reloadConsumableWorkbenchPickOrderLotsHierarchical, | |||
| confirmLotSubstitution, | |||
| suggestPickOrderWorkbenchV2, | |||
| } from "@/app/api/pickOrder/actions"; | |||
| import { workbenchScanPick } from "@/app/api/doworkbench/actions"; | |||
| import { fetchStockInLineInfo } from "@/app/api/po/actions"; | |||
| import { fetchLotDetail } from "@/app/api/inventory/actions"; | |||
| import WorkbenchLotLabelPrintModal from "@/components/DoWorkbench/WorkbenchLotLabelPrintModal"; | |||
| import TestQrCodeProvider from "../QrCodeScannerProvider/TestQrCodeProvider"; | |||
| import { useQrCodeScannerContext } from "../QrCodeScannerProvider/QrCodeScannerProvider"; | |||
| @@ -66,6 +66,7 @@ type LineRow = { | |||
| requiredQty: number; | |||
| pickedQty: number; | |||
| stockUnit: string; | |||
| uomId?: number; | |||
| status: string; | |||
| lotsRaw: unknown[]; | |||
| }; | |||
| @@ -89,6 +90,7 @@ type LotRow = { | |||
| itemCode: string; | |||
| itemName: string; | |||
| uomDesc: string; | |||
| uomId?: number; | |||
| requiredQty: number; | |||
| pickOrderLineRequiredQty?: number; | |||
| availableQty: number; | |||
| @@ -226,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) { | |||
| @@ -273,6 +266,7 @@ function flattenLotsFromPickOrder(po: PickOrderTopRow): LotRow[] { | |||
| itemCode: line.itemCode, | |||
| itemName: line.itemName, | |||
| uomDesc: toStr(lot.stockUnit) || line.stockUnit, | |||
| uomId: line.uomId, | |||
| requiredQty: toNum(lot.requiredQty, line.requiredQty), | |||
| pickOrderLineRequiredQty: line.requiredQty, | |||
| availableQty: toNum(lot.availableQty), | |||
| @@ -333,6 +327,7 @@ function mapHierarchicalToPickOrders(data: unknown): PickOrderTopRow[] { | |||
| requiredQty: toNum(line?.requiredQty), | |||
| pickedQty: sumLinePickedQtyFromLots(lots), | |||
| stockUnit: toStr(item?.uomDesc ?? item?.uomCode), | |||
| uomId: toNum(item?.uomId) > 0 ? toNum(item?.uomId) : undefined, | |||
| status: toStr(line?.status), | |||
| lotsRaw: lots, | |||
| }; | |||
| @@ -351,6 +346,7 @@ function mapHierarchicalToPickOrders(data: unknown): PickOrderTopRow[] { | |||
| }); | |||
| } | |||
| /** 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 }; | |||
| @@ -770,6 +766,16 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| processedQrCodesRef.current.add(latestQr); | |||
| }, []); | |||
| /** Soft-fail / switch-PO: allow the same QR to be processed again. */ | |||
| const releaseProcessedQr = useCallback((qr: string) => { | |||
| const key = String(qr || "").trim(); | |||
| if (!key) return; | |||
| processedQrCodesRef.current.delete(key); | |||
| if (lastProcessedQrRef.current === key) { | |||
| lastProcessedQrRef.current = ""; | |||
| } | |||
| }, []); | |||
| const openUnpickableScanLotLabelModal = useCallback( | |||
| ( | |||
| pickRow: LotRow, | |||
| @@ -946,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; | |||
| @@ -956,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(() => { | |||
| @@ -1177,16 +1153,102 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| return; | |||
| } | |||
| const expectedPool = activeSuggestedLots.length > 0 ? activeSuggestedLots : allLotsForItem; | |||
| const expectedRow = pickExpectedRowForSubstitution(expectedPool) || allLotsForItem[0]; | |||
| // Case A: reject only if scanned lot UOM matches none of this item's POL UOMs | |||
| // (same item may have multiple lines with different UOMs on one pick order) | |||
| let scannedUomId = 0; | |||
| const allowedUomIds = new Set( | |||
| allLotsForItem | |||
| .map((l) => Number(l?.uomId)) | |||
| .filter((id) => Number.isFinite(id) && id > 0), | |||
| ); | |||
| const hasLineUoms = allowedUomIds.size > 0; | |||
| try { | |||
| const lotDetail = await fetchLotDetail(scannedStockInLineId); | |||
| scannedUomId = Number(lotDetail?.uomId) || 0; | |||
| } catch (e) { | |||
| if (hasLineUoms) { | |||
| console.warn( | |||
| "[QR PROCESS] lot-detail UOM check failed; rejecting scan", | |||
| e, | |||
| ); | |||
| const msg = t( | |||
| "This lot UOM does not match the pick line. Please scan another lot.", | |||
| ); | |||
| setError(msg); | |||
| startTransition(() => { | |||
| setQrScanError(true); | |||
| setQrScanSuccess(false); | |||
| setQrScanErrorMsg(msg); | |||
| }); | |||
| releaseProcessedQr(latest); | |||
| return; | |||
| } | |||
| } | |||
| if ( | |||
| hasLineUoms && | |||
| (!(scannedUomId > 0) || !allowedUomIds.has(scannedUomId)) | |||
| ) { | |||
| const msg = t( | |||
| "This lot UOM does not match the pick line. Please scan another lot.", | |||
| ); | |||
| setError(msg); | |||
| startTransition(() => { | |||
| setQrScanError(true); | |||
| setQrScanSuccess(false); | |||
| setQrScanErrorMsg(msg); | |||
| }); | |||
| releaseProcessedQr(latest); | |||
| return; | |||
| } | |||
| // Strict UOM filter — never fall back to other UOM lines | |||
| const filterByScannedUom = (rows: LotRow[]) => { | |||
| if (!(scannedUomId > 0) || !hasLineUoms) return rows; | |||
| return rows.filter((r) => Number(r.uomId) === scannedUomId); | |||
| }; | |||
| const activeForUom = filterByScannedUom(activeSuggestedLots); | |||
| const allForUom = filterByScannedUom(allLotsForItem); | |||
| if (hasLineUoms && scannedUomId > 0 && allForUom.length === 0) { | |||
| const msg = t( | |||
| "This lot UOM does not match the pick line. Please scan another lot.", | |||
| ); | |||
| setError(msg); | |||
| startTransition(() => { | |||
| setQrScanError(true); | |||
| setQrScanSuccess(false); | |||
| setQrScanErrorMsg(msg); | |||
| }); | |||
| releaseProcessedQr(latest); | |||
| return; | |||
| } | |||
| const expectedPool = activeForUom.length > 0 ? activeForUom : allForUom; | |||
| let expectedRow = pickExpectedRowForSubstitution(expectedPool) || allForUom[0]; | |||
| if (!expectedRow) { | |||
| setError(t("Scanned item is not found in current line")); | |||
| startTransition(() => { | |||
| setQrScanError(true); | |||
| setQrScanSuccess(false); | |||
| setQrScanErrorMsg(t("Scanned item is not found in current line")); | |||
| }); | |||
| releaseProcessedQr(latest); | |||
| return; | |||
| } | |||
| const scannedRows = lotRowIndexes.byStockInLineId.get(scannedStockInLineId) || []; | |||
| const scannedRowInItem = | |||
| let scannedRowInItem = | |||
| scannedRows.find( | |||
| (r) => | |||
| Number(r.itemId) === scannedItemId && | |||
| r.stockOutLineId > 0, | |||
| r.stockOutLineId > 0 && | |||
| (!(scannedUomId > 0) || !hasLineUoms || Number(r.uomId) === scannedUomId), | |||
| ) || | |||
| (!(scannedUomId > 0) || !hasLineUoms | |||
| ? scannedRows.find( | |||
| (r) => | |||
| Number(r.itemId) === scannedItemId && | |||
| r.stockOutLineId > 0, | |||
| ) | |||
| : undefined) || | |||
| null; | |||
| if (scannedRowInItem && isRejectedStatus(scannedRowInItem.status)) { | |||
| @@ -1231,14 +1293,46 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| return; | |||
| } | |||
| if (scannedRowInItem && (isCompletedStatus(scannedRowInItem.status) || isCheckedStatus(scannedRowInItem.status))) { | |||
| setError(t("Scanned lot is already completed or checked")); | |||
| startTransition(() => { | |||
| setQrScanError(true); | |||
| setQrScanSuccess(false); | |||
| setQrScanErrorMsg(t("Scanned lot is already completed or checked")); | |||
| // Same lot already bound to a completed/checked SOL: only reuse for another | |||
| // pending line with the SAME uomId (never cross UOM A ↔ B). | |||
| if ( | |||
| scannedRowInItem && | |||
| (isCompletedStatus(scannedRowInItem.status) || isCheckedStatus(scannedRowInItem.status)) | |||
| ) { | |||
| const completedPolId = Number(scannedRowInItem.pickOrderLineId); | |||
| const completedSolId = Number(scannedRowInItem.stockOutLineId); | |||
| const pendingPoolBase = activeForUom.length > 0 ? activeForUom : allForUom; | |||
| const pendingSameUom = pendingPoolBase.filter((r) => { | |||
| if (!(r.stockOutLineId > 0)) return false; | |||
| if (isCompletedStatus(r.status) || isCheckedStatus(r.status) || isRejectedStatus(r.status)) { | |||
| return false; | |||
| } | |||
| if (Number(r.stockOutLineId) === completedSolId) return false; | |||
| if (completedPolId > 0 && Number(r.pickOrderLineId) === completedPolId) return false; | |||
| // Require explicit same UOM — do not reuse across different UOM lines | |||
| if (scannedUomId > 0 && hasLineUoms) { | |||
| return Number(r.uomId) === scannedUomId; | |||
| } | |||
| // Without line UOM metadata, do not auto-reuse (avoids A/B cross-pick) | |||
| return false; | |||
| }); | |||
| return; | |||
| const nextExpected = | |||
| pickExpectedRowForSubstitution(pendingSameUom) || pendingSameUom[0] || null; | |||
| if (nextExpected) { | |||
| expectedRow = nextExpected; | |||
| // Force substitution path using scanned SIL, not the completed SOL row. | |||
| scannedRowInItem = null; | |||
| } else { | |||
| const msg = t("This lot has already been picked. Please scan another lot."); | |||
| setError(msg); | |||
| startTransition(() => { | |||
| setQrScanError(true); | |||
| setQrScanSuccess(false); | |||
| setQrScanErrorMsg(msg); | |||
| }); | |||
| releaseProcessedQr(latest); | |||
| return; | |||
| } | |||
| } | |||
| let scannedState: ConfirmLotState | null = null; | |||
| @@ -1292,6 +1386,7 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| pickExpectedRowForSubstitution, | |||
| lotRowIndexes, | |||
| openUnpickableScanLotLabelModal, | |||
| releaseProcessedQr, | |||
| resetScan, | |||
| submitRow, | |||
| t, | |||
| @@ -1306,10 +1401,9 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| }, [isScanning, startScan, userId]); | |||
| useEffect(() => { | |||
| if (!selectedPickOrderId) { | |||
| lastProcessedQrRef.current = ""; | |||
| processedQrCodesRef.current.clear(); | |||
| } | |||
| // Clear on any PO selection change (including PO1 → PO2), so the same lot QR can be scanned again. | |||
| lastProcessedQrRef.current = ""; | |||
| processedQrCodesRef.current.clear(); | |||
| }, [selectedPickOrderId]); | |||
| useEffect(() => { | |||
| @@ -1521,6 +1615,11 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| const isRowExpired = | |||
| isWorkbenchSourceLotExpired(r) && !isRowRejected; | |||
| const isRowUnavailable = isInventoryLotLineUnavailable(r); | |||
| const isRowComplete = | |||
| rowStatus === "completed" || | |||
| rowStatus === "checked" || | |||
| rowStatus === "partially_completed" || | |||
| rowStatus === "partially_complete"; | |||
| return ( | |||
| <TableRow key={r.key}> | |||
| @@ -1544,7 +1643,9 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| ? "error.main" | |||
| : isRowExpired || isLotAvailabilityExpired(r) | |||
| ? "warning.main" | |||
| : "inherit", | |||
| : isRowComplete | |||
| ? "success.main" | |||
| : "inherit", | |||
| }} | |||
| > | |||
| {r.lotNo ? ( | |||
| @@ -1699,7 +1800,19 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| return { ...prev, [r.stockOutLineId]: n }; | |||
| }); | |||
| }} | |||
| sx={{ width: 96 }} | |||
| sx={{ | |||
| width: 96, | |||
| "& .MuiInputBase-input": { | |||
| typography: "body1", | |||
| py: 0.5, | |||
| textAlign: "center", | |||
| "&::-webkit-outer-spin-button, &::-webkit-inner-spin-button": { | |||
| WebkitAppearance: "none", | |||
| margin: 0, | |||
| }, | |||
| MozAppearance: "textfield", | |||
| }, | |||
| }} | |||
| disabled={!r.stockOutLineId || qtyEditableBySolId[r.stockOutLineId] !== true} | |||
| inputProps={{ min: 0, step: 1 }} | |||
| /> | |||
| @@ -1779,6 +1892,12 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| statusTitleSeverity={workbenchLotLabelStatusBanner.severity} | |||
| triggerLotAvailableQty={workbenchLotLabelContextLot?.availableQty ?? null} | |||
| triggerLotUom={workbenchLotLabelContextLot?.uomDesc ?? null} | |||
| expectedUomId={ | |||
| workbenchLotLabelContextLot != null && | |||
| Number(workbenchLotLabelContextLot.uomId) > 0 | |||
| ? Number(workbenchLotLabelContextLot.uomId) | |||
| : null | |||
| } | |||
| submitQty={ | |||
| workbenchLotLabelContextLot?.stockOutLineId | |||
| ? Number(resolveLockedSubmitQtyDisplay(workbenchLotLabelContextLot)) | |||
| @@ -70,6 +70,8 @@ interface CreatedItem { | |||
| // Add interface for search items with quantity | |||
| interface SearchItemWithQty extends ItemCombo { | |||
| /** Unique per item+uom search row */ | |||
| rowKey: string; | |||
| qty: number | null; // Changed from number to number | null | |||
| jobOrderCode?: string; | |||
| jobOrderId?: number; | |||
| @@ -77,6 +79,10 @@ interface SearchItemWithQty extends ItemCombo { | |||
| targetDate?: string | null; // Allow null values | |||
| groupId?: number | null; // Allow null values | |||
| } | |||
| const toSearchRowKey = (itemId: number, uomId?: number | null) => | |||
| `${itemId}_${Number(uomId) > 0 ? Number(uomId) : 0}`; | |||
| interface JobOrderDetailPickLine { | |||
| id: number; | |||
| code: string; | |||
| @@ -95,8 +101,9 @@ interface Group { | |||
| } | |||
| // Move the counter outside the component to persist across re-renders | |||
| let checkboxChangeCallCount = 0; | |||
| let processingItems = new Set<number>(); | |||
| let processingItems = new Set<string>(); | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 31 | v1.0.1 | 2026-07-22 */ | |||
| const NewCreateItem: React.FC<Props> = ({ filterArgs, searchQuery, onPickOrderCreated }) => { | |||
| const { t } = useTranslation("pickOrder"); | |||
| const [items, setItems] = useState<ItemCombo[]>([]); | |||
| @@ -174,6 +181,7 @@ const NewCreateItem: React.FC<Props> = ({ filterArgs, searchQuery, onPickOrderCr | |||
| uom: item.uom, | |||
| uomId: 0, | |||
| uomDesc: item.uomDesc || "", // Add missing uomDesc | |||
| rowKey: toSearchRowKey(item.id, 0), | |||
| jobOrderCode: jobOrderDetail.code, | |||
| jobOrderId: jobOrderDetail.id, | |||
| currentStockBalance: 0, // Add default value | |||
| @@ -263,49 +271,50 @@ const NewCreateItem: React.FC<Props> = ({ filterArgs, searchQuery, onPickOrderCr | |||
| ); | |||
| }, []); | |||
| // Modified handler for search item selection | |||
| // Modified handler for search item selection (legacy filteredItems path) | |||
| const handleSearchItemSelect = useCallback((itemId: number, isSelected: boolean) => { | |||
| if (isSelected) { | |||
| const item = filteredItems.find(i => i.id === itemId); | |||
| if (!item) return; | |||
| const existingItem = createdItems.find(created => created.itemId === item.id); | |||
| if (existingItem) { | |||
| const uomId = Number(item.uomId) || 0; | |||
| if (createdItems.some((c) => c.itemId === item.id && Number(c.uomId) === uomId)) { | |||
| alert(t("Item already exists in created items")); | |||
| return; | |||
| } | |||
| // Fix the newCreatedItem creation - add missing uomDesc | |||
| const newCreatedItem: CreatedItem = { | |||
| itemId: item.id, | |||
| itemName: item.label, | |||
| itemCode: item.label, | |||
| qty: item.qty || 1, | |||
| uom: item.uom || "", | |||
| uomId: item.uomId || 0, | |||
| uomDesc: item.uomDesc || "", // Add missing uomDesc | |||
| uomId, | |||
| uomDesc: item.uomDesc || "", | |||
| isSelected: true, | |||
| currentStockBalance: item.currentStockBalance, | |||
| targetDate: item.targetDate || targetDate, // Use item's targetDate or fallback to form's targetDate | |||
| groupId: item.groupId || undefined, // Handle null values | |||
| targetDate: item.targetDate || targetDate, | |||
| groupId: item.groupId || undefined, | |||
| }; | |||
| setCreatedItems(prev => [...prev, newCreatedItem]); | |||
| } | |||
| }, [filteredItems, createdItems, t, targetDate]); | |||
| // Handler for created item selection | |||
| const handleCreatedItemSelect = useCallback((itemId: number, isSelected: boolean) => { | |||
| const handleCreatedItemSelect = useCallback((itemId: number, isSelected: boolean, uomId?: number) => { | |||
| setCreatedItems(prev => | |||
| prev.map(item => | |||
| item.itemId === itemId ? { ...item, isSelected } : item | |||
| item.itemId === itemId && | |||
| (uomId == null || Number(item.uomId) === Number(uomId)) | |||
| ? { ...item, isSelected } | |||
| : item | |||
| ) | |||
| ); | |||
| }, []); | |||
| const handleQtyChange = useCallback((itemId: number, newQty: number) => { | |||
| const handleQtyChange = useCallback((itemId: number, newQty: number, uomId?: number) => { | |||
| setCreatedItems(prev => | |||
| prev.map(item => | |||
| item.itemId === itemId | |||
| item.itemId === itemId && | |||
| (uomId == null || Number(item.uomId) === Number(uomId)) | |||
| ? { | |||
| ...item, | |||
| qty: Math.max(1, Math.min(newQty, Math.max(0, item.currentStockBalance ?? 0))), | |||
| @@ -315,18 +324,23 @@ const NewCreateItem: React.FC<Props> = ({ filterArgs, searchQuery, onPickOrderCr | |||
| ); | |||
| }, []); | |||
| // Check if item is already in created items | |||
| const isItemInCreated = useCallback((itemId: number) => { | |||
| return createdItems.some(item => item.itemId === itemId); | |||
| // Check if item (+ optional uom) is already in created items | |||
| const isItemInCreated = useCallback((itemId: number, uomId?: number) => { | |||
| return createdItems.some( | |||
| (item) => | |||
| item.itemId === itemId && | |||
| (uomId == null || Number(item.uomId) === Number(uomId)), | |||
| ); | |||
| }, [createdItems]); | |||
| // 1) Created Items 行内改组:只改这一行的 groupId,并把该行 targetDate 同步为该组日期 | |||
| const handleCreatedItemGroupChange = useCallback((itemId: number, newGroupId: string) => { | |||
| const handleCreatedItemGroupChange = useCallback((itemId: number, newGroupId: string, uomId?: number) => { | |||
| const gid = newGroupId ? Number(newGroupId) : undefined; | |||
| const group = groups.find(g => g.id === gid); | |||
| setCreatedItems(prev => | |||
| prev.map(it => | |||
| it.itemId === itemId | |||
| it.itemId === itemId && | |||
| (uomId == null || Number(it.uomId) === Number(uomId)) | |||
| ? { | |||
| ...it, | |||
| groupId: gid, | |||
| @@ -410,27 +424,23 @@ const NewCreateItem: React.FC<Props> = ({ filterArgs, searchQuery, onPickOrderCr | |||
| } | |||
| }, [t]); | |||
| const checkAndAutoAddItem = useCallback((itemId: number) => { | |||
| const item = secondSearchResults.find(i => i.id === itemId); | |||
| const checkAndAutoAddItem = useCallback((rowKey: string) => { | |||
| const item = secondSearchResults.find((i) => (i.rowKey || toSearchRowKey(i.id, i.uomId)) === rowKey); | |||
| if (!item) return; | |||
| // Check if item has ALL 3 conditions: | |||
| // 1. Item is selected (checkbox checked) | |||
| const isSelected = selectedSecondSearchItemIds.includes(itemId); | |||
| // 2. Group is assigned | |||
| const key = item.rowKey || toSearchRowKey(item.id, item.uomId); | |||
| const isSelected = selectedSecondSearchItemIds.includes(key); | |||
| const hasGroup = item.groupId !== undefined && item.groupId !== null; | |||
| // 3. Quantity is entered | |||
| const hasQty = item.qty !== null && item.qty !== undefined && item.qty > 0; | |||
| if (isSelected && hasGroup && hasQty && !isItemInCreated(item.id)) { | |||
| // Auto-add to created items | |||
| if (isSelected && hasGroup && hasQty && !isItemInCreated(item.id, item.uomId)) { | |||
| const newCreatedItem: CreatedItem = { | |||
| itemId: item.id, | |||
| itemName: item.label, | |||
| itemCode: item.label, | |||
| qty: item.qty || 1, | |||
| uom: item.uom || "", | |||
| uomId: item.uomId || 0, | |||
| uomId: Number(item.uomId) || 0, | |||
| uomDesc: item.uomDesc || "", | |||
| isSelected: true, | |||
| currentStockBalance: item.currentStockBalance, | |||
| @@ -438,33 +448,29 @@ const NewCreateItem: React.FC<Props> = ({ filterArgs, searchQuery, onPickOrderCr | |||
| groupId: item.groupId || undefined, | |||
| }; | |||
| setCreatedItems(prev => [...prev, newCreatedItem]); | |||
| // Remove from search results since it's now in created items | |||
| setSecondSearchResults(prev => prev.filter(searchItem => searchItem.id !== itemId)); | |||
| setSelectedSecondSearchItemIds(prev => prev.filter(id => id !== itemId)); | |||
| setSecondSearchResults(prev => prev.filter((searchItem) => (searchItem.rowKey || toSearchRowKey(searchItem.id, searchItem.uomId)) !== key)); | |||
| setSelectedSecondSearchItemIds(prev => prev.filter(id => id !== key)); | |||
| } | |||
| }, [secondSearchResults, selectedSecondSearchItemIds, isItemInCreated, targetDate]); | |||
| // Add this function after checkAndAutoAddItem | |||
| // Add this function after checkAndAutoAddItem | |||
| const handleQtyBlur = useCallback((itemId: number) => { | |||
| // Only auto-add if item is already selected (scenario 1: select first, then enter quantity) | |||
| const handleQtyBlur = useCallback((rowKey: string) => { | |||
| setTimeout(() => { | |||
| const currentItem = secondSearchResults.find(i => i.id === itemId); | |||
| const currentItem = secondSearchResults.find((i) => (i.rowKey || toSearchRowKey(i.id, i.uomId)) === rowKey); | |||
| if (!currentItem) return; | |||
| const isSelected = selectedSecondSearchItemIds.includes(itemId); | |||
| const key = currentItem.rowKey || toSearchRowKey(currentItem.id, currentItem.uomId); | |||
| const isSelected = selectedSecondSearchItemIds.includes(key); | |||
| const hasGroup = currentItem.groupId !== undefined && currentItem.groupId !== null; | |||
| const hasQty = currentItem.qty !== null && currentItem.qty !== undefined && currentItem.qty > 0; | |||
| // Only auto-add if item is already selected (scenario 1: select first, then enter quantity) | |||
| if (isSelected && hasGroup && hasQty && !isItemInCreated(currentItem.id)) { | |||
| if (isSelected && hasGroup && hasQty && !isItemInCreated(currentItem.id, currentItem.uomId)) { | |||
| const newCreatedItem: CreatedItem = { | |||
| itemId: currentItem.id, | |||
| itemName: currentItem.label, | |||
| itemCode: currentItem.label, | |||
| qty: currentItem.qty || 1, | |||
| uom: currentItem.uom || "", | |||
| uomId: currentItem.uomId || 0, | |||
| uomId: Number(currentItem.uomId) || 0, | |||
| uomDesc: currentItem.uomDesc || "", | |||
| isSelected: true, | |||
| currentStockBalance: currentItem.currentStockBalance, | |||
| @@ -472,18 +478,18 @@ const handleQtyBlur = useCallback((itemId: number) => { | |||
| groupId: currentItem.groupId || undefined, | |||
| }; | |||
| setCreatedItems(prev => [...prev, newCreatedItem]); | |||
| setSecondSearchResults(prev => prev.filter(searchItem => searchItem.id !== itemId)); | |||
| setSelectedSecondSearchItemIds(prev => prev.filter(id => id !== itemId)); | |||
| setSecondSearchResults(prev => prev.filter((searchItem) => (searchItem.rowKey || toSearchRowKey(searchItem.id, searchItem.uomId)) !== key)); | |||
| setSelectedSecondSearchItemIds(prev => prev.filter(id => id !== key)); | |||
| } | |||
| }, 0); | |||
| }, [secondSearchResults, selectedSecondSearchItemIds, isItemInCreated, targetDate]); | |||
| const handleSearchItemGroupChange = useCallback((itemId: number, groupId: string) => { | |||
| const handleSearchItemGroupChange = useCallback((rowKey: string, groupId: string) => { | |||
| const gid = groupId ? Number(groupId) : undefined; | |||
| const group = groups.find(g => g.id === gid); | |||
| setSecondSearchResults(prev => prev.map(item => | |||
| item.id === itemId | |||
| (item.rowKey || toSearchRowKey(item.id, item.uomId)) === rowKey | |||
| ? { | |||
| ...item, | |||
| groupId: gid, | |||
| @@ -492,9 +498,8 @@ const handleQtyBlur = useCallback((itemId: number) => { | |||
| : item | |||
| )); | |||
| // Check auto-add after group assignment | |||
| setTimeout(() => { | |||
| checkAndAutoAddItem(itemId); | |||
| checkAndAutoAddItem(rowKey); | |||
| }, 0); | |||
| }, [groups, checkAndAutoAddItem]); | |||
| // 5) 选中新增的待选项:依然按“当前 Group”赋 groupId + targetDate(新加入的应随 Group) | |||
| @@ -550,6 +555,7 @@ const handleQtyBlur = useCallback((itemId: number) => { | |||
| uomId: item.uomId, | |||
| uom: item.uom, | |||
| uomDesc: item.uomDesc, | |||
| rowKey: toSearchRowKey(item.id, item.uomId), | |||
| currentStockBalance: item.currentStockBalance, | |||
| qty: null, | |||
| targetDate: targetDate, | |||
| @@ -1017,7 +1023,7 @@ const handleQtyBlur = useCallback((itemId: number) => { | |||
| name: "id", | |||
| label: "", | |||
| type: "checkbox", | |||
| disabled: (item) => isItemInCreated(item.id), // Disable if already in created items | |||
| disabled: (item) => isItemInCreated(item.id, item.uomId), // Disable if already in created items | |||
| }, | |||
| { | |||
| @@ -1141,7 +1147,7 @@ const handleQtyBlur = useCallback((itemId: number) => { | |||
| }, [formProps]); | |||
| // 添加数量变更处理函数 | |||
| const handleSecondSearchQtyChange = useCallback((itemId: number, newQty: number | null) => { | |||
| const handleSecondSearchQtyChange = useCallback((rowKey: string, newQty: number | null) => { | |||
| const getClampedQty = (qty: number | null, stock?: number) => { | |||
| if (qty === null) return null; | |||
| const maxQty = Math.max(0, stock ?? 0); | |||
| @@ -1150,7 +1156,9 @@ const handleQtyBlur = useCallback((itemId: number) => { | |||
| setSecondSearchResults(prev => | |||
| prev.map(item => | |||
| item.id === itemId ? { ...item, qty: getClampedQty(newQty, item.currentStockBalance) } : item | |||
| (item.rowKey || toSearchRowKey(item.id, item.uomId)) === rowKey | |||
| ? { ...item, qty: getClampedQty(newQty, item.currentStockBalance) } | |||
| : item | |||
| ) | |||
| ); | |||
| @@ -1163,24 +1171,24 @@ const handleQtyBlur = useCallback((itemId: number) => { | |||
| const newIds = ids(selectedSecondSearchItemIds); | |||
| setSelectedSecondSearchItemIds(newIds); | |||
| // 处理全选逻辑 - 选择所有搜索结果,不仅仅是当前页面 | |||
| if (newIds.length === secondSearchResults.length) { | |||
| // 全选:将所有搜索结果添加到创建项目 | |||
| secondSearchResults.forEach(item => { | |||
| if (!isItemInCreated(item.id)) { | |||
| if (!isItemInCreated(item.id, item.uomId)) { | |||
| handleSearchItemSelect(item.id, true); | |||
| } | |||
| }); | |||
| } else { | |||
| // 部分选择:只处理当前页面的选择 | |||
| secondSearchResults.forEach(item => { | |||
| const isSelected = newIds.includes(item.id); | |||
| const isCurrentlyInCreated = isItemInCreated(item.id); | |||
| const key = item.rowKey || toSearchRowKey(item.id, item.uomId); | |||
| const isSelected = newIds.includes(key); | |||
| const isCurrentlyInCreated = isItemInCreated(item.id, item.uomId); | |||
| if (isSelected && !isCurrentlyInCreated) { | |||
| handleSearchItemSelect(item.id, true); | |||
| } else if (!isSelected && isCurrentlyInCreated) { | |||
| setCreatedItems(prev => prev.filter(createdItem => createdItem.itemId !== item.id)); | |||
| setCreatedItems(prev => prev.filter(createdItem => | |||
| !(createdItem.itemId === item.id && Number(createdItem.uomId) === Number(item.uomId)) | |||
| )); | |||
| } | |||
| }); | |||
| } | |||
| @@ -1192,13 +1200,19 @@ const handleQtyBlur = useCallback((itemId: number) => { | |||
| const newlyDeselected = previousIds.filter(id => !ids.includes(id)); | |||
| newlySelected.forEach(id => { | |||
| if (!isItemInCreated(id as number)) { | |||
| handleSearchItemSelect(id as number, true); | |||
| const row = secondSearchResults.find(item => (item.rowKey || toSearchRowKey(item.id, item.uomId)) === String(id)); | |||
| if (row && !isItemInCreated(row.id, row.uomId)) { | |||
| handleSearchItemSelect(row.id, true); | |||
| } | |||
| }); | |||
| newlyDeselected.forEach(id => { | |||
| setCreatedItems(prev => prev.filter(createdItem => createdItem.itemId !== id)); | |||
| const row = secondSearchResults.find(item => (item.rowKey || toSearchRowKey(item.id, item.uomId)) === String(id)); | |||
| if (row) { | |||
| setCreatedItems(prev => prev.filter(createdItem => | |||
| !(createdItem.itemId === row.id && Number(createdItem.uomId) === Number(row.uomId)) | |||
| )); | |||
| } | |||
| }); | |||
| } | |||
| }, [selectedSecondSearchItemIds, secondSearchResults, isItemInCreated, handleSearchItemSelect]); | |||
| @@ -1209,7 +1223,7 @@ const handleQtyBlur = useCallback((itemId: number) => { | |||
| name: "id", | |||
| label: "", | |||
| type: "checkbox", | |||
| disabled: (item) => isItemInCreated(item.id), | |||
| disabled: (item) => isItemInCreated(item.id, item.uomId), | |||
| }, | |||
| { | |||
| name: "label", | |||
| @@ -1260,7 +1274,7 @@ const handleQtyBlur = useCallback((itemId: number) => { | |||
| renderCell: (item) => ( | |||
| <Box sx={{ display: 'flex', justifyContent: 'flex-end', width: '100%' }}> | |||
| <Typography sx={{ textAlign: 'right' }}> {/* Add right alignment for the value */} | |||
| {item.uom || "-"} | |||
| {item.uomDesc || item.uom || "-"} | |||
| </Typography> | |||
| </Box> | |||
| ), | |||
| @@ -1280,7 +1294,7 @@ const handleQtyBlur = useCallback((itemId: number) => { | |||
| // Only allow numbers | |||
| if (value === "" || /^\d+$/.test(value)) { | |||
| const numValue = value === "" ? null : Number(value); | |||
| handleSecondSearchQtyChange(item.id, numValue); | |||
| handleSecondSearchQtyChange(item.rowKey || toSearchRowKey(item.id, item.uomId), numValue); | |||
| } | |||
| }} | |||
| inputProps={{ | |||
| @@ -1299,7 +1313,7 @@ const handleQtyBlur = useCallback((itemId: number) => { | |||
| const value = e.target.value; | |||
| const numValue = value === "" ? null : Number(value); | |||
| if (numValue !== null && numValue < 1) { | |||
| handleSecondSearchQtyChange(item.id, 1); // Enforce min value | |||
| handleSecondSearchQtyChange(item.rowKey || toSearchRowKey(item.id, item.uomId), 1); // Enforce min value | |||
| } | |||
| }} | |||
| /> | |||
| @@ -1368,6 +1382,7 @@ const handleQtyBlur = useCallback((itemId: number) => { | |||
| uomId: item.uomId, | |||
| uom: item.uom, | |||
| uomDesc: item.uomDesc, | |||
| rowKey: toSearchRowKey(item.id, item.uomId), | |||
| currentStockBalance: item.currentStockBalance, | |||
| qty: null, | |||
| targetDate: undefined, | |||
| @@ -1493,7 +1508,7 @@ const handleQtyBlur = useCallback((itemId: number) => { | |||
| }, []); | |||
| const getValidationMessage = useCallback(() => { | |||
| const selectedItems = secondSearchResults.filter(item => | |||
| selectedSecondSearchItemIds.includes(item.id) | |||
| selectedSecondSearchItemIds.includes(item.rowKey || toSearchRowKey(item.id, item.uomId)) | |||
| ); | |||
| const itemsWithoutGroup = selectedItems.filter(item => | |||
| @@ -1517,42 +1532,42 @@ const getValidationMessage = useCallback(() => { | |||
| // Fix the handleAddSelectedToCreatedItems function to properly clear selections | |||
| const handleAddSelectedToCreatedItems = useCallback(() => { | |||
| const selectedItems = secondSearchResults.filter(item => | |||
| selectedSecondSearchItemIds.includes(item.id) | |||
| selectedSecondSearchItemIds.includes(item.rowKey || toSearchRowKey(item.id, item.uomId)) | |||
| ); | |||
| // Add selected items to created items with their own group info | |||
| selectedItems.forEach(item => { | |||
| if (!isItemInCreated(item.id)) { | |||
| const newCreatedItem: CreatedItem = { | |||
| itemId: item.id, | |||
| itemName: item.label, | |||
| itemCode: item.label, | |||
| qty: item.qty || 1, | |||
| uom: item.uom || "", | |||
| uomId: item.uomId || 0, | |||
| uomDesc: item.uomDesc || "", | |||
| isSelected: true, | |||
| currentStockBalance: item.currentStockBalance, | |||
| targetDate: item.targetDate || targetDate, | |||
| groupId: item.groupId || undefined, | |||
| }; | |||
| setCreatedItems(prev => [...prev, newCreatedItem]); | |||
| } | |||
| }); | |||
| // Clear the selection | |||
| const toAdd: CreatedItem[] = []; | |||
| const addedKeys: string[] = []; | |||
| for (const item of selectedItems) { | |||
| const key = item.rowKey || toSearchRowKey(item.id, item.uomId); | |||
| if (isItemInCreated(item.id, item.uomId)) continue; | |||
| toAdd.push({ | |||
| itemId: item.id, | |||
| itemName: item.label, | |||
| itemCode: item.label, | |||
| qty: item.qty || 1, | |||
| uom: item.uom || "", | |||
| uomId: Number(item.uomId) || 0, | |||
| uomDesc: item.uomDesc || "", | |||
| isSelected: true, | |||
| currentStockBalance: item.currentStockBalance, | |||
| targetDate: item.targetDate || targetDate, | |||
| groupId: item.groupId || undefined, | |||
| }); | |||
| addedKeys.push(key); | |||
| } | |||
| if (toAdd.length > 0) { | |||
| setCreatedItems((prev) => [...prev, ...toAdd]); | |||
| } | |||
| setSelectedSecondSearchItemIds([]); | |||
| // Remove the selected/added items from search results entirely | |||
| setSecondSearchResults(prev => prev.filter(item => | |||
| !selectedSecondSearchItemIds.includes(item.id) | |||
| )); | |||
| setSecondSearchResults((prev) => | |||
| prev.filter((item) => !addedKeys.includes(item.rowKey || toSearchRowKey(item.id, item.uomId))), | |||
| ); | |||
| }, [secondSearchResults, selectedSecondSearchItemIds, isItemInCreated, targetDate]); | |||
| // Add a validation function to check if selected items are valid | |||
| const areSelectedItemsValid = useCallback(() => { | |||
| const selectedItems = secondSearchResults.filter(item => | |||
| selectedSecondSearchItemIds.includes(item.id) | |||
| selectedSecondSearchItemIds.includes(item.rowKey || toSearchRowKey(item.id, item.uomId)) | |||
| ); | |||
| return selectedItems.every(item => | |||
| @@ -1566,17 +1581,17 @@ const getValidationMessage = useCallback(() => { | |||
| // Move these handlers to the component level (outside of CustomSearchResultsTable) | |||
| // Handle individual checkbox change - ONLY select, don't add to created items | |||
| const handleIndividualCheckboxChange = useCallback((itemId: number, checked: boolean) => { | |||
| const handleIndividualCheckboxChange = useCallback((rowKey: string, checked: boolean) => { | |||
| checkboxChangeCallCount++; | |||
| if (checked) { | |||
| // Add to selected IDs | |||
| setSelectedSecondSearchItemIds(prev => [...prev, itemId]); | |||
| setSelectedSecondSearchItemIds(prev => [...prev, rowKey]); | |||
| // Set the item's group and targetDate to current group when selected | |||
| setSecondSearchResults(prev => { | |||
| const updatedResults = prev.map(item => | |||
| item.id === itemId | |||
| (item.rowKey || toSearchRowKey(item.id, item.uomId)) === rowKey | |||
| ? { | |||
| ...item, | |||
| groupId: selectedGroup?.id || undefined, | |||
| @@ -1588,29 +1603,27 @@ const getValidationMessage = useCallback(() => { | |||
| // Check if should auto-add after state update | |||
| setTimeout(() => { | |||
| // Check if we're already processing this item | |||
| if (processingItems.has(itemId)) { | |||
| //alert(`Item ${itemId} is already being processed, skipping duplicate auto-add`); | |||
| if (processingItems.has(rowKey)) { | |||
| return; | |||
| } | |||
| const updatedItem = updatedResults.find(i => i.id === itemId); | |||
| const updatedItem = updatedResults.find(i => (i.rowKey || toSearchRowKey(i.id, i.uomId)) === rowKey); | |||
| if (updatedItem) { | |||
| const isSelected = true; // We just selected it | |||
| const hasGroup = updatedItem.groupId !== undefined && updatedItem.groupId !== null; | |||
| const hasQty = updatedItem.qty !== null && updatedItem.qty !== undefined && updatedItem.qty > 0; | |||
| // Only auto-add if item has quantity (scenario 2: enter quantity first, then select) | |||
| if (isSelected && hasGroup && hasQty && !isItemInCreated(updatedItem.id)) { | |||
| // Mark this item as being processed | |||
| processingItems.add(itemId); | |||
| if (isSelected && hasGroup && hasQty && !isItemInCreated(updatedItem.id, updatedItem.uomId)) { | |||
| processingItems.add(rowKey); | |||
| const newCreatedItem: CreatedItem = { | |||
| itemId: updatedItem.id, | |||
| itemName: updatedItem.label, | |||
| itemCode: updatedItem.label, | |||
| qty: updatedItem.qty || 1, | |||
| uom: updatedItem.uom || "", | |||
| uomId: updatedItem.uomId || 0, | |||
| uomId: Number(updatedItem.uomId) || 0, | |||
| uomDesc: updatedItem.uomDesc || "", | |||
| isSelected: true, | |||
| currentStockBalance: updatedItem.currentStockBalance, | |||
| @@ -1618,29 +1631,16 @@ const getValidationMessage = useCallback(() => { | |||
| groupId: updatedItem.groupId || undefined, | |||
| }; | |||
| setCreatedItems(prev => [...prev, newCreatedItem]); | |||
| setSecondSearchResults(current => current.filter(searchItem => searchItem.id !== itemId)); | |||
| setSelectedSecondSearchItemIds(current => current.filter(id => id !== itemId)); | |||
| // Remove from processing set after a short delay | |||
| setSecondSearchResults(current => current.filter(searchItem => | |||
| (searchItem.rowKey || toSearchRowKey(searchItem.id, searchItem.uomId)) !== rowKey | |||
| )); | |||
| setSelectedSecondSearchItemIds(current => current.filter(id => id !== rowKey)); | |||
| setTimeout(() => { | |||
| processingItems.delete(itemId); | |||
| processingItems.delete(rowKey); | |||
| }, 100); | |||
| } | |||
| // Show final debug info in one alert | |||
| /* | |||
| alert(`FINAL DEBUG INFO for item ${itemId}: | |||
| Function called ${checkboxChangeCallCount} times | |||
| Is Selected: ${isSelected} | |||
| Has Group: ${hasGroup} | |||
| Has Quantity: ${hasQty} | |||
| Quantity: ${updatedItem.qty} | |||
| Group ID: ${updatedItem.groupId} | |||
| Is Item In Created: ${isItemInCreated(updatedItem.id)} | |||
| Auto-add triggered: ${isSelected && hasGroup && hasQty && !isItemInCreated(updatedItem.id)} | |||
| Processing items: ${Array.from(processingItems).join(', ')}`); | |||
| */ | |||
| } | |||
| }, 0); | |||
| @@ -1648,11 +1648,11 @@ Processing items: ${Array.from(processingItems).join(', ')}`); | |||
| }); | |||
| } else { | |||
| // Remove from selected IDs | |||
| setSelectedSecondSearchItemIds(prev => prev.filter(id => id !== itemId)); | |||
| setSelectedSecondSearchItemIds(prev => prev.filter(id => id !== rowKey)); | |||
| // Clear the item's group and targetDate when deselected | |||
| setSecondSearchResults(prev => prev.map(item => | |||
| item.id === itemId | |||
| (item.rowKey || toSearchRowKey(item.id, item.uomId)) === rowKey | |||
| ? { | |||
| ...item, | |||
| groupId: undefined, | |||
| @@ -1668,17 +1668,19 @@ const handleSelectAllOnPage = useCallback((checked: boolean, paginatedResults: S | |||
| if (checked) { | |||
| // Select all items on current page that are not already in created items | |||
| const newSelectedIds = paginatedResults | |||
| .filter(item => !isItemInCreated(item.id)) | |||
| .map(item => item.id); | |||
| .filter(item => !isItemInCreated(item.id, item.uomId)) | |||
| .map(item => item.rowKey || toSearchRowKey(item.id, item.uomId)); | |||
| setSelectedSecondSearchItemIds(prev => { | |||
| const existingIds = prev.filter(id => !paginatedResults.some(item => item.id === id)); | |||
| const existingIds = prev.filter(id => !paginatedResults.some(item => | |||
| (item.rowKey || toSearchRowKey(item.id, item.uomId)) === id | |||
| )); | |||
| return [...existingIds, ...newSelectedIds]; | |||
| }); | |||
| // Set group and targetDate for all selected items on current page | |||
| setSecondSearchResults(prev => prev.map(item => | |||
| newSelectedIds.includes(item.id) | |||
| newSelectedIds.includes(item.rowKey || toSearchRowKey(item.id, item.uomId)) | |||
| ? { | |||
| ...item, | |||
| groupId: selectedGroup?.id || undefined, | |||
| @@ -1688,12 +1690,12 @@ const handleSelectAllOnPage = useCallback((checked: boolean, paginatedResults: S | |||
| )); | |||
| } else { | |||
| // Deselect all items on current page | |||
| const pageItemIds = paginatedResults.map(item => item.id); | |||
| setSelectedSecondSearchItemIds(prev => prev.filter(id => !pageItemIds.includes(id as number))); | |||
| const pageItemIds = paginatedResults.map(item => item.rowKey || toSearchRowKey(item.id, item.uomId)); | |||
| setSelectedSecondSearchItemIds(prev => prev.filter(id => !pageItemIds.includes(String(id)))); | |||
| // Clear group and targetDate for all deselected items on current page | |||
| setSecondSearchResults(prev => prev.map(item => | |||
| pageItemIds.includes(item.id) | |||
| pageItemIds.includes(item.rowKey || toSearchRowKey(item.id, item.uomId)) | |||
| ? { | |||
| ...item, | |||
| groupId: undefined, | |||
| @@ -0,0 +1,350 @@ | |||
| "use client"; | |||
| import React, { useState, useEffect, useCallback, useRef } from "react"; | |||
| import { | |||
| Box, | |||
| Typography, | |||
| Card, | |||
| CardContent, | |||
| Table, | |||
| TableBody, | |||
| TableCell, | |||
| TableContainer, | |||
| TableHead, | |||
| TableRow, | |||
| Paper, | |||
| CircularProgress, | |||
| Stack, | |||
| IconButton, | |||
| Collapse, | |||
| Link as MuiLink, | |||
| } from "@mui/material"; | |||
| import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; | |||
| import ExpandLessIcon from "@mui/icons-material/ExpandLess"; | |||
| import NextLink from "next/link"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import dayjs from "dayjs"; | |||
| import type { Dayjs } from "dayjs"; | |||
| import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers"; | |||
| import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | |||
| import { | |||
| fetchDrinkProductionQty, | |||
| DrinkProductionQtyResponse, | |||
| } from "@/app/api/jo/actions"; | |||
| const REFRESH_INTERVAL = 10 * 60 * 1000; // 10 分鐘 | |||
| const formatQty = (qty: number | null | undefined): string => { | |||
| if (qty === null || qty === undefined || Number.isNaN(qty)) return "-"; | |||
| return qty.toLocaleString(undefined, { maximumFractionDigits: 2 }); | |||
| }; | |||
| const formatProductionDate = (value: string | null | undefined): string => { | |||
| if (!value) return "-"; | |||
| const parsed = dayjs(value); | |||
| return parsed.isValid() ? parsed.format("YYYY-MM-DD") : value; | |||
| }; | |||
| const getRowKey = (row: DrinkProductionQtyResponse, idx: number): string => | |||
| `${row.itemCode || "unknown"}-${idx}`; | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 26 | v1.0.0 | 2026-07-20 */ | |||
| const DrinkProductionQtyDashboard: React.FC = () => { | |||
| const { t } = useTranslation(["common", "jo", "productionProcess"]); | |||
| const [data, setData] = useState<DrinkProductionQtyResponse[]>([]); | |||
| const [loading, setLoading] = useState<boolean>(true); | |||
| const [selectedDate, setSelectedDate] = useState<Dayjs>(dayjs()); | |||
| const [expandedRowKeys, setExpandedRowKeys] = useState<Set<string>>( | |||
| new Set(), | |||
| ); | |||
| const refreshCountRef = useRef<number>(0); | |||
| const [lastDataRefreshTime, setLastDataRefreshTime] = useState<Dayjs | null>( | |||
| null, | |||
| ); | |||
| const loadData = useCallback(async () => { | |||
| setLoading(true); | |||
| try { | |||
| const result = await fetchDrinkProductionQty( | |||
| selectedDate.format("YYYY-MM-DD"), | |||
| ); | |||
| setData(result || []); | |||
| setExpandedRowKeys(new Set()); | |||
| setLastDataRefreshTime(dayjs()); | |||
| refreshCountRef.current += 1; | |||
| } catch (error) { | |||
| console.error("Error fetching drink production qty:", error); | |||
| setData([]); | |||
| setExpandedRowKeys(new Set()); | |||
| } finally { | |||
| setLoading(false); | |||
| } | |||
| }, [selectedDate]); | |||
| useEffect(() => { | |||
| loadData(); | |||
| const interval = setInterval(() => { | |||
| loadData(); | |||
| }, REFRESH_INTERVAL); | |||
| return () => clearInterval(interval); | |||
| }, [loadData]); | |||
| const toggleRowExpanded = (rowKey: string) => { | |||
| setExpandedRowKeys((prev) => { | |||
| const next = new Set(prev); | |||
| if (next.has(rowKey)) { | |||
| next.delete(rowKey); | |||
| } else { | |||
| next.add(rowKey); | |||
| } | |||
| return next; | |||
| }); | |||
| }; | |||
| return ( | |||
| <Card sx={{ mb: 2 }}> | |||
| <CardContent> | |||
| <Typography variant="h5" sx={{ fontWeight: 600, mb: 2 }}> | |||
| {t("Drink Production Qty Dashboard")} | |||
| </Typography> | |||
| <Stack direction="row" spacing={2} sx={{ mb: 3, alignItems: "center" }}> | |||
| <LocalizationProvider dateAdapter={AdapterDayjs}> | |||
| <DatePicker | |||
| label={t("Date")} | |||
| value={selectedDate} | |||
| onChange={(newValue) => { | |||
| if (newValue) setSelectedDate(newValue); | |||
| }} | |||
| format="YYYY-MM-DD" | |||
| slotProps={{ | |||
| textField: { size: "small", sx: { minWidth: 160 } }, | |||
| }} | |||
| /> | |||
| </LocalizationProvider> | |||
| <Box sx={{ flexGrow: 1 }} /> | |||
| <Typography | |||
| variant="body2" | |||
| sx={{ color: "text.secondary" }} | |||
| suppressHydrationWarning | |||
| > | |||
| {t("Auto-refresh every 10 minutes")} | | |||
| {t("Last updated")}:{" "} | |||
| {lastDataRefreshTime | |||
| ? lastDataRefreshTime.format("HH:mm:ss") | |||
| : "--:--:--"} | |||
| </Typography> | |||
| </Stack> | |||
| {loading ? ( | |||
| <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}> | |||
| <CircularProgress /> | |||
| </Box> | |||
| ) : ( | |||
| <TableContainer | |||
| sx={{ | |||
| border: "3px solid #135fed", | |||
| overflowX: "auto", | |||
| maxHeight: 440, | |||
| overflow: "auto", | |||
| }} | |||
| component={Paper} | |||
| > | |||
| <Table size="small" sx={{ minWidth: 650 }}> | |||
| <TableHead> | |||
| <TableRow | |||
| sx={{ | |||
| bgcolor: "#424242", | |||
| "& th": { | |||
| borderBottom: "none", | |||
| py: 1.5, | |||
| position: "sticky", | |||
| top: 0, | |||
| zIndex: 1, | |||
| }, | |||
| }} | |||
| > | |||
| <TableCell sx={{ width: 48 }} /> | |||
| <TableCell sx={{ width: 160 }}> | |||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | |||
| {t("Item Code")} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell sx={{ width: 260 }}> | |||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | |||
| {t("Goods Name")} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell sx={{ width: 120 }}> | |||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | |||
| {t("Unit")} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell align="right" sx={{ width: 120 }}> | |||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | |||
| {t("Stock Req. Qty")} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell align="right" sx={{ width: 140 }}> | |||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | |||
| {t("Production Qty")} | |||
| </Typography> | |||
| </TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {data.length === 0 ? ( | |||
| <TableRow> | |||
| <TableCell colSpan={6} align="center"> | |||
| <Typography | |||
| variant="body2" | |||
| sx={{ py: 2, color: "text.secondary" }} | |||
| > | |||
| {t("No data available")} | |||
| </Typography> | |||
| </TableCell> | |||
| </TableRow> | |||
| ) : ( | |||
| data.map((row, idx) => { | |||
| const rowKey = getRowKey(row, idx); | |||
| const jobOrders = row.jobOrders ?? []; | |||
| const isExpanded = expandedRowKeys.has(rowKey); | |||
| const hasJobOrders = jobOrders.length > 0; | |||
| return ( | |||
| <React.Fragment key={rowKey}> | |||
| <TableRow hover={hasJobOrders}> | |||
| <TableCell padding="checkbox"> | |||
| {hasJobOrders ? ( | |||
| <IconButton | |||
| size="small" | |||
| aria-label={ | |||
| isExpanded | |||
| ? t("Collapse job order details") | |||
| : t("Expand job order details") | |||
| } | |||
| onClick={() => toggleRowExpanded(rowKey)} | |||
| > | |||
| {isExpanded ? ( | |||
| <ExpandLessIcon fontSize="small" /> | |||
| ) : ( | |||
| <ExpandMoreIcon fontSize="small" /> | |||
| )} | |||
| </IconButton> | |||
| ) : null} | |||
| </TableCell> | |||
| <TableCell> | |||
| <Typography variant="body2"> | |||
| {row.itemCode || "-"} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell> | |||
| <Typography variant="body2"> | |||
| {row.itemName || "-"} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell> | |||
| <Typography variant="body2"> | |||
| {row.uom || "-"} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| <Typography variant="body2"> | |||
| {formatQty(row.totalReqQty)} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| <Typography variant="body2"> | |||
| {formatQty(row.totalQty)} | |||
| </Typography> | |||
| </TableCell> | |||
| </TableRow> | |||
| {hasJobOrders && ( | |||
| <TableRow> | |||
| <TableCell | |||
| colSpan={6} | |||
| sx={{ py: 0, borderBottom: 0 }} | |||
| > | |||
| <Collapse | |||
| in={isExpanded} | |||
| timeout="auto" | |||
| unmountOnExit | |||
| > | |||
| <Box sx={{ py: 1.5, pl: 6, pr: 2 }}> | |||
| <Table size="small"> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell sx={{ fontWeight: 600 }}> | |||
| {t("Job Order Code")} | |||
| </TableCell> | |||
| <TableCell sx={{ fontWeight: 600 }}> | |||
| {t("Production Date")} | |||
| </TableCell> | |||
| <TableCell | |||
| align="right" | |||
| sx={{ fontWeight: 600 }} | |||
| > | |||
| {t("Stock Req. Qty")} | |||
| </TableCell> | |||
| <TableCell | |||
| align="right" | |||
| sx={{ fontWeight: 600 }} | |||
| > | |||
| {t("Production Qty")} | |||
| </TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {jobOrders.map((jo) => ( | |||
| <TableRow | |||
| key={`${rowKey}-jo-${jo.jobOrderId}`} | |||
| > | |||
| <TableCell> | |||
| {jo.jobOrderId > 0 ? ( | |||
| <MuiLink | |||
| component={NextLink} | |||
| href={`/jo/edit?id=${jo.jobOrderId}`} | |||
| underline="hover" | |||
| > | |||
| {jo.jobOrderCode || | |||
| `JO-${jo.jobOrderId}`} | |||
| </MuiLink> | |||
| ) : ( | |||
| jo.jobOrderCode || "-" | |||
| )} | |||
| </TableCell> | |||
| <TableCell> | |||
| {formatProductionDate( | |||
| jo.productionDate, | |||
| )} | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| {formatQty(jo.reqQty)} | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| {formatQty(jo.productionQty)} | |||
| </TableCell> | |||
| </TableRow> | |||
| ))} | |||
| </TableBody> | |||
| </Table> | |||
| </Box> | |||
| </Collapse> | |||
| </TableCell> | |||
| </TableRow> | |||
| )} | |||
| </React.Fragment> | |||
| ); | |||
| }) | |||
| )} | |||
| </TableBody> | |||
| </Table> | |||
| </TableContainer> | |||
| )} | |||
| </CardContent> | |||
| </Card> | |||
| ); | |||
| }; | |||
| export default DrinkProductionQtyDashboard; | |||
| @@ -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); | |||
| @@ -717,21 +716,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> | |||
| @@ -741,8 +731,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> | |||
| @@ -979,19 +969,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,12 +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"; | |||
| @@ -23,26 +24,20 @@ interface ProductionProcessPageProps { | |||
| 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(); | |||
| @@ -51,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) { | |||
| @@ -76,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') { | |||
| @@ -102,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); | |||
| @@ -113,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]); | |||
| @@ -127,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 | |||
| @@ -152,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', | |||
| @@ -203,18 +198,16 @@ 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")} /> | |||
| <Tab label={t("Drink Production Qty Dashboard")} /> | |||
| </Tabs> | |||
| {tabIndex === 0 && ( | |||
| <ProductionProcessList | |||
| printerCombo={printerCombo} | |||
| qcReady={false} | |||
| disableDateFilter={false} | |||
| printerCombo={selectedPrinter ? [selectedPrinter] : printerCombo} | |||
| listPersistedState={productionListState} | |||
| onListPersistedStateChange={setProductionListState} | |||
| onSelectProcess={(jobOrderId) => { | |||
| @@ -234,59 +227,25 @@ 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 === 2 && ( | |||
| <JobProcessStatus /> | |||
| )} | |||
| {tabIndex === 3 && ( | |||
| <JobProcessStatus /> | |||
| <OperatorKpiDashboard /> | |||
| )} | |||
| {tabIndex === 4 && ( | |||
| <OperatorKpiDashboard /> | |||
| <EquipmentStatusDashboard /> | |||
| )} | |||
| {tabIndex === 5 && ( | |||
| <EquipmentStatusDashboard /> | |||
| <DrinkProductionQtyDashboard /> | |||
| )} | |||
| </Box> | |||
| <QcStockInModal | |||
| @@ -303,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" }, | |||
| ], | |||
| }, | |||
| ] | |||
| @@ -1,75 +1,28 @@ | |||
| { | |||
| "-{{Variance}}≤Variance Percentage ≤{{Variance}} will be filtered out": "-{{Variance}}≤Variance Percentage ≤{{Variance}} will be filtered out", | |||
| "AVAILABLE": "AVAILABLE", | |||
| "Action": "Action", | |||
| "Add": "Add", | |||
| "Add entry": "Add entry", | |||
| "Add entry for items without inventory": "Add entry for items without inventory", | |||
| "Adjusted Qty": "Adjusted Qty", | |||
| "Approve": "Approve", | |||
| "Approver All": "Approver All", | |||
| "Approver search empty hint": "Set search criteria and click Search", | |||
| "ApproverAll": "ApproverAll", | |||
| "Available": "Available", | |||
| "Available Qty": "Available Qty", | |||
| "Available Qty Per Smallest Unit": "Available Qty Per Smallest Unit", | |||
| "Average unit price": "Average unit price", | |||
| "Back": "Back", | |||
| "Bad Item Handle": "Bad Item Handle", | |||
| "Bad Item Qty": "Bad Item Qty", | |||
| "Bad Item Records": "Bad Item Records", | |||
| "Balance Qty": "Balance Qty", | |||
| "Base UoM": "Base UoM", | |||
| "Batch Save Completed": "Batch Save Completed", | |||
| "Batch Save Inputted": "Batch Save Inputted", | |||
| "Batch Submit All": "Batch Submit All", | |||
| "Batch approver save completed: {{success}} success, {{skipped}} skipped, {{errors}} errors": "Batch approver save completed: {{success}} success, {{skipped}} skipped, {{errors}} errors", | |||
| "Body is unavailable": "Body is unavailable", | |||
| "Bom Req. Qty": "Bom Req. Qty", | |||
| "CMB": "Consumables", | |||
| "CO": "Consumables", | |||
| "Clear selection": "Clear selection", | |||
| "Cancel": "Cancel", | |||
| "Code": "Code", | |||
| "Collapse floor sections": "Collapse sections for this floor", | |||
| "Confirm Adjustment": "Confirm Adjustment", | |||
| "Confirm create stock take for all sections?": "Confirm create stock take for all sections?", | |||
| "Confirm remove": "Confirm remove", | |||
| "Counted Qty": "Counted Qty", | |||
| "Create Stock Take for All Sections": "Create Stock Take for All Sections", | |||
| "Current Stock": "Current Stock", | |||
| "Delivery Order": "Delivery Order", | |||
| "Difference": "Difference", | |||
| "Download QR Code": "Download QR Code", | |||
| "Edit mode": "Edit mode", | |||
| "End Date": "End Date", | |||
| "Enter item code or name to search": "Enter item code or name to search", | |||
| "Expand floor sections": "Expand sections for this floor", | |||
| "Expiry Date": "Expiry Date", | |||
| "Expiry End Date": "Expiry End Date", | |||
| "Expiry Item Handle": "Expiry Item Handle", | |||
| "Expiry Item Qty": "Expiry Item Qty", | |||
| "Expiry Item Records": "Expiry Item Records", | |||
| "Expiry Qty": "Expiry Qty", | |||
| "Expiry Start Date": "Expiry Start Date", | |||
| "FG": "Finished good", | |||
| "Failed to transfer stock": "Failed to transfer stock", | |||
| "Failed to transfer stock. Please try again.": "Failed to transfer stock. Please try again.", | |||
| "Filtered out": "Filtered out", | |||
| "First Qty": "First Qty", | |||
| "Handled Date": "Handled Date", | |||
| "Handler": "Handler", | |||
| "Hide Search Filters": "Hide Search Filters", | |||
| "In Qty": "In Qty", | |||
| "Input": "Input", | |||
| "Invalid QTY": "Invalid quantity", | |||
| "Inventory": "Inventory", | |||
| "Inventory Exception Management": "Inventory Exception Management", | |||
| "Item Name": "Item Name", | |||
| "Item": "Item", | |||
| "Item selected": "Item selected", | |||
| "Item-lotNo": "Item-lotNo", | |||
| "Job Order": "Job Order", | |||
| "Latest market unit price": "Latest market unit price", | |||
| "Loading": "Loading", | |||
| "Location": "Location", | |||
| "Lot No": "Lot No", | |||
| "MA": "Material", | |||
| "MI": "Miscellaneous", | |||
| @@ -77,130 +30,59 @@ | |||
| "NM": "Miscellaneous / non-consumables", | |||
| "Name": "Name", | |||
| "No Data": "No Data", | |||
| "No changes to submit": "No changes to submit", | |||
| "No issues found": "No issues found", | |||
| "No data": "No data", | |||
| "No items are selected yet.": "No items are selected yet.", | |||
| "No lot no entered, will be generated by system.": "No lot no entered, will be generated by system.", | |||
| "No record found": "No record found", | |||
| "Opening Inventory": "Opening Inventory", | |||
| "Optional - system will generate": "Optional - system will generate", | |||
| "Original Qty": "Original Qty", | |||
| "Out Qty": "Out Qty", | |||
| "Perform Stock Take": "Perform Stock Take", | |||
| "Pick Order, Issue No, Item, Lot...": "Pick Order, Issue No, Item, Lot...", | |||
| "Please enter QTY and Bad QTY": "Please enter QTY and Bad QTY", | |||
| "Please scan...": "Please scan...", | |||
| "Please set at least one search criterion": "Please set at least one search criterion", | |||
| "Print": "Print", | |||
| "Print QR Code": "Print QR Code", | |||
| "Print Qty": "Print Qty", | |||
| "Print failed": "Print failed", | |||
| "Print sent": "Print sent", | |||
| "Printer": "Printer", | |||
| "Qty": "Qty", | |||
| "Qty To Be Transferred": "Qty To Be Transferred", | |||
| "Quantity exceeds available quantity": "Quantity exceeds available quantity", | |||
| "RM": "Raw material", | |||
| "Reason for adjustment": "Reason for adjustment", | |||
| "Reason for removal": "Reason for removal", | |||
| "Refresh": "Refresh", | |||
| "Remaining Qty": "Remaining Qty", | |||
| "Remarks": "Remarks", | |||
| "Remove": "Remove", | |||
| "Resolved": "Resolved", | |||
| "Review Variance": "Review Variance", | |||
| "Rows per page": "Rows per page", | |||
| "Reset": "Reset", | |||
| "SFG": "Semi-finished good", | |||
| "Sales UoM": "Sales UoM", | |||
| "Save": "Save", | |||
| "Save failed": "Save failed", | |||
| "Saved successfully": "Saved successfully", | |||
| "Search lot by QR code": "Search lot by QR code", | |||
| "Search to load lot lines": "Search to load lot lines", | |||
| "Second Qty": "Second Qty", | |||
| "Select": "Select", | |||
| "Select Section": "Select Section", | |||
| "Select Stock Take Section": "Select Stock Take Section", | |||
| "Select all sections": "Select all sections", | |||
| "Select sections placeholder": "Select one or more", | |||
| "Select stock take sections to create hint": "Choose warehouse stock take sections to start a new round (selected sections share one new round id in this batch).", | |||
| "Selected section count": "Selected: {{count}} section(s)", | |||
| "Show Search Filters": "Show Search Filters", | |||
| "Something went wrong fetching data in server.": "Something went wrong fetching data in server.", | |||
| "Start Date": "Start Date", | |||
| "Start Location": "Start Location", | |||
| "Start New Stock Take": "Start New Stock Take", | |||
| "Start Time": "Start Time", | |||
| "Stock Adjustment": "Stock Adjustment", | |||
| "Stock Record": "Stock Record", | |||
| "Stock Take Variance": "Stock Take Variance", | |||
| "Stock Transfer": "Stock Transfer", | |||
| "Stock UoM": "Stock UoM", | |||
| "Stock take adjustment confirmed! (Demo only)": "Stock take adjustment confirmed! (Demo only)", | |||
| "Stock take adjustment has been confirmed successfully!": "Stock take adjustment has been confirmed successfully!", | |||
| "Stock take qty exceeds maximum": "Stock take quantity cannot exceed 999,999,999,999", | |||
| "Stock transfer created new lot": "Stock transfer completed (created new lot line).", | |||
| "Stock transfer merged ambiguous": "Merged into the earliest available batch (multiple available lines for the same lot).", | |||
| "Stock transfer merged existing lot": "Stock transfer completed (merged into existing lot).", | |||
| "Stock transfer successful": "Stock transfer completed", | |||
| "Stop QR Scan": "Stop QR Scan", | |||
| "Submit": "Submit", | |||
| "Submit completed: {{success}} success, {{errors}} errors": "Submit completed: {{success}} success, {{errors}} errors", | |||
| "System Qty": "System Qty", | |||
| "Target Location": "Target Location", | |||
| "Total Approved": "Total Approved", | |||
| "Total Issues": "Total Issues", | |||
| "Total Item Number": "Total Item Number", | |||
| "Total Stock Takes": "Total Stock Takes", | |||
| "Total need stock take": "Total need stock take", | |||
| "Type": "Type", | |||
| "UNAVAILABLE": "UNAVAILABLE", | |||
| "Variance filter strict bounds": "Exclude boundaries (use > <)", | |||
| "View": "View", | |||
| "UoM": "UoM", | |||
| "WIP": "Work in progress", | |||
| "Waiting for Approver": "Waiting for Approver", | |||
| "Warehouse": "Warehouse", | |||
| "adj": "adj", | |||
| "approver": "approver", | |||
| "approving": "approving", | |||
| "available": "available", | |||
| "bad": "bad", | |||
| "cmb": "Consumables", | |||
| "complete": "complete", | |||
| "completed": "completed", | |||
| "completed by": "completed by", | |||
| "completed date": "completed date", | |||
| "completed remarks": "completed remarks", | |||
| "completed status": "completed status", | |||
| "consumable": "Consumable", | |||
| "consumables": "Consumables", | |||
| "dnNo": "dnNo", | |||
| "expiry": "expiry", | |||
| "fg": "Finished good", | |||
| "item": "Item", | |||
| "mat": "Raw material", | |||
| "miss": "miss", | |||
| "nm": "Miscellaneous / non-consumables", | |||
| "non-consumables": "Non-consumables", | |||
| "nor": "nor", | |||
| "not available": "not available", | |||
| "not match": "not match", | |||
| "not pass": "not pass", | |||
| "notMatch": "notMatch", | |||
| "notmatch": "notmatch", | |||
| "open": "open", | |||
| "pass": "pass", | |||
| "pending": "pending", | |||
| "productLotNo": "productLotNo", | |||
| "rejected": "rejected", | |||
| "save": "save", | |||
| "section": "section", | |||
| "sfg": "Semi-finished good", | |||
| "stockTaking": "stockTaking", | |||
| "tke": "tke", | |||
| "to": "to", | |||
| "trf": "trf", | |||
| "unavailable": "unavailable", | |||
| "variance": "variance", | |||
| "wip": "Work in progress", | |||
| "材料": "Material" | |||
| } | |||
| @@ -0,0 +1,321 @@ | |||
| { | |||
| "title": "Item Tracing", | |||
| "subtitle": "Scan a lot QR code to trace the full lifecycle — stock in/out, QC, pick orders, job orders, stock take, transfers, and BOM links.", | |||
| "scanReady": "Scanner ready — scan lot label QR", | |||
| "scanning": "Scanning…", | |||
| "scanAgain": "Scan again", | |||
| "manualSearch": "Manual search", | |||
| "itemCode": "Item code", | |||
| "itemLot": "Item lot", | |||
| "lotNo": "Lot no.", | |||
| "search": "Trace", | |||
| "searching": "Tracing…", | |||
| "noResult": "No trace data. Scan a lot QR or search by item code and lot number.", | |||
| "notFound": "Lot not found or no permission to view.", | |||
| "traceError": "Unable to load trace data. Please try again or contact support.", | |||
| "scanError": "Invalid QR code. Expected lot label JSON with itemId and stockInLineId.", | |||
| "summary": "Lot summary", | |||
| "expiryDate": "Expiry", | |||
| "productionDate": "Production", | |||
| "stockInDate": "Stock in", | |||
| "totalAvailable": "Total available", | |||
| "warehouseBreakdown": "Warehouse breakdown", | |||
| "alternateLocationsTitle": "Same lot in other locations", | |||
| "alternateLocationsHint": "This lot may exist in multiple inventory records (e.g. after transfer). All locations are merged in the lifecycle graph above — click a row below to focus that warehouse in the graph.", | |||
| "traceAlternateLocation": "Focus in graph", | |||
| "focusWarehouseInGraph": "Focus in graph", | |||
| "sectionsMultiLocationHint": "Tables below merge events from the traced location and other warehouses. The warehouse column shows which inventory record each row belongs to.", | |||
| "action": "Action", | |||
| "timeline": "Movement timeline", | |||
| "flowGraph": "Lifecycle flow", | |||
| "flowLegendTime": "→ Time (later to the right)", | |||
| "flowLegendPhase": "↓ Phase (later stages below)", | |||
| "flowLegendBranch": "↔ Same day = left to right by time", | |||
| "flowLegendArrow": "Arrows only between phases", | |||
| "flowLegendPrelude": "↑ Pre-production at top", | |||
| "flowLegendTimeTip": "Horizontal axis = calendar dates, left to right", | |||
| "flowLegendPhaseTip": "Vertical axis = process phases, top to bottom in order", | |||
| "flowLegendBranchTip": "Events on the same calendar day are ordered left to right by timestamp; vertical lane still shows the process phase", | |||
| "flowLegendArrowTip": "Arrows link events in different phases to show flow", | |||
| "flowLegendPreludeTip": "FG lots: material inbound, QC (material + finished good), job pick at top", | |||
| "flowGraphPathJo": "Process order: material in → QC → putaway → pick → production / byproducts → FG in → warehouse → out → stock take", | |||
| "flowZoomIn": "Zoom in", | |||
| "flowZoomOut": "Zoom out", | |||
| "flowZoomFit": "Fit view", | |||
| "flowMinimapHide": "Hide minimap", | |||
| "flowMinimapShow": "Show minimap", | |||
| "flowZoomPanHint": "Scroll to zoom · drag to pan · click a node for details on the left · search nodes at top right", | |||
| "flowNodeDetailHint": "Click a node in the graph to view event details", | |||
| "flowGraphSearchPlaceholder": "Search nodes (doc no., lot, warehouse…)", | |||
| "flowGraphSearchNoMatch": "No matching nodes", | |||
| "flowGraphSearchMatch": "{{current}} / {{total}}", | |||
| "flowGraphSearchPrev": "Previous", | |||
| "flowGraphSearchNext": "Next", | |||
| "flowGraphSearchClear": "Clear", | |||
| "nodeDetailTitle": "Event details", | |||
| "nodeDetailClose": "Close", | |||
| "nodeExpired": "Expired", | |||
| "nodeDepleted": "Depleted", | |||
| "detailDirection": "Direction", | |||
| "categoryPurchase": "Purchase", | |||
| "categoryProduction": "Production", | |||
| "categoryOpen": "Lot open", | |||
| "categoryTerminal": "Terminal state", | |||
| "flowGraphHint": "Phases on the left, dates left to right. Same-day events are ordered left to right by time; arrows indicate cross-phase flow.", | |||
| "flowGraphHintJo": "Includes pre-production (material inbound, QC, job pick) and finished-good phases. One QC lane covers material and FG IQC.", | |||
| "phaseMaterialInbound": "Material inbound", | |||
| "phaseMaterialQc": "Material QC", | |||
| "phaseMaterialPick": "Job pick", | |||
| "phaseProduction": "Production / byproducts", | |||
| "nodeProductionStep": "Production step", | |||
| "nodeByproduct": "Byproduct lot", | |||
| "scrapQty": "Scrap qty", | |||
| "defectQty": "Defect qty", | |||
| "processOutputQty": "Process output", | |||
| "processScrapQty": "Scrap quantity", | |||
| "processDefectQty": "Defect quantity", | |||
| "equipment": "Equipment", | |||
| "processStep": "Process step", | |||
| "detailStepMaterials": "Step materials", | |||
| "detailAssignedStep": "Assigned process", | |||
| "traceByproductLot": "Trace byproduct lot", | |||
| "nodeScrap": "Scrap", | |||
| "nodeDefect": "Defect", | |||
| "nodeOpen": "Opening stock", | |||
| "nodeFail": "Pick failure", | |||
| "nodeDoOut": "Delivery outbound", | |||
| "nodeReplenishmentCreated": "Replenishment created", | |||
| "doOutboundExtra": "Add-on", | |||
| "doOutboundReplenish": "Replenishment", | |||
| "detailDoOutboundKind": "Outbound type", | |||
| "nodeDoGroup": "Delivery outbound group", | |||
| "flowDoGroupTitle": "Delivery outbound ({{count}})", | |||
| "flowDoGroupTotalQty": "Total outbound: {{qtyLabel}}", | |||
| "flowGroupCollapse": "Collapse group", | |||
| "flowGroupExpand": "Expand group", | |||
| "nodePickGroup": "Material pick group", | |||
| "flowPickGroupTitle": "Material pick · {{pickOrderCode}} ({{count}})", | |||
| "nodeReturn": "Return", | |||
| "nodeRepack": "Repack / split lot", | |||
| "traceRepackLot": "Trace related lot", | |||
| "productLotNo": "Product lot no.", | |||
| "nodeMaterialIn": "Material receipt", | |||
| "nodeJoCreated": "Job order created", | |||
| "nodeMaterialPick": "Material pick", | |||
| "nodePurchase": "Purchase", | |||
| "nodeReceipt": "Goods receipt", | |||
| "nodePutaway": "Putaway", | |||
| "nodePutawayTransfer": "Transfer inbound", | |||
| "flowLegendTitle": "How to read the graph", | |||
| "flowLegendOpen": "Graph legend", | |||
| "flowPhaseFilter": "Show phases", | |||
| "flowPhaseFilterAll": "All phases", | |||
| "flowPhaseFilterReset": "Reset", | |||
| "locationsShowAll": "▼ Show all {{count}} rows", | |||
| "locationsCollapse": "▲ Collapse (showing all {{count}})", | |||
| "exportExcel": "Export Excel", | |||
| "exportExcelTooltip": "Download a classified multi-sheet workbook for this lot genealogy", | |||
| "putawayTransferInboundDetail": "Received at target warehouse after transfer (system auto-completed; not the PO pending put-away queue)", | |||
| "putawayStatusPending": "Pending put-away", | |||
| "putawayStatusCompleted": "Put away done", | |||
| "phaseInbound": "Inbound", | |||
| "phasePurchase": "Purchase", | |||
| "phaseQc": "Quality inspection", | |||
| "phasePutaway": "Putaway", | |||
| "categoryReceipt": "Receipt", | |||
| "phaseWarehouse": "Warehouse operations", | |||
| "phaseOutbound": "Outbound / consumption", | |||
| "phaseStockTake": "Stock take", | |||
| "noTimestamp": "No date", | |||
| "nodeQcPass": "IQC passed", | |||
| "nodeQcFail": "IQC failed", | |||
| "nodeStockTake": "Stock take", | |||
| "nodeAdjustment": "Adjustment", | |||
| "nodeTransfer": "Transfer", | |||
| "tabOrigins": "Origins & receipt", | |||
| "tabQc": "Quality (IQC)", | |||
| "tabOutbound": "Outbound usage", | |||
| "tabStockTake": "Stock take", | |||
| "tabAdjustments": "Adjustments", | |||
| "tabTransfers": "Transfers", | |||
| "tabBom": "BOM trace", | |||
| "tabJoPick": "Job pick orders", | |||
| "joContext": "Job order context", | |||
| "planStart": "Planned start", | |||
| "plannedQty": "Planned qty", | |||
| "pickedQty": "Actual pick qty", | |||
| "requiredQty": "Required qty", | |||
| "pickedAt": "Picked at", | |||
| "traceMaterialLot": "Trace material lot", | |||
| "targetDate": "Target date", | |||
| "detailPickTargetDate": "Target date", | |||
| "completeDate": "Complete date", | |||
| "directionIn": "In", | |||
| "directionOut": "Out", | |||
| "bomDirection": "Trace direction", | |||
| "bomDirectionFG": "Finished good lot", | |||
| "bomDirectionMaterial": "Material lot", | |||
| "bomDirectionUnknown": "General inventory lot", | |||
| "bomUpstream": "Materials consumed (actual)", | |||
| "bomDownstream": "Finished goods produced", | |||
| "bomRecipe": "BOM recipe (theoretical)", | |||
| "noRecords": "No records", | |||
| "type": "Type", | |||
| "ref": "Doc no.", | |||
| "detailInboundRef": "Inbound SI", | |||
| "detailInboundSiNo": "Inbound SI no.", | |||
| "detailAdjustmentRef": "Adjustment #", | |||
| "detailReplenishmentCode": "Replenishment #", | |||
| "detailReason": "Reason", | |||
| "detailReturnRef": "Return #", | |||
| "detailSourceDoc": "Source doc #", | |||
| "detailPutawayBin": "Bin", | |||
| "supplier": "Supplier", | |||
| "dnNo": "DN no.", | |||
| "qty": "Qty", | |||
| "detailOrderQty": "Order qty", | |||
| "detailPutAwayQty": "Put away qty", | |||
| "detailRemainingQty": "Remaining qty", | |||
| "detailPurchaseUnit": "Purchase unit", | |||
| "detailPurchaseOrderNo": "PO number", | |||
| "detailSupplyTo": "Supply to", | |||
| "detailStockTakeRound": "Stock take round", | |||
| "detailStockTakeSection": "Stock take section", | |||
| "detailLocation": "Location", | |||
| "status": "Status", | |||
| "handler": "Handler", | |||
| "stockTaker": "First counter", | |||
| "timestamp": "Time", | |||
| "pickOrder": "Pick order", | |||
| "jobOrder": "Job order", | |||
| "deliveryOrder": "Delivery order", | |||
| "deliveryNoteCode": "Delivery note (DN)", | |||
| "ticketNo": "Ticket no.", | |||
| "variance": "Variance", | |||
| "before": "Book qty", | |||
| "after": "Accepted qty", | |||
| "approver": "Approver", | |||
| "from": "From", | |||
| "to": "To", | |||
| "material": "Material", | |||
| "Item": "Item", | |||
| "detailItemCode": "Item code", | |||
| "detailItemName": "Item name", | |||
| "finishedItem": "Finished item", | |||
| "finishedLot": "FG lot", | |||
| "materialLot": "Material lot", | |||
| "materialQty": "Actual pick qty", | |||
| "processingStatus": "Processing status", | |||
| "matchStatus": "Match status", | |||
| "fgQty": "FG qty", | |||
| "qtyPerUnit": "Required qty", | |||
| "uom": "UOM", | |||
| "remarks": "Remarks", | |||
| "detailFailCategory": "Fail category", | |||
| "detailProcessDescription": "Step description", | |||
| "qcPassed": "Passed", | |||
| "qcFailed": "Failed", | |||
| "acceptedQty": "Sample qty", | |||
| "failQty": "Defect qty", | |||
| "unqualifiedQty": "Reject qty", | |||
| "usageType": "Type", | |||
| "detailQcCriteria": "QC criteria", | |||
| "detailQcType": "QC type", | |||
| "detailQcUnknownItem": "Unnamed QC item", | |||
| "code.refType.PO": "Purchase order", | |||
| "code.refType.JO": "Job order", | |||
| "code.refType.DO": "Delivery order", | |||
| "code.refType.TKE": "Stock take", | |||
| "code.refType.TRANSFER": "Transfer", | |||
| "code.refType.ADJ": "Adjustment", | |||
| "code.refType.OPEN": "Lot open", | |||
| "code.refType.OTHER": "Other", | |||
| "code.movementType.STOCK_IN": "Stock in", | |||
| "code.movementType.STOCK_OUT": "Stock out", | |||
| "code.movementType.CONSUMABLE": "Consumption", | |||
| "code.movementType.RETURN": "Return", | |||
| "code.movementType.ADJ": "Adjustment", | |||
| "code.movementType.TKE": "Stock take", | |||
| "code.movementType.OPEN": "Lot open", | |||
| "code.movementType.IN": "In", | |||
| "code.movementType.OUT": "Out", | |||
| "code.stockInStatus.pending": "Pending", | |||
| "code.stockInStatus.qc": "Under QC", | |||
| "code.stockInStatus.escalated": "Escalated", | |||
| "code.stockInStatus.determine1": "1st determination", | |||
| "code.stockInStatus.determine2": "2nd determination", | |||
| "code.stockInStatus.determine3": "3rd determination", | |||
| "code.stockInStatus.receiving": "Receiving", | |||
| "code.stockInStatus.received": "Received, pending putaway", | |||
| "code.stockInStatus.completed": "Completed", | |||
| "code.stockInStatus.complete": "Completed", | |||
| "code.stockInStatus.partially_completed": "Partially completed", | |||
| "code.stockInStatus.rejected": "Rejected", | |||
| "code.qcType.IQC": "Incoming QC (IQC)", | |||
| "code.qcType.IPQC": "In-process QC (IPQC)", | |||
| "code.qcType.EPQC": "End-product QC (EPQC)", | |||
| "code.qcType.FQC": "Finished goods QC (FQC)", | |||
| "code.adjustmentType.ADJ": "Inventory adjustment", | |||
| "code.usageType.CONSUMABLE": "Consumption", | |||
| "code.usageType.PRODUCTION": "Production use", | |||
| "code.usageType.FG_DELIVERY": "FG delivery", | |||
| "code.usageType.MATERIAL": "Material issue", | |||
| "code.pickStatus.pending": "Pending", | |||
| "code.pickStatus.consolidated": "Consolidated", | |||
| "code.pickStatus.assigned": "Assigned", | |||
| "code.pickStatus.released": "Released", | |||
| "code.pickStatus.picking": "Picking", | |||
| "code.pickStatus.completed": "Completed", | |||
| "code.pickStatus.partially_completed": "Partially completed", | |||
| "code.processingStatus.pending": "Pending", | |||
| "code.processingStatus.completed": "Completed", | |||
| "code.processingStatus.rejected": "Rejected", | |||
| "code.matchStatus.pending": "Pending", | |||
| "code.matchStatus.scanned": "Scanned", | |||
| "code.matchStatus.completed": "Completed", | |||
| "code.lotLineStatus.available": "Available", | |||
| "code.lotLineStatus.unavailable": "Unavailable", | |||
| "code.joStatus.pending": "Pending", | |||
| "code.joStatus.completed": "Completed", | |||
| "code.joStatus.cancelled": "Cancelled", | |||
| "code.joStatus.in_progress": "In progress", | |||
| "code.joStatus.planning": "Planning", | |||
| "code.joStatus.packaging": "Packaging", | |||
| "code.joStatus.processing": "Processing", | |||
| "code.joStatus.pendingQC": "Pending QC", | |||
| "code.joStatus.storing": "Storing", | |||
| "code.joStatus.PARTIAL": "Partial", | |||
| "code.joStatus.partial": "Partial", | |||
| "continuousScanBlocked": "Finish current scan first", | |||
| "nodeJoOut": "Job order material issue", | |||
| "nodePoOut": "Purchase pick", | |||
| "code.refType.JO_PICK": "JO pick order", | |||
| "code.refType.PO_PICK": "Pick order", | |||
| "stockTakeStageCreated": "Round created", | |||
| "stockTakeStageFirstCount": "First count", | |||
| "stockTakeStageSecondCount": "Re-count", | |||
| "stockTakeStageApproverCount": "Approver count", | |||
| "stockTakeStageAccepted": "Accepted", | |||
| "stockTakeStagePending": "Pending review", | |||
| "stockTakeStageBookQty": "Book qty", | |||
| "stockTakeStageExpand": "Show lifecycle", | |||
| "stockTakeStageCollapse": "Hide lifecycle", | |||
| "stockTakeStageDetail": "{{count}} stages", | |||
| "locationHeader": "This lot exists in other locations ({{count}} total)", | |||
| "available": "Available", | |||
| "inQty": "Qty In", | |||
| "outQty": "Qty Out", | |||
| "warehouseLines": "Warehouse Lines", | |||
| "movements": "Movements", | |||
| "stockTake": "Stock Take", | |||
| "stockTakeCode": "Stock Take #", | |||
| "section": "Section", | |||
| "round": "Round", | |||
| "outbound": "Outbound", | |||
| "date": "Date", | |||
| "warehouse": "Warehouse", | |||
| "direction": "Direction", | |||
| "origins": "Origins", | |||
| "refCode": "Ref.", | |||
| "lastMove": "Last move" | |||
| } | |||
| @@ -192,6 +192,7 @@ | |||
| "Job Order Type": "Job Order Type", | |||
| "Job Order not found or has no items": "Job Order not found or has no items", | |||
| "Job Process Status Dashboard": "Job Process Status Dashboard", | |||
| "Drink Production Qty Dashboard": "Drink Production Qty Dashboard", | |||
| "Job Type": "Job Type", | |||
| "Job dashboard PP status: cancelled": "Job dashboard PP status: cancelled", | |||
| "Job dashboard PP status: completed": "Job dashboard PP status: completed", | |||
| @@ -353,6 +354,7 @@ | |||
| "Production Output Data Entry": "Production Output Data Entry", | |||
| "Production Priority": "Production Priority", | |||
| "Production Priority required!": "Production Priority required!", | |||
| "Production Qty": "Production Qty", | |||
| "Production Process": "Production Process", | |||
| "Production Process Information": "Production Process Information", | |||
| "Production Process Line Remark": "Production Process Line Remark", | |||
| @@ -5,6 +5,7 @@ | |||
| "nav.store.purchaseOrder": "Purchase Order", | |||
| "nav.store.pickOrder": "Pick Order", | |||
| "nav.store.inventoryLedger": "View item In-out And inventory Ledger", | |||
| "nav.store.itemTracing": "Item Tracing", | |||
| "nav.store.stockTake": "Stock Take Management", | |||
| "nav.store.stockIssue": "Stock Issue", | |||
| "nav.store.putAwayScan": "Put Away Scan", | |||
| @@ -81,6 +82,7 @@ | |||
| "nav.breadcrumb.schedulingDetailed": "Detail Scheduling", | |||
| "nav.breadcrumb.schedulingDetailedEdit": "FG Production Schedule", | |||
| "nav.breadcrumb.inventory": "Inventory", | |||
| "nav.breadcrumb.itemTracing": "Item Tracing", | |||
| "nav.breadcrumb.importTesting": "Import Testing", | |||
| "nav.breadcrumb.doWorkbenchSearch": "DO Workbench Search", | |||
| "nav.breadcrumb.doWorkbenchPick": "DO Workbench Pick", | |||
| @@ -497,6 +497,10 @@ | |||
| "Tomorrow": "Tomorrow", | |||
| "packaging": "packaging", | |||
| "This lot is not available, please scan another lot.": "This lot is not available, please scan another lot.", | |||
| "This lot UOM does not match the pick line. Please scan another lot.": "This lot UOM does not match the pick line. Please scan another lot.", | |||
| "Scanned lot UOM does not match pick line UOM": "This lot UOM does not match the pick line. Please scan another lot.", | |||
| "This lot has already been picked. Please scan another lot.": "This lot has already been picked. Please scan another lot.", | |||
| "Scanned lot is already completed or checked": "This lot has already been picked. Please scan another lot.", | |||
| "Please check around have QR code or not, may be have just now stock in or transfer in or transfer out.": "Please check around have QR code or not, may be have just now stock in or transfer in or transfer out.", | |||
| "Lot is expired (expiry={{expiry}})": "Lot is expired (expiry={{expiry}})", | |||
| "Day After Tomorrow": "Day After Tomorrow", | |||
| @@ -63,7 +63,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", | |||
| @@ -77,8 +77,26 @@ | |||
| "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", | |||
| "Expand job order details": "Expand job order details", | |||
| "Collapse job order details": "Collapse job order details", | |||
| "Goods Name": "Goods Name", | |||
| "Job Type": "Job Type", | |||
| "Job process detail mode label": "Job process detail mode label", | |||
| "Job process detail: equipment": "Equipment", | |||
| @@ -136,6 +154,7 @@ | |||
| "Production Output Data": "Production Output Data", | |||
| "Production Output Data Entry": "Production Output Data Entry", | |||
| "Production Priority": "Production Priority", | |||
| "Production Qty": "Production Qty", | |||
| "Production Process": "Production Process", | |||
| "Production Process Line Remark": "Production Process Line Remark", | |||
| "Production Process Steps": "Production Process Steps", | |||
| @@ -194,6 +213,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", | |||
| @@ -208,6 +237,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", | |||
| @@ -1,6 +1,7 @@ | |||
| { | |||
| "Actions": "Actions", | |||
| "All Floors": "All Floors", | |||
| "All Handlers": "All Handlers", | |||
| "All Statuses": "All Statuses", | |||
| "Auto-refresh every 1 minute": "Auto-refresh every 1 minute", | |||
| "Auto-refresh every 10 minutes": "Auto-refresh every 10 minutes", | |||
| @@ -30,9 +31,11 @@ | |||
| "Revert assignment": "Revert assignment", | |||
| "Revert assignment hint": "Revert assignment hint", | |||
| "Rows per page": "Rows per page", | |||
| "Select All": "Select All", | |||
| "Select Date": "Select Date", | |||
| "Shop Name": "Shop Name", | |||
| "Status": "Status", | |||
| "Unassigned": "Unassigned", | |||
| "Store ID": "Store ID", | |||
| "Target Date": "Target Date", | |||
| "Ticket Information": "Ticket Information", | |||
| @@ -1,206 +1,88 @@ | |||
| { | |||
| "Inventory": "存貨", | |||
| "Action": "操作", | |||
| "Add": "新增", | |||
| "Add entry": "新增倉存", | |||
| "Add entry for items without inventory": "為無庫存貨品新增倉存", | |||
| "Adjusted Qty": "調整後倉存", | |||
| "Available Qty": "可用數量", | |||
| "CMB": "消耗品", | |||
| "CO": "消耗品", | |||
| "Cancel": "取消", | |||
| "Code": "編號", | |||
| "Name": "名稱", | |||
| "Type": "類型", | |||
| "Lot No": "批號", | |||
| "Confirm remove": "確認移除", | |||
| "Current Stock": "現有庫存", | |||
| "Difference": "差異", | |||
| "Download QR Code": "下載", | |||
| "Edit mode": "編輯模式", | |||
| "Enter item code or name to search": "輸入貨品編號或名稱以搜索", | |||
| "Expiry Date": "到期日", | |||
| "Warehouse": "倉庫", | |||
| "材料": "材料", | |||
| "consumable": "消耗品", | |||
| "Qty": "盤點數量", | |||
| "Total need stock take": "總需盤點數量", | |||
| "Waiting for Approver": "待審核數量", | |||
| "Total Approved": "已審核數量", | |||
| "mat": "原料", | |||
| "variance": "差異", | |||
| "ApproverAll": "審核員", | |||
| "Approver All": "審核員全部盤點", | |||
| "fg": "成品", | |||
| "FG": "成品", | |||
| "sfg": "半成品", | |||
| "SFG": "半成品", | |||
| "consumables": "消耗品", | |||
| "non-consumables": "非消耗品", | |||
| "item": "貨品", | |||
| "NM": "雜項及非消耗品", | |||
| "CMB": "消耗品", | |||
| "RM": "原料", | |||
| "Failed to transfer stock": "轉倉失敗", | |||
| "Failed to transfer stock. Please try again.": "轉倉失敗,請重試。", | |||
| "Inventory": "存貨", | |||
| "Item": "貨品", | |||
| "Item selected": "已選擇貨品", | |||
| "Location": "位置", | |||
| "Lot No": "批號", | |||
| "MA": "材料", | |||
| "CO": "消耗品", | |||
| "MI": "雜項", | |||
| "wip": "半成品", | |||
| "WIP": "半成品", | |||
| "cmb": "消耗品", | |||
| "nm": "雜項及非消耗品", | |||
| "available": "可用", | |||
| "unavailable": "不可用", | |||
| "tke": "盤點", | |||
| "Total Stock Takes": "總盤點數量", | |||
| "Submit completed: {{success}} success, {{errors}} errors": "提交完成:{{success}} 成功,{{errors}} 失敗", | |||
| "Body is unavailable": "輸入不可用", | |||
| "Confirm create stock take for all sections?": "確認為所有區域創建盤點?", | |||
| "not available": "不可用", | |||
| "Batch Submit All": "批量提交所有", | |||
| "not match": "要求重點", | |||
| "Show Search Filters": "顯示搜索器", | |||
| "Hide Search Filters": "隱藏搜索器", | |||
| "-{{Variance}}≤Variance Percentage ≤{{Variance}} will be filtered out": "-{{Variance}}%≤差異百分比≤{{Variance}}%將被過濾掉", | |||
| "Variance filter strict bounds": "不使用=", | |||
| "Filtered out": "過濾掉", | |||
| "Total Item Number": "貨品數量", | |||
| "Please enter QTY and Bad QTY": "請輸入盤點數量和不良數量", | |||
| "Invalid QTY": "無效的數量", | |||
| "Stock take qty exceeds maximum": "盤點數量不可超過 999,999,999,999", | |||
| "Start Time": "開始時間", | |||
| "stockTaking": "盤點中", | |||
| "rejected": "已拒絕", | |||
| "miss": "缺貨", | |||
| "bad": "不良", | |||
| "expiry": "過期", | |||
| "open": "開倉", | |||
| "Bom Req. Qty": "需求數(BOM單位)", | |||
| "notmatch": "要求重點", | |||
| "pass": "已盤點", | |||
| "not pass": "不通過", | |||
| "Available": "可用", | |||
| "approving": "審核中", | |||
| "pending": "待處理", | |||
| "notMatch": "要求重點", | |||
| "Input": "輸入", | |||
| "Something went wrong fetching data in server.": "在伺服器上取得資料時發生錯誤。", | |||
| "save": "保存", | |||
| "AVAILABLE": "可用", | |||
| "View": "查看", | |||
| "approver": "審核員", | |||
| "Create Stock Take for All Sections": "為所有區域創建盤點", | |||
| "Select stock take sections to create hint": "請選擇要建立新盤點輪次的盤點區域(同一批次將共用同一輪次編號)。", | |||
| "Select sections placeholder": "可多選", | |||
| "Select all sections": "全選區域", | |||
| "Clear selection": "清除選取", | |||
| "Selected section count": "已選擇 {{count}} 個區域", | |||
| "Expand floor sections": "展開此樓層區域", | |||
| "Collapse floor sections": "收合此樓層區域", | |||
| "section": "區域", | |||
| "First Qty": "第一次盤點數量", | |||
| "Second Qty": "第二次盤點數量", | |||
| "Remarks": "備註", | |||
| "Available Qty": "可用數量", | |||
| "Sales UoM": "銷售單位", | |||
| "Stock UoM": "庫存單位", | |||
| "Available Qty Per Smallest Unit": "可用數量 (基本單位)", | |||
| "Base UoM": "基本單位", | |||
| "No items are selected yet.": "未選擇貨品", | |||
| "Item selected": "已選擇貨品", | |||
| "Delivery Order": "交貨單", | |||
| "Job Order": "工單", | |||
| "Material": "物料", | |||
| "UNAVAILABLE": "不可用", | |||
| "No issues found": "未找到問題", | |||
| "Batch approver save completed: {{success}} success, {{skipped}} skipped, {{errors}} errors": "批次審核儲存完成:成功 {{success}} 筆,略過 {{skipped}} 筆,錯誤 {{errors}} 筆", | |||
| "Approve": "審核", | |||
| "complete": "完成", | |||
| "completed": "已完成", | |||
| "completed by": "完成者", | |||
| "completed date": "完成日期", | |||
| "completed remarks": "完成備註", | |||
| "completed status": "完成狀態", | |||
| "Pick Order, Issue No, Item, Lot...": "揀貨單, 問題編號, 貨品, 批號...", | |||
| "Refresh": "刷新", | |||
| "Resolved": "已解決", | |||
| "Total Issues": "總問題數量", | |||
| "Inventory Exception Management": "存貨異常管理", | |||
| "Back": "返回", | |||
| "Confirm Adjustment": "確認調整", | |||
| "Counted Qty": "盤點數量", | |||
| "Item Name": "貨品名稱", | |||
| "Perform Stock Take": "執行盤點", | |||
| "Review Variance": "審核差異", | |||
| "Select": "選擇", | |||
| "Select Section": "選擇區域", | |||
| "Select Stock Take Section": "選擇盤點區域", | |||
| "Start New Stock Take": "開始新的盤點", | |||
| "Stock Take Variance": "盤點差異", | |||
| "Stock take adjustment confirmed! (Demo only)": "盤點調整確認!(僅演示)", | |||
| "Stock take adjustment has been confirmed successfully!": "盤點調整確認成功!", | |||
| "System Qty": "系統數量", | |||
| "Batch Save Inputted": "批量保存已輸入", | |||
| "Batch Save Completed": "批量保存完成", | |||
| "Bad Item Handle": "不良品處理", | |||
| "Search to load lot lines": "請按搜索以載入批號", | |||
| "No changes to submit": "沒有可提交的變更", | |||
| "No record found": "沒有記錄", | |||
| "Rows per page": "每頁行數", | |||
| "Bad Item Records": "不良品處理紀錄", | |||
| "Expiry Item Handle": "過期品處理", | |||
| "Expiry Item Records": "過期品處理紀錄", | |||
| "Handled Date": "處理日期", | |||
| "Expiry Start Date": "到期日(開始)", | |||
| "Expiry End Date": "到期日(結束)", | |||
| "Bad Item Qty": "不良品數量", | |||
| "Expiry Item Qty": "過期品數量", | |||
| "Handler": "處理人", | |||
| "Quantity exceeds available quantity": "數量超過可用數量", | |||
| "Stock Record": "庫存記錄", | |||
| "Item-lotNo": "貨品-批號", | |||
| "In Qty": "入庫數量", | |||
| "Expiry Qty": "過期數量", | |||
| "Out Qty": "出庫數量", | |||
| "Balance Qty": "庫存數量", | |||
| "Start Date": "開始日期", | |||
| "End Date": "結束日期", | |||
| "Loading": "加載中", | |||
| "adj": "調整", | |||
| "nor": "正常", | |||
| "trf": "轉倉", | |||
| "Stock transfer successful": "轉倉成功", | |||
| "Stock transfer merged ambiguous": "已併入較早建立的批次(同批號有多筆可用)", | |||
| "Stock transfer merged existing lot": "轉倉成功(已併入既有批號)", | |||
| "Stock transfer created new lot": "轉倉成功(已建立新批號庫存)", | |||
| "Failed to transfer stock": "轉倉失敗", | |||
| "Failed to transfer stock. Please try again.": "轉倉失敗,請重試。", | |||
| "Download QR Code": "下載", | |||
| "Print QR Code": "列印", | |||
| "Stock Transfer": "轉倉", | |||
| "Start Location": "起點倉位", | |||
| "to": "轉倉至", | |||
| "Target Location": "目標倉位", | |||
| "NM": "雜項及非消耗品", | |||
| "Name": "名稱", | |||
| "No Data": "沒有數據", | |||
| "No data": "沒有數據", | |||
| "No items are selected yet.": "未選擇貨品", | |||
| "No lot no entered, will be generated by system.": "未輸入批號,將由系統生成。", | |||
| "Opening Inventory": "開倉", | |||
| "Optional - system will generate": "選填,系統將自動生成", | |||
| "Original Qty": "原有數量", | |||
| "Qty To Be Transferred": "待轉數量", | |||
| "Remaining Qty": "剩餘數量", | |||
| "Submit": "確認", | |||
| "Printer": "列印機", | |||
| "Print Qty": "列印數量", | |||
| "Please scan...": "請掃描...", | |||
| "Print": "列印", | |||
| "Print sent": "已送出列印", | |||
| "Print QR Code": "列印", | |||
| "Print Qty": "列印數量", | |||
| "Print failed": "列印失敗", | |||
| "Stock Adjustment": "庫存調整", | |||
| "Edit mode": "編輯模式", | |||
| "Add entry": "新增倉存", | |||
| "productLotNo": "產品批號", | |||
| "dnNo": "送貨單編號", | |||
| "Optional - system will generate": "選填,系統將自動生成", | |||
| "Add": "新增", | |||
| "Opening Inventory": "開倉", | |||
| "Print sent": "已送出列印", | |||
| "Printer": "列印機", | |||
| "Qty To Be Transferred": "待轉數量", | |||
| "RM": "原料", | |||
| "Reason for adjustment": "調整原因", | |||
| "No lot no entered, will be generated by system.": "未輸入批號,將由系統生成。", | |||
| "Reason for removal": "移除原因", | |||
| "Confirm remove": "確認移除", | |||
| "Adjusted Qty": "調整後倉存", | |||
| "Difference": "差異", | |||
| "Action": "操作", | |||
| "Saved successfully": "儲存成功", | |||
| "Save failed": "儲存失敗", | |||
| "Remaining Qty": "剩餘數量", | |||
| "Remarks": "備註", | |||
| "Remove": "移除", | |||
| "Average unit price": "平均單位價格", | |||
| "Latest market unit price": "最新市場價格", | |||
| "Add entry for items without inventory": "為無庫存貨品新增倉存", | |||
| "Enter item code or name to search": "輸入貨品編號或名稱以搜索", | |||
| "Current Stock": "現有庫存", | |||
| "Reset": "重置", | |||
| "SFG": "半成品", | |||
| "Save": "儲存", | |||
| "Save failed": "儲存失敗", | |||
| "Saved successfully": "儲存成功", | |||
| "Search lot by QR code": "尋找批次(掃描二維碼)", | |||
| "Please scan...": "請掃描...", | |||
| "Start Location": "起點倉位", | |||
| "Stock Adjustment": "庫存調整", | |||
| "Stock Transfer": "轉倉", | |||
| "Stock UoM": "庫存單位", | |||
| "Stock transfer created new lot": "轉倉成功(已建立新批號庫存)", | |||
| "Stock transfer merged ambiguous": "已併入較早建立的批次(同批號有多筆可用)", | |||
| "Stock transfer merged existing lot": "轉倉成功(已併入既有批號)", | |||
| "Stock transfer successful": "轉倉成功", | |||
| "Stop QR Scan": "停止掃碼", | |||
| "No Data": "沒有數據", | |||
| "Please set at least one search criterion": "請至少設定一項搜索條件", | |||
| "Approver search empty hint": "請設定搜索條件後點擊搜索" | |||
| "Submit": "確認", | |||
| "Target Location": "目標倉位", | |||
| "Type": "類型", | |||
| "UoM": "單位", | |||
| "WIP": "半成品", | |||
| "Warehouse": "倉庫", | |||
| "cmb": "消耗品", | |||
| "consumable": "消耗品", | |||
| "consumables": "消耗品", | |||
| "dnNo": "送貨單編號", | |||
| "fg": "成品", | |||
| "item": "貨品", | |||
| "mat": "原料", | |||
| "nm": "雜項及非消耗品", | |||
| "non-consumables": "非消耗品", | |||
| "productLotNo": "產品批號", | |||
| "sfg": "半成品", | |||
| "to": "轉倉至", | |||
| "wip": "半成品", | |||
| "材料": "材料" | |||
| } | |||
| @@ -0,0 +1,321 @@ | |||
| { | |||
| "title": "批號追溯", | |||
| "subtitle": "掃描批號 QR 即可追溯完整生命週期:入庫、出庫、品檢、提料單、工單、盤點、轉倉及 BOM 關聯。", | |||
| "scanReady": "掃碼槍就緒 — 請掃描批號標籤 QR", | |||
| "scanning": "掃描中…", | |||
| "scanAgain": "重新掃描", | |||
| "manualSearch": "手動查詢", | |||
| "itemCode": "貨品編號", | |||
| "itemLot": "貨品批號", | |||
| "lotNo": "批號", | |||
| "search": "追溯", | |||
| "searching": "查詢中…", | |||
| "noResult": "尚無追溯資料。請掃描批號 QR,或輸入貨品編號與批號查詢。", | |||
| "notFound": "找不到批號或無權限查看。", | |||
| "traceError": "無法載入追溯資料,請稍後再試或聯絡管理員。", | |||
| "scanError": "QR 格式無效。批號標籤應包含 itemId 與 stockInLineId。", | |||
| "summary": "批號摘要", | |||
| "expiryDate": "效期", | |||
| "productionDate": "生產日期", | |||
| "stockInDate": "入庫日期", | |||
| "totalAvailable": "總可用量", | |||
| "warehouseBreakdown": "各倉庫存量", | |||
| "alternateLocationsTitle": "此批號亦存於其他位置", | |||
| "alternateLocationsHint": "同一批號可能因轉倉或拆批而有多筆庫存紀錄;各倉生命週期已合併顯示於上方流程圖,可點擊下方列聚焦該倉節點。", | |||
| "traceAlternateLocation": "於流程圖聚焦", | |||
| "focusWarehouseInGraph": "於流程圖聚焦", | |||
| "sectionsMultiLocationHint": "以下表格合併顯示目前追溯位置與其他倉位事件;倉位欄標示事件所屬庫存紀錄。", | |||
| "action": "操作", | |||
| "timeline": "異動時間軸", | |||
| "flowGraph": "生命週期流程", | |||
| "flowLegendTime": "→ 時間(愈右愈晚)", | |||
| "flowLegendPhase": "↓ 階段(愈下愈後續)", | |||
| "flowLegendBranch": "↔ 同日由左至右依時間", | |||
| "flowLegendArrow": "箭頭僅跨階段", | |||
| "flowLegendPrelude": "↑ 上方為製造前", | |||
| "flowLegendTimeTip": "橫軸 = 日期,由左至右時間推進", | |||
| "flowLegendPhaseTip": "縱軸 = 製程階段,由上至下依作業順序", | |||
| "flowLegendBranchTip": "同一天的事件依時間戳由左至右排列;縱向列仍表示製程階段", | |||
| "flowLegendArrowTip": "箭頭連接不同階段的事件,表示流程走向", | |||
| "flowLegendPreludeTip": "成品批上方為原料入庫、品檢(原料與成品共用)、工單提料", | |||
| "flowGraphPathJo": "製程順序:原料入庫 → 品檢 → 上架 → 提料 → 生產/副產品 → 成品入庫 → 倉儲 → 出庫 → 盤點", | |||
| "flowZoomIn": "放大", | |||
| "flowZoomOut": "縮小", | |||
| "flowZoomFit": "顯示全圖", | |||
| "flowMinimapHide": "隱藏縮圖", | |||
| "flowMinimapShow": "顯示縮圖", | |||
| "flowZoomPanHint": "滾輪縮放 · 拖曳平移 · 點擊節點於左側看詳情 · 右上角可搜尋節點", | |||
| "flowNodeDetailHint": "點擊圖中節點以查看事件詳情", | |||
| "flowGraphSearchPlaceholder": "搜尋節點(單號、批號、倉位…)", | |||
| "flowGraphSearchNoMatch": "無符合節點", | |||
| "flowGraphSearchMatch": "{{current}} / {{total}}", | |||
| "flowGraphSearchPrev": "上一個", | |||
| "flowGraphSearchNext": "下一個", | |||
| "flowGraphSearchClear": "清除", | |||
| "nodeDetailTitle": "事件詳情", | |||
| "nodeDetailClose": "關閉", | |||
| "nodeExpired": "已過期", | |||
| "nodeDepleted": "已用完", | |||
| "detailDirection": "方向", | |||
| "categoryPurchase": "採購", | |||
| "categoryProduction": "生產", | |||
| "categoryOpen": "開倉", | |||
| "categoryTerminal": "終態", | |||
| "flowGraphHint": "左側為生命週期階段,由左至右依日期排列;同一天的事件依時間由左至右排列,箭頭表示跨階段流程。", | |||
| "flowGraphHintJo": "成品批含製造前階段(原料入庫、品檢、工單提料)與成品階段;品檢列合併原料與成品 IQC。", | |||
| "phaseMaterialInbound": "原料入庫", | |||
| "phaseMaterialQc": "原料品檢", | |||
| "phaseMaterialPick": "工單提料", | |||
| "phaseProduction": "生產/副產品", | |||
| "nodeProductionStep": "生產步驟", | |||
| "nodeByproduct": "副產品批", | |||
| "scrapQty": "損耗量", | |||
| "defectQty": "不良量", | |||
| "processOutputQty": "工序產出", | |||
| "processScrapQty": "損耗數量", | |||
| "processDefectQty": "不良品數量", | |||
| "equipment": "設備", | |||
| "processStep": "製程步驟", | |||
| "detailStepMaterials": "步驟用料", | |||
| "detailAssignedStep": "對應製程", | |||
| "traceByproductLot": "追溯副產品批", | |||
| "nodeScrap": "損耗", | |||
| "nodeDefect": "不良品", | |||
| "nodeOpen": "開倉入庫", | |||
| "nodeFail": "揀貨異常", | |||
| "nodeDoOut": "成品出倉", | |||
| "nodeReplenishmentCreated": "建立補貨", | |||
| "doOutboundExtra": "加單", | |||
| "doOutboundReplenish": "補貨", | |||
| "detailDoOutboundKind": "出庫類型", | |||
| "nodeDoGroup": "成品出倉群組", | |||
| "flowDoGroupTitle": "成品出倉({{count}} 筆)", | |||
| "flowDoGroupTotalQty": "出倉數量共:{{qtyLabel}}", | |||
| "flowGroupCollapse": "收合群組", | |||
| "flowGroupExpand": "展開群組", | |||
| "nodePickGroup": "工單提料群組", | |||
| "flowPickGroupTitle": "工單提料 · {{pickOrderCode}}({{count}} 筆)", | |||
| "nodeReturn": "退貨", | |||
| "nodeRepack": "拆批/重包", | |||
| "traceRepackLot": "追溯關聯批", | |||
| "productLotNo": "生產批號", | |||
| "nodeMaterialIn": "原料入庫", | |||
| "nodeJoCreated": "工單建立", | |||
| "nodeMaterialPick": "工單提料", | |||
| "nodePurchase": "採購", | |||
| "nodeReceipt": "收貨", | |||
| "nodePutaway": "上架", | |||
| "nodePutawayTransfer": "轉倉入庫", | |||
| "flowLegendTitle": "如何閱讀流程圖", | |||
| "flowLegendOpen": "流程圖圖例", | |||
| "flowPhaseFilter": "顯示階段", | |||
| "flowPhaseFilterAll": "全部階段", | |||
| "flowPhaseFilterReset": "重置", | |||
| "locationsShowAll": "▼ 顯示全部 {{count}} 列", | |||
| "locationsCollapse": "▲ 收合(目前顯示全部 {{count}})", | |||
| "exportExcel": "匯出 Excel", | |||
| "exportExcelTooltip": "下載此批號分類整理後的多工作表 Excel", | |||
| "putawayTransferInboundDetail": "轉倉至目標倉完成入庫(系統自動完成,非採購「待上架」流程)", | |||
| "putawayStatusPending": "待上架", | |||
| "putawayStatusCompleted": "已上架", | |||
| "phaseInbound": "入庫階段", | |||
| "phasePurchase": "採購階段", | |||
| "phaseQc": "品檢階段", | |||
| "phasePutaway": "上架階段", | |||
| "categoryReceipt": "收貨", | |||
| "phaseWarehouse": "倉儲作業", | |||
| "phaseOutbound": "出庫耗用", | |||
| "phaseStockTake": "盤點階段", | |||
| "noTimestamp": "無日期", | |||
| "nodeQcPass": "品檢合格", | |||
| "nodeQcFail": "品檢不合格", | |||
| "nodeStockTake": "盤點", | |||
| "nodeAdjustment": "庫存調整", | |||
| "nodeTransfer": "轉倉", | |||
| "tabOrigins": "來源與入庫", | |||
| "tabQc": "品質檢驗 (IQC)", | |||
| "tabOutbound": "出庫使用", | |||
| "tabStockTake": "盤點", | |||
| "tabAdjustments": "庫存調整", | |||
| "tabTransfers": "轉倉", | |||
| "tabBom": "BOM 追溯", | |||
| "tabJoPick": "工單提料", | |||
| "joContext": "工單脈絡", | |||
| "planStart": "計劃開工", | |||
| "plannedQty": "計劃產量", | |||
| "pickedQty": "實際提料數量", | |||
| "requiredQty": "需求數量", | |||
| "pickedAt": "提料時間", | |||
| "traceMaterialLot": "追溯原材料/半成品", | |||
| "targetDate": "目標日期", | |||
| "detailPickTargetDate": "需求日期", | |||
| "completeDate": "完成日期", | |||
| "directionIn": "入", | |||
| "directionOut": "出", | |||
| "bomDirection": "追溯方向", | |||
| "bomDirectionFG": "成品批", | |||
| "bomDirectionMaterial": "原料批", | |||
| "bomDirectionUnknown": "一般庫存批", | |||
| "bomUpstream": "實際耗用原料", | |||
| "bomDownstream": "關聯成品產出", | |||
| "bomRecipe": "BOM", | |||
| "noRecords": "無紀錄", | |||
| "type": "類型", | |||
| "ref": "單號", | |||
| "detailInboundRef": "入庫單 (SI)", | |||
| "detailInboundSiNo": "入庫單編號", | |||
| "detailAdjustmentRef": "調整單號", | |||
| "detailReplenishmentCode": "補貨編號", | |||
| "detailReason": "原因", | |||
| "detailReturnRef": "退貨單號", | |||
| "detailSourceDoc": "來源單號", | |||
| "detailPutawayBin": "倉位", | |||
| "supplier": "供應商", | |||
| "dnNo": "送貨單號", | |||
| "qty": "數量", | |||
| "detailOrderQty": "訂單數量", | |||
| "detailPutAwayQty": "已上架數量", | |||
| "detailRemainingQty": "剩餘數量", | |||
| "detailPurchaseUnit": "採購單位", | |||
| "detailPurchaseOrderNo": "採購單編號", | |||
| "detailSupplyTo": "供應至", | |||
| "detailStockTakeRound": "盤點輪次", | |||
| "detailStockTakeSection": "盤點區域", | |||
| "detailLocation": "庫位", | |||
| "status": "狀態", | |||
| "handler": "經手人", | |||
| "stockTaker": "初盤人", | |||
| "timestamp": "時間", | |||
| "pickOrder": "提料單", | |||
| "jobOrder": "工單", | |||
| "deliveryOrder": "送貨單", | |||
| "deliveryNoteCode": "送貨單據號 (DN)", | |||
| "ticketNo": "提票號碼", | |||
| "variance": "差異", | |||
| "before": "帳面數量", | |||
| "after": "核准數量", | |||
| "approver": "核准人", | |||
| "from": "來源倉", | |||
| "to": "目標倉", | |||
| "material": "原料", | |||
| "Item": "貨品", | |||
| "detailItemCode": "貨品編號", | |||
| "detailItemName": "貨品名稱", | |||
| "finishedItem": "成品編號", | |||
| "finishedLot": "成品批號", | |||
| "materialLot": "原料批號", | |||
| "materialQty": "實際提料數量", | |||
| "processingStatus": "處理狀態", | |||
| "matchStatus": "對料狀態", | |||
| "fgQty": "成品入庫量", | |||
| "qtyPerUnit": "需求數量", | |||
| "uom": "單位", | |||
| "remarks": "備註", | |||
| "detailFailCategory": "異常類別", | |||
| "detailProcessDescription": "工序說明", | |||
| "qcPassed": "合格", | |||
| "qcFailed": "不合格", | |||
| "acceptedQty": "上架量", | |||
| "failQty": "不良量", | |||
| "unqualifiedQty": "不合格數", | |||
| "usageType": "類型", | |||
| "detailQcCriteria": "品檢項目", | |||
| "detailQcType": "品檢類型", | |||
| "detailQcUnknownItem": "未命名品檢項", | |||
| "code.refType.PO": "採購單", | |||
| "code.refType.JO": "工單", | |||
| "code.refType.DO": "送貨單", | |||
| "code.refType.TKE": "盤點", | |||
| "code.refType.TRANSFER": "轉倉", | |||
| "code.refType.ADJ": "調整", | |||
| "code.refType.OPEN": "開倉", | |||
| "code.refType.OTHER": "其他", | |||
| "code.movementType.STOCK_IN": "入庫", | |||
| "code.movementType.STOCK_OUT": "出庫", | |||
| "code.movementType.CONSUMABLE": "耗用出庫", | |||
| "code.movementType.RETURN": "退貨", | |||
| "code.movementType.ADJ": "調整", | |||
| "code.movementType.TKE": "盤點", | |||
| "code.movementType.OPEN": "開倉", | |||
| "code.movementType.IN": "入庫", | |||
| "code.movementType.OUT": "出庫", | |||
| "code.stockInStatus.pending": "待處理", | |||
| "code.stockInStatus.qc": "品檢中", | |||
| "code.stockInStatus.escalated": "已升級", | |||
| "code.stockInStatus.determine1": "一階判定", | |||
| "code.stockInStatus.determine2": "二階判定", | |||
| "code.stockInStatus.determine3": "三階判定", | |||
| "code.stockInStatus.receiving": "收貨中", | |||
| "code.stockInStatus.received": "已收貨待入庫", | |||
| "code.stockInStatus.completed": "已完成", | |||
| "code.stockInStatus.complete": "已完成", | |||
| "code.stockInStatus.partially_completed": "部分完成", | |||
| "code.stockInStatus.rejected": "已拒收", | |||
| "code.qcType.IQC": "來貨品檢 (IQC)", | |||
| "code.qcType.IPQC": "製程品檢 (IPQC)", | |||
| "code.qcType.EPQC": "生產後品檢 (EPQC)", | |||
| "code.qcType.FQC": "成品品檢 (FQC)", | |||
| "code.adjustmentType.ADJ": "庫存調整", | |||
| "code.usageType.CONSUMABLE": "耗用", | |||
| "code.usageType.PRODUCTION": "生產耗用", | |||
| "code.usageType.FG_DELIVERY": "成品出貨", | |||
| "code.usageType.MATERIAL": "原料提料", | |||
| "code.pickStatus.pending": "待處理", | |||
| "code.pickStatus.consolidated": "已合併", | |||
| "code.pickStatus.assigned": "已指派", | |||
| "code.pickStatus.released": "已放單", | |||
| "code.pickStatus.picking": "提料中", | |||
| "code.pickStatus.completed": "已完成", | |||
| "code.pickStatus.partially_completed": "部分完成", | |||
| "code.processingStatus.pending": "待處理", | |||
| "code.processingStatus.completed": "已完成", | |||
| "code.processingStatus.rejected": "已拒收", | |||
| "code.matchStatus.pending": "待對料", | |||
| "code.matchStatus.scanned": "已掃描", | |||
| "code.matchStatus.completed": "已完成", | |||
| "code.lotLineStatus.available": "可用", | |||
| "code.lotLineStatus.unavailable": "不可用", | |||
| "code.joStatus.pending": "待處理", | |||
| "code.joStatus.completed": "已完成", | |||
| "code.joStatus.cancelled": "已取消", | |||
| "code.joStatus.in_progress": "進行中", | |||
| "code.joStatus.planning": "規劃中", | |||
| "code.joStatus.packaging": "提料中", | |||
| "code.joStatus.processing": "生產中", | |||
| "code.joStatus.pendingQC": "待品檢", | |||
| "code.joStatus.storing": "待品檢入倉", | |||
| "code.joStatus.PARTIAL": "部分完成", | |||
| "code.joStatus.partial": "部分完成", | |||
| "continuousScanBlocked": "請先完成目前掃描", | |||
| "nodeJoOut": "工單提料", | |||
| "nodePoOut": "採購提料", | |||
| "code.refType.JO_PICK": "工單提料單", | |||
| "code.refType.PO_PICK": "提料單", | |||
| "stockTakeStageCreated": "建立盤點輪次", | |||
| "stockTakeStageFirstCount": "盤點人初盤", | |||
| "stockTakeStageSecondCount": "盤點人複盤", | |||
| "stockTakeStageApproverCount": "管理員覆盤", | |||
| "stockTakeStageAccepted": "盤點核准", | |||
| "stockTakeStagePending": "待審核", | |||
| "stockTakeStageBookQty": "帳面數量", | |||
| "stockTakeStageExpand": "展開生命週期", | |||
| "stockTakeStageCollapse": "收合生命週期", | |||
| "stockTakeStageDetail": "{{count}} 個階段", | |||
| "locationHeader": "此批號亦存於其他位置(共 {{count}} 處)", | |||
| "available": "可用量", | |||
| "inQty": "入庫量", | |||
| "outQty": "出庫量", | |||
| "warehouseLines": "各倉庫存量", | |||
| "movements": "異動記錄", | |||
| "stockTake": "盤點", | |||
| "stockTakeCode": "盤點單號", | |||
| "section": "區域", | |||
| "round": "輪次", | |||
| "outbound": "出庫使用", | |||
| "date": "日期", | |||
| "warehouse": "倉庫", | |||
| "direction": "方向", | |||
| "origins": "來源", | |||
| "refCode": "單號", | |||
| "lastMove": "最近異動" | |||
| } | |||
| @@ -10,6 +10,7 @@ | |||
| "Actual Pick Qty": "實際提料數量", | |||
| "Add Bag": "新增包裝袋", | |||
| "Add Record": "添加記錄", | |||
| "Just Pass": "通過", | |||
| "Add Selected Items to Created Items": "將已選擇的物品添加到創建的物品中", | |||
| "Add some entries!": "請添加條目", | |||
| "All": "全部", | |||
| @@ -202,6 +203,7 @@ | |||
| "Job Order Type": "工單類型", | |||
| "Job Order not found or has no items": "工單不存在或沒有物品", | |||
| "Job Process Status Dashboard": "儀表板 - 工單狀態", | |||
| "Drink Production Qty Dashboard": "儀表板 - 飲料生產量數", | |||
| "Job Type": "工單類型", | |||
| "Job dashboard PP status: cancelled": "工序已取消", | |||
| "Job dashboard PP status: completed": "工序完成", | |||
| @@ -363,6 +365,7 @@ | |||
| "Production Output Data Entry": "生產輸出數據輸入", | |||
| "Production Priority": "生產優先序", | |||
| "Production Priority required!": "生產優先度必填!", | |||
| "Production Qty": "生產數量", | |||
| "Production Process": "工藝流程", | |||
| "Production Process Information": "生產流程信息", | |||
| "Production Process Line Remark": "工藝明細", | |||
| @@ -21,6 +21,7 @@ | |||
| "nav.breadcrumb.home": "總覽", | |||
| "nav.breadcrumb.importTesting": "匯入測試", | |||
| "nav.breadcrumb.inventory": "存貨", | |||
| "nav.breadcrumb.itemTracing": "批號追溯", | |||
| "nav.breadcrumb.joEdit": "工單詳情", | |||
| "nav.breadcrumb.joTesting": "工單測試", | |||
| "nav.breadcrumb.joWorkbench": "工單工作台", | |||
| @@ -91,6 +92,7 @@ | |||
| "nav.store.doWorkbench": "成品出倉", | |||
| "nav.store.finishedGoodManagement": "成品出倉管理", | |||
| "nav.store.inventoryLedger": "查看物品出入庫及庫存日誌", | |||
| "nav.store.itemTracing": "批號追溯", | |||
| "nav.store.pickOrder": "提料單", | |||
| "nav.store.purchaseOrder": "採購單", | |||
| "nav.store.putAwayScan": "上架掃碼", | |||
| @@ -12,7 +12,7 @@ | |||
| "N/A": "不適用", | |||
| "Release Pick Orders": "放單", | |||
| "released": "已放單", | |||
| "is unavailable. Please check around have available QR code or not.": "不可用。請檢查周圍是否有可用的 QR 碼。", | |||
| "is unavailable. Please check around have available QR code or not.": "不可用。請檢查周圍是否有可用的二維碼。", | |||
| "No lot rows. Select a pick order above.": "沒有批次行。請選擇一個提料單。", | |||
| "Loading...": "載入中...", | |||
| "Suggestion success": "建議成功", | |||
| @@ -65,7 +65,7 @@ | |||
| "items": "項目", | |||
| "Select Pick Order:": "選擇提料單:", | |||
| "No Stock Available": "沒有庫存", | |||
| "is expired. Please check around have available QR code or not.": "已過期。請檢查周圍是否有可用的 QR 碼。", | |||
| "is expired. Please check around have available QR code or not.": "已過期。請檢查周圍是否有可用的二維碼。", | |||
| "Start Fail": "開始失敗", | |||
| "Start PO": "開始採購訂單", | |||
| "Do you want to complete?": "確定完成嗎?", | |||
| @@ -136,7 +136,7 @@ | |||
| "LotNo": "批號", | |||
| "Po Code": "採購訂單編號", | |||
| "No Warehouse": "沒有倉庫", | |||
| "Please scan warehouse qr code.": "請掃描倉庫 QR 碼。", | |||
| "Please scan warehouse qr code.": "請掃描倉庫二維碼。", | |||
| "Reject": "拒絕", | |||
| "submit": "確認提交", | |||
| @@ -270,7 +270,7 @@ | |||
| "Pick order completed successfully!": "提料單完成成功!", | |||
| "Lot has been rejected and marked as unavailable.": "批號已拒絕並標記為不可用。", | |||
| "This order is insufficient, please pick another lot.": "此訂單不足,請選擇其他批號。", | |||
| "Please finish QR code scan, QC check and pick order.": "請完成 QR 碼掃描、QC 檢查和提料。", | |||
| "Please finish QR code scan, QC check and pick order.": "請完成二維碼掃描、QC 檢查和提料。", | |||
| "No data available": "沒有資料", | |||
| "Please submit the pick order.": "請提交提料單。", | |||
| "Item lot to be Pick:": "批次貨品提料:", | |||
| @@ -293,13 +293,13 @@ | |||
| "Original Available Qty": "原可用數", | |||
| "Remaining Available Qty": "剩餘可用數", | |||
| "Please submit pick order.": "請提交提料單。", | |||
| "Please finish QR code scan and pick order.": "請完成 QR 碼掃描和提料。", | |||
| "Please finish QR code scanand pick order.": "請完成 QR 碼掃描和提料。", | |||
| "Please finish QR code scan and pick order.": "請完成二維碼掃描和提料。", | |||
| "Please finish QR code scanand pick order.": "請完成二維碼掃描和提料。", | |||
| "First created group": "首次建立分組", | |||
| "Latest created group": "最新建立分組", | |||
| "Manual Input": "手動輸入", | |||
| "QR Code Scan for Lot": " QR 碼掃描批次", | |||
| "Processing QR code...": "處理 QR 碼...", | |||
| "QR Code Scan for Lot": "二維碼掃描批次", | |||
| "Processing QR code...": "處理二維碼...", | |||
| "The input is not the same as the expected lot number.": "輸入的批次號碼與預期的不符。", | |||
| "Verified successfully!": "驗證成功!", | |||
| "Cancel": "取消", | |||
| @@ -370,7 +370,7 @@ | |||
| "Scanning...":"掃描中...", | |||
| "Print DN/Label":"列印送貨單/標籤", | |||
| "Store ID":"儲存編號", | |||
| "QR code does not match any item in current orders.":"QR 碼不符合當前訂單中的任何貨品。", | |||
| "QR code does not match any item in current orders.":"二維碼不符合當前訂單中的任何貨品。", | |||
| "Lot Number Mismatch":"批次號碼不符", | |||
| "The scanned item matches the expected item, but the lot number is different. Do you want to proceed with this different lot?":"掃描的貨品與預期的貨品相同,但批次號碼不同。您是否要繼續使用不同的批次?", | |||
| "The scanned item matches the expected item, but the lot number is different. Scan again to confirm: scan the expected lot QR to keep the suggested lot, or scan the other lot QR again to switch.":"掃描貨品相同但批次不同。請再掃描一次以確認:掃描「建議批次」的 QR 可沿用該批次;再掃描「另一批次」的 QR 則切換為該批次。", | |||
| @@ -389,7 +389,7 @@ | |||
| "Lot switch failed":"批次切換失敗", | |||
| "The system could not switch to the scanned lot. Review the lots below, then tap Confirm to retry.":"系統無法切換至掃描的批次。請核對下方批次後按「確認」重試。", | |||
| "You can also scan again: expected lot QR keeps the suggested line; scanned lot QR retries the switch.":"您也可以再掃描:掃描建議批次 QR 可保留該行;掃描欲切換批次 QR 可再次嘗試切換。", | |||
| "QR code verified.":"QR 碼驗證成功。", | |||
| "QR code verified.":"二維碼驗證成功。", | |||
| "Order Finished":"訂單完成", | |||
| "Submitted Status":"提交狀態", | |||
| "Pick Execution Record":"提料執行記錄", | |||
| @@ -507,12 +507,17 @@ | |||
| "packaging": "提料中", | |||
| "No Stock Available": "沒有庫存可用", | |||
| "This lot is not available, please scan another lot.": "此批號不可用,請掃描其他批號。", | |||
| "Please check around have QR code or not, may be have just now stock in or transfer in or transfer out.": "請檢查周圍是否有其他可用 QR 碼。", | |||
| "This lot UOM does not match the pick line. Please scan another lot.": "此批號單位不符,請掃描其他批號。", | |||
| "Scanned lot UOM does not match pick line UOM": "此批號單位不符,請掃描其他批號。", | |||
| "This lot has already been picked. Please scan another lot.": "此批號已提貨,請掃描其他批號。", | |||
| "Scanned lot is already completed or checked": "此物料已提貨,請掃描其他批號。", | |||
| "Please check around have QR code or not, may be have just now stock in or transfer in or transfer out.": "請檢查周圍是否有其他可用二維碼。", | |||
| "Lot is expired (expiry={{expiry}})": "掃描批號已過期(到期日={{expiry}})", | |||
| "Day After Tomorrow": "後日", | |||
| "Lot line is unavailable": "掃描批次不可用", | |||
| "Select Date": "請選擇日期", | |||
| "Suggest Lot No.": "推薦批號", | |||
| "Suggest Lot No.": "推薦批號/實際批號", | |||
| "Search by Shop": "搜索商店", | |||
| "Search by Truck": "搜索貨車", | |||
| "Print DN & Label": "列印提料單和送貨單標籤", | |||
| @@ -541,13 +546,13 @@ | |||
| "4F ticket": "4/F 票", | |||
| "4F lane panel legend": "貨車班次 — 裝載序(未撳數/總單數)", | |||
| "Loading sequence n": "板{{n}}", | |||
| "lot QR code": "批號 QR 碼", | |||
| "lot QR code": "批號二維碼", | |||
| "label Printer" : "標籤打印機", | |||
| "A4 Printer" : "A4 打印機", | |||
| "Loading Sequence": "裝載序", | |||
| "Ticket No": "提票號碼", | |||
| "The scanned lot inventory line is unavailable. Cannot switch or bind; pick line was not updated.": "掃描的庫存批行為「不可用」,無法換批或綁定;揀貨行未更新。", | |||
| "is unavable. Please check around have available QR code or not.": "此批號不可用,請檢查周圍是否有可用的 QR 碼。", | |||
| "is unavable. Please check around have available QR code or not.": "此批號不可用,請檢查周圍是否有可用的二維碼。", | |||
| "Lot switch failed; pick line was not marked as checked.": "換批失敗;揀貨行未標為已核對。", | |||
| "Lot confirmation failed. Please try again.": "確認批號失敗,請重試。", | |||
| "Powder Mixture": "箱料粉", | |||
| @@ -68,7 +68,7 @@ | |||
| "Not Started": "未開始", | |||
| "cancelled": "已取消", | |||
| "in_progress": "進行中", | |||
| "pending": "待處理", | |||
| "pending": "待生產", | |||
| "stopped": "已停止", | |||
| "Invalid Job Order Id": "無效工單編號", | |||
| "Invalid Stock In Line Id": "無效庫存行ID", | |||
| @@ -82,8 +82,26 @@ | |||
| "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": "儀表板 - 飲料生產量數", | |||
| "Expand job order details": "展開工單明細", | |||
| "Collapse job order details": "收合工單明細", | |||
| "Goods Name": "貨品名稱", | |||
| "Job Type": "工單類型", | |||
| "Job process detail mode label": "工序格顯示", | |||
| "Job process detail: equipment": "設備", | |||
| @@ -141,6 +159,7 @@ | |||
| "Production Output Data": "生產輸出數據", | |||
| "Production Output Data Entry": "生產輸出數據輸入", | |||
| "Production Priority": "生產優先序", | |||
| "Production Qty": "生產數量", | |||
| "Production Process": "工藝流程", | |||
| "Production Process Line Remark": "工藝明細", | |||
| "Production Process Steps": "生產流程步驟", | |||
| @@ -199,6 +218,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", | |||
| @@ -213,6 +242,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", | |||
| @@ -25,6 +25,9 @@ | |||
| "Released Time": "開始時間", | |||
| "Completed Time": "完成時間", | |||
| "Handler Name": "負責員工", | |||
| "All Handlers": "所有員工", | |||
| "Select All": "全選", | |||
| "Unassigned": "未分配", | |||
| "Number of FG Items (Order Item(s) Count)": "訂單項目數量", | |||
| "No data available": "沒有資料", | |||
| "Rows per page": "每頁行數", | |||
| @@ -0,0 +1,57 @@ | |||
| const parseTicketTypeLetter = (ticketNo?: string | null): string | undefined => { | |||
| const parts = (ticketNo ?? "").trim().split("-"); | |||
| return parts[1]?.toUpperCase(); | |||
| }; | |||
| export const isTiETicketNo = (ticketNo?: string | null): boolean => { | |||
| const tn = (ticketNo ?? "").trim().toUpperCase(); | |||
| return parseTicketTypeLetter(tn) === "E" || tn.startsWith("TI-E-"); | |||
| }; | |||
| export const isTiMTicketNo = (ticketNo?: string | null): boolean => | |||
| (ticketNo ?? "").trim().toUpperCase().startsWith("TI-M-"); | |||
| const isExtraReleaseType = (releaseType?: string | null): boolean => { | |||
| const rt = (releaseType ?? "").trim().toLowerCase(); | |||
| return rt === "isextra" || rt === "isextrabatch" || rt === "isextrasingle"; | |||
| }; | |||
| export type TraceDoOutboundExtraSource = { | |||
| isExtra?: boolean; | |||
| ticketNo?: string | null; | |||
| consoCode?: string | null; | |||
| releaseType?: string | null; | |||
| deliveryOrderIsExtra?: boolean; | |||
| deliveryOrderPickOrderId?: number | null; | |||
| relationshipId?: number | null; | |||
| }; | |||
| /** | |||
| * Trace 加單 chip rules (aligned with DeliveryOrderService.isExtraDeliveryTicket): | |||
| * - TI-E / conso TI-E- → extra | |||
| * - TI-M → per DO isExtra, conso TI-E-, or relationshipId lineage (≠ own dop id) | |||
| * - Other → releaseType / API isExtra | |||
| */ | |||
| export const resolveTraceDoOutboundIsExtra = ( | |||
| source: TraceDoOutboundExtraSource, | |||
| ): boolean => { | |||
| if (source.isExtra === true) return true; | |||
| const ticket = source.ticketNo?.trim() || ""; | |||
| const conso = source.consoCode?.trim() || ""; | |||
| const consoIsTiE = isTiETicketNo(conso); | |||
| const deliveryOrderIsExtra = source.deliveryOrderIsExtra === true; | |||
| if (isTiMTicketNo(ticket)) { | |||
| if (deliveryOrderIsExtra) return true; | |||
| if (consoIsTiE) return true; | |||
| const dopId = source.deliveryOrderPickOrderId ?? null; | |||
| const relationshipId = source.relationshipId ?? null; | |||
| if (dopId != null && relationshipId != null && relationshipId !== dopId) return true; | |||
| return false; | |||
| } | |||
| if (isTiETicketNo(ticket) || consoIsTiE) return true; | |||
| if (isExtraReleaseType(source.releaseType) && !isTiMTicketNo(ticket)) return true; | |||
| return deliveryOrderIsExtra; | |||
| }; | |||