From 5fb9c14db55ad0d519d88a3f4caf050fce13fa13 Mon Sep 17 00:00:00 2001 From: Truong Vo <41848815+vmtruong301296@users.noreply.github.com> Date: Fri, 28 Nov 2025 11:39:35 +0700 Subject: [PATCH] =?UTF-8?q?Th=E1=BB=B1c=20hi=E1=BB=87n=20page=20History?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- FRONTEND/src/components/DragTabs.tsx | 18 +- FRONTEND/src/components/ModalHistory.tsx | 439 +++++++++++++++++++++++ 2 files changed, 452 insertions(+), 5 deletions(-) create mode 100644 FRONTEND/src/components/ModalHistory.tsx diff --git a/FRONTEND/src/components/DragTabs.tsx b/FRONTEND/src/components/DragTabs.tsx index b0a3611..c3d13b8 100644 --- a/FRONTEND/src/components/DragTabs.tsx +++ b/FRONTEND/src/components/DragTabs.tsx @@ -2,6 +2,7 @@ import { ActionIcon, Avatar, Box, + Button, Flex, Group, Menu, @@ -36,6 +37,7 @@ import { import classes from "./Component.module.css"; import type { TStation, TUser } from "../untils/types"; import type { Socket } from "socket.io-client"; +import ModalHistory from "./ModalHistory"; interface DraggableTabsProps { tabsData: TStation[]; @@ -123,6 +125,7 @@ export default function DraggableTabs({ const [tabs, setTabs] = useState(tabsData); const [isChangeTab, setIsChangeTab] = useState(false); const [isSetActive, setIsSetActive] = useState(false); + const [isHistoryModalOpen, setIsHistoryModalOpen] = useState(false); const sensors = useSensors(useSensor(PointerSensor)); @@ -280,18 +283,16 @@ export default function DraggableTabs({ - {/* */} + ( @@ -346,6 +347,13 @@ export default function DraggableTabs({ {panels} + + setIsHistoryModalOpen(false)} + socket={socket} + stationIds={tabs.map((el) => el.id)} + /> ); } diff --git a/FRONTEND/src/components/ModalHistory.tsx b/FRONTEND/src/components/ModalHistory.tsx new file mode 100644 index 0000000..867159d --- /dev/null +++ b/FRONTEND/src/components/ModalHistory.tsx @@ -0,0 +1,439 @@ +import { useEffect, useState, useRef } from "react"; +import { + Box, + ScrollArea, + Text, + Flex, + CloseButton, + Button, +} from "@mantine/core"; +import classes from "./Component.module.css"; +import type { Socket } from "socket.io-client"; + +interface HistoryItem { + id: number; + number: number; + stationId: number; + pid: string; + sn: string; + vid: string; + scenario: string; + timestamp: number; +} + +interface StationHistory { + stationId: number; + stationName: string; + history: HistoryItem[]; +} + +interface ModalHistoryProps { + opened: boolean; + onClose: () => void; + socket: Socket | null; + stationIds?: number[]; +} + +const TIME_PERIODS = [ + { label: "Current", value: "current" }, + { label: "last 4h", value: "last_4h" }, + { label: "last 8h", value: "last_8h" }, + { label: "last 24h", value: "last_24h" }, + { label: "last 48h", value: "last_48h" }, +]; + +function ModalHistory({ opened, onClose, socket, stationIds = [] }: ModalHistoryProps) { + const [historyData, setHistoryData] = useState([]); + const [activeStation, setActiveStation] = useState(""); + const [activeTimePeriod, setActiveTimePeriod] = useState("current"); + const scrollViewportRef = useRef(null); + const isAutoSwitchingRef = useRef(false); + const lastScrollTopRef = useRef(0); + const scrollTimeoutRef = useRef(null); + + useEffect(() => { + if (!socket || !opened) return; + + // Listen for history response + const handleHistoryResponse = (data: StationHistory[]) => { + setHistoryData(data); + // Set first station as active by default only if not already set + if (data.length > 0 && !activeStation) { + setActiveStation(data[0].stationId.toString()); + } + }; + + socket.on("list_histories", handleHistoryResponse); + + return () => { + socket.off("list_histories", handleHistoryResponse); + }; + }, [socket, opened, activeStation]); + + // Request history when modal opens + useEffect(() => { + if (!socket || !opened) return; + + // Request history with station IDs + socket.emit("get_list_history", { stationIds }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [socket, opened]); // Only re-fetch when modal opens/closes, not when stationIds changes + + // Reset state when modal closes + useEffect(() => { + if (!opened) { + setHistoryData([]); + setActiveStation(""); + setActiveTimePeriod("current"); + } + }, [opened]); + + // Initialize scroll position when content loads + useEffect(() => { + if (scrollViewportRef.current && historyData.length > 0) { + setTimeout(() => { + if (scrollViewportRef.current) { + const scrollHeight = scrollViewportRef.current.scrollHeight; + const clientHeight = scrollViewportRef.current.clientHeight; + // Start at middle position + scrollViewportRef.current.scrollTop = (scrollHeight - clientHeight) / 2; + lastScrollTopRef.current = scrollViewportRef.current.scrollTop; + } + }, 200); + } + }, [historyData]); + + // Utility function to format timestamp (can be used later if needed) + // const formatTimestamp = (timestamp: number) => { + // const date = new Date(timestamp); + // return date.toLocaleString(); + // }; + + // Get time range based on selected period (returns [startTime, endTime]) + const getTimeRange = (period: string): [number, number] => { + const now = Date.now(); + const HOUR = 60 * 60 * 1000; + + switch (period) { + case "current": + // Current: từ 0-4h gần nhất + return [now - 4 * HOUR, now]; + case "last_4h": + // Last 4h: từ 4h-8h trước + return [now - 8 * HOUR, now - 4 * HOUR]; + case "last_8h": + // Last 8h: từ 8h-24h trước + return [now - 24 * HOUR, now - 8 * HOUR]; + case "last_24h": + // Last 24h: từ 24h-48h trước + return [now - 48 * HOUR, now - 24 * HOUR]; + case "last_48h": + // Last 48h: từ 48h trở về trước (tất cả data cũ hơn 48h) + return [0, now - 48 * HOUR]; + default: + return [0, now]; + } + }; + + // Filter history based on time period + const filterHistoryByTime = (history: HistoryItem[]): HistoryItem[] => { + const [startTime, endTime] = getTimeRange(activeTimePeriod); + return history.filter((item) => + item.timestamp >= startTime && item.timestamp < endTime + ); + }; + + const currentStationData = historyData.find( + (station) => station.stationId.toString() === activeStation + ); + + // Apply time filter to current station data + const filteredHistory = currentStationData + ? filterHistoryByTime(currentStationData.history) + : []; + + if (!opened) return null; + + return ( +
{ + e.stopPropagation(); + onClose(); + }} + > +
e.stopPropagation()} + > + {/* Header */} + + + 📊 History + + + + + {/* Content */} +
+ + {/* Sidebar - Station List */} + + + HISTORY + + + + {historyData.map((station) => ( + + ))} + + + + + {/* Main Content */} + + {/* Time Period Tabs */} + + {TIME_PERIODS.map((period) => ( + + ))} + + + {/* History Table */} + + { + if (!scrollViewportRef.current) return; + + const scrollTop = position.y; + const target = scrollViewportRef.current; + const scrollHeight = target.scrollHeight; + const clientHeight = target.clientHeight; + + // Clear existing timeout + if (scrollTimeoutRef.current) { + clearTimeout(scrollTimeoutRef.current); + } + + // Debounce scroll handling + scrollTimeoutRef.current = setTimeout(() => { + if (isAutoSwitchingRef.current) { + return; + } + + // Determine scroll direction + const isScrollingUp = scrollTop < lastScrollTopRef.current; + const isScrollingDown = scrollTop > lastScrollTopRef.current; + + // Update last scroll position AFTER checking direction + lastScrollTopRef.current = scrollTop; + + // Check if at absolute top or bottom (no spacer needed) + const isAtTop = scrollTop === 0; + const isAtBottom = Math.abs(scrollTop + clientHeight - scrollHeight) < 5; + + // Can only trigger one direction at a time + const canScrollUp = isAtTop && isScrollingUp && historyData.length > 0; + const canScrollDown = isAtBottom && isScrollingDown && historyData.length > 0; + + // Handle scroll up - khi scroll lên đến đầu + if (canScrollUp) { + isAutoSwitchingRef.current = true; + + const currentPeriodIndex = TIME_PERIODS.findIndex( + (p) => p.value === activeTimePeriod + ); + + if (currentPeriodIndex > 0) { + setActiveTimePeriod(TIME_PERIODS[currentPeriodIndex - 1].value); + } else { + const currentStationIndex = historyData.findIndex( + (station) => station.stationId.toString() === activeStation + ); + + if (currentStationIndex > 0) { + setActiveStation(historyData[currentStationIndex - 1].stationId.toString()); + setActiveTimePeriod(TIME_PERIODS[TIME_PERIODS.length - 1].value); + } else { + isAutoSwitchingRef.current = false; + return; + } + } + + // Scroll to bottom of new content + setTimeout(() => { + if (scrollViewportRef.current) { + const newScrollHeight = scrollViewportRef.current.scrollHeight; + const newClientHeight = scrollViewportRef.current.clientHeight; + scrollViewportRef.current.scrollTop = newScrollHeight - newClientHeight; + lastScrollTopRef.current = scrollViewportRef.current.scrollTop; + } + setTimeout(() => { + isAutoSwitchingRef.current = false; + }, 100); + }, 150); + } + // Handle scroll down - khi scroll xuống đến cuối + else if (canScrollDown) { + isAutoSwitchingRef.current = true; + + const currentPeriodIndex = TIME_PERIODS.findIndex( + (p) => p.value === activeTimePeriod + ); + + if (currentPeriodIndex < TIME_PERIODS.length - 1) { + setActiveTimePeriod(TIME_PERIODS[currentPeriodIndex + 1].value); + } else { + const currentStationIndex = historyData.findIndex( + (station) => station.stationId.toString() === activeStation + ); + + if (currentStationIndex !== -1 && currentStationIndex < historyData.length - 1) { + setActiveStation(historyData[currentStationIndex + 1].stationId.toString()); + setActiveTimePeriod(TIME_PERIODS[0].value); + } else { + isAutoSwitchingRef.current = false; + return; + } + } + + // Scroll to top of new content + setTimeout(() => { + if (scrollViewportRef.current) { + scrollViewportRef.current.scrollTop = 0; + lastScrollTopRef.current = 0; + } + setTimeout(() => { + isAutoSwitchingRef.current = false; + }, 100); + }, 150); + } + }, 100); // Debounce 100ms + }} + > + + {filteredHistory.length > 0 ? ( + <> + {filteredHistory.map((item, index) => ( + + + Line {item.number}: {item.pid} {item.vid} SN: {item.sn} + + | {item.scenario} + + + + ))} + + ) : ( + + + {currentStationData + ? `No history data available for ${TIME_PERIODS.find((p) => p.value === activeTimePeriod)?.label}` + : "No history data available"} + + + )} + + + + + +
+
+
+ ); +} + +export default ModalHistory; +