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:
parent
78810002b4
commit
f576cddf6a
@ -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<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
|
||||
* 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!`,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -78,13 +78,6 @@ enum TicketStatus {
|
||||
CLOSED
|
||||
}
|
||||
|
||||
enum CleaningArea {
|
||||
BATHROOM_1
|
||||
BATHROOM_2
|
||||
KITCHEN
|
||||
HALLWAY_LAUNDRY
|
||||
}
|
||||
|
||||
enum CleaningStatus {
|
||||
PENDING
|
||||
COMPLETED
|
||||
@ -380,7 +373,7 @@ model CleaningTask {
|
||||
assignedUserId String @map("assigned_user_id")
|
||||
assignedUser User @relation(fields: [assignedUserId], references: [id])
|
||||
|
||||
area CleaningArea
|
||||
area String
|
||||
weekOf DateTime @map("week_of") // Montag der jeweiligen Rotationswoche
|
||||
|
||||
dueDate DateTime @map("due_date")
|
||||
@ -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)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
@ -774,6 +774,13 @@
|
||||
</div>
|
||||
<button type="submit" class="btn-primary" style="width:auto" id="cleaningSubmitBtn">Zuweisen</button>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
@ -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 => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user