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

View File

@ -2,7 +2,6 @@ import {
ActionIcon,
Avatar,
Box,
Button,
Flex,
Group,
Menu,
@ -296,7 +295,7 @@ export default function DraggableTabs({
<Tooltip
withArrow
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"}>

View File

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