add devices list table

This commit is contained in:
Do Manh Phuong 2025-08-14 12:16:32 +07:00
parent cf735f31bf
commit 00be81e18b
6 changed files with 403 additions and 10 deletions

32
package-lock.json generated
View File

@ -16,6 +16,7 @@
"@tanstack/react-query": "^5.83.0", "@tanstack/react-query": "^5.83.0",
"@tanstack/react-router": "^1.121.2", "@tanstack/react-router": "^1.121.2",
"@tanstack/react-router-devtools": "^1.121.2", "@tanstack/react-router-devtools": "^1.121.2",
"@tanstack/react-table": "^8.21.3",
"@tanstack/router-plugin": "^1.121.2", "@tanstack/router-plugin": "^1.121.2",
"axios": "^1.11.0", "axios": "^1.11.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@ -2496,6 +2497,25 @@
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
} }
}, },
"node_modules/@tanstack/react-table": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
"integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
"dependencies": {
"@tanstack/table-core": "8.21.3"
},
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/@tanstack/router-core": { "node_modules/@tanstack/router-core": {
"version": "1.129.8", "version": "1.129.8",
"resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.129.8.tgz", "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.129.8.tgz",
@ -2648,6 +2668,18 @@
"url": "https://github.com/sponsors/tannerlinsley" "url": "https://github.com/sponsors/tannerlinsley"
} }
}, },
"node_modules/@tanstack/table-core": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/virtual-file-routes": { "node_modules/@tanstack/virtual-file-routes": {
"version": "1.129.7", "version": "1.129.7",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-file-routes/-/virtual-file-routes-1.129.7.tgz", "resolved": "https://registry.npmjs.org/@tanstack/virtual-file-routes/-/virtual-file-routes-1.129.7.tgz",

View File

@ -20,6 +20,7 @@
"@tanstack/react-query": "^5.83.0", "@tanstack/react-query": "^5.83.0",
"@tanstack/react-router": "^1.121.2", "@tanstack/react-router": "^1.121.2",
"@tanstack/react-router-devtools": "^1.121.2", "@tanstack/react-router-devtools": "^1.121.2",
"@tanstack/react-table": "^8.21.3",
"@tanstack/router-plugin": "^1.121.2", "@tanstack/router-plugin": "^1.121.2",
"axios": "^1.11.0", "axios": "^1.11.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",

View File

@ -28,4 +28,8 @@ export const API_ENDPOINTS = {
GET_ROOM_LIST: `/DeviceComm/rooms`, GET_ROOM_LIST: `/DeviceComm/rooms`,
GET_DEVICE_FROM_ROOM: (roomName: string) => `/DeviceComm/room/${roomName}`, GET_DEVICE_FROM_ROOM: (roomName: string) => `/DeviceComm/room/${roomName}`,
}, },
SSE_EVENTS: {
DEVICE_ONLINE: `/Sse/events/onlineDevices`,
DEVICE_OFFLINE: `/Sse/events/offlineDevices`,
},
}; };

View File

@ -0,0 +1,55 @@
import { useEffect } from "react";
import { BASE_URL, API_ENDPOINTS } from "@/config/api";
interface DeviceEventData {
Message: string;
DeviceId: string;
Room: string;
Timestamp?: string;
}
interface UseDeviceEventsOptions {
onRoomDeviceOnline?: (room: string, deviceId: string) => void;
onRoomDeviceOffline?: (room: string, deviceId: string) => void;
onDeviceOnlineInRoom?: (deviceId: string, room: string) => void;
onDeviceOfflineInRoom?: (deviceId: string, room: string) => void;
}
export function useDeviceEvents(options: UseDeviceEventsOptions) {
useEffect(() => {
const onlineES = new EventSource(`${BASE_URL}${API_ENDPOINTS.SSE_EVENTS.DEVICE_ONLINE}`);
const offlineES = new EventSource(`${BASE_URL}${API_ENDPOINTS.SSE_EVENTS.DEVICE_OFFLINE}`);
onlineES.addEventListener("online", (event) => {
try {
const data: DeviceEventData = JSON.parse(event.data);
options.onRoomDeviceOnline?.(data.Room, data.DeviceId);
options.onDeviceOnlineInRoom?.(data.DeviceId, data.Room);
} catch (err) {
console.error("Error parsing online event:", err);
}
});
offlineES.addEventListener("offline", (event) => {
try {
const data: DeviceEventData = JSON.parse(event.data);
options.onRoomDeviceOffline?.(data.Room, data.DeviceId);
options.onDeviceOfflineInRoom?.(data.DeviceId, data.Room);
} catch (err) {
console.error("Error parsing offline event:", err);
}
});
onlineES.onerror = (err) => {
console.error("Online SSE connection error:", err);
};
offlineES.onerror = (err) => {
console.error("Offline SSE connection error:", err);
};
return () => {
onlineES.close();
offlineES.close();
};
}, [options]);
}

View File

@ -1,9 +1,158 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute, useParams } from "@tanstack/react-router";
import { useQueryData } from "@/hooks/useQueryData";
import { API_ENDPOINTS, BASE_URL } from "@/config/api";
import {
flexRender,
getCoreRowModel,
useReactTable,
type ColumnDef,
} from "@tanstack/react-table";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useQueryClient } from "@tanstack/react-query";
import { useDeviceEvents } from "@/hooks/useDeviceEvents";
export const Route = createFileRoute('/_authenticated/room/$roomName/')({ export const Route = createFileRoute("/_authenticated/room/$roomName/")({
component: RouteComponent, head: ({ params }) => ({
}) meta: [{ title: `Danh sách thiết bị phòng ${params.roomName}` }],
}),
component: RoomDetailComponent,
});
function RouteComponent() { function RoomDetailComponent() {
return <div>Hello "/_authenticated/room/$roomName/"!</div> const { roomName } = useParams({ from: "/_authenticated/room/$roomName/" });
const queryClient = useQueryClient();
const { data: devices = [], isLoading } = useQueryData({
queryKey: ["devices", roomName],
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.GET_DEVICE_FROM_ROOM(roomName),
});
// Lắng nghe SSE và update state
useDeviceEvents({
onDeviceOnlineInRoom: (deviceId, room) => {
if (room === roomName) {
queryClient.setQueryData(
["devices", roomName],
(oldDevices: any[] = []) =>
oldDevices.map((d) =>
d.macAddress === deviceId ? { ...d, isOffline: false } : d
)
);
}
},
onDeviceOfflineInRoom: (deviceId, room) => {
if (room === roomName) {
queryClient.setQueryData(
["devices", roomName],
(oldDevices: any[] = []) =>
oldDevices.map((d) =>
d.macAddress === deviceId ? { ...d, isOffline: true } : d
)
);
}
},
});
const columns: ColumnDef<any>[] = [
{
header: "STT",
cell: ({ row }) => row.index + 1,
},
{
header: "MAC Address",
accessorKey: "macAddress",
},
{
header: "Thời gian thiết bị",
accessorKey: "deviceTime",
cell: ({ getValue }) => {
const date = new Date(getValue() as string);
return date.toLocaleString();
},
},
{
header: "Phiên bản",
accessorKey: "version",
},
{
header: "Địa chỉ IP",
accessorKey: "ipAddress",
},
{
header: "Trạng thái",
accessorKey: "isOffline",
cell: ({ getValue }) =>
getValue() ? (
<span className="text-red-500 font-semibold">Offline</span>
) : (
<span className="text-green-500 font-semibold">Online</span>
),
},
];
const table = useReactTable({
data: devices,
columns,
getCoreRowModel: getCoreRowModel(),
});
if (isLoading) return <div>Đang tải thiết bị...</div>;
return (
<div className="w-full px-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Phòng: {roomName}</h1>
<p className="text-muted-foreground mt-2">
Danh sách thiết bị trong phòng
</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Thiết bị</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
} }

View File

@ -1,9 +1,161 @@
import { createFileRoute } from '@tanstack/react-router' import { API_ENDPOINTS, BASE_URL } from "@/config/api";
import { useQueryData } from "@/hooks/useQueryData";
import { useDeviceEvents } from "@/hooks/useDeviceEvents";
import { useQueryClient } from "@tanstack/react-query";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import {
flexRender,
getCoreRowModel,
getSortedRowModel,
useReactTable,
type ColumnDef,
type SortingState,
} from "@tanstack/react-table";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import React from "react";
export const Route = createFileRoute('/_authenticated/room/')({ export const Route = createFileRoute("/_authenticated/room/")({
head: () => ({
meta: [{ title: "Danh sách phòng" }],
}),
component: RoomComponent, component: RoomComponent,
}) });
function RoomComponent() { function RoomComponent() {
return <div>Hello "/_authenticated/room/"!</div> const queryClient = useQueryClient();
const navigate = useNavigate();
const { data: roomData = [], isLoading } = useQueryData({
queryKey: ["rooms"],
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST,
});
const [sorting, setSorting] = React.useState<SortingState>([]);
useDeviceEvents({
onRoomDeviceOnline: (room) => {
queryClient.setQueryData(["rooms"], (oldRooms: any[] = []) =>
oldRooms.map((r) =>
r.name === room
? { ...r, offlineCount: Math.max((r.offlineCount || 0) - 1, 0) }
: r
)
);
},
onRoomDeviceOffline: (room) => {
queryClient.setQueryData(["rooms"], (oldRooms: any[] = []) =>
oldRooms.map((r) =>
r.name === room
? { ...r, offlineCount: (r.offlineCount || 0) + 1 }
: r
)
);
},
});
const columns: ColumnDef<any>[] = [
{
header: "STT",
cell: ({ row }) => row.index + 1,
},
{
header: "Tên phòng",
accessorKey: "name",
},
{
header: "Số lượng thiết bị",
accessorKey: "numberOfDevices",
},
{
header: "Thiết bị offline",
accessorKey: "offlineCount",
cell: ({ getValue }) => getValue() || 0,
},
];
const table = useReactTable({
data: roomData,
columns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
});
if (isLoading) return <div>Đang tải...</div>;
return (
<div className="w-full px-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Quản phòng</h1>
<p className="text-muted-foreground mt-2">
Danh sách các phòng hiện trong hệ thống
</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Danh sách phòng</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const isSorted = header.column.getIsSorted();
return (
<TableHead
key={header.id}
className="cursor-pointer select-none"
onClick={header.column.getToggleSortingHandler()}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
{isSorted ? (isSorted === "asc" ? " ▲" : " ▼") : ""}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
className="cursor-pointer hover:bg-gray-100"
onClick={() =>
navigate({
to: "/room/$roomName",
params: { roomName: row.original.name },
})
}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
} }