TTMT.ManageWebGUI/src/components/bars/device-searchbar.tsx

355 lines
13 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 | Promise<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 = async () => {
try {
await onSelect(selected);
} catch (e) {
console.error("Error on select:", e);
} finally {
setSelected([]);
setExpandedRoom(null);
setRoomDevices({});
setSearchQuery("");
onClose();
}
};
const handleClose = () => {
setSelected([]);
setExpandedRoom(null);
setRoomDevices({});
setSearchQuery("");
onClose();
};
const parseDeviceId = (id: string) => {
const match = /^P(.+?)M(\d+)$/i.exec(id.trim());
if (!match) return null;
return {
room: match[1].trim(),
index: Number(match[2]),
};
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-none w-[95vw] max-h-[90vh]">
<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 sortedDevices = [...devices].sort((a, b) => {
const aId = String(a.id);
const bId = String(b.id);
const parsedA = parseDeviceId(aId);
const parsedB = parseDeviceId(bId);
if (parsedA && parsedB) {
const roomCompare = parsedA.room.localeCompare(parsedB.room, undefined, {
numeric: true,
sensitivity: "base",
});
if (roomCompare !== 0) return roomCompare;
return parsedA.index - parsedB.index;
}
return aId.localeCompare(bId, undefined, {
numeric: true,
sensitivity: "base",
});
});
const allSelected =
sortedDevices.length > 0 &&
sortedDevices.every((d) => selected.includes(d.id));
const someSelected = sortedDevices.some((d) => selected.includes(d.id));
const selectedCount = sortedDevices.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-1 px-2 py-1.5 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 text-sm">
{room.name}
</span>
<div className="flex items-center gap-1 text-xs text-muted-foreground flex-shrink-0">
{selectedCount > 0 && (
<span className="text-primary font-medium">
{selectedCount}/
</span>
)}
<span>{room.numberOfDevices}</span>
{room.numberOfOfflineDevices > 0 && (
<span className="text-xs px-1.5 py-0.5 rounded-full bg-red-100 text-red-700">
{room.numberOfOfflineDevices}
</span>
)}
</div>
</div>
{/* Device table - collapsible */}
{isExpanded && sortedDevices.length > 0 && (
<div className="border-t bg-muted/20 overflow-x-auto">
<table className="w-full text-xs">
<thead className="bg-muted/50 border-b sticky top-0">
<tr>
<th className="w-8 px-1 py-1"></th>
<th className="text-left px-1 py-1 font-medium min-w-20 text-xs">
Thiết bị
</th>
<th className="text-left px-1 py-1 font-medium min-w-24 text-xs">
IP
</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>
{sortedDevices.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>
))}
</tbody>
</table>
</div>
)}
</div>
);
})}
</div>
</ScrollArea>
{/* Selected count */}
{selected.length > 0 && (
<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>
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={handleClose} size="sm">
Hủy
</Button>
<Button
onClick={handleConfirm}
disabled={selected.length === 0}
size="sm"
>
Xác nhận ({selected.length})
</Button>
</div>
</DialogContent>
</Dialog>
);
}