| @@ -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<LabelPrinterRow[]>([]); | |||
| const [summary, setSummary] = useState<LabelPrinterSummary | null>(null); | |||
| const [labelStats, setLabelStats] = useState<LabelStats | null>(null); | |||
| const [odometerStats, setOdometerStats] = useState<OdometerStats | null>( | |||
| 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<string | null>(null); | |||
| const inFlightRef = useRef(false); | |||
| const [statsError, setStatsError] = useState<string | null>(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 ( | |||
| <div className="space-y-4 mt-6"> | |||
| @@ -152,8 +234,8 @@ export default function LabelPrinterMonitorPanel({ active, refreshAt = 0 }: Prop | |||
| 標籤印表機監控(In Development) | |||
| </Typography> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| 監控「印表機」設定中 type=Label 的設備:TCP 連線檢測,Zebra 機型另讀取內建里程表(odometer)。 | |||
| 應用層列印統計來自 Bag3 標籤機提交(LABEL channel),不含網頁直接列印。 | |||
| 監控「印表機」設定中 type=Label 的設備:TCP 連線檢測,Zebra | |||
| 機型另讀取內建里程表(odometer)。 | |||
| </Typography> | |||
| {offlineCount > 0 && ( | |||
| @@ -167,7 +249,11 @@ export default function LabelPrinterMonitorPanel({ active, refreshAt = 0 }: Prop | |||
| <Chip size="small" label={`標籤機 ${summary.total}`} /> | |||
| <Chip size="small" color="success" label={`正常 ${summary.online}`} /> | |||
| <Chip size="small" color="error" label={`離線 ${summary.offline}`} /> | |||
| <Chip size="small" color="warning" label={`未設定 IP ${summary.unconfigured}`} /> | |||
| <Chip | |||
| size="small" | |||
| color="warning" | |||
| label={`未設定 IP ${summary.unconfigured}`} | |||
| /> | |||
| {summary.unchecked > 0 && ( | |||
| <Chip size="small" label={`未檢查 ${summary.unchecked}`} /> | |||
| )} | |||
| @@ -188,18 +274,36 @@ export default function LabelPrinterMonitorPanel({ active, refreshAt = 0 }: Prop | |||
| <TableCell>品牌</TableCell> | |||
| <TableCell>IP:Port</TableCell> | |||
| <TableCell align="right"> | |||
| <Box component="span" sx={{ display: "inline-flex", alignItems: "center", gap: 0.5 }}> | |||
| <Box | |||
| component="span" | |||
| sx={{ | |||
| display: "inline-flex", | |||
| alignItems: "center", | |||
| gap: 0.5, | |||
| }} | |||
| > | |||
| 累計里程 | |||
| <Tooltip title="Zebra 印表機透過 TCP 9100 讀取 odometer.total_label_count"> | |||
| <InfoOutlined sx={{ fontSize: 16, color: "text.secondary" }} /> | |||
| <InfoOutlined | |||
| sx={{ fontSize: 16, color: "text.secondary" }} | |||
| /> | |||
| </Tooltip> | |||
| </Box> | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| <Box component="span" sx={{ display: "inline-flex", alignItems: "center", gap: 0.5 }}> | |||
| <Box | |||
| component="span" | |||
| sx={{ | |||
| display: "inline-flex", | |||
| alignItems: "center", | |||
| gap: 0.5, | |||
| }} | |||
| > | |||
| 自上次檢查 | |||
| <Tooltip title="本次 odometer 與上次記錄的差值(實際出標張數估算)"> | |||
| <InfoOutlined sx={{ fontSize: 16, color: "text.secondary" }} /> | |||
| <InfoOutlined | |||
| sx={{ fontSize: 16, color: "text.secondary" }} | |||
| /> | |||
| </Tooltip> | |||
| </Box> | |||
| </TableCell> | |||
| @@ -239,7 +343,11 @@ export default function LabelPrinterMonitorPanel({ active, refreshAt = 0 }: Prop | |||
| </Typography> | |||
| )} | |||
| {row.errorMessage && ( | |||
| <Typography variant="caption" display="block" color="error"> | |||
| <Typography | |||
| variant="caption" | |||
| display="block" | |||
| color="error" | |||
| > | |||
| {row.errorMessage} | |||
| </Typography> | |||
| )} | |||
| @@ -273,38 +381,110 @@ export default function LabelPrinterMonitorPanel({ active, refreshAt = 0 }: Prop | |||
| </TableContainer> | |||
| <Typography variant="subtitle2" fontWeight={600} sx={{ mt: 2 }}> | |||
| 應用層標籤列印(LABEL) | |||
| Zebra 里程表區間印量(估算) | |||
| </Typography> | |||
| <Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}> | |||
| 依監控掃描寫入的 odometer 快照估算(最長 {STATS_MAX_RANGE_DAYS} 天)。非 | |||
| Zebra 機型不提供區間印量。 | |||
| </Typography> | |||
| {labelStats && ( | |||
| <Box className="flex flex-wrap gap-2 mb-2"> | |||
| <Chip size="small" label={`今日 ${labelStats.todayTotal ?? 0} 張`} /> | |||
| <Chip size="small" variant="outlined" label={`區間 ${labelStats.rangeTotal ?? 0} 張`} /> | |||
| <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-hk"> | |||
| <Box className="flex flex-wrap items-end gap-3 mb-2"> | |||
| <DateTimePicker | |||
| label="開始時間" | |||
| views={["year", "month", "day", "hours", "minutes", "seconds"]} | |||
| format={STATS_DATETIME_FORMAT} | |||
| value={statsFrom} | |||
| onChange={(value) => { | |||
| if (value?.isValid()) setStatsFrom(value); | |||
| }} | |||
| slotProps={{ | |||
| textField: { size: "small", sx: { minWidth: 220 } }, | |||
| }} | |||
| /> | |||
| <DateTimePicker | |||
| label="結束時間" | |||
| views={["year", "month", "day", "hours", "minutes", "seconds"]} | |||
| format={STATS_DATETIME_FORMAT} | |||
| value={statsTo} | |||
| onChange={(value) => { | |||
| if (value?.isValid()) setStatsTo(value); | |||
| }} | |||
| slotProps={{ | |||
| textField: { size: "small", sx: { minWidth: 220 } }, | |||
| }} | |||
| /> | |||
| <Button | |||
| variant="outlined" | |||
| size="small" | |||
| startIcon={ | |||
| statsLoading ? <CircularProgress size={16} /> : <Search /> | |||
| } | |||
| disabled={statsLoading} | |||
| onClick={() => void fetchOdometerStats(statsFrom, statsTo)} | |||
| sx={{ mb: 0.25 }} | |||
| > | |||
| 查詢統計 | |||
| </Button> | |||
| </Box> | |||
| </LocalizationProvider> | |||
| {statsError && ( | |||
| <Alert severity="error" sx={{ mb: 1 }}> | |||
| {statsError} | |||
| </Alert> | |||
| )} | |||
| <TableContainer className="rounded-lg border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-800"> | |||
| <Table size="small"> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell>時間</TableCell> | |||
| <TableCell>工單</TableCell> | |||
| <TableCell align="right">數量</TableCell> | |||
| <TableCell>印表機</TableCell> | |||
| <TableCell>區間起點</TableCell> | |||
| <TableCell>區間終點</TableCell> | |||
| <TableCell align="right">區間印量(估算)</TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {!labelStats?.recentSubmits?.length ? ( | |||
| {statsLoading && !odometerStats?.printers?.length ? ( | |||
| <TableRow> | |||
| <TableCell colSpan={3} align="center" sx={{ py: 3 }}> | |||
| 尚無 LABEL 列印提交記錄 | |||
| <TableCell colSpan={4} align="center" sx={{ py: 3 }}> | |||
| <CircularProgress size={24} /> | |||
| </TableCell> | |||
| </TableRow> | |||
| ) : !odometerStats?.printers?.length ? ( | |||
| <TableRow> | |||
| <TableCell colSpan={4} align="center" sx={{ py: 3 }}> | |||
| 此區間內尚無 Zebra 里程表記錄 | |||
| </TableCell> | |||
| </TableRow> | |||
| ) : ( | |||
| labelStats.recentSubmits.map((row) => ( | |||
| <TableRow key={row.id ?? `${row.jobOrderId}-${String(row.created)}`} hover> | |||
| <TableCell>{formatApiDateTime(row.created)}</TableCell> | |||
| odometerStats.printers.map((row) => ( | |||
| <TableRow key={row.printerId} hover> | |||
| <TableCell> | |||
| {row.jobCode ?? (row.jobOrderId != null ? `#${row.jobOrderId}` : "—")} | |||
| <Typography variant="body2" fontWeight={600}> | |||
| {row.name || row.code || `#${row.printerId}`} | |||
| </Typography> | |||
| {row.code && row.name && ( | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {row.code} | |||
| </Typography> | |||
| )} | |||
| </TableCell> | |||
| <TableCell> | |||
| {row.startAt && row.startOdometer != null | |||
| ? `${formatApiDateTime( | |||
| row.startAt, | |||
| )} · ${row.startOdometer.toLocaleString()}` | |||
| : "—"} | |||
| </TableCell> | |||
| <TableCell> | |||
| {row.endAt && row.endOdometer != null | |||
| ? `${formatApiDateTime( | |||
| row.endAt, | |||
| )} · ${row.endOdometer.toLocaleString()}` | |||
| : "—"} | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| {row.delta != null ? row.delta.toLocaleString() : "—"} | |||
| </TableCell> | |||
| <TableCell align="right">{row.qty ?? "—"}</TableCell> | |||
| </TableRow> | |||
| )) | |||
| )} | |||