From 98e328cd66e19bbae1257a682e2bbf9a0f89190d Mon Sep 17 00:00:00 2001 From: Jason Staack Date: Wed, 18 Mar 2026 21:53:06 -0500 Subject: [PATCH] feat(11-03): add Site column, multi-select bulk assign, and site selector - Add checkbox column and Site column to FleetTable - Site names link to /tenants/{tenantId}/sites/{siteId} - Multi-select checkboxes with select-all in header - Bulk assign action bar with "Assign to site" dialog - Device detail page includes site selector dropdown with assign/unassign - Viewers see site name text, operators get a Select dropdown Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/components/fleet/FleetTable.tsx | 133 +++++++++++++++++- .../tenants/$tenantId/devices/$deviceId.tsx | 52 ++++++- 2 files changed, 178 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/fleet/FleetTable.tsx b/frontend/src/components/fleet/FleetTable.tsx index d05b283..50bc2dd 100644 --- a/frontend/src/components/fleet/FleetTable.tsx +++ b/frontend/src/components/fleet/FleetTable.tsx @@ -1,9 +1,9 @@ import { useRef, useState, useCallback } from 'react' -import { useNavigate } from '@tanstack/react-router' -import { useQuery } from '@tanstack/react-query' +import { Link, useNavigate } from '@tanstack/react-router' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useVirtualizer } from '@tanstack/react-virtual' -import { ChevronUp, ChevronDown, ChevronsUpDown, Monitor } from 'lucide-react' -import { devicesApi, type DeviceResponse } from '@/lib/api' +import { ChevronUp, ChevronDown, ChevronsUpDown, Monitor, MapPin } from 'lucide-react' +import { devicesApi, sitesApi, type DeviceResponse } from '@/lib/api' import { Badge } from '@/components/ui/badge' import { useShortcut } from '@/hooks/useShortcut' import { @@ -16,6 +16,12 @@ import { import { Button } from '@/components/ui/button' import { formatUptime, formatDateTime } from '@/lib/utils' import { cn } from '@/lib/utils' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' import { DeviceLink } from '@/components/ui/device-link' import { TableSkeleton } from '@/components/ui/page-skeleton' import { EmptyState } from '@/components/ui/empty-state' @@ -126,7 +132,28 @@ export function FleetTable({ pageSize = 25, }: FleetTableProps) { const navigate = useNavigate() + const queryClient = useQueryClient() const scrollContainerRef = useRef(null) + const [selectedIds, setSelectedIds] = useState>(new Set()) + const [bulkAssignOpen, setBulkAssignOpen] = useState(false) + const [bulkSiteId, setBulkSiteId] = useState('') + + const { data: sitesData } = useQuery({ + queryKey: ['sites', tenantId], + queryFn: () => sitesApi.list(tenantId), + }) + + const bulkAssignMutation = useMutation({ + mutationFn: ({ siteId, deviceIds }: { siteId: string; deviceIds: string[] }) => + sitesApi.bulkAssign(tenantId, siteId, deviceIds), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['devices'] }) + void queryClient.invalidateQueries({ queryKey: ['sites'] }) + setSelectedIds(new Set()) + setBulkAssignOpen(false) + setBulkSiteId('') + }, + }) const { data, isLoading, isFetching } = useQuery({ queryKey: ['devices', tenantId, { search, status, sortBy, sortDir, page, pageSize }], @@ -207,9 +234,38 @@ export function FleetTable({ enabled: useVirtual, }) + const toggleSelection = (id: string) => { + setSelectedIds((prev) => { + const next = new Set(prev) + if (next.has(id)) { + next.delete(id) + } else { + next.add(id) + } + return next + }) + } + + const toggleSelectAll = () => { + if (selectedIds.size === items.length && items.length > 0) { + setSelectedIds(new Set()) + } else { + setSelectedIds(new Set(items.map((d) => d.id))) + } + } + function renderDeviceRow(device: DeviceResponse) { return ( <> + + toggleSelection(device.id)} + className="h-3.5 w-3.5 rounded border-border accent-accent" + onClick={(e) => e.stopPropagation()} + /> + @@ -218,6 +274,19 @@ export function FleetTable({ {device.ip_address} {device.model ?? '—'} + + {device.site_id ? ( + + {device.site_name} + + ) : ( + -- + )} + {device.routeros_version ?? '—'} @@ -246,10 +315,19 @@ export function FleetTable({ const tableHead = ( + + 0} + onChange={toggleSelectAll} + className="h-3.5 w-3.5 rounded border-border accent-accent" + /> + Status + Site @@ -354,13 +432,13 @@ export function FleetTable({ {isLoading ? ( - + ) : items.length === 0 ? ( - + + {/* Bulk assign action bar */} + {selectedIds.size > 0 && ( +
+ {selectedIds.size} device{selectedIds.size !== 1 ? 's' : ''} selected + + +
+ )} + + {/* Bulk assign dialog */} + + + + Assign {selectedIds.size} device{selectedIds.size !== 1 ? 's' : ''} to site + +
+ +
+ + +
+
+
+
+ {/* Pagination (shown for both views) */} {data && data.total > 0 && (
diff --git a/frontend/src/routes/_authenticated/tenants/$tenantId/devices/$deviceId.tsx b/frontend/src/routes/_authenticated/tenants/$tenantId/devices/$deviceId.tsx index 934762e..5589462 100644 --- a/frontend/src/routes/_authenticated/tenants/$tenantId/devices/$deviceId.tsx +++ b/frontend/src/routes/_authenticated/tenants/$tenantId/devices/$deviceId.tsx @@ -12,13 +12,14 @@ import { FolderOpen, BellOff, BellRing, + MapPin, CheckCircle, ShieldCheck, ShieldAlert, ShieldOff, Shield, } from 'lucide-react' -import { devicesApi, deviceGroupsApi, deviceTagsApi, tenantsApi, configApi, type DeviceResponse, type DeviceUpdate } from '@/lib/api' +import { devicesApi, deviceGroupsApi, deviceTagsApi, tenantsApi, configApi, sitesApi, type DeviceResponse, type DeviceUpdate } from '@/lib/api' import { alertsApi } from '@/lib/alertsApi' import { useAuth, canWrite, canDelete } from '@/lib/auth' import { toast } from '@/components/ui/toast' @@ -376,6 +377,29 @@ function DeviceDetailPage() { enabled: canWrite(user), }) + const { data: sitesData } = useQuery({ + queryKey: ['sites', tenantId], + queryFn: () => sitesApi.list(tenantId), + }) + + const siteAssignMutation = useMutation({ + mutationFn: async (value: string) => { + if (value === 'unassigned') { + if (device?.site_id) { + await sitesApi.removeDevice(tenantId, device.site_id, deviceId) + } + } else { + await sitesApi.assignDevice(tenantId, value, deviceId) + } + }, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['device', tenantId, deviceId] }) + void queryClient.invalidateQueries({ queryKey: ['devices'] }) + void queryClient.invalidateQueries({ queryKey: ['sites'] }) + }, + onError: () => toast({ title: 'Failed to update site assignment', variant: 'destructive' }), + }) + const deleteMutation = useMutation({ mutationFn: () => devicesApi.delete(tenantId, deviceId), onSuccess: () => { @@ -542,6 +566,32 @@ function DeviceDetailPage() { } /> + + + {canWrite(user) ? ( + + ) : ( + {device.site_name ?? 'Unassigned'} + )} +
+ } + /> {/* Credentials (masked) */}