feat(word-templates): placeholder auto-detection from .docx via JSZip
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 că vrei să ștergi acest șablon? Acțiunea este ireversibilă.</p>
|
||||
<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>
|
||||
<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) => {
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const placeholders = placeholdersText
|
||||
.split(',')
|
||||
.split(",")
|
||||
.map((p) => p.trim())
|
||||
.filter((p) => p.length > 0);
|
||||
onSubmit({
|
||||
name, description, category, fileUrl, company, version, placeholders,
|
||||
name,
|
||||
description,
|
||||
category,
|
||||
fileUrl,
|
||||
company,
|
||||
version,
|
||||
placeholders,
|
||||
clonedFrom: initial?.clonedFrom,
|
||||
tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'all',
|
||||
tags: initial?.tags ?? [],
|
||||
visibility: initial?.visibility ?? "all",
|
||||
});
|
||||
}} className="space-y-4">
|
||||
}}
|
||||
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>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>
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
|
||||
53
src/modules/word-templates/services/placeholder-parser.ts
Normal file
53
src/modules/word-templates/services/placeholder-parser.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user