feat: add contract Management
This commit is contained in:
parent
f7205ed8f6
commit
2eca3007e4
232
src/app/admin/contract-management/components/contractEditor.tsx
Normal file
232
src/app/admin/contract-management/components/contractEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
435
src/app/admin/contract-management/hooks/useContractManagement.ts
Normal file
435
src/app/admin/contract-management/hooks/useContractManagement.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
47
src/app/admin/contract-management/page.tsx
Normal file
47
src/app/admin/contract-management/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
src/app/components/delete/deleteConfirmationModal.tsx
Normal file
66
src/app/components/delete/deleteConfirmationModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user