feat(word-templates): placeholder auto-detection from .docx via JSZip

This commit is contained in:
AI Assistant
2026-02-19 07:02:12 +02:00
parent 67fd88813a
commit 713a66bcd9
4 changed files with 529 additions and 101 deletions

View File

@@ -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 Words 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

View File

@@ -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 Words 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

View File

@@ -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<TemplateCategory, string> = {
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<ViewMode>('list');
const [editingTemplate, setEditingTemplate] = useState<WordTemplate | null>(null);
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' | 'updatedAt'>) => {
if (viewMode === 'edit' && editingTemplate) {
const handleSubmit = async (
data: Omit<WordTemplate, "id" | "createdAt" | "updatedAt">,
) => {
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() {
<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">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>
<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">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' && (
{viewMode === "list" && (
<>
<div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-[200px] flex-1">
<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>
<Select value={filters.category} onValueChange={(v) => updateFilter('category', v as TemplateCategory | 'all')}>
<SelectTrigger className="w-[160px]"><SelectValue /></SelectTrigger>
<Select
value={filters.category}
onValueChange={(v) =>
updateFilter("category", v as TemplateCategory | "all")
}
>
<SelectTrigger className="w-[160px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toate categoriile</SelectItem>
{(Object.keys(CATEGORY_LABELS) as TemplateCategory[]).map((c) => (
<SelectItem key={c} value={c}>{CATEGORY_LABELS[c]}</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>
<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>
@@ -86,28 +182,51 @@ export function WordTemplatesModule() {
<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ă
</Button>
</div>
{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 ? (
<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)}>
<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" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => setDeletingId(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>
@@ -117,22 +236,42 @@ export function WordTemplatesModule() {
</div>
<div className="min-w-0">
<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">
<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>}
<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>
<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">
<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
</a>
)}
@@ -146,23 +285,48 @@ export function WordTemplatesModule() {
</>
)}
{(viewMode === 'add' || viewMode === 'edit') && (
{(viewMode === "add" || viewMode === "edit") && (
<Card>
<CardHeader><CardTitle>{viewMode === 'edit' ? 'Editare șablon' : 'Șablon nou'}</CardTitle></CardHeader>
<CardHeader>
<CardTitle>
{viewMode === "edit" ? "Editare șablon" : "Șablon nou"}
</CardTitle>
</CardHeader>
<CardContent>
<TemplateForm initial={editingTemplate ?? undefined} onSubmit={handleSubmit} onCancel={() => { setViewMode('list'); setEditingTemplate(null); }} />
<TemplateForm
initial={editingTemplate ?? undefined}
onSubmit={handleSubmit}
onCancel={() => {
setViewMode("list");
setEditingTemplate(null);
}}
/>
</CardContent>
</Card>
)}
{/* Delete confirmation */}
<Dialog open={deletingId !== null} onOpenChange={(open) => { if (!open) setDeletingId(null); }}>
<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>
<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>
<Button variant="outline" onClick={() => setDeletingId(null)}>
Anulează
</Button>
<Button variant="destructive" onClick={handleDeleteConfirm}>
Șterge
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -170,50 +334,151 @@ export function WordTemplatesModule() {
);
}
function TemplateForm({ initial, onSubmit, onCancel }: {
function TemplateForm({
initial,
onSubmit,
onCancel,
}: {
initial?: WordTemplate;
onSubmit: (data: Omit<WordTemplate, 'id' | 'createdAt' | 'updatedAt'>) => 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<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(', '));
const [name, setName] = useState(initial?.name ?? "");
const [description, setDescription] = useState(initial?.description ?? "");
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(", "),
);
const [parsing, setParsing] = useState(false);
const [parseError, setParseError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<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">
<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>Categorie</Label>
<Select value={category} onValueChange={(v) => setCategory(v as TemplateCategory)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<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={(v) => setCategory(v as TemplateCategory)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(CATEGORY_LABELS) as TemplateCategory[]).map((c) => (
<SelectItem key={c} value={c}>{CATEGORY_LABELS[c]}</SelectItem>
<SelectItem key={c} value={c}>
{CATEGORY_LABELS[c]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div><Label>Descriere</Label><Textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={2} className="mt-1" /></div>
<div>
<Label>Descriere</Label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
className="mt-1"
/>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div><Label>Companie</Label>
<Select value={company} onValueChange={(v) => setCompany(v as CompanyId)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<div>
<Label>Companie</Label>
<Select
value={company}
onValueChange={(v) => setCompany(v as CompanyId)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="beletage">Beletage</SelectItem>
<SelectItem value="urban-switch">Urban Switch</SelectItem>
@@ -222,17 +487,94 @@ function TemplateForm({ initial, onSubmit, onCancel }: {
</SelectContent>
</Select>
</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>Versiune</Label>
<Input
value={version}
onChange={(e) => setVersion(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label>URL fișier</Label>
<div className="mt-1 flex gap-1.5">
<Input
value={fileUrl}
onChange={(e) => setFileUrl(e.target.value)}
className="flex-1"
placeholder="https://..."
/>
<Button
type="button"
variant="outline"
size="icon"
title="Detectează placeholder-e din URL"
disabled={!fileUrl || parsing}
onClick={handleUrlDetect}
>
{parsing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Wand2 className="h-4 w-4" />
)}
</Button>
</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 className="flex items-center justify-between">
<Label>Placeholder-e detectate</Label>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
sau detectează automat:
</span>
<input
ref={fileInputRef}
type="file"
accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
className="hidden"
onChange={handleFileDetect}
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={parsing}
onClick={() => fileInputRef.current?.click()}
>
{parsing ? (
<>
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />{" "}
Parsare...
</>
) : (
<>
<FolderOpen className="mr-1.5 h-3.5 w-3.5" /> Alege fișier
.docx
</>
)}
</Button>
</div>
</div>
<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}}"} detectate automat
sau introduse manual, separate prin virgulă.
</p>
{parseError && (
<p className="mt-1 text-xs text-destructive">{parseError}</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>
<Button type="button" variant="outline" onClick={onCancel}>
Anulează
</Button>
<Button type="submit">{initial ? "Actualizează" : "Adaugă"}</Button>
</div>
</form>
);

View File

@@ -0,0 +1,53 @@
import JSZip from "jszip";
/**
* Parse a .docx ArrayBuffer and extract all {{placeholder}} patterns.
* A .docx is a ZIP; we scan all word/*.xml files for patterns.
*/
export async function parsePlaceholdersFromBuffer(
buffer: ArrayBuffer,
): Promise<string[]> {
const zip = await JSZip.loadAsync(buffer);
// Collect all word/ XML files (document, headers, footers, etc.)
const xmlFileNames = Object.keys(zip.files).filter(
(name) => name.startsWith("word/") && name.endsWith(".xml"),
);
const xmlContents = await Promise.all(
xmlFileNames.map((name) => {
const file = zip.files[name];
return file ? file.async("string") : Promise.resolve("");
}),
);
const combined = xmlContents.join("\n");
// Strip XML tags — placeholders may be split across <w:t> runs, so we
// also need to find patterns that cross tag boundaries. Strategy:
// 1) Try matching in raw XML first (most placeholders appear intact)
// 2) Then strip tags and try again to catch split-run cases
const rawMatches = [...combined.matchAll(/\{\{([^{}]+?)\}\}/g)].map((m) =>
(m[1] ?? "").trim(),
);
const strippedText = combined.replace(/<[^>]+>/g, "");
const strippedMatches = [...strippedText.matchAll(/\{\{([^{}]+?)\}\}/g)].map(
(m) => (m[1] ?? "").trim(),
);
const all = [...rawMatches, ...strippedMatches];
return [...new Set(all)].filter((p) => p.length > 0 && p.length < 80);
}
/**
* Fetch a URL and parse placeholders from the .docx binary.
* May fail if CORS blocks the fetch.
*/
export async function parsePlaceholdersFromUrl(url: string): Promise<string[]> {
const response = await fetch(url);
if (!response.ok)
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
const buffer = await response.arrayBuffer();
return parsePlaceholdersFromBuffer(buffer);
}