Files
radadmin/frontend/src/pages/Clients.tsx
T
shihaam 2957347583
build-and-push / build (push) Failing after 54s
new ui + search clients
2026-08-01 13:31:46 +05:00

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