Update gom lại các thư mục, add input history

This commit is contained in:
nguyentrungthat 2025-12-06 10:36:59 +07:00
parent 7e91bc8d85
commit 086c440386
16 changed files with 1250 additions and 968 deletions

View File

@ -44,7 +44,7 @@ import // ButtonConnect,
import StationSetting from "./components/FormAddEdit"; import StationSetting from "./components/FormAddEdit";
// import DrawerScenario from "./components/DrawerScenario"; // import DrawerScenario from "./components/DrawerScenario";
import { Notifications } from "@mantine/notifications"; import { Notifications } from "@mantine/notifications";
import ModalTerminal from "./components/ModalTerminal"; import ModalTerminal from "./components/Modal/ModalTerminal";
import PageLogin from "./components/Authentication/LoginPage"; import PageLogin from "./components/Authentication/LoginPage";
// import DrawerLogs from "./components/DrawerLogs"; // import DrawerLogs from "./components/DrawerLogs";
import DraggableTabs from "./components/DragTabs"; import DraggableTabs from "./components/DragTabs";

View File

@ -5,7 +5,6 @@ import {
CloseButton, CloseButton,
Flex, Flex,
Grid, Grid,
Input,
ScrollArea, ScrollArea,
Tabs, Tabs,
Text, Text,
@ -17,9 +16,9 @@ import classes from "./Component.module.css";
import type { IScenario, TLine, TStation, TUser } from "../untils/types"; import type { IScenario, TLine, TStation, TUser } from "../untils/types";
import type { Socket } from "socket.io-client"; import type { Socket } from "socket.io-client";
import { ButtonDPELP, ButtonSelect } from "./ButtonAction"; import { ButtonDPELP, ButtonSelect } from "./ButtonAction";
import DrawerLogs from "./DrawerLogs"; import DrawerLogs from "./Drawer/DrawerLogs";
import { DrawerAPCControl, DrawerSwitchControl } from "./DrawerControl"; import { DrawerAPCControl, DrawerSwitchControl } from "./Drawer/DrawerControl";
import DrawerScenario from "./DrawerScenario"; import DrawerScenario from "./Modal/ModalScenario";
import { isJsonString } from "../untils/helper"; import { isJsonString } from "../untils/helper";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { import {
@ -28,6 +27,7 @@ import {
IconPlayerPlay, IconPlayerPlay,
IconPlus, IconPlus,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import InputHistory from "./InputHistory";
interface TabsProps { interface TabsProps {
selectedLines: TLine[]; selectedLines: TLine[];
@ -339,19 +339,11 @@ const BottomToolBar = ({
? JSON.parse(localStorage.getItem("user") || "") ? JSON.parse(localStorage.getItem("user") || "")
: null; : null;
}, []); }, []);
const [valueInput, setValueInput] = useState<string>("");
const inputRef = useRef<HTMLInputElement>(null);
const [openScenarioModal, setOpenScenarioModal] = useState<boolean>(false); const [openScenarioModal, setOpenScenarioModal] = useState<boolean>(false);
const [openDrawerScenario, setOpenDrawerScenario] = useState<boolean>(false); const [openDrawerScenario, setOpenDrawerScenario] = useState<boolean>(false);
// const [activeTabBottom, setActiveTabBottom] = useState<string>("command"); // const [activeTabBottom, setActiveTabBottom] = useState<string>("command");
// const [isExpand, setIsExpand] = useState<boolean>(true); // const [isExpand, setIsExpand] = useState<boolean>(true);
useEffect(() => {
if (selectedLines?.length > 1 && inputRef?.current) {
inputRef?.current?.focus();
}
}, [selectedLines?.length]);
return ( return (
<> <>
{/* Modal chọn Scenario - Custom Simple Modal */} {/* Modal chọn Scenario - Custom Simple Modal */}
@ -718,50 +710,10 @@ const BottomToolBar = ({
</Button> </Button>
</Flex> </Flex>
<Box> <Box>
<Input <InputHistory
ref={inputRef} selectedLines={selectedLines}
style={{ socket={socket}
width: "30vw", station={station}
boxShadow: "0px 0px 10px rgba(0, 0, 0, 0.1)",
}}
placeholder={"Send command to port(s)"}
value={valueInput}
onChange={(event) => {
const newValue = event.currentTarget.value;
setValueInput(newValue);
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
const listLine = selectedLines.length
? selectedLines
: station?.lines;
if (listLine?.length) {
socket?.emit("write_command_line_from_web", {
lineIds: listLine.map((line) => line.id),
stationId: station.id,
command: valueInput + "\r\n",
});
// setTimeout(() => {
// socket?.emit("write_command_line_from_web", {
// lineIds: listLine.map((line) => line.id),
// stationId: station.id,
// command: " \n",
// });
// }, 1000);
}
setValueInput("");
}
}}
rightSectionPointerEvents="all"
rightSection={
<CloseButton
aria-label="Clear input"
onClick={() => setValueInput("")}
style={{
display: valueInput ? undefined : "none",
}}
/>
}
/> />
</Box> </Box>
</Box> </Box>

View File

@ -150,7 +150,7 @@ export const ButtonDPELP = ({
id: 0, id: 0,
is_reboot: 0, is_reboot: 0,
title: "DPELP", title: "DPELP",
timeout: 300000, timeout: 360000,
body: JSON.stringify(body), body: JSON.stringify(body),
}, },
}) })

View File

@ -1,335 +1,402 @@
import { ActionIcon, Avatar, Box, Button, Flex, Group, Menu, Tabs, Text, Tooltip, UnstyledButton } from "@mantine/core"; import {
import { DndContext, useSensor, useSensors, PointerSensor, closestCenter, type DragEndEvent } from "@dnd-kit/core"; ActionIcon,
import { arrayMove, SortableContext, useSortable, horizontalListSortingStrategy } from "@dnd-kit/sortable"; Avatar,
Box,
Button,
Flex,
Group,
Menu,
Tabs,
Text,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import {
DndContext,
useSensor,
useSensors,
PointerSensor,
closestCenter,
type DragEndEvent,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
useSortable,
horizontalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { useEffect, useMemo, useState, type JSX } from "react"; import { useEffect, useMemo, useState, type JSX } from "react";
import { IconChevronRight, IconEdit, IconLogout, IconSettings, IconSettingsPlus, IconListDetails, IconUsersGroup } from "@tabler/icons-react"; import {
IconChevronRight,
IconEdit,
IconLogout,
IconSettings,
IconSettingsPlus,
IconListDetails,
IconUsersGroup,
} from "@tabler/icons-react";
import classes from "./Component.module.css"; import classes from "./Component.module.css";
import type { IScenario, TStation, TUser } from "../untils/types"; import type { IScenario, TStation, TUser } from "../untils/types";
import type { Socket } from "socket.io-client"; import type { Socket } from "socket.io-client";
import ModalHistory from "./ModalHistory"; import ModalHistory from "./Modal/ModalHistory";
import ModalConfig from "./ModalConfig"; import ModalConfig from "./Modal/ModalConfig";
import DrawerScenario from "./DrawerScenario"; import DrawerScenario from "./Modal/ModalScenario";
interface DraggableTabsProps { interface DraggableTabsProps {
tabsData: TStation[]; tabsData: TStation[];
panels: JSX.Element[]; panels: JSX.Element[];
storageKey?: string; storageKey?: string;
onChange: (activeTabId: string | null) => void; onChange: (activeTabId: string | null) => void;
w?: string | number; w?: string | number;
isStationSettings?: boolean; isStationSettings?: boolean;
socket: Socket | null; socket: Socket | null;
usersConnecting: TUser[]; usersConnecting: TUser[];
setIsEditStation: (value: React.SetStateAction<boolean>) => void; setIsEditStation: (value: React.SetStateAction<boolean>) => void;
setIsOpenAddStation: (value: React.SetStateAction<boolean>) => void; setIsOpenAddStation: (value: React.SetStateAction<boolean>) => void;
setStationEdit: (value: React.SetStateAction<TStation | undefined>) => void; setStationEdit: (value: React.SetStateAction<TStation | undefined>) => void;
active: string; active: string;
setActive: (value: React.SetStateAction<string>) => void; setActive: (value: React.SetStateAction<string>) => void;
onSendCommand: (value: string) => void; onSendCommand: (value: string) => void;
scenarios: IScenario[]; scenarios: IScenario[];
setScenarios: (value: React.SetStateAction<IScenario[]>) => void; setScenarios: (value: React.SetStateAction<IScenario[]>) => void;
} }
function SortableTab({ function SortableTab({
tab, tab,
active, active,
onChange, onChange,
}: { }: {
tab: TStation; tab: TStation;
active: string | null; active: string | null;
onChange: (id: string) => void; onChange: (id: string) => void;
isStationSettings?: boolean; isStationSettings?: boolean;
}) { }) {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: tab.id.toString() }); const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: tab.id.toString() });
return ( return (
<Tabs.Tab <Tabs.Tab
className={classes.tab} className={classes.tab}
ref={setNodeRef} ref={setNodeRef}
{...attributes} {...attributes}
{...listeners} {...listeners}
onPointerDown={(e) => { onPointerDown={(e) => {
listeners?.onPointerDown?.(e); listeners?.onPointerDown?.(e);
onChange(tab.id.toString()); onChange(tab.id.toString());
}} }}
value={tab.id.toString()} value={tab.id.toString()}
style={{ style={{
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
transition, transition,
cursor: "grab", cursor: "grab",
userSelect: "none", userSelect: "none",
backgroundColor: active === tab.id.toString() ? "#deffde" : "", backgroundColor: active === tab.id.toString() ? "#deffde" : "",
}} }}
color={active === tab.id.toString() ? "green" : ""} color={active === tab.id.toString() ? "green" : ""}
fw={600} fw={600}
fz="md" fz="md"
c="#747474"> c="#747474"
<Box className={classes.stationName}> >
<Text fw={600} fz="md" className={classes.stationText}> <Box className={classes.stationName}>
{tab.name} <Text fw={600} fz="md" className={classes.stationText}>
</Text> {tab.name}
</Box> </Text>
</Tabs.Tab> </Box>
); </Tabs.Tab>
);
} }
export default function DraggableTabs({ export default function DraggableTabs({
tabsData, tabsData,
panels, panels,
storageKey = "draggable-tabs-order", storageKey = "draggable-tabs-order",
onChange, onChange,
w, w,
isStationSettings = false, isStationSettings = false,
socket, socket,
usersConnecting, usersConnecting,
setIsEditStation, setIsEditStation,
setIsOpenAddStation, setIsOpenAddStation,
setStationEdit, setStationEdit,
active, active,
setActive, setActive,
scenarios, scenarios,
setScenarios, setScenarios,
}: DraggableTabsProps) { }: DraggableTabsProps) {
const user = useMemo(() => { const user = useMemo(() => {
return localStorage.getItem("user") && typeof localStorage.getItem("user") === "string" ? JSON.parse(localStorage.getItem("user") || "") : null; return localStorage.getItem("user") &&
}, []); typeof localStorage.getItem("user") === "string"
const [tabs, setTabs] = useState<TStation[]>(tabsData); ? JSON.parse(localStorage.getItem("user") || "")
const [isChangeTab, setIsChangeTab] = useState<boolean>(false); : null;
const [isSetActive, setIsSetActive] = useState<boolean>(false); }, []);
const [isHistoryModalOpen, setIsHistoryModalOpen] = useState<boolean>(false); const [tabs, setTabs] = useState<TStation[]>(tabsData);
const [openConfig, setOpenConfig] = useState<boolean>(false); const [isChangeTab, setIsChangeTab] = useState<boolean>(false);
const [openDrawerScenario, setOpenDrawerScenario] = useState<boolean>(false); const [isSetActive, setIsSetActive] = useState<boolean>(false);
const [isHistoryModalOpen, setIsHistoryModalOpen] = useState<boolean>(false);
const [openConfig, setOpenConfig] = useState<boolean>(false);
const [openDrawerScenario, setOpenDrawerScenario] = useState<boolean>(false);
const sensors = useSensors(useSensor(PointerSensor)); const sensors = useSensors(useSensor(PointerSensor));
// Load saved order from localStorage // Load saved order from localStorage
useEffect(() => { useEffect(() => {
if (isChangeTab) { if (isChangeTab) {
setTabs((pre) => setTabs((pre) =>
pre pre
.map((t) => { .map((t) => {
const updatedTab = tabsData.find((td) => td.id === t.id); const updatedTab = tabsData.find((td) => td.id === t.id);
return updatedTab ? updatedTab : t; return updatedTab ? updatedTab : t;
}) })
.filter((t) => (tabsData.find((td) => td.id === t.id) ? true : false)) .filter((t) => (tabsData.find((td) => td.id === t.id) ? true : false))
); );
} else { } else {
const saved = localStorage.getItem(storageKey); const saved = localStorage.getItem(storageKey);
let tabSelected = tabsData?.length > 0 ? tabsData[0]?.id.toString() : null; let tabSelected =
if (saved) { tabsData?.length > 0 ? tabsData[0]?.id.toString() : null;
try { if (saved) {
const order = JSON.parse(saved) as { id: string; index: number }[]; try {
const order = JSON.parse(saved) as { id: string; index: number }[];
// Find the max index in saved order // Find the max index in saved order
const maxIndex = Math.max(...order.map((o) => o.index), 0); const maxIndex = Math.max(...order.map((o) => o.index), 0);
const sorted = [...tabsData].sort((a, b) => { const sorted = [...tabsData].sort((a, b) => {
const aOrder = order.find((o) => o.id.toString() === a.id.toString())?.index; const aOrder = order.find(
const bOrder = order.find((o) => o.id.toString() === b.id.toString())?.index; (o) => o.id.toString() === a.id.toString()
)?.index;
const bOrder = order.find(
(o) => o.id.toString() === b.id.toString()
)?.index;
// If not found, assign index after all existing ones // If not found, assign index after all existing ones
const aIndex = aOrder !== undefined ? aOrder : maxIndex + 1; const aIndex = aOrder !== undefined ? aOrder : maxIndex + 1;
const bIndex = bOrder !== undefined ? bOrder : maxIndex + 1; const bIndex = bOrder !== undefined ? bOrder : maxIndex + 1;
return aIndex - bIndex; return aIndex - bIndex;
}); });
tabSelected = sorted?.length > 0 ? sorted[0]?.id.toString() : null; tabSelected = sorted?.length > 0 ? sorted[0]?.id.toString() : null;
setTabs(sorted); setTabs(sorted);
} catch { } catch {
setTabs(tabsData); setTabs(tabsData);
} }
} else { } else {
setTabs(tabsData); setTabs(tabsData);
} }
if (!isSetActive && tabSelected) { if (!isSetActive && tabSelected) {
setActive(tabSelected); setActive(tabSelected);
setTimeout(() => { setTimeout(() => {
onChange(tabSelected); onChange(tabSelected);
}, 100); }, 100);
setIsSetActive(true); setIsSetActive(true);
} }
} }
}, [tabsData, storageKey]); }, [tabsData, storageKey]);
// Handle reorder // Handle reorder
const handleDragEnd = (event: DragEndEvent) => { const handleDragEnd = (event: DragEndEvent) => {
const { active: dragActive, over } = event; const { active: dragActive, over } = event;
if (dragActive.id !== over?.id && over?.id) { if (dragActive.id !== over?.id && over?.id) {
const oldIndex = tabs.findIndex((t) => t.id.toString() === dragActive.id.toString()); const oldIndex = tabs.findIndex(
const newIndex = tabs.findIndex((t) => t.id.toString() === over?.id.toString()); (t) => t.id.toString() === dragActive.id.toString()
const newTabs = arrayMove(tabs, oldIndex, newIndex); );
setTabs(newTabs); const newIndex = tabs.findIndex(
(t) => t.id.toString() === over?.id.toString()
);
const newTabs = arrayMove(tabs, oldIndex, newIndex);
setTabs(newTabs);
const order = newTabs.map((t, i) => ({ id: t.id, index: i })); const order = newTabs.map((t, i) => ({ id: t.id, index: i }));
localStorage.setItem(storageKey, JSON.stringify(order)); localStorage.setItem(storageKey, JSON.stringify(order));
} }
}; };
// Clean up // Clean up
useEffect(() => { useEffect(() => {
return () => { return () => {
setIsChangeTab(false); setIsChangeTab(false);
setIsSetActive(false); setIsSetActive(false);
setTabs([]); setTabs([]);
setActive("0"); setActive("0");
}; };
}, []); }, []);
return ( return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}> <DndContext
<Tabs sensors={sensors}
value={active} collisionDetection={closestCenter}
onChange={(val) => { onDragEnd={handleDragEnd}
setIsChangeTab(true); >
onChange(val); <Tabs
setActive(val || "0"); value={active}
}} onChange={(val) => {
w={w}> setIsChangeTab(true);
<Flex justify={"space-between"}> onChange(val);
<Flex style={{ marginTop: "8px" }} gap="xs" align="center"> setActive(val || "0");
<ActionIcon title="Setting" variant="outline" onClick={() => setOpenConfig(true)}> }}
<IconSettings /> w={w}
</ActionIcon> >
<Button <Flex justify={"space-between"}>
color="yellow" <Flex style={{ marginTop: "8px" }} gap="xs" align="center">
<ActionIcon
title="Setting"
variant="outline"
onClick={() => setOpenConfig(true)}
>
<IconSettings />
</ActionIcon>
<Button
color="yellow"
variant="filled" variant="filled"
size="xs" size="xs"
leftSection={<IconListDetails size={16} />} leftSection={<IconListDetails size={16} />}
onClick={() => setOpenDrawerScenario(true)}> onClick={() => setOpenDrawerScenario(true)}
Scenario >
</Button> Scenario
</Flex> </Button>
<Tabs.List className={classes.list}> </Flex>
<SortableContext items={tabs} strategy={horizontalListSortingStrategy}> <Tabs.List className={classes.list}>
{tabs.map((tab) => ( <SortableContext
<SortableTab items={tabs}
key={tab.id} strategy={horizontalListSortingStrategy}
tab={tab} >
active={active} {tabs.map((tab) => (
onChange={(id) => { <SortableTab
setIsChangeTab(true); key={tab.id}
onChange(id); tab={tab}
setActive(id); active={active}
}} onChange={(id) => {
isStationSettings={isStationSettings} setIsChangeTab(true);
/> onChange(id);
))} setActive(id);
</SortableContext> }}
isStationSettings={isStationSettings}
/>
))}
</SortableContext>
<Flex gap={"md"} ms={"xs"} align={"center"}> <Flex gap={"md"} ms={"xs"} align={"center"}>
{Number(active) ? ( {Number(active) ? (
<ActionIcon <ActionIcon
title="Edit Station" title="Edit Station"
variant="outline" variant="outline"
onClick={() => { onClick={() => {
setStationEdit(tabsData.find((el) => el.id === Number(active))); setStationEdit(
setIsOpenAddStation(true); tabsData.find((el) => el.id === Number(active))
setIsEditStation(true); );
}}> setIsOpenAddStation(true);
<IconEdit /> setIsEditStation(true);
</ActionIcon> }}
) : ( >
"" <IconEdit />
)} </ActionIcon>
<ActionIcon ) : (
title="Add Station" ""
variant="outline" )}
color="green" <ActionIcon
onClick={() => { title="Add Station"
setIsOpenAddStation(true); variant="outline"
setIsEditStation(false); color="green"
setStationEdit(undefined); onClick={() => {
}}> setIsOpenAddStation(true);
<IconSettingsPlus /> setIsEditStation(false);
</ActionIcon> setStationEdit(undefined);
</Flex> }}
</Tabs.List> >
<Flex align={"center"}> <IconSettingsPlus />
<Button </ActionIcon>
variant="outline" </Flex>
style={{ height: "26px", width: "80px", marginRight: "20px" }} </Tabs.List>
size="xs" <Flex align={"center"}>
onClick={() => { <Button
setIsHistoryModalOpen(true); variant="outline"
}}> style={{ height: "26px", width: "80px", marginRight: "20px" }}
History size="xs"
</Button> onClick={() => {
<Tooltip setIsHistoryModalOpen(true);
withArrow }}
label={usersConnecting.map((el) => ( >
<Text key={el.userId || el.id}>{el.userName}</Text> History
))}> </Button>
<Avatar radius="xl" me={"sm"}> <Tooltip
<IconUsersGroup color="green" /> withArrow
</Avatar> label={usersConnecting.map((el) => (
</Tooltip> <Text key={el.userId || el.id}>{el.userName}</Text>
<Menu withArrow> ))}
<Menu.Target> >
<UnstyledButton <Avatar radius="xl" me={"sm"}>
style={{ <IconUsersGroup color="green" />
padding: "var(--mantine-spacing-md)", </Avatar>
color: "var(--mantine-color-text)", </Tooltip>
borderRadius: "var(--mantine-radius-sm)", <Menu withArrow>
}}> <Menu.Target>
<Group> <UnstyledButton
<div style={{ flex: 1 }}> style={{
<Text size="sm" fw={500}> padding: "var(--mantine-spacing-md)",
{user?.userName || user?.user_name || ""} color: "var(--mantine-color-text)",
</Text> borderRadius: "var(--mantine-radius-sm)",
}}
>
<Group>
<div style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{user?.userName || user?.user_name || ""}
</Text>
<Text c="dimmed" size="xs"> <Text c="dimmed" size="xs">
{user?.email} {user?.email}
</Text> </Text>
</div> </div>
<IconChevronRight size={16} /> <IconChevronRight size={16} />
</Group> </Group>
</UnstyledButton> </UnstyledButton>
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Item <Menu.Item
style={{ width: "150px" }} style={{ width: "150px" }}
onClick={() => { onClick={() => {
localStorage.removeItem("user"); localStorage.removeItem("user");
window.location.href = "/"; window.location.href = "/";
socket?.disconnect(); socket?.disconnect();
}} }}
color="red" color="red"
leftSection={<IconLogout size={16} stroke={1.5} />}> leftSection={<IconLogout size={16} stroke={1.5} />}
Logout >
</Menu.Item> Logout
</Menu.Dropdown> </Menu.Item>
</Menu> </Menu.Dropdown>
</Flex> </Menu>
</Flex> </Flex>
</Flex>
{panels} {panels}
</Tabs> </Tabs>
<ModalHistory <ModalHistory
opened={isHistoryModalOpen} opened={isHistoryModalOpen}
onClose={() => setIsHistoryModalOpen(false)} onClose={() => setIsHistoryModalOpen(false)}
socket={socket} socket={socket}
stationIds={tabs.map((el) => el.id)} stationIds={tabs.map((el) => el.id)}
tabs={tabs} tabs={tabs}
/> />
<ModalConfig <ModalConfig
opened={openConfig} opened={openConfig}
onClose={() => setOpenConfig(false)} onClose={() => setOpenConfig(false)}
onSave={() => { onSave={() => {
onChange(active); onChange(active);
}} }}
/> />
<DrawerScenario <DrawerScenario
scenarios={scenarios} scenarios={scenarios}
setScenarios={setScenarios} setScenarios={setScenarios}
externalOpened={openDrawerScenario} externalOpened={openDrawerScenario}
onExternalClose={() => setOpenDrawerScenario(false)} onExternalClose={() => setOpenDrawerScenario(false)}
/> />
</DndContext> </DndContext>
); );
} }

View File

@ -12,8 +12,8 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { IconRepeat } from "@tabler/icons-react"; import { IconRepeat } from "@tabler/icons-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import classes from "./Component.module.css"; import classes from "../Component.module.css";
import type { APCProps, SwitchPortsProps, TStation } from "../untils/types"; import type { APCProps, SwitchPortsProps, TStation } from "../../untils/types";
import type { Socket } from "socket.io-client"; import type { Socket } from "socket.io-client";
interface DrawerProps { interface DrawerProps {

View File

@ -0,0 +1,314 @@
import { useDisclosure } from "@mantine/hooks";
import {
Button,
Box,
Drawer,
Grid,
Table,
Text,
ScrollArea,
Tooltip,
TextInput,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { useEffect, useState } from "react";
import type { ISystemLog } from "../../untils/types";
import {
IconDownload,
IconEye,
IconInfoCircle,
IconX,
} from "@tabler/icons-react";
import classes from "../Component.module.css";
import moment from "moment";
import type { Socket } from "socket.io-client";
import ModalLog from "../Modal/ModalLog";
function DrawerLogs({
socket,
isLogModalOpen,
setIsLogModalOpen,
testLogContent,
setTestLogContent,
}: {
socket: Socket | null;
isLogModalOpen: boolean;
setIsLogModalOpen: (value: React.SetStateAction<boolean>) => void;
testLogContent: string;
setTestLogContent: (value: React.SetStateAction<string>) => void;
}) {
const [opened, { open, close }] = useDisclosure(false);
const [systemLogs, setSystemLogs] = useState<ISystemLog[]>([]);
const [isDownloadLog, setIsDownloadLog] = useState(false);
// const [testLogContent, setTestLogContent] = useState("");
// const [isLogModalOpen, setIsLogModalOpen] = useState(false);
const [downloadName, setDownloadName] = useState("");
const [searchFileName, setSearchFileName] = useState("");
const [fromDate, setFromDate] = useState<Date | null>(null);
const [toDate, setToDate] = useState<Date | null>(null);
const [filteredLogs, setFilteredLogs] = useState<ISystemLog[]>([]);
useEffect(() => {
if (opened) {
socket?.emit("get_list_logs");
}
}, [opened]);
useEffect(() => {
socket?.on("list_logs", (files: string[]) => {
const list: ISystemLog[] = files.map((file) => {
const filename = file.replace(/^.*[\\/]/, "");
const createAt = filename.match(/\d{8}/);
return {
fileName:
file.split("/")[3] || file.split("/")[2] || file.split("/")[1],
createdAt: createAt ? createAt[0] : "N/A",
path: file,
};
});
setSystemLogs(
list.sort(
(a: ISystemLog, b: ISystemLog) =>
parseInt(b.createdAt) - parseInt(a.createdAt)
)
);
});
}, [socket]);
useEffect(() => {
if (isDownloadLog && testLogContent && downloadName) {
const blob = new Blob([testLogContent], { type: "text/plain" });
// Create a temporary link element
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = downloadName;
// Trigger the download by clicking the link
link.click();
// Clean up
URL.revokeObjectURL(link.href);
setIsDownloadLog(false);
setTestLogContent("");
setDownloadName("");
}
}, [testLogContent]);
useEffect(() => {
// Chuẩn bị trước các giá trị search/date để tránh tính lại trong filter cho từng phần tử
const trimmedSearch = searchFileName.trim().toLowerCase();
const hasSearch = trimmedSearch.length > 0;
const fromMoment = fromDate ? moment(fromDate).startOf("day") : null;
const toMoment = toDate ? moment(toDate).endOf("day") : null;
const delayDebounceFn = setTimeout(() => {
// Nếu không có filter nào, tránh filter tốn công, gán thẳng
if (!hasSearch && !fromMoment && !toMoment) {
setFilteredLogs(systemLogs);
return;
}
const next = systemLogs.filter((log) => {
if (hasSearch && !log.fileName.toLowerCase().includes(trimmedSearch)) {
return false;
}
const logDate = moment(log.createdAt, "YYYYMMDD");
if (fromMoment && !logDate.isSameOrAfter(fromMoment)) {
return false;
}
if (toMoment && !logDate.isSameOrBefore(toMoment)) {
return false;
}
return true;
});
setFilteredLogs(next);
}, 500);
return () => clearTimeout(delayDebounceFn);
}, [searchFileName, fromDate, toDate, systemLogs]);
return (
<>
<Drawer
size={"50%"}
position="right"
style={{ position: "absolute", left: 0 }}
offset={8}
radius="md"
opened={opened}
onClose={close}
title={
<div>
<Tooltip
label={
<div>
Format:
<i style={{ marginLeft: "4px" }}>
YYYYMMDD-AUTO-Session.{`{Station name}`}-{`{Station ID}`}-
{`{Station IP}`}-{`{Line number}`}
.log
</i>
</div>
}
position="right"
>
<Text
fw={700}
style={{ display: "flex", alignItems: "center", gap: "6px" }}
>
List Logs <IconInfoCircle color="#3bb7e9" fontSize={"12px"} />
</Text>
</Tooltip>
</div>
}
>
<Grid>
<Grid.Col span={12}>
<Box mb="xs">
<Grid gutter="xs">
<Grid.Col span={6}>
<TextInput
placeholder="Search file name"
value={searchFileName}
onChange={(event) =>
setSearchFileName(event.currentTarget.value)
}
rightSection={
searchFileName ? (
<IconX
size={14}
style={{ cursor: "pointer" }}
onClick={() => setSearchFileName("")}
/>
) : null
}
rightSectionPointerEvents="auto"
size="xs"
/>
</Grid.Col>
<Grid.Col span={3}>
<DateInput
value={fromDate}
onChange={(value) => setFromDate(value as Date | null)}
placeholder="From date"
valueFormat="DD/MM/YYYY"
size="xs"
clearable
/>
</Grid.Col>
<Grid.Col span={3}>
<DateInput
value={toDate}
onChange={(value) => setToDate(value as Date | null)}
placeholder="To date"
valueFormat="DD/MM/YYYY"
size="xs"
clearable
/>
</Grid.Col>
</Grid>
</Box>
<ScrollArea h={"85vh"} style={{ marginTop: "15px" }}>
<Table
stickyHeader
striped
highlightOnHover
withRowBorders={true}
withTableBorder={true}
withColumnBorders={true}
>
<Table.Thead
style={{
top: 1,
}}
>
<Table.Tr>
<Table.Th style={{ width: "50%" }}>File name</Table.Th>
<Table.Th style={{ width: "30%" }}>Created at</Table.Th>
<Table.Th style={{ width: "10%" }}></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filteredLogs.map((element) => (
<Table.Tr key={element.path}>
<Table.Td>{element.fileName}</Table.Td>
<Table.Td>
<Text>
{moment(element.createdAt).format("DD/MM/YYYY")}
</Text>
</Table.Td>
<Table.Td>
<Box
key={"action-" + element.fileName}
className={classes.optionIcon}
>
<IconEye
className={classes.viewIcon}
onClick={() => {
setTestLogContent("");
socket?.emit("get_content_log", {
line: { systemLogUrl: element.path },
});
setIsLogModalOpen(true);
}}
width={20}
/>
<IconDownload
className={[
classes.downloadIcon,
isDownloadLog ? classes.isDisabled : "",
].join(" ")}
onClick={() => {
socket?.emit("get_content_log", {
line: { systemLogUrl: element.path },
});
setIsDownloadLog(true);
setTestLogContent("");
setDownloadName(
element.path.split("/")[3] ||
element.path.split("/")[2] ||
element.path.split("/")[1]
);
}}
width={20}
/>
</Box>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea>
</Grid.Col>
</Grid>
{isLogModalOpen && (
<ModalLog
opened={isLogModalOpen}
onClose={() => {
setIsLogModalOpen(false);
}}
testLogContent={testLogContent}
/>
)}
</Drawer>
<Button
fw={400}
style={{ height: "30px", width: "100px" }}
title="Add Scenario"
variant="outline"
// color="green"
onClick={() => {
open();
}}
>
List logs{" "}
</Button>
</>
);
}
export default DrawerLogs;

View File

@ -1,264 +0,0 @@
import { useDisclosure } from "@mantine/hooks";
import { Button, Box, Drawer, Grid, Table, Text, ScrollArea, Tooltip, TextInput } from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { useEffect, useState } from "react";
import type { ISystemLog } from "../untils/types";
import { IconDownload, IconEye, IconInfoCircle, IconX } from "@tabler/icons-react";
import classes from "./Component.module.css";
import moment from "moment";
import type { Socket } from "socket.io-client";
import ModalLog from "./ModalLog";
function DrawerLogs({
socket,
isLogModalOpen,
setIsLogModalOpen,
testLogContent,
setTestLogContent,
}: {
socket: Socket | null;
isLogModalOpen: boolean;
setIsLogModalOpen: (value: React.SetStateAction<boolean>) => void;
testLogContent: string;
setTestLogContent: (value: React.SetStateAction<string>) => void;
}) {
const [opened, { open, close }] = useDisclosure(false);
const [systemLogs, setSystemLogs] = useState<ISystemLog[]>([]);
const [isDownloadLog, setIsDownloadLog] = useState(false);
// const [testLogContent, setTestLogContent] = useState("");
// const [isLogModalOpen, setIsLogModalOpen] = useState(false);
const [downloadName, setDownloadName] = useState("");
const [searchFileName, setSearchFileName] = useState("");
const [fromDate, setFromDate] = useState<Date | null>(null);
const [toDate, setToDate] = useState<Date | null>(null);
const [filteredLogs, setFilteredLogs] = useState<ISystemLog[]>([]);
useEffect(() => {
if (opened) {
socket?.emit("get_list_logs");
}
}, [opened]);
useEffect(() => {
socket?.on("list_logs", (files: string[]) => {
const list: ISystemLog[] = files.map((file) => {
const filename = file.replace(/^.*[\\/]/, "");
const createAt = filename.match(/\d{8}/);
return {
fileName: file.split("/")[3] || file.split("/")[2] || file.split("/")[1],
createdAt: createAt ? createAt[0] : "N/A",
path: file,
};
});
setSystemLogs(list.sort((a: ISystemLog, b: ISystemLog) => parseInt(b.createdAt) - parseInt(a.createdAt)));
});
}, [socket]);
useEffect(() => {
if (isDownloadLog && testLogContent && downloadName) {
const blob = new Blob([testLogContent], { type: "text/plain" });
// Create a temporary link element
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = downloadName;
// Trigger the download by clicking the link
link.click();
// Clean up
URL.revokeObjectURL(link.href);
setIsDownloadLog(false);
setTestLogContent("");
setDownloadName("");
}
}, [testLogContent]);
useEffect(() => {
// Chuẩn bị trước các giá trị search/date để tránh tính lại trong filter cho từng phần tử
const trimmedSearch = searchFileName.trim().toLowerCase();
const hasSearch = trimmedSearch.length > 0;
const fromMoment = fromDate ? moment(fromDate).startOf("day") : null;
const toMoment = toDate ? moment(toDate).endOf("day") : null;
const delayDebounceFn = setTimeout(() => {
// Nếu không có filter nào, tránh filter tốn công, gán thẳng
if (!hasSearch && !fromMoment && !toMoment) {
setFilteredLogs(systemLogs);
return;
}
const next = systemLogs.filter((log) => {
if (hasSearch && !log.fileName.toLowerCase().includes(trimmedSearch)) {
return false;
}
const logDate = moment(log.createdAt, "YYYYMMDD");
if (fromMoment && !logDate.isSameOrAfter(fromMoment)) {
return false;
}
if (toMoment && !logDate.isSameOrBefore(toMoment)) {
return false;
}
return true;
});
setFilteredLogs(next);
}, 500);
return () => clearTimeout(delayDebounceFn);
}, [searchFileName, fromDate, toDate, systemLogs]);
return (
<>
<Drawer
size={"50%"}
position="right"
style={{ position: "absolute", left: 0 }}
offset={8}
radius="md"
opened={opened}
onClose={close}
title={
<div>
<Tooltip
label={
<div>
Format:
<i style={{ marginLeft: "4px" }}>
YYYYMMDD-AUTO-Session.{`{Station name}`}-{`{Station ID}`}-{`{Station IP}`}-{`{Line number}`}
.log
</i>
</div>
}
position="right">
<Text fw={700} style={{ display: "flex", alignItems: "center", gap: "6px" }}>
List Logs <IconInfoCircle color="#3bb7e9" fontSize={"12px"} />
</Text>
</Tooltip>
</div>
}>
<Grid>
<Grid.Col span={12}>
<Box mb="xs">
<Grid gutter="xs">
<Grid.Col span={6}>
<TextInput
placeholder="Search file name"
value={searchFileName}
onChange={(event) => setSearchFileName(event.currentTarget.value)}
rightSection={
searchFileName ? (
<IconX
size={14}
style={{ cursor: "pointer" }}
onClick={() => setSearchFileName("")}
/>
) : null
}
rightSectionPointerEvents="auto"
size="xs"
/>
</Grid.Col>
<Grid.Col span={3}>
<DateInput
value={fromDate}
onChange={(value) => setFromDate(value as Date | null)}
placeholder="From date"
valueFormat="DD/MM/YYYY"
size="xs"
clearable
/>
</Grid.Col>
<Grid.Col span={3}>
<DateInput
value={toDate}
onChange={(value) => setToDate(value as Date | null)}
placeholder="To date"
valueFormat="DD/MM/YYYY"
size="xs"
clearable
/>
</Grid.Col>
</Grid>
</Box>
<ScrollArea h={"85vh"} style={{ marginTop: "15px" }}>
<Table stickyHeader striped highlightOnHover withRowBorders={true} withTableBorder={true} withColumnBorders={true}>
<Table.Thead
style={{
top: 1,
}}>
<Table.Tr>
<Table.Th style={{ width: "50%" }}>File name</Table.Th>
<Table.Th style={{ width: "30%" }}>Created at</Table.Th>
<Table.Th style={{ width: "10%" }}></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filteredLogs.map((element) => (
<Table.Tr key={element.path}>
<Table.Td>{element.fileName}</Table.Td>
<Table.Td>
<Text>{moment(element.createdAt).format("DD/MM/YYYY")}</Text>
</Table.Td>
<Table.Td>
<Box key={"action-" + element.fileName} className={classes.optionIcon}>
<IconEye
className={classes.viewIcon}
onClick={() => {
setTestLogContent("");
socket?.emit("get_content_log", {
line: { systemLogUrl: element.path },
});
setIsLogModalOpen(true);
}}
width={20}
/>
<IconDownload
className={[classes.downloadIcon, isDownloadLog ? classes.isDisabled : ""].join(" ")}
onClick={() => {
socket?.emit("get_content_log", {
line: { systemLogUrl: element.path },
});
setIsDownloadLog(true);
setTestLogContent("");
setDownloadName(element.path.split("/")[3] || element.path.split("/")[2] || element.path.split("/")[1]);
}}
width={20}
/>
</Box>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea>
</Grid.Col>
</Grid>
{isLogModalOpen && (
<ModalLog
opened={isLogModalOpen}
onClose={() => {
setIsLogModalOpen(false);
}}
testLogContent={testLogContent}
/>
)}
</Drawer>
<Button
fw={400}
style={{ height: "30px", width: "100px" }}
title="Add Scenario"
variant="outline"
// color="green"
onClick={() => {
open();
}}>
List logs{" "}
</Button>
</>
);
}
export default DrawerLogs;

View File

@ -0,0 +1,135 @@
import { useEffect, useState, useCallback, useRef } from "react";
import { CloseButton, TextInput } from "@mantine/core";
import type { TLine, TStation } from "../untils/types";
import type { Socket } from "socket.io-client";
const HISTORY_KEY = "command_history";
const MAX_HISTORY = 20;
export default function InputHistory({
selectedLines,
socket,
station,
}: {
selectedLines: TLine[];
socket: Socket | null;
station: TStation;
}) {
const [value, setValue] = useState("");
const [history, setHistory] = useState<string[]>([]);
const [historyIndex, setHistoryIndex] = useState<number>(-1);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (selectedLines?.length > 1 && inputRef?.current) {
inputRef?.current?.focus();
}
}, [selectedLines?.length]);
// Load history từ localStorage
useEffect(() => {
try {
const saved = JSON.parse(localStorage.getItem(HISTORY_KEY) || "[]");
if (Array.isArray(saved)) setHistory(saved);
} catch (error) {
console.error("Failed to load command history:", error);
}
}, []);
// Lưu history khi thay đổi
const saveHistory = (list: string[]) => {
localStorage.setItem(HISTORY_KEY, JSON.stringify(list));
};
// Xử lý Enter
const handleEnter = useCallback(() => {
const listLine = selectedLines.length ? selectedLines : station?.lines;
if (listLine?.length) {
socket?.emit("write_command_line_from_web", {
lineIds: listLine.map((line) => line.id),
stationId: station.id,
command: value + "\r\n",
});
}
// Reset
setHistoryIndex(-1);
setValue("");
if (!value.trim()) return;
let newHistory = [...history];
// Không thêm nếu giống command cuối cùng
if (newHistory[newHistory.length - 1] !== value.trim()) {
newHistory.push(value.trim());
}
// Giới hạn 10 lệnh
if (newHistory.length > MAX_HISTORY) {
newHistory = newHistory.slice(newHistory.length - MAX_HISTORY);
}
setHistory(newHistory);
saveHistory(newHistory);
// Reset
// setHistoryIndex(-1);
// setValue("");
// console.log("Command:", value);
}, [value, history, selectedLines, socket, station]);
// Xử lý phím
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
handleEnter();
}
if (e.key === "ArrowUp") {
e.preventDefault();
if (history.length === 0) return;
const newIndex =
historyIndex < 0 ? history.length - 1 : Math.max(0, historyIndex - 1);
setHistoryIndex(newIndex);
setValue(history[newIndex]);
}
if (e.key === "ArrowDown") {
e.preventDefault();
if (history.length === 0) return;
if (historyIndex === -1) return;
const newIndex =
historyIndex >= history.length - 1 ? -1 : historyIndex + 1;
setHistoryIndex(newIndex);
setValue(newIndex === -1 ? "" : history[newIndex]);
}
};
return (
<TextInput
style={{
width: "30vw",
boxShadow: "0px 0px 10px rgba(0, 0, 0, 0.1)",
}}
placeholder={"Send command to port(s)"}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
radius="md"
rightSectionPointerEvents="all"
rightSection={
<CloseButton
aria-label="Clear input"
onClick={() => setValue("")}
style={{
display: value ? undefined : "none",
}}
/>
}
/>
);
}

View File

@ -7,9 +7,9 @@ import {
CloseButton, CloseButton,
Button, Button,
} from "@mantine/core"; } from "@mantine/core";
import classes from "./Component.module.css"; import classes from "../Component.module.css";
import type { Socket } from "socket.io-client"; import type { Socket } from "socket.io-client";
import type { TStation } from "../untils/types"; import type { TStation } from "../../untils/types";
interface HistoryItem { interface HistoryItem {
id: number; id: number;
@ -101,7 +101,7 @@ function ModalHistory({
}); });
} }
}); });
// Sort by tabs order // Sort by tabs order
if (tabs.length > 0) { if (tabs.length > 0) {
merged.sort((a, b) => { merged.sort((a, b) => {
@ -113,7 +113,7 @@ function ModalHistory({
return 0; return 0;
}); });
} }
if (merged.length > 0 && !activeStation) { if (merged.length > 0 && !activeStation) {
setActiveStation(merged[0].stationId.toString()); setActiveStation(merged[0].stationId.toString());
} }
@ -132,7 +132,7 @@ function ModalHistory({
isBlockingScrollRef.current = false; isBlockingScrollRef.current = false;
lastScrollTopRef.current = 0; lastScrollTopRef.current = 0;
} }
return () => { return () => {
if (scrollTimeoutRef.current) { if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current); clearTimeout(scrollTimeoutRef.current);
@ -145,16 +145,17 @@ function ModalHistory({
if (!scrollViewportRef.current || !opened) return; if (!scrollViewportRef.current || !opened) return;
const scrollContainer = scrollViewportRef.current; const scrollContainer = scrollViewportRef.current;
const handleWheel = (e: WheelEvent) => { const handleWheel = (e: WheelEvent) => {
if (!isBlockingScrollRef.current) return; if (!isBlockingScrollRef.current) return;
const scrollHeight = scrollContainer.scrollHeight; const scrollHeight = scrollContainer.scrollHeight;
const clientHeight = scrollContainer.clientHeight; const clientHeight = scrollContainer.clientHeight;
const currentScrollTop = scrollContainer.scrollTop; const currentScrollTop = scrollContainer.scrollTop;
const maxScrollTop = scrollHeight - clientHeight; const maxScrollTop = scrollHeight - clientHeight;
const isAtBottom = Math.abs(currentScrollTop + clientHeight - scrollHeight) < 20; const isAtBottom =
Math.abs(currentScrollTop + clientHeight - scrollHeight) < 20;
// If at bottom and trying to scroll down, prevent it // If at bottom and trying to scroll down, prevent it
if (isAtBottom && e.deltaY > 0) { if (isAtBottom && e.deltaY > 0) {
e.preventDefault(); e.preventDefault();
@ -170,7 +171,7 @@ function ModalHistory({
}; };
scrollContainer.addEventListener("wheel", handleWheel, { passive: false }); scrollContainer.addEventListener("wheel", handleWheel, { passive: false });
return () => { return () => {
scrollContainer.removeEventListener("wheel", handleWheel); scrollContainer.removeEventListener("wheel", handleWheel);
}; };
@ -178,7 +179,7 @@ function ModalHistory({
// Scroll to station when activeStation changes (only when clicked, not when auto-detected) // Scroll to station when activeStation changes (only when clicked, not when auto-detected)
const isManualScrollRef = useRef(false); const isManualScrollRef = useRef(false);
useEffect(() => { useEffect(() => {
if (activeStation && isManualScrollRef.current) { if (activeStation && isManualScrollRef.current) {
setTimeout(() => { setTimeout(() => {
@ -192,7 +193,6 @@ function ModalHistory({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeStation]); }, [activeStation]);
// Utility function to format timestamp (can be used later if needed) // Utility function to format timestamp (can be used later if needed)
// const formatTimestamp = (timestamp: number) => { // const formatTimestamp = (timestamp: number) => {
// const date = new Date(timestamp); // const date = new Date(timestamp);
@ -263,12 +263,10 @@ function ModalHistory({
// Sort by tabs order if tabs is provided // Sort by tabs order if tabs is provided
const mergedHistoryData: StationHistory[] = (() => { const mergedHistoryData: StationHistory[] = (() => {
const result: StationHistory[] = [...historyData]; const result: StationHistory[] = [...historyData];
if (tabs.length > 0) { if (tabs.length > 0) {
tabs.forEach((tab) => { tabs.forEach((tab) => {
const existingIndex = result.findIndex( const existingIndex = result.findIndex((h) => h.stationId === tab.id);
(h) => h.stationId === tab.id
);
if (existingIndex === -1) { if (existingIndex === -1) {
// Station from tabs not found in historyData, add it with empty history // Station from tabs not found in historyData, add it with empty history
result.push({ result.push({
@ -281,12 +279,12 @@ function ModalHistory({
result[existingIndex].stationName = tab.name; result[existingIndex].stationName = tab.name;
} }
}); });
// Sort by tabs order to maintain the same order as tabs // Sort by tabs order to maintain the same order as tabs
result.sort((a, b) => { result.sort((a, b) => {
const aIndex = tabs.findIndex((tab) => tab.id === a.stationId); const aIndex = tabs.findIndex((tab) => tab.id === a.stationId);
const bIndex = tabs.findIndex((tab) => tab.id === b.stationId); const bIndex = tabs.findIndex((tab) => tab.id === b.stationId);
// If both are in tabs, sort by tabs order // If both are in tabs, sort by tabs order
if (aIndex !== -1 && bIndex !== -1) { if (aIndex !== -1 && bIndex !== -1) {
return aIndex - bIndex; return aIndex - bIndex;
@ -299,7 +297,7 @@ function ModalHistory({
return 0; return 0;
}); });
} }
return result; return result;
})(); })();
@ -315,12 +313,13 @@ function ModalHistory({
// Get lines from tabs for this station // Get lines from tabs for this station
const tabStation = tabs.find((tab) => tab.id === station.stationId); const tabStation = tabs.find((tab) => tab.id === station.stationId);
const tabLines = tabStation?.lines || []; const tabLines = tabStation?.lines || [];
// Group filtered history by line number // Group filtered history by line number
const groupedHistory = station.filteredHistory.length > 0 const groupedHistory =
? groupHistoryByLine(station.filteredHistory) station.filteredHistory.length > 0
: new Map<number, HistoryItem[]>(); ? groupHistoryByLine(station.filteredHistory)
: new Map<number, HistoryItem[]>();
// Ensure all lines from tabs are included in groupedHistory (even if empty) // Ensure all lines from tabs are included in groupedHistory (even if empty)
tabLines.forEach((line) => { tabLines.forEach((line) => {
const lineNumber = line.lineNumber || line.line_number || line.port; const lineNumber = line.lineNumber || line.line_number || line.port;
@ -329,7 +328,7 @@ function ModalHistory({
groupedHistory.set(lineNumber, []); groupedHistory.set(lineNumber, []);
} }
}); });
return { return {
...station, ...station,
groupedHistory, groupedHistory,
@ -341,29 +340,30 @@ function ModalHistory({
// Try to scroll to content first (line group đầu tiên hoặc message) // Try to scroll to content first (line group đầu tiên hoặc message)
const contentElement = stationContentRefs.current.get(stationId); const contentElement = stationContentRefs.current.get(stationId);
const stationHeader = stationRefs.current.get(stationId); const stationHeader = stationRefs.current.get(stationId);
if (contentElement && scrollViewportRef.current) { if (contentElement && scrollViewportRef.current) {
const scrollContainer = scrollViewportRef.current; const scrollContainer = scrollViewportRef.current;
const containerRect = scrollContainer.getBoundingClientRect(); const containerRect = scrollContainer.getBoundingClientRect();
const contentRect = contentElement.getBoundingClientRect(); const contentRect = contentElement.getBoundingClientRect();
const currentScrollTop = scrollContainer.scrollTop; const currentScrollTop = scrollContainer.scrollTop;
// Tính toán vị trí scroll để content nằm ngay dưới sticky header // Tính toán vị trí scroll để content nằm ngay dưới sticky header
// Sticky header có position: sticky, top: 0, nên luôn ở top của viewport // Sticky header có position: sticky, top: 0, nên luôn ở top của viewport
const headerHeight = stationHeader ? stationHeader.offsetHeight : 0; const headerHeight = stationHeader ? stationHeader.offsetHeight : 0;
// Scroll đến vị trí sao cho content nằm ngay dưới sticky header // Scroll đến vị trí sao cho content nằm ngay dưới sticky header
// contentRect.top - containerRect.top là khoảng cách từ content đến top của container // contentRect.top - containerRect.top là khoảng cách từ content đến top của container
// Trừ đi headerHeight để content nằm ngay dưới header // Trừ đi headerHeight để content nằm ngay dưới header
const targetScrollTop = currentScrollTop + (contentRect.top - containerRect.top) - headerHeight; const targetScrollTop =
currentScrollTop + (contentRect.top - containerRect.top) - headerHeight;
scrollContainer.scrollTo({ scrollContainer.scrollTo({
top: Math.max(0, targetScrollTop), top: Math.max(0, targetScrollTop),
behavior: "smooth", behavior: "smooth",
}); });
return; return;
} }
// Fallback: scroll to header if content ref not found // Fallback: scroll to header if content ref not found
const stationElement = stationRefs.current.get(stationId); const stationElement = stationRefs.current.get(stationId);
if (stationElement && scrollViewportRef.current) { if (stationElement && scrollViewportRef.current) {
@ -371,7 +371,7 @@ function ModalHistory({
const containerTop = scrollContainer.getBoundingClientRect().top; const containerTop = scrollContainer.getBoundingClientRect().top;
const elementTop = stationElement.getBoundingClientRect().top; const elementTop = stationElement.getBoundingClientRect().top;
const scrollTop = scrollContainer.scrollTop; const scrollTop = scrollContainer.scrollTop;
scrollContainer.scrollTo({ scrollContainer.scrollTo({
top: scrollTop + elementTop - containerTop, top: scrollTop + elementTop - containerTop,
behavior: "smooth", behavior: "smooth",
@ -511,7 +511,8 @@ function ModalHistory({
h="calc(75vh - 80px)" h="calc(75vh - 80px)"
viewportRef={scrollViewportRef} viewportRef={scrollViewportRef}
onScrollPositionChange={() => { onScrollPositionChange={() => {
if (isManualScrollRef.current || !scrollViewportRef.current) return; if (isManualScrollRef.current || !scrollViewportRef.current)
return;
// Debounce để scroll mượt hơn // Debounce để scroll mượt hơn
if (scrollTimeoutRef.current) { if (scrollTimeoutRef.current) {
@ -522,33 +523,42 @@ function ModalHistory({
if (!scrollViewportRef.current) return; if (!scrollViewportRef.current) return;
const scrollContainer = scrollViewportRef.current; const scrollContainer = scrollViewportRef.current;
const containerRect = scrollContainer.getBoundingClientRect(); const containerRect =
scrollContainer.getBoundingClientRect();
const topThreshold = containerRect.top + 10; // Sát top với threshold 10px const topThreshold = containerRect.top + 10; // Sát top với threshold 10px
// Check if scrolled to bottom // Check if scrolled to bottom
const scrollHeight = scrollContainer.scrollHeight; const scrollHeight = scrollContainer.scrollHeight;
const clientHeight = scrollContainer.clientHeight; const clientHeight = scrollContainer.clientHeight;
const currentScrollTop = scrollContainer.scrollTop; const currentScrollTop = scrollContainer.scrollTop;
const isAtBottom = Math.abs(currentScrollTop + clientHeight - scrollHeight) < 20; const isAtBottom =
Math.abs(
currentScrollTop + clientHeight - scrollHeight
) < 20;
// Check if currently at last station with no data - prevent scrolling // Check if currently at last station with no data - prevent scrolling
if (allGroupedStations.length > 0) { if (allGroupedStations.length > 0) {
const lastStation = allGroupedStations[allGroupedStations.length - 1]; const lastStation =
const lastStationHasNoData = lastStation.groupedHistory.size === 0; allGroupedStations[allGroupedStations.length - 1];
const lastStationHasNoData =
lastStation.groupedHistory.size === 0;
const lastStationIdStr = String(lastStation.stationId); const lastStationIdStr = String(lastStation.stationId);
// If at bottom and last station has no data, prevent scrolling // If at bottom and last station has no data, prevent scrolling
if (isAtBottom && lastStationHasNoData) { if (isAtBottom && lastStationHasNoData) {
if (lastStationIdStr !== activeStation) { if (lastStationIdStr !== activeStation) {
setActiveStation(lastStationIdStr); setActiveStation(lastStationIdStr);
} }
// Block scroll if trying to scroll down // Block scroll if trying to scroll down
isBlockingScrollRef.current = true; isBlockingScrollRef.current = true;
const maxScrollTop = scrollHeight - clientHeight; const maxScrollTop = scrollHeight - clientHeight;
// If trying to scroll down (scrollTop increased), reset to bottom // If trying to scroll down (scrollTop increased), reset to bottom
if (currentScrollTop > lastScrollTopRef.current && currentScrollTop < maxScrollTop - 5) { if (
currentScrollTop > lastScrollTopRef.current &&
currentScrollTop < maxScrollTop - 5
) {
scrollContainer.scrollTo({ scrollContainer.scrollTo({
top: maxScrollTop, top: maxScrollTop,
behavior: "auto", // Use auto for instant reset behavior: "auto", // Use auto for instant reset
@ -556,25 +566,30 @@ function ModalHistory({
lastScrollTopRef.current = maxScrollTop; lastScrollTopRef.current = maxScrollTop;
return; return;
} }
// Keep scroll at bottom // Keep scroll at bottom
lastScrollTopRef.current = maxScrollTop; lastScrollTopRef.current = maxScrollTop;
return; return;
} }
// If currently viewing last station with no data // If currently viewing last station with no data
const currentStation = allGroupedStations.find( const currentStation = allGroupedStations.find(
(s) => s.stationId.toString() === activeStation (s) => s.stationId.toString() === activeStation
); );
const isLastStation = currentStation?.stationId === lastStation.stationId; const isLastStation =
const hasNoData = currentStation?.groupedHistory.size === 0; currentStation?.stationId === lastStation.stationId;
const hasNoData =
currentStation?.groupedHistory.size === 0;
if (isLastStation && hasNoData) { if (isLastStation && hasNoData) {
isBlockingScrollRef.current = true; isBlockingScrollRef.current = true;
const maxScrollTop = scrollHeight - clientHeight; const maxScrollTop = scrollHeight - clientHeight;
// If trying to scroll down past bottom, reset to bottom // If trying to scroll down past bottom, reset to bottom
if (currentScrollTop > lastScrollTopRef.current && currentScrollTop < maxScrollTop - 5) { if (
currentScrollTop > lastScrollTopRef.current &&
currentScrollTop < maxScrollTop - 5
) {
scrollContainer.scrollTo({ scrollContainer.scrollTo({
top: maxScrollTop, top: maxScrollTop,
behavior: "auto", behavior: "auto",
@ -582,7 +597,7 @@ function ModalHistory({
lastScrollTopRef.current = maxScrollTop; lastScrollTopRef.current = maxScrollTop;
return; return;
} }
// Keep scroll at bottom if already there // Keep scroll at bottom if already there
if (isAtBottom) { if (isAtBottom) {
lastScrollTopRef.current = maxScrollTop; lastScrollTopRef.current = maxScrollTop;
@ -600,7 +615,8 @@ function ModalHistory({
// If at bottom, select the last station // If at bottom, select the last station
if (isAtBottom && allGroupedStations.length > 0) { if (isAtBottom && allGroupedStations.length > 0) {
const lastStation = allGroupedStations[allGroupedStations.length - 1]; const lastStation =
allGroupedStations[allGroupedStations.length - 1];
const lastStationIdStr = String(lastStation.stationId); const lastStationIdStr = String(lastStation.stationId);
if (lastStationIdStr !== activeStation) { if (lastStationIdStr !== activeStation) {
setActiveStation(lastStationIdStr); setActiveStation(lastStationIdStr);
@ -609,34 +625,53 @@ function ModalHistory({
// Find which station header is closest to top // Find which station header is closest to top
// Ưu tiên: header đã vượt qua top threshold (ở trên) > header chưa đến (ở dưới) // Ưu tiên: header đã vượt qua top threshold (ở trên) > header chưa đến (ở dưới)
type StationInfo = { id: number; distance: number; isAbove: boolean }; type StationInfo = {
id: number;
distance: number;
isAbove: boolean;
};
let bestStation: StationInfo | null = null; let bestStation: StationInfo | null = null;
Array.from(stationRefs.current.entries()).forEach(([stationId, element]) => { Array.from(stationRefs.current.entries()).forEach(
const elementRect = element.getBoundingClientRect(); ([stationId, element]) => {
const elementTop = elementRect.top; const elementRect = element.getBoundingClientRect();
const elementBottom = elementRect.bottom; const elementTop = elementRect.top;
const elementBottom = elementRect.bottom;
// Check if station header is visible (có phần nào đó trong viewport) // Check if station header is visible (có phần nào đó trong viewport)
const isVisible = elementBottom >= containerRect.top && elementTop <= containerRect.bottom; const isVisible =
elementBottom >= containerRect.top &&
elementTop <= containerRect.bottom;
if (isVisible) { if (isVisible) {
const isAbove = elementTop <= topThreshold; // Header đã vượt qua top const isAbove = elementTop <= topThreshold; // Header đã vượt qua top
const distance = Math.abs(elementTop - topThreshold); const distance = Math.abs(
const stationIdNum = Number(stationId); elementTop - topThreshold
);
const stationIdNum = Number(stationId);
// Ưu tiên header đã vượt qua top (isAbove = true) // Ưu tiên header đã vượt qua top (isAbove = true)
if (!bestStation || if (
(isAbove && !bestStation.isAbove) || !bestStation ||
(isAbove === bestStation.isAbove && distance < bestStation.distance)) { (isAbove && !bestStation.isAbove) ||
bestStation = { id: stationIdNum, distance, isAbove }; (isAbove === bestStation.isAbove &&
distance < bestStation.distance)
) {
bestStation = {
id: stationIdNum,
distance,
isAbove,
};
}
} }
} }
}); );
// Update active station if found // Update active station if found
if (bestStation) { if (bestStation) {
const stationIdStr = String((bestStation as StationInfo).id); const stationIdStr = String(
(bestStation as StationInfo).id
);
if (stationIdStr !== activeStation) { if (stationIdStr !== activeStation) {
setActiveStation(stationIdStr); setActiveStation(stationIdStr);
} }
@ -648,18 +683,24 @@ function ModalHistory({
{allGroupedStations.length > 0 ? ( {allGroupedStations.length > 0 ? (
<> <>
{allGroupedStations.map((station) => ( {allGroupedStations.map((station) => (
<Box <Box
key={`station-${station.stationId}`} key={`station-${station.stationId}`}
style={{ style={{
marginBottom: "24px", marginBottom: "24px",
minHeight: station.groupedHistory.size === 0 ? "200px" : "auto" // Đảm bảo station không có content vẫn có height minHeight:
station.groupedHistory.size === 0
? "200px"
: "auto", // Đảm bảo station không có content vẫn có height
}} }}
> >
{/* Station Title */} {/* Station Title */}
<Box <Box
ref={(el) => { ref={(el) => {
if (el) { if (el) {
stationRefs.current.set(station.stationId, el); stationRefs.current.set(
station.stationId,
el
);
} }
}} }}
style={{ style={{
@ -684,110 +725,116 @@ function ModalHistory({
Array.from(station.groupedHistory.entries()) Array.from(station.groupedHistory.entries())
.sort(([lineA], [lineB]) => lineA - lineB) .sort(([lineA], [lineB]) => lineA - lineB)
.map(([lineNumber, items], lineIndex) => ( .map(([lineNumber, items], lineIndex) => (
<Box
key={`station-${station.stationId}-line-${lineNumber}`}
ref={(el) => {
// Set ref cho line group đầu tiên (nội dung đầu tiên của station)
if (el && lineIndex === 0) {
stationContentRefs.current.set(station.stationId, el);
}
}}
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}
</Text>
</Box>
{/* Các items trong nhóm */}
{items
?.filter((_, i) =>
activeTimePeriod === "current"
? i === 0
: true
)
.map((item, itemIndex) => (
<Box <Box
key={`${item.stationId}-${item.number}-${item.timestamp}-${itemIndex}`} key={`station-${station.stationId}-line-${lineNumber}`}
ref={(el) => {
// Set ref cho line group đầu tiên (nội dung đầu tiên của station)
if (el && lineIndex === 0) {
stationContentRefs.current.set(
station.stationId,
el
);
}
}}
style={{ style={{
padding: "8px 16px 8px 32px", // Tăng padding-left lên 32px marginBottom: "8px",
borderTop: border: "1px solid #dee2e6",
itemIndex > 0 borderRadius: "4px",
? "1px solid #f1f3f5" overflow: "hidden",
: "none",
backgroundColor:
itemIndex % 2 === 0
? "white"
: "#f8f9fa",
}} }}
> >
<Text {/* Header của nhóm - hiển thị line number */}
size="sm" <Box
style={{ style={{
fontFamily: "monospace", padding: "8px 16px",
display: "flex", backgroundColor: "#e9ecef",
justifyContent: "space-between", fontWeight: 600,
}} }}
> >
<span style={{ display: "flex" }}> <Text size="sm" fw={600}>
<Text Line {lineNumber}
</Text>
</Box>
{/* Các items trong nhóm */}
{items
?.filter((_, i) =>
activeTimePeriod === "current"
? i === 0
: true
)
.map((item, itemIndex) => (
<Box
key={`${item.stationId}-${item.number}-${item.timestamp}-${itemIndex}`}
style={{ style={{
width: "150px", padding: "8px 16px 8px 32px", // Tăng padding-left lên 32px
fontWeight: "bold", borderTop:
itemIndex > 0
? "1px solid #f1f3f5"
: "none",
backgroundColor:
itemIndex % 2 === 0
? "white"
: "#f8f9fa",
}} }}
> >
{item.pid} <Text
</Text>{" "} size="sm"
| {item.vid} SN:{" "} style={{
<b style={{ paddingLeft: "4px" }}> fontFamily: "monospace",
{item.sn} display: "flex",
</b> justifyContent: "space-between",
</span> }}
<span> >
<span <span style={{ display: "flex" }}>
style={{ <Text
marginLeft: "20px", style={{
color: "#7c7c7c", width: "150px",
}} fontWeight: "bold",
> }}
{item.scenario} | >
</span> {item.pid}
<span </Text>{" "}
style={{ | {item.vid} SN:{" "}
marginLeft: "10px", <b style={{ paddingLeft: "4px" }}>
color: "#7c7c7c", {item.sn}
fontSize: "11px", </b>
}} </span>
> <span>
{new Date( <span
item.timestamp style={{
).toLocaleString()} marginLeft: "20px",
</span> color: "#7c7c7c",
</span> }}
</Text> >
{item.scenario} |
</span>
<span
style={{
marginLeft: "10px",
color: "#7c7c7c",
fontSize: "11px",
}}
>
{new Date(
item.timestamp
).toLocaleString()}
</span>
</span>
</Text>
</Box>
))}
</Box> </Box>
))} ))
</Box>
))
) : ( ) : (
<Box <Box
ref={(el) => { ref={(el) => {
// Set ref cho message "No history" (nội dung đầu tiên của station) // Set ref cho message "No history" (nội dung đầu tiên của station)
if (el) { if (el) {
stationContentRefs.current.set(station.stationId, el); stationContentRefs.current.set(
station.stationId,
el
);
} }
}} }}
style={{ style={{
@ -797,9 +844,12 @@ function ModalHistory({
}} }}
> >
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
No history data available for {TIME_PERIODS.find( No history data available for{" "}
(p) => p.value === activeTimePeriod {
)?.label} TIME_PERIODS.find(
(p) => p.value === activeTimePeriod
)?.label
}
</Text> </Text>
</Box> </Box>
)} )}

View File

@ -1,6 +1,6 @@
import { Modal, Text } from "@mantine/core"; import { Modal, Text } from "@mantine/core";
import classes from "./Component.module.css"; import classes from "../Component.module.css";
import { convertTimestampToDate } from "../untils/helper"; import { convertTimestampToDate } from "../../untils/helper";
const ModalLog = ({ const ModalLog = ({
opened, opened,

View File

@ -10,17 +10,17 @@ import {
Flex, Flex,
CloseButton, CloseButton,
} from "@mantine/core"; } from "@mantine/core";
import classes from "./Component.module.css"; import classes from "../Component.module.css";
import TableRows from "./Scenario/TableRows"; import TableRows from "./Scenario/TableRows";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "@mantine/form"; import { useForm } from "@mantine/form";
import DialogConfirm from "./DialogConfirm"; import DialogConfirm from "../DialogConfirm";
import type { IBodyScenario, IScenario } from "../untils/types"; import type { IBodyScenario, IScenario } from "../../untils/types";
import axios from "axios"; import axios from "axios";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
const apiUrl = import.meta.env.VITE_BACKEND_URL; const apiUrl = import.meta.env.VITE_BACKEND_URL;
function DrawerScenario({ function ModalScenario({
scenarios, scenarios,
setScenarios, setScenarios,
externalOpened, externalOpened,
@ -32,10 +32,10 @@ function DrawerScenario({
onExternalClose?: () => void; onExternalClose?: () => void;
}) { }) {
const [opened, { close }] = useDisclosure(false); const [opened, { close }] = useDisclosure(false);
// Sử dụng external state nếu được provide, nếu không thì dùng internal state // Sử dụng external state nếu được provide, nếu không thì dùng internal state
const isOpened = externalOpened !== undefined ? externalOpened : opened; const isOpened = externalOpened !== undefined ? externalOpened : opened;
const handleClose = () => { const handleClose = () => {
if (onExternalClose) { if (onExternalClose) {
onExternalClose(); onExternalClose();
@ -267,92 +267,95 @@ function DrawerScenario({
}} }}
className={classes.hideScrollBar} className={classes.hideScrollBar}
> >
<Flex gap="md" style={{ height: "75vh" }}> <Flex gap="md" style={{ height: "75vh" }}>
{/* Sidebar - List Scenarios */} {/* Sidebar - List Scenarios */}
<Box <Box
style={{
width: "200px",
borderRight: "1px solid #e9ecef",
paddingRight: "10px",
flexShrink: 0,
}}
>
<ScrollArea h="100%">
<Flex direction="column" gap="xs">
{scenarios.map((scenario) => (
<Button
disabled={isSubmit}
className={classes.buttonScenario}
key={scenario.id}
fullWidth
style={{ minHeight: "36px" }}
variant={
dataScenario && dataScenario?.id === scenario.id
? "filled"
: "outline"
}
onClick={async () => {
if (dataScenario?.id === scenario.id) {
setIsEdit(false);
setDataScenario(undefined);
form.reset();
} else {
setIsEdit(true);
setDataScenario(scenario);
form.setFieldValue("title", scenario.title);
form.setFieldValue(
"timeout",
scenario.timeout.toString()
);
form.setFieldValue("body", JSON.parse(scenario.body));
form.setFieldValue("isReboot", scenario.isReboot);
}
}}
>
{scenario.title}
</Button>
))}
</Flex>
</ScrollArea>
</Box>
{/* Main Content */}
<Box style={{ flex: 1, overflow: "hidden" }}>
<Box>
<Grid>
<Grid.Col span={4}>
<TextInput
label="Title"
placeholder="Scenario title"
value={form.values.title}
error={form.errors.title}
onChange={(e) =>
form.setFieldValue("title", e.target.value)
}
required
/>
</Grid.Col>
<Grid.Col span={2}>
<TextInput
label="Timeout (ms)"
placeholder="Timeout (ms)"
value={form.values.timeout}
error={form.errors.timeout}
onChange={(e) =>
form.setFieldValue("timeout", e.target.value)
}
required
/>
</Grid.Col>
<Grid.Col
span={3}
style={{ style={{
display: "flex", width: "200px",
alignItems: "end", borderRight: "1px solid #e9ecef",
marginBottom: "8px", paddingRight: "10px",
flexShrink: 0,
}} }}
> >
{/* <Checkbox <ScrollArea h="100%">
<Flex direction="column" gap="xs">
{scenarios.map((scenario) => (
<Button
disabled={isSubmit}
className={classes.buttonScenario}
key={scenario.id}
fullWidth
style={{ minHeight: "36px" }}
variant={
dataScenario && dataScenario?.id === scenario.id
? "filled"
: "outline"
}
onClick={async () => {
if (dataScenario?.id === scenario.id) {
setIsEdit(false);
setDataScenario(undefined);
form.reset();
} else {
setIsEdit(true);
setDataScenario(scenario);
form.setFieldValue("title", scenario.title);
form.setFieldValue(
"timeout",
scenario.timeout.toString()
);
form.setFieldValue(
"body",
JSON.parse(scenario.body)
);
form.setFieldValue("isReboot", scenario.isReboot);
}
}}
>
{scenario.title}
</Button>
))}
</Flex>
</ScrollArea>
</Box>
{/* Main Content */}
<Box style={{ flex: 1, overflow: "hidden" }}>
<Box>
<Grid>
<Grid.Col span={4}>
<TextInput
label="Title"
placeholder="Scenario title"
value={form.values.title}
error={form.errors.title}
onChange={(e) =>
form.setFieldValue("title", e.target.value)
}
required
/>
</Grid.Col>
<Grid.Col span={2}>
<TextInput
label="Timeout (ms)"
placeholder="Timeout (ms)"
value={form.values.timeout}
error={form.errors.timeout}
onChange={(e) =>
form.setFieldValue("timeout", e.target.value)
}
required
/>
</Grid.Col>
<Grid.Col
span={3}
style={{
display: "flex",
alignItems: "end",
marginBottom: "8px",
}}
>
{/* <Checkbox
label="Reboot" label="Reboot"
style={{ color: "red" }} style={{ color: "red" }}
checked={form.values.isReboot} checked={form.values.isReboot}
@ -363,90 +366,90 @@ function DrawerScenario({
) )
} }
/> */} /> */}
</Grid.Col> </Grid.Col>
<Grid.Col span={3}> <Grid.Col span={3}>
<div <div
style={{ style={{
display: "flex", display: "flex",
alignItems: "end", alignItems: "end",
gap: "10px", gap: "10px",
justifyContent: "flex-end", justifyContent: "flex-end",
height: "100%", height: "100%",
}} }}
> >
{isEdit && ( {isEdit && (
<Button <Button
disabled={isSubmit} disabled={isSubmit}
style={{ height: "30px" }} style={{ height: "30px" }}
color="red" color="red"
onClick={() => { onClick={() => {
setOpenConfirm(true); setOpenConfirm(true);
}} }}
> >
Delete Delete
</Button> </Button>
)} )}
<Button <Button
disabled={isSubmit} disabled={isSubmit}
style={{ height: "30px" }} style={{ height: "30px" }}
color="green" color="green"
onClick={() => { onClick={() => {
handleSave(); handleSave();
}} }}
>
Save
</Button>
</div>
</Grid.Col>
</Grid>
</Box>
<hr style={{ width: "100%" }} />
<Box>
<ScrollArea
h={"calc(75vh - 150px)"}
style={{ marginBottom: "20px" }}
> >
Save <Table
</Button> stickyHeader
</div> stickyHeaderOffset={-1}
</Grid.Col> striped
</Grid> highlightOnHover
</Box> withRowBorders={true}
<hr style={{ width: "100%" }} /> withTableBorder={true}
<Box> withColumnBorders={true}
<ScrollArea >
h={"calc(75vh - 150px)"} <Table.Thead style={{ zIndex: 100 }}>
style={{ marginBottom: "20px" }} <Table.Tr>
> <Table.Th>#</Table.Th>
<Table <Table.Th>
stickyHeader {/* <span style={{ marginLeft: '30px' }}>Expect</span> */}
stickyHeaderOffset={-1} Expect
striped </Table.Th>
highlightOnHover <Table.Th>Send</Table.Th>
withRowBorders={true} <Table.Th w={130}>Delay(ms)</Table.Th>
withTableBorder={true} <Table.Th w={100}>Repeat</Table.Th>
withColumnBorders={true} <Table.Th></Table.Th>
> </Table.Tr>
<Table.Thead style={{ zIndex: 100 }}> </Table.Thead>
<Table.Tr> <Table.Tbody id="tbody-table">
<Table.Th>#</Table.Th> {form.values.body.map(
<Table.Th> (element: IBodyScenario, i: number) => (
{/* <span style={{ marginLeft: '30px' }}>Expect</span> */} <TableRows
Expect key={i}
</Table.Th> addRowUnder={addRowUnder}
<Table.Th>Send</Table.Th> deleteRow={deleteRow}
<Table.Th w={130}>Delay(ms)</Table.Th> element={element}
<Table.Th w={100}>Repeat</Table.Th> form={form}
<Table.Th></Table.Th> i={i}
</Table.Tr> />
</Table.Thead> )
<Table.Tbody id="tbody-table"> )}
{form.values.body.map( </Table.Tbody>
(element: IBodyScenario, i: number) => ( </Table>
<TableRows </ScrollArea>
key={i} </Box>
addRowUnder={addRowUnder} </Box>
deleteRow={deleteRow} </Flex>
element={element}
form={form}
i={i}
/>
)
)}
</Table.Tbody>
</Table>
</ScrollArea>
</Box>
</Box>
</Flex>
</div> </div>
</div> </div>
</div> </div>
@ -468,4 +471,4 @@ function DrawerScenario({
); );
} }
export default DrawerScenario; export default ModalScenario;

View File

@ -21,8 +21,8 @@ import type {
THistoryTicket, THistoryTicket,
TLine, TLine,
TStation, TStation,
} from "../untils/types"; } from "../../untils/types";
import TerminalCLI from "./TerminalXTerm"; import TerminalCLI from "../TerminalXTerm";
import type { Socket } from "socket.io-client"; import type { Socket } from "socket.io-client";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { import {
@ -30,13 +30,13 @@ import {
IconCircleDot, IconCircleDot,
IconInfoCircle, IconInfoCircle,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { ButtonDPELP, ButtonScenario } from "./ButtonAction"; import { ButtonDPELP, ButtonScenario } from "../ButtonAction";
import CopyIcon from "./CopyIcon"; import CopyIcon from "../CopyIcon";
import moment from "moment"; import moment from "moment";
import axios from "axios"; import axios from "axios";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import classes from "./Component.module.css"; import classes from "../Component.module.css";
import { listBaudDefault } from "../untils/constanst"; import { listBaudDefault } from "../../untils/constanst";
import { motion } from "motion/react"; import { motion } from "motion/react";
const apiUrl = import.meta.env.VITE_BACKEND_URL; const apiUrl = import.meta.env.VITE_BACKEND_URL;

View File

@ -1,7 +1,7 @@
import { Table, TextInput } from "@mantine/core"; import { Table, TextInput } from "@mantine/core";
import { IconRowInsertTop, IconX } from "@tabler/icons-react"; import { IconRowInsertTop, IconX } from "@tabler/icons-react";
import classes from "./Scenario.module.css"; import classes from "./Scenario.module.css";
import { numberOnly } from "../../untils/helper"; import { numberOnly } from "../../../untils/helper";
interface IPayload { interface IPayload {
element: any; element: any;

25
README.md Normal file
View File

@ -0,0 +1,25 @@
# BACKEND
### 1. Copy .env.example -> .env
### 2. npm install
### 3. node ace migration:run
### 4. npm run dev
# FRONTEND
### 1. Copy .env.example -> .env
### 2. npm install
### 3. npm run dev
# Server Redis
### 1. sudo apt install redis-server
### 2. sudo systemctl enable redis-server
### 3. sudo systemctl start redis-server