diff --git a/web-dashboard/index.html b/web-dashboard/index.html
index 7911d5e..eea30a5 100644
--- a/web-dashboard/index.html
+++ b/web-dashboard/index.html
@@ -282,6 +282,43 @@
.invite-status-chip.PENDING { background: var(--yellow-bg); color: var(--yellow); }
.invite-status-chip.ACCEPTED { background: var(--green-bg); color: var(--green); }
.invite-status-chip.EXPIRED, .invite-status-chip.REVOKED { background: #EEF0F2; color: var(--text-muted); }
+
+ .calendar-grid {
+ display: grid;
+ grid-template-columns: repeat(7, 1fr);
+ gap: 6px;
+ }
+ .calendar-weekday {
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ color: var(--text-muted);
+ text-align: center;
+ padding-bottom: 4px;
+ }
+ .calendar-day {
+ background: var(--card-bg, #fff);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ min-height: 78px;
+ padding: 6px;
+ font-size: 11.5px;
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ }
+ .calendar-day.calendar-day-outside { opacity: 0.35; }
+ .calendar-day.calendar-day-today { border-color: var(--green, #2f8f5b); border-width: 2px; }
+ .calendar-day-num { font-weight: 700; font-size: 12px; }
+ .calendar-badge {
+ border-radius: 5px;
+ padding: 1px 5px;
+ font-size: 10.5px;
+ line-height: 1.4;
+ white-space: normal;
+ }
+ .calendar-badge-task { background: var(--green-bg, #E4F3EA); color: var(--green, #2f8f5b); }
+ .calendar-badge-absence { background: var(--yellow-bg, #FDF3DC); color: var(--yellow, #B4780A); }
@@ -458,6 +495,46 @@
Putzplan
+
+
+ Putzplan-Kalender & Abwesenheiten
+
+ Zeigt für alle sichtbar, wer wann für welchen Bereich zuständig ist, sowie eingetragene Abwesenheiten (Urlaub, "kann nicht").
+
+
+
+
+
+
+
+
+
+
+
+ Abwesenheiten im angezeigten Monat
+
+
@@ -647,11 +724,16 @@
if (isLandlord) initInvitePanel();
if (isLandlord) initCleaningAssignForm();
+ document.getElementById('absenceUserField').style.display = isLandlord ? 'flex' : 'none';
+
initTicketForm();
+ initAbsenceForm();
loadData();
loadCleaningTasks();
+ loadCalendar();
setInterval(loadData, REFRESH_INTERVAL_MS);
setInterval(loadCleaningTasks, REFRESH_INTERVAL_MS);
+ setInterval(loadCalendar, REFRESH_INTERVAL_MS);
}
// ---------------------------------------------------------------------
@@ -1123,6 +1205,163 @@
}
}
+ // ---------------------------------------------------------------------
+ // PUTZPLAN-KALENDER & ABWESENHEITEN
+ // ---------------------------------------------------------------------
+
+ 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: [] };
+
+ function initAbsenceForm() {
+ const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
+ const userSelect = document.getElementById('absenceUser');
+ if (isLandlord) {
+ userSelect.innerHTML = '';
+ apiFetch('/tenants').then(data => {
+ data.tenants.forEach(t => {
+ const opt = document.createElement('option');
+ opt.value = t.id;
+ opt.textContent = t.room ? `${t.fullName} (${t.room.roomNumber})` : t.fullName;
+ userSelect.appendChild(opt);
+ });
+ }).catch(err => console.error('[cleaning-absences] Mieterliste laden fehlgeschlagen:', err.message));
+ }
+
+ const form = document.getElementById('absenceForm');
+ const errorBox = document.getElementById('absenceFormError');
+ form.onsubmit = async (e) => {
+ e.preventDefault();
+ errorBox.style.display = 'none';
+ const submitBtn = document.getElementById('absenceSubmitBtn');
+ submitBtn.disabled = true;
+ submitBtn.textContent = 'Trägt ein…';
+ try {
+ const startDate = document.getElementById('absenceStart').value;
+ const endDate = document.getElementById('absenceEnd').value;
+ const note = document.getElementById('absenceNote').value;
+ const body = { startDate, endDate, note };
+ if (isLandlord) body.userId = userSelect.value;
+ await apiFetch('/cleaning-absences', { method: 'POST', body: JSON.stringify(body) });
+ form.reset();
+ loadCalendar();
+ } catch (err) {
+ errorBox.textContent = err.message;
+ errorBox.style.display = 'block';
+ } finally {
+ submitBtn.disabled = false;
+ submitBtn.textContent = 'Eintragen';
+ }
+ };
+
+ document.getElementById('calPrevBtn').onclick = () => {
+ calendarMonthDate = new Date(calendarMonthDate.getFullYear(), calendarMonthDate.getMonth() - 1, 1);
+ loadCalendar();
+ };
+ document.getElementById('calNextBtn').onclick = () => {
+ calendarMonthDate = new Date(calendarMonthDate.getFullYear(), calendarMonthDate.getMonth() + 1, 1);
+ loadCalendar();
+ };
+ }
+
+ async function loadCalendar() {
+ if (!currentUser) return;
+ const monthStart = new Date(calendarMonthDate.getFullYear(), calendarMonthDate.getMonth(), 1);
+ const monthEnd = new Date(calendarMonthDate.getFullYear(), calendarMonthDate.getMonth() + 1, 0);
+ const from = monthStart.toISOString().slice(0, 10);
+ const to = monthEnd.toISOString().slice(0, 10);
+ try {
+ const data = await apiFetch(`/cleaning-calendar?from=${from}&to=${to}`);
+ calendarData = data;
+ renderCalendar();
+ } catch (err) {
+ console.error('[cleaning-calendar] Laden fehlgeschlagen:', err.message);
+ }
+ }
+
+ function renderCalendar() {
+ const weekdaysEl = document.getElementById('calendarWeekdays');
+ weekdaysEl.innerHTML = '';
+ WEEKDAY_LABELS.forEach(d => weekdaysEl.appendChild(el('div', 'calendar-weekday', d)));
+
+ document.getElementById('calMonthLabel').textContent =
+ calendarMonthDate.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
+
+ const grid = document.getElementById('calendarGrid');
+ grid.innerHTML = '';
+
+ const year = calendarMonthDate.getFullYear();
+ const month = calendarMonthDate.getMonth();
+ const firstOfMonth = new Date(year, month, 1);
+ // Montag = 0 ... Sonntag = 6
+ const leadingBlanks = (firstOfMonth.getDay() + 6) % 7;
+ const daysInMonth = new Date(year, month + 1, 0).getDate();
+ const todayStr = new Date().toDateString();
+
+ const cellDate = new Date(year, month, 1 - leadingBlanks);
+ const totalCells = Math.ceil((leadingBlanks + daysInMonth) / 7) * 7;
+
+ for (let i = 0; i < totalCells; i++) {
+ const d = new Date(cellDate.getFullYear(), cellDate.getMonth(), cellDate.getDate() + i);
+ const isOutside = d.getMonth() !== month;
+ const cell = el('div', 'calendar-day' + (isOutside ? ' calendar-day-outside' : '') +
+ (d.toDateString() === todayStr ? ' calendar-day-today' : ''));
+ 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] : ''}`;
+ 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 => {
+ cell.appendChild(el('div', 'calendar-badge calendar-badge-absence', `${a.user.fullName.split(' ')[0]} abwesend`));
+ });
+
+ grid.appendChild(cell);
+ }
+
+ const absenceList = document.getElementById('absenceList');
+ absenceList.innerHTML = '';
+ if (!calendarData.absences.length) {
+ absenceList.appendChild(el('div', 'empty-state', 'Keine Abwesenheiten in diesem Monat eingetragen.'));
+ } else {
+ const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
+ calendarData.absences.forEach(a => {
+ const row = el('div', 'ticket-row');
+ const left = el('div');
+ left.appendChild(el('div', 'title', a.user.fullName));
+ left.appendChild(el('div', 'meta-text',
+ `${new Date(a.startDate).toLocaleDateString('de-DE')} – ${new Date(a.endDate).toLocaleDateString('de-DE')}` +
+ (a.note ? ` · ${a.note}` : '')));
+ row.appendChild(left);
+ if (isLandlord || a.user.id === currentUser.id) {
+ const btn = document.createElement('button');
+ btn.className = 'btn-secondary';
+ btn.textContent = 'Löschen';
+ btn.onclick = () => deleteAbsence(a.id);
+ row.appendChild(btn);
+ }
+ absenceList.appendChild(row);
+ });
+ }
+ }
+
+ function stripTime(d) {
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate());
+ }
+
+ function sameDay(a, b) {
+ return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
+ }
+
+ async function deleteAbsence(id) {
+ try {
+ await apiFetch(`/cleaning-absences/${id}`, { method: 'DELETE' });
+ loadCalendar();
+ } catch (err) {
+ alert(err.message);
+ }
+ }
+
bootstrap();