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 @@
+ + +
+

Ankündigungen

+ +
+
+

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');