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.
|
**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.
|
**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:**
|
**Files to modify:**
|
||||||
@@ -193,7 +193,7 @@
|
|||||||
**Files to create:**
|
**Files to create:**
|
||||||
- `src/modules/word-templates/services/placeholder-parser.ts`
|
- `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
|
### 1.11 `[STANDARD]` Dashboard — Activity Feed + KPI Panels
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,39 @@
|
|||||||
|
|
||||||
### Completed
|
### 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** ✅
|
- **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
|
- 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
|
- 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 { useRef, useState } from "react";
|
||||||
import { Plus, Pencil, Trash2, Search, FileText, ExternalLink, Copy } from 'lucide-react';
|
import {
|
||||||
import { Button } from '@/shared/components/ui/button';
|
Plus,
|
||||||
import { Input } from '@/shared/components/ui/input';
|
Pencil,
|
||||||
import { Label } from '@/shared/components/ui/label';
|
Trash2,
|
||||||
import { Textarea } from '@/shared/components/ui/textarea';
|
Search,
|
||||||
import { Badge } from '@/shared/components/ui/badge';
|
FileText,
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
|
ExternalLink,
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
|
Copy,
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/shared/components/ui/dialog';
|
FolderOpen,
|
||||||
import type { CompanyId } from '@/core/auth/types';
|
Wand2,
|
||||||
import type { WordTemplate, TemplateCategory } from '../types';
|
Loader2,
|
||||||
import { useTemplates } from '../hooks/use-templates';
|
} 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> = {
|
const CATEGORY_LABELS: Record<TemplateCategory, string> = {
|
||||||
contract: 'Contract',
|
contract: "Contract",
|
||||||
memoriu: 'Memoriu tehnic',
|
memoriu: "Memoriu tehnic",
|
||||||
oferta: 'Ofertă',
|
oferta: "Ofertă",
|
||||||
raport: 'Raport',
|
raport: "Raport",
|
||||||
cerere: 'Cerere',
|
cerere: "Cerere",
|
||||||
aviz: 'Aviz',
|
aviz: "Aviz",
|
||||||
scrisoare: 'Scrisoare',
|
scrisoare: "Scrisoare",
|
||||||
altele: 'Altele',
|
altele: "Altele",
|
||||||
};
|
};
|
||||||
|
|
||||||
type ViewMode = 'list' | 'add' | 'edit';
|
type ViewMode = "list" | "add" | "edit";
|
||||||
|
|
||||||
export function WordTemplatesModule() {
|
export function WordTemplatesModule() {
|
||||||
const { templates, allTemplates, loading, filters, updateFilter, addTemplate, updateTemplate, cloneTemplate, removeTemplate } = useTemplates();
|
const {
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
templates,
|
||||||
const [editingTemplate, setEditingTemplate] = useState<WordTemplate | null>(null);
|
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 [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleSubmit = async (data: Omit<WordTemplate, 'id' | 'createdAt' | 'updatedAt'>) => {
|
const handleSubmit = async (
|
||||||
if (viewMode === 'edit' && editingTemplate) {
|
data: Omit<WordTemplate, "id" | "createdAt" | "updatedAt">,
|
||||||
|
) => {
|
||||||
|
if (viewMode === "edit" && editingTemplate) {
|
||||||
await updateTemplate(editingTemplate.id, data);
|
await updateTemplate(editingTemplate.id, data);
|
||||||
} else {
|
} else {
|
||||||
await addTemplate(data);
|
await addTemplate(data);
|
||||||
}
|
}
|
||||||
setViewMode('list');
|
setViewMode("list");
|
||||||
setEditingTemplate(null);
|
setEditingTemplate(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -54,30 +100,80 @@ export function WordTemplatesModule() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<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>
|
||||||
<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>
|
<CardContent className="p-4">
|
||||||
<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>
|
<p className="text-xs text-muted-foreground">Total șabloane</p>
|
||||||
<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>
|
<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>
|
</div>
|
||||||
|
|
||||||
{viewMode === 'list' && (
|
{viewMode === "list" && (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<div className="relative min-w-[200px] flex-1">
|
<div className="relative min-w-[200px] flex-1">
|
||||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
<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>
|
</div>
|
||||||
<Select value={filters.category} onValueChange={(v) => updateFilter('category', v as TemplateCategory | 'all')}>
|
<Select
|
||||||
<SelectTrigger className="w-[160px]"><SelectValue /></SelectTrigger>
|
value={filters.category}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
updateFilter("category", v as TemplateCategory | "all")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[160px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">Toate categoriile</SelectItem>
|
<SelectItem value="all">Toate categoriile</SelectItem>
|
||||||
{(Object.keys(CATEGORY_LABELS) as TemplateCategory[]).map((c) => (
|
{(Object.keys(CATEGORY_LABELS) as TemplateCategory[]).map(
|
||||||
<SelectItem key={c} value={c}>{CATEGORY_LABELS[c]}</SelectItem>
|
(c) => (
|
||||||
))}
|
<SelectItem key={c} value={c}>
|
||||||
|
{CATEGORY_LABELS[c]}
|
||||||
|
</SelectItem>
|
||||||
|
),
|
||||||
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Select value={filters.company} onValueChange={(v) => updateFilter('company', v)}>
|
<Select
|
||||||
<SelectTrigger className="w-[150px]"><SelectValue /></SelectTrigger>
|
value={filters.company}
|
||||||
|
onValueChange={(v) => updateFilter("company", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[150px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">Toate companiile</SelectItem>
|
<SelectItem value="all">Toate companiile</SelectItem>
|
||||||
<SelectItem value="beletage">Beletage</SelectItem>
|
<SelectItem value="beletage">Beletage</SelectItem>
|
||||||
@@ -86,28 +182,51 @@ export function WordTemplatesModule() {
|
|||||||
<SelectItem value="group">Grup</SelectItem>
|
<SelectItem value="group">Grup</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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ă
|
<Plus className="mr-1.5 h-4 w-4" /> Adaugă
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{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 ? (
|
) : 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">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{templates.map((tpl) => (
|
{templates.map((tpl) => (
|
||||||
<Card key={tpl.id} className="group relative">
|
<Card key={tpl.id} className="group relative">
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
<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" />
|
<Copy className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</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" />
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</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" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -117,22 +236,42 @@ export function WordTemplatesModule() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="font-medium">{tpl.name}</p>
|
<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">
|
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||||
<Badge variant="outline" className="text-[10px]">{CATEGORY_LABELS[tpl.category]}</Badge>
|
<Badge variant="outline" className="text-[10px]">
|
||||||
<Badge variant="secondary" className="text-[10px]">v{tpl.version}</Badge>
|
{CATEGORY_LABELS[tpl.category]}
|
||||||
{tpl.clonedFrom && <Badge variant="secondary" className="text-[10px]">Clonă</Badge>}
|
</Badge>
|
||||||
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
|
v{tpl.version}
|
||||||
|
</Badge>
|
||||||
|
{tpl.clonedFrom && (
|
||||||
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
|
Clonă
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Placeholders display */}
|
{/* Placeholders display */}
|
||||||
{(tpl.placeholders ?? []).length > 0 && (
|
{(tpl.placeholders ?? []).length > 0 && (
|
||||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||||
{(tpl.placeholders ?? []).map((p) => (
|
{(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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{tpl.fileUrl && (
|
{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
|
<ExternalLink className="h-3 w-3" /> Deschide fișier
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
@@ -146,23 +285,48 @@ export function WordTemplatesModule() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(viewMode === 'add' || viewMode === 'edit') && (
|
{(viewMode === "add" || viewMode === "edit") && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader><CardTitle>{viewMode === 'edit' ? 'Editare șablon' : 'Șablon nou'}</CardTitle></CardHeader>
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
{viewMode === "edit" ? "Editare șablon" : "Șablon nou"}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<TemplateForm initial={editingTemplate ?? undefined} onSubmit={handleSubmit} onCancel={() => { setViewMode('list'); setEditingTemplate(null); }} />
|
<TemplateForm
|
||||||
|
initial={editingTemplate ?? undefined}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onCancel={() => {
|
||||||
|
setViewMode("list");
|
||||||
|
setEditingTemplate(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Delete confirmation */}
|
{/* Delete confirmation */}
|
||||||
<Dialog open={deletingId !== null} onOpenChange={(open) => { if (!open) setDeletingId(null); }}>
|
<Dialog
|
||||||
|
open={deletingId !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setDeletingId(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader><DialogTitle>Confirmare ștergere</DialogTitle></DialogHeader>
|
<DialogHeader>
|
||||||
<p className="text-sm">Ești sigur că vrei să ștergi acest șablon? Acțiunea este ireversibilă.</p>
|
<DialogTitle>Confirmare ștergere</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="text-sm">
|
||||||
|
Ești sigur că vrei să ștergi acest șablon? Acțiunea este
|
||||||
|
ireversibilă.
|
||||||
|
</p>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setDeletingId(null)}>Anulează</Button>
|
<Button variant="outline" onClick={() => setDeletingId(null)}>
|
||||||
<Button variant="destructive" onClick={handleDeleteConfirm}>Șterge</Button>
|
Anulează
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={handleDeleteConfirm}>
|
||||||
|
Șterge
|
||||||
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -170,50 +334,151 @@ export function WordTemplatesModule() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TemplateForm({ initial, onSubmit, onCancel }: {
|
function TemplateForm({
|
||||||
|
initial,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
initial?: WordTemplate;
|
initial?: WordTemplate;
|
||||||
onSubmit: (data: Omit<WordTemplate, 'id' | 'createdAt' | 'updatedAt'>) => void;
|
onSubmit: (
|
||||||
|
data: Omit<WordTemplate, "id" | "createdAt" | "updatedAt">,
|
||||||
|
) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [name, setName] = useState(initial?.name ?? '');
|
const [name, setName] = useState(initial?.name ?? "");
|
||||||
const [description, setDescription] = useState(initial?.description ?? '');
|
const [description, setDescription] = useState(initial?.description ?? "");
|
||||||
const [category, setCategory] = useState<TemplateCategory>(initial?.category ?? 'contract');
|
const [category, setCategory] = useState<TemplateCategory>(
|
||||||
const [fileUrl, setFileUrl] = useState(initial?.fileUrl ?? '');
|
initial?.category ?? "contract",
|
||||||
const [company, setCompany] = useState<CompanyId>(initial?.company ?? 'beletage');
|
);
|
||||||
const [version, setVersion] = useState(initial?.version ?? '1.0.0');
|
const [fileUrl, setFileUrl] = useState(initial?.fileUrl ?? "");
|
||||||
const [placeholdersText, setPlaceholdersText] = useState((initial?.placeholders ?? []).join(', '));
|
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 (
|
return (
|
||||||
<form onSubmit={(e) => {
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const placeholders = placeholdersText
|
const placeholders = placeholdersText
|
||||||
.split(',')
|
.split(",")
|
||||||
.map((p) => p.trim())
|
.map((p) => p.trim())
|
||||||
.filter((p) => p.length > 0);
|
.filter((p) => p.length > 0);
|
||||||
onSubmit({
|
onSubmit({
|
||||||
name, description, category, fileUrl, company, version, placeholders,
|
name,
|
||||||
|
description,
|
||||||
|
category,
|
||||||
|
fileUrl,
|
||||||
|
company,
|
||||||
|
version,
|
||||||
|
placeholders,
|
||||||
clonedFrom: initial?.clonedFrom,
|
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 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>
|
||||||
<div><Label>Categorie</Label>
|
<Label>Nume șablon *</Label>
|
||||||
<Select value={category} onValueChange={(v) => setCategory(v as TemplateCategory)}>
|
<Input
|
||||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
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>
|
<SelectContent>
|
||||||
{(Object.keys(CATEGORY_LABELS) as TemplateCategory[]).map((c) => (
|
{(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>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</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 className="grid gap-4 sm:grid-cols-3">
|
||||||
<div><Label>Companie</Label>
|
<div>
|
||||||
<Select value={company} onValueChange={(v) => setCompany(v as CompanyId)}>
|
<Label>Companie</Label>
|
||||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
<Select
|
||||||
|
value={company}
|
||||||
|
onValueChange={(v) => setCompany(v as CompanyId)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="beletage">Beletage</SelectItem>
|
<SelectItem value="beletage">Beletage</SelectItem>
|
||||||
<SelectItem value="urban-switch">Urban Switch</SelectItem>
|
<SelectItem value="urban-switch">Urban Switch</SelectItem>
|
||||||
@@ -222,17 +487,94 @@ function TemplateForm({ initial, onSubmit, onCancel }: {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div><Label>Versiune</Label><Input value={version} onChange={(e) => setVersion(e.target.value)} className="mt-1" /></div>
|
<div>
|
||||||
<div><Label>URL fișier</Label><Input value={fileUrl} onChange={(e) => setFileUrl(e.target.value)} className="mt-1" placeholder="https://..." /></div>
|
<Label>Versiune</Label>
|
||||||
|
<Input
|
||||||
|
value={version}
|
||||||
|
onChange={(e) => setVersion(e.target.value)}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>Placeholder-e (separate prin virgulă)</Label>
|
<Label>URL fișier</Label>
|
||||||
<Input value={placeholdersText} onChange={(e) => setPlaceholdersText(e.target.value)} className="mt-1" placeholder="NUME_BENEFICIAR, DATA_CONTRACT, NR_PROIECT..." />
|
<div className="mt-1 flex gap-1.5">
|
||||||
<p className="mt-1 text-xs text-muted-foreground">Variabilele din șablon, de forma {'{{VARIABILA}}'}, separate prin virgulă.</p>
|
<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>
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button>
|
<Button type="button" variant="outline" onClick={onCancel}>
|
||||||
<Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button>
|
Anulează
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">{initial ? "Actualizează" : "Adaugă"}</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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