profit-planet-frontend/src/app/admin/contract-management/hooks/useContractManagement.ts

605 lines
23 KiB
TypeScript

import { useCallback } from 'react';
import useAuthStore from '../../../store/authStore';
export type DocumentTemplate = {
id: string;
name: string;
type?: string;
contract_type?: 'contract' | 'gdpr' | 'abo' | null | string;
lang?: 'en' | 'de' | string;
user_type?: 'personal' | 'company' | 'both' | string;
tax_mode?: 'standard' | 'reverse_charge' | 'both' | null | 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 redactLongOrBase64ish(value: any) {
if (typeof value !== 'string') return value;
const len = value.length;
const head = value.slice(0, 32);
// If it's long, treat it as potentially sensitive / base64 and only log metadata.
if (len > 200) {
return { kind: 'string', len, head };
}
return value;
}
function redactJsonForLogs(input: any): any {
if (!input || typeof input !== 'object') return redactLongOrBase64ish(input);
if (Array.isArray(input)) return input.map(redactJsonForLogs);
const out: Record<string, any> = {};
for (const [k, v] of Object.entries(input)) {
if (typeof v === 'string' && (k.toLowerCase().includes('base64') || k.toLowerCase().includes('qr_code'))) {
out[k] = { kind: 'base64', len: v.length, head: v.slice(0, 32) };
} else {
out[k] = redactJsonForLogs(v);
}
}
return out;
}
function isFormData(body: any): body is FormData {
return typeof FormData !== 'undefined' && body instanceof FormData;
}
function safeDescribeBody(body: any) {
if (!body) return null;
if (isFormData(body)) {
const entries: Record<string, any> = {};
try {
for (const [k, v] of body.entries()) {
if (typeof File !== 'undefined' && v instanceof File) {
entries[k] = { kind: 'File', name: v.name, type: v.type, size: v.size };
} else {
// Strings only for our current usage.
entries[k] = v;
}
}
} catch (e: any) {
return { kind: 'FormData', error: e?.message || String(e) };
}
return { kind: 'FormData', entries };
}
if (typeof body === 'string') {
return { kind: 'string', preview: body.slice(0, 500), length: body.length };
}
// Avoid dumping arbitrary objects (could be huge / sensitive)
return { kind: typeof body };
}
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';
}
const url = `${base}${path}`;
const method = init.method || 'GET';
// Debug (safe)
try {
console.debug('[CM] fetch ->', {
url,
method,
hasAuth: !!token,
});
} catch {}
// EXTRA debug for document-template calls: show what we send (safe metadata only)
if (path.startsWith('/api/document-templates')) {
try {
const safeHeaders = { ...headers } as Record<string, any>;
if (safeHeaders.Authorization) safeHeaders.Authorization = '[redacted]';
console.info('[CM][document-templates] request', {
url,
method,
headers: safeHeaders,
body: safeDescribeBody(init.body),
});
} 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, getState]
);
// 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;
contract_type?: 'contract' | 'gdpr' | 'abo';
lang: 'en' | 'de' | string;
description?: string;
user_type?: 'personal' | 'company' | 'both';
tax_mode?: 'standard' | 'reverse_charge' | '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);
if (payload.type === 'contract' && payload.contract_type) {
fd.append('contract_type', payload.contract_type);
}
if (payload.type === 'invoice' && payload.tax_mode) {
fd.append('tax_mode', payload.tax_mode);
}
fd.append('lang', payload.lang);
if (payload.description) fd.append('description', payload.description);
fd.append('user_type', (payload.user_type ?? 'both'));
try {
console.info('[CM][document-templates] uploadTemplate()', {
name: payload.name,
type: payload.type,
contract_type: payload.contract_type,
willSendContractType: payload.type === 'contract' && Boolean(payload.contract_type),
lang: payload.lang,
user_type: payload.user_type ?? 'both',
tax_mode: payload.type === 'invoice' ? (payload.tax_mode ?? 'both') : null,
descriptionLength: payload.description ? payload.description.length : 0,
file: typeof File !== 'undefined' && file instanceof File ? { name: file.name, type: file.type, size: file.size } : null,
});
} catch {}
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;
contract_type?: 'contract' | 'gdpr' | 'abo';
lang?: 'en' | 'de' | string;
description?: string;
user_type?: 'personal' | 'company' | 'both';
tax_mode?: 'standard' | 'reverse_charge' | '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.type === 'contract' || payload.contract_type) && payload.contract_type) {
fd.append('contract_type', payload.contract_type);
}
if ((payload.type === 'invoice' || payload.tax_mode) && payload.tax_mode) {
fd.append('tax_mode', payload.tax_mode);
}
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]);
// NEW: revise template (create a new template record + bump version + deactivate previous)
const reviseTemplate = useCallback(async (id: string, payload: {
file: File | Blob;
name?: string;
type?: string;
contract_type?: 'contract' | 'gdpr' | 'abo';
lang?: 'en' | 'de' | string;
description?: string;
user_type?: 'personal' | 'company' | 'both';
tax_mode?: 'standard' | 'reverse_charge' | 'both';
state?: 'active' | 'inactive';
}): 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);
if (payload.name !== undefined) fd.append('name', payload.name);
if (payload.type !== undefined) fd.append('type', payload.type);
if (payload.contract_type !== undefined) fd.append('contract_type', payload.contract_type);
if (payload.tax_mode !== undefined) fd.append('tax_mode', payload.tax_mode);
if (payload.lang !== undefined) fd.append('lang', payload.lang);
if (payload.description !== undefined) fd.append('description', payload.description);
if (payload.user_type !== undefined) fd.append('user_type', payload.user_type);
if (payload.state !== undefined) fd.append('state', payload.state);
try {
console.info('[CM][document-templates] reviseTemplate()', {
id,
name: payload.name,
type: payload.type,
contract_type: payload.contract_type,
lang: payload.lang,
user_type: payload.user_type,
tax_mode: payload.tax_mode,
state: payload.state,
descriptionLength: payload.description ? payload.description.length : 0,
file: typeof File !== 'undefined' && file instanceof File ? { name: file.name, type: file.type, size: file.size } : null,
});
} catch {}
return authorizedFetch<DocumentTemplate>(`/api/document-templates/${id}/revise`, { method: 'POST', 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]);
// Company settings (invoice address info)
type CompanySettings = {
company_name?: string
company_street?: string
company_postal_city?: string
company_country?: string
company_logo_base64?: string | null
company_logo_mime_type?: string | null
}
const getCompanySettings = useCallback(async () => {
return authorizedFetch<CompanySettings>('/api/admin/company-settings', { method: 'GET' });
}, [authorizedFetch]);
const updateCompanySettings = useCallback(async (data: Partial<CompanySettings>) => {
return authorizedFetch<CompanySettings>('/api/admin/company-settings', {
method: 'PUT',
body: JSON.stringify(data),
});
}, [authorizedFetch]);
return {
// templates
listTemplates,
getTemplate,
previewTemplateHtml,
openPreviewInNewTab,
generatePdf,
downloadPdf,
uploadTemplate,
reviseTemplate,
updateTemplate,
updateTemplateState,
generatePdfWithSignature,
listTemplatesPublic,
// stamps
listStampsAll,
listMyCompanyStamps,
getActiveCompanyStamp,
uploadCompanyStamp,
activateCompanyStamp,
deactivateCompanyStamp,
deleteCompanyStamp,
// company settings
getCompanySettings,
updateCompanySettings,
// utils
downloadBlobFile,
};
}