281 lines
11 KiB
TypeScript
281 lines
11 KiB
TypeScript
|
|
import { useState, useMemo } from "react";
|
||
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||
|
|
import { Input } from "@/components/ui/input";
|
||
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||
|
|
import { Button } from "@/components/ui/button";
|
||
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
||
|
|
import { Building2, Monitor, ChevronDown, ChevronRight, Loader2 } from "lucide-react";
|
||
|
|
import type { Room } from "@/types/room";
|
||
|
|
import type { DeviceHealthCheck } from "@/types/device";
|
||
|
|
|
||
|
|
interface DeviceSearchDialogProps {
|
||
|
|
open: boolean;
|
||
|
|
onClose: () => void;
|
||
|
|
rooms: Room[];
|
||
|
|
onSelect: (deviceIds: string[]) => void;
|
||
|
|
fetchDevices: (roomName: string) => Promise<DeviceHealthCheck[]>; // API fetch
|
||
|
|
}
|
||
|
|
|
||
|
|
export function DeviceSearchDialog({
|
||
|
|
open,
|
||
|
|
onClose,
|
||
|
|
rooms,
|
||
|
|
onSelect,
|
||
|
|
fetchDevices,
|
||
|
|
}: DeviceSearchDialogProps) {
|
||
|
|
const [selected, setSelected] = useState<string[]>([]);
|
||
|
|
const [expandedRoom, setExpandedRoom] = useState<string | null>(null);
|
||
|
|
const [roomDevices, setRoomDevices] = useState<Record<string, DeviceHealthCheck[]>>({});
|
||
|
|
const [loadingRoom, setLoadingRoom] = useState<string | null>(null);
|
||
|
|
const [searchQuery, setSearchQuery] = useState("");
|
||
|
|
|
||
|
|
const sortedRooms = useMemo(() => {
|
||
|
|
return [...rooms].sort((a, b) => {
|
||
|
|
const nameA = typeof a.name === "string" ? a.name : "";
|
||
|
|
const nameB = typeof b.name === "string" ? b.name : "";
|
||
|
|
return nameA.localeCompare(nameB);
|
||
|
|
});
|
||
|
|
}, [rooms]);
|
||
|
|
|
||
|
|
const filteredRooms = useMemo(() => {
|
||
|
|
if (!searchQuery) return sortedRooms;
|
||
|
|
return sortedRooms.filter(room =>
|
||
|
|
room.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||
|
|
);
|
||
|
|
}, [sortedRooms, searchQuery]);
|
||
|
|
|
||
|
|
const handleRoomClick = async (roomName: string) => {
|
||
|
|
// Nếu đang mở thì đóng lại
|
||
|
|
if (expandedRoom === roomName) {
|
||
|
|
setExpandedRoom(null);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Nếu chưa fetch devices của room này thì gọi API
|
||
|
|
if (!roomDevices[roomName]) {
|
||
|
|
setLoadingRoom(roomName);
|
||
|
|
try {
|
||
|
|
const devices = await fetchDevices(roomName);
|
||
|
|
setRoomDevices(prev => ({ ...prev, [roomName]: devices }));
|
||
|
|
setExpandedRoom(roomName);
|
||
|
|
} catch (error) {
|
||
|
|
console.error("Failed to fetch devices:", error);
|
||
|
|
// Có thể thêm toast notification ở đây
|
||
|
|
} finally {
|
||
|
|
setLoadingRoom(null);
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
// Đã có data rồi thì chỉ toggle
|
||
|
|
setExpandedRoom(roomName);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const toggleDevice = (deviceId: string) => {
|
||
|
|
setSelected((prev) =>
|
||
|
|
prev.includes(deviceId) ? prev.filter((id) => id !== deviceId) : [...prev, deviceId]
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
const toggleAllInRoom = (roomName: string) => {
|
||
|
|
const devices = roomDevices[roomName] || [];
|
||
|
|
const deviceIds = devices.map(d => d.id);
|
||
|
|
const allSelected = deviceIds.every(id => selected.includes(id));
|
||
|
|
|
||
|
|
if (allSelected) {
|
||
|
|
setSelected(prev => prev.filter(id => !deviceIds.includes(id)));
|
||
|
|
} else {
|
||
|
|
setSelected(prev => [...new Set([...prev, ...deviceIds])]);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleConfirm = () => {
|
||
|
|
onSelect(selected);
|
||
|
|
setSelected([]);
|
||
|
|
setExpandedRoom(null);
|
||
|
|
setRoomDevices({});
|
||
|
|
setSearchQuery("");
|
||
|
|
onClose();
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleClose = () => {
|
||
|
|
setSelected([]);
|
||
|
|
setExpandedRoom(null);
|
||
|
|
setRoomDevices({});
|
||
|
|
setSearchQuery("");
|
||
|
|
onClose();
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Dialog open={open} onOpenChange={handleClose}>
|
||
|
|
<DialogContent className="max-w-3xl">
|
||
|
|
<DialogHeader>
|
||
|
|
<DialogTitle className="flex items-center gap-2">
|
||
|
|
<Monitor className="w-6 h-6 text-primary" />
|
||
|
|
Chọn thiết bị
|
||
|
|
</DialogTitle>
|
||
|
|
</DialogHeader>
|
||
|
|
|
||
|
|
{/* Search bar */}
|
||
|
|
<Input
|
||
|
|
placeholder="Tìm kiếm phòng..."
|
||
|
|
value={searchQuery}
|
||
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||
|
|
className="my-2"
|
||
|
|
/>
|
||
|
|
|
||
|
|
{/* Room list */}
|
||
|
|
<ScrollArea className="max-h-[500px] rounded-lg border p-2">
|
||
|
|
<div className="space-y-1">
|
||
|
|
{filteredRooms.length === 0 && (
|
||
|
|
<p className="text-sm text-muted-foreground text-center py-8">
|
||
|
|
Không tìm thấy phòng
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{filteredRooms.map((room) => {
|
||
|
|
const isExpanded = expandedRoom === room.name;
|
||
|
|
const isLoading = loadingRoom === room.name;
|
||
|
|
const devices = roomDevices[room.name] || [];
|
||
|
|
const allSelected = devices.length > 0 && 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 (
|
||
|
|
<div key={room.name} className="border rounded-lg overflow-hidden">
|
||
|
|
{/* Room header - clickable */}
|
||
|
|
<div
|
||
|
|
className="flex items-center gap-2 p-3 hover:bg-muted/50 cursor-pointer"
|
||
|
|
onClick={() => handleRoomClick(room.name)}
|
||
|
|
>
|
||
|
|
{/* Expand icon or loading */}
|
||
|
|
{isLoading ? (
|
||
|
|
<Loader2 className="w-4 h-4 text-muted-foreground flex-shrink-0 animate-spin" />
|
||
|
|
) : isExpanded ? (
|
||
|
|
<ChevronDown className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||
|
|
) : (
|
||
|
|
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Select all checkbox - chỉ hiện khi đã load devices */}
|
||
|
|
{devices.length > 0 && (
|
||
|
|
<Checkbox
|
||
|
|
checked={allSelected}
|
||
|
|
onCheckedChange={() => {
|
||
|
|
toggleAllInRoom(room.name);
|
||
|
|
}}
|
||
|
|
onClick={(e) => e.stopPropagation()}
|
||
|
|
className={someSelected && !allSelected ? "opacity-50" : ""}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<Building2 className="w-4 h-4 text-primary flex-shrink-0" />
|
||
|
|
|
||
|
|
<span className="font-semibold flex-1">{room.name}</span>
|
||
|
|
|
||
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||
|
|
{selectedCount > 0 && (
|
||
|
|
<span className="text-primary font-medium">
|
||
|
|
{selectedCount}/
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
<span>{room.numberOfDevices} thiết bị</span>
|
||
|
|
{room.numberOfOfflineDevices > 0 && (
|
||
|
|
<span className="text-xs px-2 py-0.5 rounded-full bg-red-100 text-red-700">
|
||
|
|
{room.numberOfOfflineDevices} offline
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Device table - collapsible */}
|
||
|
|
{isExpanded && devices.length > 0 && (
|
||
|
|
<div className="border-t bg-muted/20">
|
||
|
|
<div className="overflow-x-auto">
|
||
|
|
<table className="w-full text-sm">
|
||
|
|
<thead className="bg-muted/50 border-b">
|
||
|
|
<tr>
|
||
|
|
<th className="w-12 p-2"></th>
|
||
|
|
<th className="text-left p-2 font-medium">Thiết bị</th>
|
||
|
|
<th className="text-left p-2 font-medium">IP Address</th>
|
||
|
|
<th className="text-left p-2 font-medium">MAC Address</th>
|
||
|
|
<th className="text-left p-2 font-medium">Version</th>
|
||
|
|
<th className="text-left p-2 font-medium">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="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>
|
||
|
|
</ScrollArea>
|
||
|
|
|
||
|
|
{/* Selected count */}
|
||
|
|
{selected.length > 0 && (
|
||
|
|
<div className="text-sm text-muted-foreground bg-muted/50 px-3 py-2 rounded">
|
||
|
|
Đã chọn: <span className="font-semibold text-foreground">{selected.length}</span> thiết bị
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Actions */}
|
||
|
|
<div className="flex justify-end gap-2">
|
||
|
|
<Button variant="outline" onClick={handleClose}>
|
||
|
|
Hủy
|
||
|
|
</Button>
|
||
|
|
<Button onClick={handleConfirm} disabled={selected.length === 0}>
|
||
|
|
Xác nhận ({selected.length})
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
);
|
||
|
|
}
|