Refactor BottomToolBar and update user types

Improved BottomToolBar command line UI with better line selection and clearing logic. Updated TUser type to include 'id' and 'email' fields, and fixed DragTabs to support both 'userId' and 'id' for user key mapping.
This commit is contained in:
nguyentrungthat 2025-11-27 10:35:37 +07:00
parent b5bb90ca4e
commit 1b9b18ce3b
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 = {