master #1

Merged
PhuongDM merged 3 commits from master into main 2025-11-26 13:17:34 +07:00
10 changed files with 411 additions and 143 deletions
Showing only changes of commit b903549fb9 - Show all commits

View File

@ -1,10 +1,21 @@
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Building2, Monitor, ChevronDown, ChevronRight, Loader2 } from "lucide-react"; import {
Building2,
Monitor,
ChevronDown,
ChevronRight,
Loader2,
} from "lucide-react";
import type { Room } from "@/types/room"; import type { Room } from "@/types/room";
import type { DeviceHealthCheck } from "@/types/device"; import type { DeviceHealthCheck } from "@/types/device";
@ -12,7 +23,7 @@ interface DeviceSearchDialogProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
rooms: Room[]; rooms: Room[];
onSelect: (deviceIds: string[]) => void; onSelect: (deviceIds: string[]) => void | Promise<void>;
fetchDevices: (roomName: string) => Promise<DeviceHealthCheck[]>; // API fetch fetchDevices: (roomName: string) => Promise<DeviceHealthCheck[]>; // API fetch
} }
@ -25,7 +36,9 @@ export function DeviceSearchDialog({
}: DeviceSearchDialogProps) { }: DeviceSearchDialogProps) {
const [selected, setSelected] = useState<string[]>([]); const [selected, setSelected] = useState<string[]>([]);
const [expandedRoom, setExpandedRoom] = useState<string | null>(null); const [expandedRoom, setExpandedRoom] = useState<string | null>(null);
const [roomDevices, setRoomDevices] = useState<Record<string, DeviceHealthCheck[]>>({}); const [roomDevices, setRoomDevices] = useState<
Record<string, DeviceHealthCheck[]>
>({});
const [loadingRoom, setLoadingRoom] = useState<string | null>(null); const [loadingRoom, setLoadingRoom] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
@ -39,7 +52,7 @@ export function DeviceSearchDialog({
const filteredRooms = useMemo(() => { const filteredRooms = useMemo(() => {
if (!searchQuery) return sortedRooms; if (!searchQuery) return sortedRooms;
return sortedRooms.filter(room => return sortedRooms.filter((room) =>
room.name.toLowerCase().includes(searchQuery.toLowerCase()) room.name.toLowerCase().includes(searchQuery.toLowerCase())
); );
}, [sortedRooms, searchQuery]); }, [sortedRooms, searchQuery]);
@ -56,7 +69,7 @@ export function DeviceSearchDialog({
setLoadingRoom(roomName); setLoadingRoom(roomName);
try { try {
const devices = await fetchDevices(roomName); const devices = await fetchDevices(roomName);
setRoomDevices(prev => ({ ...prev, [roomName]: devices })); setRoomDevices((prev) => ({ ...prev, [roomName]: devices }));
setExpandedRoom(roomName); setExpandedRoom(roomName);
} catch (error) { } catch (error) {
console.error("Failed to fetch devices:", error); console.error("Failed to fetch devices:", error);
@ -72,29 +85,36 @@ export function DeviceSearchDialog({
const toggleDevice = (deviceId: string) => { const toggleDevice = (deviceId: string) => {
setSelected((prev) => setSelected((prev) =>
prev.includes(deviceId) ? prev.filter((id) => id !== deviceId) : [...prev, deviceId] prev.includes(deviceId)
? prev.filter((id) => id !== deviceId)
: [...prev, deviceId]
); );
}; };
const toggleAllInRoom = (roomName: string) => { const toggleAllInRoom = (roomName: string) => {
const devices = roomDevices[roomName] || []; const devices = roomDevices[roomName] || [];
const deviceIds = devices.map(d => d.id); const deviceIds = devices.map((d) => d.id);
const allSelected = deviceIds.every(id => selected.includes(id)); const allSelected = deviceIds.every((id) => selected.includes(id));
if (allSelected) { if (allSelected) {
setSelected(prev => prev.filter(id => !deviceIds.includes(id))); setSelected((prev) => prev.filter((id) => !deviceIds.includes(id)));
} else { } else {
setSelected(prev => [...new Set([...prev, ...deviceIds])]); setSelected((prev) => [...new Set([...prev, ...deviceIds])]);
} }
}; };
const handleConfirm = () => { const handleConfirm = async () => {
onSelect(selected); try {
setSelected([]); await onSelect(selected);
setExpandedRoom(null); } catch (e) {
setRoomDevices({}); console.error("Error on select:", e);
setSearchQuery(""); } finally {
onClose(); setSelected([]);
setExpandedRoom(null);
setRoomDevices({});
setSearchQuery("");
onClose();
}
}; };
const handleClose = () => { const handleClose = () => {
@ -107,7 +127,7 @@ export function DeviceSearchDialog({
return ( return (
<Dialog open={open} onOpenChange={handleClose}> <Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-3xl"> <DialogContent className="max-w-none w-[95vw] max-h-[90vh]">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Monitor className="w-6 h-6 text-primary" /> <Monitor className="w-6 h-6 text-primary" />
@ -136,15 +156,22 @@ export function DeviceSearchDialog({
const isExpanded = expandedRoom === room.name; const isExpanded = expandedRoom === room.name;
const isLoading = loadingRoom === room.name; const isLoading = loadingRoom === room.name;
const devices = roomDevices[room.name] || []; const devices = roomDevices[room.name] || [];
const allSelected = devices.length > 0 && devices.every(d => selected.includes(d.id)); const allSelected =
const someSelected = devices.some(d => selected.includes(d.id)); devices.length > 0 &&
const selectedCount = devices.filter(d => selected.includes(d.id)).length; devices.every((d) => selected.includes(d.id));
const someSelected = devices.some((d) => selected.includes(d.id));
const selectedCount = devices.filter((d) =>
selected.includes(d.id)
).length;
return ( return (
<div key={room.name} className="border rounded-lg overflow-hidden"> <div
key={room.name}
className="border rounded-lg overflow-hidden"
>
{/* Room header - clickable */} {/* Room header - clickable */}
<div <div
className="flex items-center gap-2 p-3 hover:bg-muted/50 cursor-pointer" className="flex items-center gap-1 px-2 py-1.5 hover:bg-muted/50 cursor-pointer"
onClick={() => handleRoomClick(room.name)} onClick={() => handleRoomClick(room.name)}
> >
{/* Expand icon or loading */} {/* Expand icon or loading */}
@ -164,24 +191,28 @@ export function DeviceSearchDialog({
toggleAllInRoom(room.name); toggleAllInRoom(room.name);
}} }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className={someSelected && !allSelected ? "opacity-50" : ""} className={
someSelected && !allSelected ? "opacity-50" : ""
}
/> />
)} )}
<Building2 className="w-4 h-4 text-primary flex-shrink-0" /> <Building2 className="w-4 h-4 text-primary flex-shrink-0" />
<span className="font-semibold flex-1">{room.name}</span> <span className="font-semibold flex-1 text-sm">
{room.name}
</span>
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-1 text-xs text-muted-foreground flex-shrink-0">
{selectedCount > 0 && ( {selectedCount > 0 && (
<span className="text-primary font-medium"> <span className="text-primary font-medium">
{selectedCount}/ {selectedCount}/
</span> </span>
)} )}
<span>{room.numberOfDevices} thiết bị</span> <span>{room.numberOfDevices}</span>
{room.numberOfOfflineDevices > 0 && ( {room.numberOfOfflineDevices > 0 && (
<span className="text-xs px-2 py-0.5 rounded-full bg-red-100 text-red-700"> <span className="text-xs px-1.5 py-0.5 rounded-full bg-red-100 text-red-700">
{room.numberOfOfflineDevices} offline {room.numberOfOfflineDevices}
</span> </span>
)} )}
</div> </div>
@ -189,68 +220,74 @@ export function DeviceSearchDialog({
{/* Device table - collapsible */} {/* Device table - collapsible */}
{isExpanded && devices.length > 0 && ( {isExpanded && devices.length > 0 && (
<div className="border-t bg-muted/20"> <div className="border-t bg-muted/20 overflow-x-auto">
<div className="overflow-x-auto"> <table className="w-full text-xs">
<table className="w-full text-sm"> <thead className="bg-muted/50 border-b sticky top-0">
<thead className="bg-muted/50 border-b"> <tr>
<tr> <th className="w-8 px-1 py-1"></th>
<th className="w-12 p-2"></th> <th className="text-left px-1 py-1 font-medium min-w-20 text-xs">
<th className="text-left p-2 font-medium">Thiết bị</th> Thiết bị
<th className="text-left p-2 font-medium">IP Address</th> </th>
<th className="text-left p-2 font-medium">MAC Address</th> <th className="text-left px-1 py-1 font-medium min-w-24 text-xs">
<th className="text-left p-2 font-medium">Version</th> IP
<th className="text-left p-2 font-medium">Trạng thái</th> </th>
<th className="text-left px-1 py-1 font-medium min-w-28 text-xs">
MAC
</th>
<th className="text-left px-1 py-1 font-medium min-w-12 text-xs">
Ver
</th>
<th className="text-left px-1 py-1 font-medium min-w-16 text-xs">
Trạng thái
</th>
</tr>
</thead>
<tbody>
{devices.map((device) => (
<tr
key={device.id}
className="border-b last:border-b-0 hover:bg-muted/50"
>
<td className="px-1 py-1">
<Checkbox
checked={selected.includes(device.id)}
onCheckedChange={() =>
toggleDevice(device.id)
}
/>
</td>
<td className="px-1 py-1">
<div className="flex items-center gap-0.5">
<Monitor className="w-3 h-3 text-muted-foreground flex-shrink-0" />
<span className="font-mono text-xs truncate">
{device.id}
</span>
</div>
</td>
<td className="px-1 py-1 font-mono text-xs truncate">
{device.networkInfos[0]?.ipAddress || "-"}
</td>
<td className="px-1 py-1 font-mono text-xs truncate">
{device.networkInfos[0]?.macAddress || "-"}
</td>
<td className="px-1 py-1 text-xs whitespace-nowrap">
{device.version ? `v${device.version}` : "-"}
</td>
<td className="px-1 py-1 text-xs">
{device.isOffline ? (
<span className="text-xs px-1 py-0.5 rounded-full bg-red-100 text-red-700 font-medium whitespace-nowrap inline-block">
Offline
</span>
) : (
<span className="text-xs px-1 py-0.5 rounded-full bg-green-100 text-green-700 font-medium whitespace-nowrap inline-block">
Online
</span>
)}
</td>
</tr> </tr>
</thead> ))}
<tbody> </tbody>
{devices.map((device) => ( </table>
<tr
key={device.id}
className="border-b last:border-b-0 hover:bg-muted/50"
>
<td className="p-2">
<Checkbox
checked={selected.includes(device.id)}
onCheckedChange={() => toggleDevice(device.id)}
/>
</td>
<td className="p-2">
<div className="flex items-center gap-2">
<Monitor className="w-3.5 h-3.5 text-muted-foreground" />
<span className="font-mono">{device.id}</span>
</div>
</td>
<td className="p-2 font-mono text-xs">
{device.networkInfos[0]?.ipAddress || "-"}
</td>
<td className="p-2 font-mono text-xs">
{device.networkInfos[0]?.macAddress || "-"}
</td>
<td className="p-2">
{device.version ? (
<span className="text-xs px-2 py-0.5 rounded-full bg-muted">
v{device.version}
</span>
) : (
"-"
)}
</td>
<td className="p-2">
{device.isOffline ? (
<span className="text-xs px-2 py-0.5 rounded-full bg-red-100 text-red-700 font-medium">
Offline
</span>
) : (
<span className="text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-700 font-medium">
Online
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div> </div>
)} )}
</div> </div>
@ -261,17 +298,24 @@ export function DeviceSearchDialog({
{/* Selected count */} {/* Selected count */}
{selected.length > 0 && ( {selected.length > 0 && (
<div className="text-sm text-muted-foreground bg-muted/50 px-3 py-2 rounded"> <div className="text-xs text-muted-foreground bg-muted/50 px-2 py-1.5 rounded">
Đã chọn: <span className="font-semibold text-foreground">{selected.length}</span> thiết bị Đã chọn:{" "}
<span className="font-semibold text-foreground">
{selected.length}
</span>
</div> </div>
)} )}
{/* Actions */} {/* Actions */}
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button variant="outline" onClick={handleClose}> <Button variant="outline" onClick={handleClose} size="sm">
Hủy Hủy
</Button> </Button>
<Button onClick={handleConfirm} disabled={selected.length === 0}> <Button
onClick={handleConfirm}
disabled={selected.length === 0}
size="sm"
>
Xác nhận ({selected.length}) Xác nhận ({selected.length})
</Button> </Button>
</div> </div>

View File

@ -46,6 +46,8 @@ export function SelectDialog({
const handleConfirm = async () => { const handleConfirm = async () => {
await onConfirm(selected); await onConfirm(selected);
setSelected([]); setSelected([]);
setSearch("");
onClose();
}; };
return ( return (

View File

@ -24,7 +24,11 @@ export function DeviceGrid({ devices }: { devices: any[] }) {
{Array.from({ length: 4 }).map((_, i) => { {Array.from({ length: 4 }).map((_, i) => {
const pos = leftStart + (3 - i); const pos = leftStart + (3 - i);
return ( return (
<ComputerCard key={pos} device={deviceMap.get(pos)} position={pos} /> <ComputerCard
key={pos}
device={deviceMap.get(pos)}
position={pos}
/>
); );
})} })}
@ -37,7 +41,11 @@ export function DeviceGrid({ devices }: { devices: any[] }) {
{Array.from({ length: 4 }).map((_, i) => { {Array.from({ length: 4 }).map((_, i) => {
const pos = rightStart + (3 - i); const pos = rightStart + (3 - i);
return ( return (
<ComputerCard key={pos} device={deviceMap.get(pos)} position={pos} /> <ComputerCard
key={pos}
device={deviceMap.get(pos)}
position={pos}
/>
); );
})} })}
</div> </div>
@ -46,6 +54,9 @@ export function DeviceGrid({ devices }: { devices: any[] }) {
return ( return (
<div className="px-0.5 py-8 space-y-6"> <div className="px-0.5 py-8 space-y-6">
<div className="space-y-4">
{Array.from({ length: totalRows }).map((_, i) => renderRow(i))}
</div>
<div className="flex items-center justify-between px-4 pb-6 border-b-2 border-dashed"> <div className="flex items-center justify-between px-4 pb-6 border-b-2 border-dashed">
<div className="flex items-center gap-3 px-6 py-4 bg-muted rounded-lg border-2 border-border"> <div className="flex items-center gap-3 px-6 py-4 bg-muted rounded-lg border-2 border-border">
<DoorOpen className="h-6 w-6 text-muted-foreground" /> <DoorOpen className="h-6 w-6 text-muted-foreground" />
@ -56,9 +67,6 @@ export function DeviceGrid({ devices }: { devices: any[] }) {
<span className="font-semibold text-lg">Bàn Giảng Viên</span> <span className="font-semibold text-lg">Bàn Giảng Viên</span>
</div> </div>
</div> </div>
<div className="space-y-4">
{Array.from({ length: totalRows }).map((_, i) => renderRow(i))}
</div>
</div> </div>
); );
} }

View File

@ -7,6 +7,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Loader2, RefreshCw, ChevronDown } from "lucide-react"; import { Loader2, RefreshCw, ChevronDown } from "lucide-react";
import { useState } from "react";
interface RequestUpdateMenuProps { interface RequestUpdateMenuProps {
onUpdateDevice: () => void; onUpdateDevice: () => void;
@ -21,8 +22,33 @@ export function RequestUpdateMenu({
onUpdateAll, onUpdateAll,
loading, loading,
}: RequestUpdateMenuProps) { }: RequestUpdateMenuProps) {
const [open, setOpen] = useState(false);
const handleUpdateDevice = async () => {
try {
await onUpdateDevice();
} finally {
setOpen(false);
}
};
const handleUpdateRoom = async () => {
try {
await onUpdateRoom();
} finally {
setOpen(false);
}
};
const handleUpdateAll = async () => {
try {
await onUpdateAll();
} finally {
setOpen(false);
}
};
return ( return (
<DropdownMenu> <DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
variant="outline" variant="outline"
@ -45,17 +71,17 @@ export function RequestUpdateMenu({
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56"> <DropdownMenuContent align="start" className="w-56">
<DropdownMenuItem onClick={onUpdateDevice} disabled={loading}> <DropdownMenuItem onClick={handleUpdateDevice} disabled={loading}>
<RefreshCw className="h-4 w-4 mr-2" /> <RefreshCw className="h-4 w-4 mr-2" />
<span>Cập nhật thiết bị cụ thể</span> <span>Cập nhật thiết bị cụ thể</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={onUpdateRoom} disabled={loading}> <DropdownMenuItem onClick={handleUpdateRoom} disabled={loading}>
<RefreshCw className="h-4 w-4 mr-2" /> <RefreshCw className="h-4 w-4 mr-2" />
<span>Cập nhật theo phòng</span> <span>Cập nhật theo phòng</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={onUpdateAll} disabled={loading}> <DropdownMenuItem onClick={handleUpdateAll} disabled={loading}>
<RefreshCw className="h-4 w-4 mr-2" /> <RefreshCw className="h-4 w-4 mr-2" />
<span>Cập nhật tất cả thiết bị</span> <span>Cập nhật tất cả thiết bị</span>
</DropdownMenuItem> </DropdownMenuItem>

View File

@ -16,7 +16,8 @@ export const API_ENDPOINTS = {
REQUEST_UPDATE_BLACKLIST: `${BASE_URL}/AppVersion/blacklist/request-update`, REQUEST_UPDATE_BLACKLIST: `${BASE_URL}/AppVersion/blacklist/request-update`,
}, },
DEVICE_COMM: { DEVICE_COMM: {
DOWNLOAD_MSI: (roomName: string) => `${BASE_URL}/DeviceComm/installmsi/${roomName}`, DOWNLOAD_FILES: (roomName: string) => `${BASE_URL}/DeviceComm/downloadfile/${roomName}`,
INSTALL_MSI: (roomName: string) => `${BASE_URL}/DeviceComm/installmsi/${roomName}`,
GET_ALL_DEVICES: `${BASE_URL}/DeviceComm/alldevices`, GET_ALL_DEVICES: `${BASE_URL}/DeviceComm/alldevices`,
GET_ROOM_LIST: `${BASE_URL}/DeviceComm/rooms`, GET_ROOM_LIST: `${BASE_URL}/DeviceComm/rooms`,
GET_DEVICE_FROM_ROOM: (roomName: string) => GET_DEVICE_FROM_ROOM: (roomName: string) =>

View File

@ -40,7 +40,6 @@ function AgentsPage() {
? [data] ? [data]
: []; : [];
// Mutation upload
const uploadMutation = useMutationData<FormData>({ const uploadMutation = useMutationData<FormData>({
url: BASE_URL + API_ENDPOINTS.APP_VERSION.UPLOAD, url: BASE_URL + API_ENDPOINTS.APP_VERSION.UPLOAD,
method: "POST", method: "POST",
@ -56,7 +55,10 @@ function AgentsPage() {
url: "", url: "",
method: "POST", method: "POST",
onSuccess: () => toast.success("Đã gửi yêu cầu update!"), onSuccess: () => toast.success("Đã gửi yêu cầu update!"),
onError: () => toast.error("Gửi yêu cầu thất bại!"), onError: (error) => {
console.error("Update mutation error:", error);
toast.error("Gửi yêu cầu thất bại!");
},
}); });
const handleUpload = async ( const handleUpload = async (

View File

@ -55,13 +55,23 @@ function AppsComponent() {
const installMutation = useMutationData<{ MsiFileIds: number[] }>({ const installMutation = useMutationData<{ MsiFileIds: number[] }>({
url: "", url: "",
method: "POST", method: "POST",
onSuccess: () => toast.success("Đã gửi yêu cầu cài đặt MSI!"), onSuccess: () => toast.success("Đã gửi yêu cầu cài đặt file!"),
onError: (error) => { onError: (error) => {
console.error("Install error:", error); console.error("Install error:", error);
toast.error("Gửi yêu cầu thất bại!"); toast.error("Gửi yêu cầu thất bại!");
}, },
}); });
const downloadMutation = useMutationData<{ MsiFileIds: number[] }>({
url: "",
method: "POST",
onSuccess: () => toast.success("Đã gửi yêu cầu tải file!"),
onError: (error) => {
console.error("Download error:", error);
toast.error("Gửi yêu cầu thất bại!");
},
});
// Cột bảng // Cột bảng
const columns: ColumnDef<Version>[] = [ const columns: ColumnDef<Version>[] = [
{ accessorKey: "version", header: "Phiên bản" }, { accessorKey: "version", header: "Phiên bản" },
@ -124,7 +134,34 @@ function AppsComponent() {
try { try {
for (const roomName of roomNames) { for (const roomName of roomNames) {
await installMutation.mutateAsync({ await installMutation.mutateAsync({
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.DOWNLOAD_MSI(roomName), url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.INSTALL_MSI(roomName),
data: { MsiFileIds },
});
}
toast.success("Đã gửi yêu cầu cài đặt phần mềm cho các phòng đã chọn!");
} catch (e) {
toast.error("Có lỗi xảy ra khi cài đặt!");
}
};
const handleDonwload = async (roomNames: string[]) => {
if (!table) {
toast.error("Không thể lấy thông tin bảng!");
return;
}
const selectedRows = table.getSelectedRowModel().rows;
if (selectedRows.length === 0) {
toast.error("Vui lòng chọn ít nhất một file để cài đặt!");
return;
}
const MsiFileIds = selectedRows.map((row: any) => row.original.id);
try {
for (const roomName of roomNames) {
await downloadMutation.mutateAsync({
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.DOWNLOAD_FILES(roomName),
data: { MsiFileIds }, data: { MsiFileIds },
}); });
} }
@ -137,13 +174,15 @@ function AppsComponent() {
return ( return (
<AppManagerTemplate<Version> <AppManagerTemplate<Version>
title="Quản lý phần mềm" title="Quản lý phần mềm"
description="Quản lý và gửi yêu cầu cài đặt phần mềm MSI" description="Quản lý và gửi yêu cầu cài đặt phần mềm hoặc file cấu hình"
data={versionList} data={versionList}
isLoading={isLoading} isLoading={isLoading}
columns={columns} columns={columns}
onUpload={handleUpload} onUpload={handleUpload}
onUpdate={handleInstall} onUpdate={handleInstall}
onDownload={handleDonwload}
updateLoading={installMutation.isPending} updateLoading={installMutation.isPending}
downloadLoading={downloadMutation.isPending}
onTableInit={setTable} onTableInit={setTable}
rooms={roomData} rooms={roomData}
/> />

View File

@ -5,7 +5,6 @@ import { useMutationData } from "@/hooks/useMutationData";
import { useQueryData } from "@/hooks/useQueryData"; import { useQueryData } from "@/hooks/useQueryData";
import { BASE_URL, API_ENDPOINTS } from "@/config/api"; import { BASE_URL, API_ENDPOINTS } from "@/config/api";
import { toast } from "sonner"; import { toast } from "sonner";
import type { Room } from "@/types/room";
type SendCommandRequest = { Command: string }; type SendCommandRequest = { Command: string };
type SendCommandResponse = { status: string; message: string }; type SendCommandResponse = { status: string; message: string };

View File

@ -32,6 +32,8 @@ interface AppManagerTemplateProps<TData> {
) => Promise<void>; ) => Promise<void>;
onUpdate?: (targetNames: string[]) => Promise<void> | void; onUpdate?: (targetNames: string[]) => Promise<void> | void;
updateLoading?: boolean; updateLoading?: boolean;
onDownload?: (targetNames: string[]) => Promise<void> | void;
downloadLoading?: boolean;
onTableInit?: (table: any) => void; onTableInit?: (table: any) => void;
rooms?: Room[]; rooms?: Room[];
devices?: string[]; devices?: string[];
@ -46,12 +48,14 @@ export function AppManagerTemplate<TData>({
onUpload, onUpload,
onUpdate, onUpdate,
updateLoading, updateLoading,
onDownload,
downloadLoading,
onTableInit, onTableInit,
rooms = [], rooms = [],
devices = [], devices = [],
}: AppManagerTemplateProps<TData>) { }: AppManagerTemplateProps<TData>) {
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [dialogType, setDialogType] = useState<"room" | "device" | null>(null); const [dialogType, setDialogType] = useState<"room" | "device" | "download-room" | "download-device" | null>(null);
const openRoomDialog = () => { const openRoomDialog = () => {
if (rooms.length > 0 && onUpdate) { if (rooms.length > 0 && onUpdate) {
@ -67,14 +71,31 @@ export function AppManagerTemplate<TData>({
} }
}; };
const openDownloadRoomDialog = () => {
if (rooms.length > 0 && onDownload) {
setDialogType("download-room");
setDialogOpen(true);
}
};
const openDownloadDeviceDialog = () => {
if (onDownload) {
setDialogType("download-device");
setDialogOpen(true);
}
};
const handleUpdateAll = async () => { const handleUpdateAll = async () => {
if (!onUpdate) return; if (!onUpdate) return;
// Assuming Room has an 'id' property; adjust if needed try {
const roomIds = rooms.map((room) => const roomIds = rooms.map((room) =>
typeof room === "string" ? room : room.name typeof room === "string" ? room : room.name
); );
const allTargets = [...roomIds, ...devices]; const allTargets = [...roomIds, ...devices];
await onUpdate(allTargets); await onUpdate(allTargets);
} catch (e) {
console.error("Update error:", e);
}
}; };
return ( return (
@ -113,13 +134,28 @@ export function AppManagerTemplate<TData>({
</CardContent> </CardContent>
{onUpdate && ( {onUpdate && (
<CardFooter> <CardFooter className="gap-2">
<RequestUpdateMenu <RequestUpdateMenu
onUpdateDevice={openDeviceDialog} onUpdateDevice={openDeviceDialog}
onUpdateRoom={openRoomDialog} onUpdateRoom={openRoomDialog}
onUpdateAll={handleUpdateAll} onUpdateAll={handleUpdateAll}
loading={updateLoading} loading={updateLoading}
/> />
{onDownload && (
<RequestUpdateMenu
onUpdateDevice={openDownloadDeviceDialog}
onUpdateRoom={openDownloadRoomDialog}
onUpdateAll={() => {
if (!onDownload) return;
const roomIds = rooms.map((room) =>
typeof room === "string" ? room : room.name
);
const allTargets = [...roomIds, ...devices];
onDownload(allTargets);
}}
loading={downloadLoading}
/>
)}
</CardFooter> </CardFooter>
)} )}
</Card> </Card>
@ -128,15 +164,25 @@ export function AppManagerTemplate<TData>({
{dialogType === "room" && ( {dialogType === "room" && (
<SelectDialog <SelectDialog
open={dialogOpen} open={dialogOpen}
onClose={() => setDialogOpen(false)} onClose={() => {
setDialogOpen(false);
setDialogType(null);
}}
title="Chọn phòng" title="Chọn phòng"
description="Chọn các phòng cần cập nhật" description="Chọn các phòng cần cập nhật"
icon={<Building2 className="w-6 h-6 text-primary" />} icon={<Building2 className="w-6 h-6 text-primary" />}
items={mapRoomsToSelectItems(rooms)} items={mapRoomsToSelectItems(rooms)}
onConfirm={async (selectedItems) => { onConfirm={async (selectedItems) => {
if (!onUpdate) return; if (!onUpdate) return;
await onUpdate(selectedItems); try {
setDialogOpen(false); await onUpdate(selectedItems);
} catch (e) {
console.error("Update error:", e);
} finally {
setDialogOpen(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}
}} }}
/> />
)} )}
@ -145,10 +191,84 @@ export function AppManagerTemplate<TData>({
{dialogType === "device" && ( {dialogType === "device" && (
<DeviceSearchDialog <DeviceSearchDialog
open={dialogOpen && dialogType === "device"} open={dialogOpen && dialogType === "device"}
onClose={() => setDialogOpen(false)} onClose={() => {
setDialogOpen(false);
setDialogType(null);
}}
rooms={rooms} rooms={rooms}
fetchDevices={fetchDevicesFromRoom} // ⬅ thêm vào đây fetchDevices={fetchDevicesFromRoom}
onSelect={(deviceIds) => onUpdate && onUpdate(deviceIds)} onSelect={async (deviceIds) => {
if (!onUpdate) {
setDialogOpen(false);
setDialogType(null);
return;
}
try {
await onUpdate(deviceIds);
} catch (e) {
console.error("Update error:", e);
} finally {
setDialogOpen(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}
}}
/>
)}
{/* Dialog tải file - chọn phòng */}
{dialogType === "download-room" && (
<SelectDialog
open={dialogOpen}
onClose={() => {
setDialogOpen(false);
setDialogType(null);
}}
title="Chọn phòng"
description="Chọn các phòng để tải file xuống"
icon={<Building2 className="w-6 h-6 text-primary" />}
items={mapRoomsToSelectItems(rooms)}
onConfirm={async (selectedItems) => {
if (!onDownload) return;
try {
await onDownload(selectedItems);
} catch (e) {
console.error("Download error:", e);
} finally {
setDialogOpen(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}
}}
/>
)}
{/* Dialog tải file - tìm thiết bị */}
{dialogType === "download-device" && (
<DeviceSearchDialog
open={dialogOpen && dialogType === "download-device"}
onClose={() => {
setDialogOpen(false);
setDialogType(null);
}}
rooms={rooms}
fetchDevices={fetchDevicesFromRoom}
onSelect={async (deviceIds) => {
if (!onDownload) {
setDialogOpen(false);
setDialogType(null);
return;
}
try {
await onDownload(deviceIds);
} catch (e) {
console.error("Download error:", e);
} finally {
setDialogOpen(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}
}}
/> />
)} )}
</div> </div>

View File

@ -59,14 +59,18 @@ export function FormSubmitTemplate({
}; };
// Gửi cho tất cả // Gửi cho tất cả
const handleSubmitAll = () => { const handleSubmitAll = async () => {
if (!onSubmit) return; if (!onSubmit) return;
const roomIds = rooms.map((room) => try {
typeof room === "string" ? room : room.name const roomIds = rooms.map((room) =>
); typeof room === "string" ? room : room.name
const allTargets = [...roomIds, ...devices]; );
for (const target of allTargets) { const allTargets = [...roomIds, ...devices];
onSubmit(target, command); for (const target of allTargets) {
await onSubmit(target, command);
}
} catch (e) {
console.error("Submit error:", e);
} }
}; };
@ -103,17 +107,27 @@ export function FormSubmitTemplate({
{dialogType === "room" && ( {dialogType === "room" && (
<SelectDialog <SelectDialog
open={dialogOpen} open={dialogOpen}
onClose={() => setDialogOpen(false)} onClose={() => {
setDialogOpen(false);
setDialogType(null);
}}
title="Chọn phòng để gửi lệnh" title="Chọn phòng để gửi lệnh"
description="Chọn các phòng muốn gửi lệnh CMD tới" description="Chọn các phòng muốn gửi lệnh CMD tới"
icon={<Building2 className="w-6 h-6 text-primary" />} icon={<Building2 className="w-6 h-6 text-primary" />}
items={mapRoomsToSelectItems(rooms)} items={mapRoomsToSelectItems(rooms)}
onConfirm={async (selectedItems) => { onConfirm={async (selectedItems) => {
if (!onSubmit) return; if (!onSubmit) return;
for (const item of selectedItems) { try {
await onSubmit(item, command); for (const item of selectedItems) {
await onSubmit(item, command);
}
} catch (e) {
console.error("Submit error:", e);
} finally {
setDialogOpen(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
} }
setDialogOpen(false);
}} }}
/> />
)} )}
@ -122,13 +136,26 @@ export function FormSubmitTemplate({
{dialogType === "device" && ( {dialogType === "device" && (
<DeviceSearchDialog <DeviceSearchDialog
open={dialogOpen && dialogType === "device"} open={dialogOpen && dialogType === "device"}
onClose={() => setDialogOpen(false)} onClose={() => {
setDialogOpen(false);
setDialogType(null);
}}
rooms={rooms} rooms={rooms}
fetchDevices={fetchDevicesFromRoom} // ⬅ thêm vào đây fetchDevices={fetchDevicesFromRoom}
onSelect={(deviceIds) => onSelect={async (deviceIds) => {
onSubmit && if (!onSubmit) return;
deviceIds.forEach((deviceId) => onSubmit(deviceId, command)) try {
} for (const deviceId of deviceIds) {
await onSubmit(deviceId, command);
}
} catch (e) {
console.error("Submit error:", e);
} finally {
setDialogOpen(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}
}}
/> />
)} )}
</div> </div>