feat(cc-051): Opportunity Inbox page

This commit is contained in:
admin-valentin 2026-08-01 10:09:29 +00:00
parent 91e114aaae
commit 006a4f0562

View file

@ -0,0 +1,132 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '../../../lib/api';
import type { Opportunity } from '../../../lib/api';
import { useSession } from '../../../components/session-provider';
function probBadge(p: string | null): string {
const n = Number(p ?? 0);
if (n >= 0.7) return 'bg-signal-ok text-white';
if (n >= 0.4) return 'bg-signal-warn text-white';
return 'bg-paper-sunken text-ink-faint';
}
function fmtCurrency(minor: string | null): string {
if (!minor) return '—';
return new Intl.NumberFormat('ro-RO', { maximumFractionDigits: 0 }).format(Number(minor) / 100);
}
export default function OpportunitiesPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const { data: opportunities = [], isLoading } = useQuery({
queryKey: ['opportunities', tenantId],
queryFn: () => apiFetch<Opportunity[]>('/v1/opportunities', { tenantId }),
enabled: Boolean(tenantId),
staleTime: 30_000,
});
const now = new Date();
const expiringSoon = opportunities.filter(
(o) => o.expiresAt && new Date(o.expiresAt) < new Date(now.getTime() + 7 * 86400_000),
);
const highProb = opportunities.filter((o) => Number(o.probability ?? 0) >= 0.7);
return (
<div className="max-w-5xl space-y-6">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Opportunity Inbox</h1>
<p className="mt-1 text-sm text-ink-faint">
Oportunități detectate {' '}
{isLoading ? '…' : `${opportunities.length} active`}
</p>
</div>
{isLoading ? (
<p className="text-sm text-ink-faint">Se încarcă</p>
) : (
<>
{/* Quick stats */}
<div className="grid grid-cols-3 gap-4">
<div className="card p-4">
<p className="text-xs text-ink-faint">Total</p>
<p className="mt-1 font-display text-2xl font-semibold text-ink">{opportunities.length}</p>
</div>
<div className="card p-4">
<p className="text-xs text-ink-faint">Probabilitate ridicată</p>
<p className="mt-1 font-display text-2xl font-semibold text-signal-ok">{highProb.length}</p>
</div>
<div className={`card p-4 ${expiringSoon.length > 0 ? 'border-l-2 border-l-signal-warn' : ''}`}>
<p className="text-xs text-ink-faint">Expiră în 7 zile</p>
<p className={`mt-1 font-display text-2xl font-semibold ${expiringSoon.length > 0 ? 'text-signal-warn' : 'text-ink'}`}>
{expiringSoon.length}
</p>
</div>
</div>
{opportunities.length === 0 ? (
<div className="card p-10 text-center">
<p className="text-sm font-medium text-ink">Nicio oportunitate detectată</p>
<p className="mt-1 text-xs text-ink-faint">
Oportunitățile sunt detectate automat din activitate și research.
</p>
</div>
) : (
<div className="card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-paper-sunken text-left">
<th className="px-4 py-3 text-xs font-medium text-ink-faint">Sursă</th>
<th className="px-4 py-3 text-xs font-medium text-ink-faint">Entitate</th>
<th className="px-4 py-3 text-xs font-medium text-ink-faint">Probabilitate</th>
<th className="px-4 py-3 text-right text-xs font-medium text-ink-faint">Valoare estimată</th>
<th className="px-4 py-3 text-xs font-medium text-ink-faint">Expirare</th>
<th className="px-4 py-3 text-xs font-medium text-ink-faint">Acțiune</th>
</tr>
</thead>
<tbody>
{opportunities
.sort((a, b) => Number(b.probability ?? 0) - Number(a.probability ?? 0))
.map((o) => {
const prob = Number(o.probability ?? 0);
const isExpiring = o.expiresAt && new Date(o.expiresAt) < new Date(now.getTime() + 7 * 86400_000);
return (
<tr key={o.id} className={`border-b border-paper-sunken last:border-0 hover:bg-paper-sunken/40 ${isExpiring ? 'bg-signal-warn/5' : ''}`}>
<td className="px-4 py-2.5 text-sm text-ink capitalize">{o.source.replace(/_/g, ' ')}</td>
<td className="px-4 py-2.5 text-sm text-ink-faint">
{o.entityType ? (
<span className="rounded bg-paper-sunken px-1.5 py-0.5 font-mono text-[10px]">{o.entityType}</span>
) : '—'}
</td>
<td className="px-4 py-2.5">
<span className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${probBadge(o.probability)}`}>
{prob > 0 ? `${Math.round(prob * 100)}%` : '—'}
</span>
</td>
<td className="px-4 py-2.5 text-right font-mono text-xs text-ink">
{o.valueRangeMin || o.valueRangeMax ? (
`${fmtCurrency(o.valueRangeMin)} ${fmtCurrency(o.valueRangeMax)}`
) : '—'}
</td>
<td className={`px-4 py-2.5 text-xs ${isExpiring ? 'font-semibold text-signal-warn' : 'text-ink-faint'}`}>
{o.expiresAt ? new Date(o.expiresAt).toLocaleDateString('ro-RO') : '—'}
</td>
<td className="px-4 py-2.5 text-xs text-ink">
{o.nextAction ?? '—'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</>
)}
</div>
);
}