Thên button edit scenarios ở ngoài (góc trái trên)

This commit is contained in:
Truong Vo 2025-12-03 14:57:17 +07:00
parent 237a514ae1
commit ef57da0154
2 changed files with 300 additions and 342 deletions

View File

@ -505,6 +505,8 @@ function App() {
setIsOpenAddStation={setIsOpenAddStation} setIsOpenAddStation={setIsOpenAddStation}
setStationEdit={setStationEdit} setStationEdit={setStationEdit}
tabsData={stations} tabsData={stations}
scenarios={scenarios}
setScenarios={setScenarios}
panels={stations.map((station) => ( panels={stations.map((station) => (
<Tabs.Panel <Tabs.Panel
className={classes.content} className={classes.content}

View File

@ -1,379 +1,335 @@
import { import { ActionIcon, Avatar, Box, Button, Flex, Group, Menu, Tabs, Text, Tooltip, UnstyledButton } from "@mantine/core";
ActionIcon, import { DndContext, useSensor, useSensors, PointerSensor, closestCenter, type DragEndEvent } from "@dnd-kit/core";
Avatar, import { arrayMove, SortableContext, useSortable, horizontalListSortingStrategy } from "@dnd-kit/sortable";
Box,
Button,
Flex,
Group,
Menu,
Tabs,
Text,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import {
DndContext,
useSensor,
useSensors,
PointerSensor,
closestCenter,
type DragEndEvent,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
useSortable,
horizontalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { useEffect, useMemo, useState, type JSX } from "react"; import { useEffect, useMemo, useState, type JSX } from "react";
import { import { IconChevronRight, IconEdit, IconLogout, IconSettings, IconSettingsPlus, IconListDetails, IconUsersGroup } from "@tabler/icons-react";
IconChevronRight,
IconEdit,
IconLogout,
IconSettings,
IconSettingsPlus,
IconUsersGroup,
} from "@tabler/icons-react";
import classes from "./Component.module.css"; import classes from "./Component.module.css";
import type { TStation, TUser } from "../untils/types"; import type { IScenario, TStation, TUser } from "../untils/types";
import type { Socket } from "socket.io-client"; import type { Socket } from "socket.io-client";
import ModalHistory from "./ModalHistory"; import ModalHistory from "./ModalHistory";
import ModalConfig from "./ModalConfig"; import ModalConfig from "./ModalConfig";
import DrawerScenario from "./DrawerScenario";
interface DraggableTabsProps { interface DraggableTabsProps {
tabsData: TStation[]; tabsData: TStation[];
panels: JSX.Element[]; panels: JSX.Element[];
storageKey?: string; storageKey?: string;
onChange: (activeTabId: string | null) => void; onChange: (activeTabId: string | null) => void;
w?: string | number; w?: string | number;
isStationSettings?: boolean; isStationSettings?: boolean;
socket: Socket | null; socket: Socket | null;
usersConnecting: TUser[]; usersConnecting: TUser[];
setIsEditStation: (value: React.SetStateAction<boolean>) => void; setIsEditStation: (value: React.SetStateAction<boolean>) => void;
setIsOpenAddStation: (value: React.SetStateAction<boolean>) => void; setIsOpenAddStation: (value: React.SetStateAction<boolean>) => void;
setStationEdit: (value: React.SetStateAction<TStation | undefined>) => void; setStationEdit: (value: React.SetStateAction<TStation | undefined>) => void;
active: string; active: string;
setActive: (value: React.SetStateAction<string>) => void; setActive: (value: React.SetStateAction<string>) => void;
onSendCommand: (value: string) => void; onSendCommand: (value: string) => void;
scenarios: IScenario[];
setScenarios: (value: React.SetStateAction<IScenario[]>) => void;
} }
function SortableTab({ function SortableTab({
tab, tab,
active, active,
onChange, onChange,
}: { }: {
tab: TStation; tab: TStation;
active: string | null; active: string | null;
onChange: (id: string) => void; onChange: (id: string) => void;
isStationSettings?: boolean; isStationSettings?: boolean;
}) { }) {
const { attributes, listeners, setNodeRef, transform, transition } = const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: tab.id.toString() });
useSortable({ id: tab.id.toString() });
return ( return (
<Tabs.Tab <Tabs.Tab
className={classes.tab} className={classes.tab}
ref={setNodeRef} ref={setNodeRef}
{...attributes} {...attributes}
{...listeners} {...listeners}
onPointerDown={(e) => { onPointerDown={(e) => {
listeners?.onPointerDown?.(e); listeners?.onPointerDown?.(e);
onChange(tab.id.toString()); onChange(tab.id.toString());
}} }}
value={tab.id.toString()} value={tab.id.toString()}
style={{ style={{
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
transition, transition,
cursor: "grab", cursor: "grab",
userSelect: "none", userSelect: "none",
backgroundColor: active === tab.id.toString() ? "#deffde" : "", backgroundColor: active === tab.id.toString() ? "#deffde" : "",
}} }}
color={active === tab.id.toString() ? "green" : ""} color={active === tab.id.toString() ? "green" : ""}
fw={600} fw={600}
fz="md" fz="md"
c="#747474" c="#747474">
> <Box className={classes.stationName}>
<Box className={classes.stationName}> <Text fw={600} fz="md" className={classes.stationText}>
<Text fw={600} fz="md" className={classes.stationText}> {tab.name}
{tab.name} </Text>
</Text> </Box>
</Box> </Tabs.Tab>
</Tabs.Tab> );
);
} }
export default function DraggableTabs({ export default function DraggableTabs({
tabsData, tabsData,
panels, panels,
storageKey = "draggable-tabs-order", storageKey = "draggable-tabs-order",
onChange, onChange,
w, w,
isStationSettings = false, isStationSettings = false,
socket, socket,
usersConnecting, usersConnecting,
setIsEditStation, setIsEditStation,
setIsOpenAddStation, setIsOpenAddStation,
setStationEdit, setStationEdit,
active, active,
setActive, setActive,
scenarios,
setScenarios,
}: DraggableTabsProps) { }: DraggableTabsProps) {
const user = useMemo(() => { const user = useMemo(() => {
return localStorage.getItem("user") && return localStorage.getItem("user") && typeof localStorage.getItem("user") === "string" ? JSON.parse(localStorage.getItem("user") || "") : null;
typeof localStorage.getItem("user") === "string" }, []);
? JSON.parse(localStorage.getItem("user") || "") const [tabs, setTabs] = useState<TStation[]>(tabsData);
: null; const [isChangeTab, setIsChangeTab] = useState<boolean>(false);
}, []); const [isSetActive, setIsSetActive] = useState<boolean>(false);
const [tabs, setTabs] = useState<TStation[]>(tabsData); const [isHistoryModalOpen, setIsHistoryModalOpen] = useState<boolean>(false);
const [isChangeTab, setIsChangeTab] = useState<boolean>(false); const [openConfig, setOpenConfig] = useState<boolean>(false);
const [isSetActive, setIsSetActive] = useState<boolean>(false); const [openDrawerScenario, setOpenDrawerScenario] = useState<boolean>(false);
const [isHistoryModalOpen, setIsHistoryModalOpen] = useState<boolean>(false);
const [openConfig, setOpenConfig] = useState<boolean>(false);
const sensors = useSensors(useSensor(PointerSensor)); const sensors = useSensors(useSensor(PointerSensor));
// Load saved order from localStorage // Load saved order from localStorage
useEffect(() => { useEffect(() => {
if (isChangeTab) { if (isChangeTab) {
setTabs((pre) => setTabs((pre) =>
pre pre
.map((t) => { .map((t) => {
const updatedTab = tabsData.find((td) => td.id === t.id); const updatedTab = tabsData.find((td) => td.id === t.id);
return updatedTab ? updatedTab : t; return updatedTab ? updatedTab : t;
}) })
.filter((t) => (tabsData.find((td) => td.id === t.id) ? true : false)) .filter((t) => (tabsData.find((td) => td.id === t.id) ? true : false))
); );
} else { } else {
const saved = localStorage.getItem(storageKey); const saved = localStorage.getItem(storageKey);
let tabSelected = let tabSelected = tabsData?.length > 0 ? tabsData[0]?.id.toString() : null;
tabsData?.length > 0 ? tabsData[0]?.id.toString() : null; if (saved) {
if (saved) { try {
try { const order = JSON.parse(saved) as { id: string; index: number }[];
const order = JSON.parse(saved) as { id: string; index: number }[];
// Find the max index in saved order // Find the max index in saved order
const maxIndex = Math.max(...order.map((o) => o.index), 0); const maxIndex = Math.max(...order.map((o) => o.index), 0);
const sorted = [...tabsData].sort((a, b) => { const sorted = [...tabsData].sort((a, b) => {
const aOrder = order.find( const aOrder = order.find((o) => o.id.toString() === a.id.toString())?.index;
(o) => o.id.toString() === a.id.toString() const bOrder = order.find((o) => o.id.toString() === b.id.toString())?.index;
)?.index;
const bOrder = order.find(
(o) => o.id.toString() === b.id.toString()
)?.index;
// If not found, assign index after all existing ones // If not found, assign index after all existing ones
const aIndex = aOrder !== undefined ? aOrder : maxIndex + 1; const aIndex = aOrder !== undefined ? aOrder : maxIndex + 1;
const bIndex = bOrder !== undefined ? bOrder : maxIndex + 1; const bIndex = bOrder !== undefined ? bOrder : maxIndex + 1;
return aIndex - bIndex; return aIndex - bIndex;
}); });
tabSelected = sorted?.length > 0 ? sorted[0]?.id.toString() : null; tabSelected = sorted?.length > 0 ? sorted[0]?.id.toString() : null;
setTabs(sorted); setTabs(sorted);
} catch { } catch {
setTabs(tabsData); setTabs(tabsData);
} }
} else { } else {
setTabs(tabsData); setTabs(tabsData);
} }
if (!isSetActive && tabSelected) { if (!isSetActive && tabSelected) {
setActive(tabSelected); setActive(tabSelected);
setTimeout(() => { setTimeout(() => {
onChange(tabSelected); onChange(tabSelected);
}, 100); }, 100);
setIsSetActive(true); setIsSetActive(true);
} }
} }
}, [tabsData, storageKey]); }, [tabsData, storageKey]);
// Handle reorder // Handle reorder
const handleDragEnd = (event: DragEndEvent) => { const handleDragEnd = (event: DragEndEvent) => {
const { active: dragActive, over } = event; const { active: dragActive, over } = event;
if (dragActive.id !== over?.id && over?.id) { if (dragActive.id !== over?.id && over?.id) {
const oldIndex = tabs.findIndex( const oldIndex = tabs.findIndex((t) => t.id.toString() === dragActive.id.toString());
(t) => t.id.toString() === dragActive.id.toString() const newIndex = tabs.findIndex((t) => t.id.toString() === over?.id.toString());
); const newTabs = arrayMove(tabs, oldIndex, newIndex);
const newIndex = tabs.findIndex( setTabs(newTabs);
(t) => t.id.toString() === over?.id.toString()
);
const newTabs = arrayMove(tabs, oldIndex, newIndex);
setTabs(newTabs);
const order = newTabs.map((t, i) => ({ id: t.id, index: i })); const order = newTabs.map((t, i) => ({ id: t.id, index: i }));
localStorage.setItem(storageKey, JSON.stringify(order)); localStorage.setItem(storageKey, JSON.stringify(order));
} }
}; };
// Clean up // Clean up
useEffect(() => { useEffect(() => {
return () => { return () => {
setIsChangeTab(false); setIsChangeTab(false);
setIsSetActive(false); setIsSetActive(false);
setTabs([]); setTabs([]);
setActive("0"); setActive("0");
}; };
}, []); }, []);
return ( return (
<DndContext <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
sensors={sensors} <Tabs
collisionDetection={closestCenter} value={active}
onDragEnd={handleDragEnd} onChange={(val) => {
> setIsChangeTab(true);
<Tabs onChange(val);
value={active} setActive(val || "0");
onChange={(val) => { }}
setIsChangeTab(true); w={w}>
onChange(val); <Flex justify={"space-between"}>
setActive(val || "0"); <Flex style={{ marginTop: "8px" }} gap="xs" align="center">
}} <ActionIcon title="Setting" variant="outline" onClick={() => setOpenConfig(true)}>
w={w} <IconSettings />
> </ActionIcon>
<Flex justify={"space-between"}> <Button
<Flex style={{ marginTop: "8px" }}> color="yellow"
<ActionIcon variant="filled"
title="Setting" size="xs"
variant="outline" leftSection={<IconListDetails size={16} />}
onClick={() => setOpenConfig(true)} onClick={() => setOpenDrawerScenario(true)}>
> Scenario
<IconSettings /> </Button>
</ActionIcon> </Flex>
</Flex> <Tabs.List className={classes.list}>
<Tabs.List className={classes.list}> <SortableContext items={tabs} strategy={horizontalListSortingStrategy}>
<SortableContext {tabs.map((tab) => (
items={tabs} <SortableTab
strategy={horizontalListSortingStrategy} key={tab.id}
> tab={tab}
{tabs.map((tab) => ( active={active}
<SortableTab onChange={(id) => {
key={tab.id} setIsChangeTab(true);
tab={tab} onChange(id);
active={active} setActive(id);
onChange={(id) => { }}
setIsChangeTab(true); isStationSettings={isStationSettings}
onChange(id); />
setActive(id); ))}
}} </SortableContext>
isStationSettings={isStationSettings}
/>
))}
</SortableContext>
<Flex gap={"md"} ms={"xs"} align={"center"}> <Flex gap={"md"} ms={"xs"} align={"center"}>
{Number(active) ? ( {Number(active) ? (
<ActionIcon <ActionIcon
title="Edit Station" title="Edit Station"
variant="outline" variant="outline"
onClick={() => { onClick={() => {
setStationEdit( setStationEdit(tabsData.find((el) => el.id === Number(active)));
tabsData.find((el) => el.id === Number(active)) setIsOpenAddStation(true);
); setIsEditStation(true);
setIsOpenAddStation(true); }}>
setIsEditStation(true); <IconEdit />
}} </ActionIcon>
> ) : (
<IconEdit /> ""
</ActionIcon> )}
) : ( <ActionIcon
"" title="Add Station"
)} variant="outline"
<ActionIcon color="green"
title="Add Station" onClick={() => {
variant="outline" setIsOpenAddStation(true);
color="green" setIsEditStation(false);
onClick={() => { setStationEdit(undefined);
setIsOpenAddStation(true); }}>
setIsEditStation(false); <IconSettingsPlus />
setStationEdit(undefined); </ActionIcon>
}} </Flex>
> </Tabs.List>
<IconSettingsPlus /> <Flex align={"center"}>
</ActionIcon> <Button
</Flex> variant="outline"
</Tabs.List> style={{ height: "26px", width: "80px", marginRight: "20px" }}
<Flex align={"center"}> size="xs"
<Button onClick={() => {
variant="outline" setIsHistoryModalOpen(true);
style={{ height: "26px", width: "80px", marginRight: "20px" }} }}>
size="xs" History
onClick={() => { </Button>
setIsHistoryModalOpen(true); <Tooltip
}} withArrow
> label={usersConnecting.map((el) => (
History <Text key={el.userId || el.id}>{el.userName}</Text>
</Button> ))}>
<Tooltip <Avatar radius="xl" me={"sm"}>
withArrow <IconUsersGroup color="green" />
label={usersConnecting.map((el) => ( </Avatar>
<Text key={el.userId || el.id}>{el.userName}</Text> </Tooltip>
))} <Menu withArrow>
> <Menu.Target>
<Avatar radius="xl" me={"sm"}> <UnstyledButton
<IconUsersGroup color="green" /> style={{
</Avatar> padding: "var(--mantine-spacing-md)",
</Tooltip> color: "var(--mantine-color-text)",
<Menu withArrow> borderRadius: "var(--mantine-radius-sm)",
<Menu.Target> }}>
<UnstyledButton <Group>
style={{ <div style={{ flex: 1 }}>
padding: "var(--mantine-spacing-md)", <Text size="sm" fw={500}>
color: "var(--mantine-color-text)", {user?.userName || user?.user_name || ""}
borderRadius: "var(--mantine-radius-sm)", </Text>
}}
>
<Group>
<div style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{user?.userName || user?.user_name || ""}
</Text>
<Text c="dimmed" size="xs"> <Text c="dimmed" size="xs">
{user?.email} {user?.email}
</Text> </Text>
</div> </div>
<IconChevronRight size={16} /> <IconChevronRight size={16} />
</Group> </Group>
</UnstyledButton> </UnstyledButton>
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Item <Menu.Item
style={{ width: "150px" }} style={{ width: "150px" }}
onClick={() => { onClick={() => {
localStorage.removeItem("user"); localStorage.removeItem("user");
window.location.href = "/"; window.location.href = "/";
socket?.disconnect(); socket?.disconnect();
}} }}
color="red" color="red"
leftSection={<IconLogout size={16} stroke={1.5} />} leftSection={<IconLogout size={16} stroke={1.5} />}>
> Logout
Logout </Menu.Item>
</Menu.Item> </Menu.Dropdown>
</Menu.Dropdown> </Menu>
</Menu> </Flex>
</Flex> </Flex>
</Flex>
{panels} {panels}
</Tabs> </Tabs>
<ModalHistory <ModalHistory
opened={isHistoryModalOpen} opened={isHistoryModalOpen}
onClose={() => setIsHistoryModalOpen(false)} onClose={() => setIsHistoryModalOpen(false)}
socket={socket} socket={socket}
stationIds={tabs.map((el) => el.id)} stationIds={tabs.map((el) => el.id)}
tabs={tabs} tabs={tabs}
/> />
<ModalConfig <ModalConfig
opened={openConfig} opened={openConfig}
onClose={() => setOpenConfig(false)} onClose={() => setOpenConfig(false)}
onSave={() => { onSave={() => {
onChange(active); onChange(active);
}} }}
/> />
</DndContext>
); <DrawerScenario
scenarios={scenarios}
setScenarios={setScenarios}
externalOpened={openDrawerScenario}
onExternalClose={() => setOpenDrawerScenario(false)}
/>
</DndContext>
);
} }