451 lines
15 KiB
TypeScript
451 lines
15 KiB
TypeScript
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);
|
|
|
|
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]);
|
|
|
|
// 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 (cumulative - tích lũy)
|
|
const getTimeRange = (period: string): [number, number] => {
|
|
const now = Date.now();
|
|
const HOUR = 60 * 60 * 1000;
|
|
|
|
switch (period) {
|
|
case "current":
|
|
// Current: chỉ hiện tại (4h gần nhất)
|
|
return [0, now];
|
|
case "last_4h":
|
|
// Last 4h: bao gồm current + last 4h (0-8h)
|
|
return [now - 4 * HOUR, now];
|
|
case "last_8h":
|
|
// Last 8h: bao gồm current + last 4h + last 8h (0-24h)
|
|
return [now - 8 * HOUR, now];
|
|
case "last_24h":
|
|
// Last 24h: bao gồm current + last 4h + last 8h + last 24h (0-48h)
|
|
return [now - 24 * HOUR, now];
|
|
case "last_48h":
|
|
// Last 48h: tất cả data (từ đầu đến giờ)
|
|
return [now - 48 * HOUR, now];
|
|
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
|
|
);
|
|
};
|
|
|
|
// Group history items by line number
|
|
const groupHistoryByLine = (
|
|
history: HistoryItem[]
|
|
): Map<number, HistoryItem[]> => {
|
|
const grouped = new Map<number, HistoryItem[]>();
|
|
|
|
history.forEach((item) => {
|
|
if (!grouped.has(item.number)) {
|
|
grouped.set(item.number, []);
|
|
}
|
|
grouped.get(item.number)!.push(item);
|
|
});
|
|
|
|
// Sort items within each group by timestamp (newest first)
|
|
grouped.forEach((items) => {
|
|
items.sort((a, b) => b.timestamp - a.timestamp);
|
|
});
|
|
|
|
return grouped;
|
|
};
|
|
|
|
const currentStationData = historyData.find(
|
|
(station) => station.stationId.toString() === activeStation
|
|
);
|
|
|
|
// Apply time filter to current station data
|
|
const filteredHistory = currentStationData
|
|
? filterHistoryByTime(currentStationData.history)
|
|
: [];
|
|
|
|
// Group filtered history by line number
|
|
const groupedHistory = groupHistoryByLine(filteredHistory);
|
|
|
|
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">
|
|
List stations
|
|
</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 ||
|
|
isAutoSwitchingRef.current
|
|
)
|
|
return;
|
|
|
|
const scrollTop = position.y;
|
|
const target = scrollViewportRef.current;
|
|
const scrollHeight = target.scrollHeight;
|
|
const clientHeight = target.clientHeight;
|
|
|
|
// Check if scrolled to bottom
|
|
const isAtBottom =
|
|
Math.abs(scrollTop + clientHeight - scrollHeight) < 5;
|
|
|
|
if (isAtBottom && historyData.length > 0) {
|
|
isAutoSwitchingRef.current = true;
|
|
|
|
const currentStationIndex = historyData.findIndex(
|
|
(station) =>
|
|
station.stationId.toString() === activeStation
|
|
);
|
|
|
|
if (
|
|
currentStationIndex !== -1 &&
|
|
currentStationIndex < historyData.length - 1
|
|
) {
|
|
// Chuyển sang station tiếp theo
|
|
setActiveStation(
|
|
historyData[
|
|
currentStationIndex + 1
|
|
].stationId.toString()
|
|
);
|
|
|
|
// Reset scroll to top
|
|
setTimeout(() => {
|
|
if (scrollViewportRef.current) {
|
|
scrollViewportRef.current.scrollTop = 0;
|
|
}
|
|
setTimeout(() => {
|
|
isAutoSwitchingRef.current = false;
|
|
}, 100);
|
|
}, 150);
|
|
} else {
|
|
isAutoSwitchingRef.current = false;
|
|
}
|
|
}
|
|
}}
|
|
>
|
|
<Box style={{ minHeight: "calc(75vh - 60px)" }}>
|
|
{groupedHistory.size > 0 ? (
|
|
<>
|
|
{Array.from(groupedHistory.entries())
|
|
.sort(([lineA], [lineB]) => lineA - lineB)
|
|
.map(([lineNumber, items]) => (
|
|
<Box
|
|
key={`line-group-${lineNumber}`}
|
|
style={{
|
|
marginBottom: "8px",
|
|
border: "1px solid #dee2e6",
|
|
borderRadius: "4px",
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
{/* Header của nhóm - hiển thị line number */}
|
|
<Box
|
|
style={{
|
|
padding: "8px 16px",
|
|
backgroundColor: "#e9ecef",
|
|
fontWeight: 600,
|
|
}}
|
|
>
|
|
<Text size="sm" fw={600}>
|
|
Line {lineNumber} ({items.length}{" "}
|
|
{items.length > 1 ? "records" : "record"})
|
|
</Text>
|
|
</Box>
|
|
|
|
{/* Các items trong nhóm */}
|
|
{items.map((item, itemIndex) => (
|
|
<Box
|
|
key={`${item.stationId}-${item.number}-${item.timestamp}-${itemIndex}`}
|
|
style={{
|
|
padding: "8px 16px 8px 32px", // Tăng padding-left lên 32px
|
|
borderTop:
|
|
itemIndex > 0
|
|
? "1px solid #f1f3f5"
|
|
: "none",
|
|
backgroundColor:
|
|
itemIndex % 2 === 0 ? "white" : "#f8f9fa",
|
|
}}
|
|
>
|
|
<Text
|
|
size="sm"
|
|
style={{ fontFamily: "monospace" }}
|
|
>
|
|
{item.pid} {item.vid} SN: {item.sn}
|
|
<span
|
|
style={{
|
|
marginLeft: "20px",
|
|
color: "#868e96",
|
|
}}
|
|
>
|
|
| {item.scenario}
|
|
</span>
|
|
<span
|
|
style={{
|
|
marginLeft: "10px",
|
|
color: "#adb5bd",
|
|
fontSize: "11px",
|
|
}}
|
|
>
|
|
{new Date(
|
|
item.timestamp
|
|
).toLocaleString()}
|
|
</span>
|
|
</Text>
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
))}
|
|
|
|
{/* Spacer để đảm bảo có thể scroll ngay cả khi content ít */}
|
|
<Box style={{ height: "200px" }} />
|
|
</>
|
|
) : (
|
|
<Flex
|
|
align="center"
|
|
justify="center"
|
|
style={{ height: "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;
|