Compare commits

..

No commits in common. "main" and "feature_update_button" have entirely different histories.

152 changed files with 2669 additions and 14727 deletions

View File

@ -20,6 +20,6 @@ COPY --from=development /app/dist /usr/share/nginx/html
COPY nginx/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80 443
EXPOSE 80
ENTRYPOINT [ "nginx", "-g", "daemon off;" ]

View File

@ -1,74 +0,0 @@
# User API
Tai lieu mo ta cac endpoint cap nhat role va thong tin nguoi dung.
----------------------------------------
## 1) Cap nhat thong tin nguoi dung
- PUT /api/User/{id}
- Permission: EDIT_USER_ROLE
### Request
```json
{
"name": "Nguyen Van A",
"userName": "nguyenvana",
"accessRooms": [1, 2, 3]
}
```
### Response (200)
```json
{
"success": true,
"message": "User updated successfully",
"data": {
"userId": 12,
"userName": "nguyenvana",
"name": "Nguyen Van A",
"roleId": 3,
"accessRooms": [1, 2, 3],
"updatedAt": "2026-04-03T10:20:30Z",
"updatedBy": "admin"
}
}
```
### Ghi chu
- Neu khong truyen `accessRooms` thi giu nguyen danh sach phong.
- Neu truyen `accessRooms` = [] thi xoa tat ca phong.
- Neu `userName` bi trung hoac khong hop le thi tra ve 400.
----------------------------------------
## 2) Cap nhat role nguoi dung
- PUT /api/User/{id}/role
- Permission: EDIT_USER_ROLE
### Request
```json
{
"roleId": 2
}
```
### Response (200)
```json
{
"success": true,
"message": "User role updated",
"data": {
"userId": 12,
"userName": "nguyenvana",
"roleId": 2,
"roleName": "Manager",
"updatedAt": "2026-04-03T10:20:30Z",
"updatedBy": "admin"
}
}
```
### Ghi chu
- Chi System Admin moi duoc phep cap nhat role System Admin.
----------------------------------------

View File

@ -3,13 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Be+Vietnam+Pro:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<link rel="icon" href="/computer-956.svg" />
<link rel="icon" href="/public/computer-956.svg" />
<meta name="theme-color" content="#000000" />
<meta
name="description"

View File

@ -1,38 +1,15 @@
# upstream backend {
# server 100.66.170.15:8080;
# server 127.0.0.1:8080;
# server 172.18.10.8:8080;
# }
upstream backend {
server 100.66.170.15:8080;
server 127.0.0.1:5218;
server 172.18.10.8:8080;
}
server {
listen 80;
server_name comp.soict.io;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
# root /usr/share/nginx/html;
# index index.html index.htm;
return 301 https://$host$request_uri;
}
}
server{
listen 443 ssl;
server_name comp.soict.io;
ssl_certificate /etc/letsencrypt/live/comp.soict.io/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/comp.soict.io/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
set $backend_server 172.18.10.8:8080;
root /usr/share/nginx/html;
# Default file to serve for directory requests
index index.html index.htm;
location / {
# Try to serve the requested file directly ($uri)
# If it's a directory, try serving the index file ($uri/)
@ -48,7 +25,7 @@ server{
}
location /api/ {
proxy_pass http://$backend_server;
proxy_pass http://backend/;
# Cho phép upload file lớn (vd: 200MB)
client_max_body_size 200M;
@ -61,17 +38,17 @@ server{
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
# CORS headers - Comment vi da xu ly o backend C#
# add_header 'Access-Control-Allow-Origin' '*' always;
# add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
# add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;
# CORS headers
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;
if ($request_method = OPTIONS) {
return 204;
}
}
location /api/Sse/events {
proxy_pass http://$backend_server/api/Sse/events;
proxy_pass http://backend/api/Sse/events;
proxy_http_version 1.1;
# cần thiết cho SSE
@ -80,14 +57,4 @@ server{
proxy_cache off;
proxy_read_timeout 1h;
}
location /mesh-proxy/ {
proxy_pass https://202.191.59.59/;
proxy_cookie_path / "/; HTTPOnly; Secure; SameSite=None";
# Cấu hình WebSocket cho commander.ashx
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
}

2072
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -22,7 +22,6 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tooltip": "^1.2.7",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/react-form": "^1.23.0",
@ -31,17 +30,13 @@
"@tanstack/react-router-devtools": "^1.121.2",
"@tanstack/react-table": "^8.21.3",
"@tanstack/router-plugin": "^1.121.2",
"@tanstack/zod-form-adapter": "^0.42.1",
"axios": "^1.11.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.525.0",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"recharts": "^3.8.1",
"shadcn": "^2.9.3",
"sidebar": "^1.0.0",
"sonner": "^2.0.7",
@ -56,7 +51,6 @@
"@types/node": "^24.1.0",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-basic-ssl": "^2.3.0",
"@vitejs/plugin-react": "^4.3.4",
"jsdom": "^26.0.0",
"tw-animate-css": "^1.3.6",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

38
src/App.css Normal file
View File

@ -0,0 +1,38 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

0
src/App.tsx Normal file
View File

View File

@ -12,7 +12,7 @@ import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
import { useForm, formOptions } from "@tanstack/react-form";
import axios from "@/config/axios";
import axios from "axios";
interface AddBlacklistDialogProps {
onAdded?: () => void; // callback để refresh danh sách sau khi thêm

View File

@ -1,37 +0,0 @@
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from "./ui/breadcrumb";
import { Link, useMatches } from "@tanstack/react-router";
export default function AppBreadCrumb() {
const matches = useMatches();
const crumbs = matches
.filter((m) => Boolean(m.context.breadcrumbs))
.map((m) => m.context.breadcrumbs)
.filter(Boolean);
const displayCrumbs = crumbs[0] as { path: string; title: string }[];
if (displayCrumbs == null || displayCrumbs.length == 0) return;
return (
<Breadcrumb className="flex-1">
<BreadcrumbList>
{displayCrumbs.slice(0, -1).map((b, index) => (
<>
<BreadcrumbItem key={index} className="md:block">
<Link to={b.path}>{b.title}</Link>
</BreadcrumbItem>
<BreadcrumbSeparator className="hidden md:block" />
</>
))}
<BreadcrumbItem className="md:block">
<BreadcrumbPage>{displayCrumbs[displayCrumbs.length - 1].title}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
);
}

View File

@ -0,0 +1,99 @@
import type React from "react";
import { Link } from "@tanstack/react-router";
import { Building2, Cpu } from "lucide-react";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { cn } from "@/lib/utils";
type MenuItem = {
title: string;
to: string;
icon: React.ElementType;
onPointerEnter?: () => void;
};
type AppSidebarProps = {
items: MenuItem[];
};
export function AppSidebar({ items }: AppSidebarProps) {
return (
<Sidebar
collapsible="icon"
className="border-r border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
>
<SidebarHeader className="border-b border-border/40 p-6">
<div className="flex items-center gap-3">
<div className="flex aspect-square size-10 items-center justify-center rounded-xl bg-gradient-to-br from-gray-900 to-black text-white shadow-lg ring-1 ring-gray-800/30">
<Building2 className="size-5" />
</div>
<div className="flex flex-col gap-0.5 leading-none">
<span className="font-bold text-base tracking-tight bg-gradient-to-r from-foreground to-foreground/80 bg-clip-text">
TTMT Computer Management
</span>
<span className="text-xs text-muted-foreground font-medium flex items-center gap-1">
<Cpu className="size-3" />
v1.0.0
</span>
</div>
</div>
</SidebarHeader>
<SidebarContent className="p-4">
<SidebarGroup>
<SidebarGroupLabel className="text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider mb-2">
Navigation
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu className="space-y-1">
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
asChild
tooltip={item.title}
onPointerEnter={item.onPointerEnter}
className={cn(
"w-full justify-start gap-3 px-4 py-3 rounded-xl",
"hover:bg-accent/60 hover:text-accent-foreground hover:shadow-sm",
"transition-all duration-200 ease-in-out",
"group relative overflow-hidden",
"data-[active=true]:bg-primary data-[active=true]:text-primary-foreground",
"data-[active=true]:shadow-md data-[active=true]:ring-1 data-[active=true]:ring-primary/20"
)}
>
<Link
href={item.to}
to={"."}
className="flex items-center gap-3 w-full"
>
<item.icon className="size-5 shrink-0 transition-all duration-200 group-hover:scale-110 group-hover:text-primary" />
<span className="font-medium text-sm truncate">
{item.title}
</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="border-t border-border/40 p-4 space-y-3">
<div className="px-2 text-xs text-muted-foreground/60 font-medium">
© 2025 NAVIS Centre
</div>
</SidebarFooter>
</Sidebar>
);
}

View File

@ -1,93 +0,0 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { LogOut, Settings, User, Key } from "lucide-react";
interface AvatarDropdownProps {
username: string;
role: {
roleName: string;
priority: number;
};
onLogOut: () => void;
onSettings?: () => void;
onProfile?: () => void;
onChangePassword?: () => void;
}
export default function AvatarDropdown({
username,
role,
onLogOut,
onSettings,
onProfile,
onChangePassword,
}: AvatarDropdownProps) {
// Get initials from username
const getInitials = (name: string): string => {
if (!name) return "U";
const parts = name.split(" ");
if (parts.length >= 2) {
return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
}
return name.substring(0, 2).toUpperCase();
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-2 rounded-full focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2">
<Avatar className="h-9 w-9 cursor-pointer">
<AvatarImage src="" alt={username} />
<AvatarFallback className="bg-primary text-primary-foreground text-sm font-medium">
{getInitials(username)}
</AvatarFallback>
</Avatar>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">{username}</p>
<p className="text-xs leading-none text-muted-foreground">
{role.roleName || "Người dùng"}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{onProfile && (
<DropdownMenuItem onClick={onProfile} className="cursor-pointer">
<User className="mr-2 h-4 w-4" />
<span>Thông tin nhân</span>
</DropdownMenuItem>
)}
{onChangePassword && (
<DropdownMenuItem onClick={onChangePassword} className="cursor-pointer">
<Key className="mr-2 h-4 w-4" />
<span>Đi mật khẩu</span>
</DropdownMenuItem>
)}
{onSettings && (
<DropdownMenuItem onClick={onSettings} className="cursor-pointer">
<Settings className="mr-2 h-4 w-4" />
<span>Cài đt</span>
</DropdownMenuItem>
)}
{(onProfile || onChangePassword || onSettings) && <DropdownMenuSeparator />}
<DropdownMenuItem
onClick={onLogOut}
className="cursor-pointer text-destructive focus:text-destructive"
>
<LogOut className="mr-2 h-4 w-4" />
<span>Đăng xuất</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -1,354 +0,0 @@
import { useState, useMemo } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Building2,
Monitor,
ChevronDown,
ChevronRight,
Loader2,
} from "lucide-react";
import type { Room } from "@/types/room";
import type { DeviceHealthCheck } from "@/types/device";
interface DeviceSearchDialogProps {
open: boolean;
onClose: () => void;
rooms: Room[];
onSelect: (deviceIds: string[]) => void | Promise<void>;
fetchDevices: (roomName: string) => Promise<DeviceHealthCheck[]>; // API fetch
}
export function DeviceSearchDialog({
open,
onClose,
rooms,
onSelect,
fetchDevices,
}: DeviceSearchDialogProps) {
const [selected, setSelected] = useState<string[]>([]);
const [expandedRoom, setExpandedRoom] = useState<string | null>(null);
const [roomDevices, setRoomDevices] = useState<
Record<string, DeviceHealthCheck[]>
>({});
const [loadingRoom, setLoadingRoom] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const sortedRooms = useMemo(() => {
return [...rooms].sort((a, b) => {
const nameA = typeof a.name === "string" ? a.name : "";
const nameB = typeof b.name === "string" ? b.name : "";
return nameA.localeCompare(nameB);
});
}, [rooms]);
const filteredRooms = useMemo(() => {
if (!searchQuery) return sortedRooms;
return sortedRooms.filter((room) =>
room.name.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [sortedRooms, searchQuery]);
const handleRoomClick = async (roomName: string) => {
// Nếu đang mở thì đóng lại
if (expandedRoom === roomName) {
setExpandedRoom(null);
return;
}
// Nếu chưa fetch devices của room này thì gọi API
if (!roomDevices[roomName]) {
setLoadingRoom(roomName);
try {
const devices = await fetchDevices(roomName);
setRoomDevices((prev) => ({ ...prev, [roomName]: devices }));
setExpandedRoom(roomName);
} catch (error) {
console.error("Failed to fetch devices:", error);
// Có thể thêm toast notification ở đây
} finally {
setLoadingRoom(null);
}
} else {
// Đã có data rồi thì chỉ toggle
setExpandedRoom(roomName);
}
};
const toggleDevice = (deviceId: string) => {
setSelected((prev) =>
prev.includes(deviceId)
? prev.filter((id) => id !== deviceId)
: [...prev, deviceId]
);
};
const toggleAllInRoom = (roomName: string) => {
const devices = roomDevices[roomName] || [];
const deviceIds = devices.map((d) => d.id);
const allSelected = deviceIds.every((id) => selected.includes(id));
if (allSelected) {
setSelected((prev) => prev.filter((id) => !deviceIds.includes(id)));
} else {
setSelected((prev) => [...new Set([...prev, ...deviceIds])]);
}
};
const handleConfirm = async () => {
try {
await onSelect(selected);
} catch (e) {
console.error("Error on select:", e);
} finally {
setSelected([]);
setExpandedRoom(null);
setRoomDevices({});
setSearchQuery("");
onClose();
}
};
const handleClose = () => {
setSelected([]);
setExpandedRoom(null);
setRoomDevices({});
setSearchQuery("");
onClose();
};
const parseDeviceId = (id: string) => {
const match = /^P(.+?)M(\d+)$/i.exec(id.trim());
if (!match) return null;
return {
room: match[1].trim(),
index: Number(match[2]),
};
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-none w-[95vw] max-h-[90vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Monitor className="w-6 h-6 text-primary" />
Chọn thiết bị
</DialogTitle>
</DialogHeader>
{/* Search bar */}
<Input
placeholder="Tìm kiếm phòng..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="my-2"
/>
{/* Room list */}
<ScrollArea className="max-h-[500px] rounded-lg border p-2">
<div className="space-y-1">
{filteredRooms.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">
Không tìm thấy phòng
</p>
)}
{filteredRooms.map((room) => {
const isExpanded = expandedRoom === room.name;
const isLoading = loadingRoom === room.name;
const devices = roomDevices[room.name] || [];
const sortedDevices = [...devices].sort((a, b) => {
const aId = String(a.id);
const bId = String(b.id);
const parsedA = parseDeviceId(aId);
const parsedB = parseDeviceId(bId);
if (parsedA && parsedB) {
const roomCompare = parsedA.room.localeCompare(parsedB.room, undefined, {
numeric: true,
sensitivity: "base",
});
if (roomCompare !== 0) return roomCompare;
return parsedA.index - parsedB.index;
}
return aId.localeCompare(bId, undefined, {
numeric: true,
sensitivity: "base",
});
});
const allSelected =
sortedDevices.length > 0 &&
sortedDevices.every((d) => selected.includes(d.id));
const someSelected = sortedDevices.some((d) => selected.includes(d.id));
const selectedCount = sortedDevices.filter((d) =>
selected.includes(d.id)
).length;
return (
<div
key={room.name}
className="border rounded-lg overflow-hidden"
>
{/* Room header - clickable */}
<div
className="flex items-center gap-1 px-2 py-1.5 hover:bg-muted/50 cursor-pointer"
onClick={() => handleRoomClick(room.name)}
>
{/* Expand icon or loading */}
{isLoading ? (
<Loader2 className="w-4 h-4 text-muted-foreground flex-shrink-0 animate-spin" />
) : isExpanded ? (
<ChevronDown className="w-4 h-4 text-muted-foreground flex-shrink-0" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)}
{/* Select all checkbox - chỉ hiện khi đã load devices */}
{devices.length > 0 && (
<Checkbox
checked={allSelected}
onCheckedChange={() => {
toggleAllInRoom(room.name);
}}
onClick={(e) => e.stopPropagation()}
className={
someSelected && !allSelected ? "opacity-50" : ""
}
/>
)}
<Building2 className="w-4 h-4 text-primary flex-shrink-0" />
<span className="font-semibold flex-1 text-sm">
{room.name}
</span>
<div className="flex items-center gap-1 text-xs text-muted-foreground flex-shrink-0">
{selectedCount > 0 && (
<span className="text-primary font-medium">
{selectedCount}/
</span>
)}
<span>{room.numberOfDevices}</span>
{room.numberOfOfflineDevices > 0 && (
<span className="text-xs px-1.5 py-0.5 rounded-full bg-red-100 text-red-700">
{room.numberOfOfflineDevices}
</span>
)}
</div>
</div>
{/* Device table - collapsible */}
{isExpanded && sortedDevices.length > 0 && (
<div className="border-t bg-muted/20 overflow-x-auto">
<table className="w-full text-xs">
<thead className="bg-muted/50 border-b sticky top-0">
<tr>
<th className="w-8 px-1 py-1"></th>
<th className="text-left px-1 py-1 font-medium min-w-20 text-xs">
Thiết bị
</th>
<th className="text-left px-1 py-1 font-medium min-w-24 text-xs">
IP
</th>
<th className="text-left px-1 py-1 font-medium min-w-28 text-xs">
MAC
</th>
<th className="text-left px-1 py-1 font-medium min-w-12 text-xs">
Ver
</th>
<th className="text-left px-1 py-1 font-medium min-w-16 text-xs">
Trạng thái
</th>
</tr>
</thead>
<tbody>
{sortedDevices.map((device) => (
<tr
key={device.id}
className="border-b last:border-b-0 hover:bg-muted/50"
>
<td className="px-1 py-1">
<Checkbox
checked={selected.includes(device.id)}
onCheckedChange={() =>
toggleDevice(device.id)
}
/>
</td>
<td className="px-1 py-1">
<div className="flex items-center gap-0.5">
<Monitor className="w-3 h-3 text-muted-foreground flex-shrink-0" />
<span className="font-mono text-xs truncate">
{device.id}
</span>
</div>
</td>
<td className="px-1 py-1 font-mono text-xs truncate">
{device.networkInfos[0]?.ipAddress || "-"}
</td>
<td className="px-1 py-1 font-mono text-xs truncate">
{device.networkInfos[0]?.macAddress || "-"}
</td>
<td className="px-1 py-1 text-xs whitespace-nowrap">
{device.version ? `v${device.version}` : "-"}
</td>
<td className="px-1 py-1 text-xs">
{device.isOffline ? (
<span className="text-xs px-1 py-0.5 rounded-full bg-red-100 text-red-700 font-medium whitespace-nowrap inline-block">
Offline
</span>
) : (
<span className="text-xs px-1 py-0.5 rounded-full bg-green-100 text-green-700 font-medium whitespace-nowrap inline-block">
Online
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
})}
</div>
</ScrollArea>
{/* Selected count */}
{selected.length > 0 && (
<div className="text-xs text-muted-foreground bg-muted/50 px-2 py-1.5 rounded">
Đã chọn:{" "}
<span className="font-semibold text-foreground">
{selected.length}
</span>
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={handleClose} size="sm">
Hủy
</Button>
<Button
onClick={handleConfirm}
disabled={selected.length === 0}
size="sm"
>
Xác nhận ({selected.length})
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -1,290 +0,0 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useGetSensitiveCommands, useExecuteSensitiveCommand } from "@/hooks/queries/useCommandQueries";
import { CommandType } from "@/types/command-registry";
import {
Power,
PowerOff,
XCircle,
ShieldBan,
ChevronDown,
Loader2,
AlertTriangle
} from "lucide-react";
import { toast } from "sonner";
interface CommandActionButtonsProps {
roomName: string;
selectedDevices?: string[]; // Các thiết bị đã chọn
}
const COMMAND_TYPE_CONFIG = {
[CommandType.RESTART]: {
label: "Khởi động lại",
icon: Power,
color: "text-blue-600",
bgColor: "bg-blue-50 hover:bg-blue-100",
},
[CommandType.SHUTDOWN]: {
label: "Tắt máy",
icon: PowerOff,
color: "text-red-600",
bgColor: "bg-red-50 hover:bg-red-100",
},
[CommandType.TASKKILL]: {
label: "Kết thúc tác vụ",
icon: XCircle,
color: "text-orange-600",
bgColor: "bg-orange-50 hover:bg-orange-100",
},
[CommandType.BLOCK]: {
label: "Chặn",
icon: ShieldBan,
color: "text-purple-600",
bgColor: "bg-purple-50 hover:bg-purple-100",
},
[CommandType.RESET]: {
label : "Reset",
icon: Loader2,
color: "text-green-600",
bgColor: "bg-green-50 hover:bg-green-100",
}
};
export function CommandActionButtons({ roomName, selectedDevices = [] }: CommandActionButtonsProps) {
const [confirmDialog, setConfirmDialog] = useState<{
open: boolean;
command: any;
commandType: CommandType;
isSensitive?: boolean;
}>({
open: false,
command: null,
commandType: CommandType.RESTART,
});
const [isExecuting, setIsExecuting] = useState(false);
const [sensitivePassword, setSensitivePassword] = useState("");
// Query commands for each type
const { data: sensitiveCommands = [] } = useGetSensitiveCommands();
// Send command mutation (sensitive)
const executeSensitiveMutation = useExecuteSensitiveCommand();
// Build commands mapped by CommandType using the `command` field from sensitive data
const commandsByType: Record<number, any[]> = (Object.values(CommandType) as Array<number | string>)
.filter((v) => typeof v === "number")
.reduce((acc: Record<number, any[]>, type) => {
acc[type as number] = (sensitiveCommands || []).filter((c: any) => Number(c.command) === Number(type));
return acc;
}, {} as Record<number, any[]>);
const handleCommandClick = (command: any, commandType: CommandType) => {
// When building from sensitiveCommands, all items here are sensitive
setConfirmDialog({
open: true,
command,
commandType,
isSensitive: true,
});
};
const handleConfirmExecute = async () => {
setIsExecuting(true);
try {
// All rendered commands are sourced from sensitiveCommands — send via sensitive mutation
await executeSensitiveMutation.mutateAsync({
roomName,
command: confirmDialog.command.commandName,
password: sensitivePassword,
});
toast.success(`Đã gửi lệnh: ${confirmDialog.command.commandName}`);
setConfirmDialog({ open: false, command: null, commandType: CommandType.RESTART, isSensitive: false });
setSensitivePassword("");
// Reload page để tránh freeze
setTimeout(() => {
window.location.reload();
}, 500);
} catch (error) {
console.error("Execute command error:", error);
toast.error("Lỗi khi gửi lệnh!");
setIsExecuting(false);
}
};
const handleCloseDialog = () => {
if (!isExecuting) {
setConfirmDialog({ open: false, command: null, commandType: CommandType.RESTART, isSensitive: false });
setSensitivePassword("");
// Reload để tránh freeze
setTimeout(() => {
window.location.reload();
}, 300);
}
};
const renderCommandButton = (commandType: CommandType) => {
const config = COMMAND_TYPE_CONFIG[commandType];
const commands = commandsByType[commandType];
const Icon = config.icon;
if (!commands || commands.length === 0) {
return (
<Button
key={commandType}
variant="outline"
disabled
size="sm"
className="gap-2 flex-shrink-0"
>
<Icon className={`h-4 w-4 ${config.color}`} />
{config.label}
<span className="text-xs text-muted-foreground ml-1">(0)</span>
</Button>
);
}
return (
<DropdownMenu key={commandType}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={`gap-2 ${config.bgColor} border-${config.color.split('-')[1]}-200 flex-shrink-0`}
>
<Icon className={`h-4 w-4 ${config.color}`} />
{config.label}
<span className="text-xs text-muted-foreground ml-1">({commands.length})</span>
<ChevronDown className="h-3 w-3 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
side="bottom"
sideOffset={4}
alignOffset={0}
className="w-64"
avoidCollisions={true}
>
{commands.map((command: any) => (
<DropdownMenuItem
key={command.id}
onClick={() => handleCommandClick(command, commandType)}
className="cursor-pointer"
>
<div className="flex flex-col gap-1">
<span className="font-medium">{command.commandName}</span>
{command.description && (
<span className="text-xs text-muted-foreground">
{command.description}
</span>
)}
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
};
return (
<>
<div className="flex gap-2 flex-nowrap overflow-x-auto items-center whitespace-nowrap">
{Object.values(CommandType)
.filter((value) => typeof value === "number")
.map((commandType) => renderCommandButton(commandType as CommandType))}
</div>
{/* Confirm Dialog */}
<Dialog open={confirmDialog.open} onOpenChange={handleCloseDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-orange-600" />
Xác nhận thực thi lệnh
</DialogTitle>
<DialogDescription className="text-left space-y-3">
<p>
Bạn chắc chắn muốn thực thi lệnh <strong>{confirmDialog.command?.commandName}</strong>?
</p>
{confirmDialog.command?.description && (
<p className="text-sm text-muted-foreground">
{confirmDialog.command.description}
</p>
)}
{confirmDialog.isSensitive && (
<div className="mt-2">
<label className="block text-sm font-medium mb-1">Mật khẩu</label>
<input
type="password"
value={sensitivePassword}
onChange={(e) => setSensitivePassword(e.target.value)}
className="w-full px-2 py-1 rounded border"
placeholder="Nhập mật khẩu để xác nhận"
/>
</div>
)}
<div className="bg-muted p-3 rounded-md space-y-1">
<p className="text-sm">
<span className="font-medium">Phòng:</span> {roomName}
</p>
<p className="text-sm">
<span className="font-medium">Loại lệnh:</span>{" "}
{COMMAND_TYPE_CONFIG[confirmDialog.commandType]?.label}
</p>
{selectedDevices.length > 0 && (
<p className="text-sm">
<span className="font-medium">Thiết bị đã chọn:</span>{" "}
{selectedDevices.length} thiết bị
</p>
)}
</div>
<p className="text-sm text-orange-600 font-medium">
Lệnh sẽ đưc thực thi ngay lập tức không thể hoàn tác.
</p>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={handleCloseDialog}
disabled={isExecuting}
>
Hủy
</Button>
<Button
onClick={handleConfirmExecute}
disabled={isExecuting || (confirmDialog.isSensitive && !sensitivePassword)}
className="gap-2"
>
{isExecuting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Đang thực thi...
</>
) : (
"Xác nhận"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@ -1,97 +0,0 @@
import { Button } from "@/components/ui/button";
import { Trash2 } from "lucide-react";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
interface DeleteButtonProps {
onClick: () => void | Promise<void>;
loading?: boolean;
disabled?: boolean;
label?: string;
title?: string;
description?: string;
}
export function DeleteButton({
onClick,
loading = false,
disabled = false,
label = "Xóa khỏi server",
title = "Xóa khỏi server",
description = "Bạn có chắc chắn muốn xóa các phần mềm này khỏi server không? Hành động này không thể hoàn tác.",
}: DeleteButtonProps) {
const [open, setOpen] = useState(false);
const [isConfirming, setIsConfirming] = useState(false);
const handleConfirm = async () => {
setIsConfirming(true);
try {
await onClick();
} finally {
setIsConfirming(false);
setOpen(false);
}
};
return (
<>
<Button
variant="destructive"
onClick={() => setOpen(true)}
disabled={loading || disabled}
className="gap-2 px-4"
>
{loading || isConfirming ? (
<span className="animate-spin"></span>
) : (
<Trash2 className="h-4 w-4" />
)}
{loading || isConfirming ? "Đang xóa..." : label}
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="text-lg">{title}</DialogTitle>
<DialogDescription className="text-base">{description}</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={isConfirming}
className="flex-1"
>
Hủy
</Button>
<Button
variant="destructive"
onClick={handleConfirm}
disabled={isConfirming || loading}
className="flex-1 gap-2"
>
{isConfirming ? (
<>
<span className="animate-spin"></span>
Đang xóa...
</>
) : (
<>
<Trash2 className="h-4 w-4" />
{label}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@ -1,98 +0,0 @@
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
import type { DeviceOverviewResponse } from "@/types/dashboard";
import { ResponsiveContainer, PieChart, Pie, Cell, Tooltip, Legend } from "recharts";
export function DeviceOverviewCard({
data,
isLoading = false,
}: {
data?: DeviceOverviewResponse | null;
isLoading?: boolean;
}) {
const pieData = [
{ name: "Online", value: data?.onlineDevices ?? 0 },
{ name: "Offline", value: data?.offlineDevices ?? 0 },
];
const COLORS = ["#22c55e", "#ef4444"];
return (
<Card className="bg-white/80 backdrop-blur border-muted/60 shadow-sm">
<CardHeader>
<CardTitle>Tổng quan thiết bị</CardTitle>
<CardDescription>Trạng thái chung các thiết bị offline gần đây</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-3">
<div className="p-3 rounded-lg bg-muted/30 border border-muted/40">
<div className="text-xs text-muted-foreground">Tổng thiết bị</div>
<div className="text-2xl font-bold">{data?.totalDevices ?? 0}</div>
</div>
<div className="p-3 rounded-lg bg-muted/30 border border-muted/40">
<div className="text-xs text-muted-foreground">Agent chưa đưc cập nhật</div>
<div className="text-2xl font-bold">{data?.devicesWithOutdatedVersion ?? 0}</div>
</div>
<div className="p-3 rounded-lg bg-muted/30 border border-muted/40">
<div className="text-xs text-muted-foreground">Online</div>
<div className="text-2xl font-bold">{data?.onlineDevices ?? 0}</div>
</div>
<div className="p-3 rounded-lg bg-muted/30 border border-muted/40">
<div className="text-xs text-muted-foreground">Offline</div>
<div className="text-2xl font-bold">{data?.offlineDevices ?? 0}</div>
</div>
</div>
<div className="mt-4 grid grid-cols-1 lg:grid-cols-2 gap-4">
<div>
<div className="text-sm font-medium mb-2">Tỉ lệ Online / Offline</div>
<div className="h-40">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={pieData}
dataKey="value"
nameKey="name"
innerRadius={30}
outerRadius={60}
label
>
{pieData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
</div>
<div>
<div className="text-sm font-medium">Thiết bị offline gần đây</div>
<div className="mt-2 max-h-40 overflow-auto divide-y divide-muted/40">
{data?.recentOfflineDevices && data.recentOfflineDevices.length > 0 ? (
data.recentOfflineDevices.map((d) => (
<div key={d.deviceId} className="flex items-center justify-between py-2">
<div>
<div className="font-medium">{d.deviceId}</div>
<div className="text-xs text-muted-foreground">{d.room ?? "-"}</div>
</div>
<div className="text-xs text-muted-foreground">
{d.lastSeen ? new Date(d.lastSeen).toLocaleString() : "-"}
</div>
</div>
))
) : (
<div className="text-sm text-muted-foreground">Không thiết bị offline gần đây</div>
)}
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -1,74 +0,0 @@
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import type { RoomManagementResponse, RoomHealthStatus } from "@/types/dashboard";
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip } from "recharts";
function statusBadge(status?: string) {
if (!status) return <Badge>Unknown</Badge>;
if (status === "InSession") return <Badge className="bg-green-100 text-green-700">Đang sử dụng</Badge>;
if (status === "NotInUse") return <Badge className="bg-red-100 text-red-700">Không sử dụng</Badge>;
return <Badge className="bg-yellow-100 text-yellow-700"> thể lớp học</Badge>;
}
export function RoomManagementCard({
data,
isLoading = false,
}: {
data?: RoomManagementResponse | null;
isLoading?: boolean;
}) {
const chartData = (data?.rooms ?? []).map((r) => ({ room: r.roomName, health: r.healthPercentage ?? 0 }));
return (
<Card className="bg-white/80 backdrop-blur border-muted/60 shadow-sm">
<CardHeader>
<CardTitle>Quản phòng</CardTitle>
<CardDescription>Thông tin tổng quan các phòng đang không sử dụng</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div>
<div className="text-xs text-muted-foreground">Tổng phòng</div>
<div className="text-2xl font-bold">{data?.totalRooms ?? 0}</div>
</div>
</div>
<div className="mt-4">
<div className="text-sm font-medium mb-2">Tỉ lệ thiết bị online</div>
<div className="h-48">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} layout="vertical">
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" domain={[0, 100]} />
<YAxis dataKey="room" type="category" width={110} />
<Tooltip />
<Bar dataKey="health" fill="#0ea5e9" radius={[4, 4, 4, 4]} />
</BarChart>
</ResponsiveContainer>
</div>
<div className="mt-4">
<div className="text-sm font-medium">Phòng không dùng</div>
<div className="mt-2 space-y-2">
{data?.roomsNeedAttention && data.roomsNeedAttention.length > 0 ? (
data.roomsNeedAttention.map((r: RoomHealthStatus) => (
<div key={r.roomName} className="flex items-center justify-between">
<div>
<div className="font-medium">{r.roomName}</div>
<div className="text-xs text-muted-foreground">{r.totalDevices} thiết bị</div>
</div>
<div className="flex items-center gap-3">
<div className="text-sm font-medium">{r.healthPercentage?.toFixed(1) ?? "-"}%</div>
{statusBadge(r.healthStatus)}
</div>
</div>
))
) : (
<div className="text-sm text-muted-foreground">Không phòng cần chú ý</div>
)}
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -1,88 +0,0 @@
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import type { SoftwareDistributionResponse } from "@/types/dashboard";
import { ResponsiveContainer, PieChart, Pie, Cell, Tooltip, Legend } from "recharts";
export function SoftwareDistributionCard({
data,
isLoading = false,
}: {
data?: SoftwareDistributionResponse | null;
isLoading?: boolean;
}) {
void isLoading;
const distData = [
{ name: "Success", value: data?.successfulInstallations ?? 0 },
{ name: "Failed", value: data?.failedInstallations ?? 0 },
{ name: "Pending", value: data?.pendingInstallations ?? 0 },
];
const COLORS = ["#10b981", "#ef4444", "#f59e0b"];
return (
<Card className="bg-white/80 backdrop-blur border-muted/60 shadow-sm">
<CardHeader>
<CardTitle>Phân phối phần mềm</CardTitle>
<CardDescription>Thống cài đt lỗi phổ biến</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="space-y-3">
<div>
<div className="text-xs text-muted-foreground">Tổng log</div>
<div className="text-2xl font-bold">{data?.totalInstallations ?? 0}</div>
</div>
<div className="grid grid-cols-3 gap-2">
<div className="p-3 rounded-lg bg-muted/30 border border-muted/40 text-center">
<div className="text-xs text-muted-foreground">Thành công</div>
<div className="text-2xl font-bold">{data?.successfulInstallations ?? 0}</div>
</div>
<div className="p-3 rounded-lg bg-muted/30 border border-muted/40 text-center">
<div className="text-xs text-muted-foreground">Thất bại</div>
<div className="text-2xl font-bold">{data?.failedInstallations ?? 0}</div>
</div>
<div className="p-3 rounded-lg bg-muted/30 border border-muted/40 text-center">
<div className="text-xs text-muted-foreground">Đang chờ</div>
<div className="text-2xl font-bold">{data?.pendingInstallations ?? 0}</div>
</div>
</div>
<div className="mt-4">
<div className="text-sm font-medium">Top lỗi</div>
<div className="mt-2 space-y-2">
{data?.topFailedSoftware && data.topFailedSoftware.length > 0 ? (
data.topFailedSoftware.map((t) => (
<div key={t.fileName} className="flex items-center justify-between">
<div className="truncate max-w-[180px]">{t.fileName}</div>
<Badge className="bg-red-100 text-red-700">{t.failCount}</Badge>
</div>
))
) : (
<div className="text-sm text-muted-foreground">Không lỗi phổ biến</div>
)}
</div>
</div>
</div>
<div>
<div className="text-sm font-medium mb-2">Tỉ lệ trạng thái cài đt</div>
<div className="h-40">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={distData} dataKey="value" nameKey="name" innerRadius={30} outerRadius={60} label>
{distData.map((_, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -1,23 +0,0 @@
import type { Version } from "@/types/file";
import type { ColumnDef } from "@tanstack/react-table";
export const agentColumns: ColumnDef<Version>[] = [
{ accessorKey: "version", header: "Phiên bản" },
{ accessorKey: "fileName", header: "Tên file" },
{
accessorKey: "updatedAt",
header: "Thời gian cập nhật",
cell: ({ getValue }) =>
getValue()
? new Date(getValue() as string).toLocaleString("vi-VN")
: "N/A",
},
{
accessorKey: "requestUpdateAt",
header: "Thời gian yêu cầu cập nhật",
cell: ({ getValue }) =>
getValue()
? new Date(getValue() as string).toLocaleString("vi-VN")
: "N/A",
},
];

View File

@ -1,70 +0,0 @@
// components/columns/apps-column.tsx
import type { Version } from "@/types/file";
import type { ColumnDef } from "@tanstack/react-table";
import { Check, X } from "lucide-react";
// Không gọi hook ở đây — nhận isPending từ ngoài truyền vào
export function createAppsColumns(isPending: boolean): ColumnDef<Version>[] {
return [
{ accessorKey: "version", header: "Phiên bản" },
{ accessorKey: "fileName", header: "Tên file" },
{
accessorKey: "updatedAt",
header: () => (
<div className="whitespace-normal max-w-xs">Thời gian cập nhật</div>
),
cell: ({ getValue }) =>
getValue()
? new Date(getValue() as string).toLocaleString("vi-VN")
: "N/A",
},
{
accessorKey: "requestUpdateAt",
header: () => (
<div className="whitespace-normal max-w-xs">
Thời gian yêu cầu cài đt/tải xuống
</div>
),
cell: ({ getValue }) =>
getValue()
? new Date(getValue() as string).toLocaleString("vi-VN")
: "N/A",
},
{
id: "required",
header: () => (
<div className="whitespace-normal max-w-xs">Đã thêm vào danh sách</div>
),
cell: ({ row }) => {
const isRequired = row.original.isRequired;
return isRequired ? (
<div className="flex items-center gap-1">
<Check className="h-4 w-4 text-green-600" />
<span className="text-sm text-green-600"></span>
</div>
) : (
<div className="flex items-center gap-1">
<X className="h-4 w-4 text-gray-400" />
<span className="text-sm text-gray-400">Không</span>
</div>
);
},
enableSorting: false,
enableHiding: false,
},
{
id: "select",
header: () => <div className="whitespace-normal max-w-xs">Chọn</div>,
cell: ({ row }) => (
<input
type="checkbox"
checked={row.getIsSelected?.() ?? false}
onChange={row.getToggleSelectedHandler?.()}
disabled={isPending} // ← nhận từ tham số, không gọi hook
/>
),
enableSorting: false,
enableHiding: false,
},
];
}

View File

@ -1,98 +0,0 @@
import { type ColumnDef } from "@tanstack/react-table";
import { Badge } from "@/components/ui/badge";
import type { Audits } from "@/types/audit";
export const auditColumns: ColumnDef<Audits>[] = [
{
header: "Thời gian",
accessorKey: "dateTime",
cell: ({ getValue }) => {
const v = getValue() as string;
const d = v ? new Date(v) : null;
return d ? (
<div className="text-sm whitespace-nowrap">
<div className="font-medium">{d.toLocaleDateString("vi-VN")}</div>
<div className="text-muted-foreground text-xs">
{d.toLocaleTimeString("vi-VN")}
</div>
</div>
) : (
<span className="text-muted-foreground"></span>
);
},
},
{
header: "User",
accessorKey: "username",
cell: ({ getValue }) => (
<span className="font-medium text-sm whitespace-nowrap">
{getValue() as string}
</span>
),
},
{
header: "Loại",
accessorKey: "apiCall",
cell: ({ getValue }) => {
const v = (getValue() as string) ?? "";
if (!v) return <span className="text-muted-foreground"></span>;
return (
<code className="text-xs bg-muted px-1.5 py-0.5 rounded whitespace-nowrap">
{v}
</code>
);
},
},
{
header: "Hành động",
accessorKey: "action",
cell: ({ getValue }) => (
<code className="text-xs bg-muted px-1.5 py-0.5 rounded whitespace-nowrap">
{getValue() as string}
</code>
),
},
{
header: "URL",
accessorKey: "url",
cell: ({ getValue }) => (
<code className="text-xs text-muted-foreground max-w-[180px] truncate block">
{(getValue() as string) ?? "—"}
</code>
),
},
{
header: "Kết quả",
accessorKey: "isSuccess",
cell: ({ getValue }) => {
const v = getValue();
if (v == null) return <span className="text-muted-foreground"></span>;
return v ? (
<Badge variant="outline" className="text-green-600 border-green-600 whitespace-nowrap">
Thành công
</Badge>
) : (
<Badge variant="outline" className="text-red-600 border-red-600 whitespace-nowrap">
Thất bại
</Badge>
);
},
},
{
header: "Nội dung request",
accessorKey: "requestPayload",
cell: ({ getValue }) => {
const v = getValue() as string;
if (!v) return <span className="text-muted-foreground"></span>;
let preview = v;
try {
preview = JSON.stringify(JSON.parse(v));
} catch {}
return (
<span className="text-xs text-muted-foreground max-w-[200px] truncate block">
{preview}
</span>
);
},
},
];

View File

@ -0,0 +1,68 @@
"use client";
import { useForm } from "@tanstack/react-form";
import { z } from "zod";
import { Textarea } from "@/components/ui/textarea";
interface ShellCommandFormProps {
command: string;
onCommandChange: (value: string) => void;
disabled?: boolean;
}
export function ShellCommandForm({
command,
onCommandChange,
disabled,
}: ShellCommandFormProps) {
const form = useForm({
defaultValues: { command },
onSubmit: () => {},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
className="space-y-5"
>
<form.Field
name="command"
validators={{
onChange: ({ value }: { value: string }) => {
const schema = z
.string()
.min(1, "Nhập command để thực thi")
.max(500, "Command quá dài");
const result = schema.safeParse(value);
if (!result.success) {
return result.error.issues.map((i) => i.message);
}
return [];
},
}}
children={(field) => (
<div className="w-full px-0">
<Textarea
className="w-full h-[25vh]"
placeholder="Nhập lệnh..."
value={field.state.value}
onChange={(e) => {
field.handleChange(e.target.value);
onCommandChange(e.target.value);
}}
disabled={disabled}
/>
{field.state.meta.errors?.length > 0 && (
<p className="text-sm text-red-500">
{String(field.state.meta.errors[0])}
</p>
)}
</div>
)}
/>
</form>
);
}

View File

@ -1,21 +1,14 @@
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Badge } from "@/components/ui/badge";
import { Monitor, Wifi, WifiOff, Loader2 } from "lucide-react";
import { useState } from "react";
import { Monitor, Wifi, WifiOff } from "lucide-react";
import { cn } from "@/lib/utils";
import { FolderStatusPopover } from "../folder-status-popover";
import { useGetClientFolderStatusForDevice } from "@/hooks/queries";
import type { ClientFolderStatus } from "@/types/folder";
export function ComputerCard({
device,
position,
folderStatus,
isCheckingFolder,
}: {
device: any | undefined;
position: number;
folderStatus?: ClientFolderStatus;
isCheckingFolder?: boolean;
}) {
if (!device) {
return (
@ -31,63 +24,6 @@ export function ComputerCard({
const isOffline = device.isOffline;
const firstNetworkInfo = device.networkInfos?.[0];
const agentVersion = device.version;
function DeviceFolderCheck() {
const deviceId = device.id;
const room = device.room;
const [checking, setChecking] = useState(false);
const { data: status, isLoading } = useGetClientFolderStatusForDevice(
deviceId,
room,
checking
);
const handleCheck = () => setChecking((s) => !s);
return (
<div>
<button
onClick={handleCheck}
className="inline-flex items-center gap-2 px-3 py-1 rounded border bg-background text-sm"
>
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
Kiểm tra thư mục Setup
</button>
{checking && isLoading && (
<div className="text-xs text-muted-foreground mt-2">Đang kiểm tra...</div>
)}
{checking && !isLoading && status && (
<div className="text-xs mt-2">
<div className="font-medium">Các file trong thư mục Setup({status.currentFiles?.length ?? 0})</div>
<div className="mt-1 max-h-36 overflow-auto space-y-1">
{(status.currentFiles ?? []).length === 0 ? (
<div className="text-muted-foreground">Không file hiện tại</div>
) : (
(status.currentFiles ?? []).map((f: any) => (
<div key={f.fileName} className="font-mono text-xs">
<div className="truncate">{f.fileName}</div>
{f.lastModified && (
<div className="text-muted-foreground text-[10px]">
{new Date(f.lastModified).toLocaleString()}
</div>
)}
</div>
))
)}
</div>
</div>
)}
{checking && !isLoading && !status && (
<div className="text-xs text-muted-foreground mt-2">Không dữ liệu</div>
)}
</div>
);
}
const DeviceInfo = () => (
<div className="space-y-3 min-w-[280px]">
@ -127,11 +63,6 @@ export function ComputerCard({
</div>
)}
<div>
<div className="text-xs text-muted-foreground mb-1">Kiểm tra thư mục</div>
<DeviceFolderCheck />
</div>
<div>
<div className="text-xs text-muted-foreground mb-1">Trạng thái</div>
<Badge
@ -167,29 +98,18 @@ export function ComputerCard({
{position}
</div>
{/* Folder Status Icon */}
{device && !isOffline && (
<div className="absolute -top-2 -right-2">
<FolderStatusPopover
deviceId={device.networkInfos?.[0]?.macAddress || device.id}
status={folderStatus}
isLoading={isCheckingFolder}
/>
</div>
)}
<Monitor className={cn("h-6 w-6 mb-1", isOffline ? "text-red-600" : "text-green-600")} />
{firstNetworkInfo?.ipAddress && (
<div className="text-[10px] font-mono text-center mb-1 px-1 truncate w-full">
{firstNetworkInfo.ipAddress}
{agentVersion && (
<div className="text-[10px] font-mono text-center mb-1 px-1 truncate w-full">
v{agentVersion}
</div>
)}
</div>
)}
<div className="flex items-center gap-1">
{isOffline ? (
<WifiOff className="h-3 w-3 text-red-600" />
) : (
<Wifi className="h-3 w-3 text-green-600" />
)}
<span
className={cn(
"text-xs font-medium",

View File

@ -1,17 +1,8 @@
import { Monitor, DoorOpen } from "lucide-react";
import { ComputerCard } from "../cards/computer-card";
import { useMachineNumber } from "../../hooks/useMachineNumber";
import type { ClientFolderStatus } from "@/types/folder";
import { ComputerCard } from "./computer-card";
import { useMachineNumber } from "../hooks/useMachineNumber";
export function DeviceGrid({
devices,
folderStatuses,
isCheckingFolder,
}: {
devices: any[];
folderStatuses?: Map<string, ClientFolderStatus>;
isCheckingFolder?: boolean;
}) {
export function DeviceGrid({ devices }: { devices: any[] }) {
const getMachineNumber = useMachineNumber();
const deviceMap = new Map<number, any>();
@ -23,27 +14,18 @@ export function DeviceGrid({
const totalRows = 5;
const renderRow = (rowIndex: number) => {
// Đảo ngược: 21-40 sang trái, 1-20 sang phải
const leftStart = 21 + (totalRows - 1 - rowIndex) * 4;
const rightStart = (totalRows - 1 - rowIndex) * 4 + 1;
// Trái: 120
const leftStart = rowIndex * 4 + 1;
// Phải: 2140
const rightStart = 21 + rowIndex * 4;
return (
<div key={rowIndex} className="flex items-center justify-center gap-3">
{/* Bên trái (2140) */}
{/* Bên trái (120) */}
{Array.from({ length: 4 }).map((_, i) => {
const pos = leftStart + (3 - i);
const device = deviceMap.get(pos);
const macAddress = device?.networkInfos?.[0]?.macAddress || device?.id;
const folderStatus = folderStatuses?.get(macAddress);
const pos = leftStart + i;
return (
<ComputerCard
key={pos}
device={device}
position={pos}
folderStatus={folderStatus}
isCheckingFolder={isCheckingFolder}
/>
<ComputerCard key={pos} device={deviceMap.get(pos)} position={pos} />
);
})}
@ -52,21 +34,11 @@ export function DeviceGrid({
<div className="h-px w-full bg-border border-t-2 border-dashed" />
</div>
{/* Bên phải (120) */}
{/* Bên phải (2140) */}
{Array.from({ length: 4 }).map((_, i) => {
const pos = rightStart + (3 - i);
const device = deviceMap.get(pos);
const macAddress = device?.networkInfos?.[0]?.macAddress || device?.id;
const folderStatus = folderStatuses?.get(macAddress);
const pos = rightStart + i;
return (
<ComputerCard
key={pos}
device={device}
position={pos}
folderStatus={folderStatus}
isCheckingFolder={isCheckingFolder}
/>
<ComputerCard key={pos} device={deviceMap.get(pos)} position={pos} />
);
})}
</div>
@ -75,18 +47,19 @@ export function DeviceGrid({
return (
<div className="px-0.5 py-8 space-y-6">
<div className="space-y-4">
{Array.from({ length: totalRows }).map((_, i) => renderRow(i))}
</div>
<div className="flex items-center justify-between px-4 pb-6 border-b-2 border-dashed">
<div className="flex items-center gap-3 px-6 py-4 bg-muted rounded-lg border-2 border-border">
<DoorOpen className="h-6 w-6 text-muted-foreground" />
<span className="font-semibold text-lg">Cửa Ra Vào</span>
</div>
<div className="flex items-center gap-3 px-6 py-4 bg-primary/10 rounded-lg border-2 border-primary/20">
<Monitor className="h-6 w-6 text-primary" />
<span className="font-semibold text-lg">Bàn Giảng Viên</span>
</div>
<div className="flex items-center gap-3 px-6 py-4 bg-muted rounded-lg border-2 border-border">
<DoorOpen className="h-6 w-6 text-muted-foreground" />
<span className="font-semibold text-lg">Cửa Ra Vào</span>
</div>
</div>
<div className="space-y-4">
{Array.from({ length: totalRows }).map((_, i) => renderRow(i))}
</div>
</div>
);

View File

@ -17,21 +17,16 @@ import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Wifi, WifiOff, Clock, MapPin, Monitor, ChevronLeft, ChevronRight } from "lucide-react";
import { useMachineNumber } from "@/hooks/useMachineNumber";
import { FolderStatusPopover } from "../folder-status-popover";
import { useMachineNumber } from "../hooks/useMachineNumber";
interface DeviceTableProps {
devices: any[];
isCheckingFolder?: boolean;
}
/**
* Component hiển thị danh sách thiết bị dạng bảng
*/
export function DeviceTable({
devices,
isCheckingFolder,
}: DeviceTableProps) {
export function DeviceTable({ devices }: DeviceTableProps) {
const getMachineNumber = useMachineNumber();
const columns: ColumnDef<any>[] = [
@ -142,25 +137,6 @@ export function DeviceTable({
);
},
},
{
header: "Thư mục Setup",
cell: ({ row }) => {
const device = row.original;
const isOffline = device.isOffline;
const macAddress = device.networkInfos?.[0]?.macAddress || device.id;
if (isOffline) {
return <span className="text-muted-foreground text-sm">-</span>;
}
return (
<FolderStatusPopover
deviceId={macAddress}
isLoading={isCheckingFolder}
/>
);
},
},
];
const table = useReactTable({

View File

@ -1,180 +0,0 @@
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import type { Audits } from "@/types/audit";
function JsonDisplay({ value }: { value: string | null | undefined }) {
if (!value) return <span className="text-muted-foreground"></span>;
try {
return (
<pre className="text-xs bg-muted/60 p-2.5 rounded-md overflow-auto whitespace-pre-wrap break-all leading-relaxed max-h-48 font-mono">
{JSON.stringify(JSON.parse(value), null, 2)}
</pre>
);
} catch {
return <span className="text-xs break-all font-mono">{value}</span>;
}
}
interface AuditDetailDialogProps {
audit: Audits | null;
open: boolean;
onClose: () => void;
}
export function AuditDetailDialog({
audit,
open,
onClose,
}: AuditDetailDialogProps) {
if (!audit) return null;
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-2xl w-full max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
Chi tiết audit
<span className="text-muted-foreground font-normal text-sm">
#{audit.id}
</span>
</DialogTitle>
</DialogHeader>
<Separator />
<div className="grid grid-cols-2 gap-x-6 gap-y-3 pt-1">
<div className="space-y-0.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Thời gian
</p>
<p className="text-sm font-medium">
{audit.dateTime
? new Date(audit.dateTime).toLocaleString("vi-VN")
: "—"}
</p>
</div>
<div className="space-y-0.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
User
</p>
<p className="text-sm font-medium">{audit.username}</p>
</div>
<div className="space-y-0.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
API Call
</p>
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">
{audit.apiCall ?? "—"}
</code>
</div>
<div className="space-y-0.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Kết quả
</p>
<div>
{audit.isSuccess == null ? (
<span className="text-muted-foreground text-sm"></span>
) : audit.isSuccess ? (
<Badge
variant="outline"
className="text-green-600 border-green-600"
>
Thành công
</Badge>
) : (
<Badge
variant="outline"
className="text-red-600 border-red-600"
>
Thất bại
</Badge>
)}
</div>
</div>
<div className="space-y-0.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Hành đng
</p>
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">
{audit.action}
</code>
</div>
<div className="space-y-0.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
URL
</p>
<code className="text-xs text-muted-foreground break-all">
{audit.url ?? "—"}
</code>
</div>
<div className="space-y-0.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Bảng
</p>
<p className="text-sm">{audit.tableName ?? "—"}</p>
</div>
<div className="space-y-0.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Entity ID
</p>
<p className="text-sm">{audit.entityId ?? "—"}</p>
</div>
<div className="col-span-2 space-y-0.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Lỗi
</p>
<p className="text-sm text-red-600">{audit.errorMessage ?? "—"}</p>
</div>
</div>
<Separator />
<div className="space-y-4">
<div className="space-y-1.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Nội dung request
</p>
<JsonDisplay value={audit.requestPayload} />
</div>
<div className="space-y-1.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Giá trị
</p>
<JsonDisplay value={audit.oldValues} />
</div>
<div className="space-y-1.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Giá trị mới
</p>
<JsonDisplay value={audit.newValues} />
</div>
<div className="space-y-1.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Kết quả
</p>
<p className="text-sm">{audit.isSuccess == null ? "—" : audit.isSuccess ? "Thành công" : "Thất bại"}</p>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -1,30 +0,0 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { type ReactNode, useState } from "react";
interface FormDialogProps {
triggerLabel: string;
title: string;
children: (closeDialog: () => void) => ReactNode;
}
export function FormDialog({ triggerLabel, title, children }: FormDialogProps) {
const [isOpen, setIsOpen] = useState(false);
const closeDialog = () => setIsOpen(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button>{triggerLabel}</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
{children(closeDialog)}
</DialogContent>
</Dialog>
);
}

View File

@ -1,106 +0,0 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { useEffect, useMemo, useState } from "react";
export interface SelectItem {
label: string;
value: string;
}
interface SelectDialogProps {
open: boolean;
onClose: () => void;
title: string;
description?: string;
icon?: React.ReactNode;
items: SelectItem[];
selectedValues?: string[];
onConfirm: (values: string[]) => Promise<void> | void;
}
export function SelectDialog({
open,
onClose,
title,
description,
icon,
items,
selectedValues,
onConfirm,
}: SelectDialogProps) {
const [selected, setSelected] = useState<string[]>([]);
const [search, setSearch] = useState("");
useEffect(() => {
if (!open) return;
if (!selectedValues) return;
setSelected(selectedValues);
}, [open, selectedValues]);
const filteredItems = useMemo(() => {
return items.filter((item) =>
item.label.toLowerCase().includes(search.toLowerCase())
);
}, [items, search]);
const toggleItem = (value: string) => {
setSelected((prev) =>
prev.includes(value) ? prev.filter((v) => v !== value) : [...prev, value]
);
};
const handleConfirm = async () => {
await onConfirm(selected);
setSelected([]);
setSearch("");
onClose();
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{icon}
{title}
</DialogTitle>
{description && <p className="text-sm text-muted-foreground">{description}</p>}
</DialogHeader>
<Input
placeholder="Tìm kiếm..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="my-2"
/>
<div className="max-h-64 overflow-y-auto space-y-2 mt-2 border rounded p-2">
{filteredItems.map((item) => (
<div key={item.value} className="flex items-center gap-2">
<Checkbox
checked={selected.includes(item.value)}
onCheckedChange={() => toggleItem(item.value)}
/>
<span>{item.label}</span>
</div>
))}
{filteredItems.length === 0 && (
<p className="text-sm text-muted-foreground text-center">Không kết quả</p>
)}
</div>
<div className="flex justify-end gap-2 mt-4">
<Button variant="outline" onClick={onClose}>
Hủy
</Button>
<Button onClick={handleConfirm} disabled={selected.length === 0}>
Xác nhận
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -1,73 +0,0 @@
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
interface AuditFilterBarProps {
username: string | null;
action: string | null;
from: string | null;
to: string | null;
isLoading: boolean;
isFetching: boolean;
onUsernameChange: (v: string | null) => void;
onActionChange: (v: string | null) => void;
onFromChange: (v: string | null) => void;
onToChange: (v: string | null) => void;
onSearch: () => void;
onReset: () => void;
}
export function AuditFilterBar({
username,
action,
from,
to,
isLoading,
isFetching,
onUsernameChange,
onActionChange,
onFromChange,
onToChange,
onSearch,
onReset,
}: AuditFilterBarProps) {
return (
<div className="flex gap-2 mb-4 flex-wrap items-end">
<Input
className="w-36"
placeholder="Username"
value={username ?? ""}
onChange={(e) => onUsernameChange(e.target.value || null)}
/>
<Input
className="w-44"
placeholder="Hành động..."
value={action ?? ""}
onChange={(e) => onActionChange(e.target.value || null)}
/>
<Input
className="w-36"
type="date"
value={from ?? ""}
onChange={(e) => onFromChange(e.target.value || null)}
/>
<Input
className="w-36"
type="date"
value={to ?? ""}
onChange={(e) => onToChange(e.target.value || null)}
/>
<div className="flex gap-2">
<Button onClick={onSearch} disabled={isFetching || isLoading} size="sm">
Tìm
</Button>
<Button variant="outline" onClick={onReset} size="sm">
Reset
</Button>
</div>
</div>
);
}

View File

@ -1,147 +0,0 @@
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { CheckCircle2, AlertCircle, Loader2, AlertTriangle } from "lucide-react";
import type { ClientFolderStatus } from "@/types/folder";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Badge } from "@/components/ui/badge";
interface FolderStatusPopoverProps {
deviceId: string;
status?: ClientFolderStatus;
isLoading?: boolean;
}
export function FolderStatusPopover({
deviceId,
status,
isLoading,
}: FolderStatusPopoverProps) {
const missing = status?.missingFiles ?? [];
const extra = status?.extraFiles ?? [];
const hasMissing = missing.length > 0;
const hasExtra = extra.length > 0;
const hasIssues = hasMissing || hasExtra;
// Xác định màu sắc và icon dựa trên trạng thái
let statusColor = "text-green-500";
let statusIcon = (
<CheckCircle2 className={`h-5 w-5 ${statusColor}`} />
);
if (isLoading) {
statusColor = "text-blue-500";
statusIcon = <Loader2 className={`h-5 w-5 animate-spin ${statusColor}`} />;
} else if (hasMissing && hasExtra) {
// Vừa thiếu vừa thừa -> Đỏ + Alert
statusColor = "text-red-600";
statusIcon = <AlertTriangle className={`h-5 w-5 ${statusColor}`} />;
} else if (hasMissing) {
// Chỉ thiếu -> Đỏ
statusColor = "text-red-500";
statusIcon = <AlertCircle className={`h-5 w-5 ${statusColor}`} />;
} else if (hasExtra) {
// Chỉ thừa -> Cam
statusColor = "text-orange-500";
statusIcon = <AlertCircle className={`h-5 w-5 ${statusColor}`} />;
}
return (
<Popover>
<PopoverTrigger asChild>
<button className="p-2 hover:bg-muted rounded-md transition-colors">
{statusIcon}
</button>
</PopoverTrigger>
<PopoverContent className="w-96 p-4" side="right">
<div className="space-y-4">
<div className="flex items-center gap-2">
<div className="text-sm font-semibold">Thư mục Setup: {deviceId}</div>
{hasIssues && (
<Badge variant="destructive" className="text-xs">
{hasMissing && hasExtra
? "Không đồng bộ"
: hasMissing
? "Thiếu file"
: "Thừa file"}
</Badge>
)}
</div>
{isLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Đang kiểm tra...
</div>
) : !status ? (
<div className="text-sm text-muted-foreground">
Chưa dữ liệu
</div>
) : (
<div className="space-y-3">
{/* File thiếu */}
{hasMissing && (
<div className="border-l-4 border-red-500 pl-3">
<h4 className="text-sm font-semibold text-red-600 mb-2 flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
File thiếu ({missing.length})
</h4>
<ScrollArea className="h-32 rounded-md border bg-red-50/30 p-2">
<div className="space-y-2">
{missing.map((file, idx) => (
<div
key={idx}
className="text-xs bg-white rounded p-2 border border-red-200"
>
<div className="font-mono font-semibold text-red-700">
{file.fileName}
</div>
<div className="text-xs text-muted-foreground mt-1">
{file.folderPath}
</div>
</div>
))}
</div>
</ScrollArea>
</div>
)}
{/* File thừa */}
{hasExtra && (
<div className="border-l-4 border-orange-500 pl-3">
<h4 className="text-sm font-semibold text-orange-600 mb-2 flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
File thừa ({extra.length})
</h4>
<ScrollArea className="h-32 rounded-md border bg-orange-50/30 p-2">
<div className="space-y-2">
{extra.map((file, idx) => (
<div
key={idx}
className="text-xs bg-white rounded p-2 border border-orange-200"
>
<div className="font-mono font-semibold text-orange-700">
{file.fileName}
</div>
<div className="text-xs text-muted-foreground mt-1">
{file.folderPath}
</div>
</div>
))}
</div>
</ScrollArea>
</div>
)}
{/* Trạng thái OK */}
{!hasIssues && (
<div className="flex items-center gap-2 text-sm text-green-600 bg-green-50/30 rounded p-3 border border-green-200">
<CheckCircle2 className="h-4 w-4 flex-shrink-0" />
<span className="font-medium">Thư mục đt yêu cầu</span>
</div>
)}
</div>
)}
</div>
</PopoverContent>
</Popover>
);
}

View File

@ -1,67 +0,0 @@
import { FormBuilder, FormField } from "@/components/forms/dynamic-submit-form";
import { type BlacklistFormData } from "@/types/black-list";
import { toast } from "sonner";
interface BlacklistFormProps {
onSubmit: (data: BlacklistFormData) => Promise<void>;
closeDialog: () => void;
initialData?: Partial<BlacklistFormData>;
}
export function BlacklistForm({
onSubmit,
closeDialog,
initialData,
}: BlacklistFormProps) {
return (
<FormBuilder<BlacklistFormData>
defaultValues={{
appName: initialData?.appName || "",
processName: initialData?.processName || "",
}}
onSubmit={async (values: BlacklistFormData) => {
if (!values.appName.trim()) {
toast.error("Vui lòng nhập tên ứng dụng");
return;
}
if (!values.processName.trim()) {
toast.error("Vui lòng nhập tên tiến trình");
return;
}
try {
await onSubmit(values);
toast.success("Thêm phần mềm bị chặn thành công!");
closeDialog();
} catch (error) {
console.error("Error:", error);
toast.error("Có lỗi xảy ra!");
}
}}
submitLabel="Thêm"
cancelLabel="Hủy"
onCancel={closeDialog}
showCancel={true}
>
{(form: any) => (
<>
<FormField<BlacklistFormData, "appName">
form={form}
name="appName"
label="Tên ứng dụng"
placeholder="VD: Google Chrome"
required
/>
<FormField<BlacklistFormData, "processName">
form={form}
name="processName"
label="Tên tiến trình"
placeholder="VD: chrome.exe"
required
/>
</>
)}
</FormBuilder>
);
}

View File

@ -1,164 +0,0 @@
"use client";
import { useForm } from "@tanstack/react-form";
import { z } from "zod";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Info } from "lucide-react";
import { useState } from "react";
export interface ShellCommandData {
command: string;
qos: 0 | 1 | 2;
isRetained: boolean;
}
interface ShellCommandFormProps {
command: string;
onCommandChange: (value: string) => void;
qos?: 0 | 1 | 2;
onQoSChange?: (value: 0 | 1 | 2) => void;
isRetained?: boolean;
onIsRetainedChange?: (value: boolean) => void;
disabled?: boolean;
}
const QoSDescriptions = {
0: {
name: "At Most Once (Fire and Forget)",
description:
"Gửi lệnh một lần mà không đảm bảo. Nhanh nhất, tiêu tốn ít tài nguyên.",
},
1: {
name: "At Least Once",
description:
"Đảm bảo lệnh sẽ được nhận ít nhất một lần. Cân bằng giữa tốc độ và độ tin cậy.",
},
2: {
name: "Exactly Once",
description:
"Đảm bảo lệnh được nhận chính xác một lần. Chậm nhất nhưng đáng tin cậy nhất.",
},
};
export function ShellCommandForm({
command,
onCommandChange,
qos = 0,
onQoSChange,
isRetained = false,
onIsRetainedChange,
disabled,
}: ShellCommandFormProps) {
const [selectedQoS, setSelectedQoS] = useState<0 | 1 | 2>(qos);
const form = useForm({
defaultValues: { command },
onSubmit: () => {},
});
const handleQoSChange = (value: string) => {
const newQoS = Number(value) as 0 | 1 | 2;
setSelectedQoS(newQoS);
onQoSChange?.(newQoS);
};
const handleRetainedChange = (checked: boolean) => {
onIsRetainedChange?.(checked);
};
return (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
className="space-y-5"
>
{/* Command Input */}
<form.Field
name="command"
validators={{
onChange: ({ value }: { value: string }) => {
const schema = z
.string()
.min(1, "Nhập command để thực thi")
.max(500, "Command quá dài");
const result = schema.safeParse(value);
if (!result.success) {
return result.error.issues.map((i) => i.message);
}
return [];
},
}}
children={(field) => (
<div className="w-full space-y-2">
<Label>Nội Dung Lệnh *</Label>
<Textarea
className="w-full h-[20vh] font-mono"
placeholder="VD: shutdown /s /t 60 /c 'Máy sẽ tắt trong 60 giây'"
value={field.state.value}
onChange={(e) => {
field.handleChange(e.target.value);
onCommandChange(e.target.value);
}}
disabled={disabled}
/>
{field.state.meta.errors?.length > 0 && (
<p className="text-sm text-red-500">
{String(field.state.meta.errors[0])}
</p>
)}
</div>
)}
/>
{/* QoS Selection */}
<div className="space-y-2">
<Label>QoS (Quality of Service) *</Label>
<select
value={selectedQoS}
onChange={(e) => handleQoSChange(e.target.value)}
disabled={disabled}
className="w-full h-10 rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="0">QoS 0 - At Most Once (Tốc đ cao)</option>
<option value="1">QoS 1 - At Least Once (Cân bằng)</option>
<option value="2">QoS 2 - Exactly Once (Đ tin cậy cao)</option>
</select>
{/* QoS Description */}
<Alert className="border-l-4 border-l-blue-500 bg-blue-50 mt-2">
<Info className="h-4 w-4 text-blue-600" />
<AlertDescription className="text-sm text-blue-800 mt-1">
<div className="font-semibold">
{QoSDescriptions[selectedQoS].name}
</div>
<div className="mt-1">{QoSDescriptions[selectedQoS].description}</div>
</AlertDescription>
</Alert>
</div>
{/* Retained Checkbox */}
<div className="flex items-center gap-3 rounded-lg border p-4">
<Checkbox
id="retained"
checked={isRetained}
onCheckedChange={handleRetainedChange}
disabled={disabled}
/>
<div className="flex-1">
<Label htmlFor="retained" className="text-base cursor-pointer">
Lưu giữ lệnh (Retained)
</Label>
<p className="text-sm text-muted-foreground mt-1">
Broker MQTT sẽ lưu lệnh này gửi cho client mới khi kết nối. Hữu ích
cho các lệnh cấu hình cần duy trì trạng thái.
</p>
</div>
</div>
</form>
);
}

View File

@ -1,416 +0,0 @@
import { useForm } from "@tanstack/react-form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Info } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { z } from "zod";
import { CommandType } from "@/types/command-registry";
interface CommandRegistryFormProps {
onSubmit: (data: CommandRegistryFormData) => Promise<void>;
closeDialog?: () => void;
initialData?: Partial<CommandRegistryFormData>;
title?: string;
}
export interface CommandRegistryFormData {
commandName: string;
commandType: CommandType;
description?: string;
commandContent: string;
qos: 0 | 1 | 2;
isRetained: boolean;
}
// Zod validation schema
const commandRegistrySchema = z.object({
commandName: z
.string()
.min(1, "Tên lệnh không được để trống")
.min(3, "Tên lệnh phải có ít nhất 3 ký tự")
.max(100, "Tên lệnh tối đa 100 ký tự")
.trim(),
commandType: z.nativeEnum(CommandType, {
errorMap: () => ({ message: "Loại lệnh không hợp lệ" }),
}),
description: z.string().max(500, "Mô tả tối đa 500 ký tự").optional(),
commandContent: z
.string()
.min(1, "Nội dung lệnh không được để trống")
.min(5, "Nội dung lệnh phải có ít nhất 5 ký tự")
.trim(),
qos: z.union([z.literal(0), z.literal(1), z.literal(2)]),
isRetained: z.boolean(),
});
const QoSLevels = [
{
level: 0,
name: "At Most Once (Fire and Forget)",
description:
"Gửi lệnh một lần mà không đảm bảo. Nếu broker hoặc client bị ngắt kết nối, lệnh có thể bị mất. Tốc độ nhanh nhất, tiêu tốn ít tài nguyên.",
useCase: "Các lệnh không quan trọng, có thể mất mà không ảnh hưởng",
},
{
level: 1,
name: "At Least Once",
description:
"Đảm bảo lệnh sẽ được nhận ít nhất một lần. Có thể gửi lại nếu chưa nhận được ACK. Lệnh có thể được nhận nhiều lần.",
useCase: "Hầu hết các lệnh bình thường cần đảm bảo gửi thành công",
},
{
level: 2,
name: "Exactly Once",
description:
"Đảm bảo lệnh được nhận chính xác một lần. Sử dụng bắt tay 4 chiều để đảm bảo độ tin cậy cao nhất. Tốc độ chậm hơn, tiêu tốn nhiều tài nguyên.",
useCase: "Các lệnh quan trọng như xóa dữ liệu, thay đổi cấu hình",
},
];
export function CommandRegistryForm({
onSubmit,
closeDialog,
initialData,
title = "Đăng ký Lệnh Mới",
}: CommandRegistryFormProps) {
const [selectedQoS, setSelectedQoS] = useState<number>(
initialData?.qos ?? 0
);
const [isSubmitting, setIsSubmitting] = useState(false);
const form = useForm({
defaultValues: {
commandName: initialData?.commandName || "",
commandType: initialData?.commandType || CommandType.RESTART,
description: initialData?.description || "",
commandContent: initialData?.commandContent || "",
qos: (initialData?.qos || 0) as 0 | 1 | 2,
isRetained: initialData?.isRetained || false,
},
onSubmit: async ({ value }) => {
try {
// Validate using Zod
const validatedData = commandRegistrySchema.parse(value);
setIsSubmitting(true);
await onSubmit(validatedData as CommandRegistryFormData);
toast.success("Lưu lệnh thành công!");
if (closeDialog) {
closeDialog();
}
} catch (error: any) {
if (error.errors?.length > 0) {
toast.error(error.errors[0].message);
} else {
console.error("Submit error:", error);
toast.error("Có lỗi xảy ra khi lưu lệnh!");
}
} finally {
setIsSubmitting(false);
}
},
});
return (
<div className="w-full max-w-[90vw] sm:max-w-[70vw] md:max-w-[50vw] mx-auto space-y-6">
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>
Tạo cấu hình lệnh MQTT mới đ điều khiển thiết bị
</CardDescription>
</CardHeader>
<CardContent>
<form
className="space-y-6"
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
>
{/* Tên lệnh */}
<form.Field name="commandName">
{(field: any) => (
<div className="space-y-2">
<Label>
Tên Lệnh <span className="text-red-500">*</span>
</Label>
<Input
placeholder="VD: RestartDevice, ShutdownPC, UpdateSoftware..."
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
disabled={isSubmitting}
/>
{field.state.meta.errors?.length > 0 && (
<p className="text-sm text-red-500">
{String(field.state.meta.errors[0])}
</p>
)}
<p className="text-sm text-muted-foreground">
Tên đnh danh duy nhất cho lệnh này
</p>
</div>
)}
</form.Field>
{/* Loại lệnh */}
<form.Field name="commandType">
{(field: any) => (
<div className="space-y-2">
<Label>
Loại Lệnh <span className="text-red-500">*</span>
</Label>
<select
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2"
value={field.state.value}
onChange={(e) => field.handleChange(Number(e.target.value))}
onBlur={field.handleBlur}
disabled={isSubmitting}
>
<option value={CommandType.RESTART}>RESTART - Khởi đng lại</option>
<option value={CommandType.SHUTDOWN}>SHUTDOWN - Tắt máy</option>
<option value={CommandType.TASKKILL}>TASKKILL - Kết thúc tác vụ</option>
<option value={CommandType.BLOCK}>BLOCK - Chặn</option>
</select>
{field.state.meta.errors?.length > 0 && (
<p className="text-sm text-red-500">
{String(field.state.meta.errors[0])}
</p>
)}
<p className="text-sm text-muted-foreground">
Phân loại lệnh đ dễ dàng quản tổ chức
</p>
</div>
)}
</form.Field>
{/* Mô tả */}
<form.Field name="description">
{(field: any) => (
<div className="space-y-2">
<Label> Tả (Tùy chọn)</Label>
<Textarea
placeholder="Nhập mô tả chi tiết về lệnh này..."
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
disabled={isSubmitting}
rows={3}
/>
{field.state.meta.errors?.length > 0 && (
<p className="text-sm text-red-500">
{String(field.state.meta.errors[0])}
</p>
)}
<p className="text-sm text-muted-foreground">
tả chi tiết về chức năng cách sử dụng lệnh
</p>
</div>
)}
</form.Field>
{/* Nội dung lệnh */}
<form.Field name="commandContent">
{(field: any) => (
<div className="space-y-2">
<Label>
Nội Dung Lệnh <span className="text-red-500">*</span>
</Label>
<Textarea
placeholder="VD: shutdown /s /t 30 /c 'Máy sẽ tắt trong 30 giây'"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
disabled={isSubmitting}
rows={5}
className="font-mono text-sm"
/>
{field.state.meta.errors?.length > 0 && (
<p className="text-sm text-red-500">
{String(field.state.meta.errors[0])}
</p>
)}
<p className="text-sm text-muted-foreground">
Nội dung lệnh sẽ đưc gửi tới thiết bị (PowerShell, CMD, bash...)
</p>
</div>
)}
</form.Field>
{/* QoS Level */}
<form.Field name="qos">
{(field: any) => (
<div className="space-y-2">
<Label>
QoS (Quality of Service) <span className="text-red-500">*</span>
</Label>
<select
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2"
value={field.state.value}
onChange={(e) => {
const value = Number(e.target.value) as 0 | 1 | 2;
field.handleChange(value);
setSelectedQoS(value);
}}
onBlur={field.handleBlur}
disabled={isSubmitting}
>
<option value="0">QoS 0 - At Most Once (Tốc đ cao)</option>
<option value="1">QoS 1 - At Least Once (Cân bằng)</option>
<option value="2">QoS 2 - Exactly Once (Đ tin cậy cao)</option>
</select>
{field.state.meta.errors?.length > 0 && (
<p className="text-sm text-red-500">
{String(field.state.meta.errors[0])}
</p>
)}
</div>
)}
</form.Field>
{/* Chú thích QoS */}
{selectedQoS !== null && (
<Alert className="border-l-4 border-l-blue-500 bg-blue-50">
<Info className="h-4 w-4 text-blue-600" />
<AlertDescription className="text-sm space-y-3 mt-2">
<div>
<div className="font-semibold text-blue-900">
{QoSLevels[selectedQoS].name}
</div>
<div className="text-blue-800 mt-1">
{QoSLevels[selectedQoS].description}
</div>
<div className="text-blue-700 mt-2">
<span className="font-medium">Trường hợp sử dụng:</span>{" "}
{QoSLevels[selectedQoS].useCase}
</div>
</div>
</AlertDescription>
</Alert>
)}
{/* Bảng so sánh QoS */}
<Card className="bg-muted/50">
<CardHeader className="pb-3">
<CardTitle className="text-base">
Bảng So Sánh Các Mức QoS
</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b">
<th className="text-left py-2 px-2 font-semibold">
Tiêu Chí
</th>
<th className="text-center py-2 px-2 font-semibold">
QoS 0
</th>
<th className="text-center py-2 px-2 font-semibold">
QoS 1
</th>
<th className="text-center py-2 px-2 font-semibold">
QoS 2
</th>
</tr>
</thead>
<tbody>
<tr className="border-b bg-white">
<td className="py-2 px-2">Đm bảo gửi</td>
<td className="text-center">Không</td>
<td className="text-center"></td>
<td className="text-center">Chính xác</td>
</tr>
<tr className="border-b bg-white">
<td className="py-2 px-2">Tốc đ</td>
<td className="text-center">Nhanh nhất</td>
<td className="text-center">Trung bình</td>
<td className="text-center">Chậm nhất</td>
</tr>
<tr className="border-b bg-white">
<td className="py-2 px-2">Tài nguyên</td>
<td className="text-center">Ít nhất</td>
<td className="text-center">Trung bình</td>
<td className="text-center">Nhiều nhất</td>
</tr>
<tr className="border-b bg-white">
<td className="py-2 px-2">Đ tin cậy</td>
<td className="text-center">Thấp</td>
<td className="text-center">Cao</td>
<td className="text-center">Cao nhất</td>
</tr>
<tr className="bg-white">
<td className="py-2 px-2">Số lần nhận tối đa</td>
<td className="text-center">1 (hoặc 0)</td>
<td className="text-center"> 1</td>
<td className="text-center">1</td>
</tr>
</tbody>
</table>
</div>
</CardContent>
</Card>
{/* IsRetained Checkbox */}
<form.Field name="isRetained">
{(field: any) => (
<div className="flex items-center gap-3 rounded-lg border p-4">
<Checkbox
checked={field.state.value}
onCheckedChange={field.handleChange}
disabled={isSubmitting}
/>
<div className="flex-1">
<Label className="text-base cursor-pointer">
Lưu giữ lệnh (Retained)
</Label>
<p className="text-sm text-muted-foreground mt-1">
Broker MQTT sẽ lưu lệnh này gửi cho client mới khi
kết nối. Hữu ích cho các lệnh cấu hình cần duy trì trạng
thái.
</p>
</div>
</div>
)}
</form.Field>
{/* Submit Button */}
<div className="flex gap-3 pt-4">
<Button
type="submit"
disabled={isSubmitting}
className="flex-1"
>
{isSubmitting ? "Đang lưu..." : "Lưu Lệnh"}
</Button>
{closeDialog && (
<Button
type="button"
variant="outline"
disabled={isSubmitting}
className="flex-1"
onClick={closeDialog}
>
Hủy
</Button>
)}
</div>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@ -1,161 +0,0 @@
import { useForm } from "@tanstack/react-form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { type ReactNode } from "react";
interface FormBuilderProps<T extends Record<string, any>> {
defaultValues: T;
onSubmit: (values: T) => Promise<void> | void;
submitLabel?: string;
cancelLabel?: string;
onCancel?: () => void;
showCancel?: boolean;
children: (form: any) => ReactNode;
}
export function FormBuilder<T extends Record<string, any>>({
defaultValues,
onSubmit,
submitLabel = "Submit",
cancelLabel = "Hủy",
onCancel,
showCancel = false,
children,
}: FormBuilderProps<T>) {
const form = useForm({
defaultValues,
onSubmit: async ({ value }) => {
try {
await onSubmit(value as T);
} catch (error) {
console.error("Submit error:", error);
toast.error("Có lỗi xảy ra!");
}
},
});
return (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
>
{children(form)}
<div className="flex justify-end gap-2">
{showCancel && onCancel && (
<Button type="button" variant="outline" onClick={onCancel}>
{cancelLabel}
</Button>
)}
<Button type="submit">{submitLabel}</Button>
</div>
</form>
);
}
interface FormFieldProps<T, K extends keyof T> {
form: any;
name: K;
label: string;
type?: string;
placeholder?: string;
required?: boolean;
}
export function FormField<T extends Record<string, any>, K extends keyof T>({
form,
name,
label,
type = "text",
placeholder,
required,
}: FormFieldProps<T, K>) {
return (
<form.Field name={name as string}>
{(field: any) => (
<div>
<Label>
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</Label>
<Input
type={type}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
placeholder={placeholder}
/>
</div>
)}
</form.Field>
);
}
export function FormTextarea<T extends Record<string, any>, K extends keyof T>({
form,
name,
label,
placeholder,
required,
}: Omit<FormFieldProps<T, K>, "type">) {
return (
<form.Field name={name as string}>
{(field: any) => (
<div>
<Label>
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</Label>
<textarea
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
placeholder={placeholder}
/>
</div>
)}
</form.Field>
);
}
export function FormSelect<T extends Record<string, any>, K extends keyof T>({
form,
name,
label,
options,
required,
}: {
form: any;
name: K;
label: string;
options: { value: string; label: string }[];
required?: boolean;
}) {
return (
<form.Field name={name as string}>
{(field: any) => (
<div>
<Label>
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</Label>
<select
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
)}
</form.Field>
);
}

View File

@ -1,130 +0,0 @@
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { LoginResquest } from "@/types/auth";
import { useMutation } from "@tanstack/react-query";
import { buildSsoLoginUrl, login } from "@/services/auth.service";
import { useState } from "react";
import { useNavigate, useRouter } from "@tanstack/react-router";
import { Route } from "@/routes/(auth)/login";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { useAuth } from "@/hooks/useAuth";
import { LoaderCircle } from "lucide-react";
export function LoginForm({ className }: React.ComponentProps<"form">) {
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [formData, setFormData] = useState<LoginResquest>({
username: "",
password: "",
});
const auth = useAuth();
const router = useRouter();
const navigate = useNavigate();
const search = Route.useSearch() as { redirect?: string };
const mutation = useMutation({
mutationFn: login,
async onSuccess(data) {
localStorage.setItem("accesscontrol.auth.user", data.username!);
localStorage.setItem("token", data.token!);
localStorage.setItem("name", data.name!);
localStorage.setItem("acs", (data.access ?? "").toString());
localStorage.setItem("role", data.role.roleName ?? "");
localStorage.setItem("priority", (data.role.priority ?? 0).toString());
auth.setAuthenticated(true);
auth.login(data.username!);
await router.invalidate();
await navigate({ to: search.redirect || "/dashboard" });
},
onError(error) {
setErrorMessage(error.message || "Login failed");
}
});
const handleSsoLogin = () => {
const returnUrl = new URL("/sso/callback", window.location.origin);
if (search.redirect) {
returnUrl.searchParams.set("redirect", search.redirect);
}
window.location.assign(buildSsoLoginUrl(returnUrl.toString()));
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setErrorMessage(null);
mutation.mutate(formData);
};
return (
<div className={cn("flex flex-col gap-6", className)}>
<Card>
<CardHeader className="text-center">
<CardTitle className="text-2xl font-semibold tracking-tight flex items-center justify-center gap-3">
<img src="/soict_logo.png" alt="SOICT logo" className="h-7 w-auto object-contain" />
<span>Computer Management</span>
</CardTitle>
<CardDescription>Hệ thống quản phòng máy thực hành</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit}>
<div className="grid gap-6">
<div className="grid gap-3">
<Label htmlFor="email">Tên đăng nhập</Label>
<Input
id="email"
type="text"
autoFocus
required
value={formData.username}
onChange={(e) =>
setFormData({ ...formData, username: e.target.value })
}
/>
</div>
<div className="grid gap-3">
<div className="flex items-center">
<Label htmlFor="password">Mật khẩu</Label>
</div>
<Input
id="password"
type="password"
required
value={formData.password}
onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
/>
</div>
{errorMessage && (
<div className="text-destructive text-sm font-medium">{errorMessage}</div>
)}
{mutation.isPending ? (
<Button className="w-full" disabled>
<LoaderCircle className="w-4 h-4 mr-1 animate-spin" />
Đang đăng nhập
</Button>
) : (
<Button type="submit" className="w-full">
Đăng nhập
</Button>
)}
<div className="text-center text-sm text-muted-foreground">Hoặc</div>
<Button type="button" variant="outline" className="w-full gap-2" onClick={handleSsoLogin}>
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4">
<rect x="1" y="1" width="10" height="10" fill="#F25022" />
<rect x="13" y="1" width="10" height="10" fill="#7FBA00" />
<rect x="1" y="13" width="10" height="10" fill="#00A4EF" />
<rect x="13" y="13" width="10" height="10" fill="#FFB900" />
</svg>
Đăng nhập với Microsoft
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@ -1,139 +0,0 @@
import { useForm } from "@tanstack/react-form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import type { AxiosProgressEvent } from "axios";
import { useState } from "react";
import { toast } from "sonner";
interface UploadVersionFormProps {
onSubmit: (fd: FormData, config?: { onUploadProgress: (e: AxiosProgressEvent) => void }) => Promise<void>;
closeDialog: () => void;
}
export function UploadVersionForm({ onSubmit, closeDialog }: UploadVersionFormProps) {
const [uploadPercent, setUploadPercent] = useState(0);
const [isUploading, setIsUploading] = useState(false);
const [isDone, setIsDone] = useState(false);
// Match server allowed extensions
const ALLOWED_EXTENSIONS = [".exe", ".apk", ".conf", ".json", ".xml", ".setting", ".lnk", ".url", ".seb", ".ps1"];
const isFileValid = (file: File) => {
const fileName = file.name.toLowerCase();
return ALLOWED_EXTENSIONS.some((ext) => fileName.endsWith(ext));
};
const form = useForm({
defaultValues: { files: new DataTransfer().files, newVersion: "" },
onSubmit: async ({ value }) => {
if (!value.newVersion || value.files.length === 0) {
toast.error("Vui lòng điền đầy đủ thông tin");
return;
}
// Validate file types
const invalidFiles = Array.from(value.files).filter((f) => !isFileValid(f));
if (invalidFiles.length > 0) {
toast.error(
`File không hợp lệ: ${invalidFiles.map((f) => f.name).join(", ")}. Chỉ chấp nhận ${ALLOWED_EXTENSIONS.join(", ")}`
);
return;
}
try {
setIsUploading(true);
setUploadPercent(0);
setIsDone(false);
const fd = new FormData();
Array.from(value.files).forEach((f) => fd.append("FileInput", f));
fd.append("Version", value.newVersion);
await onSubmit(fd, {
onUploadProgress: (e: AxiosProgressEvent) => {
if (e.total) {
const progress = Math.round((e.loaded * 100) / e.total);
setUploadPercent(progress);
}
},
});
setIsDone(true);
} catch (error) {
console.error("Upload error:", error);
toast.error("Upload thất bại!");
} finally {
setIsUploading(false);
}
},
});
return (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
>
<form.Field name="newVersion">
{(field) => (
<div>
<Label>Phiên bản</Label>
<Input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
placeholder="1.0.0"
disabled={isUploading || isDone}
/>
</div>
)}
</form.Field>
<form.Field name="files">
{(field) => (
<div>
<Label>File</Label>
<Input
type="file"
accept=".exe,.apk,.conf,.json,.xml,.setting,.lnk,.url,.seb"
onChange={(e) => e.target.files && field.handleChange(e.target.files)}
disabled={isUploading || isDone}
/>
<p className="text-xs text-muted-foreground mt-1">
Chỉ chấp nhận file: .exe, .apk, .conf, .json, .xml, .setting, .lnk, .url, .seb
</p>
</div>
)}
</form.Field>
{(uploadPercent > 0 || isUploading || isDone) && (
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>{isDone ? "Hoàn tất!" : "Đang tải lên..."}</span>
<span>{uploadPercent}%</span>
</div>
<Progress value={uploadPercent} className="w-full" />
</div>
)}
<div className="flex justify-end gap-2">
{!isDone ? (
<>
<Button type="button" variant="outline" onClick={closeDialog} disabled={isUploading}>
Hủy
</Button>
<Button type="submit" disabled={isUploading}>
{isUploading ? "Đang tải..." : "Upload"}
</Button>
</>
) : (
<Button type="button" onClick={closeDialog}>
Hoàn tất
</Button>
)}
</div>
</form>
);
}

View File

@ -1,140 +0,0 @@
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Loader2, Trash2, ChevronDown, AlertTriangle } from "lucide-react";
import { useState } from "react";
interface DeleteMenuProps {
onDeleteFromServer: () => void;
onDeleteFromRequired: () => void;
loading?: boolean;
label?: string;
serverLabel?: string;
requiredLabel?: string;
}
export function DeleteMenu({
onDeleteFromServer,
onDeleteFromRequired,
loading,
label = "Xóa",
serverLabel = "Xóa khỏi server",
requiredLabel = "Xóa khỏi danh sách yêu cầu",
}: DeleteMenuProps) {
const [open, setOpen] = useState(false);
const [showConfirmDelete, setShowConfirmDelete] = useState(false);
const handleDeleteFromServer = async () => {
try {
await onDeleteFromServer();
} finally {
setOpen(false);
setShowConfirmDelete(false);
}
};
const handleDeleteFromRequired = async () => {
try {
await onDeleteFromRequired();
} finally {
setOpen(false);
}
};
return (
<>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="destructive"
disabled={loading}
className="group relative overflow-hidden font-medium px-6 py-2.5 rounded-lg transition-all duration-300 hover:shadow-lg hover:shadow-red-200/50 hover:-translate-y-0.5 disabled:opacity-70 disabled:cursor-not-allowed disabled:transform-none disabled:shadow-none"
>
<div className="flex items-center gap-2">
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
<span className="text-sm font-semibold">
{loading ? "Đang xóa..." : label}
</span>
<ChevronDown className="h-4 w-4 transition-transform duration-200 group-data-[state=open]:rotate-180" />
</div>
<div className="absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-white/20 to-transparent transition-transform duration-700 group-hover:translate-x-full" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem
onClick={handleDeleteFromRequired}
disabled={loading}
className="focus:bg-orange-50 focus:text-orange-900"
>
<Trash2 className="h-4 w-4 mr-2 text-orange-600" />
<span>{requiredLabel}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setShowConfirmDelete(true)}
disabled={loading}
className="focus:bg-red-50 focus:text-red-900"
>
<Trash2 className="h-4 w-4 mr-2 text-red-600" />
<span>{serverLabel}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Confirmation Dialog for Delete from Server */}
{showConfirmDelete && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-sm mx-4 shadow-lg">
<div className="flex items-center gap-3 mb-4">
<AlertTriangle className="h-6 w-6 text-red-600" />
<h3 className="font-semibold text-lg">Cảnh báo: Xóa khỏi server</h3>
</div>
<p className="text-muted-foreground mb-6">
Bạn đang chuẩn bị xóa các phần mềm này khỏi server. Hành đng này <strong>không thể hoàn tác</strong> sẽ xóa vĩnh viễn tất cả các tệp liên quan.
</p>
<p className="text-sm text-red-600 mb-6 font-medium">
Vui lòng chắc chắn trước khi tiếp tục.
</p>
<div className="flex gap-3 justify-end">
<Button
variant="outline"
onClick={() => setShowConfirmDelete(false)}
disabled={loading}
>
Hủy
</Button>
<Button
variant="destructive"
onClick={handleDeleteFromServer}
disabled={loading}
className="gap-2"
>
{loading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Đang xóa...
</>
) : (
<>
<Trash2 className="h-4 w-4" />
Xóa khỏi server
</>
)}
</Button>
</div>
</div>
</div>
)}
</>
);
}

View File

@ -1,16 +0,0 @@
import { AxiosError } from "axios";
export function ErrorFetchingPage({ error }: { error: Error | AxiosError }) {
return (
<div className="p-4 text-destructive">
<h2 className="text-xl font-semibold mb-2">Lỗi</h2>
<p>
{"isAxiosError" in error &&
error.response?.data &&
(error.response.data as { message?: string }).message
? (error.response.data as { message?: string }).message
: "Lỗi trong quá trình render page"}
</p>
</div>
);
}

View File

@ -1,24 +0,0 @@
import { AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Link } from "@tanstack/react-router";
export default function ErrorRoute({ error }: { error: string }) {
return (
<div className="flex flex-col items-center justify-center min-h-screen px-4 text-center">
<div className="bg-destructive/10 rounded-full p-6 mb-6">
<AlertTriangle className="h-12 w-12 text-destructive" />
</div>
<h1 className="text-4xl font-bold mb-4">Lỗi</h1>
<p className="text-muted-foreground mb-8 max-w-md">
Đã xảy ra lỗi: <strong>{error}</strong>
</p>
<div className="flex flex-col sm:flex-row gap-4">
<Button asChild variant="outline">
<Link to="/dashboard">Về trang chủ</Link>
</Button>
</div>
</div>
);
}

View File

@ -1,35 +0,0 @@
import { Button } from "@/components/ui/button";
import { Link } from "@tanstack/react-router";
import { ArrowLeft, Search } from "lucide-react";
export default function NotFound() {
return (
<div className="flex flex-col items-center justify-center min-h-[70vh] px-4 text-center">
<div className="space-y-6 max-w-md mx-auto">
<div className="relative mx-auto w-40 h-40 md:w-52 md:h-52">
<div className="absolute inset-0 bg-primary/10 rounded-full animate-pulse" />
<div className="absolute inset-0 flex items-center justify-center">
<Search className="h-20 w-20 md:h-24 md:w-24 text-primary" strokeWidth={1.5} />
</div>
</div>
<div className="space-y-3">
<h1 className="text-4xl md:text-5xl font-bold tracking-tighter">404</h1>
<h2 className="text-2xl md:text-3xl font-semibold">Không tìm thấy</h2>
<p className="text-muted-foreground">
Trang bạn yêu cầu truy cập không tồn tại hoặc đã bị xoá.
</p>
</div>
<div className="pt-6">
<Button asChild size="lg" className="gap-2">
<Link to="/dashboard" className="flex items-center gap-1">
<ArrowLeft className="h-4 w-4" />
Trở về trang chủ
</Link>
</Button>
</div>
</div>
</div>
);
}

View File

@ -1,43 +0,0 @@
import { Button } from "@/components/ui/button";
import { Route } from "@/routes/_auth";
import { useRouter } from "@tanstack/react-router";
import { ArrowLeft, Search } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
export default function SessionTimeOutErrorPage() {
const router = useRouter();
const navigate = Route.useNavigate();
const auth = useAuth();
const handleLogout = () => {
auth.logout();
router.invalidate().finally(() => {
navigate({ to: "/login" });
});
};
return (
<div className="flex flex-col items-center justify-center min-h-[70vh] px-4 text-center">
<div className="space-y-6 max-w-md mx-auto">
<div className="relative mx-auto w-40 h-40 md:w-52 md:h-52">
<div className="absolute inset-0 bg-primary/10 rounded-full animate-pulse" />
<div className="absolute inset-0 flex items-center justify-center">
<Search className="h-20 w-20 md:h-24 md:w-24 text-primary" strokeWidth={1.5} />
</div>
</div>
<div className="space-y-3">
<h2 className="text-2xl md:text-3xl font-semibold">Phiên hết hạn</h2>
<p className="text-muted-foreground">Bạn cần đăng nhập lại</p>
</div>
<div className="pt-6">
<Button asChild size="lg" className="gap-2" onClick={handleLogout}>
<div className="flex items-center gap-1">
<ArrowLeft className="h-4 w-4" />
Trở về trang đăng nhập
</div>
</Button>
</div>
</div>
</div>
);
}

View File

@ -1,74 +0,0 @@
import { Button } from "@/components/ui/button";
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react";
import { RowsPerPage } from "./rows-per-page";
interface PaginationProps {
currentPage: number;
totalPages: number;
totalItems: number;
itemsPerPage: number;
onPageChange: (page: number) => void;
onPageSizeChange?: (pageSize: number) => void;
pageSizeOptions?: number[];
}
export function CustomPagination({
currentPage,
totalPages,
totalItems,
itemsPerPage,
onPageChange,
onPageSizeChange,
pageSizeOptions
}: PaginationProps) {
const startItem = Math.max(1, (currentPage - 1) * itemsPerPage + 1);
const endItem = Math.min(currentPage * itemsPerPage, totalItems);
return (
<div className="flex flex-col sm:flex-row items-center gap-4">
{onPageSizeChange && (
<RowsPerPage
pageSize={itemsPerPage}
onPageSizeChange={onPageSizeChange}
options={pageSizeOptions}
/>
)}
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon"
onClick={() => onPageChange(1)}
disabled={currentPage === 1}>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="mx-2 text-sm">
{startItem}-{endItem} của {totalItems}
</span>
<Button
variant="outline"
size="icon"
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={() => onPageChange(totalPages)}
disabled={currentPage === totalPages}>
<ChevronsRight className="h-4 w-4" />
</Button>
</div>
</div>
);
}

View File

@ -1,45 +0,0 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
interface RowsPerPageProps {
pageSize: number;
onPageSizeChange: (pageSize: number) => void;
options?: number[];
}
export function RowsPerPage({
pageSize,
onPageSizeChange,
options = [5, 10, 15, 20]
}: RowsPerPageProps) {
return (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Hiển thị</span>
<Select
value={pageSize?.toString()}
onValueChange={(value) => onPageSizeChange(Number(value))}>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue placeholder={pageSize?.toString()} />
</SelectTrigger>
<SelectContent>
{!options.includes(pageSize) && (
<SelectItem value={pageSize?.toString()} disabled>
{pageSize}
</SelectItem>
)}
{options.map((option) => (
<SelectItem key={option} value={option?.toString()}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground">mục</span>
</div>
);
}

View File

@ -0,0 +1,151 @@
import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Checkbox } from "@/components/ui/checkbox"
import { Play, PlayCircle } from "lucide-react"
import { useState } from "react"
interface PresetCommand {
id: string
label: string
command: string
description?: string
}
interface PresetCommandsProps {
onSelectCommand: (command: string) => void
onExecuteMultiple?: (commands: string[]) => void
disabled?: boolean
}
// Danh sách các command có sẵn
const PRESET_COMMANDS: PresetCommand[] = [
{
id: "check-disk",
label: "Kiểm tra dung lượng ổ đĩa",
command: "df -h",
description: "Hiển thị thông tin dung lượng các ổ đĩa",
},
{
id: "check-memory",
label: "Kiểm tra RAM",
command: "free -h",
description: "Hiển thị thông tin bộ nhớ RAM",
},
{
id: "check-cpu",
label: "Kiểm tra CPU",
command: "top -bn1 | head -20",
description: "Hiển thị thông tin CPU và tiến trình",
},
{
id: "list-processes",
label: "Danh sách tiến trình",
command: "ps aux",
description: "Liệt kê tất cả tiến trình đang chạy",
},
{
id: "network-info",
label: "Thông tin mạng",
command: "ifconfig",
description: "Hiển thị cấu hình mạng",
},
{
id: "system-info",
label: "Thông tin hệ thống",
command: "uname -a",
description: "Hiển thị thông tin hệ điều hành",
},
{
id: "uptime",
label: "Thời gian hoạt động",
command: "uptime",
description: "Hiển thị thời gian hệ thống đã chạy",
},
{
id: "reboot",
label: "Khởi động lại",
command: "reboot",
description: "Khởi động lại thiết bị",
},
]
export function PresetCommands({ onSelectCommand, onExecuteMultiple, disabled }: PresetCommandsProps) {
const [selectedCommands, setSelectedCommands] = useState<Set<string>>(new Set())
const handleToggleCommand = (commandId: string) => {
setSelectedCommands((prev) => {
const newSet = new Set(prev)
if (newSet.has(commandId)) {
newSet.delete(commandId)
} else {
newSet.add(commandId)
}
return newSet
})
}
const handleExecuteSelected = () => {
const commands = PRESET_COMMANDS.filter((cmd) => selectedCommands.has(cmd.id)).map((cmd) => cmd.command)
if (commands.length > 0 && onExecuteMultiple) {
onExecuteMultiple(commands)
setSelectedCommands(new Set()) // Clear selection after execution
}
}
const handleSelectAll = () => {
if (selectedCommands.size === PRESET_COMMANDS.length) {
setSelectedCommands(new Set())
} else {
setSelectedCommands(new Set(PRESET_COMMANDS.map((cmd) => cmd.id)))
}
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<Button variant="outline" size="sm" onClick={handleSelectAll} disabled={disabled}>
<Checkbox checked={selectedCommands.size === PRESET_COMMANDS.length} className="mr-2" />
{selectedCommands.size === PRESET_COMMANDS.length ? "Bỏ chọn tất cả" : "Chọn tất cả"}
</Button>
{selectedCommands.size > 0 && (
<Button size="sm" onClick={handleExecuteSelected} disabled={disabled}>
<PlayCircle className="h-4 w-4 mr-2" />
Thực thi {selectedCommands.size} lệnh
</Button>
)}
</div>
<ScrollArea className="h-[25vh] w-full rounded-md border p-4">
<div className="space-y-2">
{PRESET_COMMANDS.map((preset) => (
<div
key={preset.id}
className="flex items-start gap-3 rounded-lg border p-3 hover:bg-accent transition-colors"
>
<Checkbox
checked={selectedCommands.has(preset.id)}
onCheckedChange={() => handleToggleCommand(preset.id)}
disabled={disabled}
className="mt-1"
/>
<div className="flex-1 space-y-1">
<div className="font-medium text-sm">{preset.label}</div>
{preset.description && <div className="text-xs text-muted-foreground">{preset.description}</div>}
<code className="text-xs bg-muted px-2 py-1 rounded block mt-1">{preset.command}</code>
</div>
<Button
size="sm"
variant="outline"
onClick={() => onSelectCommand(preset.command)}
disabled={disabled}
className="shrink-0"
>
<Play className="h-4 w-4" />
</Button>
</div>
))}
</div>
</ScrollArea>
</div>
)
}

View File

@ -7,18 +7,12 @@ import {
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { Loader2, RefreshCw, ChevronDown } from "lucide-react";
import { useState } from "react";
interface RequestUpdateMenuProps {
onUpdateDevice: () => void;
onUpdateRoom: () => void;
onUpdateAll: () => void;
loading?: boolean;
label?: string;
deviceLabel?: string;
roomLabel?: string;
allLabel?: string;
icon?: React.ReactNode;
}
export function RequestUpdateMenu({
@ -26,39 +20,9 @@ export function RequestUpdateMenu({
onUpdateRoom,
onUpdateAll,
loading,
label = "Cập nhật",
deviceLabel = "Thiết bị cụ thể",
roomLabel = "Theo phòng",
allLabel = "Tất cả thiết bị",
icon,
}: RequestUpdateMenuProps) {
const [open, setOpen] = useState(false);
const handleUpdateDevice = async () => {
try {
await onUpdateDevice();
} finally {
setOpen(false);
}
};
const handleUpdateRoom = async () => {
try {
await onUpdateRoom();
} finally {
setOpen(false);
}
};
const handleUpdateAll = async () => {
try {
await onUpdateAll();
} finally {
setOpen(false);
}
};
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
@ -68,13 +32,11 @@ export function RequestUpdateMenu({
<div className="flex items-center gap-2">
{loading ? (
<Loader2 className="h-4 w-4 animate-spin text-gray-600" />
) : icon ? (
<div className="h-4 w-4 text-gray-600">{icon}</div>
) : (
<RefreshCw className="h-4 w-4 text-gray-600 transition-transform duration-300 group-hover:rotate-180" />
)}
<span className="text-sm font-semibold">
{loading ? "Đang gửi..." : label}
{loading ? "Đang gửi..." : "Cập nhật"}
</span>
<ChevronDown className="h-4 w-4 text-gray-600 transition-transform duration-200 group-data-[state=open]:rotate-180" />
</div>
@ -83,19 +45,19 @@ export function RequestUpdateMenu({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuItem onClick={handleUpdateDevice} disabled={loading}>
<DropdownMenuItem onClick={onUpdateDevice} disabled={loading}>
<RefreshCw className="h-4 w-4 mr-2" />
<span>{deviceLabel}</span>
<span>Cập nhật thiết bị cụ thể</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleUpdateRoom} disabled={loading}>
<DropdownMenuItem onClick={onUpdateRoom} disabled={loading}>
<RefreshCw className="h-4 w-4 mr-2" />
<span>{roomLabel}</span>
<span>Cập nhật theo phòng</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleUpdateAll} disabled={loading}>
<DropdownMenuItem onClick={onUpdateAll} disabled={loading}>
<RefreshCw className="h-4 w-4 mr-2" />
<span>{allLabel}</span>
<span>Cập nhật tất cả thiết bị</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@ -0,0 +1,135 @@
import { useEffect, useState, useMemo } from "react"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"
import { Check, Search } from "lucide-react"
import { Input } from "@/components/ui/input"
interface SelectDialogProps {
open: boolean
onClose: () => void
items: string[] // danh sách chung: có thể là devices hoặc rooms
title?: string // tiêu đề động
description?: string // mô tả ngắn
icon?: React.ReactNode // icon thay đổi tùy loại
onConfirm: (selected: string[]) => void
}
export function SelectDialog({
open,
onClose,
items,
title = "Chọn mục",
description = "Bạn có thể chọn nhiều mục để thao tác",
icon,
onConfirm,
}: SelectDialogProps) {
const [selectedItems, setSelectedItems] = useState<string[]>([])
const [search, setSearch] = useState("")
useEffect(() => {
if (!open) {
setSelectedItems([])
setSearch("")
}
}, [open])
const toggleItem = (item: string) => {
setSelectedItems((prev) =>
prev.includes(item)
? prev.filter((i) => i !== item)
: [...prev, item]
)
}
// Lọc danh sách theo từ khóa
const filteredItems = useMemo(() => {
return items.filter((item) =>
item.toLowerCase().includes(search.toLowerCase())
)
}, [items, search])
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader className="text-center pb-4">
<div className="mx-auto w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center mb-3">
{icon ?? <Search className="w-6 h-6 text-primary" />}
</div>
<DialogTitle className="text-xl font-semibold">{title}</DialogTitle>
<p className="text-sm text-muted-foreground mt-1">{description}</p>
</DialogHeader>
{/* 🔍 Thanh tìm kiếm */}
<div className="relative mb-3">
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Tìm kiếm..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
{/* Danh sách các item */}
<div className="py-3 space-y-3 max-h-64 overflow-y-auto">
{filteredItems.length > 0 ? (
filteredItems.map((item) => (
<div
key={item}
className="flex items-center justify-between p-3 rounded-lg border border-border hover:border-primary/60 hover:bg-accent/50 transition-all duration-200 cursor-pointer"
onClick={() => toggleItem(item)}
>
<div className="flex items-center gap-3">
<Checkbox
checked={selectedItems.includes(item)}
onCheckedChange={() => toggleItem(item)}
/>
<Label className="font-medium cursor-pointer hover:text-primary">
{item}
</Label>
</div>
{selectedItems.includes(item) && (
<div className="w-5 h-5 bg-primary rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-primary-foreground" />
</div>
)}
</div>
))
) : (
<p className="text-center text-sm text-muted-foreground py-4">
Không tìm thấy kết quả
</p>
)}
</div>
<DialogFooter className="gap-2 pt-4">
<Button variant="outline" onClick={onClose} className="flex-1 sm:flex-none">
Hủy
</Button>
<Button
onClick={() => {
if (selectedItems.length > 0) {
onConfirm(selectedItems)
onClose()
}
}}
disabled={selectedItems.length === 0}
className="flex-1 sm:flex-none"
>
<Check className="w-4 h-4 mr-2" />
Xác nhận ({selectedItems.length})
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -1,149 +0,0 @@
import type React from "react";
import { Link } from "@tanstack/react-router";
import { Building2, Cpu } from "lucide-react";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { appSidebarSection } from "@/types/app-sidebar";
import { PermissionEnum } from "@/types/permission";
import { useAuth } from "@/hooks/useAuth";
import { useMemo } from "react";
type SidebarItem = {
title: string;
url: string;
code?: number;
icon: React.ElementType;
permissions?: PermissionEnum[];
};
type SidebarSection = {
title: string;
items: SidebarItem[];
};
export function AppSidebar() {
const { hasPermission, acs, isSystemAdmin } = useAuth();
// Check if user is admin (has ALLOW_ALL permission OR is System Admin with priority 0)
const isAdmin = acs.includes(PermissionEnum.ALLOW_ALL) || isSystemAdmin();
// Check if user has any of the required permissions
const checkPermissions = (permissions?: PermissionEnum[]) => {
// No permissions defined = show to everyone
if (!permissions || permissions.length === 0) return true;
// Item marked as ALLOW_ALL = show to everyone
if (permissions.includes(PermissionEnum.ALLOW_ALL)) return true;
// Admin users OR System Admin (priority=0) see everything
if (isAdmin) return true;
// Check if user has any of the required permissions
return permissions.some((permission) => hasPermission(permission));
};
// Filter sidebar sections and items based on permissions
const filteredNavMain = useMemo(() => {
return appSidebarSection.navMain
.map((section) => ({
...section,
items: section.items.filter((item) => checkPermissions(item.permissions)),
}))
.filter((section) => section.items.length > 0) as SidebarSection[];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [acs]);
return (
<TooltipProvider delayDuration={300}>
<Sidebar
collapsible="icon"
className="border-r border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
>
<SidebarHeader className="border-b border-border/40 p-6">
<div className="flex items-center gap-3">
<div className="flex aspect-square size-10 items-center justify-center rounded-xl bg-gradient-to-br from-gray-900 to-black text-white shadow-lg ring-1 ring-gray-800/30">
<Building2 className="size-5" />
</div>
<div className="flex flex-col gap-0.5 leading-none">
<span className="font-bold text-base tracking-tight bg-gradient-to-r from-foreground to-foreground/80 bg-clip-text">
TTMT Computer Management
</span>
<span className="text-xs text-muted-foreground font-medium flex items-center gap-1">
<Cpu className="size-3" />
v1.0.0
</span>
</div>
</div>
</SidebarHeader>
<SidebarContent className="p-4">
{filteredNavMain.map((section) => (
<SidebarGroup key={section.title}>
<SidebarGroupLabel className="text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider mb-2">
{section.title}
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu className="space-y-1">
{section.items.map((item) => (
<SidebarMenuItem key={item.title}>
<Tooltip>
<TooltipTrigger asChild>
<SidebarMenuButton
asChild
tooltip={item.title}
className={cn(
"w-full justify-start gap-3 px-4 py-3 rounded-xl",
"hover:bg-accent/60 hover:text-accent-foreground hover:shadow-sm",
"transition-all duration-200 ease-in-out",
"group relative overflow-hidden",
"data-[active=true]:bg-primary data-[active=true]:text-primary-foreground",
"data-[active=true]:shadow-md data-[active=true]:ring-1 data-[active=true]:ring-primary/20"
)}
>
<Link
href={item.url}
to={item.url}
className="flex items-center gap-3 w-full"
>
<item.icon className="size-5 shrink-0 transition-all duration-200 group-hover:scale-110 group-hover:text-primary" />
<span className="font-medium text-sm truncate">
{item.title}
</span>
</Link>
</SidebarMenuButton>
</TooltipTrigger>
<TooltipContent side="right" className="font-medium">
{item.title}
</TooltipContent>
</Tooltip>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
))}
</SidebarContent>
<SidebarFooter className="border-t border-border/40 p-4 space-y-3">
<div className="px-2 text-xs text-muted-foreground/60 font-medium">
© 2025 NAVIS Centre
</div>
</SidebarFooter>
</Sidebar>
</TooltipProvider>
);
}

View File

@ -1,155 +0,0 @@
import {
flexRender,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
type ColumnDef,
} from "@tanstack/react-table";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ScrollArea } from "@/components/ui/scroll-area";
import { CustomPagination } from "@/components/pagination/pagination";
import { useEffect, useState } from "react";
interface VersionTableProps<TData> {
data: TData[];
columns: ColumnDef<TData, any>[];
isLoading: boolean;
onTableInit?: (table: any) => void;
onRowClick?: (row: TData) => void;
scrollable?: boolean;
maxHeight?: string;
// Pagination options
enablePagination?: boolean;
defaultPageSize?: number;
pageSizeOptions?: number[];
}
export function VersionTable<TData>({
data,
columns,
isLoading,
onTableInit,
onRowClick,
scrollable = false,
maxHeight = "calc(100vh - 320px)",
enablePagination = false,
defaultPageSize = 10,
pageSizeOptions = [5, 10, 15, 20],
}: VersionTableProps<TData>) {
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: defaultPageSize,
});
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
...(enablePagination && {
getPaginationRowModel: getPaginationRowModel(),
state: { pagination },
onPaginationChange: setPagination,
}),
getRowId: (row: any) => row.id?.toString(),
enableRowSelection: true,
});
useEffect(() => {
onTableInit?.(table);
}, [table, onTableInit]);
const tableContent = (
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={columns.length}>Đang tải dữ liệu...</TableCell>
</TableRow>
) : table.getRowModel().rows.length === 0 ? (
<TableRow>
<TableCell colSpan={columns.length}>Không dữ liệu.</TableCell>
</TableRow>
) : (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
onClick={() => onRowClick?.(row.original)}
className={onRowClick ? "cursor-pointer hover:bg-muted/50" : ""}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
);
if (scrollable) {
return (
<div className="space-y-4">
<div className="rounded-md border">
<ScrollArea className="w-full" style={{ height: maxHeight }}>
{tableContent}
</ScrollArea>
</div>
{enablePagination && data.length > 0 && (
<CustomPagination
currentPage={table.getState().pagination.pageIndex + 1}
totalPages={table.getPageCount()}
totalItems={data.length}
itemsPerPage={table.getState().pagination.pageSize}
onPageChange={(page) => table.setPageIndex(page - 1)}
onPageSizeChange={(size) => table.setPageSize(size)}
pageSizeOptions={pageSizeOptions}
/>
)}
</div>
);
}
return (
<div className="space-y-4">
<div className="rounded-md border">{tableContent}</div>
{enablePagination && data.length > 0 && (
<CustomPagination
currentPage={table.getState().pagination.pageIndex + 1}
totalPages={table.getPageCount()}
totalItems={data.length}
itemsPerPage={table.getState().pagination.pageSize}
onPageChange={(page) => table.setPageIndex(page - 1)}
onPageSizeChange={(size) => table.setPageSize(size)}
pageSizeOptions={pageSizeOptions}
/>
)}
</div>
);
}

View File

@ -1,109 +0,0 @@
import * as React from "react"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"flex flex-wrap items-center gap-1.5 text-sm break-words text-muted-foreground sm:gap-2.5",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@ -3,7 +3,7 @@ import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import { useIsMobile } from "@/hooks/useMobile"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"

View File

@ -1,27 +0,0 @@
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@ -0,0 +1,164 @@
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
import { useForm, formOptions } from "@tanstack/react-form";
import { toast } from "sonner";
import type { AxiosProgressEvent } from "axios";
interface UploadDialogProps {
onSubmit: (
fd: FormData,
config?: { onUploadProgress?: (e: AxiosProgressEvent) => void }
) => Promise<void>;
}
const formOpts = formOptions({
defaultValues: { files: new DataTransfer().files, newVersion: "" },
});
export function UploadDialog({ onSubmit }: UploadDialogProps) {
const [isOpen, setIsOpen] = useState(false);
const [uploadPercent, setUploadPercent] = useState(0);
const [isUploading, setIsUploading] = useState(false);
const [isDone, setIsDone] = useState(false);
const form = useForm({
...formOpts,
onSubmit: async ({ value }) => {
if (!value.newVersion || value.files.length === 0) {
toast.error("Vui lòng điền đầy đủ thông tin");
return;
}
try {
setIsUploading(true);
setUploadPercent(0);
setIsDone(false);
const fd = new FormData();
Array.from(value.files).forEach((f) => fd.append("FileInput", f));
fd.append("Version", value.newVersion);
await onSubmit(fd, {
onUploadProgress: (e: AxiosProgressEvent) => {
if (e.total) {
const progress = Math.round((e.loaded * 100) / e.total);
setUploadPercent(progress);
}
},
});
setIsDone(true);
} catch (error) {
console.error("Upload error:", error);
toast.error("Upload thất bại!");
} finally {
setIsUploading(false);
}
},
});
const handleDialogClose = (open: boolean) => {
if (isUploading) return;
setIsOpen(open);
if (!open) {
setUploadPercent(0);
setIsDone(false);
form.reset();
}
};
return (
<Dialog open={isOpen} onOpenChange={handleDialogClose}>
<DialogTrigger asChild>
<Button>Tải lên phiên bản mới</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Cập nhật phiên bản</DialogTitle>
</DialogHeader>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
>
<form.Field name="newVersion">
{(field) => (
<div>
<Label>Phiên bản</Label>
<Input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
placeholder="1.0.0"
disabled={isUploading || isDone}
/>
</div>
)}
</form.Field>
<form.Field name="files">
{(field) => (
<div>
<Label>File</Label>
<Input
type="file"
accept=".exe,.msi,.apk"
onChange={(e) =>
e.target.files && field.handleChange(e.target.files)
}
disabled={isUploading || isDone}
/>
</div>
)}
</form.Field>
{(uploadPercent > 0 || isUploading || isDone) && (
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>{isDone ? "Hoàn tất!" : "Đang tải lên..."}</span>
<span>{uploadPercent}%</span>
</div>
<Progress value={uploadPercent} className="w-full" />
</div>
)}
<DialogFooter>
{!isDone ? (
<>
<Button
type="button"
variant="outline"
onClick={() => handleDialogClose(false)}
disabled={isUploading}
>
Hủy
</Button>
<Button type="submit" disabled={isUploading}>
{isUploading ? "Đang tải..." : "Upload"}
</Button>
</>
) : (
<Button type="button" onClick={() => handleDialogClose(false)}>
Hoàn tất
</Button>
)}
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,87 @@
import {
flexRender,
getCoreRowModel,
useReactTable,
type ColumnDef,
} from "@tanstack/react-table";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useEffect } from "react";
interface VersionTableProps<TData> {
data: TData[];
columns: ColumnDef<TData, any>[];
isLoading: boolean;
onTableInit?: (table: any) => void; // <-- thêm
}
export function VersionTable<TData>({
data,
columns,
isLoading,
onTableInit,
}: VersionTableProps<TData>) {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getRowId: (row: any) => row.id?.toString(),
enableRowSelection: true,
});
useEffect(() => {
onTableInit?.(table);
}, [table, onTableInit]);
return (
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={columns.length}>Đang tải dữ liệu...</TableCell>
</TableRow>
) : table.getRowModel().rows.length === 0 ? (
<TableRow>
<TableCell colSpan={columns.length}>Không dữ liệu.</TableCell>
</TableRow>
) : (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
);
}

View File

@ -4,108 +4,30 @@ export const BASE_URL = isDev
? import.meta.env.VITE_API_URL_DEV
: "/api";
export const BASE_MESH_URL = isDev
? import.meta.env.VITE_API_MESH_DEV
: "/meshapi";
export const API_ENDPOINTS = {
AUTH: {
LOGIN: `${BASE_URL}/login`,
SSO_LOGIN: `${BASE_URL}/auth/sso/login`,
SSO_EXCHANGE: `${BASE_URL}/auth/sso/exchange`,
LOGOUT: `${BASE_URL}/logout`,
CHANGE_PASSWORD: `${BASE_URL}/auth/change-password`,
CHANGE_PASSWORD_ADMIN: `${BASE_URL}/auth/admin/change-password`,
PING: `${BASE_URL}/ping`,
CSRF_TOKEN: `${BASE_URL}/csrf-token`,
CREATE_ACCOUNT: `${BASE_URL}/auth/create-account`,
GET_USERS_LIST: `${BASE_URL}/users-info`,
},
USER: {
UPDATE_INFO: (id: number) => `${BASE_URL}/User/${id}`,
UPDATE_ROLE: (id: number) => `${BASE_URL}/User/${id}/role`,
},
APP_VERSION: {
//agent and app api
GET_VERSION: `${BASE_URL}/AppVersion/version`,
UPLOAD: `${BASE_URL}/AppVersion/upload`,
GET_SOFTWARE: `${BASE_URL}/AppVersion/msifiles`,
//blacklist api
GET_BLACKLIST: `${BASE_URL}/AppVersion/blacklist`,
GET_BLACKLIST: `${BASE_URL}/AppVersion/blacklist`,
ADD_BLACKLIST: `${BASE_URL}/AppVersion/blacklist/add`,
DELETE_BLACKLIST: (appId: number) => `${BASE_URL}/AppVersion/blacklist/remove/${appId}`,
DELETE_BLACKLIST: (appId: string) => `${BASE_URL}/AppVersion/blacklist/remove/${appId}`,
UPDATE_BLACKLIST: (appId: string) => `${BASE_URL}/AppVersion/blacklist/update/${appId}`,
REQUEST_UPDATE_BLACKLIST: `${BASE_URL}/AppVersion/blacklist/request-update`,
//require file api
GET_REQUIRED_FILES: `${BASE_URL}/AppVersion/requirefiles`,
ADD_REQUIRED_FILE: `${BASE_URL}/AppVersion/requirefile/add`,
DELETE_REQUIRED_FILE: `${BASE_URL}/AppVersion/requirefile/delete`,
DELETE_FILES: `${BASE_URL}/AppVersion/delete`,
},
DEVICE_COMM: {
DOWNLOAD_FILES: (roomName: string) => `${BASE_URL}/DeviceComm/downloadfile/${roomName}`,
INSTALL_MSI: (roomName: string) => `${BASE_URL}/DeviceComm/installmsi/${roomName}`,
DOWNLOAD_MSI: (roomName: string) => `${BASE_URL}/DeviceComm/installmsi/${roomName}`,
GET_ALL_DEVICES: `${BASE_URL}/DeviceComm/alldevices`,
GET_ROOM_LIST: `${BASE_URL}/DeviceComm/rooms`,
GET_DEVICE_FROM_ROOM: (roomName: string) =>
`${BASE_URL}/DeviceComm/room/${roomName}`,
UPDATE_AGENT: (roomName: string) => `${BASE_URL}/DeviceComm/updateagent/${roomName}`,
UPDATE_BLACKLIST: (roomName: string) => `${BASE_URL}/DeviceComm/updateblacklist/${roomName}`,
SEND_COMMAND: (roomName: string) => `${BASE_URL}/DeviceComm/shellcommand/${roomName}`,
CHANGE_DEVICE_ROOM: `${BASE_URL}/DeviceComm/changeroom`,
REQUEST_GET_CLIENT_FOLDER_STATUS: (roomName: string) =>
`${BASE_URL}/DeviceComm/folderstatuses/${roomName}`,
},
COMMAND: {
ADD_COMMAND: `${BASE_URL}/Command/add`,
GET_COMMANDS: `${BASE_URL}/Command/all`,
GET_COMMAND_BY_TYPES: (types: string) => `${BASE_URL}/Command/types/${types}`,
UPDATE_COMMAND: (commandId: number) => `${BASE_URL}/Command/update/${commandId}`,
DELETE_COMMAND: (commandId: number) => `${BASE_URL}/Command/delete/${commandId}`,
GET_SENSITIVE_COMMANDS: `${BASE_URL}/Command/sensitive`,
REQUEST_SEND_SENSITIVE_COMMAND: `${BASE_URL}/Command/send-sensitive`,
},
SSE_EVENTS: {
DEVICE_ONLINE: `${BASE_URL}/Sse/events/onlineDevices`,
DEVICE_OFFLINE: `${BASE_URL}/Sse/events/offlineDevices`,
GET_PROCESSES_LISTS: `${BASE_URL}/Sse/events/processLists`,
GET_CLIENT_FOLDER_STATUS: `${BASE_URL}/Sse/events/clientFolderStatuses`,
GET_PROCESSES_LISTS: `${BASE_URL}/Sse/events/processLists`,
},
PERMISSION: {
GET_LIST: `${BASE_URL}/Permission/list`,
GET_BY_CATEGORY: `${BASE_URL}/Permission/list-by-category`,
GET_BY_VALUE: (value: number) => `${BASE_URL}/Permission/${value}`,
SEED_FROM_ENUM: `${BASE_URL}/Permission/seed-from-enum`,
GET_DB_LIST: `${BASE_URL}/Permission/db-list`,
DELETE: (id: number) => `${BASE_URL}/Permission/${id}`,
},
ROLE: {
GET_LIST: `${BASE_URL}/Role/list`,
GET_BY_ID: (id: number) => `${BASE_URL}/Role/${id}`,
CREATE: `${BASE_URL}/Role/create`,
UPDATE: (id: number) => `${BASE_URL}/Role/update/${id}`,
DELETE: (id: number) => `${BASE_URL}/Role/${id}`,
GET_PERMISSIONS: (id: number) => `${BASE_URL}/Role/${id}/permissions`,
ASSIGN_PERMISSIONS: (id: number) => `${BASE_URL}/Role/${id}/assign-permissions`,
TOGGLE_PERMISSION: (roleId: number, permissionId: number) =>
`${BASE_URL}/Role/${roleId}/permissions/${permissionId}/toggle`,
},
MESH_CENTRAL: {
GET_REMOTE_DESKTOP: (deviceId: string) =>
`${BASE_URL}/MeshCentral/devices/${encodeURIComponent(deviceId)}/remote-desktop`,
},
DASHBOARD: {
GET_SUMMARY: `${BASE_URL}/dashboard/summary`,
GET_GENERAL: `${BASE_URL}/dashboard/general`,
GET_ROOM_USAGE: `${BASE_URL}/dashboard/usage/rooms`,
GET_DEVICE_OVERVIEW: `${BASE_URL}/dashboard/devices/overview`,
GET_DEVICES_BY_ROOM: `${BASE_URL}/dashboard/devices/by-room`,
GET_ROOMS: `${BASE_URL}/dashboard/rooms`,
GET_SOFTWARE: `${BASE_URL}/dashboard/software`,
},
AUDIT: {
GET_AUDITS: `${BASE_URL}/Audit/audits`,
}
};

View File

@ -1,49 +0,0 @@
import axios from "axios";
// Re-export types from axios for convenience
export type { AxiosProgressEvent, AxiosRequestConfig, AxiosResponse, AxiosError } from "axios";
/**
* Axios instance với interceptor tự đng gửi token
*/
const axiosInstance = axios.create();
/**
* Request interceptor - Tự đng thêm Authorization header
*/
axiosInstance.interceptors.request.use(
(config) => {
// Lấy token từ localStorage
const token = localStorage.getItem("token");
// Nếu có token, thêm vào header Authorization
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
/**
* Response interceptor - Xử lỗi 401 (Unauthorized)
*/
axiosInstance.interceptors.response.use(
(response) => {
return response;
},
(error) => {
// Nếu nhận được 401, có thể redirect về trang login
if (error.response?.status === 401) {
// Có thể thêm logic redirect hoặc refresh token ở đây
console.warn("Unauthorized - Token may be expired or invalid");
}
return Promise.reject(error);
}
);
export default axiosInstance;

View File

@ -1,10 +0,0 @@
/**
* System-wide constants
*/
/**
* System Admin priority value
* Priority = 0 means highest permission level (System Admin)
* Lower priority number = Higher permission level
*/
export const SYSTEM_ADMIN_PRIORITY = 0;

View File

@ -1,9 +0,0 @@
import type { Room } from "@/types/room";
import type { SelectItem } from "@/components/dialogs/select-dialog";
export function mapRoomsToSelectItems(rooms: Room[]): SelectItem[] {
return rooms.map((room) => ({
label: `${room.name} (${room.numberOfDevices} máy, ${room.numberOfOfflineDevices} offline)`,
value: room.name,
}));
}

View File

@ -1,45 +0,0 @@
import { SYSTEM_ADMIN_PRIORITY } from "@/config/constants";
/**
* Check if a priority value indicates System Admin
* @param priority - The priority value to check
* @returns true if the priority is System Admin (0), false otherwise
*/
export function isSystemAdminPriority(priority: number): boolean {
return priority === SYSTEM_ADMIN_PRIORITY;
}
/**
* Check if a priority has higher permission than another
* Lower number = Higher permission (System Admin = 0 is highest)
* @param priority1 - First priority to compare
* @param priority2 - Second priority to compare
* @returns true if priority1 has higher or equal permission than priority2
*/
export function hasHigherOrEqualPriority(priority1: number, priority2: number): boolean {
return priority1 <= priority2;
}
/**
* Compare two priorities
* @param priority1 - First priority to compare
* @param priority2 - Second priority to compare
* @returns -1 if priority1 > priority2, 0 if equal, 1 if priority1 < priority2
*/
export function comparePriorities(priority1: number, priority2: number): -1 | 0 | 1 {
if (priority1 < priority2) return 1; // Lower number = higher permission
if (priority1 > priority2) return -1;
return 0;
}
/**
* Get a human-readable priority label
* @param priority - The priority value
* @returns A label for the priority
*/
export function getPriorityLabel(priority: number): string {
if (priority === SYSTEM_ADMIN_PRIORITY) {
return "System Admin (Highest)";
}
return `Priority ${priority}`;
}

View File

@ -1,25 +0,0 @@
// Auth Queries
export * from "./useAuthQueries";
// App Version Queries
export * from "./useAppVersionQueries";
// Device Communication Queries
export * from "./useDeviceCommQueries";
// Dashboard Queries
export * from "./useDashboardQueries";
// Command Queries
export * from "./useCommandQueries";
// Audit Queries
export * from "./useAuditQueries";
// Permission Queries
export * from "./usePermissionQueries";
// Role Queries
export * from "./useRoleQueries";
// User Queries
export * from "./useUserQueries";

View File

@ -1,186 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as appVersionService from "@/services/app-version.service";
import type { AxiosProgressEvent } from "axios";
import type { Version } from "@/types/file";
const APP_VERSION_QUERY_KEYS = {
all: ["app-version"] as const,
agentVersion: () => [...APP_VERSION_QUERY_KEYS.all, "agent"] as const,
softwareList: () => [...APP_VERSION_QUERY_KEYS.all, "software"] as const,
blacklist: () => [...APP_VERSION_QUERY_KEYS.all, "blacklist"] as const,
requiredFiles: () => [...APP_VERSION_QUERY_KEYS.all, "required-files"] as const,
};
/**
* Hook đ lấy danh sách phiên bản agent
*/
export function useGetAgentVersion(enabled = true) {
return useQuery<Version[]>({
queryKey: APP_VERSION_QUERY_KEYS.agentVersion(),
queryFn: () => appVersionService.getAgentVersion(),
enabled,
staleTime: 60 * 1000, // 1 minute
});
}
/**
* Hook đ lấy danh sách phần mềm
*/
export function useGetSoftwareList(enabled = true) {
return useQuery<Version[]>({
queryKey: APP_VERSION_QUERY_KEYS.softwareList(),
queryFn: () => appVersionService.getSoftwareList(),
enabled,
staleTime: 60 * 1000,
});
}
/**
* Hook đ upload file
*/
export function useUploadSoftware() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: {
formData: FormData;
onUploadProgress?: (progressEvent: AxiosProgressEvent) => void;
}) => appVersionService.uploadSoftware(data.formData, data.onUploadProgress),
onSuccess: () => {
// Invalidate software list
queryClient.invalidateQueries({
queryKey: APP_VERSION_QUERY_KEYS.softwareList(),
});
},
});
}
/**
* Hook đ lấy danh sách blacklist
*/
export function useGetBlacklist(enabled = true) {
return useQuery({
queryKey: APP_VERSION_QUERY_KEYS.blacklist(),
queryFn: () => appVersionService.getBlacklist(),
enabled,
staleTime: 60 * 1000,
});
}
/**
* Hook đ thêm vào blacklist
*/
export function useAddBlacklist() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: any) => appVersionService.addBlacklist(data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: APP_VERSION_QUERY_KEYS.blacklist(),
});
},
});
}
/**
* Hook đ xóa khỏi blacklist
*/
export function useDeleteBlacklist() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (appId: number) => appVersionService.deleteBlacklist(appId),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: APP_VERSION_QUERY_KEYS.blacklist(),
});
},
});
}
/**
* Hook đ cập nhật blacklist
*/
export function useUpdateBlacklist() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ appId, data }: { appId: string; data: any }) =>
appVersionService.updateBlacklist(appId, data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: APP_VERSION_QUERY_KEYS.blacklist(),
});
},
});
}
/**
* Hook đ yêu cầu cập nhật blacklist
*/
export function useRequestUpdateBlacklist() {
return useMutation({
mutationFn: (data: any) => appVersionService.requestUpdateBlacklist(data),
});
}
/**
* Hook đ lấy danh sách file bắt buộc
*/
export function useGetRequiredFiles(enabled = true) {
return useQuery({
queryKey: APP_VERSION_QUERY_KEYS.requiredFiles(),
queryFn: () => appVersionService.getRequiredFiles(),
enabled,
staleTime: 60 * 1000,
});
}
/**
* Hook đ thêm file bắt buộc
*/
export function useAddRequiredFile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: any) => appVersionService.addRequiredFile(data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: APP_VERSION_QUERY_KEYS.requiredFiles(),
});
},
});
}
/**
* Hook đ xóa file bắt buộc
*/
export function useDeleteRequiredFile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: { MsiFileIds: number[] }) => appVersionService.deleteRequiredFile(data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: APP_VERSION_QUERY_KEYS.requiredFiles(),
});
},
});
}
/**
* Hook đ xóa file
*/
export function useDeleteFile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: { MsiFileIds: number[] }) => appVersionService.deleteFile(data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: APP_VERSION_QUERY_KEYS.softwareList(),
});
},
});
}

View File

@ -1,37 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import * as auditService from "@/services/audit.service";
import type { PageResult, Audits } from "@/types/audit";
const AUDIT_QUERY_KEYS = {
all: ["audit"] as const,
list: () => [...AUDIT_QUERY_KEYS.all, "list"] as const,
audits: (params: any) => [...AUDIT_QUERY_KEYS.all, "audits", params] as const,
};
export function useGetAudits(
params: {
pageNumber?: number;
pageSize?: number;
username?: string | null;
action?: string | null;
from?: string | null;
to?: string | null;
} = { pageNumber: 1, pageSize: 20 },
enabled = true
) {
const { pageNumber = 1, pageSize = 20, username, action, from, to } = params;
return useQuery<PageResult<Audits>>({
queryKey: AUDIT_QUERY_KEYS.audits({ pageNumber, pageSize, username, action, from, to }),
queryFn: () =>
auditService.getAudits(
pageNumber,
pageSize,
username ?? null,
action ?? null,
from ?? null,
to ?? null
),
enabled,
});
}

View File

@ -1,124 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as authService from "@/services/auth.service";
import type { CreateAccountRequest } from "@/types/auth";
import type { LoginResquest, LoginResponse } from "@/types/auth";
const AUTH_QUERY_KEYS = {
all: ["auth"] as const,
ping: () => [...AUTH_QUERY_KEYS.all, "ping"] as const,
csrfToken: () => [...AUTH_QUERY_KEYS.all, "csrf-token"] as const,
};
/**
* Hook đ đăng nhập
*/
export function useLogin() {
const queryClient = useQueryClient();
return useMutation<LoginResponse, any, LoginResquest>({
mutationFn: (credentials) => authService.login(credentials),
onSuccess: (data) => {
// Lưu vào localStorage
if (data.token) {
localStorage.setItem("token", data.token);
localStorage.setItem("username", data.username || "");
localStorage.setItem("name", data.name || "");
localStorage.setItem("acs", JSON.stringify(data.access || []));
localStorage.setItem("role", data.role?.roleName || "");
localStorage.setItem("priority", String(data.role?.priority || "-1"));
}
// Invalidate ping query
queryClient.invalidateQueries({ queryKey: AUTH_QUERY_KEYS.ping() });
},
});
}
/**
* Hook đ đăng xuất
*/
export function useLogout() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => authService.logout(),
onSuccess: () => {
localStorage.removeItem("token");
localStorage.removeItem("username");
localStorage.removeItem("name");
localStorage.removeItem("acs");
localStorage.removeItem("role");
localStorage.removeItem("priority");
// Clear all queries
queryClient.clear();
},
});
}
/**
* Hook đ kiểm tra phiên đăng nhập
*/
export function usePing(token?: string, enabled = true) {
return useQuery({
queryKey: AUTH_QUERY_KEYS.ping(),
queryFn: () => authService.ping(token),
enabled,
staleTime: 5 * 60 * 1000, // 5 minutes
retry: 1,
});
}
/**
* Hook đ lấy CSRF token
*/
export function useGetCsrfToken(enabled = true) {
return useQuery({
queryKey: AUTH_QUERY_KEYS.csrfToken(),
queryFn: () => authService.getCsrfToken(),
enabled,
staleTime: Infinity,
});
}
/**
* Hook đ thay đi mật khẩu
*/
export function useChangePassword() {
return useMutation({
mutationFn: (data: { currentPassword: string; newPassword: string }) =>
authService.changePassword(data),
});
}
/**
* Hook đ admin thay đi mật khẩu user khác
*/
export function useChangePasswordAdmin() {
return useMutation({
mutationFn: (data: { username: string; newPassword: string }) =>
authService.changePasswordAdmin(data),
});
}
/**
* Hook đ tạo tài khoản mới
*/
export function useCreateAccount() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateAccountRequest) => authService.createAccount(data),
onSuccess: () => {
// Có thể invalidate user list query nếu có
queryClient.invalidateQueries({ queryKey: ["users"] });
},
});
}
/**
* Hook đ đi one-time code SSO lấy payload đăng nhập
*/
export function useExchangeSsoCode() {
return useMutation<LoginResponse, any, string>({
mutationFn: (code) => authService.exchangeSsoCode(code),
});
}

View File

@ -1,128 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as commandService from "@/services/command.service";
const COMMAND_QUERY_KEYS = {
all: ["commands"] as const,
list: () => [...COMMAND_QUERY_KEYS.all, "list"] as const,
detail: (id: number) => [...COMMAND_QUERY_KEYS.all, "detail", id] as const,
};
/**
* Hook đ lấy danh sách lệnh
*/
export function useGetCommandList(enabled = true) {
return useQuery({
queryKey: COMMAND_QUERY_KEYS.list(),
queryFn: () => commandService.getCommandList(),
enabled,
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
//Hook để lấy lệnh theo loại
export function useGetCommandsByTypes(types: string, enabled = true) {
return useQuery({
queryKey: [...COMMAND_QUERY_KEYS.all, "by-types", types],
queryFn: () => commandService.getCommandsByTypes(types),
enabled,
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
/**
* Hook đ thêm lệnh mới
*/
export function useAddCommand() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: any) => commandService.addCommand(data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: COMMAND_QUERY_KEYS.list(),
});
},
});
}
/**
* Hook đ cập nhật lệnh
*/
export function useUpdateCommand() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
commandId,
data,
}: {
commandId: number;
data: any;
}) => commandService.updateCommand(commandId, data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: COMMAND_QUERY_KEYS.list(),
});
},
});
}
/**
* Hook đ xóa lệnh
*/
export function useDeleteCommand() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (commandId: number) => commandService.deleteCommand(commandId),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: COMMAND_QUERY_KEYS.list(),
});
},
});
}
/**
* Hook đ lấy danh sách lệnh nhạy cảm
*/
export function useGetSensitiveCommands(enabled = true) {
return useQuery({
queryKey: [...COMMAND_QUERY_KEYS.all, "sensitive"],
queryFn: () => commandService.getSensitiveCommands(),
enabled,
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
/**
* Hook đ gửi lệnh nhạy cảm
*/
export function useExecuteSensitiveCommand() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
roomName,
command,
password,
}: {
roomName: string;
command: any;
password: string;
}) =>
// API expects a SensitiveCommandRequest with PascalCase keys
commandService.requestSendSensitiveCommand({
Command: command,
Password: password,
RoomName: roomName,
}),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [...COMMAND_QUERY_KEYS.all, "sensitive"],
});
},
});
}

View File

@ -1,85 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import * as dashboardService from "@/services/dashboard.service";
import type {
DashboardSummaryResponse,
DashboardGeneralInfo,
DeviceOverviewResponse,
DeviceStatusByRoom,
RoomManagementResponse,
RoomUsageResponse,
SoftwareDistributionResponse,
} from "@/types/dashboard";
const DASHBOARD_QUERY_KEYS = {
all: ["dashboard"] as const,
summary: () => [...DASHBOARD_QUERY_KEYS.all, "summary"] as const,
general: () => [...DASHBOARD_QUERY_KEYS.all, "general"] as const,
roomUsage: () => [...DASHBOARD_QUERY_KEYS.all, "usage", "rooms"] as const,
deviceOverview: () => [...DASHBOARD_QUERY_KEYS.all, "devices", "overview"] as const,
devicesByRoom: () => [...DASHBOARD_QUERY_KEYS.all, "devices", "by-room"] as const,
rooms: () => [...DASHBOARD_QUERY_KEYS.all, "rooms"] as const,
software: () => [...DASHBOARD_QUERY_KEYS.all, "software"] as const,
};
export function useGetDashboardSummary(enabled = true) {
return useQuery<DashboardSummaryResponse>({
queryKey: DASHBOARD_QUERY_KEYS.summary(),
queryFn: () => dashboardService.getDashboardSummary(),
enabled,
staleTime: 60 * 1000,
});
}
export function useGetDashboardGeneralInfo(enabled = true) {
return useQuery<DashboardGeneralInfo>({
queryKey: DASHBOARD_QUERY_KEYS.general(),
queryFn: () => dashboardService.getDashboardGeneralInfo(),
enabled,
staleTime: 60 * 1000,
});
}
export function useGetRoomUsage(enabled = true) {
return useQuery<RoomUsageResponse>({
queryKey: DASHBOARD_QUERY_KEYS.roomUsage(),
queryFn: () => dashboardService.getRoomUsage(),
enabled,
staleTime: 5 * 60 * 1000,
});
}
export function useGetDeviceOverview(enabled = true) {
return useQuery<DeviceOverviewResponse>({
queryKey: DASHBOARD_QUERY_KEYS.deviceOverview(),
queryFn: () => dashboardService.getDeviceOverview(),
enabled,
staleTime: 30 * 1000,
});
}
export function useGetDeviceStatusByRoom(enabled = true) {
return useQuery<DeviceStatusByRoom[]>({
queryKey: DASHBOARD_QUERY_KEYS.devicesByRoom(),
queryFn: () => dashboardService.getDeviceStatusByRoom(),
enabled,
staleTime: 5 * 60 * 1000,
});
}
export function useGetRoomManagement(enabled = true) {
return useQuery<RoomManagementResponse>({
queryKey: DASHBOARD_QUERY_KEYS.rooms(),
queryFn: () => dashboardService.getRoomManagement(),
enabled,
staleTime: 5 * 60 * 1000,
});
}
export function useGetSoftwareDistribution(enabled = true) {
return useQuery<SoftwareDistributionResponse>({
queryKey: DASHBOARD_QUERY_KEYS.software(),
queryFn: () => dashboardService.getSoftwareDistribution(),
enabled,
staleTime: 60 * 1000,
});
}

View File

@ -1,198 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as deviceCommService from "@/services/device-comm.service";
import type { DeviceHealthCheck } from "@/types/device";
import type { ClientFolderStatus } from "@/types/folder";
const DEVICE_COMM_QUERY_KEYS = {
all: ["device-comm"] as const,
allDevices: () => [...DEVICE_COMM_QUERY_KEYS.all, "all"] as const,
roomList: () => [...DEVICE_COMM_QUERY_KEYS.all, "rooms"] as const,
devicesInRoom: (roomName: string) =>
[...DEVICE_COMM_QUERY_KEYS.all, "room", roomName] as const,
clientFolderStatus: (roomName: string) =>
[...DEVICE_COMM_QUERY_KEYS.all, "folder-status", roomName] as const,
};
/**
* Hook đ lấy tất cả thiết bị
*/
export function useGetAllDevices(enabled = true) {
return useQuery({
queryKey: DEVICE_COMM_QUERY_KEYS.allDevices(),
queryFn: () => deviceCommService.getAllDevices(),
enabled,
staleTime: 60 * 1000,
});
}
/**
* Hook đ lấy danh sách phòng
*/
export function useGetRoomList(enabled = true) {
return useQuery({
queryKey: DEVICE_COMM_QUERY_KEYS.roomList(),
queryFn: () => deviceCommService.getRoomList(),
enabled,
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
/**
* Hook đ lấy danh sách thiết bị trong phòng
*/
export function useGetDeviceFromRoom(roomName?: string, enabled = true) {
return useQuery<DeviceHealthCheck[]>({
queryKey: roomName ? DEVICE_COMM_QUERY_KEYS.devicesInRoom(roomName) : ["disabled"],
queryFn: () =>
roomName ? deviceCommService.getDeviceFromRoom(roomName) : Promise.reject("No room"),
enabled: enabled && !!roomName,
staleTime: 30 * 1000, // 30 seconds
});
}
/**
* Hook đ tải file
*/
export function useDownloadFiles() {
return useMutation({
mutationFn: ({
roomName,
data,
}: {
roomName: string;
data: any;
}) => deviceCommService.downloadFiles(roomName, data),
});
}
/**
* Hook đ cài đt MSI
*/
export function useInstallMsi() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
roomName,
data,
}: {
roomName: string;
data: any;
}) => deviceCommService.installMsi(roomName, data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: DEVICE_COMM_QUERY_KEYS.all,
});
},
});
}
/**
* Hook đ cập nhật agent
*/
export function useUpdateAgent() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
roomName,
data,
}: {
roomName: string;
data: any;
}) => deviceCommService.updateAgent(roomName, data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: DEVICE_COMM_QUERY_KEYS.all,
});
},
});
}
/**
* Hook đ cập nhật blacklist
*/
export function useUpdateDeviceBlacklist() {
return useMutation({
mutationFn: ({
roomName,
data,
}: {
roomName: string;
data: any;
}) => deviceCommService.updateBlacklist(roomName, data),
});
}
/**
* Hook đ gửi lệnh shell
*/
export function useSendCommand() {
return useMutation({
mutationFn: ({
roomName,
data,
}: {
roomName: string;
data: any;
}) => deviceCommService.sendCommand(roomName, data),
});
}
/**
* Hook đ thay đi phòng của thiết bị
*/
export function useChangeDeviceRoom() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: any) => deviceCommService.changeDeviceRoom(data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: DEVICE_COMM_QUERY_KEYS.all,
});
},
});
}
/**
* Hook đ lấy trạng thái folder client
*/
export function useGetClientFolderStatus(roomName?: string, enabled = true) {
return useQuery<ClientFolderStatus[]>({
queryKey: roomName
? DEVICE_COMM_QUERY_KEYS.clientFolderStatus(roomName)
: ["disabled"],
queryFn: () =>
roomName
? deviceCommService.getClientFolderStatus(roomName)
: Promise.reject("No room"),
enabled: enabled && !!roomName,
staleTime: 30 * 1000,
});
}
/**
* Hook to get folder status for a single device. The hook will fetch the
* folder status list for the device's room and return the matching entry
* for the provided `deviceId`.
*/
export function useGetClientFolderStatusForDevice(
deviceId?: string,
roomName?: string,
enabled = true
) {
return useQuery<ClientFolderStatus | undefined>({
queryKey: deviceId
? [...DEVICE_COMM_QUERY_KEYS.all, "folder-status-device", deviceId]
: ["disabled"],
queryFn: async () => {
if (!roomName) return Promise.reject("No room");
const list = await deviceCommService.getClientFolderStatus(roomName);
if (!Array.isArray(list)) return undefined;
return list.find((s: ClientFolderStatus) => s.deviceId === deviceId);
},
enabled: enabled && !!deviceId && !!roomName,
staleTime: 30 * 1000,
});
}

View File

@ -1,90 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as permissionService from "@/services/permission.service";
export const PERMISSION_QUERY_KEYS = {
all: ["permissions"] as const,
list: () => [...PERMISSION_QUERY_KEYS.all, "list"] as const,
dbList: () => [...PERMISSION_QUERY_KEYS.all, "db-list"] as const,
byCategory: () => [...PERMISSION_QUERY_KEYS.all, "by-category"] as const,
detail: (value: number) => [...PERMISSION_QUERY_KEYS.all, "detail", value] as const,
};
/**
* Hook đ lấy danh sách permission từ enum
*/
export function useGetPermissionList(enabled = true) {
return useQuery({
queryKey: PERMISSION_QUERY_KEYS.list(),
queryFn: () => permissionService.getPermissionList(),
enabled,
staleTime: 10 * 60 * 1000, // 10 minutes
});
}
/**
* Hook đ lấy permission theo category
*/
export function useGetPermissionByCategory(enabled = true) {
return useQuery({
queryKey: PERMISSION_QUERY_KEYS.byCategory(),
queryFn: () => permissionService.getPermissionByCategory(),
enabled,
staleTime: 10 * 60 * 1000, // 10 minutes
});
}
/**
* Hook đ lấy chi tiết permission theo value
*/
export function useGetPermissionByValue(value: number, enabled = true) {
return useQuery({
queryKey: PERMISSION_QUERY_KEYS.detail(value),
queryFn: () => permissionService.getPermissionByValue(value),
enabled: enabled && value > 0,
staleTime: 10 * 60 * 1000, // 10 minutes
});
}
/**
* Hook đ lấy danh sách permission từ database
*/
export function useGetPermissionDbList(enabled = true) {
return useQuery({
queryKey: PERMISSION_QUERY_KEYS.dbList(),
queryFn: () => permissionService.getPermissionDbList(),
enabled,
staleTime: 10 * 60 * 1000, // 10 minutes
});
}
/**
* Hook đ seed permission từ enum vào DB
*/
export function useSeedPermissionFromEnum() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => permissionService.seedPermissionFromEnum(),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: PERMISSION_QUERY_KEYS.all,
});
},
});
}
/**
* Hook đ xóa permission
*/
export function useDeletePermission() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => permissionService.deletePermission(id),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: PERMISSION_QUERY_KEYS.all,
});
},
});
}

View File

@ -1,149 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as roleService from "@/services/role.service";
import type { TCreateRoleRequestBody } from "@/types/role";
export const ROLE_QUERY_KEYS = {
all: ["roles"] as const,
list: () => [...ROLE_QUERY_KEYS.all, "list"] as const,
detail: (id: number) => [...ROLE_QUERY_KEYS.all, "detail", id] as const,
permissions: (id: number) => [...ROLE_QUERY_KEYS.all, "permissions", id] as const,
};
/**
* Hook đ lấy danh sách roles
*/
export function useGetRoleList(enabled = true) {
return useQuery({
queryKey: ROLE_QUERY_KEYS.list(),
queryFn: () => roleService.getRoleList(),
enabled,
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
/**
* Hook đ lấy chi tiết role theo ID
*/
export function useGetRoleById(id: number, enabled = true) {
return useQuery({
queryKey: ROLE_QUERY_KEYS.detail(id),
queryFn: () => roleService.getRoleById(id),
enabled: enabled && id > 0,
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
/**
* Hook đ lấy danh sách permissions của role
*/
export function useGetRolePermissions(id: number, enabled = true) {
return useQuery({
queryKey: ROLE_QUERY_KEYS.permissions(id),
queryFn: () => roleService.getRolePermissions(id),
enabled: enabled && id > 0,
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
/**
* Hook đ tạo role mới
*/
export function useCreateRole() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: TCreateRoleRequestBody) => roleService.createRole(data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ROLE_QUERY_KEYS.list(),
});
},
});
}
/**
* Hook đ cập nhật role
*/
export function useUpdateRole() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
data,
}: {
id: number;
data: Partial<TCreateRoleRequestBody>;
}) => roleService.updateRole(id, data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ROLE_QUERY_KEYS.list(),
});
queryClient.invalidateQueries({
queryKey: ROLE_QUERY_KEYS.detail(variables.id),
});
},
});
}
/**
* Hook đ xóa role
*/
export function useDeleteRole() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => roleService.deleteRole(id),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ROLE_QUERY_KEYS.list(),
});
},
});
}
/**
* Hook đ gán permissions cho role
*/
export function useAssignRolePermissions() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
roleId,
permissionIds,
}: {
roleId: number;
permissionIds: number[];
}) => roleService.assignRolePermissions(roleId, permissionIds),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ROLE_QUERY_KEYS.permissions(variables.roleId),
});
},
});
}
/**
* Hook đ bật/tắt một permission của role
*/
export function useToggleRolePermission() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
roleId,
permissionId,
isChecked,
}: {
roleId: number;
permissionId: number;
isChecked: boolean;
}) => roleService.toggleRolePermission(roleId, permissionId, isChecked),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ROLE_QUERY_KEYS.permissions(variables.roleId),
});
},
});
}

View File

@ -1,68 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as userService from "@/services/user.service";
import type {
UserProfile,
UpdateUserInfoRequest,
UpdateUserRoleRequest,
} from "@/types/user-profile";
const USER_QUERY_KEYS = {
all: ["users"] as const,
list: () => [...USER_QUERY_KEYS.all, "list"] as const,
};
/**
* Hook đ lấy danh sách thông tin người dùng
*/
export function useGetUsersInfo(enabled = true) {
return useQuery<UserProfile[]>({
queryKey: USER_QUERY_KEYS.list(),
queryFn: () => userService.getUsersInfo(),
enabled,
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
/**
* Hook đ cập nhật thông tin người dùng
*/
export function useUpdateUserInfo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
data,
}: {
id: number;
data: UpdateUserInfoRequest;
}) => userService.updateUserInfo(id, data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: USER_QUERY_KEYS.list(),
});
},
});
}
/**
* Hook đ cập nhật role người dùng
*/
export function useUpdateUserRole() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
data,
}: {
id: number;
data: UpdateUserRoleRequest;
}) => userService.updateUserRole(id, data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: USER_QUERY_KEYS.list(),
});
},
});
}

View File

@ -1,113 +0,0 @@
import { sleep } from "@/lib/utils";
import { PermissionEnum } from "@/types/permission";
import { SYSTEM_ADMIN_PRIORITY } from "@/config/constants";
import React, { useContext, useEffect, useMemo } from "react";
import { useCallback, useState } from "react";
export interface IAuthContext {
isAuthenticated: boolean;
setAuthenticated: (value: boolean) => void;
logout: () => Promise<void>;
login: (username: string) => void;
username: string;
token: string;
name: string;
acs: number[];
hasPermission: (permission: PermissionEnum) => boolean;
isSystemAdmin: () => boolean;
role: {
roleName: string;
priority: number;
};
}
const AuthContext = React.createContext<IAuthContext | null>(null);
const key = "computersmanagement.auth.user";
function getStoredUser() {
return localStorage.getItem(key);
}
function setStoredUser(user: string | null) {
if (user) {
localStorage.setItem(key, user);
} else {
localStorage.removeItem(key);
}
}
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<string>(getStoredUser() || "");
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(!!user);
const token = localStorage.getItem("token") || "";
const name = localStorage.getItem("name") || "";
const acsString = localStorage.getItem("acs");
const acs = useMemo(() => (acsString ? acsString.split(",").map(Number) : []), [acsString]);
const roleName = localStorage.getItem("role") || "";
const priority = localStorage.getItem("priority") || "-1";
const setAuthenticated = useCallback((value: boolean) => {
setIsAuthenticated(value);
}, []);
const login = useCallback((username: string) => {
setStoredUser(username);
setUser(username);
}, []);
const hasPermission = useCallback(
(permission: PermissionEnum) => {
return acs.some((a) => a === permission);
},
[acs]
);
const isSystemAdmin = useCallback(() => {
return Number(priority) === SYSTEM_ADMIN_PRIORITY;
}, [priority]);
const logout = useCallback(async () => {
await sleep(250);
setAuthenticated(false);
setStoredUser("");
setUser("");
localStorage.removeItem("token");
localStorage.removeItem("name");
localStorage.removeItem("acs");
localStorage.removeItem("role");
localStorage.removeItem("priority");
}, [setAuthenticated]);
useEffect(() => {
setUser(getStoredUser() || "");
}, []);
return (
<AuthContext.Provider
value={{
isAuthenticated,
setAuthenticated,
login,
logout,
username: user,
token,
name,
acs,
role: { roleName, priority: Number(priority) },
hasPermission,
isSystemAdmin
}}>
{children}
</AuthContext.Provider>
);
}
// eslint-disable-next-line react-refresh/only-export-components
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}

View File

@ -0,0 +1,53 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import axios, { type Method } from "axios";
type MutationDataOptions<TInput, TOutput> = {
url: string;
method?: Method;
onSuccess?: (data: TOutput) => void;
onError?: (error: any) => void;
invalidate?: string[][];
};
export function useMutationData<TInput = any, TOutput = any>({
url,
method = "POST",
onSuccess,
onError,
invalidate = [],
}: MutationDataOptions<TInput, TOutput>) {
const queryClient = useQueryClient();
return useMutation<
TOutput,
any,
{ data: TInput; url?: string; config?: any; method?: Method }
>({
mutationFn: async ({
data,
config,
url: customUrl,
method: customMethod,
}) => {
const isFormData = data instanceof FormData;
const response = await axios.request({
url: customUrl ?? url,
method: customMethod ?? method,
data,
headers: {
...(isFormData ? {} : { "Content-Type": "application/json" }),
},
...config,
});
return response.data;
},
onSuccess: (data) => {
invalidate.forEach((key) =>
queryClient.invalidateQueries({ queryKey: key })
);
onSuccess?.(data);
},
onError,
});
}

26
src/hooks/useQueryData.ts Normal file
View File

@ -0,0 +1,26 @@
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
type QueryDataOptions<T> = {
queryKey: string[];
url: string;
params?: Record<string, any>;
select?: (data: any) => T;
enabled?: boolean;
}
export function useQueryData<T = any>({
queryKey,
url,
params,
select,
enabled = true,
}: QueryDataOptions<T>) {
return useQuery<T>({
queryKey,
queryFn: () => axios.get(url, { params }).then((res) => res.data),
select,
enabled,
})
}

View File

@ -1,12 +1,14 @@
import type { ReactNode } from "react";
import { AppSidebar } from "@/components/sidebars/app-sidebar";
import { AppSidebar } from "@/components/app-sidebar";
import {
SidebarProvider,
SidebarInset,
SidebarTrigger,
} from "@/components/ui/sidebar";
import { Building } from "lucide-react";
import { Home, Building, AppWindow, Terminal, CircleX } from "lucide-react";
import { Toaster } from "@/components/ui/sonner";
import { useQueryClient } from "@tanstack/react-query";
import { API_ENDPOINTS, BASE_URL } from "@/config/api";
import { Separator } from "@/components/ui/separator";
type AppLayoutProps = {
@ -14,10 +16,84 @@ type AppLayoutProps = {
};
export default function AppLayout({ children }: AppLayoutProps) {
const queryClient = useQueryClient();
const handlePrefetchAgents = () => {
queryClient.prefetchQuery({
queryKey: ["agent-version"],
queryFn: () =>
fetch(BASE_URL + API_ENDPOINTS.APP_VERSION.GET_VERSION).then((res) =>
res.json()
),
staleTime: 60 * 1000,
});
};
const handlePrefetchSofware = () => {
queryClient.prefetchQuery({
queryKey: ["software-version"],
queryFn: () =>
fetch(BASE_URL + API_ENDPOINTS.APP_VERSION.GET_SOFTWARE).then((res) =>
res.json()
),
staleTime: 60 * 1000,
});
};
const handlePrefetchRooms = () => {
queryClient.prefetchQuery({
queryKey: ["room-list"],
queryFn: () =>
fetch(BASE_URL + API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST).then((res) =>
res.json()
),
staleTime: 60 * 1000,
});
};
const handlePrefetchBannedSoftware = () => {
queryClient.prefetchQuery({
queryKey: ["blacklist"],
queryFn: () =>
fetch(BASE_URL + API_ENDPOINTS.APP_VERSION).then((res) =>
res.json()
),
staleTime: 60 * 1000,
});
};
const items = [
{ title: "Dashboard", to: "/", icon: Home },
{
title: "Danh sách phòng",
to: "/room",
icon: Building,
onPointerEnter: handlePrefetchRooms,
},
{
title: "Quản lý Agent",
to: "/agent",
icon: AppWindow,
onPointerEnter: handlePrefetchAgents,
},
{
title: "Quản lý phần mềm",
to: "/apps",
icon: AppWindow,
onPointerEnter: handlePrefetchSofware,
},
{ title: "Gửi lệnh CMD", to: "/command", icon: Terminal },
{
title: "Danh sách đen",
to: "/blacklist",
icon: CircleX,
onPointerEnter: handlePrefetchBannedSoftware,
},
];
return (
<SidebarProvider>
<div className="flex min-h-screen w-full bg-background">
<AppSidebar />
<AppSidebar items={items} />
<SidebarInset className="flex-1">
<header className="flex h-16 shrink-0 items-center gap-2 border-b border-border/40 bg-background/95 backdrop-blur px-4 lg:hidden supports-[backdrop-filter]:bg-background/60">
<SidebarTrigger className="-ml-1 hover:bg-accent/50 rounded-lg p-2 transition-colors" />

View File

@ -1,35 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { format } from "date-fns";
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
const DEFAULT_DATE_FORMAT = "dd/MM/yyyy";
export function getCurrentTimeUTC(): string {
const date = new Date();
const month = (date.getUTCMonth() + 1).toString().padStart(2, "0");
const day = date.getUTCDate().toString().padStart(2, "0");
const year = date.getUTCFullYear();
const hour = date.getUTCHours().toString().padStart(2, "0");
const min = date.getUTCMinutes().toString().padStart(2, "0");
return `${day}/${month}/${year} ${hour}:${min}`;
}
export function formatDate(
date: string | Date | undefined | null,
formatTemplate = DEFAULT_DATE_FORMAT
) {
if (date == undefined) return "";
if (date == null) return "";
return format(new Date(date), formatTemplate, {});
}

View File

@ -1,24 +1,25 @@
/* eslint-disable react-refresh/only-export-components */
import { StrictMode } from "react";
import "./index.css";
import ReactDOM from "react-dom/client";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import useAuthToken from "./hooks/useAuthtoken";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
// Import the generated route tree
import { routeTree } from "./routeTree.gen";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import axios from "axios";
import { AuthProvider, useAuth } from "@/hooks/useAuth";
import { toast, Toaster } from "sonner";
import "./styles.css";
const auth = useAuthToken.getState();
const queryClient = new QueryClient();
// Create a new router instance
const router = createRouter({
routeTree,
context: { auth },
defaultPreload: "intent",
scrollRestoration: true,
context: {
auth: undefined!, // This will be set after we initialize the auth store
queryClient: undefined!
}
defaultStructuralSharing: true,
defaultPreloadStaleTime: 0,
});
// Register the router instance for type safety
@ -26,61 +27,18 @@ declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
interface HistoryState {
name?: string;
}
}
function InnerApp() {
const auth = useAuth();
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error: unknown) => {
if (axios.isAxiosError(error)) {
if (error.response?.status === 401) {
if (error.response?.data.message != null) {
toast.error("Không có quyền truy cập!");
return false;
} else {
auth.logout();
queryClient.invalidateQueries();
queryClient.clear();
return false;
}
}
}
return failureCount < 3;
}
}
}
});
return (
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} context={{ auth, queryClient }} />
</QueryClientProvider>
);
}
function App() {
return (
<AuthProvider>
<InnerApp />
</AuthProvider>
);
}
// Render the app
const rootElement = document.getElementById("app");
if (!rootElement) {
throw new Error("Failed to find the root element");
if (rootElement && !rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement);
root.render(
<StrictMode>
<QueryClientProvider client={queryClient}>
{" "}
<RouterProvider router={router} />
</QueryClientProvider>
</StrictMode>
);
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<StrictMode>
<App />
<Toaster richColors />
</StrictMode>
);

View File

@ -9,33 +9,22 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
import { Route as AuthRouteImport } from './routes/_auth'
import { Route as IndexRouteImport } from './routes/index'
import { Route as AuthUserIndexRouteImport } from './routes/_auth/user/index'
import { Route as AuthRoomsIndexRouteImport } from './routes/_auth/rooms/index'
import { Route as AuthRoleIndexRouteImport } from './routes/_auth/role/index'
import { Route as AuthRemoteControlIndexRouteImport } from './routes/_auth/remote-control/index'
import { Route as AuthDeviceIndexRouteImport } from './routes/_auth/device/index'
import { Route as AuthDashboardIndexRouteImport } from './routes/_auth/dashboard/index'
import { Route as AuthCommandsIndexRouteImport } from './routes/_auth/commands/index'
import { Route as AuthBlacklistsIndexRouteImport } from './routes/_auth/blacklists/index'
import { Route as AuthAuditsIndexRouteImport } from './routes/_auth/audits/index'
import { Route as AuthAppsIndexRouteImport } from './routes/_auth/apps/index'
import { Route as AuthAgentIndexRouteImport } from './routes/_auth/agent/index'
import { Route as authLoginIndexRouteImport } from './routes/(auth)/login/index'
import { Route as AuthUserCreateIndexRouteImport } from './routes/_auth/user/create/index'
import { Route as AuthRoomsRoomNameIndexRouteImport } from './routes/_auth/rooms/$roomName/index'
import { Route as AuthRoleCreateIndexRouteImport } from './routes/_auth/role/create/index'
import { Route as AuthProfileChangePasswordIndexRouteImport } from './routes/_auth/profile/change-password/index'
import { Route as AuthProfileUserNameIndexRouteImport } from './routes/_auth/profile/$userName/index'
import { Route as authSsoCallbackIndexRouteImport } from './routes/(auth)/sso/callback/index'
import { Route as AuthUserRoleRoleIdIndexRouteImport } from './routes/_auth/user/role/$roleId/index'
import { Route as AuthUserEditUserNameIndexRouteImport } from './routes/_auth/user/edit/$userName/index'
import { Route as AuthUserChangePasswordUserNameIndexRouteImport } from './routes/_auth/user/change-password/$userName/index'
import { Route as AuthRoomsRoomNameFolderStatusIndexRouteImport } from './routes/_auth/rooms/$roomName/folder-status/index'
import { Route as AuthRoomsRoomNameConnectIndexRouteImport } from './routes/_auth/rooms/$roomName/connect/index'
import { Route as AuthRoleIdEditIndexRouteImport } from './routes/_auth/role/$id/edit/index'
import { Route as AuthenticatedRoomIndexRouteImport } from './routes/_authenticated/room/index'
import { Route as AuthenticatedDeviceIndexRouteImport } from './routes/_authenticated/device/index'
import { Route as AuthenticatedCommandIndexRouteImport } from './routes/_authenticated/command/index'
import { Route as AuthenticatedBlacklistIndexRouteImport } from './routes/_authenticated/blacklist/index'
import { Route as AuthenticatedAppsIndexRouteImport } from './routes/_authenticated/apps/index'
import { Route as AuthenticatedAgentIndexRouteImport } from './routes/_authenticated/agent/index'
import { Route as AuthLoginIndexRouteImport } from './routes/_auth/login/index'
import { Route as AuthenticatedRoomRoomNameIndexRouteImport } from './routes/_authenticated/room/$roomName/index'
const AuthenticatedRoute = AuthenticatedRouteImport.update({
id: '/_authenticated',
getParentRoute: () => rootRouteImport,
} as any)
const AuthRoute = AuthRouteImport.update({
id: '/_auth',
getParentRoute: () => rootRouteImport,
@ -45,215 +34,86 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const AuthUserIndexRoute = AuthUserIndexRouteImport.update({
id: '/user/',
path: '/user/',
getParentRoute: () => AuthRoute,
const AuthenticatedRoomIndexRoute = AuthenticatedRoomIndexRouteImport.update({
id: '/room/',
path: '/room/',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthRoomsIndexRoute = AuthRoomsIndexRouteImport.update({
id: '/rooms/',
path: '/rooms/',
getParentRoute: () => AuthRoute,
} as any)
const AuthRoleIndexRoute = AuthRoleIndexRouteImport.update({
id: '/role/',
path: '/role/',
getParentRoute: () => AuthRoute,
} as any)
const AuthRemoteControlIndexRoute = AuthRemoteControlIndexRouteImport.update({
id: '/remote-control/',
path: '/remote-control/',
getParentRoute: () => AuthRoute,
} as any)
const AuthDeviceIndexRoute = AuthDeviceIndexRouteImport.update({
id: '/device/',
path: '/device/',
getParentRoute: () => AuthRoute,
} as any)
const AuthDashboardIndexRoute = AuthDashboardIndexRouteImport.update({
id: '/dashboard/',
path: '/dashboard/',
getParentRoute: () => AuthRoute,
} as any)
const AuthCommandsIndexRoute = AuthCommandsIndexRouteImport.update({
id: '/commands/',
path: '/commands/',
getParentRoute: () => AuthRoute,
} as any)
const AuthBlacklistsIndexRoute = AuthBlacklistsIndexRouteImport.update({
id: '/blacklists/',
path: '/blacklists/',
getParentRoute: () => AuthRoute,
} as any)
const AuthAuditsIndexRoute = AuthAuditsIndexRouteImport.update({
id: '/audits/',
path: '/audits/',
getParentRoute: () => AuthRoute,
} as any)
const AuthAppsIndexRoute = AuthAppsIndexRouteImport.update({
const AuthenticatedDeviceIndexRoute =
AuthenticatedDeviceIndexRouteImport.update({
id: '/device/',
path: '/device/',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedCommandIndexRoute =
AuthenticatedCommandIndexRouteImport.update({
id: '/command/',
path: '/command/',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedBlacklistIndexRoute =
AuthenticatedBlacklistIndexRouteImport.update({
id: '/blacklist/',
path: '/blacklist/',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedAppsIndexRoute = AuthenticatedAppsIndexRouteImport.update({
id: '/apps/',
path: '/apps/',
getParentRoute: () => AuthRoute,
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthAgentIndexRoute = AuthAgentIndexRouteImport.update({
const AuthenticatedAgentIndexRoute = AuthenticatedAgentIndexRouteImport.update({
id: '/agent/',
path: '/agent/',
getParentRoute: () => AuthRoute,
getParentRoute: () => AuthenticatedRoute,
} as any)
const authLoginIndexRoute = authLoginIndexRouteImport.update({
id: '/(auth)/login/',
const AuthLoginIndexRoute = AuthLoginIndexRouteImport.update({
id: '/login/',
path: '/login/',
getParentRoute: () => rootRouteImport,
} as any)
const AuthUserCreateIndexRoute = AuthUserCreateIndexRouteImport.update({
id: '/user/create/',
path: '/user/create/',
getParentRoute: () => AuthRoute,
} as any)
const AuthRoomsRoomNameIndexRoute = AuthRoomsRoomNameIndexRouteImport.update({
id: '/rooms/$roomName/',
path: '/rooms/$roomName/',
getParentRoute: () => AuthRoute,
} as any)
const AuthRoleCreateIndexRoute = AuthRoleCreateIndexRouteImport.update({
id: '/role/create/',
path: '/role/create/',
getParentRoute: () => AuthRoute,
} as any)
const AuthProfileChangePasswordIndexRoute =
AuthProfileChangePasswordIndexRouteImport.update({
id: '/profile/change-password/',
path: '/profile/change-password/',
getParentRoute: () => AuthRoute,
const AuthenticatedRoomRoomNameIndexRoute =
AuthenticatedRoomRoomNameIndexRouteImport.update({
id: '/room/$roomName/',
path: '/room/$roomName/',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthProfileUserNameIndexRoute =
AuthProfileUserNameIndexRouteImport.update({
id: '/profile/$userName/',
path: '/profile/$userName/',
getParentRoute: () => AuthRoute,
} as any)
const authSsoCallbackIndexRoute = authSsoCallbackIndexRouteImport.update({
id: '/(auth)/sso/callback/',
path: '/sso/callback/',
getParentRoute: () => rootRouteImport,
} as any)
const AuthUserRoleRoleIdIndexRoute = AuthUserRoleRoleIdIndexRouteImport.update({
id: '/user/role/$roleId/',
path: '/user/role/$roleId/',
getParentRoute: () => AuthRoute,
} as any)
const AuthUserEditUserNameIndexRoute =
AuthUserEditUserNameIndexRouteImport.update({
id: '/user/edit/$userName/',
path: '/user/edit/$userName/',
getParentRoute: () => AuthRoute,
} as any)
const AuthUserChangePasswordUserNameIndexRoute =
AuthUserChangePasswordUserNameIndexRouteImport.update({
id: '/user/change-password/$userName/',
path: '/user/change-password/$userName/',
getParentRoute: () => AuthRoute,
} as any)
const AuthRoomsRoomNameFolderStatusIndexRoute =
AuthRoomsRoomNameFolderStatusIndexRouteImport.update({
id: '/rooms/$roomName/folder-status/',
path: '/rooms/$roomName/folder-status/',
getParentRoute: () => AuthRoute,
} as any)
const AuthRoomsRoomNameConnectIndexRoute =
AuthRoomsRoomNameConnectIndexRouteImport.update({
id: '/rooms/$roomName/connect/',
path: '/rooms/$roomName/connect/',
getParentRoute: () => AuthRoute,
} as any)
const AuthRoleIdEditIndexRoute = AuthRoleIdEditIndexRouteImport.update({
id: '/role/$id/edit/',
path: '/role/$id/edit/',
getParentRoute: () => AuthRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/login': typeof authLoginIndexRoute
'/agent': typeof AuthAgentIndexRoute
'/apps': typeof AuthAppsIndexRoute
'/audits': typeof AuthAuditsIndexRoute
'/blacklists': typeof AuthBlacklistsIndexRoute
'/commands': typeof AuthCommandsIndexRoute
'/dashboard': typeof AuthDashboardIndexRoute
'/device': typeof AuthDeviceIndexRoute
'/remote-control': typeof AuthRemoteControlIndexRoute
'/role': typeof AuthRoleIndexRoute
'/rooms': typeof AuthRoomsIndexRoute
'/user': typeof AuthUserIndexRoute
'/sso/callback': typeof authSsoCallbackIndexRoute
'/profile/$userName': typeof AuthProfileUserNameIndexRoute
'/profile/change-password': typeof AuthProfileChangePasswordIndexRoute
'/role/create': typeof AuthRoleCreateIndexRoute
'/rooms/$roomName': typeof AuthRoomsRoomNameIndexRoute
'/user/create': typeof AuthUserCreateIndexRoute
'/role/$id/edit': typeof AuthRoleIdEditIndexRoute
'/rooms/$roomName/connect': typeof AuthRoomsRoomNameConnectIndexRoute
'/rooms/$roomName/folder-status': typeof AuthRoomsRoomNameFolderStatusIndexRoute
'/user/change-password/$userName': typeof AuthUserChangePasswordUserNameIndexRoute
'/user/edit/$userName': typeof AuthUserEditUserNameIndexRoute
'/user/role/$roleId': typeof AuthUserRoleRoleIdIndexRoute
'/login': typeof AuthLoginIndexRoute
'/agent': typeof AuthenticatedAgentIndexRoute
'/apps': typeof AuthenticatedAppsIndexRoute
'/blacklist': typeof AuthenticatedBlacklistIndexRoute
'/command': typeof AuthenticatedCommandIndexRoute
'/device': typeof AuthenticatedDeviceIndexRoute
'/room': typeof AuthenticatedRoomIndexRoute
'/room/$roomName': typeof AuthenticatedRoomRoomNameIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/login': typeof authLoginIndexRoute
'/agent': typeof AuthAgentIndexRoute
'/apps': typeof AuthAppsIndexRoute
'/audits': typeof AuthAuditsIndexRoute
'/blacklists': typeof AuthBlacklistsIndexRoute
'/commands': typeof AuthCommandsIndexRoute
'/dashboard': typeof AuthDashboardIndexRoute
'/device': typeof AuthDeviceIndexRoute
'/remote-control': typeof AuthRemoteControlIndexRoute
'/role': typeof AuthRoleIndexRoute
'/rooms': typeof AuthRoomsIndexRoute
'/user': typeof AuthUserIndexRoute
'/sso/callback': typeof authSsoCallbackIndexRoute
'/profile/$userName': typeof AuthProfileUserNameIndexRoute
'/profile/change-password': typeof AuthProfileChangePasswordIndexRoute
'/role/create': typeof AuthRoleCreateIndexRoute
'/rooms/$roomName': typeof AuthRoomsRoomNameIndexRoute
'/user/create': typeof AuthUserCreateIndexRoute
'/role/$id/edit': typeof AuthRoleIdEditIndexRoute
'/rooms/$roomName/connect': typeof AuthRoomsRoomNameConnectIndexRoute
'/rooms/$roomName/folder-status': typeof AuthRoomsRoomNameFolderStatusIndexRoute
'/user/change-password/$userName': typeof AuthUserChangePasswordUserNameIndexRoute
'/user/edit/$userName': typeof AuthUserEditUserNameIndexRoute
'/user/role/$roleId': typeof AuthUserRoleRoleIdIndexRoute
'/login': typeof AuthLoginIndexRoute
'/agent': typeof AuthenticatedAgentIndexRoute
'/apps': typeof AuthenticatedAppsIndexRoute
'/blacklist': typeof AuthenticatedBlacklistIndexRoute
'/command': typeof AuthenticatedCommandIndexRoute
'/device': typeof AuthenticatedDeviceIndexRoute
'/room': typeof AuthenticatedRoomIndexRoute
'/room/$roomName': typeof AuthenticatedRoomRoomNameIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/_auth': typeof AuthRouteWithChildren
'/(auth)/login/': typeof authLoginIndexRoute
'/_auth/agent/': typeof AuthAgentIndexRoute
'/_auth/apps/': typeof AuthAppsIndexRoute
'/_auth/audits/': typeof AuthAuditsIndexRoute
'/_auth/blacklists/': typeof AuthBlacklistsIndexRoute
'/_auth/commands/': typeof AuthCommandsIndexRoute
'/_auth/dashboard/': typeof AuthDashboardIndexRoute
'/_auth/device/': typeof AuthDeviceIndexRoute
'/_auth/remote-control/': typeof AuthRemoteControlIndexRoute
'/_auth/role/': typeof AuthRoleIndexRoute
'/_auth/rooms/': typeof AuthRoomsIndexRoute
'/_auth/user/': typeof AuthUserIndexRoute
'/(auth)/sso/callback/': typeof authSsoCallbackIndexRoute
'/_auth/profile/$userName/': typeof AuthProfileUserNameIndexRoute
'/_auth/profile/change-password/': typeof AuthProfileChangePasswordIndexRoute
'/_auth/role/create/': typeof AuthRoleCreateIndexRoute
'/_auth/rooms/$roomName/': typeof AuthRoomsRoomNameIndexRoute
'/_auth/user/create/': typeof AuthUserCreateIndexRoute
'/_auth/role/$id/edit/': typeof AuthRoleIdEditIndexRoute
'/_auth/rooms/$roomName/connect/': typeof AuthRoomsRoomNameConnectIndexRoute
'/_auth/rooms/$roomName/folder-status/': typeof AuthRoomsRoomNameFolderStatusIndexRoute
'/_auth/user/change-password/$userName/': typeof AuthUserChangePasswordUserNameIndexRoute
'/_auth/user/edit/$userName/': typeof AuthUserEditUserNameIndexRoute
'/_auth/user/role/$roleId/': typeof AuthUserRoleRoleIdIndexRoute
'/_authenticated': typeof AuthenticatedRouteWithChildren
'/_auth/login/': typeof AuthLoginIndexRoute
'/_authenticated/agent/': typeof AuthenticatedAgentIndexRoute
'/_authenticated/apps/': typeof AuthenticatedAppsIndexRoute
'/_authenticated/blacklist/': typeof AuthenticatedBlacklistIndexRoute
'/_authenticated/command/': typeof AuthenticatedCommandIndexRoute
'/_authenticated/device/': typeof AuthenticatedDeviceIndexRoute
'/_authenticated/room/': typeof AuthenticatedRoomIndexRoute
'/_authenticated/room/$roomName/': typeof AuthenticatedRoomRoomNameIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@ -262,93 +122,52 @@ export interface FileRouteTypes {
| '/login'
| '/agent'
| '/apps'
| '/audits'
| '/blacklists'
| '/commands'
| '/dashboard'
| '/blacklist'
| '/command'
| '/device'
| '/remote-control'
| '/role'
| '/rooms'
| '/user'
| '/sso/callback'
| '/profile/$userName'
| '/profile/change-password'
| '/role/create'
| '/rooms/$roomName'
| '/user/create'
| '/role/$id/edit'
| '/rooms/$roomName/connect'
| '/rooms/$roomName/folder-status'
| '/user/change-password/$userName'
| '/user/edit/$userName'
| '/user/role/$roleId'
| '/room'
| '/room/$roomName'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/login'
| '/agent'
| '/apps'
| '/audits'
| '/blacklists'
| '/commands'
| '/dashboard'
| '/blacklist'
| '/command'
| '/device'
| '/remote-control'
| '/role'
| '/rooms'
| '/user'
| '/sso/callback'
| '/profile/$userName'
| '/profile/change-password'
| '/role/create'
| '/rooms/$roomName'
| '/user/create'
| '/role/$id/edit'
| '/rooms/$roomName/connect'
| '/rooms/$roomName/folder-status'
| '/user/change-password/$userName'
| '/user/edit/$userName'
| '/user/role/$roleId'
| '/room'
| '/room/$roomName'
id:
| '__root__'
| '/'
| '/_auth'
| '/(auth)/login/'
| '/_auth/agent/'
| '/_auth/apps/'
| '/_auth/audits/'
| '/_auth/blacklists/'
| '/_auth/commands/'
| '/_auth/dashboard/'
| '/_auth/device/'
| '/_auth/remote-control/'
| '/_auth/role/'
| '/_auth/rooms/'
| '/_auth/user/'
| '/(auth)/sso/callback/'
| '/_auth/profile/$userName/'
| '/_auth/profile/change-password/'
| '/_auth/role/create/'
| '/_auth/rooms/$roomName/'
| '/_auth/user/create/'
| '/_auth/role/$id/edit/'
| '/_auth/rooms/$roomName/connect/'
| '/_auth/rooms/$roomName/folder-status/'
| '/_auth/user/change-password/$userName/'
| '/_auth/user/edit/$userName/'
| '/_auth/user/role/$roleId/'
| '/_authenticated'
| '/_auth/login/'
| '/_authenticated/agent/'
| '/_authenticated/apps/'
| '/_authenticated/blacklist/'
| '/_authenticated/command/'
| '/_authenticated/device/'
| '/_authenticated/room/'
| '/_authenticated/room/$roomName/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AuthRoute: typeof AuthRouteWithChildren
authLoginIndexRoute: typeof authLoginIndexRoute
authSsoCallbackIndexRoute: typeof authSsoCallbackIndexRoute
AuthenticatedRoute: typeof AuthenticatedRouteWithChildren
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/_authenticated': {
id: '/_authenticated'
path: ''
fullPath: ''
preLoaderRoute: typeof AuthenticatedRouteImport
parentRoute: typeof rootRouteImport
}
'/_auth': {
id: '/_auth'
path: ''
@ -363,236 +182,103 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/_auth/user/': {
id: '/_auth/user/'
path: '/user'
fullPath: '/user'
preLoaderRoute: typeof AuthUserIndexRouteImport
parentRoute: typeof AuthRoute
'/_authenticated/room/': {
id: '/_authenticated/room/'
path: '/room'
fullPath: '/room'
preLoaderRoute: typeof AuthenticatedRoomIndexRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_auth/rooms/': {
id: '/_auth/rooms/'
path: '/rooms'
fullPath: '/rooms'
preLoaderRoute: typeof AuthRoomsIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/role/': {
id: '/_auth/role/'
path: '/role'
fullPath: '/role'
preLoaderRoute: typeof AuthRoleIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/remote-control/': {
id: '/_auth/remote-control/'
path: '/remote-control'
fullPath: '/remote-control'
preLoaderRoute: typeof AuthRemoteControlIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/device/': {
id: '/_auth/device/'
'/_authenticated/device/': {
id: '/_authenticated/device/'
path: '/device'
fullPath: '/device'
preLoaderRoute: typeof AuthDeviceIndexRouteImport
parentRoute: typeof AuthRoute
preLoaderRoute: typeof AuthenticatedDeviceIndexRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_auth/dashboard/': {
id: '/_auth/dashboard/'
path: '/dashboard'
fullPath: '/dashboard'
preLoaderRoute: typeof AuthDashboardIndexRouteImport
parentRoute: typeof AuthRoute
'/_authenticated/command/': {
id: '/_authenticated/command/'
path: '/command'
fullPath: '/command'
preLoaderRoute: typeof AuthenticatedCommandIndexRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_auth/commands/': {
id: '/_auth/commands/'
path: '/commands'
fullPath: '/commands'
preLoaderRoute: typeof AuthCommandsIndexRouteImport
parentRoute: typeof AuthRoute
'/_authenticated/blacklist/': {
id: '/_authenticated/blacklist/'
path: '/blacklist'
fullPath: '/blacklist'
preLoaderRoute: typeof AuthenticatedBlacklistIndexRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_auth/blacklists/': {
id: '/_auth/blacklists/'
path: '/blacklists'
fullPath: '/blacklists'
preLoaderRoute: typeof AuthBlacklistsIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/audits/': {
id: '/_auth/audits/'
path: '/audits'
fullPath: '/audits'
preLoaderRoute: typeof AuthAuditsIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/apps/': {
id: '/_auth/apps/'
'/_authenticated/apps/': {
id: '/_authenticated/apps/'
path: '/apps'
fullPath: '/apps'
preLoaderRoute: typeof AuthAppsIndexRouteImport
parentRoute: typeof AuthRoute
preLoaderRoute: typeof AuthenticatedAppsIndexRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_auth/agent/': {
id: '/_auth/agent/'
'/_authenticated/agent/': {
id: '/_authenticated/agent/'
path: '/agent'
fullPath: '/agent'
preLoaderRoute: typeof AuthAgentIndexRouteImport
parentRoute: typeof AuthRoute
preLoaderRoute: typeof AuthenticatedAgentIndexRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/(auth)/login/': {
id: '/(auth)/login/'
'/_auth/login/': {
id: '/_auth/login/'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof authLoginIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/_auth/user/create/': {
id: '/_auth/user/create/'
path: '/user/create'
fullPath: '/user/create'
preLoaderRoute: typeof AuthUserCreateIndexRouteImport
preLoaderRoute: typeof AuthLoginIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/rooms/$roomName/': {
id: '/_auth/rooms/$roomName/'
path: '/rooms/$roomName'
fullPath: '/rooms/$roomName'
preLoaderRoute: typeof AuthRoomsRoomNameIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/role/create/': {
id: '/_auth/role/create/'
path: '/role/create'
fullPath: '/role/create'
preLoaderRoute: typeof AuthRoleCreateIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/profile/change-password/': {
id: '/_auth/profile/change-password/'
path: '/profile/change-password'
fullPath: '/profile/change-password'
preLoaderRoute: typeof AuthProfileChangePasswordIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/profile/$userName/': {
id: '/_auth/profile/$userName/'
path: '/profile/$userName'
fullPath: '/profile/$userName'
preLoaderRoute: typeof AuthProfileUserNameIndexRouteImport
parentRoute: typeof AuthRoute
}
'/(auth)/sso/callback/': {
id: '/(auth)/sso/callback/'
path: '/sso/callback'
fullPath: '/sso/callback'
preLoaderRoute: typeof authSsoCallbackIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/_auth/user/role/$roleId/': {
id: '/_auth/user/role/$roleId/'
path: '/user/role/$roleId'
fullPath: '/user/role/$roleId'
preLoaderRoute: typeof AuthUserRoleRoleIdIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/user/edit/$userName/': {
id: '/_auth/user/edit/$userName/'
path: '/user/edit/$userName'
fullPath: '/user/edit/$userName'
preLoaderRoute: typeof AuthUserEditUserNameIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/user/change-password/$userName/': {
id: '/_auth/user/change-password/$userName/'
path: '/user/change-password/$userName'
fullPath: '/user/change-password/$userName'
preLoaderRoute: typeof AuthUserChangePasswordUserNameIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/rooms/$roomName/folder-status/': {
id: '/_auth/rooms/$roomName/folder-status/'
path: '/rooms/$roomName/folder-status'
fullPath: '/rooms/$roomName/folder-status'
preLoaderRoute: typeof AuthRoomsRoomNameFolderStatusIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/rooms/$roomName/connect/': {
id: '/_auth/rooms/$roomName/connect/'
path: '/rooms/$roomName/connect'
fullPath: '/rooms/$roomName/connect'
preLoaderRoute: typeof AuthRoomsRoomNameConnectIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/role/$id/edit/': {
id: '/_auth/role/$id/edit/'
path: '/role/$id/edit'
fullPath: '/role/$id/edit'
preLoaderRoute: typeof AuthRoleIdEditIndexRouteImport
parentRoute: typeof AuthRoute
'/_authenticated/room/$roomName/': {
id: '/_authenticated/room/$roomName/'
path: '/room/$roomName'
fullPath: '/room/$roomName'
preLoaderRoute: typeof AuthenticatedRoomRoomNameIndexRouteImport
parentRoute: typeof AuthenticatedRoute
}
}
}
interface AuthRouteChildren {
AuthAgentIndexRoute: typeof AuthAgentIndexRoute
AuthAppsIndexRoute: typeof AuthAppsIndexRoute
AuthAuditsIndexRoute: typeof AuthAuditsIndexRoute
AuthBlacklistsIndexRoute: typeof AuthBlacklistsIndexRoute
AuthCommandsIndexRoute: typeof AuthCommandsIndexRoute
AuthDashboardIndexRoute: typeof AuthDashboardIndexRoute
AuthDeviceIndexRoute: typeof AuthDeviceIndexRoute
AuthRemoteControlIndexRoute: typeof AuthRemoteControlIndexRoute
AuthRoleIndexRoute: typeof AuthRoleIndexRoute
AuthRoomsIndexRoute: typeof AuthRoomsIndexRoute
AuthUserIndexRoute: typeof AuthUserIndexRoute
AuthProfileUserNameIndexRoute: typeof AuthProfileUserNameIndexRoute
AuthProfileChangePasswordIndexRoute: typeof AuthProfileChangePasswordIndexRoute
AuthRoleCreateIndexRoute: typeof AuthRoleCreateIndexRoute
AuthRoomsRoomNameIndexRoute: typeof AuthRoomsRoomNameIndexRoute
AuthUserCreateIndexRoute: typeof AuthUserCreateIndexRoute
AuthRoleIdEditIndexRoute: typeof AuthRoleIdEditIndexRoute
AuthRoomsRoomNameConnectIndexRoute: typeof AuthRoomsRoomNameConnectIndexRoute
AuthRoomsRoomNameFolderStatusIndexRoute: typeof AuthRoomsRoomNameFolderStatusIndexRoute
AuthUserChangePasswordUserNameIndexRoute: typeof AuthUserChangePasswordUserNameIndexRoute
AuthUserEditUserNameIndexRoute: typeof AuthUserEditUserNameIndexRoute
AuthUserRoleRoleIdIndexRoute: typeof AuthUserRoleRoleIdIndexRoute
AuthLoginIndexRoute: typeof AuthLoginIndexRoute
}
const AuthRouteChildren: AuthRouteChildren = {
AuthAgentIndexRoute: AuthAgentIndexRoute,
AuthAppsIndexRoute: AuthAppsIndexRoute,
AuthAuditsIndexRoute: AuthAuditsIndexRoute,
AuthBlacklistsIndexRoute: AuthBlacklistsIndexRoute,
AuthCommandsIndexRoute: AuthCommandsIndexRoute,
AuthDashboardIndexRoute: AuthDashboardIndexRoute,
AuthDeviceIndexRoute: AuthDeviceIndexRoute,
AuthRemoteControlIndexRoute: AuthRemoteControlIndexRoute,
AuthRoleIndexRoute: AuthRoleIndexRoute,
AuthRoomsIndexRoute: AuthRoomsIndexRoute,
AuthUserIndexRoute: AuthUserIndexRoute,
AuthProfileUserNameIndexRoute: AuthProfileUserNameIndexRoute,
AuthProfileChangePasswordIndexRoute: AuthProfileChangePasswordIndexRoute,
AuthRoleCreateIndexRoute: AuthRoleCreateIndexRoute,
AuthRoomsRoomNameIndexRoute: AuthRoomsRoomNameIndexRoute,
AuthUserCreateIndexRoute: AuthUserCreateIndexRoute,
AuthRoleIdEditIndexRoute: AuthRoleIdEditIndexRoute,
AuthRoomsRoomNameConnectIndexRoute: AuthRoomsRoomNameConnectIndexRoute,
AuthRoomsRoomNameFolderStatusIndexRoute:
AuthRoomsRoomNameFolderStatusIndexRoute,
AuthUserChangePasswordUserNameIndexRoute:
AuthUserChangePasswordUserNameIndexRoute,
AuthUserEditUserNameIndexRoute: AuthUserEditUserNameIndexRoute,
AuthUserRoleRoleIdIndexRoute: AuthUserRoleRoleIdIndexRoute,
AuthLoginIndexRoute: AuthLoginIndexRoute,
}
const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
interface AuthenticatedRouteChildren {
AuthenticatedAgentIndexRoute: typeof AuthenticatedAgentIndexRoute
AuthenticatedAppsIndexRoute: typeof AuthenticatedAppsIndexRoute
AuthenticatedBlacklistIndexRoute: typeof AuthenticatedBlacklistIndexRoute
AuthenticatedCommandIndexRoute: typeof AuthenticatedCommandIndexRoute
AuthenticatedDeviceIndexRoute: typeof AuthenticatedDeviceIndexRoute
AuthenticatedRoomIndexRoute: typeof AuthenticatedRoomIndexRoute
AuthenticatedRoomRoomNameIndexRoute: typeof AuthenticatedRoomRoomNameIndexRoute
}
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
AuthenticatedAgentIndexRoute: AuthenticatedAgentIndexRoute,
AuthenticatedAppsIndexRoute: AuthenticatedAppsIndexRoute,
AuthenticatedBlacklistIndexRoute: AuthenticatedBlacklistIndexRoute,
AuthenticatedCommandIndexRoute: AuthenticatedCommandIndexRoute,
AuthenticatedDeviceIndexRoute: AuthenticatedDeviceIndexRoute,
AuthenticatedRoomIndexRoute: AuthenticatedRoomIndexRoute,
AuthenticatedRoomRoomNameIndexRoute: AuthenticatedRoomRoomNameIndexRoute,
}
const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(
AuthenticatedRouteChildren,
)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AuthRoute: AuthRouteWithChildren,
authLoginIndexRoute: authLoginIndexRoute,
authSsoCallbackIndexRoute: authSsoCallbackIndexRoute,
AuthenticatedRoute: AuthenticatedRouteWithChildren,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)

View File

@ -1,18 +0,0 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
import { LoginForm } from '@/components/forms/login-form'
export const Route = createFileRoute('/(auth)/login/')({
beforeLoad: async ({ context }) => {
const { token } = context.auth
if (token) throw redirect({ to: '/' })
},
component: LoginPage,
})
function LoginPage() {
return (
<div className="flex items-center justify-center min-h-screen bg-gradient-to-br from-background to-muted/20">
<LoginForm className="w-full max-w-md" />
</div>
)
}

View File

@ -1,81 +0,0 @@
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { useExchangeSsoCode } from "@/hooks/queries";
import { useAuth } from "@/hooks/useAuth";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { LoaderCircle } from "lucide-react";
export const Route = createFileRoute("/(auth)/sso/callback/")({
component: SsoCallbackPage,
});
function SsoCallbackPage() {
const auth = useAuth();
const navigate = useNavigate();
const exchangeMutation = useExchangeSsoCode();
const search = Route.useSearch() as { code?: string; redirect?: string };
const [errorMessage, setErrorMessage] = useState<string | null>(null);
useEffect(() => {
if (!search.code) {
setErrorMessage("SSO code is missing.");
return;
}
setErrorMessage(null);
exchangeMutation.mutate(search.code, {
onSuccess: async (data) => {
if (!data.token) {
setErrorMessage("SSO response missing token.");
return;
}
localStorage.setItem("token", data.token);
localStorage.setItem("username", data.username || "");
localStorage.setItem("name", data.name || "");
localStorage.setItem("acs", (data.access ?? "").toString());
localStorage.setItem("role", data.role?.roleName || "");
localStorage.setItem("priority", String(data.role?.priority ?? "-1"));
localStorage.setItem("computersmanagement.auth.user", data.username || "");
localStorage.setItem("accesscontrol.auth.user", data.username || "");
auth.setAuthenticated(true);
auth.login(data.username || "");
await navigate({ to: search.redirect || "/dashboard" });
},
onError: () => {
setErrorMessage("SSO exchange failed.");
},
});
}, [auth, exchangeMutation, navigate, search.code, search.redirect]);
return (
<div className="flex items-center justify-center min-h-screen bg-gradient-to-br from-background to-muted/20">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-xl">Đang xác thực SSO</CardTitle>
<CardDescription>Vui lòng đi trong giây lát.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center gap-4">
{exchangeMutation.isPending && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<LoaderCircle className="w-4 h-4 animate-spin" />
Đang trao đi đăng nhập
</div>
)}
{errorMessage && (
<div className="text-destructive text-sm text-center">{errorMessage}</div>
)}
{errorMessage && (
<Link to="/login" className="w-full">
<Button className="w-full">Quay lại đăng nhập</Button>
</Link>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -1,37 +1,22 @@
import ErrorRoute from "@/components/pages/error-route";
import NotFound from "@/components/pages/not-found";
import { type IAuthContext } from "@/types/auth";
import { QueryClient } from "@tanstack/react-query";
import {
createRootRouteWithContext,
HeadContent,
Outlet,
} from "@tanstack/react-router";
import { Outlet, createRootRouteWithContext, HeadContent } from '@tanstack/react-router'
import type { AuthTokenProps } from '@/hooks/useAuthtoken'
export interface BreadcrumbItem {
title: string;
path: string;
export interface RouterContext {
auth: AuthTokenProps
}
export interface MyRouterContext {
auth: IAuthContext;
queryClient: QueryClient;
breadcrumbs?: BreadcrumbItem[];
}
export const Route = createRootRouteWithContext<RouterContext>()({
head: () => ({
meta: [
{ title: "Quản lý phòng máy" },
{ name: "description", content: "Ứng dụng quản lý thiết bị và phần mềm" },
],
}),
component: () => (
<>
<HeadContent />
<Outlet />
</>
),
})
export const Route = createRootRouteWithContext<MyRouterContext>()({
component: () => {
return (
<>
<HeadContent />
<Outlet />
</>
);
},
notFoundComponent: () => {
return <NotFound />;
},
errorComponent: ({ error }) => {
return <ErrorRoute error={error.message} />;
},
});

View File

@ -1,87 +1,16 @@
import { useEffect } from "react";
import AppBreadCrumb from "@/components/app-breadcrumb";
import { AppSidebar } from "@/components/sidebars/app-sidebar";
import AvatarDropdown from "@/components/avatar-dropdown";
import SessionTimeOutErrorPage from "@/components/pages/session-timeout-error";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { Separator } from "@/components/ui/separator";
import { createFileRoute, Outlet, redirect, useRouter } from "@tanstack/react-router";
import { useUIStore } from "@/stores/uiStore";
import { useAuth } from "@/hooks/useAuth";
import {createFileRoute, Outlet, redirect} from '@tanstack/react-router'
export const Route = createFileRoute("/_auth")({
beforeLoad: ({ context, location }) => {
if (!context.auth.isAuthenticated) {
throw redirect({
to: "/login",
search: {
redirect: location.href
}
});
export const Route = createFileRoute('/_auth')({
beforeLoad: async ({context}) => {
const {authToken} = context.auth
if (authToken) {
throw redirect({to: '/'})
}
},
component: RouteComponent
});
function RouteComponent() {
const auth = useAuth();
const setCurrent = useUIStore((state) => state.setCurrent);
const router = useRouter();
const navigate = Route.useNavigate();
const currentPath = router.state.location.pathname;
// Update current path in UI store when location changes
useEffect(() => {
setCurrent(currentPath);
}, [currentPath, setCurrent]);
const handleLogout = () => {
if (window.confirm("Bạn chắc chắn muốn đăng xuất khỏi hệ thống?")) {
auth.logout().then(() => {
router.invalidate().finally(() => {
navigate({ to: "/" });
});
});
}
};
const handleProfile = () => {
navigate({ to: "/profile/$userName", params: { userName: auth.username } } as any);
};
const handleChangePassword = () => {
navigate({ to: "/profile/change-password" } as any);
};
const username = auth.username;
if (!auth.isAuthenticated) {
return <SessionTimeOutErrorPage />;
}
component:AuthLayout ,
})
function AuthLayout() {
return (
<SidebarProvider className="h-screen w-screen">
<AppSidebar />
<SidebarInset>
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
<AppBreadCrumb />
<div className="flex items-center gap-4 ml-auto">
<AvatarDropdown
username={username}
role={auth.role}
onLogOut={handleLogout}
onProfile={handleProfile}
onChangePassword={handleChangePassword}
/>
</div>
</header>
<div className="flex flex-1 flex-col gap-4 p-4 overflow-x-auto">
<Outlet />
</div>
</SidebarInset>
</SidebarProvider>
);
<Outlet />
)
}

View File

@ -1,90 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { AppManagerTemplate } from "@/template/app-manager-template";
import {
useGetAgentVersion,
useGetRoomList,
useUploadSoftware,
useUpdateAgent,
} from "@/hooks/queries";
import { toast } from "sonner";
import type { AxiosProgressEvent } from "axios";
import type { Version } from "@/types/file";
import { ErrorFetchingPage } from "@/components/pages/error-fetching-page";
import { agentColumns } from "@/components/columns/agent-column";
export const Route = createFileRoute("/_auth/agent/")({
head: () => ({ meta: [{ title: "Quản lý Agent" }] }),
component: AgentsPage,
errorComponent: ErrorFetchingPage,
loader: async ({ context }) => {
context.breadcrumbs = [
{ title: "Quản lý Agent", path: "/_auth/agent/" },
];
},
});
function AgentsPage() {
// Lấy danh sách version
const { data, isLoading } = useGetAgentVersion();
// Lấy danh sách phòng
const { data: roomData } = useGetRoomList();
const versionList: Version[] = Array.isArray(data)
? data
: data
? [data]
: [];
const uploadMutation = useUploadSoftware();
const updateMutation = useUpdateAgent();
const handleUpload = async (
fd: FormData,
config?: { onUploadProgress?: (e: AxiosProgressEvent) => void },
) => {
try {
await uploadMutation.mutateAsync({
formData: fd,
onUploadProgress: config?.onUploadProgress,
});
toast.success("Upload thành công!");
} catch (error: any) {
console.error("Upload error:", error);
toast.error("Upload thất bại!");
}
};
const handleUpdate = async (roomNames: string[]) => {
try {
for (const roomName of roomNames) {
await updateMutation.mutateAsync({
roomName,
data: {},
});
}
toast.success("Đã gửi yêu cầu update cho các phòng đã chọn!");
} catch (e) {
toast.error("Có lỗi xảy ra khi cập nhật!");
}
};
// Cột bảng
return (
<AppManagerTemplate<Version>
title="Quản lý Agent"
description="Quản lý và theo dõi các phiên bản Agent"
data={versionList}
isLoading={isLoading}
columns={agentColumns}
onUpload={handleUpload}
onUpdate={handleUpdate}
updateLoading={updateMutation.isPending}
rooms={roomData}
enablePagination
defaultPageSize={10}
/>
);
}

View File

@ -1,246 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { AppManagerTemplate } from "@/template/app-manager-template";
import {
useGetSoftwareList,
useGetRoomList,
useUploadSoftware,
useDeleteFile,
useAddRequiredFile,
useDeleteRequiredFile,
useInstallMsi,
useDownloadFiles,
} from "@/hooks/queries";
import { toast } from "sonner";
import type { AxiosProgressEvent } from "axios";
import type { Version } from "@/types/file";
import { useMemo, useState } from "react";
import { createAppsColumns } from "@/components/columns/apps-column";
export const Route = createFileRoute("/_auth/apps/")({
head: () => ({ meta: [{ title: "Quản lý phần mềm" }] }),
component: AppsComponent,
loader: async ({ context }) => {
context.breadcrumbs = [
{ title: "Quản lý phần mềm", path: "/_auth/apps/" },
];
},
});
function AppsComponent() {
const { data, isLoading } = useGetSoftwareList();
const { data: roomData } = useGetRoomList();
const versionList: Version[] = Array.isArray(data)
? data
: data
? [data]
: [];
const [table, setTable] = useState<any>();
const uploadMutation = useUploadSoftware();
const installMutation = useInstallMsi();
const downloadMutation = useDownloadFiles();
const deleteMutation = useDeleteFile();
const addRequiredFileMutation = useAddRequiredFile();
const deleteRequiredFileMutation = useDeleteRequiredFile();
const columns = useMemo(
() => createAppsColumns(installMutation.isPending),
[installMutation.isPending]
);
// Upload file MSI
const handleUpload = async (
fd: FormData,
config?: { onUploadProgress?: (e: AxiosProgressEvent) => void }
) => {
try {
await uploadMutation.mutateAsync({
formData: fd,
onUploadProgress: config?.onUploadProgress,
});
toast.success("Upload thành công!");
} catch (error: any) {
console.error("Upload error:", error);
toast.error("Upload thất bại!");
}
};
// Callback khi chọn phòng
const handleInstall = async (roomNames: string[]) => {
if (!table) {
toast.error("Không thể lấy thông tin bảng!");
return;
}
const selectedRows = table.getSelectedRowModel().rows;
if (selectedRows.length === 0) {
toast.error("Vui lòng chọn ít nhất một file để cài đặt!");
return;
}
const MsiFileIds = selectedRows.map((row: any) => row.original.id);
try {
for (const roomName of roomNames) {
await installMutation.mutateAsync({
roomName,
data: { MsiFileIds },
});
}
toast.success("Đã gửi yêu cầu cài đặt phần mềm cho các phòng đã chọn!");
} catch (e) {
toast.error("Có lỗi xảy ra khi cài đặt!");
}
};
const handleDonwload = async (roomNames: string[]) => {
if (!table) {
toast.error("Không thể lấy thông tin bảng!");
return;
}
const selectedRows = table.getSelectedRowModel().rows;
if (selectedRows.length === 0) {
toast.error("Vui lòng chọn ít nhất một file để tải!");
return;
}
const MsiFileIds = selectedRows.map((row: any) => row.original.id);
try {
for (const roomName of roomNames) {
await downloadMutation.mutateAsync({
roomName,
data: { MsiFileIds },
});
}
toast.success("Đã gửi yêu cầu tải file cho các phòng đã chọn!");
} catch (e) {
toast.error("Có lỗi xảy ra khi tải!");
}
};
const handleDelete = async () => {
if (!table) {
toast.error("Không thể lấy thông tin bảng!");
return;
}
const selectedRows = table.getSelectedRowModel().rows;
if (selectedRows.length === 0) {
toast.error("Vui lòng chọn ít nhất một file để xóa!");
return;
}
const MsiFileIds = selectedRows.map((row: any) => row.original.id);
try {
await deleteMutation.mutateAsync({ MsiFileIds });
toast.success("Xóa phần mềm thành công!");
} catch (e) {
toast.error("Xóa phần mềm thất bại!");
}
};
const handleDeleteFromRequiredList = async () => {
if (!table) return;
const selectedRows = table.getSelectedRowModel().rows;
if (selectedRows.length === 0) {
toast.error("Vui lòng chọn ít nhất một file để xóa!");
return;
}
const MsiFileIds = selectedRows.map((row: any) => row.original.id);
try {
await deleteRequiredFileMutation.mutateAsync({ MsiFileIds });
toast.success("Xóa file khỏi danh sách thành công!");
if (table) {
table.setRowSelection({});
}
} catch (e) {
console.error("Delete from required list error:", e);
toast.error("Có lỗi xảy ra khi xóa!");
}
};
const handleDeleteFromServer = async () => {
if (!table) return;
const selectedRows = table.getSelectedRowModel().rows;
const MsiFileIds = selectedRows.map((row: any) => row.original.id);
try {
await deleteMutation.mutateAsync({ MsiFileIds });
toast.success("Xóa phần mềm từ server thành công!");
if (table) {
table.setRowSelection({});
}
} catch (e) {
console.error("Delete error:", e);
toast.error("Có lỗi xảy ra khi xóa!");
}
};
const handleAddToRequired = async () => {
if (!table) {
toast.error("Không thể lấy thông tin bảng!");
return;
}
const selectedRows = table.getSelectedRowModel().rows;
if (selectedRows.length === 0) {
toast.error("Vui lòng chọn ít nhất một file!");
return;
}
try {
for (const row of selectedRows) {
const { fileName, version } = row.original;
await addRequiredFileMutation.mutateAsync({
fileName,
version,
});
}
toast.success("Thêm file vào danh sách thành công!");
table.setRowSelection({});
} catch (e) {
console.error("Add required file error:", e);
toast.error("Có lỗi xảy ra!");
}
};
return (
<>
<AppManagerTemplate<Version>
title="Quản lý phần mềm"
uploadFormTitle="Tải lên || Cập nhật file phần mềm"
description="Quản lý và gửi yêu cầu cài đặt phần mềm hoặc file cấu hình"
data={versionList}
isLoading={isLoading}
columns={columns}
onUpload={handleUpload}
onUpdate={handleInstall}
onDownload={handleDonwload}
onDelete={handleDelete}
onDeleteFromServer={handleDeleteFromServer}
onDeleteFromRequired={handleDeleteFromRequiredList}
onAddToRequired={handleAddToRequired}
updateLoading={installMutation.isPending}
downloadLoading={downloadMutation.isPending}
deleteLoading={deleteMutation.isPending || deleteRequiredFileMutation.isPending}
addToRequiredLoading={addRequiredFileMutation.isPending}
onTableInit={setTable}
rooms={roomData}
enablePagination
defaultPageSize={10}
/>
</>
);
}

View File

@ -1,92 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { useState, useEffect } from "react";
import { useGetAudits } from "@/hooks/queries";
import type { Audits } from "@/types/audit";
import { AuditListTemplate } from "@/template/audit-list-template";
import { auditColumns } from "@/components/columns/audit-column";
export const Route = createFileRoute("/_auth/audits/")({
head: () => ({ meta: [{ title: "Audit Logs" }] }),
loader: async ({ context }) => {
context.breadcrumbs = [{ title: "Audit logs", path: "#" }];
},
component: AuditsPage,
});
function AuditsPage() {
const [pageNumber, setPageNumber] = useState(1);
const [pageSize] = useState(20);
const [username, setUsername] = useState<string | null>(null);
const [action, setAction] = useState<string | null>(null);
const [from, setFrom] = useState<string | null>(null);
const [to, setTo] = useState<string | null>(null);
const [selectedAudit, setSelectedAudit] = useState<Audits | null>(null);
const { data, isLoading, refetch, isFetching } = useGetAudits(
{
pageNumber,
pageSize,
username,
action,
from,
to,
},
true
) as any;
const items: Audits[] = data?.items ?? [];
const total: number = data?.totalCount ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pageSize));
useEffect(() => {
refetch();
}, [pageNumber, pageSize]);
const handleSearch = () => {
setPageNumber(1);
refetch();
};
const handleReset = () => {
setUsername(null);
setAction(null);
setFrom(null);
setTo(null);
setPageNumber(1);
refetch();
};
return (
<AuditListTemplate
// data
items={items}
total={total}
columns={auditColumns}
isLoading={isLoading}
isFetching={isFetching}
// pagination
pageNumber={pageNumber}
pageSize={pageSize}
pageCount={pageCount}
canPreviousPage={pageNumber > 1}
canNextPage={pageNumber < pageCount}
onPreviousPage={() => setPageNumber((p) => Math.max(1, p - 1))}
onNextPage={() => setPageNumber((p) => Math.min(pageCount, p + 1))}
// filter
username={username}
action={action}
from={from}
to={to}
onUsernameChange={setUsername}
onActionChange={setAction}
onFromChange={setFrom}
onToChange={setTo}
onSearch={handleSearch}
onReset={handleReset}
// detail dialog
selectedAudit={selectedAudit}
onRowClick={setSelectedAudit}
onDialogClose={() => setSelectedAudit(null)}
/>
);
}

View File

@ -1,166 +0,0 @@
import {
useGetBlacklist,
useGetRoomList,
useAddBlacklist,
useDeleteBlacklist,
useUpdateDeviceBlacklist,
} from "@/hooks/queries";
import { createFileRoute } from "@tanstack/react-router";
import type { ColumnDef } from "@tanstack/react-table";
import type { Blacklist } from "@/types/black-list";
import { BlackListManagerTemplate } from "@/template/table-manager-template";
import { toast } from "sonner";
import { useState } from "react";
export const Route = createFileRoute("/_auth/blacklists/")({
head: () => ({ meta: [{ title: "Danh sách các ứng dụng bị chặn" }] }),
component: BlacklistComponent,
loader: async ({ context }) => {
context.breadcrumbs = [
{ title: "Quản lý danh sách chặn", path: "/_auth/blacklists/" },
];
},
});
function BlacklistComponent() {
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
// Lấy danh sách blacklist
const { data, isLoading } = useGetBlacklist();
// Lấy danh sách phòng
const { data: roomData = [] } = useGetRoomList();
const blacklist: Blacklist[] = Array.isArray(data)
? (data as Blacklist[])
: [];
const columns: ColumnDef<Blacklist>[] = [
{
accessorKey: "id",
header: "STT",
cell: (info) => info.getValue(),
},
{
accessorKey: "appName",
header: "Tên ứng dụng",
cell: (info) => info.getValue(),
},
{
accessorKey: "processName",
header: "Tên tiến trình",
cell: (info) => info.getValue(),
},
{
accessorKey: "createdAt",
header: "Ngày tạo",
cell: (info) => info.getValue(),
},
{
accessorKey: "updatedAt",
header: "Ngày cập nhật",
cell: (info) => info.getValue(),
},
{
accessorKey: "createdBy",
header: "Người tạo",
cell: (info) => info.getValue(),
},
{
id: "select",
header: () => (
<input
type="checkbox"
onChange={(e) => {
if (e.target.checked && data) {
const allIds = data.map((item: { id: number }) => item.id);
setSelectedRows(new Set(allIds));
} else {
setSelectedRows(new Set());
}
}}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
checked={selectedRows.has(row.original.id)}
onChange={(e) => {
const newSelected = new Set(selectedRows);
if (e.target.checked) {
newSelected.add(row.original.id);
} else {
newSelected.delete(row.original.id);
}
setSelectedRows(newSelected);
}}
/>
),
},
];
// API mutations
const addNewBlacklistMutation = useAddBlacklist();
const deleteBlacklistMutation = useDeleteBlacklist();
const updateDeviceMutation = useUpdateDeviceBlacklist();
// Thêm blacklist
const handleAddNewBlacklist = async (blacklistData: {
appName: string;
processName: string;
}) => {
try {
await addNewBlacklistMutation.mutateAsync(blacklistData);
toast.success("Thêm mới thành công!");
} catch (error: any) {
console.error("Add blacklist error:", error);
toast.error("Thêm mới thất bại!");
}
};
// Xoá blacklist
const handleDeleteBlacklist = async () => {
try {
for (const blacklistId of selectedRows) {
await deleteBlacklistMutation.mutateAsync(blacklistId);
}
toast.success("Xóa thành công!");
setSelectedRows(new Set());
} catch (error: any) {
console.error("Delete blacklist error:", error);
toast.error("Xóa thất bại!");
}
};
const handleUpdateDevice = async (target: string | string[]) => {
const targets = Array.isArray(target) ? target : [target];
try {
for (const roomName of targets) {
await updateDeviceMutation.mutateAsync({
roomName,
data: {},
});
toast.success(`Đã gửi cập nhật cho ${roomName}`);
}
} catch (e: any) {
console.error("Update device error:", e);
toast.error("Có lỗi xảy ra khi cập nhật!");
}
};
return (
<BlackListManagerTemplate<Blacklist>
title="Danh sách các ứng dụng bị chặn"
description="Quản lý các ứng dụng và tiến trình bị chặn trên thiết bị"
data={blacklist}
columns={columns}
isLoading={isLoading}
rooms={roomData}
onAdd={handleAddNewBlacklist}
onDelete={handleDeleteBlacklist}
onUpdate={handleUpdateDevice}
enablePagination
defaultPageSize={10}
/>
);
}

View File

@ -1,321 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { useState } from "react";
import { CommandSubmitTemplate } from "@/template/command-submit-template";
import { CommandRegistryForm, type CommandRegistryFormData } from "@/components/forms/command-registry-form";
import {
useGetCommandList,
useGetRoomList,
useAddCommand,
useUpdateCommand,
useDeleteCommand,
useSendCommand,
} from "@/hooks/queries";
import { toast } from "sonner";
import { Check, X, Edit2, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { ColumnDef } from "@tanstack/react-table";
import type { ShellCommandData } from "@/components/forms/command-form";
import type { CommandRegistry } from "@/types/command-registry";
export const Route = createFileRoute("/_auth/commands/")({
head: () => ({ meta: [{ title: "Gửi lệnh từ xa" }] }),
component: CommandPage,
loader: async ({ context }) => {
// Read active tab from URL search params (client-side) to reflect breadcrumb
let activeTab = "list";
try {
if (typeof window !== "undefined") {
const params = new URLSearchParams(window.location.search);
activeTab = params.get("tab") || "list";
}
} catch (e) {
activeTab = "list";
}
context.breadcrumbs = [
{ title: "Quản lý lệnh", path: "/_auth/commands/" },
{
title: activeTab === "execute" ? "Lệnh thủ công" : "Danh sách",
path: `/ _auth/commands/?tab=${activeTab}`,
},
];
},
});
function CommandPage() {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [selectedCommand, setSelectedCommand] = useState<CommandRegistry | null>(null);
const [table, setTable] = useState<any>();
// Fetch commands
const { data: commands = [], isLoading } = useGetCommandList();
// Fetch rooms
const { data: roomData = [] } = useGetRoomList();
const commandList: CommandRegistry[] = Array.isArray(commands)
? commands.map((cmd: any) => ({
...cmd,
qoS: cmd.qoS ?? 0,
isRetained: cmd.isRetained ?? false,
}))
: [];
// Mutations
const addCommandMutation = useAddCommand();
const updateCommandMutation = useUpdateCommand();
const deleteCommandMutation = useDeleteCommand();
const sendCommandMutation = useSendCommand();
// Columns for command table
const columns: ColumnDef<CommandRegistry>[] = [
{
accessorKey: "commandName",
header: () => <div className="min-w-[220px] whitespace-normal">Tên lệnh</div>,
size: 100,
cell: ({ getValue, row }) => {
const full = (getValue() as string) || row.original.commandName || "";
return (
<div className="min-w-[220px] whitespace-normal break-words">
<span className="font-semibold block leading-tight">{full}</span>
</div>
);
},
},
{
accessorKey: "commandType",
header: "Loại lệnh",
cell: ({ getValue }) => {
const type = getValue() as number;
const typeMap: Record<number, string> = {
1: "RESTART",
2: "SHUTDOWN",
3: "TASKKILL",
4: "BLOCK",
};
return <span>{typeMap[type] || "UNKNOWN"}</span>;
},
},
{
accessorKey: "commandContent",
header: "Nội dung lệnh",
size: 130,
cell: ({ getValue }) => (
<div className="max-w-[130px]">
<code className="text-xs bg-muted/50 px-1.5 py-0.5 rounded truncate block">
{(getValue() as string).substring(0, 40)}...
</code>
</div>
),
},
{
accessorKey: "qoS",
header: "QoS",
cell: ({ getValue }) => {
const qos = getValue() as number | undefined;
const qosValue = qos !== undefined ? qos : 0;
const colors = {
0: "text-blue-600",
1: "text-amber-600",
2: "text-red-600",
};
return (
<span className={colors[qosValue as 0 | 1 | 2]}>{qosValue}</span>
);
},
},
{
accessorKey: "isRetained",
header: "Lưu trữ",
cell: ({ getValue }) => {
const retained = getValue() as boolean;
return retained ? (
<div className="flex items-center gap-1">
<Check className="h-4 w-4 text-green-600" />
<span className="text-sm text-green-600"></span>
</div>
) : (
<div className="flex items-center gap-1">
<X className="h-4 w-4 text-gray-400" />
<span className="text-sm text-gray-400">Không</span>
</div>
);
},
},
{
id: "select",
header: () => <div className="text-center text-xs">Thực thi</div>,
cell: ({ row }) => (
<input
type="checkbox"
checked={row.getIsSelected?.() ?? false}
onChange={row.getToggleSelectedHandler?.()}
/>
),
enableSorting: false,
enableHiding: false,
},
{
id: "actions",
header: () => <div className="text-center text-xs">Hành đng</div>,
cell: ({ row }) => (
<div className="flex gap-2 justify-center">
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
setSelectedCommand(row.original);
setIsDialogOpen(true);
}}
>
<Edit2 className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
handleDeleteCommand(row.original.id);
}}
disabled={deleteCommandMutation.isPending}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
),
enableSorting: false,
enableHiding: false,
},
];
// Handle form submit
const handleFormSubmit = async (data: CommandRegistryFormData) => {
try {
if (selectedCommand) {
// Update
await updateCommandMutation.mutateAsync({
commandId: selectedCommand.id,
data,
});
} else {
// Add
await addCommandMutation.mutateAsync(data);
}
setIsDialogOpen(false);
setSelectedCommand(null);
toast.success(selectedCommand ? "Cập nhật lệnh thành công!" : "Thêm lệnh thành công!");
} catch (error) {
console.error("Form submission error:", error);
toast.error(selectedCommand ? "Cập nhật lệnh thất bại!" : "Thêm lệnh thất bại!");
}
};
// Handle delete
const handleDeleteCommand = async (commandId: number) => {
if (!confirm("Bạn có chắc muốn xóa lệnh này?")) return;
try {
await deleteCommandMutation.mutateAsync(commandId);
toast.success("Xóa lệnh thành công!");
} catch (error) {
console.error("Delete error:", error);
toast.error("Xóa lệnh thất bại!");
}
};
// Handle execute commands from list
const handleExecuteSelected = async (targets: string[]) => {
if (!table) {
toast.error("Không thể lấy thông tin bảng!");
return;
}
const selectedRows = table.getSelectedRowModel().rows;
if (selectedRows.length === 0) {
toast.error("Vui lòng chọn ít nhất một lệnh để thực thi!");
return;
}
try {
for (const target of targets) {
for (const row of selectedRows) {
// API expects PascalCase directly
const apiData = {
Command: row.original.commandContent,
QoS: row.original.qoS,
IsRetained: row.original.isRetained,
};
await sendCommandMutation.mutateAsync({
roomName: target,
data: apiData as any,
});
}
}
toast.success("Đã gửi yêu cầu thực thi lệnh cho các mục đã chọn!");
if (table) {
table.setRowSelection({});
}
} catch (error) {
toast.error("Có lỗi xảy ra khi thực thi!");
}
};
// Handle execute custom command
const handleExecuteCustom = async (targets: string[], commandData: ShellCommandData) => {
try {
for (const target of targets) {
// API expects PascalCase directly
const apiData = {
Command: commandData.command,
QoS: commandData.qos,
IsRetained: commandData.isRetained,
};
await sendCommandMutation.mutateAsync({
roomName: target,
data: apiData as any,
});
}
toast.success("Đã gửi lệnh tùy chỉnh cho các mục đã chọn!");
} catch (error) {
toast.error("Gửi lệnh tùy chỉnh thất bại!");
}
};
return (
<>
<CommandSubmitTemplate
title="Gửi lệnh từ xa"
description="Quản lý và gửi yêu cầu thực thi các lệnh trên thiết bị"
data={commandList}
isLoading={isLoading}
columns={columns}
dialogOpen={isDialogOpen}
onDialogOpen={setIsDialogOpen}
dialogTitle={selectedCommand ? "Sửa Lệnh" : "Thêm Lệnh Mới"}
onAddNew={() => {
setSelectedCommand(null);
setIsDialogOpen(true);
}}
onTableInit={setTable}
formContent={
<CommandRegistryForm
onSubmit={handleFormSubmit}
closeDialog={() => setIsDialogOpen(false)}
initialData={selectedCommand || undefined}
title={selectedCommand ? "Sửa Lệnh" : "Thêm Lệnh Mới"}
/>
}
onExecuteSelected={handleExecuteSelected}
onExecuteCustom={handleExecuteCustom}
isExecuting={sendCommandMutation.isPending}
rooms={roomData}
scrollable={true}
maxHeight="500px"
enablePagination={false}
defaultPageSize={10}
/>
</>
);
}

View File

@ -1,75 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { DashboardTemplate } from '@/template/dashboard-template'
import {
useGetDashboardSummary,
useGetDashboardGeneralInfo,
useGetDeviceOverview,
useGetDeviceStatusByRoom,
useGetRoomUsage,
useGetRoomManagement,
useGetSoftwareDistribution,
} from '@/hooks/queries/useDashboardQueries'
export const Route = createFileRoute('/_auth/dashboard/')({
component: RouteComponent,
head: () => ({ meta: [{ title: 'Dashboard' }] }),
loader: async ({ context }) => {
context.breadcrumbs = [
{ title: "Dashboard", path: "#" },
];
},
})
function RouteComponent() {
const summaryQuery = useGetDashboardSummary();
const generalQuery = useGetDashboardGeneralInfo();
const deviceOverviewQuery = useGetDeviceOverview();
const devicesByRoomQuery = useGetDeviceStatusByRoom();
const roomUsageQuery = useGetRoomUsage();
const roomsQuery = useGetRoomManagement();
const softwareQuery = useGetSoftwareDistribution();
const isLoading =
summaryQuery.isLoading ||
generalQuery.isLoading ||
deviceOverviewQuery.isLoading ||
devicesByRoomQuery.isLoading ||
roomUsageQuery.isLoading ||
roomsQuery.isLoading ||
softwareQuery.isLoading;
const isFetching =
summaryQuery.isFetching ||
generalQuery.isFetching ||
deviceOverviewQuery.isFetching ||
devicesByRoomQuery.isFetching ||
roomUsageQuery.isFetching ||
roomsQuery.isFetching ||
softwareQuery.isFetching;
const handleRefresh = async () => {
await Promise.allSettled([
summaryQuery.refetch(),
generalQuery.refetch(),
deviceOverviewQuery.refetch(),
devicesByRoomQuery.refetch(),
roomUsageQuery.refetch(),
roomsQuery.refetch(),
softwareQuery.refetch(),
]);
};
return (
<DashboardTemplate
generalInfo={generalQuery.data ?? summaryQuery.data?.generalInfo}
deviceOverview={deviceOverviewQuery.data ?? summaryQuery.data?.deviceOverview}
roomManagement={roomsQuery.data ?? summaryQuery.data?.roomManagement}
roomUsage={roomUsageQuery.data ?? summaryQuery.data?.roomUsage}
softwareDistribution={softwareQuery.data ?? summaryQuery.data?.softwareDistribution}
devicesByRoom={devicesByRoomQuery.data}
isLoading={isLoading}
isFetching={isFetching}
onRefresh={handleRefresh}
/>
);
}

View File

@ -0,0 +1,125 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import {
formOptions,
useForm,
} from '@tanstack/react-form'
interface LoginFormProps {
username: string
password: string
}
const defaultInput: LoginFormProps = {
username: '',
password: '',
}
const formOpts = formOptions({
defaultValues: defaultInput,
})
export const Route = createFileRoute('/_auth/login/')({
beforeLoad: async ({ context }) => {
const { authToken } = context.auth
if (authToken) throw redirect({ to: '/' })
},
component: LoginForm,
})
function LoginForm() {
const form = useForm({
...formOpts,
onSubmit: async ({ value }) => {
console.log('Submitting login form with values:', value)
// Giả lập đăng nhập
if (value.username === 'admin' && value.password === '123456') {
alert('Đăng nhập thành công!')
// Thêm xử lý lưu token, redirect...
} else {
alert('Tài khoản hoặc mật khẩu không đúng.')
}
},
})
return (
<Card className="max-w-md mx-auto mt-20 p-6">
<CardHeader>
<CardTitle>Đăng nhập</CardTitle>
<CardDescription>
Vui lòng nhập thông tin đăng nhập của bạn.
</CardDescription>
</CardHeader>
<CardContent>
<form
onSubmit={(e) => {
e.preventDefault()
form.handleSubmit()
}}
className="space-y-4"
>
{/* Username */}
<form.Field name="username">
{(field) => (
<div>
<Label htmlFor="username">Tên đăng nhập</Label>
<Input
id="username"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
placeholder="Tên đăng nhập"
/>
{field.state.meta.isTouched && field.state.meta.errors && (
<p className="text-sm text-red-500 mt-1">
{field.state.meta.errors}
</p>
)}
</div>
)}
</form.Field>
{/* Password */}
<form.Field name="password">
{(field) => (
<div>
<Label htmlFor="password">Mật khẩu</Label>
<Input
id="password"
type="password"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
placeholder="Mật khẩu"
/>
{field.state.meta.isTouched && field.state.meta.errors && (
<p className="text-sm text-red-500 mt-1">
{field.state.meta.errors}
</p>
)}
</div>
)}
</form.Field>
<Button type="submit" className="w-full">
Đăng nhập
</Button>
</form>
</CardContent>
<CardFooter>
<p className="text-sm text-muted-foreground">
Chưa tài khoản? <span className="underline cursor-pointer">Đăng </span>
</p>
</CardFooter>
</Card>
)
}

View File

@ -1,89 +0,0 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { User, Key, Shield } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
export const Route = createFileRoute("/_auth/profile/$userName/")({
component: UserProfileComponent,
loader: async ({ context, params }) => {
const { userName } = params as unknown as { userName: string };
context.breadcrumbs = [
{ title: "Tài khoản", path: "#" },
{ title: "Thông tin cá nhân", path: `/profile/${userName}` },
];
},
});
function UserProfileComponent() {
const navigate = useNavigate();
const auth = useAuth();
const { userName } = Route.useParams() as { userName: string };
// Only allow viewing own profile
const isOwnProfile = auth.username === userName;
if (!isOwnProfile) {
return (
<div className="container w-2/3 mx-auto">
<div className="flex flex-col items-center justify-center py-12 gap-4">
<p className="text-muted-foreground">Bạn không quyền xem hồ này</p>
<Button variant="outline" onClick={() => navigate({ to: "/dashboard" })}>
Quay lại
</Button>
</div>
</div>
);
}
return (
<div className="container w-2/3 mx-auto">
<div className="flex flex-col gap-6">
{/* Avatar Section */}
<div className="flex justify-center">
<div className="w-24 h-24 rounded-full bg-muted flex items-center justify-center border">
<User className="h-12 w-12 text-muted-foreground" />
</div>
</div>
{/* Info Section */}
<div className="space-y-0">
<div className="flex justify-between items-center py-4 border-b">
<span className="text-muted-foreground">Tên đăng nhập</span>
<span className="font-medium">{auth.username}</span>
</div>
<div className="flex justify-between items-center py-4 border-b">
<span className="text-muted-foreground">Họ tên</span>
<span className="font-medium">{auth.name || "Chưa cập nhật"}</span>
</div>
<div className="flex justify-between items-center py-4 border-b">
<span className="text-muted-foreground">Vai trò</span>
<Badge variant="outline" className="flex items-center gap-1">
<Shield className="h-3 w-3" />
{auth.role.roleName || "Chưa cập nhật"}
</Badge>
</div>
<div className="flex justify-between items-center py-4 border-b">
<span className="text-muted-foreground">Cấp đ ưu tiên</span>
<span className="font-medium">{auth.role.priority}</span>
</div>
</div>
{/* Action Button */}
<div className="pt-2">
<Button
variant="outline"
className="w-full"
onClick={() => navigate({ to: "/profile/change-password" as any })}
>
<Key className="h-4 w-4 mr-2" />
Đi mật khẩu
</Button>
</div>
</div>
</div>
);
}

View File

@ -1,157 +0,0 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useChangePassword } from "@/hooks/queries";
import { toast } from "sonner";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { LoaderCircle } from "lucide-react";
import { useState } from "react";
export const Route = createFileRoute("/_auth/profile/change-password/")({
component: SelfChangePasswordComponent,
loader: async ({ context }) => {
context.breadcrumbs = [
{ title: "Tài khoản", path: "#" },
{ title: "Đổi mật khẩu", path: "/profile/change-password" },
];
},
});
function SelfChangePasswordComponent() {
const navigate = useNavigate();
const mutation = useChangePassword();
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const validateForm = () => {
if (!currentPassword) {
setError("Mật khẩu hiện tại là bắt buộc");
return false;
}
if (!newPassword) {
setError("Mật khẩu mới là bắt buộc");
return false;
}
if (newPassword.length < 6) {
setError("Mật khẩu phải có ít nhất 6 ký tự");
return false;
}
if (!confirmPassword) {
setError("Xác nhận mật khẩu là bắt buộc");
return false;
}
if (newPassword !== confirmPassword) {
setError("Mật khẩu mới và xác nhận mật khẩu chưa giống nhau");
return false;
}
if (currentPassword === newPassword) {
setError("Mật khẩu mới không được trùng với mật khẩu hiện tại");
return false;
}
return true;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!validateForm()) return;
mutation.mutate(
{ currentPassword: currentPassword, newPassword: newPassword },
{
onSuccess: () => {
toast.success("Đổi mật khẩu thành công");
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
mutation.reset();
},
onError: () => {
toast.error("Đổi mật khẩu thất bại, có lỗi xảy ra vui lòng thử lại");
},
}
);
};
const handleCancel = () => {
navigate({ to: "/dashboard" });
};
return (
<div className="container w-2/3 mx-auto">
<div className="flex flex-col gap-4">
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="currentPassword">Mật khẩu hiện tại</Label>
<Input
id="currentPassword"
type="password"
placeholder="Nhập mật khẩu hiện tại"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="newPassword">Mật khẩu mới</Label>
<Input
id="newPassword"
type="password"
placeholder="Nhập mật khẩu mới"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Xác nhận mật khẩu mới</Label>
<Input
id="confirmPassword"
type="password"
placeholder="Nhập lại mật khẩu mới"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
</div>
{error && (
<Alert variant="destructive">
<AlertTitle>Lỗi</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{mutation.isError && (
<Alert variant="destructive">
<AlertTitle>Lỗi</AlertTitle>
<AlertDescription>
lỗi xảy ra, vui lòng thử lại
</AlertDescription>
</Alert>
)}
<div className="flex gap-2">
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? (
<>
<LoaderCircle className="mr-2 h-4 w-4 animate-spin" />
Đang lưu....
</>
) : (
"Cập nhật"
)}
</Button>
<Button type="button" variant="outline" onClick={handleCancel}>
Hủy
</Button>
</div>
</form>
</div>
</div>
);
}

View File

@ -1,157 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { LoaderCircle, Monitor, X, Maximize2 } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { getRemoteDesktopUrl } from "@/services/remote-control.service";
import { BASE_URL } from "@/config/api";
export const Route = createFileRoute("/_auth/remote-control/")({
head: () => ({ meta: [{ title: "Điều khiển trực tiếp" }] }),
component: RemoteControlPage,
loader: async ({ context }) => {
context.breadcrumbs = [
{ title: "Điều khiển từ xa", path: "/_auth/remote-control/" },
{ title: "Điều khiển trực tiếp", path: "/_auth/remote-control/" },
];
},
});
function RemoteControlPage() {
const [nodeId, setNodeId] = useState("");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [showRemote, setShowRemote] = useState(false);
const [proxyUrl, setProxyUrl] = useState<string | null>(null);
const connectMutation = useMutation({
mutationFn: async (nodeIdValue: string) => {
// Gọi API để lấy URL remote desktop
const response = await getRemoteDesktopUrl(nodeIdValue);
return response;
},
onSuccess: (data) => {
setErrorMessage(null);
// Chuyển URL MeshCentral thành proxy URL
const originalUrl = new URL(data.url);
const pathAndQuery = originalUrl.pathname + originalUrl.search;
const cleanPath = pathAndQuery.startsWith('/') ? pathAndQuery.substring(1) : pathAndQuery;
const baseWithoutApi = BASE_URL.replace('/api', '');
const proxyUrlFull = `${baseWithoutApi}/api/meshcentral/proxy/${cleanPath}`;
console.log("[RemoteControl] Proxy URL:", proxyUrlFull);
setProxyUrl(proxyUrlFull);
setShowRemote(true);
},
onError: (error: any) => {
console.error("[RemoteControl] Error:", error);
setErrorMessage(error?.response?.data?.message || "Lỗi không xác định khi kết nối remote.");
},
});
const handleConnect = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const trimmedNodeId = nodeId.trim();
if (!trimmedNodeId) {
setErrorMessage("Vui lòng nhập nodeID.");
return;
}
setErrorMessage(null);
connectMutation.mutate(trimmedNodeId);
};
const handleClose = () => {
setShowRemote(false);
setProxyUrl(null);
};
const handleFullscreen = () => {
const iframe = document.getElementById("mesh-iframe") as HTMLIFrameElement;
if (iframe?.requestFullscreen) {
iframe.requestFullscreen();
}
};
return (
<div className="w-full max-w-4xl space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Monitor className="h-5 w-5" />
Điều khiển trực tiếp
</CardTitle>
<CardDescription>
Nhập nodeID thiết bị nhấn Connect đ mở phiên remote desktop.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleConnect} className="space-y-3">
<Input
placeholder="Nhập nodeID (ví dụ: node//xxxxxx)"
value={nodeId}
onChange={(event) => setNodeId(event.target.value)}
disabled={connectMutation.isPending}
/>
<Button type="submit" disabled={connectMutation.isPending}>
{connectMutation.isPending ? (
<>
<LoaderCircle className="h-4 w-4 animate-spin" />
Đang kết nối...
</>
) : (
<>
<Monitor className="h-4 w-4 mr-2" />
Connect
</>
)}
</Button>
</form>
{errorMessage && (
<p className="mt-3 text-sm font-medium text-destructive">{errorMessage}</p>
)}
</CardContent>
</Card>
{showRemote && proxyUrl && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/65 p-4">
<div className="relative h-[90vh] w-[90vw] overflow-hidden rounded-lg border bg-background shadow-2xl">
<div className="flex items-center justify-between border-b bg-muted/50 px-3 py-2">
<p className="text-sm font-medium">Remote Session</p>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
type="button"
onClick={handleFullscreen}
title="Fullscreen"
>
<Maximize2 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
type="button"
onClick={handleClose}
aria-label="Đóng"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
<iframe
id="mesh-iframe"
title="Remote Desktop"
src={proxyUrl}
className="h-[calc(90vh-44px)] w-full border-0"
allowFullScreen
allow="clipboard-read; clipboard-write; camera; microphone"
/>
</div>
</div>
)}
</div>
);
}

View File

@ -1,311 +0,0 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import {
useGetRoleById,
useGetRolePermissions,
useGetPermissionList,
useToggleRolePermission,
useAssignRolePermissions,
} from "@/hooks/queries";
import { useState, useEffect, useMemo } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Badge } from "@/components/ui/badge";
import { Shield, ArrowLeft, Save, RefreshCw } from "lucide-react";
import { toast } from "sonner";
import type { Permission, PermissionOnRole } from "@/types/permission";
export const Route = createFileRoute("/_auth/role/$id/edit/")({
component: EditRolePermissionsComponent,
loader: async ({ context, params }) => {
context.breadcrumbs = [
{ title: "Quản lý role", path: "/role" },
{ title: "Chỉnh sửa quyền", path: `/role/${params.id}/edit` },
];
},
});
function EditRolePermissionsComponent() {
const navigate = useNavigate();
const { id } = Route.useParams();
const roleId = Number(id);
// Queries
const { data: role, isLoading: roleLoading } = useGetRoleById(roleId);
const { data: rolePermissions = [], isLoading: rolePermissionsLoading } = useGetRolePermissions(roleId);
const { data: allPermissions = [], isLoading: permissionsLoading } = useGetPermissionList();
// Mutations
const toggleMutation = useToggleRolePermission();
const assignMutation = useAssignRolePermissions();
// State
const [selectedPermissions, setSelectedPermissions] = useState<number[]>([]);
const [hasChanges, setHasChanges] = useState(false);
// Initialize selected permissions from role's current permissions
useEffect(() => {
if (rolePermissions && Array.isArray(rolePermissions)) {
// Use permissionEnum as the identifier (matches value from permission list)
const checkedPermissions = rolePermissions
.filter((p: PermissionOnRole) => p.isChecked === 1)
.map((p: PermissionOnRole) => p.permissionEnum);
setSelectedPermissions(checkedPermissions);
}
}, [rolePermissions]);
// Group permissions by parent (category)
const groupedPermissions = useMemo(() => {
const groups: Record<string, Permission[]> = {};
const permissionList = Array.isArray(allPermissions) ? allPermissions : [];
// First pass: identify all parent categories
const parentPermissions: Permission[] = [];
const childPermissions: Permission[] = [];
permissionList.forEach((perm: Permission) => {
const permValue = perm.value ?? perm.enum ?? 0;
const isParent = permValue % 10 === 0;
if (isParent) {
parentPermissions.push(perm);
groups[perm.name] = [];
} else {
childPermissions.push(perm);
}
});
// Second pass: assign children to parent categories
childPermissions.forEach((perm: Permission) => {
const permValue = perm.value ?? perm.enum ?? 0;
const parentEnum = Math.floor(permValue / 10) * 10;
const parent = parentPermissions.find((p: Permission) => (p.value ?? p.enum) === parentEnum);
const parentName = parent?.name || "Khác";
if (!groups[parentName]) {
groups[parentName] = [];
}
groups[parentName].push(perm);
});
// Third pass: add parent permissions that have no children as selectable items
parentPermissions.forEach((parent) => {
const parentValue = parent.value ?? parent.enum ?? 0;
const hasChildren = childPermissions.some((child) => {
const childValue = child.value ?? child.enum ?? 0;
return Math.floor(childValue / 10) * 10 === parentValue;
});
if (!hasChildren) {
groups[parent.name].push(parent);
}
});
// Remove empty groups
Object.keys(groups).forEach((key) => {
if (groups[key].length === 0) {
delete groups[key];
}
});
return groups;
}, [allPermissions]);
// Helper to get unique identifier for permission (use value as ID)
const getPermId = (perm: Permission) => perm.value ?? perm.id ?? 0;
const handleTogglePermission = (permissionValue: number) => {
setSelectedPermissions((prev) =>
prev.includes(permissionValue)
? prev.filter((v) => v !== permissionValue)
: [...prev, permissionValue]
);
setHasChanges(true);
};
const handleSelectAll = (categoryPermissions: Permission[]) => {
const allValues = categoryPermissions.map((p) => getPermId(p));
const allSelected = allValues.every((v) => selectedPermissions.includes(v));
if (allSelected) {
setSelectedPermissions((prev) =>
prev.filter((v) => !allValues.includes(v))
);
} else {
setSelectedPermissions((prev) => [...new Set([...prev, ...allValues])]);
}
setHasChanges(true);
};
// Save all permissions at once
const handleSaveAll = async () => {
try {
await assignMutation.mutateAsync({
roleId,
permissionIds: selectedPermissions,
});
toast.success("Cập nhật quyền thành công!");
setHasChanges(false);
} catch (error) {
toast.error("Cập nhật quyền thất bại!");
}
};
const isLoading = roleLoading || rolePermissionsLoading || permissionsLoading;
if (isLoading) {
return (
<div className="w-full px-6 flex items-center justify-center h-64">
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="w-full px-6 space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold flex items-center gap-2">
Chỉnh sửa quyền:
<Badge variant="secondary" className="text-lg">
{role?.roleName}
</Badge>
</h1>
<p className="text-muted-foreground mt-2">
Quản quyền hạn của role này
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => navigate({ to: "/role" })}>
<ArrowLeft className="h-4 w-4 mr-2" />
Quay lại
</Button>
{hasChanges && (
<Button onClick={handleSaveAll} disabled={assignMutation.isPending}>
<Save className="h-4 w-4 mr-2" />
{assignMutation.isPending ? "Đang lưu..." : "Lưu thay đổi"}
</Button>
)}
</div>
</div>
{/* Role Info */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" /> Thông tin Role
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-4">
<div>
<span className="text-sm text-muted-foreground">Tên Role</span>
<p className="font-medium">{role?.roleName}</p>
</div>
<div>
<span className="text-sm text-muted-foreground">Đ ưu tiên</span>
<p className="font-medium">{role?.priority}</p>
</div>
<div>
<span className="text-sm text-muted-foreground">Số quyền đã gán</span>
<p className="font-medium">{selectedPermissions.length}</p>
</div>
</div>
</CardContent>
</Card>
{/* Permissions */}
<Card>
<CardHeader>
<CardTitle>Quyền hạn</CardTitle>
<CardDescription>
Tick chọn đ bật/tắt quyền ({selectedPermissions.length} đang đưc gán)
</CardDescription>
</CardHeader>
<CardContent>
<ScrollArea className="h-[500px] pr-4">
<div className="space-y-6">
{Object.entries(groupedPermissions).map(([category, perms]) => (
<div key={category} className="space-y-2">
<div className="flex items-center justify-between border-b pb-2">
<h4 className="font-semibold text-sm text-muted-foreground uppercase">
{category}
</h4>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleSelectAll(perms)}
>
{perms.every((p) => selectedPermissions.includes(getPermId(p)))
? "Bỏ tất cả"
: "Chọn tất cả"}
</Button>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{perms.map((perm) => {
const permValue = getPermId(perm);
const isChecked = selectedPermissions.includes(permValue);
return (
<div
key={permValue}
className={`flex items-center space-x-2 p-2 rounded border hover:bg-muted/50 transition-colors ${
isChecked ? "bg-primary/5 border-primary/30" : ""
}`}
>
<Checkbox
id={`perm-${permValue}`}
checked={isChecked}
onCheckedChange={() => handleTogglePermission(permValue)}
disabled={toggleMutation.isPending}
/>
<label
htmlFor={`perm-${permValue}`}
className="text-sm cursor-pointer flex-1"
>
{perm.name}
<span className="text-xs text-muted-foreground ml-1">
({permValue})
</span>
</label>
</div>
);
})}
</div>
</div>
))}
</div>
</ScrollArea>
</CardContent>
</Card>
{/* Footer Actions */}
{hasChanges && (
<div className="flex justify-end gap-2 sticky bottom-4 bg-background p-4 rounded-lg border shadow-lg">
<Button
variant="outline"
onClick={() => {
// Reset to original - use permissionEnum as identifier
const checkedPermissions = (rolePermissions as PermissionOnRole[])
.filter((p) => p.isChecked === 1)
.map((p) => p.permissionEnum);
setSelectedPermissions(checkedPermissions);
setHasChanges(false);
}}
>
Hủy thay đi
</Button>
<Button onClick={handleSaveAll} disabled={assignMutation.isPending}>
<Save className="h-4 w-4 mr-2" />
{assignMutation.isPending ? "Đang lưu..." : "Lưu tất cả thay đổi"}
</Button>
</div>
)}
</div>
);
}

View File

@ -1,273 +0,0 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useGetPermissionList, useCreateRole } from "@/hooks/queries";
import { useState, useMemo } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Shield, ArrowLeft, Save } from "lucide-react";
import { toast } from "sonner";
import type { Permission } from "@/types/permission";
export const Route = createFileRoute("/_auth/role/create/")({
component: CreateRoleComponent,
loader: async ({ context }) => {
context.breadcrumbs = [
{ title: "Quản lý role", path: "/role" },
{ title: "Tạo role mới", path: "/role/create" },
];
},
});
function CreateRoleComponent() {
const navigate = useNavigate();
const { data: permissions = [], isLoading: permissionsLoading } = useGetPermissionList();
const createMutation = useCreateRole();
const [roleName, setRoleName] = useState("");
const [priority, setPriority] = useState(0);
const [selectedPermissions, setSelectedPermissions] = useState<number[]>([]);
// Helper to get unique identifier for permission
const getPermValue = (perm: Permission) => perm.value ?? perm.id ?? 0;
// Group permissions by parent (category)
const groupedPermissions = useMemo(() => {
const groups: Record<string, Permission[]> = {};
const permissionList = Array.isArray(permissions) ? permissions : [];
// First pass: identify all parent categories
const parentPermissions: Permission[] = [];
const childPermissions: Permission[] = [];
permissionList.forEach((perm: Permission) => {
const permValue = perm.value ?? perm.enum ?? 0;
const isParent = permValue % 10 === 0;
if (isParent) {
parentPermissions.push(perm);
groups[perm.name] = [];
} else {
childPermissions.push(perm);
}
});
// Second pass: assign children to parent categories
childPermissions.forEach((perm: Permission) => {
const permValue = perm.value ?? perm.enum ?? 0;
const parentEnum = Math.floor(permValue / 10) * 10;
const parent = parentPermissions.find((p: Permission) => (p.value ?? p.enum) === parentEnum);
const parentName = parent?.name || "Khác";
if (!groups[parentName]) {
groups[parentName] = [];
}
groups[parentName].push(perm);
});
// Third pass: add parent permissions that have no children as selectable items
// (like ALLOW_ALL which is value 0 with no children)
parentPermissions.forEach((parent) => {
const parentValue = parent.value ?? parent.enum ?? 0;
// Check if this parent has any children
const hasChildren = childPermissions.some((child) => {
const childValue = child.value ?? child.enum ?? 0;
return Math.floor(childValue / 10) * 10 === parentValue;
});
// If no children, add the parent itself as a selectable item
if (!hasChildren) {
groups[parent.name].push(parent);
}
});
// Remove empty groups
Object.keys(groups).forEach((key) => {
if (groups[key].length === 0) {
delete groups[key];
}
});
return groups;
}, [permissions]);
const handleTogglePermission = (permissionValue: number) => {
setSelectedPermissions((prev) =>
prev.includes(permissionValue)
? prev.filter((v) => v !== permissionValue)
: [...prev, permissionValue]
);
};
const handleSelectAll = (categoryPermissions: Permission[]) => {
const allValues = categoryPermissions.map((p) => getPermValue(p));
const allSelected = allValues.every((v) => selectedPermissions.includes(v));
if (allSelected) {
setSelectedPermissions((prev) =>
prev.filter((v) => !allValues.includes(v))
);
} else {
setSelectedPermissions((prev) => [...new Set([...prev, ...allValues])]);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!roleName.trim()) {
toast.error("Vui lòng nhập tên role!");
return;
}
try {
await createMutation.mutateAsync({
RoleName: roleName,
Priority: priority,
PermissionIds: selectedPermissions,
});
toast.success("Tạo role thành công!");
navigate({ to: "/role" });
} catch (error) {
toast.error("Tạo role thất bại!");
}
};
return (
<div className="w-full px-6 space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Tạo Role mới</h1>
<p className="text-muted-foreground mt-2">
Tạo vai trò mới gán quyền hạn
</p>
</div>
<Button variant="outline" onClick={() => navigate({ to: "/role" })}>
<ArrowLeft className="h-4 w-4 mr-2" />
Quay lại
</Button>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Role Info */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" /> Thông tin Role
</CardTitle>
<CardDescription>
Nhập thông tin bản của role
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="roleName">Tên Role *</Label>
<Input
id="roleName"
value={roleName}
onChange={(e) => setRoleName(e.target.value)}
placeholder="Nhập tên role..."
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="priority">Đ ưu tiên</Label>
<Input
id="priority"
type="number"
value={priority}
onChange={(e) => setPriority(Number(e.target.value))}
placeholder="0"
/>
</div>
</div>
</CardContent>
</Card>
{/* Permissions Selection */}
<Card>
<CardHeader>
<CardTitle>Chọn quyền hạn</CardTitle>
<CardDescription>
Chọn các quyền role này đưc phép thực hiện ({selectedPermissions.length} đã chọn)
</CardDescription>
</CardHeader>
<CardContent>
{permissionsLoading ? (
<div className="text-center py-4">Đang tải danh sách quyền...</div>
) : (
<ScrollArea className="h-[400px] pr-4">
<div className="space-y-6">
{Object.entries(groupedPermissions).map(([category, perms]) => (
<div key={category} className="space-y-2">
<div className="flex items-center justify-between">
<h4 className="font-semibold text-sm text-muted-foreground uppercase">
{category}
</h4>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleSelectAll(perms)}
>
{perms.every((p) => selectedPermissions.includes(getPermValue(p)))
? "Bỏ tất cả"
: "Chọn tất cả"}
</Button>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{perms.map((perm) => {
const permValue = getPermValue(perm);
return (
<div
key={permValue}
className="flex items-center space-x-2 p-2 rounded border hover:bg-muted/50"
>
<Checkbox
id={`perm-${permValue}`}
checked={selectedPermissions.includes(permValue)}
onCheckedChange={() => handleTogglePermission(permValue)}
/>
<Label
htmlFor={`perm-${permValue}`}
className="text-sm cursor-pointer flex-1"
>
{perm.name}
</Label>
</div>
);
})}
</div>
</div>
))}
</div>
</ScrollArea>
)}
</CardContent>
</Card>
{/* Submit */}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => navigate({ to: "/role" })}
>
Hủy
</Button>
<Button type="submit" disabled={createMutation.isPending}>
<Save className="h-4 w-4 mr-2" />
{createMutation.isPending ? "Đang tạo..." : "Tạo Role"}
</Button>
</div>
</form>
</div>
);
}

View File

@ -1,134 +0,0 @@
import { useGetRoleList, useDeleteRole } from "@/hooks/queries";
import { formatDate } from "@/lib/utils";
import type { TRoleResponse } from "@/types/role";
import { createFileRoute, Link } from "@tanstack/react-router";
import type { ColumnDef } from "@tanstack/react-table";
import { RoleManagerTemplate } from "@/template/role-manager-template";
import { Button } from "@/components/ui/button";
import { Pencil, Trash2, Shield } from "lucide-react";
import { toast } from "sonner";
export const Route = createFileRoute("/_auth/role/")({
component: RoleComponent,
loader: async ({ context }) => {
context.breadcrumbs = [
{
title: "Quản lý role",
path: "#",
},
{
title: "Danh sách role",
path: "/role",
},
];
},
});
function RoleComponent() {
const { data: roles = [], isLoading } = useGetRoleList();
const roleList = Array.isArray(roles) ? roles : [roles];
const deleteMutation = useDeleteRole();
const handleDelete = async (id: number, roleName: string) => {
if (window.confirm(`Bạn có chắc chắn muốn xóa role "${roleName}"?`)) {
try {
await deleteMutation.mutateAsync(id);
toast.success("Xóa role thành công!");
} catch (error) {
toast.error("Xóa role thất bại!");
}
}
};
const columns: ColumnDef<TRoleResponse>[] = [
{
accessorKey: "roleName",
header: () => <div className="font-bold text-center">Role</div>,
cell: ({ row }) => (
<div className="text-center font-medium">{row.original.roleName}</div>
),
},
{
accessorKey: "priority",
header: () => <div className="font-bold text-center">Đ ưu tiên</div>,
cell: ({ row }) => (
<div className="text-center">{row.original.priority}</div>
),
},
{
accessorKey: "createdAt",
header: () => <div className="font-bold text-center">Ngày tạo</div>,
cell: ({ row }) => (
<div className="text-center">
{formatDate(row.original.createdAt)}
</div>
),
},
{
accessorKey: "createdBy",
header: () => <div className="font-bold text-center">Người tạo</div>,
cell: ({ row }) => (
<div className="text-center">{row.original.createdBy || "-"}</div>
),
},
{
accessorKey: "updatedAt",
header: () => <div className="font-bold text-center">Ngày cập nhật</div>,
cell: ({ row }) => (
<div className="text-center">
{formatDate(row.original.updatedAt)}
</div>
),
},
{
accessorKey: "updatedBy",
header: () => <div className="font-bold text-center">Người cập nhật</div>,
cell: ({ row }) => (
<div className="text-center">{row.original.updatedBy || "-"}</div>
),
},
{
id: "actions",
header: () => <div className="font-bold text-center">Hành đng</div>,
cell: ({ row }) => (
<div className="flex items-center justify-center gap-2">
<Link
to="/role/$id/edit"
params={{ id: String(row.original.id) }}
>
<Button variant="outline" size="sm">
<Pencil className="h-4 w-4 mr-1" />
Sửa quyền
</Button>
</Link>
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(row.original.id, row.original.roleName)}
disabled={deleteMutation.isPending}
>
<Trash2 className="h-4 w-4 mr-1" />
Xóa
</Button>
</div>
),
},
];
return (
<RoleManagerTemplate<TRoleResponse>
title="Quản lý Role"
description="Quản lý các vai trò và quyền hạn trong hệ thống"
data={roleList}
isLoading={isLoading}
columns={columns}
icon={Shield}
tableTitle="Danh sách Role"
tableDescription="Các vai trò trong hệ thống và quyền hạn tương ứng"
createButtonLabel="Tạo role mới"
createLink="/role/create"
enablePagination
defaultPageSize={10}
/>
);
}

View File

@ -1,9 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/_auth/rooms/$roomName/connect/')({
component: RouteComponent,
})
function RouteComponent() {
return <div>Hello "/_auth/rooms/$roomName/connect/"!</div>
}

View File

@ -1,99 +0,0 @@
import {
createFileRoute,
useParams,
useNavigate,
} from "@tanstack/react-router";
import { useMemo } from "react";
import { useGetClientFolderStatus } from "@/hooks/queries";
import type { ClientFolderStatus } from "@/types/folder";
import FolderStatusTemplate from "@/template/folder-status-template";
import {
createColumnHelper,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
export const Route = createFileRoute("/_auth/rooms/$roomName/folder-status/")({
head: ({ params }) => ({
meta: [{ title: `Trạng thái thư mục Setup phòng ${params.roomName}` }],
}),
loader: async ({ context, params }) => {
context.breadcrumbs = [
{ title: "Danh sách phòng", path: "/rooms/" },
{ title: `Phòng ${params.roomName}`, path: `/rooms/${params.roomName}/` },
{ title: `Trạng thái thư mục Setup phòng ${params.roomName}`, path: `/rooms/${params.roomName}/folder-status/` },
];
},
component: RouteComponent,
});
function RouteComponent() {
const { roomName } = useParams({
from: "/_auth/rooms/$roomName/folder-status/",
});
const navigate = useNavigate();
const { data: folderStatusList = [], isLoading } = useGetClientFolderStatus(
roomName as string,
);
const columnHelper = createColumnHelper<ClientFolderStatus>();
const columns = useMemo(
() => [
columnHelper.accessor("deviceId", {
header: "Máy tính",
cell: (info) => info.getValue() ?? "-",
}),
columnHelper.display({
id: "missing",
header: "Số lượng file thiếu",
cell: (info) =>
(info.row.original.missingFiles?.length ?? 0).toString(),
}),
columnHelper.display({
id: "extra",
header: "Số lượng file thừa",
cell: (info) => (info.row.original.extraFiles?.length ?? 0).toString(),
}),
columnHelper.display({
id: "current",
header: "Số lượng file hiện tại",
cell: (info) =>
(info.row.original.currentFiles?.length ?? 0).toString(),
}),
columnHelper.accessor("updatedAt", {
header: "Updated",
cell: (info) => {
const v = info.getValue();
try {
const d = new Date(v as string);
return isNaN(d.getTime())
? (v as string)
: d.toLocaleString("vi-VN");
} catch {
return v as string;
}
},
}),
],
[],
);
const table = useReactTable({
data: folderStatusList ?? [],
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<FolderStatusTemplate
roomName={roomName as string}
data={folderStatusList}
isLoading={isLoading}
onBack={() =>
navigate({ to: "/rooms/$roomName/", params: { roomName } } as any)
}
table={table}
/>
);
}

View File

@ -1,134 +0,0 @@
import { createFileRoute, useParams, useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { LayoutGrid, TableIcon, Monitor, FolderCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useGetDeviceFromRoom } from "@/hooks/queries";
import { useDeviceEvents } from "@/hooks/useDeviceEvents";
import { DeviceGrid } from "@/components/grids/device-grid";
import { DeviceTable } from "@/components/tables/device-table";
import { useMachineNumber } from "@/hooks/useMachineNumber";
import { CommandActionButtons } from "@/components/buttons/command-action-buttons";
export const Route = createFileRoute("/_auth/rooms/$roomName/")({
head: ({ params }) => ({
meta: [{ title: `Danh sách thiết bị phòng ${params.roomName}` }],
}),
loader: async ({ context, params }) => {
context.breadcrumbs = [
{ title: "Danh sách phòng", path: "/rooms/" },
{ title: `Phòng ${params.roomName}`, path: `/rooms/${params.roomName}/` },
];
},
component: RoomDetailPage,
});
function RoomDetailPage() {
const { roomName } = useParams({ from: "/_auth/rooms/$roomName/" });
const [viewMode, setViewMode] = useState<"grid" | "table">("grid");
// SSE real-time updates
useDeviceEvents(roomName);
// Folder status from SS
const { data: devices = [] } = useGetDeviceFromRoom(roomName);
const parseMachineNumber = useMachineNumber();
const navigate = useNavigate();
const sortedDevices = [...devices].sort((a, b) => {
return parseMachineNumber(a.id) - parseMachineNumber(b.id);
});
return (
<div className="w-full px-6 space-y-6">
<Card className="shadow-sm">
<CardHeader className="bg-muted/50 space-y-4">
{/* Hàng 1: Thông tin phòng và controls */}
<div className="flex items-center justify-between w-full gap-4">
<div className="flex items-center gap-2">
<Monitor className="h-5 w-5" />
<CardTitle>Danh sách thiết bị phòng {roomName}</CardTitle>
</div>
<div className="flex items-center gap-2 bg-background rounded-lg p-1 border shrink-0">
<Button
variant={viewMode === "grid" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("grid")}
className="flex items-center gap-2"
>
<LayoutGrid className="h-4 w-4" />
đ
</Button>
<Button
variant={viewMode === "table" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("table")}
className="flex items-center gap-2"
>
<TableIcon className="h-4 w-4" />
Bảng
</Button>
</div>
</div>
{/* Hàng 2: Thực thi lệnh */}
<div className="flex items-center justify-between w-full gap-4">
<div className="flex items-center gap-2 text-sm font-semibold">
Thực thi lệnh
</div>
<div className="flex items-center gap-3 justify-end">
{/* Command Action Buttons */}
{devices.length > 0 && (
<>
<CommandActionButtons roomName={roomName} />
<div className="h-8 w-px bg-border" />
<Button
onClick={() =>
navigate({
to: "/rooms/$roomName/folder-status/",
params: { roomName },
} as any)
}
variant="outline"
size="sm"
className="flex items-center gap-2 shrink-0"
>
<FolderCheck className="h-4 w-4" />
Kiểm tra thư mục Setup
</Button>
</>
)}
</div>
</div>
</CardHeader>
<CardContent className="p-0">
{devices.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12">
<Monitor className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Không thiết bị</h3>
<p className="text-muted-foreground text-center max-w-sm">
Phòng này chưa thiết bị nào đưc kết nối.
</p>
</div>
) : viewMode === "grid" ? (
<DeviceGrid
devices={sortedDevices}
/>
) : (
<DeviceTable
devices={sortedDevices}
/>
)}
</CardContent>
</Card>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More