feat(CC-059): add Business Reports page (orgs, tasks, goals, decisions, opportunities)

This commit is contained in:
admin-valentin 2026-08-01 21:07:47 +00:00
parent 35a71859f3
commit 8a0e7a220c

View file

@ -0,0 +1,228 @@
'use client';
import Link from 'next/link';
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '../../../../lib/api';
import type { Goal, Decision, Task } from '../../../../lib/api';
import { useSession } from '../../../../components/session-provider';
const B = '/dashboard';
interface Organization { id: string; name: string; country?: string | null; createdAt: string; }
interface Opportunity { id: string; source: string; probability?: string | null; expiresAt?: string | null; }
function StatCard({ label, value, sub, href, color = 'text-ink' }: {
label: string; value: number | string; sub?: string; href?: string; color?: string;
}) {
const inner = (
<div className="card p-5 space-y-1 hover:border-primary/30 transition-colors">
<p className="text-xs font-semibold uppercase tracking-wider text-ink-faint">{label}</p>
<p className={`font-display text-3xl font-bold ${color}`}>{value}</p>
{sub && <p className="text-xs text-ink-faint">{sub}</p>}
</div>
);
return href ? <Link href={href}>{inner}</Link> : inner;
}
function ProgressBar({ value, total, color = 'bg-primary' }: { value: number; total: number; color?: string }) {
const pct = total > 0 ? Math.round((value / total) * 100) : 0;
return (
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
<div className={`h-full ${color} rounded-full`} style={{ width: `${pct}%` }} />
</div>
<span className="text-xs text-ink-faint w-8 text-right">{pct}%</span>
</div>
);
}
export default function BusinessReportsPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const opts = { enabled: Boolean(tenantId), staleTime: 120_000 };
const { data: orgs = [] } = useQuery({
queryKey: ['rep-orgs', tenantId],
queryFn: () => apiFetch<Organization[]>('/v1/organizations', { tenantId }),
...opts,
});
const { data: tasks = [] } = useQuery({
queryKey: ['rep-tasks', tenantId],
queryFn: () => apiFetch<Task[]>('/v1/tasks', { tenantId }),
...opts,
});
const { data: goals = [] } = useQuery({
queryKey: ['rep-goals', tenantId],
queryFn: () => apiFetch<Goal[]>('/v1/goals', { tenantId }),
...opts,
});
const { data: decisions = [] } = useQuery({
queryKey: ['rep-decisions', tenantId],
queryFn: () => apiFetch<Decision[]>('/v1/decisions', { tenantId }),
...opts,
});
const { data: opps = [] } = useQuery({
queryKey: ['rep-opps', tenantId],
queryFn: () => apiFetch<Opportunity[]>('/v1/opportunities', { tenantId }),
...opts,
});
// Task stats
const taskDone = tasks.filter((t) => t.status === 'done').length;
const taskOpen = tasks.filter((t) => t.status === 'open').length;
const taskInProg = tasks.filter((t) => t.status === 'in_progress').length;
const taskBlocked = tasks.filter((t) => t.status === 'blocked').length;
const taskRate = tasks.length > 0 ? Math.round((taskDone / tasks.length) * 100) : 0;
// Goal stats
const goalAchieved = goals.filter((g) => g.status === 'achieved').length;
const goalOnTrack = goals.filter((g) => g.status === 'on_track').length;
const goalAtRisk = goals.filter((g) => g.status === 'at_risk').length;
const goalNotStart = goals.filter((g) => g.status === 'not_started').length;
// Decision stats
const decDecided = decisions.filter((d) => d.selectedOption).length;
const decPending = decisions.filter((d) => !d.selectedOption).length;
const decWithReview = decisions.filter((d) => d.outcomeReview).length;
// Opportunity stats
const now = new Date();
const oppsActive = opps.filter((o) => !o.expiresAt || new Date(o.expiresAt) > now).length;
const oppsExpiring = opps.filter((o) => {
if (!o.expiresAt) return false;
const exp = new Date(o.expiresAt);
const in7 = new Date(now.getTime() + 7 * 86400_000);
return exp > now && exp <= in7;
}).length;
// Recent orgs (last 30 days)
const in30 = new Date(now.getTime() - 30 * 86400_000);
const recentOrgs = orgs.filter((o) => new Date(o.createdAt) > in30).length;
return (
<div className="max-w-5xl space-y-8 p-6">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Raport Business</h1>
<p className="text-sm text-ink-faint mt-1">
Metrici cheie organizații, taskuri, obiective, decizii, oportunități
</p>
</div>
{/* KPI grid */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<StatCard label="Organizații" value={orgs.length} sub={`+${recentOrgs} în 30 zile`} href={`${B}/organizations`} />
<StatCard label="Taskuri active" value={taskOpen + taskInProg} sub={`${taskDone} finalizate`} href={`${B}/tasks`} />
<StatCard label="Obiective" value={goals.length} sub={`${goalAtRisk} la risc`} href={`${B}/goals`}
color={goalAtRisk > 0 ? 'text-signal-warn' : 'text-ink'} />
<StatCard label="Oportunități" value={oppsActive} sub={oppsExpiring > 0 ? `${oppsExpiring} expiră curând` : 'toate active'}
href={`${B}/opportunities`} color={oppsExpiring > 0 ? 'text-signal-warn' : 'text-ink'} />
</div>
{/* Tasks breakdown */}
<div className="card p-6 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-ink">Taskuri</h2>
<Link href={`${B}/tasks`} className="text-xs text-bronze-deep hover:underline">Deschide </Link>
</div>
<div className="space-y-3">
{[
{ label: 'Finalizate', count: taskDone, color: 'bg-signal-ok' },
{ label: 'În progres', count: taskInProg, color: 'bg-primary' },
{ label: 'Deschise', count: taskOpen, color: 'bg-amber-400' },
{ label: 'Blocate', count: taskBlocked, color: 'bg-signal-danger' },
].map(({ label, count, color }) => (
<div key={label} className="space-y-1">
<div className="flex justify-between text-xs">
<span className="text-ink-faint">{label}</span>
<span className="font-medium text-ink">{count}</span>
</div>
<ProgressBar value={count} total={tasks.length} color={color} />
</div>
))}
</div>
<div className="pt-2 border-t border-border/50 flex items-center justify-between">
<span className="text-xs text-ink-faint">Rată completare</span>
<span className="text-sm font-semibold text-ink">{taskRate}%</span>
</div>
</div>
{/* Goals + Decisions in 2 cols */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{/* Goals breakdown */}
<div className="card p-6 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-ink">Obiective</h2>
<Link href={`${B}/goals`} className="text-xs text-bronze-deep hover:underline">Deschide </Link>
</div>
{goals.length === 0 ? (
<p className="text-xs text-ink-faint">Niciun obiectiv înregistrat</p>
) : (
<div className="space-y-2">
{[
{ label: 'Realizate', count: goalAchieved, cls: 'text-signal-ok' },
{ label: 'Pe plan', count: goalOnTrack, cls: 'text-primary' },
{ label: 'Neîncepute', count: goalNotStart, cls: 'text-ink-faint' },
{ label: 'La risc', count: goalAtRisk, cls: 'text-signal-warn' },
].map(({ label, count, cls }) => (
<div key={label} className="flex justify-between text-xs">
<span className="text-ink-faint">{label}</span>
<span className={`font-semibold ${cls}`}>{count}</span>
</div>
))}
</div>
)}
</div>
{/* Decisions breakdown */}
<div className="card p-6 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-ink">Decizii</h2>
<Link href={`${B}/decisions`} className="text-xs text-bronze-deep hover:underline">Deschide </Link>
</div>
{decisions.length === 0 ? (
<p className="text-xs text-ink-faint">Nicio decizie înregistrată</p>
) : (
<div className="space-y-2">
{[
{ label: 'Total decizii', count: decisions.length, cls: 'text-ink' },
{ label: 'Pronunțate', count: decDecided, cls: 'text-signal-ok' },
{ label: 'În analiză', count: decPending, cls: 'text-signal-warn' },
{ label: 'Cu review', count: decWithReview, cls: 'text-primary' },
].map(({ label, count, cls }) => (
<div key={label} className="flex justify-between text-xs">
<span className="text-ink-faint">{label}</span>
<span className={`font-semibold ${cls}`}>{count}</span>
</div>
))}
</div>
)}
</div>
</div>
{/* Recent organizations */}
{orgs.length > 0 && (
<div className="card p-6 space-y-3">
<h2 className="text-sm font-semibold text-ink">Organizații recente</h2>
<div className="divide-y divide-border/50">
{orgs.slice(0, 8).map((org) => (
<div key={org.id} className="flex items-center justify-between py-2">
<div>
<p className="text-sm text-ink">{org.name}</p>
{org.country && <p className="text-xs text-ink-faint">{org.country}</p>}
</div>
<p className="text-[10px] text-ink-faint">
{new Date(org.createdAt).toLocaleDateString('ro-RO', { day: 'numeric', month: 'short', year: 'numeric' })}
</p>
</div>
))}
</div>
{orgs.length > 8 && (
<Link href={`${B}/organizations`} className="block text-xs text-center text-bronze-deep hover:underline">
Vezi toate {orgs.length} organizații
</Link>
)}
</div>
)}
</div>
);
}