Update modal run scenario

This commit is contained in:
nguyentrungthat 2025-12-16 11:11:19 +07:00
parent 2729feed4e
commit 76659b3e68
9 changed files with 990 additions and 470 deletions

View File

@ -12,7 +12,7 @@ export default class ScenariosController {
const perPage = request.input('per_page', 10) const perPage = request.input('per_page', 10)
const page = request.input('page', 1) const page = request.input('page', 1)
const query = Scenario.query() const query = Scenario.query().preload('brand').preload('category')
const scenarios = await query.orderBy('scenarios.created_at', 'asc').paginate(page, perPage) const scenarios = await query.orderBy('scenarios.created_at', 'asc').paginate(page, perPage)
return response.ok({ return response.ok({
@ -80,10 +80,16 @@ export default class ScenariosController {
await trx.commit() await trx.commit()
// Lấy lại station kèm lines
const resScenario = await Scenario.query()
.where('id', scenario.id)
.preload('brand')
.preload('category')
return response.ok({ return response.ok({
status: true, status: true,
message: 'Scenario created successfully', message: 'Scenario created successfully',
data: scenario, data: resScenario,
}) })
} catch (error) { } catch (error) {
await trx.rollback() await trx.rollback()
@ -153,7 +159,17 @@ export default class ScenariosController {
scenario.merge(payload) scenario.merge(payload)
await scenario.save() await scenario.save()
return response.ok({ status: true, message: 'Scenario updated successfully', data: scenario }) // Lấy lại station kèm lines
const resScenario = await Scenario.query()
.where('id', scenario.id)
.preload('brand')
.preload('category')
return response.ok({
status: true,
message: 'Scenario updated successfully',
data: resScenario,
})
} catch (error) { } catch (error) {
return response.internalServerError({ return response.internalServerError({
status: false, status: false,

View File

@ -58,11 +58,19 @@ const Login = () => {
} catch (error) { } catch (error) {
setIsSubmit(false); setIsSubmit(false);
console.log(error); console.log(error);
notifications.show({ if (axios.isAxiosError(error)) {
title: "Error", notifications.show({
message: "Login fail, please try again!", title: "Error",
color: "red", message: error?.response?.data?.message,
}); color: "red",
});
} else {
notifications.show({
title: "Error",
message: "Login fail, please try again!",
color: "red",
});
}
} }
}; };

View File

@ -8,10 +8,8 @@ import {
ScrollArea, ScrollArea,
Tabs, Tabs,
Text, Text,
Card,
Badge,
} from "@mantine/core"; } from "@mantine/core";
import { useEffect, useMemo, useRef, useState } from "react"; import { useMemo, useState } from "react";
import classes from "./Component.module.css"; import classes from "./Component.module.css";
import type { import type {
IScenario, IScenario,
@ -19,7 +17,6 @@ import type {
TCategories, TCategories,
TLine, TLine,
TStation, TStation,
TUser,
} from "../untils/types"; } 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";
@ -28,13 +25,9 @@ import { DrawerAPCControl, DrawerSwitchControl } from "./Drawer/DrawerControl";
import DrawerScenario from "./Modal/ModalScenario"; 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 { IconCaretDown, IconCaretUp } from "@tabler/icons-react";
IconCaretDown,
IconCaretUp,
IconPlayerPlay,
IconPlus,
} from "@tabler/icons-react";
import InputHistory from "./InputHistory"; import InputHistory from "./InputHistory";
import ModalRunScenario from "./Modal/ModalRunScenario";
interface TabsProps { interface TabsProps {
selectedLines: TLine[]; selectedLines: TLine[];
@ -58,318 +51,6 @@ interface TabsProps {
listCategories: TCategories[]; listCategories: TCategories[];
} }
// Component cho từng Scenario Card
const ScenarioCard = ({
scenario,
index,
isDisable,
selectedLines,
user,
socket,
setOpenScenarioModal,
setIsDisable,
station,
}: {
scenario: IScenario;
index: number;
isDisable: boolean;
selectedLines: TLine[];
user: TUser;
socket: Socket | null;
setOpenScenarioModal: (value: boolean) => void;
setIsDisable: (value: boolean) => void;
station: TStation;
}) => {
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;
const gap = 10; // Khoảng cách giữa card và overlay
// Đặt overlay bên phải card
let left = rect.right + gap;
// Nếu overlay tràn ra ngoài màn hình bên phải → đặt bên trái card
if (left + overlayWidth > viewportWidth) {
left = rect.left - overlayWidth - gap;
// Nếu vẫn tràn ra ngoài bên trái → đặt ở giữa màn hình
if (left < 10) {
left = (viewportWidth - overlayWidth) / 2;
}
}
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;
const gap = 10; // Khoảng cách giữa card và overlay
// Đặt overlay bên phải card
let left = rect.right + gap;
// Nếu overlay tràn ra ngoài màn hình bên phải → đặt bên trái card
if (left + overlayWidth > viewportWidth) {
left = rect.left - overlayWidth - gap;
// Nếu vẫn tràn ra ngoài bên trái → đặt ở giữa màn hình
if (left < 10) {
left = (viewportWidth - overlayWidth) / 2;
}
}
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);
if (scenario?.isReboot || scenario?.is_reboot) {
const lineApc1 = selectedLines
?.filter(
(el) =>
el.outlet &&
Number(el.outlet) > 0 &&
(el.apcName === "apc_1" || el.apc_name === "apc_1")
)
?.map((el) => el.outlet);
const lineApc2 = selectedLines
?.filter(
(el) =>
el.outlet &&
Number(el.outlet) > 0 &&
(el.apcName === "apc_2" || el.apc_name === "apc_2")
)
?.map((el) => el.outlet);
if (lineApc1.length > 0)
socket?.emit("control_apc", {
outletNumbers: lineApc1,
station: { ...station, lines: [] },
action: "restart",
apcName: "apc_1",
});
if (lineApc2.length > 0)
socket?.emit("control_apc", {
outletNumbers: lineApc2,
station: { ...station, lines: [] },
action: "restart",
apcName: "apc_2",
});
}
if (scenario?.send_result)
socket?.emit("run_all_dpelp", {
lineIds: selectedLines?.map((el) => el.id),
stationName: station.name,
stationId: Number(station.id),
});
selectedLines
.filter(
(el) =>
!el?.userEmailOpenCLI ||
el?.userEmailOpenCLI === user?.email
)
.forEach((el) => {
socket?.emit(
"run_scenario",
Object.assign(el, {
scenario: {
...scenario,
isReboot:
typeof scenario?.isReboot !== "undefined"
? scenario?.isReboot
: scenario?.is_reboot,
},
})
);
});
}}
>
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 = ({ const BottomToolBar = ({
selectedLines, selectedLines,
socket, socket,
@ -948,129 +629,20 @@ const BottomToolBar = ({
</Box> </Box>
</motion.div> </motion.div>
{openScenarioModal && ( <ModalRunScenario
<div open={openScenarioModal}
style={{ setOpen={setOpenScenarioModal}
position: "fixed", scenarios={scenarios}
top: 0, isDisable={isDisable}
left: 0, selectedLines={selectedLines}
right: 0, user={user}
bottom: 0, socket={socket}
backgroundColor: "rgba(0,0,0,0.6)", setIsDisable={setIsDisable}
zIndex: 99998, station={station}
display: "flex", setOpenEdit={setOpenDrawerScenario}
alignItems: "center", listBrands={listBrands}
justifyContent: "center", listCategories={listCategories}
backdropFilter: "blur(3px)", />
}}
onClick={() => setOpenScenarioModal(false)}
>
<div
style={{
background: "white",
borderRadius: "12px",
maxWidth: "90vw",
width: "90%",
height: "85vh",
display: "flex",
flexDirection: "column",
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
overflowY: "auto",
overflowX: "visible",
}}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<Flex
justify="space-between"
align="center"
p="lg"
style={{
borderBottom: "1px solid #e9ecef",
flexShrink: 0,
}}
>
<Text fw={700} size="xl">
🎯 Select Scenario to Run
</Text>
<Flex gap="md" align="center">
<Button
leftSection={<IconPlus size={16} />}
variant="light"
color="green"
size="sm"
onClick={(e) => {
e.stopPropagation();
setOpenDrawerScenario(true);
}}
>
Add/Edit Scenario
</Button>
<CloseButton
size="lg"
onClick={() => setOpenScenarioModal(false)}
/>
</Flex>
</Flex>
{/* Content */}
<div
style={{
padding: "20px",
overflowY: "auto",
overflowX: "visible",
flex: 1,
position: "relative",
}}
className={classes.hideScrollBar}
>
{scenarios.length > 0 ? (
<Grid
gutter="md"
style={{
margin: 0,
overflow: "visible",
position: "relative",
}}
>
{scenarios.map((scenario, index) => (
<ScenarioCard
key={scenario.id}
scenario={scenario}
index={index}
isDisable={isDisable}
selectedLines={selectedLines}
user={user}
socket={socket}
setOpenScenarioModal={setOpenScenarioModal}
setIsDisable={setIsDisable}
station={station}
/>
))}
</Grid>
) : (
<Flex
direction="column"
align="center"
justify="center"
style={{ minHeight: "300px" }}
gap="md"
>
<Text size="xl" c="dimmed">
📋
</Text>
<Text ta="center" c="dimmed" size="lg">
No scenarios available
</Text>
<Text ta="center" c="dimmed" size="sm">
Please create a new scenario to get started
</Text>
</Flex>
)}
</div>
</div>
</div>
)}
{/* Drawer Scenario để Add/Edit */} {/* Drawer Scenario để Add/Edit */}
<DrawerScenario <DrawerScenario

View File

@ -0,0 +1,560 @@
import {
Badge,
Box,
Button,
CloseButton,
Flex,
ScrollArea,
Select,
Table,
TagsInput,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import { IconPlayerPlay, IconPlus, IconX } from "@tabler/icons-react";
import classes from "../Component.module.css";
import type {
IScenario,
TBrands,
TCategories,
TLine,
TStation,
TUser,
} from "../../untils/types";
import type { Socket } from "socket.io-client";
import { useState } from "react";
const ModalRunScenario = ({
open,
setOpen,
scenarios,
isDisable,
selectedLines,
user,
socket,
setIsDisable,
station,
setOpenEdit,
listBrands,
listCategories,
}: {
open: boolean;
setOpen: (value: React.SetStateAction<boolean>) => void;
setOpenEdit: (value: React.SetStateAction<boolean>) => void;
scenarios: IScenario[];
isDisable: boolean;
selectedLines: TLine[];
user: TUser;
socket: Socket | null;
setIsDisable: (value: boolean) => void;
station: TStation;
listBrands: TBrands[];
listCategories: TCategories[];
}) => {
const [optionBrand, setOptionBrand] = useState("");
const [optionCategory, setOptionCategory] = useState("");
const [inputSeries, setInputSeries] = useState<string[]>([]);
const [inputTitle, setInputTitle] = useState("");
const filteredScenarios = () => {
let listScenarios = [...scenarios];
if (inputTitle.trim())
listScenarios = listScenarios.filter((el) =>
el.title?.toLowerCase()?.includes(inputTitle?.toLowerCase())
);
if (optionBrand)
listScenarios = listScenarios.filter(
(el) =>
el.brandId === Number(optionBrand) ||
el.brand_id === Number(optionBrand)
);
if (optionCategory)
listScenarios = listScenarios.filter(
(el) =>
el.categoryId === Number(optionCategory) ||
el.category_id === Number(optionCategory)
);
if (inputSeries.length)
listScenarios = listScenarios.filter((el) => {
const series: string[] = JSON.parse(el.series) || [];
if (!series.length) return false;
return series.some((serial) =>
inputSeries.some((input) =>
serial.toLowerCase().includes(input.toLowerCase())
)
);
});
return listScenarios;
};
return (
open && (
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0,0,0,0.6)",
zIndex: 98,
display: "flex",
alignItems: "center",
justifyContent: "center",
backdropFilter: "blur(3px)",
}}
onClick={() => setOpen(false)}
>
<div
style={{
background: "white",
borderRadius: "12px",
maxWidth: "90vw",
width: "90%",
height: "85vh",
display: "flex",
flexDirection: "column",
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
overflowY: "auto",
overflowX: "visible",
}}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<Flex
justify="space-between"
align="center"
p="lg"
style={{
borderBottom: "1px solid #e9ecef",
flexShrink: 0,
}}
>
<Text fw={700} size="xl">
🎯 Select Scenario to Run
</Text>
<Flex gap="md" align="center">
<Button
leftSection={<IconPlus size={16} />}
variant="light"
color="green"
size="sm"
onClick={(e) => {
e.stopPropagation();
setOpenEdit(true);
}}
>
Add/Edit Scenario
</Button>
<CloseButton size="lg" onClick={() => setOpen(false)} />
</Flex>
</Flex>
<Flex style={{ paddingLeft: "20px" }} gap={"20px"}>
<Box style={{ width: "155px" }}>
<Select
label="Brand"
placeholder="Select Brand"
data={listBrands?.map((el) => ({
value: el.id.toString(),
label: el.name,
}))}
value={optionBrand || null}
onChange={(value) => setOptionBrand(value || "")}
clearable
/>
</Box>
<Box style={{ width: "150px" }}>
<Select
label="Category"
placeholder="Select Category"
data={listCategories?.map((el) => ({
value: el.id.toString(),
label: el.name,
}))}
value={optionCategory || null}
onChange={(value) => setOptionCategory(value || "")}
clearable
/>
</Box>
<Box style={{ width: "31vw", maxWidth: "500px" }}>
<TagsInput
label="Series"
placeholder="Enter Series"
data={[]}
value={inputSeries || []}
onChange={(value: string[]) => setInputSeries(value)}
/>
</Box>
<Box style={{ marginLeft: "2vw" }}>
<TextInput
style={{ width: "250px" }}
label="Title"
placeholder="Search title"
value={inputTitle}
onChange={(event) => setInputTitle(event.currentTarget.value)}
rightSection={
inputTitle ? (
<IconX
size={14}
style={{ cursor: "pointer" }}
onClick={() => setInputTitle("")}
/>
) : null
}
rightSectionPointerEvents="auto"
/>
</Box>
</Flex>
{/* Content */}
<div
style={{
padding: "20px",
paddingTop: 0,
overflowY: "auto",
overflowX: "visible",
flex: 1,
position: "relative",
}}
className={classes.hideScrollBar}
>
<ScrollArea h={"60vh"} style={{ marginTop: "15px" }}>
<Table
stickyHeader
striped
highlightOnHover
withRowBorders={true}
withTableBorder={true}
withColumnBorders={true}
>
<Table.Thead
style={{
top: 1,
}}
>
<Table.Tr>
<Table.Th
style={{
width: "170px",
textAlign: "center",
backgroundColor: "#94c6ff",
}}
>
Brand
</Table.Th>
<Table.Th
style={{
width: "170px",
textAlign: "center",
backgroundColor: "#94c6ff",
}}
>
Category
</Table.Th>
<Table.Th
style={{
width: "550px",
textAlign: "center",
backgroundColor: "#94c6ff",
}}
>
Series
</Table.Th>
<Table.Th
style={{
width: "340px",
textAlign: "center",
backgroundColor: "#94c6ff",
}}
>
Title
</Table.Th>
<Table.Th
style={{
textAlign: "center",
backgroundColor: "#94c6ff",
}}
>
Note
</Table.Th>
<Table.Th
style={{
width: "140px",
textAlign: "center",
backgroundColor: "#94c6ff",
}}
>
Action
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filteredScenarios().length > 0 ? (
filteredScenarios().map((scenario, index) => (
<Table.Tr key={scenario.id}>
<Table.Td
style={{
textAlign: "center",
}}
>
<Text>{scenario?.brand?.name || ""}</Text>
</Table.Td>
<Table.Td
style={{
textAlign: "center",
}}
>
<Text>{scenario?.category?.name || ""}</Text>
</Table.Td>
<Table.Td>
<Flex wrap={"wrap"} gap={"4px"}>
{scenario.series
? JSON.parse(scenario.series)?.map(
(el: string, i: number) => (
<Badge
key={i}
size="md"
color="orange"
variant="dot"
>
{el}
</Badge>
)
)
: ""}
</Flex>
</Table.Td>
<Table.Td>
<Text>{scenario.title}</Text>
</Table.Td>
<Table.Td>
<Text>{scenario.note || ""}</Text>
</Table.Td>
<Table.Td
style={{
textAlign: "center",
}}
>
<Tooltip
openDelay={0}
position="left-start"
style={{
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: 99,
minHeight: "200px",
maxHeight: "450px",
overflowY: "auto",
overflowX: "hidden",
}}
className={classes.hideScrollBar}
onClick={(e) => e.stopPropagation()}
label={
<Flex direction="column" gap="xs">
<Flex justify="space-between" align="center">
<Box>
<Text c={"dark"} fw={700} size="md">
{scenario.title}
</Text>
</Box>
<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}s
</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" fw={600} mt="xs" c="dimmed">
Commands Preview:
</Text>
<Box
style={{
background: "#f8f9fa",
padding: "8px",
borderRadius: "4px",
maxHeight: "150px",
overflow: "auto",
}}
>
{(JSON.parse(scenario?.body) || [])
.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>
)
)}
{(JSON.parse(scenario?.body) || []).length >
5 && (
<Text
size="xs"
c="dimmed"
ta="center"
mt="xs"
>
... and{" "}
{(JSON.parse(scenario?.body) || [])
.length - 5}{" "}
more
</Text>
)}
</Box>
</Flex>
}
>
<Button
style={{ width: "100px" }}
variant="light"
color="green"
size="sm"
leftSection={<IconPlayerPlay size={16} />}
disabled={
isDisable ||
selectedLines.filter(
(el) =>
!el?.userEmailOpenCLI ||
el?.userEmailOpenCLI === user?.email
).length === 0
}
onClick={() => {
setOpen(false);
setIsDisable(true);
setTimeout(() => {
setIsDisable(false);
}, 5000);
if (scenario?.isReboot || scenario?.is_reboot) {
const lineApc1 = selectedLines
?.filter(
(el) =>
el.outlet &&
Number(el.outlet) > 0 &&
(el.apcName === "apc_1" ||
el.apc_name === "apc_1")
)
?.map((el) => el.outlet);
const lineApc2 = selectedLines
?.filter(
(el) =>
el.outlet &&
Number(el.outlet) > 0 &&
(el.apcName === "apc_2" ||
el.apc_name === "apc_2")
)
?.map((el) => el.outlet);
if (lineApc1.length > 0)
socket?.emit("control_apc", {
outletNumbers: lineApc1,
station: { ...station, lines: [] },
action: "restart",
apcName: "apc_1",
});
if (lineApc2.length > 0)
socket?.emit("control_apc", {
outletNumbers: lineApc2,
station: { ...station, lines: [] },
action: "restart",
apcName: "apc_2",
});
}
if (scenario?.send_result)
socket?.emit("run_all_dpelp", {
lineIds: selectedLines?.map((el) => el.id),
stationName: station.name,
stationId: Number(station.id),
});
selectedLines
.filter(
(el) =>
!el?.userEmailOpenCLI ||
el?.userEmailOpenCLI === user?.email
)
.forEach((el) => {
socket?.emit(
"run_scenario",
Object.assign(el, {
scenario: {
...scenario,
isReboot:
typeof scenario?.isReboot !==
"undefined"
? scenario?.isReboot
: scenario?.is_reboot,
},
})
);
});
}}
>
Run
</Button>
</Tooltip>
</Table.Td>
</Table.Tr>
))
) : (
<Table.Tr>
<Table.Td colSpan={6}>
<Flex
direction="column"
align="center"
justify="center"
style={{ minHeight: "300px" }}
gap="md"
>
<Text size="xl" c="dimmed">
📋
</Text>
<Text ta="center" c="dimmed" size="lg">
No scenarios available
</Text>
</Flex>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</ScrollArea>
</div>
</div>
</div>
)
);
};
export default ModalRunScenario;

View File

@ -156,7 +156,7 @@ function ModalScenario({
isEdit ? { ...payload, id: dataScenario?.id } : payload isEdit ? { ...payload, id: dataScenario?.id } : payload
); );
if (res.data.status === true) { if (res.data.status === true) {
const scenario = res.data.data; const scenario = res.data.data[0];
setScenarios((pre) => setScenarios((pre) =>
isEdit isEdit
? pre.map((el) => ? pre.map((el) =>
@ -181,11 +181,19 @@ function ModalScenario({
} }
} catch (error) { } catch (error) {
console.log(error); console.log(error);
notifications.show({ if (axios.isAxiosError(error)) {
title: "Error", notifications.show({
message: "Failed to create scenario, please try again!", title: "Error",
color: "red", message: error?.response?.data?.message,
}); color: "red",
});
} else {
notifications.show({
title: "Error",
message: "Failed to create scenario, please try again!",
color: "red",
});
}
} finally { } finally {
setIsSubmit(false); setIsSubmit(false);
} }
@ -294,7 +302,7 @@ function ModalScenario({
{/* Sidebar - List Scenarios */} {/* Sidebar - List Scenarios */}
<Box <Box
style={{ style={{
width: "200px", width: "250px",
borderRight: "1px solid #e9ecef", borderRight: "1px solid #e9ecef",
paddingRight: "10px", paddingRight: "10px",
flexShrink: 0, flexShrink: 0,
@ -350,7 +358,7 @@ function ModalScenario({
"categoryId", "categoryId",
scenario.category_id scenario.category_id
? scenario.category_id.toString() ? scenario.category_id.toString()
: scenario.categoryId.toString() : scenario.categoryId?.toString()
); );
form.setFieldValue( form.setFieldValue(
"series", "series",
@ -502,7 +510,6 @@ function ModalScenario({
onChange={(e) => onChange={(e) =>
form.setFieldValue("note", e.target.value) form.setFieldValue("note", e.target.value)
} }
required
/> />
</Grid.Col> </Grid.Col>
<Grid.Col span={6}> <Grid.Col span={6}>

View File

@ -0,0 +1,320 @@
import type { Socket } from "socket.io-client";
import type { IScenario, TLine, TStation, TUser } from "../../../untils/types";
import { useEffect, useRef, useState } from "react";
import { Badge, Box, Button, Card, Flex, Grid, Text } from "@mantine/core";
import classes from "../../Component.module.css";
import { IconPlayerPlay } from "@tabler/icons-react";
// Component cho từng Scenario Card
const ScenarioCard = ({
scenario,
index,
isDisable,
selectedLines,
user,
socket,
setOpenScenarioModal,
setIsDisable,
station,
}: {
scenario: IScenario;
index: number;
isDisable: boolean;
selectedLines: TLine[];
user: TUser;
socket: Socket | null;
setOpenScenarioModal: (value: boolean) => void;
setIsDisable: (value: boolean) => void;
station: TStation;
}) => {
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;
const gap = 10; // Khoảng cách giữa card và overlay
// Đặt overlay bên phải card
let left = rect.right + gap;
// Nếu overlay tràn ra ngoài màn hình bên phải → đặt bên trái card
if (left + overlayWidth > viewportWidth) {
left = rect.left - overlayWidth - gap;
// Nếu vẫn tràn ra ngoài bên trái → đặt ở giữa màn hình
if (left < 10) {
left = (viewportWidth - overlayWidth) / 2;
}
}
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;
const gap = 10; // Khoảng cách giữa card và overlay
// Đặt overlay bên phải card
let left = rect.right + gap;
// Nếu overlay tràn ra ngoài màn hình bên phải → đặt bên trái card
if (left + overlayWidth > viewportWidth) {
left = rect.left - overlayWidth - gap;
// Nếu vẫn tràn ra ngoài bên trái → đặt ở giữa màn hình
if (left < 10) {
left = (viewportWidth - overlayWidth) / 2;
}
}
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);
if (scenario?.isReboot || scenario?.is_reboot) {
const lineApc1 = selectedLines
?.filter(
(el) =>
el.outlet &&
Number(el.outlet) > 0 &&
(el.apcName === "apc_1" || el.apc_name === "apc_1")
)
?.map((el) => el.outlet);
const lineApc2 = selectedLines
?.filter(
(el) =>
el.outlet &&
Number(el.outlet) > 0 &&
(el.apcName === "apc_2" || el.apc_name === "apc_2")
)
?.map((el) => el.outlet);
if (lineApc1.length > 0)
socket?.emit("control_apc", {
outletNumbers: lineApc1,
station: { ...station, lines: [] },
action: "restart",
apcName: "apc_1",
});
if (lineApc2.length > 0)
socket?.emit("control_apc", {
outletNumbers: lineApc2,
station: { ...station, lines: [] },
action: "restart",
apcName: "apc_2",
});
}
if (scenario?.send_result)
socket?.emit("run_all_dpelp", {
lineIds: selectedLines?.map((el) => el.id),
stationName: station.name,
stationId: Number(station.id),
});
selectedLines
.filter(
(el) =>
!el?.userEmailOpenCLI ||
el?.userEmailOpenCLI === user?.email
)
.forEach((el) => {
socket?.emit(
"run_scenario",
Object.assign(el, {
scenario: {
...scenario,
isReboot:
typeof scenario?.isReboot !== "undefined"
? scenario?.isReboot
: scenario?.is_reboot,
},
})
);
});
}}
>
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>
);
};
export default ScenarioCard;

View File

@ -2,13 +2,48 @@ 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";
import type { IBodyScenario } from "../../../untils/types";
import type { UseFormReturnType } from "@mantine/form";
interface IPayload { interface IPayload {
element: any; element: IBodyScenario;
i: any; i: number;
form: any; form: UseFormReturnType<
deleteRow: any; {
addRowUnder: any; title: string;
body: IBodyScenario[];
timeout: string;
isReboot: boolean;
send_result: boolean;
note: string;
series: string[];
brandId: string;
categoryId: string;
},
(values: {
title: string;
body: IBodyScenario[];
timeout: string;
isReboot: boolean;
send_result: boolean;
note: string;
series: string[];
brandId: string;
categoryId: string;
}) => {
title: string;
body: IBodyScenario[];
timeout: string;
isReboot: boolean;
send_result: boolean;
note: string;
series: string[];
brandId: string;
categoryId: string;
}
>;
deleteRow: (index: number) => void;
addRowUnder: (index: number) => void;
} }
const TableRows = ({ element, i, form, deleteRow, addRowUnder }: IPayload) => { const TableRows = ({ element, i, form, deleteRow, addRowUnder }: IPayload) => {
@ -28,7 +63,7 @@ const TableRows = ({ element, i, form, deleteRow, addRowUnder }: IPayload) => {
<IconGripVertical /> <IconGripVertical />
</Box> */} </Box> */}
<TextInput <TextInput
style={{ width: "250px" }} style={{ width: "300px" }}
value={element.expect} value={element.expect}
placeholder="Expect previous output" placeholder="Expect previous output"
onChange={(e) => { onChange={(e) => {
@ -49,7 +84,7 @@ const TableRows = ({ element, i, form, deleteRow, addRowUnder }: IPayload) => {
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<TextInput <TextInput
style={{ width: "250px" }} style={{ width: "350px" }}
value={element.send} value={element.send}
placeholder="Command send" placeholder="Command send"
onChange={(e) => { onChange={(e) => {

View File

@ -208,7 +208,7 @@ const TerminalCLI: React.FC<TerminalCLIProps> = ({
useEffect(() => { useEffect(() => {
const el = xtermRef.current; const el = xtermRef.current;
if (el) { if (el && miniSize) {
if (!isSelected) { if (!isSelected) {
// tắt pointer events để terminal không bắt wheel/click nữa // tắt pointer events để terminal không bắt wheel/click nữa
el.style.pointerEvents = "none"; el.style.pointerEvents = "none";

View File

@ -167,8 +167,10 @@ export type IScenario = {
send_result: boolean; send_result: boolean;
brandId: number; brandId: number;
brand_id?: number; brand_id?: number;
brand?: TBrands;
categoryId: number; categoryId: number;
category_id?: number; category_id?: number;
category?: TCategories;
note: string; note: string;
series: string; series: string;
updated_at: string; updated_at: string;