feat(CC-068): add Data Sources registry page (CRUD, type breakdown, sync status)

This commit is contained in:
admin-valentin 2026-08-02 12:18:11 +00:00
parent f259585d57
commit e382372096

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