feat(word-templates): add company pools, template cloning, typed categories, and placeholder display

- TemplateCategory union type (8 values) replacing plain string
- Company-specific filtering in template list
- Template cloning with clone badge indicator
- Placeholder display ({{VARIABLE}} markers) in card and form
- Delete confirmation dialog
- updatedAt timestamp support

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Marius Tarau
2026-02-18 06:35:47 +02:00
parent 455d95a8c6
commit 2c9f0bc6b7
4 changed files with 139 additions and 35 deletions

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { Plus, Pencil, Trash2, Search, FileText, ExternalLink } from 'lucide-react'; import { Plus, Pencil, Trash2, Search, FileText, ExternalLink, Copy } from 'lucide-react';
import { Button } from '@/shared/components/ui/button'; import { Button } from '@/shared/components/ui/button';
import { Input } from '@/shared/components/ui/input'; import { Input } from '@/shared/components/ui/input';
import { Label } from '@/shared/components/ui/label'; import { Label } from '@/shared/components/ui/label';
@@ -9,22 +9,31 @@ import { Textarea } from '@/shared/components/ui/textarea';
import { Badge } from '@/shared/components/ui/badge'; import { Badge } from '@/shared/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/shared/components/ui/dialog';
import type { CompanyId } from '@/core/auth/types'; import type { CompanyId } from '@/core/auth/types';
import type { WordTemplate } from '../types'; import type { WordTemplate, TemplateCategory } from '../types';
import { useTemplates } from '../hooks/use-templates'; import { useTemplates } from '../hooks/use-templates';
const TEMPLATE_CATEGORIES = [ const CATEGORY_LABELS: Record<TemplateCategory, string> = {
'Contract', 'Memoriu tehnic', 'Ofertă', 'Factură', 'Raport', 'Deviz', 'Proces-verbal', 'Altele', contract: 'Contract',
]; memoriu: 'Memoriu tehnic',
oferta: 'Ofertă',
raport: 'Raport',
cerere: 'Cerere',
aviz: 'Aviz',
scrisoare: 'Scrisoare',
altele: 'Altele',
};
type ViewMode = 'list' | 'add' | 'edit'; type ViewMode = 'list' | 'add' | 'edit';
export function WordTemplatesModule() { export function WordTemplatesModule() {
const { templates, allTemplates, allCategories, loading, filters, updateFilter, addTemplate, updateTemplate, removeTemplate } = useTemplates(); const { templates, allTemplates, loading, filters, updateFilter, addTemplate, updateTemplate, cloneTemplate, removeTemplate } = useTemplates();
const [viewMode, setViewMode] = useState<ViewMode>('list'); const [viewMode, setViewMode] = useState<ViewMode>('list');
const [editingTemplate, setEditingTemplate] = useState<WordTemplate | null>(null); const [editingTemplate, setEditingTemplate] = useState<WordTemplate | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const handleSubmit = async (data: Omit<WordTemplate, 'id' | 'createdAt'>) => { const handleSubmit = async (data: Omit<WordTemplate, 'id' | 'createdAt' | 'updatedAt'>) => {
if (viewMode === 'edit' && editingTemplate) { if (viewMode === 'edit' && editingTemplate) {
await updateTemplate(editingTemplate.id, data); await updateTemplate(editingTemplate.id, data);
} else { } else {
@@ -34,16 +43,21 @@ export function WordTemplatesModule() {
setEditingTemplate(null); setEditingTemplate(null);
}; };
const filterCategories = allCategories.length > 0 ? allCategories : TEMPLATE_CATEGORIES; const handleDeleteConfirm = async () => {
if (deletingId) {
await removeTemplate(deletingId);
setDeletingId(null);
}
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Stats */} {/* Stats */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Total șabloane</p><p className="text-2xl font-bold">{allTemplates.length}</p></CardContent></Card> <Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Total șabloane</p><p className="text-2xl font-bold">{allTemplates.length}</p></CardContent></Card>
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Categorii</p><p className="text-2xl font-bold">{allCategories.length}</p></CardContent></Card>
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Beletage</p><p className="text-2xl font-bold">{allTemplates.filter((t) => t.company === 'beletage').length}</p></CardContent></Card> <Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Beletage</p><p className="text-2xl font-bold">{allTemplates.filter((t) => t.company === 'beletage').length}</p></CardContent></Card>
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Urban Switch</p><p className="text-2xl font-bold">{allTemplates.filter((t) => t.company === 'urban-switch').length}</p></CardContent></Card> <Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Urban Switch</p><p className="text-2xl font-bold">{allTemplates.filter((t) => t.company === 'urban-switch').length}</p></CardContent></Card>
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Studii de Teren</p><p className="text-2xl font-bold">{allTemplates.filter((t) => t.company === 'studii-de-teren').length}</p></CardContent></Card>
</div> </div>
{viewMode === 'list' && ( {viewMode === 'list' && (
@@ -53,15 +67,25 @@ export function WordTemplatesModule() {
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input placeholder="Caută șablon..." value={filters.search} onChange={(e) => updateFilter('search', e.target.value)} className="pl-9" /> <Input placeholder="Caută șablon..." value={filters.search} onChange={(e) => updateFilter('search', e.target.value)} className="pl-9" />
</div> </div>
<Select value={filters.category} onValueChange={(v) => updateFilter('category', v)}> <Select value={filters.category} onValueChange={(v) => updateFilter('category', v as TemplateCategory | 'all')}>
<SelectTrigger className="w-[160px]"><SelectValue /></SelectTrigger> <SelectTrigger className="w-[160px]"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">Toate</SelectItem> <SelectItem value="all">Toate categoriile</SelectItem>
{filterCategories.map((c) => ( {(Object.keys(CATEGORY_LABELS) as TemplateCategory[]).map((c) => (
<SelectItem key={c} value={c}>{c}</SelectItem> <SelectItem key={c} value={c}>{CATEGORY_LABELS[c]}</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={filters.company} onValueChange={(v) => updateFilter('company', v)}>
<SelectTrigger className="w-[150px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">Toate companiile</SelectItem>
<SelectItem value="beletage">Beletage</SelectItem>
<SelectItem value="urban-switch">Urban Switch</SelectItem>
<SelectItem value="studii-de-teren">Studii de Teren</SelectItem>
<SelectItem value="group">Grup</SelectItem>
</SelectContent>
</Select>
<Button onClick={() => setViewMode('add')} className="shrink-0"> <Button onClick={() => setViewMode('add')} className="shrink-0">
<Plus className="mr-1.5 h-4 w-4" /> Adaugă <Plus className="mr-1.5 h-4 w-4" /> Adaugă
</Button> </Button>
@@ -70,19 +94,20 @@ export function WordTemplatesModule() {
{loading ? ( {loading ? (
<p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p> <p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p>
) : templates.length === 0 ? ( ) : templates.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground"> <p className="py-8 text-center text-sm text-muted-foreground">Niciun șablon găsit. Adaugă primul șablon Word.</p>
Niciun șablon găsit. Adaugă primul șablon Word.
</p>
) : ( ) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{templates.map((tpl) => ( {templates.map((tpl) => (
<Card key={tpl.id} className="group relative"> <Card key={tpl.id} className="group relative">
<CardContent className="p-4"> <CardContent className="p-4">
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100"> <div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<Button variant="ghost" size="icon" className="h-7 w-7" title="Clonează" onClick={() => cloneTemplate(tpl.id)}>
<Copy className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditingTemplate(tpl); setViewMode('edit'); }}> <Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditingTemplate(tpl); setViewMode('edit'); }}>
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
</Button> </Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => removeTemplate(tpl.id)}> <Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => setDeletingId(tpl.id)}>
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
@@ -94,9 +119,18 @@ export function WordTemplatesModule() {
<p className="font-medium">{tpl.name}</p> <p className="font-medium">{tpl.name}</p>
{tpl.description && <p className="mt-0.5 text-xs text-muted-foreground line-clamp-2">{tpl.description}</p>} {tpl.description && <p className="mt-0.5 text-xs text-muted-foreground line-clamp-2">{tpl.description}</p>}
<div className="mt-1.5 flex flex-wrap gap-1"> <div className="mt-1.5 flex flex-wrap gap-1">
{tpl.category && <Badge variant="outline" className="text-[10px]">{tpl.category}</Badge>} <Badge variant="outline" className="text-[10px]">{CATEGORY_LABELS[tpl.category]}</Badge>
<Badge variant="secondary" className="text-[10px]">v{tpl.version}</Badge> <Badge variant="secondary" className="text-[10px]">v{tpl.version}</Badge>
{tpl.clonedFrom && <Badge variant="secondary" className="text-[10px]">Clonă</Badge>}
</div> </div>
{/* Placeholders display */}
{tpl.placeholders.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1">
{tpl.placeholders.map((p) => (
<span key={p} className="rounded bg-muted px-1 py-0.5 font-mono text-[10px] text-muted-foreground">{`{{${p}}}`}</span>
))}
</div>
)}
{tpl.fileUrl && ( {tpl.fileUrl && (
<a href={tpl.fileUrl} target="_blank" rel="noopener noreferrer" className="mt-1 inline-flex items-center gap-1 text-xs text-primary hover:underline"> <a href={tpl.fileUrl} target="_blank" rel="noopener noreferrer" className="mt-1 inline-flex items-center gap-1 text-xs text-primary hover:underline">
<ExternalLink className="h-3 w-3" /> Deschide fișier <ExternalLink className="h-3 w-3" /> Deschide fișier
@@ -120,30 +154,58 @@ export function WordTemplatesModule() {
</CardContent> </CardContent>
</Card> </Card>
)} )}
{/* Delete confirmation */}
<Dialog open={deletingId !== null} onOpenChange={(open) => { if (!open) setDeletingId(null); }}>
<DialogContent>
<DialogHeader><DialogTitle>Confirmare ștergere</DialogTitle></DialogHeader>
<p className="text-sm">Ești sigur vrei ștergi acest șablon? Acțiunea este ireversibilă.</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeletingId(null)}>Anulează</Button>
<Button variant="destructive" onClick={handleDeleteConfirm}>Șterge</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }
function TemplateForm({ initial, onSubmit, onCancel }: { function TemplateForm({ initial, onSubmit, onCancel }: {
initial?: WordTemplate; initial?: WordTemplate;
onSubmit: (data: Omit<WordTemplate, 'id' | 'createdAt'>) => void; onSubmit: (data: Omit<WordTemplate, 'id' | 'createdAt' | 'updatedAt'>) => void;
onCancel: () => void; onCancel: () => void;
}) { }) {
const [name, setName] = useState(initial?.name ?? ''); const [name, setName] = useState(initial?.name ?? '');
const [description, setDescription] = useState(initial?.description ?? ''); const [description, setDescription] = useState(initial?.description ?? '');
const [category, setCategory] = useState(initial?.category ?? 'Contract'); const [category, setCategory] = useState<TemplateCategory>(initial?.category ?? 'contract');
const [fileUrl, setFileUrl] = useState(initial?.fileUrl ?? ''); const [fileUrl, setFileUrl] = useState(initial?.fileUrl ?? '');
const [company, setCompany] = useState<CompanyId>(initial?.company ?? 'beletage'); const [company, setCompany] = useState<CompanyId>(initial?.company ?? 'beletage');
const [version, setVersion] = useState(initial?.version ?? '1.0.0'); const [version, setVersion] = useState(initial?.version ?? '1.0.0');
const [placeholdersText, setPlaceholdersText] = useState(initial?.placeholders.join(', ') ?? '');
return ( return (
<form onSubmit={(e) => { e.preventDefault(); onSubmit({ name, description, category, fileUrl, company, version, tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'all' }); }} className="space-y-4"> <form onSubmit={(e) => {
e.preventDefault();
const placeholders = placeholdersText
.split(',')
.map((p) => p.trim())
.filter((p) => p.length > 0);
onSubmit({
name, description, category, fileUrl, company, version, placeholders,
clonedFrom: initial?.clonedFrom,
tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'all',
});
}} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div><Label>Nume șablon</Label><Input value={name} onChange={(e) => setName(e.target.value)} className="mt-1" required /></div> <div><Label>Nume șablon *</Label><Input value={name} onChange={(e) => setName(e.target.value)} className="mt-1" required /></div>
<div><Label>Categorie</Label> <div><Label>Categorie</Label>
<Select value={category} onValueChange={setCategory}> <Select value={category} onValueChange={(v) => setCategory(v as TemplateCategory)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger> <SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>{TEMPLATE_CATEGORIES.map((c) => (<SelectItem key={c} value={c}>{c}</SelectItem>))}</SelectContent> <SelectContent>
{(Object.keys(CATEGORY_LABELS) as TemplateCategory[]).map((c) => (
<SelectItem key={c} value={c}>{CATEGORY_LABELS[c]}</SelectItem>
))}
</SelectContent>
</Select> </Select>
</div> </div>
</div> </div>
@@ -163,6 +225,11 @@ function TemplateForm({ initial, onSubmit, onCancel }: {
<div><Label>Versiune</Label><Input value={version} onChange={(e) => setVersion(e.target.value)} className="mt-1" /></div> <div><Label>Versiune</Label><Input value={version} onChange={(e) => setVersion(e.target.value)} className="mt-1" /></div>
<div><Label>URL fișier</Label><Input value={fileUrl} onChange={(e) => setFileUrl(e.target.value)} className="mt-1" placeholder="https://..." /></div> <div><Label>URL fișier</Label><Input value={fileUrl} onChange={(e) => setFileUrl(e.target.value)} className="mt-1" placeholder="https://..." /></div>
</div> </div>
<div>
<Label>Placeholder-e (separate prin virgulă)</Label>
<Input value={placeholdersText} onChange={(e) => setPlaceholdersText(e.target.value)} className="mt-1" placeholder="NUME_BENEFICIAR, DATA_CONTRACT, NR_PROIECT..." />
<p className="mt-1 text-xs text-muted-foreground">Variabilele din șablon, de forma {'{{VARIABILA}}'}, separate prin virgulă.</p>
</div>
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button> <Button type="button" variant="outline" onClick={onCancel}>Anulează</Button>
<Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button> <Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button>

View File

@@ -3,20 +3,21 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useStorage } from '@/core/storage'; import { useStorage } from '@/core/storage';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import type { WordTemplate } from '../types'; import type { WordTemplate, TemplateCategory } from '../types';
const PREFIX = 'tpl:'; const PREFIX = 'tpl:';
export interface TemplateFilters { export interface TemplateFilters {
search: string; search: string;
category: string; category: TemplateCategory | 'all';
company: string;
} }
export function useTemplates() { export function useTemplates() {
const storage = useStorage('word-templates'); const storage = useStorage('word-templates');
const [templates, setTemplates] = useState<WordTemplate[]>([]); const [templates, setTemplates] = useState<WordTemplate[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState<TemplateFilters>({ search: '', category: 'all' }); const [filters, setFilters] = useState<TemplateFilters>({ search: '', category: 'all', company: 'all' });
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -36,8 +37,9 @@ export function useTemplates() {
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { refresh(); }, [refresh]); useEffect(() => { refresh(); }, [refresh]);
const addTemplate = useCallback(async (data: Omit<WordTemplate, 'id' | 'createdAt'>) => { const addTemplate = useCallback(async (data: Omit<WordTemplate, 'id' | 'createdAt' | 'updatedAt'>) => {
const template: WordTemplate = { ...data, id: uuid(), createdAt: new Date().toISOString() }; const now = new Date().toISOString();
const template: WordTemplate = { ...data, id: uuid(), createdAt: now, updatedAt: now };
await storage.set(`${PREFIX}${template.id}`, template); await storage.set(`${PREFIX}${template.id}`, template);
await refresh(); await refresh();
return template; return template;
@@ -46,11 +48,32 @@ export function useTemplates() {
const updateTemplate = useCallback(async (id: string, updates: Partial<WordTemplate>) => { const updateTemplate = useCallback(async (id: string, updates: Partial<WordTemplate>) => {
const existing = templates.find((t) => t.id === id); const existing = templates.find((t) => t.id === id);
if (!existing) return; if (!existing) return;
const updated = { ...existing, ...updates, id: existing.id, createdAt: existing.createdAt }; const updated: WordTemplate = {
...existing, ...updates,
id: existing.id, createdAt: existing.createdAt,
updatedAt: new Date().toISOString(),
};
await storage.set(`${PREFIX}${id}`, updated); await storage.set(`${PREFIX}${id}`, updated);
await refresh(); await refresh();
}, [storage, refresh, templates]); }, [storage, refresh, templates]);
const cloneTemplate = useCallback(async (id: string) => {
const existing = templates.find((t) => t.id === id);
if (!existing) return;
const now = new Date().toISOString();
const cloned: WordTemplate = {
...existing,
id: uuid(),
name: `${existing.name} (copie)`,
clonedFrom: existing.id,
createdAt: now,
updatedAt: now,
};
await storage.set(`${PREFIX}${cloned.id}`, cloned);
await refresh();
return cloned;
}, [storage, refresh, templates]);
const removeTemplate = useCallback(async (id: string) => { const removeTemplate = useCallback(async (id: string) => {
await storage.delete(`${PREFIX}${id}`); await storage.delete(`${PREFIX}${id}`);
await refresh(); await refresh();
@@ -60,10 +83,9 @@ export function useTemplates() {
setFilters((prev) => ({ ...prev, [key]: value })); setFilters((prev) => ({ ...prev, [key]: value }));
}, []); }, []);
const allCategories = [...new Set(templates.map((t) => t.category).filter(Boolean))];
const filteredTemplates = templates.filter((t) => { const filteredTemplates = templates.filter((t) => {
if (filters.category !== 'all' && t.category !== filters.category) return false; if (filters.category !== 'all' && t.category !== filters.category) return false;
if (filters.company !== 'all' && t.company !== filters.company) return false;
if (filters.search) { if (filters.search) {
const q = filters.search.toLowerCase(); const q = filters.search.toLowerCase();
return t.name.toLowerCase().includes(q) || t.description.toLowerCase().includes(q); return t.name.toLowerCase().includes(q) || t.description.toLowerCase().includes(q);
@@ -71,5 +93,5 @@ export function useTemplates() {
return true; return true;
}); });
return { templates: filteredTemplates, allTemplates: templates, allCategories, loading, filters, updateFilter, addTemplate, updateTemplate, removeTemplate, refresh }; return { templates: filteredTemplates, allTemplates: templates, loading, filters, updateFilter, addTemplate, updateTemplate, cloneTemplate, removeTemplate, refresh };
} }

View File

@@ -1,3 +1,3 @@
export { wordTemplatesConfig } from './config'; export { wordTemplatesConfig } from './config';
export { WordTemplatesModule } from './components/word-templates-module'; export { WordTemplatesModule } from './components/word-templates-module';
export type { WordTemplate } from './types'; export type { WordTemplate, TemplateCategory } from './types';

View File

@@ -1,15 +1,30 @@
import type { Visibility } from '@/core/module-registry/types'; import type { Visibility } from '@/core/module-registry/types';
import type { CompanyId } from '@/core/auth/types'; import type { CompanyId } from '@/core/auth/types';
export type TemplateCategory =
| 'contract'
| 'memoriu'
| 'oferta'
| 'raport'
| 'cerere'
| 'aviz'
| 'scrisoare'
| 'altele';
export interface WordTemplate { export interface WordTemplate {
id: string; id: string;
name: string; name: string;
description: string; description: string;
category: string; category: TemplateCategory;
fileUrl: string; fileUrl: string;
company: CompanyId; company: CompanyId;
/** Detected placeholders in template */
placeholders: string[];
/** Cloned from template ID */
clonedFrom?: string;
tags: string[]; tags: string[];
version: string; version: string;
visibility: Visibility; visibility: Visibility;
createdAt: string; createdAt: string;
updatedAt: string;
} }