Ankündigungen: Mieter können Handwerker/Schornsteinfeger-Termine bestätigen
Jeder Mieter kann für Ankündigungen mit Termin markieren, ob die Person
tatsächlich da war ('War da'/'War nicht da') — nicht nur der Vermieter.
Ankündigungen mit Termin erscheinen jetzt außerdem als Eintrag im
gemeinsamen Putzplan-Kalender, inkl. Rückmeldestatus.
This commit is contained in:
parent
0b43e9fcba
commit
1ac31b1032
@ -1,5 +1,5 @@
|
||||
import { Router, Response } from 'express';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { PrismaClient, AttendanceStatus } from '@prisma/client';
|
||||
import { AuthedRequest, requireAuth, requireRole } from '../middleware/auth';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
@ -18,6 +18,9 @@ const ANNOUNCEMENT_SELECT = {
|
||||
eventDate: true,
|
||||
createdAt: true,
|
||||
createdBy: { select: { id: true, fullName: true } },
|
||||
attendanceStatus: true,
|
||||
attendanceMarkedAt: true,
|
||||
attendanceMarkedBy: { select: { id: true, fullName: true } },
|
||||
};
|
||||
|
||||
announcementsRouter.post(
|
||||
@ -56,6 +59,31 @@ announcementsRouter.get('/announcements', requireAuth, async (_req: AuthedReques
|
||||
res.status(200).json({ announcements });
|
||||
});
|
||||
|
||||
// POST /announcements/:id/attendance { status: 'PRESENT' | 'ABSENT' }
|
||||
// Jeder eingeloggte Nutzer (nicht nur der Vermieter) kann rückmelden, ob z. B.
|
||||
// der Handwerker/Schornsteinfeger tatsächlich da war — wer zuhause war, sieht
|
||||
// es vor Ort, nicht der Vermieter.
|
||||
announcementsRouter.post(
|
||||
'/announcements/:id/attendance',
|
||||
requireAuth,
|
||||
async (req: AuthedRequest, res: Response) => {
|
||||
const { status } = req.body || {};
|
||||
if (status !== AttendanceStatus.PRESENT && status !== AttendanceStatus.ABSENT) {
|
||||
return res.status(400).json({ error: 'Ungültiger Status. Erlaubt: PRESENT, ABSENT' });
|
||||
}
|
||||
try {
|
||||
const announcement = await prisma.announcement.update({
|
||||
where: { id: req.params.id },
|
||||
data: { attendanceStatus: status, attendanceMarkedById: req.user!.id, attendanceMarkedAt: new Date() },
|
||||
select: ANNOUNCEMENT_SELECT,
|
||||
});
|
||||
res.status(200).json({ announcement });
|
||||
} catch {
|
||||
res.status(404).json({ error: 'Ankündigung nicht gefunden' });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
announcementsRouter.delete(
|
||||
'/announcements/:id',
|
||||
requireAuth,
|
||||
|
||||
@ -189,6 +189,7 @@ model User {
|
||||
accessCodeAssigned AccessCode[] @relation("AccessCodeUser")
|
||||
accessCodesGiven AccessCode[] @relation("AccessCodeAssignedBy")
|
||||
announcementsMade Announcement[]
|
||||
announcementAttendanceMarked Announcement[] @relation("AnnouncementAttendanceMarkedBy")
|
||||
trashCompleted TrashSchedule[]
|
||||
notifications Notification[]
|
||||
invitationsSent Invitation[] @relation("InvitationsSent")
|
||||
@ -423,11 +424,24 @@ model Announcement {
|
||||
createdById String @map("created_by_id")
|
||||
createdBy User @relation(fields: [createdById], references: [id])
|
||||
|
||||
// Rückmeldung eines Mieters, ob z. B. der Handwerker/Schornsteinfeger
|
||||
// tatsächlich da war — jeder Mieter kann das markieren, nicht nur der
|
||||
// Vermieter (Beobachtung vor Ort).
|
||||
attendanceStatus AttendanceStatus? @map("attendance_status")
|
||||
attendanceMarkedById String? @map("attendance_marked_by_id")
|
||||
attendanceMarkedBy User? @relation("AnnouncementAttendanceMarkedBy", fields: [attendanceMarkedById], references: [id])
|
||||
attendanceMarkedAt DateTime? @map("attendance_marked_at")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@map("announcements")
|
||||
}
|
||||
|
||||
enum AttendanceStatus {
|
||||
PRESENT
|
||||
ABSENT
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
@ -326,6 +326,7 @@
|
||||
.calendar-badge-task-verified { background: var(--green-bg, #E4F3EA); color: var(--green, #2f8f5b); }
|
||||
.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; }
|
||||
|
||||
.inventory-thumb {
|
||||
width: 48px;
|
||||
@ -823,7 +824,7 @@
|
||||
<section id="cleaningCalendarSection">
|
||||
<h2>Putzplan-Kalender & Abwesenheiten</h2>
|
||||
<p style="color:var(--text-muted); font-size:13px; margin-top:-8px;">
|
||||
Zeigt für alle sichtbar, wer wann für welchen Bereich zuständig ist und ob die Aufgabe bereits erledigt wurde, sowie eingetragene Abwesenheiten (Urlaub, "kann nicht").
|
||||
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.
|
||||
</p>
|
||||
<div style="display:flex; flex-wrap:wrap; gap:10px; margin-bottom:12px; font-size:13px;">
|
||||
<span class="calendar-badge calendar-badge-task-pending" style="padding:3px 8px;">○ Offen</span>
|
||||
@ -831,6 +832,7 @@
|
||||
<span class="calendar-badge calendar-badge-task-verified" style="padding:3px 8px;">✓✓ Bestätigt</span>
|
||||
<span class="calendar-badge calendar-badge-task-missed" style="padding:3px 8px;">✕ Verpasst</span>
|
||||
<span class="calendar-badge calendar-badge-absence" style="padding:3px 8px;">Abwesend</span>
|
||||
<span class="calendar-badge calendar-badge-announcement" style="padding:3px 8px;">📢 Ankündigung</span>
|
||||
</div>
|
||||
<div class="invite-card" style="margin-bottom:16px;">
|
||||
<div id="absenceFormError" style="display:none" class="form-error"></div>
|
||||
@ -2037,6 +2039,7 @@
|
||||
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 calendarAnnouncements = [];
|
||||
|
||||
function initAbsenceForm() {
|
||||
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
|
||||
@ -2098,10 +2101,16 @@
|
||||
try {
|
||||
const data = await apiFetch(`/cleaning-calendar?from=${from}&to=${to}`);
|
||||
calendarData = data;
|
||||
renderCalendar();
|
||||
} catch (err) {
|
||||
console.error('[cleaning-calendar] Laden fehlgeschlagen:', err.message);
|
||||
}
|
||||
try {
|
||||
const announcementData = await apiFetch('/announcements');
|
||||
calendarAnnouncements = announcementData.announcements.filter(a => a.eventDate);
|
||||
} catch (err) {
|
||||
console.error('[cleaning-calendar] Ankündigungen laden fehlgeschlagen:', err.message);
|
||||
}
|
||||
renderCalendar();
|
||||
}
|
||||
|
||||
function renderCalendar() {
|
||||
@ -2149,6 +2158,10 @@
|
||||
calendarData.absences.filter(a => d >= stripTime(new Date(a.startDate)) && d <= stripTime(new Date(a.endDate))).forEach(a => {
|
||||
cell.appendChild(el('div', 'calendar-badge calendar-badge-absence', `${a.user.fullName.split(' ')[0]} abwesend`));
|
||||
});
|
||||
calendarAnnouncements.filter(a => sameDay(new Date(a.eventDate), d)).forEach(a => {
|
||||
const attendanceSuffix = a.attendanceStatus ? (a.attendanceStatus === 'PRESENT' ? ' ✓' : ' ✕') : '';
|
||||
cell.appendChild(el('div', 'calendar-badge calendar-badge-announcement', `📢 ${a.title}${attendanceSuffix}`));
|
||||
});
|
||||
|
||||
grid.appendChild(cell);
|
||||
}
|
||||
@ -3204,6 +3217,40 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Rückmeldung "War da"/"War nicht da" für Ankündigungen mit Termin (z. B.
|
||||
// Handwerker/Schornsteinfeger) — jeder Nutzer darf das markieren, nicht
|
||||
// nur der Vermieter. Einmal markiert, wird nur noch der Status angezeigt.
|
||||
function buildAttendanceControl(a, onDone) {
|
||||
if (!a.eventDate) return null;
|
||||
if (a.attendanceStatus) {
|
||||
const label = a.attendanceStatus === 'PRESENT' ? '✓ War da' : '✕ War nicht da';
|
||||
const span = el('span', 'invite-status-chip', `${label}${a.attendanceMarkedBy ? ' · ' + a.attendanceMarkedBy.fullName.split(' ')[0] : ''}`);
|
||||
return span;
|
||||
}
|
||||
const wrap = el('div');
|
||||
wrap.style.cssText = 'display:flex; gap:6px;';
|
||||
const presentBtn = document.createElement('button');
|
||||
presentBtn.className = 'btn-secondary';
|
||||
presentBtn.textContent = 'War da';
|
||||
presentBtn.onclick = () => markAnnouncementAttendance(a.id, 'PRESENT', onDone);
|
||||
const absentBtn = document.createElement('button');
|
||||
absentBtn.className = 'btn-secondary';
|
||||
absentBtn.textContent = 'War nicht da';
|
||||
absentBtn.onclick = () => markAnnouncementAttendance(a.id, 'ABSENT', onDone);
|
||||
wrap.appendChild(presentBtn);
|
||||
wrap.appendChild(absentBtn);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
async function markAnnouncementAttendance(id, status, onDone) {
|
||||
try {
|
||||
await apiFetch(`/announcements/${id}/attendance`, { method: 'POST', body: JSON.stringify({ status }) });
|
||||
if (onDone) onDone();
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function renderAnnouncements(announcements) {
|
||||
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
|
||||
const list = document.getElementById('announcementList');
|
||||
@ -3219,13 +3266,18 @@
|
||||
left.appendChild(el('div', 'meta-text',
|
||||
`${a.eventDate ? new Date(a.eventDate).toLocaleDateString('de-DE') + ' · ' : ''}${a.message || ''}`));
|
||||
row.appendChild(left);
|
||||
const right = el('div');
|
||||
right.style.cssText = 'display:flex; gap:8px; align-items:center;';
|
||||
const attendance = buildAttendanceControl(a, () => { loadAnnouncements(); loadNotifications(); loadCalendar(); });
|
||||
if (attendance) right.appendChild(attendance);
|
||||
if (isLandlord) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'btn-secondary';
|
||||
btn.textContent = 'Löschen';
|
||||
btn.onclick = () => deleteAnnouncement(a.id);
|
||||
row.appendChild(btn);
|
||||
right.appendChild(btn);
|
||||
}
|
||||
row.appendChild(right);
|
||||
list.appendChild(row);
|
||||
});
|
||||
}
|
||||
@ -3308,6 +3360,7 @@
|
||||
date: a.eventDate ? new Date(a.eventDate) : new Date(a.createdAt),
|
||||
icon: '📢',
|
||||
text: `${a.title}${a.eventDate ? ' am ' + new Date(a.eventDate).toLocaleDateString('de-DE') : ''}${a.message ? ' · ' + a.message : ''}`,
|
||||
extraNode: buildAttendanceControl(a, () => { loadNotifications(); loadAnnouncements(); loadCalendar(); }),
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
@ -3332,6 +3385,7 @@
|
||||
btn.onclick = item.onAction;
|
||||
row.appendChild(btn);
|
||||
}
|
||||
if (item.extraNode) row.appendChild(item.extraNode);
|
||||
list.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user