Replace flat trash list with a real month-calendar view (FullCalendar)

Rebuilds the "Müll-Kalender" as a proper color-coded FullCalendar (v6,
MIT-licensed, CDN, no build step) month grid instead of a bare list of
type+date rows. Adds a manual "Kalender aktualisieren" action for
landlords (POST /v1/trash-schedule/sync) that re-runs the same KAW
sync job as the daily cron, instead of letting them delete individual
calendar entries by clicking — accidental single-click deletion of
official data was too easy and had no real justification, since a
missing date just gets re-added by the next sync anyway.
This commit is contained in:
Giuseppe Lombardo 2026-08-13 09:20:54 +00:00
parent 39220bddf4
commit aa59ab452a
2 changed files with 116 additions and 27 deletions

View File

@ -1,6 +1,7 @@
import { Router, Response } from 'express'; import { Router, Response } from 'express';
import { PrismaClient, StorageLocation, TrashType } from '@prisma/client'; import { PrismaClient, StorageLocation, TrashType } from '@prisma/client';
import { AuthedRequest, requireAuth, requireRole } from '../middleware/auth'; import { AuthedRequest, requireAuth, requireRole } from '../middleware/auth';
import { runTrashCalendarSyncJob } from '../jobs/trashCalendarSyncJob';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
@ -158,3 +159,20 @@ storageRouter.delete(
} }
}, },
); );
// Manuelles Nachladen der amtlichen Nackenheim-Termine (dieselbe Quelle wie
// der tägliche Host-Cron, siehe jobs/trashCalendarSyncJob.ts) — für den Fall,
// dass der Vermieter nicht bis zum nächsten automatischen Lauf warten will.
storageRouter.post(
'/trash-schedule/sync',
requireAuth,
requireRole('LANDLORD', 'ADMIN'),
async (_req: AuthedRequest, res: Response) => {
try {
const result = await runTrashCalendarSyncJob();
res.status(200).json(result);
} catch (err) {
res.status(502).json({ error: err instanceof Error ? err.message : 'Sync fehlgeschlagen' });
}
},
);

View File

@ -4,6 +4,8 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WG Nackenheim — Vermieter-Cockpit</title> <title>WG Nackenheim — Vermieter-Cockpit</title>
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@fullcalendar/core@6.1.15/locales/de.global.min.js"></script>
<style> <style>
:root { :root {
--bg: #F5F6F5; --bg: #F5F6F5;
@ -329,6 +331,34 @@
} }
.inventory-thumb-row { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px; } .inventory-thumb-row { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px; }
/* --- Müll-Kalender (FullCalendar) --- */
.trash-calendar-wrap {
background: var(--card-bg);
border-radius: 14px;
padding: 16px 18px;
box-shadow: var(--shadow);
}
.trash-calendar-legend { display: flex; gap: 14px; flex-wrap: wrap; margin-bottom: 14px; font-size: 12.5px; color: var(--text-muted); }
.trash-calendar-legend span { display: inline-flex; align-items: center; gap: 6px; }
.trash-calendar-legend .dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
#trashCalendarEl { font-size: 13px; }
#trashCalendarEl .fc { font-family: inherit; }
#trashCalendarEl .fc-toolbar-title { font-size: 16px; font-weight: 700; color: var(--text); }
#trashCalendarEl .fc-button {
background: var(--card-bg);
border: 1px solid var(--border);
color: var(--text);
box-shadow: none;
text-transform: capitalize;
padding: 6px 12px;
}
#trashCalendarEl .fc-button:hover { background: var(--bg); }
#trashCalendarEl .fc-button-primary:not(:disabled).fc-button-active { background: var(--green); border-color: var(--green); color: #fff; }
#trashCalendarEl .fc-daygrid-day.fc-day-today { background: var(--green-bg); }
#trashCalendarEl .fc-event { border: none; padding: 1px 5px; font-size: 11.5px; cursor: default; }
#trashCalendarEl .fc-daygrid-event-dot { display: none; }
#trashCalendarEl a { text-decoration: none; }
/* --- App shell / sidebar navigation --- */ /* --- App shell / sidebar navigation --- */
.app-shell { display: flex; align-items: flex-start; min-height: 100vh; } .app-shell { display: flex; align-items: flex-start; min-height: 100vh; }
.sidebar { .sidebar {
@ -861,7 +891,15 @@
<button type="submit" class="btn-primary" style="width:auto" id="trashSubmitBtn">Termin eintragen</button> <button type="submit" class="btn-primary" style="width:auto" id="trashSubmitBtn">Termin eintragen</button>
</form> </form>
</div> </div>
<div class="ticket-list" id="trashList"></div> <div style="display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; margin:-4px 0 12px;">
<p class="meta-text" style="margin:0;">Restmüll, Biomüll, Gelbe Tonne und Papiertonne werden automatisch täglich von der Kommunalen Abfallwirtschaft Mainz-Bingen übernommen.</p>
<button type="button" class="btn-secondary" id="trashSyncBtn" style="display:none; white-space:nowrap;">↻ Kalender aktualisieren</button>
</div>
<div id="trashSyncStatus" class="meta-text" style="display:none; margin:-6px 0 12px;"></div>
<div class="trash-calendar-wrap">
<div class="trash-calendar-legend" id="trashCalendarLegend"></div>
<div id="trashCalendarEl"></div>
</div>
</section> </section>
</div> </div>
@ -956,6 +994,7 @@
const GUEST_CODE_STATUS_LABEL = { ACTIVE: 'Aktiv', EXPIRED: 'Abgelaufen', REVOKED: 'Widerrufen' }; const GUEST_CODE_STATUS_LABEL = { ACTIVE: 'Aktiv', EXPIRED: 'Abgelaufen', REVOKED: 'Widerrufen' };
const STORAGE_LOCATION_LABEL = { FRIDGE: 'Kühlschrank', FREEZER: 'Gefrierfach', KITCHEN_CABINET_1: 'Küchenschrank 1', KITCHEN_CABINET_2: 'Küchenschrank 2', KITCHEN_CABINET_3: 'Küchenschrank 3', PANTRY: 'Vorratsschrank' }; const STORAGE_LOCATION_LABEL = { FRIDGE: 'Kühlschrank', FREEZER: 'Gefrierfach', KITCHEN_CABINET_1: 'Küchenschrank 1', KITCHEN_CABINET_2: 'Küchenschrank 2', KITCHEN_CABINET_3: 'Küchenschrank 3', PANTRY: 'Vorratsschrank' };
const TRASH_TYPE_LABEL = { RESTMUELL: 'Restmüll', BIOMUELL: 'Biomüll', GELBER_SACK: 'Gelber Sack', PAPIER: 'Papier', GLAS: 'Glas' }; const TRASH_TYPE_LABEL = { RESTMUELL: 'Restmüll', BIOMUELL: 'Biomüll', GELBER_SACK: 'Gelber Sack', PAPIER: 'Papier', GLAS: 'Glas' };
const TRASH_TYPE_COLOR = { RESTMUELL: '#4A4A4A', BIOMUELL: '#8B5E34', GELBER_SACK: '#E0B400', PAPIER: '#2F80ED', GLAS: '#27AE60' };
const DOCUMENT_CATEGORY_LABEL = { HAUSORDNUNG: 'Hausordnung', WLAN: 'WLAN', VERTRAG: 'Vertrag', TUTORIAL: 'Tutorial', SONSTIGES: 'Sonstiges' }; const DOCUMENT_CATEGORY_LABEL = { HAUSORDNUNG: 'Hausordnung', WLAN: 'WLAN', VERTRAG: 'Vertrag', TUTORIAL: 'Tutorial', SONSTIGES: 'Sonstiges' };
const CRAFTSMAN_TRADE_LABEL = { SANITAER: 'Sanitär', ELEKTRIK: 'Elektrik', HEIZUNG: 'Heizung', SCHREINEREI: 'Schreinerei', MALER: 'Maler', SCHLUESSELDIENST: 'Schlüsseldienst', SONSTIGES: 'Sonstiges' }; const CRAFTSMAN_TRADE_LABEL = { SANITAER: 'Sanitär', ELEKTRIK: 'Elektrik', HEIZUNG: 'Heizung', SCHREINEREI: 'Schreinerei', MALER: 'Maler', SCHLUESSELDIENST: 'Schlüsseldienst', SONSTIGES: 'Sonstiges' };
const SETTLEMENT_STATUS_LABEL = { OPEN: 'Offen', SETTLED: 'Beglichen' }; const SETTLEMENT_STATUS_LABEL = { OPEN: 'Offen', SETTLED: 'Beglichen' };
@ -1128,6 +1167,10 @@
if (activeBtn) activeBtn.classList.add('active'); if (activeBtn) activeBtn.classList.add('active');
closeSidebar(); closeSidebar();
window.scrollTo({ top: 0, behavior: 'instant' in window ? 'instant' : 'auto' }); window.scrollTo({ top: 0, behavior: 'instant' in window ? 'instant' : 'auto' });
// FullCalendar berechnet seine Breite beim Erstellen; war die Seite beim
// ersten Laden der Daten noch nicht sichtbar (display:none), muss es nach
// dem Einblenden einmal neu vermessen werden.
if (pageId === 'page-household' && trashCalendar) trashCalendar.updateSize();
} }
function openSidebar() { function openSidebar() {
@ -2413,6 +2456,8 @@
trashSubmitBtn.textContent = 'Termin eintragen'; trashSubmitBtn.textContent = 'Termin eintragen';
} }
}; };
document.getElementById('trashSyncBtn').onclick = syncTrashCalendar;
} }
async function loadStorage() { async function loadStorage() {
@ -2499,39 +2544,65 @@
} }
} }
let trashCalendar = null;
function renderTrashSchedule(entries) { function renderTrashSchedule(entries) {
const list = document.getElementById('trashList');
list.innerHTML = '';
const now = stripTime(new Date());
const upcoming = entries.filter(e => stripTime(new Date(e.date)) >= now);
if (!upcoming.length) {
list.appendChild(el('div', 'empty-state', 'Keine anstehenden Mülltermine.'));
return;
}
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN'; const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
upcoming.forEach(t => { document.getElementById('trashSyncBtn').style.display = isLandlord ? 'inline-block' : 'none';
const row = el('div', 'ticket-row');
const left = el('div'); const legend = document.getElementById('trashCalendarLegend');
left.appendChild(el('div', 'title', TRASH_TYPE_LABEL[t.type] || t.type)); legend.innerHTML = '';
left.appendChild(el('div', 'meta-text', new Date(t.date).toLocaleDateString('de-DE'))); const typesPresent = new Set(entries.map(e => e.type));
row.appendChild(left); Object.keys(TRASH_TYPE_LABEL).filter(t => typesPresent.has(t)).forEach(t => {
if (isLandlord) { const item = el('span');
const btn = document.createElement('button'); const dot = el('span', 'dot');
btn.className = 'btn-secondary'; dot.style.background = TRASH_TYPE_COLOR[t] || '#888';
btn.textContent = 'Löschen'; item.appendChild(dot);
btn.onclick = () => deleteTrashEntry(t.id); item.appendChild(document.createTextNode(TRASH_TYPE_LABEL[t] || t));
row.appendChild(btn); legend.appendChild(item);
});
if (!trashCalendar) {
trashCalendar = new FullCalendar.Calendar(document.getElementById('trashCalendarEl'), {
locale: 'de',
firstDay: 1,
height: 'auto',
headerToolbar: { left: 'prev,next today', center: 'title', right: '' },
events: [],
});
trashCalendar.render();
} }
list.appendChild(row);
trashCalendar.removeAllEvents();
entries.forEach(t => {
trashCalendar.addEvent({
id: t.id,
title: TRASH_TYPE_LABEL[t.type] || t.type,
start: (t.date || '').slice(0, 10),
allDay: true,
backgroundColor: TRASH_TYPE_COLOR[t.type] || '#888',
borderColor: TRASH_TYPE_COLOR[t.type] || '#888',
});
}); });
} }
async function deleteTrashEntry(id) { async function syncTrashCalendar() {
const btn = document.getElementById('trashSyncBtn');
const status = document.getElementById('trashSyncStatus');
btn.disabled = true;
btn.textContent = 'Lädt…';
status.style.display = 'none';
try { try {
await apiFetch(`/trash-schedule/${id}`, { method: 'DELETE' }); const result = await apiFetch('/trash-schedule/sync', { method: 'POST' });
loadStorage(); status.textContent = `${result.fetched} Termine von der Kommunalen Abfallwirtschaft geladen, ${result.created} neu übernommen.`;
status.style.display = 'block';
await loadStorage();
} catch (err) { } catch (err) {
alert(err.message); status.textContent = `Aktualisierung fehlgeschlagen: ${err.message}`;
status.style.display = 'block';
} finally {
btn.disabled = false;
btn.textContent = '↻ Kalender aktualisieren';
} }
} }