feat(CC-078): add Document Templates page (filter docs by template tags + create + download)

This commit is contained in:
admin-valentin 2026-08-02 12:53:50 +00:00
parent f16f61a1db
commit 6a7adb6f5d

View file

@ -0,0 +1,200 @@
'use client';
import { useMemo, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiFetch } from '../../../../lib/api';
import { useSession } from '../../../../components/session-provider';
interface Document {
id: string; title: string; type: string | null; content: string | null;
classification: string | null; tags: string[]; status: string | null;
createdAt: string; updatedAt: string;
}
const TEMPLATE_MARKERS = ['template', 'sablon', 'model', 'template-'];
function isTemplate(doc: Document) {
const tags = doc.tags.map((t) => t.toLowerCase());
const type = (doc.type ?? '').toLowerCase();
return tags.some((t) => TEMPLATE_MARKERS.some((m) => t.includes(m))) ||
type === 'template' || type === 'sablon';
}
const TEMPLATE_CATEGORIES = [
{ key: 'all', label: 'Toate' },
{ key: 'contract', label: 'Contracte' },
{ key: 'raport', label: 'Rapoarte' },
{ key: 'propunere', label: 'Propuneri' },
{ key: 'acord', label: 'Acorduri' },
{ key: 'factura', label: 'Facturi' },
{ key: 'other', label: 'Altele' },
];
function categoryOf(doc: Document) {
const text = `${doc.title} ${doc.type ?? ''} ${doc.tags.join(' ')}`.toLowerCase();
if (text.includes('contract')) return 'contract';
if (text.includes('raport') || text.includes('report')) return 'raport';
if (text.includes('propun') || text.includes('propos')) return 'propunere';
if (text.includes('acord') || text.includes('nda')) return 'acord';
if (text.includes('factur') || text.includes('invoic')) return 'factura';
return 'other';
}
export default function DocTemplatesPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const qc = useQueryClient();
const [category, setCategory] = useState('all');
const [search, setSearch] = useState('');
const [selected, setSelected] = useState<Document | null>(null);
const [showCreate, setShowCreate] = useState(false);
const [form, setForm] = useState({ title: '', type: 'template', content: '', tags: 'template' });
const { data: allDocs = [], isLoading } = useQuery({
queryKey: ['docs-templates', tenantId],
queryFn: () => apiFetch<Document[]>('/v1/documents?limit=500', { tenantId }),
enabled: Boolean(tenantId), staleTime: 60_000,
});
const templates = useMemo(() => allDocs.filter(isTemplate), [allDocs]);
const filtered = useMemo(() => {
return templates.filter((d) => {
if (category !== 'all' && categoryOf(d) !== category) return false;
if (search && !d.title.toLowerCase().includes(search.toLowerCase())) return false;
return true;
});
}, [templates, category, search]);
const createMut = useMutation({
mutationFn: (body: typeof form) =>
apiFetch('/v1/documents', { tenantId, method: 'POST', body: { ...body, tags: body.tags.split(',').map(t => t.trim()).filter(Boolean) } }),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['docs-templates', tenantId] }); setShowCreate(false); setForm({ title: '', type: 'template', content: '', tags: 'template' }); },
});
return (
<div className="max-w-5xl p-6">
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Șabloane Documente</h1>
<p className="text-sm text-ink-faint mt-1">
{isLoading ? 'Se încarcă…' : `${templates.length} șabloane din ${allDocs.length} documente`}
</p>
</div>
<button onClick={() => setShowCreate(true)}
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90">
+ Șablon nou
</button>
</div>
<div className="flex flex-wrap gap-3 mb-4">
<input placeholder="Caută șablon…" value={search} onChange={(e) => setSearch(e.target.value)}
className="rounded-lg border bg-card px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
<div className="flex flex-wrap gap-1">
{TEMPLATE_CATEGORIES.map((c) => (
<button key={c.key} onClick={() => setCategory(c.key)}
className={`rounded-full border px-3 py-1 text-xs ${category === c.key ? 'bg-primary/10 border-primary text-ink' : 'bg-card border-border text-ink-faint'}`}>
{c.label}
</button>
))}
</div>
</div>
{showCreate && (
<div className="card p-5 mb-4 space-y-3">
<p className="text-sm font-semibold text-ink">Șablon nou</p>
<div className="grid gap-3 sm:grid-cols-2">
<input placeholder="Titlu" value={form.title} onChange={(e) => setForm((p) => ({ ...p, title: e.target.value }))}
className="rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
<input placeholder="Taguri (virgulă)" value={form.tags} onChange={(e) => setForm((p) => ({ ...p, tags: e.target.value }))}
className="rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
</div>
<textarea placeholder="Conținut șablon (opțional)" value={form.content}
onChange={(e) => setForm((p) => ({ ...p, content: e.target.value }))} rows={4}
className="w-full rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
<div className="flex gap-2">
<button onClick={() => createMut.mutate(form)} disabled={!form.title || createMut.isPending}
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white disabled:opacity-50 hover:bg-primary/90">
{createMut.isPending ? 'Se salvează…' : 'Salvează'}
</button>
<button onClick={() => setShowCreate(false)} className="rounded-lg border px-4 py-2 text-sm text-ink hover:bg-muted/50">Anulează</button>
</div>
</div>
)}
<div className="grid gap-4 lg:grid-cols-3">
<div className="lg:col-span-2">
{isLoading ? (
<div className="text-center text-sm text-ink-faint py-8">Se încarcă</div>
) : filtered.length === 0 ? (
<div className="card p-8 text-center space-y-2">
<p className="text-2xl">📄</p>
<p className="text-sm text-ink-faint">
{templates.length === 0
? 'Niciun șablon găsit. Creează documente cu tagul "template".'
: 'Niciun șablon în această categorie.'}
</p>
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{filtered.map((doc) => (
<button key={doc.id} onClick={() => setSelected(doc)}
className={`card p-4 text-left hover:border-primary/40 transition-colors ${selected?.id === doc.id ? 'border-primary/50 bg-primary/5' : ''}`}>
<p className="text-sm font-medium text-ink line-clamp-1">{doc.title}</p>
<p className="text-[10px] text-ink-faint mt-1 capitalize">
{categoryOf(doc)} · {doc.classification ?? 'C1'}
</p>
{doc.tags.length > 0 && (
<div className="flex flex-wrap gap-0.5 mt-1.5">
{doc.tags.slice(0, 3).map((t) => (
<span key={t} className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[9px] text-ink">{t}</span>
))}
</div>
)}
<p className="text-[10px] text-ink-faint mt-2">
{new Date(doc.updatedAt).toLocaleDateString('ro-RO', { day: '2-digit', month: 'short', year: 'numeric' })}
</p>
</button>
))}
</div>
)}
</div>
<div>
{selected ? (
<div className="card p-4 space-y-3 sticky top-4">
<p className="text-sm font-semibold text-ink">{selected.title}</p>
<div className="text-[10px] text-ink-faint space-y-1">
<p>Tip: {selected.type ?? '—'}</p>
<p>Clasificare: {selected.classification ?? 'C1'}</p>
<p>Status: {selected.status ?? 'draft'}</p>
<p>Actualizat: {new Date(selected.updatedAt).toLocaleDateString('ro-RO')}</p>
</div>
{selected.content && (
<div className="border-t border-border/50 pt-3">
<p className="text-[10px] font-semibold text-ink-faint mb-1">Previzualizare</p>
<p className="text-xs text-ink-faint line-clamp-6 whitespace-pre-wrap">{selected.content}</p>
</div>
)}
<button
onClick={() => {
const el = document.createElement('a');
el.href = `data:text/plain;charset=utf-8,${encodeURIComponent(selected.content ?? selected.title)}`;
el.download = `${selected.title.replace(/[^a-z0-9]/gi,'_')}.txt`;
el.click();
}}
className="w-full rounded-lg border px-3 py-1.5 text-xs text-ink hover:bg-muted/50">
Descarcă TXT
</button>
</div>
) : (
<div className="card p-6 text-center space-y-2">
<p className="text-2xl">📋</p>
<p className="text-xs text-ink-faint">Selectează un șablon.</p>
</div>
)}
</div>
</div>
</div>
);
}