@@ -10,14 +10,23 @@ the RADIUS ``username``. One device touches three tables:
|
||||
This router hides that fan-out behind mac_address + group + status.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import delete, func, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..errors import APIError
|
||||
from ..models import Customer, RadCheck, RadGroupReply, RadUserGroup
|
||||
from ..pagination import Page, PageParams
|
||||
from ..schemas import DeviceCreate, DeviceEdit, DeviceOut
|
||||
from ..schemas import (
|
||||
DeviceCreate,
|
||||
DeviceEdit,
|
||||
DeviceImportError,
|
||||
DeviceImportRequest,
|
||||
DeviceImportResult,
|
||||
DeviceOut,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/device", tags=["device"])
|
||||
|
||||
@@ -32,6 +41,20 @@ def _device_exists(db: Session, mac: str) -> bool:
|
||||
return db.execute(stmt).first() is not None
|
||||
|
||||
|
||||
def _stage_device(db: Session, dev: DeviceCreate) -> None:
|
||||
"""Add a device's three rows (radcheck, radusergroup, customers) to the session.
|
||||
|
||||
Does not commit — the caller controls the transaction boundary.
|
||||
"""
|
||||
mac = dev.mac_address
|
||||
db.add(RadCheck(username=mac, attribute="Cleartext-Password", op=":=", value=mac))
|
||||
db.add(RadUserGroup(username=mac, groupname=dev.group, priority=1))
|
||||
db.add(Customer(
|
||||
username=mac, mac_address=mac, status="paid",
|
||||
name=dev.name, phone=dev.phone, device_alias=dev.alias,
|
||||
))
|
||||
|
||||
|
||||
@router.get("/", response_model=Page[DeviceOut])
|
||||
def list_devices(page: PageParams = Depends(), db: Session = Depends(get_db)):
|
||||
"""List devices — MAC, group and status, joined from customers + radusergroup."""
|
||||
@@ -78,12 +101,7 @@ def add_device(payload: DeviceCreate, db: Session = Depends(get_db)):
|
||||
if _device_exists(db, mac):
|
||||
raise APIError(status_code=409, detail=f"Device '{mac}' already exists")
|
||||
|
||||
db.add(RadCheck(username=mac, attribute="Cleartext-Password", op=":=", value=mac))
|
||||
db.add(RadUserGroup(username=mac, groupname=payload.group, priority=1))
|
||||
db.add(Customer(
|
||||
username=mac, mac_address=mac, status="paid",
|
||||
name=payload.name, phone=payload.phone, device_alias=payload.alias,
|
||||
))
|
||||
_stage_device(db, payload)
|
||||
db.commit()
|
||||
return DeviceOut(
|
||||
mac_address=mac, group=payload.group, status="paid",
|
||||
@@ -91,6 +109,70 @@ def add_device(payload: DeviceCreate, db: Session = Depends(get_db)):
|
||||
)
|
||||
|
||||
|
||||
def _format_validation_error(exc: ValidationError) -> str:
|
||||
"""Turn a pydantic ValidationError into a short, human message."""
|
||||
parts = []
|
||||
for err in exc.errors():
|
||||
loc = ".".join(str(p) for p in err["loc"])
|
||||
parts.append(f"{loc}: {err['msg']}" if loc else err["msg"])
|
||||
return "; ".join(parts)
|
||||
|
||||
|
||||
@router.post("/import", response_model=DeviceImportResult)
|
||||
def import_devices(payload: DeviceImportRequest, db: Session = Depends(get_db)):
|
||||
"""Bulk-import devices from parsed CSV rows.
|
||||
|
||||
Every row is validated with the same rules as ``/device/add`` (MAC/phone
|
||||
normalization, required fields, group-exists, duplicate MAC — both against the
|
||||
DB and within the file). Errors are collected per row rather than failing the
|
||||
batch. With ``dry_run`` nothing is written, so the UI can preview and confirm;
|
||||
otherwise the valid rows are inserted best-effort (each in its own commit).
|
||||
"""
|
||||
errors: list[DeviceImportError] = []
|
||||
valid: list[tuple[int, DeviceCreate]] = []
|
||||
seen_macs: set[str] = set()
|
||||
|
||||
for idx, raw in enumerate(payload.devices, start=1):
|
||||
try:
|
||||
dev = DeviceCreate(**raw.model_dump())
|
||||
except ValidationError as exc:
|
||||
errors.append(DeviceImportError(row=idx, mac=raw.mac_address, detail=_format_validation_error(exc)))
|
||||
continue
|
||||
|
||||
mac = dev.mac_address
|
||||
if mac in seen_macs:
|
||||
errors.append(DeviceImportError(row=idx, mac=mac, detail="Duplicate MAC within file"))
|
||||
continue
|
||||
if not _group_exists(db, dev.group):
|
||||
errors.append(DeviceImportError(row=idx, mac=mac, detail=f"Group '{dev.group}' not found"))
|
||||
continue
|
||||
if _device_exists(db, mac):
|
||||
errors.append(DeviceImportError(row=idx, mac=mac, detail=f"Device '{mac}' already exists"))
|
||||
continue
|
||||
|
||||
seen_macs.add(mac)
|
||||
valid.append((idx, dev))
|
||||
|
||||
created = 0
|
||||
if not payload.dry_run:
|
||||
for idx, dev in valid:
|
||||
_stage_device(db, dev)
|
||||
try:
|
||||
db.commit()
|
||||
created += 1
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
errors.append(DeviceImportError(row=idx, mac=dev.mac_address, detail="Insert failed (integrity error)"))
|
||||
|
||||
return DeviceImportResult(
|
||||
total=len(payload.devices),
|
||||
valid=len(valid),
|
||||
created=created,
|
||||
dry_run=payload.dry_run,
|
||||
errors=sorted(errors, key=lambda e: e.row),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/edit", response_model=DeviceOut)
|
||||
def edit_device(payload: DeviceEdit, db: Session = Depends(get_db)):
|
||||
"""Edit a device — any subset of group, status, name, phone, alias."""
|
||||
|
||||
@@ -209,6 +209,40 @@ class DeviceEdit(BaseModel):
|
||||
return self
|
||||
|
||||
|
||||
# ---------- device CSV import ----------
|
||||
class DeviceImportRow(BaseModel):
|
||||
"""A single CSV row — lenient so one bad row never 422s the whole batch.
|
||||
|
||||
Each row is re-validated with ``DeviceCreate`` inside the router so failures
|
||||
are collected per-row instead of rejecting the entire request.
|
||||
"""
|
||||
|
||||
mac_address: str | None = None
|
||||
group: str | None = None
|
||||
name: str | None = None
|
||||
phone: str | None = None
|
||||
alias: str | None = None
|
||||
|
||||
|
||||
class DeviceImportRequest(BaseModel):
|
||||
devices: list[DeviceImportRow]
|
||||
dry_run: bool = Field(default=False, description="Validate only; commit nothing")
|
||||
|
||||
|
||||
class DeviceImportError(BaseModel):
|
||||
row: int # 1-based index within the submitted rows
|
||||
mac: str | None = None
|
||||
detail: str
|
||||
|
||||
|
||||
class DeviceImportResult(BaseModel):
|
||||
total: int # rows submitted
|
||||
valid: int # rows that passed validation
|
||||
created: int # rows actually inserted (0 on dry_run)
|
||||
dry_run: bool
|
||||
errors: list[DeviceImportError]
|
||||
|
||||
|
||||
# ---------- radusergroup ----------
|
||||
class UserGroupBase(BaseModel):
|
||||
username: str = Field(max_length=64)
|
||||
|
||||
Generated
+17
-2
@@ -14,9 +14,11 @@
|
||||
"@radix-ui/react-select": "^2.3.7",
|
||||
"@radix-ui/react-slot": "^1.3.3",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/papaparse": "^5.5.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.28.0",
|
||||
"papaparse": "^5.5.4",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"sonner": "^2.0.7",
|
||||
@@ -1946,12 +1948,20 @@
|
||||
"version": "24.13.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
||||
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/papaparse": {
|
||||
"version": "5.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.2.tgz",
|
||||
"integrity": "sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.18",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
|
||||
@@ -2469,6 +2479,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/papaparse": {
|
||||
"version": "5.5.4",
|
||||
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.4.tgz",
|
||||
"integrity": "sha512-SwzWD9gl/ElwYLCI0nUja1mFJzjq2D8ziShfNBa7zCHzkOozeOGDwHWQ+tvCzEZcewecWZ5U7kUopDnG+DFYEQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -2779,7 +2795,6 @@
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/use-callback-ref": {
|
||||
|
||||
@@ -16,9 +16,11 @@
|
||||
"@radix-ui/react-select": "^2.3.7",
|
||||
"@radix-ui/react-slot": "^1.3.3",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/papaparse": "^5.5.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.28.0",
|
||||
"papaparse": "^5.5.4",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"sonner": "^2.0.7",
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import Papa from 'papaparse'
|
||||
import { AlertTriangle, ArrowDownUp, Download, FileText, Loader2, Upload } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
api,
|
||||
ApiError,
|
||||
type Device,
|
||||
type DeviceImportResult,
|
||||
type DeviceImportRow,
|
||||
} from '@/lib/api'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
|
||||
const IMPORT_COLUMNS = ['mac_address', 'group', 'name', 'phone', 'alias'] as const
|
||||
const EXPORT_COLUMNS = [...IMPORT_COLUMNS, 'status'] as const
|
||||
|
||||
const EXAMPLE_CSV = Papa.unparse({
|
||||
fields: [...IMPORT_COLUMNS],
|
||||
data: [
|
||||
['AA-BB-CC-DD-EE-FF', 'customer', 'Ali Hassan', '7712345', 'Living Room TV'],
|
||||
['11-22-33-44-55-66', 'staff', 'Sara Ibrahim', '9998887', ''],
|
||||
],
|
||||
})
|
||||
|
||||
function downloadCsv(filename: string, csv: string) {
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function parseCsv(file: File): Promise<DeviceImportRow[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
Papa.parse<Record<string, string>>(file, {
|
||||
header: true,
|
||||
skipEmptyLines: 'greedy',
|
||||
transformHeader: (h) => h.trim().toLowerCase(),
|
||||
complete: (res) => {
|
||||
const rows = res.data.map((r) => {
|
||||
const pick = (k: string) => (r[k] ?? '').trim() || undefined
|
||||
return {
|
||||
mac_address: pick('mac_address'),
|
||||
group: pick('group'),
|
||||
name: pick('name'),
|
||||
phone: pick('phone'),
|
||||
alias: pick('alias'),
|
||||
}
|
||||
})
|
||||
resolve(rows)
|
||||
},
|
||||
error: (err) => reject(err),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function DeviceImportExport({ onImported }: { onImported: () => void }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [rows, setRows] = useState<DeviceImportRow[] | null>(null)
|
||||
const [preview, setPreview] = useState<DeviceImportResult | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
function reset() {
|
||||
setRows(null)
|
||||
setPreview(null)
|
||||
setBusy(false)
|
||||
if (fileRef.current) fileRef.current.value = ''
|
||||
}
|
||||
|
||||
function openChange(o: boolean) {
|
||||
setOpen(o)
|
||||
if (!o) reset()
|
||||
}
|
||||
|
||||
async function onFile(file: File) {
|
||||
setBusy(true)
|
||||
try {
|
||||
const parsed = await parseCsv(file)
|
||||
if (parsed.length === 0) {
|
||||
toast.error('No rows found in that CSV')
|
||||
return
|
||||
}
|
||||
const result = await api.devices.import(parsed, true) // dry run
|
||||
setRows(parsed)
|
||||
setPreview(result)
|
||||
} catch (err) {
|
||||
if (!(err instanceof ApiError && err.status === 401))
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to read CSV')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
if (fileRef.current) fileRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmImport() {
|
||||
if (!rows) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const res = await api.devices.import(rows, false)
|
||||
const skipped = res.errors.length
|
||||
toast.success(
|
||||
`Imported ${res.created} device${res.created === 1 ? '' : 's'}` +
|
||||
(skipped ? `, ${skipped} skipped` : ''),
|
||||
)
|
||||
onImported()
|
||||
openChange(false)
|
||||
} catch (err) {
|
||||
if (!(err instanceof ApiError && err.status === 401))
|
||||
toast.error(err instanceof Error ? err.message : 'Import failed')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function exportAll() {
|
||||
setExporting(true)
|
||||
try {
|
||||
const all: Device[] = []
|
||||
const limit = 500
|
||||
let offset = 0
|
||||
for (;;) {
|
||||
const page = await api.devices.list(limit, offset)
|
||||
all.push(...page.items)
|
||||
offset += page.items.length
|
||||
if (page.items.length === 0 || all.length >= page.total) break
|
||||
}
|
||||
const csv = Papa.unparse({
|
||||
fields: [...EXPORT_COLUMNS],
|
||||
data: all.map((d) => [
|
||||
d.mac_address,
|
||||
d.group ?? '',
|
||||
d.name ?? '',
|
||||
d.phone ?? '',
|
||||
d.alias ?? '',
|
||||
d.status ?? '',
|
||||
]),
|
||||
})
|
||||
downloadCsv('devices.csv', csv)
|
||||
toast.success(`Exported ${all.length} device${all.length === 1 ? '' : 's'}`)
|
||||
} catch (err) {
|
||||
if (!(err instanceof ApiError && err.status === 401))
|
||||
toast.error(err instanceof Error ? err.message : 'Export failed')
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setOpen(true)}>
|
||||
<ArrowDownUp className="h-4 w-4" /> Import / Export
|
||||
</Button>
|
||||
|
||||
<Dialog open={open} onOpenChange={openChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import / Export devices</DialogTitle>
|
||||
<DialogDescription>
|
||||
Bulk-add devices from a CSV, or download all devices as a CSV.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{preview ? (
|
||||
<ImportPreview
|
||||
preview={preview}
|
||||
busy={busy}
|
||||
onConfirm={confirmImport}
|
||||
onBack={reset}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Example */}
|
||||
<section className="rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">Example CSV</div>
|
||||
<div className="text-muted-foreground">
|
||||
Columns: <span className="font-mono text-xs">{IMPORT_COLUMNS.join(', ')}</span>{' '}
|
||||
— <span className="font-mono text-xs">alias</span> optional.
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => downloadCsv('devices-template.csv', EXAMPLE_CSV)}
|
||||
>
|
||||
<FileText className="h-4 w-4" /> Download
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Import */}
|
||||
<section className="rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">Import from CSV</div>
|
||||
<div className="text-muted-foreground">
|
||||
You'll see a preview and confirm before anything is saved.
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => fileRef.current?.click()} disabled={busy}>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
||||
Choose file
|
||||
</Button>
|
||||
</div>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0]
|
||||
if (f) onFile(f)
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Export */}
|
||||
<section className="rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">Export all devices</div>
|
||||
<div className="text-muted-foreground">Download every device as a CSV file.</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={exportAll} disabled={exporting}>
|
||||
{exporting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ImportPreview({
|
||||
preview,
|
||||
busy,
|
||||
onConfirm,
|
||||
onBack,
|
||||
}: {
|
||||
preview: DeviceImportResult
|
||||
busy: boolean
|
||||
onConfirm: () => void
|
||||
onBack: () => void
|
||||
}) {
|
||||
const hasErrors = preview.errors.length > 0
|
||||
const nothingValid = preview.valid === 0
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2 text-sm">
|
||||
<span className="rounded-md bg-muted px-2 py-1">{preview.total} rows</span>
|
||||
<span className="rounded-md bg-emerald-500/10 px-2 py-1 text-emerald-600 dark:text-emerald-400">
|
||||
{preview.valid} valid
|
||||
</span>
|
||||
{hasErrors && (
|
||||
<span className="rounded-md bg-destructive/10 px-2 py-1 text-destructive">
|
||||
{preview.errors.length} with errors
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasErrors && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<AlertTriangle className="h-4 w-4 text-destructive" />
|
||||
These rows will be skipped:
|
||||
</div>
|
||||
<div className="max-h-56 overflow-y-auto rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<tbody className="divide-y">
|
||||
{preview.errors.map((e) => (
|
||||
<tr key={`${e.row}-${e.mac ?? ''}`} className="align-top">
|
||||
<td className="w-14 px-3 py-2 text-muted-foreground">#{e.row}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs">{e.mac || '—'}</td>
|
||||
<td className="px-3 py-2 text-destructive">{e.detail}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{nothingValid ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No valid rows to import. Fix the CSV and try again.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{hasErrors
|
||||
? `Add the ${preview.valid} valid row${preview.valid === 1 ? '' : 's'} anyway, or go back and fix the file.`
|
||||
: `All rows are valid. Import ${preview.valid} device${preview.valid === 1 ? '' : 's'}?`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onBack} disabled={busy}>
|
||||
{hasErrors ? 'Cancel & retry' : 'Back'}
|
||||
</Button>
|
||||
<Button onClick={onConfirm} disabled={busy || nothingValid}>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{hasErrors ? `Add anyway (${preview.valid})` : `Import ${preview.valid}`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -108,6 +108,28 @@ export interface DeviceEdit {
|
||||
alias?: string
|
||||
}
|
||||
|
||||
export interface DeviceImportRow {
|
||||
mac_address?: string
|
||||
group?: string
|
||||
name?: string
|
||||
phone?: string
|
||||
alias?: string
|
||||
}
|
||||
|
||||
export interface DeviceImportRowError {
|
||||
row: number
|
||||
mac: string | null
|
||||
detail: string
|
||||
}
|
||||
|
||||
export interface DeviceImportResult {
|
||||
total: number
|
||||
valid: number
|
||||
created: number
|
||||
dry_run: boolean
|
||||
errors: DeviceImportRowError[]
|
||||
}
|
||||
|
||||
export interface Vlan {
|
||||
alias: string
|
||||
vlanid: number
|
||||
@@ -135,6 +157,13 @@ export const api = {
|
||||
request<Device>('/device/edit', { method: 'POST', body: JSON.stringify(body) }),
|
||||
remove: (mac: string) =>
|
||||
request<void>(`/device/${encodeURIComponent(mac)}`, { method: 'DELETE' }),
|
||||
// Bulk import. dryRun=true validates only (nothing written) so the UI can
|
||||
// preview errors and confirm; dryRun=false inserts the valid rows.
|
||||
import: (devices: DeviceImportRow[], dryRun: boolean) =>
|
||||
request<DeviceImportResult>('/device/import', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ devices, dry_run: dryRun }),
|
||||
}),
|
||||
},
|
||||
|
||||
vlans: {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { toast } from 'sonner'
|
||||
import { api, ApiError, type Device, type DeviceStatus, type Vlan } from '@/lib/api'
|
||||
import { normalizePhone, phoneError } from '@/lib/phone'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DeviceImportExport } from '@/components/DeviceImportExport'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
@@ -76,6 +77,7 @@ export function Devices() {
|
||||
<Button variant="outline" size="icon" onClick={load} title="Refresh">
|
||||
<RefreshCw className={loading ? 'animate-spin' : ''} />
|
||||
</Button>
|
||||
<DeviceImportExport onImported={load} />
|
||||
<Button onClick={() => setAddOpen(true)}>
|
||||
<Plus /> Add device
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user