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:
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
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 { Input } from '@/shared/components/ui/input';
|
||||
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 { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
|
||||
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 { WordTemplate } from '../types';
|
||||
import type { WordTemplate, TemplateCategory } from '../types';
|
||||
import { useTemplates } from '../hooks/use-templates';
|
||||
|
||||
const TEMPLATE_CATEGORIES = [
|
||||
'Contract', 'Memoriu tehnic', 'Ofertă', 'Factură', 'Raport', 'Deviz', 'Proces-verbal', 'Altele',
|
||||
];
|
||||
const CATEGORY_LABELS: Record<TemplateCategory, string> = {
|
||||
contract: 'Contract',
|
||||
memoriu: 'Memoriu tehnic',
|
||||
oferta: 'Ofertă',
|
||||
raport: 'Raport',
|
||||
cerere: 'Cerere',
|
||||
aviz: 'Aviz',
|
||||
scrisoare: 'Scrisoare',
|
||||
altele: 'Altele',
|
||||
};
|
||||
|
||||
type ViewMode = 'list' | 'add' | 'edit';
|
||||
|
||||
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 [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) {
|
||||
await updateTemplate(editingTemplate.id, data);
|
||||
} else {
|
||||
@@ -34,16 +43,21 @@ export function WordTemplatesModule() {
|
||||
setEditingTemplate(null);
|
||||
};
|
||||
|
||||
const filterCategories = allCategories.length > 0 ? allCategories : TEMPLATE_CATEGORIES;
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (deletingId) {
|
||||
await removeTemplate(deletingId);
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Stats */}
|
||||
<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">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">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>
|
||||
|
||||
{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" />
|
||||
<Input placeholder="Caută șablon..." value={filters.search} onChange={(e) => updateFilter('search', e.target.value)} className="pl-9" />
|
||||
</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>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Toate</SelectItem>
|
||||
{filterCategories.map((c) => (
|
||||
<SelectItem key={c} value={c}>{c}</SelectItem>
|
||||
<SelectItem value="all">Toate categoriile</SelectItem>
|
||||
{(Object.keys(CATEGORY_LABELS) as TemplateCategory[]).map((c) => (
|
||||
<SelectItem key={c} value={c}>{CATEGORY_LABELS[c]}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</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">
|
||||
<Plus className="mr-1.5 h-4 w-4" /> Adaugă
|
||||
</Button>
|
||||
@@ -70,19 +94,20 @@ export function WordTemplatesModule() {
|
||||
{loading ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p>
|
||||
) : templates.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
Niciun șablon găsit. Adaugă primul șablon Word.
|
||||
</p>
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">Niciun șablon găsit. Adaugă primul șablon Word.</p>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{templates.map((tpl) => (
|
||||
<Card key={tpl.id} className="group relative">
|
||||
<CardContent className="p-4">
|
||||
<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'); }}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</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" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -94,9 +119,18 @@ export function WordTemplatesModule() {
|
||||
<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>}
|
||||
<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>
|
||||
{tpl.clonedFrom && <Badge variant="secondary" className="text-[10px]">Clonă</Badge>}
|
||||
</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 && (
|
||||
<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
|
||||
@@ -120,30 +154,58 @@ export function WordTemplatesModule() {
|
||||
</CardContent>
|
||||
</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 că vrei să ș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>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateForm({ initial, onSubmit, onCancel }: {
|
||||
initial?: WordTemplate;
|
||||
onSubmit: (data: Omit<WordTemplate, 'id' | 'createdAt'>) => void;
|
||||
onSubmit: (data: Omit<WordTemplate, 'id' | 'createdAt' | 'updatedAt'>) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState(initial?.name ?? '');
|
||||
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 [company, setCompany] = useState<CompanyId>(initial?.company ?? 'beletage');
|
||||
const [version, setVersion] = useState(initial?.version ?? '1.0.0');
|
||||
const [placeholdersText, setPlaceholdersText] = useState(initial?.placeholders.join(', ') ?? '');
|
||||
|
||||
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><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>
|
||||
<Select value={category} onValueChange={setCategory}>
|
||||
<Select value={category} onValueChange={(v) => setCategory(v as TemplateCategory)}>
|
||||
<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>
|
||||
</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>URL fișier</Label><Input value={fileUrl} onChange={(e) => setFileUrl(e.target.value)} className="mt-1" placeholder="https://..." /></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">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button>
|
||||
<Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button>
|
||||
|
||||
Reference in New Issue
Block a user