From f906a9e747762be94a9f0a53fbe5392d030fee71 Mon Sep 17 00:00:00 2001 From: tommy Date: Mon, 20 Jul 2026 18:44:10 +0800 Subject: [PATCH] item trace , drink produce dashboard --- src/app/api/itemTracing/index.ts | 5 +- src/app/api/jo/actions.ts | 30 ++ .../ItemTracingFlowGraphSearch.tsx | 27 +- .../ItemTracing/ItemTracingSections.tsx | 31 +- src/components/ItemTracing/TraceFlowNodes.tsx | 38 +- .../buildExtendedTraceGraphNodes.ts | 1 + .../ItemTracing/buildTraceGraphNodes.ts | 20 +- src/components/ItemTracing/traceLabelUtils.ts | 13 + .../ItemTracing/traceStockTakeUtils.ts | 13 +- .../DrinkProductionQtyDashboard.tsx | 349 ++++++++++++++++++ .../ProductionProcessPage.tsx | 8 +- src/i18n/en/itemTracing.json | 9 + src/i18n/en/jo.json | 2 + src/i18n/en/productionProcess.json | 5 + src/i18n/zh/itemTracing.json | 8 + src/i18n/zh/jo.json | 3 + src/i18n/zh/productionProcess.json | 5 + 17 files changed, 521 insertions(+), 46 deletions(-) create mode 100644 src/components/ProductionProcess/DrinkProductionQtyDashboard.tsx diff --git a/src/app/api/itemTracing/index.ts b/src/app/api/itemTracing/index.ts index 2ffe85d..fedea25 100644 --- a/src/app/api/itemTracing/index.ts +++ b/src/app/api/itemTracing/index.ts @@ -138,6 +138,7 @@ export interface ItemLotTraceMovement { relationshipId?: number | null; doOutboundIsExtra?: boolean; doOutboundIsReplenish?: boolean; + doOutboundQtyChanged?: boolean; processingStatus?: string; matchStatus?: string; } @@ -199,7 +200,7 @@ export interface ItemLotTraceStockTakeEvent { stockTakeRoundName?: string; varianceQty: number; beforeQty: number; - afterQty: number; + afterQty?: number | null; approver: string; timestamp: string | null; recordDetail?: ItemLotTraceStockTakeRecordDetail | null; @@ -409,6 +410,8 @@ export interface ItemLotTraceDoDelivery { 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 { diff --git a/src/app/api/jo/actions.ts b/src/app/api/jo/actions.ts index cd58bae..02ca263 100644 --- a/src/app/api/jo/actions.ts +++ b/src/app/api/jo/actions.ts @@ -1643,6 +1643,36 @@ 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[]; +} + +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(url, { + method: "GET", + next: { tags: ["drinkProductionQty"] }, + }); +}); + // ===== Equipment Status Dashboard ===== export interface EquipmentStatusProcessInfo { diff --git a/src/components/ItemTracing/ItemTracingFlowGraphSearch.tsx b/src/components/ItemTracing/ItemTracingFlowGraphSearch.tsx index 7d8e93a..bd9d6e9 100644 --- a/src/components/ItemTracing/ItemTracingFlowGraphSearch.tsx +++ b/src/components/ItemTracing/ItemTracingFlowGraphSearch.tsx @@ -91,31 +91,22 @@ const ItemTracingFlowGraphSearch: React.FC = ({ }} sx={{ "& .MuiOutlinedInput-root": { - justifyContent: hasQuery ? "flex-start" : "center", + alignItems: "center", }, "& .MuiOutlinedInput-input": { - textAlign: hasQuery ? "left" : "center", color: "text.secondary", - ...(hasQuery - ? {} - : { - flex: "0 1 auto", - width: "auto", - maxWidth: "90%", - }), + py: "8.5px", + lineHeight: 1.4375, }, "& .MuiOutlinedInput-input::placeholder": { - color: "text.secondary", + color: "text.disabled", opacity: 1, - textAlign: "center", }, - ...(!hasQuery - ? { - "& .MuiInputAdornment-positionStart": { - marginRight: 0.75, - }, - } - : {}), + "& .MuiInputAdornment-root": { + height: "100%", + maxHeight: "none", + alignItems: "center", + }, }} /> {hasQuery && ( diff --git a/src/components/ItemTracing/ItemTracingSections.tsx b/src/components/ItemTracing/ItemTracingSections.tsx index fe2b9d8..b820638 100644 --- a/src/components/ItemTracing/ItemTracingSections.tsx +++ b/src/components/ItemTracing/ItemTracingSections.tsx @@ -459,11 +459,6 @@ const ItemTracingSections: React.FC = ({ data, compiledGraph, onTrace }) align: "right", value: (row) => formatQty(row.qty, stockUom), }, - { - key: "ref", - label: t("ref"), - value: (row) => row.transferCode, - }, { key: "timestamp", label: t("timestamp"), @@ -485,8 +480,8 @@ const ItemTracingSections: React.FC = ({ data, compiledGraph, onTrace }) ), }, - { key: "material", label: t("material"), value: (m) => m.materialItemCode }, - { key: "materialLot", label: t("materialLot"), value: (m) => m.materialLotNo }, + { key: "material", label: t("itemCode"), value: (m) => m.materialItemCode }, + { key: "materialLot", label: t("itemLot"), value: (m) => m.materialLotNo }, { key: "pickOrder", label: t("pickOrder"), @@ -579,8 +574,8 @@ const ItemTracingSections: React.FC = ({ data, compiledGraph, onTrace }) ), }, - { key: "material", label: t("material"), value: (u) => u.materialItemCode }, - { key: "materialLot", label: t("materialLot"), value: (u) => u.materialLotNo }, + { key: "material", label: t("itemCode"), value: (u) => u.materialItemCode }, + { key: "materialLot", label: t("itemLot"), value: (u) => u.materialLotNo }, { key: "materialQty", label: t("materialQty"), @@ -610,8 +605,8 @@ const ItemTracingSections: React.FC = ({ data, compiledGraph, onTrace }) ), }, - { key: "finishedItem", label: t("finishedItem"), value: (d) => d.finishedItemCode }, - { key: "finishedLot", label: t("finishedLot"), value: (d) => d.finishedLotNo }, + { key: "finishedItem", label: t("itemCode"), value: (d) => d.finishedItemCode }, + { key: "finishedLot", label: t("itemLot"), value: (d) => d.finishedLotNo }, { key: "fgQty", label: t("fgQty"), @@ -627,10 +622,10 @@ const ItemTracingSections: React.FC = ({ data, compiledGraph, onTrace }) ], [t, stockUom]); const bomRecipeCols = useMemo((): FilterableColumnDef<(typeof bomTrace.bomRecipe)[number]>[] => [ - { key: "material", label: t("material"), value: (r) => r.materialItemCode }, + { key: "material", label: t("itemCode"), value: (r) => r.materialItemCode }, { key: "materialName", - label: `${t("material")} name`, + label: t("detailItemName"), value: (r) => r.materialItemName, }, { @@ -664,7 +659,7 @@ const ItemTracingSections: React.FC = ({ data, compiledGraph, onTrace }) "—" ), }, - { key: "material", label: t("material"), value: (row) => row.materialItemCode }, + { key: "material", label: t("Item"), value: (row) => row.materialItemCode }, { key: "lotNo", label: t("lotNo"), @@ -703,7 +698,7 @@ const ItemTracingSections: React.FC = ({ data, compiledGraph, onTrace }) const joPickLineCols = useMemo((): FilterableColumnDef[] => [ { key: "material", - label: t("material"), + label: t("Item"), value: (line) => line.itemName ? `${line.itemCode} · ${line.itemName}` : line.itemCode, }, @@ -719,8 +714,8 @@ const ItemTracingSections: React.FC = ({ data, compiledGraph, onTrace }) align: "right", value: (line) => formatQty(Number(line.pickedQty), stockUom), }, - { key: "status", label: t("status"), value: (line) => line.status }, - ], [t, stockUom]); + { key: "status", label: t("status"), value: (line) => tr.pickStatus(line.status) }, + ], [t, tr, stockUom]); return ( @@ -878,7 +873,7 @@ const ItemTracingSections: React.FC = ({ data, compiledGraph, onTrace }) {" · "} diff --git a/src/components/ItemTracing/TraceFlowNodes.tsx b/src/components/ItemTracing/TraceFlowNodes.tsx index 6a3be79..43d32dc 100644 --- a/src/components/ItemTracing/TraceFlowNodes.tsx +++ b/src/components/ItemTracing/TraceFlowNodes.tsx @@ -31,7 +31,7 @@ import { traceFlowHandleTopPercent } from "./traceFlowEdgeLayout"; import { kindColor, kindLabelKey } from "./traceFlowNodeUtils"; import { formatQty, formatSignedQty } from "./traceQtyUtils"; import { buildStockTakeLifecycleStages } from "./traceStockTakeUtils"; -import { pickStatusValueColor } from "./traceLabelUtils"; +import { pickStatusValueColor, resolveDoOutboundQtyColor } from "./traceLabelUtils"; const PHASE_LABEL_INNER = 96; /** Expanded stock-take stage panel (mt + maxHeight + padding) below the fixed card. */ @@ -348,7 +348,7 @@ export const TraceFlowEventNode = memo(function TraceFlowEventNode({ {t("Item")}: {node.meta?.trim() || node.traceItemCode} ) : null} - {node.qty != null && ( + {(node.qty != null || node.kind === "STOCK_TAKE") && ( {node.kind === "PURCHASE" @@ -385,6 +394,31 @@ export const TraceFlowEventNode = memo(function TraceFlowEventNode({ : formatQty(node.qty, node.uom)} )} + {node.kind === "STOCK_TAKE" ? ( + 0 + ? "success.main" + : node.stockTakeVarianceQty != null && node.stockTakeVarianceQty < 0 + ? "error.main" + : undefined, + }} + > + {t("variance")}: {formatQty(node.stockTakeVarianceQty, node.uom)} + + ) : null} {node.traceLotNo ? ( (); @@ -660,6 +665,7 @@ export const buildTraceGraphNodes = ( doMetaByPickCode.set(code, { isExtra: chipFlags.isExtra, isReplenish: chipFlags.isReplenish, + qtyChanged: d.qtyChanged === true, consoCode: d.consoCode?.trim() || code, }); }); @@ -884,6 +890,9 @@ export const buildTraceGraphNodes = ( : undefined, doOutboundIsExtra: chipFlags?.isExtra, doOutboundIsReplenish: chipFlags?.isReplenish, + doOutboundQtyChanged: isDoOut + ? (pickMeta?.qtyChanged ?? m.doOutboundQtyChanged === true) + : undefined, warehouseCode: m.warehouseCode?.trim() || undefined, categoryLabel: categoryForKind(kind, labels), ...(isJoOut @@ -1022,10 +1031,12 @@ export const buildTraceGraphNodes = ( const bookQty = resolveStockTakeBookQty(detail, e.beforeQty); const acceptedQty = resolveStockTakeAcceptedQty(detail, e.afterQty); const varianceQty = - detail?.varianceQty != null ? Number(detail.varianceQty) : e.varianceQty; - const meta = [e.approver, roundLabel, e.stockTakeSection, `Δ ${formatQty(varianceQty, stockUom)}`] - .filter(Boolean) - .join(" · "); + detail?.varianceQty != null + ? Number(detail.varianceQty) + : acceptedQty != null && e.varianceQty != null + ? Number(e.varianceQty) + : null; + const meta = [e.approver, roundLabel, e.stockTakeSection].filter(Boolean).join(" · "); nodes.push({ id: lb ? `${idPfx}st-${i}-${e.stockTakeCode}` @@ -1043,6 +1054,7 @@ export const buildTraceGraphNodes = ( traceLotNo: e.lotNo?.trim() || data.lot.lotNo?.trim() || undefined, categoryLabel: labels.categoryStockTake, stockTakeRecordDetail: detail, + stockTakeVarianceQty: varianceQty, details: [ field(labels.detailStockTakeCode, e.stockTakeCode), fieldIf(labels.detailStockTakeRound, roundLabel || undefined), diff --git a/src/components/ItemTracing/traceLabelUtils.ts b/src/components/ItemTracing/traceLabelUtils.ts index 577d5ea..5aae4a4 100644 --- a/src/components/ItemTracing/traceLabelUtils.ts +++ b/src/components/ItemTracing/traceLabelUtils.ts @@ -176,3 +176,16 @@ export const resolveDoOutboundChipFlags = ( isExtra: resolveTraceDoOutboundIsExtra(source), isReplenish: source.isReplenish === true, }); + +/** + * 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"; +}; diff --git a/src/components/ItemTracing/traceStockTakeUtils.ts b/src/components/ItemTracing/traceStockTakeUtils.ts index ef2cc1f..d192736 100644 --- a/src/components/ItemTracing/traceStockTakeUtils.ts +++ b/src/components/ItemTracing/traceStockTakeUtils.ts @@ -34,14 +34,16 @@ export const resolveStockTakeBookQty = ( /** * 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) { - const pick = (n: number | null | undefined) => - n != null && !Number.isNaN(Number(n)) ? Number(n) : null; switch (detail.lastSelect) { case 3: { const q = pick(detail.approverQty); @@ -67,6 +69,13 @@ export const resolveStockTakeAcceptedQty = ( 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); diff --git a/src/components/ProductionProcess/DrinkProductionQtyDashboard.tsx b/src/components/ProductionProcess/DrinkProductionQtyDashboard.tsx new file mode 100644 index 0000000..9dd30d3 --- /dev/null +++ b/src/components/ProductionProcess/DrinkProductionQtyDashboard.tsx @@ -0,0 +1,349 @@ +"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}`; + +const DrinkProductionQtyDashboard: React.FC = () => { + const { t } = useTranslation(["common", "jo", "productionProcess"]); + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedDate, setSelectedDate] = useState(dayjs()); + const [expandedRowKeys, setExpandedRowKeys] = useState>( + new Set(), + ); + const refreshCountRef = useRef(0); + const [lastDataRefreshTime, setLastDataRefreshTime] = useState( + 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 ( + + + + {t("Drink Production Qty Dashboard")} + + + + + { + if (newValue) setSelectedDate(newValue); + }} + format="YYYY-MM-DD" + slotProps={{ + textField: { size: "small", sx: { minWidth: 160 } }, + }} + /> + + + + + + {t("Auto-refresh every 10 minutes")} |  + {t("Last updated")}:{" "} + {lastDataRefreshTime + ? lastDataRefreshTime.format("HH:mm:ss") + : "--:--:--"} + + + + {loading ? ( + + + + ) : ( + + + + + + + + {t("Item Code")} + + + + + {t("Goods Name")} + + + + + {t("Unit")} + + + + + {t("Stock Req. Qty")} + + + + + {t("Production Qty")} + + + + + + {data.length === 0 ? ( + + + + {t("No data available")} + + + + ) : ( + data.map((row, idx) => { + const rowKey = getRowKey(row, idx); + const jobOrders = row.jobOrders ?? []; + const isExpanded = expandedRowKeys.has(rowKey); + const hasJobOrders = jobOrders.length > 0; + + return ( + + + + {hasJobOrders ? ( + toggleRowExpanded(rowKey)} + > + {isExpanded ? ( + + ) : ( + + )} + + ) : null} + + + + {row.itemCode || "-"} + + + + + {row.itemName || "-"} + + + + + {row.uom || "-"} + + + + + {formatQty(row.totalReqQty)} + + + + + {formatQty(row.totalQty)} + + + + {hasJobOrders && ( + + + + +
+ + + + {t("Job Order Code")} + + + {t("Production Date")} + + + {t("Stock Req. Qty")} + + + {t("Production Qty")} + + + + + {jobOrders.map((jo) => ( + + + {jo.jobOrderId > 0 ? ( + + {jo.jobOrderCode || + `JO-${jo.jobOrderId}`} + + ) : ( + jo.jobOrderCode || "-" + )} + + + {formatProductionDate( + jo.productionDate, + )} + + + {formatQty(jo.reqQty)} + + + {formatQty(jo.productionQty)} + + + ))} + +
+ + + + + )} + + ); + }) + )} + + +
+ )} +
+
+ ); +}; + +export default DrinkProductionQtyDashboard; diff --git a/src/components/ProductionProcess/ProductionProcessPage.tsx b/src/components/ProductionProcess/ProductionProcessPage.tsx index f78c95e..09e4772 100644 --- a/src/components/ProductionProcess/ProductionProcessPage.tsx +++ b/src/components/ProductionProcess/ProductionProcessPage.tsx @@ -14,6 +14,7 @@ import JobPickExecutionsecondscan from "@/components/Jodetail/JobPickExecutionse import JobProcessStatus from "@/components/ProductionProcess/JobProcessStatus"; import OperatorKpiDashboard from "@/components/ProductionProcess/OperatorKpiDashboard"; import EquipmentStatusDashboard from "@/components/ProductionProcess/EquipmentStatusDashboard"; +import DrinkProductionQtyDashboard from "@/components/ProductionProcess/DrinkProductionQtyDashboard"; import type { PrinterCombo } from "@/app/api/settings/printer"; import { useTranslation } from "react-i18next"; @@ -208,6 +209,7 @@ const ProductionProcessPage: React.FC = ({ printerCo + {tabIndex === 0 && ( @@ -256,7 +258,8 @@ const ProductionProcessPage: React.FC = ({ printerCo }} /> )} - {tabIndex === 2 && ( + + {tabIndex === 2 && ( = ({ printerCo {tabIndex === 5 && ( )} + {tabIndex === 6 && ( + + )}