438 lines
17 KiB
TypeScript
438 lines
17 KiB
TypeScript
'use client';
|
|
|
|
import React, { useEffect, useRef, useState } from 'react';
|
|
import useContractManagement from '../hooks/useContractManagement';
|
|
import ConfirmActionModal from '../../../components/modals/ConfirmActionModal';
|
|
|
|
import { useTranslation } from '../../../i18n/useTranslation';
|
|
|
|
type Props = {
|
|
editingTemplateId?: string | null;
|
|
onCancelEdit?: () => void;
|
|
onSaved?: (info?: { action: 'created' | 'revised'; templateId: string }) => void;
|
|
};
|
|
|
|
export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdit }: Props) {
|
|
const { t } = useTranslation();
|
|
const [name, setName] = useState('');
|
|
const [htmlCode, setHtmlCode] = useState('');
|
|
const [isPreview, setIsPreview] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [statusMsg, setStatusMsg] = useState<string | null>(null);
|
|
|
|
const [lang, setLang] = useState<'en' | 'de'>('en');
|
|
const [type, setType] = useState<'contract' | 'invoice' | 'other'>('contract');
|
|
const [contractType, setContractType] = useState<'contract' | 'gdpr' | 'abo'>('contract');
|
|
const [userType, setUserType] = useState<'personal' | 'company' | 'both'>('personal');
|
|
const [taxMode, setTaxMode] = useState<'standard' | 'reverse_charge' | 'both'>('both');
|
|
const [description, setDescription] = useState<string>('');
|
|
|
|
const [editingMeta, setEditingMeta] = useState<{ id: string; version: number; state: string } | null>(null);
|
|
const [publishConfirmOpen, setPublishConfirmOpen] = useState(false)
|
|
const [publishConfirmMessage, setPublishConfirmMessage] = useState('')
|
|
|
|
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
|
|
const { uploadTemplate, updateTemplateState, getTemplate, reviseTemplate } = useContractManagement();
|
|
|
|
const resetEditorFields = () => {
|
|
setName('');
|
|
setHtmlCode('');
|
|
setDescription('');
|
|
setTaxMode('both');
|
|
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 as any)?.description as string) || ''); // FIX: DocumentTemplate may not declare `description`
|
|
setLang((tpl.lang as any) || 'en');
|
|
setType(((tpl.type as any) || 'contract') as 'contract' | 'invoice' | 'other');
|
|
setContractType(((tpl.contract_type as any) || 'contract') as 'contract' | 'gdpr' | 'abo');
|
|
setUserType(((tpl.user_type as any) || 'both') as 'personal' | 'company' | 'both');
|
|
setTaxMode(((tpl.tax_mode as any) || 'both') as 'standard' | 'reverse_charge' | '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
|
|
const wrapIfNeeded = (src: string) => {
|
|
const hasDoc = /<!DOCTYPE|<html[\s>]/i.test(src);
|
|
if (hasDoc) return src;
|
|
// Minimal A4 skeleton so snippets render and print correctly
|
|
return `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8"/>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
|
<title>Preview</title>
|
|
<style>
|
|
@page { size: A4; margin: 0; }
|
|
html, body { margin:0; padding:0; background:#eee; }
|
|
body { display:flex; justify-content:center; }
|
|
.a4 { width:210mm; min-height:297mm; background:#fff; box-shadow:0 0 5mm rgba(0,0,0,0.1); box-sizing:border-box; padding:20mm; }
|
|
@media print {
|
|
html, body { background:#fff; }
|
|
.a4 { box-shadow:none; margin:0; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="a4">${src}</div>
|
|
</body>
|
|
</html>`;
|
|
};
|
|
|
|
// Write/refresh iframe preview
|
|
useEffect(() => {
|
|
if (!isPreview) return;
|
|
const iframe = iframeRef.current;
|
|
if (!iframe) return;
|
|
const doc = iframe.contentDocument || iframe.contentWindow?.document;
|
|
if (!doc) return;
|
|
|
|
const html = wrapIfNeeded(htmlCode);
|
|
doc.open();
|
|
doc.write(html);
|
|
doc.close();
|
|
|
|
const resize = () => {
|
|
// Allow time for layout/styles
|
|
requestAnimationFrame(() => {
|
|
const h = doc.body ? Math.max(doc.body.scrollHeight, doc.documentElement.scrollHeight) : 1200;
|
|
iframe.style.height = Math.min(Math.max(h, 1123), 2000) + 'px'; // clamp for UX
|
|
});
|
|
};
|
|
|
|
// Initial resize and after load
|
|
resize();
|
|
const onLoad = () => resize();
|
|
iframe.addEventListener('load', onLoad);
|
|
// Also observe mutations to adjust height if content changes
|
|
const mo = new MutationObserver(resize);
|
|
mo.observe(doc.documentElement, { childList: true, subtree: true, attributes: true, characterData: true });
|
|
|
|
return () => {
|
|
iframe.removeEventListener('load', onLoad);
|
|
mo.disconnect();
|
|
};
|
|
}, [isPreview, htmlCode]);
|
|
|
|
const printPreview = () => {
|
|
const w = iframeRef.current?.contentWindow;
|
|
w?.focus();
|
|
w?.print();
|
|
};
|
|
|
|
const slug = (s: string) =>
|
|
s.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'template';
|
|
|
|
// NEW: all-fields-required guard
|
|
const canSave = Boolean(
|
|
name.trim() &&
|
|
htmlCode.trim() &&
|
|
type &&
|
|
(type === 'contract' ? contractType : true) &&
|
|
userType &&
|
|
lang
|
|
)
|
|
|
|
const doSave = async (publish: boolean) => {
|
|
const html = htmlCode.trim();
|
|
// NEW: validate all fields
|
|
if (!canSave) {
|
|
setStatusMsg('Please fill all required fields (name, HTML, type, user type, language).');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
console.info('[ContractEditor] doSave()', {
|
|
editingTemplateId: editingTemplateId ?? null,
|
|
publish,
|
|
name: name.trim(),
|
|
type,
|
|
contract_type: type === 'contract' ? contractType : null,
|
|
lang,
|
|
user_type: userType,
|
|
tax_mode: type === 'invoice' ? taxMode : null,
|
|
descriptionLength: description ? description.length : 0,
|
|
htmlLength: html.length,
|
|
});
|
|
} catch {}
|
|
|
|
setSaving(true);
|
|
setStatusMsg(null);
|
|
|
|
try {
|
|
// Build a file from HTML code
|
|
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,
|
|
tax_mode: type === 'invoice' ? taxMode : 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({
|
|
file,
|
|
name,
|
|
type,
|
|
contract_type: type === 'contract' ? contractType : undefined,
|
|
tax_mode: type === 'invoice' ? taxMode : undefined,
|
|
lang,
|
|
description: description || undefined,
|
|
user_type: userType,
|
|
});
|
|
|
|
if (publish && created?.id) {
|
|
await updateTemplateState(created.id, 'active');
|
|
}
|
|
|
|
setStatusMsg(publish ? 'Template created and activated.' : 'Template created.');
|
|
if (onSaved && created?.id) onSaved({ action: 'created', templateId: String(created.id) });
|
|
// Reset so another template can be created immediately
|
|
resetEditorFields();
|
|
} catch (e: any) {
|
|
setStatusMsg(e?.message || 'Save failed.');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const save = async (publish: boolean) => {
|
|
if (publish) {
|
|
let kind = type === 'contract'
|
|
? (contractType === 'gdpr' ? 'GDPR' : contractType === 'abo' ? 'ABO' : 'Contract')
|
|
: type === 'invoice'
|
|
? 'Invoice'
|
|
: 'Other';
|
|
const scope = type === 'invoice'
|
|
? 'same user type, tax mode and language'
|
|
: 'same user type and language';
|
|
setPublishConfirmMessage(`This will deactivate other active ${kind} templates that apply to the ${scope}.`)
|
|
setPublishConfirmOpen(true)
|
|
return
|
|
}
|
|
await doSave(publish)
|
|
}
|
|
|
|
const confirmPublish = async () => {
|
|
setPublishConfirmOpen(false)
|
|
await doSave(true)
|
|
}
|
|
|
|
return (
|
|
<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">{t('autofix.k41afd863')}</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"
|
|
>{t('autofix.k06d4487f')}</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className="flex flex-col gap-4">
|
|
<div className="flex flex-col sm:flex-row gap-4">
|
|
<input
|
|
type="text"
|
|
placeholder={t('autofix.k2fac9ff2')}
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
required
|
|
className="w-full sm:w-1/2 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-indigo-500/50 shadow"
|
|
/>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsPreview((v) => !v)}
|
|
className="inline-flex items-center rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-900 px-4 py-2 text-sm font-medium shadow transition"
|
|
>{isPreview ? t('autofix.k49165061') : t('autofix.k95d19932')}</button>
|
|
{isPreview && (
|
|
<button
|
|
type="button"
|
|
onClick={printPreview}
|
|
className="inline-flex items-center rounded-lg bg-indigo-50 hover:bg-indigo-100 text-indigo-700 px-4 py-2 text-sm font-medium shadow transition"
|
|
>
|
|
Print
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* New metadata inputs */}
|
|
<div className="flex flex-col sm:flex-row gap-4">
|
|
<select
|
|
value={type}
|
|
onChange={(e) => {
|
|
const newType = e.target.value as 'contract' | 'invoice' | 'other';
|
|
setType(newType);
|
|
}}
|
|
required
|
|
className="w-full sm:w-1/3 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-indigo-500/50 shadow"
|
|
>
|
|
<option value="contract">Contract</option>
|
|
<option value="invoice">Invoice</option>
|
|
<option value="other">Other</option>
|
|
</select>
|
|
{type === 'contract' && (
|
|
<select
|
|
value={contractType}
|
|
onChange={(e) => setContractType(e.target.value as 'contract' | 'gdpr' | 'abo')}
|
|
required
|
|
className="w-full sm:w-40 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-indigo-500/50 shadow"
|
|
>
|
|
<option value="contract">Contract</option>
|
|
<option value="gdpr">GDPR</option>
|
|
<option value="abo">ABO</option>
|
|
</select>
|
|
)}
|
|
{type === 'invoice' && (
|
|
<select
|
|
value={taxMode}
|
|
onChange={(e) => setTaxMode(e.target.value as 'standard' | 'reverse_charge' | 'both')}
|
|
required
|
|
className="w-full sm:w-56 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-indigo-500/50 shadow"
|
|
>
|
|
<option value="both">Invoice mode: Both / Fallback</option>
|
|
<option value="standard">Invoice mode: Standard VAT</option>
|
|
<option value="reverse_charge">Invoice mode: Reverse Charge</option>
|
|
</select>
|
|
)}
|
|
<select
|
|
value={userType}
|
|
onChange={(e) => setUserType(e.target.value as 'personal' | 'company' | 'both')}
|
|
required
|
|
className="w-full sm:w-40 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-indigo-500/50 shadow"
|
|
>
|
|
<option value="personal">Personal</option>
|
|
<option value="company">Company</option>
|
|
<option value="both">Both</option>
|
|
</select>
|
|
<select
|
|
value={lang}
|
|
onChange={(e) => setLang(e.target.value as 'en' | 'de')}
|
|
required
|
|
className="w-full sm:w-32 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-indigo-500/50 shadow"
|
|
>
|
|
<option value="en">English (en)</option>
|
|
<option value="de">Deutsch (de)</option>
|
|
</select>
|
|
<input
|
|
type="text"
|
|
placeholder="Description (optional)"
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
className="w-full rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-indigo-500/50 shadow"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{!isPreview && (
|
|
<div className="space-y-3">
|
|
{type === 'invoice' && (
|
|
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-900">
|
|
<p className="font-semibold">{t('autofix.k221fa311')}</p>
|
|
<p className="mt-1">{t('autofix.kb791958e')}</p>
|
|
<p className="mt-1">Use the invoice mode dropdown to separate standard VAT and reverse-charge company templates. Keep "Both / Fallback" only for a shared default layout.</p>
|
|
<p className="mt-1">Important: include <span className="font-semibold">itemsHtml</span>{t('autofix.k7a3a6ea3')}</p>
|
|
</div>
|
|
)}
|
|
<textarea
|
|
value={htmlCode}
|
|
onChange={(e) => setHtmlCode(e.target.value)}
|
|
placeholder="Paste your full HTML (or snippet) here…"
|
|
required
|
|
className="min-h-[320px] w-full rounded-lg border border-gray-300 bg-white px-4 py-3 text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 font-mono shadow"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{isPreview && (
|
|
<div className="rounded-lg border border-gray-300 bg-white shadow">
|
|
<iframe
|
|
ref={iframeRef}
|
|
title={t('autofix.kd9e4bcbd')}
|
|
className="w-full rounded-lg"
|
|
style={{ height: 1200, background: 'transparent' }}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center gap-4">
|
|
<button
|
|
onClick={() => save(false)}
|
|
disabled={saving || !canSave}
|
|
className="inline-flex items-center rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-900 px-4 py-2 text-sm font-medium shadow disabled:opacity-60 transition"
|
|
>
|
|
Create (inactive)
|
|
</button>
|
|
<button
|
|
onClick={() => save(true)}
|
|
disabled={saving || !canSave}
|
|
className="inline-flex items-center rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 text-sm font-medium shadow disabled:opacity-60 transition"
|
|
>{t('autofix.k0af6c6be')}</button>
|
|
{/* NEW: helper text */}
|
|
{!canSave && <span className="text-xs text-red-600">{t('autofix.k99bffb65')}</span>}
|
|
{saving && <span className="text-xs text-gray-500">{t('autofix.kac6cedc7')}</span>}
|
|
{statusMsg && <span className="text-xs text-gray-600">{statusMsg}</span>}
|
|
</div>
|
|
|
|
<ConfirmActionModal
|
|
open={publishConfirmOpen}
|
|
pending={saving}
|
|
title={t('autofix.k0c51fa85')}
|
|
description={publishConfirmMessage || 'This will activate this template.'}
|
|
confirmText="Activate"
|
|
onClose={() => !saving && setPublishConfirmOpen(false)}
|
|
onConfirm={confirmPublish}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|