Fix giao diện select scebario và modal add/edit

This commit is contained in:
Truong Vo 2025-11-27 10:26:20 +07:00
parent 3fedaa33de
commit b5bb90ca4e
3 changed files with 550 additions and 410 deletions

View File

@ -22,12 +22,7 @@ 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[];
@ -48,6 +43,264 @@ interface TabsProps {
isExpand: boolean;
}
// Component cho từng Scenario Card
const ScenarioCard = ({
scenario,
index,
isDisable,
selectedLines,
user,
socket,
setOpenScenarioModal,
setIsDisable,
}: {
scenario: IScenario;
index: number;
isDisable: boolean;
selectedLines: TLine[];
user: any;
socket: Socket | null;
setOpenScenarioModal: (value: boolean) => void;
setIsDisable: (value: boolean) => void;
}) => {
const [isHovered, setIsHovered] = useState(false);
const [overlayPosition, setOverlayPosition] = useState({ top: 0, left: 0 });
const cardRef = useRef<HTMLDivElement>(null);
const steps = JSON.parse(scenario.body || "[]");
const handleMouseEnter = () => {
if (cardRef.current) {
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) {
left = viewportWidth - overlayWidth - 10; // 10px margin
}
if (left < 10) {
left = 10; // 10px margin từ bên trái
}
setOverlayPosition({
top: rect.top,
left: left,
});
}
setIsHovered(true);
};
useEffect(() => {
if (isHovered && cardRef.current) {
const updatePosition = () => {
if (cardRef.current) {
const rect = cardRef.current.getBoundingClientRect();
const overlayWidth = 400;
const viewportWidth = window.innerWidth;
let left = rect.left;
if (left + overlayWidth > viewportWidth) {
left = viewportWidth - overlayWidth - 10;
}
if (left < 10) {
left = 10;
}
setOverlayPosition({
top: rect.top,
left: left,
});
}
};
// Update position immediately
updatePosition();
// Listen to scroll events (including inside modal)
const scrollContainers = document.querySelectorAll('[style*="overflow"]');
scrollContainers.forEach((container) => {
container.addEventListener("scroll", updatePosition, true);
});
window.addEventListener("scroll", updatePosition, true);
window.addEventListener("resize", updatePosition);
return () => {
scrollContainers.forEach((container) => {
container.removeEventListener("scroll", updatePosition, true);
});
window.removeEventListener("scroll", updatePosition, true);
window.removeEventListener("resize", updatePosition);
};
}
}, [isHovered]);
return (
<Grid.Col key={scenario.id} span={3}>
<div
ref={cardRef}
style={{ position: "relative" }}
>
<Card
shadow="sm"
padding="md"
radius="md"
withBorder
style={{
transition: "all 0.2s ease",
height: "auto",
minHeight: "80px",
}}
className={classes.scenarioCard}
>
<Flex direction="column" gap="sm" align="center" justify="center">
<Text
fw={600}
size="lg"
ta="center"
style={{
cursor: "pointer",
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={() => setIsHovered(false)}
>
{scenario.title}
</Text>
<Button
fullWidth
variant="light"
color="green"
size="sm"
leftSection={<IconPlayerPlay size={16} />}
disabled={
isDisable ||
selectedLines.filter(
(el) =>
!el?.userEmailOpenCLI || el?.userEmailOpenCLI === user?.email
).length === 0
}
onClick={() => {
setOpenScenarioModal(false);
setIsDisable(true);
setTimeout(() => {
setIsDisable(false);
}, 5000);
selectedLines
.filter(
(el) =>
!el?.userEmailOpenCLI ||
el?.userEmailOpenCLI === user?.email
)
.forEach((el) => {
socket?.emit(
"run_scenario",
Object.assign(el, {
scenario: scenario,
})
);
});
}}
>
Run
</Button>
</Flex>
</Card>
{/* Hover overlay - Fixed position để không bị cắt bởi modal */}
{isHovered && (
<div
style={{
position: "fixed",
top: `${overlayPosition.top}px`,
left: `${overlayPosition.left}px`,
width: "400px",
maxWidth: "90vw",
background: "white",
border: "2px solid #4dabf7",
borderRadius: "8px",
padding: "16px",
boxShadow: "0 8px 24px rgba(0,0,0,0.15)",
zIndex: 99999,
minHeight: "200px",
maxHeight: "400px",
overflowY: "auto",
overflowX: "hidden",
}}
className={classes.hideScrollBar}
onClick={(e) => e.stopPropagation()}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<Flex direction="column" gap="xs">
<Flex justify="space-between" align="center">
<Text fw={700} size="md">
{scenario.title}
</Text>
<Badge color="blue" variant="light">
#{index + 1}
</Badge>
</Flex>
<Flex gap="xs" wrap="wrap" mt="xs">
<Badge size="sm" color="gray" variant="dot">
Timeout: {scenario.timeout}ms
</Badge>
{scenario.isReboot && (
<Badge size="sm" color="orange" variant="dot">
Reboot
</Badge>
)}
<Badge size="sm" color="green" variant="dot">
{steps.length} steps
</Badge>
</Flex>
<Text size="xs" fw={600} mt="xs" c="dimmed">
Commands Preview:
</Text>
<Box
style={{
background: "#f8f9fa",
padding: "8px",
borderRadius: "4px",
maxHeight: "100px",
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.length > 5 && (
<Text size="xs" c="dimmed" ta="center" mt="xs">
... and {steps.length - 5} more
</Text>
)}
</Box>
</Flex>
</div>
)}
</div>
</Grid.Col>
);
};
const BottomToolBar = ({
selectedLines,
socket,
@ -109,13 +362,14 @@ const BottomToolBar = ({
style={{
background: "white",
borderRadius: "12px",
maxWidth: "1000px",
width: "auto",
maxHeight: "85vh",
maxWidth: "90vw",
width: "90%",
height: "85vh",
display: "flex",
flexDirection: "column",
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
overflow: "hidden",
overflowY: "auto",
overflowX: "visible",
}}
onClick={(e) => e.stopPropagation()}
>
@ -138,11 +392,9 @@ const BottomToolBar = ({
variant="light"
color="green"
size="sm"
onClick={() => {
setOpenScenarioModal(false);
setTimeout(() => {
onClick={(e) => {
e.stopPropagation();
setOpenDrawerScenario(true);
}, 100);
}}
>
Add/Edit Scenario
@ -159,107 +411,29 @@ const BottomToolBar = ({
style={{
padding: "20px",
overflowY: "auto",
overflowX: "hidden",
overflowX: "visible",
flex: 1,
position: "relative",
}}
className={classes.hideScrollBar}
>
{scenarios.length > 0 ? (
<Grid gutter="md" style={{ margin: 0 }}>
<Grid
gutter="md"
style={{ margin: 0, overflow: "visible", position: "relative" }}
>
{scenarios.map((scenario, index) => (
<Grid.Col key={scenario.id} span={6}>
<Card
shadow="sm"
padding="lg"
radius="md"
withBorder
style={{
cursor: "pointer",
transition: "all 0.2s ease",
height: "100%",
}}
className={classes.scenarioCard}
>
<Flex direction="column" gap="xs" h="100%">
<Flex justify="space-between" align="center">
<Text fw={600} size="md" lineClamp={1}>
{scenario.title}
</Text>
<Badge color="blue" variant="light">
#{index + 1}
</Badge>
</Flex>
<Flex gap="xs" wrap="wrap">
<Badge size="sm" color="gray" variant="dot">
Timeout: {scenario.timeout}ms
</Badge>
{scenario.isReboot && (
<Badge size="sm" color="orange" variant="dot">
Reboot
</Badge>
)}
<Badge size="sm" color="green" variant="dot">
{JSON.parse(scenario.body || "[]").length} steps
</Badge>
</Flex>
<Text
size="xs"
c="dimmed"
lineClamp={2}
style={{ flex: 1 }}
>
{JSON.parse(scenario.body || "[]")
.slice(0, 2)
.map((step: { send: string }) => step.send)
.join(" → ")}
{JSON.parse(scenario.body || "[]").length > 2 &&
" ..."}
</Text>
<Button
fullWidth
variant="light"
color="green"
leftSection={<IconPlayerPlay size={16} />}
disabled={
isDisable ||
selectedLines.filter(
(el) =>
!el?.userEmailOpenCLI ||
el?.userEmailOpenCLI === user?.email
).length === 0
}
onClick={() => {
setOpenScenarioModal(false);
setIsDisable(true);
setTimeout(() => {
setIsDisable(false);
}, 5000);
// Run scenario cho các line được chọn
selectedLines
.filter(
(el) =>
!el?.userEmailOpenCLI ||
el?.userEmailOpenCLI === user?.email
)
.forEach((el) => {
socket?.emit(
"run_scenario",
Object.assign(el, {
scenario: scenario,
})
);
});
}}
>
Run Scenario
</Button>
</Flex>
</Card>
</Grid.Col>
<ScenarioCard
key={scenario.id}
scenario={scenario}
index={index}
isDisable={isDisable}
selectedLines={selectedLines}
user={user}
socket={socket}
setOpenScenarioModal={setOpenScenarioModal}
setIsDisable={setIsDisable}
/>
))}
</Grid>
) : (
@ -350,8 +524,7 @@ const BottomToolBar = ({
</Tabs.Tab>
<Tabs.Tab
style={{
backgroundColor:
activeTabBottom === "apc" ? "#c8d9fd" : "",
backgroundColor: activeTabBottom === "apc" ? "#c8d9fd" : "",
fontSize: "13px",
paddingTop: "8px",
paddingBottom: "8px",
@ -376,33 +549,24 @@ const BottomToolBar = ({
<Tabs.Panel value="command" p={"xs"}>
<Flex justify={"space-between"}>
<Box>
<ScrollArea h={"12vh"}>
<Flex wrap={"wrap"} gap={"8px"} w={"420px"}>
<ScrollArea h={"15vh"}>
<Flex wrap={"wrap"} gap={"xs"} w={"400px"}>
{selectedLines.map((el) => (
<Box
key={el.id}
style={{
position: "relative",
padding: "4px 6px",
height: "26px",
width: "60px",
paddingLeft: "4px",
height: "30px",
width: "80px",
backgroundColor: "#d4e3ff",
borderRadius: "8px",
}}
>
{/* Close button góc trên phải */}
<Flex align={"center"} justify={"center"} gap={"4px"}>
<Text fz={"12px"}>Line {el.lineNumber}</Text>
<CloseButton
size="xs"
style={{
position: "absolute",
top: "-4px",
right: "-6px",
minWidth: "18px",
width: "18px",
height: "18px",
zIndex: 10,
}}
style={{ minWidth: "24px" }}
aria-label="Clear input"
onClick={() => {
setSelectedLines(
selectedLines.filter(
@ -415,45 +579,11 @@ const BottomToolBar = ({
});
}}
/>
<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,
stationId: line.stationId || line.station_id,
});
});
setSelectedLines([]);
}}
>
Clear
</Button>
) : (
""
)}
</Box>
<Box pl={"md"} pr={"md"}>
<Flex justify={"space-between"} mb={"xs"}>
<Flex></Flex>

View File

@ -26,6 +26,13 @@ const DialogConfirm = ({
onClose={close}
size="xs"
radius="md"
zIndex={100001}
withinPortal={true}
portalProps={{ target: document.body }}
overlayProps={{
backgroundOpacity: 0.7,
blur: 2,
}}
>
<Text
size="sm"

View File

@ -218,7 +218,10 @@ function DrawerScenario({
justifyContent: "center",
backdropFilter: "blur(3px)",
}}
onClick={handleClose}
onClick={(e) => {
e.stopPropagation();
handleClose();
}}
>
<div
style={{