3.06 Template Library Redenumire, Versionare, Multi-format

- Renamed from 'Sabloane Word' to 'Biblioteca Sabloane' (Template Library)
- Multi-format support: Word, Excel, PDF, DWG, Archicad with auto-detection
- Auto versioning: 'Revizie Noua' button archives current version, bumps semver
- Version history dialog: browse and download any previous version
- Simplified UX: file upload vs external link toggle, auto-detect placeholders
  silently for .docx, hide placeholders section for non-Word formats
- File type icons: distinct icons for docx, xlsx, archicad, dwg, pdf
- Updated stats cards: Word/Excel count, DWG/Archicad count, versioned count
- Backward compatible: old entries without fileType/versionHistory get defaults
This commit is contained in:
AI Assistant
2026-02-28 02:33:57 +02:00
parent 4f6964ac41
commit 5992fc867d
7 changed files with 529 additions and 182 deletions
+3 -2
View File
@@ -91,8 +91,9 @@ export const ro: Labels = {
description: "Clienți, furnizori, instituții", description: "Clienți, furnizori, instituții",
}, },
"word-templates": { "word-templates": {
title: "Șabloane Word", title: "Bibliotecă Șabloane",
description: "Bibliotecă contracte, oferte, rapoarte", description:
"Șabloane contracte, oferte, rapoarte (Word, Excel, Archicad, DWG, PDF)",
}, },
"tag-manager": { "tag-manager": {
title: "Manager Etichete", title: "Manager Etichete",
@@ -1,25 +1,32 @@
'use client'; "use client";
import { useSignatureConfig } from '../hooks/use-signature-config'; import { useSignatureConfig } from "../hooks/use-signature-config";
import { useSavedSignatures } from '../hooks/use-saved-signatures'; import { useSavedSignatures } from "../hooks/use-saved-signatures";
import { SignatureConfigurator } from './signature-configurator'; import { SignatureConfigurator } from "./signature-configurator";
import { SignaturePreview } from './signature-preview'; import { SignaturePreview } from "./signature-preview";
import { SavedSignaturesPanel } from './saved-signatures-panel'; import { SavedSignaturesPanel } from "./saved-signatures-panel";
import { Separator } from '@/shared/components/ui/separator'; import { Separator } from "@/shared/components/ui/separator";
import { Button } from '@/shared/components/ui/button'; import { Button } from "@/shared/components/ui/button";
import { RotateCcw } from 'lucide-react'; import { RotateCcw } from "lucide-react";
import type { SignatureBanner } from '../types'; import type { SignatureBanner } from "../types";
export function EmailSignatureModule() { export function EmailSignatureModule() {
const { const {
config, updateField, updateColor, updateLayout, config,
setVariant, setCompany, setAddress, resetToDefaults, loadConfig, updateField,
updateColor,
updateLayout,
setVariant,
setCompany,
setAddress,
resetToDefaults,
loadConfig,
} = useSignatureConfig(); } = useSignatureConfig();
const { saved, loading, save, remove } = useSavedSignatures(); const { saved, loading, save, remove } = useSavedSignatures();
const setBanner = (banner: SignatureBanner | undefined) => { const setBanner = (banner: SignatureBanner | undefined) => {
updateField('banner', banner); updateField("banner", banner);
}; };
return ( return (
@@ -42,7 +49,9 @@ export function EmailSignatureModule() {
<SavedSignaturesPanel <SavedSignaturesPanel
saved={saved} saved={saved}
loading={loading} loading={loading}
onSave={async (label, cfg) => { await save(label, cfg); }} onSave={async (label, cfg) => {
await save(label, cfg);
}}
onLoad={loadConfig} onLoad={loadConfig}
onRemove={remove} onRemove={remove}
currentConfig={config} currentConfig={config}
@@ -50,7 +59,12 @@ export function EmailSignatureModule() {
<Separator /> <Separator />
<Button variant="outline" size="sm" onClick={resetToDefaults} className="w-full"> <Button
variant="outline"
size="sm"
onClick={resetToDefaults}
className="w-full"
>
<RotateCcw className="mr-1.5 h-3.5 w-3.5" /> <RotateCcw className="mr-1.5 h-3.5 w-3.5" />
Resetare la valorile implicite Resetare la valorile implicite
</Button> </Button>
@@ -7,11 +7,17 @@ import {
Trash2, Trash2,
Search, Search,
FileText, FileText,
FileSpreadsheet,
FileArchive,
File,
ExternalLink, ExternalLink,
Copy, Copy,
FolderOpen, FolderOpen,
Wand2,
Loader2, Loader2,
History,
Link,
Upload,
RotateCcw,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input"; import { Input } from "@/shared/components/ui/input";
@@ -39,12 +45,13 @@ import {
DialogFooter, DialogFooter,
} from "@/shared/components/ui/dialog"; } from "@/shared/components/ui/dialog";
import type { CompanyId } from "@/core/auth/types"; import type { CompanyId } from "@/core/auth/types";
import type { WordTemplate, TemplateCategory } from "../types"; import type {
WordTemplate,
TemplateCategory,
TemplateFileType,
} from "../types";
import { useTemplates } from "../hooks/use-templates"; import { useTemplates } from "../hooks/use-templates";
import { import { parsePlaceholdersFromBuffer } from "../services/placeholder-parser";
parsePlaceholdersFromBuffer,
parsePlaceholdersFromUrl,
} from "../services/placeholder-parser";
const CATEGORY_LABELS: Record<TemplateCategory, string> = { const CATEGORY_LABELS: Record<TemplateCategory, string> = {
contract: "Contract", contract: "Contract",
@@ -57,6 +64,44 @@ const CATEGORY_LABELS: Record<TemplateCategory, string> = {
altele: "Altele", altele: "Altele",
}; };
const FILE_TYPE_LABELS: Record<TemplateFileType, string> = {
docx: "Word (.docx)",
xlsx: "Excel (.xlsx)",
pdf: "PDF",
dwg: "DWG",
archicad: "Archicad (.tpl/.pln)",
other: "Altul",
};
function detectFileType(url: string): TemplateFileType {
const ext = url.split(".").pop()?.toLowerCase() ?? "";
if (ext === "docx" || ext === "doc") return "docx";
if (ext === "xlsx" || ext === "xls") return "xlsx";
if (ext === "pdf") return "pdf";
if (ext === "dwg" || ext === "dxf") return "dwg";
if (["tpl", "pln", "pla", "bpn"].includes(ext)) return "archicad";
return "other";
}
function FileTypeIcon({
fileType,
className,
}: {
fileType: TemplateFileType;
className?: string;
}) {
switch (fileType) {
case "docx":
return <FileText className={className} />;
case "xlsx":
return <FileSpreadsheet className={className} />;
case "archicad":
return <FileArchive className={className} />;
default:
return <File className={className} />;
}
}
type ViewMode = "list" | "add" | "edit"; type ViewMode = "list" | "add" | "edit";
export function WordTemplatesModule() { export function WordTemplatesModule() {
@@ -68,6 +113,7 @@ export function WordTemplatesModule() {
updateFilter, updateFilter,
addTemplate, addTemplate,
updateTemplate, updateTemplate,
createRevision,
cloneTemplate, cloneTemplate,
removeTemplate, removeTemplate,
} = useTemplates(); } = useTemplates();
@@ -76,6 +122,13 @@ export function WordTemplatesModule() {
null, null,
); );
const [deletingId, setDeletingId] = useState<string | null>(null); const [deletingId, setDeletingId] = useState<string | null>(null);
const [revisingTemplate, setRevisingTemplate] =
useState<WordTemplate | null>(null);
const [revisionUrl, setRevisionUrl] = useState("");
const [revisionNotes, setRevisionNotes] = useState("");
const [historyTemplate, setHistoryTemplate] = useState<WordTemplate | null>(
null,
);
const handleSubmit = async ( const handleSubmit = async (
data: Omit<WordTemplate, "id" | "createdAt" | "updatedAt">, data: Omit<WordTemplate, "id" | "createdAt" | "updatedAt">,
@@ -96,6 +149,15 @@ export function WordTemplatesModule() {
} }
}; };
const handleRevisionConfirm = async () => {
if (revisingTemplate) {
await createRevision(revisingTemplate.id, revisionUrl, revisionNotes);
setRevisingTemplate(null);
setRevisionUrl("");
setRevisionNotes("");
}
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Stats */} {/* Stats */}
@@ -108,28 +170,25 @@ export function WordTemplatesModule() {
</Card> </Card>
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<p className="text-xs text-muted-foreground">Beletage</p> <p className="text-xs text-muted-foreground">Word/Excel</p>
<p className="text-2xl font-bold"> <p className="text-2xl font-bold">
{allTemplates.filter((t) => t.company === "beletage").length} {allTemplates.filter((t) => (t.fileType ?? "docx") === "docx" || t.fileType === "xlsx").length}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<p className="text-xs text-muted-foreground">Urban Switch</p> <p className="text-xs text-muted-foreground">DWG/Archicad</p>
<p className="text-2xl font-bold"> <p className="text-2xl font-bold">
{allTemplates.filter((t) => t.company === "urban-switch").length} {allTemplates.filter((t) => t.fileType === "dwg" || t.fileType === "archicad").length}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<p className="text-xs text-muted-foreground">Studii de Teren</p> <p className="text-xs text-muted-foreground">Cu versiuni</p>
<p className="text-2xl font-bold"> <p className="text-2xl font-bold">
{ {allTemplates.filter((t) => (t.versionHistory ?? []).length > 0).length}
allTemplates.filter((t) => t.company === "studii-de-teren")
.length
}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -193,7 +252,7 @@ export function WordTemplatesModule() {
</p> </p>
) : templates.length === 0 ? ( ) : templates.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground"> <p className="py-8 text-center text-sm text-muted-foreground">
Niciun șablon găsit. Adaugă primul șablon Word. Niciun șablon găsit. Adaugă primul șablon.
</p> </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">
@@ -201,6 +260,15 @@ export function WordTemplatesModule() {
<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="Revizie nouă"
onClick={() => setRevisingTemplate(tpl)}
>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -232,7 +300,10 @@ export function WordTemplatesModule() {
</div> </div>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border bg-muted/30"> <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border bg-muted/30">
<FileText className="h-5 w-5 text-muted-foreground" /> <FileTypeIcon
fileType={tpl.fileType ?? "docx"}
className="h-5 w-5 text-muted-foreground"
/>
</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>
@@ -248,33 +319,49 @@ export function WordTemplatesModule() {
<Badge variant="secondary" className="text-[10px]"> <Badge variant="secondary" className="text-[10px]">
v{tpl.version} v{tpl.version}
</Badge> </Badge>
<Badge variant="secondary" className="text-[10px]">
{FILE_TYPE_LABELS[tpl.fileType ?? "docx"]}
</Badge>
{tpl.clonedFrom && ( {tpl.clonedFrom && (
<Badge variant="secondary" className="text-[10px]"> <Badge variant="secondary" className="text-[10px]">
Clonă Clonă
</Badge> </Badge>
)} )}
</div> </div>
{/* Placeholders display */} {/* Placeholders display (only for .docx) */}
{(tpl.placeholders ?? []).length > 0 && ( {(tpl.fileType ?? "docx") === "docx" &&
<div className="mt-1.5 flex flex-wrap gap-1"> (tpl.placeholders ?? []).length > 0 && (
{(tpl.placeholders ?? []).map((p) => ( <div className="mt-1.5 flex flex-wrap gap-1">
<span {(tpl.placeholders ?? []).map((p) => (
key={p} <span
className="rounded bg-muted px-1 py-0.5 font-mono text-[10px] text-muted-foreground" key={p}
>{`{{${p}}}`}</span> className="rounded bg-muted px-1 py-0.5 font-mono text-[10px] text-muted-foreground"
))} >{`{{${p}}}`}</span>
</div> ))}
)} </div>
{tpl.fileUrl && ( )}
<a <div className="mt-1.5 flex items-center gap-2">
href={tpl.fileUrl} {tpl.fileUrl && (
target="_blank" <a
rel="noopener noreferrer" href={tpl.fileUrl}
className="mt-1 inline-flex items-center gap-1 text-xs text-primary hover:underline" target="_blank"
> rel="noopener noreferrer"
<ExternalLink className="h-3 w-3" /> Deschide fișier className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
</a> >
)} <ExternalLink className="h-3 w-3" /> Deschide
</a>
)}
{(tpl.versionHistory ?? []).length > 0 && (
<button
type="button"
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
onClick={() => setHistoryTemplate(tpl)}
>
<History className="h-3 w-3" />{" "}
{(tpl.versionHistory ?? []).length} versiuni
</button>
)}
</div>
</div> </div>
</div> </div>
</CardContent> </CardContent>
@@ -330,6 +417,144 @@ export function WordTemplatesModule() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Revision dialog */}
<Dialog
open={revisingTemplate !== null}
onOpenChange={(open) => {
if (!open) {
setRevisingTemplate(null);
setRevisionUrl("");
setRevisionNotes("");
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
Revizie nouă {revisingTemplate?.name}
</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Versiunea curentă <strong>v{revisingTemplate?.version}</strong> va
fi arhivată. Se va crea automat versiunea următoare.
</p>
<div>
<Label>URL fișier nou (opțional)</Label>
<Input
value={revisionUrl}
onChange={(e) => setRevisionUrl(e.target.value)}
placeholder="Lasă gol pentru a păstra URL-ul actual"
className="mt-1"
/>
</div>
<div>
<Label>Note revizie</Label>
<Textarea
value={revisionNotes}
onChange={(e) => setRevisionNotes(e.target.value)}
rows={2}
placeholder="Ce s-a modificat..."
className="mt-1"
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setRevisingTemplate(null);
setRevisionUrl("");
setRevisionNotes("");
}}
>
Anulează
</Button>
<Button onClick={handleRevisionConfirm}>
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
Crează revizia
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Version history dialog */}
<Dialog
open={historyTemplate !== null}
onOpenChange={(open) => {
if (!open) setHistoryTemplate(null);
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
Istoric versiuni {historyTemplate?.name}
</DialogTitle>
</DialogHeader>
<div className="space-y-2">
{/* Current version */}
<div className="rounded-md border p-3">
<div className="flex items-center justify-between">
<Badge>v{historyTemplate?.version} (curentă)</Badge>
{historyTemplate?.fileUrl && (
<a
href={historyTemplate.fileUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline"
>
Deschide
</a>
)}
</div>
</div>
{/* Previous versions */}
{[...(historyTemplate?.versionHistory ?? [])]
.reverse()
.map((entry, idx) => (
<div key={idx} className="rounded-md border p-3">
<div className="flex items-center justify-between">
<Badge variant="secondary">v{entry.version}</Badge>
<div className="flex items-center gap-2">
<span className="text-[10px] text-muted-foreground">
{new Date(entry.createdAt).toLocaleDateString("ro-RO")}
</span>
{entry.fileUrl && (
<a
href={entry.fileUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline"
>
Deschide
</a>
)}
</div>
</div>
{entry.notes && (
<p className="mt-1 text-xs text-muted-foreground">
{entry.notes}
</p>
)}
</div>
))}
{(historyTemplate?.versionHistory ?? []).length === 0 && (
<p className="py-4 text-center text-sm text-muted-foreground">
Nu există versiuni anterioare.
</p>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setHistoryTemplate(null)}
>
Închide
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }
@@ -351,79 +576,83 @@ function TemplateForm({
initial?.category ?? "contract", initial?.category ?? "contract",
); );
const [fileUrl, setFileUrl] = useState(initial?.fileUrl ?? ""); const [fileUrl, setFileUrl] = useState(initial?.fileUrl ?? "");
const [fileType, setFileType] = useState<TemplateFileType>(
initial?.fileType ?? "docx",
);
const [company, setCompany] = useState<CompanyId>( const [company, setCompany] = useState<CompanyId>(
initial?.company ?? "beletage", initial?.company ?? "beletage",
); );
const [version, setVersion] = useState(initial?.version ?? "1.0.0"); const [placeholders, setPlaceholders] = useState<string[]>(
const [placeholdersText, setPlaceholdersText] = useState( initial?.placeholders ?? [],
(initial?.placeholders ?? []).join(", "),
); );
const [parsing, setParsing] = useState(false); const [parsing, setParsing] = useState(false);
const [parseError, setParseError] = useState<string | null>(null); const [parseMessage, setParseMessage] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [urlMode, setUrlMode] = useState<"link" | "upload">(
initial?.fileUrl ? "link" : "link",
);
const applyPlaceholders = (found: string[]) => { const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
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]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
setParsing(true);
setParseError(null); // Set name from filename if empty
try { if (!name) {
const buffer = await file.arrayBuffer(); setName(file.name.replace(/\.[^.]+$/, ""));
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 = "";
} }
// Detect file type from extension
const detected = detectFileType(file.name);
setFileType(detected);
// Auto-detect placeholders for .docx (silently in background)
if (detected === "docx") {
setParsing(true);
setParseMessage(null);
try {
const buffer = await file.arrayBuffer();
const found = await parsePlaceholdersFromBuffer(buffer);
setPlaceholders(found);
if (found.length > 0) {
setParseMessage(`${found.length} placeholder-e detectate automat`);
}
} catch {
// Silent fail — user can still add manually
} finally {
setParsing(false);
}
}
// For now store nothing (URL mode is "link extern" or future MinIO)
// Show message about file storage
setParseMessage(
(prev) =>
(prev ? prev + ". " : "") +
"Fișierul va fi disponibil după integrarea MinIO. Adaugă un link extern.",
);
if (fileInputRef.current) fileInputRef.current.value = "";
}; };
const handleUrlDetect = async () => { const handleUrlChange = (url: string) => {
if (!fileUrl) return; setFileUrl(url);
setParsing(true); const detected = detectFileType(url);
setParseError(null); setFileType(detected);
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 <form
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
const placeholders = placeholdersText
.split(",")
.map((p) => p.trim())
.filter((p) => p.length > 0);
onSubmit({ onSubmit({
name, name,
description, description,
category, category,
fileUrl, fileUrl,
fileType,
company, company,
version, version: initial?.version ?? "1.0",
placeholders, placeholders,
versionHistory: initial?.versionHistory ?? [],
clonedFrom: initial?.clonedFrom, clonedFrom: initial?.clonedFrom,
tags: initial?.tags ?? [], tags: initial?.tags ?? [],
visibility: initial?.visibility ?? "all", visibility: initial?.visibility ?? "all",
@@ -469,7 +698,7 @@ function TemplateForm({
className="mt-1" className="mt-1"
/> />
</div> </div>
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-2">
<div> <div>
<Label>Companie</Label> <Label>Companie</Label>
<Select <Select
@@ -488,88 +717,118 @@ function TemplateForm({
</Select> </Select>
</div> </div>
<div> <div>
<Label>Versiune</Label> <Label>Tip fișier</Label>
<Input <Select
value={version} value={fileType}
onChange={(e) => setVersion(e.target.value)} onValueChange={(v) => setFileType(v as TemplateFileType)}
className="mt-1" >
/> <SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(
Object.keys(FILE_TYPE_LABELS) as TemplateFileType[]
).map((ft) => (
<SelectItem key={ft} value={ft}>
{FILE_TYPE_LABELS[ft]}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
<div> </div>
<Label>URL fișier</Label>
<div className="mt-1 flex gap-1.5"> {/* File source — upload or link */}
<Input <div>
value={fileUrl} <div className="mb-2 flex items-center gap-2">
onChange={(e) => setFileUrl(e.target.value)} <Label>Sursă fișier</Label>
className="flex-1" <div className="flex rounded-md border">
placeholder="https://..."
/>
<Button <Button
type="button" type="button"
variant="outline" variant={urlMode === "link" ? "secondary" : "ghost"}
size="icon" size="sm"
title="Detectează placeholder-e din URL" className="h-7 rounded-r-none text-xs"
disabled={!fileUrl || parsing} onClick={() => setUrlMode("link")}
onClick={handleUrlDetect}
> >
{parsing ? ( <Link className="mr-1 h-3 w-3" /> Link extern
<Loader2 className="h-4 w-4 animate-spin" /> </Button>
) : ( <Button
<Wand2 className="h-4 w-4" /> type="button"
)} variant={urlMode === "upload" ? "secondary" : "ghost"}
size="sm"
className="h-7 rounded-l-none text-xs"
onClick={() => setUrlMode("upload")}
>
<Upload className="mr-1 h-3 w-3" /> Încarcă fișier
</Button> </Button>
</div> </div>
</div> </div>
</div> {urlMode === "link" ? (
<div> <Input
<div className="flex items-center justify-between"> value={fileUrl}
<Label>Placeholder-e detectate</Label> onChange={(e) => handleUrlChange(e.target.value)}
<div className="flex items-center gap-2"> placeholder="https://sharepoint.com/... sau link NextCloud"
<span className="text-xs text-muted-foreground"> />
sau detectează automat: ) : (
</span> <div>
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document" accept=".docx,.xlsx,.xls,.pdf,.dwg,.dxf,.tpl,.pln,.pla,.bpn"
className="hidden" className="hidden"
onChange={handleFileDetect} onChange={handleFileUpload}
/> />
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm" className="w-full"
disabled={parsing} disabled={parsing}
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
> >
{parsing ? ( {parsing ? (
<> <>
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />{" "} <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />{" "}
Parsare... Analiză fișier...
</> </>
) : ( ) : (
<> <>
<FolderOpen className="mr-1.5 h-3.5 w-3.5" /> Alege fișier <FolderOpen className="mr-1.5 h-3.5 w-3.5" /> Alege fișier
.docx
</> </>
)} )}
</Button> </Button>
<p className="mt-1 text-xs text-muted-foreground">
Selectează un fișier pentru a detecta automat tipul și
placeholder-ele (pentru .docx). Stocare pe MinIO în curând.
</p>
</div> </div>
</div> )}
<Input {parseMessage && (
value={placeholdersText} <p className="mt-1 text-xs text-blue-600">{parseMessage}</p>
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>
{/* Placeholders — only for docx */}
{fileType === "docx" && (
<div>
<Label>Placeholder-e detectate</Label>
{placeholders.length > 0 ? (
<div className="mt-1 flex flex-wrap gap-1">
{placeholders.map((p) => (
<span
key={p}
className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-muted-foreground"
>{`{{${p}}}`}</span>
))}
</div>
) : (
<p className="mt-1 text-xs text-muted-foreground">
Încarcă un fișier .docx pentru detecție automată, sau vor fi
detectate la încărcarea fișierului.
</p>
)}
</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}> <Button type="button" variant="outline" onClick={onCancel}>
Anulează Anulează
+13 -12
View File
@@ -1,17 +1,18 @@
import type { ModuleConfig } from '@/core/module-registry/types'; import type { ModuleConfig } from "@/core/module-registry/types";
export const wordTemplatesConfig: ModuleConfig = { export const wordTemplatesConfig: ModuleConfig = {
id: 'word-templates', id: "word-templates",
name: 'Șabloane Word', name: "Bibliotecă Șabloane",
description: 'Bibliotecă de șabloane Word organizate pe categorii cu suport versionare', description:
icon: 'file-text', "Bibliotecă de șabloane documente cu versionare automată (Word, Excel, Archicad, DWG, PDF)",
route: '/word-templates', icon: "file-text",
category: 'generators', route: "/word-templates",
featureFlag: 'module.word-templates', category: "generators",
visibility: 'all', featureFlag: "module.word-templates",
version: '0.1.0', visibility: "all",
version: "0.2.0",
dependencies: [], dependencies: [],
storageNamespace: 'word-templates', storageNamespace: "word-templates",
navOrder: 22, navOrder: 22,
tags: ['word', 'șabloane', 'documente'], tags: ["word", "șabloane", "documente", "excel", "archicad", "dwg"],
}; };
@@ -13,6 +13,17 @@ export interface TemplateFilters {
company: string; company: string;
} }
/** Increment a semver-like version string (e.g. "1.2" → "1.3") */
function bumpVersion(ver: string): string {
const parts = ver.split(".");
if (parts.length >= 2) {
const minor = parseInt(parts[parts.length - 1] ?? "0", 10);
parts[parts.length - 1] = String(isNaN(minor) ? 1 : minor + 1);
return parts.join(".");
}
return `${ver}.1`;
}
export function useTemplates() { export function useTemplates() {
const storage = useStorage("word-templates"); const storage = useStorage("word-templates");
const [templates, setTemplates] = useState<WordTemplate[]>([]); const [templates, setTemplates] = useState<WordTemplate[]>([]);
@@ -29,7 +40,11 @@ export function useTemplates() {
const results: WordTemplate[] = []; const results: WordTemplate[] = [];
for (const [key, value] of Object.entries(all)) { for (const [key, value] of Object.entries(all)) {
if (key.startsWith(PREFIX) && value) { if (key.startsWith(PREFIX) && value) {
results.push(value as WordTemplate); const tpl = value as WordTemplate;
// Backfill new fields for old entries
if (!tpl.fileType) tpl.fileType = "docx";
if (!tpl.versionHistory) tpl.versionHistory = [];
results.push(tpl);
} }
} }
results.sort((a, b) => a.name.localeCompare(b.name)); results.sort((a, b) => a.name.localeCompare(b.name));
@@ -75,6 +90,36 @@ export function useTemplates() {
[storage, refresh, templates], [storage, refresh, templates],
); );
/** Create a new revision: archives current version in history, bumps version */
const createRevision = useCallback(
async (id: string, newFileUrl: string, notes: string) => {
const existing = templates.find((t) => t.id === id);
if (!existing) return;
const now = new Date().toISOString();
const history = [...(existing.versionHistory ?? [])];
// Archive current version
history.push({
version: existing.version,
fileUrl: existing.fileUrl,
notes: "",
createdAt: existing.updatedAt,
});
const updated: WordTemplate = {
...existing,
version: bumpVersion(existing.version),
fileUrl: newFileUrl || existing.fileUrl,
versionHistory: history,
updatedAt: now,
};
if (notes) {
updated.versionHistory[updated.versionHistory.length - 1]!.notes = notes;
}
await storage.set(`${PREFIX}${id}`, updated);
await refresh();
},
[storage, refresh, templates],
);
const cloneTemplate = useCallback( const cloneTemplate = useCallback(
async (id: string) => { async (id: string) => {
const existing = templates.find((t) => t.id === id); const existing = templates.find((t) => t.id === id);
@@ -85,6 +130,7 @@ export function useTemplates() {
id: uuid(), id: uuid(),
name: `${existing.name} (copie)`, name: `${existing.name} (copie)`,
clonedFrom: existing.id, clonedFrom: existing.id,
versionHistory: [],
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}; };
@@ -133,6 +179,7 @@ export function useTemplates() {
updateFilter, updateFilter,
addTemplate, addTemplate,
updateTemplate, updateTemplate,
createRevision,
cloneTemplate, cloneTemplate,
removeTemplate, removeTemplate,
refresh, refresh,
+8 -3
View File
@@ -1,3 +1,8 @@
export { wordTemplatesConfig } from './config'; export { wordTemplatesConfig } from "./config";
export { WordTemplatesModule } from './components/word-templates-module'; export { WordTemplatesModule } from "./components/word-templates-module";
export type { WordTemplate, TemplateCategory } from './types'; export type {
WordTemplate,
TemplateCategory,
TemplateFileType,
TemplateVersionEntry,
} from "./types";
+31 -11
View File
@@ -1,29 +1,49 @@
import type { Visibility } from '@/core/module-registry/types'; import type { Visibility } from "@/core/module-registry/types";
import type { CompanyId } from '@/core/auth/types'; import type { CompanyId } from "@/core/auth/types";
export type TemplateCategory = export type TemplateCategory =
| 'contract' | "contract"
| 'memoriu' | "memoriu"
| 'oferta' | "oferta"
| 'raport' | "raport"
| 'cerere' | "cerere"
| 'aviz' | "aviz"
| 'scrisoare' | "scrisoare"
| 'altele'; | "altele";
export type TemplateFileType =
| "docx"
| "xlsx"
| "pdf"
| "dwg"
| "archicad"
| "other";
export interface TemplateVersionEntry {
version: string;
fileUrl: string;
notes: string;
createdAt: string;
}
export interface WordTemplate { export interface WordTemplate {
id: string; id: string;
name: string; name: string;
description: string; description: string;
category: TemplateCategory; category: TemplateCategory;
/** Primary URL or link to current version */
fileUrl: string; fileUrl: string;
/** Detected file type from extension */
fileType: TemplateFileType;
company: CompanyId; company: CompanyId;
/** Detected placeholders in template */ /** Detected placeholders in template (only for .docx) */
placeholders: string[]; placeholders: string[];
/** Cloned from template ID */ /** Cloned from template ID */
clonedFrom?: string; clonedFrom?: string;
tags: string[]; tags: string[];
version: string; version: string;
/** History of previous versions */
versionHistory: TemplateVersionEntry[];
visibility: Visibility; visibility: Visibility;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;