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 '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!`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,7 +373,7 @@ 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")
|
||||||
@ -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)
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|||||||
@ -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 => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user