diff --git a/frontend/src/components/sites/SiteFormDialog.tsx b/frontend/src/components/sites/SiteFormDialog.tsx new file mode 100644 index 0000000..3257c60 --- /dev/null +++ b/frontend/src/components/sites/SiteFormDialog.tsx @@ -0,0 +1,182 @@ +import { useState, useEffect } from 'react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { sitesApi, type SiteResponse, type SiteCreate, type SiteUpdate } from '@/lib/api' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Button } from '@/components/ui/button' + +interface SiteFormDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + tenantId: string + site?: SiteResponse | null +} + +export function SiteFormDialog({ open, onOpenChange, tenantId, site }: SiteFormDialogProps) { + const queryClient = useQueryClient() + const isEdit = !!site + + const [name, setName] = useState('') + const [address, setAddress] = useState('') + const [latitude, setLatitude] = useState('') + const [longitude, setLongitude] = useState('') + const [elevation, setElevation] = useState('') + const [notes, setNotes] = useState('') + + // Populate form when editing or reset when dialog opens/closes + useEffect(() => { + if (site) { + setName(site.name) + setAddress(site.address ?? '') + setLatitude(site.latitude != null ? String(site.latitude) : '') + setLongitude(site.longitude != null ? String(site.longitude) : '') + setElevation(site.elevation != null ? String(site.elevation) : '') + setNotes(site.notes ?? '') + } else { + setName('') + setAddress('') + setLatitude('') + setLongitude('') + setElevation('') + setNotes('') + } + }, [site, open]) + + const createMutation = useMutation({ + mutationFn: (data: SiteCreate) => sitesApi.create(tenantId, data), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['sites', tenantId] }) + onOpenChange(false) + }, + }) + + const updateMutation = useMutation({ + mutationFn: (data: SiteUpdate) => sitesApi.update(tenantId, site!.id, data), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['sites', tenantId] }) + onOpenChange(false) + }, + }) + + const isPending = createMutation.isPending || updateMutation.isPending + + function handleSubmit(e: React.FormEvent) { + e.preventDefault() + const data = { + name: name.trim(), + address: address.trim() || null, + latitude: latitude ? parseFloat(latitude) : null, + longitude: longitude ? parseFloat(longitude) : null, + elevation: elevation ? parseFloat(elevation) : null, + notes: notes.trim() || null, + } + + if (isEdit) { + updateMutation.mutate(data) + } else { + createMutation.mutate(data) + } + } + + return ( + + + + {isEdit ? 'Edit Site' : 'Create Site'} + + {isEdit ? 'Update site details.' : 'Add a new site to organize devices by physical location.'} + + + + + + Name * + setName(e.target.value)} + placeholder="Main Office" + required + /> + + + + Address + setAddress(e.target.value)} + placeholder="123 Main St, City, State" + /> + + + + + Latitude + setLatitude(e.target.value)} + placeholder="-33.8688" + /> + + + Longitude + setLongitude(e.target.value)} + placeholder="151.2093" + /> + + + + + Elevation (m) + setElevation(e.target.value)} + placeholder="58" + /> + + + + Notes + setNotes(e.target.value)} + placeholder="Additional details about this site..." + rows={3} + className="flex w-full rounded-md border border-border bg-elevated/50 px-3 py-2 text-sm text-text-primary placeholder:text-text-muted transition-colors focus:border-accent focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 resize-none" + /> + + + + onOpenChange(false)}> + Cancel + + + {isEdit ? 'Save Changes' : 'Create Site'} + + + + + + ) +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index e0200c6..077acef 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -493,6 +493,77 @@ export const deviceTagsApi = { api.delete(`/api/tenants/${tenantId}/device-tags/${tagId}`).then((r) => r.data), } +// ─── Sites ─────────────────────────────────────────────────────────────────── + +export interface SiteResponse { + id: string + name: string + latitude: number | null + longitude: number | null + address: string | null + elevation: number | null + notes: string | null + device_count: number + online_count: number + online_percent: number + alert_count: number + created_at: string + updated_at: string +} + +export interface SiteListResponse { + sites: SiteResponse[] + unassigned_count: number +} + +export interface SiteCreate { + name: string + latitude?: number | null + longitude?: number | null + address?: string | null + elevation?: number | null + notes?: string | null +} + +export interface SiteUpdate { + name?: string + latitude?: number | null + longitude?: number | null + address?: string | null + elevation?: number | null + notes?: string | null +} + +export const sitesApi = { + list: (tenantId: string) => + api.get(`/api/tenants/${tenantId}/sites`).then((r) => r.data), + + get: (tenantId: string, siteId: string) => + api.get(`/api/tenants/${tenantId}/sites/${siteId}`).then((r) => r.data), + + create: (tenantId: string, data: SiteCreate) => + api.post(`/api/tenants/${tenantId}/sites`, data).then((r) => r.data), + + update: (tenantId: string, siteId: string, data: SiteUpdate) => + api.put(`/api/tenants/${tenantId}/sites/${siteId}`, data).then((r) => r.data), + + delete: (tenantId: string, siteId: string) => + api.delete(`/api/tenants/${tenantId}/sites/${siteId}`).then((r) => r.data), + + assignDevice: (tenantId: string, siteId: string, deviceId: string) => + api.post(`/api/tenants/${tenantId}/sites/${siteId}/devices/${deviceId}`).then((r) => r.data), + + removeDevice: (tenantId: string, siteId: string, deviceId: string) => + api.delete(`/api/tenants/${tenantId}/sites/${siteId}/devices/${deviceId}`).then((r) => r.data), + + bulkAssign: (tenantId: string, siteId: string, deviceIds: string[]) => + api + .post<{ assigned: number }>(`/api/tenants/${tenantId}/sites/${siteId}/devices/bulk-assign`, { + device_ids: deviceIds, + }) + .then((r) => r.data), +} + // ─── Metrics ────────────────────────────────────────────────────────────────── export interface HealthMetricPoint {