dev #21

Merged
Seazn merged 35 commits from dev into main 2026-05-21 17:34:44 +00:00
2 changed files with 771 additions and 121 deletions
Showing only changes of commit ffe357a05a - Show all commits

View File

@ -1,7 +1,7 @@
'use client'; 'use client';
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import useContractManagement from '../hooks/useContractManagement'; import useContractManagement, { DocumentTemplate } from '../hooks/useContractManagement';
import ConfirmActionModal from '../../../components/modals/ConfirmActionModal'; import ConfirmActionModal from '../../../components/modals/ConfirmActionModal';
type Props = { type Props = {
@ -15,30 +15,342 @@ type ContractTemplate = {
type?: string; type?: string;
contract_type?: string | null; contract_type?: string | null;
user_type?: string | null; user_type?: string | null;
lang?: string | null;
version: number; version: number;
status: 'draft' | 'published' | 'archived' | string; status: 'draft' | 'published' | 'archived' | string;
updatedAt?: 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<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 = '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 }) { function Pill({ children, className }: { children: React.ReactNode; className: string }) {
return <span className={`px-2 py-0.5 rounded text-xs font-semibold border ${className}`}>{children}</span>; 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 }) { function StatusBadge({ status }: { status: string }) {
const map: Record<string, string> = { const map: Record<string, string> = {
draft: 'bg-gray-100 text-gray-800 border border-gray-300', draft: 'border-slate-200 bg-slate-100 text-slate-700',
published: 'bg-green-100 text-green-800 border border-green-300', published: 'border-emerald-200 bg-emerald-100 text-emerald-800',
archived: 'bg-yellow-100 text-yellow-800 border border-yellow-300', 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'; const cls = map[status] || 'bg-blue-100 text-blue-800 border border-blue-300';
return <span className={`px-2 py-0.5 rounded text-xs font-semibold ${cls}`}>{status}</span>; 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>;
} }
export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props) { export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props) {
const [items, setItems] = useState<ContractTemplate[]>([]); const [items, setItems] = useState<ContractTemplate[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [q, setQ] = useState(''); const [q, setQ] = useState('');
const [pendingToggle, setPendingToggle] = useState<{ id: string; target: 'active' | 'inactive'; message?: string; requiresConfirm: boolean } | null>(null) 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 { const {
listTemplates, listTemplates,
@ -52,28 +364,121 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
const filtered = useMemo(() => { const filtered = useMemo(() => {
const term = q.trim().toLowerCase(); const term = q.trim().toLowerCase();
if (!term) return items; 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]); }, [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 () => { const load = async () => {
setLoading(true); setLoading(true);
try { try {
const data = await listTemplates(); const data = await listTemplates();
const mapped: ContractTemplate[] = data.map((x: any) => ({ const mapped: ContractTemplate[] = data
id: x.id ?? x._id ?? x.uuid, .map((template) => {
name: x.name ?? 'Untitled', const normalized = template as NormalizedTemplate;
type: x.type,
contract_type: x.contract_type ?? x.contractType ?? null, return {
user_type: x.user_type ?? x.userType ?? null, id: normalized.id ?? normalized._id ?? normalized.uuid ?? '',
version: Number(x.version ?? 1), name: normalized.name ?? 'Untitled',
status: (x.state === 'active') ? 'published' : 'draft', type: normalized.type,
updatedAt: x.updatedAt ?? x.modifiedAt ?? x.updated_at, 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); setItems(mapped);
} catch { } catch {
setItems((prev) => prev.length ? prev : [ setItems((prev) => prev.length ? prev : [
{ id: 'ex1', name: 'Sample Contract A', version: 1, status: 'draft', 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: 'NDA Template', version: 3, 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 { } finally {
setLoading(false); setLoading(false);
@ -85,6 +490,13 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [refreshKey]); }, [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') => { const executeToggleState = async (id: string, target: 'active' | 'inactive') => {
try { try {
const updated = await updateTemplateState(id, target as 'active' | 'inactive'); 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)); setItems((prev) => prev.map((i) => i.id === id ? { ...i, status: updated.state === 'active' ? 'published' : 'draft' } : i));
await load(); await load();
} catch {} } catch {}
} };
const onToggleState = async (id: string, current: string) => { const onToggleState = async (id: string, current: string) => {
const target = current === 'published' ? 'inactive' : 'active'; const target = current === 'published' ? 'inactive' : 'active';
@ -117,10 +529,10 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
}; };
const confirmToggleState = async () => { const confirmToggleState = async () => {
if (!pendingToggle) return if (!pendingToggle) return;
await executeToggleState(pendingToggle.id, pendingToggle.target) await executeToggleState(pendingToggle.id, pendingToggle.target);
setPendingToggle(null) setPendingToggle(null);
} };
const onPreview = (id: string) => openPreviewInNewTab(id); const onPreview = (id: string) => openPreviewInNewTab(id);
@ -138,75 +550,307 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
} catch {} } catch {}
}; };
return ( const renderTrackCard = (track: TemplateTrack) => {
<div className="space-y-4"> const contractMeta = track.family === 'contract' ? getContractTypeMeta(track.templates[0]?.contract_type) : null;
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-900"> const defaultLanguageKey = track.languageColumns.find((languageColumn) => languageColumn.activeTemplate)?.key || track.languageColumns[0]?.key;
Invoice templates always use user type &quot;Both&quot;. Provide templates for each language (en/de). If no active invoice template matches, backend falls back to a text-only invoice. const selectedLanguageKey = selectedLanguageByTrack[track.key] || defaultLanguageKey;
</div> const visibleLanguageColumn = track.languageColumns.find((languageColumn) => languageColumn.key === selectedLanguageKey) || track.languageColumns[0];
<div className="flex gap-2 items-center">
<input
placeholder="Search templates…"
value={q}
onChange={(e) => 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"
/>
<button
onClick={load}
disabled={loading}
className="rounded-lg bg-indigo-50 hover:bg-indigo-100 text-indigo-700 px-4 py-2 text-sm font-medium shadow disabled:opacity-60"
>
{loading ? 'Loading…' : 'Refresh'}
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> if (!visibleLanguageColumn) return null;
{filtered.map((c) => (
<div key={c.id} className="rounded-xl border border-gray-100 bg-white shadow-sm p-4 flex flex-col gap-2 hover:shadow-md transition"> return (
<p className="font-semibold text-lg text-gray-900 truncate">{c.name}</p> <article
<div className="flex flex-wrap items-center gap-2"> key={track.key}
<StatusBadge status={c.status} /> 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'}`}
{c.type && ( >
<Pill className="bg-slate-50 text-slate-800 border-slate-200"> <div className="flex flex-col gap-4 border-b border-slate-200 pb-4 lg:flex-row lg:items-start lg:justify-between">
{c.type === 'contract' ? 'Contract' : c.type === 'invoice' ? 'Invoice' : 'Other'} <div>
</Pill> <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>
)} )}
{c.type === 'contract' && ( {track.family === 'other' && track.templates[0]?.user_type && (
<Pill className="bg-indigo-50 text-indigo-800 border-indigo-200"> <Pill className="border-slate-200 bg-slate-100 text-slate-700">{formatUserType(track.templates[0].user_type)}</Pill>
{c.contract_type === 'gdpr' ? 'GDPR' : c.contract_type === 'abo' ? 'ABO' : 'Contract'}
</Pill>
)}
{c.user_type && c.type !== 'invoice' && (
<Pill className="bg-emerald-50 text-emerald-800 border-emerald-200">
{c.user_type === 'personal' ? 'Personal' : c.user_type === 'company' ? 'Company' : 'Both'}
</Pill>
)} )}
</div> </div>
<p className="text-xs text-gray-500">Version {c.version}{c.updatedAt ? ` • Updated ${new Date(c.updatedAt).toLocaleString()}` : ''}</p> <h5 className="mt-3 text-xl font-semibold tracking-tight text-slate-950">{track.title}</h5>
<div className="flex flex-wrap gap-2 mt-2"> <p className="mt-1 text-sm text-slate-500">{track.subtitle}</p>
{onEdit && ( {track.description && (
<button <p className="mt-2 max-w-2xl text-sm leading-5 text-slate-600">{track.description}</p>
onClick={() => onEdit(c.id)} )}
className="px-3 py-1 text-xs rounded-lg bg-indigo-50 hover:bg-indigo-100 text-indigo-700 border border-indigo-200 transition"
> <div className="mt-3">
Edit <div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Languages</div>
</button> <div className="mt-2 flex gap-2 overflow-x-auto pb-1">
)} {track.languageColumns.map((languageColumn) => {
<button onClick={() => onPreview(c.id)} className="px-3 py-1 text-xs rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 border border-gray-200 transition">Preview</button> const isSelected = languageColumn.key === selectedLanguageKey;
<button onClick={() => onGenPdf(c.id)} className="px-3 py-1 text-xs rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 border border-gray-200 transition">PDF</button>
<button onClick={() => onDownloadPdf(c.id)} className="px-3 py-1 text-xs rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 border border-gray-200 transition">Download</button> return (
<button onClick={() => onToggleState(c.id, c.status)} className={`px-3 py-1 text-xs rounded-lg font-semibold transition <button
${c.status === 'published' key={`${track.key}-${languageColumn.key}-selector`}
? 'bg-red-100 hover:bg-red-200 text-red-700 border border-red-200' type="button"
: 'bg-indigo-600 hover:bg-indigo-500 text-white border border-indigo-600'}`}> onClick={() => setSelectedLanguageByTrack((current) => ({
{c.status === 'published' ? 'Deactivate' : 'Activate'} ...current,
</button> [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> </div>
))}
{!filtered.length && ( <div className="flex flex-wrap items-center gap-2 text-xs text-slate-600 lg:max-w-88 lg:justify-end">
<div className="col-span-full py-8 text-center text-sm text-gray-500">No contracts found.</div> <MetricChip label="Active" value={track.activeCount} />
)} <MetricChip label="Languages" value={track.languageColumns.length} />
</div> <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}`
: 'No active version yet'}
</div>
</div>
</div>
<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>
</div>
<div className="divide-y divide-slate-200">
{visibleLanguageColumn.templates.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) || 'No update time'}</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">
Template overview
</div>
<div>
<h3 className="text-2xl font-semibold tracking-tight text-slate-950 md:text-3xl">Organized by template family</h3>
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-600 md:text-[15px]">
Jump between template families, spot the currently active versions immediately and keep language-specific revisions in one place.
</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="Search by name, language, version or status"
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 ? 'Loading…' : '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 always use user type &quot;Both&quot;. Keep one active version per language 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">
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.
</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}`}>
Contract type
</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
? 'No templates available yet. Create the first template to populate this workspace.'
: 'No templates match the current search.'}
</div>
)}
</div>
</section>
<ConfirmActionModal <ConfirmActionModal
open={Boolean(pendingToggle?.requiresConfirm)} open={Boolean(pendingToggle?.requiresConfirm)}

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import React, { useState, useEffect } from 'react'; import React, { useEffect, useState, useSyncExternalStore } from 'react';
import PageLayout from '../../components/PageLayout'; import PageLayout from '../../components/PageLayout';
import ContractEditor from './components/contractEditor'; import ContractEditor from './components/contractEditor';
import ContractUploadCompanyStamp from './components/contractUploadCompanyStamp'; import ContractUploadCompanyStamp from './components/contractUploadCompanyStamp';
@ -18,14 +18,16 @@ const NAV = [
export default function ContractManagementPage() { export default function ContractManagementPage() {
const [refreshKey, setRefreshKey] = useState(0); const [refreshKey, setRefreshKey] = useState(0);
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const [mounted, setMounted] = useState(false); const mounted = useSyncExternalStore(
() => () => {},
() => true,
() => false
);
const router = useRouter(); const router = useRouter();
const [section, setSection] = useState('templates'); const [section, setSection] = useState('templates');
const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null); const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null);
const [editorKey, setEditorKey] = useState(0); const [editorKey, setEditorKey] = useState(0);
useEffect(() => { setMounted(true); }, []);
// Only allow admin // Only allow admin
const isAdmin = const isAdmin =
!!user && !!user &&
@ -49,12 +51,10 @@ export default function ContractManagementPage() {
return ( return (
<PageLayout> <PageLayout>
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen"> <div className="min-h-screen bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.14),transparent_24%),radial-gradient(circle_at_top_right,rgba(56,189,248,0.14),transparent_26%),linear-gradient(180deg,#f8fafc_0%,#f8fafc_52%,#eef2ff_100%)]">
{/* tighter horizontal padding on mobile */} <div className="mx-auto flex max-w-420 flex-col gap-6 px-4 py-6 sm:px-6 md:flex-row md:gap-10 md:py-10 lg:px-10">
<div className="flex flex-col md:flex-row max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 md:py-8 gap-6 md:gap-8"> <nav className="w-full md:sticky md:top-6 md:w-64 md:self-start">
{/* Sidebar Navigation (mobile = horizontal scroll tabs, desktop = vertical) */} <div className="flex md:flex-col flex-row gap-2 md:gap-3 overflow-x-auto md:overflow-visible -mx-4 rounded-[28px] border border-white/70 bg-white/80 px-4 py-3 shadow-[0_22px_60px_-34px_rgba(15,23,42,0.38)] backdrop-blur md:mx-0 md:px-3 md:py-3 pb-3 md:pb-3">
<nav className="md:w-56 w-full md:self-start md:sticky md:top-6">
<div className="flex md:flex-col flex-row gap-2 md:gap-3 overflow-x-auto md:overflow-visible -mx-4 px-4 md:mx-0 md:px-0 pb-2 md:pb-0">
{NAV.map((item) => ( {NAV.map((item) => (
<button <button
key={item.key} key={item.key}
@ -69,10 +69,10 @@ export default function ContractManagementPage() {
} }
setSection(item.key); setSection(item.key);
}} }}
className={`flex flex-shrink-0 items-center gap-2 px-4 py-2 rounded-lg font-medium transition whitespace-nowrap text-sm md:text-base className={`flex shrink-0 items-center gap-2 px-4 py-3 rounded-2xl font-medium transition whitespace-nowrap text-sm md:text-base
${section === item.key ${section === item.key
? 'bg-blue-900 text-blue-50 shadow' ? 'bg-slate-900 text-white shadow-[0_18px_40px_-24px_rgba(15,23,42,0.85)]'
: 'bg-white text-blue-900 hover:bg-blue-50 hover:text-blue-900 border border-blue-200'}`} : 'bg-transparent text-slate-700 hover:bg-slate-100 hover:text-slate-900 border border-transparent hover:border-slate-200'}`}
> >
{item.icon} {item.icon}
<span>{item.label}</span> <span>{item.label}</span>
@ -82,40 +82,46 @@ export default function ContractManagementPage() {
</nav> </nav>
{/* Main Content */} {/* Main Content */}
<main className="flex-1 space-y-6 md:space-y-8"> <main className="flex-1 space-y-6 md:space-y-8 xl:space-y-9">
{/* sticky only on md+; smaller padding/title on mobile */} <header className="rounded-[30px] border border-white/80 bg-white/85 px-5 py-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.38)] backdrop-blur md:px-8 md:py-8 xl:px-10">
<header className="md:sticky md:top-0 z-10 bg-white/90 backdrop-blur border-b border-blue-100 py-5 px-4 md:py-10 md:px-8 rounded-2xl shadow-lg flex flex-col gap-3 md:gap-4 mb-2 md:mb-4"> <div className="space-y-4">
<h1 className="text-2xl md:text-4xl font-extrabold text-blue-900 tracking-tight">Contract Management</h1> <div className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.28em] text-slate-500">
<p className="text-sm md:text-lg text-blue-700"> Admin workspace
Manage contract templates, company stamp, and create new templates. </div>
</p> <div>
<h1 className="text-3xl font-black tracking-tight text-slate-950 md:text-5xl">Template Management</h1>
<p className="mt-3 max-w-2xl text-sm leading-6 text-slate-600 md:text-base">
Keep contract, invoice and custom templates tidy in one place, with clearer navigation between active versions, languages and revisions.
</p>
</div>
<div className="flex flex-wrap gap-2 text-xs text-slate-600">
<span className="inline-flex items-center rounded-full border border-slate-200 bg-white px-3 py-1.5 font-medium text-slate-700 shadow-sm">Grouped library</span>
<span className="inline-flex items-center rounded-full border border-slate-200 bg-white px-3 py-1.5 font-medium text-slate-700 shadow-sm">Stronger language switching</span>
<span className="inline-flex items-center rounded-full border border-slate-200 bg-white px-3 py-1.5 font-medium text-slate-700 shadow-sm">Faster edit flow</span>
</div>
</div>
</header> </header>
{/* Section Panels (compact padding on mobile) */}
{section === 'stamp' && ( {section === 'stamp' && (
<section className="rounded-2xl bg-white shadow-lg border border-gray-100 p-4 md:p-6"> <section className="rounded-[28px] border border-white/80 bg-white/85 p-4 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.38)] backdrop-blur md:p-6 xl:p-7">
<h2 className="text-xl font-semibold text-blue-900 mb-4 flex items-center gap-2"> <h2 className="mb-4 flex items-center gap-2 text-xl font-semibold text-slate-900">
<svg className="w-6 h-6" fill="none" stroke="currentColor"><circle cx="12" cy="12" r="10"/><path d="M12 8v4l3 3"/></svg> <svg className="w-6 h-6" fill="none" stroke="currentColor"><circle cx="12" cy="12" r="10"/><path d="M12 8v4l3 3"/></svg>
Company Stamp Company Stamp
</h2> </h2>
<ContractUploadCompanyStamp onUploaded={bumpRefresh} /> <ContractUploadCompanyStamp onUploaded={bumpRefresh} />
<div className="mt-8 pt-6 border-t border-gray-200"> <div className="mt-8 border-t border-slate-200 pt-6">
<h3 className="text-lg font-semibold text-blue-900 mb-3 flex items-center gap-2"> <h3 className="mb-3 flex items-center gap-2 text-lg font-semibold text-slate-900">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /></svg> <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /></svg>
Company Information Company Information
</h3> </h3>
<p className="text-sm text-gray-500 mb-4">Address details used on invoices.</p> <p className="mb-4 text-sm text-slate-500">Address details used on invoices.</p>
<CompanySettingsPanel /> <CompanySettingsPanel />
</div> </div>
</section> </section>
)} )}
{section === 'templates' && ( {section === 'templates' && (
<section className="rounded-2xl bg-white shadow-lg border border-gray-100 p-4 md:p-6"> <section className="rounded-[28px] border border-white/80 bg-white/60 p-4 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur md:p-5 xl:p-6">
<h2 className="text-xl font-semibold text-blue-900 mb-4 flex items-center gap-2">
<svg className="w-6 h-6" fill="none" stroke="currentColor"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
Templates
</h2>
<ContractTemplateList <ContractTemplateList
refreshKey={refreshKey} refreshKey={refreshKey}
onEdit={(id) => { onEdit={(id) => {
@ -127,8 +133,8 @@ export default function ContractManagementPage() {
</section> </section>
)} )}
{section === 'editor' && ( {section === 'editor' && (
<section className="rounded-2xl bg-white shadow-lg border border-gray-100 p-4 md:p-6"> <section className="rounded-[28px] border border-white/80 bg-white/85 p-4 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.38)] backdrop-blur md:p-6 xl:p-7">
<h2 className="text-xl font-semibold text-blue-900 mb-4 flex items-center gap-2"> <h2 className="mb-4 flex items-center gap-2 text-xl font-semibold text-slate-900">
<svg className="w-6 h-6" fill="none" stroke="currentColor"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 113 3L7 19l-4 1 1-4 12.5-12.5z"/></svg> <svg className="w-6 h-6" fill="none" stroke="currentColor"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 113 3L7 19l-4 1 1-4 12.5-12.5z"/></svg>
Create Template Create Template
</h2> </h2>