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 { Router, Response } from 'express';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { AuthedRequest, requireAuth } from '../middleware/auth';
|
import { AuthedRequest, requireAuth, requireRole } from '../middleware/auth';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@ -75,3 +75,84 @@ contractsRouter.get('/contracts/notice-deadlines', requireAuth, async (req: Auth
|
|||||||
|
|
||||||
res.status(200).json({ deadlines: items });
|
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")
|
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")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|||||||
@ -933,6 +933,12 @@
|
|||||||
|
|
||||||
<div class="page" id="page-contracts">
|
<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">
|
<section id="noticeDeadlinesSection">
|
||||||
<h2>Kündigungsfristen</h2>
|
<h2>Kündigungsfristen</h2>
|
||||||
<p class="hint-text">Zeigt für befristete Mietverträge, bis wann spätestens gekündigt werden muss.</p>
|
<p class="hint-text">Zeigt für befristete Mietverträge, bis wann spätestens gekündigt werden muss.</p>
|
||||||
@ -1231,6 +1237,7 @@
|
|||||||
loadUtilityStatements();
|
loadUtilityStatements();
|
||||||
loadCraftsmen();
|
loadCraftsmen();
|
||||||
loadRatings();
|
loadRatings();
|
||||||
|
loadContractDocuments();
|
||||||
|
|
||||||
setInterval(loadData, REFRESH_INTERVAL_MS);
|
setInterval(loadData, REFRESH_INTERVAL_MS);
|
||||||
setInterval(loadCleaningTasks, REFRESH_INTERVAL_MS);
|
setInterval(loadCleaningTasks, REFRESH_INTERVAL_MS);
|
||||||
@ -1245,6 +1252,7 @@
|
|||||||
setInterval(loadUtilityStatements, REFRESH_INTERVAL_MS);
|
setInterval(loadUtilityStatements, REFRESH_INTERVAL_MS);
|
||||||
setInterval(loadCraftsmen, REFRESH_INTERVAL_MS);
|
setInterval(loadCraftsmen, REFRESH_INTERVAL_MS);
|
||||||
setInterval(loadRatings, 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
|
// KÜNDIGUNGSFRISTEN
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user