Browse Source

label printer tracking update

production
tommy 4 days ago
parent
commit
633be96898
1 changed files with 245 additions and 65 deletions
  1. +245
    -65
      src/components/ClientMonitor/LabelPrinterMonitorPanel.tsx

+ 245
- 65
src/components/ClientMonitor/LabelPrinterMonitorPanel.tsx View File

@@ -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>
))
)}


Loading…
Cancel
Save