581 lines
18 KiB
TypeScript
581 lines
18 KiB
TypeScript
import { useCallback, useEffect, useState, type ReactNode } from 'react'
|
|
import { Link } from 'react-router-dom'
|
|
import { Loader2, Pencil, Plus, RefreshCw, Search, Trash2, X } from 'lucide-react'
|
|
import { toast } from 'sonner'
|
|
import { api, ApiError, type Client, type ClientStatus, type Vlan } from '@/lib/api'
|
|
import { normalizePhone, phoneError } from '@/lib/phone'
|
|
import { TablePagination, usePageSize } from '@/components/Pagination'
|
|
import { Button } from '@/components/ui/button'
|
|
import { ClientImportExport } from '@/components/ClientImportExport'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog'
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select'
|
|
|
|
const STATUSES: ClientStatus[] = ['new', 'paid', 'unpaid']
|
|
|
|
function StatusBadge({ status }: { status: ClientStatus | null }) {
|
|
if (!status) return <span className="text-muted-foreground">—</span>
|
|
const variant = status === 'paid' ? 'success' : status === 'unpaid' ? 'destructive' : 'secondary'
|
|
return <Badge variant={variant}>{status}</Badge>
|
|
}
|
|
|
|
export function Clients() {
|
|
const [devices, setDevices] = useState<Client[]>([])
|
|
const [vlans, setVlans] = useState<Vlan[]>([])
|
|
const [total, setTotal] = useState(0)
|
|
const [loading, setLoading] = useState(true)
|
|
const [addOpen, setAddOpen] = useState(false)
|
|
const [editing, setEditing] = useState<Client | null>(null)
|
|
const [deleting, setDeleting] = useState<Client | null>(null)
|
|
|
|
// Filters — `search` is the raw input; `debouncedSearch` is what we query with
|
|
// so we don't fire a request on every keystroke.
|
|
const [search, setSearch] = useState('')
|
|
const [debouncedSearch, setDebouncedSearch] = useState('')
|
|
const [status, setStatus] = useState<ClientStatus | 'all'>('all')
|
|
const hasFilters = debouncedSearch.trim() !== '' || status !== 'all'
|
|
|
|
const [pageSize, setPageSize] = usePageSize()
|
|
const [page, setPage] = useState(1)
|
|
const pageCount = Math.max(1, Math.ceil(total / pageSize))
|
|
// A device must be assigned to a VLAN group, so adding one is impossible
|
|
// until at least one VLAN exists.
|
|
const noVlans = !loading && vlans.length === 0
|
|
|
|
useEffect(() => {
|
|
const t = setTimeout(() => setDebouncedSearch(search), 300)
|
|
return () => clearTimeout(t)
|
|
}, [search])
|
|
|
|
// Server-side pagination + filtering: fetch only the current page's rows.
|
|
const load = useCallback(async () => {
|
|
setLoading(true)
|
|
try {
|
|
const [d, v] = await Promise.all([
|
|
api.clients.list(pageSize, (page - 1) * pageSize, {
|
|
search: debouncedSearch,
|
|
status: status === 'all' ? undefined : status,
|
|
}),
|
|
api.vlans.list(),
|
|
])
|
|
setDevices(d.items)
|
|
setTotal(d.total)
|
|
setVlans(v)
|
|
} catch (err) {
|
|
if (!(err instanceof ApiError && err.status === 401))
|
|
toast.error(err instanceof Error ? err.message : 'Failed to load devices')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [page, pageSize, debouncedSearch, status])
|
|
|
|
useEffect(() => {
|
|
load()
|
|
}, [load])
|
|
|
|
// Reset to the first page when the page size or an active filter changes.
|
|
useEffect(() => setPage(1), [pageSize, debouncedSearch, status])
|
|
// Clamp the page when the total shrinks (e.g. after a delete).
|
|
useEffect(() => {
|
|
if (page > pageCount) setPage(pageCount)
|
|
}, [page, pageCount])
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold">Clients</h1>
|
|
<p className="text-sm text-muted-foreground">
|
|
{total} {hasFilters ? 'match' : 'registered'}
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" size="icon" onClick={load} title="Refresh">
|
|
<RefreshCw className={loading ? 'animate-spin' : ''} />
|
|
</Button>
|
|
<ClientImportExport onImported={load} />
|
|
<Button
|
|
onClick={() => setAddOpen(true)}
|
|
disabled={noVlans}
|
|
title={noVlans ? 'Add a VLAN before registering a device' : undefined}
|
|
>
|
|
<Plus /> Add device
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
|
<div className="relative flex-1">
|
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
placeholder="Search by MAC, name, phone or alias…"
|
|
className="pl-9 pr-9"
|
|
/>
|
|
{search && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setSearch('')}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-sm p-1 text-muted-foreground hover:text-foreground"
|
|
aria-label="Clear search"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
<Select value={status} onValueChange={(v) => setStatus(v as ClientStatus | 'all')}>
|
|
<SelectTrigger className="sm:w-40">
|
|
<SelectValue placeholder="Status" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All statuses</SelectItem>
|
|
{STATUSES.map((s) => (
|
|
<SelectItem key={s} value={s}>
|
|
{s}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
{hasFilters && (
|
|
<Button
|
|
variant="ghost"
|
|
onClick={() => {
|
|
setSearch('')
|
|
setStatus('all')
|
|
}}
|
|
>
|
|
Clear
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{noVlans && (
|
|
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-400">
|
|
No VLANs exist yet. <Link to="/vlans" className="font-medium underline">Add a VLAN</Link>{' '}
|
|
before registering a device.
|
|
</div>
|
|
)}
|
|
|
|
<div className="rounded-lg border bg-background">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>MAC address</TableHead>
|
|
<TableHead>Name</TableHead>
|
|
<TableHead>Phone</TableHead>
|
|
<TableHead>Group</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead>Alias</TableHead>
|
|
<TableHead className="w-24 text-right">Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{loading && devices.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={7} className="py-10 text-center text-muted-foreground">
|
|
<Loader2 className="mx-auto h-5 w-5 animate-spin" />
|
|
</TableCell>
|
|
</TableRow>
|
|
) : devices.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={7} className="py-10 text-center text-muted-foreground">
|
|
{hasFilters
|
|
? 'No clients match your filters.'
|
|
: 'No devices yet. Add one to get started.'}
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
devices.map((d) => (
|
|
<TableRow key={d.mac_address}>
|
|
<TableCell className="font-mono text-xs">{d.mac_address}</TableCell>
|
|
<TableCell>{d.name ?? '—'}</TableCell>
|
|
<TableCell>{d.phone ?? '—'}</TableCell>
|
|
<TableCell>{d.group ?? <span className="text-muted-foreground">—</span>}</TableCell>
|
|
<TableCell>
|
|
<StatusBadge status={d.status} />
|
|
</TableCell>
|
|
<TableCell className="text-muted-foreground">{d.alias ?? '—'}</TableCell>
|
|
<TableCell className="text-right">
|
|
<div className="flex justify-end gap-1">
|
|
<Button variant="ghost" size="icon" onClick={() => setEditing(d)} title="Edit">
|
|
<Pencil className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="text-destructive hover:text-destructive"
|
|
onClick={() => setDeleting(d)}
|
|
title="Delete"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
<TablePagination
|
|
page={page}
|
|
pageCount={pageCount}
|
|
total={total}
|
|
pageSize={pageSize}
|
|
onPageChange={setPage}
|
|
onPageSizeChange={setPageSize}
|
|
/>
|
|
</div>
|
|
|
|
<AddClientDialog
|
|
open={addOpen}
|
|
onOpenChange={setAddOpen}
|
|
vlans={vlans}
|
|
onSaved={load}
|
|
/>
|
|
<EditClientDialog
|
|
device={editing}
|
|
onClose={() => setEditing(null)}
|
|
vlans={vlans}
|
|
onSaved={load}
|
|
/>
|
|
<DeleteClientDialog device={deleting} onClose={() => setDeleting(null)} onDeleted={load} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ---------- Add ----------
|
|
function AddClientDialog({
|
|
open,
|
|
onOpenChange,
|
|
vlans,
|
|
onSaved,
|
|
}: {
|
|
open: boolean
|
|
onOpenChange: (o: boolean) => void
|
|
vlans: Vlan[]
|
|
onSaved: () => void
|
|
}) {
|
|
const [mac, setMac] = useState('')
|
|
const [group, setGroup] = useState('')
|
|
const [name, setName] = useState('')
|
|
const [phone, setPhone] = useState('')
|
|
const [alias, setAlias] = useState('')
|
|
const [busy, setBusy] = useState(false)
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
setMac('')
|
|
setGroup('')
|
|
setName('')
|
|
setPhone('')
|
|
setAlias('')
|
|
}
|
|
}, [open])
|
|
|
|
const phoneErr = phone.trim() ? phoneError(phone) : null
|
|
const valid = mac.trim() && group && name.trim() && phone.trim() && !phoneErr
|
|
|
|
async function submit() {
|
|
if (!valid) return
|
|
setBusy(true)
|
|
try {
|
|
await api.clients.add({
|
|
mac_address: mac.trim(),
|
|
group,
|
|
name: name.trim(),
|
|
phone: normalizePhone(phone) ?? phone.trim(),
|
|
alias: alias.trim() || null,
|
|
})
|
|
toast.success(`Client ${mac.trim()} added`)
|
|
onOpenChange(false)
|
|
onSaved()
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'Failed to add device')
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Add device</DialogTitle>
|
|
<DialogDescription>Register a new MAC address and assign it to a VLAN group.</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-3">
|
|
<Field label="MAC address" required>
|
|
<Input
|
|
placeholder="AA-BB-CC-DD-EE-FF"
|
|
className="font-mono"
|
|
value={mac}
|
|
onChange={(e) => setMac(e.target.value)}
|
|
/>
|
|
</Field>
|
|
<Field label="Group (VLAN)" required>
|
|
<GroupSelect vlans={vlans} value={group} onChange={setGroup} />
|
|
</Field>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Field label="Name" required>
|
|
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
|
</Field>
|
|
<Field label="Phone" required error={phoneErr}>
|
|
<Input
|
|
placeholder="9XXXXXX"
|
|
value={phone}
|
|
onChange={(e) => setPhone(e.target.value)}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
<Field label="Device alias (optional)">
|
|
<Input value={alias} onChange={(e) => setAlias(e.target.value)} />
|
|
</Field>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={submit} disabled={busy || !valid}>
|
|
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
Add device
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
// ---------- Edit ----------
|
|
function EditClientDialog({
|
|
device,
|
|
onClose,
|
|
vlans,
|
|
onSaved,
|
|
}: {
|
|
device: Client | null
|
|
onClose: () => void
|
|
vlans: Vlan[]
|
|
onSaved: () => void
|
|
}) {
|
|
const [group, setGroup] = useState('')
|
|
const [status, setStatus] = useState<ClientStatus>('new')
|
|
const [name, setName] = useState('')
|
|
const [phone, setPhone] = useState('')
|
|
const [alias, setAlias] = useState('')
|
|
const [busy, setBusy] = useState(false)
|
|
|
|
useEffect(() => {
|
|
if (device) {
|
|
setGroup(device.group ?? '')
|
|
setStatus(device.status ?? 'new')
|
|
setName(device.name ?? '')
|
|
setPhone(device.phone ?? '')
|
|
setAlias(device.alias ?? '')
|
|
}
|
|
}, [device])
|
|
|
|
const phoneErr = phone.trim() ? phoneError(phone) : 'Phone is required'
|
|
|
|
async function submit() {
|
|
if (!device || phoneErr) return
|
|
setBusy(true)
|
|
try {
|
|
await api.clients.edit({
|
|
mac_address: device.mac_address,
|
|
group: group || undefined,
|
|
status,
|
|
name,
|
|
phone: normalizePhone(phone) ?? phone,
|
|
alias,
|
|
})
|
|
toast.success(`Client ${device.mac_address} updated`)
|
|
onClose()
|
|
onSaved()
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'Failed to update device')
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Dialog open={!!device} onOpenChange={(o) => !o && onClose()}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Edit device</DialogTitle>
|
|
<DialogDescription className="font-mono">{device?.mac_address}</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-3">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Field label="Group (VLAN)">
|
|
<GroupSelect vlans={vlans} value={group} onChange={setGroup} />
|
|
</Field>
|
|
<Field label="Status">
|
|
<Select value={status} onValueChange={(v) => setStatus(v as ClientStatus)}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{STATUSES.map((s) => (
|
|
<SelectItem key={s} value={s}>
|
|
{s}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</Field>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Field label="Name">
|
|
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
|
</Field>
|
|
<Field label="Phone" error={phoneErr}>
|
|
<Input
|
|
placeholder="9XXXXXX"
|
|
value={phone}
|
|
onChange={(e) => setPhone(e.target.value)}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
<Field label="Device alias">
|
|
<Input value={alias} onChange={(e) => setAlias(e.target.value)} />
|
|
</Field>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={onClose}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={submit} disabled={busy || !!phoneErr}>
|
|
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
Save changes
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
// ---------- Delete ----------
|
|
function DeleteClientDialog({
|
|
device,
|
|
onClose,
|
|
onDeleted,
|
|
}: {
|
|
device: Client | null
|
|
onClose: () => void
|
|
onDeleted: () => void
|
|
}) {
|
|
const [busy, setBusy] = useState(false)
|
|
|
|
async function confirm() {
|
|
if (!device) return
|
|
setBusy(true)
|
|
try {
|
|
await api.clients.remove(device.mac_address)
|
|
toast.success(`Client ${device.mac_address} deleted`)
|
|
onClose()
|
|
onDeleted()
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'Failed to delete device')
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Dialog open={!!device} onOpenChange={(o) => !o && onClose()}>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>Delete device?</DialogTitle>
|
|
<DialogDescription>
|
|
This removes <span className="font-mono">{device?.mac_address}</span> from radcheck,
|
|
radusergroup and customers. This cannot be undone.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={onClose}>
|
|
Cancel
|
|
</Button>
|
|
<Button variant="destructive" onClick={confirm} disabled={busy}>
|
|
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
Delete
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
// ---------- shared bits ----------
|
|
function Field({
|
|
label,
|
|
required,
|
|
error,
|
|
children,
|
|
}: {
|
|
label: string
|
|
required?: boolean
|
|
error?: string | null
|
|
children: ReactNode
|
|
}) {
|
|
return (
|
|
<div className="space-y-1.5">
|
|
<Label>
|
|
{label}
|
|
{required && <span className="text-destructive"> *</span>}
|
|
</Label>
|
|
{children}
|
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function GroupSelect({
|
|
vlans,
|
|
value,
|
|
onChange,
|
|
}: {
|
|
vlans: Vlan[]
|
|
value: string
|
|
onChange: (v: string) => void
|
|
}) {
|
|
return (
|
|
<Select value={value} onValueChange={onChange}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select a VLAN" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{vlans.map((v) => (
|
|
<SelectItem key={v.vlanid} value={v.alias}>
|
|
{v.alias} (VLAN {v.vlanid})
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
)
|
|
}
|