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:
parent
0179517979
commit
bba4fc318b
@ -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 });
|
||||
});
|
||||
|
||||
102
backend/src/services/contractDocumentGenerator.ts
Normal file
102
backend/src/services/contractDocumentGenerator.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user