feat: implement template editing functionality with versioning and state management

This commit is contained in:
seaznCode 2026-01-14 22:08:40 +01:00
parent 3c531daa91
commit 19e6d34b1c
4 changed files with 141 additions and 7 deletions

View File

@ -4,10 +4,12 @@ import React, { useEffect, useRef, useState } from 'react';
import useContractManagement from '../hooks/useContractManagement'; import useContractManagement from '../hooks/useContractManagement';
type Props = { type Props = {
onSaved?: () => void; editingTemplateId?: string | null;
onCancelEdit?: () => void;
onSaved?: (info?: { action: 'created' | 'revised'; templateId: string }) => void;
}; };
export default function ContractEditor({ onSaved }: Props) { export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdit }: Props) {
const [name, setName] = useState(''); const [name, setName] = useState('');
const [htmlCode, setHtmlCode] = useState(''); const [htmlCode, setHtmlCode] = useState('');
const [isPreview, setIsPreview] = useState(false); const [isPreview, setIsPreview] = useState(false);
@ -20,17 +22,54 @@ export default function ContractEditor({ onSaved }: Props) {
const [userType, setUserType] = useState<'personal' | 'company' | 'both'>('personal'); const [userType, setUserType] = useState<'personal' | 'company' | 'both'>('personal');
const [description, setDescription] = useState<string>(''); const [description, setDescription] = useState<string>('');
const [editingMeta, setEditingMeta] = useState<{ id: string; version: number; state: string } | null>(null);
const iframeRef = useRef<HTMLIFrameElement | null>(null); const iframeRef = useRef<HTMLIFrameElement | null>(null);
const { uploadTemplate, updateTemplateState } = useContractManagement(); const { uploadTemplate, updateTemplateState, getTemplate, reviseTemplate } = useContractManagement();
const resetEditorFields = () => { const resetEditorFields = () => {
setName(''); setName('');
setHtmlCode(''); setHtmlCode('');
setDescription(''); setDescription('');
setIsPreview(false); setIsPreview(false);
setEditingMeta(null);
}; };
// Load template into editor when editing
useEffect(() => {
const load = async () => {
if (!editingTemplateId) {
setEditingMeta(null);
return;
}
setSaving(true);
setStatusMsg(null);
try {
const tpl = await getTemplate(editingTemplateId);
setName(tpl.name || '');
setHtmlCode(tpl.html || '');
setDescription((tpl.description as any) || '');
setLang((tpl.lang as any) || 'en');
setType((tpl.type as any) || 'contract');
setContractType(((tpl.contract_type as any) || 'contract') as 'contract' | 'gdpr');
setUserType(((tpl.user_type as any) || 'both') as 'personal' | 'company' | 'both');
setEditingMeta({
id: editingTemplateId,
version: Number(tpl.version || 1),
state: String(tpl.state || 'inactive')
});
setStatusMsg(`Loaded template for editing (v${Number(tpl.version || 1)}).`);
} catch (e: any) {
setStatusMsg(e?.message || 'Failed to load template.');
} finally {
setSaving(false);
}
};
load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editingTemplateId]);
// Build a full HTML doc if user pasted only a snippet // Build a full HTML doc if user pasted only a snippet
const wrapIfNeeded = (src: string) => { const wrapIfNeeded = (src: string) => {
const hasDoc = /<!DOCTYPE|<html[\s>]/i.test(src); const hasDoc = /<!DOCTYPE|<html[\s>]/i.test(src);
@ -127,6 +166,25 @@ export default function ContractEditor({ onSaved }: Props) {
try { try {
// Build a file from HTML code // Build a file from HTML code
const file = new File([html], `${slug(name)}.html`, { type: 'text/html' }); const file = new File([html], `${slug(name)}.html`, { type: 'text/html' });
// If editing: revise (new object + version bump) and deactivate previous
if (editingTemplateId) {
const revised = await reviseTemplate(editingTemplateId, {
file,
name,
type,
contract_type: type === 'contract' ? contractType : undefined,
lang,
description: description || undefined,
user_type: userType,
state: publish ? 'active' : 'inactive',
});
setStatusMsg(publish ? 'New version created and activated (previous deactivated).' : 'New version created (previous deactivated).');
if (onSaved && revised?.id) onSaved({ action: 'revised', templateId: String(revised.id) });
resetEditorFields();
return;
}
// Otherwise: create new
const created = await uploadTemplate({ const created = await uploadTemplate({
file, file,
name, name,
@ -142,7 +200,7 @@ export default function ContractEditor({ onSaved }: Props) {
} }
setStatusMsg(publish ? 'Template created and activated.' : 'Template created.'); setStatusMsg(publish ? 'Template created and activated.' : 'Template created.');
if (onSaved) onSaved(); if (onSaved && created?.id) onSaved({ action: 'created', templateId: String(created.id) });
// Reset so another template can be created immediately // Reset so another template can be created immediately
resetEditorFields(); resetEditorFields();
} catch (e: any) { } catch (e: any) {
@ -154,6 +212,25 @@ export default function ContractEditor({ onSaved }: Props) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{editingMeta && (
<div className="rounded-lg border border-indigo-200 bg-indigo-50 px-4 py-3 flex items-center justify-between gap-3">
<div className="text-sm text-indigo-900">
<span className="font-semibold">Editing:</span> {name || 'Untitled'} (v{editingMeta.version}) state: {editingMeta.state}
</div>
{onCancelEdit && (
<button
type="button"
onClick={() => {
resetEditorFields();
onCancelEdit();
}}
className="inline-flex items-center rounded-lg bg-white hover:bg-gray-50 text-gray-900 px-3 py-1.5 text-sm font-medium shadow border border-gray-200 transition"
>
Cancel editing
</button>
)}
</div>
)}
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex flex-col sm:flex-row gap-4"> <div className="flex flex-col sm:flex-row gap-4">
<input <input

View File

@ -5,6 +5,7 @@ import useContractManagement from '../hooks/useContractManagement';
type Props = { type Props = {
refreshKey?: number; refreshKey?: number;
onEdit?: (id: string) => void;
}; };
type ContractTemplate = { type ContractTemplate = {
@ -27,7 +28,7 @@ function StatusBadge({ status }: { status: string }) {
return <span className={`px-2 py-0.5 rounded text-xs font-semibold ${cls}`}>{status}</span>; return <span className={`px-2 py-0.5 rounded text-xs font-semibold ${cls}`}>{status}</span>;
} }
export default function ContractTemplateList({ refreshKey = 0 }: Props) { export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props) {
const [items, setItems] = useState<ContractTemplate[]>([]); const [items, setItems] = useState<ContractTemplate[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [q, setQ] = useState(''); const [q, setQ] = useState('');
@ -132,6 +133,14 @@ export default function ContractTemplateList({ refreshKey = 0 }: Props) {
</div> </div>
<p className="text-xs text-gray-500">Version {c.version}{c.updatedAt ? ` • Updated ${new Date(c.updatedAt).toLocaleString()}` : ''}</p> <p className="text-xs text-gray-500">Version {c.version}{c.updatedAt ? ` • Updated ${new Date(c.updatedAt).toLocaleString()}` : ''}</p>
<div className="flex flex-wrap gap-2 mt-2"> <div className="flex flex-wrap gap-2 mt-2">
{onEdit && (
<button
onClick={() => onEdit(c.id)}
className="px-3 py-1 text-xs rounded-lg bg-indigo-50 hover:bg-indigo-100 text-indigo-700 border border-indigo-200 transition"
>
Edit
</button>
)}
<button onClick={() => onPreview(c.id)} className="px-3 py-1 text-xs rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 border border-gray-200 transition">Preview</button> <button onClick={() => onPreview(c.id)} className="px-3 py-1 text-xs rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 border border-gray-200 transition">Preview</button>
<button onClick={() => onGenPdf(c.id)} className="px-3 py-1 text-xs rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 border border-gray-200 transition">PDF</button> <button onClick={() => onGenPdf(c.id)} className="px-3 py-1 text-xs rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 border border-gray-200 transition">PDF</button>
<button onClick={() => onDownloadPdf(c.id)} className="px-3 py-1 text-xs rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 border border-gray-200 transition">Download</button> <button onClick={() => onDownloadPdf(c.id)} className="px-3 py-1 text-xs rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 border border-gray-200 transition">Download</button>

View File

@ -200,6 +200,33 @@ export default function useContractManagement() {
return authorizedFetch<DocumentTemplate>(`/api/document-templates/${id}`, { method: 'PUT', body: fd }); return authorizedFetch<DocumentTemplate>(`/api/document-templates/${id}`, { method: 'PUT', body: fd });
}, [authorizedFetch]); }, [authorizedFetch]);
// NEW: revise template (create a new template record + bump version + deactivate previous)
const reviseTemplate = useCallback(async (id: string, payload: {
file: File | Blob;
name?: string;
type?: string;
contract_type?: 'contract' | 'gdpr';
lang?: 'en' | 'de' | string;
description?: string;
user_type?: 'personal' | 'company' | 'both';
state?: 'active' | 'inactive';
}): Promise<DocumentTemplate> => {
const fd = new FormData();
const file = payload.file instanceof File
? payload.file
: new File([payload.file], `${payload.name || 'template'}.html`, { type: 'text/html' });
fd.append('file', file);
if (payload.name !== undefined) fd.append('name', payload.name);
if (payload.type !== undefined) fd.append('type', payload.type);
if (payload.contract_type !== undefined) fd.append('contract_type', payload.contract_type);
if (payload.lang !== undefined) fd.append('lang', payload.lang);
if (payload.description !== undefined) fd.append('description', payload.description);
if (payload.user_type !== undefined) fd.append('user_type', payload.user_type);
if (payload.state !== undefined) fd.append('state', payload.state);
return authorizedFetch<DocumentTemplate>(`/api/document-templates/${id}/revise`, { method: 'POST', body: fd });
}, [authorizedFetch]);
const updateTemplateState = useCallback(async (id: string, state: 'active' | 'inactive'): Promise<DocumentTemplate> => { const updateTemplateState = useCallback(async (id: string, state: 'active' | 'inactive'): Promise<DocumentTemplate> => {
return authorizedFetch<DocumentTemplate>(`/api/document-templates/${id}/state`, { return authorizedFetch<DocumentTemplate>(`/api/document-templates/${id}/state`, {
method: 'PATCH', method: 'PATCH',
@ -426,6 +453,7 @@ export default function useContractManagement() {
generatePdf, generatePdf,
downloadPdf, downloadPdf,
uploadTemplate, uploadTemplate,
reviseTemplate,
updateTemplate, updateTemplate,
updateTemplateState, updateTemplateState,
generatePdfWithSignature, generatePdfWithSignature,

View File

@ -20,6 +20,7 @@ export default function ContractManagementPage() {
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const router = useRouter(); const router = useRouter();
const [section, setSection] = useState('templates'); const [section, setSection] = useState('templates');
const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null);
useEffect(() => { setMounted(true); }, []); useEffect(() => { setMounted(true); }, []);
@ -90,7 +91,13 @@ export default function ContractManagementPage() {
<svg className="w-6 h-6" fill="none" stroke="currentColor"><path d="M4 6h16M4 12h16M4 18h16"/></svg> <svg className="w-6 h-6" fill="none" stroke="currentColor"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
Templates Templates
</h2> </h2>
<ContractTemplateList refreshKey={refreshKey} /> <ContractTemplateList
refreshKey={refreshKey}
onEdit={(id) => {
setEditingTemplateId(id);
setSection('editor');
}}
/>
</section> </section>
)} )}
{section === 'editor' && ( {section === 'editor' && (
@ -99,7 +106,20 @@ export default function ContractManagementPage() {
<svg className="w-6 h-6" fill="none" stroke="currentColor"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 113 3L7 19l-4 1 1-4 12.5-12.5z"/></svg> <svg className="w-6 h-6" fill="none" stroke="currentColor"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 113 3L7 19l-4 1 1-4 12.5-12.5z"/></svg>
Create Template Create Template
</h2> </h2>
<ContractEditor onSaved={bumpRefresh} /> <ContractEditor
editingTemplateId={editingTemplateId}
onCancelEdit={() => {
setEditingTemplateId(null);
setSection('templates');
}}
onSaved={(info) => {
bumpRefresh();
if (info?.action === 'revised') {
setEditingTemplateId(null);
setSection('templates');
}
}}
/>
</section> </section>
)} )}
</main> </main>