feat(cc-049): Business Overview — aggregate view across all modules
This commit is contained in:
parent
e5604f2696
commit
2c3408e968
1 changed files with 234 additions and 0 deletions
234
src/app/dashboard/business/page.tsx
Normal file
234
src/app/dashboard/business/page.tsx
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiFetch, type Goal, type Transaction, type Decision } from '../../../lib/api';
|
||||
import { useSession } from '../../../components/session-provider';
|
||||
|
||||
interface ExecDash {
|
||||
status: 'ready' | 'building';
|
||||
counters: {
|
||||
openTasks: number;
|
||||
overdueTasks: number;
|
||||
deadlinesNext7Days: number;
|
||||
organizations: number;
|
||||
segments: number;
|
||||
researchBriefs: number;
|
||||
} | null;
|
||||
generatedAt: string | null;
|
||||
}
|
||||
|
||||
export default function BusinessOverviewPage() {
|
||||
const { activeTenant } = useSession();
|
||||
const tenantId = activeTenant?.tenantId ?? '';
|
||||
|
||||
const { data: exec } = useQuery({
|
||||
queryKey: ['exec-dash-biz', tenantId],
|
||||
queryFn: () => apiFetch<ExecDash>('/v1/dashboard/executive', { tenantId }),
|
||||
enabled: Boolean(tenantId),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const { data: goals = [] } = useQuery({
|
||||
queryKey: ['goals-biz', tenantId],
|
||||
queryFn: () => apiFetch<Goal[]>('/v1/goals', { tenantId }),
|
||||
enabled: Boolean(tenantId),
|
||||
});
|
||||
|
||||
const { data: transactions = [] } = useQuery({
|
||||
queryKey: ['transactions-biz', tenantId],
|
||||
queryFn: () => apiFetch<Transaction[]>('/v1/transactions', { tenantId }),
|
||||
enabled: Boolean(tenantId),
|
||||
});
|
||||
|
||||
const { data: decisions = [] } = useQuery({
|
||||
queryKey: ['decisions-biz', tenantId],
|
||||
queryFn: () => apiFetch<Decision[]>('/v1/decisions', { tenantId }),
|
||||
enabled: Boolean(tenantId),
|
||||
});
|
||||
|
||||
const c = exec?.counters;
|
||||
const goalsAtRisk = goals.filter((g) => g.status === 'at_risk').length;
|
||||
const txMissing = transactions.filter((t) => t.evidenceStatus === 'missing').length;
|
||||
const decisionsPending = decisions.filter((d) => !d.selectedOption).length;
|
||||
|
||||
const modules = [
|
||||
{
|
||||
label: 'Organizații',
|
||||
value: c?.organizations ?? '—',
|
||||
href: '/dashboard/organizations',
|
||||
badge: null,
|
||||
badgeColor: '',
|
||||
},
|
||||
{
|
||||
label: 'Sarcini deschise',
|
||||
value: c?.openTasks ?? '—',
|
||||
href: '/dashboard/tasks',
|
||||
badge: c?.overdueTasks ? `${c.overdueTasks} restante` : null,
|
||||
badgeColor: 'bg-signal-danger text-white',
|
||||
},
|
||||
{
|
||||
label: 'Obiective active',
|
||||
value: goals.length,
|
||||
href: '/dashboard/goals',
|
||||
badge: goalsAtRisk > 0 ? `${goalsAtRisk} la risc` : null,
|
||||
badgeColor: 'bg-signal-warn text-white',
|
||||
},
|
||||
{
|
||||
label: 'Tranzacții',
|
||||
value: transactions.length,
|
||||
href: '/dashboard/transactions',
|
||||
badge: txMissing > 0 ? `${txMissing} fără dovadă` : null,
|
||||
badgeColor: 'bg-signal-warn text-white',
|
||||
},
|
||||
{
|
||||
label: 'Decizii pending',
|
||||
value: decisionsPending,
|
||||
href: '/dashboard/decisions',
|
||||
badge: null,
|
||||
badgeColor: '',
|
||||
},
|
||||
{
|
||||
label: 'Segmente',
|
||||
value: c?.segments ?? '—',
|
||||
href: '/dashboard/segments',
|
||||
badge: null,
|
||||
badgeColor: '',
|
||||
},
|
||||
{
|
||||
label: 'Research briefs',
|
||||
value: c?.researchBriefs ?? '—',
|
||||
href: '/dashboard/research',
|
||||
badge: null,
|
||||
badgeColor: '',
|
||||
},
|
||||
{
|
||||
label: 'Scadențe 7 zile',
|
||||
value: c?.deadlinesNext7Days ?? '—',
|
||||
href: '/dashboard/tasks',
|
||||
badge: null,
|
||||
badgeColor: '',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl space-y-6">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold text-ink">Vedere Generală Business</h1>
|
||||
<p className="mt-1 text-sm text-ink-faint">
|
||||
Sumar executiv al tuturor modulelor CEO-OS.
|
||||
{exec?.generatedAt && (
|
||||
<> Actualizat: {new Date(exec.generatedAt).toLocaleString('ro-RO')}.</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{exec?.status === 'building' && (
|
||||
<div className="card border-l-2 border-signal-warn p-4">
|
||||
<p className="text-sm text-signal-warn">
|
||||
Proiecția executive se construiește. Contoarele vor apărea în câteva secunde.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
{modules.map(({ label, value, href, badge, badgeColor }) => (
|
||||
<Link
|
||||
key={label}
|
||||
href={href}
|
||||
className="card group flex flex-col gap-1 p-5 hover:border-bronze-deep transition-colors"
|
||||
>
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-ink-faint group-hover:text-bronze-deep">
|
||||
{label}
|
||||
</p>
|
||||
<p className="font-display text-3xl font-semibold text-ink">{value}</p>
|
||||
{badge && (
|
||||
<span className={`mt-1 inline-block rounded-full px-2 py-0.5 text-[10px] font-medium ${badgeColor}`}>
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<section className="card p-5">
|
||||
<h2 className="mb-3 text-xs font-semibold uppercase tracking-wider text-ink-faint">
|
||||
Obiective
|
||||
</h2>
|
||||
{goals.length === 0 ? (
|
||||
<p className="text-sm text-ink-faint">Niciun obiectiv definit.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{goals.slice(0, 5).map((g) => {
|
||||
const dot =
|
||||
g.status === 'on_track'
|
||||
? 'bg-signal-ok'
|
||||
: g.status === 'at_risk'
|
||||
? 'bg-signal-warn'
|
||||
: g.status === 'achieved'
|
||||
? 'bg-bronze-deep'
|
||||
: g.status === 'abandoned'
|
||||
? 'bg-signal-danger'
|
||||
: 'bg-paper-sunken';
|
||||
return (
|
||||
<li key={g.id} className="flex items-center gap-3">
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${dot}`} />
|
||||
<span className="flex-1 truncate text-sm text-ink">{g.metric}</span>
|
||||
<span className="shrink-0 text-xs text-ink-faint">{g.target}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
<Link
|
||||
href="/dashboard/goals"
|
||||
className="mt-3 block text-right text-xs font-medium text-bronze-deep hover:underline"
|
||||
>
|
||||
Toate obiectivele →
|
||||
</Link>
|
||||
</section>
|
||||
|
||||
<section className="card p-5">
|
||||
<h2 className="mb-3 text-xs font-semibold uppercase tracking-wider text-ink-faint">
|
||||
Tranzacții recente
|
||||
</h2>
|
||||
{transactions.length === 0 ? (
|
||||
<p className="text-sm text-ink-faint">Nicio tranzacție înregistrată.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{transactions.slice(0, 5).map((t) => (
|
||||
<li key={t.id} className="flex items-center justify-between gap-3">
|
||||
<span className="flex-1 truncate text-sm text-ink">{t.type}</span>
|
||||
<span className="shrink-0 font-mono text-xs text-ink-faint">
|
||||
{new Intl.NumberFormat('ro-RO', {
|
||||
style: 'currency',
|
||||
currency: t.currency,
|
||||
}).format(Number(t.amountMinorUnits) / 100)}
|
||||
</span>
|
||||
<span
|
||||
className={`shrink-0 rounded-full px-1.5 py-0.5 text-[9px] font-medium ${
|
||||
t.evidenceStatus === 'complete'
|
||||
? 'bg-signal-ok text-white'
|
||||
: t.evidenceStatus === 'partial'
|
||||
? 'bg-signal-warn text-white'
|
||||
: 'bg-signal-danger text-white'
|
||||
}`}
|
||||
>
|
||||
{t.evidenceStatus}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<Link
|
||||
href="/dashboard/transactions"
|
||||
className="mt-3 block text-right text-xs font-medium text-bronze-deep hover:underline"
|
||||
>
|
||||
Toate tranzacțiile →
|
||||
</Link>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue