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 { Router, Response } from 'express';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient, AttendanceStatus } 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();
|
||||||
@ -18,6 +18,9 @@ const ANNOUNCEMENT_SELECT = {
|
|||||||
eventDate: true,
|
eventDate: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
createdBy: { select: { id: true, fullName: true } },
|
createdBy: { select: { id: true, fullName: true } },
|
||||||
|
attendanceStatus: true,
|
||||||
|
attendanceMarkedAt: true,
|
||||||
|
attendanceMarkedBy: { select: { id: true, fullName: true } },
|
||||||
};
|
};
|
||||||
|
|
||||||
announcementsRouter.post(
|
announcementsRouter.post(
|
||||||
@ -56,6 +59,31 @@ announcementsRouter.get('/announcements', requireAuth, async (_req: AuthedReques
|
|||||||
res.status(200).json({ announcements });
|
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(
|
announcementsRouter.delete(
|
||||||
'/announcements/:id',
|
'/announcements/:id',
|
||||||
requireAuth,
|
requireAuth,
|
||||||
|
|||||||
@ -189,6 +189,7 @@ model User {
|
|||||||
accessCodeAssigned AccessCode[] @relation("AccessCodeUser")
|
accessCodeAssigned AccessCode[] @relation("AccessCodeUser")
|
||||||
accessCodesGiven AccessCode[] @relation("AccessCodeAssignedBy")
|
accessCodesGiven AccessCode[] @relation("AccessCodeAssignedBy")
|
||||||
announcementsMade Announcement[]
|
announcementsMade Announcement[]
|
||||||
|
announcementAttendanceMarked Announcement[] @relation("AnnouncementAttendanceMarkedBy")
|
||||||
trashCompleted TrashSchedule[]
|
trashCompleted TrashSchedule[]
|
||||||
notifications Notification[]
|
notifications Notification[]
|
||||||
invitationsSent Invitation[] @relation("InvitationsSent")
|
invitationsSent Invitation[] @relation("InvitationsSent")
|
||||||
@ -423,11 +424,24 @@ model Announcement {
|
|||||||
createdById String @map("created_by_id")
|
createdById String @map("created_by_id")
|
||||||
createdBy User @relation(fields: [createdById], references: [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")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
@@map("announcements")
|
@@map("announcements")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum AttendanceStatus {
|
||||||
|
PRESENT
|
||||||
|
ABSENT
|
||||||
|
}
|
||||||
|
|
||||||
// Frei erweiterbare Liste der Putzplan-Bereiche. Ersetzt das frühere feste
|
// Frei erweiterbare Liste der Putzplan-Bereiche. Ersetzt das frühere feste
|
||||||
// Enum, damit der Vermieter im Dashboard eigene Bereiche anlegen kann, ohne
|
// Enum, damit der Vermieter im Dashboard eigene Bereiche anlegen kann, ohne
|
||||||
// dass ein Code-Deploy nötig ist.
|
// 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-verified { background: var(--green-bg, #E4F3EA); color: var(--green, #2f8f5b); }
|
||||||
.calendar-badge-task-missed { background: #FBE0DE; color: #C23B2E; }
|
.calendar-badge-task-missed { background: #FBE0DE; color: #C23B2E; }
|
||||||
.calendar-badge-absence { background: var(--yellow-bg, #FDF3DC); color: var(--yellow, #B4780A); }
|
.calendar-badge-absence { background: var(--yellow-bg, #FDF3DC); color: var(--yellow, #B4780A); }
|
||||||
|
.calendar-badge-announcement { background: #F4E3FB; color: #8A2EBE; }
|
||||||
|
|
||||||
.inventory-thumb {
|
.inventory-thumb {
|
||||||
width: 48px;
|
width: 48px;
|
||||||
@ -823,7 +824,7 @@
|
|||||||
<section id="cleaningCalendarSection">
|
<section id="cleaningCalendarSection">
|
||||||
<h2>Putzplan-Kalender & Abwesenheiten</h2>
|
<h2>Putzplan-Kalender & Abwesenheiten</h2>
|
||||||
<p style="color:var(--text-muted); font-size:13px; margin-top:-8px;">
|
<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>
|
</p>
|
||||||
<div style="display:flex; flex-wrap:wrap; gap:10px; margin-bottom:12px; font-size:13px;">
|
<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>
|
<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-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-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-absence" style="padding:3px 8px;">Abwesend</span>
|
||||||
|
<span class="calendar-badge calendar-badge-announcement" style="padding:3px 8px;">📢 Ankündigung</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="invite-card" style="margin-bottom:16px;">
|
<div class="invite-card" style="margin-bottom:16px;">
|
||||||
<div id="absenceFormError" style="display:none" class="form-error"></div>
|
<div id="absenceFormError" style="display:none" class="form-error"></div>
|
||||||
@ -2037,6 +2039,7 @@
|
|||||||
const WEEKDAY_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
const WEEKDAY_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||||||
let calendarMonthDate = new Date(new Date().getFullYear(), new Date().getMonth(), 1);
|
let calendarMonthDate = new Date(new Date().getFullYear(), new Date().getMonth(), 1);
|
||||||
let calendarData = { tasks: [], absences: [] };
|
let calendarData = { tasks: [], absences: [] };
|
||||||
|
let calendarAnnouncements = [];
|
||||||
|
|
||||||
function initAbsenceForm() {
|
function initAbsenceForm() {
|
||||||
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
|
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
|
||||||
@ -2098,10 +2101,16 @@
|
|||||||
try {
|
try {
|
||||||
const data = await apiFetch(`/cleaning-calendar?from=${from}&to=${to}`);
|
const data = await apiFetch(`/cleaning-calendar?from=${from}&to=${to}`);
|
||||||
calendarData = data;
|
calendarData = data;
|
||||||
renderCalendar();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[cleaning-calendar] Laden fehlgeschlagen:', err.message);
|
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() {
|
function renderCalendar() {
|
||||||
@ -2149,6 +2158,10 @@
|
|||||||
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 => {
|
||||||
cell.appendChild(el('div', 'calendar-badge calendar-badge-absence', `${a.user.fullName.split(' ')[0]} abwesend`));
|
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);
|
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) {
|
function renderAnnouncements(announcements) {
|
||||||
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
|
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
|
||||||
const list = document.getElementById('announcementList');
|
const list = document.getElementById('announcementList');
|
||||||
@ -3219,13 +3266,18 @@
|
|||||||
left.appendChild(el('div', 'meta-text',
|
left.appendChild(el('div', 'meta-text',
|
||||||
`${a.eventDate ? new Date(a.eventDate).toLocaleDateString('de-DE') + ' · ' : ''}${a.message || ''}`));
|
`${a.eventDate ? new Date(a.eventDate).toLocaleDateString('de-DE') + ' · ' : ''}${a.message || ''}`));
|
||||||
row.appendChild(left);
|
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) {
|
if (isLandlord) {
|
||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
btn.className = 'btn-secondary';
|
btn.className = 'btn-secondary';
|
||||||
btn.textContent = 'Löschen';
|
btn.textContent = 'Löschen';
|
||||||
btn.onclick = () => deleteAnnouncement(a.id);
|
btn.onclick = () => deleteAnnouncement(a.id);
|
||||||
row.appendChild(btn);
|
right.appendChild(btn);
|
||||||
}
|
}
|
||||||
|
row.appendChild(right);
|
||||||
list.appendChild(row);
|
list.appendChild(row);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -3308,6 +3360,7 @@
|
|||||||
date: a.eventDate ? new Date(a.eventDate) : new Date(a.createdAt),
|
date: a.eventDate ? new Date(a.eventDate) : new Date(a.createdAt),
|
||||||
icon: '📢',
|
icon: '📢',
|
||||||
text: `${a.title}${a.eventDate ? ' am ' + new Date(a.eventDate).toLocaleDateString('de-DE') : ''}${a.message ? ' · ' + a.message : ''}`,
|
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) {
|
} catch (err) {
|
||||||
@ -3332,6 +3385,7 @@
|
|||||||
btn.onclick = item.onAction;
|
btn.onclick = item.onAction;
|
||||||
row.appendChild(btn);
|
row.appendChild(btn);
|
||||||
}
|
}
|
||||||
|
if (item.extraNode) row.appendChild(item.extraNode);
|
||||||
list.appendChild(row);
|
list.appendChild(row);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user