feat: enhance contract management with support for GDPR and contract types in previews

This commit is contained in:
seaznCode 2026-01-13 19:02:14 +01:00
parent e3198991a9
commit f144918b6a
7 changed files with 185 additions and 184 deletions

View File

@ -16,6 +16,7 @@ export default function ContractEditor({ onSaved }: Props) {
const [lang, setLang] = useState<'en' | 'de'>('en');
const [type, setType] = useState<'contract' | 'bill' | 'other'>('contract');
const [contractType, setContractType] = useState<'contract' | 'gdpr'>('contract');
const [userType, setUserType] = useState<'personal' | 'company' | 'both'>('personal');
const [description, setDescription] = useState<string>('');
@ -101,6 +102,7 @@ export default function ContractEditor({ onSaved }: Props) {
htmlCode.trim() &&
description.trim() &&
type &&
(type === 'contract' ? contractType : true) &&
userType &&
lang
)
@ -123,6 +125,7 @@ export default function ContractEditor({ onSaved }: Props) {
file,
name,
type,
contract_type: type === 'contract' ? contractType : undefined,
lang,
description: description || undefined,
user_type: userType,
@ -187,6 +190,17 @@ export default function ContractEditor({ onSaved }: Props) {
<option value="bill">Bill</option>
<option value="other">Other</option>
</select>
{type === 'contract' && (
<select
value={contractType}
onChange={(e) => setContractType(e.target.value as 'contract' | 'gdpr')}
required
className="w-full sm:w-40 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"
>
<option value="contract">Contract</option>
<option value="gdpr">GDPR</option>
</select>
)}
<select
value={userType}
onChange={(e) => setUserType(e.target.value as 'personal' | 'company' | 'both')}

View File

@ -10,6 +10,8 @@ type Props = {
type ContractTemplate = {
id: string;
name: string;
type?: string;
contract_type?: string | null;
version: number;
status: 'draft' | 'published' | 'archived' | string;
updatedAt?: string;
@ -52,6 +54,8 @@ export default function ContractTemplateList({ refreshKey = 0 }: Props) {
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,
version: Number(x.version ?? 1),
status: (x.state === 'active') ? 'published' : 'draft',
updatedAt: x.updatedAt ?? x.modifiedAt ?? x.updated_at,
@ -120,6 +124,11 @@ export default function ContractTemplateList({ refreshKey = 0 }: Props) {
<div className="flex items-center gap-2">
<p className="font-semibold text-lg text-gray-900 truncate">{c.name}</p>
<StatusBadge status={c.status} />
{c.type === 'contract' && c.contract_type && (
<span className="px-2 py-0.5 rounded text-xs font-semibold bg-indigo-50 text-indigo-800 border border-indigo-200">
{c.contract_type === 'gdpr' ? 'GDPR' : 'Contract'}
</span>
)}
</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">

View File

@ -5,6 +5,7 @@ export type DocumentTemplate = {
id: string;
name: string;
type?: string;
contract_type?: 'contract' | 'gdpr' | null | string;
lang?: 'en' | 'de' | string;
user_type?: 'personal' | 'company' | 'both' | string;
state?: 'active' | 'inactive' | string;
@ -153,6 +154,7 @@ export default function useContractManagement() {
file: File | Blob;
name: string;
type: string;
contract_type?: 'contract' | 'gdpr';
lang: 'en' | 'de' | string;
description?: string;
user_type?: 'personal' | 'company' | 'both';
@ -162,6 +164,9 @@ export default function useContractManagement() {
fd.append('file', file);
fd.append('name', payload.name);
fd.append('type', payload.type);
if (payload.type === 'contract' && payload.contract_type) {
fd.append('contract_type', payload.contract_type);
}
fd.append('lang', payload.lang);
if (payload.description) fd.append('description', payload.description);
fd.append('user_type', (payload.user_type ?? 'both'));
@ -173,6 +178,7 @@ export default function useContractManagement() {
file?: File | Blob;
name?: string;
type?: string;
contract_type?: 'contract' | 'gdpr';
lang?: 'en' | 'de' | string;
description?: string;
user_type?: 'personal' | 'company' | 'both';
@ -184,6 +190,9 @@ export default function useContractManagement() {
}
if (payload.name) fd.append('name', payload.name);
if (payload.type) fd.append('type', payload.type);
if ((payload.type === 'contract' || payload.contract_type) && payload.contract_type) {
fd.append('contract_type', payload.contract_type);
}
if (payload.lang) fd.append('lang', payload.lang);
if (payload.description !== undefined) fd.append('description', payload.description);
if (payload.user_type) fd.append('user_type', payload.user_type);

View File

@ -46,10 +46,13 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
const [selectedStatus, setSelectedStatus] = useState<UserStatus>('pending')
const token = useAuthStore(state => state.accessToken)
// Contract preview state (lazy-loaded)
// Contract preview state (lazy-loaded, per contract type)
const [previewLoading, setPreviewLoading] = useState(false)
const [previewHtml, setPreviewHtml] = useState<string | null>(null)
const [previewError, setPreviewError] = useState<string | null>(null)
const [activePreviewTab, setActivePreviewTab] = useState<'contract' | 'gdpr'>('contract')
const [previewState, setPreviewState] = useState({
contract: { loading: false, html: null as string | null, error: null as string | null },
gdpr: { loading: false, html: null as string | null, error: null as string | null },
})
useEffect(() => {
if (isOpen && userId && token) {
@ -85,6 +88,17 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
}
}
// Load both contract and GDPR previews when modal opens after user is known
useEffect(() => {
if (!isOpen || !userId || !token || !userDetails) return
setPreviewLoading(true)
Promise.all([
loadContractPreview('contract'),
loadContractPreview('gdpr')
]).finally(() => setPreviewLoading(false))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen, userId, token, userDetails])
const handleStatusChange = async (newStatus: UserStatus) => {
if (!userId || !token || newStatus === selectedStatus) return
@ -138,19 +152,24 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
}
}
const loadContractPreview = async () => {
const loadContractPreview = async (contractType: 'contract' | 'gdpr') => {
if (!userId || !token || !userDetails) return
setPreviewLoading(true)
setPreviewError(null)
setPreviewState((prev) => ({
...prev,
[contractType]: { ...prev[contractType], loading: true, error: null }
}))
try {
const html = await AdminAPI.getContractPreviewHtml(token, String(userId), userDetails.user.user_type)
setPreviewHtml(html)
const html = await AdminAPI.getContractPreviewHtml(token, String(userId), userDetails.user.user_type, contractType)
setPreviewState((prev) => ({
...prev,
[contractType]: { loading: false, html, error: null }
}))
} catch (e: any) {
console.error('UserDetailModal.loadContractPreview error:', e)
setPreviewError(e?.message || 'Failed to load contract preview')
setPreviewHtml(null)
} finally {
setPreviewLoading(false)
setPreviewState((prev) => ({
...prev,
[contractType]: { loading: false, html: null, error: e?.message || 'Failed to load contract preview' }
}))
}
}
@ -399,28 +418,43 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
{/* Contract Preview (admin verify flow) */}
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<div className="flex items-center gap-3">
<DocumentTextIcon className="h-5 w-5 text-gray-600" />
Contract Preview
</h3>
<div className="flex items-center gap-2">
<span className="text-lg font-semibold text-gray-900">Contract Preview</span>
<div className="flex items-center gap-1 rounded-full border border-gray-200 bg-white px-1">
{(['contract','gdpr'] as const).map((tab) => (
<button
key={tab}
type="button"
onClick={() => setActivePreviewTab(tab)}
className={`px-2.5 py-1 text-xs rounded-full transition ${activePreviewTab === tab ? 'bg-indigo-600 text-white shadow' : 'text-gray-700 hover:bg-gray-100'}`}
>
{tab === 'contract' ? 'Contract' : 'GDPR'}
</button>
))}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={loadContractPreview}
disabled={previewLoading}
onClick={() => Promise.all([loadContractPreview('contract'), loadContractPreview('gdpr')])}
disabled={previewLoading || previewState.contract.loading || previewState.gdpr.loading}
className="inline-flex items-center justify-center rounded-md bg-indigo-600 hover:bg-indigo-500 text-white px-3 py-2 text-sm disabled:opacity-60"
>
{previewLoading ? 'Loading…' : (previewHtml ? 'Refresh Preview' : 'Load Preview')}
{previewLoading || previewState.contract.loading || previewState.gdpr.loading ? 'Loading…' : 'Refresh'}
</button>
<button
type="button"
onClick={() => {
if (!previewHtml) return
const blob = new Blob([previewHtml], { type: 'text/html' })
const url = URL.createObjectURL(blob)
window.open(url, '_blank', 'noopener,noreferrer')
const current = previewState[activePreviewTab];
if (!current?.html) return;
const blob = new Blob([current.html], { type: 'text/html' });
const url = URL.createObjectURL(blob);
window.open(url, '_blank', 'noopener,noreferrer');
}}
disabled={!previewHtml}
disabled={!previewState[activePreviewTab]?.html}
className="inline-flex items-center justify-center rounded-md bg-gray-100 hover:bg-gray-200 text-gray-900 px-3 py-2 text-sm disabled:opacity-60"
>
Open in new tab
@ -428,27 +462,27 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
</div>
</div>
<div className="px-6 py-5">
{previewError && (
{previewState[activePreviewTab].error && (
<div className="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 mb-4">
{previewError}
{previewState[activePreviewTab].error}
</div>
)}
{previewLoading && (
{(previewLoading || previewState[activePreviewTab].loading) && (
<div className="flex items-center justify-center h-40 text-sm text-gray-500">
Loading preview
</div>
)}
{!previewLoading && previewHtml && (
{!previewLoading && !previewState[activePreviewTab].loading && previewState[activePreviewTab].html && (
<div className="rounded-md border border-gray-200 overflow-hidden">
<iframe
title="Contract Preview"
title={`Contract Preview ${activePreviewTab}`}
className="w-full h-[600px] bg-white"
srcDoc={previewHtml}
srcDoc={previewState[activePreviewTab].html || ''}
/>
</div>
)}
{!previewLoading && !previewHtml && !previewError && (
<p className="text-sm text-gray-500">Click "Load Preview" to render the latest active contract template for this user.</p>
{!previewLoading && !previewState[activePreviewTab].loading && !previewState[activePreviewTab].html && !previewState[activePreviewTab].error && (
<p className="text-sm text-gray-500">Click "Refresh" to render the latest active contract or GDPR template for this user.</p>
)}
</div>
</div>
@ -598,103 +632,6 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
</div>
)}
{/* Documents Section */}
{(userDetails.documents.length > 0 || userDetails.contracts.length > 0 || userDetails.idDocuments.length > 0) && (
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<DocumentTextIcon className="h-5 w-5 text-gray-600" />
Documents ({userDetails.documents.length + userDetails.contracts.length + userDetails.idDocuments.length})
</h3>
</div>
<div className="px-6 py-5 space-y-4">
{/* Regular Documents */}
{userDetails.documents.length > 0 && (
<div>
<h5 className="text-sm font-medium text-gray-700 mb-3">Uploaded Documents</h5>
<div className="space-y-2">
{userDetails.documents.map((doc) => (
<div key={doc.id} className="flex items-center justify-between bg-gray-50 p-3 rounded-lg border border-gray-200">
<div className="flex items-center gap-3">
<DocumentTextIcon className="h-5 w-5 text-gray-400" />
<div>
<div className="text-sm font-medium text-gray-900">{doc.file_name}</div>
<div className="text-xs text-gray-500">{formatFileSize(doc.file_size)}</div>
</div>
</div>
<span className="text-xs text-gray-500">{formatDate(doc.uploaded_at)}</span>
</div>
))}
</div>
</div>
)}
{/* Contracts */}
{userDetails.contracts.length > 0 && (
<div>
<h5 className="text-sm font-medium text-gray-700 mb-3">Contracts</h5>
<div className="space-y-2">
{userDetails.contracts.map((contract) => (
<div key={contract.id} className="flex items-center justify-between bg-blue-50 p-3 rounded-lg border border-blue-200">
<div className="flex items-center gap-3">
<DocumentTextIcon className="h-5 w-5 text-blue-600" />
<div>
<div className="text-sm font-medium text-gray-900">{contract.file_name}</div>
<div className="text-xs text-gray-500">{formatFileSize(contract.file_size)}</div>
</div>
</div>
<span className="text-xs text-gray-500">{formatDate(contract.uploaded_at)}</span>
</div>
))}
</div>
</div>
)}
{/* ID Documents */}
{userDetails.idDocuments.length > 0 && (
<div>
<h5 className="text-sm font-medium text-gray-700 mb-3">ID Documents</h5>
<div className="space-y-4">
{userDetails.idDocuments.map((idDoc) => (
<div key={idDoc.id} className="bg-purple-50 p-4 rounded-lg border border-purple-200">
<div className="flex items-center gap-2 mb-3">
<IdentificationIcon className="h-5 w-5 text-purple-600" />
<span className="text-sm font-medium text-gray-900">{idDoc.document_type}</span>
<span className="text-xs text-gray-500 ml-auto">{formatDate(idDoc.uploaded_at)}</span>
</div>
{(idDoc.frontUrl || idDoc.backUrl) && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{idDoc.frontUrl && (
<div>
<p className="text-xs font-medium text-gray-600 mb-2">Front</p>
<img
src={idDoc.frontUrl}
alt="ID Front"
className="w-full h-40 object-cover rounded border border-gray-300"
/>
</div>
)}
{idDoc.backUrl && (
<div>
<p className="text-xs font-medium text-gray-600 mb-2">Back</p>
<img
src={idDoc.backUrl}
alt="ID Back"
className="w-full h-40 object-cover rounded border border-gray-300"
/>
</div>
)}
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
</div>
)}
{/* Permissions */}
{userDetails.permissions.length > 0 && (
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">

View File

@ -192,8 +192,8 @@ export default function Header() {
const isLoggedIn = !!user
const userPresent = mounted && isLoggedIn
// NEW: detect admin role across common shapes
const isAdmin =
// NEW: detect admin role across common shapes (guarded by mount to avoid SSR/CSR mismatch)
const rawIsAdmin =
!!user &&
(
(user as any)?.role === 'admin' ||
@ -201,6 +201,7 @@ export default function Header() {
(user as any)?.isAdmin === true ||
((user as any)?.roles?.includes?.('admin'))
)
const isAdmin = mounted && rawIsAdmin
return (
<header

View File

@ -25,21 +25,28 @@ export default function PersonalSignContractPage() {
const [success, setSuccess] = useState(false)
const [error, setError] = useState('')
const [previewLoading, setPreviewLoading] = useState(false)
const [previewHtml, setPreviewHtml] = useState<string | null>(null)
const [previewError, setPreviewError] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<'contract' | 'gdpr'>('contract')
const [previewState, setPreviewState] = useState<{
contract: { loading: boolean; html: string | null; error: string | null }
gdpr: { loading: boolean; html: string | null; error: string | null }
}>({
contract: { loading: false, html: null, error: null },
gdpr: { loading: false, html: null, error: null }
})
useEffect(() => {
setDate(new Date().toISOString().slice(0, 10))
}, [])
// Load latest contract preview for personal user
useEffect(() => {
const load = async () => {
const loadPreview = async (contractType: 'contract' | 'gdpr') => {
if (!accessToken) return
setPreviewLoading(true)
setPreviewError('')
setPreviewState((prev) => ({
...prev,
[contractType]: { ...prev[contractType], loading: true, error: null }
}))
try {
const res = await fetch(`${API_BASE_URL}/api/contracts/preview/latest`, {
const qs = contractType ? `?contract_type=${contractType}` : ''
const res = await fetch(`${API_BASE_URL}/api/contracts/preview/latest${qs}`, {
method: 'GET',
headers: { Authorization: `Bearer ${accessToken}` },
credentials: 'include'
@ -49,15 +56,27 @@ export default function PersonalSignContractPage() {
throw new Error(text || 'Failed to load contract preview')
}
const html = await res.text()
setPreviewHtml(html)
setPreviewState((prev) => ({
...prev,
[contractType]: { loading: false, html, error: null }
}))
} catch (e: any) {
console.error('PersonalSignContractPage.loadPreview error:', e)
setPreviewError(e?.message || 'Failed to load contract preview')
} finally {
setPreviewLoading(false)
setPreviewState((prev) => ({
...prev,
[contractType]: { loading: false, html: null, error: e?.message || 'Failed to load contract preview' }
}))
}
}
load()
// Load latest contract and GDPR previews for personal user
useEffect(() => {
if (!accessToken) return
setPreviewLoading(true)
Promise.all([
loadPreview('contract'),
loadPreview('gdpr')
]).finally(() => setPreviewLoading(false))
}, [accessToken])
const valid = () => {
@ -119,13 +138,9 @@ export default function PersonalSignContractPage() {
}
}
// Create FormData for the existing backend endpoint
// Create FormData for the backend endpoint (no dummy PDF needed; server generates from templates)
const formData = new FormData()
formData.append('contractData', JSON.stringify(contractData))
// Create a dummy PDF file since the backend expects one (electronic signature)
const dummyPdfContent = '%PDF-1.4\n1 0 obj\n<<\n/Type /Catalog\n/Pages 2 0 R\n>>\nendobj\n2 0 obj\n<<\n/Type /Pages\n/Kids [3 0 R]\n/Count 1\n>>\nendobj\n3 0 obj\n<<\n/Type /Page\n/Parent 2 0 R\n/MediaBox [0 0 612 792]\n/Contents 4 0 R\n>>\nendobj\n4 0 obj\n<<\n/Length 44\n>>\nstream\nBT\n/F1 12 Tf\n100 700 Td\n(Electronic Signature) Tj\nET\nendstream\nendobj\nxref\n0 5\n0000000000 65535 f \n0000000010 00000 n \n0000000079 00000 n \n0000000136 00000 n \n0000000225 00000 n \ntrailer\n<<\n/Size 5\n/Root 1 0 R\n>>\nstartxref\n319\n%%EOF'
const dummyFile = new Blob([dummyPdfContent], { type: 'application/pdf' })
formData.append('contract', dummyFile, 'electronic_signature.pdf')
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/upload/contract/personal`, {
method: 'POST',
@ -226,29 +241,44 @@ export default function PersonalSignContractPage() {
<div>
<div className="rounded-lg border border-gray-200 bg-white relative overflow-hidden">
<div className="flex items-center justify-between p-3 border-b border-gray-200 bg-gray-50">
<h3 className="text-sm font-semibold text-gray-900">Contract Preview</h3>
<div className="flex items-center gap-2 text-sm font-semibold text-gray-900">
<span>Contract Preview</span>
<div className="flex items-center gap-1 rounded-full border border-gray-200 bg-white px-1">
{(['contract','gdpr'] as const).map((tab) => (
<button
key={tab}
type="button"
onClick={() => setActiveTab(tab)}
className={`px-2.5 py-1 text-xs rounded-full transition ${activeTab === tab ? 'bg-indigo-600 text-white shadow' : 'text-gray-700 hover:bg-gray-100'}`}
>
{tab === 'contract' ? 'Contract' : 'GDPR'}
</button>
))}
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={async () => {
if (!previewHtml) return
const blob = new Blob([previewHtml], { type: 'text/html' })
const current = previewState[activeTab];
if (!current?.html) return
const blob = new Blob([current.html], { type: 'text/html' })
const url = URL.createObjectURL(blob)
window.open(url, '_blank', 'noopener,noreferrer')
}}
disabled={!previewHtml}
disabled={!previewState[activeTab]?.html}
className="inline-flex items-center rounded-md bg-gray-100 hover:bg-gray-200 text-gray-900 px-2.5 py-1.5 text-xs disabled:opacity-60"
>
Open in new tab
</button>
</div>
</div>
{previewLoading ? (
{previewLoading || previewState[activeTab].loading ? (
<div className="h-72 flex items-center justify-center text-xs text-gray-500">Loading preview</div>
) : previewError ? (
<div className="h-72 flex items-center justify-center text-xs text-red-600 px-4 text-center">{previewError}</div>
) : previewHtml ? (
<iframe title="Contract Preview" className="w-full h-72" srcDoc={previewHtml} />
) : previewState[activeTab].error ? (
<div className="h-72 flex items-center justify-center text-xs text-red-600 px-4 text-center">{previewState[activeTab].error}</div>
) : previewState[activeTab].html ? (
<iframe title={`Contract Preview ${activeTab}`} className="w-full h-72" srcDoc={previewState[activeTab].html || ''} />
) : (
<div className="h-72 flex items-center justify-center text-xs text-gray-500">No preview available.</div>
)}

View File

@ -441,12 +441,13 @@ export class AdminAPI {
return true
}
static async getContractPreviewHtml(token: string, userId: string, userType?: 'personal' | 'company') {
static async getContractPreviewHtml(token: string, userId: string, userType?: 'personal' | 'company', contractType?: 'contract' | 'gdpr') {
let endpoint = API_ENDPOINTS.ADMIN_CONTRACT_PREVIEW.replace(':id', userId)
if (userType) {
const qs = new URLSearchParams({ userType }).toString()
endpoint += `?${qs}`
}
const qs = new URLSearchParams()
if (userType) qs.set('userType', userType)
if (contractType) qs.set('contract_type', contractType)
const qsStr = qs.toString()
if (qsStr) endpoint += `?${qsStr}`
const response = await ApiClient.get(endpoint, token)
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Failed to fetch contract preview' }))