feat(CC-065): add Expirations page (documents + contracts expiring in 90 days)

This commit is contained in:
admin-valentin 2026-08-01 21:29:52 +00:00
parent c8011d5985
commit 5121cb4cef

View file

@ -0,0 +1,152 @@
'use client';
import Link from 'next/link';
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '../../../lib/api';
import { useSession } from '../../../components/session-provider';
interface Document {
id: string; filename: string; classification: string;
expiresAt: string | null; ocrStatus: string | null; createdAt: string;
}
interface Contract {
id: string; title: string; contractType: string; status: string;
endDate: string | null;
}
const B = '/dashboard';
function daysUntil(dateStr: string): number {
return Math.ceil((new Date(dateStr).getTime() - Date.now()) / 86400_000);
}
function ExpiryBadge({ days }: { days: number }) {
if (days < 0) return <span className="text-[10px] font-bold text-signal-danger">EXPIRAT {Math.abs(days)}z</span>;
if (days <= 7) return <span className="text-[10px] font-bold text-signal-danger">{days}z</span>;
if (days <= 30) return <span className="text-[10px] font-semibold text-signal-warn">{days}z</span>;
return <span className="text-[10px] text-ink-faint">{days}z</span>;
}
export default function ExpirationsPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const { data: docs = [], isLoading: docsLoading } = useQuery({
queryKey: ['docs-exp', tenantId],
queryFn: () => apiFetch<Document[]>('/v1/documents?limit=200', { tenantId }),
enabled: Boolean(tenantId),
staleTime: 120_000,
});
const { data: contracts = [], isLoading: cLoading } = useQuery({
queryKey: ['contracts-exp', tenantId],
queryFn: () => apiFetch<Contract[]>('/v1/contracts?status=active', { tenantId }),
enabled: Boolean(tenantId),
staleTime: 120_000,
});
const now = new Date();
const in90 = new Date(now.getTime() + 90 * 86400_000);
const expiringDocs = docs
.filter((d) => d.expiresAt && new Date(d.expiresAt) <= in90)
.sort((a, b) => new Date(a.expiresAt!).getTime() - new Date(b.expiresAt!).getTime());
const expiringContracts = contracts
.filter((c) => c.endDate && new Date(c.endDate) <= in90)
.sort((a, b) => new Date(a.endDate!).getTime() - new Date(b.endDate!).getTime());
const totalExpiring = expiringDocs.length + expiringContracts.length;
const critical = expiringDocs.filter((d) => daysUntil(d.expiresAt!) <= 7).length
+ expiringContracts.filter((c) => daysUntil(c.endDate!) <= 7).length;
const isLoading = docsLoading || cLoading;
return (
<div className="max-w-4xl space-y-6 p-6">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Expirări</h1>
<p className="text-sm text-ink-faint mt-1">
{isLoading ? 'Se încarcă…' : `${totalExpiring} expirări în 90 zile${critical > 0 ? ` · ${critical} critice` : ''}`}
</p>
</div>
{!isLoading && totalExpiring === 0 && (
<div className="card p-12 text-center space-y-2">
<p className="text-3xl"></p>
<p className="text-sm text-ink-faint">Nicio expirare în următoarele 90 zile.</p>
</div>
)}
{!isLoading && totalExpiring > 0 && (
<>
{/* Summary chips */}
<div className="flex gap-3 flex-wrap">
{[
{ label: 'Azi7 zile', count: expiringDocs.filter((d) => daysUntil(d.expiresAt!) <= 7).length + expiringContracts.filter((c) => daysUntil(c.endDate!) <= 7).length, cls: 'text-signal-danger bg-signal-danger/5 border-signal-danger/20' },
{ label: '830 zile', count: expiringDocs.filter((d) => { const n = daysUntil(d.expiresAt!); return n > 7 && n <= 30; }).length + expiringContracts.filter((c) => { const n = daysUntil(c.endDate!); return n > 7 && n <= 30; }).length, cls: 'text-signal-warn bg-signal-warn/5 border-signal-warn/20' },
{ label: '3190 zile', count: expiringDocs.filter((d) => daysUntil(d.expiresAt!) > 30).length + expiringContracts.filter((c) => daysUntil(c.endDate!) > 30).length, cls: 'text-ink-faint bg-muted/30 border-border/50' },
].map(({ label, count, cls }) => count > 0 ? (
<div key={label} className={`rounded-lg border px-3 py-2 text-center min-w-20 ${cls}`}>
<p className="font-display text-xl font-bold">{count}</p>
<p className="text-[10px]">{label}</p>
</div>
) : null)}
</div>
{/* Expiring contracts */}
{expiringContracts.length > 0 && (
<div className="card p-5 space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-ink">Contracte</h2>
<Link href={`${B}/contracts`} className="text-xs text-bronze-deep hover:underline">Administrează </Link>
</div>
<div className="divide-y divide-border/50">
{expiringContracts.map((c) => {
const days = daysUntil(c.endDate!);
return (
<div key={c.id} className="flex items-center justify-between py-2.5 gap-4">
<div className="flex-1 min-w-0">
<p className="text-sm text-ink font-medium truncate">{c.title}</p>
<p className="text-[10px] text-ink-faint capitalize">{c.contractType}</p>
</div>
<div className="text-right shrink-0">
<ExpiryBadge days={days} />
<p className="text-[10px] text-ink-faint mt-0.5">{c.endDate}</p>
</div>
</div>
);
})}
</div>
</div>
)}
{/* Expiring documents */}
{expiringDocs.length > 0 && (
<div className="card p-5 space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-ink">Documente</h2>
<Link href={`${B}/documents`} className="text-xs text-bronze-deep hover:underline">Bibliotecă </Link>
</div>
<div className="divide-y divide-border/50">
{expiringDocs.map((d) => {
const days = daysUntil(d.expiresAt!);
return (
<div key={d.id} className="flex items-center justify-between py-2.5 gap-4">
<div className="flex-1 min-w-0">
<p className="text-sm text-ink font-medium truncate">{d.filename}</p>
<p className="text-[10px] text-ink-faint uppercase">{d.classification}</p>
</div>
<div className="text-right shrink-0">
<ExpiryBadge days={days} />
<p className="text-[10px] text-ink-faint mt-0.5">{d.expiresAt?.slice(0, 10)}</p>
</div>
</div>
);
})}
</div>
</div>
)}
</>
)}
</div>
);
}