Merge pull request 'ha/contract-manager-versioning' (#7) from ha/contract-manager-versioning into dev
Reviewed-on: #7
This commit is contained in:
commit
05fc7bd6ac
@ -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);
|
||||||
@ -121,12 +160,39 @@ export default function ContractEditor({ onSaved }: Props) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (publish && type === 'contract') {
|
||||||
|
const kind = contractType === 'gdpr' ? 'GDPR' : 'Contract';
|
||||||
|
const ok = window.confirm(
|
||||||
|
`Activate this ${kind} template now?\n\nThis will deactivate other active ${kind} templates that apply to the same user type and language.`
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setStatusMsg(null);
|
setStatusMsg(null);
|
||||||
|
|
||||||
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 +208,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 +220,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
|
||||||
|
|||||||
@ -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 = {
|
||||||
@ -12,11 +13,16 @@ type ContractTemplate = {
|
|||||||
name: string;
|
name: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
contract_type?: string | null;
|
contract_type?: string | null;
|
||||||
|
user_type?: string | null;
|
||||||
version: number;
|
version: number;
|
||||||
status: 'draft' | 'published' | 'archived' | string;
|
status: 'draft' | 'published' | 'archived' | string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function Pill({ children, className }: { children: React.ReactNode; className: string }) {
|
||||||
|
return <span className={`px-2 py-0.5 rounded text-xs font-semibold border ${className}`}>{children}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: string }) {
|
function StatusBadge({ status }: { status: string }) {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
draft: 'bg-gray-100 text-gray-800 border border-gray-300',
|
draft: 'bg-gray-100 text-gray-800 border border-gray-300',
|
||||||
@ -27,7 +33,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('');
|
||||||
@ -56,6 +62,7 @@ export default function ContractTemplateList({ refreshKey = 0 }: Props) {
|
|||||||
name: x.name ?? 'Untitled',
|
name: x.name ?? 'Untitled',
|
||||||
type: x.type,
|
type: x.type,
|
||||||
contract_type: x.contract_type ?? x.contractType ?? null,
|
contract_type: x.contract_type ?? x.contractType ?? null,
|
||||||
|
user_type: x.user_type ?? x.userType ?? null,
|
||||||
version: Number(x.version ?? 1),
|
version: Number(x.version ?? 1),
|
||||||
status: (x.state === 'active') ? 'published' : 'draft',
|
status: (x.state === 'active') ? 'published' : 'draft',
|
||||||
updatedAt: x.updatedAt ?? x.modifiedAt ?? x.updated_at,
|
updatedAt: x.updatedAt ?? x.modifiedAt ?? x.updated_at,
|
||||||
@ -78,9 +85,24 @@ export default function ContractTemplateList({ refreshKey = 0 }: Props) {
|
|||||||
|
|
||||||
const onToggleState = async (id: string, current: string) => {
|
const onToggleState = async (id: string, current: string) => {
|
||||||
const target = current === 'published' ? 'inactive' : 'active';
|
const target = current === 'published' ? 'inactive' : 'active';
|
||||||
|
|
||||||
|
// Confirmation: activating a contract/GDPR will deactivate other active templates of the same kind
|
||||||
|
if (target === 'active') {
|
||||||
|
const tpl = items.find((i) => i.id === id);
|
||||||
|
if (tpl?.type === 'contract') {
|
||||||
|
const kind = tpl.contract_type === 'gdpr' ? 'GDPR' : 'Contract';
|
||||||
|
const ok = window.confirm(
|
||||||
|
`Activate this ${kind} template now?\n\nThis will deactivate other active ${kind} templates that apply to the same user type and language.`
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const updated = await updateTemplateState(id, target as 'active' | 'inactive');
|
const updated = await updateTemplateState(id, target as 'active' | 'inactive');
|
||||||
|
// Update clicked item immediately, then refresh list to reflect any auto-deactivations.
|
||||||
setItems((prev) => prev.map((i) => i.id === id ? { ...i, status: updated.state === 'active' ? 'published' : 'draft' } : i));
|
setItems((prev) => prev.map((i) => i.id === id ? { ...i, status: updated.state === 'active' ? 'published' : 'draft' } : i));
|
||||||
|
await load();
|
||||||
} catch {}
|
} catch {}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -124,14 +146,32 @@ export default function ContractTemplateList({ refreshKey = 0 }: Props) {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<p className="font-semibold text-lg text-gray-900 truncate">{c.name}</p>
|
<p className="font-semibold text-lg text-gray-900 truncate">{c.name}</p>
|
||||||
<StatusBadge status={c.status} />
|
<StatusBadge status={c.status} />
|
||||||
{c.type === 'contract' && c.contract_type && (
|
{c.type && (
|
||||||
<span className="px-2 py-0.5 rounded text-xs font-semibold bg-indigo-50 text-indigo-800 border border-indigo-200">
|
<Pill className="bg-slate-50 text-slate-800 border-slate-200">
|
||||||
|
{c.type === 'contract' ? 'Contract' : c.type === 'bill' ? 'Bill' : 'Other'}
|
||||||
|
</Pill>
|
||||||
|
)}
|
||||||
|
{c.type === 'contract' && (
|
||||||
|
<Pill className="bg-indigo-50 text-indigo-800 border-indigo-200">
|
||||||
{c.contract_type === 'gdpr' ? 'GDPR' : 'Contract'}
|
{c.contract_type === 'gdpr' ? 'GDPR' : 'Contract'}
|
||||||
</span>
|
</Pill>
|
||||||
|
)}
|
||||||
|
{c.user_type && (
|
||||||
|
<Pill className="bg-emerald-50 text-emerald-800 border-emerald-200">
|
||||||
|
{c.user_type === 'personal' ? 'Personal' : c.user_type === 'company' ? 'Company' : 'Both'}
|
||||||
|
</Pill>
|
||||||
)}
|
)}
|
||||||
</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>
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user