Thực hiện page History
This commit is contained in:
parent
40beb16f6f
commit
5fb9c14db5
|
|
@ -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<TStation[]>(tabsData);
|
||||
const [isChangeTab, setIsChangeTab] = useState<boolean>(false);
|
||||
const [isSetActive, setIsSetActive] = useState<boolean>(false);
|
||||
const [isHistoryModalOpen, setIsHistoryModalOpen] = useState<boolean>(false);
|
||||
|
||||
const sensors = useSensors(useSensor(PointerSensor));
|
||||
|
||||
|
|
@ -280,18 +283,16 @@ export default function DraggableTabs({
|
|||
</Flex>
|
||||
</Tabs.List>
|
||||
<Flex align={"center"}>
|
||||
{/* <Button
|
||||
<Button
|
||||
variant="outline"
|
||||
style={{ height: "26px", width: "80px", marginRight: "20px" }}
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
socket?.emit("get_list_history", {
|
||||
stationIds: tabs?.map((el) => el.id),
|
||||
});
|
||||
setIsHistoryModalOpen(true);
|
||||
}}
|
||||
>
|
||||
History
|
||||
</Button> */}
|
||||
</Button>
|
||||
<Tooltip
|
||||
withArrow
|
||||
label={usersConnecting.map((el) => (
|
||||
|
|
@ -346,6 +347,13 @@ export default function DraggableTabs({
|
|||
|
||||
{panels}
|
||||
</Tabs>
|
||||
|
||||
<ModalHistory
|
||||
opened={isHistoryModalOpen}
|
||||
onClose={() => setIsHistoryModalOpen(false)}
|
||||
socket={socket}
|
||||
stationIds={tabs.map((el) => el.id)}
|
||||
/>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<StationHistory[]>([]);
|
||||
const [activeStation, setActiveStation] = useState<string>("");
|
||||
const [activeTimePeriod, setActiveTimePeriod] = useState<string>("current");
|
||||
const scrollViewportRef = useRef<HTMLDivElement>(null);
|
||||
const isAutoSwitchingRef = useRef(false);
|
||||
const lastScrollTopRef = useRef(0);
|
||||
const scrollTimeoutRef = useRef<NodeJS.Timeout | null>(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 (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "rgba(0,0,0,0.6)",
|
||||
zIndex: 100000,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backdropFilter: "blur(3px)",
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "white",
|
||||
borderRadius: "12px",
|
||||
maxWidth: "90vw",
|
||||
width: "90%",
|
||||
maxHeight: "85vh",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<Flex
|
||||
justify="space-between"
|
||||
align="center"
|
||||
p="lg"
|
||||
style={{
|
||||
borderBottom: "1px solid #e9ecef",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Text fw={700} size="xl">
|
||||
📊 History
|
||||
</Text>
|
||||
<CloseButton size="lg" onClick={onClose} />
|
||||
</Flex>
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
style={{
|
||||
padding: "20px",
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden",
|
||||
flex: 1,
|
||||
}}
|
||||
className={classes.hideScrollBar}
|
||||
>
|
||||
<Flex gap="md" style={{ height: "75vh" }}>
|
||||
{/* Sidebar - Station List */}
|
||||
<Box
|
||||
style={{
|
||||
width: "200px",
|
||||
borderRight: "1px solid #e9ecef",
|
||||
paddingRight: "10px",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Text fw={600} size="sm" mb="xs" c="dimmed">
|
||||
HISTORY
|
||||
</Text>
|
||||
<ScrollArea h="calc(100% - 30px)">
|
||||
<Flex direction="column" gap="xs">
|
||||
{historyData.map((station) => (
|
||||
<Button
|
||||
key={station.stationId}
|
||||
fullWidth
|
||||
style={{ minHeight: "36px" }}
|
||||
variant={
|
||||
activeStation === station.stationId.toString()
|
||||
? "filled"
|
||||
: "outline"
|
||||
}
|
||||
onClick={() =>
|
||||
setActiveStation(station.stationId.toString())
|
||||
}
|
||||
>
|
||||
{station.stationName}
|
||||
</Button>
|
||||
))}
|
||||
</Flex>
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
|
||||
{/* Main Content */}
|
||||
<Box style={{ flex: 1, overflow: "hidden" }}>
|
||||
{/* Time Period Tabs */}
|
||||
<Flex gap="xs" mb="md" wrap="wrap">
|
||||
{TIME_PERIODS.map((period) => (
|
||||
<Button
|
||||
key={period.value}
|
||||
size="sm"
|
||||
variant={
|
||||
activeTimePeriod === period.value ? "filled" : "outline"
|
||||
}
|
||||
onClick={() => setActiveTimePeriod(period.value)}
|
||||
style={{ flex: 1, minWidth: "100px" }}
|
||||
>
|
||||
{period.label}
|
||||
</Button>
|
||||
))}
|
||||
</Flex>
|
||||
|
||||
{/* History Table */}
|
||||
<Box
|
||||
style={{
|
||||
border: "1px solid #dee2e6",
|
||||
borderRadius: "4px",
|
||||
overflow: "hidden",
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
>
|
||||
<ScrollArea
|
||||
h="calc(75vh - 80px)"
|
||||
viewportRef={scrollViewportRef}
|
||||
onScrollPositionChange={(position) => {
|
||||
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
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
{filteredHistory.length > 0 ? (
|
||||
<>
|
||||
{filteredHistory.map((item, index) => (
|
||||
<Box
|
||||
key={`${item.stationId}-${item.number}-${item.timestamp}-${index}`}
|
||||
style={{
|
||||
padding: "10px 16px",
|
||||
borderBottom: "1px solid #dee2e6",
|
||||
backgroundColor: index % 2 === 0 ? "#f8f9fa" : "white",
|
||||
}}
|
||||
>
|
||||
<Text size="sm" style={{ fontFamily: "monospace" }}>
|
||||
Line {item.number}: {item.pid} {item.vid} SN: {item.sn}
|
||||
<span style={{ marginLeft: "20px", color: "#868e96" }}>
|
||||
| {item.scenario}
|
||||
</span>
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
style={{ minHeight: "calc(75vh - 80px)" }}
|
||||
>
|
||||
<Text c="dimmed" size="lg">
|
||||
{currentStationData
|
||||
? `No history data available for ${TIME_PERIODS.find((p) => p.value === activeTimePeriod)?.label}`
|
||||
: "No history data available"}
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</Box>
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
</Box>
|
||||
</Flex>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ModalHistory;
|
||||
|
||||
Loading…
Reference in New Issue