| @@ -9,6 +9,8 @@ import { SetupAxiosInterceptors } from "@/app/(main)/axios/axiosInstance"; | |||||
| import { UploadProvider } from "@/components/UploadProvider/UploadProvider"; | import { UploadProvider } from "@/components/UploadProvider/UploadProvider"; | ||||
| import SessionProviderWrapper from "@/components/SessionProviderWrapper/SessionProviderWrapper"; | import SessionProviderWrapper from "@/components/SessionProviderWrapper/SessionProviderWrapper"; | ||||
| import QrCodeScannerProvider from "@/components/QrCodeScannerProvider/QrCodeScannerProvider"; | import QrCodeScannerProvider from "@/components/QrCodeScannerProvider/QrCodeScannerProvider"; | ||||
| import DevicePresenceReporterHost from "@/components/DevicePresence/DevicePresenceReporterHost"; | |||||
| import { isMonitoringEnabled } from "@/config/monitoring"; | |||||
| import { I18nProvider } from "@/i18n"; | import { I18nProvider } from "@/i18n"; | ||||
| import "src/app/global.css"; | import "src/app/global.css"; | ||||
| export default async function MainLayout({ | export default async function MainLayout({ | ||||
| @@ -33,6 +35,7 @@ export default async function MainLayout({ | |||||
| return ( | return ( | ||||
| <SessionProviderWrapper session={session}> | <SessionProviderWrapper session={session}> | ||||
| {isMonitoringEnabled && <DevicePresenceReporterHost />} | |||||
| <UploadProvider> | <UploadProvider> | ||||
| {/* <CameraProvider> */} | {/* <CameraProvider> */} | ||||
| <AxiosProvider> | <AxiosProvider> | ||||
| @@ -9,6 +9,7 @@ import Download from "@mui/icons-material/Download"; | |||||
| import Hub from "@mui/icons-material/Hub"; | import Hub from "@mui/icons-material/Hub"; | ||||
| import Settings from "@mui/icons-material/Settings"; | import Settings from "@mui/icons-material/Settings"; | ||||
| import Clear from "@mui/icons-material/Clear"; | import Clear from "@mui/icons-material/Clear"; | ||||
| import Refresh from "@mui/icons-material/Refresh"; | |||||
| import { CircularProgress } from "@mui/material"; | import { CircularProgress } from "@mui/material"; | ||||
| import PageTitleBar from "@/components/PageTitleBar"; | import PageTitleBar from "@/components/PageTitleBar"; | ||||
| import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
| @@ -50,6 +51,16 @@ export default function ProductionSchedulePage() { | |||||
| dayjs().format("YYYY-MM-DD") | dayjs().format("YYYY-MM-DD") | ||||
| ); | ); | ||||
| const [isDoQtyExportDialogOpen, setIsDoQtyExportDialogOpen] = useState(false); | |||||
| const [doQtyExportFromDate, setDoQtyExportFromDate] = useState( | |||||
| dayjs().subtract(6, "day").format("YYYY-MM-DD") | |||||
| ); | |||||
| const [doQtyExportToDate, setDoQtyExportToDate] = useState( | |||||
| dayjs().format("YYYY-MM-DD") | |||||
| ); | |||||
| const [isDoQtyExporting, setIsDoQtyExporting] = useState(false); | |||||
| const doQtyExportInFlightRef = useRef(false); | |||||
| const [isDailyOutPanelOpen, setIsDailyOutPanelOpen] = useState(false); | const [isDailyOutPanelOpen, setIsDailyOutPanelOpen] = useState(false); | ||||
| const [itemDailyOutList, setItemDailyOutList] = useState<ItemDailyOutRow[]>([]); | const [itemDailyOutList, setItemDailyOutList] = useState<ItemDailyOutRow[]>([]); | ||||
| const [itemDailyOutLoading, setItemDailyOutLoading] = useState(false); | const [itemDailyOutLoading, setItemDailyOutLoading] = useState(false); | ||||
| @@ -59,6 +70,8 @@ export default function ProductionSchedulePage() { | |||||
| const [fakeOnHandSavingCode, setFakeOnHandSavingCode] = useState<string | null>(null); | const [fakeOnHandSavingCode, setFakeOnHandSavingCode] = useState<string | null>(null); | ||||
| const [fakeOnHandClearingCode, setFakeOnHandClearingCode] = useState<string | null>(null); | const [fakeOnHandClearingCode, setFakeOnHandClearingCode] = useState<string | null>(null); | ||||
| const [isImportingFakeOnHand, setIsImportingFakeOnHand] = useState(false); | const [isImportingFakeOnHand, setIsImportingFakeOnHand] = useState(false); | ||||
| const [isRefreshingOnHand, setIsRefreshingOnHand] = useState(false); | |||||
| const refreshOnHandInFlightRef = useRef(false); | |||||
| const itemDailyOutRequestRef = useRef(0); | const itemDailyOutRequestRef = useRef(0); | ||||
| useEffect(() => { | useEffect(() => { | ||||
| @@ -125,6 +138,51 @@ export default function ProductionSchedulePage() { | |||||
| } | } | ||||
| }; | }; | ||||
| const handleConfirmDoQtyExport = async () => { | |||||
| if (doQtyExportInFlightRef.current) return; | |||||
| if (!doQtyExportFromDate || !doQtyExportToDate) { | |||||
| alert("請選擇開始及結束日期。"); | |||||
| return; | |||||
| } | |||||
| if (dayjs(doQtyExportFromDate).isAfter(dayjs(doQtyExportToDate))) { | |||||
| alert("開始日期不可晚於結束日期。"); | |||||
| return; | |||||
| } | |||||
| doQtyExportInFlightRef.current = true; | |||||
| setIsDoQtyExporting(true); | |||||
| setIsDoQtyExportDialogOpen(false); | |||||
| try { | |||||
| const params = new URLSearchParams({ | |||||
| fromDate: doQtyExportFromDate, | |||||
| toDate: doQtyExportToDate, | |||||
| }); | |||||
| const response = await clientAuthFetch( | |||||
| `${NEXT_PUBLIC_API_URL}/ps/export-do-qty-by-date?${params.toString()}`, | |||||
| { method: "GET" } | |||||
| ); | |||||
| if (response.status === 401 || response.status === 403) return; | |||||
| if (!response.ok) { | |||||
| const errText = await response.text().catch(() => ""); | |||||
| throw new Error(errText || `Export failed: ${response.status}`); | |||||
| } | |||||
| const blob = await response.blob(); | |||||
| const url = window.URL.createObjectURL(blob); | |||||
| const a = document.createElement("a"); | |||||
| a.href = url; | |||||
| a.download = `do_qty_${doQtyExportFromDate.replace(/-/g, "")}_${doQtyExportToDate.replace(/-/g, "")}.xlsx`; | |||||
| document.body.appendChild(a); | |||||
| a.click(); | |||||
| window.URL.revokeObjectURL(url); | |||||
| document.body.removeChild(a); | |||||
| } catch (e) { | |||||
| console.error("DO qty export Error:", e); | |||||
| alert("匯出送貨單數量失敗。"); | |||||
| } finally { | |||||
| setIsDoQtyExporting(false); | |||||
| doQtyExportInFlightRef.current = false; | |||||
| } | |||||
| }; | |||||
| const handleConfirmExport = async () => { | const handleConfirmExport = async () => { | ||||
| if (!exportFromDate) { | if (!exportFromDate) { | ||||
| alert("Please select a from date."); | alert("Please select a from date."); | ||||
| @@ -274,6 +332,32 @@ export default function ProductionSchedulePage() { | |||||
| fetchItemDailyOut(); | fetchItemDailyOut(); | ||||
| }; | }; | ||||
| const handleRefreshInventoryOnHand = async () => { | |||||
| if (refreshOnHandInFlightRef.current) return; | |||||
| refreshOnHandInFlightRef.current = true; | |||||
| setIsRefreshingOnHand(true); | |||||
| try { | |||||
| const response = await clientAuthFetch( | |||||
| `${NEXT_PUBLIC_API_URL}/ps/refresh-inventory-onhand`, | |||||
| { method: "POST" } | |||||
| ); | |||||
| if (response.status === 401 || response.status === 403) return; | |||||
| if (!response.ok) { | |||||
| const msg = await response.text().catch(() => ""); | |||||
| alert(`刷新庫存失敗(${response.status})${msg ? `: ${msg.slice(0, 120)}` : ""}`); | |||||
| return; | |||||
| } | |||||
| await response.json().catch(() => null); | |||||
| await fetchItemDailyOut(true); | |||||
| } catch (e) { | |||||
| console.error("refreshInventoryOnHand Error:", e); | |||||
| alert("刷新庫存失敗。"); | |||||
| } finally { | |||||
| setIsRefreshingOnHand(false); | |||||
| refreshOnHandInFlightRef.current = false; | |||||
| } | |||||
| }; | |||||
| /** Download current fake on-hand overrides (item_fake_onhand) as Excel template. */ | /** Download current fake on-hand overrides (item_fake_onhand) as Excel template. */ | ||||
| const handleExportFakeOnHand = () => { | const handleExportFakeOnHand = () => { | ||||
| const rows = itemDailyOutList | const rows = itemDailyOutList | ||||
| @@ -554,6 +638,19 @@ export default function ProductionSchedulePage() { | |||||
| <Download sx={{ fontSize: 16 }} /> | <Download sx={{ fontSize: 16 }} /> | ||||
| 匯出計劃/物料需求Excel | 匯出計劃/物料需求Excel | ||||
| </button> | </button> | ||||
| <button | |||||
| type="button" | |||||
| onClick={() => setIsDoQtyExportDialogOpen(true)} | |||||
| disabled={isDoQtyExporting} | |||||
| className="inline-flex items-center gap-2 rounded-lg border border-violet-500/70 bg-white px-4 py-2 text-sm font-semibold text-violet-600 shadow-sm transition hover:bg-violet-50 disabled:opacity-50 dark:border-violet-500/50 dark:bg-slate-800 dark:text-violet-400 dark:hover:bg-violet-500/10" | |||||
| > | |||||
| {isDoQtyExporting ? ( | |||||
| <CircularProgress size={16} sx={{ display: "block" }} /> | |||||
| ) : ( | |||||
| <Download sx={{ fontSize: 16 }} /> | |||||
| )} | |||||
| 匯出送貨單數量 | |||||
| </button> | |||||
| <button | <button | ||||
| type="button" | type="button" | ||||
| onClick={() => setIsForecastDialogOpen(true)} | onClick={() => setIsForecastDialogOpen(true)} | ||||
| @@ -858,6 +955,87 @@ export default function ProductionSchedulePage() { | |||||
| </div> | </div> | ||||
| )} | )} | ||||
| {/* DO Qty Export Dialog */} | |||||
| {isDoQtyExportDialogOpen && ( | |||||
| <div | |||||
| className="fixed inset-0 z-[1300] flex items-center justify-center p-4" | |||||
| role="dialog" | |||||
| aria-modal="true" | |||||
| > | |||||
| <div | |||||
| className="absolute inset-0 bg-black/50" | |||||
| onClick={() => !isDoQtyExporting && setIsDoQtyExportDialogOpen(false)} | |||||
| /> | |||||
| <div className="relative z-10 w-full max-w-sm rounded-lg border border-slate-200 bg-white p-4 shadow-xl dark:border-slate-700 dark:bg-slate-800"> | |||||
| <h3 className="mb-2 text-lg font-semibold text-slate-900 dark:text-white"> | |||||
| 匯出送貨單數量 | |||||
| </h3> | |||||
| <p className="mb-4 text-sm text-slate-600 dark:text-slate-400"> | |||||
| 選擇日期範圍(僅含具 BOM 的物料,數量為庫存單位) | |||||
| </p> | |||||
| <div className="flex flex-col gap-4"> | |||||
| <div> | |||||
| <label | |||||
| htmlFor="do-qty-export-from" | |||||
| className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300" | |||||
| > | |||||
| 開始日期 | |||||
| </label> | |||||
| <input | |||||
| id="do-qty-export-from" | |||||
| type="date" | |||||
| value={doQtyExportFromDate} | |||||
| onChange={(e) => setDoQtyExportFromDate(e.target.value)} | |||||
| className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-900 focus:border-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-500/20 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100" | |||||
| /> | |||||
| </div> | |||||
| <div> | |||||
| <label | |||||
| htmlFor="do-qty-export-to" | |||||
| className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300" | |||||
| > | |||||
| 結束日期 | |||||
| </label> | |||||
| <input | |||||
| id="do-qty-export-to" | |||||
| type="date" | |||||
| value={doQtyExportToDate} | |||||
| onChange={(e) => setDoQtyExportToDate(e.target.value)} | |||||
| className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-900 focus:border-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-500/20 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100" | |||||
| /> | |||||
| </div> | |||||
| </div> | |||||
| <div className="mt-6 flex justify-end gap-2"> | |||||
| <button | |||||
| type="button" | |||||
| onClick={() => setIsDoQtyExportDialogOpen(false)} | |||||
| disabled={isDoQtyExporting} | |||||
| className="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm text-slate-700 hover:bg-slate-100 disabled:opacity-50 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-300 dark:hover:bg-slate-600" | |||||
| > | |||||
| 取消 | |||||
| </button> | |||||
| <button | |||||
| type="button" | |||||
| onClick={handleConfirmDoQtyExport} | |||||
| disabled={ | |||||
| !doQtyExportFromDate || | |||||
| !doQtyExportToDate || | |||||
| isDoQtyExporting | |||||
| } | |||||
| className="inline-flex items-center gap-2 rounded-lg bg-violet-500 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-violet-600 disabled:opacity-50" | |||||
| > | |||||
| {isDoQtyExporting ? ( | |||||
| <CircularProgress size={16} sx={{ display: "block" }} /> | |||||
| ) : ( | |||||
| <Download sx={{ fontSize: 16 }} /> | |||||
| )} | |||||
| 匯出 | |||||
| </button> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| )} | |||||
| {/* Export Dialog */} | {/* Export Dialog */} | ||||
| {isExportDialogOpen && ( | {isExportDialogOpen && ( | ||||
| <div | <div | ||||
| @@ -937,6 +1115,19 @@ export default function ProductionSchedulePage() { | |||||
| 排期設定 | 排期設定 | ||||
| </h2> | </h2> | ||||
| <div className="flex items-center gap-2"> | <div className="flex items-center gap-2"> | ||||
| <button | |||||
| type="button" | |||||
| onClick={() => void handleRefreshInventoryOnHand()} | |||||
| disabled={isRefreshingOnHand || itemDailyOutLoading} | |||||
| className="inline-flex items-center gap-1 rounded border border-sky-500/80 bg-sky-50 px-3 py-1 text-xs font-semibold text-sky-700 shadow-sm hover:bg-sky-100 disabled:opacity-50 dark:border-sky-400/80 dark:bg-slate-800 dark:text-sky-300 dark:hover:bg-sky-500/20" | |||||
| > | |||||
| {isRefreshingOnHand ? ( | |||||
| <CircularProgress size={14} sx={{ display: "block" }} /> | |||||
| ) : ( | |||||
| <Refresh sx={{ fontSize: 14 }} /> | |||||
| )} | |||||
| 刷新系統庫存 | |||||
| </button> | |||||
| <button | <button | ||||
| type="button" | type="button" | ||||
| onClick={handleExportFakeOnHand} | onClick={handleExportFakeOnHand} | ||||
| @@ -0,0 +1,15 @@ | |||||
| import ClientMonitorPage from "@/components/ClientMonitor/ClientMonitorPage"; | |||||
| import { isMonitoringEnabled } from "@/config/monitoring"; | |||||
| import { Metadata } from "next"; | |||||
| import { redirect } from "next/navigation"; | |||||
| export const metadata: Metadata = { | |||||
| title: "裝置連線監控", | |||||
| }; | |||||
| export default function ClientMonitorRoutePage() { | |||||
| if (!isMonitoringEnabled) { | |||||
| redirect("/settings/user"); | |||||
| } | |||||
| return <ClientMonitorPage />; | |||||
| } | |||||
| @@ -0,0 +1,572 @@ | |||||
| "use client"; | |||||
| import React, { useCallback, useEffect, useRef, useState } from "react"; | |||||
| import { | |||||
| Alert, | |||||
| Box, | |||||
| Chip, | |||||
| CircularProgress, | |||||
| FormControl, | |||||
| FormControlLabel, | |||||
| Checkbox, | |||||
| InputLabel, | |||||
| MenuItem, | |||||
| Select, | |||||
| Tab, | |||||
| Tabs, | |||||
| Table, | |||||
| TableBody, | |||||
| TableCell, | |||||
| TableContainer, | |||||
| TableHead, | |||||
| TableRow, | |||||
| Typography, | |||||
| } from "@mui/material"; | |||||
| import Refresh from "@mui/icons-material/Refresh"; | |||||
| import Search from "@mui/icons-material/Search"; | |||||
| import PageTitleBar from "@/components/PageTitleBar"; | |||||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||||
| import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | |||||
| import dayjs from "dayjs"; | |||||
| import PrinterMonitorTab from "@/components/ClientMonitor/PrinterMonitorTab"; | |||||
| type ClientRow = { | |||||
| deviceId: string; | |||||
| username?: string; | |||||
| displayName?: string; | |||||
| currentPath?: string; | |||||
| clientType?: string; | |||||
| rttMs?: number; | |||||
| connectionQuality?: string; | |||||
| navigatorOnline?: boolean | number; | |||||
| lastHeartbeat?: string; | |||||
| lastActivity?: string; | |||||
| status: "online" | "idle" | "offline" | "poor"; | |||||
| secondsSinceHeartbeat?: number; | |||||
| }; | |||||
| type HistoryRow = { | |||||
| id?: number; | |||||
| deviceId: string; | |||||
| username?: string; | |||||
| displayName?: string; | |||||
| clientType?: string; | |||||
| status: string; | |||||
| rttMs?: number; | |||||
| currentPath?: string; | |||||
| recordedAt?: string; | |||||
| }; | |||||
| type Summary = { | |||||
| total: number; | |||||
| offline: number; | |||||
| poor: number; | |||||
| online: number; | |||||
| idle: number; | |||||
| }; | |||||
| type HistorySummary = { | |||||
| total: number; | |||||
| offline: number; | |||||
| poor: number; | |||||
| deviceCount: number; | |||||
| }; | |||||
| const STATUS_LABEL: Record<string, { label: string; color: "success" | "warning" | "error" | "default" }> = { | |||||
| online: { label: "在線", color: "success" }, | |||||
| idle: { label: "閒置", color: "warning" }, | |||||
| poor: { label: "連線差", color: "warning" }, | |||||
| offline: { label: "離線", color: "error" }, | |||||
| }; | |||||
| const CLIENT_TYPE_LABEL: Record<string, string> = { | |||||
| tablet: "平板", | |||||
| desktop: "電腦", | |||||
| mobile: "手機", | |||||
| unknown: "未知", | |||||
| }; | |||||
| function formatAgo(seconds?: number): string { | |||||
| if (seconds == null) return "—"; | |||||
| if (seconds < 60) return `${seconds} 秒前`; | |||||
| if (seconds < 3600) return `${Math.floor(seconds / 60)} 分鐘前`; | |||||
| return `${Math.floor(seconds / 3600)} 小時前`; | |||||
| } | |||||
| function toApiDateTime(localValue: string): string { | |||||
| const v = localValue.trim(); | |||||
| if (!v) return ""; | |||||
| if (v.length === 16) return `${v}:00`; | |||||
| return v.replace(" ", "T").slice(0, 19); | |||||
| } | |||||
| function defaultHistoryFrom(): string { | |||||
| return dayjs().startOf("day").format("YYYY-MM-DDTHH:mm"); | |||||
| } | |||||
| function defaultHistoryTo(): string { | |||||
| return dayjs().format("YYYY-MM-DDTHH:mm"); | |||||
| } | |||||
| export default function ClientMonitorPage() { | |||||
| const [tab, setTab] = useState<"live" | "history" | "printers">("live"); | |||||
| const [printerRefreshAt, setPrinterRefreshAt] = useState(0); | |||||
| const [clientTypeFilter, setClientTypeFilter] = useState<string>("all"); | |||||
| const [rows, setRows] = useState<ClientRow[]>([]); | |||||
| const [summary, setSummary] = useState<Summary | null>(null); | |||||
| const [loading, setLoading] = useState(true); | |||||
| const [error, setError] = useState<string | null>(null); | |||||
| const fetchInFlightRef = useRef(false); | |||||
| const [historyFrom, setHistoryFrom] = useState(defaultHistoryFrom); | |||||
| const [historyTo, setHistoryTo] = useState(defaultHistoryTo); | |||||
| const [historyClientType, setHistoryClientType] = useState("all"); | |||||
| const [historyIncludeOffline, setHistoryIncludeOffline] = useState(true); | |||||
| const [historyIncludePoor, setHistoryIncludePoor] = useState(true); | |||||
| const [historyRows, setHistoryRows] = useState<HistoryRow[]>([]); | |||||
| const [historySummary, setHistorySummary] = useState<HistorySummary | null>(null); | |||||
| const [historyLoading, setHistoryLoading] = useState(false); | |||||
| const [historyError, setHistoryError] = useState<string | null>(null); | |||||
| const historyFetchInFlightRef = useRef(false); | |||||
| const fetchClients = useCallback(async () => { | |||||
| if (fetchInFlightRef.current) return; | |||||
| fetchInFlightRef.current = true; | |||||
| setLoading(true); | |||||
| setError(null); | |||||
| try { | |||||
| const params = new URLSearchParams(); | |||||
| if (clientTypeFilter && clientTypeFilter !== "all") { | |||||
| params.set("clientType", clientTypeFilter); | |||||
| } | |||||
| const qs = params.toString(); | |||||
| const response = await clientAuthFetch( | |||||
| `${NEXT_PUBLIC_API_URL}/device-presence/active${qs ? `?${qs}` : ""}`, | |||||
| { method: "GET", cache: "no-store" } | |||||
| ); | |||||
| if (response.status === 401 || response.status === 403) return; | |||||
| if (!response.ok) { | |||||
| throw new Error(`HTTP ${response.status}`); | |||||
| } | |||||
| const data = await response.json(); | |||||
| setRows(Array.isArray(data.clients) ? data.clients : []); | |||||
| setSummary(data.summary ?? null); | |||||
| } catch (e) { | |||||
| console.error("client monitor fetch", e); | |||||
| setError("無法載入裝置狀態"); | |||||
| } finally { | |||||
| setLoading(false); | |||||
| fetchInFlightRef.current = false; | |||||
| } | |||||
| }, [clientTypeFilter]); | |||||
| const fetchHistory = useCallback(async () => { | |||||
| if (historyFetchInFlightRef.current) return; | |||||
| const from = toApiDateTime(historyFrom); | |||||
| const to = toApiDateTime(historyTo); | |||||
| if (!from || !to) { | |||||
| setHistoryError("請選擇開始與結束時間"); | |||||
| return; | |||||
| } | |||||
| const statuses: string[] = []; | |||||
| if (historyIncludeOffline) statuses.push("offline"); | |||||
| if (historyIncludePoor) statuses.push("poor"); | |||||
| if (statuses.length === 0) { | |||||
| setHistoryError("請至少勾選一種狀態(離線或連線差)"); | |||||
| return; | |||||
| } | |||||
| historyFetchInFlightRef.current = true; | |||||
| setHistoryLoading(true); | |||||
| setHistoryError(null); | |||||
| try { | |||||
| const params = new URLSearchParams({ | |||||
| fromDateTime: from, | |||||
| toDateTime: to, | |||||
| status: statuses.join(","), | |||||
| }); | |||||
| if (historyClientType && historyClientType !== "all") { | |||||
| params.set("clientType", historyClientType); | |||||
| } | |||||
| const response = await clientAuthFetch( | |||||
| `${NEXT_PUBLIC_API_URL}/device-presence/history?${params.toString()}`, | |||||
| { method: "GET", cache: "no-store" } | |||||
| ); | |||||
| if (response.status === 401 || response.status === 403) return; | |||||
| if (!response.ok) { | |||||
| const body = await response.json().catch(() => ({})); | |||||
| throw new Error(body?.error ?? `HTTP ${response.status}`); | |||||
| } | |||||
| const data = await response.json(); | |||||
| setHistoryRows(Array.isArray(data.events) ? data.events : []); | |||||
| setHistorySummary(data.summary ?? null); | |||||
| } catch (e) { | |||||
| console.error("client monitor history", e); | |||||
| setHistoryError(e instanceof Error ? e.message : "無法載入歷史記錄"); | |||||
| setHistoryRows([]); | |||||
| setHistorySummary(null); | |||||
| } finally { | |||||
| setHistoryLoading(false); | |||||
| historyFetchInFlightRef.current = false; | |||||
| } | |||||
| }, [ | |||||
| historyFrom, | |||||
| historyTo, | |||||
| historyClientType, | |||||
| historyIncludeOffline, | |||||
| historyIncludePoor, | |||||
| ]); | |||||
| useEffect(() => { | |||||
| if (tab !== "live") return; | |||||
| void fetchClients(); | |||||
| const id = window.setInterval(() => void fetchClients(), 10_000); | |||||
| return () => window.clearInterval(id); | |||||
| }, [fetchClients, tab]); | |||||
| const offlineCount = summary?.offline ?? 0; | |||||
| return ( | |||||
| <div className="space-y-4"> | |||||
| <PageTitleBar | |||||
| title="裝置連線監控" | |||||
| actions={ | |||||
| tab === "live" ? ( | |||||
| <button | |||||
| type="button" | |||||
| onClick={() => void fetchClients()} | |||||
| disabled={loading} | |||||
| className="inline-flex items-center gap-2 rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50 disabled:opacity-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200" | |||||
| > | |||||
| {loading ? ( | |||||
| <CircularProgress size={16} /> | |||||
| ) : ( | |||||
| <Refresh sx={{ fontSize: 18 }} /> | |||||
| )} | |||||
| 重新整理 | |||||
| </button> | |||||
| ) : tab === "history" ? ( | |||||
| <button | |||||
| type="button" | |||||
| onClick={() => void fetchHistory()} | |||||
| disabled={historyLoading} | |||||
| className="inline-flex items-center gap-2 rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50 disabled:opacity-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200" | |||||
| > | |||||
| {historyLoading ? ( | |||||
| <CircularProgress size={16} /> | |||||
| ) : ( | |||||
| <Search sx={{ fontSize: 18 }} /> | |||||
| )} | |||||
| 查詢 | |||||
| </button> | |||||
| ) : ( | |||||
| <button | |||||
| type="button" | |||||
| onClick={() => setPrinterRefreshAt(Date.now())} | |||||
| className="inline-flex items-center gap-2 rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50 disabled:opacity-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200" | |||||
| > | |||||
| <Refresh sx={{ fontSize: 18 }} /> | |||||
| 立即檢查 | |||||
| </button> | |||||
| ) | |||||
| } | |||||
| /> | |||||
| <Tabs | |||||
| value={tab} | |||||
| onChange={(_, v: "live" | "history" | "printers") => setTab(v)} | |||||
| sx={{ borderBottom: 1, borderColor: "divider" }} | |||||
| > | |||||
| <Tab label="即時狀態" value="live" /> | |||||
| <Tab label="歷史記錄" value="history" /> | |||||
| <Tab label="印表機" value="printers" /> | |||||
| </Tabs> | |||||
| {tab === "live" && ( | |||||
| <> | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| 顯示所有已登入並開啟 FPSMS 的瀏覽器(平板、電腦等)。超過約 90 秒無心跳視為離線;超過 5 | |||||
| 分鐘無頁面活動視為閒置。每 10 秒自動更新。 | |||||
| </Typography> | |||||
| {offlineCount > 0 && ( | |||||
| <Alert severity="warning"> | |||||
| 有 {offlineCount} 台裝置可能已斷線或關閉應用,請檢查倉庫平板/網路。 | |||||
| </Alert> | |||||
| )} | |||||
| <Box className="flex flex-wrap items-center gap-4"> | |||||
| <FormControl size="small" sx={{ minWidth: 160 }}> | |||||
| <InputLabel id="client-type-filter">裝置類型</InputLabel> | |||||
| <Select | |||||
| labelId="client-type-filter" | |||||
| label="裝置類型" | |||||
| value={clientTypeFilter} | |||||
| onChange={(e) => setClientTypeFilter(e.target.value)} | |||||
| > | |||||
| <MenuItem value="all">全部</MenuItem> | |||||
| <MenuItem value="tablet">平板</MenuItem> | |||||
| <MenuItem value="desktop">電腦</MenuItem> | |||||
| <MenuItem value="mobile">手機</MenuItem> | |||||
| </Select> | |||||
| </FormControl> | |||||
| {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="warning" label={`閒置 ${summary.idle}`} /> | |||||
| <Chip size="small" color="warning" label={`連線差 ${summary.poor}`} /> | |||||
| <Chip size="small" color="error" label={`離線 ${summary.offline}`} /> | |||||
| </Box> | |||||
| )} | |||||
| </Box> | |||||
| {error && <Alert severity="error">{error}</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>類型</TableCell> | |||||
| <TableCell>帳號</TableCell> | |||||
| <TableCell>目前頁面</TableCell> | |||||
| <TableCell align="right">RTT (ms)</TableCell> | |||||
| <TableCell>最後心跳</TableCell> | |||||
| <TableCell>最後活動</TableCell> | |||||
| </TableRow> | |||||
| </TableHead> | |||||
| <TableBody> | |||||
| {loading && rows.length === 0 ? ( | |||||
| <TableRow> | |||||
| <TableCell colSpan={8} align="center" sx={{ py: 4 }}> | |||||
| <CircularProgress size={28} /> | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ) : rows.length === 0 ? ( | |||||
| <TableRow> | |||||
| <TableCell colSpan={8} align="center" sx={{ py: 4 }}> | |||||
| 目前沒有活躍裝置 | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ) : ( | |||||
| rows.map((row) => { | |||||
| const st = STATUS_LABEL[row.status] ?? STATUS_LABEL.offline; | |||||
| return ( | |||||
| <TableRow key={row.deviceId} hover> | |||||
| <TableCell> | |||||
| <Chip size="small" color={st.color} label={st.label} /> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <Typography variant="body2" fontWeight={600}> | |||||
| {row.displayName || row.deviceId.slice(0, 8) + "…"} | |||||
| </Typography> | |||||
| <Typography variant="caption" color="text.secondary"> | |||||
| {row.deviceId} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| {CLIENT_TYPE_LABEL[row.clientType ?? ""] ?? | |||||
| row.clientType ?? | |||||
| "—"} | |||||
| </TableCell> | |||||
| <TableCell>{row.username ?? "—"}</TableCell> | |||||
| <TableCell sx={{ maxWidth: 280 }}> | |||||
| <Typography variant="body2" noWrap title={row.currentPath}> | |||||
| {row.currentPath ?? "—"} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| <TableCell align="right"> | |||||
| {row.rttMs != null ? row.rttMs : "—"} | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| {formatAgo(row.secondsSinceHeartbeat)} | |||||
| {row.lastHeartbeat && ( | |||||
| <Typography variant="caption" display="block" color="text.secondary"> | |||||
| {dayjs(row.lastHeartbeat).format("HH:mm:ss")} | |||||
| </Typography> | |||||
| )} | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| {row.lastActivity | |||||
| ? dayjs(row.lastActivity).format("HH:mm:ss") | |||||
| : "—"} | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ); | |||||
| }) | |||||
| )} | |||||
| </TableBody> | |||||
| </Table> | |||||
| </TableContainer> | |||||
| </> | |||||
| )} | |||||
| {tab === "printers" && ( | |||||
| <PrinterMonitorTab | |||||
| active={tab === "printers"} | |||||
| refreshAt={printerRefreshAt} | |||||
| /> | |||||
| )} | |||||
| {tab === "history" && ( | |||||
| <> | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| 查詢指定時間範圍內的離線或連線差事件(最長 31 天)。系統每分鐘掃描無心跳裝置,並在連線異常時記錄事件。 | |||||
| </Typography> | |||||
| <Box className="flex flex-wrap items-end gap-4"> | |||||
| <div> | |||||
| <label | |||||
| htmlFor="history-from" | |||||
| className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300" | |||||
| > | |||||
| 開始時間 | |||||
| </label> | |||||
| <input | |||||
| id="history-from" | |||||
| type="datetime-local" | |||||
| value={historyFrom} | |||||
| onChange={(e) => setHistoryFrom(e.target.value)} | |||||
| className="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100" | |||||
| /> | |||||
| </div> | |||||
| <div> | |||||
| <label | |||||
| htmlFor="history-to" | |||||
| className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300" | |||||
| > | |||||
| 結束時間 | |||||
| </label> | |||||
| <input | |||||
| id="history-to" | |||||
| type="datetime-local" | |||||
| value={historyTo} | |||||
| onChange={(e) => setHistoryTo(e.target.value)} | |||||
| className="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100" | |||||
| /> | |||||
| </div> | |||||
| <FormControl size="small" sx={{ minWidth: 160 }}> | |||||
| <InputLabel id="history-client-type">裝置類型</InputLabel> | |||||
| <Select | |||||
| labelId="history-client-type" | |||||
| label="裝置類型" | |||||
| value={historyClientType} | |||||
| onChange={(e) => setHistoryClientType(e.target.value)} | |||||
| > | |||||
| <MenuItem value="all">全部</MenuItem> | |||||
| <MenuItem value="tablet">平板</MenuItem> | |||||
| <MenuItem value="desktop">電腦</MenuItem> | |||||
| <MenuItem value="mobile">手機</MenuItem> | |||||
| </Select> | |||||
| </FormControl> | |||||
| <Box className="flex flex-col gap-0.5"> | |||||
| <FormControlLabel | |||||
| control={ | |||||
| <Checkbox | |||||
| size="small" | |||||
| checked={historyIncludeOffline} | |||||
| onChange={(e) => setHistoryIncludeOffline(e.target.checked)} | |||||
| /> | |||||
| } | |||||
| label="離線" | |||||
| /> | |||||
| <FormControlLabel | |||||
| control={ | |||||
| <Checkbox | |||||
| size="small" | |||||
| checked={historyIncludePoor} | |||||
| onChange={(e) => setHistoryIncludePoor(e.target.checked)} | |||||
| /> | |||||
| } | |||||
| label="連線差" | |||||
| /> | |||||
| </Box> | |||||
| </Box> | |||||
| {historySummary && ( | |||||
| <Box className="flex flex-wrap gap-2"> | |||||
| <Chip size="small" label={`事件 ${historySummary.total}`} /> | |||||
| <Chip size="small" color="error" label={`離線 ${historySummary.offline}`} /> | |||||
| <Chip size="small" color="warning" label={`連線差 ${historySummary.poor}`} /> | |||||
| <Chip size="small" label={`裝置數 ${historySummary.deviceCount}`} /> | |||||
| </Box> | |||||
| )} | |||||
| {historyError && <Alert severity="error">{historyError}</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>裝置</TableCell> | |||||
| <TableCell>類型</TableCell> | |||||
| <TableCell>帳號</TableCell> | |||||
| <TableCell>頁面</TableCell> | |||||
| <TableCell align="right">RTT (ms)</TableCell> | |||||
| </TableRow> | |||||
| </TableHead> | |||||
| <TableBody> | |||||
| {historyLoading && historyRows.length === 0 ? ( | |||||
| <TableRow> | |||||
| <TableCell colSpan={7} align="center" sx={{ py: 4 }}> | |||||
| <CircularProgress size={28} /> | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ) : historyRows.length === 0 ? ( | |||||
| <TableRow> | |||||
| <TableCell colSpan={7} align="center" sx={{ py: 4 }}> | |||||
| 此時間範圍內沒有離線或連線差記錄 | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ) : ( | |||||
| historyRows.map((row) => { | |||||
| const st = STATUS_LABEL[row.status] ?? STATUS_LABEL.offline; | |||||
| return ( | |||||
| <TableRow key={`${row.id ?? row.recordedAt}-${row.deviceId}`} hover> | |||||
| <TableCell> | |||||
| {row.recordedAt | |||||
| ? dayjs(row.recordedAt).format("YYYY-MM-DD HH:mm:ss") | |||||
| : "—"} | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <Chip size="small" color={st.color} label={st.label} /> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <Typography variant="body2" fontWeight={600}> | |||||
| {row.displayName || row.deviceId.slice(0, 8) + "…"} | |||||
| </Typography> | |||||
| <Typography variant="caption" color="text.secondary"> | |||||
| {row.deviceId} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| {CLIENT_TYPE_LABEL[row.clientType ?? ""] ?? | |||||
| row.clientType ?? | |||||
| "—"} | |||||
| </TableCell> | |||||
| <TableCell>{row.username ?? "—"}</TableCell> | |||||
| <TableCell sx={{ maxWidth: 240 }}> | |||||
| <Typography variant="body2" noWrap title={row.currentPath}> | |||||
| {row.currentPath ?? "—"} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| <TableCell align="right"> | |||||
| {row.rttMs != null ? row.rttMs : "—"} | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ); | |||||
| }) | |||||
| )} | |||||
| </TableBody> | |||||
| </Table> | |||||
| </TableContainer> | |||||
| </> | |||||
| )} | |||||
| </div> | |||||
| ); | |||||
| } | |||||
| @@ -0,0 +1,264 @@ | |||||
| "use client"; | |||||
| import React, { useCallback, useEffect, useRef, useState } from "react"; | |||||
| import { | |||||
| Alert, | |||||
| Box, | |||||
| Chip, | |||||
| CircularProgress, | |||||
| Table, | |||||
| TableBody, | |||||
| TableCell, | |||||
| TableContainer, | |||||
| TableHead, | |||||
| TableRow, | |||||
| Typography, | |||||
| } from "@mui/material"; | |||||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||||
| import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | |||||
| import dayjs from "dayjs"; | |||||
| type PrinterRow = { | |||||
| id: number; | |||||
| code?: string; | |||||
| name?: string; | |||||
| type?: string; | |||||
| brand?: string; | |||||
| ip?: string; | |||||
| port?: number; | |||||
| status: "online" | "offline" | "unconfigured" | "unchecked"; | |||||
| latencyMs?: number; | |||||
| errorMessage?: string; | |||||
| lastCheckAt?: string; | |||||
| }; | |||||
| type PrinterSummary = { | |||||
| total: number; | |||||
| online: number; | |||||
| offline: number; | |||||
| unconfigured: number; | |||||
| unchecked: number; | |||||
| }; | |||||
| const PRINTER_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; | |||||
| /** Increment from parent to trigger an immediate connectivity check. */ | |||||
| refreshAt?: number; | |||||
| }; | |||||
| export default function PrinterMonitorTab({ active, refreshAt = 0 }: Props) { | |||||
| const [printers, setPrinters] = useState<PrinterRow[]>([]); | |||||
| const [warnings, setWarnings] = useState<PrinterRow[]>([]); | |||||
| const [summary, setSummary] = useState<PrinterSummary | 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 response = await clientAuthFetch( | |||||
| `${NEXT_PUBLIC_API_URL}/printer-monitor/check`, | |||||
| { method: "POST" } | |||||
| ); | |||||
| if (response.status === 401 || response.status === 403) return; | |||||
| if (!response.ok) { | |||||
| throw new Error(`HTTP ${response.status}`); | |||||
| } | |||||
| const data = await response.json(); | |||||
| setPrinters(Array.isArray(data.printers) ? data.printers : []); | |||||
| setWarnings(Array.isArray(data.warnings) ? data.warnings : []); | |||||
| setSummary(data.summary ?? null); | |||||
| } catch (e) { | |||||
| console.error("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 ?? warnings.length; | |||||
| return ( | |||||
| <div className="space-y-4"> | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| 監控「印表機」設定中所有未刪除的印表機,以 TCP 連線至 IP:Port(預設 9100)檢測是否可連線。每 | |||||
| 2 分鐘自動檢查;離線印表機會顯示於下方警告清單並寫入事件記錄。 | |||||
| </Typography> | |||||
| {offlineCount > 0 && ( | |||||
| <Alert severity="error"> | |||||
| 有 {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>} | |||||
| {warnings.length > 0 && ( | |||||
| <div className="space-y-2"> | |||||
| <Typography variant="subtitle2" fontWeight={600} color="error"> | |||||
| 離線警告清單 | |||||
| </Typography> | |||||
| <TableContainer className="rounded-lg border border-red-200 bg-red-50/50 dark:border-red-900 dark:bg-red-950/20"> | |||||
| <Table size="small"> | |||||
| <TableHead> | |||||
| <TableRow> | |||||
| <TableCell>印表機</TableCell> | |||||
| <TableCell>類型</TableCell> | |||||
| <TableCell>IP:Port</TableCell> | |||||
| <TableCell>錯誤</TableCell> | |||||
| <TableCell>最後檢查</TableCell> | |||||
| </TableRow> | |||||
| </TableHead> | |||||
| <TableBody> | |||||
| {warnings.map((row) => ( | |||||
| <TableRow key={row.id} hover> | |||||
| <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> | |||||
| )} | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| {[row.type, row.brand].filter(Boolean).join(" / ") || "—"} | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| {row.ip ? `${row.ip}:${row.port ?? 9100}` : "—"} | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <Typography variant="body2" color="error"> | |||||
| {row.errorMessage ?? "無法連線"} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| {row.lastCheckAt | |||||
| ? dayjs(row.lastCheckAt).format("YYYY-MM-DD HH:mm:ss") | |||||
| : "—"} | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ))} | |||||
| </TableBody> | |||||
| </Table> | |||||
| </TableContainer> | |||||
| </div> | |||||
| )} | |||||
| <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">延遲 (ms)</TableCell> | |||||
| <TableCell>最後檢查</TableCell> | |||||
| </TableRow> | |||||
| </TableHead> | |||||
| <TableBody> | |||||
| {loading && printers.length === 0 ? ( | |||||
| <TableRow> | |||||
| <TableCell colSpan={6} align="center" sx={{ py: 4 }}> | |||||
| <CircularProgress size={28} /> | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ) : printers.length === 0 ? ( | |||||
| <TableRow> | |||||
| <TableCell colSpan={6} align="center" sx={{ py: 4 }}> | |||||
| 沒有印表機資料 | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ) : ( | |||||
| printers.map((row) => { | |||||
| const st = | |||||
| PRINTER_STATUS_LABEL[row.status] ?? PRINTER_STATUS_LABEL.unchecked; | |||||
| 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> | |||||
| )} | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| {[row.type, row.brand].filter(Boolean).join(" / ") || "—"} | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| {row.ip ? `${row.ip}:${row.port ?? 9100}` : "—"} | |||||
| </TableCell> | |||||
| <TableCell align="right"> | |||||
| {row.latencyMs != null ? row.latencyMs : "—"} | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| {row.lastCheckAt | |||||
| ? dayjs(row.lastCheckAt).format("YYYY-MM-DD HH:mm:ss") | |||||
| : "—"} | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ); | |||||
| }) | |||||
| )} | |||||
| </TableBody> | |||||
| </Table> | |||||
| </TableContainer> | |||||
| {loading && printers.length > 0 && ( | |||||
| <Box className="flex justify-center py-2"> | |||||
| <CircularProgress size={24} /> | |||||
| </Box> | |||||
| )} | |||||
| </div> | |||||
| ); | |||||
| } | |||||
| @@ -0,0 +1,104 @@ | |||||
| "use client"; | |||||
| import { useEffect, useRef } from "react"; | |||||
| import { usePathname } from "next/navigation"; | |||||
| import { useSession } from "next-auth/react"; | |||||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||||
| import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | |||||
| import { isMonitoringEnabled } from "@/config/monitoring"; | |||||
| import { getDeviceDisplayName, getOrCreateDeviceId } from "@/lib/devicePresence"; | |||||
| const HEARTBEAT_INTERVAL_MS = 30_000; | |||||
| const POOR_RTT_MS = 3_000; | |||||
| async function measurePingRtt(): Promise<number | null> { | |||||
| const start = performance.now(); | |||||
| try { | |||||
| const res = await clientAuthFetch( | |||||
| `${NEXT_PUBLIC_API_URL}/device-presence/ping`, | |||||
| { method: "GET", cache: "no-store" } | |||||
| ); | |||||
| if (!res.ok) return null; | |||||
| return Math.round(performance.now() - start); | |||||
| } catch { | |||||
| return null; | |||||
| } | |||||
| } | |||||
| function resolveConnectionQuality( | |||||
| navigatorOnline: boolean, | |||||
| rttMs: number | null | |||||
| ): "good" | "poor" | "offline" { | |||||
| if (!navigatorOnline) return "offline"; | |||||
| if (rttMs == null || rttMs >= POOR_RTT_MS) return "poor"; | |||||
| return "good"; | |||||
| } | |||||
| export default function DevicePresenceReporter() { | |||||
| const pathname = usePathname(); | |||||
| const { status } = useSession(); | |||||
| const inFlightRef = useRef(false); | |||||
| const lastPathRef = useRef<string | null>(null); | |||||
| useEffect(() => { | |||||
| if (!isMonitoringEnabled || status !== "authenticated") return; | |||||
| const sendHeartbeat = async (activityBump: boolean) => { | |||||
| if (inFlightRef.current) return; | |||||
| inFlightRef.current = true; | |||||
| try { | |||||
| const navOnline = | |||||
| typeof navigator !== "undefined" ? navigator.onLine : true; | |||||
| const rttMs = await measurePingRtt(); | |||||
| const connectionQuality = resolveConnectionQuality(navOnline, rttMs); | |||||
| const response = await clientAuthFetch( | |||||
| `${NEXT_PUBLIC_API_URL}/device-presence/heartbeat`, | |||||
| { | |||||
| method: "POST", | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| body: JSON.stringify({ | |||||
| deviceId: getOrCreateDeviceId(), | |||||
| displayName: getDeviceDisplayName() || undefined, | |||||
| currentPath: pathname, | |||||
| rttMs, | |||||
| connectionQuality, | |||||
| navigatorOnline: navOnline, | |||||
| userAgent: | |||||
| typeof navigator !== "undefined" | |||||
| ? navigator.userAgent | |||||
| : undefined, | |||||
| activityBump, | |||||
| }), | |||||
| } | |||||
| ); | |||||
| if (response.status === 401 || response.status === 403) return; | |||||
| } catch { | |||||
| // silent — monitor page will show offline via missed heartbeats | |||||
| } finally { | |||||
| inFlightRef.current = false; | |||||
| } | |||||
| }; | |||||
| const pathChanged = lastPathRef.current !== pathname; | |||||
| lastPathRef.current = pathname; | |||||
| void sendHeartbeat(pathChanged); | |||||
| const intervalId = window.setInterval(() => { | |||||
| void sendHeartbeat(false); | |||||
| }, HEARTBEAT_INTERVAL_MS); | |||||
| const onOnline = () => void sendHeartbeat(true); | |||||
| const onOffline = () => void sendHeartbeat(false); | |||||
| window.addEventListener("online", onOnline); | |||||
| window.addEventListener("offline", onOffline); | |||||
| return () => { | |||||
| window.clearInterval(intervalId); | |||||
| window.removeEventListener("online", onOnline); | |||||
| window.removeEventListener("offline", onOffline); | |||||
| }; | |||||
| }, [pathname, status]); | |||||
| return null; | |||||
| } | |||||
| @@ -0,0 +1,12 @@ | |||||
| "use client"; | |||||
| import dynamic from "next/dynamic"; | |||||
| const DevicePresenceReporter = dynamic( | |||||
| () => import("@/components/DevicePresence/DevicePresenceReporter"), | |||||
| { ssr: false } | |||||
| ); | |||||
| export default function DevicePresenceReporterHost() { | |||||
| return <DevicePresenceReporter />; | |||||
| } | |||||
| @@ -39,12 +39,14 @@ import Science from "@mui/icons-material/Science"; | |||||
| import UploadFile from "@mui/icons-material/UploadFile"; | import UploadFile from "@mui/icons-material/UploadFile"; | ||||
| import Sync from "@mui/icons-material/Sync"; | import Sync from "@mui/icons-material/Sync"; | ||||
| import Layers from "@mui/icons-material/Layers"; | import Layers from "@mui/icons-material/Layers"; | ||||
| import Devices from "@mui/icons-material/Devices"; | |||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { usePathname } from "next/navigation"; | import { usePathname } from "next/navigation"; | ||||
| import Link from "next/link"; | import Link from "next/link"; | ||||
| import { NAVIGATION_CONTENT_WIDTH } from "@/config/uiConfig"; | import { NAVIGATION_CONTENT_WIDTH } from "@/config/uiConfig"; | ||||
| import Logo from "../Logo"; | import Logo from "../Logo"; | ||||
| import { AUTH } from "../../authorities"; | import { AUTH } from "../../authorities"; | ||||
| import { isMonitoringEnabled } from "@/config/monitoring"; | |||||
| import PurchaseStockInNavAlerts from "./PurchaseStockInNavAlerts"; | import PurchaseStockInNavAlerts from "./PurchaseStockInNavAlerts"; | ||||
| import JobOrderFgStockInNavAlerts from "./JobOrderFgStockInNavAlerts"; | import JobOrderFgStockInNavAlerts from "./JobOrderFgStockInNavAlerts"; | ||||
| @@ -282,6 +284,13 @@ const NavigationContent: React.FC = () => { | |||||
| path: "/settings/user", | path: "/settings/user", | ||||
| requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN], | requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN], | ||||
| }, | }, | ||||
| { | |||||
| icon: <Devices />, | |||||
| label: "裝置連線監控", | |||||
| path: "/settings/clientMonitor", | |||||
| requiredAbility: [AUTH.ADMIN, AUTH.TESTING], | |||||
| isHidden: !isMonitoringEnabled, | |||||
| }, | |||||
| //{ | //{ | ||||
| // icon: <Group />, | // icon: <Group />, | ||||
| // label: "User Group", | // label: "User Group", | ||||
| @@ -0,0 +1,5 @@ | |||||
| /** | |||||
| * Device + printer monitoring is for production deployments only | |||||
| * (matches backend fpsms.monitoring.enabled on prod profile). | |||||
| */ | |||||
| export const isMonitoringEnabled = process.env.NODE_ENV === "production"; | |||||
| @@ -0,0 +1,46 @@ | |||||
| const DEVICE_ID_KEY = "fpsms_device_id"; | |||||
| const DISPLAY_NAME_KEY = "fpsms_device_display_name"; | |||||
| export function getOrCreateDeviceId(): string { | |||||
| if (typeof window === "undefined") return ""; | |||||
| let id = localStorage.getItem(DEVICE_ID_KEY); | |||||
| if (!id) { | |||||
| id = | |||||
| typeof crypto !== "undefined" && crypto.randomUUID | |||||
| ? crypto.randomUUID() | |||||
| : `dev-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; | |||||
| localStorage.setItem(DEVICE_ID_KEY, id); | |||||
| } | |||||
| return id; | |||||
| } | |||||
| export function getDeviceDisplayName(): string { | |||||
| if (typeof window === "undefined") return ""; | |||||
| return localStorage.getItem(DISPLAY_NAME_KEY)?.trim() ?? ""; | |||||
| } | |||||
| export function setDeviceDisplayName(name: string): void { | |||||
| if (typeof window === "undefined") return; | |||||
| const trimmed = name.trim(); | |||||
| if (trimmed) { | |||||
| localStorage.setItem(DISPLAY_NAME_KEY, trimmed.slice(0, 128)); | |||||
| } else { | |||||
| localStorage.removeItem(DISPLAY_NAME_KEY); | |||||
| } | |||||
| } | |||||
| export function detectClientTypeFromUa(): string { | |||||
| if (typeof navigator === "undefined") return "unknown"; | |||||
| const ua = navigator.userAgent.toLowerCase(); | |||||
| if (ua.includes("ipad") || ua.includes("tablet")) return "tablet"; | |||||
| if (ua.includes("android") && !ua.includes("mobile")) return "tablet"; | |||||
| if ( | |||||
| ua.includes("iphone") || | |||||
| ua.includes("ipod") || | |||||
| (ua.includes("android") && ua.includes("mobile")) || | |||||
| ua.includes("mobile") | |||||
| ) { | |||||
| return "mobile"; | |||||
| } | |||||
| return "desktop"; | |||||
| } | |||||
| @@ -12,5 +12,6 @@ export const PRIVATE_ROUTES = [ | |||||
| "/projects", | "/projects", | ||||
| "/tasks", | "/tasks", | ||||
| "/settings", | "/settings", | ||||
| "/settings/clientMonitor", | |||||
| "/material", | "/material", | ||||
| ]; | ]; | ||||