Putzplan: feste Bereichsliste durch frei erweiterbare Bereiche ersetzen

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.
This commit is contained in:
Giuseppe Lombardo 2026-08-13 12:44:53 +00:00
parent 78810002b4
commit f576cddf6a
4 changed files with 96 additions and 33 deletions

View File

@ -1,16 +1,9 @@
import 'dotenv/config'; import 'dotenv/config';
import { PrismaClient, CleaningArea } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import { sendPushNotification } from '../services/notificationService'; import { sendPushNotification } from '../services/notificationService';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
const AREA_LABEL: Record<CleaningArea, string> = {
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 * Täglicher Cron-Job (analog dailyDunningJob.ts): erinnert Mieter per Push an
* eine offene Putzaufgabe, die morgen fällig ist. Verschickt (max. 1x je * 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, userId: task.assignedUserId,
type: 'CLEANING_REMINDER', type: 'CLEANING_REMINDER',
title: 'Putzplan-Erinnerung', 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!`,
}); });
} }

View File

@ -1,5 +1,5 @@
import { Router, Response } from 'express'; 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'; import { AuthedRequest, requireAuth, requireRole } from '../middleware/auth';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
@ -13,7 +13,32 @@ const prisma = new PrismaClient();
*/ */
export const cleaningTasksRouter = Router(); 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 = { const TASK_SELECT = {
id: true, id: true,
@ -53,8 +78,12 @@ cleaningTasksRouter.post(
if (!assignedUserId || typeof assignedUserId !== 'string') { if (!assignedUserId || typeof assignedUserId !== 'string') {
return res.status(400).json({ error: 'assignedUserId ist erforderlich' }); return res.status(400).json({ error: 'assignedUserId ist erforderlich' });
} }
if (!area || !AREAS.includes(area)) { if (!area || !String(area).trim()) {
return res.status(400).json({ error: `Ungültiger Bereich. Erlaubt: ${AREAS.join(', ')}` }); 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))) { if (!weekOf || Number.isNaN(Date.parse(weekOf))) {
return res.status(400).json({ error: 'weekOf (Datum) ist erforderlich' }); return res.status(400).json({ error: 'weekOf (Datum) ist erforderlich' });
@ -71,7 +100,7 @@ cleaningTasksRouter.post(
const task = await prisma.cleaningTask.create({ const task = await prisma.cleaningTask.create({
data: { data: {
assignedUserId, assignedUserId,
area, area: areaOption.name,
weekOf: new Date(weekOf), weekOf: new Date(weekOf),
dueDate: new Date(dueDate), dueDate: new Date(dueDate),
status: CleaningStatus.PENDING, status: CleaningStatus.PENDING,

View File

@ -78,13 +78,6 @@ enum TicketStatus {
CLOSED CLOSED
} }
enum CleaningArea {
BATHROOM_1
BATHROOM_2
KITCHEN
HALLWAY_LAUNDRY
}
enum CleaningStatus { enum CleaningStatus {
PENDING PENDING
COMPLETED COMPLETED
@ -380,8 +373,8 @@ model CleaningTask {
assignedUserId String @map("assigned_user_id") assignedUserId String @map("assigned_user_id")
assignedUser User @relation(fields: [assignedUserId], references: [id]) assignedUser User @relation(fields: [assignedUserId], references: [id])
area CleaningArea area String
weekOf DateTime @map("week_of") // Montag der jeweiligen Rotationswoche weekOf DateTime @map("week_of") // Montag der jeweiligen Rotationswoche
dueDate DateTime @map("due_date") dueDate DateTime @map("due_date")
completedAt DateTime? @map("completed_at") completedAt DateTime? @map("completed_at")
@ -418,6 +411,17 @@ model CleaningAbsence {
@@map("cleaning_absences") @@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) // INVENTORY (Möbel & Geräte)
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------

View File

@ -774,6 +774,13 @@
</div> </div>
<button type="submit" class="btn-primary" style="width:auto" id="cleaningSubmitBtn">Zuweisen</button> <button type="submit" class="btn-primary" style="width:auto" id="cleaningSubmitBtn">Zuweisen</button>
</form> </form>
<form id="cleaningNewAreaForm" class="invite-form-row" style="flex-wrap:wrap; margin-top:10px;">
<div class="field" style="flex:1 1 200px;">
<label for="cleaningNewAreaName">Neuen Bereich anlegen</label>
<input type="text" id="cleaningNewAreaName" placeholder="z. B. Keller" maxlength="60" />
</div>
<button type="submit" class="btn-secondary" style="width:auto" id="cleaningNewAreaBtn">Bereich hinzufügen</button>
</form>
</div> </div>
</section> </section>
@ -1125,7 +1132,6 @@
SCHIMMEL: 'Schimmel', SCHLUESSEL_SCHLOSS: 'Schlüssel/Schloss', SONSTIGES: 'Sonstiges', SCHIMMEL: 'Schimmel', SCHLUESSEL_SCHLOSS: 'Schlüssel/Schloss', SONSTIGES: 'Sonstiges',
}; };
const TICKET_STATUS_LABEL = { OPEN: 'Gemeldet', IN_PROGRESS: 'In Bearbeitung', RESOLVED: 'Erledigt', CLOSED: 'Geschlossen' }; 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 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 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' }; const ITEM_CONDITION_LABEL = { NEW: 'Neu', GOOD: 'Gut', WEAR_AND_TEAR: 'Abgenutzt', DAMAGED: 'Beschädigt' };
@ -1818,17 +1824,26 @@
// PUTZPLAN // 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() { async function initCleaningAssignForm() {
const userSelect = document.getElementById('cleaningUser'); const userSelect = document.getElementById('cleaningUser');
const areaSelect = document.getElementById('cleaningArea'); const areaSelect = document.getElementById('cleaningArea');
userSelect.innerHTML = ''; userSelect.innerHTML = '';
areaSelect.innerHTML = ''; await loadCleaningAreasIntoSelect(areaSelect);
Object.keys(CLEANING_AREA_LABEL).forEach(key => {
const opt = document.createElement('option');
opt.value = key;
opt.textContent = CLEANING_AREA_LABEL[key];
areaSelect.appendChild(opt);
});
try { try {
const data = await apiFetch('/tenants'); const data = await apiFetch('/tenants');
@ -1842,6 +1857,28 @@
console.error('[cleaning-tasks] Mieterliste laden fehlgeschlagen:', err.message); 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 form = document.getElementById('cleaningForm');
const errorBox = document.getElementById('cleaningFormError'); const errorBox = document.getElementById('cleaningFormError');
form.onsubmit = async (e) => { form.onsubmit = async (e) => {
@ -1894,7 +1931,7 @@
tasks.forEach(t => { tasks.forEach(t => {
const row = el('div', 'ticket-row'); const row = el('div', 'ticket-row');
const left = el('div'); 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', left.appendChild(el('div', 'meta-text',
`${isLandlord && t.assignedUser ? t.assignedUser.fullName + ' · ' : ''}` + `${isLandlord && t.assignedUser ? t.assignedUser.fullName + ' · ' : ''}` +
`Fällig ${new Date(t.dueDate).toLocaleDateString('de-DE')} · ` + `Fällig ${new Date(t.dueDate).toLocaleDateString('de-DE')} · ` +
@ -2048,7 +2085,7 @@
cell.appendChild(el('div', 'calendar-day-num', String(d.getDate()))); cell.appendChild(el('div', 'calendar-day-num', String(d.getDate())));
calendarData.tasks.filter(t => sameDay(new Date(t.dueDate), d)).forEach(t => { 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)); 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 => { calendarData.absences.filter(a => d >= stripTime(new Date(a.startDate)) && d <= stripTime(new Date(a.endDate))).forEach(a => {