feat(CC-065): add Expirations page (documents + contracts expiring in 90 days)
This commit is contained in:
parent
c8011d5985
commit
5121cb4cef
1 changed files with 152 additions and 0 deletions
152
src/app/dashboard/expirations/page.tsx
Normal file
152
src/app/dashboard/expirations/page.tsx
Normal 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: 'Azi–7 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: '8–30 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: '31–90 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue