Compare commits

...

3 Commits

3 changed files with 69 additions and 5 deletions

View File

@ -24,6 +24,7 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
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);
@ -38,6 +39,7 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
setName('');
setHtmlCode('');
setDescription('');
setTaxMode('both');
setIsPreview(false);
setEditingMeta(null);
};
@ -60,6 +62,7 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
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),
@ -175,6 +178,7 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
contract_type: type === 'contract' ? contractType : null,
lang,
user_type: userType,
tax_mode: type === 'invoice' ? taxMode : null,
descriptionLength: description ? description.length : 0,
htmlLength: html.length,
});
@ -193,6 +197,7 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
name,
type,
contract_type: type === 'contract' ? contractType : undefined,
tax_mode: type === 'invoice' ? taxMode : undefined,
lang,
description: description || undefined,
user_type: userType,
@ -210,6 +215,7 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
name,
type,
contract_type: type === 'contract' ? contractType : undefined,
tax_mode: type === 'invoice' ? taxMode : undefined,
lang,
description: description || undefined,
user_type: userType,
@ -237,7 +243,10 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
: type === 'invoice'
? 'Invoice'
: 'Other';
setPublishConfirmMessage(`This will deactivate other active ${kind} templates that apply to the same user type and language.`)
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
}
@ -323,6 +332,18 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
<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')}
@ -358,6 +379,7 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
<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>
)}

View File

@ -19,6 +19,7 @@ type ContractTemplate = {
type?: string;
contract_type?: string | null;
user_type?: string | null;
tax_mode?: string | null;
lang?: string | null;
version: number;
status: 'draft' | 'published' | 'archived' | string;
@ -29,6 +30,7 @@ type ContractTemplate = {
type TemplateFamilyKey = 'contract' | 'invoice' | 'other';
type ContractTypeKey = 'contract' | 'gdpr' | 'abo';
type AudienceKey = 'personal' | 'company' | 'both';
type InvoiceTaxModeKey = 'standard' | 'reverse_charge' | 'both';
type TemplateLanguageColumn = {
key: string;
@ -91,6 +93,7 @@ type NormalizedTemplate = DocumentTemplate & {
updated_at?: string;
contractType?: string | null;
userType?: string | null;
taxMode?: string | null;
};
const FAMILY_ORDER: TemplateFamilyKey[] = ['contract', 'invoice', 'other'];
@ -175,6 +178,11 @@ function normalizeUserType(userType?: string | null): AudienceKey {
return ['personal', 'company', 'both'].includes(value) ? (value as AudienceKey) : 'both';
}
function normalizeInvoiceTaxMode(taxMode?: string | null): InvoiceTaxModeKey {
const value = (taxMode || 'both').toLowerCase();
return ['standard', 'reverse_charge', 'both'].includes(value) ? (value as InvoiceTaxModeKey) : 'both';
}
function normalizeLanguage(lang?: string | null) {
const value = (lang || '').toLowerCase();
return value === 'de' || value === 'en' ? value : 'other';
@ -217,6 +225,17 @@ function formatLanguageCode(lang?: string | null) {
}
}
function formatInvoiceTaxMode(taxMode?: string | null) {
switch (normalizeInvoiceTaxMode(taxMode)) {
case 'standard':
return 'Standard VAT';
case 'reverse_charge':
return 'Reverse Charge';
default:
return 'Fallback / Both';
}
}
function formatContractType(contractType?: string | null) {
switch (normalizeContractType(contractType)) {
case 'abo':
@ -272,6 +291,7 @@ function buildTrackKey(template: ContractTemplate) {
family,
normalizeContractType(template.contract_type),
normalizeUserType(template.user_type),
family === 'invoice' ? normalizeInvoiceTaxMode(template.tax_mode) : 'default',
normalizeName(template.name),
].join(':');
}
@ -310,7 +330,7 @@ function buildTrack(templates: ContractTemplate[]): TemplateTrack {
if (family === 'invoice') {
title = names.length === 1 ? names[0] : 'Invoice Templates';
subtitle = `${formatUserType(lead?.user_type)}${sortedTemplates.length} version${sortedTemplates.length === 1 ? '' : 's'}`;
subtitle = `${formatUserType(lead?.user_type)}${formatInvoiceTaxMode(lead?.tax_mode)}${sortedTemplates.length} version${sortedTemplates.length === 1 ? '' : 's'}`;
}
return {
@ -520,11 +540,13 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
item.type || '',
item.contract_type || '',
item.user_type || '',
item.tax_mode || '',
item.lang || '',
item.status,
`v${item.version}`,
formatContractType(item.contract_type),
formatUserType(item.user_type),
formatInvoiceTaxMode(item.tax_mode),
formatLanguage(item.lang),
]
.join(' ')
@ -614,6 +636,7 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
type: normalized.type,
contract_type: normalized.contract_type ?? normalized.contractType ?? null,
user_type: normalized.user_type ?? normalized.userType ?? null,
tax_mode: normalized.tax_mode ?? normalized.taxMode ?? null,
lang: normalized.lang ?? null,
version: Number(normalized.version ?? 1),
status: normalized.state === 'active' ? 'published' : 'draft',
@ -627,7 +650,7 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
setItems((prev) => prev.length ? prev : [
{ id: 'ex1', name: 'Standard Subscription Agreement', type: 'contract', contract_type: 'abo', user_type: 'personal', lang: 'de', version: 2, status: 'published', updatedAt: new Date().toISOString() },
{ id: 'ex2', name: 'Standard Subscription Agreement', type: 'contract', contract_type: 'abo', user_type: 'personal', lang: 'de', version: 1, status: 'draft', updatedAt: new Date().toISOString() },
{ id: 'ex3', name: 'Invoice Standard', type: 'invoice', user_type: 'both', lang: 'en', version: 3, status: 'published', updatedAt: new Date().toISOString() },
{ id: 'ex3', name: 'Invoice Standard', type: 'invoice', user_type: 'both', tax_mode: 'both', lang: 'en', version: 3, status: 'published', updatedAt: new Date().toISOString() },
]);
} finally {
setLoading(false);
@ -665,11 +688,14 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
: tpl.type === 'invoice'
? 'Invoice'
: 'Other';
const scope = tpl.type === 'invoice'
? 'same user type, tax mode and language'
: 'same user type and language';
setPendingToggle({
id,
target,
requiresConfirm: true,
message: `This will deactivate other active ${kind} templates that apply to the same user type and language.`,
message: `This will deactivate other active ${kind} templates that apply to the ${scope}.`,
});
return;
}
@ -734,6 +760,9 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
{track.family === 'invoice' && track.templates[0]?.user_type && (
<Pill className="border-sky-200 bg-white/90 text-sky-800">{formatUserType(track.templates[0].user_type)}</Pill>
)}
{track.family === 'invoice' && (
<Pill className="border-sky-200 bg-sky-100 text-sky-900">{formatInvoiceTaxMode(track.templates[0]?.tax_mode)}</Pill>
)}
{track.family === 'other' && track.templates[0]?.user_type && (
<Pill className="border-slate-200 bg-slate-100 text-slate-700">{formatUserType(track.templates[0].user_type)}</Pill>
)}
@ -962,7 +991,7 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
{activeFamily.key === 'invoice' && (
<div className="mt-4 rounded-2xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-900">
Invoice templates can target Personal, Company, or Both. Keep one active version per language and user type so the invoice flow stays predictable.
Invoice templates can target Personal, Company, or Both. Keep one active version per language, user type and invoice mode so the invoice flow stays predictable.
</div>
)}

View File

@ -8,6 +8,7 @@ export type DocumentTemplate = {
contract_type?: 'contract' | 'gdpr' | 'abo' | null | string;
lang?: 'en' | 'de' | string;
user_type?: 'personal' | 'company' | 'both' | string;
tax_mode?: 'standard' | 'reverse_charge' | 'both' | null | string;
state?: 'active' | 'inactive' | string;
version?: number;
previewUrl?: string | null;
@ -226,6 +227,7 @@ export default function useContractManagement() {
lang: 'en' | 'de' | string;
description?: string;
user_type?: 'personal' | 'company' | 'both';
tax_mode?: 'standard' | 'reverse_charge' | 'both';
}): 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' });
@ -235,6 +237,9 @@ export default function useContractManagement() {
if (payload.type === 'contract' && payload.contract_type) {
fd.append('contract_type', payload.contract_type);
}
if (payload.type === 'invoice' && payload.tax_mode) {
fd.append('tax_mode', payload.tax_mode);
}
fd.append('lang', payload.lang);
if (payload.description) fd.append('description', payload.description);
fd.append('user_type', (payload.user_type ?? 'both'));
@ -247,6 +252,7 @@ export default function useContractManagement() {
willSendContractType: payload.type === 'contract' && Boolean(payload.contract_type),
lang: payload.lang,
user_type: payload.user_type ?? 'both',
tax_mode: payload.type === 'invoice' ? (payload.tax_mode ?? 'both') : null,
descriptionLength: payload.description ? payload.description.length : 0,
file: typeof File !== 'undefined' && file instanceof File ? { name: file.name, type: file.type, size: file.size } : null,
});
@ -263,6 +269,7 @@ export default function useContractManagement() {
lang?: 'en' | 'de' | string;
description?: string;
user_type?: 'personal' | 'company' | 'both';
tax_mode?: 'standard' | 'reverse_charge' | 'both';
}): Promise<DocumentTemplate> => {
const fd = new FormData();
if (payload.file) {
@ -274,6 +281,9 @@ export default function useContractManagement() {
if ((payload.type === 'contract' || payload.contract_type) && payload.contract_type) {
fd.append('contract_type', payload.contract_type);
}
if ((payload.type === 'invoice' || payload.tax_mode) && payload.tax_mode) {
fd.append('tax_mode', payload.tax_mode);
}
if (payload.lang) fd.append('lang', payload.lang);
if (payload.description !== undefined) fd.append('description', payload.description);
if (payload.user_type) fd.append('user_type', payload.user_type);
@ -290,6 +300,7 @@ export default function useContractManagement() {
lang?: 'en' | 'de' | string;
description?: string;
user_type?: 'personal' | 'company' | 'both';
tax_mode?: 'standard' | 'reverse_charge' | 'both';
state?: 'active' | 'inactive';
}): Promise<DocumentTemplate> => {
const fd = new FormData();
@ -300,6 +311,7 @@ export default function useContractManagement() {
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.tax_mode !== undefined) fd.append('tax_mode', payload.tax_mode);
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);
@ -313,6 +325,7 @@ export default function useContractManagement() {
contract_type: payload.contract_type,
lang: payload.lang,
user_type: payload.user_type,
tax_mode: payload.tax_mode,
state: payload.state,
descriptionLength: payload.description ? payload.description.length : 0,
file: typeof File !== 'undefined' && file instanceof File ? { name: file.name, type: file.type, size: file.size } : null,