feat(CC-059): add Business Reports page (orgs, tasks, goals, decisions, opportunities)
This commit is contained in:
parent
35a71859f3
commit
8a0e7a220c
1 changed files with 228 additions and 0 deletions
228
src/app/dashboard/reports/business/page.tsx
Normal file
228
src/app/dashboard/reports/business/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue