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:
@@ -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ă
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user