| @@ -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 { | |||
| @@ -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<DrinkProductionQtyResponse[]>(url, { | |||
| method: "GET", | |||
| next: { tags: ["drinkProductionQty"] }, | |||
| }); | |||
| }); | |||
| // ===== Equipment Status Dashboard ===== | |||
| export interface EquipmentStatusProcessInfo { | |||
| @@ -91,31 +91,22 @@ const ItemTracingFlowGraphSearch: React.FC<Props> = ({ | |||
| }} | |||
| 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 && ( | |||
| @@ -459,11 +459,6 @@ const ItemTracingSections: React.FC<Props> = ({ 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<Props> = ({ data, compiledGraph, onTrace }) | |||
| <ItemTracingDocLink kind="jo" code={m.jobOrderCode} id={m.jobOrderId} /> | |||
| ), | |||
| }, | |||
| { 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<Props> = ({ data, compiledGraph, onTrace }) | |||
| <ItemTracingDocLink kind="jo" code={u.jobOrderCode} id={u.jobOrderId} /> | |||
| ), | |||
| }, | |||
| { 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<Props> = ({ data, compiledGraph, onTrace }) | |||
| <ItemTracingDocLink kind="jo" code={d.jobOrderCode} id={d.jobOrderId} /> | |||
| ), | |||
| }, | |||
| { 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ data, compiledGraph, onTrace }) | |||
| const joPickLineCols = useMemo((): FilterableColumnDef<JoPickLine>[] => [ | |||
| { | |||
| 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<Props> = ({ 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 ( | |||
| <Paper variant="outlined"> | |||
| @@ -878,7 +873,7 @@ const ItemTracingSections: React.FC<Props> = ({ data, compiledGraph, onTrace }) | |||
| {" · "} | |||
| <Chip | |||
| size="small" | |||
| label={po.status} | |||
| label={tr.pickStatus(po.status)} | |||
| variant="outlined" | |||
| sx={{ ml: 0.5 }} | |||
| /> | |||
| @@ -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} | |||
| </Typography> | |||
| ) : null} | |||
| {node.qty != null && ( | |||
| {(node.qty != null || node.kind === "STOCK_TAKE") && ( | |||
| <Typography | |||
| variant="body2" | |||
| fontWeight={600} | |||
| @@ -366,6 +366,15 @@ export const TraceFlowEventNode = memo(function TraceFlowEventNode({ | |||
| display: "-webkit-box", | |||
| WebkitLineClamp: 2, | |||
| WebkitBoxOrient: "vertical", | |||
| color: | |||
| node.kind === "DO_OUT" || | |||
| node.kind === "JO_OUT" || | |||
| node.kind === "MATERIAL_PICK" | |||
| ? resolveDoOutboundQtyColor( | |||
| node.qty, | |||
| node.kind === "DO_OUT" ? node.doOutboundQtyChanged : false, | |||
| ) | |||
| : undefined, | |||
| }} | |||
| > | |||
| {node.kind === "PURCHASE" | |||
| @@ -385,6 +394,31 @@ export const TraceFlowEventNode = memo(function TraceFlowEventNode({ | |||
| : formatQty(node.qty, node.uom)} | |||
| </Typography> | |||
| )} | |||
| {node.kind === "STOCK_TAKE" ? ( | |||
| <Typography | |||
| variant="body2" | |||
| fontWeight={600} | |||
| title={formatQty(node.stockTakeVarianceQty, node.uom)} | |||
| sx={{ | |||
| mt: 0.25, | |||
| fontSize: data.compact ? "0.8rem" : undefined, | |||
| overflow: "hidden", | |||
| overflowWrap: "anywhere", | |||
| wordBreak: "break-word", | |||
| display: "-webkit-box", | |||
| WebkitLineClamp: 2, | |||
| WebkitBoxOrient: "vertical", | |||
| color: | |||
| node.stockTakeVarianceQty != null && node.stockTakeVarianceQty > 0 | |||
| ? "success.main" | |||
| : node.stockTakeVarianceQty != null && node.stockTakeVarianceQty < 0 | |||
| ? "error.main" | |||
| : undefined, | |||
| }} | |||
| > | |||
| {t("variance")}: {formatQty(node.stockTakeVarianceQty, node.uom)} | |||
| </Typography> | |||
| ) : null} | |||
| {node.traceLotNo ? ( | |||
| <ItemTracingLotTraceLink | |||
| label={ | |||
| @@ -193,6 +193,7 @@ export const buildExtendedTraceGraphNodes = ( | |||
| categoryLabel: labels.categoryOutbound, | |||
| doOutboundIsExtra: chipFlags.isExtra, | |||
| doOutboundIsReplenish: chipFlags.isReplenish, | |||
| doOutboundQtyChanged: d.qtyChanged === true, | |||
| outboundKindLabel, | |||
| warehouseCode: scope?.defaultWarehouseCode, | |||
| details: [ | |||
| @@ -123,6 +123,8 @@ export interface TraceGraphNode { | |||
| /** 加單 / 補貨 flags for DO_OUT title chips (detail uses outboundKindLabel). */ | |||
| doOutboundIsExtra?: boolean; | |||
| doOutboundIsReplenish?: boolean; | |||
| /** 改數 — pick line qty ≠ sum of stock-out lines for that pick line. */ | |||
| doOutboundQtyChanged?: boolean; | |||
| outboundKindLabel?: string; | |||
| /** Longer status explanation in detail panel (e.g. transfer inbound). */ | |||
| putawayStatusDetail?: string; | |||
| @@ -156,6 +158,8 @@ export interface TraceGraphNode { | |||
| adjustmentDirection?: string; | |||
| /** Stocktake lifecycle record detail (for expandable STOCK_TAKE nodes). */ | |||
| stockTakeRecordDetail?: ItemLotTraceStockTakeRecordDetail; | |||
| /** Stocktake variance (accepted − book); null when round has no accepted qty yet. */ | |||
| stockTakeVarianceQty?: number | null; | |||
| /** Owning inventory_lot for multi-location graph scopes. */ | |||
| inventoryLotId?: number; | |||
| /** Translated pick-line statuses for JO pick cards. */ | |||
| @@ -642,6 +646,7 @@ export const buildTraceGraphNodes = ( | |||
| { | |||
| isExtra: boolean; | |||
| isReplenish: boolean; | |||
| qtyChanged: boolean; | |||
| consoCode?: string; | |||
| } | |||
| >(); | |||
| @@ -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), | |||
| @@ -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"; | |||
| }; | |||
| @@ -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); | |||
| @@ -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<DrinkProductionQtyResponse[]>([]); | |||
| const [loading, setLoading] = useState<boolean>(true); | |||
| const [selectedDate, setSelectedDate] = useState<Dayjs>(dayjs()); | |||
| const [expandedRowKeys, setExpandedRowKeys] = useState<Set<string>>( | |||
| new Set(), | |||
| ); | |||
| const refreshCountRef = useRef<number>(0); | |||
| const [lastDataRefreshTime, setLastDataRefreshTime] = useState<Dayjs | null>( | |||
| null, | |||
| ); | |||
| const loadData = useCallback(async () => { | |||
| setLoading(true); | |||
| try { | |||
| const result = await fetchDrinkProductionQty( | |||
| selectedDate.format("YYYY-MM-DD"), | |||
| ); | |||
| setData(result || []); | |||
| setExpandedRowKeys(new Set()); | |||
| setLastDataRefreshTime(dayjs()); | |||
| refreshCountRef.current += 1; | |||
| } catch (error) { | |||
| console.error("Error fetching drink production qty:", error); | |||
| setData([]); | |||
| setExpandedRowKeys(new Set()); | |||
| } finally { | |||
| setLoading(false); | |||
| } | |||
| }, [selectedDate]); | |||
| useEffect(() => { | |||
| loadData(); | |||
| const interval = setInterval(() => { | |||
| loadData(); | |||
| }, REFRESH_INTERVAL); | |||
| return () => clearInterval(interval); | |||
| }, [loadData]); | |||
| const toggleRowExpanded = (rowKey: string) => { | |||
| setExpandedRowKeys((prev) => { | |||
| const next = new Set(prev); | |||
| if (next.has(rowKey)) { | |||
| next.delete(rowKey); | |||
| } else { | |||
| next.add(rowKey); | |||
| } | |||
| return next; | |||
| }); | |||
| }; | |||
| return ( | |||
| <Card sx={{ mb: 2 }}> | |||
| <CardContent> | |||
| <Typography variant="h5" sx={{ fontWeight: 600, mb: 2 }}> | |||
| {t("Drink Production Qty Dashboard")} | |||
| </Typography> | |||
| <Stack direction="row" spacing={2} sx={{ mb: 3, alignItems: "center" }}> | |||
| <LocalizationProvider dateAdapter={AdapterDayjs}> | |||
| <DatePicker | |||
| label={t("Date")} | |||
| value={selectedDate} | |||
| onChange={(newValue) => { | |||
| if (newValue) setSelectedDate(newValue); | |||
| }} | |||
| format="YYYY-MM-DD" | |||
| slotProps={{ | |||
| textField: { size: "small", sx: { minWidth: 160 } }, | |||
| }} | |||
| /> | |||
| </LocalizationProvider> | |||
| <Box sx={{ flexGrow: 1 }} /> | |||
| <Typography | |||
| variant="body2" | |||
| sx={{ color: "text.secondary" }} | |||
| suppressHydrationWarning | |||
| > | |||
| {t("Auto-refresh every 10 minutes")} | | |||
| {t("Last updated")}:{" "} | |||
| {lastDataRefreshTime | |||
| ? lastDataRefreshTime.format("HH:mm:ss") | |||
| : "--:--:--"} | |||
| </Typography> | |||
| </Stack> | |||
| {loading ? ( | |||
| <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}> | |||
| <CircularProgress /> | |||
| </Box> | |||
| ) : ( | |||
| <TableContainer | |||
| sx={{ | |||
| border: "3px solid #135fed", | |||
| overflowX: "auto", | |||
| maxHeight: 440, | |||
| overflow: "auto", | |||
| }} | |||
| component={Paper} | |||
| > | |||
| <Table size="small" sx={{ minWidth: 650 }}> | |||
| <TableHead> | |||
| <TableRow | |||
| sx={{ | |||
| bgcolor: "#424242", | |||
| "& th": { | |||
| borderBottom: "none", | |||
| py: 1.5, | |||
| position: "sticky", | |||
| top: 0, | |||
| zIndex: 1, | |||
| }, | |||
| }} | |||
| > | |||
| <TableCell sx={{ width: 48 }} /> | |||
| <TableCell sx={{ width: 160 }}> | |||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | |||
| {t("Item Code")} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell sx={{ width: 260 }}> | |||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | |||
| {t("Goods Name")} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell sx={{ width: 120 }}> | |||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | |||
| {t("Unit")} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell align="right" sx={{ width: 120 }}> | |||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | |||
| {t("Stock Req. Qty")} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell align="right" sx={{ width: 140 }}> | |||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | |||
| {t("Production Qty")} | |||
| </Typography> | |||
| </TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {data.length === 0 ? ( | |||
| <TableRow> | |||
| <TableCell colSpan={6} align="center"> | |||
| <Typography | |||
| variant="body2" | |||
| sx={{ py: 2, color: "text.secondary" }} | |||
| > | |||
| {t("No data available")} | |||
| </Typography> | |||
| </TableCell> | |||
| </TableRow> | |||
| ) : ( | |||
| data.map((row, idx) => { | |||
| const rowKey = getRowKey(row, idx); | |||
| const jobOrders = row.jobOrders ?? []; | |||
| const isExpanded = expandedRowKeys.has(rowKey); | |||
| const hasJobOrders = jobOrders.length > 0; | |||
| return ( | |||
| <React.Fragment key={rowKey}> | |||
| <TableRow hover={hasJobOrders}> | |||
| <TableCell padding="checkbox"> | |||
| {hasJobOrders ? ( | |||
| <IconButton | |||
| size="small" | |||
| aria-label={ | |||
| isExpanded | |||
| ? t("Collapse job order details") | |||
| : t("Expand job order details") | |||
| } | |||
| onClick={() => toggleRowExpanded(rowKey)} | |||
| > | |||
| {isExpanded ? ( | |||
| <ExpandLessIcon fontSize="small" /> | |||
| ) : ( | |||
| <ExpandMoreIcon fontSize="small" /> | |||
| )} | |||
| </IconButton> | |||
| ) : null} | |||
| </TableCell> | |||
| <TableCell> | |||
| <Typography variant="body2"> | |||
| {row.itemCode || "-"} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell> | |||
| <Typography variant="body2"> | |||
| {row.itemName || "-"} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell> | |||
| <Typography variant="body2"> | |||
| {row.uom || "-"} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| <Typography variant="body2"> | |||
| {formatQty(row.totalReqQty)} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| <Typography variant="body2"> | |||
| {formatQty(row.totalQty)} | |||
| </Typography> | |||
| </TableCell> | |||
| </TableRow> | |||
| {hasJobOrders && ( | |||
| <TableRow> | |||
| <TableCell | |||
| colSpan={6} | |||
| sx={{ py: 0, borderBottom: 0 }} | |||
| > | |||
| <Collapse | |||
| in={isExpanded} | |||
| timeout="auto" | |||
| unmountOnExit | |||
| > | |||
| <Box sx={{ py: 1.5, pl: 6, pr: 2 }}> | |||
| <Table size="small"> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell sx={{ fontWeight: 600 }}> | |||
| {t("Job Order Code")} | |||
| </TableCell> | |||
| <TableCell sx={{ fontWeight: 600 }}> | |||
| {t("Production Date")} | |||
| </TableCell> | |||
| <TableCell | |||
| align="right" | |||
| sx={{ fontWeight: 600 }} | |||
| > | |||
| {t("Stock Req. Qty")} | |||
| </TableCell> | |||
| <TableCell | |||
| align="right" | |||
| sx={{ fontWeight: 600 }} | |||
| > | |||
| {t("Production Qty")} | |||
| </TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {jobOrders.map((jo) => ( | |||
| <TableRow | |||
| key={`${rowKey}-jo-${jo.jobOrderId}`} | |||
| > | |||
| <TableCell> | |||
| {jo.jobOrderId > 0 ? ( | |||
| <MuiLink | |||
| component={NextLink} | |||
| href={`/jo/edit?id=${jo.jobOrderId}`} | |||
| underline="hover" | |||
| > | |||
| {jo.jobOrderCode || | |||
| `JO-${jo.jobOrderId}`} | |||
| </MuiLink> | |||
| ) : ( | |||
| jo.jobOrderCode || "-" | |||
| )} | |||
| </TableCell> | |||
| <TableCell> | |||
| {formatProductionDate( | |||
| jo.productionDate, | |||
| )} | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| {formatQty(jo.reqQty)} | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| {formatQty(jo.productionQty)} | |||
| </TableCell> | |||
| </TableRow> | |||
| ))} | |||
| </TableBody> | |||
| </Table> | |||
| </Box> | |||
| </Collapse> | |||
| </TableCell> | |||
| </TableRow> | |||
| )} | |||
| </React.Fragment> | |||
| ); | |||
| }) | |||
| )} | |||
| </TableBody> | |||
| </Table> | |||
| </TableContainer> | |||
| )} | |||
| </CardContent> | |||
| </Card> | |||
| ); | |||
| }; | |||
| export default DrinkProductionQtyDashboard; | |||
| @@ -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<ProductionProcessPageProps> = ({ printerCo | |||
| <Tab label={t("Job Process Status Dashboard")} /> | |||
| <Tab label={t("Operator KPI Dashboard")} /> | |||
| <Tab label={t("Production Equipment Status Dashboard")} /> | |||
| <Tab label={t("Drink Production Qty Dashboard")} /> | |||
| </Tabs> | |||
| {tabIndex === 0 && ( | |||
| @@ -256,7 +258,8 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||
| }} | |||
| /> | |||
| )} | |||
| {tabIndex === 2 && ( | |||
| {tabIndex === 2 && ( | |||
| <ProductionProcessList | |||
| printerCombo={printerCombo} | |||
| qcReady={true} | |||
| @@ -288,6 +291,9 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||
| {tabIndex === 5 && ( | |||
| <EquipmentStatusDashboard /> | |||
| )} | |||
| {tabIndex === 6 && ( | |||
| <DrinkProductionQtyDashboard /> | |||
| )} | |||
| </Box> | |||
| <QcStockInModal | |||
| session={sessionToken} | |||
| @@ -6,6 +6,7 @@ | |||
| "scanAgain": "Scan again", | |||
| "manualSearch": "Manual search", | |||
| "itemCode": "Item code", | |||
| "itemLot": "Item lot", | |||
| "lotNo": "Lot no.", | |||
| "search": "Trace", | |||
| "searching": "Tracing…", | |||
| @@ -197,6 +198,7 @@ | |||
| "from": "From", | |||
| "to": "To", | |||
| "material": "Material", | |||
| "Item": "Item", | |||
| "detailItemCode": "Item code", | |||
| "detailItemName": "Item name", | |||
| "finishedItem": "Finished item", | |||
| @@ -258,6 +260,13 @@ | |||
| "code.usageType.PRODUCTION": "Production use", | |||
| "code.usageType.FG_DELIVERY": "FG delivery", | |||
| "code.usageType.MATERIAL": "Material issue", | |||
| "code.pickStatus.pending": "Pending", | |||
| "code.pickStatus.consolidated": "Consolidated", | |||
| "code.pickStatus.assigned": "Assigned", | |||
| "code.pickStatus.released": "Released", | |||
| "code.pickStatus.picking": "Picking", | |||
| "code.pickStatus.completed": "Completed", | |||
| "code.pickStatus.partially_completed": "Partially completed", | |||
| "code.processingStatus.pending": "Pending", | |||
| "code.processingStatus.completed": "Completed", | |||
| "code.processingStatus.rejected": "Rejected", | |||
| @@ -192,6 +192,7 @@ | |||
| "Job Order Type": "Job Order Type", | |||
| "Job Order not found or has no items": "Job Order not found or has no items", | |||
| "Job Process Status Dashboard": "Job Process Status Dashboard", | |||
| "Drink Production Qty Dashboard": "Drink Production Qty Dashboard", | |||
| "Job Type": "Job Type", | |||
| "Job dashboard PP status: cancelled": "Job dashboard PP status: cancelled", | |||
| "Job dashboard PP status: completed": "Job dashboard PP status: completed", | |||
| @@ -353,6 +354,7 @@ | |||
| "Production Output Data Entry": "Production Output Data Entry", | |||
| "Production Priority": "Production Priority", | |||
| "Production Priority required!": "Production Priority required!", | |||
| "Production Qty": "Production Qty", | |||
| "Production Process": "Production Process", | |||
| "Production Process Information": "Production Process Information", | |||
| "Production Process Line Remark": "Production Process Line Remark", | |||
| @@ -78,6 +78,10 @@ | |||
| "Job Order and Product": "Job Order and Product", | |||
| "Job Order Production Process": "Job Order Production Process", | |||
| "Job Process Status Dashboard": "Job Process Status Dashboard", | |||
| "Drink Production Qty Dashboard": "Drink Production Qty Dashboard", | |||
| "Expand job order details": "Expand job order details", | |||
| "Collapse job order details": "Collapse job order details", | |||
| "Goods Name": "Goods Name", | |||
| "Job Type": "Job Type", | |||
| "Job process detail mode label": "Job process detail mode label", | |||
| "Job process detail: equipment": "Equipment", | |||
| @@ -135,6 +139,7 @@ | |||
| "Production Output Data": "Production Output Data", | |||
| "Production Output Data Entry": "Production Output Data Entry", | |||
| "Production Priority": "Production Priority", | |||
| "Production Qty": "Production Qty", | |||
| "Production Process": "Production Process", | |||
| "Production Process Line Remark": "Production Process Line Remark", | |||
| "Production Process Steps": "Production Process Steps", | |||
| @@ -6,6 +6,7 @@ | |||
| "scanAgain": "重新掃描", | |||
| "manualSearch": "手動查詢", | |||
| "itemCode": "貨品編號", | |||
| "itemLot": "貨品批號", | |||
| "lotNo": "批號", | |||
| "search": "追溯", | |||
| "searching": "查詢中…", | |||
| @@ -259,6 +260,13 @@ | |||
| "code.usageType.PRODUCTION": "生產耗用", | |||
| "code.usageType.FG_DELIVERY": "成品出貨", | |||
| "code.usageType.MATERIAL": "原料提料", | |||
| "code.pickStatus.pending": "待處理", | |||
| "code.pickStatus.consolidated": "已合併", | |||
| "code.pickStatus.assigned": "已指派", | |||
| "code.pickStatus.released": "已放單", | |||
| "code.pickStatus.picking": "提料中", | |||
| "code.pickStatus.completed": "已完成", | |||
| "code.pickStatus.partially_completed": "部分完成", | |||
| "code.processingStatus.pending": "待處理", | |||
| "code.processingStatus.completed": "已完成", | |||
| "code.processingStatus.rejected": "已拒收", | |||
| @@ -10,6 +10,7 @@ | |||
| "Actual Pick Qty": "實際提料數量", | |||
| "Add Bag": "新增包裝袋", | |||
| "Add Record": "添加記錄", | |||
| "Just Pass": "通過", | |||
| "Add Selected Items to Created Items": "將已選擇的物品添加到創建的物品中", | |||
| "Add some entries!": "請添加條目", | |||
| "All": "全部", | |||
| @@ -194,6 +195,7 @@ | |||
| "Job Order Type": "工單類型", | |||
| "Job Order not found or has no items": "工單不存在或沒有物品", | |||
| "Job Process Status Dashboard": "儀表板 - 工單狀態", | |||
| "Drink Production Qty Dashboard": "儀表板 - 飲料生產量數", | |||
| "Job Type": "工單類型", | |||
| "Job dashboard PP status: cancelled": "工序已取消", | |||
| "Job dashboard PP status: completed": "工序完成", | |||
| @@ -355,6 +357,7 @@ | |||
| "Production Output Data Entry": "生產輸出數據輸入", | |||
| "Production Priority": "生產優先序", | |||
| "Production Priority required!": "生產優先度必填!", | |||
| "Production Qty": "生產數量", | |||
| "Production Process": "工藝流程", | |||
| "Production Process Information": "生產流程信息", | |||
| "Production Process Line Remark": "工藝明細", | |||
| @@ -78,6 +78,10 @@ | |||
| "Job Order and Product": "工單及貨品", | |||
| "Job Order Production Process": "工單生產流程", | |||
| "Job Process Status Dashboard": "儀表板 - 工單狀態", | |||
| "Drink Production Qty Dashboard": "儀表板 - 飲料生產量數", | |||
| "Expand job order details": "展開工單明細", | |||
| "Collapse job order details": "收合工單明細", | |||
| "Goods Name": "貨品名稱", | |||
| "Job Type": "工單類型", | |||
| "Job process detail mode label": "工序格顯示", | |||
| "Job process detail: equipment": "設備", | |||
| @@ -135,6 +139,7 @@ | |||
| "Production Output Data": "生產輸出數據", | |||
| "Production Output Data Entry": "生產輸出數據輸入", | |||
| "Production Priority": "生產優先序", | |||
| "Production Qty": "生產數量", | |||
| "Production Process": "工藝流程", | |||
| "Production Process Line Remark": "工藝明細", | |||
| "Production Process Steps": "生產流程步驟", | |||