| @@ -287,6 +287,15 @@ export interface ItemLotTraceJoPickLine { | |||
| requiredQty: number; | |||
| pickedQty: number; | |||
| status: string; | |||
| itemType?: string; | |||
| stockUom?: string; | |||
| bomReqQty?: number | null; | |||
| bomUom?: string; | |||
| bomProcessId?: number | null; | |||
| bomProcessSeqNo?: number | null; | |||
| assignedStepName?: string; | |||
| stockAvailable?: number | null; | |||
| stockStatusApplicable?: boolean; | |||
| } | |||
| export interface ItemLotTraceJoPickOrder { | |||
| @@ -905,7 +905,7 @@ export const fetchAllJoborderProductProcessInfo = cache(async (type?: string | n | |||
| ); | |||
| }); | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.5 | 2026-08-06 */ | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.7 | 2026-08-06 */ | |||
| export const fetchJoborderProductProcessesPage = cache(async (params: { | |||
| /** Job order / process date(YYYY-MM-DD) */ | |||
| date?: string | null; | |||
| @@ -1683,12 +1683,37 @@ export const fetchOperatorKpi = cache(async (date?: string) => { | |||
| }); | |||
| // ===== Drink Production Qty Dashboard ===== | |||
| export interface DrinkProductionQtyProcessStep { | |||
| jobOrderId: number; | |||
| jobOrderCode?: string | null; | |||
| itemCode?: string | null; | |||
| itemName?: string | null; | |||
| seqNo?: number | null; | |||
| processName?: string | null; | |||
| operatorName?: string | null; | |||
| handlerName?: string | null; | |||
| startTime?: string | null; | |||
| endTime?: string | null; | |||
| status?: string | null; | |||
| } | |||
| export interface DrinkProductionQtyJobOrderDetail { | |||
| jobOrderId: number; | |||
| jobOrderCode?: string | null; | |||
| productionDate?: string | null; | |||
| reqQty: number; | |||
| productionQty: number; | |||
| jobOrderStatus?: string | null; | |||
| startTime?: string | null; | |||
| assumeTimeNeedMins?: number; | |||
| assumeEndTime?: string | null; | |||
| actualEndTime?: string | null; | |||
| latestStartBy?: string | null; | |||
| processOperators?: string | null; | |||
| processHandlers?: string | null; | |||
| qcUsers?: string | null; | |||
| putAwayUsers?: string | null; | |||
| processSteps?: DrinkProductionQtyProcessStep[]; | |||
| } | |||
| export interface DrinkProductionQtyResponse { | |||
| @@ -1701,17 +1726,20 @@ export interface DrinkProductionQtyResponse { | |||
| } | |||
| /** 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}` : ""}`; | |||
| export const fetchDrinkProductionQty = cache( | |||
| async (date?: string, view: "actual" | "planned" = "actual") => { | |||
| const params = new URLSearchParams(); | |||
| if (date) params.set("date", date); | |||
| params.set("view", view); | |||
| const qs = params.toString(); | |||
| const url = `${BASE_API_URL}/product-process/Demo/DrinkProductionQty${qs ? `?${qs}` : ""}`; | |||
| return serverFetchJson<DrinkProductionQtyResponse[]>(url, { | |||
| method: "GET", | |||
| next: { tags: ["drinkProductionQty"] }, | |||
| }); | |||
| }); | |||
| return serverFetchJson<DrinkProductionQtyResponse[]>(url, { | |||
| method: "GET", | |||
| next: { tags: ["drinkProductionQty"] }, | |||
| }); | |||
| }, | |||
| ); | |||
| // ===== Equipment Status Dashboard ===== | |||
| @@ -2,6 +2,8 @@ | |||
| @tailwind components; | |||
| @tailwind utilities; | |||
| /* FP-MTMS Version Checklist | Functions Ref. No. 57 | v1.0.0 | 2026-08-06 */ | |||
| /* UI standard: light default, primary #3b82f6, accent #10b981 */ | |||
| @layer base { | |||
| :root { | |||
| @@ -48,6 +50,7 @@ html { | |||
| /* Base font size: slightly larger for readability */ | |||
| font-size: 16px; | |||
| } | |||
| @media (min-width: 640px) { | |||
| html { | |||
| font-size: 17px; | |||
| @@ -20,7 +20,7 @@ export const AUTH = { | |||
| JOB_MAT: "JOB_MAT", | |||
| JOB_PROD: "JOB_PROD", | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 51 | v1.0.0 | 2026-08-05 | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 51 | v1.0.1 | 2026-08-06 | |||
| * 工單 生產流程 完成工單 | |||
| */ | |||
| PRODUCT_PROCESS: "PRODUCT_PROCESS", | |||
| @@ -28,7 +28,7 @@ export const AUTH = { | |||
| } as const; | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 51 | v1.0.0 | 2026-08-05 | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 51 | v1.0.1 | 2026-08-06 | |||
| * Match session ability codes (exact, trimmed). | |||
| */ | |||
| export function hasAbility( | |||
| @@ -14,6 +14,7 @@ import { | |||
| import { formatQty, formatSignedQty } from "./traceQtyUtils"; | |||
| import { | |||
| createMaterialPickNode, | |||
| createMaterialPickNodeFromPickLine, | |||
| docLinkFromOriginType, | |||
| docLinkFromRefType, | |||
| field, | |||
| @@ -264,6 +265,9 @@ const buildMaterialStockInPreludeNodes = ( | |||
| // ADJ is emitted after putaways as ADJUSTMENT (FG-aligned: 上架 → 庫存調整). | |||
| ctx.seenInboundLots.add(lotId); | |||
| const isJoOrigin = originType === "JO"; | |||
| const joSubtitleBase = [m.materialLotNo, m.materialItemCode, m.materialItemName] | |||
| .filter(Boolean) | |||
| .join(" · "); | |||
| nodes.push({ | |||
| id: `mat-in-${lotId}-${origin.stockInLineId}`, | |||
| kind: isJoOrigin ? "JO_CREATED" : "MATERIAL_IN", | |||
| @@ -272,11 +276,13 @@ const buildMaterialStockInPreludeNodes = ( | |||
| title: isJoOrigin | |||
| ? labels.nodeJoCreated | |||
| : `${labels.tr.refType(origin.type)} · ${labels.nodeMaterialIn}`, | |||
| subtitle: appendPickToSubtitle( | |||
| subtitle || m.materialItemCode, | |||
| picks, | |||
| labels.pickOrder, | |||
| ), | |||
| subtitle: isJoOrigin | |||
| ? joSubtitleBase || origin.refCode || m.materialItemCode | |||
| : appendPickToSubtitle( | |||
| subtitle || m.materialItemCode, | |||
| picks, | |||
| labels.pickOrder, | |||
| ), | |||
| qty: origin.acceptedQty, | |||
| uom: matUom, | |||
| refType: origin.type, | |||
| @@ -300,13 +306,23 @@ const buildMaterialStockInPreludeNodes = ( | |||
| linkCode: origin.refCode, | |||
| linkId: origin.refId, | |||
| }), | |||
| field(labels.detailSupplier, [origin.supplierCode, origin.supplierName].filter(Boolean).join(" ")), | |||
| ...(isJoOrigin | |||
| ? [] | |||
| : [ | |||
| field( | |||
| labels.detailSupplier, | |||
| [origin.supplierCode, origin.supplierName].filter(Boolean).join(" "), | |||
| ), | |||
| ]), | |||
| field(labels.detailItemCode, m.materialItemCode), | |||
| field(labels.detailItemName, m.materialItemName), | |||
| field(labels.detailLot, m.materialLotNo), | |||
| field(labels.detailQty, formatQty(origin.acceptedQty, matUom)), | |||
| field(labels.detailStatus, labels.tr.stockInStatus(origin.status)), | |||
| ...(isJoOrigin | |||
| ? [] | |||
| : [field(labels.detailStatus, labels.tr.stockInStatus(origin.status))]), | |||
| field(labels.detailTime, origin.receiptDate), | |||
| ...pickOrderDetailFields(picks, labels.pickOrder), | |||
| ...(isJoOrigin ? [] : pickOrderDetailFields(picks, labels.pickOrder)), | |||
| ], | |||
| }); | |||
| } | |||
| @@ -501,7 +517,6 @@ const buildMaterialStockInPreludeNodes = ( | |||
| const buildNestedJoCreatedNode = ( | |||
| m: ItemLotTraceMaterialInput, | |||
| labels: JoPreludeGraphLabels, | |||
| picks: LotPickRef[], | |||
| ctx: PreludeBuildContext, | |||
| ): TraceGraphNode | null => { | |||
| const nested = m.nestedJoPrelude; | |||
| @@ -524,7 +539,8 @@ const buildNestedJoCreatedNode = ( | |||
| origin?.receiptDate?.trim() || | |||
| null; | |||
| const matUom = m.materialUom?.trim() || ""; | |||
| const qty = origin?.acceptedQty ?? jo.reqQty; | |||
| const req = Number(jo.reqQty); | |||
| const qty = Number.isFinite(req) ? req : (origin?.acceptedQty ?? 0); | |||
| return { | |||
| id: `mat-jo-created-${jo.jobOrderId}-${m.materialLotNo}`, | |||
| @@ -532,11 +548,9 @@ const buildNestedJoCreatedNode = ( | |||
| timestamp: ts, | |||
| sortKey: parseSortKey(ts, nextSeq(ctx)), | |||
| title: labels.nodeJoCreated, | |||
| subtitle: appendPickToSubtitle( | |||
| [m.materialLotNo, m.materialItemCode].filter(Boolean).join(" · ") || jo.jobOrderCode, | |||
| picks, | |||
| labels.pickOrder, | |||
| ), | |||
| subtitle: | |||
| [m.materialLotNo, m.materialItemCode, m.materialItemName].filter(Boolean).join(" · ") || | |||
| jo.jobOrderCode, | |||
| qty, | |||
| uom: matUom, | |||
| refType: "JO", | |||
| @@ -554,10 +568,10 @@ const buildNestedJoCreatedNode = ( | |||
| linkId: jo.jobOrderId, | |||
| }), | |||
| field(labels.detailItemCode, m.materialItemCode), | |||
| field(labels.detailItemName, m.materialItemName), | |||
| field(labels.detailLot, m.materialLotNo), | |||
| field(labels.detailQty, formatQty(qty, matUom)), | |||
| field(labels.detailRequiredQty, formatQty(qty, matUom)), | |||
| field(labels.detailTime, ts), | |||
| ...pickOrderDetailFields(picks, labels.pickOrder), | |||
| ], | |||
| }; | |||
| }; | |||
| @@ -576,22 +590,60 @@ const buildMaterialInputChainNodes = ( | |||
| const picks = pickOrdersForLot(lotPicks, m.materialItemCode, m.materialLotNo); | |||
| if (joProduced && m.nestedJoPrelude) { | |||
| const nestedJoCreated = buildNestedJoCreatedNode(m, labels, picks, ctx); | |||
| const nestedJoCreated = buildNestedJoCreatedNode(m, labels, 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)); | |||
| const nestedPrelude = m.nestedJoPrelude; | |||
| if (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, | |||
| pickLineByItemCode(nestedPrelude, nm.materialItemCode), | |||
| ), | |||
| ); | |||
| }); | |||
| } | |||
| // Always show nested JO pick-order lines (even when no stock-outs / nestedInputs yet). | |||
| const nestedCovered = coveredPickItemCodes(nestedInputs); | |||
| (nestedPrelude.pickOrders ?? []).forEach((po) => { | |||
| (po.lines ?? []).forEach((line, li) => { | |||
| const code = line.itemCode?.trim().toUpperCase(); | |||
| if (!code || nestedCovered.has(code)) return; | |||
| nestedCovered.add(code); | |||
| nodes.push( | |||
| createMaterialPickNodeFromPickLine( | |||
| line, | |||
| li, | |||
| labels, | |||
| { | |||
| ...pickCtx(ctx), | |||
| pickOrderCode: po.pickOrderCode, | |||
| pickOrderId: po.pickOrderId, | |||
| consoCode: po.consoCode, | |||
| jobOrderCode: nestedPrelude.jobOrder.jobOrderCode, | |||
| jobOrderId: nestedPrelude.jobOrder.jobOrderId, | |||
| timestamp: | |||
| po.releasedDate || po.targetDate || nestedPrelude.jobOrder.createdAt, | |||
| }, | |||
| m.materialLotNo, | |||
| ), | |||
| ); | |||
| }); | |||
| }); | |||
| } | |||
| @@ -605,6 +657,29 @@ const buildMaterialInputChainNodes = ( | |||
| return nodes; | |||
| }; | |||
| const pickLineByItemCode = ( | |||
| joPrelude: ItemLotTraceJoPrelude, | |||
| itemCode: string, | |||
| ): ItemLotTraceJoPickLine | null => { | |||
| const key = itemCode.trim().toUpperCase(); | |||
| if (!key) return null; | |||
| for (const po of joPrelude.pickOrders ?? []) { | |||
| for (const line of po.lines ?? []) { | |||
| if (line.itemCode?.trim().toUpperCase() === key) return line; | |||
| } | |||
| } | |||
| return null; | |||
| }; | |||
| const coveredPickItemCodes = (materialInputs: ItemLotTraceMaterialInput[]): Set<string> => { | |||
| const set = new Set<string>(); | |||
| materialInputs.forEach((m) => { | |||
| const code = m.materialItemCode?.trim().toUpperCase(); | |||
| if (code) set.add(code); | |||
| }); | |||
| return set; | |||
| }; | |||
| export const buildJoPreludeGraphNodes = ( | |||
| joPrelude: ItemLotTraceJoPrelude, | |||
| labels: JoPreludeGraphLabels & Partial<ProductionGraphLabels>, | |||
| @@ -619,12 +694,61 @@ export const buildJoPreludeGraphNodes = ( | |||
| seq: 0, | |||
| }; | |||
| const lotPicks = buildLotPickOrderMap(joPrelude.materialInputs); | |||
| const coveredItems = coveredPickItemCodes(joPrelude.materialInputs); | |||
| const lotPickNodes = joPrelude.materialInputs.map((m, i) => | |||
| createMaterialPickNode( | |||
| m, | |||
| i, | |||
| labels, | |||
| pickCtx(ctx), | |||
| undefined, | |||
| pickLineByItemCode(joPrelude, m.materialItemCode), | |||
| ), | |||
| ); | |||
| const linePickNodes: TraceGraphNode[] = []; | |||
| (joPrelude.pickOrders ?? []).forEach((po) => { | |||
| (po.lines ?? []).forEach((line, li) => { | |||
| const code = line.itemCode?.trim().toUpperCase(); | |||
| if (!code || coveredItems.has(code)) return; | |||
| coveredItems.add(code); | |||
| linePickNodes.push( | |||
| createMaterialPickNodeFromPickLine(line, li, labels, { | |||
| ...pickCtx(ctx), | |||
| pickOrderCode: po.pickOrderCode, | |||
| pickOrderId: po.pickOrderId, | |||
| consoCode: po.consoCode, | |||
| jobOrderCode: joPrelude.jobOrder.jobOrderCode, | |||
| jobOrderId: joPrelude.jobOrder.jobOrderId, | |||
| timestamp: po.releasedDate || po.targetDate || joPrelude.jobOrder.createdAt, | |||
| }), | |||
| ); | |||
| }); | |||
| }); | |||
| const nodes: TraceGraphNode[] = [ | |||
| ...buildMaterialInputChainNodes(joPrelude.materialInputs, labels, lotPicks, ctx), | |||
| ...joPrelude.materialInputs.map((m, i) => createMaterialPickNode(m, i, labels, pickCtx(ctx))), | |||
| ...lotPickNodes, | |||
| ...linePickNodes, | |||
| ]; | |||
| // Keep 工單提料 after 建立工單 for the same JO. | |||
| const joCreatedSort = new Map<string, number>(); | |||
| nodes.forEach((n) => { | |||
| if (n.kind !== "JO_CREATED") return; | |||
| const code = (n.refCode || n.jobOrderCode || "").trim(); | |||
| if (!code) return; | |||
| const prev = joCreatedSort.get(code); | |||
| if (prev == null || n.sortKey < prev) joCreatedSort.set(code, n.sortKey); | |||
| }); | |||
| nodes.forEach((n) => { | |||
| if (n.kind !== "MATERIAL_PICK" && n.kind !== "PICK_GROUP") return; | |||
| const code = (n.jobOrderCode || "").trim(); | |||
| const joSort = code ? joCreatedSort.get(code) : undefined; | |||
| if (joSort != null && n.sortKey <= joSort) n.sortKey = joSort + 1; | |||
| }); | |||
| return nodes.sort((a, b) => { | |||
| if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey; | |||
| return a.id.localeCompare(b.id); | |||
| @@ -178,6 +178,8 @@ export interface TraceGraphDetailLabels extends TraceGraphNodeLabels { | |||
| nodeExpired: string; | |||
| nodeDepleted: string; | |||
| detailQty: string; | |||
| /** JO_CREATED detail / chip qty — job order required qty (需求數量). */ | |||
| detailRequiredQty: string; | |||
| detailTime: string; | |||
| detailHandler: string; | |||
| detailStockTaker: string; | |||
| @@ -435,6 +437,59 @@ const resolveJoCreatedTimestamp = ( | |||
| return fallback ?? null; | |||
| }; | |||
| /** Prefer job-order reqQty for 工單建立; fall back to stock-in / movement qty when JO context is missing. */ | |||
| const resolveJoReqQty = ( | |||
| data: ItemLotTraceResponse, | |||
| refCode: string | null | undefined, | |||
| fallback: number, | |||
| ): number => { | |||
| const code = refCode?.trim(); | |||
| if (!code) return fallback; | |||
| const candidates: Array<ItemLotTraceJoContext | undefined> = [ | |||
| data.joPrelude?.jobOrder, | |||
| ...(data.joPrelude?.materialInputs ?? []).map((m) => m.nestedJoPrelude?.jobOrder), | |||
| ]; | |||
| for (const jo of candidates) { | |||
| if (jo?.jobOrderCode?.trim() !== code) continue; | |||
| const req = Number(jo.reqQty); | |||
| if (Number.isFinite(req)) return req; | |||
| } | |||
| return fallback; | |||
| }; | |||
| /** Subtitle for FG / current-lot JO_CREATED: lot · itemCode · itemName */ | |||
| const joCreatedLotSubtitle = (lot: { | |||
| lotNo?: string | null; | |||
| itemCode?: string | null; | |||
| itemName?: string | null; | |||
| }): string => | |||
| [lot.lotNo, lot.itemCode, lot.itemName].filter(Boolean).join(" · ") || "—"; | |||
| const joCreatedLotDetails = ( | |||
| labels: TraceGraphDetailLabels, | |||
| lot: { lotNo?: string | null; itemCode?: string | null; itemName?: string | null }, | |||
| refCode: string, | |||
| refId: number | null | undefined, | |||
| qty: number, | |||
| uom: string, | |||
| timestamp: string | null | undefined, | |||
| ) => | |||
| detailsOf( | |||
| field(labels.detailType, labels.nodeJoCreated), | |||
| field(labels.jobOrder, refCode, { | |||
| linkKind: "jo", | |||
| linkCode: refCode, | |||
| linkId: refId, | |||
| }), | |||
| field(labels.detailItemCode, lot.itemCode), | |||
| field(labels.detailItemName, lot.itemName), | |||
| field(labels.detailLot, lot.lotNo), | |||
| field(labels.detailRequiredQty, formatQty(qty, uom)), | |||
| field(labels.detailTime, timestamp), | |||
| ); | |||
| const poInboundKey = ( | |||
| refCode: string | null | undefined, | |||
| refId: number | null | undefined, | |||
| @@ -532,14 +587,16 @@ const buildInboundOriginNodes = ( | |||
| const joKey = `${refCode}|${origin.refId ?? 0}`; | |||
| coveredJoInbound.add(joKey); | |||
| const eventTimestamp = resolveJoCreatedTimestamp(data, refCode, origin.receiptDate); | |||
| const joQty = resolveJoReqQty(data, refCode, origin.acceptedQty); | |||
| const lot = data.lot; | |||
| nodes.push({ | |||
| id: `${idPfx}origin-${i}-${origin.stockInLineId}`, | |||
| kind: "JO_CREATED", | |||
| timestamp: eventTimestamp, | |||
| sortKey: parseSortKey(eventTimestamp, seq++), | |||
| title: labels.nodeJoCreated, | |||
| subtitle: refCode, | |||
| qty: origin.acceptedQty, | |||
| subtitle: joCreatedLotSubtitle(lot), | |||
| qty: joQty, | |||
| uom: stockUom, | |||
| refType: origin.type, | |||
| refCode, | |||
| @@ -547,15 +604,14 @@ const buildInboundOriginNodes = ( | |||
| docLinkKind: linkKind, | |||
| warehouseCode: defaultWh, | |||
| categoryLabel: categoryForKind("JO_CREATED", labels), | |||
| details: detailsOf( | |||
| field(labels.detailType, labels.nodeJoCreated), | |||
| field(labels.jobOrder, refCode, { | |||
| linkKind, | |||
| linkCode: refCode, | |||
| linkId: origin.refId, | |||
| }), | |||
| field(labels.detailQty, formatQty(origin.acceptedQty, stockUom)), | |||
| field(labels.detailTime, eventTimestamp), | |||
| details: joCreatedLotDetails( | |||
| labels, | |||
| lot, | |||
| refCode, | |||
| origin.refId, | |||
| joQty, | |||
| stockUom, | |||
| eventTimestamp, | |||
| ), | |||
| }); | |||
| } | |||
| @@ -714,6 +770,8 @@ export const buildTraceGraphNodes = ( | |||
| kind === "JO_CREATED" | |||
| ? resolveJoCreatedTimestamp(data, m.refCode, m.timestamp) | |||
| : m.timestamp; | |||
| const joCreatedQty = | |||
| kind === "JO_CREATED" ? resolveJoReqQty(data, m.refCode, m.qty) : m.qty; | |||
| const title = | |||
| kind === "RECEIPT" | |||
| ? labels.nodeReceipt | |||
| @@ -729,7 +787,9 @@ export const buildTraceGraphNodes = ( | |||
| const subtitle = | |||
| kind === "DO_OUT" || kind === "JO_OUT" || kind === "PO_OUT" | |||
| ? "" | |||
| : [m.refCode, m.warehouseCode].filter(Boolean).join(" · ") || "—"; | |||
| : kind === "JO_CREATED" | |||
| ? joCreatedLotSubtitle(data.lot) | |||
| : [m.refCode, m.warehouseCode].filter(Boolean).join(" · ") || "—"; | |||
| const meta = [m.handledBy, m.remarks].filter(Boolean).join(" · "); | |||
| const linkKind = isDoOut || isJoOut || isPoOut ? "pick" : docLinkFromRefType(m.refType); | |||
| const receiptDetails = | |||
| @@ -745,6 +805,16 @@ export const buildTraceGraphNodes = ( | |||
| field(labels.detailTime, m.timestamp), | |||
| fieldIf(labels.detailRemarks, m.remarks), | |||
| ) | |||
| : kind === "JO_CREATED" | |||
| ? joCreatedLotDetails( | |||
| labels, | |||
| data.lot, | |||
| m.refCode?.trim() || "—", | |||
| m.refId, | |||
| joCreatedQty, | |||
| stockUom, | |||
| eventTimestamp, | |||
| ) | |||
| : kind === "DO_OUT" | |||
| ? detailsOf( | |||
| field(labels.detailType, labels.nodeDoOut), | |||
| @@ -875,7 +945,7 @@ export const buildTraceGraphNodes = ( | |||
| sortKey: parseSortKey(eventTimestamp, seq++), | |||
| title, | |||
| subtitle, | |||
| qty: m.qty, | |||
| qty: kind === "JO_CREATED" ? joCreatedQty : m.qty, | |||
| uom: stockUom, | |||
| meta: meta || undefined, | |||
| refType: m.refType, | |||
| @@ -63,6 +63,7 @@ export const buildTraceGraphLabels = ( | |||
| formatQcSubtitle: (failQty: number, acceptedQty: number) => | |||
| `${t("failQty")}: ${formatQty(failQty, lotUom)} / ${t("acceptedQty")}: ${formatQty(acceptedQty, lotUom)}`, | |||
| detailQty: t("qty"), | |||
| detailRequiredQty: t("requiredQty"), | |||
| detailTime: t("timestamp"), | |||
| detailHandler: t("handler"), | |||
| detailStockTaker: t("stockTaker"), | |||
| @@ -139,6 +140,14 @@ export const buildTraceGraphLabels = ( | |||
| categoryQc: t("phaseQc"), | |||
| categoryOutbound: t("phaseOutbound"), | |||
| categoryTerminal: t("categoryTerminal"), | |||
| detailBomReqQty: t("detailBomReqQty"), | |||
| detailStockReqQty: t("detailStockReqQty"), | |||
| detailStockAvailable: t("detailStockAvailable"), | |||
| detailStockStatus: t("detailStockStatus"), | |||
| stockStatusSufficient: t("stockStatusSufficient"), | |||
| stockStatusInsufficient: t("stockStatusInsufficient"), | |||
| na: t("N/A"), | |||
| pendingPick: t("pendingPick"), | |||
| processingStatus: t("processingStatus"), | |||
| matchStatus: t("matchStatus"), | |||
| }; | |||
| @@ -146,7 +155,7 @@ export const buildTraceGraphLabels = ( | |||
| /** Minimal labels for unit tests (no i18n). */ | |||
| export const createTestTraceGraphLabels = (): TraceGraphCompileLabels => { | |||
| const identity = (s: string | null | undefined) => s?.trim() || "—"; | |||
| const identity = (s: string | null | undefined) => s?.trim() || "N/A"; | |||
| const tr = { | |||
| refType: identity, | |||
| movementType: identity, | |||
| @@ -209,6 +218,7 @@ export const createTestTraceGraphLabels = (): TraceGraphCompileLabels => { | |||
| directionOut: "Out", | |||
| formatQcSubtitle: (f, a) => `Fail ${f} / Accepted ${a}`, | |||
| detailQty: "Qty", | |||
| detailRequiredQty: "Required qty", | |||
| detailTime: "Time", | |||
| detailHandler: "Handler", | |||
| detailStockTaker: "First counter", | |||
| @@ -285,6 +295,14 @@ export const createTestTraceGraphLabels = (): TraceGraphCompileLabels => { | |||
| categoryQc: "QC", | |||
| categoryOutbound: "Outbound", | |||
| categoryTerminal: "Terminal", | |||
| detailBomReqQty: "BOM req. qty", | |||
| detailStockReqQty: "Stock req. qty", | |||
| detailStockAvailable: "Stock available", | |||
| detailStockStatus: "Stock status", | |||
| stockStatusSufficient: "Sufficient", | |||
| stockStatusInsufficient: "Insufficient", | |||
| na: "N/A", | |||
| pendingPick: "Pending pick", | |||
| processingStatus: "Processing status", | |||
| matchStatus: "Match status", | |||
| }; | |||
| @@ -248,16 +248,23 @@ export const shouldSkipTraceFlowEdge = ( | |||
| to: TraceGraphLayoutNode, | |||
| ): boolean => { | |||
| if (from.kind === "JO_CREATED" && !isMaterialPreludeNode(from)) { | |||
| if (to.kind === "MATERIAL_PICK" || to.kind === "PICK_GROUP") return false; | |||
| if ( | |||
| to.kind === "MATERIAL_PICK" || | |||
| to.kind === "PICK_GROUP" || | |||
| to.kind === "PRODUCTION_STEP" | |||
| ) { | |||
| return false; | |||
| } | |||
| return true; | |||
| } | |||
| // Allow PRODUCTION_STEP → PRODUCTION_STEP (process chain). Block other sources into steps | |||
| // except material picks (handled by dedicated pick→step edges). | |||
| // except material picks / JO_CREATED fallback (dedicated builders). | |||
| if ( | |||
| to.kind === "PRODUCTION_STEP" && | |||
| from.kind !== "MATERIAL_PICK" && | |||
| from.kind !== "PICK_GROUP" && | |||
| from.kind !== "PRODUCTION_STEP" | |||
| from.kind !== "PRODUCTION_STEP" && | |||
| from.kind !== "JO_CREATED" | |||
| ) { | |||
| return true; | |||
| } | |||
| @@ -435,6 +442,9 @@ const buildMaterialLotFlowEdgePairs = ( | |||
| nodes.forEach((n) => { | |||
| if (!isMaterialPreludeNode(n)) return; | |||
| // JO_CREATED lives in MATERIAL_PICK for layout, but its early planStart would pull that | |||
| // phase before PUTAWAY and break 已上架 → 工單提料. Dedicated builder handles JO→提料. | |||
| if (n.kind === "JO_CREATED") return; | |||
| const key = materialLotKey(n); | |||
| if (!key) return; | |||
| const list = byLot.get(key) ?? []; | |||
| @@ -464,12 +474,14 @@ const buildMaterialLotFlowEdgePairs = ( | |||
| if (toPhase === "MATERIAL_PICK") { | |||
| const upstream = fromList[fromList.length - 1]!; | |||
| const seenPickTargets = new Set<string>(); | |||
| toList.forEach((pick) => { | |||
| const target = resolvePickFlowTarget(pick, nodes); | |||
| if (seenPickTargets.has(target.id)) return; | |||
| seenPickTargets.add(target.id); | |||
| add(upstream, target); | |||
| }); | |||
| toList | |||
| .filter((n) => n.kind === "MATERIAL_PICK" || n.kind === "PICK_GROUP") | |||
| .forEach((pick) => { | |||
| const target = resolvePickFlowTarget(pick, nodes); | |||
| if (seenPickTargets.has(target.id)) return; | |||
| seenPickTargets.add(target.id); | |||
| add(upstream, target); | |||
| }); | |||
| } else { | |||
| add(fromList[fromList.length - 1]!, toList[0]!); | |||
| } | |||
| @@ -686,15 +698,11 @@ const sortProductionStepsForChain = ( | |||
| return sortNodesInPhase(a, b); | |||
| }; | |||
| const buildMaterialPickToProductionStepEdges = ( | |||
| nodes: TraceGraphLayoutNode[], | |||
| add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void, | |||
| ): void => { | |||
| const prodSteps = nodes.filter( | |||
| (n) => n.kind === "PRODUCTION_STEP" && !isDoGroupChild(n), | |||
| ); | |||
| if (!prodSteps.length) return; | |||
| const resolveProductionTargetForPick = ( | |||
| scope: string, | |||
| pick: TraceGraphLayoutNode, | |||
| prodSteps: TraceGraphLayoutNode[], | |||
| ): TraceGraphLayoutNode | null => { | |||
| const productionByScopeAndProcess = new Map<string, TraceGraphLayoutNode[]>(); | |||
| prodSteps.forEach((n) => { | |||
| if (n.bomProcessId == null) return; | |||
| @@ -704,38 +712,43 @@ const buildMaterialPickToProductionStepEdges = ( | |||
| productionByScopeAndProcess.set(key, list); | |||
| }); | |||
| const resolveProductionTarget = ( | |||
| scope: string, | |||
| pick: TraceGraphLayoutNode, | |||
| ): TraceGraphLayoutNode | null => { | |||
| const inScope = (n: TraceGraphLayoutNode) => productionScopeKey(n) === scope; | |||
| const inScope = (n: TraceGraphLayoutNode) => productionScopeKey(n) === scope; | |||
| if (pick.bomProcessId != null) { | |||
| const byId = productionByScopeAndProcess.get(`${scope}::${pick.bomProcessId}`); | |||
| if (byId?.length) return [...byId].sort(sortProductionStepsForChain)[0]!; | |||
| } | |||
| if (pick.bomProcessId != null) { | |||
| const byId = productionByScopeAndProcess.get(`${scope}::${pick.bomProcessId}`); | |||
| if (byId?.length) return [...byId].sort(sortProductionStepsForChain)[0]!; | |||
| } | |||
| if (pick.bomProcessSeqNo != null) { | |||
| const bySeq = prodSteps.filter( | |||
| (n) => inScope(n) && n.bomProcessSeqNo === pick.bomProcessSeqNo, | |||
| ); | |||
| if (bySeq.length) return [...bySeq].sort(sortProductionStepsForChain)[0]!; | |||
| } | |||
| if (pick.bomProcessSeqNo != null) { | |||
| const bySeq = prodSteps.filter( | |||
| (n) => inScope(n) && n.bomProcessSeqNo === pick.bomProcessSeqNo, | |||
| ); | |||
| if (bySeq.length) return [...bySeq].sort(sortProductionStepsForChain)[0]!; | |||
| } | |||
| const itemCode = pick.traceItemCode?.trim().toUpperCase(); | |||
| if (itemCode) { | |||
| const byItem = prodSteps.filter( | |||
| (n) => | |||
| inScope(n) && | |||
| (n.stepMaterialItemCodes ?? []).some( | |||
| (code) => code.trim().toUpperCase() === itemCode, | |||
| ), | |||
| ); | |||
| if (byItem.length) return [...byItem].sort(sortProductionStepsForChain)[0]!; | |||
| } | |||
| const itemCode = pick.traceItemCode?.trim().toUpperCase(); | |||
| if (itemCode) { | |||
| const byItem = prodSteps.filter( | |||
| (n) => | |||
| inScope(n) && | |||
| (n.stepMaterialItemCodes ?? []).some( | |||
| (code) => code.trim().toUpperCase() === itemCode, | |||
| ), | |||
| ); | |||
| if (byItem.length) return [...byItem].sort(sortProductionStepsForChain)[0]!; | |||
| } | |||
| return null; | |||
| }; | |||
| return null; | |||
| }; | |||
| const buildMaterialPickToProductionStepEdges = ( | |||
| nodes: TraceGraphLayoutNode[], | |||
| add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void, | |||
| ): void => { | |||
| const prodSteps = nodes.filter( | |||
| (n) => n.kind === "PRODUCTION_STEP" && !isDoGroupChild(n), | |||
| ); | |||
| if (!prodSteps.length) return; | |||
| const connectPickOrGroup = ( | |||
| source: TraceGraphLayoutNode, | |||
| @@ -743,7 +756,7 @@ const buildMaterialPickToProductionStepEdges = ( | |||
| seenTargets: Set<string>, | |||
| ) => { | |||
| const scope = pick.feedsProductionScopeLotNo?.trim() || source.feedsProductionScopeLotNo?.trim() || "__fg__"; | |||
| const target = resolveProductionTarget(scope, pick); | |||
| const target = resolveProductionTargetForPick(scope, pick, prodSteps); | |||
| if (!target || seenTargets.has(target.id)) return; | |||
| seenTargets.add(target.id); | |||
| add( | |||
| @@ -884,6 +897,77 @@ const buildJoCreatedToMaterialPickEdges = ( | |||
| }); | |||
| }; | |||
| /** When a JO has no 提料 that reaches 生產步驟, link 工單建立 → first 生產步驟 in scope. */ | |||
| const buildJoCreatedToProductionStepEdges = ( | |||
| nodes: TraceGraphLayoutNode[], | |||
| add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void, | |||
| ): void => { | |||
| const joCreatedNodes = nodes.filter((n) => n.kind === "JO_CREATED"); | |||
| const prodSteps = nodes.filter( | |||
| (n) => n.kind === "PRODUCTION_STEP" && !isDoGroupChild(n), | |||
| ); | |||
| if (!joCreatedNodes.length || !prodSteps.length) return; | |||
| const pickTargets = nodes.filter( | |||
| (n) => | |||
| !isDoGroupChild(n) && | |||
| (n.kind === "PICK_GROUP" || | |||
| (n.kind === "MATERIAL_PICK" && !n.doGroupId?.trim())), | |||
| ); | |||
| const feedLotForTarget = (target: TraceGraphLayoutNode): string => { | |||
| if (target.kind === "PICK_GROUP") { | |||
| return ( | |||
| nodes | |||
| .find( | |||
| (n) => | |||
| n.doGroupId === target.id && Boolean(n.feedsProductionScopeLotNo?.trim()), | |||
| ) | |||
| ?.feedsProductionScopeLotNo?.trim() ?? "" | |||
| ); | |||
| } | |||
| return target.feedsProductionScopeLotNo?.trim() ?? ""; | |||
| }; | |||
| const materialPicksForTarget = (target: TraceGraphLayoutNode): TraceGraphLayoutNode[] => { | |||
| if (target.kind === "PICK_GROUP") { | |||
| return nodes.filter((n) => n.doGroupId === target.id && n.kind === "MATERIAL_PICK"); | |||
| } | |||
| return [target]; | |||
| }; | |||
| /** Only suppress fallback when a pick for this JO can actually reach a production step. */ | |||
| const joHasPickReachingProduction = (jo: TraceGraphLayoutNode): boolean => { | |||
| const joCode = jo.refCode?.trim(); | |||
| if (!joCode) return false; | |||
| const joLot = jo.traceLotNo?.trim() ?? ""; | |||
| const scope = productionScopeKey(jo); | |||
| return pickTargets.some((target) => { | |||
| if (joCodeForPickTarget(target, nodes) !== joCode) return false; | |||
| const feedLot = feedLotForTarget(target); | |||
| if (joLot) { | |||
| // Nested JO: only picks that feed this SF lot. | |||
| if (feedLot !== joLot) return false; | |||
| } else if (feedLot) { | |||
| // FG JO: ignore nested-scoped picks. | |||
| return false; | |||
| } | |||
| return materialPicksForTarget(target).some( | |||
| (pick) => resolveProductionTargetForPick(scope, pick, prodSteps) != null, | |||
| ); | |||
| }); | |||
| }; | |||
| joCreatedNodes.forEach((jo) => { | |||
| if (joHasPickReachingProduction(jo)) return; | |||
| const scope = productionScopeKey(jo); | |||
| const firstStep = prodSteps | |||
| .filter((n) => productionScopeKey(n) === scope) | |||
| .sort(sortProductionStepsForChain)[0]; | |||
| if (firstStep) add(jo, firstStep); | |||
| }); | |||
| }; | |||
| const resolveDoOutFlowTarget = ( | |||
| doOut: TraceGraphLayoutNode, | |||
| nodes: TraceGraphLayoutNode[], | |||
| @@ -1102,6 +1186,9 @@ export const buildTraceFlowEdgePairs = ( | |||
| const to = toList[0]!; | |||
| if (isMaterialPreludeNode(from) || isMaterialPreludeNode(to)) continue; | |||
| // Dedicated builder owns JO_CREATED → PRODUCTION_STEP (avoid day-phase noise). | |||
| if (from.kind === "JO_CREATED" && to.kind === "PRODUCTION_STEP") continue; | |||
| if (phasesPresent[i] === "OUTBOUND" && phasesPresent[i + 1] === "STOCK_TAKE") { | |||
| continue; | |||
| } | |||
| @@ -1129,6 +1216,7 @@ export const buildTraceFlowEdgePairs = ( | |||
| buildProductionStepChainEdges(nodes, add); | |||
| buildProductionToFgQcEdges(nodes, add); | |||
| buildJoCreatedToMaterialPickEdges(nodes, add); | |||
| buildJoCreatedToProductionStepEdges(nodes, add); | |||
| buildReplenishmentToDoOutEdges(nodes, add); | |||
| buildMaterialPickToProductionStepEdges(nodes, add); | |||
| buildFgLotFlowEdgePairs(nodes, phaseOrder, add); | |||
| @@ -1,5 +1,8 @@ | |||
| import dayjs from "dayjs"; | |||
| import { ItemLotTraceMaterialInput } from "@/app/api/itemTracing"; | |||
| import { | |||
| ItemLotTraceJoPickLine, | |||
| ItemLotTraceMaterialInput, | |||
| } from "@/app/api/itemTracing"; | |||
| import { | |||
| TraceGraphDetailField, | |||
| TraceGraphDocLinkKind, | |||
| @@ -21,6 +24,16 @@ export type MaterialPickLabels = { | |||
| detailAssignedStep: string; | |||
| detailPickTargetDate: string; | |||
| detailTime: string; | |||
| detailBomReqQty: string; | |||
| detailStockReqQty: string; | |||
| detailStockAvailable: string; | |||
| detailStockStatus: string; | |||
| stockStatusSufficient: string; | |||
| stockStatusInsufficient: string; | |||
| /** Displayed when a JO pick-table field does not apply. */ | |||
| na: string; | |||
| /** Unpicked pick-order line (no stock-out yet). */ | |||
| pendingPick: string; | |||
| processingStatus: string; | |||
| matchStatus: string; | |||
| tr: { | |||
| @@ -30,32 +43,37 @@ export type MaterialPickLabels = { | |||
| }; | |||
| export const pickStatusDetailFields = ( | |||
| labels: Pick<MaterialPickLabels, "processingStatus" | "matchStatus" | "tr">, | |||
| labels: Pick<MaterialPickLabels, "processingStatus" | "matchStatus" | "tr" | "na">, | |||
| processingStatus?: string | null, | |||
| matchStatus?: string | null, | |||
| opts?: { always?: boolean }, | |||
| ): TraceGraphDetailField[] => { | |||
| const always = opts?.always === true; | |||
| const na = labels.na || "N/A"; | |||
| const rows: TraceGraphDetailField[] = []; | |||
| if (always || processingStatus?.trim()) { | |||
| rows.push( | |||
| field(labels.processingStatus, labels.tr.processingStatus(processingStatus), { | |||
| valueColor: pickStatusValueColor(processingStatus), | |||
| }), | |||
| field( | |||
| labels.processingStatus, | |||
| processingStatus?.trim() ? labels.tr.processingStatus(processingStatus) : na, | |||
| { valueColor: pickStatusValueColor(processingStatus) }, | |||
| ), | |||
| ); | |||
| } | |||
| if (always || matchStatus?.trim()) { | |||
| rows.push( | |||
| field(labels.matchStatus, labels.tr.matchStatus(matchStatus), { | |||
| valueColor: pickStatusValueColor(matchStatus), | |||
| }), | |||
| field( | |||
| labels.matchStatus, | |||
| matchStatus?.trim() ? labels.tr.matchStatus(matchStatus) : na, | |||
| { valueColor: pickStatusValueColor(matchStatus) }, | |||
| ), | |||
| ); | |||
| } | |||
| return rows; | |||
| }; | |||
| export const pickStatusNodeLabels = ( | |||
| labels: Pick<MaterialPickLabels, "tr">, | |||
| labels: Pick<MaterialPickLabels, "tr" | "na">, | |||
| processingStatus?: string | null, | |||
| matchStatus?: string | null, | |||
| opts?: { always?: boolean }, | |||
| @@ -64,13 +82,20 @@ export const pickStatusNodeLabels = ( | |||
| "processingStatusLabel" | "matchStatusLabel" | "processingStatus" | "matchStatus" | |||
| > => { | |||
| const always = opts?.always === true; | |||
| const na = labels.na || "N/A"; | |||
| const showProcessing = always || Boolean(processingStatus?.trim()); | |||
| const showMatch = always || Boolean(matchStatus?.trim()); | |||
| return { | |||
| processingStatusLabel: showProcessing | |||
| ? labels.tr.processingStatus(processingStatus) | |||
| ? processingStatus?.trim() | |||
| ? labels.tr.processingStatus(processingStatus) | |||
| : na | |||
| : undefined, | |||
| matchStatusLabel: showMatch | |||
| ? matchStatus?.trim() | |||
| ? labels.tr.matchStatus(matchStatus) | |||
| : na | |||
| : undefined, | |||
| matchStatusLabel: showMatch ? labels.tr.matchStatus(matchStatus) : undefined, | |||
| processingStatus: showProcessing ? processingStatus?.trim() || undefined : undefined, | |||
| matchStatus: showMatch ? matchStatus?.trim() || undefined : undefined, | |||
| }; | |||
| @@ -143,33 +168,78 @@ export type MaterialPickNodeContext = { | |||
| pickOrderTargetDate?: (pickOrderCode: string) => string | undefined; | |||
| }; | |||
| export const isPickStockApplicable = (itemType?: string | null): boolean => { | |||
| const normalized = (itemType ?? "").trim().toLowerCase(); | |||
| return !["consumables", "consumable", "cmb", "nm"].includes(normalized); | |||
| }; | |||
| const formatStockStatus = ( | |||
| labels: MaterialPickLabels, | |||
| stockStatusApplicable: boolean, | |||
| stockAvailable: number | null | undefined, | |||
| requiredQty: number, | |||
| ): string => { | |||
| if (!stockStatusApplicable) return labels.na; | |||
| if (stockAvailable == null) return labels.na; | |||
| return stockAvailable >= requiredQty | |||
| ? labels.stockStatusSufficient | |||
| : labels.stockStatusInsufficient; | |||
| }; | |||
| const formatMaybeNaQty = ( | |||
| labels: MaterialPickLabels, | |||
| applicable: boolean, | |||
| qty: number | null | undefined, | |||
| uom: string, | |||
| ): string => { | |||
| if (!applicable) return labels.na; | |||
| if (qty == null) return labels.na; | |||
| return formatQty(qty, uom); | |||
| }; | |||
| const stepLabelOf = (stepName: string, stepSeq: number | null | undefined): string => { | |||
| if (!stepName) return ""; | |||
| if (stepSeq != null) return `${stepSeq}. ${stepName}`; | |||
| return stepName; | |||
| }; | |||
| export const createMaterialPickNode = ( | |||
| m: ItemLotTraceMaterialInput, | |||
| index: number, | |||
| labels: MaterialPickLabels, | |||
| ctx: MaterialPickNodeContext, | |||
| feedsProductionScopeLotNo?: string, | |||
| pickLine?: ItemLotTraceJoPickLine | null, | |||
| ): TraceGraphNode => { | |||
| const matUom = m.materialUom?.trim() || ""; | |||
| const matUom = m.materialUom?.trim() || pickLine?.stockUom?.trim() || ""; | |||
| const pickTitle = m.pickOrderCode || labels.nodeMaterialPick; | |||
| const stepLabel = m.assignedStepName?.trim() | |||
| ? m.bomProcessSeqNo != null | |||
| ? `${m.bomProcessSeqNo}. ${m.assignedStepName}` | |||
| : m.assignedStepName | |||
| : ""; | |||
| const stepName = m.assignedStepName?.trim() || pickLine?.assignedStepName?.trim() || ""; | |||
| const stepSeq = m.bomProcessSeqNo ?? pickLine?.bomProcessSeqNo ?? null; | |||
| const stepLabel = stepLabelOf(stepName, stepSeq); | |||
| 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; | |||
| const stockApplicable = pickLine | |||
| ? pickLine.stockStatusApplicable !== false && isPickStockApplicable(pickLine.itemType) | |||
| : true; | |||
| const bomQty = pickLine?.bomReqQty ?? m.bomQtyPerUnit; | |||
| const bomUom = pickLine?.bomUom?.trim() || matUom; | |||
| const stockReqQty = pickLine?.requiredQty ?? m.materialQty; | |||
| const stockAvail = pickLine?.stockAvailable; | |||
| const lotDisplay = m.materialLotNo?.trim() || labels.na; | |||
| 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(" · "), | |||
| subtitle: [lotDisplay !== labels.na ? m.materialLotNo : "", stepLabel] | |||
| .filter(Boolean) | |||
| .join(" · "), | |||
| qty: m.materialQty, | |||
| uom: matUom, | |||
| meta: itemLabel || undefined, | |||
| @@ -179,13 +249,22 @@ export const createMaterialPickNode = ( | |||
| jobOrderCode: m.jobOrderCode?.trim() || undefined, | |||
| docLinkKind: m.pickOrderCode ? "jodetail" : undefined, | |||
| docLinkTargetDate: linkTargetDate, | |||
| traceLotNo: m.materialLotNo, | |||
| traceLotNo: m.materialLotNo?.trim() || undefined, | |||
| traceItemCode: m.materialItemCode, | |||
| bomProcessId: m.bomProcessId, | |||
| bomProcessSeqNo: m.bomProcessSeqNo, | |||
| assignedStepName: m.assignedStepName, | |||
| bomProcessId: m.bomProcessId ?? pickLine?.bomProcessId, | |||
| bomProcessSeqNo: stepSeq, | |||
| assignedStepName: stepName || undefined, | |||
| feedsProductionScopeLotNo, | |||
| categoryLabel: labels.categoryPick, | |||
| stockAvailableLabel: formatMaybeNaQty(labels, stockApplicable, stockAvail, matUom), | |||
| stockStatusLabel: formatStockStatus( | |||
| labels, | |||
| stockApplicable, | |||
| stockAvail, | |||
| Number(stockReqQty) || 0, | |||
| ), | |||
| bomReqQtyLabel: bomQty != null ? formatQty(bomQty, bomUom) : labels.na, | |||
| stockReqQtyLabel: formatQty(stockReqQty, matUom), | |||
| details: [ | |||
| field(labels.pickOrder, m.pickOrderCode, { | |||
| linkKind: "jodetail", | |||
| @@ -194,20 +273,134 @@ export const createMaterialPickNode = ( | |||
| consoCode: m.consoCode, | |||
| linkTargetDate, | |||
| }), | |||
| field(labels.detailPickTargetDate, pickTargetDate || pickedDate), | |||
| field(labels.detailPickTargetDate, pickTargetDate || pickedDate || labels.na), | |||
| field(labels.detailMaterial, `${m.materialItemCode} · ${m.materialItemName}`), | |||
| field(labels.detailLot, m.materialLotNo), | |||
| field(labels.detailLot, lotDisplay), | |||
| field(labels.detailBomReqQty, bomQty != null ? formatQty(bomQty, bomUom) : labels.na), | |||
| field(labels.detailStockReqQty, formatQty(stockReqQty, matUom)), | |||
| field( | |||
| labels.detailStockAvailable, | |||
| formatMaybeNaQty(labels, stockApplicable, stockAvail, matUom), | |||
| ), | |||
| field( | |||
| labels.detailStockStatus, | |||
| formatStockStatus(labels, stockApplicable, stockAvail, Number(stockReqQty) || 0), | |||
| ), | |||
| field(labels.detailQty, formatQty(m.materialQty, matUom)), | |||
| field(labels.detailType, labels.nodeMaterialPick), | |||
| field(labels.detailAssignedStep, stepLabel || "—"), | |||
| field(labels.detailAssignedStep, stepLabel || labels.na), | |||
| field(labels.jobOrder, m.jobOrderCode, { | |||
| linkKind: "jo", | |||
| linkCode: m.jobOrderCode, | |||
| linkId: m.jobOrderId, | |||
| }), | |||
| ...pickStatusDetailFields(labels, m.processingStatus, m.matchStatus), | |||
| field(labels.detailTime, m.pickedAt), | |||
| ...pickStatusDetailFields(labels, m.processingStatus, m.matchStatus, { always: true }), | |||
| field(labels.detailTime, m.pickedAt?.trim() || labels.na), | |||
| ], | |||
| ...pickStatusNodeLabels(labels, m.processingStatus, m.matchStatus, { always: true }), | |||
| }; | |||
| }; | |||
| /** Pick-order line with no lot stock-out yet (or N/A stock types like oil/water). */ | |||
| export const createMaterialPickNodeFromPickLine = ( | |||
| line: ItemLotTraceJoPickLine, | |||
| index: number, | |||
| labels: MaterialPickLabels, | |||
| ctx: MaterialPickNodeContext & { | |||
| pickOrderCode: string; | |||
| pickOrderId: number | null; | |||
| consoCode: string; | |||
| jobOrderCode: string; | |||
| jobOrderId?: number | null; | |||
| timestamp?: string | null; | |||
| }, | |||
| feedsProductionScopeLotNo?: string, | |||
| ): TraceGraphNode => { | |||
| const stockUom = line.stockUom?.trim() || ""; | |||
| const stockApplicable = | |||
| line.stockStatusApplicable !== false && isPickStockApplicable(line.itemType); | |||
| const stepName = line.assignedStepName?.trim() || ""; | |||
| const stepLabel = stepLabelOf(stepName, line.bomProcessSeqNo); | |||
| const itemLabel = [line.itemCode, line.itemName].filter(Boolean).join(" · "); | |||
| const pickTargetDate = normalizeTargetDateForLink( | |||
| ctx.pickOrderTargetDate?.(ctx.pickOrderCode ?? ""), | |||
| ); | |||
| const linkTargetDate = pickTargetDate || normalizeTargetDateForLink(ctx.timestamp); | |||
| const requiredQty = Number(line.requiredQty) || 0; | |||
| return { | |||
| id: `mat-pick-line-${index}-${ctx.pickOrderId}-${line.pickOrderLineId}`, | |||
| kind: "MATERIAL_PICK", | |||
| timestamp: ctx.timestamp ?? null, | |||
| sortKey: parseSortKey(ctx.timestamp, ctx.nextSeq()), | |||
| title: ctx.pickOrderCode || labels.nodeMaterialPick, | |||
| subtitle: [labels.pendingPick, stepLabel].filter(Boolean).join(" · "), | |||
| qty: Number(line.pickedQty) || 0, | |||
| uom: stockUom, | |||
| meta: itemLabel || undefined, | |||
| refCode: ctx.pickOrderCode, | |||
| refId: ctx.pickOrderId, | |||
| consoCode: ctx.consoCode, | |||
| jobOrderCode: ctx.jobOrderCode?.trim() || undefined, | |||
| docLinkKind: ctx.pickOrderCode ? "jodetail" : undefined, | |||
| docLinkTargetDate: linkTargetDate, | |||
| traceItemCode: line.itemCode, | |||
| bomProcessId: line.bomProcessId, | |||
| bomProcessSeqNo: line.bomProcessSeqNo, | |||
| assignedStepName: stepName || undefined, | |||
| feedsProductionScopeLotNo, | |||
| categoryLabel: labels.categoryPick, | |||
| stockAvailableLabel: formatMaybeNaQty( | |||
| labels, | |||
| stockApplicable, | |||
| line.stockAvailable, | |||
| stockUom, | |||
| ), | |||
| stockStatusLabel: formatStockStatus( | |||
| labels, | |||
| stockApplicable, | |||
| line.stockAvailable, | |||
| requiredQty, | |||
| ), | |||
| bomReqQtyLabel: | |||
| line.bomReqQty != null ? formatQty(line.bomReqQty, line.bomUom || stockUom) : labels.na, | |||
| stockReqQtyLabel: formatQty(requiredQty, stockUom), | |||
| processingStatus: "pending", | |||
| processingStatusLabel: labels.pendingPick, | |||
| details: [ | |||
| field(labels.pickOrder, ctx.pickOrderCode, { | |||
| linkKind: "jodetail", | |||
| linkCode: ctx.pickOrderCode, | |||
| linkId: ctx.pickOrderId, | |||
| consoCode: ctx.consoCode, | |||
| linkTargetDate, | |||
| }), | |||
| field(labels.detailPickTargetDate, pickTargetDate || labels.na), | |||
| field(labels.detailMaterial, itemLabel), | |||
| field(labels.detailLot, labels.na), | |||
| field( | |||
| labels.detailBomReqQty, | |||
| line.bomReqQty != null ? formatQty(line.bomReqQty, line.bomUom || stockUom) : labels.na, | |||
| ), | |||
| field(labels.detailStockReqQty, formatQty(requiredQty, stockUom)), | |||
| field( | |||
| labels.detailStockAvailable, | |||
| formatMaybeNaQty(labels, stockApplicable, line.stockAvailable, stockUom), | |||
| ), | |||
| field( | |||
| labels.detailStockStatus, | |||
| formatStockStatus(labels, stockApplicable, line.stockAvailable, requiredQty), | |||
| ), | |||
| field(labels.detailQty, formatQty(Number(line.pickedQty) || 0, stockUom)), | |||
| field(labels.detailType, labels.nodeMaterialPick), | |||
| field(labels.detailAssignedStep, stepLabel || labels.na), | |||
| field(labels.jobOrder, ctx.jobOrderCode, { | |||
| linkKind: "jo", | |||
| linkCode: ctx.jobOrderCode, | |||
| linkId: ctx.jobOrderId, | |||
| }), | |||
| field(labels.processingStatus, labels.pendingPick, { valueColor: "warning" }), | |||
| field(labels.detailTime, labels.na), | |||
| ], | |||
| ...pickStatusNodeLabels(labels, m.processingStatus, m.matchStatus), | |||
| }; | |||
| }; | |||
| @@ -17,9 +17,18 @@ import { | |||
| IconButton, | |||
| Collapse, | |||
| Link as MuiLink, | |||
| ToggleButton, | |||
| ToggleButtonGroup, | |||
| FormControl, | |||
| InputLabel, | |||
| Select, | |||
| MenuItem, | |||
| Chip, | |||
| Button, | |||
| } from "@mui/material"; | |||
| import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; | |||
| import ExpandLessIcon from "@mui/icons-material/ExpandLess"; | |||
| import FileDownloadIcon from "@mui/icons-material/FileDownload"; | |||
| import NextLink from "next/link"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import dayjs from "dayjs"; | |||
| @@ -29,10 +38,25 @@ import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | |||
| import { | |||
| fetchDrinkProductionQty, | |||
| DrinkProductionQtyResponse, | |||
| DrinkProductionQtyJobOrderDetail, | |||
| } from "@/app/api/jo/actions"; | |||
| import { arrayToDayjs } from "@/app/utils/formatUtil"; | |||
| import { exportDrinkProductionQtyXlsx } from "@/components/ProductionProcess/exportDrinkProductionQtyXlsx"; | |||
| const REFRESH_INTERVAL = 10 * 60 * 1000; // 10 分鐘 | |||
| const JO_STATUS_FILTER_VALUES = [ | |||
| "planning", | |||
| "pending", | |||
| "packaging", | |||
| "processing", | |||
| "pendingQC", | |||
| "storing", | |||
| "completed", | |||
| ] as const; | |||
| type DrinkViewMode = "actual" | "planned"; | |||
| const formatQty = (qty: number | null | undefined): string => { | |||
| if (qty === null || qty === undefined || Number.isNaN(qty)) return "-"; | |||
| return qty.toLocaleString(undefined, { maximumFractionDigits: 2 }); | |||
| @@ -44,6 +68,35 @@ const formatProductionDate = (value: string | null | undefined): string => { | |||
| return parsed.isValid() ? parsed.format("YYYY-MM-DD") : value; | |||
| }; | |||
| const parseProcessTime = (timeData: unknown): Dayjs | null => { | |||
| if (timeData == null) return null; | |||
| if (Array.isArray(timeData)) { | |||
| try { | |||
| const parsed = arrayToDayjs(timeData, true); | |||
| return parsed.isValid() ? parsed : null; | |||
| } catch { | |||
| return null; | |||
| } | |||
| } | |||
| if (typeof timeData === "string") { | |||
| const parsed = dayjs(timeData); | |||
| return parsed.isValid() ? parsed : null; | |||
| } | |||
| return null; | |||
| }; | |||
| const ProcessSummaryTimeText: React.FC<{ value: unknown }> = ({ value }) => { | |||
| const d = parseProcessTime(value); | |||
| if (!d) return <>—</>; | |||
| return ( | |||
| <> | |||
| <span style={{ color: "green" }}>{d.format("MM-DD")}</span> | |||
| {" "} | |||
| <span style={{ color: "blue" }}>{d.format("HH:mm")}</span> | |||
| </> | |||
| ); | |||
| }; | |||
| const getRowKey = (row: DrinkProductionQtyResponse, idx: number): string => | |||
| `${row.itemCode || "unknown"}-${idx}`; | |||
| @@ -53,6 +106,8 @@ const DrinkProductionQtyDashboard: React.FC = () => { | |||
| const [data, setData] = useState<DrinkProductionQtyResponse[]>([]); | |||
| const [loading, setLoading] = useState<boolean>(true); | |||
| const [selectedDate, setSelectedDate] = useState<Dayjs>(dayjs()); | |||
| const [joStatusFilter, setJoStatusFilter] = useState<string>(""); | |||
| const [viewMode, setViewMode] = useState<DrinkViewMode>("actual"); | |||
| const [expandedRowKeys, setExpandedRowKeys] = useState<Set<string>>( | |||
| new Set(), | |||
| ); | |||
| @@ -61,11 +116,14 @@ const DrinkProductionQtyDashboard: React.FC = () => { | |||
| null, | |||
| ); | |||
| const isPlanned = viewMode === "planned"; | |||
| const loadData = useCallback(async () => { | |||
| setLoading(true); | |||
| try { | |||
| const result = await fetchDrinkProductionQty( | |||
| selectedDate.format("YYYY-MM-DD"), | |||
| viewMode, | |||
| ); | |||
| setData(result || []); | |||
| setExpandedRowKeys(new Set()); | |||
| @@ -78,7 +136,7 @@ const DrinkProductionQtyDashboard: React.FC = () => { | |||
| } finally { | |||
| setLoading(false); | |||
| } | |||
| }, [selectedDate]); | |||
| }, [selectedDate, viewMode]); | |||
| useEffect(() => { | |||
| loadData(); | |||
| @@ -100,6 +158,38 @@ const DrinkProductionQtyDashboard: React.FC = () => { | |||
| }); | |||
| }; | |||
| const filterJobOrders = ( | |||
| jobOrders: DrinkProductionQtyJobOrderDetail[], | |||
| ): DrinkProductionQtyJobOrderDetail[] => { | |||
| if (!joStatusFilter) return jobOrders; | |||
| return jobOrders.filter((jo) => jo.jobOrderStatus === joStatusFilter); | |||
| }; | |||
| const renderActualEnd = (jo: DrinkProductionQtyJobOrderDetail) => { | |||
| const start = parseProcessTime(jo.startTime); | |||
| const end = parseProcessTime(jo.actualEndTime); | |||
| if (end) return <ProcessSummaryTimeText value={jo.actualEndTime} />; | |||
| if (start) { | |||
| return ( | |||
| <Typography variant="body2" color="info.main" component="span"> | |||
| {t("In progress")} | |||
| </Typography> | |||
| ); | |||
| } | |||
| return <>—</>; | |||
| }; | |||
| const renderJoStatusChip = (status: string | null | undefined) => { | |||
| if (!status) return <>—</>; | |||
| return ( | |||
| <Chip | |||
| size="small" | |||
| label={t(status, { ns: "jo", defaultValue: status })} | |||
| sx={{ height: 22 }} | |||
| /> | |||
| ); | |||
| }; | |||
| return ( | |||
| <Card sx={{ mb: 2 }}> | |||
| <CardContent> | |||
| @@ -107,7 +197,11 @@ const DrinkProductionQtyDashboard: React.FC = () => { | |||
| {t("Drink Production Qty Dashboard")} | |||
| </Typography> | |||
| <Stack direction="row" spacing={2} sx={{ mb: 3, alignItems: "center" }}> | |||
| <Stack | |||
| direction="row" | |||
| spacing={2} | |||
| sx={{ mb: 2, flexWrap: "wrap", alignItems: "center", gap: 1 }} | |||
| > | |||
| <LocalizationProvider dateAdapter={AdapterDayjs}> | |||
| <DatePicker | |||
| label={t("Date")} | |||
| @@ -122,8 +216,51 @@ const DrinkProductionQtyDashboard: React.FC = () => { | |||
| /> | |||
| </LocalizationProvider> | |||
| <FormControl size="small" sx={{ minWidth: 180 }}> | |||
| <InputLabel id="drink-jo-status-filter-label"> | |||
| {t("Job Order Status")} | |||
| </InputLabel> | |||
| <Select | |||
| labelId="drink-jo-status-filter-label" | |||
| id="drink-jo-status-filter" | |||
| label={t("Job Order Status")} | |||
| value={joStatusFilter} | |||
| onChange={(e) => { | |||
| setJoStatusFilter(String(e.target.value)); | |||
| }} | |||
| > | |||
| <MenuItem value=""> | |||
| <em>{t("All")}</em> | |||
| </MenuItem> | |||
| {JO_STATUS_FILTER_VALUES.map((v) => ( | |||
| <MenuItem key={v} value={v}> | |||
| {t(v, { ns: "jo" })} | |||
| </MenuItem> | |||
| ))} | |||
| </Select> | |||
| </FormControl> | |||
| <Box sx={{ flexGrow: 1 }} /> | |||
| <Button | |||
| variant="outlined" | |||
| size="small" | |||
| startIcon={<FileDownloadIcon />} | |||
| disabled={loading || data.length === 0} | |||
| sx={{ display: "none" }} | |||
| onClick={() => { | |||
| exportDrinkProductionQtyXlsx({ | |||
| data, | |||
| viewMode, | |||
| selectedDate: selectedDate.format("YYYY-MM-DD"), | |||
| statusFilter: joStatusFilter, | |||
| t, | |||
| }); | |||
| }} | |||
| > | |||
| {t("Export Excel")} | |||
| </Button> | |||
| <Typography | |||
| variant="body2" | |||
| sx={{ color: "text.secondary" }} | |||
| @@ -137,6 +274,33 @@ const DrinkProductionQtyDashboard: React.FC = () => { | |||
| </Typography> | |||
| </Stack> | |||
| <Stack | |||
| direction="row" | |||
| alignItems="center" | |||
| spacing={1} | |||
| sx={{ mb: 2, flexWrap: "wrap", gap: 1 }} | |||
| > | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("Drink detail mode label")} | |||
| </Typography> | |||
| <ToggleButtonGroup | |||
| value={viewMode} | |||
| exclusive | |||
| size="small" | |||
| color="primary" | |||
| onChange={(_, value: DrinkViewMode | null) => { | |||
| if (value != null) setViewMode(value); | |||
| }} | |||
| > | |||
| <ToggleButton value="actual"> | |||
| {t("Drink detail mode: actual")} | |||
| </ToggleButton> | |||
| <ToggleButton value="planned"> | |||
| {t("Drink detail mode: planned")} | |||
| </ToggleButton> | |||
| </ToggleButtonGroup> | |||
| </Stack> | |||
| {loading ? ( | |||
| <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}> | |||
| <CircularProgress /> | |||
| @@ -181,14 +345,18 @@ const DrinkProductionQtyDashboard: React.FC = () => { | |||
| {t("Unit")} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell align="right" sx={{ width: 120 }}> | |||
| <TableCell align="right" sx={{ width: 140 }}> | |||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | |||
| {t("Stock Req. Qty")} | |||
| {isPlanned | |||
| ? t("Planned Output Qty") | |||
| : t("Stock Req. Qty")} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell align="right" sx={{ width: 140 }}> | |||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | |||
| {t("Production Qty")} | |||
| {isPlanned | |||
| ? t("Actual Output Qty") | |||
| : t("Production Qty")} | |||
| </Typography> | |||
| </TableCell> | |||
| </TableRow> | |||
| @@ -208,15 +376,18 @@ const DrinkProductionQtyDashboard: React.FC = () => { | |||
| ) : ( | |||
| data.map((row, idx) => { | |||
| const rowKey = getRowKey(row, idx); | |||
| const jobOrders = row.jobOrders ?? []; | |||
| const allJobOrders = row.jobOrders ?? []; | |||
| const jobOrders = filterJobOrders(allJobOrders); | |||
| const isExpanded = expandedRowKeys.has(rowKey); | |||
| const hasJobOrders = jobOrders.length > 0; | |||
| const hasExpandable = | |||
| allJobOrders.length > 0 && | |||
| (joStatusFilter === "" || jobOrders.length > 0); | |||
| return ( | |||
| <React.Fragment key={rowKey}> | |||
| <TableRow hover={hasJobOrders}> | |||
| <TableRow hover={hasExpandable}> | |||
| <TableCell padding="checkbox"> | |||
| {hasJobOrders ? ( | |||
| {hasExpandable ? ( | |||
| <IconButton | |||
| size="small" | |||
| aria-label={ | |||
| @@ -260,7 +431,7 @@ const DrinkProductionQtyDashboard: React.FC = () => { | |||
| </Typography> | |||
| </TableCell> | |||
| </TableRow> | |||
| {hasJobOrders && ( | |||
| {hasExpandable && ( | |||
| <TableRow> | |||
| <TableCell | |||
| colSpan={6} | |||
| @@ -272,63 +443,185 @@ const DrinkProductionQtyDashboard: React.FC = () => { | |||
| 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 || "-" | |||
| )} | |||
| {jobOrders.length === 0 ? ( | |||
| <Typography | |||
| variant="body2" | |||
| color="text.secondary" | |||
| sx={{ py: 1 }} | |||
| > | |||
| {t("No matching job orders for status")} | |||
| </Typography> | |||
| ) : ( | |||
| <Table size="small"> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell sx={{ fontWeight: 600 }}> | |||
| {t("Job Order Code")} | |||
| </TableCell> | |||
| <TableCell> | |||
| {formatProductionDate( | |||
| jo.productionDate, | |||
| )} | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| {formatQty(jo.reqQty)} | |||
| <TableCell sx={{ fontWeight: 600 }}> | |||
| {t("Job Order Status")} | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| {formatQty(jo.productionQty)} | |||
| <TableCell sx={{ fontWeight: 600 }}> | |||
| {t("Production Date")} | |||
| </TableCell> | |||
| {isPlanned ? ( | |||
| <> | |||
| <TableCell | |||
| align="right" | |||
| sx={{ fontWeight: 600 }} | |||
| > | |||
| {t("Assume Time Need")} | |||
| </TableCell> | |||
| <TableCell | |||
| sx={{ fontWeight: 600 }} | |||
| > | |||
| {t("Latest Start By")} | |||
| </TableCell> | |||
| <TableCell | |||
| sx={{ fontWeight: 600 }} | |||
| > | |||
| {t("Start Time")} | |||
| </TableCell> | |||
| <TableCell | |||
| sx={{ fontWeight: 600 }} | |||
| > | |||
| {t("Assume End Time")} | |||
| </TableCell> | |||
| <TableCell | |||
| sx={{ fontWeight: 600 }} | |||
| > | |||
| {t("Actual End Time")} | |||
| </TableCell> | |||
| <TableCell | |||
| align="right" | |||
| sx={{ fontWeight: 600 }} | |||
| > | |||
| {t("Planned Output Qty")} | |||
| </TableCell> | |||
| <TableCell | |||
| align="right" | |||
| sx={{ fontWeight: 600 }} | |||
| > | |||
| {t("Actual Output Qty")} | |||
| </TableCell> | |||
| </> | |||
| ) : ( | |||
| <> | |||
| <TableCell | |||
| sx={{ fontWeight: 600 }} | |||
| > | |||
| {t("Start Time")} | |||
| </TableCell> | |||
| <TableCell | |||
| sx={{ fontWeight: 600 }} | |||
| > | |||
| {t("Actual End Time")} | |||
| </TableCell> | |||
| <TableCell | |||
| align="right" | |||
| sx={{ fontWeight: 600 }} | |||
| > | |||
| {t("Stock Req. Qty")} | |||
| </TableCell> | |||
| <TableCell | |||
| align="right" | |||
| sx={{ fontWeight: 600 }} | |||
| > | |||
| {t("Production Qty")} | |||
| </TableCell> | |||
| </> | |||
| )} | |||
| </TableRow> | |||
| ))} | |||
| </TableBody> | |||
| </Table> | |||
| </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> | |||
| {renderJoStatusChip( | |||
| jo.jobOrderStatus, | |||
| )} | |||
| </TableCell> | |||
| <TableCell> | |||
| {formatProductionDate( | |||
| jo.productionDate, | |||
| )} | |||
| </TableCell> | |||
| {isPlanned ? ( | |||
| <> | |||
| <TableCell align="right"> | |||
| <Typography | |||
| variant="body2" | |||
| component="span" | |||
| sx={{ color: "blue" }} | |||
| > | |||
| {jo.assumeTimeNeedMins ?? 0}{" "} | |||
| {t("minutes")} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell> | |||
| <ProcessSummaryTimeText | |||
| value={jo.latestStartBy} | |||
| /> | |||
| </TableCell> | |||
| <TableCell> | |||
| <ProcessSummaryTimeText | |||
| value={jo.startTime} | |||
| /> | |||
| </TableCell> | |||
| <TableCell> | |||
| <ProcessSummaryTimeText | |||
| value={jo.assumeEndTime} | |||
| /> | |||
| </TableCell> | |||
| <TableCell> | |||
| {renderActualEnd(jo)} | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| {formatQty(jo.reqQty)} | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| {formatQty(jo.productionQty)} | |||
| </TableCell> | |||
| </> | |||
| ) : ( | |||
| <> | |||
| <TableCell> | |||
| <ProcessSummaryTimeText | |||
| value={jo.startTime} | |||
| /> | |||
| </TableCell> | |||
| <TableCell> | |||
| {renderActualEnd(jo)} | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| {formatQty(jo.reqQty)} | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| {formatQty(jo.productionQty)} | |||
| </TableCell> | |||
| </> | |||
| )} | |||
| </TableRow> | |||
| ))} | |||
| </TableBody> | |||
| </Table> | |||
| )} | |||
| </Box> | |||
| </Collapse> | |||
| </TableCell> | |||
| @@ -223,7 +223,7 @@ interface JobOrderOpsTableProps { | |||
| printerCombo?: PrinterCombo[]; | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 47 | v1.0.4 | 2026-08-06 */ | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 47 | v1.0.5 | 2026-08-06 */ | |||
| const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | |||
| onSelectProcess, | |||
| printerCombo = [], | |||
| @@ -232,10 +232,9 @@ const JobOrderOpsTable: React.FC<JobOrderOpsTableProps> = ({ | |||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | |||
| const sessionToken = session as SessionWithTokens | null; | |||
| const abilities = session?.abilities ?? session?.user?.abilities ?? []; | |||
| /** 取消工單:僅 ADMIN */ | |||
| /** 取消工單 / 完成工單:僅 ADMIN */ | |||
| const canCancel = hasAbility(abilities, AUTH.ADMIN); | |||
| /** 完成工單:僅 PRODUCT_PROCESS(工單 生產流程 完成工單) */ | |||
| const canComplete = hasAbility(abilities, AUTH.PRODUCT_PROCESS); | |||
| const canComplete = hasAbility(abilities, AUTH.ADMIN); | |||
| const labelPrinterCombo = useMemo( | |||
| () => (printerCombo || []).filter((p) => p.type === "Label"), | |||
| @@ -53,7 +53,7 @@ interface ProductProcessJobOrderDetailProps { | |||
| initialTabIndex?: number; | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.5 | 2026-08-06 */ | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.7 | 2026-08-06 */ | |||
| const ProductionProcessJobOrderDetail: React.FC<ProductProcessJobOrderDetailProps> = ({ | |||
| jobOrderId, | |||
| onBack, | |||
| @@ -178,7 +178,7 @@ function isWaitingQcPutAway( | |||
| return s !== "completed" && s !== "rejected"; | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.5 | 2026-08-06 */ | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.8 | 2026-08-09 */ | |||
| const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| onSelectProcess, | |||
| printerCombo, | |||
| @@ -209,8 +209,8 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| const [modalInfo, setModalInfo] = useState<StockInLineInput>(); | |||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | |||
| const abilities = session?.abilities ?? session?.user?.abilities ?? []; | |||
| /** 完成工單:僅 PRODUCT_PROCESS(工單 生產流程 完成工單);不含 ADMIN 以免群組 ADMIN 繞過勾選 */ | |||
| const canManageUpdateJo = hasAbility(abilities, AUTH.PRODUCT_PROCESS); | |||
| /** 完成工單:僅 ADMIN */ | |||
| const canManageUpdateJo = hasAbility(abilities, AUTH.ADMIN); | |||
| type ProcessFilter = "all" | "drink" | "Powder_Mixture" | "other"; | |||
| const listTab = normalizeListTab(listPersistedState.pickBucket); | |||
| @@ -447,7 +447,7 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| return productionCache; | |||
| }, [listTab, productionCache, pendingQcCache, putawayedCache, offPlanRows]); | |||
| // QC 的业务判定:同一个 jobOrder 下,所有 productProcess 的所有 lines 都必须是 Completed/Pass | |||
| // QC ready: same JO — all lines Completed/Pass (sibling 包裝 auto-Pass on backend) + has SIL | |||
| const jobOrderQcReadyById = useMemo(() => { | |||
| const byJobOrder = new Map<number, AllJoborderProductProcessInfoResponse[]>(); | |||
| for (const p of tabProcesses) { | |||
| @@ -465,31 +465,11 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| byJobOrder.forEach((jobOrderProcesses, jobOrderId) => { | |||
| const hasStockInLine = jobOrderProcesses.some((p) => p.stockInLineId != null); | |||
| const allLines = jobOrderProcesses.flatMap((p) => p.lines ?? []); | |||
| const allLinesDone = | |||
| allLines.length > 0 && allLines.every((l) => isDone(l.status)); | |||
| const packingProcesses = jobOrderProcesses.filter( | |||
| (p) => String((p as any).code ?? "").trim() === "包裝", | |||
| ); | |||
| const nonPackingProcesses = jobOrderProcesses.filter( | |||
| (p) => String((p as any).code ?? "").trim() !== "包裝", | |||
| ); | |||
| const allNonPackingDone = | |||
| nonPackingProcesses.length === 0 || | |||
| nonPackingProcesses.every((p) => { | |||
| const lines = p.lines ?? []; | |||
| return lines.length > 0 && lines.every((l) => isDone(l.status)); | |||
| }); | |||
| const hasOnePackingDone = | |||
| packingProcesses.length > 0 && | |||
| packingProcesses.some((p) => { | |||
| const lines = p.lines ?? []; | |||
| return lines.some((l) => isDone(l.status)); | |||
| }); | |||
| const packingOk = packingProcesses.length === 0 ? true : hasOnePackingDone; | |||
| result.set(jobOrderId, hasStockInLine && allNonPackingDone && packingOk); | |||
| result.set(jobOrderId, hasStockInLine && allLinesDone); | |||
| }); | |||
| return result; | |||
| @@ -1230,20 +1210,17 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| {t("Matching Stock")} | |||
| </Button> | |||
| {statusLower !== "completed" && ( | |||
| {canManageUpdateJo && statusLower !== "completed" && ( | |||
| <Button | |||
| variant="contained" | |||
| size="small" | |||
| disabled={!canManageUpdateJo} | |||
| onClick={() => | |||
| canManageUpdateJo | |||
| ? openConfirm( | |||
| t("Confirm to update this Job Order?"), | |||
| async () => { | |||
| await handleUpdateJo(process); | |||
| }, | |||
| ) | |||
| : undefined | |||
| openConfirm( | |||
| t("Confirm to update this Job Order?"), | |||
| async () => { | |||
| await handleUpdateJo(process); | |||
| }, | |||
| ) | |||
| } | |||
| > | |||
| {t("Update Job Order")} | |||
| @@ -17,6 +17,7 @@ import DrinkProductionQtyDashboard from "@/components/ProductionProcess/DrinkPro | |||
| import JobOrderOpsTable from "@/components/ProductionProcess/JobOrderOpsTable"; | |||
| import type { PrinterCombo } from "@/app/api/settings/printer"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { AUTH, hasAbility } from "@/authorities"; | |||
| interface ProductionProcessPageProps { | |||
| printerCombo: PrinterCombo[]; | |||
| @@ -24,9 +25,17 @@ interface ProductionProcessPageProps { | |||
| const STORAGE_KEY = 'productionProcess_selectedMatchingStock'; | |||
| type PageTab = | |||
| | "list" | |||
| | "ops" | |||
| | "jobProcessStatus" | |||
| | "operatorKpi" | |||
| | "equipmentStatus" | |||
| | "drinkQty"; | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 26 | v1.0.0 | 2026-07-20 | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 47 | v1.0.4 | 2026-08-06 | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 47 | v1.0.5 | 2026-08-06 | |||
| */ | |||
| const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCombo }) => { | |||
| const { t } = useTranslation(["common", "productionProcess"]); | |||
| @@ -36,19 +45,27 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||
| productProcessId: number; | |||
| pickOrderId: number; | |||
| } | null>(null); | |||
| /** 0 = Production Process list; 1 = JO ops table; 2..5 = dashboards */ | |||
| const [tabIndex, setTabIndex] = useState(0); | |||
| const [pageTab, setPageTab] = useState<PageTab>("list"); | |||
| const [productionListState, setProductionListState] = useState(() => ({ | |||
| ...createDefaultProductionProcessListPersistedState(), | |||
| })); | |||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | |||
| const sessionToken = session as SessionWithTokens | null; | |||
| const abilities = session?.abilities ?? session?.user?.abilities ?? []; | |||
| /** 「查看工單流程情況」僅 ADMIN */ | |||
| const canSeeOpsTable = hasAbility(abilities, AUTH.ADMIN); | |||
| const searchParams = useSearchParams(); | |||
| const pathname = usePathname(); | |||
| const router = useRouter(); | |||
| const [linkQcOpen, setLinkQcOpen] = useState(false); | |||
| const [linkQcSilId, setLinkQcSilId] = useState<number | null>(null); | |||
| useEffect(() => { | |||
| if (!canSeeOpsTable && pageTab === "ops") { | |||
| setPageTab("list"); | |||
| } | |||
| }, [canSeeOpsTable, pageTab]); | |||
| useEffect(() => { | |||
| if (typeof window !== 'undefined') { | |||
| try { | |||
| @@ -83,8 +100,8 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||
| } | |||
| }, []); | |||
| const handleTabChange = useCallback((event: React.SyntheticEvent, newValue: number) => { | |||
| setTabIndex(newValue); | |||
| const handleTabChange = useCallback((_: React.SyntheticEvent, newValue: PageTab) => { | |||
| setPageTab(newValue); | |||
| }, []); | |||
| const openStockInLineIdQ = searchParams.get("openStockInLineId"); | |||
| @@ -100,7 +117,7 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||
| if (!Number.isFinite(id) || id <= 0) return; | |||
| setSelectedProcessId(null); | |||
| setSelectedMatchingStock(null); | |||
| setTabIndex(0); | |||
| setPageTab("list"); | |||
| setProductionListState((prev) => ({ | |||
| ...prev, | |||
| pickBucket: "pending_qc", | |||
| @@ -144,16 +161,18 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||
| return ( | |||
| <> | |||
| <Box> | |||
| <Tabs value={tabIndex} onChange={handleTabChange} sx={{ mb: 2 }}> | |||
| <Tab label={t("Production Process")} /> | |||
| <Tab label={t("Job Order Ops Table")} /> | |||
| <Tab label={t("Job Process Status Dashboard")} /> | |||
| <Tab label={t("Operator KPI Dashboard")} /> | |||
| <Tab label={t("Production Equipment Status Dashboard")} /> | |||
| <Tab label={t("Drink Production Qty Dashboard")} /> | |||
| <Tabs value={pageTab} onChange={handleTabChange} sx={{ mb: 2 }}> | |||
| <Tab value="list" label={t("Production Process")} /> | |||
| {canSeeOpsTable && ( | |||
| <Tab value="ops" label={t("Job Order Ops Table")} /> | |||
| )} | |||
| <Tab value="jobProcessStatus" label={t("Job Process Status Dashboard")} /> | |||
| <Tab value="operatorKpi" label={t("Operator KPI Dashboard")} /> | |||
| <Tab value="equipmentStatus" label={t("Production Equipment Status Dashboard")} /> | |||
| <Tab value="drinkQty" label={t("Drink Production Qty Dashboard")} /> | |||
| </Tabs> | |||
| {tabIndex === 0 && ( | |||
| {pageTab === "list" && ( | |||
| <ProductionProcessList | |||
| printerCombo={printerCombo} | |||
| listPersistedState={productionListState} | |||
| @@ -174,7 +193,7 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||
| /> | |||
| )} | |||
| {tabIndex === 1 && ( | |||
| {pageTab === "ops" && canSeeOpsTable && ( | |||
| <JobOrderOpsTable | |||
| printerCombo={printerCombo} | |||
| onSelectProcess={(jobOrderId) => { | |||
| @@ -183,16 +202,16 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||
| /> | |||
| )} | |||
| {tabIndex === 2 && ( | |||
| {pageTab === "jobProcessStatus" && ( | |||
| <JobProcessStatus /> | |||
| )} | |||
| {tabIndex === 3 && ( | |||
| {pageTab === "operatorKpi" && ( | |||
| <OperatorKpiDashboard /> | |||
| )} | |||
| {tabIndex === 4 && ( | |||
| {pageTab === "equipmentStatus" && ( | |||
| <EquipmentStatusDashboard /> | |||
| )} | |||
| {tabIndex === 5 && ( | |||
| {pageTab === "drinkQty" && ( | |||
| <DrinkProductionQtyDashboard /> | |||
| )} | |||
| </Box> | |||
| @@ -0,0 +1,141 @@ | |||
| import dayjs from "dayjs"; | |||
| import type { TFunction } from "i18next"; | |||
| import { exportMultiSheetToXlsx } from "@/app/(main)/chart/_components/exportChartToXlsx"; | |||
| import type { | |||
| DrinkProductionQtyJobOrderDetail, | |||
| DrinkProductionQtyResponse, | |||
| } from "@/app/api/jo/actions"; | |||
| type DrinkViewMode = "actual" | "planned"; | |||
| const formatDateTime = (value: unknown): string => { | |||
| if (value == null || value === "") return ""; | |||
| if (Array.isArray(value)) { | |||
| const [y, m, d, h = 0, min = 0] = value as number[]; | |||
| if (y == null || m == null || d == null) return ""; | |||
| return dayjs(new Date(y, m - 1, d, h, min)).format("YYYY-MM-DD HH:mm"); | |||
| } | |||
| const parsed = dayjs(value as string); | |||
| return parsed.isValid() ? parsed.format("YYYY-MM-DD HH:mm") : String(value); | |||
| }; | |||
| const formatDate = (value: string | null | undefined): string => { | |||
| if (!value) return ""; | |||
| const parsed = dayjs(value); | |||
| return parsed.isValid() ? parsed.format("YYYY-MM-DD") : value; | |||
| }; | |||
| export type ExportDrinkProductionQtyParams = { | |||
| data: DrinkProductionQtyResponse[]; | |||
| viewMode: DrinkViewMode; | |||
| selectedDate: string; | |||
| statusFilter: string; | |||
| t: TFunction; | |||
| }; | |||
| export function exportDrinkProductionQtyXlsx({ | |||
| data, | |||
| viewMode, | |||
| selectedDate, | |||
| statusFilter, | |||
| t, | |||
| }: ExportDrinkProductionQtyParams): void { | |||
| const viewLabel = | |||
| viewMode === "planned" | |||
| ? t("Drink detail mode: planned") | |||
| : t("Drink detail mode: actual"); | |||
| const statusFilterLabel = statusFilter | |||
| ? t(statusFilter, { ns: "jo", defaultValue: statusFilter }) | |||
| : t("All"); | |||
| const exportedAt = dayjs().format("YYYY-MM-DD HH:mm:ss"); | |||
| const filterJos = ( | |||
| jobOrders: DrinkProductionQtyJobOrderDetail[], | |||
| ): DrinkProductionQtyJobOrderDetail[] => { | |||
| if (!statusFilter) return jobOrders; | |||
| return jobOrders.filter((jo) => jo.jobOrderStatus === statusFilter); | |||
| }; | |||
| const joRows: Record<string, unknown>[] = []; | |||
| const stepRows: Record<string, unknown>[] = []; | |||
| for (const item of data) { | |||
| for (const jo of filterJos(item.jobOrders ?? [])) { | |||
| joRows.push({ | |||
| [t("Export meta: exported at")]: exportedAt, | |||
| [t("Drink detail mode label")]: viewLabel, | |||
| [t("Date")]: selectedDate, | |||
| [t("Job Order Status") + ` (${t("Filter")})`]: statusFilterLabel, | |||
| [t("Item Code")]: item.itemCode ?? "", | |||
| [t("Goods Name")]: item.itemName ?? "", | |||
| [t("Unit")]: item.uom ?? "", | |||
| [t("Job Order Code")]: jo.jobOrderCode ?? "", | |||
| [t("Job Order Status")]: jo.jobOrderStatus | |||
| ? t(jo.jobOrderStatus, { ns: "jo", defaultValue: jo.jobOrderStatus }) | |||
| : "", | |||
| [t("Production Date")]: formatDate(jo.productionDate), | |||
| [viewMode === "planned" | |||
| ? t("Planned Output Qty") | |||
| : t("Stock Req. Qty")]: jo.reqQty ?? 0, | |||
| [viewMode === "planned" | |||
| ? t("Actual Output Qty") | |||
| : t("Production Qty")]: jo.productionQty ?? 0, | |||
| [t("Assume Time Need")]: jo.assumeTimeNeedMins ?? 0, | |||
| [t("Latest Start By")]: formatDateTime(jo.latestStartBy), | |||
| [t("Start Time")]: formatDateTime(jo.startTime), | |||
| [t("Assume End Time")]: formatDateTime(jo.assumeEndTime), | |||
| [t("Actual End Time")]: formatDateTime(jo.actualEndTime), | |||
| [t("Process operators")]: jo.processOperators ?? "", | |||
| [t("Process handlers")]: jo.processHandlers ?? "", | |||
| [t("QC users")]: jo.qcUsers ?? "", | |||
| [t("Put away users")]: jo.putAwayUsers ?? "", | |||
| }); | |||
| for (const step of jo.processSteps ?? []) { | |||
| stepRows.push({ | |||
| [t("Export meta: exported at")]: exportedAt, | |||
| [t("Drink detail mode label")]: viewLabel, | |||
| [t("Date")]: selectedDate, | |||
| [t("Item Code")]: step.itemCode ?? item.itemCode ?? "", | |||
| [t("Goods Name")]: step.itemName ?? item.itemName ?? "", | |||
| [t("Job Order Code")]: step.jobOrderCode ?? jo.jobOrderCode ?? "", | |||
| [t("Process seq")]: step.seqNo ?? "", | |||
| [t("Process Name")]: step.processName ?? "", | |||
| [t("Operator Name & No.")]: step.operatorName ?? "", | |||
| [t("Handler")]: step.handlerName ?? "", | |||
| [t("Start Time")]: formatDateTime(step.startTime), | |||
| [t("End Time")]: formatDateTime(step.endTime), | |||
| [t("Status")]: step.status ?? "", | |||
| }); | |||
| } | |||
| } | |||
| } | |||
| const itemRows = data.map((item) => ({ | |||
| [t("Export meta: exported at")]: exportedAt, | |||
| [t("Drink detail mode label")]: viewLabel, | |||
| [t("Date")]: selectedDate, | |||
| [t("Item Code")]: item.itemCode ?? "", | |||
| [t("Goods Name")]: item.itemName ?? "", | |||
| [t("Unit")]: item.uom ?? "", | |||
| [viewMode === "planned" | |||
| ? t("Planned Output Qty") | |||
| : t("Stock Req. Qty")]: item.totalReqQty ?? 0, | |||
| [viewMode === "planned" | |||
| ? t("Actual Output Qty") | |||
| : t("Production Qty")]: item.totalQty ?? 0, | |||
| [t("JO count")]: filterJos(item.jobOrders ?? []).length, | |||
| })); | |||
| const modeSlug = viewMode === "planned" ? "planned" : "actual"; | |||
| const filename = `DrinkProductionQty_${modeSlug}_${selectedDate}_${dayjs().format("HHmm")}`; | |||
| exportMultiSheetToXlsx( | |||
| [ | |||
| { name: t("Excel sheet: JO detail"), rows: joRows }, | |||
| { name: t("Excel sheet: process people"), rows: stepRows }, | |||
| { name: t("Excel sheet: item summary"), rows: itemRows }, | |||
| ], | |||
| filename, | |||
| ); | |||
| } | |||
| @@ -286,6 +286,12 @@ | |||
| "code.joStatus.storing": "Storing", | |||
| "code.joStatus.PARTIAL": "Partial", | |||
| "code.joStatus.partial": "Partial", | |||
| "code.productionStatus.Pass": "Pass", | |||
| "code.productionStatus.Completed": "Completed", | |||
| "code.productionStatus.Pending": "Pending", | |||
| "code.productionStatus.Paused": "Paused", | |||
| "code.productionStatus.InProgress": "In progress", | |||
| "code.productionStatus.Skip": "Skip", | |||
| "continuousScanBlocked": "Finish current scan first", | |||
| "nodeJoOut": "Job order material issue", | |||
| "nodePoOut": "Purchase pick", | |||
| @@ -317,5 +323,13 @@ | |||
| "direction": "Direction", | |||
| "origins": "Origins", | |||
| "refCode": "Ref.", | |||
| "lastMove": "Last move" | |||
| "lastMove": "Last move", | |||
| "N/A": "N/A", | |||
| "pendingPick": "Pending pick", | |||
| "detailBomReqQty": "BOM req. qty", | |||
| "detailStockReqQty": "Stock req. qty", | |||
| "detailStockAvailable": "Stock available", | |||
| "detailStockStatus": "Stock status", | |||
| "stockStatusSufficient": "Sufficient", | |||
| "stockStatusInsufficient": "Insufficient" | |||
| } | |||
| @@ -80,11 +80,11 @@ | |||
| "Issue": "Issue", | |||
| "Job Order Ops Table": "Job Order Ops Table", | |||
| "Pending (picked)": "Picked", | |||
| "Pending (not picked)": "Not picked", | |||
| "Pending (not picked)": "Pick incomplete", | |||
| "Processing (picked)": "Picked", | |||
| "Processing (not picked)": "Not picked", | |||
| "Processing (not picked)": "Pick incomplete", | |||
| "Picked": "Picked", | |||
| "Not picked": "Not picked", | |||
| "Not picked": "Pick incomplete", | |||
| "Stop (paused)": "Stop (paused)", | |||
| "Cancelled": "Cancelled", | |||
| "Reload data": "Reload data", | |||
| @@ -96,6 +96,27 @@ | |||
| "Drink Production Qty Dashboard": "Drink Production Qty Dashboard", | |||
| "Expand job order details": "Expand job order details", | |||
| "Collapse job order details": "Collapse job order details", | |||
| "Drink detail mode label": "Detail display", | |||
| "Drink detail mode: actual": "Actual production", | |||
| "Drink detail mode: planned": "Planned production", | |||
| "Planned Output Qty": "Planned output", | |||
| "Actual Output Qty": "Actual output", | |||
| "Latest Start By": "Latest start by", | |||
| "Actual End Time": "Actual End Time", | |||
| "Job Order Status": "Job Order Status", | |||
| "No matching job orders for status": "No job orders match the selected status", | |||
| "Export Excel": "Export Excel", | |||
| "Export meta: exported at": "Exported at", | |||
| "Process operators": "Process operators", | |||
| "Process handlers": "Process handlers", | |||
| "QC users": "QC users", | |||
| "Put away users": "Put-away users", | |||
| "Excel sheet: JO detail": "JO detail", | |||
| "Excel sheet: process people": "Process people", | |||
| "Excel sheet: item summary": "Item summary", | |||
| "JO count": "JO count", | |||
| "Process seq": "Process seq", | |||
| "Handler": "Handler", | |||
| "Goods Name": "Goods Name", | |||
| "Job Type": "Job Type", | |||
| "Job process detail mode label": "Job process detail mode label", | |||
| @@ -225,10 +246,10 @@ | |||
| "Off-plan unfinished": "Off-plan unfinished", | |||
| "All unfinished": "Needs action", | |||
| "Needs action": "Needs action", | |||
| "Not picked · Not started": "Not picked · Not started", | |||
| "Not picked · Not started": "Pick incomplete · Not started", | |||
| "Picked · Not started": "Picked · Not started", | |||
| "Picked · In production": "Picked · In production", | |||
| "Not picked · In production": "Not picked · In production", | |||
| "Not picked · In production": "Pick incomplete · In production", | |||
| "Powder_Mixture": "Powder Mixture", | |||
| "Processing": "Processing", | |||
| "Total lines: ": "Total lines: ", | |||
| @@ -283,9 +283,15 @@ | |||
| "code.joStatus.packaging": "提料中", | |||
| "code.joStatus.processing": "生產中", | |||
| "code.joStatus.pendingQC": "待品檢", | |||
| "code.joStatus.storing": "待品檢入倉", | |||
| "code.joStatus.storing": "待QC上架", | |||
| "code.joStatus.PARTIAL": "部分完成", | |||
| "code.joStatus.partial": "部分完成", | |||
| "code.productionStatus.Pass": "通過", | |||
| "code.productionStatus.Completed": "完成", | |||
| "code.productionStatus.Pending": "待處理", | |||
| "code.productionStatus.Paused": "已暫停", | |||
| "code.productionStatus.InProgress": "進行中", | |||
| "code.productionStatus.Skip": "跳過", | |||
| "continuousScanBlocked": "請先完成目前掃描", | |||
| "nodeJoOut": "工單提料", | |||
| "nodePoOut": "採購提料", | |||
| @@ -317,5 +323,13 @@ | |||
| "direction": "方向", | |||
| "origins": "來源", | |||
| "refCode": "單號", | |||
| "lastMove": "最近異動" | |||
| "lastMove": "最近異動", | |||
| "N/A": "不適用", | |||
| "pendingPick": "待提料", | |||
| "detailBomReqQty": "BOM 需求數", | |||
| "detailStockReqQty": "庫存需求數", | |||
| "detailStockAvailable": "庫存數", | |||
| "detailStockStatus": "庫存狀態", | |||
| "stockStatusSufficient": "足夠", | |||
| "stockStatusInsufficient": "不足" | |||
| } | |||
| @@ -85,11 +85,11 @@ | |||
| "Issue": "異常", | |||
| "Job Order Ops Table": "工單生產流程", | |||
| "Pending (picked)": "已提料", | |||
| "Pending (not picked)": "未提料", | |||
| "Pending (not picked)": "未完成提料", | |||
| "Processing (picked)": "已提料", | |||
| "Processing (not picked)": "未提料", | |||
| "Processing (not picked)": "未完成提料", | |||
| "Picked": "已提料", | |||
| "Not picked": "未提料", | |||
| "Not picked": "未完成提料", | |||
| "Stop (paused)": "暫停中", | |||
| "Cancelled": "已取消", | |||
| "Reload data": "重新載入", | |||
| @@ -101,6 +101,27 @@ | |||
| "Drink Production Qty Dashboard": "儀表板 - 飲料生產數量", | |||
| "Expand job order details": "展開工單明細", | |||
| "Collapse job order details": "收合工單明細", | |||
| "Drink detail mode label": "明細顯示", | |||
| "Drink detail mode: actual": "實際生產", | |||
| "Drink detail mode: planned": "預計生產", | |||
| "Planned Output Qty": "預計生產數量", | |||
| "Actual Output Qty": "實際生產數量", | |||
| "Latest Start By": "最晚開工時間", | |||
| "Actual End Time": "實際完成時間", | |||
| "Job Order Status": "工單狀態", | |||
| "No matching job orders for status": "無符合狀態的工單", | |||
| "Export Excel": "匯出 Excel", | |||
| "Export meta: exported at": "匯出時間", | |||
| "Process operators": "工序操作員", | |||
| "Process handlers": "工序處理人", | |||
| "QC users": "QC人員", | |||
| "Put away users": "上架人員", | |||
| "Excel sheet: JO detail": "JO明細", | |||
| "Excel sheet: process people": "工序人員", | |||
| "Excel sheet: item summary": "貨品彙總", | |||
| "JO count": "工單筆數", | |||
| "Process seq": "工序序號", | |||
| "Handler": "處理人", | |||
| "Goods Name": "貨品名稱", | |||
| "Job Type": "工單類型", | |||
| "Job process detail mode label": "工序格顯示", | |||
| @@ -230,10 +251,10 @@ | |||
| "Off-plan unfinished": "未按規劃完成工單", | |||
| "All unfinished": "需處理", | |||
| "Needs action": "需處理", | |||
| "Not picked · Not started": "未提料 · 未開工", | |||
| "Not picked · Not started": "未完成提料 · 未開工", | |||
| "Picked · Not started": "已提料 · 未開工", | |||
| "Picked · In production": "已提料 · 未完成生產", | |||
| "Not picked · In production": "未提料 · 未完成生產", | |||
| "Not picked · In production": "未完成提料 · 未完成生產", | |||
| "Powder_Mixture": "箱料粉", | |||
| "Processing": "生產中", | |||
| "Total lines: ": "總數量:", | |||