feat: add contract Management

This commit is contained in:
DeathKaioken 2025-10-28 21:55:47 +01:00
parent f7205ed8f6
commit 2eca3007e4
6 changed files with 1358 additions and 0 deletions

View File

@ -0,0 +1,232 @@
'use client';
import React, { useEffect, useRef, useState } from 'react';
import useContractManagement from '../hooks/useContractManagement';
type Props = {
onSaved?: () => void;
};
export default function ContractEditor({ onSaved }: Props) {
const [name, setName] = useState('');
const [htmlCode, setHtmlCode] = useState('');
const [isPreview, setIsPreview] = useState(false);
const [saving, setSaving] = useState(false);
const [statusMsg, setStatusMsg] = useState<string | null>(null);
const [lang, setLang] = useState<'en' | 'de'>('en');
const [type, setType] = useState<string>('contract');
const [description, setDescription] = useState<string>('');
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const { uploadTemplate, updateTemplateState } = useContractManagement();
// Build a full HTML doc if user pasted only a snippet
const wrapIfNeeded = (src: string) => {
const hasDoc = /<!DOCTYPE|<html[\s>]/i.test(src);
if (hasDoc) return src;
// Minimal A4 skeleton so snippets render and print correctly
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Preview</title>
<style>
@page { size: A4; margin: 0; }
html, body { margin:0; padding:0; background:#eee; }
body { display:flex; justify-content:center; }
.a4 { width:210mm; min-height:297mm; background:#fff; box-shadow:0 0 5mm rgba(0,0,0,0.1); box-sizing:border-box; padding:20mm; }
@media print {
html, body { background:#fff; }
.a4 { box-shadow:none; margin:0; }
}
</style>
</head>
<body>
<div class="a4">${src}</div>
</body>
</html>`;
};
// Write/refresh iframe preview
useEffect(() => {
if (!isPreview) return;
const iframe = iframeRef.current;
if (!iframe) return;
const doc = iframe.contentDocument || iframe.contentWindow?.document;
if (!doc) return;
const html = wrapIfNeeded(htmlCode);
doc.open();
doc.write(html);
doc.close();
const resize = () => {
// Allow time for layout/styles
requestAnimationFrame(() => {
const h = doc.body ? Math.max(doc.body.scrollHeight, doc.documentElement.scrollHeight) : 1200;
iframe.style.height = Math.min(Math.max(h, 1123), 2000) + 'px'; // clamp for UX
});
};
// Initial resize and after load
resize();
const onLoad = () => resize();
iframe.addEventListener('load', onLoad);
// Also observe mutations to adjust height if content changes
const mo = new MutationObserver(resize);
mo.observe(doc.documentElement, { childList: true, subtree: true, attributes: true, characterData: true });
return () => {
iframe.removeEventListener('load', onLoad);
mo.disconnect();
};
}, [isPreview, htmlCode]);
const printPreview = () => {
const w = iframeRef.current?.contentWindow;
w?.focus();
w?.print();
};
const slug = (s: string) =>
s.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'template';
const save = async (publish: boolean) => {
const html = htmlCode.trim();
if (!name || !html) {
setStatusMsg('Please enter a template name and content.');
return;
}
setSaving(true);
setStatusMsg(null);
try {
// Build a file from HTML code
const file = new File([html], `${slug(name)}.html`, { type: 'text/html' });
const created = await uploadTemplate({
file,
name,
type,
lang,
description: description || undefined,
user_type: 'both',
});
if (publish && created?.id) {
await updateTemplateState(created.id, 'active');
}
setStatusMsg(publish ? 'Template created and activated.' : 'Template created.');
if (onSaved) onSaved();
// Optionally clear fields
// setName(''); setHtmlCode(''); setDescription(''); setType('contract'); setLang('en');
} catch (e: any) {
setStatusMsg(e?.message || 'Save failed.');
} finally {
setSaving(false);
}
};
return (
<div className="space-y-3">
<div className="flex flex-col gap-3">
<div className="flex flex-col sm:flex-row gap-3">
<input
type="text"
placeholder="Template name"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full sm:w-1/2 rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-indigo-500/50"
/>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setIsPreview((v) => !v)}
className="inline-flex items-center rounded-md bg-gray-100 hover:bg-gray-200 text-gray-900 px-3 py-2 text-sm"
>
{isPreview ? 'Switch to Code' : 'Preview HTML'}
</button>
{isPreview && (
<button
type="button"
onClick={() => { const w = iframeRef.current?.contentWindow; w?.focus(); w?.print(); }}
className="inline-flex items-center rounded-md bg-gray-100 hover:bg-gray-200 text-gray-900 px-3 py-2 text-sm"
>
Print
</button>
)}
</div>
</div>
{/* New metadata inputs */}
<div className="flex flex-col sm:flex-row gap-3">
<input
type="text"
placeholder="Type (e.g., contract, nda, invoice)"
value={type}
onChange={(e) => setType(e.target.value)}
className="w-full sm:w-1/3 rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-indigo-500/50"
/>
<select
value={lang}
onChange={(e) => setLang(e.target.value as 'en' | 'de')}
className="w-full sm:w-32 rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-indigo-500/50"
>
<option value="en">English (en)</option>
<option value="de">Deutsch (de)</option>
</select>
<input
type="text"
placeholder="Description (optional)"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-indigo-500/50"
/>
</div>
</div>
{!isPreview && (
<textarea
value={htmlCode}
onChange={(e) => setHtmlCode(e.target.value)}
placeholder="Paste your full HTML (or snippet) here…"
className="min-h-[240px] w-full rounded-md border border-gray-300 bg-white px-3 py-3 text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 font-mono"
/>
)}
{isPreview && (
<div className="rounded-md border border-gray-300 bg-white">
<iframe
ref={iframeRef}
title="Contract Preview"
className="w-full rounded-md"
style={{ height: 1200, background: 'transparent' }}
/>
</div>
)}
<div className="flex items-center gap-3">
<button
onClick={() => save(false)}
disabled={saving}
className="inline-flex items-center rounded-md bg-gray-100 hover:bg-gray-200 text-gray-900 px-3 py-2 text-sm disabled:opacity-60"
>
Create (inactive)
</button>
<button
onClick={() => save(true)}
disabled={saving}
className="inline-flex items-center rounded-md bg-indigo-600 hover:bg-indigo-500 text-white px-3 py-2 text-sm disabled:opacity-60"
>
Create & Activate
</button>
{saving && <span className="text-xs text-gray-500">Saving</span>}
{statusMsg && <span className="text-xs text-gray-600">{statusMsg}</span>}
</div>
</div>
);
}

View File

@ -0,0 +1,143 @@
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import useContractManagement from '../hooks/useContractManagement';
type Props = {
refreshKey?: number;
};
type ContractTemplate = {
id: string;
name: string;
version: number;
status: 'draft' | 'published' | 'archived' | string;
updatedAt?: string;
};
function StatusBadge({ status }: { status: string }) {
const map: Record<string, string> = {
draft: 'bg-gray-100 text-gray-800',
published: 'bg-green-100 text-green-800',
archived: 'bg-yellow-100 text-yellow-800',
};
const cls = map[status] || 'bg-blue-100 text-blue-800';
return <span className={`px-2 py-0.5 rounded text-xs font-semibold ${cls}`}>{status}</span>;
}
export default function ContractTemplateList({ refreshKey = 0 }: Props) {
const [items, setItems] = useState<ContractTemplate[]>([]);
const [loading, setLoading] = useState(false);
const [q, setQ] = useState('');
const {
listTemplates,
openPreviewInNewTab,
generatePdf,
downloadPdf,
updateTemplateState,
downloadBlobFile,
} = useContractManagement();
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));
}, [items, q]);
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',
version: Number(x.version ?? 1),
status: (x.state === 'active') ? 'published' : 'draft',
updatedAt: x.updatedAt ?? x.modifiedAt ?? x.updated_at,
}));
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() },
]);
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [refreshKey]);
const onToggleState = async (id: string, current: string) => {
const target = current === 'published' ? 'inactive' : 'active';
try {
const updated = await updateTemplateState(id, target as 'active' | 'inactive');
setItems((prev) => prev.map((i) => i.id === id ? { ...i, status: updated.state === 'active' ? 'published' : 'draft' } : i));
} catch {}
};
const onPreview = (id: string) => openPreviewInNewTab(id);
const onGenPdf = async (id: string) => {
try {
const blob = await generatePdf(id, { preview: true });
downloadBlobFile(blob, `${id}-preview.pdf`);
} catch {}
};
const onDownloadPdf = async (id: string) => {
try {
const blob = await downloadPdf(id);
downloadBlobFile(blob, `${id}.pdf`);
} catch {}
};
return (
<div className="space-y-3">
<div className="flex gap-2">
<input
placeholder="Search…"
value={q}
onChange={(e) => setQ(e.target.value)}
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-indigo-500/50"
/>
<button
onClick={load}
disabled={loading}
className="rounded-md bg-gray-100 hover:bg-gray-200 text-gray-900 px-3 py-2 text-sm disabled:opacity-60"
>
{loading ? 'Loading…' : 'Refresh'}
</button>
</div>
<ul className="divide-y divide-gray-200">
{filtered.map((c) => (
<li key={c.id} className="py-3 flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium text-sm text-gray-900 truncate">{c.name}</p>
<StatusBadge status={c.status} />
</div>
<p className="text-xs text-gray-600">Version {c.version}{c.updatedAt ? ` • Updated ${new Date(c.updatedAt).toLocaleString()}` : ''}</p>
</div>
<div className="flex flex-wrap gap-2">
<button onClick={() => onPreview(c.id)} className="px-2 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200 text-gray-800 border border-gray-200">Preview</button>
<button onClick={() => onGenPdf(c.id)} className="px-2 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200 text-gray-800 border border-gray-200">PDF</button>
<button onClick={() => onDownloadPdf(c.id)} className="px-2 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200 text-gray-800 border border-gray-200">Download</button>
<button onClick={() => onToggleState(c.id, c.status)} className="px-2 py-1 text-xs rounded bg-indigo-600 hover:bg-indigo-500 text-white">
{c.status === 'published' ? 'Deactivate' : 'Activate'}
</button>
</div>
</li>
))}
{!filtered.length && (
<li className="py-6 text-sm text-gray-600">No contracts found.</li>
)}
</ul>
</div>
);
}

View File

@ -0,0 +1,435 @@
'use client';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import useContractManagement, { CompanyStamp } from '../hooks/useContractManagement';
import DeleteConfirmationModal from '../../../components/delete/deleteConfirmationModal';
type Props = {
onUploaded?: () => void;
};
export default function ContractUploadCompanyStamp({ onUploaded }: Props) {
const [file, setFile] = useState<File | null>(null);
const [label, setLabel] = useState<string>('');
const [uploading, setUploading] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [stamps, setStamps] = useState<CompanyStamp[]>([]);
const [showModal, setShowModal] = useState(false);
const [modalFile, setModalFile] = useState<File | null>(null);
const [modalLabel, setModalLabel] = useState<string>('');
const [modalError, setModalError] = useState<string | null>(null);
const [modalUploading, setModalUploading] = useState(false);
const [deleteModal, setDeleteModal] = useState<{ open: boolean; id?: string; label?: string; active?: boolean }>({ open: false });
const [deleteLoading, setDeleteLoading] = useState(false);
const inputRef = useRef<HTMLInputElement | null>(null);
const {
uploadCompanyStamp,
listStampsAll,
activateCompanyStamp,
deactivateCompanyStamp,
deleteCompanyStamp,
} = useContractManagement();
const previewUrl = useMemo(() => (file ? URL.createObjectURL(file) : null), [file]);
const modalPreviewUrl = useMemo(() => (modalFile ? URL.createObjectURL(modalFile) : null), [modalFile]);
const onPick = (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0];
if (f) setFile(f);
};
const loadStamps = async () => {
try {
const { stamps, active, activeId } = await listStampsAll();
console.debug('[CM/UI] loadStamps (/all):', {
count: stamps.length,
activeId,
withImgCount: stamps.filter((s) => !!s.base64).length,
});
setStamps(stamps);
} catch (e) {
console.warn('[CM/UI] loadStamps error:', e);
setStamps([]);
}
};
useEffect(() => {
loadStamps();
}, []);
const upload = async () => {
if (!file) {
setMsg('Select an image first.');
return;
}
setUploading(true);
setMsg(null);
try {
await uploadCompanyStamp(file, label || undefined);
console.debug('[CM/UI] upload success, refreshing list');
setMsg('Company stamp uploaded.');
if (onUploaded) onUploaded();
await loadStamps();
// Optional clear
// setFile(null);
// setLabel('');
// if (inputRef.current) inputRef.current.value = '';
} catch (e: any) {
console.warn('[CM/UI] upload error:', e);
setMsg(e?.message || 'Upload failed.');
} finally {
setUploading(false);
}
};
const onActivate = async (id: string) => {
try {
await activateCompanyStamp(id);
console.debug('[CM/UI] activated:', id);
await loadStamps();
setMsg('Activated company stamp.');
} catch (e: any) {
console.warn('[CM/UI] activate error:', e);
setMsg(e?.message || 'Activation failed.');
}
};
const onDeactivate = async (id: string) => {
try {
await deactivateCompanyStamp(id);
console.debug('[CM/UI] deactivated:', id);
await loadStamps();
setMsg('Deactivated company stamp.');
} catch (e: any) {
console.warn('[CM/UI] deactivate error:', e);
setMsg(e?.message || 'Deactivation failed.');
}
};
const onDelete = (id: string, active?: boolean, label?: string) => {
setDeleteModal({ open: true, id, label, active });
};
const handleDeleteConfirm = async () => {
if (!deleteModal.id) return;
setDeleteLoading(true);
try {
await deleteCompanyStamp(deleteModal.id);
console.debug('[CM/UI] deleted:', deleteModal.id);
await loadStamps();
setMsg('Deleted company stamp.');
} catch (e: any) {
console.warn('[CM/UI] delete error:', e);
setMsg(e?.message || 'Delete failed.');
} finally {
setDeleteLoading(false);
setDeleteModal({ open: false });
}
};
// Validation helpers for modal
const ACCEPTED_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml'];
const MAX_BYTES = 5 * 1024 * 1024; // 5MB
const validateFile = (f: File) => {
if (!ACCEPTED_TYPES.includes(f.type)) return `Invalid file type (${f.type}). Allowed: PNG, JPEG, WebP, SVG.`;
if (f.size > MAX_BYTES) return `File too large (${Math.round(f.size / 1024 / 1024)}MB). Max 5MB.`;
return null;
};
const onDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setModalError(null);
const f = e.dataTransfer.files?.[0];
if (!f) return;
const err = validateFile(f);
if (err) { setModalError(err); setModalFile(null); return; }
setModalFile(f);
};
const onBrowse = (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0];
if (!f) return;
setModalError(null);
const err = validateFile(f);
if (err) { setModalError(err); setModalFile(null); return; }
setModalFile(f);
};
const openModal = () => {
setModalLabel('');
setModalFile(null);
setModalError(null);
setShowModal(true);
};
const closeModal = () => {
setShowModal(false);
setModalUploading(false);
};
const confirmUpload = async () => {
if (!modalFile) {
setModalError('Please select an image.');
return;
}
setModalUploading(true);
setModalError(null);
try {
await uploadCompanyStamp(modalFile, modalLabel || undefined);
if (onUploaded) onUploaded();
await loadStamps();
closeModal();
} catch (e: any) {
setModalError(e?.message || 'Upload failed.');
} finally {
setModalUploading(false);
}
};
const activeStamp = stamps.find((s) => s.active);
useEffect(() => {
if (activeStamp) {
console.debug('[CM/UI] activeStamp:', {
id: activeStamp.id,
label: activeStamp.label,
hasImg: !!activeStamp.base64,
mime: activeStamp.mimeType,
});
if (!activeStamp.base64) {
console.warn('[CM/UI] Active stamp has no image data; preview will show placeholder.');
}
}
}, [activeStamp?.id, activeStamp?.base64]);
const toImgSrc = (s: CompanyStamp) => {
if (!s?.base64) {
console.warn('[CM/UI] toImgSrc: missing base64 for stamp', s?.id);
return '';
}
return s.base64.startsWith('data:') ? s.base64 : `data:${s.mimeType || 'image/png'};base64,${s.base64}`;
};
return (
<div className="space-y-4">
{/* Header with Add New Stamp modal trigger */}
<div className="flex items-center justify-between">
<p className="text-sm text-gray-700">Manage your company stamps. One active at a time.</p>
<button
type="button"
onClick={openModal}
className="inline-flex items-center gap-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white px-3 py-2 text-sm shadow-sm"
>
<svg width="16" height="16" fill="currentColor" className="opacity-90"><path d="M7 1a1 1 0 0 1 2 0v5h5a1 1 0 1 1 0 2H9v5a1 1 0 1 1-2 0V8H2a1 1 0 1 1 0-2h5V1z"/></svg>
Add New Stamp
</button>
</div>
{/* Emphasized Active stamp */}
{activeStamp && (
<div className="relative rounded-2xl p-[1px] bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500">
<div className="rounded-2xl bg-white p-4 sm:p-5 flex items-center gap-4 sm:gap-6">
<div className="relative">
{activeStamp.base64 ? (
<img
src={toImgSrc(activeStamp)}
alt="Active stamp"
className="h-20 w-20 sm:h-24 sm:w-24 object-contain rounded-xl ring-1 ring-gray-200 shadow-md"
/>
) : (
<div className="h-20 w-20 sm:h-24 sm:w-24 flex items-center justify-center rounded-xl ring-1 ring-gray-200 bg-gray-50 text-[10px] text-gray-500">
no image
</div>
)}
<span className="absolute -top-2 -right-2 rounded-full bg-green-600 text-white text-[10px] px-2 py-0.5 shadow">
Active
</span>
</div>
<div className="min-w-0">
<p className="text-sm font-semibold text-gray-900 truncate">{activeStamp.label || activeStamp.id}</p>
<p className="text-xs text-gray-600">This stamp is auto-applied to documents where applicable.</p>
</div>
<div className="ml-auto flex items-center gap-2">
<button
onClick={() => onDeactivate(activeStamp.id)}
className="text-xs px-3 py-2 rounded-lg bg-white hover:bg-gray-50 text-gray-800 border border-gray-200 shadow-sm"
>
Deactivate
</button>
</div>
</div>
</div>
)}
{/* Stamps list */}
{!!stamps.length && (
<div className="mt-2">
<p className="text-sm font-medium text-gray-900 mb-2">Your Company Stamps</p>
<ul className="space-y-2">
{stamps.map((s) => {
const src = toImgSrc(s);
const activeCls = s.active
? 'border-green-300 bg-green-50'
: 'border-gray-200 hover:border-gray-300 transition-colors';
return (
<li
key={s.id}
className={`flex items-center justify-between gap-3 p-3 border rounded-xl ${activeCls}`}
>
<div className="flex items-center gap-3">
{s.base64 ? (
<img
src={src}
alt="Stamp"
className="h-12 w-12 object-contain rounded-lg ring-1 ring-gray-200 bg-white"
/>
) : (
<div className="h-12 w-12 flex items-center justify-center rounded-lg ring-1 ring-gray-200 bg-white text-[10px] text-gray-500">
no image
</div>
)}
<div className="flex flex-col">
<span className="text-sm text-gray-900">{s.label || s.id}</span>
{s.active && (
<span className="text-[10px] mt-1 px-2 py-0.5 rounded bg-green-100 text-green-800 w-fit">
Active
</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
{s.active ? (
<button
onClick={() => onDeactivate(s.id)}
className="text-xs px-3 py-1.5 rounded-lg bg-white hover:bg-gray-50 text-gray-800 border border-gray-200"
>
Deactivate
</button>
) : (
<button
onClick={() => onActivate(s.id)}
className="text-xs px-3 py-1.5 rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 border border-gray-200"
>
Activate
</button>
)}
<button
onClick={() => onDelete(s.id, s.active, s.label ?? undefined)}
className="text-xs px-3 py-1.5 rounded-lg bg-red-50 hover:bg-red-100 text-red-700 border border-red-200"
>
Delete
</button>
</div>
</li>
);
})}
</ul>
</div>
)}
{/* Modal: Add New Stamp */}
{showModal && (
<div className="fixed inset-0 z-50">
<div className="absolute inset-0 bg-black/40" onClick={closeModal} />
<div className="absolute inset-0 flex items-center justify-center p-4">
<div className="w-full max-w-lg rounded-2xl bg-white shadow-xl ring-1 ring-black/5">
<div className="p-5 border-b border-gray-100">
<h3 className="text-base font-semibold text-gray-900">Add New Stamp</h3>
<p className="mt-1 text-xs text-gray-600">
Accepted types: PNG, JPEG, WebP, SVG. Max size: 5MB.
</p>
</div>
<div className="p-5 space-y-4">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Label</label>
<input
type="text"
value={modalLabel}
onChange={(e) => setModalLabel(e.target.value)}
placeholder="e.g., Company Seal 2025"
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-indigo-500/50"
/>
</div>
<div
onDragOver={(e) => e.preventDefault()}
onDrop={onDrop}
className="rounded-xl border-2 border-dashed border-gray-300 hover:border-indigo-400 transition-colors p-4 bg-gray-50"
>
<div className="flex items-center gap-4">
{modalPreviewUrl ? (
<img
src={modalPreviewUrl}
alt="Preview"
className="h-20 w-20 object-contain rounded-lg ring-1 ring-gray-200 bg-white"
/>
) : (
<div className="h-20 w-20 flex items-center justify-center rounded-lg ring-1 ring-gray-200 bg-white text-[10px] text-gray-500">
No image
</div>
)}
<div className="min-w-0">
<p className="text-sm text-gray-900">Drag and drop your stamp here</p>
<p className="text-xs text-gray-600">or click to browse</p>
<div className="mt-2">
<label className="inline-block">
<input
type="file"
accept={ACCEPTED_TYPES.join(',')}
onChange={onBrowse}
className="hidden"
/>
<span className="cursor-pointer text-xs px-3 py-1.5 rounded-lg bg-white hover:bg-gray-50 text-gray-800 border border-gray-200 inline-flex items-center gap-1">
<svg width="14" height="14" fill="currentColor"><path d="M12 10v2H2v-2H0v4h14v-4h-2zM7 0l4 4H9v5H5V4H3l4-4z"/></svg>
Choose file
</span>
</label>
</div>
</div>
</div>
</div>
{modalError && <p className="text-xs text-red-600">{modalError}</p>}
</div>
<div className="px-5 py-4 border-t border-gray-100 flex items-center justify-end gap-2">
<button
type="button"
onClick={closeModal}
className="text-sm px-3 py-2 rounded-lg bg-white hover:bg-gray-50 text-gray-800 border border-gray-200"
>
Cancel
</button>
<button
type="button"
onClick={confirmUpload}
disabled={modalUploading || !modalFile}
className="text-sm px-3 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white disabled:opacity-60"
>
{modalUploading ? 'Uploading…' : 'Upload'}
</button>
</div>
</div>
</div>
</div>
)}
{/* Delete Confirmation Modal */}
<DeleteConfirmationModal
open={deleteModal.open}
title="Delete Company Stamp"
description={
deleteModal.active
? `This stamp (${deleteModal.label || deleteModal.id}) is currently active. Are you sure you want to delete it? This action cannot be undone.`
: `Are you sure you want to delete the stamp "${deleteModal.label || deleteModal.id}"? This action cannot be undone.`
}
confirmText="Delete"
cancelText="Cancel"
loading={deleteLoading}
onConfirm={handleDeleteConfirm}
onCancel={() => setDeleteModal({ open: false })}
/>
</div>
);
}

View File

@ -0,0 +1,435 @@
import { useCallback } from 'react';
import useAuthStore from '../../../store/authStore';
export type DocumentTemplate = {
id: string;
name: string;
type?: string;
lang?: 'en' | 'de' | string;
user_type?: 'personal' | 'company' | 'both' | string;
state?: 'active' | 'inactive' | string;
version?: number;
previewUrl?: string | null;
fileUrl?: string | null;
html?: string | null;
updatedAt?: string;
};
export type CompanyStamp = {
id: string;
label?: string | null;
mimeType?: string;
base64?: string | null; // normalized base64 or data URI
active?: boolean;
createdAt?: string;
// ...other metadata...
};
type Json = Record<string, any>;
function isFormData(body: any): body is FormData {
return typeof FormData !== 'undefined' && body instanceof FormData;
}
export default function useContractManagement() {
const base = process.env.NEXT_PUBLIC_API_BASE_URL || '';
const getState = useAuthStore.getState;
const authorizedFetch = useCallback(
async <T = any>(
path: string,
init: RequestInit = {},
responseType: 'json' | 'text' | 'blob' = 'json'
): Promise<T> => {
let token = getState().accessToken;
if (!token) {
const ok = await getState().refreshAuthToken();
if (ok) token = getState().accessToken;
}
const headers: Record<string, string> = {
...(init.headers as Record<string, string> || {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
// Do not set Content-Type for FormData; browser will set proper boundary
if (!isFormData(init.body) && init.method && init.method !== 'GET') {
headers['Content-Type'] = headers['Content-Type'] || 'application/json';
}
// Debug (safe)
try {
console.debug('[CM] fetch ->', {
url: `${base}${path}`,
method: init.method || 'GET',
hasAuth: !!token,
tokenPrefix: token ? `${token.substring(0, 12)}...` : null,
});
} catch {}
// Include cookies + Authorization on all requests
const res = await fetch(`${base}${path}`, {
credentials: 'include',
...init,
headers,
});
try {
console.debug('[CM] fetch <-', { path, status: res.status, ok: res.ok, ct: res.headers.get('content-type') });
} catch {}
if (!res.ok) {
const text = await res.text().catch(() => '');
console.warn('[CM] fetch error body:', text?.slice(0, 2000));
throw new Error(`HTTP ${res.status}: ${text || res.statusText}`);
}
// Log and return body by responseType
if (responseType === 'blob') {
const len = res.headers.get('content-length');
try {
console.debug('[CM] fetch body (blob):', { contentType: res.headers.get('content-type'), contentLength: len ? Number(len) : null });
} catch {}
return (await res.blob()) as unknown as T;
}
if (responseType === 'text') {
const text = await res.text();
try {
console.debug('[CM] fetch body (text):', text.slice(0, 2000));
} catch {}
return text as unknown as T;
}
// json (default): read text once, log, then parse
const text = await res.text();
try {
console.debug('[CM] fetch body (json):', text.slice(0, 2000));
} catch {}
try {
return JSON.parse(text) as T;
} catch {
console.warn('[CM] failed to parse JSON, returning empty object');
return {} as T;
}
},
[base]
);
// Document templates
const listTemplates = useCallback(async (): Promise<DocumentTemplate[]> => {
const data = await authorizedFetch<DocumentTemplate[]>('/api/document-templates', { method: 'GET' });
return Array.isArray(data) ? data : [];
}, [authorizedFetch]);
const getTemplate = useCallback(async (id: string): Promise<DocumentTemplate> => {
return authorizedFetch<DocumentTemplate>(`/api/document-templates/${id}`, { method: 'GET' });
}, [authorizedFetch]);
const previewTemplateHtml = useCallback(async (id: string): Promise<string> => {
return authorizedFetch<string>(`/api/document-templates/${id}/preview`, { method: 'GET' }, 'text');
}, [authorizedFetch]);
const openPreviewInNewTab = useCallback(async (id: string) => {
const html = await previewTemplateHtml(id);
const blob = new Blob([html], { type: 'text/html' });
const url = URL.createObjectURL(blob);
window.open(url, '_blank', 'noopener,noreferrer');
// No revoke here to keep the tab content; browser will clean up eventually
}, [previewTemplateHtml]);
const generatePdf = useCallback(async (id: string, opts?: { preview?: boolean; sanitize?: boolean }): Promise<Blob> => {
const params = new URLSearchParams();
if (opts?.preview) params.set('preview', '1');
if (opts?.sanitize) params.set('sanitize', '1');
const qs = params.toString() ? `?${params.toString()}` : '';
return authorizedFetch<Blob>(`/api/document-templates/${id}/generate-pdf${qs}`, { method: 'GET' }, 'blob');
}, [authorizedFetch]);
const downloadPdf = useCallback(async (id: string): Promise<Blob> => {
return authorizedFetch<Blob>(`/api/document-templates/${id}/download-pdf`, { method: 'GET' }, 'blob');
}, [authorizedFetch]);
const uploadTemplate = useCallback(async (payload: {
file: File | Blob;
name: string;
type: string;
lang: 'en' | 'de' | string;
description?: string;
user_type?: 'personal' | 'company' | 'both';
}): Promise<DocumentTemplate> => {
const fd = new FormData();
const file = payload.file instanceof File ? payload.file : new File([payload.file], `${payload.name || 'template'}.html`, { type: 'text/html' });
fd.append('file', file);
fd.append('name', payload.name);
fd.append('type', payload.type);
fd.append('lang', payload.lang);
if (payload.description) fd.append('description', payload.description);
if (payload.user_type) fd.append('user_type', payload.user_type);
return authorizedFetch<DocumentTemplate>('/api/document-templates', { method: 'POST', body: fd });
}, [authorizedFetch]);
const updateTemplate = useCallback(async (id: string, payload: {
file?: File | Blob;
name?: string;
type?: string;
lang?: 'en' | 'de' | string;
description?: string;
user_type?: 'personal' | 'company' | 'both';
}): Promise<DocumentTemplate> => {
const fd = new FormData();
if (payload.file) {
const f = payload.file instanceof File ? payload.file : new File([payload.file], `${payload.name || 'template'}.html`, { type: 'text/html' });
fd.append('file', f);
}
if (payload.name) fd.append('name', payload.name);
if (payload.type) fd.append('type', payload.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);
return authorizedFetch<DocumentTemplate>(`/api/document-templates/${id}`, { method: 'PUT', body: fd });
}, [authorizedFetch]);
const updateTemplateState = useCallback(async (id: string, state: 'active' | 'inactive'): Promise<DocumentTemplate> => {
return authorizedFetch<DocumentTemplate>(`/api/document-templates/${id}/state`, {
method: 'PATCH',
body: JSON.stringify({ state }),
});
}, [authorizedFetch]);
const generatePdfWithSignature = useCallback(async (id: string, payload: {
signatureImage?: string; signature?: string; signatureData?: string;
userData?: Json; user?: Json; context?: Json;
currentDate?: string;
}): Promise<Blob> => {
const body: Json = {};
if (payload.signatureImage) body.signatureImage = payload.signatureImage;
if (payload.signature) body.signature = payload.signature;
if (payload.signatureData) body.signatureData = payload.signatureData;
if (payload.userData) body.userData = payload.userData;
if (payload.user) body.user = payload.user;
if (payload.context) body.context = payload.context;
if (payload.currentDate) body.currentDate = payload.currentDate;
return authorizedFetch<Blob>(`/api/document-templates/${id}/generate-pdf-with-signature`, {
method: 'POST',
body: JSON.stringify(body),
}, 'blob');
}, [authorizedFetch]);
// Helper: convert various base64 forms into a clean data URI
const toDataUri = useCallback((raw: any, mime?: string | null): string | null => {
if (!raw) return null;
try {
let s = String(raw);
if (s.startsWith('data:')) return s; // already a data URI
// Remove optional "base64," prefix
s = s.replace(/^base64,/, '');
// Remove whitespace/newlines
s = s.replace(/\s+/g, '');
// Convert URL-safe base64 to standard
s = s.replace(/-/g, '+').replace(/_/g, '/');
// Pad to a multiple of 4
while (s.length % 4 !== 0) s += '=';
const m = mime || 'image/png';
return `data:${m};base64,${s}`;
} catch {
return null;
}
}, []);
// Helper: unwrap arrays from common API envelope shapes
const unwrapList = useCallback((raw: any): any[] => {
if (Array.isArray(raw)) return raw;
if (Array.isArray(raw?.data)) return raw.data;
if (Array.isArray(raw?.items)) return raw.items;
if (Array.isArray(raw?.results)) return raw.results;
return [];
}, []);
// Add image_base64 and other common variants
const normalizeStamp = useCallback((s: any, forceActive = false): CompanyStamp => {
const mime = s?.mime_type ?? s?.mimeType ?? s?.mimetype ?? s?.type ?? 'image/png';
const imgRaw =
s?.image ??
s?.image_data ??
s?.image_base64 ?? // backend key seen in logs
s?.imageBase64 ??
s?.base64 ??
s?.data ??
null;
try {
const presentKeys = Object.keys(s || {}).filter(k =>
['image', 'image_data', 'image_base64', 'imageBase64', 'base64', 'data', 'mime_type', 'mimeType'].includes(k)
);
console.debug('[CM] normalizeStamp keys:', presentKeys, 'hasImg:', !!imgRaw);
} catch {}
const dataUri = toDataUri(imgRaw, mime);
return {
id: s?.id ?? s?._id ?? s?.uuid ?? s?.stamp_id ?? String(Math.random()),
label: s?.label ?? null,
mimeType: mime,
base64: dataUri,
active: forceActive ? true : !!(s?.is_active ?? s?.active ?? s?.isActive),
createdAt: s?.createdAt ?? s?.created_at,
};
}, [toDataUri]);
// New: fetch all stamps and the active one in one request
const listStampsAll = useCallback(async (): Promise<{ stamps: CompanyStamp[]; active: CompanyStamp | null; activeId?: string | null; }> => {
const raw = await authorizedFetch<any>('/api/company-stamps/all', { method: 'GET' });
try {
console.debug('[CM] /api/company-stamps/all raw:', {
isArray: Array.isArray(raw),
topKeys: raw && !Array.isArray(raw) ? Object.keys(raw) : [],
dataKeys: raw?.data ? Object.keys(raw.data) : [],
});
// Log first item keys to confirm field names like image_base64
const sample = Array.isArray(raw) ? raw[0] : (raw?.data?.[0] ?? raw?.items?.[0] ?? raw?.stamps?.[0]);
if (sample) console.debug('[CM] /api/company-stamps/all sample keys:', Object.keys(sample));
} catch {}
const container = raw?.data ?? raw;
const rawList: any[] =
Array.isArray(container?.items) ? container.items
: Array.isArray(container?.stamps) ? container.stamps
: Array.isArray(container?.list) ? container.list
: Array.isArray(container) ? container
: Array.isArray(raw?.items) ? raw.items
: Array.isArray(raw) ? raw
: [];
const rawActive = container?.active ?? container?.current ?? container?.activeStamp ?? null;
const stamps = rawList.map((s: any) => normalizeStamp(s));
let active = rawActive ? normalizeStamp(rawActive, true) : null;
// Derive active from list if not provided separately
if (!active) {
const fromList = stamps.find(s => s.active);
if (fromList) active = { ...fromList, active: true };
}
// Mark the active in stamps if present
const activeId = active?.id ?? (container?.active_id ?? container?.activeId ?? null);
const stampsMarked = activeId
? stamps.map((s) => (s.id === activeId ? { ...s, active: true, base64: s.base64 || active?.base64, mimeType: s.mimeType || active?.mimeType } : s))
: stamps;
try {
console.debug('[CM] /api/company-stamps/all normalized:', {
total: stampsMarked.length,
withImg: stampsMarked.filter(s => !!s.base64).length,
activeId: activeId || active?.id || null,
hasActiveImg: !!active?.base64,
});
} catch {}
return { stamps: stampsMarked, active, activeId: activeId || active?.id || null };
}, [authorizedFetch, normalizeStamp]);
const listMyCompanyStamps = useCallback(async (): Promise<CompanyStamp[]> => {
const { stamps } = await listStampsAll();
return stamps;
}, [listStampsAll]);
const getActiveCompanyStamp = useCallback(async (): Promise<CompanyStamp | null> => {
const { active } = await listStampsAll();
return active ?? null;
}, [listStampsAll]);
// helper: convert File/Blob to base64 string and mime
const fileToBase64 = useCallback(
(file: File | Blob) =>
new Promise<{ base64: string; mime: string }>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error('Failed to read file'));
reader.onload = () => {
const result = String(reader.result || '');
const m = result.match(/^data:(.+?);base64,(.*)$/);
if (m) {
resolve({ mime: m[1], base64: m[2] });
} else {
resolve({ mime: (file as File).type || 'application/octet-stream', base64: result });
}
};
reader.readAsDataURL(file);
}),
[]
);
// Upload expects JSON { base64, mime_type/mimeType, label? }
const uploadCompanyStamp = useCallback(async (file: File | Blob, label?: string) => {
const { base64, mime } = await fileToBase64(file);
try {
console.debug('[CM] uploadCompanyStamp payload:', { mime, base64Len: base64?.length || 0, hasLabel: !!label });
} catch {}
return authorizedFetch<CompanyStamp>('/api/company-stamps', {
method: 'POST',
body: JSON.stringify({
base64,
mimeType: mime,
mime_type: mime,
...(label ? { label } : {}),
}),
});
}, [authorizedFetch, fileToBase64]);
const activateCompanyStamp = useCallback(async (id: string) => {
console.debug('[CM] activateCompanyStamp ->', id);
return authorizedFetch<{ success?: boolean }>(`/api/company-stamps/${id}/activate`, { method: 'PATCH' });
}, [authorizedFetch]);
const deactivateCompanyStamp = useCallback(async (id: string) => {
console.debug('[CM] deactivateCompanyStamp ->', id);
return authorizedFetch<{ success?: boolean }>(`/api/company-stamps/${id}/deactivate`, { method: 'PATCH' });
}, [authorizedFetch]);
// Delete a company stamp (strict: no fallback)
const deleteCompanyStamp = useCallback(async (id: string) => {
console.debug('[CM] deleteCompanyStamp ->', id);
return authorizedFetch<{ success?: boolean }>(`/api/company-stamps/${id}`, { method: 'DELETE' });
}, [authorizedFetch]);
const downloadBlobFile = useCallback((blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, []);
const listTemplatesPublic = useCallback(async (): Promise<DocumentTemplate[]> => {
const data = await authorizedFetch<DocumentTemplate[]>('/api/document-templates/public', { method: 'GET' });
return Array.isArray(data) ? data : [];
}, [authorizedFetch]);
return {
// templates
listTemplates,
getTemplate,
previewTemplateHtml,
openPreviewInNewTab,
generatePdf,
downloadPdf,
uploadTemplate,
updateTemplate,
updateTemplateState,
generatePdfWithSignature,
listTemplatesPublic,
// stamps
listStampsAll,
listMyCompanyStamps,
getActiveCompanyStamp,
uploadCompanyStamp,
activateCompanyStamp,
deactivateCompanyStamp,
deleteCompanyStamp,
// utils
downloadBlobFile,
};
}

View File

@ -0,0 +1,47 @@
'use client';
import React, { useState } from 'react';
import PageLayout from '../../components/PageLayout';
import ContractEditor from './components/contractEditor';
import ContractUploadCompanyStamp from './components/contractUploadCompanyStamp';
import ContractTemplateList from './components/contractTemplateList';
export default function ContractManagementPage() {
const [refreshKey, setRefreshKey] = useState(0);
const bumpRefresh = () => setRefreshKey((k) => k + 1);
return (
<PageLayout>
{/* Force white background for this page */}
<div className="bg-white min-h-screen">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8">
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Contract Management</h1>
<p className="mt-1 text-sm text-gray-600">
Create contract templates in HTML, upload the company stamp, and manage existing contracts.
</p>
</div>
{/* Reordered vertical layout */}
<div className="space-y-6">
<div className="rounded-xl border border-gray-200 bg-white p-4 sm:p-6 shadow-sm">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Company Stamp</h2>
<ContractUploadCompanyStamp onUploaded={bumpRefresh} />
</div>
<div className="rounded-xl border border-gray-200 bg-white p-4 sm:p-6 shadow-sm">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Contracts</h2>
<ContractTemplateList refreshKey={refreshKey} />
</div>
<div className="rounded-xl border border-gray-200 bg-white p-4 sm:p-6 shadow-sm">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Create Template</h2>
<ContractEditor onSaved={bumpRefresh} />
</div>
</div>
</div>
</div>
</PageLayout>
);
}

View File

@ -0,0 +1,66 @@
import React from "react";
type DeleteConfirmationModalProps = {
open: boolean;
title?: string;
description?: string;
confirmText?: string;
cancelText?: string;
loading?: boolean;
onConfirm: () => void;
onCancel: () => void;
children?: React.ReactNode;
};
export default function DeleteConfirmationModal({
open,
title = "Delete Item",
description = "Are you sure you want to delete this item? This action cannot be undone.",
confirmText = "Delete",
cancelText = "Cancel",
loading = false,
onConfirm,
onCancel,
children,
}: DeleteConfirmationModalProps) {
if (!open) return null;
return (
<div className="fixed inset-0 z-50">
<div className="absolute inset-0 bg-black/40" onClick={onCancel} />
<div className="absolute inset-0 flex items-center justify-center p-4">
<div className="w-full max-w-md rounded-2xl bg-white shadow-2xl ring-1 ring-black/10">
<div className="p-6">
<div className="flex items-center gap-3 mb-3">
<div className="flex items-center justify-center h-10 w-10 rounded-full bg-red-100">
<svg width="24" height="24" fill="none" stroke="currentColor" className="text-red-600">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
</div>
<p className="text-sm text-gray-700 mb-4">{description}</p>
{children}
<div className="flex items-center justify-end gap-2 mt-6">
<button
type="button"
onClick={onCancel}
className="text-sm px-4 py-2 rounded-lg bg-white hover:bg-gray-50 text-gray-800 border border-gray-200"
disabled={loading}
>
{cancelText}
</button>
<button
type="button"
onClick={onConfirm}
disabled={loading}
className="text-sm px-4 py-2 rounded-lg bg-red-600 hover:bg-red-500 text-white disabled:opacity-60"
>
{loading ? "Deleting…" : confirmText}
</button>
</div>
</div>
</div>
</div>
</div>
);
}