This commit is contained in:
Do Manh Phuong 2025-09-26 17:56:55 +07:00
parent 8f41579972
commit 26bb177d54
19 changed files with 1222 additions and 310 deletions

202
package-lock.json generated
View File

@ -6,9 +6,11 @@
"": { "": {
"name": ".", "name": ".",
"dependencies": { "dependencies": {
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.7", "@radix-ui/react-tooltip": "^1.2.7",
@ -1346,6 +1348,59 @@
} }
} }
}, },
"node_modules/@radix-ui/react-avatar": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz",
"integrity": "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-is-hydrated": "0.1.0",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collection": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
"integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-compose-refs": { "node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
@ -1412,6 +1467,21 @@
} }
} }
}, },
"node_modules/@radix-ui/react-direction": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
"integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer": { "node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.10", "version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz",
@ -1647,6 +1717,105 @@
} }
} }
}, },
"node_modules/@radix-ui/react-radio-group": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz",
"integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-roving-focus": "1.1.11",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT"
},
"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-presence": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
"integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT"
},
"node_modules/@radix-ui/react-separator": { "node_modules/@radix-ui/react-separator": {
"version": "1.1.7", "version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz",
@ -1792,6 +1961,24 @@
} }
} }
}, },
"node_modules/@radix-ui/react-use-is-hydrated": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz",
"integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.5.0"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-layout-effect": { "node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
@ -1807,6 +1994,21 @@
} }
} }
}, },
"node_modules/@radix-ui/react-use-previous": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
"integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-rect": { "node_modules/@radix-ui/react-use-rect": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",

View File

@ -10,9 +10,11 @@
"test": "vitest run" "test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.7", "@radix-ui/react-tooltip": "^1.2.7",

View File

@ -1,6 +1,6 @@
// app-sidebar.tsx - Đã hỗ trợ onPointerEnter import type React from "react";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { Building } from "lucide-react"; import { Building2, Cpu } from "lucide-react";
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
@ -13,6 +13,7 @@ import {
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { cn } from "@/lib/utils";
type MenuItem = { type MenuItem = {
title: string; title: string;
@ -27,34 +28,58 @@ type AppSidebarProps = {
export function AppSidebar({ items }: AppSidebarProps) { export function AppSidebar({ items }: AppSidebarProps) {
return ( return (
<Sidebar collapsible="icon" className="border-r"> <Sidebar
<SidebarHeader className="border-b"> collapsible="icon"
<div className="flex items-center gap-2 px-2 py-2"> className="border-r border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground"> >
<Building className="size-4" /> <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>
<div className="flex flex-col gap-0.5 leading-none"> <div className="flex flex-col gap-0.5 leading-none">
<span className="font-semibold">TTMT Computer Management</span> <span className="font-bold text-base tracking-tight bg-gradient-to-r from-foreground to-foreground/80 bg-clip-text">
<span className="text-xs text-muted-foreground">v1.0.0</span> 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>
</div> </div>
</SidebarHeader> </SidebarHeader>
<SidebarContent> <SidebarContent className="p-4">
<SidebarGroup> <SidebarGroup>
<SidebarGroupLabel>Navigation</SidebarGroupLabel> <SidebarGroupLabel className="text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider mb-2">
Navigation
</SidebarGroupLabel>
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu className="space-y-1">
{items.map((item) => ( {items.map((item) => (
<SidebarMenuItem key={item.title}> <SidebarMenuItem key={item.title}>
<SidebarMenuButton <SidebarMenuButton
asChild asChild
tooltip={item.title} tooltip={item.title}
onPointerEnter={item.onPointerEnter} 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={"."}> <Link
<item.icon className="size-4" /> href={item.to}
<span>{item.title}</span> 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> </Link>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
@ -63,10 +88,10 @@ export function AppSidebar({ items }: AppSidebarProps) {
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>
</SidebarContent> </SidebarContent>
<SidebarFooter className="border-t"> <SidebarFooter className="border-t border-border/40 p-4 space-y-3">
<div className="p-2 text-xs text-muted-foreground"> <div className="px-2 text-xs text-muted-foreground/60 font-medium">
© 2025 NAVIS Centre © 2025 NAVIS Centre
</div> </div>
</SidebarFooter> </SidebarFooter>
</Sidebar> </Sidebar>

View File

@ -1,90 +1,68 @@
"use client"; "use client";
import { useState } from "react";
import { useForm } from "@tanstack/react-form"; import { useForm } from "@tanstack/react-form";
import { z } from "zod"; import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Loader2, Terminal, AlertTriangle, CheckCircle } from "lucide-react";
interface ShellCommandFormProps { interface ShellCommandFormProps {
onExecute: (command: string) => Promise<{ success: boolean; output: string }>; command: string;
onCommandChange: (value: string) => void;
disabled?: boolean;
} }
export function ShellCommandForm({ onExecute }: ShellCommandFormProps) { export function ShellCommandForm({
const [isLoading, setIsLoading] = useState(false); command,
onCommandChange,
// init form disabled,
}: ShellCommandFormProps) {
const form = useForm({ const form = useForm({
defaultValues: { defaultValues: { command },
command: "", onSubmit: () => {},
},
onSubmit: async ({ value }) => {
setIsLoading(true);
try {
const res = await onExecute(value.command);
if (res.success) {
form.reset();
}
} finally {
setIsLoading(false);
}
},
}); });
return ( return (
<div className="space-y-6"> <form
onSubmit={(e) => {
{/* Form */} e.preventDefault();
<form form.handleSubmit();
onSubmit={(e) => { }}
e.preventDefault(); className="space-y-5"
form.handleSubmit(); >
}} <form.Field
className="space-y-5" name="command"
> validators={{
{/* Field: command */} onChange: ({ value }: { value: string }) => {
<form.Field const schema = z
name="command" .string()
validators={{ .min(1, "Nhập command để thực thi")
onChange: z .max(500, "Command quá dài");
.string() const result = schema.safeParse(value);
.min(1, "Nhập command để thực thi") if (!result.success) {
.max(500, "Command quá dài"), 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);
}} }}
children={(field) => ( disabled={disabled}
<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)}
disabled={isLoading}
/>
{field.state.meta.errors?.length > 0 && (
<p className="text-sm text-red-500">
{field.state.meta.errors.join(", ")}
</p>
)}
</div>
)}
/> />
{field.state.meta.errors?.length > 0 && (
<Button type="submit" disabled={isLoading}> <p className="text-sm text-red-500">
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {String(field.state.meta.errors[0])}
Yêu cầu thiết bị thực thi </p>
</Button> )}
</form> </div>
</div> )}
/>
</form>
); );
} }

View File

@ -0,0 +1,98 @@
"use client"
import { useState } from "react"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Label } from "@/components/ui/label"
import { Check, Home } from "lucide-react"
interface RoomSelectDialogProps {
open: boolean
onClose: () => void
rooms: string[]
onConfirm: (roomName: string) => void
}
export function RoomSelectDialog({
open,
onClose,
rooms,
onConfirm,
}: RoomSelectDialogProps) {
const [selectedRoom, setSelectedRoom] = useState<string>("")
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">
<Home className="w-6 h-6 text-primary" />
</div>
<DialogTitle className="text-xl font-semibold">
Chọn phòng đ cập nhật
</DialogTitle>
<p className="text-sm text-muted-foreground mt-1">
Vui lòng chọn phòng đ gửi lệnh cập nhật
</p>
</DialogHeader>
<div className="py-3">
<RadioGroup
value={selectedRoom}
onValueChange={setSelectedRoom}
className="space-y-3"
>
{rooms.map((room) => (
<div
key={room}
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"
>
<div className="flex items-center gap-3">
<RadioGroupItem value={room} id={room} />
<Label
htmlFor={room}
className="font-medium cursor-pointer hover:text-primary"
>
{room}
</Label>
</div>
{selectedRoom === room && (
<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>
))}
</RadioGroup>
</div>
<DialogFooter className="gap-2 pt-4">
<Button variant="outline" onClick={onClose} className="flex-1 sm:flex-none">
Hủy
</Button>
<Button
onClick={() => {
if (selectedRoom) {
onConfirm(selectedRoom)
onClose()
}
}}
disabled={!selectedRoom}
className="flex-1 sm:flex-none"
>
<Check className="w-4 h-4 mr-2" />
Xác nhận
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,51 @@
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }

View File

@ -0,0 +1,43 @@
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }

View File

@ -1,14 +1,34 @@
import { Button } from "@/components/ui/button"; "use client"
import { Button } from "@/components/ui/button"
import { Loader2, RefreshCw } from "lucide-react"
interface UpdateButtonProps { interface UpdateButtonProps {
onClick: () => void; onClick: () => void
loading?: boolean; loading?: boolean
label?: string
} }
export function UpdateButton({ onClick, loading }: UpdateButtonProps) { export function UpdateButton({ onClick, loading, label }: UpdateButtonProps) {
return ( return (
<Button variant="outline" onClick={onClick} disabled={loading}> <Button
{loading ? "Đang gửi..." : "Yêu cầu thiết bị cập nhật"} variant="outline"
onClick={onClick}
disabled={loading}
className="group relative overflow-hidden border-2 border-gray-300 bg-white text-gray-800 font-medium px-6 py-2.5 rounded-lg transition-all duration-300 hover:border-gray-400 hover:bg-gray-50 hover:shadow-lg hover:shadow-gray-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 text-gray-600" />
) : (
<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 ?? "Yêu cầu thiết bị cập nhật")}
</span>
</div>
<div className="absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-gray-100/30 to-transparent transition-transform duration-700 group-hover:translate-x-full" />
</Button> </Button>
); )
} }

View File

@ -11,12 +11,12 @@ export const API_ENDPOINTS = {
GET_SOFTWARE: `${BASE_URL}/AppVersion/msifiles`, GET_SOFTWARE: `${BASE_URL}/AppVersion/msifiles`,
}, },
DEVICE_COMM: { DEVICE_COMM: {
DOWNLOAD_MSI: `${BASE_URL}/DeviceComm/installmsi`, DOWNLOAD_MSI: (roomName: string) => `${BASE_URL}/DeviceComm/installmsi/${roomName}`,
GET_ROOM_LIST: `${BASE_URL}/DeviceComm/rooms`, GET_ROOM_LIST: `${BASE_URL}/DeviceComm/rooms`,
GET_DEVICE_FROM_ROOM: (roomName: string) => GET_DEVICE_FROM_ROOM: (roomName: string) =>
`${BASE_URL}/DeviceComm/room/${roomName}`, `${BASE_URL}/DeviceComm/room/${roomName}`,
UPDATE_AGENT: `${BASE_URL}/DeviceComm/updateagent`, UPDATE_AGENT: (roomName: string) => `${BASE_URL}/DeviceComm/updateagent/${roomName}`,
SEND_COMMAND: `${BASE_URL}/DeviceComm/shellcommand`, SEND_COMMAND: (roomName: string) => `${BASE_URL}/DeviceComm/shellcommand/${roomName}`,
CHANGE_DEVICE_ROOM: `${BASE_URL}/DeviceComm/changeroom`, CHANGE_DEVICE_ROOM: `${BASE_URL}/DeviceComm/changeroom`,
}, },
SSE_EVENTS: { SSE_EVENTS: {

View File

@ -18,18 +18,25 @@ export function useMutationData<TInput = any, TOutput = any>({
}: MutationDataOptions<TInput, TOutput>) { }: MutationDataOptions<TInput, TOutput>) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<TOutput, any, { data: TInput; config?: any }>({ return useMutation<
mutationFn: async ({ data, config }) => { TOutput,
const isFormData = data instanceof FormData; 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({ const response = await axios.request({
url, url: customUrl ?? url,
method, method: customMethod ?? method,
data, data,
headers: { headers: {
...(isFormData ...(isFormData ? {} : { "Content-Type": "application/json" }),
? {}
: { "Content-Type": "application/json" }),
}, },
...config, ...config,
}); });

View File

@ -64,37 +64,43 @@ export default function AppLayout({ children }: AppLayoutProps) {
icon: AppWindow, icon: AppWindow,
onPointerEnter: handlePrefetchAgents, onPointerEnter: handlePrefetchAgents,
}, },
{ title: "Quản lý phần mềm", to: "/apps", icon: AppWindow, {
title: "Quản lý phần mềm",
to: "/apps",
icon: AppWindow,
onPointerEnter: handlePrefetchSofware, onPointerEnter: handlePrefetchSofware,
}, },
{ title: "Gửi lệnh CMD", to: "/command", icon: Terminal }, { title: "Gửi lệnh CMD", to: "/command", icon: Terminal },
]; ];
return ( return (
<SidebarProvider> <SidebarProvider>
<div className="flex min-h-screen w-full"> <div className="flex min-h-screen w-full bg-background">
<AppSidebar items={items} /> <AppSidebar items={items} />
<SidebarInset className="flex-1"> <SidebarInset className="flex-1">
{/* Mobile header with sidebar trigger */} <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">
<header className="flex h-14 shrink-0 items-center gap-2 border-b px-4 lg:hidden"> <SidebarTrigger className="-ml-1 hover:bg-accent/50 rounded-lg p-2 transition-colors" />
<SidebarTrigger className="-ml-1" /> <Separator
<Separator orientation="vertical" className="mr-2 h-4" /> orientation="vertical"
<div className="flex items-center gap-2"> className="mr-2 h-6 bg-border/60"
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground"> />
<div className="flex items-center gap-3">
<div className="flex aspect-square size-9 items-center justify-center rounded-xl bg-gradient-to-br from-primary to-primary/80 text-primary-foreground shadow-md">
<Building className="size-4" /> <Building className="size-4" />
</div> </div>
<div className="flex flex-col gap-0.5 leading-none"> <div className="flex flex-col gap-0.5 leading-none">
<span className="font-semibold text-sm"> <span className="font-bold text-sm tracking-tight">
TTMT Computer Management TTMT Computer Management
</span> </span>
<span className="text-xs text-muted-foreground">v1.0.0</span> <span className="text-xs text-muted-foreground font-medium">
v1.0.0
</span>
</div> </div>
</div> </div>
</header> </header>
{/* Main content with responsive padding */} <main className="flex-1 p-4 sm:p-6 lg:p-8 overflow-auto bg-gradient-to-br from-background to-muted/20 min-h-[calc(100vh-4rem)] lg:min-h-screen">
<main className="flex-1 p-4 sm:p-6 lg:p-8 overflow-auto"> <div className="mx-auto max-w-7xl">{children}</div>
{children}
</main> </main>
<Toaster <Toaster
@ -102,9 +108,12 @@ export default function AppLayout({ children }: AppLayoutProps) {
toastOptions={{ toastOptions={{
classNames: { classNames: {
toast: toast:
"text-sm sm:text-lg px-4 sm:px-6 py-3 sm:py-4 rounded-xl max-w-sm sm:max-w-md shadow-lg", "text-sm sm:text-base px-4 sm:px-6 py-3 sm:py-4 rounded-xl max-w-sm sm:max-w-md shadow-xl border border-border/50 backdrop-blur-sm bg-background/95",
title: "text-sm sm:text-lg font-semibold", title: "text-sm sm:text-base font-semibold",
description: "text-xs sm:text-base", description: "text-xs sm:text-sm text-muted-foreground",
success: "border-green-200 bg-green-50/90 text-green-900",
error: "border-red-200 bg-red-50/90 text-red-900",
warning: "border-yellow-200 bg-yellow-50/90 text-yellow-900",
}, },
}} }}
/> />

View File

@ -6,6 +6,7 @@ import { BASE_URL, API_ENDPOINTS } from "@/config/api";
import { toast } from "sonner"; import { toast } from "sonner";
import type { ColumnDef } from "@tanstack/react-table"; import type { ColumnDef } from "@tanstack/react-table";
import type { AxiosProgressEvent } from "axios"; import type { AxiosProgressEvent } from "axios";
import { type Room } from "@/types/room";
type Version = { type Version = {
id?: string; id?: string;
@ -28,11 +29,22 @@ function AgentsPage() {
url: BASE_URL + API_ENDPOINTS.APP_VERSION.GET_VERSION, url: BASE_URL + API_ENDPOINTS.APP_VERSION.GET_VERSION,
}); });
// Lấy danh sách phòng
const { data: roomData } = useQueryData({
queryKey: ["rooms"],
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST,
});
// Map từ object sang string[]
const rooms: string[] = Array.isArray(roomData)
? (roomData as Room[]).map((r) => r.name)
: [];
const versionList: Version[] = Array.isArray(data) const versionList: Version[] = Array.isArray(data)
? data ? data
: data : data
? [data] ? [data]
: []; : [];
// Mutation upload // Mutation upload
const uploadMutation = useMutationData<FormData>({ const uploadMutation = useMutationData<FormData>({
@ -41,19 +53,38 @@ function AgentsPage() {
invalidate: [["agent-version"]], invalidate: [["agent-version"]],
onSuccess: () => toast.success("Upload thành công!"), onSuccess: () => toast.success("Upload thành công!"),
onError: (error) => { onError: (error) => {
console.error("Upload error:", error) console.error("Upload error:", error);
toast.error("Upload thất bại!") toast.error("Upload thất bại!");
}, },
}); });
// Mutation update
const updateMutation = useMutationData<void>({ const updateMutation = useMutationData<void>({
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.UPDATE_AGENT, url: "",
method: "POST", method: "POST",
onSuccess: () => toast.success("Đã gửi yêu cầu update!"), onSuccess: () => toast.success("Đã gửi yêu cầu update!"),
onError: () => toast.error("Gửi yêu cầu thất bại!"), onError: () => toast.error("Gửi yêu cầu thất bại!"),
}); });
const handleUpload = async (
fd: FormData,
config?: { onUploadProgress?: (e: AxiosProgressEvent) => void }
) => {
return uploadMutation.mutateAsync({
data: fd,
config,
});
};
// Callback khi chọn phòng update
const handleUpdate = async (roomName: string) => {
return updateMutation.mutateAsync({
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.UPDATE_AGENT(roomName),
method: "POST",
data: undefined,
});
};
// Cột bảng
const columns: ColumnDef<Version>[] = [ const columns: ColumnDef<Version>[] = [
{ accessorKey: "version", header: "Phiên bản" }, { accessorKey: "version", header: "Phiên bản" },
{ accessorKey: "fileName", header: "Tên file" }, { accessorKey: "fileName", header: "Tên file" },
@ -73,25 +104,9 @@ function AgentsPage() {
getValue() getValue()
? new Date(getValue() as string).toLocaleString("vi-VN") ? new Date(getValue() as string).toLocaleString("vi-VN")
: "N/A", : "N/A",
} },
]; ];
const handleUpload = async (
fd: FormData,
config?: { onUploadProgress?: (e: AxiosProgressEvent) => void }
) => {
return uploadMutation.mutateAsync({
data: fd,
config
});
};
const handleUpdate = async () => {
return updateMutation.mutateAsync({
data: undefined,
});
};
return ( return (
<AppManagerTemplate<Version> <AppManagerTemplate<Version>
title="Quản lý Agent" title="Quản lý Agent"
@ -102,6 +117,7 @@ function AgentsPage() {
onUpload={handleUpload} onUpload={handleUpload}
onUpdate={handleUpdate} onUpdate={handleUpdate}
updateLoading={updateMutation.isPending} updateLoading={updateMutation.isPending}
rooms={rooms}
/> />
); );
} }

View File

@ -7,6 +7,7 @@ import { toast } from "sonner";
import type { ColumnDef } from "@tanstack/react-table"; import type { ColumnDef } from "@tanstack/react-table";
import { useState } from "react"; import { useState } from "react";
import type { AxiosProgressEvent } from "axios"; import type { AxiosProgressEvent } from "axios";
import type { Room } from "@/types/room";
export const Route = createFileRoute("/_authenticated/apps/")({ export const Route = createFileRoute("/_authenticated/apps/")({
head: () => ({ meta: [{ title: "Quản lý phần mềm" }] }), head: () => ({ meta: [{ title: "Quản lý phần mềm" }] }),
@ -25,21 +26,31 @@ type Version = {
function AppsComponent() { function AppsComponent() {
const { data, isLoading } = useQueryData({ const { data, isLoading } = useQueryData({
queryKey: ["software-version"], queryKey: ["software-version"],
url: BASE_URL + API_ENDPOINTS.APP_VERSION.GET_SOFTWARE, // API lấy danh sách file MSI url: BASE_URL + API_ENDPOINTS.APP_VERSION.GET_SOFTWARE,
}); });
const { data: roomData } = useQueryData({
queryKey: ["rooms"],
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST,
});
// Map từ object sang string[]
const rooms: string[] = Array.isArray(roomData)
? (roomData as Room[]).map((r) => r.name)
: [];
const versionList: Version[] = Array.isArray(data) const versionList: Version[] = Array.isArray(data)
? data ? data
: data : data
? [data] ? [data]
: []; : [];
const [table, setTable] = useState<any>(); const [table, setTable] = useState<any>();
const uploadMutation = useMutationData<FormData>({ const uploadMutation = useMutationData<FormData>({
url: BASE_URL + API_ENDPOINTS.APP_VERSION.UPLOAD, url: BASE_URL + API_ENDPOINTS.APP_VERSION.UPLOAD,
method: "POST", method: "POST",
invalidate: [["software-version"]], // Add this to refresh data after upload invalidate: [["software-version"]],
onSuccess: () => toast.success("Upload thành công!"), onSuccess: () => toast.success("Upload thành công!"),
onError: (error) => { onError: (error) => {
console.error("Upload error:", error); console.error("Upload error:", error);
@ -48,7 +59,7 @@ function AppsComponent() {
}); });
const installMutation = useMutationData<{ MsiFileIds: number[] }>({ const installMutation = useMutationData<{ MsiFileIds: number[] }>({
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.DOWNLOAD_MSI, url: "",
method: "POST", method: "POST",
onSuccess: () => toast.success("Đã gửi yêu cầu cài đặt MSI!"), onSuccess: () => toast.success("Đã gửi yêu cầu cài đặt MSI!"),
onError: (error) => { onError: (error) => {
@ -57,8 +68,8 @@ function AppsComponent() {
}, },
}); });
// Cột bảng
const columns: ColumnDef<Version>[] = [ const columns: ColumnDef<Version>[] = [
{ accessorKey: "version", header: "Phiên bản" }, { accessorKey: "version", header: "Phiên bản" },
{ accessorKey: "fileName", header: "Tên file" }, { accessorKey: "fileName", header: "Tên file" },
{ accessorKey: "folderPath", header: "Đường dẫn" }, { accessorKey: "folderPath", header: "Đường dẫn" },
@ -87,9 +98,10 @@ function AppsComponent() {
), ),
enableSorting: false, enableSorting: false,
enableHiding: false, enableHiding: false,
} },
]; ];
// Upload file MSI
const handleUpload = async ( const handleUpload = async (
fd: FormData, fd: FormData,
config?: { onUploadProgress?: (e: AxiosProgressEvent) => void } config?: { onUploadProgress?: (e: AxiosProgressEvent) => void }
@ -100,24 +112,23 @@ function AppsComponent() {
}); });
}; };
const handleInstall = async () => { // Callback khi chọn phòng
const handleInstall = async (roomName: string) => {
if (!table) { if (!table) {
toast.error("Không thể lấy thông tin bảng!"); toast.error("Không thể lấy thông tin bảng!");
return; return;
} }
const selectedRows = table.getSelectedRowModel().rows; const selectedRows = table.getSelectedRowModel().rows;
if (selectedRows.length === 0) { if (selectedRows.length === 0) {
toast.error("Vui lòng chọn ít nhất một file để cài đặt!"); toast.error("Vui lòng chọn ít nhất một file để cài đặt!");
return; return;
} }
const MsiFileIds = selectedRows.map((row: any) => row.original.id); const MsiFileIds = selectedRows.map((row: any) => row.original.id);
console.log("Selected MSI file IDs:", MsiFileIds);
return installMutation.mutateAsync({ return installMutation.mutateAsync({
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.DOWNLOAD_MSI(roomName), // set url động
data: { MsiFileIds }, data: { MsiFileIds },
}); });
}; };
@ -130,9 +141,10 @@ function AppsComponent() {
isLoading={isLoading} isLoading={isLoading}
columns={columns} columns={columns}
onUpload={handleUpload} onUpload={handleUpload}
onUpdate={handleInstall} onUpdate={handleInstall}
updateLoading={installMutation.isPending} updateLoading={installMutation.isPending}
onTableInit={setTable} onTableInit={setTable}
rooms={rooms}
/> />
); );
} }

View File

@ -2,8 +2,13 @@ import { createFileRoute } from "@tanstack/react-router";
import { FormSubmitTemplate } from "@/template/form-submit-template"; import { FormSubmitTemplate } from "@/template/form-submit-template";
import { ShellCommandForm } from "@/components/command-form"; import { ShellCommandForm } from "@/components/command-form";
import { useMutationData } from "@/hooks/useMutationData"; import { useMutationData } from "@/hooks/useMutationData";
import { useQueryData } from "@/hooks/useQueryData";
import { BASE_URL, API_ENDPOINTS } from "@/config/api"; import { BASE_URL, API_ENDPOINTS } from "@/config/api";
import { toast } from "sonner"; import { toast } from "sonner";
import type { Room } from "@/types/room";
type SendCommandRequest = { Command: string };
type SendCommandResponse = { status: string; message: string };
export const Route = createFileRoute("/_authenticated/command/")({ export const Route = createFileRoute("/_authenticated/command/")({
head: () => ({ meta: [{ title: "Gửi lệnh CMD" }] }), head: () => ({ meta: [{ title: "Gửi lệnh CMD" }] }),
@ -11,17 +16,29 @@ export const Route = createFileRoute("/_authenticated/command/")({
}); });
function CommandPage() { function CommandPage() {
// Lấy danh sách phòng từ API
const { data: roomData } = useQueryData({
queryKey: ["rooms"],
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST,
});
// Map từ object sang string[]
const rooms: string[] = Array.isArray(roomData)
? (roomData as Room[]).map((r) => r.name)
: [];
// Mutation gửi lệnh
const sendCommandMutation = useMutationData< const sendCommandMutation = useMutationData<
string, SendCommandRequest,
{ success: boolean; output: string } SendCommandResponse
>({ >({
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.SEND_COMMAND, url: "", // sẽ set động theo roomName khi gọi
method: "POST", method: "POST",
onSuccess: (data) => { onSuccess: (data) => {
if (data.success) { if (data.status === "OK") {
toast.success("Lệnh đã được gửi thành công!"); toast.success("Gửi lệnh thành công!");
} else { } else {
toast.error("Lệnh không thể thực thi trên thiết bị!"); toast.error("Gửi lệnh thất bại!");
} }
}, },
onError: (error) => { onError: (error) => {
@ -35,12 +52,22 @@ function CommandPage() {
title="CMD Command" title="CMD Command"
description="Gửi lệnh shell xuống thiết bị để thực thi" description="Gửi lệnh shell xuống thiết bị để thực thi"
isLoading={sendCommandMutation.isPending} isLoading={sendCommandMutation.isPending}
rooms={rooms}
onSubmit={(roomName, command) => {
sendCommandMutation.mutateAsync({
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.SEND_COMMAND(roomName),
data: { Command: command },
});
}}
submitLoading={sendCommandMutation.isPending}
> >
<ShellCommandForm {({ command, setCommand }) => (
onExecute={async (cmd: string) => { <ShellCommandForm
return await sendCommandMutation.mutateAsync({ data: cmd }); command={command}
}} onCommandChange={setCommand}
/> disabled={sendCommandMutation.isPending}
/>
)}
</FormSubmitTemplate> </FormSubmitTemplate>
); );
} }

View File

@ -4,6 +4,7 @@ import { API_ENDPOINTS, BASE_URL } from "@/config/api";
import { import {
flexRender, flexRender,
getCoreRowModel, getCoreRowModel,
getPaginationRowModel,
useReactTable, useReactTable,
type ColumnDef, type ColumnDef,
} from "@tanstack/react-table"; } from "@tanstack/react-table";
@ -17,6 +18,20 @@ import {
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { useDeviceEvents } from "@/hooks/useDeviceEvents"; import { useDeviceEvents } from "@/hooks/useDeviceEvents";
import {
ChevronLeft,
ChevronRight,
Clock,
Hash,
Loader2,
MapPin,
Monitor,
Wifi,
WifiOff,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
export const Route = createFileRoute("/_authenticated/room/$roomName/")({ export const Route = createFileRoute("/_authenticated/room/$roomName/")({
head: ({ params }) => ({ head: ({ params }) => ({
@ -39,37 +54,99 @@ function RoomDetailComponent() {
const columns: ColumnDef<any>[] = [ const columns: ColumnDef<any>[] = [
{ {
header: "STT", header: "STT",
cell: ({ row }) => row.index + 1, cell: ({ row }) => (
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarFallback className="bg-primary/10 text-primary">
<Monitor className="h-4 w-4" />
</AvatarFallback>
</Avatar>
<span className="font-medium text-sm">{row.index + 1}</span>
</div>
),
}, },
{ {
header: "MAC Address", header: () => (
<div className="flex items-center gap-2">
<Hash className="h-4 w-4" />
MAC Address
</div>
),
accessorKey: "macAddress", accessorKey: "macAddress",
cell: ({ getValue }) => (
<code className="bg-muted px-2 py-1 rounded text-sm font-mono">
{getValue() as string}
</code>
),
}, },
{ {
header: "Thời gian thiết bị", header: () => (
<div className="flex items-center gap-2">
<Clock className="h-4 w-4" />
Thời gian thiết bị
</div>
),
accessorKey: "deviceTime", accessorKey: "deviceTime",
cell: ({ getValue }) => { cell: ({ getValue }) => {
const date = new Date(getValue() as string); const date = new Date(getValue() as string);
return date.toLocaleString(); return (
<div className="text-sm">
<div className="font-medium">
{date.toLocaleDateString("vi-VN")}
</div>
<div className="text-muted-foreground">
{date.toLocaleTimeString("vi-VN")}
</div>
</div>
);
}, },
}, },
{ {
header: "Phiên bản", header: "Phiên bản",
accessorKey: "version", accessorKey: "version",
cell: ({ getValue }) => (
<Badge variant="secondary" className="font-mono">
v{getValue() as string}
</Badge>
),
}, },
{ {
header: "Địa chỉ IP", header: () => (
<div className="flex items-center gap-2">
<MapPin className="h-4 w-4" />
Đa chỉ IP
</div>
),
accessorKey: "ipAddress", accessorKey: "ipAddress",
cell: ({ getValue }) => (
<code className="bg-muted px-2 py-1 rounded text-sm font-mono">
{getValue() as string}
</code>
),
}, },
{ {
header: "Trạng thái", header: "Trạng thái",
accessorKey: "isOffline", accessorKey: "isOffline",
cell: ({ getValue }) => cell: ({ getValue }) => {
getValue() ? ( const isOffline = getValue() as boolean;
<span className="text-red-500 font-semibold">Offline</span> return (
) : ( <Badge
<span className="text-green-500 font-semibold">Online</span> variant={isOffline ? "destructive" : "default"}
), className={`flex items-center gap-1 w-fit ${
isOffline
? "bg-red-100 text-red-700 hover:bg-red-100"
: "bg-green-100 text-green-700 hover:bg-green-100"
}`}
>
{isOffline ? (
<WifiOff className="h-3 w-3" />
) : (
<Wifi className="h-3 w-3" />
)}
{isOffline ? "Offline" : "Online"}
</Badge>
);
},
}, },
]; ];
@ -77,55 +154,170 @@ function RoomDetailComponent() {
data: devices, data: devices,
columns, columns,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: { pagination: { pageSize: 16 } },
}); });
if (isLoading) return <div>Đang tải thiết bị...</div>; if (isLoading) {
return (
return ( <div className="w-full px-6 py-8">
<div className="w-full px-6 space-y-4"> <div className="flex items-center justify-center min-h-[400px]">
<div className="flex items-center justify-between"> <div className="flex flex-col items-center gap-4">
<div> <Loader2 className="h-8 w-8 animate-spin text-primary" />
<h1 className="text-3xl font-bold">Phòng: {roomName}</h1> <p className="text-muted-foreground">Đang tải danh sách phòng...</p>
<p className="text-muted-foreground mt-2"> </div>
Danh sách thiết bị trong phòng
</p>
</div> </div>
</div> </div>
<Card> );
<CardHeader> }
<CardTitle>Thiết bị</CardTitle>
const onlineDevices = devices.filter(
(device: any) => !device.isOffline
).length;
const offlineDevices = devices.length - onlineDevices;
return (
<div className="w-full px-6 space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h1 className="text-3xl font-bold tracking-tight">
Phòng: {roomName}
</h1>
<p className="text-muted-foreground">
Quản theo dõi thiết bị trong phòng
</p>
</div>
<div className="flex gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-green-600">
{onlineDevices}
</div>
<div className="text-sm text-muted-foreground">Online</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-red-600">
{offlineDevices}
</div>
<div className="text-sm text-muted-foreground">Offline</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold">{devices.length}</div>
<div className="text-sm text-muted-foreground">Tổng cộng</div>
</div>
</div>
</div>
<Card className="shadow-sm">
<CardHeader className="bg-muted/50">
<CardTitle className="flex items-center gap-2">
<Monitor className="h-5 w-5" />
Danh sách thiết bị
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="p-0">
<Table> {devices.length === 0 ? (
<TableHeader> <div className="flex flex-col items-center justify-center py-12">
{table.getHeaderGroups().map((headerGroup) => ( <Monitor className="h-12 w-12 text-muted-foreground mb-4" />
<TableRow key={headerGroup.id}> <h3 className="text-lg font-semibold mb-2">Không thiết bị</h3>
{headerGroup.headers.map((header) => ( <p className="text-muted-foreground text-center max-w-sm">
<TableHead key={header.id}> Phòng này chưa thiết bị nào đưc kết nối.
{flexRender( </p>
header.column.columnDef.header, </div>
header.getContext() ) : (
)} <>
</TableHead> <div className="max-h-[600px] overflow-y-auto">
))} <Table>
</TableRow> <TableHeader className="sticky top-0 bg-background z-10">
))} {table.getHeaderGroups().map((headerGroup) => (
</TableHeader> <TableRow
<TableBody> key={headerGroup.id}
{table.getRowModel().rows.map((row) => ( className="hover:bg-transparent border-b"
<TableRow key={row.id}> >
{row.getVisibleCells().map((cell) => ( {headerGroup.headers.map((header) => (
<TableCell key={cell.id}> <TableHead
{flexRender( key={header.id}
cell.column.columnDef.cell, className="font-semibold text-foreground bg-muted/30"
cell.getContext() >
)} {flexRender(
</TableCell> header.column.columnDef.header,
))} header.getContext()
</TableRow> )}
))} </TableHead>
</TableBody> ))}
</Table> </TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
className="hover:bg-muted/50 transition-colors"
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="py-4">
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between p-4 border-t bg-muted/20">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>
Hiển thị{" "}
{table.getState().pagination.pageIndex *
table.getState().pagination.pageSize +
1}{" "}
-{" "}
{Math.min(
(table.getState().pagination.pageIndex + 1) *
table.getState().pagination.pageSize,
devices.length
)}{" "}
trong tổng số {devices.length} thiết bị
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
className="flex items-center gap-1"
>
<ChevronLeft className="h-4 w-4" />
Trước
</Button>
<div className="flex items-center gap-1 text-sm font-medium">
<span>Trang</span>
<span className="bg-primary text-primary-foreground px-2 py-1 rounded">
{table.getState().pagination.pageIndex + 1}
</span>
<span>của {table.getPageCount()}</span>
</div>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
className="flex items-center gap-1"
>
Sau
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@ -19,7 +19,19 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { getPaginationRowModel } from "@tanstack/react-table";
import {
Building2,
ChevronLeft,
ChevronRight,
Loader2,
Wifi,
WifiOff,
} from "lucide-react";
import React from "react"; import React from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
export const Route = createFileRoute("/_authenticated/room/")({ export const Route = createFileRoute("/_authenticated/room/")({
head: () => ({ head: () => ({
@ -43,19 +55,60 @@ function RoomComponent() {
const columns: ColumnDef<any>[] = [ const columns: ColumnDef<any>[] = [
{ {
header: "STT", header: "STT",
cell: ({ row }) => row.index + 1, cell: ({ row }) => (
<div className="font-medium text-muted-foreground">{row.index + 1}</div>
),
}, },
{ {
header: "Tên phòng", header: "Tên phòng",
accessorKey: "name", accessorKey: "name",
cell: ({ row }) => (
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Building2 className="h-5 w-5 text-primary" />
</div>
<div>
<div className="font-semibold">{row.original.name}</div>
<div className="text-sm text-muted-foreground">
Phòng #{row.index + 1}
</div>
</div>
</div>
),
}, },
{ {
header: "Số lượng thiết bị", header: "Số lượng thiết bị",
accessorKey: "numberOfDevices", accessorKey: "numberOfDevices",
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Wifi className="h-4 w-4 text-green-600" />
<Badge variant="secondary" className="font-medium">
{row.original.numberOfDevices} thiết bị
</Badge>
</div>
),
}, },
{ {
header: "Thiết bị offline", header: "Thiết bị offline",
accessorKey: "numberOfOfflineDevices", accessorKey: "numberOfOfflineDevices",
cell: ({ row }) => {
const offlineCount = row.original.numberOfOfflineDevices;
const isOffline = offlineCount > 0;
return (
<div className="flex items-center gap-2">
<WifiOff
className={`h-4 w-4 ${isOffline ? "text-red-500" : "text-muted-foreground"}`}
/>
<Badge
variant={isOffline ? "destructive" : "outline"}
className="font-medium"
>
{offlineCount} offline
</Badge>
</div>
);
},
}, },
]; ];
@ -66,72 +119,174 @@ function RoomComponent() {
onSortingChange: setSorting, onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(), getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: {
pagination: {
pageSize: 30,
},
},
}); });
if (isLoading) return <div>Đang tải...</div>; if (isLoading) {
return (
<div className="w-full px-6 py-8">
<div className="flex items-center justify-center min-h-[400px]">
<div className="flex flex-col items-center gap-4">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-muted-foreground">Đang tải danh sách phòng...</p>
</div>
</div>
</div>
);
}
return ( return (
<div className="w-full px-6 space-y-4"> <div className="w-full px-6 py-6 space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div className="space-y-1">
<h1 className="text-3xl font-bold">Quản phòng</h1> <h1 className="text-3xl font-bold tracking-tight">Quản phòng</h1>
<p className="text-muted-foreground mt-2"> <p className="text-muted-foreground">
Danh sách các phòng hiện trong hệ thống Danh sách các phòng hiện trong hệ thống ({roomData.length} phòng)
</p> </p>
</div> </div>
</div> </div>
<Card>
<CardHeader> <Card className="shadow-sm">
<CardTitle>Danh sách phòng</CardTitle> <CardHeader className="pb-4">
<CardTitle className="flex items-center gap-2">
<Building2 className="h-5 w-5" />
Danh sách phòng
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="p-0">
<Table> <div className="border-t">
<TableHeader> <Table>
{table.getHeaderGroups().map((headerGroup) => ( <TableHeader>
<TableRow key={headerGroup.id}> {table.getHeaderGroups().map((headerGroup) => (
{headerGroup.headers.map((header) => { <TableRow
const isSorted = header.column.getIsSorted(); key={headerGroup.id}
return ( className="hover:bg-transparent"
<TableHead >
key={header.id} {headerGroup.headers.map((header) => {
className="cursor-pointer select-none" const isSorted = header.column.getIsSorted();
onClick={header.column.getToggleSortingHandler()} return (
> <TableHead
{flexRender( key={header.id}
header.column.columnDef.header, className="cursor-pointer select-none font-semibold hover:bg-muted/50 transition-colors"
header.getContext() onClick={header.column.getToggleSortingHandler()}
)} >
{isSorted ? (isSorted === "asc" ? " ▲" : " ▼") : ""} <div className="flex items-center gap-2">
</TableHead> {flexRender(
); header.column.columnDef.header,
})} header.getContext()
</TableRow> )}
))} {isSorted && (
</TableHeader> <span className="text-primary">
<TableBody> {isSorted === "asc" ? "↑" : "↓"}
{table.getRowModel().rows.map((row) => ( </span>
<TableRow )}
key={row.id} </div>
className="cursor-pointer hover:bg-gray-100" </TableHead>
onClick={() => );
navigate({ })}
to: "/room/$roomName", </TableRow>
params: { roomName: row.original.name }, ))}
}) </TableHeader>
} <TableBody>
> {table.getRowModel().rows.length === 0 ? (
{row.getVisibleCells().map((cell) => ( <TableRow>
<TableCell key={cell.id}> <TableCell
{flexRender( colSpan={columns.length}
cell.column.columnDef.cell, className="h-24 text-center"
cell.getContext() >
)} <div className="flex flex-col items-center gap-2 text-muted-foreground">
<Building2 className="h-8 w-8" />
<p>Không phòng nào đưc tìm thấy</p>
</div>
</TableCell> </TableCell>
))} </TableRow>
</TableRow> ) : (
))} table.getRowModel().rows.map((row) => (
</TableBody> <TableRow
</Table> key={row.id}
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() =>
navigate({
to: "/room/$roomName",
params: { roomName: row.original.name },
})
}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="py-4">
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{table.getPageCount() > 1 && (
<div className="flex items-center justify-between px-6 py-4 border-t bg-muted/20">
<div className="flex items-center gap-2">
<p className="text-sm text-muted-foreground">
Hiển thị{" "}
<span className="font-medium">
{table.getState().pagination.pageIndex *
table.getState().pagination.pageSize +
1}
</span>{" "}
đến{" "}
<span className="font-medium">
{Math.min(
(table.getState().pagination.pageIndex + 1) *
table.getState().pagination.pageSize,
roomData.length
)}
</span>{" "}
trong tổng số{" "}
<span className="font-medium">{roomData.length}</span> phòng
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
className="h-8 w-8 p-0"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="flex items-center gap-1">
<span className="text-sm font-medium">
{table.getState().pagination.pageIndex + 1}
</span>
<span className="text-sm text-muted-foreground">
/ {table.getPageCount()}
</span>
</div>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
className="h-8 w-8 p-0"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@ -11,7 +11,9 @@ import { FileText } from "lucide-react";
import { UploadDialog } from "@/components/upload-dialog"; import { UploadDialog } from "@/components/upload-dialog";
import { VersionTable } from "@/components/version-table"; import { VersionTable } from "@/components/version-table";
import { UpdateButton } from "@/components/update-button"; import { UpdateButton } from "@/components/update-button";
import { RoomSelectDialog } from "@/components/room-select-dialog";
import type { AxiosProgressEvent } from "axios"; import type { AxiosProgressEvent } from "axios";
import { useState } from "react";
interface AppManagerTemplateProps<TData> { interface AppManagerTemplateProps<TData> {
title: string; title: string;
@ -19,10 +21,14 @@ interface AppManagerTemplateProps<TData> {
data: TData[]; data: TData[];
isLoading: boolean; isLoading: boolean;
columns: ColumnDef<TData, any>[]; columns: ColumnDef<TData, any>[];
onUpload: (fd: FormData, config?: { onUploadProgress?: (e: AxiosProgressEvent) => void }) => Promise<void>; onUpload: (
onUpdate?: () => void; fd: FormData,
config?: { onUploadProgress?: (e: AxiosProgressEvent) => void }
) => Promise<void>;
onUpdate?: (roomName: string) => void;
updateLoading?: boolean; updateLoading?: boolean;
onTableInit?: (table: any) => void; onTableInit?: (table: any) => void;
rooms: string[];
} }
export function AppManagerTemplate<TData>({ export function AppManagerTemplate<TData>({
@ -35,7 +41,14 @@ export function AppManagerTemplate<TData>({
onUpdate, onUpdate,
updateLoading, updateLoading,
onTableInit, onTableInit,
rooms,
}: AppManagerTemplateProps<TData>) { }: AppManagerTemplateProps<TData>) {
const [dialogOpen, setDialogOpen] = useState(false);
const handleUpdateClick = () => {
if (rooms && onUpdate) {
setDialogOpen(true);
}
};
return ( return (
<div className="w-full px-6 space-y-4"> <div className="w-full px-6 space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -63,10 +76,26 @@ export function AppManagerTemplate<TData>({
</CardContent> </CardContent>
{onUpdate && ( {onUpdate && (
<CardFooter> <CardFooter>
<UpdateButton onClick={onUpdate} loading={updateLoading} /> <UpdateButton onClick={handleUpdateClick} loading={updateLoading} />
<UpdateButton
onClick={() => onUpdate("All")}
loading={updateLoading}
label="Cập nhật tất cả thiết bị"
/>
</CardFooter> </CardFooter>
)} )}
{rooms && onUpdate && (
<RoomSelectDialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
rooms={rooms}
onConfirm={(roomName) => {
onUpdate(roomName);
setDialogOpen(false);
}}
/>
)}
</Card> </Card>
</div> </div>
); );
} }

View File

@ -1,4 +1,6 @@
import { ShellCommandForm } from "@/components/command-form"; "use client"
import { useState } from "react"
import { import {
Card, Card,
CardContent, CardContent,
@ -6,17 +8,22 @@ import {
CardFooter, CardFooter,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card"
import { UpdateButton } from "@/components/update-button"; import { UpdateButton } from "@/components/update-button"
import { FileText, Terminal } from "lucide-react"; import { Terminal } from "lucide-react"
import { RoomSelectDialog } from "@/components/room-select-dialog"
interface FormSubmitTemplateProps { interface FormSubmitTemplateProps {
title: string; title: string
description: string; description: string
isLoading?: boolean; isLoading?: boolean
children: React.ReactNode; children: (props: {
onSubmit?: () => void; command: string
submitLoading?: boolean; setCommand: (val: string) => void
}) => React.ReactNode
onSubmit?: (roomName: string, command: string) => void
submitLoading?: boolean
rooms?: string[]
} }
export function FormSubmitTemplate({ export function FormSubmitTemplate({
@ -26,7 +33,17 @@ export function FormSubmitTemplate({
children, children,
onSubmit, onSubmit,
submitLoading, submitLoading,
rooms = [],
}: FormSubmitTemplateProps) { }: FormSubmitTemplateProps) {
const [dialogOpen, setDialogOpen] = useState(false)
const [command, setCommand] = useState("")
const handleClick = () => {
if (rooms.length > 0 && onSubmit) {
setDialogOpen(true)
}
}
return ( return (
<div className="w-full px-6 space-y-4"> <div className="w-full px-6 space-y-4">
<div> <div>
@ -41,13 +58,37 @@ export function FormSubmitTemplate({
</CardTitle> </CardTitle>
<CardDescription>Nhập gửi lệnh xuống thiết bị</CardDescription> <CardDescription>Nhập gửi lệnh xuống thiết bị</CardDescription>
</CardHeader> </CardHeader>
<CardContent>{children}</CardContent> <CardContent>
{children({ command, setCommand })}
</CardContent>
{onSubmit && ( {onSubmit && (
<CardFooter> <CardFooter className="flex gap-2">
<UpdateButton onClick={onSubmit} loading={submitLoading} /> <UpdateButton
onClick={handleClick}
loading={submitLoading}
label="Yêu cầu theo phòng"
/>
<UpdateButton
onClick={() => onSubmit("All", command)}
loading={submitLoading}
label="Cập nhật tất cả thiết bị"
/>
</CardFooter> </CardFooter>
)} )}
</Card> </Card>
{onSubmit && rooms.length > 0 && (
<RoomSelectDialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
rooms={rooms}
onConfirm={(roomName) => {
onSubmit(roomName, command)
setDialogOpen(false)
}}
/>
)}
</div> </div>
); )
} }

5
src/types/room.ts Normal file
View File

@ -0,0 +1,5 @@
export type Room = {
name: string;
numberOfDevices: number;
numberOfOfflineDevices: number;
};