From c0ca782e4ccb97f325ff00a7edc4b4d33199f704 Mon Sep 17 00:00:00 2001 From: "vluk@2fi-solutions.com.hk" Date: Tue, 19 May 2026 19:37:01 +0800 Subject: [PATCH] added export do qty in /ps for daily delivery qty; added monitor page for production use --- src/app/(main)/layout.tsx | 3 + src/app/(main)/ps/page.tsx | 191 ++++++ .../(main)/settings/clientMonitor/page.tsx | 15 + .../ClientMonitor/ClientMonitorPage.tsx | 572 ++++++++++++++++++ .../ClientMonitor/PrinterMonitorTab.tsx | 264 ++++++++ .../DevicePresence/DevicePresenceReporter.tsx | 104 ++++ .../DevicePresenceReporterHost.tsx | 12 + .../NavigationContent/NavigationContent.tsx | 9 + src/config/monitoring.ts | 5 + src/lib/devicePresence.ts | 46 ++ src/routes.ts | 1 + 11 files changed, 1222 insertions(+) create mode 100644 src/app/(main)/settings/clientMonitor/page.tsx create mode 100644 src/components/ClientMonitor/ClientMonitorPage.tsx create mode 100644 src/components/ClientMonitor/PrinterMonitorTab.tsx create mode 100644 src/components/DevicePresence/DevicePresenceReporter.tsx create mode 100644 src/components/DevicePresence/DevicePresenceReporterHost.tsx create mode 100644 src/config/monitoring.ts create mode 100644 src/lib/devicePresence.ts diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx index cc020d8..f62ca9b 100644 --- a/src/app/(main)/layout.tsx +++ b/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 ( + {isMonitoringEnabled && } {/* */} diff --git a/src/app/(main)/ps/page.tsx b/src/app/(main)/ps/page.tsx index 81bb48e..0d7e3c4 100644 --- a/src/app/(main)/ps/page.tsx +++ b/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([]); const [itemDailyOutLoading, setItemDailyOutLoading] = useState(false); @@ -59,6 +70,8 @@ export default function ProductionSchedulePage() { const [fakeOnHandSavingCode, setFakeOnHandSavingCode] = useState(null); const [fakeOnHandClearingCode, setFakeOnHandClearingCode] = useState(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() { 匯出計劃/物料需求Excel + + + + + + )} + {/* Export Dialog */} {isExportDialogOpen && (
+ + ) : tab === "history" ? ( + + ) : ( + + ) + } + /> + + setTab(v)} + sx={{ borderBottom: 1, borderColor: "divider" }} + > + + + + + + {tab === "live" && ( + <> + + 顯示所有已登入並開啟 FPSMS 的瀏覽器(平板、電腦等)。超過約 90 秒無心跳視為離線;超過 5 + 分鐘無頁面活動視為閒置。每 10 秒自動更新。 + + + {offlineCount > 0 && ( + + 有 {offlineCount} 台裝置可能已斷線或關閉應用,請檢查倉庫平板/網路。 + + )} + + + + 裝置類型 + + + {summary && ( + + + + + + + + )} + + + {error && {error}} + + + + + + 狀態 + 裝置 + 類型 + 帳號 + 目前頁面 + RTT (ms) + 最後心跳 + 最後活動 + + + + {loading && rows.length === 0 ? ( + + + + + + ) : rows.length === 0 ? ( + + + 目前沒有活躍裝置 + + + ) : ( + rows.map((row) => { + const st = STATUS_LABEL[row.status] ?? STATUS_LABEL.offline; + return ( + + + + + + + {row.displayName || row.deviceId.slice(0, 8) + "…"} + + + {row.deviceId} + + + + {CLIENT_TYPE_LABEL[row.clientType ?? ""] ?? + row.clientType ?? + "—"} + + {row.username ?? "—"} + + + {row.currentPath ?? "—"} + + + + {row.rttMs != null ? row.rttMs : "—"} + + + {formatAgo(row.secondsSinceHeartbeat)} + {row.lastHeartbeat && ( + + {dayjs(row.lastHeartbeat).format("HH:mm:ss")} + + )} + + + {row.lastActivity + ? dayjs(row.lastActivity).format("HH:mm:ss") + : "—"} + + + ); + }) + )} + +
+
+ + )} + + {tab === "printers" && ( + + )} + + {tab === "history" && ( + <> + + 查詢指定時間範圍內的離線或連線差事件(最長 31 天)。系統每分鐘掃描無心跳裝置,並在連線異常時記錄事件。 + + + +
+ + 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" + /> +
+
+ + 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" + /> +
+ + 裝置類型 + + + + setHistoryIncludeOffline(e.target.checked)} + /> + } + label="離線" + /> + setHistoryIncludePoor(e.target.checked)} + /> + } + label="連線差" + /> + +
+ + {historySummary && ( + + + + + + + )} + + {historyError && {historyError}} + + + + + + 時間 + 狀態 + 裝置 + 類型 + 帳號 + 頁面 + RTT (ms) + + + + {historyLoading && historyRows.length === 0 ? ( + + + + + + ) : historyRows.length === 0 ? ( + + + 此時間範圍內沒有離線或連線差記錄 + + + ) : ( + historyRows.map((row) => { + const st = STATUS_LABEL[row.status] ?? STATUS_LABEL.offline; + return ( + + + {row.recordedAt + ? dayjs(row.recordedAt).format("YYYY-MM-DD HH:mm:ss") + : "—"} + + + + + + + {row.displayName || row.deviceId.slice(0, 8) + "…"} + + + {row.deviceId} + + + + {CLIENT_TYPE_LABEL[row.clientType ?? ""] ?? + row.clientType ?? + "—"} + + {row.username ?? "—"} + + + {row.currentPath ?? "—"} + + + + {row.rttMs != null ? row.rttMs : "—"} + + + ); + }) + )} + +
+
+ + )} +
+ ); +} diff --git a/src/components/ClientMonitor/PrinterMonitorTab.tsx b/src/components/ClientMonitor/PrinterMonitorTab.tsx new file mode 100644 index 0000000..adc0b86 --- /dev/null +++ b/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([]); + const [warnings, setWarnings] = useState([]); + const [summary, setSummary] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( +
+ + 監控「印表機」設定中所有未刪除的印表機,以 TCP 連線至 IP:Port(預設 9100)檢測是否可連線。每 + 2 分鐘自動檢查;離線印表機會顯示於下方警告清單並寫入事件記錄。 + + + {offlineCount > 0 && ( + + 有 {offlineCount} 台印表機無法連線,請檢查電源、網路或 IP/Port 設定。 + + )} + + {summary && ( + + + + + + {summary.unchecked > 0 && ( + + )} + + )} + + {error && {error}} + + {warnings.length > 0 && ( +
+ + 離線警告清單 + + + + + + 印表機 + 類型 + IP:Port + 錯誤 + 最後檢查 + + + + {warnings.map((row) => ( + + + + {row.name || row.code || `#${row.id}`} + + {row.code && row.name && ( + + {row.code} + + )} + + + {[row.type, row.brand].filter(Boolean).join(" / ") || "—"} + + + {row.ip ? `${row.ip}:${row.port ?? 9100}` : "—"} + + + + {row.errorMessage ?? "無法連線"} + + + + {row.lastCheckAt + ? dayjs(row.lastCheckAt).format("YYYY-MM-DD HH:mm:ss") + : "—"} + + + ))} + +
+
+
+ )} + + + 全部印表機 + + + + + + 狀態 + 印表機 + 類型 + IP:Port + 延遲 (ms) + 最後檢查 + + + + {loading && printers.length === 0 ? ( + + + + + + ) : printers.length === 0 ? ( + + + 沒有印表機資料 + + + ) : ( + printers.map((row) => { + const st = + PRINTER_STATUS_LABEL[row.status] ?? PRINTER_STATUS_LABEL.unchecked; + return ( + + + + + + + {row.name || row.code || `#${row.id}`} + + {row.code && row.name && ( + + {row.code} + + )} + + + {[row.type, row.brand].filter(Boolean).join(" / ") || "—"} + + + {row.ip ? `${row.ip}:${row.port ?? 9100}` : "—"} + + + {row.latencyMs != null ? row.latencyMs : "—"} + + + {row.lastCheckAt + ? dayjs(row.lastCheckAt).format("YYYY-MM-DD HH:mm:ss") + : "—"} + + + ); + }) + )} + +
+
+ + {loading && printers.length > 0 && ( + + + + )} +
+ ); +} diff --git a/src/components/DevicePresence/DevicePresenceReporter.tsx b/src/components/DevicePresence/DevicePresenceReporter.tsx new file mode 100644 index 0000000..03c5db5 --- /dev/null +++ b/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 { + 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(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; +} diff --git a/src/components/DevicePresence/DevicePresenceReporterHost.tsx b/src/components/DevicePresence/DevicePresenceReporterHost.tsx new file mode 100644 index 0000000..d3fc834 --- /dev/null +++ b/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 ; +} diff --git a/src/components/NavigationContent/NavigationContent.tsx b/src/components/NavigationContent/NavigationContent.tsx index fbf18dd..06b6f65 100644 --- a/src/components/NavigationContent/NavigationContent.tsx +++ b/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: , + label: "裝置連線監控", + path: "/settings/clientMonitor", + requiredAbility: [AUTH.ADMIN, AUTH.TESTING], + isHidden: !isMonitoringEnabled, + }, //{ // icon: , // label: "User Group", diff --git a/src/config/monitoring.ts b/src/config/monitoring.ts new file mode 100644 index 0000000..d240300 --- /dev/null +++ b/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"; diff --git a/src/lib/devicePresence.ts b/src/lib/devicePresence.ts new file mode 100644 index 0000000..a6a4c75 --- /dev/null +++ b/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"; +} diff --git a/src/routes.ts b/src/routes.ts index 603ffc4..58b5e44 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -12,5 +12,6 @@ export const PRIVATE_ROUTES = [ "/projects", "/tasks", "/settings", + "/settings/clientMonitor", "/material", ];