Add multi-file contract document uploads (Verträge & Abrechnung)
Replaces the unused, never-wired-up single contractPdfUrl field with contractDocumentUrls (String[]) so landlords can attach several files per contract (signed lease, addenda, ...) instead of just one. New endpoints: GET /contracts/documents (tenant sees own contract only, landlord sees all — same visibility rule as the rest of the app), POST/DELETE /contracts/:id/documents (landlord/admin only). Frontend reuses the existing FileReader-to-data-URL upload pattern already used for inventory photos, so files are stored inline like everywhere else in this app rather than introducing a new storage mechanism.
This commit is contained in:
parent
aa59ab452a
commit
a8dc999180
@ -1,6 +1,6 @@
|
||||
import { Router, Response } from 'express';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { AuthedRequest, requireAuth } from '../middleware/auth';
|
||||
import { AuthedRequest, requireAuth, requireRole } from '../middleware/auth';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@ -75,3 +75,84 @@ contractsRouter.get('/contracts/notice-deadlines', requireAuth, async (req: Auth
|
||||
|
||||
res.status(200).json({ deadlines: items });
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// VERTRAGS-DOKUMENTE (unterschriebener Vertrag, Nachträge etc.)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
const MAX_CONTRACT_DOCUMENTS = 10;
|
||||
|
||||
const CONTRACT_DOCUMENTS_SELECT = {
|
||||
id: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
isActive: true,
|
||||
contractDocumentUrls: true,
|
||||
user: { select: { id: true, fullName: true } },
|
||||
room: { select: { id: true, roomNumber: true } },
|
||||
};
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
// Mieter sehen nur den eigenen Vertrag, Vermieter/Admin alle — dieselbe
|
||||
// Sichtbarkeitsregel wie bei allen anderen privaten Vertragsdaten.
|
||||
contractsRouter.get('/contracts/documents', requireAuth, async (req: AuthedRequest, res: Response) => {
|
||||
const isLandlord = req.user!.role === 'LANDLORD' || req.user!.role === 'ADMIN';
|
||||
const where: Record<string, unknown> = {};
|
||||
if (!isLandlord) where.userId = req.user!.id;
|
||||
|
||||
const contracts = await prisma.contract.findMany({
|
||||
where,
|
||||
select: CONTRACT_DOCUMENTS_SELECT,
|
||||
orderBy: { startDate: 'desc' },
|
||||
});
|
||||
res.status(200).json({ contracts });
|
||||
});
|
||||
|
||||
// Vermieter/Admin: Dateien zu einem Vertrag hinzufügen (mehrere auf einmal,
|
||||
// werden an bereits vorhandene angehängt statt sie zu ersetzen).
|
||||
contractsRouter.post(
|
||||
'/contracts/:id/documents',
|
||||
requireAuth,
|
||||
requireRole('LANDLORD', 'ADMIN'),
|
||||
async (req: AuthedRequest, res: Response) => {
|
||||
const contract = await prisma.contract.findUnique({ where: { id: req.params.id } });
|
||||
if (!contract) return res.status(404).json({ error: 'Vertrag nicht gefunden' });
|
||||
|
||||
const newUrls = sanitizeDocumentUrls(req.body?.urls);
|
||||
if (!newUrls.length) return res.status(400).json({ error: 'urls (Array) ist erforderlich' });
|
||||
|
||||
const merged = [...contract.contractDocumentUrls, ...newUrls].slice(0, MAX_CONTRACT_DOCUMENTS);
|
||||
const updated = await prisma.contract.update({
|
||||
where: { id: contract.id },
|
||||
data: { contractDocumentUrls: merged },
|
||||
select: CONTRACT_DOCUMENTS_SELECT,
|
||||
});
|
||||
res.status(200).json({ contract: updated });
|
||||
},
|
||||
);
|
||||
|
||||
// Vermieter/Admin: einzelne Datei aus einem Vertrag entfernen.
|
||||
contractsRouter.delete(
|
||||
'/contracts/:id/documents',
|
||||
requireAuth,
|
||||
requireRole('LANDLORD', 'ADMIN'),
|
||||
async (req: AuthedRequest, res: Response) => {
|
||||
const contract = await prisma.contract.findUnique({ where: { id: req.params.id } });
|
||||
if (!contract) return res.status(404).json({ error: 'Vertrag nicht gefunden' });
|
||||
|
||||
const { url } = req.body || {};
|
||||
if (!url || typeof url !== 'string') return res.status(400).json({ error: 'url ist erforderlich' });
|
||||
|
||||
const remaining = contract.contractDocumentUrls.filter((u) => u !== url);
|
||||
const updated = await prisma.contract.update({
|
||||
where: { id: contract.id },
|
||||
data: { contractDocumentUrls: remaining },
|
||||
select: CONTRACT_DOCUMENTS_SELECT,
|
||||
});
|
||||
res.status(200).json({ contract: updated });
|
||||
},
|
||||
);
|
||||
|
||||
@ -270,7 +270,7 @@ model Contract {
|
||||
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
|
||||
contractPdfUrl String? @map("contract_pdf_url")
|
||||
contractDocumentUrls String[] @default([]) @map("contract_document_urls") // unterschriebener Vertrag, Nachträge etc. (mehrere Dateien möglich)
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@ -933,6 +933,12 @@
|
||||
|
||||
<div class="page" id="page-contracts">
|
||||
|
||||
<section id="contractDocumentsSection">
|
||||
<h2>Vertragsdokumente</h2>
|
||||
<p class="hint-text">Unterschriebener Mietvertrag, Nachträge etc. — je Vertrag können mehrere Dateien hinterlegt werden.</p>
|
||||
<div class="ticket-list" id="contractDocumentsList"></div>
|
||||
</section>
|
||||
|
||||
<section id="noticeDeadlinesSection">
|
||||
<h2>Kündigungsfristen</h2>
|
||||
<p class="hint-text">Zeigt für befristete Mietverträge, bis wann spätestens gekündigt werden muss.</p>
|
||||
@ -1231,6 +1237,7 @@
|
||||
loadUtilityStatements();
|
||||
loadCraftsmen();
|
||||
loadRatings();
|
||||
loadContractDocuments();
|
||||
|
||||
setInterval(loadData, REFRESH_INTERVAL_MS);
|
||||
setInterval(loadCleaningTasks, REFRESH_INTERVAL_MS);
|
||||
@ -1245,6 +1252,7 @@
|
||||
setInterval(loadUtilityStatements, REFRESH_INTERVAL_MS);
|
||||
setInterval(loadCraftsmen, REFRESH_INTERVAL_MS);
|
||||
setInterval(loadRatings, REFRESH_INTERVAL_MS);
|
||||
setInterval(loadContractDocuments, REFRESH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
@ -2700,6 +2708,140 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// VERTRAGSDOKUMENTE
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
const MAX_CONTRACT_DOCUMENTS = 10;
|
||||
const contractUploadFiles = {}; // contractId -> ausgewählte, noch nicht hochgeladene Files
|
||||
|
||||
async function loadContractDocuments() {
|
||||
try {
|
||||
const data = await apiFetch('/contracts/documents');
|
||||
renderContractDocuments(data.contracts);
|
||||
} catch (err) {
|
||||
console.error('[contracts] Vertragsdokumente laden fehlgeschlagen:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function documentFileName(dataUrl, index) {
|
||||
const match = /^data:([^;]+);/.exec(dataUrl);
|
||||
const mime = match ? match[1] : '';
|
||||
const ext = mime.includes('pdf') ? 'pdf' : (mime.split('/')[1] || 'datei');
|
||||
return `Dokument ${index + 1}.${ext}`;
|
||||
}
|
||||
|
||||
function renderContractDocuments(contracts) {
|
||||
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
|
||||
const list = document.getElementById('contractDocumentsList');
|
||||
list.innerHTML = '';
|
||||
if (!contracts.length) {
|
||||
list.appendChild(el('div', 'empty-state', 'Kein aktiver Mietvertrag gefunden.'));
|
||||
return;
|
||||
}
|
||||
|
||||
contracts.forEach(c => {
|
||||
const card = el('div', 'ticket-row');
|
||||
card.style.flexDirection = 'column';
|
||||
card.style.alignItems = 'stretch';
|
||||
card.style.gap = '10px';
|
||||
|
||||
const top = el('div');
|
||||
top.appendChild(el('div', 'title', `${c.user.fullName} · ${c.room.roomNumber}`));
|
||||
top.appendChild(el('div', 'meta-text', `Vertrag seit ${new Date(c.startDate).toLocaleDateString('de-DE')}`));
|
||||
card.appendChild(top);
|
||||
|
||||
const docWrap = el('div');
|
||||
docWrap.style.display = 'flex';
|
||||
docWrap.style.flexWrap = 'wrap';
|
||||
docWrap.style.gap = '8px';
|
||||
if (!c.contractDocumentUrls.length) {
|
||||
docWrap.appendChild(el('span', 'meta-text', 'Noch keine Dateien hochgeladen.'));
|
||||
} else {
|
||||
c.contractDocumentUrls.forEach((url, idx) => {
|
||||
const chip = el('span', 'invite-status-chip');
|
||||
chip.style.cssText = 'background:var(--bg); color:var(--text); display:inline-flex; align-items:center; gap:6px; text-transform:none;';
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = documentFileName(url, idx);
|
||||
link.textContent = documentFileName(url, idx);
|
||||
link.style.color = 'var(--text)';
|
||||
chip.appendChild(link);
|
||||
if (isLandlord) {
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.type = 'button';
|
||||
removeBtn.textContent = '×';
|
||||
removeBtn.title = 'Entfernen';
|
||||
removeBtn.style.cssText = 'border:none; background:none; cursor:pointer; font-size:13px; color:var(--text-muted); padding:0;';
|
||||
removeBtn.onclick = () => deleteContractDocument(c.id, url);
|
||||
chip.appendChild(removeBtn);
|
||||
}
|
||||
docWrap.appendChild(chip);
|
||||
});
|
||||
}
|
||||
card.appendChild(docWrap);
|
||||
|
||||
if (isLandlord) {
|
||||
const uploadRow = el('div');
|
||||
uploadRow.style.display = 'flex';
|
||||
uploadRow.style.gap = '8px';
|
||||
uploadRow.style.alignItems = 'center';
|
||||
uploadRow.style.flexWrap = 'wrap';
|
||||
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.multiple = true;
|
||||
fileInput.accept = 'application/pdf,image/*';
|
||||
fileInput.onchange = () => {
|
||||
contractUploadFiles[c.id] = Array.from(fileInput.files || []);
|
||||
};
|
||||
uploadRow.appendChild(fileInput);
|
||||
|
||||
const uploadBtn = document.createElement('button');
|
||||
uploadBtn.type = 'button';
|
||||
uploadBtn.className = 'btn-secondary';
|
||||
uploadBtn.textContent = 'Hochladen';
|
||||
uploadBtn.onclick = () => uploadContractDocuments(c.id, fileInput, uploadBtn);
|
||||
uploadRow.appendChild(uploadBtn);
|
||||
|
||||
card.appendChild(uploadRow);
|
||||
}
|
||||
|
||||
list.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadContractDocuments(contractId, fileInput, uploadBtn) {
|
||||
const files = (contractUploadFiles[contractId] || []).slice(0, MAX_CONTRACT_DOCUMENTS);
|
||||
if (!files.length) return;
|
||||
uploadBtn.disabled = true;
|
||||
uploadBtn.textContent = 'Lädt hoch…';
|
||||
try {
|
||||
const urls = [];
|
||||
for (const file of files) {
|
||||
urls.push(await readFileAsDataUrl(file));
|
||||
}
|
||||
await apiFetch(`/contracts/${contractId}/documents`, { method: 'POST', body: JSON.stringify({ urls }) });
|
||||
delete contractUploadFiles[contractId];
|
||||
fileInput.value = '';
|
||||
loadContractDocuments();
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
} finally {
|
||||
uploadBtn.disabled = false;
|
||||
uploadBtn.textContent = 'Hochladen';
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteContractDocument(contractId, url) {
|
||||
try {
|
||||
await apiFetch(`/contracts/${contractId}/documents`, { method: 'DELETE', body: JSON.stringify({ url }) });
|
||||
loadContractDocuments();
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// KÜNDIGUNGSFRISTEN
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
Loading…
Reference in New Issue
Block a user