Nachrichten-Übersicht für Mieter + Vermieter-Ankündigungen hinzufügen

Neue Ankündigungen (z. B. Schornsteinfeger, Handwerkertermin), nur vom
Vermieter anlegbar, für alle sichtbar. Zusätzlich eine gebündelte
'Für dich diese Woche'-Übersicht für Mieter mit fälligem Putzplan,
fälliger Miete, anstehendem Mülltermin und Ankündigungen der nächsten
7 Tage.
This commit is contained in:
Giuseppe Lombardo 2026-08-13 13:24:21 +00:00
parent ca1c1fdfb7
commit b15ba77336
4 changed files with 285 additions and 0 deletions

View File

@ -11,6 +11,7 @@ import { expensesRouter } from './routes/expenses';
import { inventoryRouter } from './routes/inventory';
import { handoverProtocolsRouter } from './routes/handoverProtocols';
import { accessCodesRouter } from './routes/accessCodes';
import { announcementsRouter } from './routes/announcements';
import { storageRouter } from './routes/storage';
import { documentsRouter } from './routes/documents';
import { contractsRouter } from './routes/contracts';
@ -50,6 +51,7 @@ export function createApp() {
app.use('/v1', inventoryRouter);
app.use('/v1', handoverProtocolsRouter);
app.use('/v1', accessCodesRouter);
app.use('/v1', announcementsRouter);
app.use('/v1', storageRouter);
app.use('/v1', documentsRouter);
app.use('/v1', contractsRouter);

View File

@ -0,0 +1,71 @@
import { Router, Response } from 'express';
import { PrismaClient } from '@prisma/client';
import { AuthedRequest, requireAuth, requireRole } from '../middleware/auth';
const prisma = new PrismaClient();
/**
* Ankündigungen des Vermieters (Schornsteinfeger, Handwerkertermin, etc.),
* für alle eingeloggten Nutzer sichtbar. Nur Vermieter/Admin legen sie an
* oder löschen sie.
*/
export const announcementsRouter = Router();
const ANNOUNCEMENT_SELECT = {
id: true,
title: true,
message: true,
eventDate: true,
createdAt: true,
createdBy: { select: { id: true, fullName: true } },
};
announcementsRouter.post(
'/announcements',
requireAuth,
requireRole('LANDLORD', 'ADMIN'),
async (req: AuthedRequest, res: Response) => {
const { title, message, eventDate } = req.body || {};
if (!title || !String(title).trim()) {
return res.status(400).json({ error: 'Titel ist erforderlich' });
}
if (eventDate && Number.isNaN(Date.parse(eventDate))) {
return res.status(400).json({ error: 'Ungültiges Datum' });
}
const announcement = await prisma.announcement.create({
data: {
title: String(title).trim().slice(0, 120),
message: typeof message === 'string' && message.trim() ? message.trim().slice(0, 500) : null,
eventDate: eventDate ? new Date(eventDate) : null,
createdById: req.user!.id,
},
select: ANNOUNCEMENT_SELECT,
});
res.status(201).json({ announcement });
},
);
// GET /announcements — für alle sichtbar, neueste/nächste Termine zuerst.
announcementsRouter.get('/announcements', requireAuth, async (_req: AuthedRequest, res: Response) => {
const announcements = await prisma.announcement.findMany({
orderBy: [{ eventDate: 'asc' }, { createdAt: 'desc' }],
select: ANNOUNCEMENT_SELECT,
});
res.status(200).json({ announcements });
});
announcementsRouter.delete(
'/announcements/:id',
requireAuth,
requireRole('LANDLORD', 'ADMIN'),
async (req: AuthedRequest, res: Response) => {
try {
await prisma.announcement.delete({ where: { id: req.params.id } });
res.status(204).send();
} catch {
res.status(404).json({ error: 'Ankündigung nicht gefunden' });
}
},
);

View File

@ -188,6 +188,7 @@ model User {
handoverProtocols HandoverProtocol[]
accessCodeAssigned AccessCode[] @relation("AccessCodeUser")
accessCodesGiven AccessCode[] @relation("AccessCodeAssignedBy")
announcementsMade Announcement[]
notifications Notification[]
invitationsSent Invitation[] @relation("InvitationsSent")
storageSlots StorageSlot[]
@ -411,6 +412,21 @@ model CleaningAbsence {
@@map("cleaning_absences")
}
// Ankündigungen des Vermieters (z. B. "Schornsteinfeger kommt am...",
// "Handwerker kommt am..."), für alle Mieter im Dashboard sichtbar.
model Announcement {
id String @id @default(uuid())
title String
message String? @db.Text
eventDate DateTime? @map("event_date")
createdById String @map("created_by_id")
createdBy User @relation(fields: [createdById], references: [id])
createdAt DateTime @default(now()) @map("created_at")
@@map("announcements")
}
// Frei erweiterbare Liste der Putzplan-Bereiche. Ersetzt das frühere feste
// Enum, damit der Vermieter im Dashboard eigene Bereiche anlegen kann, ohne
// dass ein Code-Deploy nötig ist.

View File

@ -614,6 +614,34 @@
<div class="page active" id="page-overview">
<div class="stat-row" id="statRow"></div>
<section id="notificationsSection" style="display:none">
<h2>Für dich diese Woche</h2>
<div class="ticket-list" id="notificationsList"></div>
</section>
<section id="announcementSection">
<h2>Ankündigungen</h2>
<div id="announcementFormCard" class="invite-card" style="margin-bottom:16px; display:none">
<div id="announcementFormError" style="display:none" class="form-error"></div>
<form id="announcementForm" class="invite-form-row" style="flex-wrap:wrap;">
<div class="field" style="flex:1 1 200px;">
<label for="announcementTitle">Titel</label>
<input type="text" id="announcementTitle" placeholder="z. B. Schornsteinfeger" required maxlength="120" />
</div>
<div class="field" style="flex:1 1 160px;">
<label for="announcementDate">Termin (optional)</label>
<input type="date" id="announcementDate" />
</div>
<div class="field" style="flex:2 1 240px;">
<label for="announcementMessage">Nachricht (optional)</label>
<input type="text" id="announcementMessage" maxlength="500" placeholder="z. B. bitte Zugang zum Keller freihalten" />
</div>
<button type="submit" class="btn-primary" style="width:auto" id="announcementSubmitBtn">Ankündigen</button>
</form>
</div>
<div class="ticket-list" id="announcementList"></div>
</section>
<section id="rentSection">
<h2 id="rentSectionTitle">Miet-Ampel je Zimmer</h2>
<div class="room-grid" id="roomGrid"></div>
@ -1413,6 +1441,7 @@
if (isLandlord) initCraftsmanForm();
initRatingForm();
initChangePasswordForm();
initAnnouncementForm();
loadData();
loadCleaningTasks();
@ -1429,6 +1458,7 @@
loadRatings();
loadContractDocuments();
loadContractArchive();
loadAnnouncements();
setInterval(loadData, REFRESH_INTERVAL_MS);
setInterval(loadCleaningTasks, REFRESH_INTERVAL_MS);
@ -1445,6 +1475,7 @@
setInterval(loadRatings, REFRESH_INTERVAL_MS);
setInterval(loadContractDocuments, REFRESH_INTERVAL_MS);
setInterval(loadContractArchive, REFRESH_INTERVAL_MS);
setInterval(loadAnnouncements, REFRESH_INTERVAL_MS);
}
// ---------------------------------------------------------------------
@ -1452,6 +1483,7 @@
// ---------------------------------------------------------------------
let lastRooms = [];
let lastCockpitData = null;
function populateRoomSelect() {
const select = document.getElementById('inviteRoom');
@ -1558,10 +1590,12 @@
const data = await apiFetch('/cockpit/summary');
lastRooms = data.rooms || [];
lastMyRoom = data.myRoom || null;
lastCockpitData = data;
populateRoomSelect();
populateTicketRoomSelect();
populateInventoryRoomSelect();
render(data);
loadNotifications();
} catch (err) {
if (err.message === 'Nicht authentifiziert' || err.message === 'Token ungültig oder abgelaufen') {
clearSession();
@ -3124,6 +3158,168 @@
};
}
// ---------------------------------------------------------------------
// ANKÜNDIGUNGEN
// ---------------------------------------------------------------------
function initAnnouncementForm() {
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
document.getElementById('announcementFormCard').style.display = isLandlord ? 'block' : 'none';
if (!isLandlord) return;
const form = document.getElementById('announcementForm');
const errorBox = document.getElementById('announcementFormError');
form.onsubmit = async (e) => {
e.preventDefault();
errorBox.style.display = 'none';
const submitBtn = document.getElementById('announcementSubmitBtn');
submitBtn.disabled = true;
submitBtn.textContent = 'Kündigt an…';
try {
const title = document.getElementById('announcementTitle').value;
const eventDate = document.getElementById('announcementDate').value;
const message = document.getElementById('announcementMessage').value;
await apiFetch('/announcements', { method: 'POST', body: JSON.stringify({ title, eventDate: eventDate || null, message }) });
form.reset();
loadAnnouncements();
loadNotifications();
} catch (err) {
errorBox.textContent = err.message;
errorBox.style.display = 'block';
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Ankündigen';
}
};
}
async function loadAnnouncements() {
if (!currentUser) return;
try {
const data = await apiFetch('/announcements');
renderAnnouncements(data.announcements);
} catch (err) {
console.error('[announcements] Laden fehlgeschlagen:', err.message);
}
}
function renderAnnouncements(announcements) {
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
const list = document.getElementById('announcementList');
list.innerHTML = '';
if (!announcements.length) {
list.appendChild(el('div', 'empty-state', 'Keine Ankündigungen.'));
return;
}
announcements.forEach(a => {
const row = el('div', 'ticket-row');
const left = el('div');
left.appendChild(el('div', 'title', a.title));
left.appendChild(el('div', 'meta-text',
`${a.eventDate ? new Date(a.eventDate).toLocaleDateString('de-DE') + ' · ' : ''}${a.message || ''}`));
row.appendChild(left);
if (isLandlord) {
const btn = document.createElement('button');
btn.className = 'btn-secondary';
btn.textContent = 'Löschen';
btn.onclick = () => deleteAnnouncement(a.id);
row.appendChild(btn);
}
list.appendChild(row);
});
}
async function deleteAnnouncement(id) {
try {
await apiFetch(`/announcements/${id}`, { method: 'DELETE' });
loadAnnouncements();
loadNotifications();
} catch (err) {
alert(err.message);
}
}
// ---------------------------------------------------------------------
// NACHRICHTEN-ÜBERSICHT ("Für dich diese Woche")
//
// Bündelt für den Mieter alles Terminliche an einer Stelle: eigene fällige
// Putzaufgaben, fällige Miete, anstehende Mülltermine sowie allgemeine
// Ankündigungen des Vermieters — jeweils nur, wenn sie in den nächsten 7
// Tagen liegen (bzw. bei der Miete: aktuell fällig/überfällig).
// ---------------------------------------------------------------------
async function loadNotifications() {
if (!currentUser) return;
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
document.getElementById('notificationsSection').style.display = isLandlord ? 'none' : 'block';
if (isLandlord) return;
const items = [];
const today = stripTime(new Date());
const weekAhead = new Date(today);
weekAhead.setDate(weekAhead.getDate() + 7);
if (lastCockpitData && lastCockpitData.myRent && lastCockpitData.myRent.status !== 'GREEN') {
items.push({
date: today,
icon: '💶',
text: `Miete ${STATUS_LABEL[lastCockpitData.myRent.status].toLowerCase()}: ${formatEuro(lastCockpitData.myRent.amountDue)}`,
});
}
try {
const data = await apiFetch('/cleaning-tasks?status=PENDING');
data.tasks
.filter(t => t.assignedUser && t.assignedUser.id === currentUser.id)
.filter(t => { const d = stripTime(new Date(t.dueDate)); return d >= today && d <= weekAhead; })
.forEach(t => {
items.push({ date: new Date(t.dueDate), icon: '🧹', text: `Putzplan: ${t.area} fällig am ${new Date(t.dueDate).toLocaleDateString('de-DE')}` });
});
} catch (err) {
console.error('[notifications] Putzplan laden fehlgeschlagen:', err.message);
}
try {
const from = today.toISOString().slice(0, 10);
const to = weekAhead.toISOString().slice(0, 10);
const data = await apiFetch(`/trash-schedule?from=${from}&to=${to}`);
data.entries.forEach(e => {
items.push({ date: new Date(e.date), icon: '🗑️', text: `Müll rausstellen: ${TRASH_TYPE_LABEL[e.type] || e.type} am ${new Date(e.date).toLocaleDateString('de-DE')}` });
});
} catch (err) {
console.error('[notifications] Müllkalender laden fehlgeschlagen:', err.message);
}
try {
const data = await apiFetch('/announcements');
data.announcements
.filter(a => !a.eventDate || (stripTime(new Date(a.eventDate)) >= today && stripTime(new Date(a.eventDate)) <= weekAhead))
.forEach(a => {
items.push({
date: a.eventDate ? new Date(a.eventDate) : new Date(a.createdAt),
icon: '📢',
text: `${a.title}${a.eventDate ? ' am ' + new Date(a.eventDate).toLocaleDateString('de-DE') : ''}${a.message ? ' · ' + a.message : ''}`,
});
});
} catch (err) {
console.error('[notifications] Ankündigungen laden fehlgeschlagen:', err.message);
}
items.sort((a, b) => a.date - b.date);
const list = document.getElementById('notificationsList');
list.innerHTML = '';
if (!items.length) {
list.appendChild(el('div', 'empty-state', 'Für diese Woche gibt es nichts Neues für dich.'));
return;
}
items.forEach(item => {
const row = el('div', 'ticket-row');
row.appendChild(el('div', 'title', `${item.icon} ${item.text}`));
list.appendChild(row);
});
}
function buildProfileCompletenessBadge(user) {
const missing = [];
if (!user.phoneNumber) missing.push('Telefonnummer');