feat(CC-086): add Trust Contributions page (OS/content/speaking/volunteer by category)
This commit is contained in:
parent
4ebb715025
commit
a958fbb924
1 changed files with 178 additions and 0 deletions
178
src/app/dashboard/trust/contributions/page.tsx
Normal file
178
src/app/dashboard/trust/contributions/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue