diff --git a/backend/src/routes/announcements.ts b/backend/src/routes/announcements.ts index 790ab9b..c389bd2 100644 --- a/backend/src/routes/announcements.ts +++ b/backend/src/routes/announcements.ts @@ -1,5 +1,5 @@ import { Router, Response } from 'express'; -import { PrismaClient } from '@prisma/client'; +import { PrismaClient, AttendanceStatus } from '@prisma/client'; import { AuthedRequest, requireAuth, requireRole } from '../middleware/auth'; const prisma = new PrismaClient(); @@ -18,6 +18,9 @@ const ANNOUNCEMENT_SELECT = { eventDate: true, createdAt: true, createdBy: { select: { id: true, fullName: true } }, + attendanceStatus: true, + attendanceMarkedAt: true, + attendanceMarkedBy: { select: { id: true, fullName: true } }, }; announcementsRouter.post( @@ -56,6 +59,31 @@ announcementsRouter.get('/announcements', requireAuth, async (_req: AuthedReques res.status(200).json({ announcements }); }); +// POST /announcements/:id/attendance { status: 'PRESENT' | 'ABSENT' } +// Jeder eingeloggte Nutzer (nicht nur der Vermieter) kann rückmelden, ob z. B. +// der Handwerker/Schornsteinfeger tatsächlich da war — wer zuhause war, sieht +// es vor Ort, nicht der Vermieter. +announcementsRouter.post( + '/announcements/:id/attendance', + requireAuth, + async (req: AuthedRequest, res: Response) => { + const { status } = req.body || {}; + if (status !== AttendanceStatus.PRESENT && status !== AttendanceStatus.ABSENT) { + return res.status(400).json({ error: 'Ungültiger Status. Erlaubt: PRESENT, ABSENT' }); + } + try { + const announcement = await prisma.announcement.update({ + where: { id: req.params.id }, + data: { attendanceStatus: status, attendanceMarkedById: req.user!.id, attendanceMarkedAt: new Date() }, + select: ANNOUNCEMENT_SELECT, + }); + res.status(200).json({ announcement }); + } catch { + res.status(404).json({ error: 'Ankündigung nicht gefunden' }); + } + }, +); + announcementsRouter.delete( '/announcements/:id', requireAuth, diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 301dc02..6536325 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -189,6 +189,7 @@ model User { accessCodeAssigned AccessCode[] @relation("AccessCodeUser") accessCodesGiven AccessCode[] @relation("AccessCodeAssignedBy") announcementsMade Announcement[] + announcementAttendanceMarked Announcement[] @relation("AnnouncementAttendanceMarkedBy") trashCompleted TrashSchedule[] notifications Notification[] invitationsSent Invitation[] @relation("InvitationsSent") @@ -423,11 +424,24 @@ model Announcement { createdById String @map("created_by_id") createdBy User @relation(fields: [createdById], references: [id]) + // Rückmeldung eines Mieters, ob z. B. der Handwerker/Schornsteinfeger + // tatsächlich da war — jeder Mieter kann das markieren, nicht nur der + // Vermieter (Beobachtung vor Ort). + attendanceStatus AttendanceStatus? @map("attendance_status") + attendanceMarkedById String? @map("attendance_marked_by_id") + attendanceMarkedBy User? @relation("AnnouncementAttendanceMarkedBy", fields: [attendanceMarkedById], references: [id]) + attendanceMarkedAt DateTime? @map("attendance_marked_at") + createdAt DateTime @default(now()) @map("created_at") @@map("announcements") } +enum AttendanceStatus { + PRESENT + ABSENT +} + // 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 74c30aa..88ced8d 100644 --- a/web-dashboard/index.html +++ b/web-dashboard/index.html @@ -326,6 +326,7 @@ .calendar-badge-task-verified { background: var(--green-bg, #E4F3EA); color: var(--green, #2f8f5b); } .calendar-badge-task-missed { background: #FBE0DE; color: #C23B2E; } .calendar-badge-absence { background: var(--yellow-bg, #FDF3DC); color: var(--yellow, #B4780A); } + .calendar-badge-announcement { background: #F4E3FB; color: #8A2EBE; } .inventory-thumb { width: 48px; @@ -823,7 +824,7 @@

Putzplan-Kalender & Abwesenheiten

- Zeigt für alle sichtbar, wer wann für welchen Bereich zuständig ist und ob die Aufgabe bereits erledigt wurde, sowie eingetragene Abwesenheiten (Urlaub, "kann nicht"). + Zeigt für alle sichtbar, wer wann für welchen Bereich zuständig ist und ob die Aufgabe bereits erledigt wurde, eingetragene Abwesenheiten (Urlaub, "kann nicht") sowie Ankündigungen mit Termin (z. B. Handwerker) inkl. Rückmeldung, ob die Person da war.

○ Offen @@ -831,6 +832,7 @@ ✓✓ Bestätigt ✕ Verpasst Abwesend + 📢 Ankündigung
@@ -2037,6 +2039,7 @@ const WEEKDAY_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']; let calendarMonthDate = new Date(new Date().getFullYear(), new Date().getMonth(), 1); let calendarData = { tasks: [], absences: [] }; + let calendarAnnouncements = []; function initAbsenceForm() { const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN'; @@ -2098,10 +2101,16 @@ try { const data = await apiFetch(`/cleaning-calendar?from=${from}&to=${to}`); calendarData = data; - renderCalendar(); } catch (err) { console.error('[cleaning-calendar] Laden fehlgeschlagen:', err.message); } + try { + const announcementData = await apiFetch('/announcements'); + calendarAnnouncements = announcementData.announcements.filter(a => a.eventDate); + } catch (err) { + console.error('[cleaning-calendar] Ankündigungen laden fehlgeschlagen:', err.message); + } + renderCalendar(); } function renderCalendar() { @@ -2149,6 +2158,10 @@ calendarData.absences.filter(a => d >= stripTime(new Date(a.startDate)) && d <= stripTime(new Date(a.endDate))).forEach(a => { cell.appendChild(el('div', 'calendar-badge calendar-badge-absence', `${a.user.fullName.split(' ')[0]} abwesend`)); }); + calendarAnnouncements.filter(a => sameDay(new Date(a.eventDate), d)).forEach(a => { + const attendanceSuffix = a.attendanceStatus ? (a.attendanceStatus === 'PRESENT' ? ' ✓' : ' ✕') : ''; + cell.appendChild(el('div', 'calendar-badge calendar-badge-announcement', `📢 ${a.title}${attendanceSuffix}`)); + }); grid.appendChild(cell); } @@ -3204,6 +3217,40 @@ } } + // Rückmeldung "War da"/"War nicht da" für Ankündigungen mit Termin (z. B. + // Handwerker/Schornsteinfeger) — jeder Nutzer darf das markieren, nicht + // nur der Vermieter. Einmal markiert, wird nur noch der Status angezeigt. + function buildAttendanceControl(a, onDone) { + if (!a.eventDate) return null; + if (a.attendanceStatus) { + const label = a.attendanceStatus === 'PRESENT' ? '✓ War da' : '✕ War nicht da'; + const span = el('span', 'invite-status-chip', `${label}${a.attendanceMarkedBy ? ' · ' + a.attendanceMarkedBy.fullName.split(' ')[0] : ''}`); + return span; + } + const wrap = el('div'); + wrap.style.cssText = 'display:flex; gap:6px;'; + const presentBtn = document.createElement('button'); + presentBtn.className = 'btn-secondary'; + presentBtn.textContent = 'War da'; + presentBtn.onclick = () => markAnnouncementAttendance(a.id, 'PRESENT', onDone); + const absentBtn = document.createElement('button'); + absentBtn.className = 'btn-secondary'; + absentBtn.textContent = 'War nicht da'; + absentBtn.onclick = () => markAnnouncementAttendance(a.id, 'ABSENT', onDone); + wrap.appendChild(presentBtn); + wrap.appendChild(absentBtn); + return wrap; + } + + async function markAnnouncementAttendance(id, status, onDone) { + try { + await apiFetch(`/announcements/${id}/attendance`, { method: 'POST', body: JSON.stringify({ status }) }); + if (onDone) onDone(); + } catch (err) { + alert(err.message); + } + } + function renderAnnouncements(announcements) { const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN'; const list = document.getElementById('announcementList'); @@ -3219,13 +3266,18 @@ left.appendChild(el('div', 'meta-text', `${a.eventDate ? new Date(a.eventDate).toLocaleDateString('de-DE') + ' · ' : ''}${a.message || ''}`)); row.appendChild(left); + const right = el('div'); + right.style.cssText = 'display:flex; gap:8px; align-items:center;'; + const attendance = buildAttendanceControl(a, () => { loadAnnouncements(); loadNotifications(); loadCalendar(); }); + if (attendance) right.appendChild(attendance); if (isLandlord) { const btn = document.createElement('button'); btn.className = 'btn-secondary'; btn.textContent = 'Löschen'; btn.onclick = () => deleteAnnouncement(a.id); - row.appendChild(btn); + right.appendChild(btn); } + row.appendChild(right); list.appendChild(row); }); } @@ -3308,6 +3360,7 @@ 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 : ''}`, + extraNode: buildAttendanceControl(a, () => { loadNotifications(); loadAnnouncements(); loadCalendar(); }), }); }); } catch (err) { @@ -3332,6 +3385,7 @@ btn.onclick = item.onAction; row.appendChild(btn); } + if (item.extraNode) row.appendChild(item.extraNode); list.appendChild(row); }); }