Add Putzplan calendar/absences UI

This commit is contained in:
Giuseppe Lombardo 2026-08-12 19:57:54 +00:00
parent 9c99d3b631
commit 7bfa45a400

View File

@ -282,6 +282,43 @@
.invite-status-chip.PENDING { background: var(--yellow-bg); color: var(--yellow); } .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.ACCEPTED { background: var(--green-bg); color: var(--green); }
.invite-status-chip.EXPIRED, .invite-status-chip.REVOKED { background: #EEF0F2; color: var(--text-muted); } .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); }
</style> </style>
</head> </head>
<body> <body>
@ -458,6 +495,46 @@
<h2 id="cleaningSectionTitle">Putzplan</h2> <h2 id="cleaningSectionTitle">Putzplan</h2>
<div class="ticket-list" id="cleaningList"></div> <div class="ticket-list" id="cleaningList"></div>
</section> </section>
<section id="cleaningCalendarSection">
<h2>Putzplan-Kalender &amp; 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, sowie eingetragene Abwesenheiten (Urlaub, "kann nicht").
</p>
<div class="invite-card" style="margin-bottom:16px;">
<div id="absenceFormError" style="display:none" class="form-error"></div>
<form id="absenceForm" class="invite-form-row" style="flex-wrap:wrap;">
<div class="field" id="absenceUserField" style="flex:1 1 200px; display:none;">
<label for="absenceUser">Mieter</label>
<select id="absenceUser"></select>
</div>
<div class="field" style="flex:1 1 150px;">
<label for="absenceStart">Von</label>
<input type="date" id="absenceStart" required />
</div>
<div class="field" style="flex:1 1 150px;">
<label for="absenceEnd">Bis</label>
<input type="date" id="absenceEnd" required />
</div>
<div class="field" style="flex:2 1 220px;">
<label for="absenceNote">Notiz (optional)</label>
<input type="text" id="absenceNote" maxlength="280" placeholder="z. B. Urlaub" />
</div>
<button type="submit" class="btn-primary" style="width:auto" id="absenceSubmitBtn">Eintragen</button>
</form>
</div>
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:10px;">
<button type="button" class="btn-secondary" id="calPrevBtn"> Vorheriger Monat</button>
<strong id="calMonthLabel"></strong>
<button type="button" class="btn-secondary" id="calNextBtn">Nächster Monat </button>
</div>
<div class="calendar-grid" id="calendarWeekdays"></div>
<div class="calendar-grid" id="calendarGrid" style="margin-top:6px;"></div>
<h3 style="margin-top:20px; font-size:14px;">Abwesenheiten im angezeigten Monat</h3>
<div class="ticket-list" id="absenceList"></div>
</section>
</main> </main>
</div> </div>
@ -647,11 +724,16 @@
if (isLandlord) initInvitePanel(); if (isLandlord) initInvitePanel();
if (isLandlord) initCleaningAssignForm(); if (isLandlord) initCleaningAssignForm();
document.getElementById('absenceUserField').style.display = isLandlord ? 'flex' : 'none';
initTicketForm(); initTicketForm();
initAbsenceForm();
loadData(); loadData();
loadCleaningTasks(); loadCleaningTasks();
loadCalendar();
setInterval(loadData, REFRESH_INTERVAL_MS); setInterval(loadData, REFRESH_INTERVAL_MS);
setInterval(loadCleaningTasks, 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(); bootstrap();
</script> </script>