| @@ -4,6 +4,7 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; | |||||
| import { | import { | ||||
| Alert, | Alert, | ||||
| Box, | Box, | ||||
| Button, | |||||
| Chip, | Chip, | ||||
| CircularProgress, | CircularProgress, | ||||
| Table, | Table, | ||||
| @@ -16,10 +17,19 @@ import { | |||||
| Typography, | Typography, | ||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import InfoOutlined from "@mui/icons-material/InfoOutlined"; | import InfoOutlined from "@mui/icons-material/InfoOutlined"; | ||||
| import Search from "@mui/icons-material/Search"; | |||||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | import { NEXT_PUBLIC_API_URL } from "@/config/api"; | ||||
| import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | 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 { | function formatApiDateTime(value: unknown): string { | ||||
| if (value == null) return "—"; | if (value == null) return "—"; | ||||
| @@ -56,20 +66,40 @@ type LabelPrinterSummary = { | |||||
| unchecked: number; | 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< | const STATUS_LABEL: Record< | ||||
| string, | string, | ||||
| { label: string; color: "success" | "warning" | "error" | "default" } | { label: string; color: "success" | "warning" | "error" | "default" } | ||||
| @@ -85,31 +115,79 @@ type Props = { | |||||
| refreshAt?: number; | refreshAt?: number; | ||||
| }; | }; | ||||
| export default function LabelPrinterMonitorPanel({ active, refreshAt = 0 }: Props) { | |||||
| export default function LabelPrinterMonitorPanel({ | |||||
| active, | |||||
| refreshAt = 0, | |||||
| }: Props) { | |||||
| const [printers, setPrinters] = useState<LabelPrinterRow[]>([]); | const [printers, setPrinters] = useState<LabelPrinterRow[]>([]); | ||||
| const [summary, setSummary] = useState<LabelPrinterSummary | null>(null); | 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 [loading, setLoading] = useState(false); | ||||
| const [statsLoading, setStatsLoading] = useState(false); | |||||
| const [error, setError] = useState<string | null>(null); | 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); | setLoading(true); | ||||
| setError(null); | setError(null); | ||||
| try { | 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.status === 401 || checkRes.status === 403) return; | ||||
| if (!checkRes.ok) { | if (!checkRes.ok) { | ||||
| throw new Error(`check HTTP ${checkRes.status}`); | throw new Error(`check HTTP ${checkRes.status}`); | ||||
| @@ -117,34 +195,38 @@ export default function LabelPrinterMonitorPanel({ active, refreshAt = 0 }: Prop | |||||
| const checkData = await checkRes.json(); | const checkData = await checkRes.json(); | ||||
| setPrinters(Array.isArray(checkData.printers) ? checkData.printers : []); | setPrinters(Array.isArray(checkData.printers) ? checkData.printers : []); | ||||
| setSummary(checkData.summary ?? null); | setSummary(checkData.summary ?? null); | ||||
| if (checkData.labelStats) { | |||||
| setLabelStats(checkData.labelStats); | |||||
| } else if (statsRes.ok) { | |||||
| setLabelStats(await statsRes.json()); | |||||
| } | |||||
| } catch (e) { | } catch (e) { | ||||
| console.error("label printer monitor check", e); | console.error("label printer monitor check", e); | ||||
| setError("無法檢查標籤印表機狀態"); | setError("無法檢查標籤印表機狀態"); | ||||
| } finally { | } finally { | ||||
| setLoading(false); | setLoading(false); | ||||
| inFlightRef.current = false; | |||||
| checkInFlightRef.current = false; | |||||
| } | } | ||||
| }, []); | }, []); | ||||
| const runFullRefresh = useCallback(async () => { | |||||
| await Promise.all([ | |||||
| runPrinterCheck(), | |||||
| fetchOdometerStats(statsFrom, statsTo), | |||||
| ]); | |||||
| }, [runPrinterCheck, fetchOdometerStats, statsFrom, statsTo]); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| if (!active) return; | 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); | return () => window.clearInterval(id); | ||||
| }, [active, runCheck]); | |||||
| }, [active, runPrinterCheck, fetchOdometerStats]); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| if (!active || refreshAt <= 0) return; | if (!active || refreshAt <= 0) return; | ||||
| void runCheck(); | |||||
| }, [refreshAt, active, runCheck]); | |||||
| void runFullRefresh(); | |||||
| }, [refreshAt, active, runFullRefresh]); | |||||
| const offlineCount = summary?.offline ?? 0; | const offlineCount = summary?.offline ?? 0; | ||||
| const isZebra = (brand?: string) => (brand ?? "").toLowerCase().includes("zebra"); | |||||
| const isZebra = (brand?: string) => | |||||
| (brand ?? "").toLowerCase().includes("zebra"); | |||||
| return ( | return ( | ||||
| <div className="space-y-4 mt-6"> | <div className="space-y-4 mt-6"> | ||||
| @@ -152,8 +234,8 @@ export default function LabelPrinterMonitorPanel({ active, refreshAt = 0 }: Prop | |||||
| 標籤印表機監控(In Development) | 標籤印表機監控(In Development) | ||||
| </Typography> | </Typography> | ||||
| <Typography variant="body2" color="text.secondary"> | <Typography variant="body2" color="text.secondary"> | ||||
| 監控「印表機」設定中 type=Label 的設備:TCP 連線檢測,Zebra 機型另讀取內建里程表(odometer)。 | |||||
| 應用層列印統計來自 Bag3 標籤機提交(LABEL channel),不含網頁直接列印。 | |||||
| 監控「印表機」設定中 type=Label 的設備:TCP 連線檢測,Zebra | |||||
| 機型另讀取內建里程表(odometer)。 | |||||
| </Typography> | </Typography> | ||||
| {offlineCount > 0 && ( | {offlineCount > 0 && ( | ||||
| @@ -167,7 +249,11 @@ export default function LabelPrinterMonitorPanel({ active, refreshAt = 0 }: Prop | |||||
| <Chip size="small" label={`標籤機 ${summary.total}`} /> | <Chip size="small" label={`標籤機 ${summary.total}`} /> | ||||
| <Chip size="small" color="success" label={`正常 ${summary.online}`} /> | <Chip size="small" color="success" label={`正常 ${summary.online}`} /> | ||||
| <Chip size="small" color="error" label={`離線 ${summary.offline}`} /> | <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 && ( | {summary.unchecked > 0 && ( | ||||
| <Chip size="small" label={`未檢查 ${summary.unchecked}`} /> | <Chip size="small" label={`未檢查 ${summary.unchecked}`} /> | ||||
| )} | )} | ||||
| @@ -188,18 +274,36 @@ export default function LabelPrinterMonitorPanel({ active, refreshAt = 0 }: Prop | |||||
| <TableCell>品牌</TableCell> | <TableCell>品牌</TableCell> | ||||
| <TableCell>IP:Port</TableCell> | <TableCell>IP:Port</TableCell> | ||||
| <TableCell align="right"> | <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"> | <Tooltip title="Zebra 印表機透過 TCP 9100 讀取 odometer.total_label_count"> | ||||
| <InfoOutlined sx={{ fontSize: 16, color: "text.secondary" }} /> | |||||
| <InfoOutlined | |||||
| sx={{ fontSize: 16, color: "text.secondary" }} | |||||
| /> | |||||
| </Tooltip> | </Tooltip> | ||||
| </Box> | </Box> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell align="right"> | <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 與上次記錄的差值(實際出標張數估算)"> | <Tooltip title="本次 odometer 與上次記錄的差值(實際出標張數估算)"> | ||||
| <InfoOutlined sx={{ fontSize: 16, color: "text.secondary" }} /> | |||||
| <InfoOutlined | |||||
| sx={{ fontSize: 16, color: "text.secondary" }} | |||||
| /> | |||||
| </Tooltip> | </Tooltip> | ||||
| </Box> | </Box> | ||||
| </TableCell> | </TableCell> | ||||
| @@ -239,7 +343,11 @@ export default function LabelPrinterMonitorPanel({ active, refreshAt = 0 }: Prop | |||||
| </Typography> | </Typography> | ||||
| )} | )} | ||||
| {row.errorMessage && ( | {row.errorMessage && ( | ||||
| <Typography variant="caption" display="block" color="error"> | |||||
| <Typography | |||||
| variant="caption" | |||||
| display="block" | |||||
| color="error" | |||||
| > | |||||
| {row.errorMessage} | {row.errorMessage} | ||||
| </Typography> | </Typography> | ||||
| )} | )} | ||||
| @@ -273,38 +381,110 @@ export default function LabelPrinterMonitorPanel({ active, refreshAt = 0 }: Prop | |||||
| </TableContainer> | </TableContainer> | ||||
| <Typography variant="subtitle2" fontWeight={600} sx={{ mt: 2 }}> | <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> | </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> | </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"> | <TableContainer className="rounded-lg border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-800"> | ||||
| <Table size="small"> | <Table size="small"> | ||||
| <TableHead> | <TableHead> | ||||
| <TableRow> | <TableRow> | ||||
| <TableCell>時間</TableCell> | |||||
| <TableCell>工單</TableCell> | |||||
| <TableCell align="right">數量</TableCell> | |||||
| <TableCell>印表機</TableCell> | |||||
| <TableCell>區間起點</TableCell> | |||||
| <TableCell>區間終點</TableCell> | |||||
| <TableCell align="right">區間印量(估算)</TableCell> | |||||
| </TableRow> | </TableRow> | ||||
| </TableHead> | </TableHead> | ||||
| <TableBody> | <TableBody> | ||||
| {!labelStats?.recentSubmits?.length ? ( | |||||
| {statsLoading && !odometerStats?.printers?.length ? ( | |||||
| <TableRow> | <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> | </TableCell> | ||||
| </TableRow> | </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> | <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> | ||||
| <TableCell align="right">{row.qty ?? "—"}</TableCell> | |||||
| </TableRow> | </TableRow> | ||||
| )) | )) | ||||
| )} | )} | ||||