1046 lines
45 KiB
TypeScript
1046 lines
45 KiB
TypeScript
'use client';
|
|
|
|
import React, { useEffect, useMemo, useState } from 'react';
|
|
import { Dialog, Transition } from '@headlessui/react';
|
|
import { XMarkIcon } from '@heroicons/react/24/outline';
|
|
import useContractManagement, { DocumentTemplate } from '../hooks/useContractManagement';
|
|
import ConfirmActionModal from '../../../components/modals/ConfirmActionModal';
|
|
|
|
import { useTranslation } from '../../../i18n/useTranslation';
|
|
|
|
type Props = {
|
|
refreshKey?: number;
|
|
onEdit?: (id: string) => void;
|
|
};
|
|
|
|
type ContractTemplate = {
|
|
id: string;
|
|
name: string;
|
|
type?: string;
|
|
contract_type?: string | null;
|
|
user_type?: string | null;
|
|
lang?: string | null;
|
|
version: number;
|
|
status: 'draft' | 'published' | 'archived' | string;
|
|
updatedAt?: string;
|
|
description?: string;
|
|
};
|
|
|
|
type TemplateFamilyKey = 'contract' | 'invoice' | 'other';
|
|
type ContractTypeKey = 'contract' | 'gdpr' | 'abo';
|
|
type AudienceKey = 'personal' | 'company' | 'both';
|
|
|
|
type TemplateLanguageColumn = {
|
|
key: string;
|
|
label: string;
|
|
templates: ContractTemplate[];
|
|
activeTemplate?: ContractTemplate;
|
|
};
|
|
|
|
type TemplateTrack = {
|
|
key: string;
|
|
family: TemplateFamilyKey;
|
|
title: string;
|
|
subtitle: string;
|
|
description?: string;
|
|
templates: ContractTemplate[];
|
|
languageColumns: TemplateLanguageColumn[];
|
|
activeCount: number;
|
|
latestUpdatedAt?: string;
|
|
};
|
|
|
|
type TemplateFamilyGroup = {
|
|
key: TemplateFamilyKey;
|
|
label: string;
|
|
description: string;
|
|
tracks: TemplateTrack[];
|
|
totalTemplates: number;
|
|
activeTemplates: number;
|
|
};
|
|
|
|
type ContractTypeMeta = {
|
|
label: string;
|
|
shortLabel: string;
|
|
description: string;
|
|
badgeClass: string;
|
|
headerClass: string;
|
|
iconClass: string;
|
|
sectionClass: string;
|
|
trackClass: string;
|
|
};
|
|
|
|
type ContractTypeSection = {
|
|
key: ContractTypeKey;
|
|
meta: ContractTypeMeta;
|
|
tracks: TemplateTrack[];
|
|
totalTemplates: number;
|
|
activeTemplates: number;
|
|
};
|
|
|
|
type VersionHistoryModalState = {
|
|
trackTitle: string;
|
|
languageLabel: string;
|
|
templates: ContractTemplate[];
|
|
};
|
|
|
|
type NormalizedTemplate = DocumentTemplate & {
|
|
_id?: string;
|
|
uuid?: string;
|
|
description?: string;
|
|
modifiedAt?: string;
|
|
updated_at?: string;
|
|
contractType?: string | null;
|
|
userType?: string | null;
|
|
};
|
|
|
|
const FAMILY_ORDER: TemplateFamilyKey[] = ['contract', 'invoice', 'other'];
|
|
|
|
const FAMILY_META: Record<TemplateFamilyKey, { label: string; description: string; tabClass: string; tabActiveClass: string; tagClass: string; icon: string }> = {
|
|
contract: {
|
|
label: 'Contracts',
|
|
description: 'ABO, GDPR and standard contracts grouped by audience and language.',
|
|
tabClass: 'border-amber-200 bg-white text-slate-700 hover:border-amber-300 hover:bg-amber-50/80',
|
|
tabActiveClass: 'border-amber-300 bg-amber-50 text-amber-950 shadow-[0_12px_30px_-18px_rgba(217,119,6,0.65)]',
|
|
tagClass: 'border-amber-200 bg-amber-50 text-amber-900',
|
|
icon: 'C',
|
|
},
|
|
invoice: {
|
|
label: 'Invoices',
|
|
description: 'Invoice layouts with their active language variants and revision history.',
|
|
tabClass: 'border-sky-200 bg-white text-slate-700 hover:border-sky-300 hover:bg-sky-50/80',
|
|
tabActiveClass: 'border-sky-300 bg-sky-50 text-sky-950 shadow-[0_12px_30px_-18px_rgba(2,132,199,0.55)]',
|
|
tagClass: 'border-sky-200 bg-sky-50 text-sky-900',
|
|
icon: 'I',
|
|
},
|
|
other: {
|
|
label: 'Other',
|
|
description: 'Custom templates that do not belong to the main contract or invoice flows.',
|
|
tabClass: 'border-emerald-200 bg-white text-slate-700 hover:border-emerald-300 hover:bg-emerald-50/80',
|
|
tabActiveClass: 'border-emerald-300 bg-emerald-50 text-emerald-950 shadow-[0_12px_30px_-18px_rgba(5,150,105,0.55)]',
|
|
tagClass: 'border-emerald-200 bg-emerald-50 text-emerald-900',
|
|
icon: 'O',
|
|
},
|
|
};
|
|
|
|
const CONTRACT_TYPE_ORDER: ContractTypeKey[] = ['abo', 'gdpr', 'contract'];
|
|
|
|
const CONTRACT_TYPE_META: Record<ContractTypeKey, ContractTypeMeta> = {
|
|
abo: {
|
|
label: 'ABO Contracts',
|
|
shortLabel: 'ABO',
|
|
description: 'Subscription and recurring-delivery templates with their own active revision lanes.',
|
|
badgeClass: 'border-amber-200 bg-amber-50 text-amber-900',
|
|
headerClass: 'border-amber-200 bg-amber-50 text-amber-950',
|
|
iconClass: 'border-amber-200 bg-amber-100 text-amber-950',
|
|
sectionClass: 'border-amber-200 bg-[linear-gradient(180deg,rgba(255,251,235,0.96)_0%,rgba(255,255,255,0.99)_24%)]',
|
|
trackClass: 'border-amber-200 bg-[linear-gradient(180deg,rgba(255,251,235,0.92)_0%,rgba(255,255,255,0.99)_28%)]',
|
|
},
|
|
gdpr: {
|
|
label: 'GDPR Contracts',
|
|
shortLabel: 'GDPR',
|
|
description: 'Privacy and consent-related templates separated from commercial contract flows.',
|
|
badgeClass: 'border-sky-200 bg-sky-50 text-sky-900',
|
|
headerClass: 'border-sky-200 bg-sky-50 text-sky-950',
|
|
iconClass: 'border-sky-200 bg-sky-100 text-sky-950',
|
|
sectionClass: 'border-sky-200 bg-[linear-gradient(180deg,rgba(240,249,255,0.96)_0%,rgba(255,255,255,0.99)_24%)]',
|
|
trackClass: 'border-sky-200 bg-[linear-gradient(180deg,rgba(240,249,255,0.92)_0%,rgba(255,255,255,0.99)_28%)]',
|
|
},
|
|
contract: {
|
|
label: 'Standard Contracts',
|
|
shortLabel: 'STD',
|
|
description: 'General contract templates outside ABO and GDPR, kept in a separate lane for faster scanning.',
|
|
badgeClass: 'border-slate-300 bg-slate-100 text-slate-800',
|
|
headerClass: 'border-slate-300 bg-slate-100 text-slate-950',
|
|
iconClass: 'border-slate-300 bg-white text-slate-950',
|
|
sectionClass: 'border-slate-300 bg-[linear-gradient(180deg,rgba(248,250,252,0.96)_0%,rgba(255,255,255,0.99)_24%)]',
|
|
trackClass: 'border-slate-300 bg-[linear-gradient(180deg,rgba(248,250,252,0.94)_0%,rgba(255,255,255,0.99)_28%)]',
|
|
},
|
|
};
|
|
|
|
const LANGUAGE_ORDER = ['de', 'en', 'other'];
|
|
|
|
function normalizeFamily(type?: string | null): TemplateFamilyKey {
|
|
if (type === 'contract') return 'contract';
|
|
if (type === 'invoice') return 'invoice';
|
|
return 'other';
|
|
}
|
|
|
|
function normalizeContractType(contractType?: string | null): ContractTypeKey {
|
|
const value = (contractType || 'contract').toLowerCase();
|
|
return ['contract', 'gdpr', 'abo'].includes(value) ? (value as ContractTypeKey) : 'contract';
|
|
}
|
|
|
|
function normalizeUserType(userType?: string | null): AudienceKey {
|
|
const value = (userType || 'both').toLowerCase();
|
|
return ['personal', 'company', 'both'].includes(value) ? (value as AudienceKey) : 'both';
|
|
}
|
|
|
|
function normalizeLanguage(lang?: string | null) {
|
|
const value = (lang || '').toLowerCase();
|
|
return value === 'de' || value === 'en' ? value : 'other';
|
|
}
|
|
|
|
function normalizeName(name?: string | null) {
|
|
return (name || 'untitled').trim().toLowerCase().replace(/\s+/g, ' ');
|
|
}
|
|
|
|
function formatUserType(userType?: string | null) {
|
|
switch (normalizeUserType(userType)) {
|
|
case 'personal':
|
|
return 'Privat';
|
|
case 'company':
|
|
return 'Business';
|
|
default:
|
|
return 'Beide';
|
|
}
|
|
}
|
|
|
|
function formatLanguage(lang?: string | null) {
|
|
switch (normalizeLanguage(lang)) {
|
|
case 'de':
|
|
return 'German';
|
|
case 'en':
|
|
return 'English';
|
|
default:
|
|
return 'Other language';
|
|
}
|
|
}
|
|
|
|
function formatLanguageCode(lang?: string | null) {
|
|
switch (normalizeLanguage(lang)) {
|
|
case 'de':
|
|
return 'DE';
|
|
case 'en':
|
|
return 'EN';
|
|
default:
|
|
return 'OT';
|
|
}
|
|
}
|
|
|
|
function formatContractType(contractType?: string | null) {
|
|
switch (normalizeContractType(contractType)) {
|
|
case 'abo':
|
|
return 'ABO Contract';
|
|
case 'gdpr':
|
|
return 'GDPR Contract';
|
|
default:
|
|
return 'Standard Contract';
|
|
}
|
|
}
|
|
|
|
function getContractTypeMeta(contractType?: string | null) {
|
|
return CONTRACT_TYPE_META[normalizeContractType(contractType)];
|
|
}
|
|
|
|
function getTrackContractType(track: TemplateTrack) {
|
|
return normalizeContractType(track.templates[0]?.contract_type);
|
|
}
|
|
|
|
function formatTimestamp(value?: string) {
|
|
if (!value) return null;
|
|
|
|
const parsed = new Date(value);
|
|
if (Number.isNaN(parsed.getTime())) return null;
|
|
|
|
return new Intl.DateTimeFormat(undefined, {
|
|
dateStyle: 'medium',
|
|
timeStyle: 'short',
|
|
}).format(parsed);
|
|
}
|
|
|
|
function compareTemplates(left: ContractTemplate, right: ContractTemplate) {
|
|
const statusWeight = (status: string) => {
|
|
if (status === 'published') return 0;
|
|
if (status === 'draft') return 1;
|
|
return 2;
|
|
};
|
|
|
|
const statusDelta = statusWeight(left.status) - statusWeight(right.status);
|
|
if (statusDelta !== 0) return statusDelta;
|
|
|
|
const versionDelta = right.version - left.version;
|
|
if (versionDelta !== 0) return versionDelta;
|
|
|
|
const leftTime = left.updatedAt ? new Date(left.updatedAt).getTime() : 0;
|
|
const rightTime = right.updatedAt ? new Date(right.updatedAt).getTime() : 0;
|
|
return rightTime - leftTime;
|
|
}
|
|
|
|
function buildTrackKey(template: ContractTemplate) {
|
|
const family = normalizeFamily(template.type);
|
|
return [
|
|
family,
|
|
normalizeContractType(template.contract_type),
|
|
normalizeUserType(template.user_type),
|
|
normalizeName(template.name),
|
|
].join(':');
|
|
}
|
|
|
|
function buildTrack(templates: ContractTemplate[]): TemplateTrack {
|
|
const sortedTemplates = [...templates].sort(compareTemplates);
|
|
const lead = sortedTemplates.find((template) => template.status === 'published') || sortedTemplates[0];
|
|
const family = normalizeFamily(lead?.type);
|
|
const names = Array.from(new Set(sortedTemplates.map((template) => template.name.trim()).filter(Boolean)));
|
|
const languageKeys = Array.from(new Set(sortedTemplates.map((template) => normalizeLanguage(template.lang)))).sort(
|
|
(left, right) => LANGUAGE_ORDER.indexOf(left) - LANGUAGE_ORDER.indexOf(right)
|
|
);
|
|
const languageColumns = languageKeys.map((langKey) => {
|
|
const templatesInLanguage = sortedTemplates.filter((template) => normalizeLanguage(template.lang) === langKey);
|
|
return {
|
|
key: langKey,
|
|
label: formatLanguage(langKey),
|
|
templates: templatesInLanguage,
|
|
activeTemplate: templatesInLanguage.find((template) => template.status === 'published'),
|
|
};
|
|
});
|
|
const latestUpdatedAt = sortedTemplates.find((template) => template.updatedAt)?.updatedAt;
|
|
|
|
let title = lead?.name || 'Untitled Template';
|
|
let subtitle = `${sortedTemplates.length} version${sortedTemplates.length === 1 ? '' : 's'}`;
|
|
|
|
if (family === 'contract') {
|
|
const contractLabel = formatContractType(lead?.contract_type);
|
|
title = names[0] || contractLabel;
|
|
subtitle = `${sortedTemplates.length} version${sortedTemplates.length === 1 ? '' : 's'}`;
|
|
|
|
if (title !== contractLabel) {
|
|
subtitle = `${contractLabel} • ${subtitle}`;
|
|
}
|
|
}
|
|
|
|
if (family === 'invoice') {
|
|
title = names.length === 1 ? names[0] : 'Invoice Templates';
|
|
subtitle = `${formatUserType(lead?.user_type)} • ${sortedTemplates.length} version${sortedTemplates.length === 1 ? '' : 's'}`;
|
|
}
|
|
|
|
return {
|
|
key: buildTrackKey(lead),
|
|
family,
|
|
title,
|
|
subtitle,
|
|
description: lead?.description || undefined,
|
|
templates: sortedTemplates,
|
|
languageColumns,
|
|
activeCount: sortedTemplates.filter((template) => template.status === 'published').length,
|
|
latestUpdatedAt,
|
|
};
|
|
}
|
|
|
|
function Pill({ children, className }: { children: React.ReactNode; className: string }) {
|
|
return <span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium leading-none ${className}`}>{children}</span>;
|
|
}
|
|
|
|
function MetricChip({ label, value }: { label: string; value: React.ReactNode }) {
|
|
return (
|
|
<span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs text-slate-600 shadow-sm">
|
|
<span className="uppercase tracking-[0.18em] text-slate-400">{label}</span>
|
|
<span className="text-sm font-semibold text-slate-900">{value}</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function StatusBadge({ status }: { status: string }) {
|
|
const map: Record<string, string> = {
|
|
draft: 'border-slate-200 bg-slate-100 text-slate-700',
|
|
published: 'border-emerald-200 bg-emerald-100 text-emerald-800',
|
|
archived: 'border-amber-200 bg-amber-100 text-amber-800',
|
|
};
|
|
const labels: Record<string, string> = {
|
|
draft: 'Draft',
|
|
published: 'Active',
|
|
archived: 'Archived',
|
|
};
|
|
const cls = map[status] || 'bg-blue-100 text-blue-800 border border-blue-300';
|
|
return <span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium leading-none ${cls}`}>{labels[status] || status}</span>;
|
|
}
|
|
|
|
function TemplateVersionHistoryModal({
|
|
history,
|
|
open,
|
|
onClose,
|
|
onEdit,
|
|
onPreview,
|
|
onGenPdf,
|
|
onDownloadPdf,
|
|
onToggleState,
|
|
}: {
|
|
history: VersionHistoryModalState | null;
|
|
open: boolean;
|
|
onClose: () => void;
|
|
onEdit?: (id: string) => void;
|
|
onPreview: (id: string) => void;
|
|
onGenPdf: (id: string) => void;
|
|
onDownloadPdf: (id: string) => void;
|
|
onToggleState: (id: string, current: string) => Promise<void>;
|
|
}) {
|
|
const templates = history?.templates || [];
|
|
|
|
return (
|
|
<Transition show={open} as={React.Fragment}>
|
|
<Dialog onClose={onClose} className="relative z-1150">
|
|
<Transition.Child
|
|
as={React.Fragment}
|
|
enter="transition-opacity ease-out duration-200"
|
|
enterFrom="opacity-0"
|
|
enterTo="opacity-100"
|
|
leave="transition-opacity ease-in duration-150"
|
|
leaveFrom="opacity-100"
|
|
leaveTo="opacity-0"
|
|
>
|
|
<div className="fixed inset-0 bg-slate-950/45 backdrop-blur-sm" />
|
|
</Transition.Child>
|
|
|
|
<div className="fixed inset-0 overflow-y-auto">
|
|
<div className="flex min-h-full items-center justify-center p-4 md:p-6">
|
|
<Transition.Child
|
|
as={React.Fragment}
|
|
enter="transition-all ease-out duration-200"
|
|
enterFrom="opacity-0 translate-y-2 sm:scale-95"
|
|
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
|
leave="transition-all ease-in duration-150"
|
|
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
|
leaveTo="opacity-0 translate-y-2 sm:scale-95"
|
|
>
|
|
<Dialog.Panel className="w-full max-w-4xl rounded-[28px] border border-white/80 bg-white p-5 shadow-[0_30px_90px_-40px_rgba(15,23,42,0.45)] md:p-6">
|
|
<div className="flex items-start justify-between gap-4 border-b border-slate-200 pb-4">
|
|
<div>
|
|
<Dialog.Title className="text-xl font-semibold tracking-tight text-slate-950">Version history</Dialog.Title>
|
|
<p className="mt-1 text-sm text-slate-600">
|
|
{history ? `${history.trackTitle} • ${history.languageLabel}` : ''}
|
|
</p>
|
|
<p className="mt-2 text-sm text-slate-500">
|
|
Hidden revisions stay out of the main list, but remain available for preview, editing and re-activation here.
|
|
</p>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-500 transition hover:border-slate-300 hover:text-slate-700"
|
|
aria-label="Close version history"
|
|
>
|
|
<XMarkIcon className="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="mt-5 max-h-[70vh] space-y-3 overflow-y-auto pr-1">
|
|
{templates.map((template) => (
|
|
<div key={template.id} className="rounded-2xl border border-slate-200 bg-slate-50/70 px-4 py-4">
|
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<div className="text-sm font-semibold text-slate-900">v{template.version}</div>
|
|
<StatusBadge status={template.status} />
|
|
</div>
|
|
<p className="mt-2 truncate text-sm font-medium text-slate-900">{template.name}</p>
|
|
<div className="mt-1 text-xs text-slate-500">{formatTimestamp(template.updatedAt) || 'n/a'}</div>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-2 lg:justify-end">
|
|
{onEdit && (
|
|
<button
|
|
onClick={() => onEdit(template.id)}
|
|
className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-xs font-medium text-slate-700 transition hover:border-slate-300 hover:bg-slate-50"
|
|
>
|
|
Edit
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => onPreview(template.id)}
|
|
className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-xs font-medium text-slate-700 transition hover:border-slate-300 hover:bg-slate-50"
|
|
>
|
|
Preview
|
|
</button>
|
|
<button
|
|
onClick={() => onGenPdf(template.id)}
|
|
className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-xs font-medium text-slate-700 transition hover:border-slate-300 hover:bg-slate-50"
|
|
>
|
|
PDF
|
|
</button>
|
|
<button
|
|
onClick={() => onDownloadPdf(template.id)}
|
|
className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-xs font-medium text-slate-700 transition hover:border-slate-300 hover:bg-slate-50"
|
|
>
|
|
Download
|
|
</button>
|
|
<button
|
|
onClick={() => onToggleState(template.id, template.status)}
|
|
className={`rounded-xl px-3 py-2 text-xs font-semibold transition ${template.status === 'published'
|
|
? 'border border-red-200 bg-red-50 text-red-700 hover:bg-red-100'
|
|
: 'border border-slate-900 bg-slate-900 text-white hover:bg-slate-800'}`}
|
|
>
|
|
{template.status === 'published' ? 'Deactivate' : 'Activate'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{!templates.length && (
|
|
<div className="rounded-2xl border border-dashed border-slate-300 bg-slate-50 px-5 py-10 text-center text-sm text-slate-500">
|
|
No hidden versions for this language.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Dialog.Panel>
|
|
</Transition.Child>
|
|
</div>
|
|
</div>
|
|
</Dialog>
|
|
</Transition>
|
|
);
|
|
}
|
|
|
|
export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props) {
|
|
const { t } = useTranslation();
|
|
const [items, setItems] = useState<ContractTemplate[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [q, setQ] = useState('');
|
|
const [selectedFamily, setSelectedFamily] = useState<TemplateFamilyKey>('contract');
|
|
const [selectedLanguageByTrack, setSelectedLanguageByTrack] = useState<Record<string, string>>({});
|
|
const [pendingToggle, setPendingToggle] = useState<{ id: string; target: 'active' | 'inactive'; message?: string; requiresConfirm: boolean } | null>(null);
|
|
const [versionHistory, setVersionHistory] = useState<VersionHistoryModalState | null>(null);
|
|
|
|
const {
|
|
listTemplates,
|
|
openPreviewInNewTab,
|
|
generatePdf,
|
|
downloadPdf,
|
|
updateTemplateState,
|
|
downloadBlobFile,
|
|
} = useContractManagement();
|
|
|
|
const filtered = useMemo(() => {
|
|
const term = q.trim().toLowerCase();
|
|
if (!term) return items;
|
|
return items.filter((item) => {
|
|
const haystack = [
|
|
item.name,
|
|
item.description || '',
|
|
item.type || '',
|
|
item.contract_type || '',
|
|
item.user_type || '',
|
|
item.lang || '',
|
|
item.status,
|
|
`v${item.version}`,
|
|
formatContractType(item.contract_type),
|
|
formatUserType(item.user_type),
|
|
formatLanguage(item.lang),
|
|
]
|
|
.join(' ')
|
|
.toLowerCase();
|
|
|
|
return haystack.includes(term);
|
|
});
|
|
}, [items, q]);
|
|
|
|
const familyGroups = useMemo<TemplateFamilyGroup[]>(() => {
|
|
const grouped = new Map<TemplateFamilyKey, Map<string, ContractTemplate[]>>();
|
|
|
|
filtered.forEach((item) => {
|
|
const family = normalizeFamily(item.type);
|
|
const trackKey = buildTrackKey(item);
|
|
|
|
if (!grouped.has(family)) {
|
|
grouped.set(family, new Map());
|
|
}
|
|
|
|
const familyMap = grouped.get(family);
|
|
if (!familyMap) return;
|
|
|
|
if (!familyMap.has(trackKey)) {
|
|
familyMap.set(trackKey, []);
|
|
}
|
|
|
|
familyMap.get(trackKey)?.push(item);
|
|
});
|
|
|
|
return FAMILY_ORDER.map((family) => {
|
|
const trackMap = grouped.get(family) || new Map<string, ContractTemplate[]>();
|
|
const tracks = Array.from(trackMap.values())
|
|
.map((templates) => buildTrack(templates))
|
|
.sort((left, right) => {
|
|
const activityDelta = right.activeCount - left.activeCount;
|
|
if (activityDelta !== 0) return activityDelta;
|
|
return left.title.localeCompare(right.title);
|
|
});
|
|
|
|
const allTemplates = tracks.flatMap((track) => track.templates);
|
|
|
|
return {
|
|
key: family,
|
|
label: FAMILY_META[family].label,
|
|
description: FAMILY_META[family].description,
|
|
tracks,
|
|
totalTemplates: allTemplates.length,
|
|
activeTemplates: allTemplates.filter((template) => template.status === 'published').length,
|
|
};
|
|
}).filter((group) => group.totalTemplates > 0);
|
|
}, [filtered]);
|
|
|
|
const activeFamily = familyGroups.find((group) => group.key === selectedFamily) || familyGroups[0] || null;
|
|
const activeContractSections = useMemo<ContractTypeSection[]>(() => {
|
|
if (!activeFamily || activeFamily.key !== 'contract') return [];
|
|
|
|
return CONTRACT_TYPE_ORDER
|
|
.map((contractType) => {
|
|
const tracks = activeFamily.tracks.filter((track) => getTrackContractType(track) === contractType);
|
|
if (!tracks.length) return null;
|
|
|
|
const templates = tracks.flatMap((track) => track.templates);
|
|
|
|
return {
|
|
key: contractType,
|
|
meta: CONTRACT_TYPE_META[contractType],
|
|
tracks: [...tracks].sort((left, right) => left.title.localeCompare(right.title)),
|
|
totalTemplates: templates.length,
|
|
activeTemplates: templates.filter((template) => template.status === 'published').length,
|
|
};
|
|
})
|
|
.filter((section): section is ContractTypeSection => Boolean(section));
|
|
}, [activeFamily]);
|
|
|
|
const load = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const data = await listTemplates();
|
|
const mapped: ContractTemplate[] = data
|
|
.map((template) => {
|
|
const normalized = template as NormalizedTemplate;
|
|
|
|
return {
|
|
id: normalized.id ?? normalized._id ?? normalized.uuid ?? '',
|
|
name: normalized.name ?? 'Untitled',
|
|
type: normalized.type,
|
|
contract_type: normalized.contract_type ?? normalized.contractType ?? null,
|
|
user_type: normalized.user_type ?? normalized.userType ?? null,
|
|
lang: normalized.lang ?? null,
|
|
version: Number(normalized.version ?? 1),
|
|
status: normalized.state === 'active' ? 'published' : 'draft',
|
|
updatedAt: normalized.updatedAt ?? normalized.modifiedAt ?? normalized.updated_at,
|
|
description: normalized.description ?? '',
|
|
};
|
|
})
|
|
.filter((template) => template.id);
|
|
setItems(mapped);
|
|
} catch {
|
|
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() },
|
|
]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
load();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [refreshKey]);
|
|
|
|
useEffect(() => {
|
|
if (!familyGroups.length) return;
|
|
if (!familyGroups.some((group) => group.key === selectedFamily)) {
|
|
setSelectedFamily(familyGroups[0].key);
|
|
}
|
|
}, [familyGroups, selectedFamily]);
|
|
|
|
const executeToggleState = async (id: string, target: 'active' | 'inactive') => {
|
|
try {
|
|
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));
|
|
await load();
|
|
} catch {}
|
|
};
|
|
|
|
const onToggleState = async (id: string, current: string) => {
|
|
const target = current === 'published' ? 'inactive' : 'active';
|
|
if (target === 'active') {
|
|
const tpl = items.find((i) => i.id === id);
|
|
if (tpl) {
|
|
const kind = tpl.type === 'contract'
|
|
? (tpl.contract_type === 'gdpr' ? 'GDPR' : tpl.contract_type === 'abo' ? 'ABO' : 'Contract')
|
|
: tpl.type === 'invoice'
|
|
? 'Invoice'
|
|
: 'Other';
|
|
setPendingToggle({
|
|
id,
|
|
target,
|
|
requiresConfirm: true,
|
|
message: `This will deactivate other active ${kind} templates that apply to the same user type and language.`,
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
await executeToggleState(id, target);
|
|
};
|
|
|
|
const confirmToggleState = async () => {
|
|
if (!pendingToggle) return;
|
|
const shouldCloseVersionHistory = pendingToggle.target === 'active'
|
|
&& Boolean(versionHistory?.templates.some((template) => template.id === pendingToggle.id));
|
|
await executeToggleState(pendingToggle.id, pendingToggle.target);
|
|
if (shouldCloseVersionHistory) {
|
|
setVersionHistory(null);
|
|
}
|
|
setPendingToggle(null);
|
|
};
|
|
|
|
const onPreview = (id: string) => openPreviewInNewTab(id);
|
|
|
|
const onGenPdf = async (id: string) => {
|
|
try {
|
|
const blob = await generatePdf(id, { preview: true });
|
|
downloadBlobFile(blob, `${id}-preview.pdf`);
|
|
} catch {}
|
|
};
|
|
|
|
const onDownloadPdf = async (id: string) => {
|
|
try {
|
|
const blob = await downloadPdf(id);
|
|
downloadBlobFile(blob, `${id}.pdf`);
|
|
} catch {}
|
|
};
|
|
|
|
const renderTrackCard = (track: TemplateTrack) => {
|
|
const contractMeta = track.family === 'contract' ? getContractTypeMeta(track.templates[0]?.contract_type) : null;
|
|
const defaultLanguageKey = track.languageColumns.find((languageColumn) => languageColumn.activeTemplate)?.key || track.languageColumns[0]?.key;
|
|
const selectedLanguageKey = selectedLanguageByTrack[track.key] || defaultLanguageKey;
|
|
const visibleLanguageColumn = track.languageColumns.find((languageColumn) => languageColumn.key === selectedLanguageKey) || track.languageColumns[0];
|
|
|
|
if (!visibleLanguageColumn) return null;
|
|
|
|
const primaryTemplate = visibleLanguageColumn.activeTemplate || visibleLanguageColumn.templates[0] || null;
|
|
const visibleTemplates = primaryTemplate ? [primaryTemplate] : [];
|
|
const hiddenTemplates = primaryTemplate
|
|
? visibleLanguageColumn.templates.filter((template) => template.id !== primaryTemplate.id)
|
|
: visibleLanguageColumn.templates;
|
|
const hiddenVersionCount = hiddenTemplates.length;
|
|
|
|
return (
|
|
<article
|
|
key={track.key}
|
|
className={`rounded-3xl border p-4 shadow-[0_18px_40px_-30px_rgba(15,23,42,0.4)] md:p-5 ${contractMeta ? contractMeta.trackClass : 'border-slate-200 bg-white'}`}
|
|
>
|
|
<div className="flex flex-col gap-4 border-b border-slate-200 pb-4 lg:flex-row lg:items-start lg:justify-between">
|
|
<div>
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
{track.family !== 'contract' && <Pill className={FAMILY_META[track.family].tagClass}>{FAMILY_META[track.family].label}</Pill>}
|
|
{track.family === 'contract' && (
|
|
<Pill className="border-slate-200 bg-white/90 text-slate-700">{formatUserType(track.templates[0]?.user_type)}</Pill>
|
|
)}
|
|
{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 === 'other' && track.templates[0]?.user_type && (
|
|
<Pill className="border-slate-200 bg-slate-100 text-slate-700">{formatUserType(track.templates[0].user_type)}</Pill>
|
|
)}
|
|
</div>
|
|
<h5 className="mt-3 text-xl font-semibold tracking-tight text-slate-950">{track.title}</h5>
|
|
<p className="mt-1 text-sm text-slate-500">{track.subtitle}</p>
|
|
{track.description && (
|
|
<p className="mt-2 max-w-2xl text-sm leading-5 text-slate-600">{track.description}</p>
|
|
)}
|
|
|
|
<div className="mt-3">
|
|
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Languages</div>
|
|
<div className="mt-2 flex gap-2 overflow-x-auto pb-1">
|
|
{track.languageColumns.map((languageColumn) => {
|
|
const isSelected = languageColumn.key === selectedLanguageKey;
|
|
|
|
return (
|
|
<button
|
|
key={`${track.key}-${languageColumn.key}-selector`}
|
|
type="button"
|
|
onClick={() => setSelectedLanguageByTrack((current) => ({
|
|
...current,
|
|
[track.key]: languageColumn.key,
|
|
}))}
|
|
className={`inline-flex shrink-0 items-center gap-2 rounded-full border px-3 py-2 text-xs font-medium transition ${isSelected
|
|
? 'border-slate-900 bg-slate-900 text-white shadow-[0_12px_24px_-18px_rgba(15,23,42,0.75)]'
|
|
: 'border-slate-200 bg-white/90 text-slate-700 hover:border-slate-300 hover:bg-slate-50'}`}
|
|
>
|
|
<span className={`inline-flex h-5 min-w-5 items-center justify-center rounded-full px-1 text-[10px] font-bold ${isSelected ? 'bg-white/15 text-white' : 'bg-slate-100 text-slate-700'}`}>
|
|
{formatLanguageCode(languageColumn.key)}
|
|
</span>
|
|
<span>{languageColumn.label}</span>
|
|
{languageColumn.activeTemplate && (
|
|
<span className={`text-[10px] font-semibold ${isSelected ? 'text-white/85' : 'text-emerald-700'}`}>
|
|
v{languageColumn.activeTemplate.version}
|
|
</span>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-600 lg:max-w-88 lg:justify-end">
|
|
<MetricChip label="Active" value={track.activeCount} />
|
|
<MetricChip label="Languages" value={track.languageColumns.length} />
|
|
<MetricChip label="Updated" value={formatTimestamp(track.latestUpdatedAt) || 'n/a'} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 overflow-hidden rounded-2xl border border-slate-200 bg-white/92">
|
|
<div className="flex flex-col gap-2 border-b border-slate-200 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<span className="inline-flex h-8 min-w-8 items-center justify-center rounded-full border border-slate-200 bg-slate-100 px-2 text-xs font-bold text-slate-700">
|
|
{formatLanguageCode(visibleLanguageColumn.key)}
|
|
</span>
|
|
<div>
|
|
<div className="text-base font-semibold text-slate-900">{visibleLanguageColumn.label}</div>
|
|
<div className="mt-1 text-sm text-slate-500">
|
|
{visibleLanguageColumn.activeTemplate
|
|
? `Active version: v${visibleLanguageColumn.activeTemplate.version}`
|
|
: primaryTemplate
|
|
? `Latest version: v${primaryTemplate.version}`
|
|
: 'No version yet'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<div className="rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-semibold text-slate-600">
|
|
{visibleLanguageColumn.templates.length} version{visibleLanguageColumn.templates.length === 1 ? '' : 's'}
|
|
</div>
|
|
{hiddenVersionCount > 0 && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setVersionHistory({
|
|
trackTitle: track.title,
|
|
languageLabel: visibleLanguageColumn.label,
|
|
templates: hiddenTemplates,
|
|
})}
|
|
className="rounded-full border border-slate-200 bg-slate-900 px-3 py-1 text-xs font-semibold text-white transition hover:bg-slate-800"
|
|
>
|
|
Version history ({hiddenVersionCount})
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="divide-y divide-slate-200">
|
|
{visibleTemplates.map((template) => (
|
|
<div key={template.id} className="flex flex-col gap-3 px-4 py-4 lg:flex-row lg:items-center lg:justify-between">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<div className="text-sm font-semibold text-slate-900">v{template.version}</div>
|
|
<StatusBadge status={template.status} />
|
|
</div>
|
|
<p className="mt-2 truncate text-sm font-medium text-slate-900">{template.name}</p>
|
|
<div className="mt-1 text-xs text-slate-500">{formatTimestamp(template.updatedAt) || t('autofix.kee838580')}</div>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-2 lg:justify-end">
|
|
{onEdit && (
|
|
<button
|
|
onClick={() => onEdit(template.id)}
|
|
className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-xs font-medium text-slate-700 transition hover:border-slate-300 hover:bg-slate-50"
|
|
>
|
|
Edit
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => onPreview(template.id)}
|
|
className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-xs font-medium text-slate-700 transition hover:border-slate-300 hover:bg-slate-50"
|
|
>
|
|
Preview
|
|
</button>
|
|
<button
|
|
onClick={() => onGenPdf(template.id)}
|
|
className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-xs font-medium text-slate-700 transition hover:border-slate-300 hover:bg-slate-50"
|
|
>
|
|
PDF
|
|
</button>
|
|
<button
|
|
onClick={() => onDownloadPdf(template.id)}
|
|
className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-xs font-medium text-slate-700 transition hover:border-slate-300 hover:bg-slate-50"
|
|
>
|
|
Download
|
|
</button>
|
|
<button
|
|
onClick={() => onToggleState(template.id, template.status)}
|
|
className={`rounded-xl px-3 py-2 text-xs font-semibold transition ${template.status === 'published'
|
|
? 'border border-red-200 bg-red-50 text-red-700 hover:bg-red-100'
|
|
: 'border border-slate-900 bg-slate-900 text-white hover:bg-slate-800'}`}
|
|
>
|
|
{template.status === 'published' ? 'Deactivate' : 'Activate'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</article>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<section className="overflow-hidden rounded-4xl border border-slate-200 bg-white shadow-[0_24px_70px_-45px_rgba(15,23,42,0.45)]">
|
|
<div className="border-b border-slate-200 bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.18),transparent_30%),radial-gradient(circle_at_top_right,rgba(56,189,248,0.18),transparent_28%),linear-gradient(180deg,#ffffff_0%,#f8fafc_100%)] px-6 py-6 md:px-7 md:py-7 xl:px-8">
|
|
<div className="space-y-3">
|
|
<div className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white/85 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.26em] text-slate-500 shadow-sm">{t('autofix.k66b39536')}</div>
|
|
<div>
|
|
<h3 className="text-2xl font-semibold tracking-tight text-slate-950 md:text-3xl">{t('autofix.k429d94bf')}</h3>
|
|
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-600 md:text-[15px]">{t('autofix.k7e4ef084')}</p>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2 pt-1">
|
|
<MetricChip label="Families" value={familyGroups.length} />
|
|
<MetricChip label="Templates" value={filtered.length} />
|
|
<MetricChip label="Active" value={filtered.filter((template) => template.status === 'published').length} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-5 flex flex-col gap-3 lg:flex-row lg:items-center">
|
|
<input
|
|
placeholder={t('autofix.k35ac864e')}
|
|
value={q}
|
|
onChange={(e) => setQ(e.target.value)}
|
|
className="w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-slate-300 focus:ring-4 focus:ring-sky-100"
|
|
/>
|
|
<button
|
|
onClick={load}
|
|
disabled={loading}
|
|
className="inline-flex shrink-0 items-center justify-center rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm font-medium text-slate-700 shadow-sm transition hover:border-slate-300 hover:bg-slate-50 disabled:opacity-60"
|
|
>{loading ? t('autofix.k832387c5') : 'Refresh'}</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-6 px-5 py-6 md:px-7 md:py-7 xl:px-8">
|
|
{familyGroups.length > 0 && (
|
|
<div className="flex gap-3 overflow-x-auto pb-1">
|
|
{familyGroups.map((familyGroup) => {
|
|
const familyMeta = FAMILY_META[familyGroup.key];
|
|
const isActive = activeFamily?.key === familyGroup.key;
|
|
|
|
return (
|
|
<button
|
|
key={familyGroup.key}
|
|
type="button"
|
|
onClick={() => setSelectedFamily(familyGroup.key)}
|
|
className={`min-w-56 rounded-2xl border px-4 py-4 text-left transition ${isActive ? familyMeta.tabActiveClass : familyMeta.tabClass}`}
|
|
>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div>
|
|
<div className="text-base font-semibold text-current">{familyGroup.label}</div>
|
|
</div>
|
|
<span className="inline-flex h-9 w-9 items-center justify-center rounded-full border border-current/10 bg-white/80 text-sm font-semibold">
|
|
{familyMeta.icon}
|
|
</span>
|
|
</div>
|
|
<div className="mt-3 flex items-center gap-3 text-xs text-current/80">
|
|
<span>{familyGroup.tracks.length} tracks</span>
|
|
<span>{familyGroup.activeTemplates} active</span>
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{activeFamily && (
|
|
<div className="rounded-[28px] border border-slate-200 bg-slate-50/70 p-5 md:p-6">
|
|
<div className="flex flex-col gap-4 border-b border-slate-200 pb-4 md:flex-row md:items-end md:justify-between">
|
|
<div>
|
|
<div className={`inline-flex items-center gap-2 rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] ${FAMILY_META[activeFamily.key].tagClass}`}>
|
|
{activeFamily.label}
|
|
</div>
|
|
<h4 className="mt-3 text-2xl font-semibold tracking-tight text-slate-950">{activeFamily.label} library</h4>
|
|
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-600">{activeFamily.description}</p>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
<MetricChip label="Tracks" value={activeFamily.tracks.length} />
|
|
<MetricChip label="Versions" value={activeFamily.totalTemplates} />
|
|
<MetricChip label="Active" value={activeFamily.activeTemplates} />
|
|
</div>
|
|
</div>
|
|
|
|
{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.
|
|
</div>
|
|
)}
|
|
|
|
{activeFamily.key === 'contract' && (
|
|
<div className="mt-4 rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-700">{t('autofix.k766a5504')}</div>
|
|
)}
|
|
|
|
{activeFamily.key === 'contract' ? (
|
|
<div className="mt-6 space-y-5">
|
|
{activeContractSections.map((section) => (
|
|
<section key={section.key} className={`rounded-[28px] border p-4 md:p-5 ${section.meta.sectionClass}`}>
|
|
<div className="flex flex-col gap-4 border-b border-slate-200/80 pb-4 lg:flex-row lg:items-end lg:justify-between">
|
|
<div className="flex items-start gap-4">
|
|
<span className={`inline-flex shrink-0 items-center justify-center rounded-2xl border px-3 py-3 text-xs font-bold uppercase tracking-[0.18em] ${section.meta.iconClass}`}>
|
|
{section.meta.shortLabel}
|
|
</span>
|
|
<div>
|
|
<div className={`inline-flex items-center gap-2 rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] ${section.meta.headerClass}`}>{t('autofix.kf962066f')}</div>
|
|
<h5 className="mt-3 text-2xl font-semibold tracking-tight text-slate-950">{section.meta.label}</h5>
|
|
<p className="mt-2 max-w-2xl text-sm leading-5 text-slate-600">{section.meta.description}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-2 text-xs text-slate-600">
|
|
<span className="rounded-full border border-white/80 bg-white/90 px-3 py-1.5 font-medium text-slate-700">
|
|
{section.tracks.length} tracks
|
|
</span>
|
|
<span className="rounded-full border border-white/80 bg-white/90 px-3 py-1.5 font-medium text-slate-700">
|
|
{section.totalTemplates} versions
|
|
</span>
|
|
<span className="rounded-full border border-white/80 bg-white/90 px-3 py-1.5 font-medium text-slate-700">
|
|
{section.activeTemplates} active
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 grid gap-4 xl:grid-cols-[repeat(auto-fit,minmax(30rem,1fr))]">
|
|
{section.tracks.map((track) => renderTrackCard(track))}
|
|
</div>
|
|
</section>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="mt-6 grid gap-5 xl:grid-cols-[repeat(auto-fit,minmax(34rem,1fr))]">
|
|
{activeFamily.tracks.map((track) => renderTrackCard(track))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{!activeFamily && (
|
|
<div className="rounded-3xl border border-dashed border-slate-300 bg-slate-50 px-6 py-12 text-center text-sm text-slate-500">{items.length === 0
|
|
? t('autofix.k3772baff')
|
|
: t('autofix.k047a175d')}</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
<ConfirmActionModal
|
|
open={Boolean(pendingToggle?.requiresConfirm)}
|
|
title={t('autofix.k0c51fa85')}
|
|
description={pendingToggle?.message || 'This action will update template activation status.'}
|
|
confirmText="Activate"
|
|
onClose={() => setPendingToggle(null)}
|
|
onConfirm={confirmToggleState}
|
|
/>
|
|
|
|
<TemplateVersionHistoryModal
|
|
history={versionHistory}
|
|
open={Boolean(versionHistory)}
|
|
onClose={() => setVersionHistory(null)}
|
|
onEdit={onEdit}
|
|
onPreview={onPreview}
|
|
onGenPdf={onGenPdf}
|
|
onDownloadPdf={onDownloadPdf}
|
|
onToggleState={onToggleState}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|