feat(CC-078): add Document Templates page (filter docs by template tags + create + download)
This commit is contained in:
parent
f16f61a1db
commit
6a7adb6f5d
1 changed files with 200 additions and 0 deletions
200
src/app/dashboard/documents/templates/page.tsx
Normal file
200
src/app/dashboard/documents/templates/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue