feat(cc-051): Opportunity Inbox page
This commit is contained in:
parent
91e114aaae
commit
006a4f0562
1 changed files with 132 additions and 0 deletions
132
src/app/dashboard/opportunities/page.tsx
Normal file
132
src/app/dashboard/opportunities/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue