feat(CC-085): add Trust Outcomes page (portfolio: goals + delivered contracts)
This commit is contained in:
parent
827eb3bb30
commit
7d782ddf0e
1 changed files with 133 additions and 0 deletions
133
src/app/dashboard/trust/outcomes/page.tsx
Normal file
133
src/app/dashboard/trust/outcomes/page.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import { apiFetch } from '../../../lib/api';
|
||||
import { useSession } from '../../../components/session-provider';
|
||||
|
||||
interface Goal { id: string; title: string; description: string | null; status: string; progress: number | null; tags: string[]; targetDate: string | null; updatedAt: string; createdAt: string; }
|
||||
interface Contract { id: string; title: string; status: string; value: number | null; currency: string | null; counterpartyName: string | null; endDate: string | null; }
|
||||
|
||||
const PORTFOLIO_TAGS = ['project', 'proiect', 'product', 'produs', 'launch', 'lansare', 'client', 'outcome'];
|
||||
|
||||
export default function TrustOutcomesPage() {
|
||||
const { activeTenant } = useSession();
|
||||
const tenantId = activeTenant?.tenantId ?? '';
|
||||
const [view, setView] = useState<'goals' | 'contracts'>('goals');
|
||||
|
||||
const { data: goals = [] } = useQuery({ queryKey: ['to-goals', tenantId], queryFn: () => apiFetch<Goal[]>('/v1/goals?limit=200', { tenantId }), enabled: Boolean(tenantId), staleTime: 120_000 });
|
||||
const { data: contracts = [] } = useQuery({ queryKey: ['to-contracts', tenantId], queryFn: () => apiFetch<Contract[]>('/v1/contracts?limit=200', { tenantId }), enabled: Boolean(tenantId), staleTime: 120_000 });
|
||||
|
||||
const portfolio = useMemo(() => {
|
||||
const projectGoals = goals.filter((g) =>
|
||||
g.status === 'completed' || g.tags.some((t) => PORTFOLIO_TAGS.includes(t.toLowerCase()))
|
||||
).sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||
|
||||
const deliveredContracts = contracts.filter((c) => ['signed', 'active', 'completed', 'delivered'].includes(c.status))
|
||||
.sort((a, b) => (b.value ?? 0) - (a.value ?? 0));
|
||||
|
||||
const totalContractValue = deliveredContracts.reduce((s, c) => s + (c.value ?? 0), 0);
|
||||
const currencies = [...new Set(deliveredContracts.map((c) => c.currency).filter(Boolean))];
|
||||
|
||||
return { projectGoals, deliveredContracts, totalContractValue, currencies };
|
||||
}, [goals, contracts]);
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl space-y-6 p-6">
|
||||
<div>
|
||||
<nav className="text-xs text-ink-faint mb-1">
|
||||
<Link href="/dashboard/trust" className="hover:underline">Trust Dashboard</Link> / Proiecte & Rezultate
|
||||
</nav>
|
||||
<h1 className="font-display text-2xl font-semibold text-ink">Proiecte & Rezultate</h1>
|
||||
<p className="text-sm text-ink-faint mt-1">Portofoliu de obiective finalizate și contracte livrate.</p>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="card p-4 text-center">
|
||||
<p className="text-2xl font-bold text-ink">{portfolio.projectGoals.length}</p>
|
||||
<p className="text-[10px] text-ink-faint">proiecte / obiective</p>
|
||||
</div>
|
||||
<div className="card p-4 text-center">
|
||||
<p className="text-2xl font-bold text-ink">{portfolio.deliveredContracts.length}</p>
|
||||
<p className="text-[10px] text-ink-faint">contracte livrate</p>
|
||||
</div>
|
||||
<div className="card p-4 text-center">
|
||||
<p className="text-lg font-bold text-ink">
|
||||
{portfolio.totalContractValue > 0
|
||||
? `${portfolio.totalContractValue.toLocaleString()} ${portfolio.currencies[0] ?? ''}`
|
||||
: '—'}
|
||||
</p>
|
||||
<p className="text-[10px] text-ink-faint">valoare contracte</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toggle */}
|
||||
<div className="flex gap-2">
|
||||
{(['goals', 'contracts'] as const).map((v) => (
|
||||
<button key={v} onClick={() => setView(v)}
|
||||
className={`rounded-full px-4 py-1.5 text-xs font-medium border transition-colors ${view === v ? 'bg-primary text-white border-primary' : 'bg-background text-ink-faint border-border hover:border-primary/40'}`}>
|
||||
{v === 'goals' ? `Obiective (${portfolio.projectGoals.length})` : `Contracte (${portfolio.deliveredContracts.length})`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{view === 'goals' ? (
|
||||
portfolio.projectGoals.length === 0 ? (
|
||||
<div className="card p-8 text-center">
|
||||
<p className="text-2xl mb-2">🎯</p>
|
||||
<p className="text-sm text-ink-faint">Niciun obiectiv finalizat sau cu tag de proiect.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{portfolio.projectGoals.map((g) => (
|
||||
<div key={g.id} className="card p-4 space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm font-medium text-ink leading-snug">{g.title}</p>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[9px] font-bold shrink-0 ${g.status === 'completed' ? 'bg-signal-ok/10 text-signal-ok' : 'bg-primary/10 text-primary'}`}>
|
||||
{g.status}
|
||||
</span>
|
||||
</div>
|
||||
{g.description && <p className="text-[10px] text-ink-faint line-clamp-2">{g.description}</p>}
|
||||
{g.progress !== null && (
|
||||
<div className="h-1 rounded-full bg-muted overflow-hidden">
|
||||
<div className="h-full bg-primary/60 rounded-full" style={{ width: `${g.progress}%` }} />
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[9px] text-ink-faint">
|
||||
{new Date(g.updatedAt).toLocaleDateString('ro-RO', { month: 'short', year: 'numeric' })}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
portfolio.deliveredContracts.length === 0 ? (
|
||||
<div className="card p-8 text-center">
|
||||
<p className="text-2xl mb-2">📄</p>
|
||||
<p className="text-sm text-ink-faint">Niciun contract în portofoliu.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card divide-y divide-border/50">
|
||||
{portfolio.deliveredContracts.map((c) => (
|
||||
<div key={c.id} className="p-4 flex items-center gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-ink truncate">{c.title}</p>
|
||||
<div className="flex gap-3 text-[10px] text-ink-faint mt-0.5">
|
||||
{c.counterpartyName && <span>{c.counterpartyName}</span>}
|
||||
{c.endDate && <span>{new Date(c.endDate).toLocaleDateString('ro-RO', { month: 'short', year: 'numeric' })}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
{c.value ? <p className="text-sm font-bold text-ink">{c.value.toLocaleString()} {c.currency}</p> : null}
|
||||
<span className="text-[9px] text-signal-ok font-bold">{c.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue