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