| @@ -9,6 +9,8 @@ import { SetupAxiosInterceptors } from "@/app/(main)/axios/axiosInstance"; | |||
| import { UploadProvider } from "@/components/UploadProvider/UploadProvider"; | |||
| import SessionProviderWrapper from "@/components/SessionProviderWrapper/SessionProviderWrapper"; | |||
| import QrCodeScannerProvider from "@/components/QrCodeScannerProvider/QrCodeScannerProvider"; | |||
| import DevicePresenceReporterHost from "@/components/DevicePresence/DevicePresenceReporterHost"; | |||
| import { isMonitoringEnabled } from "@/config/monitoring"; | |||
| import { I18nProvider } from "@/i18n"; | |||
| import "src/app/global.css"; | |||
| export default async function MainLayout({ | |||
| @@ -33,6 +35,7 @@ export default async function MainLayout({ | |||
| return ( | |||
| <SessionProviderWrapper session={session}> | |||
| {isMonitoringEnabled && <DevicePresenceReporterHost />} | |||
| <UploadProvider> | |||
| {/* <CameraProvider> */} | |||
| <AxiosProvider> | |||
| @@ -9,6 +9,7 @@ import Download from "@mui/icons-material/Download"; | |||
| import Hub from "@mui/icons-material/Hub"; | |||
| import Settings from "@mui/icons-material/Settings"; | |||
| import Clear from "@mui/icons-material/Clear"; | |||
| import Refresh from "@mui/icons-material/Refresh"; | |||
| import { CircularProgress } from "@mui/material"; | |||
| import PageTitleBar from "@/components/PageTitleBar"; | |||
| import dayjs from "dayjs"; | |||
| @@ -50,6 +51,16 @@ export default function ProductionSchedulePage() { | |||
| 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 [itemDailyOutList, setItemDailyOutList] = useState<ItemDailyOutRow[]>([]); | |||
| const [itemDailyOutLoading, setItemDailyOutLoading] = useState(false); | |||
| @@ -59,6 +70,8 @@ export default function ProductionSchedulePage() { | |||
| const [fakeOnHandSavingCode, setFakeOnHandSavingCode] = useState<string | null>(null); | |||
| const [fakeOnHandClearingCode, setFakeOnHandClearingCode] = useState<string | null>(null); | |||
| const [isImportingFakeOnHand, setIsImportingFakeOnHand] = useState(false); | |||
| const [isRefreshingOnHand, setIsRefreshingOnHand] = useState(false); | |||
| const refreshOnHandInFlightRef = useRef(false); | |||
| const itemDailyOutRequestRef = useRef(0); | |||
| 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 () => { | |||
| if (!exportFromDate) { | |||
| alert("Please select a from date."); | |||
| @@ -274,6 +332,32 @@ export default function ProductionSchedulePage() { | |||
| 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. */ | |||
| const handleExportFakeOnHand = () => { | |||
| const rows = itemDailyOutList | |||
| @@ -554,6 +638,19 @@ export default function ProductionSchedulePage() { | |||
| <Download sx={{ fontSize: 16 }} /> | |||
| 匯出計劃/物料需求Excel | |||
| </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 | |||
| type="button" | |||
| onClick={() => setIsForecastDialogOpen(true)} | |||
| @@ -858,6 +955,87 @@ export default function ProductionSchedulePage() { | |||
| </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 */} | |||
| {isExportDialogOpen && ( | |||
| <div | |||
| @@ -937,6 +1115,19 @@ export default function ProductionSchedulePage() { | |||
| 排期設定 | |||
| </h2> | |||
| <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 | |||
| type="button" | |||
| 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 Sync from "@mui/icons-material/Sync"; | |||
| import Layers from "@mui/icons-material/Layers"; | |||
| import Devices from "@mui/icons-material/Devices"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { usePathname } from "next/navigation"; | |||
| import Link from "next/link"; | |||
| import { NAVIGATION_CONTENT_WIDTH } from "@/config/uiConfig"; | |||
| import Logo from "../Logo"; | |||
| import { AUTH } from "../../authorities"; | |||
| import { isMonitoringEnabled } from "@/config/monitoring"; | |||
| import PurchaseStockInNavAlerts from "./PurchaseStockInNavAlerts"; | |||
| import JobOrderFgStockInNavAlerts from "./JobOrderFgStockInNavAlerts"; | |||
| @@ -282,6 +284,13 @@ const NavigationContent: React.FC = () => { | |||
| path: "/settings/user", | |||
| requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN], | |||
| }, | |||
| { | |||
| icon: <Devices />, | |||
| label: "裝置連線監控", | |||
| path: "/settings/clientMonitor", | |||
| requiredAbility: [AUTH.ADMIN, AUTH.TESTING], | |||
| isHidden: !isMonitoringEnabled, | |||
| }, | |||
| //{ | |||
| // icon: <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", | |||
| "/tasks", | |||
| "/settings", | |||
| "/settings/clientMonitor", | |||
| "/material", | |||
| ]; | |||