diff --git a/backend/src/app.ts b/backend/src/app.ts
index 6b5a44c..fe3b220 100644
--- a/backend/src/app.ts
+++ b/backend/src/app.ts
@@ -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);
diff --git a/backend/src/routes/announcements.ts b/backend/src/routes/announcements.ts
new file mode 100644
index 0000000..790ab9b
--- /dev/null
+++ b/backend/src/routes/announcements.ts
@@ -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' });
+ }
+ },
+);
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index e2e3355..31af41d 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -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.
diff --git a/web-dashboard/index.html b/web-dashboard/index.html
index 4a1c0c2..0e4a005 100644
--- a/web-dashboard/index.html
+++ b/web-dashboard/index.html
@@ -614,6 +614,34 @@
+
+ Für dich diese Woche
+
+
+
+
+
Miet-Ampel je Zimmer
@@ -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');