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:
chihlasm
2026-04-04 07:57:43 +00:00
parent 12f6a29dd5
commit 6aef6e2602

View 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>
)
}