diff --git a/backend/src/routes/cleaningTasks.ts b/backend/src/routes/cleaningTasks.ts index fb047b7..513f5f2 100644 --- a/backend/src/routes/cleaningTasks.ts +++ b/backend/src/routes/cleaningTasks.ts @@ -1,18 +1,21 @@ import { Router, Response } from 'express'; -import { PrismaClient, CleaningStatus } from '@prisma/client'; +import { PrismaClient, CleaningStatus, WgEventCategory } from '@prisma/client'; import { AuthedRequest, requireAuth, requireRole } from '../middleware/auth'; const prisma = new PrismaClient(); /** - * Router für den Putzplan (rotierende Reinigungsaufgaben). + * Router für den WG-Kalender: Putzplan (rotierende Reinigungsaufgaben), + * Abwesenheiten und freie WG-Ereignisse (Einkaufen, Gäste/Party, Ruhe/Lernen, + * Sonstiges) — alles gemeinsam im selben Kalender sichtbar. * - * Zugriffsregel: der Putzplan wird von den Mietern selbst gepflegt — jeder - * eingeloggte Nutzer (Mieter wie Vermieter/Admin) kann Putzbereiche anlegen - * und Aufgaben eintragen/zuweisen. Ein Mieter sieht (und kann abhaken) nur - * seine eigenen zugewiesenen Aufgaben; Vermieter/Admin sehen den kompletten - * Plan. Die Bestätigung/Verifizierung erledigter Aufgaben bleibt Vermieter/ - * Admin vorbehalten (Qualitätskontrolle). + * Zugriffsregel: der WG-Kalender wird von den Mietern selbst gepflegt — jeder + * eingeloggte Nutzer (Mieter wie Vermieter/Admin) kann Putzbereiche anlegen, + * Aufgaben eintragen/zuweisen und freie Kalendereinträge für sich selbst + * anlegen. Ein Mieter sieht (und kann abhaken) nur seine eigenen zugewiesenen + * Putzaufgaben; Vermieter/Admin sehen den kompletten Plan. Die Bestätigung/ + * Verifizierung erledigter Putzaufgaben bleibt Vermieter/Admin vorbehalten + * (Qualitätskontrolle). */ export const cleaningTasksRouter = Router(); @@ -211,8 +214,19 @@ const ABSENCE_SELECT = { user: { select: { id: true, fullName: true } }, }; +const EVENT_SELECT = { + id: true, + title: true, + category: true, + note: true, + startDate: true, + endDate: true, + createdBy: { select: { id: true, fullName: true } }, +}; + // GET /cleaning-calendar?from=YYYY-MM-DD&to=YYYY-MM-DD -// Liefert alle Putzaufgaben + alle Abwesenheiten im Zeitraum (für alle Rollen sichtbar). +// Liefert alle Putzaufgaben + Abwesenheiten + freien WG-Ereignisse im +// Zeitraum (für alle Rollen sichtbar) — der gemeinsame WG-Kalender. cleaningTasksRouter.get( '/cleaning-calendar', requireAuth, @@ -238,7 +252,15 @@ cleaningTasksRouter.get( ]; } - const [tasks, absences] = await Promise.all([ + const eventWhere: Record = {}; + if (fromDate || toDate) { + eventWhere.AND = [ + fromDate ? { endDate: { gte: fromDate } } : {}, + toDate ? { startDate: { lte: toDate } } : {}, + ]; + } + + const [tasks, absences, events] = await Promise.all([ prisma.cleaningTask.findMany({ where: taskWhere, orderBy: [{ dueDate: 'asc' }], @@ -249,9 +271,71 @@ cleaningTasksRouter.get( orderBy: [{ startDate: 'asc' }], select: ABSENCE_SELECT, }), + prisma.wgEvent.findMany({ + where: eventWhere, + orderBy: [{ startDate: 'asc' }], + select: EVENT_SELECT, + }), ]); - res.status(200).json({ tasks, absences }); + res.status(200).json({ tasks, absences, events }); + }, +); + +// POST /wg-events — jeder eingeloggte Nutzer trägt einen freien +// Kalendereintrag für sich selbst ein (Einkaufen, Gäste/Party, Ruhe/Lernen, +// Sonstiges oder frei betitelt). +cleaningTasksRouter.post( + '/wg-events', + requireAuth, + async (req: AuthedRequest, res: Response) => { + const { title, category, note, startDate, endDate } = req.body || {}; + + if (!title || !String(title).trim()) { + return res.status(400).json({ error: 'Titel ist erforderlich' }); + } + if (!startDate || Number.isNaN(Date.parse(startDate))) { + return res.status(400).json({ error: 'startDate (Datum) ist erforderlich' }); + } + const parsedStart = new Date(startDate); + const parsedEnd = endDate && !Number.isNaN(Date.parse(endDate)) ? new Date(endDate) : parsedStart; + if (parsedEnd < parsedStart) { + return res.status(400).json({ error: 'endDate darf nicht vor startDate liegen' }); + } + const validCategories = Object.values(WgEventCategory) as string[]; + const resolvedCategory = category && validCategories.includes(category) ? category : WgEventCategory.SONSTIGES; + + const event = await prisma.wgEvent.create({ + data: { + createdById: req.user!.id, + title: String(title).trim().slice(0, 120), + category: resolvedCategory, + note: typeof note === 'string' && note.trim() ? note.trim().slice(0, 280) : null, + startDate: parsedStart, + endDate: parsedEnd, + }, + select: EVENT_SELECT, + }); + + res.status(201).json({ event }); + }, +); + +// DELETE /wg-events/:id — eigener Eintrag löschbar, oder Vermieter/Admin jederzeit. +cleaningTasksRouter.delete( + '/wg-events/:id', + requireAuth, + async (req: AuthedRequest, res: Response) => { + const event = await prisma.wgEvent.findUnique({ where: { id: req.params.id } }); + if (!event) return res.status(404).json({ error: 'Eintrag nicht gefunden' }); + + const isLandlord = req.user!.role === 'LANDLORD' || req.user!.role === 'ADMIN'; + if (!isLandlord && event.createdById !== req.user!.id) { + return res.status(403).json({ error: 'Du kannst nur deine eigenen Einträge löschen' }); + } + + await prisma.wgEvent.delete({ where: { id: req.params.id } }); + res.status(204).send(); }, ); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 858c87d..ad962cb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -183,6 +183,7 @@ model User { assignedTickets Ticket[] @relation("TicketAssignee") cleaningTasks CleaningTask[] cleaningAbsences CleaningAbsence[] + wgEventsCreated WgEvent[] @relation("WgEventsCreated") expensesPaid Expense[] expenseShares ExpenseShare[] handoverProtocols HandoverProtocol[] @@ -420,6 +421,34 @@ model CleaningAbsence { @@map("cleaning_absences") } +// Freie Kalendereinträge im WG-Kalender (z. B. "gehe einkaufen", "Gäste/Party +// am Wochenende", "brauche Ruhe zum Lernen") — jeder Mieter kann für sich +// selbst Einträge anlegen, damit das Zusammenleben besser koordiniert werden +// kann. Ergänzt Putzaufgaben/Abwesenheiten/Ankündigungen im selben Kalender. +enum WgEventCategory { + EINKAUFEN + GAESTE_PARTY + RUHE_LERNEN + SONSTIGES +} + +model WgEvent { + id String @id @default(uuid()) + createdById String @map("created_by_id") + createdBy User @relation("WgEventsCreated", fields: [createdById], references: [id]) + + title String + category WgEventCategory @default(SONSTIGES) + note String? + startDate DateTime @map("start_date") + endDate DateTime @map("end_date") // gleich startDate bei eintägigen Einträgen + + createdAt DateTime @default(now()) @map("created_at") + + @@index([startDate]) + @@map("wg_events") +} + // Ankündigungen des Vermieters (z. B. "Schornsteinfeger kommt am...", // "Handwerker kommt am..."), für alle Mieter im Dashboard sichtbar. model Announcement { diff --git a/web-dashboard/index.html b/web-dashboard/index.html index 425eaf4..57f780f 100644 --- a/web-dashboard/index.html +++ b/web-dashboard/index.html @@ -327,6 +327,10 @@ .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; } + .calendar-badge-event-EINKAUFEN { background: #DBF3EF; color: #157A6E; } + .calendar-badge-event-GAESTE_PARTY { background: #FCE4D6; color: #C2540C; } + .calendar-badge-event-RUHE_LERNEN { background: #E4E9F7; color: #33507D; } + .calendar-badge-event-SONSTIGES { background: #ECECEC; color: #555555; } .inventory-thumb { width: 48px; @@ -593,7 +597,7 @@ - + @@ -866,9 +870,9 @@
-

Putzplan-Kalender & Abwesenheiten

+

WG-Kalender

- 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. + Zeigt für alle sichtbar, wer wann für welchen Bereich zuständig ist und ob die Putzaufgabe bereits erledigt wurde, eingetragene Abwesenheiten (Urlaub, "kann nicht"), Ankündigungen mit Termin (z. B. Handwerker) inkl. Rückmeldung, ob die Person da war — sowie freie WG-Ereignisse wie "gehe einkaufen", "Gäste/Party" oder "brauche Ruhe zum Lernen".

○ Offen @@ -877,6 +881,10 @@ ✕ Verpasst Abwesend 📢 Ankündigung + 🛒 Einkaufen + 🎉 Gäste/Party + 🤫 Ruhe/Lernen + 📌 Sonstiges
@@ -901,6 +909,42 @@
+

WG-Ereignis eintragen

+

+ Für alles, was die Mitbewohner wissen sollten — z. B. "gehe einkaufen", "hab am Samstag Gäste/Party" oder "muss lernen, bitte Ruhe". +

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
@@ -909,6 +953,9 @@
+

WG-Ereignisse im angezeigten Monat

+
+

Abwesenheiten im angezeigten Monat

@@ -1493,6 +1540,7 @@ initTicketForm(); initAbsenceForm(); + initEventForm(); initExpenseForm(); if (isLandlord) initInventoryForm(); if (isLandlord) initHandoverForm(); @@ -2189,7 +2237,8 @@ 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 calendarData = { tasks: [], absences: [], events: [] }; + const EVENT_CATEGORY_ICON = { EINKAUFEN: '🛒', GAESTE_PARTY: '🎉', RUHE_LERNEN: '🤫', SONSTIGES: '📌' }; let calendarAnnouncements = []; function initAbsenceForm() { @@ -2243,6 +2292,38 @@ }; } + function initEventForm() { + const form = document.getElementById('eventForm'); + const errorBox = document.getElementById('eventFormError'); + form.onsubmit = async (e) => { + e.preventDefault(); + errorBox.style.display = 'none'; + const submitBtn = document.getElementById('eventSubmitBtn'); + submitBtn.disabled = true; + submitBtn.textContent = 'Trägt ein…'; + try { + const title = document.getElementById('eventTitle').value.trim(); + const category = document.getElementById('eventCategory').value; + const startDate = document.getElementById('eventStart').value; + const endDate = document.getElementById('eventEnd').value || undefined; + const note = document.getElementById('eventNote').value; + await apiFetch('/wg-events', { + method: 'POST', + body: JSON.stringify({ title, category, startDate, endDate, note }), + }); + form.reset(); + document.getElementById('eventCategory').value = 'SONSTIGES'; + loadCalendar(); + } catch (err) { + errorBox.textContent = err.message; + errorBox.style.display = 'block'; + } finally { + submitBtn.disabled = false; + submitBtn.textContent = 'Eintragen'; + } + }; + } + async function loadCalendar() { if (!currentUser) return; const monthStart = new Date(calendarMonthDate.getFullYear(), calendarMonthDate.getMonth(), 1); @@ -2313,10 +2394,44 @@ const attendanceSuffix = a.attendanceStatus ? (a.attendanceStatus === 'PRESENT' ? ' ✓' : ' ✕') : ''; cell.appendChild(el('div', 'calendar-badge calendar-badge-announcement', `📢 ${a.title}${attendanceSuffix}`)); }); + (calendarData.events || []).filter(ev => d >= stripTime(new Date(ev.startDate)) && d <= stripTime(new Date(ev.endDate))).forEach(ev => { + const icon = EVENT_CATEGORY_ICON[ev.category] || '📌'; + const badge = el('div', `calendar-badge calendar-badge-event-${ev.category}`, + `${icon} ${ev.title} · ${ev.createdBy.fullName.split(' ')[0]}`); + cell.appendChild(badge); + }); grid.appendChild(cell); } + const eventList = document.getElementById('eventList'); + eventList.innerHTML = ''; + if (!(calendarData.events || []).length) { + eventList.appendChild(el('div', 'empty-state', 'Keine WG-Ereignisse in diesem Monat eingetragen.')); + } else { + const isLandlordForEvents = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN'; + calendarData.events.forEach(ev => { + const row = el('div', 'ticket-row'); + const left = el('div'); + const icon = EVENT_CATEGORY_ICON[ev.category] || '📌'; + left.appendChild(el('div', 'title', `${icon} ${ev.title}`)); + const rangeText = sameDay(new Date(ev.startDate), new Date(ev.endDate)) + ? new Date(ev.startDate).toLocaleDateString('de-DE') + : `${new Date(ev.startDate).toLocaleDateString('de-DE')} – ${new Date(ev.endDate).toLocaleDateString('de-DE')}`; + left.appendChild(el('div', 'meta-text', + `${rangeText} · ${ev.createdBy.fullName}${ev.note ? ' · ' + ev.note : ''}`)); + row.appendChild(left); + if (isLandlordForEvents || ev.createdBy.id === currentUser.id) { + const btn = document.createElement('button'); + btn.className = 'btn-secondary'; + btn.textContent = 'Löschen'; + btn.onclick = () => deleteWgEvent(ev.id); + row.appendChild(btn); + } + eventList.appendChild(row); + }); + } + const absenceList = document.getElementById('absenceList'); absenceList.innerHTML = ''; if (!calendarData.absences.length) { @@ -2360,6 +2475,15 @@ } } + async function deleteWgEvent(id) { + try { + await apiFetch(`/wg-events/${id}`, { method: 'DELETE' }); + loadCalendar(); + } catch (err) { + alert(err.message); + } + } + // --------------------------------------------------------------------- // WG-KASSE (AUSGABEN-TEILER) // ---------------------------------------------------------------------