Add Putzplan calendar/absences UI
This commit is contained in:
parent
9c99d3b631
commit
7bfa45a400
@ -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); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@ -458,6 +495,46 @@
|
||||
<h2 id="cleaningSectionTitle">Putzplan</h2>
|
||||
<div class="ticket-list" id="cleaningList"></div>
|
||||
</section>
|
||||
|
||||
<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, 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>
|
||||
</div>
|
||||
|
||||
@ -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();
|
||||
</script>
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user