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",
},
"word-templates": {
title: "Șabloane Word",
description: "Bibliotecă contracte, oferte, rapoarte",
title: "Bibliotecă Șabloane",
description:
"Șabloane contracte, oferte, rapoarte (Word, Excel, Archicad, DWG, PDF)",
},
"tag-manager": {
title: "Manager Etichete",
@@ -1,25 +1,32 @@
'use client';
"use client";
import { useSignatureConfig } from '../hooks/use-signature-config';
import { useSavedSignatures } from '../hooks/use-saved-signatures';
import { SignatureConfigurator } from './signature-configurator';
import { SignaturePreview } from './signature-preview';
import { SavedSignaturesPanel } from './saved-signatures-panel';
import { Separator } from '@/shared/components/ui/separator';
import { Button } from '@/shared/components/ui/button';
import { RotateCcw } from 'lucide-react';
import type { SignatureBanner } from '../types';
import { useSignatureConfig } from "../hooks/use-signature-config";
import { useSavedSignatures } from "../hooks/use-saved-signatures";
import { SignatureConfigurator } from "./signature-configurator";
import { SignaturePreview } from "./signature-preview";
import { SavedSignaturesPanel } from "./saved-signatures-panel";
import { Separator } from "@/shared/components/ui/separator";
import { Button } from "@/shared/components/ui/button";
import { RotateCcw } from "lucide-react";
import type { SignatureBanner } from "../types";
export function EmailSignatureModule() {
const {
config, updateField, updateColor, updateLayout,
setVariant, setCompany, setAddress, resetToDefaults, loadConfig,
config,
updateField,
updateColor,
updateLayout,
setVariant,
setCompany,
setAddress,
resetToDefaults,
loadConfig,
} = useSignatureConfig();
const { saved, loading, save, remove } = useSavedSignatures();
const setBanner = (banner: SignatureBanner | undefined) => {
updateField('banner', banner);
updateField("banner", banner);
};
return (
@@ -42,7 +49,9 @@ export function EmailSignatureModule() {
<SavedSignaturesPanel
saved={saved}
loading={loading}
onSave={async (label, cfg) => { await save(label, cfg); }}
onSave={async (label, cfg) => {
await save(label, cfg);
}}
onLoad={loadConfig}
onRemove={remove}
currentConfig={config}
@@ -50,7 +59,12 @@ export function EmailSignatureModule() {
<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" />
Resetare la valorile implicite
</Button>
@@ -7,11 +7,17 @@ import {
Trash2,
Search,
FileText,
FileSpreadsheet,
FileArchive,
File,
ExternalLink,
Copy,
FolderOpen,
Wand2,
Loader2,
History,
Link,
Upload,
RotateCcw,
} from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
@@ -39,12 +45,13 @@ import {
DialogFooter,
} from "@/shared/components/ui/dialog";
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 {
parsePlaceholdersFromBuffer,
parsePlaceholdersFromUrl,
} from "../services/placeholder-parser";
import { parsePlaceholdersFromBuffer } from "../services/placeholder-parser";
const CATEGORY_LABELS: Record<TemplateCategory, string> = {
contract: "Contract",
@@ -57,6 +64,44 @@ const CATEGORY_LABELS: Record<TemplateCategory, string> = {
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";
export function WordTemplatesModule() {
@@ -68,6 +113,7 @@ export function WordTemplatesModule() {
updateFilter,
addTemplate,
updateTemplate,
createRevision,
cloneTemplate,
removeTemplate,
} = useTemplates();
@@ -76,6 +122,13 @@ export function WordTemplatesModule() {
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 (
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 (
<div className="space-y-6">
{/* Stats */}
@@ -108,28 +170,25 @@ export function WordTemplatesModule() {
</Card>
<Card>
<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">
{allTemplates.filter((t) => t.company === "beletage").length}
{allTemplates.filter((t) => (t.fileType ?? "docx") === "docx" || t.fileType === "xlsx").length}
</p>
</CardContent>
</Card>
<Card>
<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">
{allTemplates.filter((t) => t.company === "urban-switch").length}
{allTemplates.filter((t) => t.fileType === "dwg" || t.fileType === "archicad").length}
</p>
</CardContent>
</Card>
<Card>
<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">
{
allTemplates.filter((t) => t.company === "studii-de-teren")
.length
}
{allTemplates.filter((t) => (t.versionHistory ?? []).length > 0).length}
</p>
</CardContent>
</Card>
@@ -193,7 +252,7 @@ export function WordTemplatesModule() {
</p>
) : templates.length === 0 ? (
<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>
) : (
<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">
<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="Revizie nouă"
onClick={() => setRevisingTemplate(tpl)}
>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
@@ -232,7 +300,10 @@ export function WordTemplatesModule() {
</div>
<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">
<FileText className="h-5 w-5 text-muted-foreground" />
<FileTypeIcon
fileType={tpl.fileType ?? "docx"}
className="h-5 w-5 text-muted-foreground"
/>
</div>
<div className="min-w-0">
<p className="font-medium">{tpl.name}</p>
@@ -248,33 +319,49 @@ export function WordTemplatesModule() {
<Badge variant="secondary" className="text-[10px]">
v{tpl.version}
</Badge>
<Badge variant="secondary" className="text-[10px]">
{FILE_TYPE_LABELS[tpl.fileType ?? "docx"]}
</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>
))}
</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"
>
<ExternalLink className="h-3 w-3" /> Deschide fișier
</a>
)}
{/* Placeholders display (only for .docx) */}
{(tpl.fileType ?? "docx") === "docx" &&
(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>
))}
</div>
)}
<div className="mt-1.5 flex items-center gap-2">
{tpl.fileUrl && (
<a
href={tpl.fileUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
>
<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>
</CardContent>
@@ -330,6 +417,144 @@ export function WordTemplatesModule() {
</DialogFooter>
</DialogContent>
</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>
);
}
@@ -351,79 +576,83 @@ function TemplateForm({
initial?.category ?? "contract",
);
const [fileUrl, setFileUrl] = useState(initial?.fileUrl ?? "");
const [fileType, setFileType] = useState<TemplateFileType>(
initial?.fileType ?? "docx",
);
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 [placeholders, setPlaceholders] = useState<string[]>(
initial?.placeholders ?? [],
);
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 [urlMode, setUrlMode] = useState<"link" | "upload">(
initial?.fileUrl ? "link" : "link",
);
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 handleFileUpload = 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 = "";
// Set name from filename if empty
if (!name) {
setName(file.name.replace(/\.[^.]+$/, ""));
}
// 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 () => {
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);
}
const handleUrlChange = (url: string) => {
setFileUrl(url);
const detected = detectFileType(url);
setFileType(detected);
};
return (
<form
onSubmit={(e) => {
e.preventDefault();
const placeholders = placeholdersText
.split(",")
.map((p) => p.trim())
.filter((p) => p.length > 0);
onSubmit({
name,
description,
category,
fileUrl,
fileType,
company,
version,
version: initial?.version ?? "1.0",
placeholders,
versionHistory: initial?.versionHistory ?? [],
clonedFrom: initial?.clonedFrom,
tags: initial?.tags ?? [],
visibility: initial?.visibility ?? "all",
@@ -469,7 +698,7 @@ function TemplateForm({
className="mt-1"
/>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<Label>Companie</Label>
<Select
@@ -488,88 +717,118 @@ function TemplateForm({
</Select>
</div>
<div>
<Label>Versiune</Label>
<Input
value={version}
onChange={(e) => setVersion(e.target.value)}
className="mt-1"
/>
<Label>Tip fișier</Label>
<Select
value={fileType}
onValueChange={(v) => setFileType(v as TemplateFileType)}
>
<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>
<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://..."
/>
</div>
{/* File source — upload or link */}
<div>
<div className="mb-2 flex items-center gap-2">
<Label>Sursă fișier</Label>
<div className="flex rounded-md border">
<Button
type="button"
variant="outline"
size="icon"
title="Detectează placeholder-e din URL"
disabled={!fileUrl || parsing}
onClick={handleUrlDetect}
variant={urlMode === "link" ? "secondary" : "ghost"}
size="sm"
className="h-7 rounded-r-none text-xs"
onClick={() => setUrlMode("link")}
>
{parsing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Wand2 className="h-4 w-4" />
)}
<Link className="mr-1 h-3 w-3" /> Link extern
</Button>
<Button
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>
</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>
{urlMode === "link" ? (
<Input
value={fileUrl}
onChange={(e) => handleUrlChange(e.target.value)}
placeholder="https://sharepoint.com/... sau link NextCloud"
/>
) : (
<div>
<input
ref={fileInputRef}
type="file"
accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
accept=".docx,.xlsx,.xls,.pdf,.dwg,.dxf,.tpl,.pln,.pla,.bpn"
className="hidden"
onChange={handleFileDetect}
onChange={handleFileUpload}
/>
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
disabled={parsing}
onClick={() => fileInputRef.current?.click()}
>
{parsing ? (
<>
<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
.docx
</>
)}
</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>
<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>
)}
{parseMessage && (
<p className="mt-1 text-xs text-blue-600">{parseMessage}</p>
)}
</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">
<Button type="button" variant="outline" onClick={onCancel}>
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 = {
id: 'word-templates',
name: 'Șabloane Word',
description: 'Bibliotecă de șabloane Word organizate pe categorii cu suport versionare',
icon: 'file-text',
route: '/word-templates',
category: 'generators',
featureFlag: 'module.word-templates',
visibility: 'all',
version: '0.1.0',
id: "word-templates",
name: "Bibliotecă Șabloane",
description:
"Bibliotecă de șabloane documente cu versionare automată (Word, Excel, Archicad, DWG, PDF)",
icon: "file-text",
route: "/word-templates",
category: "generators",
featureFlag: "module.word-templates",
visibility: "all",
version: "0.2.0",
dependencies: [],
storageNamespace: 'word-templates',
storageNamespace: "word-templates",
navOrder: 22,
tags: ['word', 'șabloane', 'documente'],
tags: ["word", "șabloane", "documente", "excel", "archicad", "dwg"],
};
@@ -13,6 +13,17 @@ export interface TemplateFilters {
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() {
const storage = useStorage("word-templates");
const [templates, setTemplates] = useState<WordTemplate[]>([]);
@@ -29,7 +40,11 @@ export function useTemplates() {
const results: WordTemplate[] = [];
for (const [key, value] of Object.entries(all)) {
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));
@@ -75,6 +90,36 @@ export function useTemplates() {
[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(
async (id: string) => {
const existing = templates.find((t) => t.id === id);
@@ -85,6 +130,7 @@ export function useTemplates() {
id: uuid(),
name: `${existing.name} (copie)`,
clonedFrom: existing.id,
versionHistory: [],
createdAt: now,
updatedAt: now,
};
@@ -133,6 +179,7 @@ export function useTemplates() {
updateFilter,
addTemplate,
updateTemplate,
createRevision,
cloneTemplate,
removeTemplate,
refresh,
+8 -3
View File
@@ -1,3 +1,8 @@
export { wordTemplatesConfig } from './config';
export { WordTemplatesModule } from './components/word-templates-module';
export type { WordTemplate, TemplateCategory } from './types';
export { wordTemplatesConfig } from "./config";
export { WordTemplatesModule } from "./components/word-templates-module";
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 { CompanyId } from '@/core/auth/types';
import type { Visibility } from "@/core/module-registry/types";
import type { CompanyId } from "@/core/auth/types";
export type TemplateCategory =
| 'contract'
| 'memoriu'
| 'oferta'
| 'raport'
| 'cerere'
| 'aviz'
| 'scrisoare'
| 'altele';
| "contract"
| "memoriu"
| "oferta"
| "raport"
| "cerere"
| "aviz"
| "scrisoare"
| "altele";
export type TemplateFileType =
| "docx"
| "xlsx"
| "pdf"
| "dwg"
| "archicad"
| "other";
export interface TemplateVersionEntry {
version: string;
fileUrl: string;
notes: string;
createdAt: string;
}
export interface WordTemplate {
id: string;
name: string;
description: string;
category: TemplateCategory;
/** Primary URL or link to current version */
fileUrl: string;
/** Detected file type from extension */
fileType: TemplateFileType;
company: CompanyId;
/** Detected placeholders in template */
/** Detected placeholders in template (only for .docx) */
placeholders: string[];
/** Cloned from template ID */
clonedFrom?: string;
tags: string[];
version: string;
/** History of previous versions */
versionHistory: TemplateVersionEntry[];
visibility: Visibility;
createdAt: string;
updatedAt: string;