From bba4fc318b5a6125cfcaabc1e9b345438f5bbcd2 Mon Sep 17 00:00:00 2001 From: bernd Date: Thu, 13 Aug 2026 10:23:27 +0000 Subject: [PATCH] Add click-based contract generator + digital tenant signature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/src/routes/contracts.ts | 111 +++++++- .../src/services/contractDocumentGenerator.ts | 102 ++++++++ prisma/schema.prisma | 14 + web-dashboard/index.html | 246 ++++++++++++++++++ 4 files changed, 471 insertions(+), 2 deletions(-) create mode 100644 backend/src/services/contractDocumentGenerator.ts diff --git a/backend/src/routes/contracts.ts b/backend/src/routes/contracts.ts index 86b89f6..28ee612 100644 --- a/backend/src/routes/contracts.ts +++ b/backend/src/routes/contracts.ts @@ -1,6 +1,7 @@ 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 { generateContractPdf } from '../services/contractDocumentGenerator'; const prisma = new PrismaClient(); @@ -88,10 +89,23 @@ const CONTRACT_DOCUMENTS_SELECT = { endDate: true, isActive: 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 } }, - 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[] { if (!Array.isArray(input)) return []; 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 }); }, ); + +// ---------------------------------------------------------------------------- +// 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 }); +}); diff --git a/backend/src/services/contractDocumentGenerator.ts b/backend/src/services/contractDocumentGenerator.ts new file mode 100644 index 0000000..6a43e52 --- /dev/null +++ b/backend/src/services/contractDocumentGenerator.ts @@ -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 = { + 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 = { + 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 { + 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(); + }); +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 91c9b66..8771621 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -41,6 +41,11 @@ enum RentAdjustmentType { STAFFEL_MIETE // Staffelmietklausel } +enum UtilityBillingModel { + PAUSCHALE // Nebenkosten sind in der Warmmiete abgegolten + ABRECHNUNG // jährliche Nebenkostenabrechnung (siehe UtilityStatement) +} + enum PaymentStatus { PENDING PAID @@ -272,6 +277,15 @@ model Contract { 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") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/web-dashboard/index.html b/web-dashboard/index.html index c819efd..f7973f0 100644 --- a/web-dashboard/index.html +++ b/web-dashboard/index.html @@ -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 span { display: inline-flex; align-items: center; gap: 6px; } .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 .fc { font-family: inherit; } #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 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 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 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' }; @@ -2751,6 +2770,18 @@ top.appendChild(el('div', 'meta-text', `Vertrag seit ${new Date(c.startDate).toLocaleDateString('de-DE')}`)); 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'); docWrap.style.display = 'flex'; docWrap.style.flexWrap = 'wrap'; @@ -2781,6 +2812,10 @@ } card.appendChild(docWrap); + if (isLandlord) { + card.appendChild(buildContractTemplateForm(c)); + } + if (isLandlord) { const uploadRow = el('div'); 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) { const files = (contractUploadFiles[contractId] || []).slice(0, MAX_CONTRACT_DOCUMENTS); if (!files.length) return;