Просмотр исходного кода

added export do qty in /ps for daily delivery qty; added monitor page for production use

production
[email protected] 1 месяц назад
Родитель
Сommit
c0ca782e4c
11 измененных файлов: 1222 добавлений и 0 удалений
  1. +3
    -0
      src/app/(main)/layout.tsx
  2. +191
    -0
      src/app/(main)/ps/page.tsx
  3. +15
    -0
      src/app/(main)/settings/clientMonitor/page.tsx
  4. +572
    -0
      src/components/ClientMonitor/ClientMonitorPage.tsx
  5. +264
    -0
      src/components/ClientMonitor/PrinterMonitorTab.tsx
  6. +104
    -0
      src/components/DevicePresence/DevicePresenceReporter.tsx
  7. +12
    -0
      src/components/DevicePresence/DevicePresenceReporterHost.tsx
  8. +9
    -0
      src/components/NavigationContent/NavigationContent.tsx
  9. +5
    -0
      src/config/monitoring.ts
  10. +46
    -0
      src/lib/devicePresence.ts
  11. +1
    -0
      src/routes.ts

+ 3
- 0
src/app/(main)/layout.tsx Просмотреть файл

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


+ 191
- 0
src/app/(main)/ps/page.tsx Просмотреть файл

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


+ 15
- 0
src/app/(main)/settings/clientMonitor/page.tsx Просмотреть файл

@@ -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 />;
}

+ 572
- 0
src/components/ClientMonitor/ClientMonitorPage.tsx Просмотреть файл

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

+ 264
- 0
src/components/ClientMonitor/PrinterMonitorTab.tsx Просмотреть файл

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

+ 104
- 0
src/components/DevicePresence/DevicePresenceReporter.tsx Просмотреть файл

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

+ 12
- 0
src/components/DevicePresence/DevicePresenceReporterHost.tsx Просмотреть файл

@@ -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 />;
}

+ 9
- 0
src/components/NavigationContent/NavigationContent.tsx Просмотреть файл

@@ -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",


+ 5
- 0
src/config/monitoring.ts Просмотреть файл

@@ -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";

+ 46
- 0
src/lib/devicePresence.ts Просмотреть файл

@@ -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";
}

+ 1
- 0
src/routes.ts Просмотреть файл

@@ -12,5 +12,6 @@ export const PRIVATE_ROUTES = [
"/projects",
"/tasks",
"/settings",
"/settings/clientMonitor",
"/material",
];

Загрузка…
Отмена
Сохранить