profit-planet-frontend/src/app/admin/contract-management/components/contractTemplateList.tsx
2026-05-21 20:31:43 +02:00

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>
);
}