feat(CC-080): add Reputation Reports page (engagement score + commitment rate + limitations disclaimer)
This commit is contained in:
parent
4d7a3c60ec
commit
18ff0e691f
1 changed files with 201 additions and 0 deletions
201
src/app/dashboard/reports/reputation/page.tsx
Normal file
201
src/app/dashboard/reports/reputation/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue