From 2c9f0bc6b73e0e2fb07a68455065023e25e11acc Mon Sep 17 00:00:00 2001 From: Marius Tarau Date: Wed, 18 Feb 2026 06:35:47 +0200 Subject: [PATCH] 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 --- .../components/word-templates-module.tsx | 115 ++++++++++++++---- .../word-templates/hooks/use-templates.ts | 40 ++++-- src/modules/word-templates/index.ts | 2 +- src/modules/word-templates/types.ts | 17 ++- 4 files changed, 139 insertions(+), 35 deletions(-) diff --git a/src/modules/word-templates/components/word-templates-module.tsx b/src/modules/word-templates/components/word-templates-module.tsx index 6236255..8936c81 100644 --- a/src/modules/word-templates/components/word-templates-module.tsx +++ b/src/modules/word-templates/components/word-templates-module.tsx @@ -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 = { + 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('list'); const [editingTemplate, setEditingTemplate] = useState(null); + const [deletingId, setDeletingId] = useState(null); - const handleSubmit = async (data: Omit) => { + const handleSubmit = async (data: Omit) => { 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 (
{/* Stats */}

Total șabloane

{allTemplates.length}

-

Categorii

{allCategories.length}

Beletage

{allTemplates.filter((t) => t.company === 'beletage').length}

Urban Switch

{allTemplates.filter((t) => t.company === 'urban-switch').length}

+

Studii de Teren

{allTemplates.filter((t) => t.company === 'studii-de-teren').length}

{viewMode === 'list' && ( @@ -53,15 +67,25 @@ export function WordTemplatesModule() { updateFilter('search', e.target.value)} className="pl-9" />
- updateFilter('category', v as TemplateCategory | 'all')}> - Toate - {filterCategories.map((c) => ( - {c} + Toate categoriile + {(Object.keys(CATEGORY_LABELS) as TemplateCategory[]).map((c) => ( + {CATEGORY_LABELS[c]} ))} + @@ -70,19 +94,20 @@ export function WordTemplatesModule() { {loading ? (

Se încarcă...

) : templates.length === 0 ? ( -

- Niciun șablon găsit. Adaugă primul șablon Word. -

+

Niciun șablon găsit. Adaugă primul șablon Word.

) : (
{templates.map((tpl) => (
+ -
@@ -94,9 +119,18 @@ export function WordTemplatesModule() {

{tpl.name}

{tpl.description &&

{tpl.description}

}
- {tpl.category && {tpl.category}} + {CATEGORY_LABELS[tpl.category]} v{tpl.version} + {tpl.clonedFrom && Clonă}
+ {/* Placeholders display */} + {tpl.placeholders.length > 0 && ( +
+ {tpl.placeholders.map((p) => ( + {`{{${p}}}`} + ))} +
+ )} {tpl.fileUrl && ( Deschide fișier @@ -120,30 +154,58 @@ export function WordTemplatesModule() {
)} + + {/* Delete confirmation */} + { if (!open) setDeletingId(null); }}> + + Confirmare ștergere +

Ești sigur că vrei să ștergi acest șablon? Acțiunea este ireversibilă.

+ + + + +
+
); } function TemplateForm({ initial, onSubmit, onCancel }: { initial?: WordTemplate; - onSubmit: (data: Omit) => void; + onSubmit: (data: Omit) => 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(initial?.category ?? 'contract'); const [fileUrl, setFileUrl] = useState(initial?.fileUrl ?? ''); const [company, setCompany] = useState(initial?.company ?? 'beletage'); const [version, setVersion] = useState(initial?.version ?? '1.0.0'); + const [placeholdersText, setPlaceholdersText] = useState(initial?.placeholders.join(', ') ?? ''); return ( -
{ e.preventDefault(); onSubmit({ name, description, category, fileUrl, company, version, tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'all' }); }} className="space-y-4"> + { + 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">
-
setName(e.target.value)} className="mt-1" required />
+
setName(e.target.value)} className="mt-1" required />
- setCategory(v as TemplateCategory)}> - {TEMPLATE_CATEGORIES.map((c) => ({c}))} + + {(Object.keys(CATEGORY_LABELS) as TemplateCategory[]).map((c) => ( + {CATEGORY_LABELS[c]} + ))} +
@@ -163,6 +225,11 @@ function TemplateForm({ initial, onSubmit, onCancel }: {
setVersion(e.target.value)} className="mt-1" />
setFileUrl(e.target.value)} className="mt-1" placeholder="https://..." />
+
+ + setPlaceholdersText(e.target.value)} className="mt-1" placeholder="NUME_BENEFICIAR, DATA_CONTRACT, NR_PROIECT..." /> +

Variabilele din șablon, de forma {'{{VARIABILA}}'}, separate prin virgulă.

+
diff --git a/src/modules/word-templates/hooks/use-templates.ts b/src/modules/word-templates/hooks/use-templates.ts index 9c161c0..9991430 100644 --- a/src/modules/word-templates/hooks/use-templates.ts +++ b/src/modules/word-templates/hooks/use-templates.ts @@ -3,20 +3,21 @@ import { useState, useEffect, useCallback } from 'react'; import { useStorage } from '@/core/storage'; import { v4 as uuid } from 'uuid'; -import type { WordTemplate } from '../types'; +import type { WordTemplate, TemplateCategory } from '../types'; const PREFIX = 'tpl:'; export interface TemplateFilters { search: string; - category: string; + category: TemplateCategory | 'all'; + company: string; } export function useTemplates() { const storage = useStorage('word-templates'); const [templates, setTemplates] = useState([]); const [loading, setLoading] = useState(true); - const [filters, setFilters] = useState({ search: '', category: 'all' }); + const [filters, setFilters] = useState({ search: '', category: 'all', company: 'all' }); const refresh = useCallback(async () => { setLoading(true); @@ -36,8 +37,9 @@ export function useTemplates() { // eslint-disable-next-line react-hooks/set-state-in-effect useEffect(() => { refresh(); }, [refresh]); - const addTemplate = useCallback(async (data: Omit) => { - const template: WordTemplate = { ...data, id: uuid(), createdAt: new Date().toISOString() }; + const addTemplate = useCallback(async (data: Omit) => { + const now = new Date().toISOString(); + const template: WordTemplate = { ...data, id: uuid(), createdAt: now, updatedAt: now }; await storage.set(`${PREFIX}${template.id}`, template); await refresh(); return template; @@ -46,11 +48,32 @@ export function useTemplates() { const updateTemplate = useCallback(async (id: string, updates: Partial) => { const existing = templates.find((t) => t.id === id); 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 refresh(); }, [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) => { await storage.delete(`${PREFIX}${id}`); await refresh(); @@ -60,10 +83,9 @@ export function useTemplates() { setFilters((prev) => ({ ...prev, [key]: value })); }, []); - const allCategories = [...new Set(templates.map((t) => t.category).filter(Boolean))]; - const filteredTemplates = templates.filter((t) => { if (filters.category !== 'all' && t.category !== filters.category) return false; + if (filters.company !== 'all' && t.company !== filters.company) return false; if (filters.search) { const q = filters.search.toLowerCase(); return t.name.toLowerCase().includes(q) || t.description.toLowerCase().includes(q); @@ -71,5 +93,5 @@ export function useTemplates() { 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 }; } diff --git a/src/modules/word-templates/index.ts b/src/modules/word-templates/index.ts index 7b6c256..1db6604 100644 --- a/src/modules/word-templates/index.ts +++ b/src/modules/word-templates/index.ts @@ -1,3 +1,3 @@ export { wordTemplatesConfig } from './config'; export { WordTemplatesModule } from './components/word-templates-module'; -export type { WordTemplate } from './types'; +export type { WordTemplate, TemplateCategory } from './types'; diff --git a/src/modules/word-templates/types.ts b/src/modules/word-templates/types.ts index 4497904..f266f7f 100644 --- a/src/modules/word-templates/types.ts +++ b/src/modules/word-templates/types.ts @@ -1,15 +1,30 @@ import type { Visibility } from '@/core/module-registry/types'; import type { CompanyId } from '@/core/auth/types'; +export type TemplateCategory = + | 'contract' + | 'memoriu' + | 'oferta' + | 'raport' + | 'cerere' + | 'aviz' + | 'scrisoare' + | 'altele'; + export interface WordTemplate { id: string; name: string; description: string; - category: string; + category: TemplateCategory; fileUrl: string; company: CompanyId; + /** Detected placeholders in template */ + placeholders: string[]; + /** Cloned from template ID */ + clonedFrom?: string; tags: string[]; version: string; visibility: Visibility; createdAt: string; + updatedAt: string; }