|
|
|
@@ -0,0 +1,322 @@ |
|
|
|
"use client"; |
|
|
|
|
|
|
|
import React, { useCallback, useEffect, useRef, useState } from "react"; |
|
|
|
import { |
|
|
|
Alert, |
|
|
|
Box, |
|
|
|
Chip, |
|
|
|
CircularProgress, |
|
|
|
Table, |
|
|
|
TableBody, |
|
|
|
TableCell, |
|
|
|
TableContainer, |
|
|
|
TableHead, |
|
|
|
TableRow, |
|
|
|
Tooltip, |
|
|
|
Typography, |
|
|
|
} from "@mui/material"; |
|
|
|
import InfoOutlined from "@mui/icons-material/InfoOutlined"; |
|
|
|
import { NEXT_PUBLIC_API_URL } from "@/config/api"; |
|
|
|
import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; |
|
|
|
import { arrayToDateTimeString } from "@/app/utils/formatUtil"; |
|
|
|
import dayjs from "dayjs"; |
|
|
|
|
|
|
|
function formatApiDateTime(value: unknown): string { |
|
|
|
if (value == null) return "—"; |
|
|
|
if (Array.isArray(value)) { |
|
|
|
return arrayToDateTimeString(value as (number | undefined)[]); |
|
|
|
} |
|
|
|
const d = dayjs(value as string | number); |
|
|
|
return d.isValid() ? d.format("YYYY-MM-DD HH:mm:ss") : "—"; |
|
|
|
} |
|
|
|
|
|
|
|
type LabelPrinterRow = { |
|
|
|
id: number; |
|
|
|
code?: string; |
|
|
|
name?: string; |
|
|
|
type?: string; |
|
|
|
brand?: string; |
|
|
|
ip?: string; |
|
|
|
port?: number; |
|
|
|
status: "online" | "offline" | "unconfigured" | "unchecked"; |
|
|
|
latencyMs?: number; |
|
|
|
errorMessage?: string; |
|
|
|
odometerTotal?: number | null; |
|
|
|
deltaSincePrevious?: number | null; |
|
|
|
hostStatusSnippet?: string; |
|
|
|
zebraOdometerEnabled?: boolean; |
|
|
|
lastCheckAt?: string; |
|
|
|
}; |
|
|
|
|
|
|
|
type LabelPrinterSummary = { |
|
|
|
total: number; |
|
|
|
online: number; |
|
|
|
offline: number; |
|
|
|
unconfigured: number; |
|
|
|
unchecked: number; |
|
|
|
}; |
|
|
|
|
|
|
|
type LabelSubmitRow = { |
|
|
|
id?: number; |
|
|
|
jobOrderId?: number; |
|
|
|
qty?: number; |
|
|
|
created?: unknown; |
|
|
|
jobCode?: string; |
|
|
|
}; |
|
|
|
|
|
|
|
type LabelStats = { |
|
|
|
todayTotal?: number; |
|
|
|
rangeTotal?: number; |
|
|
|
recentSubmits?: LabelSubmitRow[]; |
|
|
|
}; |
|
|
|
|
|
|
|
const STATUS_LABEL: Record< |
|
|
|
string, |
|
|
|
{ label: string; color: "success" | "warning" | "error" | "default" } |
|
|
|
> = { |
|
|
|
online: { label: "連線正常", color: "success" }, |
|
|
|
offline: { label: "離線", color: "error" }, |
|
|
|
unconfigured: { label: "未設定 IP", color: "warning" }, |
|
|
|
unchecked: { label: "未檢查", color: "default" }, |
|
|
|
}; |
|
|
|
|
|
|
|
type Props = { |
|
|
|
active: boolean; |
|
|
|
refreshAt?: number; |
|
|
|
}; |
|
|
|
|
|
|
|
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 [loading, setLoading] = useState(false); |
|
|
|
const [error, setError] = useState<string | null>(null); |
|
|
|
const inFlightRef = useRef(false); |
|
|
|
|
|
|
|
const runCheck = useCallback(async () => { |
|
|
|
if (inFlightRef.current) return; |
|
|
|
inFlightRef.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" }, |
|
|
|
), |
|
|
|
]); |
|
|
|
if (checkRes.status === 401 || checkRes.status === 403) return; |
|
|
|
if (!checkRes.ok) { |
|
|
|
throw new Error(`check HTTP ${checkRes.status}`); |
|
|
|
} |
|
|
|
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; |
|
|
|
} |
|
|
|
}, []); |
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
if (!active) return; |
|
|
|
void runCheck(); |
|
|
|
const id = window.setInterval(() => void runCheck(), 120_000); |
|
|
|
return () => window.clearInterval(id); |
|
|
|
}, [active, runCheck]); |
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
if (!active || refreshAt <= 0) return; |
|
|
|
void runCheck(); |
|
|
|
}, [refreshAt, active, runCheck]); |
|
|
|
|
|
|
|
const offlineCount = summary?.offline ?? 0; |
|
|
|
const isZebra = (brand?: string) => (brand ?? "").toLowerCase().includes("zebra"); |
|
|
|
|
|
|
|
return ( |
|
|
|
<div className="space-y-4 mt-6"> |
|
|
|
<Typography variant="h6" fontWeight={600}> |
|
|
|
標籤印表機監控(In Development) |
|
|
|
</Typography> |
|
|
|
<Typography variant="body2" color="text.secondary"> |
|
|
|
監控「印表機」設定中 type=Label 的設備:TCP 連線檢測,Zebra 機型另讀取內建里程表(odometer)。 |
|
|
|
應用層列印統計來自 Bag3 標籤機提交(LABEL channel),不含網頁直接列印。 |
|
|
|
</Typography> |
|
|
|
|
|
|
|
{offlineCount > 0 && ( |
|
|
|
<Alert severity="warning"> |
|
|
|
有 {offlineCount} 台標籤印表機無法連線,請檢查電源、網路或 IP/Port。 |
|
|
|
</Alert> |
|
|
|
)} |
|
|
|
|
|
|
|
{summary && ( |
|
|
|
<Box className="flex flex-wrap gap-2"> |
|
|
|
<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}`} /> |
|
|
|
{summary.unchecked > 0 && ( |
|
|
|
<Chip size="small" label={`未檢查 ${summary.unchecked}`} /> |
|
|
|
)} |
|
|
|
</Box> |
|
|
|
)} |
|
|
|
|
|
|
|
{error && <Alert severity="error">{error}</Alert>} |
|
|
|
|
|
|
|
<Typography variant="subtitle2" fontWeight={600}> |
|
|
|
標籤印表機狀態 |
|
|
|
</Typography> |
|
|
|
<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>品牌</TableCell> |
|
|
|
<TableCell>IP:Port</TableCell> |
|
|
|
<TableCell align="right"> |
|
|
|
<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" }} /> |
|
|
|
</Tooltip> |
|
|
|
</Box> |
|
|
|
</TableCell> |
|
|
|
<TableCell align="right"> |
|
|
|
<Box component="span" sx={{ display: "inline-flex", alignItems: "center", gap: 0.5 }}> |
|
|
|
自上次檢查 |
|
|
|
<Tooltip title="本次 odometer 與上次記錄的差值(實際出標張數估算)"> |
|
|
|
<InfoOutlined sx={{ fontSize: 16, color: "text.secondary" }} /> |
|
|
|
</Tooltip> |
|
|
|
</Box> |
|
|
|
</TableCell> |
|
|
|
<TableCell align="right">延遲 (ms)</TableCell> |
|
|
|
<TableCell>最後檢查</TableCell> |
|
|
|
</TableRow> |
|
|
|
</TableHead> |
|
|
|
<TableBody> |
|
|
|
{loading && printers.length === 0 ? ( |
|
|
|
<TableRow> |
|
|
|
<TableCell colSpan={8} align="center" sx={{ py: 4 }}> |
|
|
|
<CircularProgress size={28} /> |
|
|
|
</TableCell> |
|
|
|
</TableRow> |
|
|
|
) : printers.length === 0 ? ( |
|
|
|
<TableRow> |
|
|
|
<TableCell colSpan={8} align="center" sx={{ py: 4 }}> |
|
|
|
沒有 Label 類型印表機 |
|
|
|
</TableCell> |
|
|
|
</TableRow> |
|
|
|
) : ( |
|
|
|
printers.map((row) => { |
|
|
|
const st = STATUS_LABEL[row.status] ?? STATUS_LABEL.unchecked; |
|
|
|
const showOdometer = isZebra(row.brand); |
|
|
|
return ( |
|
|
|
<TableRow key={row.id} hover> |
|
|
|
<TableCell> |
|
|
|
<Chip size="small" color={st.color} label={st.label} /> |
|
|
|
</TableCell> |
|
|
|
<TableCell> |
|
|
|
<Typography variant="body2" fontWeight={600}> |
|
|
|
{row.name || row.code || `#${row.id}`} |
|
|
|
</Typography> |
|
|
|
{row.code && row.name && ( |
|
|
|
<Typography variant="caption" color="text.secondary"> |
|
|
|
{row.code} |
|
|
|
</Typography> |
|
|
|
)} |
|
|
|
{row.errorMessage && ( |
|
|
|
<Typography variant="caption" display="block" color="error"> |
|
|
|
{row.errorMessage} |
|
|
|
</Typography> |
|
|
|
)} |
|
|
|
</TableCell> |
|
|
|
<TableCell>{row.brand ?? "—"}</TableCell> |
|
|
|
<TableCell> |
|
|
|
{row.ip ? `${row.ip}:${row.port ?? 9100}` : "—"} |
|
|
|
</TableCell> |
|
|
|
<TableCell align="right"> |
|
|
|
{showOdometer |
|
|
|
? row.odometerTotal != null |
|
|
|
? row.odometerTotal.toLocaleString() |
|
|
|
: "—" |
|
|
|
: "—"} |
|
|
|
</TableCell> |
|
|
|
<TableCell align="right"> |
|
|
|
{showOdometer && row.deltaSincePrevious != null |
|
|
|
? row.deltaSincePrevious.toLocaleString() |
|
|
|
: "—"} |
|
|
|
</TableCell> |
|
|
|
<TableCell align="right"> |
|
|
|
{row.latencyMs != null ? row.latencyMs : "—"} |
|
|
|
</TableCell> |
|
|
|
<TableCell>{formatApiDateTime(row.lastCheckAt)}</TableCell> |
|
|
|
</TableRow> |
|
|
|
); |
|
|
|
}) |
|
|
|
)} |
|
|
|
</TableBody> |
|
|
|
</Table> |
|
|
|
</TableContainer> |
|
|
|
|
|
|
|
<Typography variant="subtitle2" fontWeight={600} sx={{ mt: 2 }}> |
|
|
|
應用層標籤列印(LABEL) |
|
|
|
</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} 張`} /> |
|
|
|
</Box> |
|
|
|
)} |
|
|
|
<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> |
|
|
|
</TableRow> |
|
|
|
</TableHead> |
|
|
|
<TableBody> |
|
|
|
{!labelStats?.recentSubmits?.length ? ( |
|
|
|
<TableRow> |
|
|
|
<TableCell colSpan={3} align="center" sx={{ py: 3 }}> |
|
|
|
尚無 LABEL 列印提交記錄 |
|
|
|
</TableCell> |
|
|
|
</TableRow> |
|
|
|
) : ( |
|
|
|
labelStats.recentSubmits.map((row) => ( |
|
|
|
<TableRow key={row.id ?? `${row.jobOrderId}-${String(row.created)}`} hover> |
|
|
|
<TableCell>{formatApiDateTime(row.created)}</TableCell> |
|
|
|
<TableCell> |
|
|
|
{row.jobCode ?? (row.jobOrderId != null ? `#${row.jobOrderId}` : "—")} |
|
|
|
</TableCell> |
|
|
|
<TableCell align="right">{row.qty ?? "—"}</TableCell> |
|
|
|
</TableRow> |
|
|
|
)) |
|
|
|
)} |
|
|
|
</TableBody> |
|
|
|
</Table> |
|
|
|
</TableContainer> |
|
|
|
|
|
|
|
{loading && printers.length > 0 && ( |
|
|
|
<Box className="flex justify-center py-2"> |
|
|
|
<CircularProgress size={24} /> |
|
|
|
</Box> |
|
|
|
)} |
|
|
|
</div> |
|
|
|
); |
|
|
|
} |