feat(CC-083): add Trust Credentials page (certifications from observations with issuer grouping)
This commit is contained in:
parent
4288839b06
commit
b71cb4a88a
1 changed files with 175 additions and 0 deletions
175
src/app/dashboard/trust/credentials/page.tsx
Normal file
175
src/app/dashboard/trust/credentials/page.tsx
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
'use client';
|
||||
|
||||
import { useMemo, 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 Observation {
|
||||
id: string; metric: string; value: string; unit: string | null;
|
||||
subjectType: string; source: string | null; confidence: number | null;
|
||||
createdAt: string; observedAt: string | null;
|
||||
}
|
||||
|
||||
const CRED_METRICS = ['certificare', 'certification', 'certificate', 'diploma', 'curs', 'course', 'badge', 'acreditare'];
|
||||
const ISSUER_ICONS: Record<string, string> = {
|
||||
harvard: '🎓', columbia: '🎓', ibm: '🔵', google: '🔴', microsoft: '🟦',
|
||||
coursera: '📘', udemy: '📙', linkedin: '💼', aws: '☁️', nyif: '💰', default: '📜',
|
||||
};
|
||||
|
||||
function issuerIcon(source: string | null): string {
|
||||
if (!source) return ISSUER_ICONS.default;
|
||||
const s = source.toLowerCase();
|
||||
for (const [k, v] of Object.entries(ISSUER_ICONS)) {
|
||||
if (s.includes(k)) return v;
|
||||
}
|
||||
return ISSUER_ICONS.default;
|
||||
}
|
||||
|
||||
export default function TrustCredentialsPage() {
|
||||
const { activeTenant } = useSession();
|
||||
const tenantId = activeTenant?.tenantId ?? '';
|
||||
const qc = useQueryClient();
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [form, setForm] = useState({ metric: 'certificare', value: '', source: '', unit: '', confidence: '1' });
|
||||
|
||||
const { data: observations = [], isLoading } = useQuery({
|
||||
queryKey: ['trust-creds', tenantId],
|
||||
queryFn: () => apiFetch<Observation[]>('/v1/observations', { tenantId }),
|
||||
enabled: Boolean(tenantId), staleTime: 60_000,
|
||||
});
|
||||
|
||||
const credentials = useMemo(() =>
|
||||
observations
|
||||
.filter((o) => CRED_METRICS.some((k) => o.metric.toLowerCase().includes(k)))
|
||||
.sort((a, b) => new Date(b.observedAt ?? b.createdAt).getTime() - new Date(a.observedAt ?? a.createdAt).getTime()),
|
||||
[observations]);
|
||||
|
||||
const addMut = useMutation({
|
||||
mutationFn: () => apiFetch('/v1/observations', { tenantId, method: 'POST', body: {
|
||||
...form, confidence: parseFloat(form.confidence),
|
||||
subjectType: 'certification', observedAt: new Date().toISOString(),
|
||||
}}),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['trust-creds', tenantId] }); setShowAdd(false); setForm({ metric: 'certificare', value: '', source: '', unit: '', confidence: '1' }); },
|
||||
});
|
||||
|
||||
const byIssuer = useMemo(() => {
|
||||
const map: Record<string, Observation[]> = {};
|
||||
for (const c of credentials) {
|
||||
const key = c.source ?? 'Necunoscut';
|
||||
map[key] = [...(map[key] ?? []), c];
|
||||
}
|
||||
return Object.entries(map).sort((a, b) => b[1].length - a[1].length);
|
||||
}, [credentials]);
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl space-y-6 p-6">
|
||||
<div className="flex items-start justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<nav className="text-xs text-ink-faint mb-1">
|
||||
<Link href="/dashboard/trust" className="hover:underline">Trust Dashboard</Link> / Credențiale
|
||||
</nav>
|
||||
<h1 className="font-display text-2xl font-semibold text-ink">Credențiale & Certificări</h1>
|
||||
<p className="text-sm text-ink-faint mt-1">
|
||||
{isLoading ? 'Se încarcă…' : `${credentials.length} credențiale logate din ${byIssuer.length} emitenti`}
|
||||
</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ă credențial
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showAdd && (
|
||||
<div className="card p-5 space-y-3">
|
||||
<p className="text-sm font-semibold text-ink">Credențial nou</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<input placeholder="Titlu (ex: Machine Learning Certificate)" value={form.value}
|
||||
onChange={(e) => setForm((p) => ({ ...p, value: 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="Emitent (ex: Harvard, IBM, Google)" value={form.source}
|
||||
onChange={(e) => setForm((p) => ({ ...p, source: 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" />
|
||||
<select value={form.metric} onChange={(e) => setForm((p) => ({ ...p, metric: 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="certificare">Certificare</option>
|
||||
<option value="diploma">Diplomă</option>
|
||||
<option value="curs">Curs</option>
|
||||
<option value="badge">Badge / Insignă</option>
|
||||
</select>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] text-ink-faint">Confidență: {Math.round(parseFloat(form.confidence) * 100)}%</label>
|
||||
<input type="range" min="0.5" max="1" step="0.1" value={form.confidence}
|
||||
onChange={(e) => setForm((p) => ({ ...p, confidence: e.target.value }))}
|
||||
className="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => addMut.mutate()} disabled={!form.value || addMut.isPending}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white disabled:opacity-50">
|
||||
{addMut.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>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="card p-4 text-center">
|
||||
<p className="text-2xl font-bold text-ink">{credentials.length}</p>
|
||||
<p className="text-[10px] text-ink-faint">total credențiale</p>
|
||||
</div>
|
||||
<div className="card p-4 text-center">
|
||||
<p className="text-2xl font-bold text-ink">{byIssuer.length}</p>
|
||||
<p className="text-[10px] text-ink-faint">emitenți unici</p>
|
||||
</div>
|
||||
<div className="card p-4 text-center">
|
||||
<p className="text-2xl font-bold text-ink">
|
||||
{credentials.length > 0 ? Math.round(credentials.reduce((s, c) => s + (c.confidence ?? 1), 0) / credentials.length * 100) : 0}%
|
||||
</p>
|
||||
<p className="text-[10px] text-ink-faint">confidență medie</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center text-sm text-ink-faint py-8">Se încarcă…</div>
|
||||
) : credentials.length === 0 ? (
|
||||
<div className="card p-8 text-center space-y-2">
|
||||
<p className="text-2xl">🎓</p>
|
||||
<p className="text-sm text-ink-faint">Nicio credențială. Adaugă certificările tale.</p>
|
||||
<p className="text-xs text-ink-faint">Credențialele se pot adăuga și din <Link href="/dashboard/education" className="text-primary hover:underline">Education & Development</Link>.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{byIssuer.map(([issuer, creds]) => (
|
||||
<div key={issuer} className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">{issuerIcon(issuer)}</span>
|
||||
<p className="text-sm font-semibold text-ink">{issuer}</p>
|
||||
<span className="text-xs text-ink-faint">({creds.length})</span>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{creds.map((c) => (
|
||||
<div key={c.id} className="card p-3 space-y-1">
|
||||
<p className="text-xs font-medium text-ink">{c.value}</p>
|
||||
<div className="flex items-center justify-between text-[10px] text-ink-faint">
|
||||
<span className="capitalize">{c.metric}</span>
|
||||
<span>{new Date(c.observedAt ?? c.createdAt).toLocaleDateString('ro-RO', { month: 'short', year: 'numeric' })}</span>
|
||||
</div>
|
||||
{c.confidence !== null && (
|
||||
<div className="h-1 rounded-full bg-muted overflow-hidden">
|
||||
<div className="h-full bg-primary/50 rounded-full" style={{ width: `${c.confidence * 100}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue