feat(CC-080): add Reputation Reports page (engagement score + commitment rate + limitations disclaimer)

This commit is contained in:
admin-valentin 2026-08-02 13:02:12 +00:00
parent 4d7a3c60ec
commit 18ff0e691f

View file

@ -0,0 +1,201 @@
'use client';
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '../../../lib/api';
import { useSession } from '../../../components/session-provider';
interface Observation { id: string; metric: string; value: string; unit: string | null; subjectType: string; confidence: number | null; createdAt: string; observedAt: string | null; source: string | null; }
interface Goal { id: string; title: string; status: string; progress: number | null; }
interface Decision { id: string; title: string; status: string; impact: string | null; outcome: string | null; createdAt: string; }
interface Task { id: string; status: string; priority: string | null; tags: string[]; }
const EDU_TYPES = ['course', 'certification', 'skill', 'education', 'certificate'];
export default function ReputationReportsPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const [period, setPeriod] = useState(90);
const { data: observations = [] } = useQuery({ queryKey: ['rep-obs', tenantId], queryFn: () => apiFetch<Observation[]>('/v1/observations', { tenantId }), enabled: Boolean(tenantId), staleTime: 120_000 });
const { data: goals = [] } = useQuery({ queryKey: ['rep-goals', tenantId], queryFn: () => apiFetch<Goal[]>('/v1/goals?limit=200', { tenantId }), enabled: Boolean(tenantId), staleTime: 120_000 });
const { data: decisions = [] } = useQuery({ queryKey: ['rep-decisions', tenantId], queryFn: () => apiFetch<Decision[]>('/v1/decisions?limit=200', { tenantId }), enabled: Boolean(tenantId), staleTime: 120_000 });
const { data: tasks = [] } = useQuery({ queryKey: ['rep-tasks', tenantId], queryFn: () => apiFetch<Task[]>('/v1/tasks?limit=500', { tenantId }), enabled: Boolean(tenantId), staleTime: 120_000 });
const cutoff = useMemo(() => new Date(Date.now() - period * 86400_000), [period]);
const metrics = useMemo(() => {
const recentObs = observations.filter((o) => new Date(o.observedAt ?? o.createdAt) >= cutoff);
const eduObs = recentObs.filter((o) => EDU_TYPES.some((e) => o.metric.toLowerCase().includes(e) || o.subjectType.toLowerCase().includes(e)));
const certs = recentObs.filter((o) => o.metric.toLowerCase().includes('certificar') || o.metric.toLowerCase().includes('certificate'));
const recentGoals = goals.filter((g) => g.status === 'completed');
const recentDecisions = decisions.filter((d) => new Date(d.createdAt) >= cutoff);
const recentTasks = tasks.filter((t) => t.status === 'completed' && !t.tags.includes('routine'));
const commitmentTasks = tasks.filter((t) => t.tags.some((tag) => ['commitment', 'promise'].includes(tag)));
const keptCommitments = commitmentTasks.filter((t) => t.status === 'completed');
const engagementScore = Math.min(100, Math.round(
(recentObs.length * 2 + recentDecisions.length * 5 + recentTasks.length * 1) / (period / 30)
));
return { recentObs, eduObs, certs, recentGoals, recentDecisions, recentTasks, commitmentTasks, keptCommitments, engagementScore };
}, [observations, goals, decisions, tasks, cutoff]);
function exportReport() {
const lines = [
`RAPORT REPUTAȚIE CEO OS — ${new Date().toLocaleDateString('ro-RO')}`,
`Perioadă: ultimele ${period} zile`,
'',
'## EDUCAȚIE & COMPETENȚE',
`Observații educație: ${metrics.eduObs.length}`,
`Certificări noi: ${metrics.certs.length}`,
'',
'## OBIECTIVE',
`Obiective finalizate: ${metrics.recentGoals.length}`,
`Progres mediu obiective active: ${goals.filter(g=>g.status==='active').length > 0 ? Math.round(goals.filter(g=>g.status==='active').reduce((s,g)=>s+(g.progress??0),0)/goals.filter(g=>g.status==='active').length) : 0}%`,
'',
'## ANGAJAMENTE',
`Angajamente asumate: ${metrics.commitmentTasks.length}`,
`Angajamente onorate: ${metrics.keptCommitments.length}`,
`Rată onorare: ${metrics.commitmentTasks.length > 0 ? Math.round((metrics.keptCommitments.length/metrics.commitmentTasks.length)*100) : 100}%`,
'',
'## DECIZII',
`Decizii logate: ${metrics.recentDecisions.length}`,
'',
'## PRODUCTIVITATE',
`Taskuri completate: ${metrics.recentTasks.length}`,
`Scor angajament: ${metrics.engagementScore}/100`,
'',
'---',
'Generat din CEO OS (boardmind.dev)',
'LIMITĂRI: Acest raport se bazează exclusiv pe date auto-raportate.',
'Sursele sunt indicate per observație. Nu reprezintă un scor reputațional oficial.',
];
const blob = new Blob([lines.join('\n')], { type: 'text/plain;charset=utf-8' });
const el = document.createElement('a');
el.href = URL.createObjectURL(blob);
el.download = `reputatie-${new Date().toISOString().slice(0,10)}.txt`;
el.click();
}
return (
<div className="max-w-4xl space-y-6 p-6">
<div className="flex items-start justify-between flex-wrap gap-3">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Rapoarte Reputație</h1>
<p className="text-sm text-ink-faint mt-1">Sumar dovezi date auto-raportate, cu surse explicite.</p>
</div>
<div className="flex gap-2">
{[30, 90, 180].map((p) => (
<button key={p} onClick={() => setPeriod(p)}
className={`rounded border px-3 py-1 text-xs ${period === p ? 'bg-primary/10 border-primary text-ink' : 'bg-card border-border text-ink-faint'}`}>
{p}z
</button>
))}
<button onClick={exportReport} className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90">
Export TXT
</button>
</div>
</div>
<div className="card p-4 bg-warn/5 border-warn/30">
<p className="text-xs font-semibold text-warn mb-1"> Limitări obligatorii</p>
<div className="text-[10px] text-ink-faint space-y-0.5">
<p> Toate datele sunt auto-raportate. Nu există verificare externă independentă.</p>
<p> Scorul de reputație afișat NU este un scor social universal sau de credit.</p>
<p> Nu pot fi formulate acuzații neverificate pe baza acestor date.</p>
<p> Partajarea raportului este controlată exclusiv de tine.</p>
</div>
</div>
{/* Engagement score */}
<div className="card p-6 flex items-center gap-6">
<div className="text-center">
<p className="font-display text-5xl font-bold text-primary">{metrics.engagementScore}</p>
<p className="text-xs text-ink-faint mt-1">Scor angajament</p>
</div>
<div className="flex-1 space-y-2">
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div className="h-full bg-primary rounded-full" style={{ width: `${metrics.engagementScore}%` }} />
</div>
<p className="text-xs text-ink-faint">Bazat pe observații ({metrics.recentObs.length}), decizii ({metrics.recentDecisions.length}) și taskuri ({metrics.recentTasks.length}) în ultimele {period} zile.</p>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="card p-5 space-y-3">
<p className="text-xs font-semibold uppercase tracking-wider text-ink-faint">Educație & Competențe</p>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-ink-faint">Observații educație</span>
<span className="font-semibold text-ink">{metrics.eduObs.length}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-ink-faint">Certificări</span>
<span className="font-semibold text-ink">{metrics.certs.length}</span>
</div>
</div>
</div>
<div className="card p-5 space-y-3">
<p className="text-xs font-semibold uppercase tracking-wider text-ink-faint">Angajamente</p>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-ink-faint">Asumate</span>
<span className="font-semibold text-ink">{metrics.commitmentTasks.length}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-ink-faint">Onorate</span>
<span className="font-semibold text-signal-ok">{metrics.keptCommitments.length}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-ink-faint">Rată onorare</span>
<span className="font-semibold text-ink">
{metrics.commitmentTasks.length > 0
? `${Math.round((metrics.keptCommitments.length / metrics.commitmentTasks.length) * 100)}%`
: '—'}
</span>
</div>
</div>
</div>
<div className="card p-5 space-y-3">
<p className="text-xs font-semibold uppercase tracking-wider text-ink-faint">Obiective</p>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-ink-faint">Finalizate</span>
<span className="font-semibold text-ink">{metrics.recentGoals.length}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-ink-faint">Progres mediu activ</span>
<span className="font-semibold text-ink">
{goals.filter((g) => g.status === 'active').length > 0
? `${Math.round(goals.filter((g) => g.status === 'active').reduce((s, g) => s + (g.progress ?? 0), 0) / goals.filter((g) => g.status === 'active').length)}%`
: '—'}
</span>
</div>
</div>
</div>
<div className="card p-5 space-y-3">
<p className="text-xs font-semibold uppercase tracking-wider text-ink-faint">Decizii & Activitate</p>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-ink-faint">Decizii logate</span>
<span className="font-semibold text-ink">{metrics.recentDecisions.length}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-ink-faint">Taskuri completate</span>
<span className="font-semibold text-ink">{metrics.recentTasks.length}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-ink-faint">Observații total</span>
<span className="font-semibold text-ink">{metrics.recentObs.length}</span>
</div>
</div>
</div>
</div>
</div>
);
}