feat: add Network Diagrams list page with search, client filter, import
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
260
frontend/src/pages/NetworkDiagrams/index.tsx
Normal file
260
frontend/src/pages/NetworkDiagrams/index.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Plus, Search, Network, MoreHorizontal, Upload } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { networkDiagramsApi } from '@/api'
|
||||
import { toast } from '@/lib/toast'
|
||||
import type { NetworkDiagramListItem, DiagramImportData } from '@/types'
|
||||
|
||||
export default function NetworkDiagramsPage() {
|
||||
const navigate = useNavigate()
|
||||
const [diagrams, setDiagrams] = useState<NetworkDiagramListItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [clientFilter, setClientFilter] = useState<string | null>(null)
|
||||
const [clientOptions, setClientOptions] = useState<string[]>([])
|
||||
const [clientDropdownOpen, setClientDropdownOpen] = useState(false)
|
||||
const [clientSearch, setClientSearch] = useState('')
|
||||
const [menuOpenId, setMenuOpenId] = useState<string | null>(null)
|
||||
|
||||
const loadDiagrams = useCallback(async () => {
|
||||
try {
|
||||
const params: Record<string, string> = {}
|
||||
if (clientFilter) params.client_name = clientFilter
|
||||
if (search) params.search = search
|
||||
const data = await networkDiagramsApi.list(params)
|
||||
setDiagrams(data)
|
||||
} catch {
|
||||
toast.error('Failed to load diagrams')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [clientFilter, search])
|
||||
|
||||
const loadClients = useCallback(async () => {
|
||||
try {
|
||||
const clients = await networkDiagramsApi.listClients()
|
||||
setClientOptions(clients)
|
||||
} catch { /* ignore */ }
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadDiagrams() }, [loadDiagrams])
|
||||
useEffect(() => { loadClients() }, [loadClients])
|
||||
|
||||
const filteredClients = useMemo(() => {
|
||||
if (!clientSearch) return clientOptions
|
||||
const lower = clientSearch.toLowerCase()
|
||||
return clientOptions.filter(c => c.toLowerCase().includes(lower))
|
||||
}, [clientOptions, clientSearch])
|
||||
|
||||
const handleDuplicate = useCallback(async (id: string) => {
|
||||
try {
|
||||
const dup = await networkDiagramsApi.duplicate(id)
|
||||
toast.success(`Created: ${dup.name}`)
|
||||
loadDiagrams()
|
||||
} catch {
|
||||
toast.error('Failed to duplicate')
|
||||
}
|
||||
setMenuOpenId(null)
|
||||
}, [loadDiagrams])
|
||||
|
||||
const handleArchive = useCallback(async (id: string) => {
|
||||
try {
|
||||
await networkDiagramsApi.archive(id)
|
||||
toast.success('Diagram archived')
|
||||
loadDiagrams()
|
||||
} catch {
|
||||
toast.error('Failed to archive')
|
||||
}
|
||||
setMenuOpenId(null)
|
||||
}, [loadDiagrams])
|
||||
|
||||
const handleImport = useCallback(async () => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = '.json'
|
||||
input.onchange = async (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (!file) return
|
||||
try {
|
||||
const text = await file.text()
|
||||
const data = JSON.parse(text) as DiagramImportData
|
||||
const result = await networkDiagramsApi.importJson(data)
|
||||
if (result.warnings.length > 0) {
|
||||
toast.warning(`Imported with warnings: ${result.warnings.join(', ')}`)
|
||||
} else {
|
||||
toast.success('Diagram imported')
|
||||
}
|
||||
navigate(`/network-diagrams/${result.diagram.id}`)
|
||||
} catch {
|
||||
toast.error('Failed to import — check that the file is a valid diagram JSON')
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
}, [navigate])
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-6 py-8">
|
||||
<div className="mb-6 flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="font-heading text-2xl font-bold text-heading">Network Maps</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Visual network topology documentation for your clients</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleImport}
|
||||
className="flex items-center gap-1.5 rounded border border-default px-3 py-2 text-sm text-primary hover:border-hover"
|
||||
>
|
||||
<Upload size={14} />
|
||||
Import
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/network-diagrams/new')}
|
||||
className="flex items-center gap-1.5 rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent/90"
|
||||
>
|
||||
<Plus size={14} />
|
||||
New Diagram
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 flex gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search diagrams..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="w-full rounded border border-default bg-input pl-9 pr-3 py-2 text-sm text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative w-48">
|
||||
<button
|
||||
onClick={() => setClientDropdownOpen(prev => !prev)}
|
||||
className="flex w-full items-center justify-between rounded border border-default bg-input px-3 py-2 text-sm text-primary"
|
||||
>
|
||||
<span className={clientFilter ? 'text-primary' : 'text-muted-foreground'}>
|
||||
{clientFilter || 'All clients'}
|
||||
</span>
|
||||
<span className="text-muted-foreground">▾</span>
|
||||
</button>
|
||||
{clientDropdownOpen && (
|
||||
<div className="absolute left-0 top-full z-50 mt-1 w-full rounded border border-default bg-card shadow-lg">
|
||||
<div className="p-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search clients..."
|
||||
value={clientSearch}
|
||||
onChange={e => setClientSearch(e.target.value)}
|
||||
className="w-full rounded border border-default bg-input px-2 py-1 text-xs text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
<button
|
||||
onClick={() => { setClientFilter(null); setClientDropdownOpen(false); setClientSearch('') }}
|
||||
className={cn('w-full px-3 py-1.5 text-left text-xs hover:bg-elevated', !clientFilter && 'text-accent')}
|
||||
>
|
||||
All clients
|
||||
</button>
|
||||
{filteredClients.map(c => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => { setClientFilter(c); setClientDropdownOpen(false); setClientSearch('') }}
|
||||
className={cn('w-full px-3 py-1.5 text-left text-xs text-primary hover:bg-elevated', clientFilter === c && 'text-accent')}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="h-40 animate-pulse rounded-lg border border-default bg-card" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && diagrams.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<Network size={48} className="mb-4 text-muted-foreground" />
|
||||
<h2 className="font-heading text-lg font-semibold text-heading">No network maps yet</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Create your first network diagram to get started</p>
|
||||
<button
|
||||
onClick={() => navigate('/network-diagrams/new')}
|
||||
className="mt-4 rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent/90"
|
||||
>
|
||||
Create First Diagram
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && diagrams.length > 0 && (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{diagrams.map(d => (
|
||||
<div
|
||||
key={d.id}
|
||||
onClick={() => navigate(`/network-diagrams/${d.id}`)}
|
||||
className="group relative cursor-pointer rounded-lg border border-default bg-card p-4 hover:border-hover"
|
||||
>
|
||||
<div className="mb-2 flex items-start justify-between">
|
||||
<h3 className="font-heading text-sm font-semibold text-heading">{d.name}</h3>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); setMenuOpenId(menuOpenId === d.id ? null : d.id) }}
|
||||
className="rounded p-1 text-muted-foreground opacity-0 hover:bg-elevated group-hover:opacity-100"
|
||||
>
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{d.client_name && (
|
||||
<span className="mb-2 inline-block rounded-full bg-elevated px-2 py-0.5 text-[10px] text-muted-foreground">
|
||||
{d.client_name}
|
||||
</span>
|
||||
)}
|
||||
{d.description && (
|
||||
<p className="mb-3 line-clamp-2 text-xs text-muted-foreground">{d.description}</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
|
||||
<span>{d.node_count} device{d.node_count !== 1 ? 's' : ''}</span>
|
||||
<span>{formatDate(d.created_at)}</span>
|
||||
</div>
|
||||
|
||||
{menuOpenId === d.id && (
|
||||
<div className="absolute right-2 top-10 z-50 w-36 rounded border border-default bg-card py-1 shadow-lg">
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); navigate(`/network-diagrams/${d.id}`) }}
|
||||
className="w-full px-3 py-1.5 text-left text-xs text-primary hover:bg-elevated"
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); handleDuplicate(d.id) }}
|
||||
className="w-full px-3 py-1.5 text-left text-xs text-primary hover:bg-elevated"
|
||||
>
|
||||
Duplicate
|
||||
</button>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); handleArchive(d.id) }}
|
||||
className="w-full px-3 py-1.5 text-left text-xs text-red-400 hover:bg-elevated"
|
||||
>
|
||||
Archive
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user