feat(CC-061): add Document Library page (C0-C4 classification, OCR status, expiry alerts)

This commit is contained in:
admin-valentin 2026-08-01 21:13:35 +00:00
parent 255145fe8f
commit 24a42f2ae6

View file

@ -0,0 +1,211 @@
'use client';
import Link from 'next/link';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '../../../lib/api';
import { useSession } from '../../../components/session-provider';
interface Document {
id: string;
tenantId: string;
organizationId: string | null;
classification: 'c0' | 'c1' | 'c2' | 'c3' | 'c4';
paperlessId: string | null;
filename: string;
checksum: string | null;
expiresAt: string | null;
retentionPolicy: string | null;
ocrStatus: string | null;
version: number;
createdAt: string;
updatedAt: string;
}
const CLASS_META: Record<string, { label: string; desc: string; cls: string }> = {
c0: { label: 'C0 Public', desc: 'Date publice, nicio restricție', cls: 'bg-sky-500/10 text-sky-700 dark:text-sky-300' },
c1: { label: 'C1 Intern', desc: 'Date interne, distribuție limitată', cls: 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-300' },
c2: { label: 'C2 Confidențial', desc: 'Date confidențiale, acces controlat', cls: 'bg-amber-500/10 text-amber-700 dark:text-amber-300' },
c3: { label: 'C3 Restricționat', desc: 'Date sensibile, autorizare necesară', cls: 'bg-orange-500/10 text-orange-700 dark:text-orange-300' },
c4: { label: 'C4 Secret', desc: 'Date critice, acces maxim restricționat', cls: 'bg-signal-danger/10 text-signal-danger' },
};
const OCR_META: Record<string, { label: string; cls: string }> = {
pending: { label: 'OCR în așteptare', cls: 'text-signal-warn' },
done: { label: 'OCR complet', cls: 'text-signal-ok' },
failed: { label: 'OCR eșuat', cls: 'text-signal-danger' },
not_needed:{ label: 'Fără OCR', cls: 'text-ink-faint' },
};
function fileIcon(filename: string): string {
const ext = filename.split('.').pop()?.toLowerCase() ?? '';
const map: Record<string, string> = {
pdf: '📄', jpg: '🖼️', jpeg: '🖼️', png: '🖼️', doc: '📝', docx: '📝',
xls: '📊', xlsx: '📊', csv: '📊', zip: '📦', xml: '🔖',
};
return map[ext] ?? '📎';
}
export default function DocumentLibraryPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const [filterClass, setFilterClass] = useState('');
const [search, setSearch] = useState('');
const { data: docs = [], isLoading } = useQuery({
queryKey: ['documents', tenantId, filterClass],
queryFn: () => {
const qs = filterClass ? `?classification=${filterClass}&limit=200` : '?limit=200';
return apiFetch<Document[]>(`/v1/documents${qs}`, { tenantId });
},
enabled: Boolean(tenantId),
staleTime: 60_000,
});
const filtered = search
? docs.filter((d) => d.filename.toLowerCase().includes(search.toLowerCase()))
: docs;
const expiringSoon = docs.filter((d) => {
if (!d.expiresAt) return false;
const exp = new Date(d.expiresAt);
const in30 = new Date(Date.now() + 30 * 86400_000);
return exp < in30 && exp > new Date();
});
return (
<div className="max-w-5xl space-y-6 p-6">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Bibliotecă Documente</h1>
<p className="text-sm text-ink-faint mt-1">{docs.length} documente înregistrate</p>
</div>
</div>
{/* Expiry alert */}
{expiringSoon.length > 0 && (
<div className="rounded-xl border border-signal-warn/30 bg-signal-warn/5 p-4 flex items-start gap-3">
<span className="text-signal-warn text-lg shrink-0"></span>
<div>
<p className="text-sm font-medium text-signal-warn">
{expiringSoon.length} document{expiringSoon.length > 1 ? 'e expiră' : ' expiră'} în 30 zile
</p>
<ul className="mt-1 space-y-0.5">
{expiringSoon.slice(0, 3).map((d) => (
<li key={d.id} className="text-xs text-ink-faint">
{fileIcon(d.filename)} {d.filename} expiră {new Date(d.expiresAt!).toLocaleDateString('ro-RO')}
</li>
))}
{expiringSoon.length > 3 && (
<li className="text-xs text-ink-faint">și alte {expiringSoon.length - 3}</li>
)}
</ul>
</div>
</div>
)}
{/* Filters */}
<div className="flex flex-wrap gap-2 items-center">
<input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Caută după nume fișier…"
className="rounded-lg border bg-card px-3 py-1.5 text-sm text-ink placeholder:text-ink-faint focus:outline-none focus:ring-2 focus:ring-ring w-60"
/>
<div className="flex gap-1 flex-wrap">
{['', 'c0', 'c1', 'c2', 'c3', 'c4'].map((cls) => (
<button
key={cls}
onClick={() => setFilterClass(cls)}
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
filterClass === cls
? 'bg-primary text-white border-primary'
: 'bg-card text-ink-faint border-border hover:border-primary/40'
}`}
>
{cls === '' ? 'Toate' : CLASS_META[cls]?.label ?? cls}
</button>
))}
</div>
</div>
{/* Document grid */}
{isLoading ? (
<div className="card p-12 text-center text-sm text-ink-faint">Se încarcă</div>
) : filtered.length === 0 ? (
<div className="card p-12 text-center space-y-2">
<p className="text-3xl">📂</p>
<p className="text-sm text-ink-faint">
{search ? 'Niciun document nu corespunde căutării.' : 'Niciun document înregistrat.'}
</p>
<p className="text-xs text-ink-faint">
Documentele sunt gestionate prin Paperless-ngx și indexate automat.
</p>
</div>
) : (
<div className="divide-y divide-border/50 rounded-xl border overflow-hidden">
{filtered.map((doc) => {
const cls = CLASS_META[doc.classification];
const ocr = doc.ocrStatus ? OCR_META[doc.ocrStatus] : null;
const isExpiring = doc.expiresAt && new Date(doc.expiresAt) < new Date(Date.now() + 30 * 86400_000);
return (
<div key={doc.id} className="flex items-center gap-4 px-4 py-3 bg-card hover:bg-muted/30 transition-colors">
<span className="text-2xl shrink-0">{fileIcon(doc.filename)}</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-sm text-ink font-medium truncate">{doc.filename}</p>
{doc.version > 1 && (
<span className="text-[10px] text-ink-faint">v{doc.version}</span>
)}
{isExpiring && (
<span className="text-[10px] text-signal-warn font-medium">
Expiră {new Date(doc.expiresAt!).toLocaleDateString('ro-RO')}
</span>
)}
</div>
<div className="flex items-center gap-3 mt-0.5">
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${cls.cls}`}>
{cls.label}
</span>
{ocr && (
<span className={`text-[10px] ${ocr.cls}`}>{ocr.label}</span>
)}
<span className="text-[10px] text-ink-faint">
{new Date(doc.createdAt).toLocaleDateString('ro-RO')}
</span>
{doc.paperlessId && (
<span className="text-[10px] text-ink-faint font-mono">
pls:{doc.paperlessId}
</span>
)}
</div>
</div>
{doc.retentionPolicy && (
<span className="text-[10px] text-ink-faint shrink-0">
Retenție: {doc.retentionPolicy}
</span>
)}
</div>
);
})}
</div>
)}
{/* C0-C4 legend */}
<div className="rounded-xl border bg-card p-4">
<p className="text-xs font-semibold text-ink-faint uppercase tracking-wider mb-3">
Clasificare date (C0C4)
</p>
<div className="grid grid-cols-1 sm:grid-cols-5 gap-2">
{Object.entries(CLASS_META).map(([key, { label, desc, cls }]) => (
<div key={key} className={`rounded-lg px-2 py-2 ${cls.split(' ')[0]}`}>
<p className={`text-[10px] font-bold ${cls.split(' ').slice(1).join(' ')}`}>{label}</p>
<p className="text-[9px] text-ink-faint mt-0.5 leading-tight">{desc}</p>
</div>
))}
</div>
</div>
</div>
);
}