From f576cddf6a39e8693770da60bd35c5a27f4e0c66 Mon Sep 17 00:00:00 2001 From: bernd Date: Thu, 13 Aug 2026 12:44:53 +0000 Subject: [PATCH] Putzplan: feste Bereichsliste durch frei erweiterbare Bereiche ersetzen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vermieter kann jetzt eigene Putzbereiche anlegen (CleaningAreaOption statt festem Enum). Neue Standardbereiche: Balkon oben, Terrasse unten, Gehweg unten, Flur unten (Eingangstür), Flur Wendeltreppe, Flur oben, Bad oben, Bad unten, Wohnzimmer, Küche. --- backend/src/jobs/cleaningReminderJob.ts | 11 +---- backend/src/routes/cleaningTasks.ts | 39 ++++++++++++++--- prisma/schema.prisma | 22 ++++++---- web-dashboard/index.html | 57 ++++++++++++++++++++----- 4 files changed, 96 insertions(+), 33 deletions(-) diff --git a/backend/src/jobs/cleaningReminderJob.ts b/backend/src/jobs/cleaningReminderJob.ts index 68f56bd..aef7a2f 100644 --- a/backend/src/jobs/cleaningReminderJob.ts +++ b/backend/src/jobs/cleaningReminderJob.ts @@ -1,16 +1,9 @@ import 'dotenv/config'; -import { PrismaClient, CleaningArea } from '@prisma/client'; +import { PrismaClient } from '@prisma/client'; import { sendPushNotification } from '../services/notificationService'; const prisma = new PrismaClient(); -const AREA_LABEL: Record = { - BATHROOM_1: 'Bad 1', - BATHROOM_2: 'Bad 2', - KITCHEN: 'Wohnküche', - HALLWAY_LAUNDRY: 'Waschraum/Flur', -}; - /** * Täglicher Cron-Job (analog dailyDunningJob.ts): erinnert Mieter per Push an * eine offene Putzaufgabe, die morgen fällig ist. Verschickt (max. 1x je @@ -42,7 +35,7 @@ export async function runCleaningReminderJob(referenceDate: Date = new Date()) { userId: task.assignedUserId, type: 'CLEANING_REMINDER', title: 'Putzplan-Erinnerung', - body: `Morgen ist "${AREA_LABEL[task.area]}" an der Reihe. Bitte nicht vergessen!`, + body: `Morgen ist "${task.area}" an der Reihe. Bitte nicht vergessen!`, }); } diff --git a/backend/src/routes/cleaningTasks.ts b/backend/src/routes/cleaningTasks.ts index c6df248..3f0b5cc 100644 --- a/backend/src/routes/cleaningTasks.ts +++ b/backend/src/routes/cleaningTasks.ts @@ -1,5 +1,5 @@ import { Router, Response } from 'express'; -import { PrismaClient, CleaningArea, CleaningStatus } from '@prisma/client'; +import { PrismaClient, CleaningStatus } from '@prisma/client'; import { AuthedRequest, requireAuth, requireRole } from '../middleware/auth'; const prisma = new PrismaClient(); @@ -13,7 +13,32 @@ const prisma = new PrismaClient(); */ export const cleaningTasksRouter = Router(); -const AREAS = Object.values(CleaningArea); +// GET /cleaning-areas — Liste der anlegbaren Putzbereiche (für alle +// eingeloggten Nutzer sichtbar, z. B. für die Bereich-Auswahl). +cleaningTasksRouter.get('/cleaning-areas', requireAuth, async (_req: AuthedRequest, res: Response) => { + const areas = await prisma.cleaningAreaOption.findMany({ orderBy: { name: 'asc' } }); + res.status(200).json({ areas }); +}); + +// POST /cleaning-areas { name } — nur Vermieter/Admin legen neue Bereiche an. +cleaningTasksRouter.post( + '/cleaning-areas', + requireAuth, + requireRole('LANDLORD', 'ADMIN'), + async (req: AuthedRequest, res: Response) => { + const { name } = req.body || {}; + if (!name || !String(name).trim()) { + return res.status(400).json({ error: 'Name des Bereichs erforderlich' }); + } + const trimmed = String(name).trim().slice(0, 60); + const existing = await prisma.cleaningAreaOption.findUnique({ where: { name: trimmed } }); + if (existing) { + return res.status(200).json({ area: existing, note: 'Bereich existiert bereits.' }); + } + const area = await prisma.cleaningAreaOption.create({ data: { name: trimmed } }); + res.status(201).json({ area }); + }, +); const TASK_SELECT = { id: true, @@ -53,8 +78,12 @@ cleaningTasksRouter.post( if (!assignedUserId || typeof assignedUserId !== 'string') { return res.status(400).json({ error: 'assignedUserId ist erforderlich' }); } - if (!area || !AREAS.includes(area)) { - return res.status(400).json({ error: `Ungültiger Bereich. Erlaubt: ${AREAS.join(', ')}` }); + if (!area || !String(area).trim()) { + return res.status(400).json({ error: 'Bereich ist erforderlich' }); + } + const areaOption = await prisma.cleaningAreaOption.findUnique({ where: { name: String(area).trim() } }); + if (!areaOption) { + return res.status(400).json({ error: 'Ungültiger Bereich. Bitte zuerst unter Bereichen anlegen.' }); } if (!weekOf || Number.isNaN(Date.parse(weekOf))) { return res.status(400).json({ error: 'weekOf (Datum) ist erforderlich' }); @@ -71,7 +100,7 @@ cleaningTasksRouter.post( const task = await prisma.cleaningTask.create({ data: { assignedUserId, - area, + area: areaOption.name, weekOf: new Date(weekOf), dueDate: new Date(dueDate), status: CleaningStatus.PENDING, diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5e3e5fc..39bef31 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -78,13 +78,6 @@ enum TicketStatus { CLOSED } -enum CleaningArea { - BATHROOM_1 - BATHROOM_2 - KITCHEN - HALLWAY_LAUNDRY -} - enum CleaningStatus { PENDING COMPLETED @@ -380,8 +373,8 @@ model CleaningTask { assignedUserId String @map("assigned_user_id") assignedUser User @relation(fields: [assignedUserId], references: [id]) - area CleaningArea - weekOf DateTime @map("week_of") // Montag der jeweiligen Rotationswoche + area String + weekOf DateTime @map("week_of") // Montag der jeweiligen Rotationswoche dueDate DateTime @map("due_date") completedAt DateTime? @map("completed_at") @@ -418,6 +411,17 @@ model CleaningAbsence { @@map("cleaning_absences") } +// 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. +model CleaningAreaOption { + id String @id @default(uuid()) + name String @unique + createdAt DateTime @default(now()) @map("created_at") + + @@map("cleaning_area_options") +} + // ---------------------------------------------------------------------------- // INVENTORY (Möbel & Geräte) // ---------------------------------------------------------------------------- diff --git a/web-dashboard/index.html b/web-dashboard/index.html index dbccb44..45d8226 100644 --- a/web-dashboard/index.html +++ b/web-dashboard/index.html @@ -774,6 +774,13 @@ +
+
+ + +
+ +
@@ -1125,7 +1132,6 @@ SCHIMMEL: 'Schimmel', SCHLUESSEL_SCHLOSS: 'Schlüssel/Schloss', SONSTIGES: 'Sonstiges', }; const TICKET_STATUS_LABEL = { OPEN: 'Gemeldet', IN_PROGRESS: 'In Bearbeitung', RESOLVED: 'Erledigt', CLOSED: 'Geschlossen' }; - const CLEANING_AREA_LABEL = { BATHROOM_1: 'Bad 1', BATHROOM_2: 'Bad 2', KITCHEN: 'Wohnküche', HALLWAY_LAUNDRY: 'Waschraum/Flur' }; const CLEANING_STATUS_LABEL = { PENDING: 'Offen', COMPLETED: 'Erledigt', VERIFIED: 'Bestätigt', MISSED: 'Verpasst' }; const EXPENSE_CATEGORY_LABEL = { HAUSHALT: 'Haushalt', LEBENSMITTEL_GEMEINSAM: 'Lebensmittel (gemeinsam)', REPARATUR_VORAUSLAGE: 'Reparatur-Vorauslage', SONSTIGES: 'Sonstiges' }; const ITEM_CONDITION_LABEL = { NEW: 'Neu', GOOD: 'Gut', WEAR_AND_TEAR: 'Abgenutzt', DAMAGED: 'Beschädigt' }; @@ -1818,17 +1824,26 @@ // PUTZPLAN // --------------------------------------------------------------------- + async function loadCleaningAreasIntoSelect(areaSelect) { + areaSelect.innerHTML = ''; + try { + const data = await apiFetch('/cleaning-areas'); + data.areas.forEach(a => { + const opt = document.createElement('option'); + opt.value = a.name; + opt.textContent = a.name; + areaSelect.appendChild(opt); + }); + } catch (err) { + console.error('[cleaning-tasks] Bereichsliste laden fehlgeschlagen:', err.message); + } + } + async function initCleaningAssignForm() { const userSelect = document.getElementById('cleaningUser'); const areaSelect = document.getElementById('cleaningArea'); userSelect.innerHTML = ''; - areaSelect.innerHTML = ''; - Object.keys(CLEANING_AREA_LABEL).forEach(key => { - const opt = document.createElement('option'); - opt.value = key; - opt.textContent = CLEANING_AREA_LABEL[key]; - areaSelect.appendChild(opt); - }); + await loadCleaningAreasIntoSelect(areaSelect); try { const data = await apiFetch('/tenants'); @@ -1842,6 +1857,28 @@ console.error('[cleaning-tasks] Mieterliste laden fehlgeschlagen:', err.message); } + const newAreaForm = document.getElementById('cleaningNewAreaForm'); + if (newAreaForm) { + newAreaForm.onsubmit = async (e) => { + e.preventDefault(); + const input = document.getElementById('cleaningNewAreaName'); + const name = input.value.trim(); + if (!name) return; + const btn = document.getElementById('cleaningNewAreaBtn'); + btn.disabled = true; + try { + await apiFetch('/cleaning-areas', { method: 'POST', body: JSON.stringify({ name }) }); + input.value = ''; + await loadCleaningAreasIntoSelect(areaSelect); + areaSelect.value = name; + } catch (err) { + alert(err.message); + } finally { + btn.disabled = false; + } + }; + } + const form = document.getElementById('cleaningForm'); const errorBox = document.getElementById('cleaningFormError'); form.onsubmit = async (e) => { @@ -1894,7 +1931,7 @@ tasks.forEach(t => { const row = el('div', 'ticket-row'); const left = el('div'); - left.appendChild(el('div', 'title', CLEANING_AREA_LABEL[t.area] || t.area)); + left.appendChild(el('div', 'title', t.area)); left.appendChild(el('div', 'meta-text', `${isLandlord && t.assignedUser ? t.assignedUser.fullName + ' · ' : ''}` + `Fällig ${new Date(t.dueDate).toLocaleDateString('de-DE')} · ` + @@ -2048,7 +2085,7 @@ cell.appendChild(el('div', 'calendar-day-num', String(d.getDate()))); calendarData.tasks.filter(t => sameDay(new Date(t.dueDate), d)).forEach(t => { - const label = `${CLEANING_AREA_LABEL[t.area] || t.area}${t.assignedUser ? ' · ' + t.assignedUser.fullName.split(' ')[0] : ''}`; + const label = `${t.area}${t.assignedUser ? ' · ' + t.assignedUser.fullName.split(' ')[0] : ''}`; cell.appendChild(el('div', 'calendar-badge calendar-badge-task', label)); }); calendarData.absences.filter(a => d >= stripTime(new Date(a.startDate)) && d <= stripTime(new Date(a.endDate))).forEach(a => {