diff --git a/src/app/admin/contract-management/components/contractTemplateList.tsx b/src/app/admin/contract-management/components/contractTemplateList.tsx index 6af3a65..3024c9e 100644 --- a/src/app/admin/contract-management/components/contractTemplateList.tsx +++ b/src/app/admin/contract-management/components/contractTemplateList.tsx @@ -1,7 +1,7 @@ 'use client'; import React, { useEffect, useMemo, useState } from 'react'; -import useContractManagement from '../hooks/useContractManagement'; +import useContractManagement, { DocumentTemplate } from '../hooks/useContractManagement'; import ConfirmActionModal from '../../../components/modals/ConfirmActionModal'; type Props = { @@ -15,30 +15,342 @@ type ContractTemplate = { 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 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 = { + 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 = { + 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 = 'All customers'; + } + + 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 {children}; + return {children}; +} + +function MetricChip({ label, value }: { label: string; value: React.ReactNode }) { + return ( + + {label} + {value} + + ); } function StatusBadge({ status }: { status: string }) { const map: Record = { - draft: 'bg-gray-100 text-gray-800 border border-gray-300', - published: 'bg-green-100 text-green-800 border border-green-300', - archived: 'bg-yellow-100 text-yellow-800 border border-yellow-300', + 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 = { + draft: 'Draft', + published: 'Active', + archived: 'Archived', }; const cls = map[status] || 'bg-blue-100 text-blue-800 border border-blue-300'; - return {status}; + return {labels[status] || status}; } export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props) { const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); const [q, setQ] = useState(''); - const [pendingToggle, setPendingToggle] = useState<{ id: string; target: 'active' | 'inactive'; message?: string; requiresConfirm: boolean } | null>(null) + const [selectedFamily, setSelectedFamily] = useState('contract'); + const [selectedLanguageByTrack, setSelectedLanguageByTrack] = useState>({}); + const [pendingToggle, setPendingToggle] = useState<{ id: string; target: 'active' | 'inactive'; message?: string; requiresConfirm: boolean } | null>(null); const { listTemplates, @@ -52,28 +364,121 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props) const filtered = useMemo(() => { const term = q.trim().toLowerCase(); if (!term) return items; - return items.filter((i) => i.name.toLowerCase().includes(term) || String(i.version).includes(term) || i.status.toLowerCase().includes(term)); + 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(() => { + const grouped = new Map>(); + + 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(); + 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(() => { + 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((x: any) => ({ - id: x.id ?? x._id ?? x.uuid, - name: x.name ?? 'Untitled', - type: x.type, - contract_type: x.contract_type ?? x.contractType ?? null, - user_type: x.user_type ?? x.userType ?? null, - version: Number(x.version ?? 1), - status: (x.state === 'active') ? 'published' : 'draft', - updatedAt: x.updatedAt ?? x.modifiedAt ?? x.updated_at, - })); + 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: 'Sample Contract A', version: 1, status: 'draft', updatedAt: new Date().toISOString() }, - { id: 'ex2', name: 'NDA Template', version: 3, status: 'published', updatedAt: new Date().toISOString() }, + { 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); @@ -85,6 +490,13 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props) // 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'); @@ -92,7 +504,7 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props) 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'; @@ -117,10 +529,10 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props) }; const confirmToggleState = async () => { - if (!pendingToggle) return - await executeToggleState(pendingToggle.id, pendingToggle.target) - setPendingToggle(null) - } + if (!pendingToggle) return; + await executeToggleState(pendingToggle.id, pendingToggle.target); + setPendingToggle(null); + }; const onPreview = (id: string) => openPreviewInNewTab(id); @@ -138,75 +550,307 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props) } catch {} }; - return ( -
-
- Invoice templates always use user type "Both". Provide templates for each language (en/de). If no active invoice template matches, backend falls back to a text-only invoice. -
-
- setQ(e.target.value)} - className="w-full rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-indigo-500/50 shadow" - /> - -
+ 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]; -
- {filtered.map((c) => ( -
-

{c.name}

-
- - {c.type && ( - - {c.type === 'contract' ? 'Contract' : c.type === 'invoice' ? 'Invoice' : 'Other'} - + if (!visibleLanguageColumn) return null; + + return ( +
+
+
+
+ {track.family !== 'contract' && {FAMILY_META[track.family].label}} + {track.family === 'contract' && ( + {formatUserType(track.templates[0]?.user_type)} )} - {c.type === 'contract' && ( - - {c.contract_type === 'gdpr' ? 'GDPR' : c.contract_type === 'abo' ? 'ABO' : 'Contract'} - - )} - {c.user_type && c.type !== 'invoice' && ( - - {c.user_type === 'personal' ? 'Personal' : c.user_type === 'company' ? 'Company' : 'Both'} - + {track.family === 'other' && track.templates[0]?.user_type && ( + {formatUserType(track.templates[0].user_type)} )}
-

Version {c.version}{c.updatedAt ? ` • Updated ${new Date(c.updatedAt).toLocaleString()}` : ''}

-
- {onEdit && ( - - )} - - - - +
{track.title}
+

{track.subtitle}

+ {track.description && ( +

{track.description}

+ )} + +
+
Languages
+
+ {track.languageColumns.map((languageColumn) => { + const isSelected = languageColumn.key === selectedLanguageKey; + + return ( + + ); + })} +
- ))} - {!filtered.length && ( -
No contracts found.
- )} -
+ +
+ + + +
+
+ +
+
+
+ + {formatLanguageCode(visibleLanguageColumn.key)} + +
+
{visibleLanguageColumn.label}
+
+ {visibleLanguageColumn.activeTemplate + ? `Active version: v${visibleLanguageColumn.activeTemplate.version}` + : 'No active version yet'} +
+
+
+
+ {visibleLanguageColumn.templates.length} version{visibleLanguageColumn.templates.length === 1 ? '' : 's'} +
+
+ +
+ {visibleLanguageColumn.templates.map((template) => ( +
+
+
+
v{template.version}
+ +
+

{template.name}

+
{formatTimestamp(template.updatedAt) || 'No update time'}
+
+ +
+ {onEdit && ( + + )} + + + + +
+
+ ))} +
+
+
+ ); + }; + + return ( +
+
+
+
+
+ Template overview +
+
+

Organized by template family

+

+ Jump between template families, spot the currently active versions immediately and keep language-specific revisions in one place. +

+
+
+ + + template.status === 'published').length} /> +
+
+ +
+ 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" + /> + +
+
+ +
+ {familyGroups.length > 0 && ( +
+ {familyGroups.map((familyGroup) => { + const familyMeta = FAMILY_META[familyGroup.key]; + const isActive = activeFamily?.key === familyGroup.key; + + return ( + + ); + })} +
+ )} + + {activeFamily && ( +
+
+
+
+ {activeFamily.label} +
+

{activeFamily.label} library

+

{activeFamily.description}

+
+ +
+ + + +
+
+ + {activeFamily.key === 'invoice' && ( +
+ Invoice templates always use user type "Both". Keep one active version per language so the invoice flow stays predictable. +
+ )} + + {activeFamily.key === 'contract' && ( +
+ Contracts are grouped by type again. Language now sits in the main header area of each track as a direct selector, so you can switch faster without digging into nested cards. +
+ )} + + {activeFamily.key === 'contract' ? ( +
+ {activeContractSections.map((section) => ( +
+
+
+ + {section.meta.shortLabel} + +
+
+ Contract type +
+
{section.meta.label}
+

{section.meta.description}

+
+
+ +
+ + {section.tracks.length} tracks + + + {section.totalTemplates} versions + + + {section.activeTemplates} active + +
+
+ +
+ {section.tracks.map((track) => renderTrackCard(track))} +
+
+ ))} +
+ ) : ( +
+ {activeFamily.tracks.map((track) => renderTrackCard(track))} +
+ )} +
+ )} + + {!activeFamily && ( +
+ {items.length === 0 + ? 'No templates available yet. Create the first template to populate this workspace.' + : 'No templates match the current search.'} +
+ )} +
+
s.user); - const [mounted, setMounted] = useState(false); + const mounted = useSyncExternalStore( + () => () => {}, + () => true, + () => false + ); const router = useRouter(); const [section, setSection] = useState('templates'); const [editingTemplateId, setEditingTemplateId] = useState(null); const [editorKey, setEditorKey] = useState(0); - useEffect(() => { setMounted(true); }, []); - // Only allow admin const isAdmin = !!user && @@ -49,12 +51,10 @@ export default function ContractManagementPage() { return ( -
- {/* tighter horizontal padding on mobile */} -
- {/* Sidebar Navigation (mobile = horizontal scroll tabs, desktop = vertical) */} -