feat(CC-068): add Data Sources registry page (CRUD, type breakdown, sync status)
This commit is contained in:
parent
f259585d57
commit
e382372096
1 changed files with 187 additions and 0 deletions
187
src/app/dashboard/data/sources/page.tsx
Normal file
187
src/app/dashboard/data/sources/page.tsx
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiFetch } from '../../../../lib/api';
|
||||
import { useSession } from '../../../../components/session-provider';
|
||||
|
||||
interface DataSource {
|
||||
id: string; name: string; sourceType: string; description: string | null;
|
||||
connectionInfo: string | null; status: string;
|
||||
lastSyncAt: string | null; recordCount: number | null; createdAt: string;
|
||||
}
|
||||
|
||||
const TYPE_META: Record<string, { label: string; icon: string }> = {
|
||||
manual: { label: 'Manual', icon: '✋' },
|
||||
api: { label: 'API', icon: '🔌' },
|
||||
file: { label: 'Fișier', icon: '📄' },
|
||||
database: { label: 'Bază de date', icon: '🗄️' },
|
||||
integration: { label: 'Integrare', icon: '🔗' },
|
||||
clickhouse: { label: 'ClickHouse', icon: '🏢' },
|
||||
sheet: { label: 'Spreadsheet', icon: '📊' },
|
||||
};
|
||||
|
||||
const STATUS_META: Record<string, { label: string; cls: string }> = {
|
||||
active: { label: 'Activ', cls: 'text-signal-ok bg-signal-ok/10' },
|
||||
paused: { label: 'Pauză', cls: 'text-signal-warn bg-signal-warn/10' },
|
||||
error: { label: 'Eroare', cls: 'text-signal-danger bg-signal-danger/10' },
|
||||
archived: { label: 'Arhivat', cls: 'text-ink-faint bg-muted' },
|
||||
};
|
||||
|
||||
const SOURCE_TYPES = ['manual','api','file','database','integration','clickhouse','sheet'];
|
||||
|
||||
export default function DataSourcesPage() {
|
||||
const { activeTenant } = useSession();
|
||||
const tenantId = activeTenant?.tenantId ?? '';
|
||||
const qc = useQueryClient();
|
||||
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [form, setForm] = useState({ name: '', sourceType: 'manual', description: '', connectionInfo: '' });
|
||||
|
||||
const { data: sources = [], isLoading } = useQuery({
|
||||
queryKey: ['data-sources', tenantId],
|
||||
queryFn: () => apiFetch<DataSource[]>('/v1/data-sources', { tenantId }),
|
||||
enabled: Boolean(tenantId), staleTime: 60_000,
|
||||
});
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (body: Record<string, string>) =>
|
||||
apiFetch<DataSource>('/v1/data-sources', { tenantId, method: 'POST', body }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['data-sources', tenantId] });
|
||||
setShowCreate(false);
|
||||
setForm({ name: '', sourceType: 'manual', description: '', connectionInfo: '' });
|
||||
},
|
||||
});
|
||||
|
||||
const patchMut = useMutation({
|
||||
mutationFn: ({ id, ...body }: { id: string; status: string }) =>
|
||||
apiFetch(`/v1/data-sources/${id}`, { tenantId, method: 'PATCH', body }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['data-sources', tenantId] }),
|
||||
});
|
||||
|
||||
const totalRecords = sources.reduce((s, ds) => s + (ds.recordCount ?? 0), 0);
|
||||
const active = sources.filter((ds) => ds.status === 'active').length;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl space-y-6 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold text-ink">Surse de Date</h1>
|
||||
<p className="text-sm text-ink-faint mt-1">
|
||||
{isLoading ? 'Se încarcă…' : `${sources.length} surse · ${active} active · ${totalRecords.toLocaleString('ro-RO')} înregistrări totale`}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => setShowCreate(!showCreate)} className="btn btn-primary px-4 py-2 text-sm rounded-lg">
|
||||
+ Sursă nouă
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Type breakdown */}
|
||||
{sources.length > 0 && (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{SOURCE_TYPES.map((t) => {
|
||||
const count = sources.filter((s) => s.sourceType === t).length;
|
||||
if (!count) return null;
|
||||
const m = TYPE_META[t] ?? { label: t, icon: '❓' };
|
||||
return (
|
||||
<span key={t} className="rounded-full border bg-card px-3 py-1 text-xs text-ink-faint">
|
||||
{m.icon} {m.label} ({count})
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create form */}
|
||||
{showCreate && (
|
||||
<div className="card p-5 space-y-4">
|
||||
<h2 className="text-sm font-semibold text-ink">Sursă nouă</h2>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<input placeholder="Nume sursă *" value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
className="w-full rounded-lg border bg-card px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
</div>
|
||||
<select value={form.sourceType} onChange={(e) => setForm({ ...form, sourceType: e.target.value })}
|
||||
className="rounded-lg border bg-card px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring">
|
||||
{SOURCE_TYPES.map((t) => <option key={t} value={t}>{TYPE_META[t]?.icon} {TYPE_META[t]?.label ?? t}</option>)}
|
||||
</select>
|
||||
<input placeholder="Descriere scurtă" value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
className="rounded-lg border bg-card px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
<div className="sm:col-span-2">
|
||||
<input placeholder="Info conexiune (endpoint, cale fișier, etc.) — fără parole" value={form.connectionInfo}
|
||||
onChange={(e) => setForm({ ...form, connectionInfo: e.target.value })}
|
||||
className="w-full rounded-lg border bg-card px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => createMut.mutate({ name: form.name, sourceType: form.sourceType, description: form.description, connectionInfo: form.connectionInfo })}
|
||||
disabled={!form.name || createMut.isPending}
|
||||
className="btn btn-primary px-4 py-1.5 text-sm rounded-lg disabled:opacity-50">
|
||||
{createMut.isPending ? 'Se creează…' : 'Adaugă sursă'}
|
||||
</button>
|
||||
<button onClick={() => setShowCreate(false)} className="text-sm text-ink-faint hover:text-ink px-2">Anulează</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sources list */}
|
||||
{!isLoading && sources.length === 0 ? (
|
||||
<div className="card p-12 text-center space-y-2">
|
||||
<p className="text-3xl">🗄️</p>
|
||||
<p className="text-sm text-ink-faint">Nicio sursă de date înregistrată.</p>
|
||||
<p className="text-xs text-ink-faint/70">Adaugă sursele de date pe care le utilizezi: baze de date, API-uri, fișiere, etc.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card divide-y divide-border/50">
|
||||
{sources.map((ds) => {
|
||||
const tm = TYPE_META[ds.sourceType] ?? { icon: '❓', label: ds.sourceType };
|
||||
const sm = STATUS_META[ds.status] ?? STATUS_META.active;
|
||||
return (
|
||||
<div key={ds.id} className="flex items-start gap-4 p-4">
|
||||
<span className="text-2xl shrink-0 mt-0.5">{tm.icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-ink">{ds.name}</span>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${sm.cls}`}>{sm.label}</span>
|
||||
<span className="text-[10px] text-ink-faint">{tm.label}</span>
|
||||
</div>
|
||||
{ds.description && <p className="text-xs text-ink-faint mt-0.5 truncate">{ds.description}</p>}
|
||||
{ds.connectionInfo && (
|
||||
<p className="text-[10px] text-ink-faint/60 mt-0.5 font-mono truncate">{ds.connectionInfo}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
{ds.recordCount != null && (
|
||||
<span className="text-[10px] text-ink-faint">{ds.recordCount.toLocaleString('ro-RO')} înreg.</span>
|
||||
)}
|
||||
{ds.lastSyncAt && (
|
||||
<span className="text-[10px] text-ink-faint">
|
||||
Sync: {new Date(ds.lastSyncAt).toLocaleDateString('ro-RO')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
{ds.status === 'active' && (
|
||||
<button onClick={() => patchMut.mutate({ id: ds.id, status: 'paused' })}
|
||||
className="rounded border px-2 py-1 text-[10px] text-ink-faint hover:bg-muted transition-colors">
|
||||
Pauză
|
||||
</button>
|
||||
)}
|
||||
{ds.status === 'paused' && (
|
||||
<button onClick={() => patchMut.mutate({ id: ds.id, status: 'active' })}
|
||||
className="rounded border px-2 py-1 text-[10px] text-signal-ok hover:bg-signal-ok/5 transition-colors">
|
||||
Activează
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue