feat(CC-086): add Trust Contributions page (OS/content/speaking/volunteer by category)

This commit is contained in:
admin-valentin 2026-08-02 17:38:03 +00:00
parent 4ebb715025
commit a958fbb924

View file

@ -0,0 +1,178 @@
'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; subjectType: string; source: string | null; confidence: number | null; createdAt: string; observedAt: string | null; }
const CONTRIB_METRICS = [
'open-source', 'opensource', 'github', 'contributie', 'contribution',
'publicatie', 'publication', 'articol', 'article', 'talk', 'prezentare',
'voluntariat', 'volunteer', 'mentoring', 'mentorship', 'community',
];
const CONTRIB_CATEGORIES = [
{ id: 'opensource', label: 'Open Source', icon: '💻', keywords: ['github', 'open-source', 'opensource', 'contributie', 'contribution'] },
{ id: 'content', label: 'Conținut & Publicații', icon: '✍️', keywords: ['publicatie', 'publication', 'articol', 'article', 'blog', 'newsletter'] },
{ id: 'speaking', label: 'Speaking & Prezentări', icon: '🎤', keywords: ['talk', 'prezentare', 'conferinta', 'workshop', 'webinar'] },
{ id: 'mentoring', label: 'Mentoring', icon: '🧭', keywords: ['mentoring', 'mentorship', 'coaching'] },
{ id: 'volunteer', label: 'Voluntariat', icon: '🤲', keywords: ['voluntariat', 'volunteer', 'pro-bono'] },
];
export default function TrustContributionsPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const qc = useQueryClient();
const [showAdd, setShowAdd] = useState(false);
const [form, setForm] = useState({ metric: 'contributie', value: '', source: '', confidence: '1' });
const { data: observations = [], isLoading } = useQuery({
queryKey: ['tc-obs', tenantId],
queryFn: () => apiFetch<Observation[]>('/v1/observations', { tenantId }),
enabled: Boolean(tenantId), staleTime: 60_000,
});
const contributions = useMemo(() =>
observations
.filter((o) => CONTRIB_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 byCategory = useMemo(() =>
CONTRIB_CATEGORIES.map((cat) => ({
...cat,
items: contributions.filter((c) =>
cat.keywords.some((k) => c.metric.toLowerCase().includes(k))
),
})).filter((cat) => cat.items.length > 0),
[contributions]);
const uncategorized = useMemo(() =>
contributions.filter((c) =>
!CONTRIB_CATEGORIES.some((cat) =>
cat.keywords.some((k) => c.metric.toLowerCase().includes(k))
)
),
[contributions]);
const addMut = useMutation({
mutationFn: () => apiFetch('/v1/observations', { tenantId, method: 'POST', body: {
...form, confidence: parseFloat(form.confidence),
subjectType: 'contribution', observedAt: new Date().toISOString(),
}}),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['tc-obs', tenantId] }); setShowAdd(false); setForm({ metric: 'contributie', value: '', source: '', confidence: '1' }); },
});
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> / Contribuții
</nav>
<h1 className="font-display text-2xl font-semibold text-ink">Contribuții & Impact</h1>
<p className="text-sm text-ink-faint mt-1">
{isLoading ? 'Se încarcă…' : `${contributions.length} contribuții documentate`}
</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ă contribuție
</button>
</div>
{showAdd && (
<div className="card p-5 space-y-3">
<p className="text-sm font-semibold text-ink">Contribuție nouă</p>
<div className="grid gap-3 sm:grid-cols-2">
<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">
{CONTRIB_CATEGORIES.map((c) => <option key={c.id} value={c.keywords[0]}>{c.label}</option>)}
</select>
<input placeholder="Sursă / URL / Link" 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 font-mono text-xs focus:outline-none focus:ring-2 focus:ring-ring" />
<input placeholder="Descriere (ex: PR merged în TensorFlow)" 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 sm:col-span-2" />
</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>
)}
{/* Summary chips */}
<div className="flex flex-wrap gap-2">
{CONTRIB_CATEGORIES.map((cat) => {
const count = byCategory.find((b) => b.id === cat.id)?.items.length ?? 0;
return (
<div key={cat.id} className={`flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium ${count > 0 ? 'bg-primary/10 text-primary' : 'bg-muted text-ink-faint'}`}>
<span>{cat.icon}</span>
<span>{cat.label}</span>
<span className="font-bold">({count})</span>
</div>
);
})}
</div>
{isLoading ? (
<div className="text-center text-sm text-ink-faint py-8">Se încarcă</div>
) : contributions.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 contribuție documentată. Adaugă articole, PR-uri, talk-uri, voluntariat.</p>
</div>
) : (
<div className="space-y-5">
{byCategory.map((cat) => (
<div key={cat.id} className="space-y-2">
<p className="text-sm font-semibold text-ink flex items-center gap-2">
<span>{cat.icon}</span>{cat.label} <span className="text-ink-faint font-normal">({cat.items.length})</span>
</p>
<div className="card divide-y divide-border/50">
{cat.items.map((c) => (
<div key={c.id} className="p-3 flex items-start gap-3">
<div className="flex-1 min-w-0">
<p className="text-sm text-ink">{c.value}</p>
{c.source && (
<a href={c.source.startsWith('http') ? c.source : undefined}
target="_blank" rel="noopener noreferrer"
className="text-[10px] text-primary hover:underline font-mono truncate block">
{c.source}
</a>
)}
</div>
<p className="text-[10px] text-ink-faint shrink-0">
{new Date(c.observedAt ?? c.createdAt).toLocaleDateString('ro-RO', { month: 'short', year: 'numeric' })}
</p>
</div>
))}
</div>
</div>
))}
{uncategorized.length > 0 && (
<div className="space-y-2">
<p className="text-sm font-semibold text-ink">Alte contribuții ({uncategorized.length})</p>
<div className="card divide-y divide-border/50">
{uncategorized.map((c) => (
<div key={c.id} className="p-3">
<p className="text-sm text-ink">{c.value}</p>
<p className="text-[10px] text-ink-faint capitalize">{c.metric}</p>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
);
}