| @@ -7,7 +7,9 @@ | |||||
| "build": "next build", | "build": "next build", | ||||
| "start": "set NODE_OPTIONS=--inspect --max-old-space-size=6144&& next start", | "start": "set NODE_OPTIONS=--inspect --max-old-space-size=6144&& next start", | ||||
| "lint": "next lint", | "lint": "next lint", | ||||
| "type-check": "tsc --noEmit" | |||||
| "type-check": "tsc --noEmit", | |||||
| "test": "vitest run", | |||||
| "test:watch": "vitest" | |||||
| }, | }, | ||||
| "dependencies": { | "dependencies": { | ||||
| "@emotion/cache": "^11.11.0", | "@emotion/cache": "^11.11.0", | ||||
| @@ -40,6 +42,7 @@ | |||||
| "@tiptap/react": "^2.14.0", | "@tiptap/react": "^2.14.0", | ||||
| "@tiptap/starter-kit": "^2.14.0", | "@tiptap/starter-kit": "^2.14.0", | ||||
| "@unly/universal-language-detector": "^2.0.3", | "@unly/universal-language-detector": "^2.0.3", | ||||
| "@xyflow/react": "^12.11.2", | |||||
| "apexcharts": "^3.45.2", | "apexcharts": "^3.45.2", | ||||
| "axios": "^1.9.0", | "axios": "^1.9.0", | ||||
| "dayjs": "^1.11.10", | "dayjs": "^1.11.10", | ||||
| @@ -67,7 +70,8 @@ | |||||
| "styled-components": "^6.1.8", | "styled-components": "^6.1.8", | ||||
| "sweetalert2": "^11.10.3", | "sweetalert2": "^11.10.3", | ||||
| "xlsx": "^0.18.5", | "xlsx": "^0.18.5", | ||||
| "xlsx-js-style": "^1.2.0" | |||||
| "xlsx-js-style": "^1.2.0", | |||||
| "zod": "^3.25.76" | |||||
| }, | }, | ||||
| "devDependencies": { | "devDependencies": { | ||||
| "@types/lodash": "^4.14.202", | "@types/lodash": "^4.14.202", | ||||
| @@ -85,6 +89,7 @@ | |||||
| "postcss": "^8.4.33", | "postcss": "^8.4.33", | ||||
| "prettier": "3.1.1", | "prettier": "3.1.1", | ||||
| "tailwindcss": "^3.4.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'; | } from './semiFGProductionAnalysisApi'; | ||||
| import { generateGrnReportExcel } from './grnReportApi'; | import { generateGrnReportExcel } from './grnReportApi'; | ||||
| import { generateBomShopSyncReportExcel } from './bomShopSyncReportApi'; | import { generateBomShopSyncReportExcel } from './bomShopSyncReportApi'; | ||||
| import { generateShopOrderReplenishmentReportExcel } from './shopOrderReplenishmentReportApi'; | |||||
| import { | import { | ||||
| FEATURE_USAGE, | FEATURE_USAGE, | ||||
| FEATURE_USAGE_ACTION, | FEATURE_USAGE_ACTION, | ||||
| @@ -42,6 +43,7 @@ interface ItemCodeWithName { | |||||
| name: string; | name: string; | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 39 | v1.0.0 | 2026-08-03 */ | |||||
| export default function ReportPage() { | export default function ReportPage() { | ||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | const { data: session } = useSession() as { data: SessionWithTokens | null }; | ||||
| const includeGrnFinancialColumns = | const includeGrnFinancialColumns = | ||||
| @@ -267,12 +269,24 @@ export default function ReportPage() { | |||||
| ); | ); | ||||
| } else if (currentReport.id === 'rep-015') { | } else if (currentReport.id === 'rep-015') { | ||||
| await generateBomShopSyncReportExcel(criteria, currentReport.title); | await generateBomShopSyncReportExcel(criteria, currentReport.title); | ||||
| } else if (currentReport.id === 'rep-017') { | |||||
| await generateShopOrderReplenishmentReportExcel(criteria, currentReport.title); | |||||
| } else { | } else { | ||||
| // Backend returns actual .xlsx bytes for this Excel endpoint. | // Backend returns actual .xlsx bytes for this Excel endpoint. | ||||
| const queryParams = | |||||
| let queryParams = | |||||
| currentReport.id === 'rep-012' | currentReport.id === 'rep-012' | ||||
| ? buildRep012QueryString() | ? buildRep012QueryString() | ||||
| : new URLSearchParams(criteria).toString(); | : 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 excelUrl = `${currentReport.apiEndpoint}-excel?${queryParams}`; | ||||
| const response = await clientAuthFetch(excelUrl, { | const response = await clientAuthFetch(excelUrl, { | ||||
| @@ -25,7 +25,7 @@ export const REPORT_CATEGORIES: ReportCategoryConfig[] = [ | |||||
| headerBg: "#b3d4f0", | headerBg: "#b3d4f0", | ||||
| bodyBg: "#eef5fc", | bodyBg: "#eef5fc", | ||||
| accent: "#1565c0", | 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", | 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" /> | <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 />}> | <Suspense fallback={<ItemPriceSearch.Loading />}> | ||||
| <ItemPriceSearch /> | <ItemPriceSearch /> | ||||
| </Suspense> | </Suspense> | ||||
| @@ -94,6 +94,8 @@ export type WorkbenchScanPickBody = { | |||||
| excludeWarehouseCodes?: string[] | null; | excludeWarehouseCodes?: string[] | null; | ||||
| /** Optional decimal string or number serialized by JSON */ | /** Optional decimal string or number serialized by JSON */ | ||||
| qty?: number | string | null; | qty?: number | string | null; | ||||
| /** Just Complete button (no QR) — DO user-audit flag */ | |||||
| justComplete?: boolean | null; | |||||
| userId: number; | userId: number; | ||||
| }; | }; | ||||
| @@ -144,6 +146,7 @@ export async function workbenchScanPick( | |||||
| ...(storeId !== undefined ? { storeId } : {}), | ...(storeId !== undefined ? { storeId } : {}), | ||||
| ...(excludeWarehouseCodes !== undefined ? { excludeWarehouseCodes } : {}), | ...(excludeWarehouseCodes !== undefined ? { excludeWarehouseCodes } : {}), | ||||
| ...(qty !== undefined ? { qty } : {}), | ...(qty !== undefined ? { qty } : {}), | ||||
| ...(body.justComplete === true ? { justComplete: true } : {}), | |||||
| userId: body.userId, | userId: body.userId, | ||||
| }), | }), | ||||
| headers: { "Content-Type": "application/json" }, | headers: { "Content-Type": "application/json" }, | ||||
| @@ -242,13 +245,16 @@ export async function fetchWorkbenchReleasedDoPickOrdersForSelection( | |||||
| shopName?: string, | shopName?: string, | ||||
| storeId?: string, | storeId?: string, | ||||
| truck?: 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[]> { | ): Promise<ReleasedDoPickOrderListItem[]> { | ||||
| const params = new URLSearchParams(); | const params = new URLSearchParams(); | ||||
| if (shopName?.trim()) params.append("shopName", shopName.trim()); | if (shopName?.trim()) params.append("shopName", shopName.trim()); | ||||
| if (storeId?.trim()) params.append("storeId", storeId.trim()); | if (storeId?.trim()) params.append("storeId", storeId.trim()); | ||||
| if (truck?.trim()) params.append("truck", truck.trim()); | if (truck?.trim()) params.append("truck", truck.trim()); | ||||
| if (releaseType?.trim()) params.append("releaseType", releaseType.trim()); | if (releaseType?.trim()) params.append("releaseType", releaseType.trim()); | ||||
| if (floor?.trim()) params.append("floor", floor.trim().replace("/", "").toUpperCase()); | |||||
| const query = params.toString(); | const query = params.toString(); | ||||
| const url = `${BASE_API_URL}/doPickOrder/workbench/released${query ? `?${query}` : ""}`; | const url = `${BASE_API_URL}/doPickOrder/workbench/released${query ? `?${query}` : ""}`; | ||||
| const response = await serverFetchJson<ReleasedDoPickOrderListItem[]>(url, { method: "GET" }); | const response = await serverFetchJson<ReleasedDoPickOrderListItem[]>(url, { method: "GET" }); | ||||
| @@ -261,7 +267,9 @@ export async function fetchWorkbenchReleasedDoPickOrdersForSelectionToday( | |||||
| storeId?: string, | storeId?: string, | ||||
| truck?: string, | truck?: string, | ||||
| requiredDeliveryDate?: 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[]> { | ): Promise<ReleasedDoPickOrderListItem[]> { | ||||
| const params = new URLSearchParams(); | const params = new URLSearchParams(); | ||||
| if (shopName?.trim()) params.append("shopName", shopName.trim()); | 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 (truck?.trim()) params.append("truck", truck.trim()); | ||||
| if (requiredDeliveryDate?.trim()) params.append("requiredDate", requiredDeliveryDate.trim()); | if (requiredDeliveryDate?.trim()) params.append("requiredDate", requiredDeliveryDate.trim()); | ||||
| if (releaseType?.trim()) params.append("releaseType", releaseType.trim()); | if (releaseType?.trim()) params.append("releaseType", releaseType.trim()); | ||||
| if (floor?.trim()) params.append("floor", floor.trim().replace("/", "").toUpperCase()); | |||||
| const query = params.toString(); | const query = params.toString(); | ||||
| const url = `${BASE_API_URL}/doPickOrder/workbench/released-today${query ? `?${query}` : ""}`; | const url = `${BASE_API_URL}/doPickOrder/workbench/released-today${query ? `?${query}` : ""}`; | ||||
| const response = await serverFetchJson<ReleasedDoPickOrderListItem[]>(url, { method: "GET" }); | const response = await serverFetchJson<ReleasedDoPickOrderListItem[]>(url, { method: "GET" }); | ||||
| @@ -417,6 +426,8 @@ export async function fetchWorkbenchCompletedLotDetails( | |||||
| export type WorkbenchScanPayload = { | export type WorkbenchScanPayload = { | ||||
| itemId: number; | itemId: number; | ||||
| stockInLineId: number; | stockInLineId: number; | ||||
| /** POL UomConversion id — when set, sameItemLots only include matching lot UOM */ | |||||
| uomId?: number | null; | |||||
| }; | }; | ||||
| export async function fetchWorkbenchPrinters() { | export async function fetchWorkbenchPrinters() { | ||||
| return serverFetchJson<any[]>(`${BASE_API_URL}/printers`, { | 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>( | 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", | method: "GET", | ||||
| cache: "no-store", | cache: "no-store", | ||||
| @@ -23,6 +23,7 @@ export interface LotLineInfo { | |||||
| lotNo: string; | lotNo: string; | ||||
| remainingQty: number; | remainingQty: number; | ||||
| uom: string; | uom: string; | ||||
| uomId: number; | |||||
| } | } | ||||
| export interface SearchInventoryLotLine extends Pageable { | 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); | 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. */ | /** Bypass React cache() after mutations so lists show fresh qty. */ | ||||
| export async function fetchInventoriesFresh(data: SearchInventory) { | export async function fetchInventoriesFresh(data: SearchInventory) { | ||||
| return fetchInventoriesImpl(data); | 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) { | async function fetchInventoryLotLinesImpl(data: SearchInventoryLotLine) { | ||||
| const queryStr = convertObjToURLSearchParams(data); | const queryStr = convertObjToURLSearchParams(data); | ||||
| return serverFetchJson<InventoryLotLineResultByPage>( | 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; | itemCode: string; | ||||
| itemName: string; | itemName: string; | ||||
| bomDescription?: string | null; | bomDescription?: string | null; | ||||
| /** BOM.type (e.g. drink / Powder_Mixture / other). */ | |||||
| bomType?: string | null; | |||||
| lotNo: string; | lotNo: string; | ||||
| requiredQty: number; | requiredQty: number; | ||||
| jobOrderId: number; | jobOrderId: number; | ||||
| @@ -381,6 +383,26 @@ export interface AllJoborderProductProcessInfoResponse { | |||||
| productProcessLineCount: number; | productProcessLineCount: number; | ||||
| FinishedProductProcessLineCount: number; | FinishedProductProcessLineCount: number; | ||||
| lines: ProductProcessInfoResponse[]; | 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 { | export interface JobOrderProductProcessPageResponse { | ||||
| @@ -388,6 +410,9 @@ export interface JobOrderProductProcessPageResponse { | |||||
| totalJobOrders: number; | totalJobOrders: number; | ||||
| page: number; | page: number; | ||||
| size: number; | size: number; | ||||
| bucketCounts?: JobOrderProductProcessBucketCounts | null; | |||||
| searchDate?: string | null; | |||||
| carriedOverCount?: number | null; | |||||
| } | } | ||||
| export interface ProductProcessInfoResponse { | export interface ProductProcessInfoResponse { | ||||
| id: number; | id: number; | ||||
| @@ -558,7 +583,10 @@ export interface AllJoPickOrderResponse { | |||||
| jobOrderType: string | null; | jobOrderType: string | null; | ||||
| itemId: number; | itemId: number; | ||||
| itemName: string; | itemName: string; | ||||
| itemCode?: string | null; | |||||
| bomDescription?: string | null; | bomDescription?: string | null; | ||||
| /** BOM.type (e.g. drink / Powder_Mixture / other). */ | |||||
| bomType?: string | null; | |||||
| lotNo: string | null; | lotNo: string | null; | ||||
| planStart?: string | number[] | null; | planStart?: string | number[] | null; | ||||
| reqQty: number; | 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: { | export const fetchJoborderProductProcessesPage = cache(async (params: { | ||||
| /** Job order planStart 區間起(YYYY-MM-DD,含當日) */ | |||||
| /** Job order / process date(YYYY-MM-DD) */ | |||||
| date?: string | null; | date?: string | null; | ||||
| itemCode?: string | null; | itemCode?: string | null; | ||||
| jobOrderCode?: string | null; | jobOrderCode?: string | null; | ||||
| @@ -888,6 +917,10 @@ export const fetchJoborderProductProcessesPage = cache(async (params: { | |||||
| includePutaway?: boolean | null; | includePutaway?: boolean | null; | ||||
| /** all | completed | notCompleted */ | /** all | completed | notCompleted */ | ||||
| putawayStatus?: string | null; | 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; | page?: number; | ||||
| size?: number; | size?: number; | ||||
| }) => { | }) => { | ||||
| @@ -900,6 +933,8 @@ export const fetchJoborderProductProcessesPage = cache(async (params: { | |||||
| includePutaway, | includePutaway, | ||||
| putawayStatus, | putawayStatus, | ||||
| type, | type, | ||||
| lookbackDays, | |||||
| bucket, | |||||
| page = 0, | page = 0, | ||||
| size = 50, | size = 50, | ||||
| } = params; | } = params; | ||||
| @@ -917,6 +952,10 @@ export const fetchJoborderProductProcessesPage = cache(async (params: { | |||||
| queryParts.push(`includePutaway=${includePutaway}`); | queryParts.push(`includePutaway=${includePutaway}`); | ||||
| } | } | ||||
| if (putawayStatus) queryParts.push(`putawayStatus=${encodeURIComponent(putawayStatus)}`); | 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(`page=${page}`); | ||||
| queryParts.push(`size=${size}`); | queryParts.push(`size=${size}`); | ||||
| @@ -1203,8 +1242,15 @@ export const fetchJobOrderPickOrdersrecords = async ( | |||||
| ) => { | ) => { | ||||
| const params = new URLSearchParams(); | 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") { | if (status && String(status).trim() !== "" && String(status) !== "All") { | ||||
| params.set("status", String(status).trim()); | 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 ===== | // ===== Equipment Status Dashboard ===== | ||||
| export interface EquipmentStatusProcessInfo { | export interface EquipmentStatusProcessInfo { | ||||
| @@ -95,6 +95,30 @@ export interface ItemWithDetails { | |||||
| uomDesc: string; | uomDesc: string; | ||||
| currentStockBalance: number; | 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>) => { | export const fetchItemsWithDetails = cache(async (searchParams?: Record<string, any>) => { | ||||
| if (searchParams) { | if (searchParams) { | ||||
| const queryString = new URLSearchParams(searchParams).toString(); | const queryString = new URLSearchParams(searchParams).toString(); | ||||
| @@ -2,6 +2,9 @@ import { SessionWithTokens, authOptions } from "@/config/authConfig"; | |||||
| import { getServerSession } from "next-auth"; | import { getServerSession } from "next-auth"; | ||||
| import { headers } from "next/headers"; | import { headers } from "next/headers"; | ||||
| import { redirect } from "next/navigation"; | import { redirect } from "next/navigation"; | ||||
| import { ServerFetchError } from "./serverFetchError"; | |||||
| export { ServerFetchError }; | |||||
| export interface BlobResponse { | export interface BlobResponse { | ||||
| filename: string; | filename: string; | ||||
| @@ -21,16 +24,6 @@ export interface searchParamsProps { | |||||
| searchParams: { [key: string]: string | string[] | undefined }; | 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) { | export async function serverFetchWithNoContent(...args: FetchParams) { | ||||
| const response = await serverFetch(...args); | 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", | ADMIN: "ADMIN", | ||||
| STOCK: "STOCK", | STOCK: "STOCK", | ||||
| INVENTORY_ADJUST: "INVENTORY_ADJUST", | INVENTORY_ADJUST: "INVENTORY_ADJUST", | ||||
| ITEM_TRACING: "ITEM_TRACING", | |||||
| PURCHASE: "PURCHASE", | PURCHASE: "PURCHASE", | ||||
| STOCK_TAKE: "STOCK_TAKE", | STOCK_TAKE: "STOCK_TAKE", | ||||
| STOCK_IN_BIND: "STOCK_IN_BIND", | STOCK_IN_BIND: "STOCK_IN_BIND", | ||||
| @@ -47,6 +47,7 @@ const pathToLabelKey: { [path: string]: string } = { | |||||
| "/scheduling/detailed": "nav.breadcrumb.schedulingDetailed", | "/scheduling/detailed": "nav.breadcrumb.schedulingDetailed", | ||||
| "/scheduling/detailed/edit": "nav.breadcrumb.schedulingDetailedEdit", | "/scheduling/detailed/edit": "nav.breadcrumb.schedulingDetailedEdit", | ||||
| "/inventory": "nav.breadcrumb.inventory", | "/inventory": "nav.breadcrumb.inventory", | ||||
| "/itemTracing": "nav.breadcrumb.itemTracing", | |||||
| "/settings/importTesting": "nav.breadcrumb.importTesting", | "/settings/importTesting": "nav.breadcrumb.importTesting", | ||||
| "/settings/m18ImportTesting": "nav.breadcrumb.importTesting", | "/settings/m18ImportTesting": "nav.breadcrumb.importTesting", | ||||
| "/do": "nav.deliveryOrder", | "/do": "nav.deliveryOrder", | ||||
| @@ -58,6 +58,7 @@ function TabPanel(props: { value: number; index: number; children: React.ReactNo | |||||
| return <Box sx={{ pt: 2 }}>{children}</Box>; | 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 DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCombo = [] }) => { | ||||
| const searchParams = useSearchParams(); | const searchParams = useSearchParams(); | ||||
| const router = useRouter(); | const router = useRouter(); | ||||
| @@ -74,6 +75,8 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom | |||||
| urlTargetDateRaw && urlTargetDateRaw.trim() !== "" | urlTargetDateRaw && urlTargetDateRaw.trim() !== "" | ||||
| ? decodeURIComponent(urlTargetDateRaw.trim()) | ? decodeURIComponent(urlTargetDateRaw.trim()) | ||||
| : null; | : 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 [tab, setTab] = React.useState<number>(defaultTabIndex); | ||||
| const [lanePanelPrefs, setLanePanelPrefs] = React.useState<WorkbenchLanePanelPrefs>( | const [lanePanelPrefs, setLanePanelPrefs] = React.useState<WorkbenchLanePanelPrefs>( | ||||
| @@ -147,10 +150,11 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom | |||||
| setTab(newTab); | setTab(newTab); | ||||
| const params = new URLSearchParams(searchParams.toString()); | const params = new URLSearchParams(searchParams.toString()); | ||||
| params.set("tab", String(newTab)); | 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("ticketNo"); | ||||
| params.delete("targetDate"); | params.delete("targetDate"); | ||||
| params.delete("openDetail"); | |||||
| } | } | ||||
| const qs = params.toString(); | const qs = params.toString(); | ||||
| router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false }); | router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false }); | ||||
| @@ -380,21 +384,26 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom | |||||
| </TabPanel> | </TabPanel> | ||||
| <TabPanel value={tab} index={2}> | <TabPanel value={tab} index={2}> | ||||
| <GoodPickExecutionWorkbenchRecord | <GoodPickExecutionWorkbenchRecord | ||||
| key={`workbench-record-mine-${urlTicketNo ?? ""}-${urlTargetDate ?? ""}`} | |||||
| key={`workbench-record-mine-${urlTicketNo ?? ""}-${urlTargetDate ?? ""}-${urlOpenDetail ? "1" : "0"}`} | |||||
| printerCombo={printerCombo} | printerCombo={printerCombo} | ||||
| listScope="mine" | listScope="mine" | ||||
| a4Printer={a4Printer} | a4Printer={a4Printer} | ||||
| labelPrinter={labelPrinter} | labelPrinter={labelPrinter} | ||||
| initialTicketNo={urlTicketNo} | initialTicketNo={urlTicketNo} | ||||
| initialTargetDate={urlTargetDate} | initialTargetDate={urlTargetDate} | ||||
| openDetail={urlOpenDetail} | |||||
| /> | /> | ||||
| </TabPanel> | </TabPanel> | ||||
| <TabPanel value={tab} index={3}> | <TabPanel value={tab} index={3}> | ||||
| <GoodPickExecutionWorkbenchRecord | <GoodPickExecutionWorkbenchRecord | ||||
| key={`workbench-record-all-${urlTicketNo ?? ""}-${urlTargetDate ?? ""}-${urlOpenDetail ? "1" : "0"}`} | |||||
| printerCombo={printerCombo} | printerCombo={printerCombo} | ||||
| listScope="all" | listScope="all" | ||||
| a4Printer={a4Printer} | a4Printer={a4Printer} | ||||
| labelPrinter={labelPrinter} | labelPrinter={labelPrinter} | ||||
| initialTicketNo={urlTicketNo} | |||||
| initialTargetDate={urlTargetDate} | |||||
| openDetail={urlOpenDetail} | |||||
| /> | /> | ||||
| </TabPanel> | </TabPanel> | ||||
| <TabPanel value={tab} index={4}> | <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) => ( | const DoWorkbenchTabs: React.FC<Props> = (props) => ( | ||||
| <Suspense | <Suspense | ||||
| fallback={ | fallback={ | ||||
| @@ -1,6 +1,6 @@ | |||||
| "use client"; | "use client"; | ||||
| import React, { useCallback, useEffect, useMemo, useState } from "react"; | |||||
| import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |||||
| import { | import { | ||||
| Accordion, | Accordion, | ||||
| AccordionDetails, | AccordionDetails, | ||||
| @@ -48,8 +48,11 @@ type Props = { | |||||
| labelPrinter: PrinterCombo | null; | labelPrinter: PrinterCombo | null; | ||||
| initialTicketNo?: string | null; | initialTicketNo?: string | null; | ||||
| initialTargetDate?: 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> = ({ | const GoodPickExecutionWorkbenchRecord: React.FC<Props> = ({ | ||||
| printerCombo, | printerCombo, | ||||
| listScope = "mine", | listScope = "mine", | ||||
| @@ -57,6 +60,7 @@ const GoodPickExecutionWorkbenchRecord: React.FC<Props> = ({ | |||||
| labelPrinter, | labelPrinter, | ||||
| initialTicketNo, | initialTicketNo, | ||||
| initialTargetDate, | initialTargetDate, | ||||
| openDetail = false, | |||||
| }) => { | }) => { | ||||
| const { t } = useTranslation("pickOrder"); | const { t } = useTranslation("pickOrder"); | ||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | 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(() => { | const handleBackToList = useCallback(() => { | ||||
| setShowDetailView(false); | setShowDetailView(false); | ||||
| setSelectedRecord(null); | setSelectedRecord(null); | ||||
| @@ -40,6 +40,7 @@ interface Props { | |||||
| type LaneSlot4F = { truckDepartureTime: string; lane: LaneBtn }; | type LaneSlot4F = { truckDepartureTime: string; lane: LaneBtn }; | ||||
| type TruckGroup4F = { truckLanceCode: string; slots: (LaneSlot4F & { sequenceIndex: number })[] }; | 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> = ({ | const WorkbenchFloorLanePanel: React.FC<Props> = ({ | ||||
| onPickOrderAssigned, | onPickOrderAssigned, | ||||
| onSwitchToDetailTab, | onSwitchToDetailTab, | ||||
| @@ -85,7 +86,13 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ | |||||
| const [modalReleaseTypeFilter, setModalReleaseTypeFilter] = useState<string | undefined>(undefined); | const [modalReleaseTypeFilter, setModalReleaseTypeFilter] = useState<string | undefined>(undefined); | ||||
| const [modalFilterRequiredDeliveryDate, setModalFilterRequiredDeliveryDate] = useState<string | undefined>(undefined); | const [modalFilterRequiredDeliveryDate, setModalFilterRequiredDeliveryDate] = useState<string | undefined>(undefined); | ||||
| const [modalInitialShopSearch, setModalInitialShopSearch] = 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 etraEnterInFlightRef = useRef(false); | ||||
| const [etraMergeDialogOpen, setEtraMergeDialogOpen] = useState(false); | const [etraMergeDialogOpen, setEtraMergeDialogOpen] = useState(false); | ||||
| @@ -108,16 +115,25 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ | |||||
| shopName?: string, | shopName?: string, | ||||
| storeId?: string, | storeId?: string, | ||||
| truck?: string, | truck?: string, | ||||
| releaseType?: string | |||||
| ) => fetchWorkbenchReleasedDoPickOrdersForSelection(shopName, storeId, truck, releaseType), | |||||
| releaseType?: string, | |||||
| floor?: string | |||||
| ) => fetchWorkbenchReleasedDoPickOrdersForSelection(shopName, storeId, truck, releaseType, floor), | |||||
| loadToday: ( | loadToday: ( | ||||
| shopName?: string, | shopName?: string, | ||||
| storeId?: string, | storeId?: string, | ||||
| truck?: string, | truck?: string, | ||||
| requiredDeliveryDate?: string, | requiredDeliveryDate?: string, | ||||
| releaseType?: string | |||||
| releaseType?: string, | |||||
| floor?: string | |||||
| ) => | ) => | ||||
| fetchWorkbenchReleasedDoPickOrdersForSelectionToday(shopName, storeId, truck, requiredDeliveryDate, releaseType), | |||||
| fetchWorkbenchReleasedDoPickOrdersForSelectionToday( | |||||
| shopName, | |||||
| storeId, | |||||
| truck, | |||||
| requiredDeliveryDate, | |||||
| releaseType, | |||||
| floor | |||||
| ), | |||||
| assignByListItemId: assignByDeliveryOrderPickOrderId, | assignByListItemId: assignByDeliveryOrderPickOrderId, | ||||
| }), | }), | ||||
| [], | [], | ||||
| @@ -234,7 +250,14 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ | |||||
| pendingRef.current += 1; | pendingRef.current += 1; | ||||
| startFullTimer(); | startFullTimer(); | ||||
| try { | 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); | setBeforeTodayTruckXCount(list.length); | ||||
| } catch { | } catch { | ||||
| setBeforeTodayTruckXCount(0); | setBeforeTodayTruckXCount(0); | ||||
| @@ -244,12 +267,13 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ | |||||
| } | } | ||||
| }; | }; | ||||
| void loadBeforeTodayTruckX(); | void loadBeforeTodayTruckX(); | ||||
| }, [inEtraUi]); | |||||
| }, [inEtraUi, ticketFloorApiKey]); | |||||
| const clearModalEtraContext = useCallback(() => { | const clearModalEtraContext = useCallback(() => { | ||||
| setModalReleaseTypeFilter(undefined); | setModalReleaseTypeFilter(undefined); | ||||
| setModalFilterRequiredDeliveryDate(undefined); | setModalFilterRequiredDeliveryDate(undefined); | ||||
| setModalInitialShopSearch(undefined); | setModalInitialShopSearch(undefined); | ||||
| setModalTruckXFloor(undefined); | |||||
| }, []); | }, []); | ||||
| const openEnterEtraView = useCallback(async () => { | const openEnterEtraView = useCallback(async () => { | ||||
| @@ -538,6 +562,7 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ | |||||
| setSelectedTruck("車線-X"); | setSelectedTruck("車線-X"); | ||||
| setIsDefaultTruck(true); | setIsDefaultTruck(true); | ||||
| setDefaultDateScope("today"); | setDefaultDateScope("today"); | ||||
| setModalTruckXFloor(ticketFloorApiKey); | |||||
| setModalOpen(true); | setModalOpen(true); | ||||
| }} | }} | ||||
| > | > | ||||
| @@ -638,10 +663,11 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ | |||||
| variant="outlined" | variant="outlined" | ||||
| onClick={() => { | onClick={() => { | ||||
| clearModalEtraContext(); | clearModalEtraContext(); | ||||
| setSelectedStore("4/F"); | |||||
| setSelectedStore(""); | |||||
| setSelectedTruck("車線-X"); | setSelectedTruck("車線-X"); | ||||
| setIsDefaultTruck(true); | setIsDefaultTruck(true); | ||||
| setDefaultDateScope("before"); | setDefaultDateScope("before"); | ||||
| setModalTruckXFloor(ticketFloorApiKey); | |||||
| setModalOpen(true); | setModalOpen(true); | ||||
| }} | }} | ||||
| > | > | ||||
| @@ -664,11 +690,17 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ | |||||
| group.lanes.map((lane, li) => { | group.lanes.map((lane, li) => { | ||||
| const sid = (lane.storeId ?? "").trim(); | const sid = (lane.storeId ?? "").trim(); | ||||
| const dep = (lane.truckDepartureTime ?? "").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 = | 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 handlerName = (lane.handlerName ?? "").trim(); | ||||
| const shopCode = (group.shopCode ?? "").trim(); | const shopCode = (group.shopCode ?? "").trim(); | ||||
| const shopName = (group.shopName ?? "").trim(); | const shopName = (group.shopName ?? "").trim(); | ||||
| @@ -677,20 +709,29 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ | |||||
| ? `${shopCode} · ${shopName}` | ? `${shopCode} · ${shopName}` | ||||
| : shopName || shopCode || t("Shop"); | : shopName || shopCode || t("Shop"); | ||||
| const laneSecondary = `${labelCore}${handlerName ? ` · ${handlerName}` : ""}`; | 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 ( | return ( | ||||
| <Grid item xs={12} sm={6} md={4} lg={3} key={tileKey}> | <Grid item xs={12} sm={6} md={4} lg={3} key={tileKey}> | ||||
| <Button | <Button | ||||
| fullWidth | fullWidth | ||||
| variant="outlined" | variant="outlined" | ||||
| disabled={lane.unassigned === 0 || !sid} | |||||
| disabled={lane.unassigned === 0 || (!isTruckX && !sid)} | |||||
| onClick={() => { | onClick={() => { | ||||
| setModalReleaseTypeFilter("isExtra"); | setModalReleaseTypeFilter("isExtra"); | ||||
| setModalFilterRequiredDeliveryDate(selectedDeliveryDateYmd); | setModalFilterRequiredDeliveryDate(selectedDeliveryDateYmd); | ||||
| setModalInitialShopSearch((group.shopName || group.shopCode || "").trim() || undefined); | 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"); | setDefaultDateScope("today"); | ||||
| setModalOpen(true); | setModalOpen(true); | ||||
| }} | }} | ||||
| @@ -746,6 +787,7 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ | |||||
| releaseTypeFilter={modalReleaseTypeFilter} | releaseTypeFilter={modalReleaseTypeFilter} | ||||
| filterRequiredDeliveryDate={modalFilterRequiredDeliveryDate} | filterRequiredDeliveryDate={modalFilterRequiredDeliveryDate} | ||||
| initialShopSearch={modalInitialShopSearch} | initialShopSearch={modalInitialShopSearch} | ||||
| truckXFloor={modalTruckXFloor} | |||||
| onClose={() => { | onClose={() => { | ||||
| setModalOpen(false); | setModalOpen(false); | ||||
| clearModalEtraContext(); | 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> = ({ | const WorkbenchGoodPickExecutionDetail: React.FC<Props> = ({ | ||||
| filterArgs, | filterArgs, | ||||
| onSwitchToRecordTab, | onSwitchToRecordTab, | ||||
| @@ -1076,6 +1077,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| itemId: line.item.id, | itemId: line.item.id, | ||||
| itemCode: line.item.code, | itemCode: line.item.code, | ||||
| itemName: line.item.name, | itemName: line.item.name, | ||||
| uomId: line.item.uomId ?? null, | |||||
| uomDesc: line.item.uomDesc, | uomDesc: line.item.uomDesc, | ||||
| uomShortDesc: line.item.uomShortDesc, | uomShortDesc: line.item.uomShortDesc, | ||||
| @@ -1147,6 +1149,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| itemId: line.item.id, | itemId: line.item.id, | ||||
| itemCode: line.item.code, | itemCode: line.item.code, | ||||
| itemName: line.item.name, | itemName: line.item.name, | ||||
| uomId: line.item.uomId ?? null, | |||||
| uomDesc: line.item.uomDesc, | uomDesc: line.item.uomDesc, | ||||
| uomShortDesc: line.item.uomShortDesc, | uomShortDesc: line.item.uomShortDesc, | ||||
| @@ -1220,13 +1223,21 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| }, [fetchAllCombinedLotData]); | }, [fetchAllCombinedLotData]); | ||||
| const openWorkbenchLotLabelModalForLot = useCallback( | const openWorkbenchLotLabelModalForLot = useCallback( | ||||
| (lot: any, reminderText?: string | null) => { | |||||
| ( | |||||
| lot: any, | |||||
| reminderText?: string | null, | |||||
| options?: { markAsScanIssue?: boolean }, | |||||
| ) => { | |||||
| const itemId = Number(lot?.itemId); | const itemId = Number(lot?.itemId); | ||||
| const stockInLineId = Number(lot?.stockInLineId); | const stockInLineId = Number(lot?.stockInLineId); | ||||
| const solId = Number(lot?.stockOutLineId); | const solId = Number(lot?.stockOutLineId); | ||||
| if (!Number.isFinite(itemId) || itemId <= 0 || !Number.isFinite(solId) || solId <= 0) { | if (!Number.isFinite(itemId) || itemId <= 0 || !Number.isFinite(solId) || solId <= 0) { | ||||
| return; | return; | ||||
| } | } | ||||
| // 掃碼 issue 才標記:手動「批號二維碼」不傳 markAsScanIssue,批號不變紅 | |||||
| if (options?.markAsScanIssue) { | |||||
| rememberWorkbenchScanReject(solId, reminderText); | |||||
| } | |||||
| setWorkbenchLotLabelContextLot(lot); | setWorkbenchLotLabelContextLot(lot); | ||||
| if (Number.isFinite(stockInLineId) && stockInLineId > 0) { | if (Number.isFinite(stockInLineId) && stockInLineId > 0) { | ||||
| setWorkbenchLotLabelInitialPayload({ itemId, stockInLineId }); | setWorkbenchLotLabelInitialPayload({ itemId, stockInLineId }); | ||||
| @@ -1238,7 +1249,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| setQrScanSuccess(false); | setQrScanSuccess(false); | ||||
| setWorkbenchLotLabelModalOpen(true); | setWorkbenchLotLabelModalOpen(true); | ||||
| }, | }, | ||||
| [], | |||||
| [rememberWorkbenchScanReject], | |||||
| ); | ); | ||||
| const shouldOpenWorkbenchLotLabelModalForFailure = useCallback( | const shouldOpenWorkbenchLotLabelModalForFailure = useCallback( | ||||
| @@ -1688,14 +1699,91 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| }); | }); | ||||
| return; | return; | ||||
| } | } | ||||
| // ✅ OPTIMIZATION: Use cached active lots directly (no filtering needed) | // ✅ OPTIMIZATION: Use cached active lots directly (no filtering needed) | ||||
| const lookupStartTime = performance.now(); | 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 | // ✅ 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; | const lookupTime = performance.now() - lookupStartTime; | ||||
| console.log(` [PERF] Index lookup time: ${lookupTime.toFixed(2)}ms, found ${activeSuggestedLots.length} active lots, ${allLotsForItem.length} total lots`); | 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 | // ✅ Check if scanned lot is rejected BEFORE checking activeSuggestedLots | ||||
| // This allows users to scan other lots even when all suggested lots are rejected | // 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( | openWorkbenchLotLabelModalForLot( | ||||
| scannedLot, | scannedLot, | ||||
| t("This lot is not available, please scan another lot."), | t("This lot is not available, please scan another lot."), | ||||
| { markAsScanIssue: true }, | |||||
| ); | ); | ||||
| return; | return; | ||||
| } | } | ||||
| @@ -1761,6 +1850,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| openWorkbenchLotLabelModalForLot( | openWorkbenchLotLabelModalForLot( | ||||
| scannedLot, | scannedLot, | ||||
| `Lot is expired (expiry=${scannedLot.expiryDate || "-"})`, | `Lot is expired (expiry=${scannedLot.expiryDate || "-"})`, | ||||
| { markAsScanIssue: true }, | |||||
| ); | ); | ||||
| return; | return; | ||||
| } | } | ||||
| @@ -1883,7 +1973,9 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) && | shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) && | ||||
| expectedLot | expectedLot | ||||
| ) { | ) { | ||||
| openWorkbenchLotLabelModalForLot(expectedLot, failMsg); | |||||
| openWorkbenchLotLabelModalForLot(expectedLot, failMsg, { | |||||
| markAsScanIssue: true, | |||||
| }); | |||||
| return; | return; | ||||
| } | } | ||||
| if (workbenchMode && expectedLot.stockOutLineId != null) { | if (workbenchMode && expectedLot.stockOutLineId != null) { | ||||
| @@ -2055,7 +2147,9 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) && | shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) && | ||||
| expectedLot | expectedLot | ||||
| ) { | ) { | ||||
| openWorkbenchLotLabelModalForLot(expectedLot, failMsg); | |||||
| openWorkbenchLotLabelModalForLot(expectedLot, failMsg, { | |||||
| markAsScanIssue: true, | |||||
| }); | |||||
| return; | return; | ||||
| } | } | ||||
| if (workbenchMode && expectedLot.stockOutLineId != null) { | if (workbenchMode && expectedLot.stockOutLineId != null) { | ||||
| @@ -2250,7 +2344,9 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) && | shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) && | ||||
| exactMatch | exactMatch | ||||
| ) { | ) { | ||||
| openWorkbenchLotLabelModalForLot(exactMatch, failMsg); | |||||
| openWorkbenchLotLabelModalForLot(exactMatch, failMsg, { | |||||
| markAsScanIssue: true, | |||||
| }); | |||||
| return; | return; | ||||
| } | } | ||||
| if (workbenchMode && exactMatch.stockOutLineId != null) { | if (workbenchMode && exactMatch.stockOutLineId != null) { | ||||
| @@ -2394,7 +2490,9 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) && | shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) && | ||||
| expectedLot | expectedLot | ||||
| ) { | ) { | ||||
| openWorkbenchLotLabelModalForLot(expectedLot, failMsg); | |||||
| openWorkbenchLotLabelModalForLot(expectedLot, failMsg, { | |||||
| markAsScanIssue: true, | |||||
| }); | |||||
| return; | return; | ||||
| } | } | ||||
| if (workbenchMode && expectedLot.stockOutLineId != null) { | if (workbenchMode && expectedLot.stockOutLineId != null) { | ||||
| @@ -2646,7 +2744,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| const qrDetectionStartTime = performance.now(); | const qrDetectionStartTime = performance.now(); | ||||
| console.log(` [QR DETECTION] Latest QR detected: ${latestQr?.substring(0, 50)}...`); | 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`); | console.log(` [QR DETECTION] Time since QR scanner set value: ${(qrDetectionStartTime - qrValuesChangeStartTime).toFixed(2)}ms`); | ||||
| const qrPayload = parseWorkbenchScanQrPayload(latestQr); | const qrPayload = parseWorkbenchScanQrPayload(latestQr); | ||||
| @@ -3176,6 +3274,7 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe | |||||
| ? { stockInLineId: canonicalLotForSol.stockInLineId } | ? { stockInLineId: canonicalLotForSol.stockInLineId } | ||||
| : {}), | : {}), | ||||
| qty: qtyToSend, | qty: qtyToSend, | ||||
| justComplete: true, | |||||
| storeId: fgPickOrders?.[0]?.storeId ?? null, | storeId: fgPickOrders?.[0]?.storeId ?? null, | ||||
| userId: currentUserId ?? 1, | userId: currentUserId ?? 1, | ||||
| }); | }); | ||||
| @@ -3457,7 +3556,7 @@ const handleStartScan = useCallback(() => { | |||||
| resetScan(); | resetScan(); | ||||
| lastConsumedQrValuesLengthRef.current = 0; | lastConsumedQrValuesLengthRef.current = 0; | ||||
| }, [stopScan, resetScan]); | }, [stopScan, resetScan]); | ||||
| // ... existing code around line 1469 ... | |||||
| const handlelotnull = useCallback(async (lot: any) => { | const handlelotnull = useCallback(async (lot: any) => { | ||||
| // 优先使用 stockouts 中的 id,如果没有则使用 stockOutLineId | // 优先使用 stockouts 中的 id,如果没有则使用 stockOutLineId | ||||
| const stockOutLineId = lot.stockOutLineId; | const stockOutLineId = lot.stockOutLineId; | ||||
| @@ -4074,10 +4173,21 @@ const handleSubmitAllScanned = useCallback(async () => { | |||||
| <TableCell>{t("Item Code")}</TableCell> | <TableCell>{t("Item Code")}</TableCell> | ||||
| <TableCell>{t("Item Name")}</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("Qty will submit")}</TableCell>*/} | ||||
| <TableCell align="center">{t("Submit Required Pick Qty")}</TableCell> | <TableCell align="center">{t("Submit Required Pick Qty")}</TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| @@ -4085,7 +4195,7 @@ const handleSubmitAllScanned = useCallback(async () => { | |||||
| <TableBody> | <TableBody> | ||||
| {paginatedData.length === 0 ? ( | {paginatedData.length === 0 ? ( | ||||
| <TableRow> | <TableRow> | ||||
| <TableCell colSpan={11} align="center"> | |||||
| <TableCell colSpan={7} align="center"> | |||||
| <Typography variant="body2" color="text.secondary"> | <Typography variant="body2" color="text.secondary"> | ||||
| {t("No data available")} | {t("No data available")} | ||||
| </Typography> | </Typography> | ||||
| @@ -4106,7 +4216,7 @@ paginatedData.map((row, index) => { | |||||
| ? Number(fromPickRow) | ? Number(fromPickRow) | ||||
| : lockedSubmitQtyDisplay; | : lockedSubmitQtyDisplay; | ||||
| // 检查是否是 issue lot | |||||
| const isIssueLot = lot.stockOutLineStatus === 'rejected' || !lot.lotNo; | const isIssueLot = lot.stockOutLineStatus === 'rejected' || !lot.lotNo; | ||||
| const rejectDisplay = buildLotRejectDisplayMessage(lot, scanRejectMessageBySolId, t); | const rejectDisplay = buildLotRejectDisplayMessage(lot, scanRejectMessageBySolId, t); | ||||
| const solSt = String(lot.stockOutLineStatus || "").toLowerCase(); | const solSt = String(lot.stockOutLineStatus || "").toLowerCase(); | ||||
| @@ -4131,45 +4241,64 @@ paginatedData.map((row, index) => { | |||||
| }} | }} | ||||
| > | > | ||||
| <TableCell> | <TableCell> | ||||
| <Typography variant="body2" fontWeight="bold"> | |||||
| <Typography variant="body1" fontWeight="bold"> | |||||
| {row.isGroupFirst ? row.groupDisplayIndex : ""} | {row.isGroupFirst ? row.groupDisplayIndex : ""} | ||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell>{row.isGroupFirst ? lot.itemCode : ""}</TableCell> | |||||
| <TableCell> | <TableCell> | ||||
| {row.isGroupFirst ? lot.itemName + '(' + lot.stockUnit + ')' : ""} | |||||
| <Typography variant="body1"> | |||||
| {row.isGroupFirst ? lot.itemCode : ""} | |||||
| </Typography> | |||||
| </TableCell> | </TableCell> | ||||
| <TableCell> | |||||
| <Typography variant="body2"> | |||||
| {lot.routerRoute || '-'} | |||||
| <TableCell> | |||||
| <Typography variant="body1"> | |||||
| {row.isGroupFirst ? lot.itemName + '(' + lot.stockUnit + ')' : ""} | |||||
| </Typography> | </Typography> | ||||
| </TableCell> | </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 hasLotNo = Boolean(lot.lotNo); | ||||
| const isExpired = lot.lotAvailability === 'expired'; | const isExpired = lot.lotAvailability === 'expired'; | ||||
| const isUnavailable = isInventoryLotLineUnavailable(lot); | const isUnavailable = isInventoryLotLineUnavailable(lot); | ||||
| const isNoLotHint = !hasLotNo && !rejectDisplay; // 顯示「請檢查周圍…」那種 | 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 ( | return ( | ||||
| <Typography | <Typography | ||||
| variant={isIssueText ? "body2" : "body1"} | |||||
| variant="body2" | |||||
| sx={{ | sx={{ | ||||
| color: textColor, | color: textColor, | ||||
| whiteSpace: 'pre-wrap', | |||||
| wordBreak: 'break-word', | |||||
| whiteSpace: lotOnly ? "nowrap" : "pre-wrap", | |||||
| wordBreak: lotOnly ? "normal" : "break-word", | |||||
| textAlign: "center", | |||||
| }} | }} | ||||
| > | > | ||||
| {hasLotNo ? ( | {hasLotNo ? ( | ||||
| @@ -4213,7 +4342,6 @@ paginatedData.map((row, index) => { | |||||
| sx={{ | sx={{ | ||||
| flexShrink: 0, | flexShrink: 0, | ||||
| fontSize: "0.75rem", | fontSize: "0.75rem", | ||||
| py: 0.25, | |||||
| minWidth: "auto", | minWidth: "auto", | ||||
| px: 1, | px: 1, | ||||
| whiteSpace: "nowrap", | whiteSpace: "nowrap", | ||||
| @@ -4224,23 +4352,34 @@ paginatedData.map((row, index) => { | |||||
| )} | )} | ||||
| </Stack> | </Stack> | ||||
| </TableCell> | </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> | ||||
| <TableCell align="center"> | |||||
| <TableCell align="center" sx={{ width: 88, minWidth: 88, px: 0.5 }}> | |||||
| {(() => { | {(() => { | ||||
| const status = lot.stockOutLineStatus?.toLowerCase(); | const status = lot.stockOutLineStatus?.toLowerCase(); | ||||
| const isRejected = status === 'rejected' || lot.lotAvailability === 'rejected'; | const isRejected = status === 'rejected' || lot.lotAvailability === 'rejected'; | ||||
| const isNoLot = !lot.lotNo; | const isNoLot = !lot.lotNo; | ||||
| const scanResultSlotSx = { | |||||
| width: 42, | |||||
| height: 42, | |||||
| display: 'flex', | |||||
| justifyContent: 'center', | |||||
| alignItems: 'center', | |||||
| mx: 'auto', | |||||
| } as const; | |||||
| // rejected lot:显示红色勾选(已扫描但被拒绝) | // rejected lot:显示红色勾选(已扫描但被拒绝) | ||||
| if (isRejected && !isNoLot) { | if (isRejected && !isNoLot) { | ||||
| return ( | return ( | ||||
| <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}> | |||||
| <Box sx={scanResultSlotSx}> | |||||
| <Checkbox | <Checkbox | ||||
| checked={true} | checked={true} | ||||
| disabled={true} | disabled={true} | ||||
| @@ -4264,13 +4403,13 @@ paginatedData.map((row, index) => { | |||||
| status !== "partially_completed" && | status !== "partially_completed" && | ||||
| status !== "partially_complete" | status !== "partially_complete" | ||||
| ) { | ) { | ||||
| return null; | |||||
| return <Box sx={scanResultSlotSx} />; | |||||
| } | } | ||||
| // 正常 lot:已扫描(checked/partially_completed/completed) | // 正常 lot:已扫描(checked/partially_completed/completed) | ||||
| if (!isNoLot && status !== 'pending' && status !== 'rejected') { | if (!isNoLot && status !== 'pending' && status !== 'rejected') { | ||||
| return ( | return ( | ||||
| <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}> | |||||
| <Box sx={scanResultSlotSx}> | |||||
| <Checkbox | <Checkbox | ||||
| checked={true} | checked={true} | ||||
| disabled={true} | disabled={true} | ||||
| @@ -4289,7 +4428,7 @@ paginatedData.map((row, index) => { | |||||
| // noLot 且已完成/部分完成:显示红色勾选 | // noLot 且已完成/部分完成:显示红色勾选 | ||||
| if (isNoLot && (status === 'partially_completed' || status === 'completed')) { | if (isNoLot && (status === 'partially_completed' || status === 'completed')) { | ||||
| return ( | return ( | ||||
| <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}> | |||||
| <Box sx={scanResultSlotSx}> | |||||
| <Checkbox | <Checkbox | ||||
| checked={true} | checked={true} | ||||
| disabled={true} | disabled={true} | ||||
| @@ -4305,7 +4444,7 @@ paginatedData.map((row, index) => { | |||||
| ); | ); | ||||
| } | } | ||||
| return null; | |||||
| return <Box sx={scanResultSlotSx} />; | |||||
| })()} | })()} | ||||
| </TableCell> | </TableCell> | ||||
| {/* | {/* | ||||
| @@ -4391,11 +4530,10 @@ paginatedData.map((row, index) => { | |||||
| <Stack direction="row" spacing={1} alignItems="center" justifyContent="center"> | <Stack direction="row" spacing={1} alignItems="center" justifyContent="center"> | ||||
| {isRowPicked ? ( | {isRowPicked ? ( | ||||
| <Typography | <Typography | ||||
| variant="body2" | |||||
| variant="body1" | |||||
| sx={{ | sx={{ | ||||
| width: 96, | width: 96, | ||||
| textAlign: "center", | textAlign: "center", | ||||
| fontSize: "1rem", | |||||
| fontWeight: 500, | fontWeight: 500, | ||||
| }} | }} | ||||
| > | > | ||||
| @@ -4426,7 +4564,16 @@ paginatedData.map((row, index) => { | |||||
| inputProps={{ min: 0, step: 1 }} | inputProps={{ min: 0, step: 1 }} | ||||
| sx={{ | sx={{ | ||||
| width: 96, | 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")} | {t("Just Completed")} | ||||
| </Button> | </Button> | ||||
| @@ -4556,6 +4710,12 @@ paginatedData.map((row, index) => { | |||||
| ).trim() || null | ).trim() || null | ||||
| : null | : null | ||||
| } | } | ||||
| expectedUomId={ | |||||
| workbenchLotLabelContextLot != null && | |||||
| Number(workbenchLotLabelContextLot.uomId) > 0 | |||||
| ? Number(workbenchLotLabelContextLot.uomId) | |||||
| : null | |||||
| } | |||||
| disableScanPick={workbenchLotLabelScanPickDisabled} | disableScanPick={workbenchLotLabelScanPickDisabled} | ||||
| onWorkbenchScanPick={handleWorkbenchLotLabelScanPick} | onWorkbenchScanPick={handleWorkbenchLotLabelScanPick} | ||||
| submitQty={workbenchLotLabelSubmitQty} | submitQty={workbenchLotLabelSubmitQty} | ||||
| @@ -63,6 +63,8 @@ type QrCodeAnalysisResponse = { | |||||
| inventoryLotLineId: number; | inventoryLotLineId: number; | ||||
| warehouseCode?: string | null; | warehouseCode?: string | null; | ||||
| warehouseName?: string | null; | warehouseName?: string | null; | ||||
| uom?: string | null; | |||||
| uomId?: number | null; | |||||
| } | null; | } | null; | ||||
| sameItemLots: Array<{ | sameItemLots: Array<{ | ||||
| lotNo: string; | lotNo: string; | ||||
| @@ -70,6 +72,7 @@ type QrCodeAnalysisResponse = { | |||||
| stockInLineId?: number | null; | stockInLineId?: number | null; | ||||
| availableQty: number; | availableQty: number; | ||||
| uom: string; | uom: string; | ||||
| uomId?: number | null; | |||||
| warehouseCode?: string | null; | warehouseCode?: string | null; | ||||
| warehouseName?: string | null; | warehouseName?: string | null; | ||||
| }>; | }>; | ||||
| @@ -95,6 +98,8 @@ export interface WorkbenchLotLabelPrintModalProps { | |||||
| /** 提貨台表格列上的可用量/單位(API 的 sameItemLots 不含掃描行,需補上才能顯示「目前這筆」) */ | /** 提貨台表格列上的可用量/單位(API 的 sameItemLots 不含掃描行,需補上才能顯示「目前這筆」) */ | ||||
| triggerLotAvailableQty?: number | null; | triggerLotAvailableQty?: number | null; | ||||
| triggerLotUom?: string | null; | triggerLotUom?: string | null; | ||||
| /** POL UomConversion id — workbench lot list only returns matching UOM */ | |||||
| expectedUomId?: number | null; | |||||
| /** 此出庫行已掃碼/已完成時為 true,停用所有「掃碼提貨」(仍可列印標籤) */ | /** 此出庫行已掃碼/已完成時為 true,停用所有「掃碼提貨」(仍可列印標籤) */ | ||||
| disableScanPick?: boolean; | disableScanPick?: boolean; | ||||
| /** | /** | ||||
| @@ -141,6 +146,7 @@ function isLabelPrinter(p: Printer): boolean { | |||||
| return s.includes("label") && !s.includes("a4"); | 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> = ({ | const WorkbenchLotLabelPrintModal: React.FC<WorkbenchLotLabelPrintModalProps> = ({ | ||||
| open, | open, | ||||
| onClose, | onClose, | ||||
| @@ -155,6 +161,7 @@ const WorkbenchLotLabelPrintModal: React.FC<WorkbenchLotLabelPrintModalProps> = | |||||
| hideTriggeredLot = false, | hideTriggeredLot = false, | ||||
| triggerLotAvailableQty = null, | triggerLotAvailableQty = null, | ||||
| triggerLotUom = null, | triggerLotUom = null, | ||||
| expectedUomId = null, | |||||
| disableScanPick = false, | disableScanPick = false, | ||||
| onWorkbenchScanPick, | onWorkbenchScanPick, | ||||
| submitQty = null, | submitQty = null, | ||||
| @@ -257,13 +264,22 @@ const WorkbenchLotLabelPrintModal: React.FC<WorkbenchLotLabelPrintModalProps> = | |||||
| if (id != null) setSelectedPrinterId(id); | if (id != null) setSelectedPrinterId(id); | ||||
| }, [open, printers, selectedPrinterId, pickDefaultPrinterId]); | }, [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( | const analyzePayload = useCallback( | ||||
| async (payload: ScanPayload) => { | async (payload: ScanPayload) => { | ||||
| setLastPayload(payload); | setLastPayload(payload); | ||||
| setScanError(null); | setScanError(null); | ||||
| setAnalysisLoading(true); | setAnalysisLoading(true); | ||||
| try { | try { | ||||
| const data = (await analyzeWorkbenchQrCode(payload)) as QrCodeAnalysisResponse; | |||||
| const uomId = resolveExpectedUomId(); | |||||
| const data = (await analyzeWorkbenchQrCode({ | |||||
| ...payload, | |||||
| ...(uomId != null ? { uomId } : {}), | |||||
| })) as QrCodeAnalysisResponse; | |||||
| setAnalysis(data); | setAnalysis(data); | ||||
| setSnackbar({ | setSnackbar({ | ||||
| open: true, | open: true, | ||||
| @@ -277,7 +293,7 @@ const WorkbenchLotLabelPrintModal: React.FC<WorkbenchLotLabelPrintModalProps> = | |||||
| setAnalysisLoading(false); | setAnalysisLoading(false); | ||||
| } | } | ||||
| }, | }, | ||||
| [], | |||||
| [resolveExpectedUomId], | |||||
| ); | ); | ||||
| const analyzeByItem = useCallback( | const analyzeByItem = useCallback( | ||||
| @@ -290,7 +306,11 @@ const WorkbenchLotLabelPrintModal: React.FC<WorkbenchLotLabelPrintModalProps> = | |||||
| setScanError(null); | setScanError(null); | ||||
| setAnalysisLoading(true); | setAnalysisLoading(true); | ||||
| try { | try { | ||||
| const data = (await fetchWorkbenchAvailableLotsByItem(itemId)) as { | |||||
| const uomId = resolveExpectedUomId(); | |||||
| const data = (await fetchWorkbenchAvailableLotsByItem( | |||||
| itemId, | |||||
| uomId, | |||||
| )) as { | |||||
| itemId: number; | itemId: number; | ||||
| itemCode: string; | itemCode: string; | ||||
| itemName: string; | itemName: string; | ||||
| @@ -315,7 +335,7 @@ const WorkbenchLotLabelPrintModal: React.FC<WorkbenchLotLabelPrintModalProps> = | |||||
| setAnalysisLoading(false); | setAnalysisLoading(false); | ||||
| } | } | ||||
| }, | }, | ||||
| [], | |||||
| [resolveExpectedUomId], | |||||
| ); | ); | ||||
| const handleAnalyze = useCallback(async () => { | const handleAnalyze = useCallback(async () => { | ||||
| @@ -381,30 +401,51 @@ const WorkbenchLotLabelPrintModal: React.FC<WorkbenchLotLabelPrintModalProps> = | |||||
| Number.isFinite(tableQty) && tableQty >= 0 ? tableQty : 0; | Number.isFinite(tableQty) && tableQty >= 0 ? tableQty : 0; | ||||
| const fromApi = Number(scannedRow?.availableQty ?? 0); | const fromApi = Number(scannedRow?.availableQty ?? 0); | ||||
| const scanned = analysis.scanned; | 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 = [ | const merged = [ | ||||
| ...(!hideTriggeredLot && scannedLot ? [scannedLot] : []), | ...(!hideTriggeredLot && scannedLot ? [scannedLot] : []), | ||||
| ...list | ...list | ||||
| .filter((x) => x.inventoryLotLineId !== scannedLotLineId) | .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 })), | .map((x) => ({ ...x, _scanned: false as const })), | ||||
| ]; | ]; | ||||
| return merged; | return merged; | ||||
| }, [analysis, hideTriggeredLot, triggerLotAvailableQty, triggerLotUom]); | |||||
| }, [ | |||||
| analysis, | |||||
| hideTriggeredLot, | |||||
| triggerLotAvailableQty, | |||||
| triggerLotUom, | |||||
| expectedUomId, | |||||
| ]); | |||||
| const filteredLots = useMemo(() => { | const filteredLots = useMemo(() => { | ||||
| const prefix = String(warehouseCodePrefixFilter ?? "").trim(); | const prefix = String(warehouseCodePrefixFilter ?? "").trim(); | ||||
| @@ -5,13 +5,16 @@ import { | |||||
| Box, | Box, | ||||
| Card, | Card, | ||||
| CardContent, | CardContent, | ||||
| Checkbox, | |||||
| Chip, | Chip, | ||||
| CircularProgress, | CircularProgress, | ||||
| FormControl, | FormControl, | ||||
| InputLabel, | InputLabel, | ||||
| ListItemText, | |||||
| MenuItem, | MenuItem, | ||||
| Paper, | Paper, | ||||
| Select, | Select, | ||||
| SelectChangeEvent, | |||||
| Stack, | Stack, | ||||
| Table, | Table, | ||||
| TableBody, | 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 WorkbenchTicketReleaseTableTab: React.FC = () => { | ||||
| const { t } = useTranslation("ticketReleaseTable"); | const { t } = useTranslation("ticketReleaseTable"); | ||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | const { data: session } = useSession() as { data: SessionWithTokens | null }; | ||||
| @@ -73,6 +84,7 @@ const WorkbenchTicketReleaseTableTab: React.FC = () => { | |||||
| const [queryDate, setQueryDate] = useState<Dayjs>(() => dayjs()); | const [queryDate, setQueryDate] = useState<Dayjs>(() => dayjs()); | ||||
| const [selectedFloor, setSelectedFloor] = useState<string>(""); | const [selectedFloor, setSelectedFloor] = useState<string>(""); | ||||
| const [selectedStatus, setSelectedStatus] = useState<string>("released"); | const [selectedStatus, setSelectedStatus] = useState<string>("released"); | ||||
| const [selectedHandlers, setSelectedHandlers] = useState<string[]>([]); | |||||
| const [data, setData] = useState<WorkbenchTicketReleaseTable[]>([]); | const [data, setData] = useState<WorkbenchTicketReleaseTable[]>([]); | ||||
| const [loading, setLoading] = useState<boolean>(true); | const [loading, setLoading] = useState<boolean>(true); | ||||
| const [now, setNow] = useState(dayjs()); | const [now, setNow] = useState(dayjs()); | ||||
| @@ -111,6 +123,28 @@ const WorkbenchTicketReleaseTableTab: React.FC = () => { | |||||
| }, []); | }, []); | ||||
| const dayStr = queryDate.format("YYYY-MM-DD"); | 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(() => { | const filteredData = useMemo(() => { | ||||
| return data.filter((item) => { | return data.filter((item) => { | ||||
| if (selectedFloor && item.storeId !== selectedFloor) return false; | if (selectedFloor && item.storeId !== selectedFloor) return false; | ||||
| @@ -119,9 +153,27 @@ const WorkbenchTicketReleaseTableTab: React.FC = () => { | |||||
| if (itemDate !== dayStr) return false; | if (itemDate !== dayStr) return false; | ||||
| } | } | ||||
| if (selectedStatus && item.ticketStatus?.toLowerCase() !== selectedStatus.toLowerCase()) return false; | if (selectedStatus && item.ticketStatus?.toLowerCase() !== selectedStatus.toLowerCase()) return false; | ||||
| if (selectedHandlers.length > 0 && !selectedHandlers.includes(handlerFilterKey(item.handlerName))) { | |||||
| return false; | |||||
| } | |||||
| return true; | 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 paginatedData = useMemo(() => { | ||||
| const startIndex = paginationController.pageNum * paginationController.pageSize; | const startIndex = paginationController.pageNum * paginationController.pageSize; | ||||
| @@ -247,6 +299,39 @@ const WorkbenchTicketReleaseTableTab: React.FC = () => { | |||||
| <MenuItem value="completed">{t("completed")}</MenuItem> | <MenuItem value="completed">{t("completed")}</MenuItem> | ||||
| </Select> | </Select> | ||||
| </FormControl> | </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 }} /> | <Box sx={{ flexGrow: 1 }} /> | ||||
| <Stack direction="row" spacing={2} sx={{ flexShrink: 0, alignSelf: "center" }}> | <Stack direction="row" spacing={2} sx={{ flexShrink: 0, alignSelf: "center" }}> | ||||
| <Typography variant="body2" sx={{ color: "text.secondary" }} suppressHydrationWarning> | <Typography variant="body2" sx={{ color: "text.secondary" }} suppressHydrationWarning> | ||||
| @@ -36,7 +36,9 @@ export type ReleasedDoPickListBridge = { | |||||
| shopName?: string, | shopName?: string, | ||||
| storeId?: string, | storeId?: string, | ||||
| truck?: string, | truck?: string, | ||||
| releaseType?: string | |||||
| releaseType?: string, | |||||
| /** Optional `2F`/`4F` for Truck X supplier-floor split */ | |||||
| floor?: string | |||||
| ) => Promise<ReleasedDoPickOrderListItem[]>; | ) => Promise<ReleasedDoPickOrderListItem[]>; | ||||
| /** Optional 4th arg: workbench `requiredDeliveryDate` (YYYY-MM-DD) for default truck list; omit = calendar today. */ | /** Optional 4th arg: workbench `requiredDeliveryDate` (YYYY-MM-DD) for default truck list; omit = calendar today. */ | ||||
| loadToday: ( | loadToday: ( | ||||
| @@ -44,7 +46,9 @@ export type ReleasedDoPickListBridge = { | |||||
| storeId?: string, | storeId?: string, | ||||
| truck?: string, | truck?: string, | ||||
| requiredDeliveryDate?: string, | requiredDeliveryDate?: string, | ||||
| releaseType?: string | |||||
| releaseType?: string, | |||||
| /** Optional `2F`/`4F` for Truck X supplier-floor split */ | |||||
| floor?: string | |||||
| ) => Promise<ReleasedDoPickOrderListItem[]>; | ) => Promise<ReleasedDoPickOrderListItem[]>; | ||||
| assignByListItemId: (userId: number, id: number) => Promise<PostPickOrderResponse>; | assignByListItemId: (userId: number, id: number) => Promise<PostPickOrderResponse>; | ||||
| }; | }; | ||||
| @@ -70,8 +74,13 @@ interface Props { | |||||
| * requiredDate instead of historical released (delivery date before calendar today). | * requiredDate instead of historical released (delivery date before calendar today). | ||||
| */ | */ | ||||
| filterRequiredDeliveryDate?: string; | 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> = ({ | const ReleasedDoPickOrderSelectModal: React.FC<Props> = ({ | ||||
| open, | open, | ||||
| onClose, | onClose, | ||||
| @@ -85,6 +94,7 @@ const ReleasedDoPickOrderSelectModal: React.FC<Props> = ({ | |||||
| releaseTypeFilter, | releaseTypeFilter, | ||||
| initialShopSearch, | initialShopSearch, | ||||
| filterRequiredDeliveryDate, | filterRequiredDeliveryDate, | ||||
| truckXFloor, | |||||
| }) => { | }) => { | ||||
| const { t } = useTranslation("pickOrder"); | const { t } = useTranslation("pickOrder"); | ||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | const { data: session } = useSession() as { data: SessionWithTokens | null }; | ||||
| @@ -102,21 +112,25 @@ const ReleasedDoPickOrderSelectModal: React.FC<Props> = ({ | |||||
| const loadReleased = listBridge?.loadBeforeToday ?? fetchReleasedDoPickOrdersForSelection; | const loadReleased = listBridge?.loadBeforeToday ?? fetchReleasedDoPickOrdersForSelection; | ||||
| const loadTodayFn = listBridge?.loadToday ?? fetchReleasedDoPickOrdersForSelectionToday; | const loadTodayFn = listBridge?.loadToday ?? fetchReleasedDoPickOrdersForSelectionToday; | ||||
| const floorArg = truckXFloor?.trim() || undefined; | |||||
| const shopArg = shopSearch.trim() || undefined; | |||||
| if (isDefaultTruck) { | if (isDefaultTruck) { | ||||
| if (defaultDateScopeProp === "today") { | if (defaultDateScopeProp === "today") { | ||||
| data = await loadTodayFn( | data = await loadTodayFn( | ||||
| undefined, | |||||
| shopArg, | |||||
| undefined, | undefined, | ||||
| "車線-X", | "車線-X", | ||||
| defaultTruckRequiredDeliveryDate?.trim() || undefined, | defaultTruckRequiredDeliveryDate?.trim() || undefined, | ||||
| releaseTypeFilter?.trim() || undefined | |||||
| releaseTypeFilter?.trim() || undefined, | |||||
| floorArg | |||||
| ); | ); | ||||
| } else { | } else { | ||||
| data = await loadReleased( | data = await loadReleased( | ||||
| undefined, | |||||
| shopArg, | |||||
| undefined, | undefined, | ||||
| "車線-X", | "車線-X", | ||||
| releaseTypeFilter?.trim() || undefined | |||||
| releaseTypeFilter?.trim() || undefined, | |||||
| floorArg | |||||
| ); | ); | ||||
| } | } | ||||
| } else if (filterRequiredDeliveryDate?.trim() && listBridge?.loadToday) { | } else if (filterRequiredDeliveryDate?.trim() && listBridge?.loadToday) { | ||||
| @@ -143,7 +157,7 @@ const ReleasedDoPickOrderSelectModal: React.FC<Props> = ({ | |||||
| } finally { | } finally { | ||||
| setLoading(false); | setLoading(false); | ||||
| } | } | ||||
| }, [open, shopSearch, storeId, truck, isDefaultTruck, defaultDateScopeProp, listBridge, defaultTruckRequiredDeliveryDate, releaseTypeFilter, filterRequiredDeliveryDate]); | |||||
| }, [open, shopSearch, storeId, truck, isDefaultTruck, defaultDateScopeProp, listBridge, defaultTruckRequiredDeliveryDate, releaseTypeFilter, filterRequiredDeliveryDate, truckXFloor]); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| loadList(); | loadList(); | ||||
| @@ -12,7 +12,7 @@ import { | |||||
| analyzeQrCode, | analyzeQrCode, | ||||
| SearchInventory, | SearchInventory, | ||||
| SearchInventoryLotLine, | SearchInventoryLotLine, | ||||
| fetchInventories, | |||||
| fetchInventoriesLatest, | |||||
| fetchInventoryLotLines, | fetchInventoryLotLines, | ||||
| } from '@/app/api/inventory/actions'; | } from '@/app/api/inventory/actions'; | ||||
| import { PrinterCombo } from '@/app/api/settings/printer'; | import { PrinterCombo } from '@/app/api/settings/printer'; | ||||
| @@ -56,6 +56,7 @@ type SearchQuery = Partial< | |||||
| >; | >; | ||||
| type SearchParamNames = keyof SearchQuery; | 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 InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | ||||
| const { t } = useTranslation(['inventory', 'common', 'item']); | const { t } = useTranslation(['inventory', 'common', 'item']); | ||||
| @@ -171,12 +172,12 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||||
| pagingController: typeof defaultPagingController, | pagingController: typeof defaultPagingController, | ||||
| lotNo: string, | lotNo: string, | ||||
| ) => { | ) => { | ||||
| console.log('%c Action Type 1.', 'color:red', actionType); | |||||
| //console.log('%c Action Type 1.', 'color:red', actionType); | |||||
| // Avoid loading data again | // Avoid loading data again | ||||
| if (actionType === 'paging' && pagingController === defaultPagingController) { | if (actionType === 'paging' && pagingController === defaultPagingController) { | ||||
| return; | return; | ||||
| } | } | ||||
| console.log('%c Action Type 2.', 'color:blue', actionType); | |||||
| // console.log('%c Action Type 2.', 'color:blue', actionType); | |||||
| const params: SearchInventory = { | const params: SearchInventory = { | ||||
| code: query?.itemCode ?? '', | code: query?.itemCode ?? '', | ||||
| @@ -187,7 +188,7 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||||
| pageSize: pagingController.pageSize, | pageSize: pagingController.pageSize, | ||||
| }; | }; | ||||
| const response = await fetchInventories(params); | |||||
| const response = await fetchInventoriesLatest(params); | |||||
| if (response) { | if (response) { | ||||
| setInventoriesTotalCount(response.total); | setInventoriesTotalCount(response.total); | ||||
| @@ -199,7 +200,7 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||||
| break; | break; | ||||
| case 'paging': | case 'paging': | ||||
| setFilteredInventories((fi) => | setFilteredInventories((fi) => | ||||
| uniqBy([...fi, ...response.records], 'id'), | |||||
| uniqBy([...fi, ...response.records], 'itemId'), | |||||
| ); | ); | ||||
| } | } | ||||
| } | } | ||||
| @@ -414,7 +415,7 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||||
| scanUiMode, | scanUiMode, | ||||
| ]); | ]); | ||||
| console.log('', 'color: #666', inventoriesPagingController); | |||||
| //console.log('', 'color: #666', inventoriesPagingController); | |||||
| const handleOpenOpeningInventoryModal = useCallback(() => { | const handleOpenOpeningInventoryModal = useCallback(() => { | ||||
| setOpeningSelectedItem(null); | setOpeningSelectedItem(null); | ||||
| @@ -24,7 +24,7 @@ type ItemPriceSearchComponent = React.FC & { | |||||
| type SearchParamNames = keyof SearchQuery; | type SearchParamNames = keyof SearchQuery; | ||||
| const ItemPriceSearch: ItemPriceSearchComponent = () => { | const ItemPriceSearch: ItemPriceSearchComponent = () => { | ||||
| const { t } = useTranslation(["itemPrice", "inventory", "common", "importExcel"]); | |||||
| const { t } = useTranslation(["itemPrice", "common", "importExcel"]); | |||||
| const [item, setItem] = useState<ItemsResult | null>(null); | const [item, setItem] = useState<ItemsResult | null>(null); | ||||
| const [isSearching, setIsSearching] = useState(false); | 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 }}> | <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" }}> | <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 }}> | <Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1, flexShrink: 0 }}> | ||||
| {t("Average unit price", { ns: "inventory" })} | |||||
| {t("Average unit price")} | |||||
| </Typography> | </Typography> | ||||
| <Typography variant="h5" sx={{ width: "100%", flex: 1, display: "flex", alignItems: "center", justifyContent: "center", textAlign: "center", fontWeight: 500, color: "black", minHeight: 0 }}> | <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 | {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 }}> | <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" }}> | <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 }}> | <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> | ||||
| <Typography variant="h5" sx={{ width: "100%", textAlign: "center", fontWeight: 500, color: "black", flex: 1, minHeight: 0, display: "flex", alignItems: "center", justifyContent: "center" }}> | <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 | {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"; | "use client"; | ||||
| import React, { useCallback, useEffect, useMemo, useState } from "react"; | |||||
| import React, { useCallback, useEffect, useState } from "react"; | |||||
| import { | import { | ||||
| Box, | Box, | ||||
| Button, | Button, | ||||
| Card, | Card, | ||||
| CardContent, | |||||
| CardActions, | |||||
| Stack, | Stack, | ||||
| Typography, | Typography, | ||||
| Chip, | Chip, | ||||
| @@ -26,6 +24,17 @@ interface Props { | |||||
| printerCombo: PrinterCombo[]; | 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`. */ | /** Jo workbench: same list + detail flow as Jodetail `JoPickOrderList`, detail uses `JoWorkbench/newJobPickExecution`. */ | ||||
| const JoPickOrderList: React.FC<Props> = ({ printerCombo }) => { | const JoPickOrderList: React.FC<Props> = ({ printerCombo }) => { | ||||
| const { t } = useTranslation(["common", "jo"]); | 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("Item Name"), paramName: "itemName", type: "text" }, | ||||
| { | { | ||||
| label: t("Job Order Type"), | label: t("Job Order Type"), | ||||
| paramName: "BOM Description", | |||||
| paramName: "bomDescription", | |||||
| type: "select-labelled", | type: "select-labelled", | ||||
| options: [ | options: [ | ||||
| //{ label: t("All"), value: "All" }, | |||||
| { label: t("FG"), value: "FG" }, | { label: t("FG"), value: "FG" }, | ||||
| { label: t("WIP"), value: "WIP" }, | { label: t("WIP"), value: "WIP" }, | ||||
| ], | ], | ||||
| @@ -58,7 +66,6 @@ const JoPickOrderList: React.FC<Props> = ({ printerCombo }) => { | |||||
| paramName: "bomType", | paramName: "bomType", | ||||
| type: "select-labelled", | type: "select-labelled", | ||||
| options: [ | options: [ | ||||
| //{ label: t("All"), value: "All" }, | |||||
| { label: t("Drink"), value: "drink" }, | { label: t("Drink"), value: "drink" }, | ||||
| { label: t("Powder Mixture"), value: "Powder_Mixture" }, | { label: t("Powder Mixture"), value: "Powder_Mixture" }, | ||||
| { label: t("Other"), value: "other" }, | { label: t("Other"), value: "other" }, | ||||
| @@ -69,7 +76,6 @@ const JoPickOrderList: React.FC<Props> = ({ printerCombo }) => { | |||||
| paramName: "floor", | paramName: "floor", | ||||
| type: "select-labelled", | type: "select-labelled", | ||||
| options: [ | options: [ | ||||
| //{ label: t("All"), value: "ALL" }, | |||||
| { label: "2F", value: "2F" }, | { label: "2F", value: "2F" }, | ||||
| { label: "3F", value: "3F" }, | { label: "3F", value: "3F" }, | ||||
| { label: "4F", value: "4F" }, | { label: "4F", value: "4F" }, | ||||
| @@ -179,8 +185,8 @@ const JoPickOrderList: React.FC<Props> = ({ printerCombo }) => { | |||||
| <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> | <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> | ||||
| {t("Total pick orders")}: {pickOrders.length} | {t("Total pick orders")}: {pickOrders.length} | ||||
| </Typography> | </Typography> | ||||
| <Grid container spacing={2}> | |||||
| <Grid container spacing={2} alignItems="stretch"> | |||||
| {pickOrders.map((pickOrder) => { | {pickOrders.map((pickOrder) => { | ||||
| const status = String(pickOrder.jobOrderStatus || ""); | const status = String(pickOrder.jobOrderStatus || ""); | ||||
| const statusLower = status.toLowerCase(); | const statusLower = status.toLowerCase(); | ||||
| @@ -190,126 +196,216 @@ const JoPickOrderList: React.FC<Props> = ({ printerCombo }) => { | |||||
| : statusLower === "pending" || statusLower === "processing" | : statusLower === "pending" || statusLower === "processing" | ||||
| ? "primary" | ? "primary" | ||||
| : "default"; | : "default"; | ||||
| const finishedCount = pickOrder.finishedPickOLineCount ?? 0; | const finishedCount = pickOrder.finishedPickOLineCount ?? 0; | ||||
| const bomDescription = pickOrder.bomDescription | |||||
| ? String(pickOrder.bomDescription).trim() | |||||
| : ""; | |||||
| const bomType = pickOrder.bomType | |||||
| ? String(pickOrder.bomType).trim() | |||||
| : ""; | |||||
| return ( | 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 | <Card | ||||
| sx={{ | sx={{ | ||||
| minHeight: 180, | |||||
| maxHeight: 280, | |||||
| width: "100%", | |||||
| height: "100%", | |||||
| display: "flex", | display: "flex", | ||||
| flexDirection: "column", | flexDirection: "column", | ||||
| border: "1px solid", | |||||
| borderColor: "divider", | |||||
| borderRadius: 2, | |||||
| boxShadow: "none", | |||||
| }} | }} | ||||
| > | > | ||||
| <CardContent | |||||
| <Box | |||||
| sx={{ | sx={{ | ||||
| pb: 1, | |||||
| p: 2, | |||||
| flexGrow: 1, | 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> | </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> | </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} | {t("Finished lines")}: {finishedCount} | ||||
| </Typography> | </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> | </Card> | ||||
| </Grid> | </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 JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCombo = [] }) => { | ||||
| const workbenchMode = true; | const workbenchMode = true; | ||||
| const { t } = useTranslation("jo"); | const { t } = useTranslation("jo"); | ||||
| @@ -1799,88 +1800,92 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCo | |||||
| selectedLotForQr.suggestedPickLotId || selectedLotForQr.lotId; | selectedLotForQr.suggestedPickLotId || selectedLotForQr.lotId; | ||||
| let switchedToUnavailable = false; | 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"; | "use client"; | ||||
| import { PickOrderResult } from "@/app/api/pickOrder"; | import { PickOrderResult } from "@/app/api/pickOrder"; | ||||
| import { useCallback, useEffect, useMemo, useState } from "react"; | import { useCallback, useEffect, useMemo, useState } from "react"; | ||||
| import { useSearchParams } from "next/navigation"; | |||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import SearchBox, { Criterion } from "../SearchBox"; | import SearchBox, { Criterion } from "../SearchBox"; | ||||
| import { | import { | ||||
| @@ -50,8 +51,25 @@ type SearchQuery = Partial< | |||||
| type SearchParamNames = keyof SearchQuery; | 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 JodetailSearch: React.FC<Props> = ({ printerCombo }) => { | ||||
| const { t } = useTranslation("jo"); | 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 { data: session } = useSession() as { data: SessionWithTokens | null }; | ||||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | const currentUserId = session?.id ? parseInt(session.id) : undefined; | ||||
| @@ -61,7 +79,7 @@ const JodetailSearch: React.FC<Props> = ({ printerCombo }) => { | |||||
| //const [filteredPickOrders, setFilteredPickOrders] = useState(pickOrders); | //const [filteredPickOrders, setFilteredPickOrders] = useState(pickOrders); | ||||
| const [filterArgs, setFilterArgs] = useState<Record<string, any>>({}); | const [filterArgs, setFilterArgs] = useState<Record<string, any>>({}); | ||||
| const [searchQuery, setSearchQuery] = 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 [totalCount, setTotalCount] = useState<number>(); | ||||
| const [isAssigning, setIsAssigning] = useState(false); | const [isAssigning, setIsAssigning] = useState(false); | ||||
| const [unassignedOrders, setUnassignedOrders] = useState<any[]>([]); | const [unassignedOrders, setUnassignedOrders] = useState<any[]>([]); | ||||
| @@ -70,6 +88,10 @@ const JodetailSearch: React.FC<Props> = ({ printerCombo }) => { | |||||
| const [hasDataTab0, setHasDataTab0] = useState(false); | const [hasDataTab0, setHasDataTab0] = useState(false); | ||||
| const [hasDataTab1, setHasDataTab1] = useState(false); | const [hasDataTab1, setHasDataTab1] = useState(false); | ||||
| const hasAnyAssignedData = hasDataTab0 || hasDataTab1; | const hasAnyAssignedData = hasDataTab0 || hasDataTab1; | ||||
| useEffect(() => { | |||||
| if (urlTabIndex != null) setTabIndex(urlTabIndex); | |||||
| }, [urlTabIndex]); | |||||
| // Add printer selection state | // Add printer selection state | ||||
| const [selectedPrinter, setSelectedPrinter] = useState<PrinterCombo | null>( | const [selectedPrinter, setSelectedPrinter] = useState<PrinterCombo | null>( | ||||
| @@ -492,6 +514,9 @@ const JodetailSearch: React.FC<Props> = ({ printerCombo }) => { | |||||
| printerCombo={printerCombo} | printerCombo={printerCombo} | ||||
| selectedPrinter={selectedPrinter} | selectedPrinter={selectedPrinter} | ||||
| printQty={printQty} | printQty={printQty} | ||||
| initialPickOrderCode={urlPickOrderCode} | |||||
| initialTargetDate={urlTargetDate} | |||||
| openDetail={urlOpenDetail} | |||||
| /> | /> | ||||
| )} | )} | ||||
| {tabIndex === 2 && <MaterialPickStatusTable />} | {tabIndex === 2 && <MaterialPickStatusTable />} | ||||
| @@ -58,6 +58,10 @@ interface Props { | |||||
| printerCombo: PrinterCombo[]; | printerCombo: PrinterCombo[]; | ||||
| selectedPrinter?: PrinterCombo | null; | selectedPrinter?: PrinterCombo | null; | ||||
| printQty?: number; | printQty?: number; | ||||
| initialPickOrderCode?: string; | |||||
| initialTargetDate?: string; | |||||
| /** When true (Item Tracing `openDetail=1`), auto-open matching pick-order detail. */ | |||||
| openDetail?: boolean; | |||||
| } | } | ||||
| // 修改:已完成的 Job Order Pick Order 接口 | // 修改:已完成的 Job Order Pick Order 接口 | ||||
| @@ -112,11 +116,15 @@ interface LotDetail { | |||||
| match_status: string | null; | 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, | filterArgs, | ||||
| printerCombo, | printerCombo, | ||||
| selectedPrinter: selectedPrinterProp, | selectedPrinter: selectedPrinterProp, | ||||
| printQty: printQtyProp | |||||
| printQty: printQtyProp, | |||||
| initialPickOrderCode, | |||||
| initialTargetDate, | |||||
| openDetail = false, | |||||
| }) => { | }) => { | ||||
| const { t } = useTranslation("jo"); | const { t } = useTranslation("jo"); | ||||
| const router = useRouter(); | const router = useRouter(); | ||||
| @@ -135,15 +143,23 @@ const CompleteJobOrderRecord: React.FC<Props> = ({ | |||||
| const [detailLotDataLoading, setDetailLotDataLoading] = useState(false); | 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[]>([]); | const [filteredJobOrderPickOrders, setFilteredJobOrderPickOrders] = useState<CompletedJobOrderPickOrder[]>([]); | ||||
| // Use props with fallback | // Use props with fallback | ||||
| const selectedPrinter = selectedPrinterProp ?? (printerCombo && printerCombo.length > 0 ? printerCombo[0] : null); | const selectedPrinter = selectedPrinterProp ?? (printerCombo && printerCombo.length > 0 ? printerCombo[0] : null); | ||||
| const printQty = printQtyProp ?? 1; | const printQty = printQtyProp ?? 1; | ||||
| const pickRecordPrintInFlightRef = useRef(false); | const pickRecordPrintInFlightRef = useRef(false); | ||||
| const initialDetailOpenedRef = useRef(false); | |||||
| // 修改:分页状态 | // 修改:分页状态 | ||||
| const [paginationController, setPaginationController] = useState({ | const [paginationController, setPaginationController] = useState({ | ||||
| @@ -370,6 +386,27 @@ const CompleteJobOrderRecord: React.FC<Props> = ({ | |||||
| }, [fetchLotDetailsData]); | }, [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(() => { | const handleBackToList = useCallback(() => { | ||||
| setShowDetailView(false); | setShowDetailView(false); | ||||
| @@ -86,7 +86,7 @@ const NavigationContent: React.FC = () => { | |||||
| icon: <Storefront />, | icon: <Storefront />, | ||||
| labelKey: "nav.storeManagement", | labelKey: "nav.storeManagement", | ||||
| path: "", | 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: [ | children: [ | ||||
| { | { | ||||
| id: "nav.store.purchaseOrder", | id: "nav.store.purchaseOrder", | ||||
| @@ -109,6 +109,13 @@ const NavigationContent: React.FC = () => { | |||||
| requiredAbility: [AUTH.STOCK, AUTH.ADMIN], | requiredAbility: [AUTH.STOCK, AUTH.ADMIN], | ||||
| path: "/inventory", | path: "/inventory", | ||||
| }, | }, | ||||
| { | |||||
| id: "nav.store.itemTracing", | |||||
| icon: <QrCodeIcon />, | |||||
| labelKey: "nav.store.itemTracing", | |||||
| requiredAbility: [AUTH.ITEM_TRACING], | |||||
| path: "/itemTracing", | |||||
| }, | |||||
| { | { | ||||
| id: "nav.store.stockTake", | id: "nav.store.stockTake", | ||||
| icon: <AssignmentTurnedIn />, | icon: <AssignmentTurnedIn />, | ||||
| @@ -50,7 +50,9 @@ interface ItemRow { | |||||
| id: string; | id: string; | ||||
| pickOrderId: number; | pickOrderId: number; | ||||
| pickOrderCode: string; | pickOrderCode: string; | ||||
| pickOrderLineId?: number; | |||||
| itemId: number; | itemId: number; | ||||
| uomId?: number; | |||||
| itemCode: string; | itemCode: string; | ||||
| itemName: string; | itemName: string; | ||||
| requiredQty: number; | requiredQty: number; | ||||
| @@ -85,6 +87,7 @@ const style = { | |||||
| width: { xs: "100%", sm: "100%", md: "100%" }, | 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 AssignAndRelease: React.FC<Props> = ({ filterArgs }) => { | ||||
| const { t } = useTranslation("pickOrder"); | const { t } = useTranslation("pickOrder"); | ||||
| const { setIsUploading } = useUploadContext(); | const { setIsUploading } = useUploadContext(); | ||||
| @@ -167,11 +170,13 @@ const AssignAndRelease: React.FC<Props> = ({ filterArgs }) => { | |||||
| // const filteredRecords = res.records.filter( | // const filteredRecords = res.records.filter( | ||||
| // (item: any) => (item.status || "").toLowerCase() === "pending" | // (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, | pickOrderId: item.pickOrderId, | ||||
| pickOrderCode: item.pickOrderCode, | pickOrderCode: item.pickOrderCode, | ||||
| pickOrderLineId: item.pickOrderLineId, | |||||
| itemId: item.itemId, | itemId: item.itemId, | ||||
| uomId: item.uomId, | |||||
| itemCode: item.itemCode, | itemCode: item.itemCode, | ||||
| itemName: item.itemName, | itemName: item.itemName, | ||||
| requiredQty: item.requiredQty, | requiredQty: item.requiredQty, | ||||
| @@ -493,7 +498,7 @@ const AssignAndRelease: React.FC<Props> = ({ filterArgs }) => { | |||||
| ) : ( | ) : ( | ||||
| groupedItems.map((group) => ( | groupedItems.map((group) => ( | ||||
| group.items.map((item, index) => ( | group.items.map((item, index) => ( | ||||
| <TableRow key={item.id}> | |||||
| <TableRow key={item.id || `${group.pickOrderId}-${item.pickOrderLineId ?? item.itemId}-${index}`}> | |||||
| {/* Checkbox - 只在第一个项目显示,按 pick order 选择 */} | {/* Checkbox - 只在第一个项目显示,按 pick order 选择 */} | ||||
| <TableCell> | <TableCell> | ||||
| {index === 0 ? ( | {index === 0 ? ( | ||||
| @@ -1,6 +1,5 @@ | |||||
| import React, { useCallback } from 'react'; | import React, { useCallback } from 'react'; | ||||
| import { | import { | ||||
| Box, | |||||
| Typography, | Typography, | ||||
| Table, | Table, | ||||
| TableBody, | TableBody, | ||||
| @@ -43,15 +42,16 @@ interface Group { | |||||
| interface CreatedItemsTableProps { | interface CreatedItemsTableProps { | ||||
| items: CreatedItem[]; | items: CreatedItem[]; | ||||
| groups: Group[]; | 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; | pageNum: number; | ||||
| pageSize: number; | pageSize: number; | ||||
| onPageChange: (event: unknown, newPage: number) => void; | onPageChange: (event: unknown, newPage: number) => void; | ||||
| onPageSizeChange: (event: React.ChangeEvent<HTMLInputElement>) => 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> = ({ | const CreatedItemsTable: React.FC<CreatedItemsTableProps> = ({ | ||||
| items, | items, | ||||
| groups, | groups, | ||||
| @@ -65,15 +65,14 @@ const CreatedItemsTable: React.FC<CreatedItemsTableProps> = ({ | |||||
| }) => { | }) => { | ||||
| const { t } = useTranslation("pickOrder"); | const { t } = useTranslation("pickOrder"); | ||||
| // Calculate pagination | |||||
| const startIndex = (pageNum - 1) * pageSize; | const startIndex = (pageNum - 1) * pageSize; | ||||
| const endIndex = startIndex + pageSize; | const endIndex = startIndex + pageSize; | ||||
| const paginatedItems = items.slice(startIndex, endIndex); | 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); | const numValue = Number(value); | ||||
| if (!isNaN(numValue) && numValue >= 1) { | if (!isNaN(numValue) && numValue >= 1) { | ||||
| onQtyChange(itemId, numValue); | |||||
| onQtyChange(itemId, numValue, uomId); | |||||
| } | } | ||||
| }, [onQtyChange]); | }, [onQtyChange]); | ||||
| @@ -117,11 +116,11 @@ const CreatedItemsTable: React.FC<CreatedItemsTableProps> = ({ | |||||
| </TableRow> | </TableRow> | ||||
| ) : ( | ) : ( | ||||
| paginatedItems.map((item) => ( | paginatedItems.map((item) => ( | ||||
| <TableRow key={item.itemId}> | |||||
| <TableRow key={`${item.itemId}_${item.uomId}`}> | |||||
| <TableCell padding="checkbox"> | <TableCell padding="checkbox"> | ||||
| <Checkbox | <Checkbox | ||||
| checked={item.isSelected} | checked={item.isSelected} | ||||
| onChange={(e) => onItemSelect(item.itemId, e.target.checked)} | |||||
| onChange={(e) => onItemSelect(item.itemId, e.target.checked, item.uomId)} | |||||
| /> | /> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell> | <TableCell> | ||||
| @@ -134,7 +133,7 @@ const CreatedItemsTable: React.FC<CreatedItemsTableProps> = ({ | |||||
| <FormControl size="small" sx={{ minWidth: 120 }}> | <FormControl size="small" sx={{ minWidth: 120 }}> | ||||
| <Select | <Select | ||||
| value={item.groupId?.toString() || ""} | value={item.groupId?.toString() || ""} | ||||
| onChange={(e) => onGroupChange(item.itemId, e.target.value)} | |||||
| onChange={(e) => onGroupChange(item.itemId, e.target.value, item.uomId)} | |||||
| displayEmpty | displayEmpty | ||||
| > | > | ||||
| <MenuItem value=""> | <MenuItem value=""> | ||||
| @@ -157,14 +156,14 @@ const CreatedItemsTable: React.FC<CreatedItemsTableProps> = ({ | |||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell align="right"> | <TableCell align="right"> | ||||
| <Typography variant="body2">{item.uomDesc}</Typography> | |||||
| <Typography variant="body2">{item.uomDesc || item.uom || "-"}</Typography> | |||||
| </TableCell> | </TableCell> | ||||
| <TableCell align="right"> | <TableCell align="right"> | ||||
| <TextField | <TextField | ||||
| type="number" | type="number" | ||||
| size="small" | size="small" | ||||
| value={item.qty || ""} | value={item.qty || ""} | ||||
| onChange={(e) => handleQtyChange(item.itemId, e.target.value)} | |||||
| onChange={(e) => handleQtyChange(item.itemId, item.uomId, e.target.value)} | |||||
| inputProps={{ | inputProps={{ | ||||
| min: 1, | min: 1, | ||||
| step: 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 React, { useCallback } from 'react'; | ||||
| import { | import { | ||||
| Box, | |||||
| Typography, | Typography, | ||||
| Table, | Table, | ||||
| TableBody, | TableBody, | ||||
| @@ -22,8 +21,12 @@ import dayjs from 'dayjs'; | |||||
| interface SearchItemWithQty { | interface SearchItemWithQty { | ||||
| id: number; | id: number; | ||||
| /** Unique search-row key: itemId + uomId */ | |||||
| rowKey: string; | |||||
| label: string; | label: string; | ||||
| qty: number | null; | qty: number | null; | ||||
| uomId?: number; | |||||
| uom?: string; | |||||
| currentStockBalance?: number; | currentStockBalance?: number; | ||||
| uomDesc?: string; | uomDesc?: string; | ||||
| targetDate?: string | null; | targetDate?: string | null; | ||||
| @@ -40,17 +43,18 @@ interface SearchResultsTableProps { | |||||
| items: SearchItemWithQty[]; | items: SearchItemWithQty[]; | ||||
| selectedItemIds: (string | number)[]; | selectedItemIds: (string | number)[]; | ||||
| groups: Group[]; | 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; | pageNum: number; | ||||
| pageSize: number; | pageSize: number; | ||||
| onPageChange: (event: unknown, newPage: number) => void; | onPageChange: (event: unknown, newPage: number) => void; | ||||
| onPageSizeChange: (event: React.ChangeEvent<HTMLInputElement>) => 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> = ({ | const SearchResultsTable: React.FC<SearchResultsTableProps> = ({ | ||||
| items, | items, | ||||
| selectedItemIds, | selectedItemIds, | ||||
| @@ -67,16 +71,14 @@ const SearchResultsTable: React.FC<SearchResultsTableProps> = ({ | |||||
| }) => { | }) => { | ||||
| const { t } = useTranslation("pickOrder"); | const { t } = useTranslation("pickOrder"); | ||||
| // Calculate pagination | |||||
| const startIndex = (pageNum - 1) * pageSize; | const startIndex = (pageNum - 1) * pageSize; | ||||
| const endIndex = startIndex + pageSize; | const endIndex = startIndex + pageSize; | ||||
| const paginatedResults = items.slice(startIndex, endIndex); | 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)) { | if (value === "" || /^\d*\.?\d+$/.test(value)) { | ||||
| const numValue = value === "" ? null : Number(value); | const numValue = value === "" ? null : Number(value); | ||||
| onQtyChange(itemId, numValue); | |||||
| onQtyChange(rowKey, numValue); | |||||
| } | } | ||||
| }, [onQtyChange]); | }, [onQtyChange]); | ||||
| @@ -119,50 +121,45 @@ const SearchResultsTable: React.FC<SearchResultsTableProps> = ({ | |||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </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"> | <TableCell padding="checkbox"> | ||||
| <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> | </TableCell> | ||||
| {/* Item */} | |||||
| <TableCell> | <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> | </TableCell> | ||||
| {/* Group */} | |||||
| <TableCell> | <TableCell> | ||||
| <FormControl size="small" sx={{ minWidth: 120 }}> | <FormControl size="small" sx={{ minWidth: 120 }}> | ||||
| <Select | |||||
| <Select | |||||
| value={item.groupId?.toString() || ""} | value={item.groupId?.toString() || ""} | ||||
| onChange={(e) => onGroupChange(item.id, e.target.value)} | |||||
| onChange={(e) => onGroupChange(rowKey, e.target.value)} | |||||
| displayEmpty | displayEmpty | ||||
| disabled={isItemInCreated(item.id)} | |||||
| > | |||||
| disabled={inCreated} | |||||
| > | |||||
| <MenuItem value=""> | <MenuItem value=""> | ||||
| <em>{t("No Group")}</em> | |||||
| <em>{t("No Group")}</em> | |||||
| </MenuItem> | </MenuItem> | ||||
| {groups.map((group) => ( | {groups.map((group) => ( | ||||
| <MenuItem key={group.id} value={group.id.toString()}> | |||||
| <MenuItem key={group.id} value={group.id.toString()}> | |||||
| {group.name} | {group.name} | ||||
| </MenuItem> | |||||
| </MenuItem> | |||||
| ))} | ))} | ||||
| </Select> | |||||
| </Select> | |||||
| </FormControl> | </FormControl> | ||||
| </TableCell> | |||||
| {/* Current Stock */} | |||||
| </TableCell> | |||||
| <TableCell align="right"> | <TableCell align="right"> | ||||
| <Typography | <Typography | ||||
| variant="body2" | variant="body2" | ||||
| @@ -172,54 +169,42 @@ const SearchResultsTable: React.FC<SearchResultsTableProps> = ({ | |||||
| {item.currentStockBalance?.toLocaleString()||0} | {item.currentStockBalance?.toLocaleString()||0} | ||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| {/* Stock Unit */} | |||||
| <TableCell align="right"> | <TableCell align="right"> | ||||
| <Typography variant="body2"> | <Typography variant="body2"> | ||||
| {item.uomDesc || "-"} | |||||
| {item.uomDesc || item.uom || "-"} | |||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell align="right"> | <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' } | style: { textAlign: 'center' } | ||||
| }} | |||||
| sx={{ | |||||
| }} | |||||
| sx={{ | |||||
| width: '80px', | width: '80px', | ||||
| '& .MuiInputBase-input': { | '& .MuiInputBase-input': { | ||||
| textAlign: 'center', | |||||
| cursor: 'text' | |||||
| textAlign: 'center', | |||||
| cursor: 'text' | |||||
| } | } | ||||
| }} | |||||
| disabled={isItemInCreated(item.id)} | |||||
| }} | |||||
| /> | /> | ||||
| </TableCell> | </TableCell> | ||||
| {/* Target Date */} | |||||
| <TableCell align="right"> | <TableCell align="right"> | ||||
| <Typography variant="body2"> | <Typography variant="body2"> | ||||
| {item.targetDate ? dayjs(item.targetDate).format(OUTPUT_DATE_FORMAT) : "-"} | {item.targetDate ? dayjs(item.targetDate).format(OUTPUT_DATE_FORMAT) : "-"} | ||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| )) | |||||
| ); | |||||
| }) | |||||
| )} | )} | ||||
| </TableBody> | </TableBody> | ||||
| </Table> | </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 { | import { | ||||
| fetchConsumableWorkbenchPickOrderLotsHierarchical, | fetchConsumableWorkbenchPickOrderLotsHierarchical, | ||||
| reloadConsumableWorkbenchPickOrderLotsHierarchical, | reloadConsumableWorkbenchPickOrderLotsHierarchical, | ||||
| confirmLotSubstitution, | |||||
| suggestPickOrderWorkbenchV2, | suggestPickOrderWorkbenchV2, | ||||
| } from "@/app/api/pickOrder/actions"; | } from "@/app/api/pickOrder/actions"; | ||||
| import { workbenchScanPick } from "@/app/api/doworkbench/actions"; | import { workbenchScanPick } from "@/app/api/doworkbench/actions"; | ||||
| import { fetchStockInLineInfo } from "@/app/api/po/actions"; | import { fetchStockInLineInfo } from "@/app/api/po/actions"; | ||||
| import { fetchLotDetail } from "@/app/api/inventory/actions"; | |||||
| import WorkbenchLotLabelPrintModal from "@/components/DoWorkbench/WorkbenchLotLabelPrintModal"; | import WorkbenchLotLabelPrintModal from "@/components/DoWorkbench/WorkbenchLotLabelPrintModal"; | ||||
| import TestQrCodeProvider from "../QrCodeScannerProvider/TestQrCodeProvider"; | import TestQrCodeProvider from "../QrCodeScannerProvider/TestQrCodeProvider"; | ||||
| import { useQrCodeScannerContext } from "../QrCodeScannerProvider/QrCodeScannerProvider"; | import { useQrCodeScannerContext } from "../QrCodeScannerProvider/QrCodeScannerProvider"; | ||||
| @@ -66,6 +66,7 @@ type LineRow = { | |||||
| requiredQty: number; | requiredQty: number; | ||||
| pickedQty: number; | pickedQty: number; | ||||
| stockUnit: string; | stockUnit: string; | ||||
| uomId?: number; | |||||
| status: string; | status: string; | ||||
| lotsRaw: unknown[]; | lotsRaw: unknown[]; | ||||
| }; | }; | ||||
| @@ -89,6 +90,7 @@ type LotRow = { | |||||
| itemCode: string; | itemCode: string; | ||||
| itemName: string; | itemName: string; | ||||
| uomDesc: string; | uomDesc: string; | ||||
| uomId?: number; | |||||
| requiredQty: number; | requiredQty: number; | ||||
| pickOrderLineRequiredQty?: number; | pickOrderLineRequiredQty?: number; | ||||
| availableQty: number; | availableQty: number; | ||||
| @@ -226,15 +228,6 @@ const isCheckedStatus = (status: string | undefined): boolean => | |||||
| const isRejectedStatus = (status: string | undefined): boolean => | const isRejectedStatus = (status: string | undefined): boolean => | ||||
| String(status || "").toLowerCase() === "rejected"; | 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 { | function safeDisplayTargetDate(targetDate: string | number[]): string { | ||||
| try { | try { | ||||
| if (Array.isArray(targetDate) && targetDate.length >= 3) { | if (Array.isArray(targetDate) && targetDate.length >= 3) { | ||||
| @@ -273,6 +266,7 @@ function flattenLotsFromPickOrder(po: PickOrderTopRow): LotRow[] { | |||||
| itemCode: line.itemCode, | itemCode: line.itemCode, | ||||
| itemName: line.itemName, | itemName: line.itemName, | ||||
| uomDesc: toStr(lot.stockUnit) || line.stockUnit, | uomDesc: toStr(lot.stockUnit) || line.stockUnit, | ||||
| uomId: line.uomId, | |||||
| requiredQty: toNum(lot.requiredQty, line.requiredQty), | requiredQty: toNum(lot.requiredQty, line.requiredQty), | ||||
| pickOrderLineRequiredQty: line.requiredQty, | pickOrderLineRequiredQty: line.requiredQty, | ||||
| availableQty: toNum(lot.availableQty), | availableQty: toNum(lot.availableQty), | ||||
| @@ -333,6 +327,7 @@ function mapHierarchicalToPickOrders(data: unknown): PickOrderTopRow[] { | |||||
| requiredQty: toNum(line?.requiredQty), | requiredQty: toNum(line?.requiredQty), | ||||
| pickedQty: sumLinePickedQtyFromLots(lots), | pickedQty: sumLinePickedQtyFromLots(lots), | ||||
| stockUnit: toStr(item?.uomDesc ?? item?.uomCode), | stockUnit: toStr(item?.uomDesc ?? item?.uomCode), | ||||
| uomId: toNum(item?.uomId) > 0 ? toNum(item?.uomId) : undefined, | |||||
| status: toStr(line?.status), | status: toStr(line?.status), | ||||
| lotsRaw: lots, | 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 WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | ||||
| const { t } = useTranslation("pickOrder"); | const { t } = useTranslation("pickOrder"); | ||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | const { data: session } = useSession() as { data: SessionWithTokens | null }; | ||||
| @@ -770,6 +766,16 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| processedQrCodesRef.current.add(latestQr); | 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( | const openUnpickableScanLotLabelModal = useCallback( | ||||
| ( | ( | ||||
| pickRow: LotRow, | 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( | const handleLotConfirmation = useCallback( | ||||
| async (overrideScanned?: ConfirmLotState, overrideExpected?: ConfirmLotState) => { | async (overrideScanned?: ConfirmLotState, overrideExpected?: ConfirmLotState) => { | ||||
| const expected = overrideExpected ?? expectedLotData; | const expected = overrideExpected ?? expectedLotData; | ||||
| @@ -956,56 +963,25 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| setError(""); | setError(""); | ||||
| setMessage(""); | setMessage(""); | ||||
| try { | 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")); | setMessage(t("Scan pick success")); | ||||
| startTransition(() => { | startTransition(() => { | ||||
| @@ -1177,16 +1153,102 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| return; | 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 scannedRows = lotRowIndexes.byStockInLineId.get(scannedStockInLineId) || []; | ||||
| const scannedRowInItem = | |||||
| let scannedRowInItem = | |||||
| scannedRows.find( | scannedRows.find( | ||||
| (r) => | (r) => | ||||
| Number(r.itemId) === scannedItemId && | 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; | null; | ||||
| if (scannedRowInItem && isRejectedStatus(scannedRowInItem.status)) { | if (scannedRowInItem && isRejectedStatus(scannedRowInItem.status)) { | ||||
| @@ -1231,14 +1293,46 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| return; | 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; | let scannedState: ConfirmLotState | null = null; | ||||
| @@ -1292,6 +1386,7 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| pickExpectedRowForSubstitution, | pickExpectedRowForSubstitution, | ||||
| lotRowIndexes, | lotRowIndexes, | ||||
| openUnpickableScanLotLabelModal, | openUnpickableScanLotLabelModal, | ||||
| releaseProcessedQr, | |||||
| resetScan, | resetScan, | ||||
| submitRow, | submitRow, | ||||
| t, | t, | ||||
| @@ -1306,10 +1401,9 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| }, [isScanning, startScan, userId]); | }, [isScanning, startScan, userId]); | ||||
| useEffect(() => { | 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]); | }, [selectedPickOrderId]); | ||||
| useEffect(() => { | useEffect(() => { | ||||
| @@ -1521,6 +1615,11 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| const isRowExpired = | const isRowExpired = | ||||
| isWorkbenchSourceLotExpired(r) && !isRowRejected; | isWorkbenchSourceLotExpired(r) && !isRowRejected; | ||||
| const isRowUnavailable = isInventoryLotLineUnavailable(r); | const isRowUnavailable = isInventoryLotLineUnavailable(r); | ||||
| const isRowComplete = | |||||
| rowStatus === "completed" || | |||||
| rowStatus === "checked" || | |||||
| rowStatus === "partially_completed" || | |||||
| rowStatus === "partially_complete"; | |||||
| return ( | return ( | ||||
| <TableRow key={r.key}> | <TableRow key={r.key}> | ||||
| @@ -1544,7 +1643,9 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| ? "error.main" | ? "error.main" | ||||
| : isRowExpired || isLotAvailabilityExpired(r) | : isRowExpired || isLotAvailabilityExpired(r) | ||||
| ? "warning.main" | ? "warning.main" | ||||
| : "inherit", | |||||
| : isRowComplete | |||||
| ? "success.main" | |||||
| : "inherit", | |||||
| }} | }} | ||||
| > | > | ||||
| {r.lotNo ? ( | {r.lotNo ? ( | ||||
| @@ -1699,7 +1800,19 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| return { ...prev, [r.stockOutLineId]: n }; | 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} | disabled={!r.stockOutLineId || qtyEditableBySolId[r.stockOutLineId] !== true} | ||||
| inputProps={{ min: 0, step: 1 }} | inputProps={{ min: 0, step: 1 }} | ||||
| /> | /> | ||||
| @@ -1779,6 +1892,12 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| statusTitleSeverity={workbenchLotLabelStatusBanner.severity} | statusTitleSeverity={workbenchLotLabelStatusBanner.severity} | ||||
| triggerLotAvailableQty={workbenchLotLabelContextLot?.availableQty ?? null} | triggerLotAvailableQty={workbenchLotLabelContextLot?.availableQty ?? null} | ||||
| triggerLotUom={workbenchLotLabelContextLot?.uomDesc ?? null} | triggerLotUom={workbenchLotLabelContextLot?.uomDesc ?? null} | ||||
| expectedUomId={ | |||||
| workbenchLotLabelContextLot != null && | |||||
| Number(workbenchLotLabelContextLot.uomId) > 0 | |||||
| ? Number(workbenchLotLabelContextLot.uomId) | |||||
| : null | |||||
| } | |||||
| submitQty={ | submitQty={ | ||||
| workbenchLotLabelContextLot?.stockOutLineId | workbenchLotLabelContextLot?.stockOutLineId | ||||
| ? Number(resolveLockedSubmitQtyDisplay(workbenchLotLabelContextLot)) | ? Number(resolveLockedSubmitQtyDisplay(workbenchLotLabelContextLot)) | ||||
| @@ -70,6 +70,8 @@ interface CreatedItem { | |||||
| // Add interface for search items with quantity | // Add interface for search items with quantity | ||||
| interface SearchItemWithQty extends ItemCombo { | interface SearchItemWithQty extends ItemCombo { | ||||
| /** Unique per item+uom search row */ | |||||
| rowKey: string; | |||||
| qty: number | null; // Changed from number to number | null | qty: number | null; // Changed from number to number | null | ||||
| jobOrderCode?: string; | jobOrderCode?: string; | ||||
| jobOrderId?: number; | jobOrderId?: number; | ||||
| @@ -77,6 +79,10 @@ interface SearchItemWithQty extends ItemCombo { | |||||
| targetDate?: string | null; // Allow null values | targetDate?: string | null; // Allow null values | ||||
| groupId?: number | 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 { | interface JobOrderDetailPickLine { | ||||
| id: number; | id: number; | ||||
| code: string; | code: string; | ||||
| @@ -95,8 +101,9 @@ interface Group { | |||||
| } | } | ||||
| // Move the counter outside the component to persist across re-renders | // Move the counter outside the component to persist across re-renders | ||||
| let checkboxChangeCallCount = 0; | 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 NewCreateItem: React.FC<Props> = ({ filterArgs, searchQuery, onPickOrderCreated }) => { | ||||
| const { t } = useTranslation("pickOrder"); | const { t } = useTranslation("pickOrder"); | ||||
| const [items, setItems] = useState<ItemCombo[]>([]); | const [items, setItems] = useState<ItemCombo[]>([]); | ||||
| @@ -174,6 +181,7 @@ const NewCreateItem: React.FC<Props> = ({ filterArgs, searchQuery, onPickOrderCr | |||||
| uom: item.uom, | uom: item.uom, | ||||
| uomId: 0, | uomId: 0, | ||||
| uomDesc: item.uomDesc || "", // Add missing uomDesc | uomDesc: item.uomDesc || "", // Add missing uomDesc | ||||
| rowKey: toSearchRowKey(item.id, 0), | |||||
| jobOrderCode: jobOrderDetail.code, | jobOrderCode: jobOrderDetail.code, | ||||
| jobOrderId: jobOrderDetail.id, | jobOrderId: jobOrderDetail.id, | ||||
| currentStockBalance: 0, // Add default value | 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) => { | const handleSearchItemSelect = useCallback((itemId: number, isSelected: boolean) => { | ||||
| if (isSelected) { | if (isSelected) { | ||||
| const item = filteredItems.find(i => i.id === itemId); | const item = filteredItems.find(i => i.id === itemId); | ||||
| if (!item) return; | 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")); | alert(t("Item already exists in created items")); | ||||
| return; | return; | ||||
| } | } | ||||
| // Fix the newCreatedItem creation - add missing uomDesc | |||||
| const newCreatedItem: CreatedItem = { | const newCreatedItem: CreatedItem = { | ||||
| itemId: item.id, | itemId: item.id, | ||||
| itemName: item.label, | itemName: item.label, | ||||
| itemCode: item.label, | itemCode: item.label, | ||||
| qty: item.qty || 1, | qty: item.qty || 1, | ||||
| uom: item.uom || "", | uom: item.uom || "", | ||||
| uomId: item.uomId || 0, | |||||
| uomDesc: item.uomDesc || "", // Add missing uomDesc | |||||
| uomId, | |||||
| uomDesc: item.uomDesc || "", | |||||
| isSelected: true, | isSelected: true, | ||||
| currentStockBalance: item.currentStockBalance, | 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]); | setCreatedItems(prev => [...prev, newCreatedItem]); | ||||
| } | } | ||||
| }, [filteredItems, createdItems, t, targetDate]); | }, [filteredItems, createdItems, t, targetDate]); | ||||
| // Handler for created item selection | // Handler for created item selection | ||||
| const handleCreatedItemSelect = useCallback((itemId: number, isSelected: boolean) => { | |||||
| const handleCreatedItemSelect = useCallback((itemId: number, isSelected: boolean, uomId?: number) => { | |||||
| setCreatedItems(prev => | setCreatedItems(prev => | ||||
| prev.map(item => | 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 => | setCreatedItems(prev => | ||||
| prev.map(item => | prev.map(item => | ||||
| item.itemId === itemId | |||||
| item.itemId === itemId && | |||||
| (uomId == null || Number(item.uomId) === Number(uomId)) | |||||
| ? { | ? { | ||||
| ...item, | ...item, | ||||
| qty: Math.max(1, Math.min(newQty, Math.max(0, item.currentStockBalance ?? 0))), | 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]); | }, [createdItems]); | ||||
| // 1) Created Items 行内改组:只改这一行的 groupId,并把该行 targetDate 同步为该组日期 | // 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 gid = newGroupId ? Number(newGroupId) : undefined; | ||||
| const group = groups.find(g => g.id === gid); | const group = groups.find(g => g.id === gid); | ||||
| setCreatedItems(prev => | setCreatedItems(prev => | ||||
| prev.map(it => | prev.map(it => | ||||
| it.itemId === itemId | |||||
| it.itemId === itemId && | |||||
| (uomId == null || Number(it.uomId) === Number(uomId)) | |||||
| ? { | ? { | ||||
| ...it, | ...it, | ||||
| groupId: gid, | groupId: gid, | ||||
| @@ -410,27 +424,23 @@ const NewCreateItem: React.FC<Props> = ({ filterArgs, searchQuery, onPickOrderCr | |||||
| } | } | ||||
| }, [t]); | }, [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; | 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; | const hasGroup = item.groupId !== undefined && item.groupId !== null; | ||||
| // 3. Quantity is entered | |||||
| const hasQty = item.qty !== null && item.qty !== undefined && item.qty > 0; | 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 = { | const newCreatedItem: CreatedItem = { | ||||
| itemId: item.id, | itemId: item.id, | ||||
| itemName: item.label, | itemName: item.label, | ||||
| itemCode: item.label, | itemCode: item.label, | ||||
| qty: item.qty || 1, | qty: item.qty || 1, | ||||
| uom: item.uom || "", | uom: item.uom || "", | ||||
| uomId: item.uomId || 0, | |||||
| uomId: Number(item.uomId) || 0, | |||||
| uomDesc: item.uomDesc || "", | uomDesc: item.uomDesc || "", | ||||
| isSelected: true, | isSelected: true, | ||||
| currentStockBalance: item.currentStockBalance, | currentStockBalance: item.currentStockBalance, | ||||
| @@ -438,33 +448,29 @@ const NewCreateItem: React.FC<Props> = ({ filterArgs, searchQuery, onPickOrderCr | |||||
| groupId: item.groupId || undefined, | groupId: item.groupId || undefined, | ||||
| }; | }; | ||||
| setCreatedItems(prev => [...prev, newCreatedItem]); | 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]); | }, [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(() => { | 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; | 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 hasGroup = currentItem.groupId !== undefined && currentItem.groupId !== null; | ||||
| const hasQty = currentItem.qty !== null && currentItem.qty !== undefined && currentItem.qty > 0; | 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 = { | const newCreatedItem: CreatedItem = { | ||||
| itemId: currentItem.id, | itemId: currentItem.id, | ||||
| itemName: currentItem.label, | itemName: currentItem.label, | ||||
| itemCode: currentItem.label, | itemCode: currentItem.label, | ||||
| qty: currentItem.qty || 1, | qty: currentItem.qty || 1, | ||||
| uom: currentItem.uom || "", | uom: currentItem.uom || "", | ||||
| uomId: currentItem.uomId || 0, | |||||
| uomId: Number(currentItem.uomId) || 0, | |||||
| uomDesc: currentItem.uomDesc || "", | uomDesc: currentItem.uomDesc || "", | ||||
| isSelected: true, | isSelected: true, | ||||
| currentStockBalance: currentItem.currentStockBalance, | currentStockBalance: currentItem.currentStockBalance, | ||||
| @@ -472,18 +478,18 @@ const handleQtyBlur = useCallback((itemId: number) => { | |||||
| groupId: currentItem.groupId || undefined, | groupId: currentItem.groupId || undefined, | ||||
| }; | }; | ||||
| setCreatedItems(prev => [...prev, newCreatedItem]); | 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); | }, 0); | ||||
| }, [secondSearchResults, selectedSecondSearchItemIds, isItemInCreated, targetDate]); | }, [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 gid = groupId ? Number(groupId) : undefined; | ||||
| const group = groups.find(g => g.id === gid); | const group = groups.find(g => g.id === gid); | ||||
| setSecondSearchResults(prev => prev.map(item => | setSecondSearchResults(prev => prev.map(item => | ||||
| item.id === itemId | |||||
| (item.rowKey || toSearchRowKey(item.id, item.uomId)) === rowKey | |||||
| ? { | ? { | ||||
| ...item, | ...item, | ||||
| groupId: gid, | groupId: gid, | ||||
| @@ -492,9 +498,8 @@ const handleQtyBlur = useCallback((itemId: number) => { | |||||
| : item | : item | ||||
| )); | )); | ||||
| // Check auto-add after group assignment | |||||
| setTimeout(() => { | setTimeout(() => { | ||||
| checkAndAutoAddItem(itemId); | |||||
| checkAndAutoAddItem(rowKey); | |||||
| }, 0); | }, 0); | ||||
| }, [groups, checkAndAutoAddItem]); | }, [groups, checkAndAutoAddItem]); | ||||
| // 5) 选中新增的待选项:依然按“当前 Group”赋 groupId + targetDate(新加入的应随 Group) | // 5) 选中新增的待选项:依然按“当前 Group”赋 groupId + targetDate(新加入的应随 Group) | ||||
| @@ -550,6 +555,7 @@ const handleQtyBlur = useCallback((itemId: number) => { | |||||
| uomId: item.uomId, | uomId: item.uomId, | ||||
| uom: item.uom, | uom: item.uom, | ||||
| uomDesc: item.uomDesc, | uomDesc: item.uomDesc, | ||||
| rowKey: toSearchRowKey(item.id, item.uomId), | |||||
| currentStockBalance: item.currentStockBalance, | currentStockBalance: item.currentStockBalance, | ||||
| qty: null, | qty: null, | ||||
| targetDate: targetDate, | targetDate: targetDate, | ||||
| @@ -1017,7 +1023,7 @@ const handleQtyBlur = useCallback((itemId: number) => { | |||||
| name: "id", | name: "id", | ||||
| label: "", | label: "", | ||||
| type: "checkbox", | 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]); | }, [formProps]); | ||||
| // 添加数量变更处理函数 | // 添加数量变更处理函数 | ||||
| const handleSecondSearchQtyChange = useCallback((itemId: number, newQty: number | null) => { | |||||
| const handleSecondSearchQtyChange = useCallback((rowKey: string, newQty: number | null) => { | |||||
| const getClampedQty = (qty: number | null, stock?: number) => { | const getClampedQty = (qty: number | null, stock?: number) => { | ||||
| if (qty === null) return null; | if (qty === null) return null; | ||||
| const maxQty = Math.max(0, stock ?? 0); | const maxQty = Math.max(0, stock ?? 0); | ||||
| @@ -1150,7 +1156,9 @@ const handleQtyBlur = useCallback((itemId: number) => { | |||||
| setSecondSearchResults(prev => | setSecondSearchResults(prev => | ||||
| prev.map(item => | 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); | const newIds = ids(selectedSecondSearchItemIds); | ||||
| setSelectedSecondSearchItemIds(newIds); | setSelectedSecondSearchItemIds(newIds); | ||||
| // 处理全选逻辑 - 选择所有搜索结果,不仅仅是当前页面 | |||||
| if (newIds.length === secondSearchResults.length) { | if (newIds.length === secondSearchResults.length) { | ||||
| // 全选:将所有搜索结果添加到创建项目 | |||||
| secondSearchResults.forEach(item => { | secondSearchResults.forEach(item => { | ||||
| if (!isItemInCreated(item.id)) { | |||||
| if (!isItemInCreated(item.id, item.uomId)) { | |||||
| handleSearchItemSelect(item.id, true); | handleSearchItemSelect(item.id, true); | ||||
| } | } | ||||
| }); | }); | ||||
| } else { | } else { | ||||
| // 部分选择:只处理当前页面的选择 | |||||
| secondSearchResults.forEach(item => { | 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) { | if (isSelected && !isCurrentlyInCreated) { | ||||
| handleSearchItemSelect(item.id, true); | handleSearchItemSelect(item.id, true); | ||||
| } else if (!isSelected && isCurrentlyInCreated) { | } 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)); | const newlyDeselected = previousIds.filter(id => !ids.includes(id)); | ||||
| newlySelected.forEach(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 => { | 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]); | }, [selectedSecondSearchItemIds, secondSearchResults, isItemInCreated, handleSearchItemSelect]); | ||||
| @@ -1209,7 +1223,7 @@ const handleQtyBlur = useCallback((itemId: number) => { | |||||
| name: "id", | name: "id", | ||||
| label: "", | label: "", | ||||
| type: "checkbox", | type: "checkbox", | ||||
| disabled: (item) => isItemInCreated(item.id), | |||||
| disabled: (item) => isItemInCreated(item.id, item.uomId), | |||||
| }, | }, | ||||
| { | { | ||||
| name: "label", | name: "label", | ||||
| @@ -1260,7 +1274,7 @@ const handleQtyBlur = useCallback((itemId: number) => { | |||||
| renderCell: (item) => ( | renderCell: (item) => ( | ||||
| <Box sx={{ display: 'flex', justifyContent: 'flex-end', width: '100%' }}> | <Box sx={{ display: 'flex', justifyContent: 'flex-end', width: '100%' }}> | ||||
| <Typography sx={{ textAlign: 'right' }}> {/* Add right alignment for the value */} | <Typography sx={{ textAlign: 'right' }}> {/* Add right alignment for the value */} | ||||
| {item.uom || "-"} | |||||
| {item.uomDesc || item.uom || "-"} | |||||
| </Typography> | </Typography> | ||||
| </Box> | </Box> | ||||
| ), | ), | ||||
| @@ -1280,7 +1294,7 @@ const handleQtyBlur = useCallback((itemId: number) => { | |||||
| // Only allow numbers | // Only allow numbers | ||||
| if (value === "" || /^\d+$/.test(value)) { | if (value === "" || /^\d+$/.test(value)) { | ||||
| const numValue = value === "" ? null : Number(value); | const numValue = value === "" ? null : Number(value); | ||||
| handleSecondSearchQtyChange(item.id, numValue); | |||||
| handleSecondSearchQtyChange(item.rowKey || toSearchRowKey(item.id, item.uomId), numValue); | |||||
| } | } | ||||
| }} | }} | ||||
| inputProps={{ | inputProps={{ | ||||
| @@ -1299,7 +1313,7 @@ const handleQtyBlur = useCallback((itemId: number) => { | |||||
| const value = e.target.value; | const value = e.target.value; | ||||
| const numValue = value === "" ? null : Number(value); | const numValue = value === "" ? null : Number(value); | ||||
| if (numValue !== null && numValue < 1) { | 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, | uomId: item.uomId, | ||||
| uom: item.uom, | uom: item.uom, | ||||
| uomDesc: item.uomDesc, | uomDesc: item.uomDesc, | ||||
| rowKey: toSearchRowKey(item.id, item.uomId), | |||||
| currentStockBalance: item.currentStockBalance, | currentStockBalance: item.currentStockBalance, | ||||
| qty: null, | qty: null, | ||||
| targetDate: undefined, | targetDate: undefined, | ||||
| @@ -1493,7 +1508,7 @@ const handleQtyBlur = useCallback((itemId: number) => { | |||||
| }, []); | }, []); | ||||
| const getValidationMessage = useCallback(() => { | const getValidationMessage = useCallback(() => { | ||||
| const selectedItems = secondSearchResults.filter(item => | const selectedItems = secondSearchResults.filter(item => | ||||
| selectedSecondSearchItemIds.includes(item.id) | |||||
| selectedSecondSearchItemIds.includes(item.rowKey || toSearchRowKey(item.id, item.uomId)) | |||||
| ); | ); | ||||
| const itemsWithoutGroup = selectedItems.filter(item => | const itemsWithoutGroup = selectedItems.filter(item => | ||||
| @@ -1517,42 +1532,42 @@ const getValidationMessage = useCallback(() => { | |||||
| // Fix the handleAddSelectedToCreatedItems function to properly clear selections | // Fix the handleAddSelectedToCreatedItems function to properly clear selections | ||||
| const handleAddSelectedToCreatedItems = useCallback(() => { | const handleAddSelectedToCreatedItems = useCallback(() => { | ||||
| const selectedItems = secondSearchResults.filter(item => | 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([]); | 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]); | }, [secondSearchResults, selectedSecondSearchItemIds, isItemInCreated, targetDate]); | ||||
| // Add a validation function to check if selected items are valid | // Add a validation function to check if selected items are valid | ||||
| const areSelectedItemsValid = useCallback(() => { | const areSelectedItemsValid = useCallback(() => { | ||||
| const selectedItems = secondSearchResults.filter(item => | const selectedItems = secondSearchResults.filter(item => | ||||
| selectedSecondSearchItemIds.includes(item.id) | |||||
| selectedSecondSearchItemIds.includes(item.rowKey || toSearchRowKey(item.id, item.uomId)) | |||||
| ); | ); | ||||
| return selectedItems.every(item => | return selectedItems.every(item => | ||||
| @@ -1566,17 +1581,17 @@ const getValidationMessage = useCallback(() => { | |||||
| // Move these handlers to the component level (outside of CustomSearchResultsTable) | // Move these handlers to the component level (outside of CustomSearchResultsTable) | ||||
| // Handle individual checkbox change - ONLY select, don't add to created items | // 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++; | checkboxChangeCallCount++; | ||||
| if (checked) { | if (checked) { | ||||
| // Add to selected IDs | // Add to selected IDs | ||||
| setSelectedSecondSearchItemIds(prev => [...prev, itemId]); | |||||
| setSelectedSecondSearchItemIds(prev => [...prev, rowKey]); | |||||
| // Set the item's group and targetDate to current group when selected | // Set the item's group and targetDate to current group when selected | ||||
| setSecondSearchResults(prev => { | setSecondSearchResults(prev => { | ||||
| const updatedResults = prev.map(item => | const updatedResults = prev.map(item => | ||||
| item.id === itemId | |||||
| (item.rowKey || toSearchRowKey(item.id, item.uomId)) === rowKey | |||||
| ? { | ? { | ||||
| ...item, | ...item, | ||||
| groupId: selectedGroup?.id || undefined, | groupId: selectedGroup?.id || undefined, | ||||
| @@ -1588,29 +1603,27 @@ const getValidationMessage = useCallback(() => { | |||||
| // Check if should auto-add after state update | // Check if should auto-add after state update | ||||
| setTimeout(() => { | setTimeout(() => { | ||||
| // Check if we're already processing this item | // 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; | return; | ||||
| } | } | ||||
| const updatedItem = updatedResults.find(i => i.id === itemId); | |||||
| const updatedItem = updatedResults.find(i => (i.rowKey || toSearchRowKey(i.id, i.uomId)) === rowKey); | |||||
| if (updatedItem) { | if (updatedItem) { | ||||
| const isSelected = true; // We just selected it | const isSelected = true; // We just selected it | ||||
| const hasGroup = updatedItem.groupId !== undefined && updatedItem.groupId !== null; | const hasGroup = updatedItem.groupId !== undefined && updatedItem.groupId !== null; | ||||
| const hasQty = updatedItem.qty !== null && updatedItem.qty !== undefined && updatedItem.qty > 0; | 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) | // 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 = { | const newCreatedItem: CreatedItem = { | ||||
| itemId: updatedItem.id, | itemId: updatedItem.id, | ||||
| itemName: updatedItem.label, | itemName: updatedItem.label, | ||||
| itemCode: updatedItem.label, | itemCode: updatedItem.label, | ||||
| qty: updatedItem.qty || 1, | qty: updatedItem.qty || 1, | ||||
| uom: updatedItem.uom || "", | uom: updatedItem.uom || "", | ||||
| uomId: updatedItem.uomId || 0, | |||||
| uomId: Number(updatedItem.uomId) || 0, | |||||
| uomDesc: updatedItem.uomDesc || "", | uomDesc: updatedItem.uomDesc || "", | ||||
| isSelected: true, | isSelected: true, | ||||
| currentStockBalance: updatedItem.currentStockBalance, | currentStockBalance: updatedItem.currentStockBalance, | ||||
| @@ -1618,29 +1631,16 @@ const getValidationMessage = useCallback(() => { | |||||
| groupId: updatedItem.groupId || undefined, | groupId: updatedItem.groupId || undefined, | ||||
| }; | }; | ||||
| setCreatedItems(prev => [...prev, newCreatedItem]); | 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(() => { | setTimeout(() => { | ||||
| processingItems.delete(itemId); | |||||
| processingItems.delete(rowKey); | |||||
| }, 100); | }, 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); | }, 0); | ||||
| @@ -1648,11 +1648,11 @@ Processing items: ${Array.from(processingItems).join(', ')}`); | |||||
| }); | }); | ||||
| } else { | } else { | ||||
| // Remove from selected IDs | // 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 | // Clear the item's group and targetDate when deselected | ||||
| setSecondSearchResults(prev => prev.map(item => | setSecondSearchResults(prev => prev.map(item => | ||||
| item.id === itemId | |||||
| (item.rowKey || toSearchRowKey(item.id, item.uomId)) === rowKey | |||||
| ? { | ? { | ||||
| ...item, | ...item, | ||||
| groupId: undefined, | groupId: undefined, | ||||
| @@ -1668,17 +1668,19 @@ const handleSelectAllOnPage = useCallback((checked: boolean, paginatedResults: S | |||||
| if (checked) { | if (checked) { | ||||
| // Select all items on current page that are not already in created items | // Select all items on current page that are not already in created items | ||||
| const newSelectedIds = paginatedResults | 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 => { | 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]; | return [...existingIds, ...newSelectedIds]; | ||||
| }); | }); | ||||
| // Set group and targetDate for all selected items on current page | // Set group and targetDate for all selected items on current page | ||||
| setSecondSearchResults(prev => prev.map(item => | setSecondSearchResults(prev => prev.map(item => | ||||
| newSelectedIds.includes(item.id) | |||||
| newSelectedIds.includes(item.rowKey || toSearchRowKey(item.id, item.uomId)) | |||||
| ? { | ? { | ||||
| ...item, | ...item, | ||||
| groupId: selectedGroup?.id || undefined, | groupId: selectedGroup?.id || undefined, | ||||
| @@ -1688,12 +1690,12 @@ const handleSelectAllOnPage = useCallback((checked: boolean, paginatedResults: S | |||||
| )); | )); | ||||
| } else { | } else { | ||||
| // Deselect all items on current page | // 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 | // Clear group and targetDate for all deselected items on current page | ||||
| setSecondSearchResults(prev => prev.map(item => | setSecondSearchResults(prev => prev.map(item => | ||||
| pageItemIds.includes(item.id) | |||||
| pageItemIds.includes(item.rowKey || toSearchRowKey(item.id, item.uomId)) | |||||
| ? { | ? { | ||||
| ...item, | ...item, | ||||
| groupId: undefined, | 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"; | "use client"; | ||||
| import React, { useCallback, useEffect, useState, useMemo } from "react"; | |||||
| import React, { useCallback, useEffect, useRef, useState, useMemo } from "react"; | |||||
| import { | import { | ||||
| Box, | Box, | ||||
| Button, | Button, | ||||
| @@ -23,7 +23,7 @@ import { | |||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import ArrowBackIcon from '@mui/icons-material/ArrowBack'; | import ArrowBackIcon from '@mui/icons-material/ArrowBack'; | ||||
| import { useTranslation } from "react-i18next"; | 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 ProductionProcessDetail from "./ProductionProcessDetail"; | ||||
| import { BomCombo } from "@/app/api/bom"; | import { BomCombo } from "@/app/api/bom"; | ||||
| import { fetchBomCombo } from "@/app/api/bom/index"; | 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 DoDisturbAltRoundedIcon from '@mui/icons-material/DoDisturbAltRounded'; | ||||
| import { fetchInventories } from "@/app/api/inventory/actions"; | import { fetchInventories } from "@/app/api/inventory/actions"; | ||||
| import { InventoryResult } from "@/app/api/inventory"; | 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 JobPickExecutionsecondscan from "../Jodetail/JobPickExecutionsecondscan"; | ||||
| import ProcessSummaryHeader from "./ProcessSummaryHeader"; | import ProcessSummaryHeader from "./ProcessSummaryHeader"; | ||||
| import EditIcon from "@mui/icons-material/Edit"; | import EditIcon from "@mui/icons-material/Edit"; | ||||
| @@ -53,6 +53,7 @@ interface ProductProcessJobOrderDetailProps { | |||||
| initialTabIndex?: number; | initialTabIndex?: number; | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.0 | 2026-08-03 */ | |||||
| const ProductionProcessJobOrderDetail: React.FC<ProductProcessJobOrderDetailProps> = ({ | const ProductionProcessJobOrderDetail: React.FC<ProductProcessJobOrderDetailProps> = ({ | ||||
| jobOrderId, | jobOrderId, | ||||
| onBack, | onBack, | ||||
| @@ -276,25 +277,13 @@ const isPutAwayed = useMemo( | |||||
| () => (processData?.jobOrderStatus ?? "").toLowerCase() === "completed", | () => (processData?.jobOrderStatus ?? "").toLowerCase() === "completed", | ||||
| [processData?.jobOrderStatus] | [processData?.jobOrderStatus] | ||||
| ); | ); | ||||
| const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); | |||||
| const [cancelConfirmOpen, setCancelConfirmOpen] = useState(false); | const [cancelConfirmOpen, setCancelConfirmOpen] = useState(false); | ||||
| const [deleteLoading, setDeleteLoading] = useState(false); | |||||
| const [cancelLoading, setCancelLoading] = 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 () => { | const handleConfirmCancelJobOrder = useCallback(async () => { | ||||
| if (cancelInFlightRef.current) return; | |||||
| cancelInFlightRef.current = true; | |||||
| setCancelLoading(true); | setCancelLoading(true); | ||||
| try { | try { | ||||
| await setJobOrderHidden(jobOrderId, true); | await setJobOrderHidden(jobOrderId, true); | ||||
| @@ -302,17 +291,27 @@ const handleConfirmCancelJobOrder = useCallback(async () => { | |||||
| onBack(); | onBack(); | ||||
| } finally { | } finally { | ||||
| setCancelLoading(false); | setCancelLoading(false); | ||||
| cancelInFlightRef.current = false; | |||||
| } | } | ||||
| }, [jobOrderId, onBack]); | }, [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"]>>( | const handleTabChange = useCallback<NonNullable<TabsProps["onChange"]>>( | ||||
| (_e, newValue) => { | (_e, newValue) => { | ||||
| setTabIndex(newValue); | setTabIndex(newValue); | ||||
| @@ -717,21 +716,12 @@ const handleRelease = useCallback(async ( jobOrderId: number) => { | |||||
| <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}> | <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}> | ||||
| {t("Lines with insufficient stock: ")}<strong style={{ color: "red" }}>{stockCounts.insufficient}</strong> | {t("Lines with insufficient stock: ")}<strong style={{ color: "red" }}>{stockCounts.insufficient}</strong> | ||||
| </Typography> | </Typography> | ||||
| {fromJosave && jobOrderPlanning && ( | |||||
| <Button | |||||
| variant="contained" | |||||
| color="error" | |||||
| onClick={() => setDeleteConfirmOpen(true)} | |||||
| > | |||||
| {t("Delete Job Order")} | |||||
| </Button> | |||||
| )} | |||||
| {fromJosave && !jobOrderPlanning && ( | |||||
| {fromJosave && ( | |||||
| <Button | <Button | ||||
| variant="contained" | variant="contained" | ||||
| color="warning" | color="warning" | ||||
| onClick={() => setCancelConfirmOpen(true)} | onClick={() => setCancelConfirmOpen(true)} | ||||
| disabled={isPutAwayed} | |||||
| disabled={isPutAwayed || cancelLoading} | |||||
| > | > | ||||
| {t("Cancel Job Order")} | {t("Cancel Job Order")} | ||||
| </Button> | </Button> | ||||
| @@ -741,8 +731,8 @@ const handleRelease = useCallback(async ( jobOrderId: number) => { | |||||
| variant="contained" | variant="contained" | ||||
| color="primary" | color="primary" | ||||
| onClick={() => handleRelease(jobOrderId)} | 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")} | {t("Release")} | ||||
| </Button> | </Button> | ||||
| @@ -979,19 +969,6 @@ const handleRelease = useCallback(async ( jobOrderId: number) => { | |||||
| </DialogActions> | </DialogActions> | ||||
| </Dialog> | </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> | <Dialog open={cancelConfirmOpen} onClose={() => !cancelLoading && setCancelConfirmOpen(false)} maxWidth="xs" fullWidth> | ||||
| <DialogTitle>{t("Confirm cancel job order")}</DialogTitle> | <DialogTitle>{t("Confirm cancel job order")}</DialogTitle> | ||||
| <DialogContent> | <DialogContent> | ||||
| @@ -8,12 +8,13 @@ import QcStockInModal from "@/components/Qc/QcStockInModal"; | |||||
| import ProductionProcessList, { | import ProductionProcessList, { | ||||
| createDefaultProductionProcessListPersistedState, | createDefaultProductionProcessListPersistedState, | ||||
| } from "@/components/ProductionProcess/ProductionProcessList"; | } from "@/components/ProductionProcess/ProductionProcessList"; | ||||
| import ProductionProcessDetail from "@/components/ProductionProcess/ProductionProcessDetail"; | |||||
| import ProductionProcessJobOrderDetail from "@/components/ProductionProcess/ProductionProcessJobOrderDetail"; | import ProductionProcessJobOrderDetail from "@/components/ProductionProcess/ProductionProcessJobOrderDetail"; | ||||
| import JobPickExecutionsecondscan from "@/components/Jodetail/JobPickExecutionsecondscan"; | import JobPickExecutionsecondscan from "@/components/Jodetail/JobPickExecutionsecondscan"; | ||||
| import JobProcessStatus from "@/components/ProductionProcess/JobProcessStatus"; | import JobProcessStatus from "@/components/ProductionProcess/JobProcessStatus"; | ||||
| import OperatorKpiDashboard from "@/components/ProductionProcess/OperatorKpiDashboard"; | import OperatorKpiDashboard from "@/components/ProductionProcess/OperatorKpiDashboard"; | ||||
| import EquipmentStatusDashboard from "@/components/ProductionProcess/EquipmentStatusDashboard"; | 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 type { PrinterCombo } from "@/app/api/settings/printer"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| @@ -23,26 +24,20 @@ interface ProductionProcessPageProps { | |||||
| const STORAGE_KEY = 'productionProcess_selectedMatchingStock'; | const STORAGE_KEY = 'productionProcess_selectedMatchingStock'; | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 26 | v1.0.0 | 2026-07-20 */ | |||||
| const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCombo }) => { | const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCombo }) => { | ||||
| const { t } = useTranslation(["common"]); | |||||
| const { t } = useTranslation(["common", "productionProcess"]); | |||||
| const [selectedProcessId, setSelectedProcessId] = useState<number | null>(null); | const [selectedProcessId, setSelectedProcessId] = useState<number | null>(null); | ||||
| const [selectedMatchingStock, setSelectedMatchingStock] = useState<{ | const [selectedMatchingStock, setSelectedMatchingStock] = useState<{ | ||||
| jobOrderId: number; | jobOrderId: number; | ||||
| productProcessId: number; | productProcessId: number; | ||||
| pickOrderId: number; | pickOrderId: number; | ||||
| } | null>(null); | } | null>(null); | ||||
| /** 0 = Production Process list; 1 = JO ops table; 2..5 = dashboards */ | |||||
| const [tabIndex, setTabIndex] = useState(0); | const [tabIndex, setTabIndex] = useState(0); | ||||
| /** 列表搜索/分頁:保留在切換工單詳情時,返回後仍為同一條件 */ | |||||
| const [productionListState, setProductionListState] = useState(() => ({ | const [productionListState, setProductionListState] = useState(() => ({ | ||||
| ...createDefaultProductionProcessListPersistedState(), | ...createDefaultProductionProcessListPersistedState(), | ||||
| // date: "", | |||||
| })); | })); | ||||
| const [waitingPutawayListState, setWaitingPutawayListState] = useState( | |||||
| createDefaultProductionProcessListPersistedState, | |||||
| ); | |||||
| const [putawayedListState, setPutawayedListState] = useState( | |||||
| createDefaultProductionProcessListPersistedState, | |||||
| ); | |||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | const { data: session } = useSession() as { data: SessionWithTokens | null }; | ||||
| const sessionToken = session as SessionWithTokens | null; | const sessionToken = session as SessionWithTokens | null; | ||||
| const searchParams = useSearchParams(); | const searchParams = useSearchParams(); | ||||
| @@ -51,22 +46,18 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||||
| const [linkQcOpen, setLinkQcOpen] = useState(false); | const [linkQcOpen, setLinkQcOpen] = useState(false); | ||||
| const [linkQcSilId, setLinkQcSilId] = useState<number | null>(null); | const [linkQcSilId, setLinkQcSilId] = useState<number | null>(null); | ||||
| // Add printer selection state | |||||
| const [selectedPrinter, setSelectedPrinter] = useState<PrinterCombo | null>( | const [selectedPrinter, setSelectedPrinter] = useState<PrinterCombo | null>( | ||||
| printerCombo && printerCombo.length > 0 ? printerCombo[0] : null | printerCombo && printerCombo.length > 0 ? printerCombo[0] : null | ||||
| ); | ); | ||||
| // 从 sessionStorage 恢复状态(仅在客户端) | |||||
| useEffect(() => { | useEffect(() => { | ||||
| if (typeof window !== 'undefined') { | if (typeof window !== 'undefined') { | ||||
| try { | try { | ||||
| const saved = sessionStorage.getItem(STORAGE_KEY); | const saved = sessionStorage.getItem(STORAGE_KEY); | ||||
| if (saved) { | if (saved) { | ||||
| const parsed = JSON.parse(saved); | const parsed = JSON.parse(saved); | ||||
| // 验证数据有效性 | |||||
| if (parsed && typeof parsed.jobOrderId === 'number' && typeof parsed.productProcessId === 'number') { | if (parsed && typeof parsed.jobOrderId === 'number' && typeof parsed.productProcessId === 'number') { | ||||
| setSelectedMatchingStock(parsed); | setSelectedMatchingStock(parsed); | ||||
| console.log(" Restored selectedMatchingStock from sessionStorage:", parsed); | |||||
| } | } | ||||
| } | } | ||||
| } catch (error) { | } catch (error) { | ||||
| @@ -76,19 +67,16 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||||
| } | } | ||||
| }, []); | }, []); | ||||
| // 保存状态到 sessionStorage | |||||
| useEffect(() => { | useEffect(() => { | ||||
| if (typeof window !== 'undefined') { | if (typeof window !== 'undefined') { | ||||
| if (selectedMatchingStock) { | if (selectedMatchingStock) { | ||||
| sessionStorage.setItem(STORAGE_KEY, JSON.stringify(selectedMatchingStock)); | sessionStorage.setItem(STORAGE_KEY, JSON.stringify(selectedMatchingStock)); | ||||
| console.log(" Saved selectedMatchingStock to sessionStorage:", selectedMatchingStock); | |||||
| } else { | } else { | ||||
| sessionStorage.removeItem(STORAGE_KEY); | sessionStorage.removeItem(STORAGE_KEY); | ||||
| } | } | ||||
| } | } | ||||
| }, [selectedMatchingStock]); | }, [selectedMatchingStock]); | ||||
| // 处理返回列表时清除存储 | |||||
| const handleBackFromSecondScan = useCallback(() => { | const handleBackFromSecondScan = useCallback(() => { | ||||
| setSelectedMatchingStock(null); | setSelectedMatchingStock(null); | ||||
| if (typeof window !== 'undefined') { | if (typeof window !== 'undefined') { | ||||
| @@ -102,7 +90,7 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||||
| const openStockInLineIdQ = searchParams.get("openStockInLineId"); | 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(() => { | useEffect(() => { | ||||
| if (!openStockInLineIdQ) { | if (!openStockInLineIdQ) { | ||||
| setLinkQcOpen(false); | setLinkQcOpen(false); | ||||
| @@ -113,7 +101,12 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||||
| if (!Number.isFinite(id) || id <= 0) return; | if (!Number.isFinite(id) || id <= 0) return; | ||||
| setSelectedProcessId(null); | setSelectedProcessId(null); | ||||
| setSelectedMatchingStock(null); | setSelectedMatchingStock(null); | ||||
| setTabIndex(1); | |||||
| setTabIndex(0); | |||||
| setProductionListState((prev) => ({ | |||||
| ...prev, | |||||
| pickBucket: "pending_qc", | |||||
| page: 0, | |||||
| })); | |||||
| setLinkQcSilId(id); | setLinkQcSilId(id); | ||||
| setLinkQcOpen(true); | setLinkQcOpen(true); | ||||
| }, [openStockInLineIdQ]); | }, [openStockInLineIdQ]); | ||||
| @@ -127,6 +120,9 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||||
| router.replace(q ? `${pathname}?${q}` : pathname, { scroll: false }); | router.replace(q ? `${pathname}?${q}` : pathname, { scroll: false }); | ||||
| }, [pathname, router, searchParams]); | }, [pathname, router, searchParams]); | ||||
| const listTab = String(productionListState.pickBucket || "needs_action"); | |||||
| const showPrinterBar = tabIndex === 0 && listTab === "pending_qc"; | |||||
| if (selectedMatchingStock) { | if (selectedMatchingStock) { | ||||
| return ( | return ( | ||||
| <JobPickExecutionsecondscan | <JobPickExecutionsecondscan | ||||
| @@ -152,8 +148,7 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| <Box> | <Box> | ||||
| {/* Header section with printer selection */} | |||||
| {tabIndex === 1 && ( | |||||
| {showPrinterBar && ( | |||||
| <Box sx={{ | <Box sx={{ | ||||
| p: 1, | p: 1, | ||||
| borderBottom: '1px solid #e0e0e0', | borderBottom: '1px solid #e0e0e0', | ||||
| @@ -203,18 +198,16 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||||
| <Tabs value={tabIndex} onChange={handleTabChange} sx={{ mb: 2 }}> | <Tabs value={tabIndex} onChange={handleTabChange} sx={{ mb: 2 }}> | ||||
| <Tab label={t("Production Process")} /> | <Tab label={t("Production Process")} /> | ||||
| <Tab label={t("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("Job Process Status Dashboard")} /> | ||||
| <Tab label={t("Operator KPI Dashboard")} /> | <Tab label={t("Operator KPI Dashboard")} /> | ||||
| <Tab label={t("Production Equipment Status Dashboard")} /> | <Tab label={t("Production Equipment Status Dashboard")} /> | ||||
| <Tab label={t("Drink Production Qty Dashboard")} /> | |||||
| </Tabs> | </Tabs> | ||||
| {tabIndex === 0 && ( | {tabIndex === 0 && ( | ||||
| <ProductionProcessList | <ProductionProcessList | ||||
| printerCombo={printerCombo} | |||||
| qcReady={false} | |||||
| disableDateFilter={false} | |||||
| printerCombo={selectedPrinter ? [selectedPrinter] : printerCombo} | |||||
| listPersistedState={productionListState} | listPersistedState={productionListState} | ||||
| onListPersistedStateChange={setProductionListState} | onListPersistedStateChange={setProductionListState} | ||||
| onSelectProcess={(jobOrderId) => { | onSelectProcess={(jobOrderId) => { | ||||
| @@ -234,59 +227,25 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||||
| )} | )} | ||||
| {tabIndex === 1 && ( | {tabIndex === 1 && ( | ||||
| <ProductionProcessList | |||||
| printerCombo={printerCombo} | |||||
| qcReady={true} | |||||
| includePutaway={true} | |||||
| putawayStatus="notCompleted" | |||||
| listPersistedState={waitingPutawayListState} | |||||
| onListPersistedStateChange={setWaitingPutawayListState} | |||||
| <JobOrderOpsTable | |||||
| printerCombo={selectedPrinter ? [selectedPrinter] : printerCombo} | |||||
| onSelectProcess={(jobOrderId) => { | 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 && ( | {tabIndex === 3 && ( | ||||
| <JobProcessStatus /> | |||||
| <OperatorKpiDashboard /> | |||||
| )} | )} | ||||
| {tabIndex === 4 && ( | {tabIndex === 4 && ( | ||||
| <OperatorKpiDashboard /> | |||||
| <EquipmentStatusDashboard /> | |||||
| )} | )} | ||||
| {tabIndex === 5 && ( | {tabIndex === 5 && ( | ||||
| <EquipmentStatusDashboard /> | |||||
| <DrinkProductionQtyDashboard /> | |||||
| )} | )} | ||||
| </Box> | </Box> | ||||
| <QcStockInModal | <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'; | export type FieldType = 'date' | 'text' | 'select' | 'number' | 'checkbox'; | ||||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | 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 Start", name: "lastOutDateStart", type: "date", required: false }, | ||||
| { label: "出貨日期:至 Last Out Date End", name: "lastOutDateEnd", 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: "貨品編號 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, | multiple: true, | ||||
| dynamicOptions: true, | dynamicOptions: true, | ||||
| dynamicOptionsEndpoint: `${NEXT_PUBLIC_API_URL}/report/fg-stock-out-traceability-handlers`, | 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", | "Action": "Action", | ||||
| "Add": "Add", | "Add": "Add", | ||||
| "Add entry": "Add entry", | "Add entry": "Add entry", | ||||
| "Add entry for items without inventory": "Add entry for items without inventory", | "Add entry for items without inventory": "Add entry for items without inventory", | ||||
| "Adjusted Qty": "Adjusted Qty", | "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": "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", | "CMB": "Consumables", | ||||
| "CO": "Consumables", | "CO": "Consumables", | ||||
| "Clear selection": "Clear selection", | |||||
| "Cancel": "Cancel", | |||||
| "Code": "Code", | "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", | "Confirm remove": "Confirm remove", | ||||
| "Counted Qty": "Counted Qty", | |||||
| "Create Stock Take for All Sections": "Create Stock Take for All Sections", | |||||
| "Current Stock": "Current Stock", | "Current Stock": "Current Stock", | ||||
| "Delivery Order": "Delivery Order", | |||||
| "Difference": "Difference", | "Difference": "Difference", | ||||
| "Download QR Code": "Download QR Code", | "Download QR Code": "Download QR Code", | ||||
| "Edit mode": "Edit mode", | "Edit mode": "Edit mode", | ||||
| "End Date": "End Date", | |||||
| "Enter item code or name to search": "Enter item code or name to search", | "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 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", | "FG": "Finished good", | ||||
| "Failed to transfer stock": "Failed to transfer stock", | "Failed to transfer stock": "Failed to transfer stock", | ||||
| "Failed to transfer stock. Please try again.": "Failed to transfer stock. Please try again.", | "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": "Inventory", | ||||
| "Inventory Exception Management": "Inventory Exception Management", | |||||
| "Item Name": "Item Name", | |||||
| "Item": "Item", | |||||
| "Item selected": "Item selected", | "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", | "Lot No": "Lot No", | ||||
| "MA": "Material", | "MA": "Material", | ||||
| "MI": "Miscellaneous", | "MI": "Miscellaneous", | ||||
| @@ -77,130 +30,59 @@ | |||||
| "NM": "Miscellaneous / non-consumables", | "NM": "Miscellaneous / non-consumables", | ||||
| "Name": "Name", | "Name": "Name", | ||||
| "No Data": "No Data", | "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 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 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", | "Opening Inventory": "Opening Inventory", | ||||
| "Optional - system will generate": "Optional - system will generate", | "Optional - system will generate": "Optional - system will generate", | ||||
| "Original Qty": "Original Qty", | "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 scan...": "Please scan...", | ||||
| "Please set at least one search criterion": "Please set at least one search criterion", | |||||
| "Print": "Print", | "Print": "Print", | ||||
| "Print QR Code": "Print QR Code", | "Print QR Code": "Print QR Code", | ||||
| "Print Qty": "Print Qty", | "Print Qty": "Print Qty", | ||||
| "Print failed": "Print failed", | "Print failed": "Print failed", | ||||
| "Print sent": "Print sent", | "Print sent": "Print sent", | ||||
| "Printer": "Printer", | "Printer": "Printer", | ||||
| "Qty": "Qty", | |||||
| "Qty To Be Transferred": "Qty To Be Transferred", | "Qty To Be Transferred": "Qty To Be Transferred", | ||||
| "Quantity exceeds available quantity": "Quantity exceeds available quantity", | |||||
| "RM": "Raw material", | "RM": "Raw material", | ||||
| "Reason for adjustment": "Reason for adjustment", | "Reason for adjustment": "Reason for adjustment", | ||||
| "Reason for removal": "Reason for removal", | "Reason for removal": "Reason for removal", | ||||
| "Refresh": "Refresh", | |||||
| "Remaining Qty": "Remaining Qty", | "Remaining Qty": "Remaining Qty", | ||||
| "Remarks": "Remarks", | "Remarks": "Remarks", | ||||
| "Remove": "Remove", | "Remove": "Remove", | ||||
| "Resolved": "Resolved", | |||||
| "Review Variance": "Review Variance", | |||||
| "Rows per page": "Rows per page", | |||||
| "Reset": "Reset", | |||||
| "SFG": "Semi-finished good", | "SFG": "Semi-finished good", | ||||
| "Sales UoM": "Sales UoM", | |||||
| "Save": "Save", | |||||
| "Save failed": "Save failed", | "Save failed": "Save failed", | ||||
| "Saved successfully": "Saved successfully", | "Saved successfully": "Saved successfully", | ||||
| "Search lot by QR code": "Search lot by QR code", | "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 Location": "Start Location", | ||||
| "Start New Stock Take": "Start New Stock Take", | |||||
| "Start Time": "Start Time", | |||||
| "Stock Adjustment": "Stock Adjustment", | "Stock Adjustment": "Stock Adjustment", | ||||
| "Stock Record": "Stock Record", | |||||
| "Stock Take Variance": "Stock Take Variance", | |||||
| "Stock Transfer": "Stock Transfer", | "Stock Transfer": "Stock Transfer", | ||||
| "Stock UoM": "Stock UoM", | "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 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 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 merged existing lot": "Stock transfer completed (merged into existing lot).", | ||||
| "Stock transfer successful": "Stock transfer completed", | "Stock transfer successful": "Stock transfer completed", | ||||
| "Stop QR Scan": "Stop QR Scan", | "Stop QR Scan": "Stop QR Scan", | ||||
| "Submit": "Submit", | "Submit": "Submit", | ||||
| "Submit completed: {{success}} success, {{errors}} errors": "Submit completed: {{success}} success, {{errors}} errors", | |||||
| "System Qty": "System Qty", | |||||
| "Target Location": "Target Location", | "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", | "Type": "Type", | ||||
| "UNAVAILABLE": "UNAVAILABLE", | |||||
| "Variance filter strict bounds": "Exclude boundaries (use > <)", | |||||
| "View": "View", | |||||
| "UoM": "UoM", | |||||
| "WIP": "Work in progress", | "WIP": "Work in progress", | ||||
| "Waiting for Approver": "Waiting for Approver", | |||||
| "Warehouse": "Warehouse", | "Warehouse": "Warehouse", | ||||
| "adj": "adj", | |||||
| "approver": "approver", | |||||
| "approving": "approving", | |||||
| "available": "available", | |||||
| "bad": "bad", | |||||
| "cmb": "Consumables", | "cmb": "Consumables", | ||||
| "complete": "complete", | |||||
| "completed": "completed", | |||||
| "completed by": "completed by", | |||||
| "completed date": "completed date", | |||||
| "completed remarks": "completed remarks", | |||||
| "completed status": "completed status", | |||||
| "consumable": "Consumable", | "consumable": "Consumable", | ||||
| "consumables": "Consumables", | "consumables": "Consumables", | ||||
| "dnNo": "dnNo", | "dnNo": "dnNo", | ||||
| "expiry": "expiry", | |||||
| "fg": "Finished good", | "fg": "Finished good", | ||||
| "item": "Item", | "item": "Item", | ||||
| "mat": "Raw material", | "mat": "Raw material", | ||||
| "miss": "miss", | |||||
| "nm": "Miscellaneous / non-consumables", | "nm": "Miscellaneous / non-consumables", | ||||
| "non-consumables": "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", | "productLotNo": "productLotNo", | ||||
| "rejected": "rejected", | |||||
| "save": "save", | |||||
| "section": "section", | |||||
| "sfg": "Semi-finished good", | "sfg": "Semi-finished good", | ||||
| "stockTaking": "stockTaking", | |||||
| "tke": "tke", | |||||
| "to": "to", | "to": "to", | ||||
| "trf": "trf", | |||||
| "unavailable": "unavailable", | |||||
| "variance": "variance", | |||||
| "wip": "Work in progress", | "wip": "Work in progress", | ||||
| "材料": "Material" | "材料": "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 Type": "Job Order Type", | ||||
| "Job Order not found or has no items": "Job Order not found or has no items", | "Job Order not found or has no items": "Job Order not found or has no items", | ||||
| "Job Process Status Dashboard": "Job Process Status Dashboard", | "Job Process Status Dashboard": "Job Process Status Dashboard", | ||||
| "Drink Production Qty Dashboard": "Drink Production Qty Dashboard", | |||||
| "Job Type": "Job Type", | "Job Type": "Job Type", | ||||
| "Job dashboard PP status: cancelled": "Job dashboard PP status: cancelled", | "Job dashboard PP status: cancelled": "Job dashboard PP status: cancelled", | ||||
| "Job dashboard PP status: completed": "Job dashboard PP status: completed", | "Job dashboard PP status: completed": "Job dashboard PP status: completed", | ||||
| @@ -353,6 +354,7 @@ | |||||
| "Production Output Data Entry": "Production Output Data Entry", | "Production Output Data Entry": "Production Output Data Entry", | ||||
| "Production Priority": "Production Priority", | "Production Priority": "Production Priority", | ||||
| "Production Priority required!": "Production Priority required!", | "Production Priority required!": "Production Priority required!", | ||||
| "Production Qty": "Production Qty", | |||||
| "Production Process": "Production Process", | "Production Process": "Production Process", | ||||
| "Production Process Information": "Production Process Information", | "Production Process Information": "Production Process Information", | ||||
| "Production Process Line Remark": "Production Process Line Remark", | "Production Process Line Remark": "Production Process Line Remark", | ||||
| @@ -5,6 +5,7 @@ | |||||
| "nav.store.purchaseOrder": "Purchase Order", | "nav.store.purchaseOrder": "Purchase Order", | ||||
| "nav.store.pickOrder": "Pick Order", | "nav.store.pickOrder": "Pick Order", | ||||
| "nav.store.inventoryLedger": "View item In-out And inventory Ledger", | "nav.store.inventoryLedger": "View item In-out And inventory Ledger", | ||||
| "nav.store.itemTracing": "Item Tracing", | |||||
| "nav.store.stockTake": "Stock Take Management", | "nav.store.stockTake": "Stock Take Management", | ||||
| "nav.store.stockIssue": "Stock Issue", | "nav.store.stockIssue": "Stock Issue", | ||||
| "nav.store.putAwayScan": "Put Away Scan", | "nav.store.putAwayScan": "Put Away Scan", | ||||
| @@ -81,6 +82,7 @@ | |||||
| "nav.breadcrumb.schedulingDetailed": "Detail Scheduling", | "nav.breadcrumb.schedulingDetailed": "Detail Scheduling", | ||||
| "nav.breadcrumb.schedulingDetailedEdit": "FG Production Schedule", | "nav.breadcrumb.schedulingDetailedEdit": "FG Production Schedule", | ||||
| "nav.breadcrumb.inventory": "Inventory", | "nav.breadcrumb.inventory": "Inventory", | ||||
| "nav.breadcrumb.itemTracing": "Item Tracing", | |||||
| "nav.breadcrumb.importTesting": "Import Testing", | "nav.breadcrumb.importTesting": "Import Testing", | ||||
| "nav.breadcrumb.doWorkbenchSearch": "DO Workbench Search", | "nav.breadcrumb.doWorkbenchSearch": "DO Workbench Search", | ||||
| "nav.breadcrumb.doWorkbenchPick": "DO Workbench Pick", | "nav.breadcrumb.doWorkbenchPick": "DO Workbench Pick", | ||||
| @@ -497,6 +497,10 @@ | |||||
| "Tomorrow": "Tomorrow", | "Tomorrow": "Tomorrow", | ||||
| "packaging": "packaging", | "packaging": "packaging", | ||||
| "This lot is not available, please scan another lot.": "This lot is not available, please scan another lot.", | "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.", | "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}})", | "Lot is expired (expiry={{expiry}})": "Lot is expired (expiry={{expiry}})", | ||||
| "Day After Tomorrow": "Day After Tomorrow", | "Day After Tomorrow": "Day After Tomorrow", | ||||
| @@ -63,7 +63,7 @@ | |||||
| "Not Started": "Not Started", | "Not Started": "Not Started", | ||||
| "cancelled": "Cancelled", | "cancelled": "Cancelled", | ||||
| "in_progress": "In Progress", | "in_progress": "In Progress", | ||||
| "pending": "Pending", | |||||
| "pending": "Awaiting production", | |||||
| "stopped": "Stopped", | "stopped": "Stopped", | ||||
| "Invalid Job Order Id": "Invalid Job Order Id", | "Invalid Job Order Id": "Invalid Job Order Id", | ||||
| "Invalid Stock In Line Id": "Invalid Stock In Line Id", | "Invalid Stock In Line Id": "Invalid Stock In Line Id", | ||||
| @@ -77,8 +77,26 @@ | |||||
| "Job Order Info": "Job Order Info", | "Job Order Info": "Job Order Info", | ||||
| "Job Order No.": "Job Order No.", | "Job Order No.": "Job Order No.", | ||||
| "Job Order and Product": "Job Order and Product", | "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 Order Production Process": "Job Order Production Process", | ||||
| "Job Process Status Dashboard": "Job Process Status Dashboard", | "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 Type": "Job Type", | ||||
| "Job process detail mode label": "Job process detail mode label", | "Job process detail mode label": "Job process detail mode label", | ||||
| "Job process detail: equipment": "Equipment", | "Job process detail: equipment": "Equipment", | ||||
| @@ -136,6 +154,7 @@ | |||||
| "Production Output Data": "Production Output Data", | "Production Output Data": "Production Output Data", | ||||
| "Production Output Data Entry": "Production Output Data Entry", | "Production Output Data Entry": "Production Output Data Entry", | ||||
| "Production Priority": "Production Priority", | "Production Priority": "Production Priority", | ||||
| "Production Qty": "Production Qty", | |||||
| "Production Process": "Production Process", | "Production Process": "Production Process", | ||||
| "Production Process Line Remark": "Production Process Line Remark", | "Production Process Line Remark": "Production Process Line Remark", | ||||
| "Production Process Steps": "Production Process Steps", | "Production Process Steps": "Production Process Steps", | ||||
| @@ -194,6 +213,16 @@ | |||||
| "Total Time": "Total Time", | "Total Time": "Total Time", | ||||
| "Total finished QC job orders": "Total finished QC job orders", | "Total finished QC job orders": "Total finished QC job orders", | ||||
| "Total job orders": "Total 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: ", | "Total lines: ": "Total lines: ", | ||||
| "Type": "Type", | "Type": "Type", | ||||
| "Unable to get user ID": "Unable to get user ID", | "Unable to get user ID": "Unable to get user ID", | ||||
| @@ -208,6 +237,9 @@ | |||||
| "View Details": "View Details", | "View Details": "View Details", | ||||
| "Wait Time": "Wait Time", | "Wait Time": "Wait Time", | ||||
| "Waiting QC Put Away Job Orders": "Waiting QC Put Away Job Orders", | "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", | "all": "All", | ||||
| "drink": "Drink", | "drink": "Drink", | ||||
| "id": "ID", | "id": "ID", | ||||
| @@ -1,6 +1,7 @@ | |||||
| { | { | ||||
| "Actions": "Actions", | "Actions": "Actions", | ||||
| "All Floors": "All Floors", | "All Floors": "All Floors", | ||||
| "All Handlers": "All Handlers", | |||||
| "All Statuses": "All Statuses", | "All Statuses": "All Statuses", | ||||
| "Auto-refresh every 1 minute": "Auto-refresh every 1 minute", | "Auto-refresh every 1 minute": "Auto-refresh every 1 minute", | ||||
| "Auto-refresh every 10 minutes": "Auto-refresh every 10 minutes", | "Auto-refresh every 10 minutes": "Auto-refresh every 10 minutes", | ||||
| @@ -30,9 +31,11 @@ | |||||
| "Revert assignment": "Revert assignment", | "Revert assignment": "Revert assignment", | ||||
| "Revert assignment hint": "Revert assignment hint", | "Revert assignment hint": "Revert assignment hint", | ||||
| "Rows per page": "Rows per page", | "Rows per page": "Rows per page", | ||||
| "Select All": "Select All", | |||||
| "Select Date": "Select Date", | "Select Date": "Select Date", | ||||
| "Shop Name": "Shop Name", | "Shop Name": "Shop Name", | ||||
| "Status": "Status", | "Status": "Status", | ||||
| "Unassigned": "Unassigned", | |||||
| "Store ID": "Store ID", | "Store ID": "Store ID", | ||||
| "Target Date": "Target Date", | "Target Date": "Target Date", | ||||
| "Ticket Information": "Ticket Information", | "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": "編號", | "Code": "編號", | ||||
| "Name": "名稱", | |||||
| "Type": "類型", | |||||
| "Lot No": "批號", | |||||
| "Confirm remove": "確認移除", | |||||
| "Current Stock": "現有庫存", | |||||
| "Difference": "差異", | |||||
| "Download QR Code": "下載", | |||||
| "Edit mode": "編輯模式", | |||||
| "Enter item code or name to search": "輸入貨品編號或名稱以搜索", | |||||
| "Expiry Date": "到期日", | "Expiry Date": "到期日", | ||||
| "Warehouse": "倉庫", | |||||
| "材料": "材料", | |||||
| "consumable": "消耗品", | |||||
| "Qty": "盤點數量", | |||||
| "Total need stock take": "總需盤點數量", | |||||
| "Waiting for Approver": "待審核數量", | |||||
| "Total Approved": "已審核數量", | |||||
| "mat": "原料", | |||||
| "variance": "差異", | |||||
| "ApproverAll": "審核員", | |||||
| "Approver All": "審核員全部盤點", | |||||
| "fg": "成品", | |||||
| "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": "材料", | "MA": "材料", | ||||
| "CO": "消耗品", | |||||
| "MI": "雜項", | "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": "物料", | "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": "原有數量", | "Original Qty": "原有數量", | ||||
| "Qty To Be Transferred": "待轉數量", | |||||
| "Remaining Qty": "剩餘數量", | |||||
| "Submit": "確認", | |||||
| "Printer": "列印機", | |||||
| "Print Qty": "列印數量", | |||||
| "Please scan...": "請掃描...", | |||||
| "Print": "列印", | "Print": "列印", | ||||
| "Print sent": "已送出列印", | |||||
| "Print QR Code": "列印", | |||||
| "Print Qty": "列印數量", | |||||
| "Print failed": "列印失敗", | "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": "調整原因", | "Reason for adjustment": "調整原因", | ||||
| "No lot no entered, will be generated by system.": "未輸入批號,將由系統生成。", | |||||
| "Reason for removal": "移除原因", | "Reason for removal": "移除原因", | ||||
| "Confirm remove": "確認移除", | |||||
| "Adjusted Qty": "調整後倉存", | |||||
| "Difference": "差異", | |||||
| "Action": "操作", | |||||
| "Saved successfully": "儲存成功", | |||||
| "Save failed": "儲存失敗", | |||||
| "Remaining Qty": "剩餘數量", | |||||
| "Remarks": "備註", | |||||
| "Remove": "移除", | "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": "尋找批次(掃描二維碼)", | "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": "停止掃碼", | "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": "實際提料數量", | "Actual Pick Qty": "實際提料數量", | ||||
| "Add Bag": "新增包裝袋", | "Add Bag": "新增包裝袋", | ||||
| "Add Record": "添加記錄", | "Add Record": "添加記錄", | ||||
| "Just Pass": "通過", | |||||
| "Add Selected Items to Created Items": "將已選擇的物品添加到創建的物品中", | "Add Selected Items to Created Items": "將已選擇的物品添加到創建的物品中", | ||||
| "Add some entries!": "請添加條目", | "Add some entries!": "請添加條目", | ||||
| "All": "全部", | "All": "全部", | ||||
| @@ -202,6 +203,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": "儀表板 - 飲料生產量數", | |||||
| "Job Type": "工單類型", | "Job Type": "工單類型", | ||||
| "Job dashboard PP status: cancelled": "工序已取消", | "Job dashboard PP status: cancelled": "工序已取消", | ||||
| "Job dashboard PP status: completed": "工序完成", | "Job dashboard PP status: completed": "工序完成", | ||||
| @@ -363,6 +365,7 @@ | |||||
| "Production Output Data Entry": "生產輸出數據輸入", | "Production Output Data Entry": "生產輸出數據輸入", | ||||
| "Production Priority": "生產優先序", | "Production Priority": "生產優先序", | ||||
| "Production Priority required!": "生產優先度必填!", | "Production Priority required!": "生產優先度必填!", | ||||
| "Production Qty": "生產數量", | |||||
| "Production Process": "工藝流程", | "Production Process": "工藝流程", | ||||
| "Production Process Information": "生產流程信息", | "Production Process Information": "生產流程信息", | ||||
| "Production Process Line Remark": "工藝明細", | "Production Process Line Remark": "工藝明細", | ||||
| @@ -21,6 +21,7 @@ | |||||
| "nav.breadcrumb.home": "總覽", | "nav.breadcrumb.home": "總覽", | ||||
| "nav.breadcrumb.importTesting": "匯入測試", | "nav.breadcrumb.importTesting": "匯入測試", | ||||
| "nav.breadcrumb.inventory": "存貨", | "nav.breadcrumb.inventory": "存貨", | ||||
| "nav.breadcrumb.itemTracing": "批號追溯", | |||||
| "nav.breadcrumb.joEdit": "工單詳情", | "nav.breadcrumb.joEdit": "工單詳情", | ||||
| "nav.breadcrumb.joTesting": "工單測試", | "nav.breadcrumb.joTesting": "工單測試", | ||||
| "nav.breadcrumb.joWorkbench": "工單工作台", | "nav.breadcrumb.joWorkbench": "工單工作台", | ||||
| @@ -91,6 +92,7 @@ | |||||
| "nav.store.doWorkbench": "成品出倉", | "nav.store.doWorkbench": "成品出倉", | ||||
| "nav.store.finishedGoodManagement": "成品出倉管理", | "nav.store.finishedGoodManagement": "成品出倉管理", | ||||
| "nav.store.inventoryLedger": "查看物品出入庫及庫存日誌", | "nav.store.inventoryLedger": "查看物品出入庫及庫存日誌", | ||||
| "nav.store.itemTracing": "批號追溯", | |||||
| "nav.store.pickOrder": "提料單", | "nav.store.pickOrder": "提料單", | ||||
| "nav.store.purchaseOrder": "採購單", | "nav.store.purchaseOrder": "採購單", | ||||
| "nav.store.putAwayScan": "上架掃碼", | "nav.store.putAwayScan": "上架掃碼", | ||||
| @@ -12,7 +12,7 @@ | |||||
| "N/A": "不適用", | "N/A": "不適用", | ||||
| "Release Pick Orders": "放單", | "Release Pick Orders": "放單", | ||||
| "released": "已放單", | "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.": "沒有批次行。請選擇一個提料單。", | "No lot rows. Select a pick order above.": "沒有批次行。請選擇一個提料單。", | ||||
| "Loading...": "載入中...", | "Loading...": "載入中...", | ||||
| "Suggestion success": "建議成功", | "Suggestion success": "建議成功", | ||||
| @@ -65,7 +65,7 @@ | |||||
| "items": "項目", | "items": "項目", | ||||
| "Select Pick Order:": "選擇提料單:", | "Select Pick Order:": "選擇提料單:", | ||||
| "No Stock Available": "沒有庫存", | "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 Fail": "開始失敗", | ||||
| "Start PO": "開始採購訂單", | "Start PO": "開始採購訂單", | ||||
| "Do you want to complete?": "確定完成嗎?", | "Do you want to complete?": "確定完成嗎?", | ||||
| @@ -136,7 +136,7 @@ | |||||
| "LotNo": "批號", | "LotNo": "批號", | ||||
| "Po Code": "採購訂單編號", | "Po Code": "採購訂單編號", | ||||
| "No Warehouse": "沒有倉庫", | "No Warehouse": "沒有倉庫", | ||||
| "Please scan warehouse qr code.": "請掃描倉庫 QR 碼。", | |||||
| "Please scan warehouse qr code.": "請掃描倉庫二維碼。", | |||||
| "Reject": "拒絕", | "Reject": "拒絕", | ||||
| "submit": "確認提交", | "submit": "確認提交", | ||||
| @@ -270,7 +270,7 @@ | |||||
| "Pick order completed successfully!": "提料單完成成功!", | "Pick order completed successfully!": "提料單完成成功!", | ||||
| "Lot has been rejected and marked as unavailable.": "批號已拒絕並標記為不可用。", | "Lot has been rejected and marked as unavailable.": "批號已拒絕並標記為不可用。", | ||||
| "This order is insufficient, please pick another lot.": "此訂單不足,請選擇其他批號。", | "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": "沒有資料", | "No data available": "沒有資料", | ||||
| "Please submit the pick order.": "請提交提料單。", | "Please submit the pick order.": "請提交提料單。", | ||||
| "Item lot to be Pick:": "批次貨品提料:", | "Item lot to be Pick:": "批次貨品提料:", | ||||
| @@ -293,13 +293,13 @@ | |||||
| "Original Available Qty": "原可用數", | "Original Available Qty": "原可用數", | ||||
| "Remaining Available Qty": "剩餘可用數", | "Remaining Available Qty": "剩餘可用數", | ||||
| "Please submit pick order.": "請提交提料單。", | "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": "首次建立分組", | "First created group": "首次建立分組", | ||||
| "Latest created group": "最新建立分組", | "Latest created group": "最新建立分組", | ||||
| "Manual Input": "手動輸入", | "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.": "輸入的批次號碼與預期的不符。", | "The input is not the same as the expected lot number.": "輸入的批次號碼與預期的不符。", | ||||
| "Verified successfully!": "驗證成功!", | "Verified successfully!": "驗證成功!", | ||||
| "Cancel": "取消", | "Cancel": "取消", | ||||
| @@ -370,7 +370,7 @@ | |||||
| "Scanning...":"掃描中...", | "Scanning...":"掃描中...", | ||||
| "Print DN/Label":"列印送貨單/標籤", | "Print DN/Label":"列印送貨單/標籤", | ||||
| "Store ID":"儲存編號", | "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":"批次號碼不符", | "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. 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 則切換為該批次。", | "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":"批次切換失敗", | "Lot switch failed":"批次切換失敗", | ||||
| "The system could not switch to the scanned lot. Review the lots below, then tap Confirm to retry.":"系統無法切換至掃描的批次。請核對下方批次後按「確認」重試。", | "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 可再次嘗試切換。", | "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":"訂單完成", | "Order Finished":"訂單完成", | ||||
| "Submitted Status":"提交狀態", | "Submitted Status":"提交狀態", | ||||
| "Pick Execution Record":"提料執行記錄", | "Pick Execution Record":"提料執行記錄", | ||||
| @@ -507,12 +507,17 @@ | |||||
| "packaging": "提料中", | "packaging": "提料中", | ||||
| "No Stock Available": "沒有庫存可用", | "No Stock Available": "沒有庫存可用", | ||||
| "This lot is not available, please scan another lot.": "此批號不可用,請掃描其他批號。", | "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}})", | "Lot is expired (expiry={{expiry}})": "掃描批號已過期(到期日={{expiry}})", | ||||
| "Day After Tomorrow": "後日", | "Day After Tomorrow": "後日", | ||||
| "Lot line is unavailable": "掃描批次不可用", | "Lot line is unavailable": "掃描批次不可用", | ||||
| "Select Date": "請選擇日期", | "Select Date": "請選擇日期", | ||||
| "Suggest Lot No.": "推薦批號", | |||||
| "Suggest Lot No.": "推薦批號/實際批號", | |||||
| "Search by Shop": "搜索商店", | "Search by Shop": "搜索商店", | ||||
| "Search by Truck": "搜索貨車", | "Search by Truck": "搜索貨車", | ||||
| "Print DN & Label": "列印提料單和送貨單標籤", | "Print DN & Label": "列印提料單和送貨單標籤", | ||||
| @@ -541,13 +546,13 @@ | |||||
| "4F ticket": "4/F 票", | "4F ticket": "4/F 票", | ||||
| "4F lane panel legend": "貨車班次 — 裝載序(未撳數/總單數)", | "4F lane panel legend": "貨車班次 — 裝載序(未撳數/總單數)", | ||||
| "Loading sequence n": "板{{n}}", | "Loading sequence n": "板{{n}}", | ||||
| "lot QR code": "批號 QR 碼", | |||||
| "lot QR code": "批號二維碼", | |||||
| "label Printer" : "標籤打印機", | "label Printer" : "標籤打印機", | ||||
| "A4 Printer" : "A4 打印機", | "A4 Printer" : "A4 打印機", | ||||
| "Loading Sequence": "裝載序", | "Loading Sequence": "裝載序", | ||||
| "Ticket No": "提票號碼", | "Ticket No": "提票號碼", | ||||
| "The scanned lot inventory line is unavailable. Cannot switch or bind; pick line was not updated.": "掃描的庫存批行為「不可用」,無法換批或綁定;揀貨行未更新。", | "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 switch failed; pick line was not marked as checked.": "換批失敗;揀貨行未標為已核對。", | ||||
| "Lot confirmation failed. Please try again.": "確認批號失敗,請重試。", | "Lot confirmation failed. Please try again.": "確認批號失敗,請重試。", | ||||
| "Powder Mixture": "箱料粉", | "Powder Mixture": "箱料粉", | ||||
| @@ -68,7 +68,7 @@ | |||||
| "Not Started": "未開始", | "Not Started": "未開始", | ||||
| "cancelled": "已取消", | "cancelled": "已取消", | ||||
| "in_progress": "進行中", | "in_progress": "進行中", | ||||
| "pending": "待處理", | |||||
| "pending": "待生產", | |||||
| "stopped": "已停止", | "stopped": "已停止", | ||||
| "Invalid Job Order Id": "無效工單編號", | "Invalid Job Order Id": "無效工單編號", | ||||
| "Invalid Stock In Line Id": "無效庫存行ID", | "Invalid Stock In Line Id": "無效庫存行ID", | ||||
| @@ -82,8 +82,26 @@ | |||||
| "Job Order Info": "工單信息", | "Job Order Info": "工單信息", | ||||
| "Job Order No.": "工單編號", | "Job Order No.": "工單編號", | ||||
| "Job Order and Product": "工單及貨品", | "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 Order Production Process": "工單生產流程", | ||||
| "Job Process Status Dashboard": "儀表板 - 工單狀態", | "Job Process Status Dashboard": "儀表板 - 工單狀態", | ||||
| "Drink Production Qty Dashboard": "儀表板 - 飲料生產量數", | |||||
| "Expand job order details": "展開工單明細", | |||||
| "Collapse job order details": "收合工單明細", | |||||
| "Goods Name": "貨品名稱", | |||||
| "Job Type": "工單類型", | "Job Type": "工單類型", | ||||
| "Job process detail mode label": "工序格顯示", | "Job process detail mode label": "工序格顯示", | ||||
| "Job process detail: equipment": "設備", | "Job process detail: equipment": "設備", | ||||
| @@ -141,6 +159,7 @@ | |||||
| "Production Output Data": "生產輸出數據", | "Production Output Data": "生產輸出數據", | ||||
| "Production Output Data Entry": "生產輸出數據輸入", | "Production Output Data Entry": "生產輸出數據輸入", | ||||
| "Production Priority": "生產優先序", | "Production Priority": "生產優先序", | ||||
| "Production Qty": "生產數量", | |||||
| "Production Process": "工藝流程", | "Production Process": "工藝流程", | ||||
| "Production Process Line Remark": "工藝明細", | "Production Process Line Remark": "工藝明細", | ||||
| "Production Process Steps": "生產流程步驟", | "Production Process Steps": "生產流程步驟", | ||||
| @@ -199,6 +218,16 @@ | |||||
| "Total Time": "總時間", | "Total Time": "總時間", | ||||
| "Total finished QC job orders": "總完成QC工單數量", | "Total finished QC job orders": "總完成QC工單數量", | ||||
| "Total job orders": "總工單數量", | "Total job orders": "總工單數量", | ||||
| "Including carried over": "含過去轉來", | |||||
| "Carried over from past day": "過去轉來的工單", | |||||
| "All unfinished": "需處理", | |||||
| "Needs action": "需處理", | |||||
| "Not picked · Not started": "未提料 · 未開工", | |||||
| "Picked · Not started": "已提料 · 未開工", | |||||
| "Picked · In production": "已提料 · 未完成生產", | |||||
| "Not picked · In production": "未提料 · 未完成生產", | |||||
| "Powder_Mixture": "箱料粉", | |||||
| "Processing": "生產中", | |||||
| "Total lines: ": "總數量:", | "Total lines: ": "總數量:", | ||||
| "Type": "類型", | "Type": "類型", | ||||
| "Unable to get user ID": "無法獲取用戶ID", | "Unable to get user ID": "無法獲取用戶ID", | ||||
| @@ -213,6 +242,9 @@ | |||||
| "View Details": "查看詳情", | "View Details": "查看詳情", | ||||
| "Wait Time": "等待時間", | "Wait Time": "等待時間", | ||||
| "Waiting QC Put Away Job Orders": "待QC上架工單", | "Waiting QC Put Away Job Orders": "待QC上架工單", | ||||
| "Waiting QC Put Away": "待QC上架", | |||||
| "Put Awayed": "已上架", | |||||
| "Put Away Detail": "上架詳情", | |||||
| "all": "全部", | "all": "全部", | ||||
| "drink": "飲料", | "drink": "飲料", | ||||
| "id": "ID", | "id": "ID", | ||||
| @@ -25,6 +25,9 @@ | |||||
| "Released Time": "開始時間", | "Released Time": "開始時間", | ||||
| "Completed Time": "完成時間", | "Completed Time": "完成時間", | ||||
| "Handler Name": "負責員工", | "Handler Name": "負責員工", | ||||
| "All Handlers": "所有員工", | |||||
| "Select All": "全選", | |||||
| "Unassigned": "未分配", | |||||
| "Number of FG Items (Order Item(s) Count)": "訂單項目數量", | "Number of FG Items (Order Item(s) Count)": "訂單項目數量", | ||||
| "No data available": "沒有資料", | "No data available": "沒有資料", | ||||
| "Rows per page": "每頁行數", | "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; | |||||
| }; | |||||