dev #21
@ -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<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 }) {
|
||||
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 }) {
|
||||
const map: Record<string, string> = {
|
||||
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<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={`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) {
|
||||
const [items, setItems] = useState<ContractTemplate[]>([]);
|
||||
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<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 {
|
||||
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<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((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 {}
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-900">
|
||||
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.
|
||||
</div>
|
||||
<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"
|
||||
<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'}`}
|
||||
>
|
||||
{loading ? 'Loading…' : 'Refresh'}
|
||||
<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 === '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="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{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">
|
||||
<p className="font-semibold text-lg text-gray-900 truncate">{c.name}</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge status={c.status} />
|
||||
{c.type && (
|
||||
<Pill className="bg-slate-50 text-slate-800 border-slate-200">
|
||||
{c.type === 'contract' ? 'Contract' : c.type === 'invoice' ? 'Invoice' : 'Other'}
|
||||
</Pill>
|
||||
)}
|
||||
{c.type === 'contract' && (
|
||||
<Pill className="bg-indigo-50 text-indigo-800 border-indigo-200">
|
||||
{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 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>
|
||||
<p className="text-xs text-gray-500">Version {c.version}{c.updatedAt ? ` • Updated ${new Date(c.updatedAt).toLocaleString()}` : ''}</p>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
</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(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"
|
||||
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(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>
|
||||
<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>
|
||||
<button onClick={() => onToggleState(c.id, c.status)} className={`px-3 py-1 text-xs rounded-lg font-semibold transition
|
||||
${c.status === 'published'
|
||||
? 'bg-red-100 hover:bg-red-200 text-red-700 border border-red-200'
|
||||
: 'bg-indigo-600 hover:bg-indigo-500 text-white border border-indigo-600'}`}>
|
||||
{c.status === 'published' ? 'Deactivate' : 'Activate'}
|
||||
<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>
|
||||
))}
|
||||
{!filtered.length && (
|
||||
<div className="col-span-full py-8 text-center text-sm text-gray-500">No contracts found.</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 "Both". 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
|
||||
open={Boolean(pendingToggle?.requiresConfirm)}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useEffect, useState, useSyncExternalStore } from 'react';
|
||||
import PageLayout from '../../components/PageLayout';
|
||||
import ContractEditor from './components/contractEditor';
|
||||
import ContractUploadCompanyStamp from './components/contractUploadCompanyStamp';
|
||||
@ -18,14 +18,16 @@ const NAV = [
|
||||
export default function ContractManagementPage() {
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const mounted = useSyncExternalStore(
|
||||
() => () => {},
|
||||
() => true,
|
||||
() => false
|
||||
);
|
||||
const router = useRouter();
|
||||
const [section, setSection] = useState('templates');
|
||||
const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null);
|
||||
const [editorKey, setEditorKey] = useState(0);
|
||||
|
||||
useEffect(() => { setMounted(true); }, []);
|
||||
|
||||
// Only allow admin
|
||||
const isAdmin =
|
||||
!!user &&
|
||||
@ -49,12 +51,10 @@ export default function ContractManagementPage() {
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
|
||||
{/* tighter horizontal padding on mobile */}
|
||||
<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">
|
||||
{/* Sidebar Navigation (mobile = horizontal scroll tabs, desktop = vertical) */}
|
||||
<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">
|
||||
<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%)]">
|
||||
<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">
|
||||
<nav className="w-full md:sticky md:top-6 md:w-64 md:self-start">
|
||||
<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.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
@ -69,10 +69,10 @@ export default function ContractManagementPage() {
|
||||
}
|
||||
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
|
||||
? 'bg-blue-900 text-blue-50 shadow'
|
||||
: 'bg-white text-blue-900 hover:bg-blue-50 hover:text-blue-900 border border-blue-200'}`}
|
||||
? 'bg-slate-900 text-white shadow-[0_18px_40px_-24px_rgba(15,23,42,0.85)]'
|
||||
: 'bg-transparent text-slate-700 hover:bg-slate-100 hover:text-slate-900 border border-transparent hover:border-slate-200'}`}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
@ -82,40 +82,46 @@ export default function ContractManagementPage() {
|
||||
</nav>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 space-y-6 md:space-y-8">
|
||||
{/* sticky only on md+; smaller padding/title on mobile */}
|
||||
<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">
|
||||
<h1 className="text-2xl md:text-4xl font-extrabold text-blue-900 tracking-tight">Contract Management</h1>
|
||||
<p className="text-sm md:text-lg text-blue-700">
|
||||
Manage contract templates, company stamp, and create new templates.
|
||||
<main className="flex-1 space-y-6 md:space-y-8 xl:space-y-9">
|
||||
<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">
|
||||
<div className="space-y-4">
|
||||
<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">
|
||||
Admin workspace
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{/* Section Panels (compact padding on mobile) */}
|
||||
{section === 'stamp' && (
|
||||
<section className="rounded-2xl bg-white shadow-lg border border-gray-100 p-4 md:p-6">
|
||||
<h2 className="text-xl font-semibold text-blue-900 mb-4 flex items-center gap-2">
|
||||
<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="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>
|
||||
Company Stamp
|
||||
</h2>
|
||||
<ContractUploadCompanyStamp onUploaded={bumpRefresh} />
|
||||
|
||||
<div className="mt-8 pt-6 border-t border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-blue-900 mb-3 flex items-center gap-2">
|
||||
<div className="mt-8 border-t border-slate-200 pt-6">
|
||||
<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>
|
||||
Company Information
|
||||
</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 />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{section === 'templates' && (
|
||||
<section className="rounded-2xl bg-white shadow-lg border border-gray-100 p-4 md: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>
|
||||
<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">
|
||||
<ContractTemplateList
|
||||
refreshKey={refreshKey}
|
||||
onEdit={(id) => {
|
||||
@ -127,8 +133,8 @@ export default function ContractManagementPage() {
|
||||
</section>
|
||||
)}
|
||||
{section === 'editor' && (
|
||||
<section className="rounded-2xl bg-white shadow-lg border border-gray-100 p-4 md:p-6">
|
||||
<h2 className="text-xl font-semibold text-blue-900 mb-4 flex items-center gap-2">
|
||||
<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="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>
|
||||
Create Template
|
||||
</h2>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user