Add click-based contract generator + digital tenant signature

Landlords configure a contract via checkboxes/selects (WLAN included,
furnished, utility billing model, notice period, deposit, rent
adjustment clause) and generate a PDF (pdfkit) that's automatically
attached to the contract's document list. Tenants sign their own
contract in-app by drawing a signature on a canvas; signing embeds the
signature image into a freshly generated final PDF and marks the
contract as signed with a timestamp.

New: Contract.wifiIncluded/furnished/utilityBillingModel (+ new
UtilityBillingModel enum), Contract.tenantSignatureUrl/signedAt.
New endpoints: POST /contracts/:id/generate-document (landlord/admin),
POST /contracts/:id/sign (tenant on own contract, or landlord/admin).

Explicitly a documentation template generated from app data, not a
legally reviewed contract — noted in the PDF itself.
This commit is contained in:
Giuseppe Lombardo 2026-08-13 10:23:27 +00:00
parent 0179517979
commit bba4fc318b
4 changed files with 471 additions and 2 deletions

View File

@ -1,6 +1,7 @@
import { Router, Response } from 'express'; import { Router, Response } from 'express';
import { PrismaClient } from '@prisma/client'; import { PrismaClient, UtilityBillingModel, RentAdjustmentType } from '@prisma/client';
import { AuthedRequest, requireAuth, requireRole } from '../middleware/auth'; import { AuthedRequest, requireAuth, requireRole } from '../middleware/auth';
import { generateContractPdf } from '../services/contractDocumentGenerator';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
@ -88,10 +89,23 @@ const CONTRACT_DOCUMENTS_SELECT = {
endDate: true, endDate: true,
isActive: true, isActive: true,
contractDocumentUrls: true, contractDocumentUrls: true,
totalWarmRent: true,
depositAmount: true,
paymentDueDay: true,
noticePeriodMonths: true,
rentAdjustmentType: true,
rentAdjustmentClause: true,
wifiIncluded: true,
furnished: true,
utilityBillingModel: true,
signedAt: true,
user: { select: { id: true, fullName: true } }, user: { select: { id: true, fullName: true } },
room: { select: { id: true, roomNumber: true } }, room: { select: { id: true, roomNumber: true, sizeSqm: true } },
}; };
const UTILITY_MODELS = Object.values(UtilityBillingModel);
const ADJUSTMENT_TYPES = Object.values(RentAdjustmentType);
function sanitizeDocumentUrls(input: unknown): string[] { function sanitizeDocumentUrls(input: unknown): string[] {
if (!Array.isArray(input)) return []; if (!Array.isArray(input)) return [];
return input.filter((u) => typeof u === 'string' && u.trim()).map((u) => (u as string).trim()); return input.filter((u) => typeof u === 'string' && u.trim()).map((u) => (u as string).trim());
@ -156,3 +170,96 @@ contractsRouter.delete(
res.status(200).json({ contract: updated }); res.status(200).json({ contract: updated });
}, },
); );
// ----------------------------------------------------------------------------
// MUSTERMIETVERTRAG GENERIEREN & DIGITAL UNTERSCHREIBEN
// ----------------------------------------------------------------------------
// Vermieter/Admin: Vertrags-Baukasten-Optionen speichern und daraus ein PDF
// erzeugen, das automatisch den Vertragsdokumenten angehängt wird. Eine
// bereits vorhandene Unterschrift bleibt unangetastet — wer die Klauseln
// nachträglich ändert, muss den Mieter erneut unterschreiben lassen (dafür
// separat der Sign-Endpunkt).
contractsRouter.post(
'/contracts/:id/generate-document',
requireAuth,
requireRole('LANDLORD', 'ADMIN'),
async (req: AuthedRequest, res: Response) => {
const {
wifiIncluded,
furnished,
utilityBillingModel,
noticePeriodMonths,
depositAmount,
rentAdjustmentType,
rentAdjustmentClause,
} = req.body || {};
if (utilityBillingModel !== undefined && !UTILITY_MODELS.includes(utilityBillingModel)) {
return res.status(400).json({ error: `Ungültiges Nebenkosten-Modell. Erlaubt: ${UTILITY_MODELS.join(', ')}` });
}
if (rentAdjustmentType !== undefined && !ADJUSTMENT_TYPES.includes(rentAdjustmentType)) {
return res.status(400).json({ error: `Ungültige Mietanpassung. Erlaubt: ${ADJUSTMENT_TYPES.join(', ')}` });
}
const contract = await prisma.contract.update({
where: { id: req.params.id },
data: {
wifiIncluded: typeof wifiIncluded === 'boolean' ? wifiIncluded : undefined,
furnished: typeof furnished === 'boolean' ? furnished : undefined,
utilityBillingModel: utilityBillingModel || undefined,
noticePeriodMonths: Number.isFinite(Number(noticePeriodMonths)) ? Number(noticePeriodMonths) : undefined,
depositAmount: Number.isFinite(Number(depositAmount)) ? Number(depositAmount) : undefined,
rentAdjustmentType: rentAdjustmentType || undefined,
rentAdjustmentClause: typeof rentAdjustmentClause === 'string' ? rentAdjustmentClause.trim() || null : undefined,
},
include: { room: true, user: true },
}).catch(() => null);
if (!contract) return res.status(404).json({ error: 'Vertrag nicht gefunden' });
const pdfBuffer = await generateContractPdf(contract);
const dataUrl = `data:application/pdf;base64,${pdfBuffer.toString('base64')}`;
const updated = await prisma.contract.update({
where: { id: contract.id },
data: { contractDocumentUrls: [...contract.contractDocumentUrls, dataUrl].slice(0, MAX_CONTRACT_DOCUMENTS) },
select: CONTRACT_DOCUMENTS_SELECT,
});
res.status(200).json({ contract: updated });
},
);
// Mieter (nur der eigene Vertrag) unterschreibt digital: Unterschrift-Bild
// wird gespeichert, ein finales PDF mit eingebetteter Unterschrift erzeugt
// und den Vertragsdokumenten angehängt.
contractsRouter.post('/contracts/:id/sign', requireAuth, async (req: AuthedRequest, res: Response) => {
const contract = await prisma.contract.findUnique({ where: { id: req.params.id }, include: { room: true, user: true } });
if (!contract) return res.status(404).json({ error: 'Vertrag nicht gefunden' });
const isLandlord = req.user!.role === 'LANDLORD' || req.user!.role === 'ADMIN';
if (!isLandlord && contract.userId !== req.user!.id) {
return res.status(403).json({ error: 'Nur der eigene Vertrag kann unterschrieben werden' });
}
const { signatureDataUrl } = req.body || {};
if (typeof signatureDataUrl !== 'string' || !/^data:image\/(png|jpeg);base64,/.test(signatureDataUrl)) {
return res.status(400).json({ error: 'signatureDataUrl (PNG/JPEG Data-URL) ist erforderlich' });
}
const signedAt = new Date();
const signedContract = { ...contract, tenantSignatureUrl: signatureDataUrl, signedAt };
const pdfBuffer = await generateContractPdf(signedContract);
const dataUrl = `data:application/pdf;base64,${pdfBuffer.toString('base64')}`;
const updated = await prisma.contract.update({
where: { id: contract.id },
data: {
tenantSignatureUrl: signatureDataUrl,
signedAt,
contractDocumentUrls: [...contract.contractDocumentUrls, dataUrl].slice(0, MAX_CONTRACT_DOCUMENTS),
},
select: CONTRACT_DOCUMENTS_SELECT,
});
res.status(200).json({ contract: updated });
});

View File

@ -0,0 +1,102 @@
import PDFDocument from 'pdfkit';
import { Contract, Room, User, UtilityBillingModel, RentAdjustmentType } from '@prisma/client';
type ContractForPdf = Contract & { room: Room; user: User };
const ADJUSTMENT_LABEL: Record<RentAdjustmentType, string> = {
NONE: 'Keine Mietanpassungsklausel.',
INDEX_MIETE: 'Indexmietklausel: Die Miete kann sich gemäß der Entwicklung des Verbraucherpreisindexes ändern.',
STAFFEL_MIETE: 'Staffelmietklausel: Die Miete steigt zu vorab vereinbarten Zeitpunkten in vereinbarten Schritten.',
};
const UTILITY_LABEL: Record<UtilityBillingModel, string> = {
PAUSCHALE: 'Die Nebenkosten sind als Pauschale in der Warmmiete enthalten und werden nicht separat abgerechnet.',
ABRECHNUNG: 'Die Nebenkosten werden jährlich auf Basis der tatsächlichen Kosten anteilig nach Zimmergröße abgerechnet.',
};
function formatEuro(value: unknown): string {
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(Number(value));
}
function formatDate(date: Date | null | undefined): string {
return date ? date.toLocaleDateString('de-DE') : 'unbefristet';
}
/**
* Erzeugt aus den Vertrags-Baukasten-Feldern (siehe Contract-Model:
* wifiIncluded, furnished, utilityBillingModel, noticePeriodMonths,
* rentAdjustmentType, depositAmount, ...) ein PDF-Dokument als Buffer.
*
* Ausdrücklich eine Vorlage/Dokumentation auf Basis der App-Daten, keine
* rechtsverbindliche oder anwaltlich geprüfte Vertragsvorlage.
*/
export function generateContractPdf(contract: ContractForPdf): Promise<Buffer> {
return new Promise((resolve, reject) => {
const doc = new PDFDocument({ margin: 56 });
const chunks: Buffer[] = [];
doc.on('data', (chunk) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
doc.fontSize(18).text('Mietvertrag über Wohnraum (Vorlage)', { underline: true });
doc.moveDown(0.3);
doc.fontSize(9).fillColor('#666666').text(
'Automatisch erstellte Vorlage auf Basis der Angaben in der WG-Verwaltungs-App. ' +
'Keine Rechtsberatung, keine Gewähr auf rechtliche Vollständigkeit.',
);
doc.fillColor('#000000');
doc.moveDown(1);
doc.fontSize(13).text('§ 1 Mietparteien und Mietobjekt', { underline: true });
doc.fontSize(11).moveDown(0.3);
doc.text(`Vermieter: Familie Lombardo, WG Nackenheim`);
doc.text(`Mieter: ${contract.user.fullName}`);
doc.text(`Mietobjekt: ${contract.room.roomNumber}, ${Number(contract.room.sizeSqm)} qm, WG Nackenheim`);
doc.moveDown(1);
doc.fontSize(13).text('§ 2 Mietzeit', { underline: true });
doc.fontSize(11).moveDown(0.3);
doc.text(`Mietbeginn: ${formatDate(contract.startDate)}`);
doc.text(`Mietende: ${formatDate(contract.endDate)}`);
doc.text(`Kündigungsfrist: ${contract.noticePeriodMonths} Monate zum Monatsende.`);
doc.moveDown(1);
doc.fontSize(13).text('§ 3 Miete und Nebenkosten', { underline: true });
doc.fontSize(11).moveDown(0.3);
doc.text(`Warmmiete gesamt: ${formatEuro(contract.totalWarmRent)} pro Monat, fällig am ${contract.paymentDueDay}. Werktag.`);
doc.text(UTILITY_LABEL[contract.utilityBillingModel]);
doc.text(`Kaution: ${formatEuro(contract.depositAmount)}.`);
doc.moveDown(1);
doc.fontSize(13).text('§ 4 Ausstattung', { underline: true });
doc.fontSize(11).moveDown(0.3);
doc.text(`WLAN: ${contract.wifiIncluded ? 'im Mietpreis inbegriffen.' : 'nicht im Mietpreis enthalten.'}`);
doc.text(`Zimmer: ${contract.furnished ? 'möbliert übergeben.' : 'unmöbliert übergeben.'}`);
doc.moveDown(1);
doc.fontSize(13).text('§ 5 Mietanpassung', { underline: true });
doc.fontSize(11).moveDown(0.3);
doc.text(ADJUSTMENT_LABEL[contract.rentAdjustmentType]);
if (contract.rentAdjustmentClause) doc.text(contract.rentAdjustmentClause);
doc.moveDown(2);
if (contract.signedAt && contract.tenantSignatureUrl) {
doc.fontSize(13).text('§ 6 Unterschrift', { underline: true });
doc.fontSize(11).moveDown(0.3);
doc.text(`Digital unterschrieben von ${contract.user.fullName} am ${formatDate(contract.signedAt)}.`);
doc.moveDown(0.5);
const match = /^data:image\/(png|jpeg);base64,(.+)$/.exec(contract.tenantSignatureUrl);
if (match) {
try {
doc.image(Buffer.from(match[2], 'base64'), { width: 200 });
} catch {
doc.text('(Unterschrift-Bild konnte nicht eingebettet werden)');
}
}
} else {
doc.fontSize(11).text('Noch nicht unterschrieben.');
}
doc.end();
});
}

View File

@ -41,6 +41,11 @@ enum RentAdjustmentType {
STAFFEL_MIETE // Staffelmietklausel STAFFEL_MIETE // Staffelmietklausel
} }
enum UtilityBillingModel {
PAUSCHALE // Nebenkosten sind in der Warmmiete abgegolten
ABRECHNUNG // jährliche Nebenkostenabrechnung (siehe UtilityStatement)
}
enum PaymentStatus { enum PaymentStatus {
PENDING PENDING
PAID PAID
@ -272,6 +277,15 @@ model Contract {
contractDocumentUrls String[] @default([]) @map("contract_document_urls") // unterschriebener Vertrag, Nachträge etc. (mehrere Dateien möglich) contractDocumentUrls String[] @default([]) @map("contract_document_urls") // unterschriebener Vertrag, Nachträge etc. (mehrere Dateien möglich)
// Baukasten für den generierten Mustermietvertrag (siehe contractDocumentGenerator.ts)
wifiIncluded Boolean @default(false) @map("wifi_included")
furnished Boolean @default(false)
utilityBillingModel UtilityBillingModel @default(PAUSCHALE) @map("utility_billing_model")
// Digitale Unterschrift des Mieters zum generierten Vertragsdokument
tenantSignatureUrl String? @map("tenant_signature_url") // Data-URL des gezeichneten Unterschrift-Bilds
signedAt DateTime? @map("signed_at")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")

View File

@ -341,6 +341,23 @@
.trash-calendar-legend { display: flex; gap: 14px; flex-wrap: wrap; margin-bottom: 14px; font-size: 12.5px; color: var(--text-muted); } .trash-calendar-legend { display: flex; gap: 14px; flex-wrap: wrap; margin-bottom: 14px; font-size: 12.5px; color: var(--text-muted); }
.trash-calendar-legend span { display: inline-flex; align-items: center; gap: 6px; } .trash-calendar-legend span { display: inline-flex; align-items: center; gap: 6px; }
.trash-calendar-legend .dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; } .trash-calendar-legend .dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
/* --- Vertragsvorlage & Unterschrift --- */
.contract-template-form {
display: flex;
flex-wrap: wrap;
gap: 10px 18px;
align-items: flex-end;
background: var(--bg);
border: 1px dashed var(--border);
border-radius: 10px;
padding: 12px 14px;
}
.contract-template-form .field { margin-bottom: 0; }
.contract-template-form .field-checkbox { display: flex; align-items: center; gap: 6px; font-size: 13px; padding-bottom: 10px; }
.contract-template-form .field-checkbox input { width: auto; }
.signature-pad-wrap { display: flex; flex-direction: column; gap: 8px; align-items: flex-start; }
.signature-pad-canvas { border: 1px solid var(--border); border-radius: 8px; background: #fff; touch-action: none; cursor: crosshair; }
#trashCalendarEl { font-size: 13px; } #trashCalendarEl { font-size: 13px; }
#trashCalendarEl .fc { font-family: inherit; } #trashCalendarEl .fc { font-family: inherit; }
#trashCalendarEl .fc-toolbar-title { font-size: 16px; font-weight: 700; color: var(--text); } #trashCalendarEl .fc-toolbar-title { font-size: 16px; font-weight: 700; color: var(--text); }
@ -1001,6 +1018,8 @@
const STORAGE_LOCATION_LABEL = { FRIDGE: 'Kühlschrank', FREEZER: 'Gefrierfach', KITCHEN_CABINET_1: 'Küchenschrank 1', KITCHEN_CABINET_2: 'Küchenschrank 2', KITCHEN_CABINET_3: 'Küchenschrank 3', PANTRY: 'Vorratsschrank' }; const STORAGE_LOCATION_LABEL = { FRIDGE: 'Kühlschrank', FREEZER: 'Gefrierfach', KITCHEN_CABINET_1: 'Küchenschrank 1', KITCHEN_CABINET_2: 'Küchenschrank 2', KITCHEN_CABINET_3: 'Küchenschrank 3', PANTRY: 'Vorratsschrank' };
const TRASH_TYPE_LABEL = { RESTMUELL: 'Restmüll', BIOMUELL: 'Biomüll', GELBER_SACK: 'Gelber Sack', PAPIER: 'Papier', GLAS: 'Glas' }; const TRASH_TYPE_LABEL = { RESTMUELL: 'Restmüll', BIOMUELL: 'Biomüll', GELBER_SACK: 'Gelber Sack', PAPIER: 'Papier', GLAS: 'Glas' };
const TRASH_TYPE_COLOR = { RESTMUELL: '#4A4A4A', BIOMUELL: '#8B5E34', GELBER_SACK: '#E0B400', PAPIER: '#2F80ED', GLAS: '#27AE60' }; const TRASH_TYPE_COLOR = { RESTMUELL: '#4A4A4A', BIOMUELL: '#8B5E34', GELBER_SACK: '#E0B400', PAPIER: '#2F80ED', GLAS: '#27AE60' };
const UTILITY_MODEL_LABEL = { PAUSCHALE: 'Pauschale (in Warmmiete enthalten)', ABRECHNUNG: 'Jährliche Abrechnung' };
const RENT_ADJUSTMENT_LABEL = { NONE: 'Keine', INDEX_MIETE: 'Indexmiete', STAFFEL_MIETE: 'Staffelmiete' };
const DOCUMENT_CATEGORY_LABEL = { HAUSORDNUNG: 'Hausordnung', WLAN: 'WLAN', VERTRAG: 'Vertrag', TUTORIAL: 'Tutorial', SONSTIGES: 'Sonstiges' }; const DOCUMENT_CATEGORY_LABEL = { HAUSORDNUNG: 'Hausordnung', WLAN: 'WLAN', VERTRAG: 'Vertrag', TUTORIAL: 'Tutorial', SONSTIGES: 'Sonstiges' };
const CRAFTSMAN_TRADE_LABEL = { SANITAER: 'Sanitär', ELEKTRIK: 'Elektrik', HEIZUNG: 'Heizung', SCHREINEREI: 'Schreinerei', MALER: 'Maler', SCHLUESSELDIENST: 'Schlüsseldienst', SONSTIGES: 'Sonstiges' }; const CRAFTSMAN_TRADE_LABEL = { SANITAER: 'Sanitär', ELEKTRIK: 'Elektrik', HEIZUNG: 'Heizung', SCHREINEREI: 'Schreinerei', MALER: 'Maler', SCHLUESSELDIENST: 'Schlüsseldienst', SONSTIGES: 'Sonstiges' };
const SETTLEMENT_STATUS_LABEL = { OPEN: 'Offen', SETTLED: 'Beglichen' }; const SETTLEMENT_STATUS_LABEL = { OPEN: 'Offen', SETTLED: 'Beglichen' };
@ -2751,6 +2770,18 @@
top.appendChild(el('div', 'meta-text', `Vertrag seit ${new Date(c.startDate).toLocaleDateString('de-DE')}`)); top.appendChild(el('div', 'meta-text', `Vertrag seit ${new Date(c.startDate).toLocaleDateString('de-DE')}`));
card.appendChild(top); card.appendChild(top);
const isOwnContract = c.user.id === currentUser.id;
if (c.signedAt) {
const signedNote = el('div', 'meta-text', `✓ Digital unterschrieben am ${new Date(c.signedAt).toLocaleDateString('de-DE')}`);
signedNote.style.color = 'var(--green)';
signedNote.style.fontWeight = '600';
card.appendChild(signedNote);
} else if (!isLandlord && isOwnContract) {
card.appendChild(buildSignatureSection(c.id));
} else {
card.appendChild(el('div', 'meta-text', 'Noch nicht unterschrieben.'));
}
const docWrap = el('div'); const docWrap = el('div');
docWrap.style.display = 'flex'; docWrap.style.display = 'flex';
docWrap.style.flexWrap = 'wrap'; docWrap.style.flexWrap = 'wrap';
@ -2781,6 +2812,10 @@
} }
card.appendChild(docWrap); card.appendChild(docWrap);
if (isLandlord) {
card.appendChild(buildContractTemplateForm(c));
}
if (isLandlord) { if (isLandlord) {
const uploadRow = el('div'); const uploadRow = el('div');
uploadRow.style.display = 'flex'; uploadRow.style.display = 'flex';
@ -2811,6 +2846,217 @@
}); });
} }
function buildContractTemplateForm(c) {
const form = el('div', 'contract-template-form');
const wifiField = el('label', 'field-checkbox');
const wifiCheckbox = document.createElement('input');
wifiCheckbox.type = 'checkbox';
wifiCheckbox.checked = !!c.wifiIncluded;
wifiField.appendChild(wifiCheckbox);
wifiField.appendChild(document.createTextNode('WLAN inklusive'));
form.appendChild(wifiField);
const furnishedField = el('label', 'field-checkbox');
const furnishedCheckbox = document.createElement('input');
furnishedCheckbox.type = 'checkbox';
furnishedCheckbox.checked = !!c.furnished;
furnishedField.appendChild(furnishedCheckbox);
furnishedField.appendChild(document.createTextNode('Möbliert'));
form.appendChild(furnishedField);
const utilityField = el('div', 'field');
utilityField.style.minWidth = '190px';
utilityField.appendChild(el('label', null, 'Nebenkosten'));
const utilitySelect = document.createElement('select');
Object.keys(UTILITY_MODEL_LABEL).forEach(key => {
const opt = document.createElement('option');
opt.value = key;
opt.textContent = UTILITY_MODEL_LABEL[key];
if (key === c.utilityBillingModel) opt.selected = true;
utilitySelect.appendChild(opt);
});
utilityField.appendChild(utilitySelect);
form.appendChild(utilityField);
const adjustmentField = el('div', 'field');
adjustmentField.style.minWidth = '150px';
adjustmentField.appendChild(el('label', null, 'Mietanpassung'));
const adjustmentSelect = document.createElement('select');
Object.keys(RENT_ADJUSTMENT_LABEL).forEach(key => {
const opt = document.createElement('option');
opt.value = key;
opt.textContent = RENT_ADJUSTMENT_LABEL[key];
if (key === c.rentAdjustmentType) opt.selected = true;
adjustmentSelect.appendChild(opt);
});
adjustmentField.appendChild(adjustmentSelect);
form.appendChild(adjustmentField);
const noticeField = el('div', 'field');
noticeField.style.width = '110px';
noticeField.appendChild(el('label', null, 'Kündigungsfrist (Monate)'));
const noticeInput = document.createElement('input');
noticeInput.type = 'number';
noticeInput.min = '0';
noticeInput.value = c.noticePeriodMonths;
noticeField.appendChild(noticeInput);
form.appendChild(noticeField);
const depositField = el('div', 'field');
depositField.style.width = '130px';
depositField.appendChild(el('label', null, 'Kaution (€)'));
const depositInput = document.createElement('input');
depositInput.type = 'number';
depositInput.min = '0';
depositInput.step = '0.01';
depositInput.value = c.depositAmount;
depositField.appendChild(depositInput);
form.appendChild(depositField);
const generateBtn = document.createElement('button');
generateBtn.type = 'button';
generateBtn.className = 'btn-primary';
generateBtn.style.width = 'auto';
generateBtn.textContent = 'Vertrag erstellen (PDF)';
generateBtn.onclick = () => generateContractDocument(c.id, {
wifiIncluded: wifiCheckbox.checked,
furnished: furnishedCheckbox.checked,
utilityBillingModel: utilitySelect.value,
rentAdjustmentType: adjustmentSelect.value,
noticePeriodMonths: noticeInput.value,
depositAmount: depositInput.value,
}, generateBtn);
form.appendChild(generateBtn);
return form;
}
async function generateContractDocument(contractId, options, btn) {
btn.disabled = true;
btn.textContent = 'Erstellt…';
try {
await apiFetch(`/contracts/${contractId}/generate-document`, { method: 'POST', body: JSON.stringify(options) });
loadContractDocuments();
} catch (err) {
alert(err.message);
} finally {
btn.disabled = false;
btn.textContent = 'Vertrag erstellen (PDF)';
}
}
function buildSignatureSection(contractId) {
const wrap = el('div', 'signature-pad-wrap');
const toggleBtn = document.createElement('button');
toggleBtn.type = 'button';
toggleBtn.className = 'btn-secondary';
toggleBtn.textContent = 'Jetzt unterschreiben';
const padArea = el('div', 'signature-pad-wrap');
padArea.style.display = 'none';
const canvas = document.createElement('canvas');
canvas.className = 'signature-pad-canvas';
canvas.width = 320;
canvas.height = 120;
padArea.appendChild(canvas);
const actionRow = el('div');
actionRow.style.cssText = 'display:flex; gap:8px;';
const clearBtn = document.createElement('button');
clearBtn.type = 'button';
clearBtn.className = 'btn-secondary';
clearBtn.textContent = 'Löschen';
const saveBtn = document.createElement('button');
saveBtn.type = 'button';
saveBtn.className = 'btn-primary';
saveBtn.style.width = 'auto';
saveBtn.textContent = 'Unterschrift speichern';
actionRow.appendChild(clearBtn);
actionRow.appendChild(saveBtn);
padArea.appendChild(actionRow);
const signaturePad = setupSignatureCanvas(canvas);
clearBtn.onclick = () => signaturePad.clear();
saveBtn.onclick = () => submitSignature(contractId, signaturePad, saveBtn);
toggleBtn.onclick = () => {
padArea.style.display = padArea.style.display === 'none' ? 'flex' : 'none';
padArea.style.flexDirection = 'column';
};
wrap.appendChild(toggleBtn);
wrap.appendChild(padArea);
return wrap;
}
function setupSignatureCanvas(canvas) {
const ctx = canvas.getContext('2d');
ctx.lineWidth = 2;
ctx.lineCap = 'round';
ctx.strokeStyle = '#1F2933';
let drawing = false;
let hasStroke = false;
function pos(e) {
const rect = canvas.getBoundingClientRect();
const point = e.touches ? e.touches[0] : e;
return { x: point.clientX - rect.left, y: point.clientY - rect.top };
}
function start(e) {
e.preventDefault();
drawing = true;
const p = pos(e);
ctx.beginPath();
ctx.moveTo(p.x, p.y);
}
function move(e) {
if (!drawing) return;
e.preventDefault();
const p = pos(e);
ctx.lineTo(p.x, p.y);
ctx.stroke();
hasStroke = true;
}
function end() { drawing = false; }
canvas.addEventListener('mousedown', start);
canvas.addEventListener('mousemove', move);
window.addEventListener('mouseup', end);
canvas.addEventListener('touchstart', start);
canvas.addEventListener('touchmove', move);
canvas.addEventListener('touchend', end);
return {
clear() { ctx.clearRect(0, 0, canvas.width, canvas.height); hasStroke = false; },
isEmpty() { return !hasStroke; },
toDataUrl() { return canvas.toDataURL('image/png'); },
};
}
async function submitSignature(contractId, signaturePad, saveBtn) {
if (signaturePad.isEmpty()) {
alert('Bitte erst unterschreiben.');
return;
}
saveBtn.disabled = true;
saveBtn.textContent = 'Speichert…';
try {
await apiFetch(`/contracts/${contractId}/sign`, {
method: 'POST',
body: JSON.stringify({ signatureDataUrl: signaturePad.toDataUrl() }),
});
loadContractDocuments();
} catch (err) {
alert(err.message);
} finally {
saveBtn.disabled = false;
saveBtn.textContent = 'Unterschrift speichern';
}
}
async function uploadContractDocuments(contractId, fileInput, uploadBtn) { async function uploadContractDocuments(contractId, fileInput, uploadBtn) {
const files = (contractUploadFiles[contractId] || []).slice(0, MAX_CONTRACT_DOCUMENTS); const files = (contractUploadFiles[contractId] || []).slice(0, MAX_CONTRACT_DOCUMENTS);
if (!files.length) return; if (!files.length) return;