From 713a66bcd991ca0473dfa01a21202d651cdaf0a1 Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Thu, 19 Feb 2026 07:02:12 +0200 Subject: [PATCH] feat(word-templates): placeholder auto-detection from .docx via JSZip --- ROADMAP.md | 4 +- SESSION-LOG.md | 33 ++ .../components/word-templates-module.tsx | 540 ++++++++++++++---- .../services/placeholder-parser.ts | 53 ++ 4 files changed, 529 insertions(+), 101 deletions(-) create mode 100644 src/modules/word-templates/services/placeholder-parser.ts diff --git a/ROADMAP.md b/ROADMAP.md index 0a02514..557a3e0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -184,7 +184,7 @@ **Status:** ✅ Done. Created `vcard-export.ts` generating vCard 3.0 (.vcf) with name, org, title, phones, emails, address, website, notes, contact persons. Added Download icon button on card hover. Added detail dialog (FileText icon) showing full contact info + scrollable table of all Registratura entries where this contact appears as sender or recipient (uses `allEntries` to bypass filters). Build ok, pushed. -### 1.10 `[STANDARD]` Word Templates — Placeholder Auto-Detection +### ✅ 1.10 `[STANDARD]` Word Templates — Placeholder Auto-Detection **What:** When a template file URL points to a `.docx`, parse it client-side to extract `{{placeholder}}` patterns and auto-populate the `placeholders[]` field. Use JSZip (already installed) to read the docx XML. **Files to modify:** @@ -193,7 +193,7 @@ **Files to create:** - `src/modules/word-templates/services/placeholder-parser.ts` ---- +**Status:** ✅ Done. `placeholder-parser.ts` uses JSZip to read all `word/*.xml` files from the .docx ZIP, searches for `{{...}}` patterns in both raw XML and stripped text (handles Word’s split-run encoding). Form now has: “Alege fișier .docx” button (local file picker, most reliable — no CORS) and a Wand icon on the URL field for URL-based detection (may fail on CORS). Parsing spinner shown during detection. Detected placeholders auto-populate the field. Build ok, pushed. ### 1.11 `[STANDARD]` Dashboard — Activity Feed + KPI Panels diff --git a/SESSION-LOG.md b/SESSION-LOG.md index 6f4975c..7b1bb91 100644 --- a/SESSION-LOG.md +++ b/SESSION-LOG.md @@ -8,6 +8,39 @@ ### Completed +- **Task 1.09: Address Book — vCard Export + Registratura Reverse Lookup** ✅ + - Created `src/modules/address-book/services/vcard-export.ts` — generates vCard 3.0 with all contact fields + - Download icon button on card hover → triggers `.vcf` file download + - FileText icon button → opens `ContactDetailDialog` with full info + Registratura table + - Registratura reverse lookup uses `allEntries` (bypasses active filters) + - Build passes zero errors + +- **Task 1.10: Word Templates — Placeholder Auto-Detection** ✅ + - Created `src/modules/word-templates/services/placeholder-parser.ts` + - Reads `.docx` (ZIP) via JSZip, scans all `word/*.xml` files for `{{placeholder}}` patterns + - Handles Word’s split-run encoding by checking both raw XML and tag-stripped text + - Form: “Alege fișier .docx” button (local file picker, CORS-free) auto-populates placeholders field + - Form: Wand icon next to URL field tries URL-based fetch detection + - Spinner during parsing, error message if detection fails + - Build passes zero errors + +### Commits + +- `da33dc9` feat(address-book): vCard export and Registratura reverse lookup +- `67fd888` docs: mark task 1.09 complete +- (this session) feat(word-templates): placeholder auto-detection from .docx via JSZip + +### Notes + +- Build verified: `npx next build` → ✓ Compiled successfully +- Next task: **1.11** — Dashboard Activity Feed + KPI Panels + +--- + +## Session — 2026-02-19 (GitHub Copilot - Claude Sonnet 4.6) [earlier] + +### Completed + - **Task 1.09: Address Book — vCard Export + Registratura Reverse Lookup** ✅ - Created `src/modules/address-book/services/vcard-export.ts` — generates vCard 3.0 (`.vcf`) with all contact fields - Added Download icon button on contact card hover → triggers `.vcf` file download diff --git a/src/modules/word-templates/components/word-templates-module.tsx b/src/modules/word-templates/components/word-templates-module.tsx index 7426475..9c8716f 100644 --- a/src/modules/word-templates/components/word-templates-module.tsx +++ b/src/modules/word-templates/components/word-templates-module.tsx @@ -1,45 +1,91 @@ -'use client'; +"use client"; -import { useState } from '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'; -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, TemplateCategory } from '../types'; -import { useTemplates } from '../hooks/use-templates'; +import { useRef, useState } from "react"; +import { + Plus, + Pencil, + Trash2, + Search, + FileText, + ExternalLink, + Copy, + FolderOpen, + Wand2, + Loader2, +} from "lucide-react"; +import { Button } from "@/shared/components/ui/button"; +import { Input } from "@/shared/components/ui/input"; +import { Label } from "@/shared/components/ui/label"; +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, TemplateCategory } from "../types"; +import { useTemplates } from "../hooks/use-templates"; +import { + parsePlaceholdersFromBuffer, + parsePlaceholdersFromUrl, +} from "../services/placeholder-parser"; const CATEGORY_LABELS: Record = { - contract: 'Contract', - memoriu: 'Memoriu tehnic', - oferta: 'Ofertă', - raport: 'Raport', - cerere: 'Cerere', - aviz: 'Aviz', - scrisoare: 'Scrisoare', - altele: '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() { - const { templates, allTemplates, loading, filters, updateFilter, addTemplate, updateTemplate, cloneTemplate, removeTemplate } = useTemplates(); - const [viewMode, setViewMode] = useState('list'); - const [editingTemplate, setEditingTemplate] = useState(null); + 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) => { - if (viewMode === 'edit' && editingTemplate) { + const handleSubmit = async ( + data: Omit, + ) => { + if (viewMode === "edit" && editingTemplate) { await updateTemplate(editingTemplate.id, data); } else { await addTemplate(data); } - setViewMode('list'); + setViewMode("list"); setEditingTemplate(null); }; @@ -54,30 +100,80 @@ export function WordTemplatesModule() {
{/* Stats */}
-

Total șabloane

{allTemplates.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}

+ + +

Total șabloane

+

{allTemplates.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' && ( + {viewMode === "list" && ( <>
- updateFilter('search', e.target.value)} className="pl-9" /> + updateFilter("search", e.target.value)} + className="pl-9" + />
- + updateFilter("category", v as TemplateCategory | "all") + } + > + + + Toate categoriile - {(Object.keys(CATEGORY_LABELS) as TemplateCategory[]).map((c) => ( - {CATEGORY_LABELS[c]} - ))} + {(Object.keys(CATEGORY_LABELS) as TemplateCategory[]).map( + (c) => ( + + {CATEGORY_LABELS[c]} + + ), + )} - updateFilter("company", v)} + > + + + Toate companiile Beletage @@ -86,28 +182,51 @@ export function WordTemplatesModule() { Grup -
{loading ? ( -

Se încarcă...

+

+ Se încarcă... +

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

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

+

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

) : (
{templates.map((tpl) => (
- - -
@@ -117,22 +236,42 @@ export function WordTemplatesModule() {

{tpl.name}

- {tpl.description &&

{tpl.description}

} + {tpl.description && ( +

+ {tpl.description} +

+ )}
- {CATEGORY_LABELS[tpl.category]} - v{tpl.version} - {tpl.clonedFrom && Clonă} + + {CATEGORY_LABELS[tpl.category]} + + + v{tpl.version} + + {tpl.clonedFrom && ( + + Clonă + + )}
{/* Placeholders display */} {(tpl.placeholders ?? []).length > 0 && (
{(tpl.placeholders ?? []).map((p) => ( - {`{{${p}}}`} + {`{{${p}}}`} ))}
)} {tpl.fileUrl && ( - + Deschide fișier )} @@ -146,23 +285,48 @@ export function WordTemplatesModule() { )} - {(viewMode === 'add' || viewMode === 'edit') && ( + {(viewMode === "add" || viewMode === "edit") && ( - {viewMode === 'edit' ? 'Editare șablon' : 'Șablon nou'} + + + {viewMode === "edit" ? "Editare șablon" : "Șablon nou"} + + - { setViewMode('list'); setEditingTemplate(null); }} /> + { + setViewMode("list"); + setEditingTemplate(null); + }} + /> )} {/* Delete confirmation */} - { if (!open) setDeletingId(null); }}> + { + if (!open) setDeletingId(null); + }} + > - Confirmare ștergere -

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

+ + Confirmare ștergere + +

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

- - + +
@@ -170,50 +334,151 @@ export function WordTemplatesModule() { ); } -function TemplateForm({ initial, onSubmit, onCancel }: { +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 [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(', ')); + const [name, setName] = useState(initial?.name ?? ""); + const [description, setDescription] = useState(initial?.description ?? ""); + 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(", "), + ); + const [parsing, setParsing] = useState(false); + const [parseError, setParseError] = useState(null); + const fileInputRef = useRef(null); + + const applyPlaceholders = (found: string[]) => { + if (found.length === 0) { + setParseError( + "Nu s-au găsit placeholder-e de forma {{VARIABILA}} în fișier.", + ); + return; + } + setPlaceholdersText(found.join(", ")); + setParseError(null); + }; + + const handleFileDetect = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + setParsing(true); + setParseError(null); + try { + const buffer = await file.arrayBuffer(); + const found = await parsePlaceholdersFromBuffer(buffer); + applyPlaceholders(found); + } catch (err) { + setParseError( + `Eroare la parsare: ${err instanceof Error ? err.message : String(err)}`, + ); + } finally { + setParsing(false); + if (fileInputRef.current) fileInputRef.current.value = ""; + } + }; + + const handleUrlDetect = async () => { + if (!fileUrl) return; + setParsing(true); + setParseError(null); + try { + const found = await parsePlaceholdersFromUrl(fileUrl); + applyPlaceholders(found); + } catch (err) { + setParseError( + `Nu s-a putut accesa URL-ul (CORS sau rețea): ${err instanceof Error ? err.message : String(err)}`, + ); + } finally { + setParsing(false); + } + }; return ( -
{ - 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"> + { + 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 + /> +
+
+ +
-