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": ".",
"dependencies": {
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-label": "^2.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-slot": "^1.2.3",
"@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": {
"version": "1.1.2",
"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": {
"version": "1.1.10",
"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": {
"version": "1.1.7",
"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": {
"version": "1.1.1",
"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": {
"version": "1.1.1",
"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"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-label": "^2.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-slot": "^1.2.3",
"@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 { Building } from "lucide-react";
import { Building2, Cpu } from "lucide-react";
import {
Sidebar,
SidebarContent,
@ -13,6 +13,7 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { cn } from "@/lib/utils";
type MenuItem = {
title: string;
@ -27,34 +28,58 @@ type AppSidebarProps = {
export function AppSidebar({ items }: AppSidebarProps) {
return (
<Sidebar collapsible="icon" className="border-r">
<SidebarHeader className="border-b">
<div className="flex items-center gap-2 px-2 py-2">
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<Building className="size-4" />
<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-semibold">TTMT Computer Management</span>
<span className="text-xs text-muted-foreground">v1.0.0</span>
<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>
<SidebarContent className="p-4">
<SidebarGroup>
<SidebarGroupLabel>Navigation</SidebarGroupLabel>
<SidebarGroupLabel className="text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider mb-2">
Navigation
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<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={"."}>
<item.icon className="size-4" />
<span>{item.title}</span>
<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>
@ -64,8 +89,8 @@ export function AppSidebar({ items }: AppSidebarProps) {
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="border-t">
<div className="p-2 text-xs text-muted-foreground">
<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>

View File

@ -1,90 +1,68 @@
"use client";
import { useState } from "react";
import { useForm } from "@tanstack/react-form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
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 {
onExecute: (command: string) => Promise<{ success: boolean; output: string }>;
command: string;
onCommandChange: (value: string) => void;
disabled?: boolean;
}
export function ShellCommandForm({ onExecute }: ShellCommandFormProps) {
const [isLoading, setIsLoading] = useState(false);
// init form
export function ShellCommandForm({
command,
onCommandChange,
disabled,
}: ShellCommandFormProps) {
const form = useForm({
defaultValues: {
command: "",
},
onSubmit: async ({ value }) => {
setIsLoading(true);
try {
const res = await onExecute(value.command);
if (res.success) {
form.reset();
}
} finally {
setIsLoading(false);
}
},
defaultValues: { command },
onSubmit: () => {},
});
return (
<div className="space-y-6">
{/* Form */}
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
className="space-y-5"
>
{/* Field: command */}
<form.Field
name="command"
validators={{
onChange: z
.string()
.min(1, "Nhập command để thực thi")
.max(500, "Command quá dài"),
<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);
}}
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)}
disabled={isLoading}
/>
{field.state.meta.errors?.length > 0 && (
<p className="text-sm text-red-500">
{field.state.meta.errors.join(", ")}
</p>
)}
</div>
)}
disabled={disabled}
/>
<Button type="submit" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Yêu cầu thiết bị thực thi
</Button>
</form>
</div>
{field.state.meta.errors?.length > 0 && (
<p className="text-sm text-red-500">
{String(field.state.meta.errors[0])}
</p>
)}
</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 {
onClick: () => void;
loading?: boolean;
onClick: () => void
loading?: boolean
label?: string
}
export function UpdateButton({ onClick, loading }: UpdateButtonProps) {
export function UpdateButton({ onClick, loading, label }: UpdateButtonProps) {
return (
<Button variant="outline" onClick={onClick} disabled={loading}>
{loading ? "Đang gửi..." : "Yêu cầu thiết bị cập nhật"}
<Button
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>
);
)
}

View File

@ -11,12 +11,12 @@ export const API_ENDPOINTS = {
GET_SOFTWARE: `${BASE_URL}/AppVersion/msifiles`,
},
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_DEVICE_FROM_ROOM: (roomName: string) =>
`${BASE_URL}/DeviceComm/room/${roomName}`,
UPDATE_AGENT: `${BASE_URL}/DeviceComm/updateagent`,
SEND_COMMAND: `${BASE_URL}/DeviceComm/shellcommand`,
UPDATE_AGENT: (roomName: string) => `${BASE_URL}/DeviceComm/updateagent/${roomName}`,
SEND_COMMAND: (roomName: string) => `${BASE_URL}/DeviceComm/shellcommand/${roomName}`,
CHANGE_DEVICE_ROOM: `${BASE_URL}/DeviceComm/changeroom`,
},
SSE_EVENTS: {

View File

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

View File

@ -64,37 +64,43 @@ export default function AppLayout({ children }: AppLayoutProps) {
icon: AppWindow,
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,
},
},
{ title: "Gửi lệnh CMD", to: "/command", icon: Terminal },
];
return (
<SidebarProvider>
<div className="flex min-h-screen w-full">
<div className="flex min-h-screen w-full bg-background">
<AppSidebar items={items} />
<SidebarInset className="flex-1">
{/* Mobile header with sidebar trigger */}
<header className="flex h-14 shrink-0 items-center gap-2 border-b px-4 lg:hidden">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
<div className="flex items-center gap-2">
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<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" />
<Separator
orientation="vertical"
className="mr-2 h-6 bg-border/60"
/>
<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" />
</div>
<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
</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>
</header>
{/* Main content with responsive padding */}
<main className="flex-1 p-4 sm:p-6 lg:p-8 overflow-auto">
{children}
<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">
<div className="mx-auto max-w-7xl">{children}</div>
</main>
<Toaster
@ -102,9 +108,12 @@ export default function AppLayout({ children }: AppLayoutProps) {
toastOptions={{
classNames: {
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",
title: "text-sm sm:text-lg font-semibold",
description: "text-xs sm:text-base",
"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-base font-semibold",
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 type { ColumnDef } from "@tanstack/react-table";
import type { AxiosProgressEvent } from "axios";
import { type Room } from "@/types/room";
type Version = {
id?: string;
@ -28,11 +29,22 @@ function AgentsPage() {
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)
? data
: data
? [data]
: [];
? [data]
: [];
// Mutation upload
const uploadMutation = useMutationData<FormData>({
@ -41,19 +53,38 @@ function AgentsPage() {
invalidate: [["agent-version"]],
onSuccess: () => toast.success("Upload thành công!"),
onError: (error) => {
console.error("Upload error:", error)
toast.error("Upload thất bại!")
console.error("Upload error:", error);
toast.error("Upload thất bại!");
},
});
// Mutation update
const updateMutation = useMutationData<void>({
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.UPDATE_AGENT,
url: "",
method: "POST",
onSuccess: () => toast.success("Đã gửi yêu cầu update!"),
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>[] = [
{ accessorKey: "version", header: "Phiên bản" },
{ accessorKey: "fileName", header: "Tên file" },
@ -73,25 +104,9 @@ function AgentsPage() {
getValue()
? new Date(getValue() as string).toLocaleString("vi-VN")
: "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 (
<AppManagerTemplate<Version>
title="Quản lý Agent"
@ -102,6 +117,7 @@ function AgentsPage() {
onUpload={handleUpload}
onUpdate={handleUpdate}
updateLoading={updateMutation.isPending}
rooms={rooms}
/>
);
}

View File

@ -7,6 +7,7 @@ import { toast } from "sonner";
import type { ColumnDef } from "@tanstack/react-table";
import { useState } from "react";
import type { AxiosProgressEvent } from "axios";
import type { Room } from "@/types/room";
export const Route = createFileRoute("/_authenticated/apps/")({
head: () => ({ meta: [{ title: "Quản lý phần mềm" }] }),
@ -25,21 +26,31 @@ type Version = {
function AppsComponent() {
const { data, isLoading } = useQueryData({
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)
? data
: data
? [data]
: [];
? [data]
: [];
const [table, setTable] = useState<any>();
const uploadMutation = useMutationData<FormData>({
url: BASE_URL + API_ENDPOINTS.APP_VERSION.UPLOAD,
method: "POST",
invalidate: [["software-version"]], // Add this to refresh data after upload
invalidate: [["software-version"]],
onSuccess: () => toast.success("Upload thành công!"),
onError: (error) => {
console.error("Upload error:", error);
@ -48,7 +59,7 @@ function AppsComponent() {
});
const installMutation = useMutationData<{ MsiFileIds: number[] }>({
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.DOWNLOAD_MSI,
url: "",
method: "POST",
onSuccess: () => toast.success("Đã gửi yêu cầu cài đặt MSI!"),
onError: (error) => {
@ -57,8 +68,8 @@ function AppsComponent() {
},
});
// Cột bảng
const columns: ColumnDef<Version>[] = [
{ accessorKey: "version", header: "Phiên bản" },
{ accessorKey: "fileName", header: "Tên file" },
{ accessorKey: "folderPath", header: "Đường dẫn" },
@ -87,9 +98,10 @@ function AppsComponent() {
),
enableSorting: false,
enableHiding: false,
}
},
];
// Upload file MSI
const handleUpload = async (
fd: FormData,
config?: { onUploadProgress?: (e: AxiosProgressEvent) => void }
@ -100,14 +112,14 @@ function AppsComponent() {
});
};
const handleInstall = async () => {
// Callback khi chọn phòng
const handleInstall = async (roomName: 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;
@ -115,9 +127,8 @@ function AppsComponent() {
const MsiFileIds = selectedRows.map((row: any) => row.original.id);
console.log("Selected MSI file IDs:", MsiFileIds);
return installMutation.mutateAsync({
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.DOWNLOAD_MSI(roomName), // set url động
data: { MsiFileIds },
});
};
@ -133,6 +144,7 @@ function AppsComponent() {
onUpdate={handleInstall}
updateLoading={installMutation.isPending}
onTableInit={setTable}
rooms={rooms}
/>
);
}

View File

@ -2,8 +2,13 @@ import { createFileRoute } from "@tanstack/react-router";
import { FormSubmitTemplate } from "@/template/form-submit-template";
import { ShellCommandForm } from "@/components/command-form";
import { useMutationData } from "@/hooks/useMutationData";
import { useQueryData } from "@/hooks/useQueryData";
import { BASE_URL, API_ENDPOINTS } from "@/config/api";
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/")({
head: () => ({ meta: [{ title: "Gửi lệnh CMD" }] }),
@ -11,17 +16,29 @@ export const Route = createFileRoute("/_authenticated/command/")({
});
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<
string,
{ success: boolean; output: string }
SendCommandRequest,
SendCommandResponse
>({
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.SEND_COMMAND,
url: "", // sẽ set động theo roomName khi gọi
method: "POST",
onSuccess: (data) => {
if (data.success) {
toast.success("Lệnh đã được gửi thành công!");
if (data.status === "OK") {
toast.success("Gửi lệnh thành công!");
} 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) => {
@ -35,12 +52,22 @@ function CommandPage() {
title="CMD Command"
description="Gửi lệnh shell xuống thiết bị để thực thi"
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
onExecute={async (cmd: string) => {
return await sendCommandMutation.mutateAsync({ data: cmd });
}}
/>
{({ command, setCommand }) => (
<ShellCommandForm
command={command}
onCommandChange={setCommand}
disabled={sendCommandMutation.isPending}
/>
)}
</FormSubmitTemplate>
);
}

View File

@ -4,6 +4,7 @@ import { API_ENDPOINTS, BASE_URL } from "@/config/api";
import {
flexRender,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
type ColumnDef,
} from "@tanstack/react-table";
@ -17,6 +18,20 @@ import {
TableRow,
} from "@/components/ui/table";
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/")({
head: ({ params }) => ({
@ -39,37 +54,99 @@ function RoomDetailComponent() {
const columns: ColumnDef<any>[] = [
{
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",
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",
cell: ({ getValue }) => {
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",
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",
cell: ({ getValue }) => (
<code className="bg-muted px-2 py-1 rounded text-sm font-mono">
{getValue() as string}
</code>
),
},
{
header: "Trạng thái",
accessorKey: "isOffline",
cell: ({ getValue }) =>
getValue() ? (
<span className="text-red-500 font-semibold">Offline</span>
) : (
<span className="text-green-500 font-semibold">Online</span>
),
cell: ({ getValue }) => {
const isOffline = getValue() as boolean;
return (
<Badge
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,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: { pagination: { pageSize: 16 } },
});
if (isLoading) return <div>Đang tải thiết bị...</div>;
return (
<div className="w-full px-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Phòng: {roomName}</h1>
<p className="text-muted-foreground mt-2">
Danh sách thiết bị trong phòng
</p>
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>
<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>
<CardContent>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
<CardContent 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>
) : (
<>
<div className="max-h-[600px] overflow-y-auto">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow
key={headerGroup.id}
className="hover:bg-transparent border-b"
>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
className="font-semibold text-foreground bg-muted/30"
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</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>
</Card>
</div>

View File

@ -19,7 +19,19 @@ import {
TableHeader,
TableRow,
} 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 { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
export const Route = createFileRoute("/_authenticated/room/")({
head: () => ({
@ -43,19 +55,60 @@ function RoomComponent() {
const columns: ColumnDef<any>[] = [
{
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",
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ị",
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",
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,
getCoreRowModel: getCoreRowModel(),
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 (
<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>
<h1 className="text-3xl font-bold">Quản phòng</h1>
<p className="text-muted-foreground mt-2">
Danh sách các phòng hiện trong hệ thống
<div className="space-y-1">
<h1 className="text-3xl font-bold tracking-tight">Quản phòng</h1>
<p className="text-muted-foreground">
Danh sách các phòng hiện trong hệ thống ({roomData.length} phòng)
</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Danh sách phòng</CardTitle>
<Card className="shadow-sm">
<CardHeader className="pb-4">
<CardTitle className="flex items-center gap-2">
<Building2 className="h-5 w-5" />
Danh sách phòng
</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const isSorted = header.column.getIsSorted();
return (
<TableHead
key={header.id}
className="cursor-pointer select-none"
onClick={header.column.getToggleSortingHandler()}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
{isSorted ? (isSorted === "asc" ? " ▲" : " ▼") : ""}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
className="cursor-pointer hover:bg-gray-100"
onClick={() =>
navigate({
to: "/room/$roomName",
params: { roomName: row.original.name },
})
}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
<CardContent className="p-0">
<div className="border-t">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow
key={headerGroup.id}
className="hover:bg-transparent"
>
{headerGroup.headers.map((header) => {
const isSorted = header.column.getIsSorted();
return (
<TableHead
key={header.id}
className="cursor-pointer select-none font-semibold hover:bg-muted/50 transition-colors"
onClick={header.column.getToggleSortingHandler()}
>
<div className="flex items-center gap-2">
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
{isSorted && (
<span className="text-primary">
{isSorted === "asc" ? "↑" : "↓"}
</span>
)}
</div>
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length === 0 ? (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
<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>
))}
</TableRow>
))}
</TableBody>
</Table>
</TableRow>
) : (
table.getRowModel().rows.map((row) => (
<TableRow
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>
</Card>
</div>

View File

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

View File

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

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

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