feat(CC-086): add Devices page (data-sources of device types with location/notes config)
This commit is contained in:
parent
21c5aa05ec
commit
ab9d20555b
1 changed files with 138 additions and 0 deletions
138
src/app/dashboard/devices/page.tsx
Normal file
138
src/app/dashboard/devices/page.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import { apiFetch } from '../../../lib/api';
|
||||
import { useSession } from '../../../components/session-provider';
|
||||
|
||||
interface DataSource { id: string; name: string; type: string; status: string; config: Record<string, unknown> | null; lastSyncAt: string | null; createdAt: string; }
|
||||
|
||||
const DEVICE_TYPES = ['device', 'iot', 'sensor', 'laptop', 'phone', 'tablet', 'server', 'raspberry', 'arduino'];
|
||||
const DEVICE_ICONS: Record<string, string> = {
|
||||
laptop: '💻', phone: '📱', tablet: '📟', server: '🖥️', raspberry: '🍓',
|
||||
arduino: '🔧', sensor: '📡', iot: '🌐', device: '⚙️',
|
||||
};
|
||||
|
||||
export default function DevicesPage() {
|
||||
const { activeTenant } = useSession();
|
||||
const tenantId = activeTenant?.tenantId ?? '';
|
||||
const qc = useQueryClient();
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [form, setForm] = useState({ name: '', type: 'device', location: '', notes: '' });
|
||||
|
||||
const { data: sources = [], isLoading } = useQuery({
|
||||
queryKey: ['devices', tenantId],
|
||||
queryFn: () => apiFetch<DataSource[]>('/v1/data-sources', { tenantId }),
|
||||
enabled: Boolean(tenantId), staleTime: 60_000,
|
||||
});
|
||||
|
||||
const devices = sources.filter((s) => DEVICE_TYPES.includes(s.type.toLowerCase()));
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: () => apiFetch('/v1/data-sources', { tenantId, method: 'POST', body: {
|
||||
name: form.name, type: form.type,
|
||||
config: { location: form.location || undefined, notes: form.notes || undefined },
|
||||
status: 'active',
|
||||
}}),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['devices', tenantId] }); setShowAdd(false); setForm({ name: '', type: 'device', location: '', notes: '' }); },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-6 p-6">
|
||||
<div className="flex items-start justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold text-ink">Dispozitive</h1>
|
||||
<p className="text-sm text-ink-faint mt-1">
|
||||
{isLoading ? 'Se încarcă…' : `${devices.length} dispozitive înregistrate`}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => setShowAdd(true)}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90">
|
||||
+ Adaugă dispozitiv
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card p-3 bg-primary/5 border-primary/20">
|
||||
<p className="text-[10px] text-ink-faint">
|
||||
Dispozitivele pot trimite date (telemetrie, senzori) către CEO OS via webhook sau API.
|
||||
Vezi <Link href="/dashboard/devices/telemetry" className="text-primary hover:underline">Telemetrie</Link> pentru date primite.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{showAdd && (
|
||||
<div className="card p-5 space-y-3">
|
||||
<p className="text-sm font-semibold text-ink">Dispozitiv nou</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<input placeholder="Nume (ex: MacBook Pro Work)" value={form.name}
|
||||
onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))}
|
||||
className="rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring sm:col-span-2" />
|
||||
<select value={form.type} onChange={(e) => setForm((p) => ({ ...p, type: e.target.value }))}
|
||||
className="rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring">
|
||||
<option value="laptop">Laptop</option>
|
||||
<option value="phone">Telefon</option>
|
||||
<option value="tablet">Tabletă</option>
|
||||
<option value="server">Server</option>
|
||||
<option value="raspberry">Raspberry Pi</option>
|
||||
<option value="arduino">Arduino / Microcontroller</option>
|
||||
<option value="sensor">Senzor IoT</option>
|
||||
<option value="device">Altul</option>
|
||||
</select>
|
||||
<input placeholder="Locație (ex: Birou / Acasă)" value={form.location}
|
||||
onChange={(e) => setForm((p) => ({ ...p, location: e.target.value }))}
|
||||
className="rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
<input placeholder="Note (sistem de operare, serial, etc.)" value={form.notes}
|
||||
onChange={(e) => setForm((p) => ({ ...p, notes: e.target.value }))}
|
||||
className="rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring sm:col-span-2" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => createMut.mutate()} disabled={!form.name || createMut.isPending}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white disabled:opacity-50">
|
||||
{createMut.isPending ? 'Se salvează…' : 'Salvează'}
|
||||
</button>
|
||||
<button onClick={() => setShowAdd(false)} className="rounded-lg border px-4 py-2 text-sm text-ink">Anulează</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center text-sm text-ink-faint py-8">Se încarcă…</div>
|
||||
) : devices.length === 0 ? (
|
||||
<div className="card p-8 text-center space-y-2">
|
||||
<p className="text-2xl">⚙️</p>
|
||||
<p className="text-sm text-ink-faint">Niciun dispozitiv înregistrat.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{devices.map((d) => {
|
||||
const cfg = (d.config ?? {}) as Record<string, string>;
|
||||
const icon = DEVICE_ICONS[d.type.toLowerCase()] ?? '⚙️';
|
||||
return (
|
||||
<div key={d.id} className="card p-4 space-y-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-2xl shrink-0">{icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold text-ink">{d.name}</p>
|
||||
<p className="text-[10px] text-ink-faint capitalize">{d.type}</p>
|
||||
</div>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[9px] font-bold shrink-0 ${d.status === 'active' ? 'bg-signal-ok/10 text-signal-ok' : 'bg-muted text-ink-faint'}`}>
|
||||
{d.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-ink-faint space-y-0.5">
|
||||
{cfg.location && <p>📍 {cfg.location}</p>}
|
||||
{cfg.notes && <p className="truncate">{cfg.notes}</p>}
|
||||
{d.lastSyncAt && <p>Ultima activitate: {new Date(d.lastSyncAt).toLocaleDateString('ro-RO')}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Link href="/dashboard/devices/telemetry" className="text-xs text-primary hover:underline">
|
||||
📡 Telemetrie dispozitive →
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue