diff --git a/src/components/ClientMonitor/LabelPrinterMonitorPanel.tsx b/src/components/ClientMonitor/LabelPrinterMonitorPanel.tsx index 7edf93f..eb8b10d 100644 --- a/src/components/ClientMonitor/LabelPrinterMonitorPanel.tsx +++ b/src/components/ClientMonitor/LabelPrinterMonitorPanel.tsx @@ -4,6 +4,7 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; import { Alert, Box, + Button, Chip, CircularProgress, Table, @@ -16,10 +17,19 @@ import { Typography, } from "@mui/material"; import InfoOutlined from "@mui/icons-material/InfoOutlined"; +import Search from "@mui/icons-material/Search"; import { NEXT_PUBLIC_API_URL } from "@/config/api"; import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; -import { arrayToDateTimeString } from "@/app/utils/formatUtil"; -import dayjs from "dayjs"; +import { + arrayToDateTimeString, + OUTPUT_DATE_FORMAT, + OUTPUT_TIME_FORMAT, +} from "@/app/utils/formatUtil"; +import dayjs, { type Dayjs } from "dayjs"; +import "dayjs/locale/zh-hk"; +import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker"; +import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; function formatApiDateTime(value: unknown): string { if (value == null) return "—"; @@ -56,20 +66,40 @@ type LabelPrinterSummary = { unchecked: number; }; -type LabelSubmitRow = { - id?: number; - jobOrderId?: number; - qty?: number; - created?: unknown; - jobCode?: string; +type OdometerPrinterRow = { + printerId: number; + code?: string; + name?: string; + brand?: string; + startAt?: string | null; + startOdometer?: number | null; + endAt?: string | null; + endOdometer?: number | null; + delta?: number | null; }; -type LabelStats = { - todayTotal?: number; - rangeTotal?: number; - recentSubmits?: LabelSubmitRow[]; +type OdometerStats = { + from?: string; + to?: string; + printers?: OdometerPrinterRow[]; }; +const STATS_DATETIME_FORMAT = `${OUTPUT_DATE_FORMAT} ${OUTPUT_TIME_FORMAT}`; +const STATS_MAX_RANGE_DAYS = 30; + +function toApiDateTime(value: Dayjs | null): string { + if (value == null || !value.isValid()) return ""; + return value.format("YYYY-MM-DDTHH:mm:ss"); +} + +function defaultStatsFrom(): Dayjs { + return dayjs().startOf("day"); +} + +function defaultStatsTo(): Dayjs { + return dayjs(); +} + const STATUS_LABEL: Record< string, { label: string; color: "success" | "warning" | "error" | "default" } @@ -85,31 +115,79 @@ type Props = { refreshAt?: number; }; -export default function LabelPrinterMonitorPanel({ active, refreshAt = 0 }: Props) { +export default function LabelPrinterMonitorPanel({ + active, + refreshAt = 0, +}: Props) { const [printers, setPrinters] = useState([]); const [summary, setSummary] = useState(null); - const [labelStats, setLabelStats] = useState(null); + const [odometerStats, setOdometerStats] = useState( + null, + ); + const [statsFrom, setStatsFrom] = useState(defaultStatsFrom); + const [statsTo, setStatsTo] = useState(defaultStatsTo); const [loading, setLoading] = useState(false); + const [statsLoading, setStatsLoading] = useState(false); const [error, setError] = useState(null); - const inFlightRef = useRef(false); + const [statsError, setStatsError] = useState(null); + const checkInFlightRef = useRef(false); + const statsInFlightRef = useRef(false); - const runCheck = useCallback(async () => { - if (inFlightRef.current) return; - inFlightRef.current = true; + const fetchOdometerStats = useCallback(async (from: Dayjs, to: Dayjs) => { + if (statsInFlightRef.current) return; + const fromApi = toApiDateTime(from); + const toApi = toApiDateTime(to); + if (!fromApi || !toApi) { + setStatsError("請選擇開始與結束時間"); + return; + } + if (from.isAfter(to)) { + setStatsError("開始時間必須早於結束時間"); + return; + } + if (to.diff(from, "day") > STATS_MAX_RANGE_DAYS) { + setStatsError(`查詢區間不可超過 ${STATS_MAX_RANGE_DAYS} 天`); + return; + } + + statsInFlightRef.current = true; + setStatsLoading(true); + setStatsError(null); + try { + const statsRes = await clientAuthFetch( + `${NEXT_PUBLIC_API_URL}/label-printer-monitor/odometer-stats?fromDateTime=${encodeURIComponent( + fromApi, + )}&toDateTime=${encodeURIComponent(toApi)}`, + { method: "GET", cache: "no-store" }, + ); + if (statsRes.status === 401 || statsRes.status === 403) return; + if (!statsRes.ok) { + const body = await statsRes.json().catch(() => ({})); + throw new Error(body?.error ?? `HTTP ${statsRes.status}`); + } + setOdometerStats(await statsRes.json()); + } catch (e) { + console.error("label printer odometer stats", e); + setStatsError( + e instanceof Error ? e.message : "無法載入 Zebra 里程表區間統計", + ); + setOdometerStats(null); + } finally { + setStatsLoading(false); + statsInFlightRef.current = false; + } + }, []); + + const runPrinterCheck = useCallback(async () => { + if (checkInFlightRef.current) return; + checkInFlightRef.current = true; setLoading(true); setError(null); try { - const from = dayjs().startOf("day").format("YYYY-MM-DDTHH:mm:ss"); - const to = dayjs().format("YYYY-MM-DDTHH:mm:ss"); - const [checkRes, statsRes] = await Promise.all([ - clientAuthFetch(`${NEXT_PUBLIC_API_URL}/label-printer-monitor/check`, { - method: "POST", - }), - clientAuthFetch( - `${NEXT_PUBLIC_API_URL}/label-printer-monitor/label-stats?fromDateTime=${encodeURIComponent(from)}&toDateTime=${encodeURIComponent(to)}`, - { method: "GET", cache: "no-store" }, - ), - ]); + const checkRes = await clientAuthFetch( + `${NEXT_PUBLIC_API_URL}/label-printer-monitor/check`, + { method: "POST" }, + ); if (checkRes.status === 401 || checkRes.status === 403) return; if (!checkRes.ok) { throw new Error(`check HTTP ${checkRes.status}`); @@ -117,34 +195,38 @@ export default function LabelPrinterMonitorPanel({ active, refreshAt = 0 }: Prop const checkData = await checkRes.json(); setPrinters(Array.isArray(checkData.printers) ? checkData.printers : []); setSummary(checkData.summary ?? null); - if (checkData.labelStats) { - setLabelStats(checkData.labelStats); - } else if (statsRes.ok) { - setLabelStats(await statsRes.json()); - } } catch (e) { console.error("label printer monitor check", e); setError("無法檢查標籤印表機狀態"); } finally { setLoading(false); - inFlightRef.current = false; + checkInFlightRef.current = false; } }, []); + const runFullRefresh = useCallback(async () => { + await Promise.all([ + runPrinterCheck(), + fetchOdometerStats(statsFrom, statsTo), + ]); + }, [runPrinterCheck, fetchOdometerStats, statsFrom, statsTo]); + useEffect(() => { if (!active) return; - void runCheck(); - const id = window.setInterval(() => void runCheck(), 120_000); + void runPrinterCheck(); + void fetchOdometerStats(defaultStatsFrom(), defaultStatsTo()); + const id = window.setInterval(() => void runPrinterCheck(), 120_000); return () => window.clearInterval(id); - }, [active, runCheck]); + }, [active, runPrinterCheck, fetchOdometerStats]); useEffect(() => { if (!active || refreshAt <= 0) return; - void runCheck(); - }, [refreshAt, active, runCheck]); + void runFullRefresh(); + }, [refreshAt, active, runFullRefresh]); const offlineCount = summary?.offline ?? 0; - const isZebra = (brand?: string) => (brand ?? "").toLowerCase().includes("zebra"); + const isZebra = (brand?: string) => + (brand ?? "").toLowerCase().includes("zebra"); return (
@@ -152,8 +234,8 @@ export default function LabelPrinterMonitorPanel({ active, refreshAt = 0 }: Prop 標籤印表機監控(In Development) - 監控「印表機」設定中 type=Label 的設備:TCP 連線檢測,Zebra 機型另讀取內建里程表(odometer)。 - 應用層列印統計來自 Bag3 標籤機提交(LABEL channel),不含網頁直接列印。 + 監控「印表機」設定中 type=Label 的設備:TCP 連線檢測,Zebra + 機型另讀取內建里程表(odometer)。 {offlineCount > 0 && ( @@ -167,7 +249,11 @@ export default function LabelPrinterMonitorPanel({ active, refreshAt = 0 }: Prop - + {summary.unchecked > 0 && ( )} @@ -188,18 +274,36 @@ export default function LabelPrinterMonitorPanel({ active, refreshAt = 0 }: Prop 品牌 IP:Port - + 累計里程 - + - + 自上次檢查 - + @@ -239,7 +343,11 @@ export default function LabelPrinterMonitorPanel({ active, refreshAt = 0 }: Prop )} {row.errorMessage && ( - + {row.errorMessage} )} @@ -273,38 +381,110 @@ export default function LabelPrinterMonitorPanel({ active, refreshAt = 0 }: Prop - 應用層標籤列印(LABEL) + Zebra 里程表區間印量(估算) + + + 依監控掃描寫入的 odometer 快照估算(最長 {STATS_MAX_RANGE_DAYS} 天)。非 + Zebra 機型不提供區間印量。 - {labelStats && ( - - - + + + { + if (value?.isValid()) setStatsFrom(value); + }} + slotProps={{ + textField: { size: "small", sx: { minWidth: 220 } }, + }} + /> + { + if (value?.isValid()) setStatsTo(value); + }} + slotProps={{ + textField: { size: "small", sx: { minWidth: 220 } }, + }} + /> + + + {statsError && ( + + {statsError} + )} - 時間 - 工單 - 數量 + 印表機 + 區間起點 + 區間終點 + 區間印量(估算) - {!labelStats?.recentSubmits?.length ? ( + {statsLoading && !odometerStats?.printers?.length ? ( - - 尚無 LABEL 列印提交記錄 + + + + + ) : !odometerStats?.printers?.length ? ( + + + 此區間內尚無 Zebra 里程表記錄 ) : ( - labelStats.recentSubmits.map((row) => ( - - {formatApiDateTime(row.created)} + odometerStats.printers.map((row) => ( + - {row.jobCode ?? (row.jobOrderId != null ? `#${row.jobOrderId}` : "—")} + + {row.name || row.code || `#${row.printerId}`} + + {row.code && row.name && ( + + {row.code} + + )} + + + {row.startAt && row.startOdometer != null + ? `${formatApiDateTime( + row.startAt, + )} · ${row.startOdometer.toLocaleString()}` + : "—"} + + + {row.endAt && row.endOdometer != null + ? `${formatApiDateTime( + row.endAt, + )} · ${row.endOdometer.toLocaleString()}` + : "—"} + + + {row.delta != null ? row.delta.toLocaleString() : "—"} - {row.qty ?? "—"} )) )}