Refactor BottomToolBar and update user types #15

Merged
andrew.ng merged 1 commits from that into main 2025-11-27 14:37:00 +11:00
3 changed files with 337 additions and 288 deletions

View File

@ -14,7 +14,7 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import classes from "./Component.module.css"; import classes from "./Component.module.css";
import type { IScenario, TLine, TStation } 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 "./DrawerLogs";
@ -22,7 +22,12 @@ import { DrawerAPCControl, DrawerSwitchControl } from "./DrawerControl";
import DrawerScenario from "./DrawerScenario"; import DrawerScenario from "./DrawerScenario";
import { isJsonString } from "../untils/helper"; import { isJsonString } from "../untils/helper";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { IconCaretDown, IconCaretUp, IconPlayerPlay, IconPlus } from "@tabler/icons-react"; import {
IconCaretDown,
IconCaretUp,
IconPlayerPlay,
IconPlus,
} from "@tabler/icons-react";
interface TabsProps { interface TabsProps {
selectedLines: TLine[]; selectedLines: TLine[];
@ -58,7 +63,7 @@ const ScenarioCard = ({
index: number; index: number;
isDisable: boolean; isDisable: boolean;
selectedLines: TLine[]; selectedLines: TLine[];
user: any; user: TUser;
socket: Socket | null; socket: Socket | null;
setOpenScenarioModal: (value: boolean) => void; setOpenScenarioModal: (value: boolean) => void;
setIsDisable: (value: boolean) => void; setIsDisable: (value: boolean) => void;
@ -73,7 +78,7 @@ const ScenarioCard = ({
const rect = cardRef.current.getBoundingClientRect(); const rect = cardRef.current.getBoundingClientRect();
const overlayWidth = 400; const overlayWidth = 400;
const viewportWidth = window.innerWidth; const viewportWidth = window.innerWidth;
// Tính toán vị trí để overlay không tràn ra ngoài màn hình // Tính toán vị trí để overlay không tràn ra ngoài màn hình
let left = rect.left; let left = rect.left;
if (left + overlayWidth > viewportWidth) { if (left + overlayWidth > viewportWidth) {
@ -82,7 +87,7 @@ const ScenarioCard = ({
if (left < 10) { if (left < 10) {
left = 10; // 10px margin từ bên trái left = 10; // 10px margin từ bên trái
} }
setOverlayPosition({ setOverlayPosition({
top: rect.top, top: rect.top,
left: left, left: left,
@ -98,7 +103,7 @@ const ScenarioCard = ({
const rect = cardRef.current.getBoundingClientRect(); const rect = cardRef.current.getBoundingClientRect();
const overlayWidth = 400; const overlayWidth = 400;
const viewportWidth = window.innerWidth; const viewportWidth = window.innerWidth;
let left = rect.left; let left = rect.left;
if (left + overlayWidth > viewportWidth) { if (left + overlayWidth > viewportWidth) {
left = viewportWidth - overlayWidth - 10; left = viewportWidth - overlayWidth - 10;
@ -106,7 +111,7 @@ const ScenarioCard = ({
if (left < 10) { if (left < 10) {
left = 10; left = 10;
} }
setOverlayPosition({ setOverlayPosition({
top: rect.top, top: rect.top,
left: left, left: left,
@ -122,7 +127,7 @@ const ScenarioCard = ({
scrollContainers.forEach((container) => { scrollContainers.forEach((container) => {
container.addEventListener("scroll", updatePosition, true); container.addEventListener("scroll", updatePosition, true);
}); });
window.addEventListener("scroll", updatePosition, true); window.addEventListener("scroll", updatePosition, true);
window.addEventListener("resize", updatePosition); window.addEventListener("resize", updatePosition);
@ -138,10 +143,7 @@ const ScenarioCard = ({
return ( return (
<Grid.Col key={scenario.id} span={3}> <Grid.Col key={scenario.id} span={3}>
<div <div ref={cardRef} style={{ position: "relative" }}>
ref={cardRef}
style={{ position: "relative" }}
>
<Card <Card
shadow="sm" shadow="sm"
padding="md" padding="md"
@ -178,7 +180,8 @@ const ScenarioCard = ({
isDisable || isDisable ||
selectedLines.filter( selectedLines.filter(
(el) => (el) =>
!el?.userEmailOpenCLI || el?.userEmailOpenCLI === user?.email !el?.userEmailOpenCLI ||
el?.userEmailOpenCLI === user?.email
).length === 0 ).length === 0
} }
onClick={() => { onClick={() => {
@ -270,23 +273,21 @@ const ScenarioCard = ({
overflow: "auto", overflow: "auto",
}} }}
> >
{steps {steps.slice(0, 5).map((step: { send: string }, i: number) => (
.slice(0, 5) <Text
.map((step: { send: string }, i: number) => ( key={i}
<Text size="xs"
key={i} c="dimmed"
size="xs" style={{
c="dimmed" fontFamily: "monospace",
style={{ whiteSpace: "nowrap",
fontFamily: "monospace", overflow: "hidden",
whiteSpace: "nowrap", textOverflow: "ellipsis",
overflow: "hidden", }}
textOverflow: "ellipsis", >
}} {i + 1}. {step.send || "(empty)"}
> </Text>
{i + 1}. {step.send || "(empty)"} ))}
</Text>
))}
{steps.length > 5 && ( {steps.length > 5 && (
<Text size="xs" c="dimmed" ta="center" mt="xs"> <Text size="xs" c="dimmed" ta="center" mt="xs">
... and {steps.length - 5} more ... and {steps.length - 5} more
@ -420,7 +421,11 @@ const BottomToolBar = ({
{scenarios.length > 0 ? ( {scenarios.length > 0 ? (
<Grid <Grid
gutter="md" gutter="md"
style={{ margin: 0, overflow: "visible", position: "relative" }} style={{
margin: 0,
overflow: "visible",
position: "relative",
}}
> >
{scenarios.map((scenario, index) => ( {scenarios.map((scenario, index) => (
<ScenarioCard <ScenarioCard
@ -475,217 +480,143 @@ const BottomToolBar = ({
zIndex: 1, zIndex: 1,
}} }}
> >
<Box style={{ position: "relative" }}> <Box style={{ position: "relative" }}>
<ActionIcon <ActionIcon
style={{ style={{
position: "absolute", position: "absolute",
top: isExpand ? -4 : -24, top: isExpand ? -4 : -24,
left: "50%", left: "50%",
translate: "-19px 0", translate: "-19px 0",
backgroundColor: "#e3e0e0", backgroundColor: "#e3e0e0",
width: "55px", width: "55px",
}} }}
variant="light" variant="light"
onClick={() => { onClick={() => {
setExpanded((prev) => !prev); setExpanded((prev) => !prev);
}} }}
> >
{isExpand ? ( {isExpand ? (
<IconCaretDown color="green" /> <IconCaretDown color="green" />
) : ( ) : (
<IconCaretUp color="green" /> <IconCaretUp color="green" />
)} )}
</ActionIcon> </ActionIcon>
<Grid> <Grid>
<Grid.Col span={1}></Grid.Col> <Grid.Col span={1}></Grid.Col>
<Grid.Col span={10}> <Grid.Col span={10}>
<Tabs <Tabs
defaultValue="command" defaultValue="command"
orientation="vertical" orientation="vertical"
value={activeTabBottom} value={activeTabBottom}
onChange={(val) => { onChange={(val) => {
setActiveTabBottom(val || "command"); setActiveTabBottom(val || "command");
}} }}
className={classes.containerBottom} className={classes.containerBottom}
style={{ height: "20vh" }} style={{ height: "20vh" }}
> >
<Tabs.List> <Tabs.List>
<Tabs.Tab <Tabs.Tab
style={{ style={{
backgroundColor: backgroundColor:
activeTabBottom === "command" ? "#c8d9fd" : "", activeTabBottom === "command" ? "#c8d9fd" : "",
fontSize: "13px", fontSize: "13px",
paddingTop: "8px", paddingTop: "8px",
paddingBottom: "8px", paddingBottom: "8px",
}} }}
value="command" value="command"
> >
Command Line Command Line
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab <Tabs.Tab
style={{ style={{
backgroundColor: activeTabBottom === "apc" ? "#c8d9fd" : "", backgroundColor:
fontSize: "13px", activeTabBottom === "apc" ? "#c8d9fd" : "",
paddingTop: "8px", fontSize: "13px",
paddingBottom: "8px", paddingTop: "8px",
}} paddingBottom: "8px",
value="apc" }}
> value="apc"
APC >
</Tabs.Tab> APC
<Tabs.Tab </Tabs.Tab>
style={{ <Tabs.Tab
backgroundColor: style={{
activeTabBottom === "switch" ? "#c8d9fd" : "", backgroundColor:
fontSize: "13px", activeTabBottom === "switch" ? "#c8d9fd" : "",
paddingTop: "8px", fontSize: "13px",
paddingBottom: "8px", paddingTop: "8px",
}} paddingBottom: "8px",
value="switch" }}
> value="switch"
Switch >
</Tabs.Tab> Switch
</Tabs.List> </Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="command" p={"xs"}> <Tabs.Panel value="command" p={"xs"}>
<Flex justify={"space-between"}> <Flex justify={"space-between"}>
<ScrollArea h={"15vh"}>
<Flex wrap={"wrap"} gap={"xs"} w={"400px"}>
{selectedLines.map((el) => (
<Box
key={el.id}
style={{
paddingLeft: "4px",
height: "30px",
width: "80px",
backgroundColor: "#d4e3ff",
borderRadius: "8px",
}}
>
<Flex align={"center"} justify={"center"} gap={"4px"}>
<Text fz={"12px"}>Line {el.lineNumber}</Text>
<CloseButton
style={{ minWidth: "24px" }}
aria-label="Clear input"
onClick={() => {
setSelectedLines(
selectedLines.filter(
(line) => line.id !== el.id
)
);
socket?.emit("close_cli", {
lineId: el?.id,
stationId: el.stationId || el.station_id,
});
}}
/>
</Flex>
</Box>
))}
</Flex>
</ScrollArea>
<Box pl={"md"} pr={"md"}>
<Flex justify={"space-between"} mb={"xs"}>
<Flex></Flex>
<Button
fw={400}
disabled={isDisable || selectedLines.length === 0}
variant="filled"
color="orange"
size="xs"
radius="md"
onClick={() => {
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: "spam_break",
});
setIsDisable(true);
setTimeout(() => {
setIsDisable(false);
}, 5000);
}
}}
>
Send Break
</Button>
</Flex>
<Box> <Box>
<Input <ScrollArea h={"12vh"}>
ref={inputRef} <Flex wrap={"wrap"} gap={"8px"} w={"420px"}>
style={{ {selectedLines.map((el) => (
width: "30vw", <Box
boxShadow: "0px 0px 10px rgba(0, 0, 0, 0.1)", key={el.id}
}} style={{
placeholder={"Send command to port(s)"} position: "relative",
value={valueInput} padding: "4px 6px",
onChange={(event) => { height: "26px",
const newValue = event.currentTarget.value; width: "60px",
setValueInput(newValue); backgroundColor: "#d4e3ff",
}} borderRadius: "8px",
onKeyDown={(event) => { }}
if (event.key === "Enter") { >
const listLine = selectedLines.length {/* Close button góc trên phải */}
? selectedLines <CloseButton
: station?.lines; size="xs"
if (listLine?.length) { style={{
socket?.emit("write_command_line_from_web", { position: "absolute",
lineIds: listLine.map((line) => line.id), top: "-4px",
stationId: station.id, right: "-6px",
command: valueInput + "\r\n", minWidth: "18px",
}); width: "18px",
// setTimeout(() => { height: "18px",
// socket?.emit("write_command_line_from_web", { zIndex: 10,
// lineIds: listLine.map((line) => line.id), }}
// stationId: station.id, onClick={() => {
// command: " \n", setSelectedLines(
// }); selectedLines.filter(
// }, 1000); (line) => line.id !== el.id
} )
setValueInput(""); );
} socket?.emit("close_cli", {
}} lineId: el?.id,
rightSectionPointerEvents="all" stationId: el.stationId || el.station_id,
rightSection={ });
<CloseButton }}
aria-label="Clear input" />
onClick={() => setValueInput("")}
style={{ <Flex
display: valueInput ? undefined : "none", align={"center"}
}} justify={"center"}
/> h="100%"
} >
/> <Text fz={"11px"}>Line {el.lineNumber}</Text>
</Box> </Flex>
</Box> </Box>
<Box style={{ width: "220px" }}> ))}
<Flex align={"center"} wrap={"wrap"} gap={"xs"}> </Flex>
<ButtonSelect </ScrollArea>
selectedLines={selectedLines} {selectedLines?.length > 0 ? (
setSelectedLines={setSelectedLines} <Button
station={station} fw={400}
userName={user?.userName} className={classes.buttonControl}
onClick={() => { variant="outline"
const lines = station.lines.filter( onClick={() => {
(line) => const lines = station.lines.filter(
!line?.userOpenCLI || (line) =>
line?.userOpenCLI === user?.userName !line?.userOpenCLI ||
); line?.userOpenCLI === user?.userName
if (selectedLines.length !== lines.length) { );
setSelectedLines(lines);
lines.forEach((line) => {
socket?.emit("open_cli", {
lineId: line.id,
stationId: line.stationId || line.station_id,
userEmail: user?.email,
userName: user?.userName,
});
});
} else {
lines.forEach((line) => { lines.forEach((line) => {
socket?.emit("close_cli", { socket?.emit("close_cli", {
lineId: line?.id, lineId: line?.id,
@ -693,53 +624,170 @@ const BottomToolBar = ({
}); });
}); });
setSelectedLines([]); setSelectedLines([]);
}}
>
Clear
</Button>
) : (
""
)}
</Box>
<Box pl={"md"} pr={"md"}>
<Flex justify={"space-between"} mb={"xs"}>
<Flex></Flex>
<Button
fw={400}
disabled={isDisable || selectedLines.length === 0}
variant="filled"
color="orange"
size="xs"
radius="md"
onClick={() => {
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: "spam_break",
});
setIsDisable(true);
setTimeout(() => {
setIsDisable(false);
}, 5000);
}
}}
>
Send Break
</Button>
</Flex>
<Box>
<Input
ref={inputRef}
style={{
width: "30vw",
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>
<ButtonDPELP </Box>
socket={socket} <Box style={{ width: "220px" }}>
selectedLines={selectedLines} <Flex align={"center"} wrap={"wrap"} gap={"xs"}>
isDisable={isDisable || selectedLines.length === 0} <ButtonSelect
onClick={() => { selectedLines={selectedLines}
// setSelectedLines([]); setSelectedLines={setSelectedLines}
setIsDisable(true); station={station}
setTimeout(() => { userName={user?.userName}
setIsDisable(false); onClick={() => {
}, 5000); const lines = station.lines.filter(
}} (line) =>
/> !line?.userOpenCLI ||
<Button line?.userOpenCLI === user?.userName
fw={400} );
disabled={isDisable || selectedLines.length === 0} if (selectedLines.length !== lines.length) {
variant="filled" setSelectedLines(lines);
color="yellow" lines.forEach((line) => {
style={{ height: "30px", width: "100px" }} socket?.emit("open_cli", {
onClick={() => setOpenScenarioModal(true)} lineId: line.id,
> stationId: line.stationId || line.station_id,
Scenario userEmail: user?.email,
</Button> userName: user?.userName,
<DrawerLogs });
socket={socket} });
isLogModalOpen={isLogModalOpen} } else {
setIsLogModalOpen={setIsLogModalOpen} lines.forEach((line) => {
testLogContent={testLogContent} socket?.emit("close_cli", {
setTestLogContent={setTestLogContent} lineId: line?.id,
/> stationId: line.stationId || line.station_id,
</Flex> });
</Box> });
</Flex> setSelectedLines([]);
</Tabs.Panel> }
<Tabs.Panel value="apc" ps={"xs"}> }}
<DrawerAPCControl socket={socket} stationAPI={station} /> />
</Tabs.Panel> <ButtonDPELP
<Tabs.Panel value="switch" ps={"xs"}> socket={socket}
<DrawerSwitchControl socket={socket} stationAPI={station} /> selectedLines={selectedLines}
</Tabs.Panel> isDisable={isDisable || selectedLines.length === 0}
</Tabs> onClick={() => {
</Grid.Col> // setSelectedLines([]);
<Grid.Col span={1}></Grid.Col> setIsDisable(true);
</Grid> setTimeout(() => {
</Box> setIsDisable(false);
}, 5000);
}}
/>
<Button
fw={400}
disabled={isDisable || selectedLines.length === 0}
variant="filled"
color="yellow"
style={{ height: "30px", width: "100px" }}
onClick={() => setOpenScenarioModal(true)}
>
Scenario
</Button>
<DrawerLogs
socket={socket}
isLogModalOpen={isLogModalOpen}
setIsLogModalOpen={setIsLogModalOpen}
testLogContent={testLogContent}
setTestLogContent={setTestLogContent}
/>
</Flex>
</Box>
</Flex>
</Tabs.Panel>
<Tabs.Panel value="apc" ps={"xs"}>
<DrawerAPCControl socket={socket} stationAPI={station} />
</Tabs.Panel>
<Tabs.Panel value="switch" ps={"xs"}>
<DrawerSwitchControl socket={socket} stationAPI={station} />
</Tabs.Panel>
</Tabs>
</Grid.Col>
<Grid.Col span={1}></Grid.Col>
</Grid>
</Box>
</motion.div> </motion.div>
{/* Drawer Scenario để Add/Edit */} {/* Drawer Scenario để Add/Edit */}

View File

@ -2,7 +2,6 @@ import {
ActionIcon, ActionIcon,
Avatar, Avatar,
Box, Box,
Button,
Flex, Flex,
Group, Group,
Menu, Menu,
@ -296,7 +295,7 @@ export default function DraggableTabs({
<Tooltip <Tooltip
withArrow withArrow
label={usersConnecting.map((el) => ( label={usersConnecting.map((el) => (
<Text key={el.userId}>{el.userName}</Text> <Text key={el.userId || el.id}>{el.userName}</Text>
))} ))}
> >
<Avatar radius="xl" me={"sm"}> <Avatar radius="xl" me={"sm"}>

View File

@ -105,6 +105,8 @@ export type TLine = {
export type TUser = { export type TUser = {
userId: number; userId: number;
userName: string; userName: string;
id: number;
email: string;
}; };
export type APCProps = { export type APCProps = {