add css
This commit is contained in:
parent
8f41579972
commit
26bb177d54
202
package-lock.json
generated
202
package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
98
src/components/room-select-dialog.tsx
Normal file
98
src/components/room-select-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
51
src/components/ui/avatar.tsx
Normal file
51
src/components/ui/avatar.tsx
Normal 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 }
|
43
src/components/ui/radio-group.tsx
Normal file
43
src/components/ui/radio-group.tsx
Normal 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 }
|
|
@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 lý và 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 có thiết bị</h3>
|
||||
<p className="text-muted-foreground text-center max-w-sm">
|
||||
Phòng này chưa có 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>
|
||||
|
|
|
@ -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 lý phòng</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Danh sách các phòng hiện có trong hệ thống
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Quản lý phòng</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Danh sách các phòng hiện có 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 có 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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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 và 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
5
src/types/room.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export type Room = {
|
||||
name: string;
|
||||
numberOfDevices: number;
|
||||
numberOfOfflineDevices: number;
|
||||
};
|
Loading…
Reference in New Issue
Block a user