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:
parent
39220bddf4
commit
aa59ab452a
@ -1,6 +1,7 @@
|
||||
import { Router, Response } from 'express';
|
||||
import { PrismaClient, StorageLocation, TrashType } from '@prisma/client';
|
||||
import { AuthedRequest, requireAuth, requireRole } from '../middleware/auth';
|
||||
import { runTrashCalendarSyncJob } from '../jobs/trashCalendarSyncJob';
|
||||
|
||||
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' });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@ -4,6 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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>
|
||||
:root {
|
||||
--bg: #F5F6F5;
|
||||
@ -329,6 +331,34 @@
|
||||
}
|
||||
.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 { display: flex; align-items: flex-start; min-height: 100vh; }
|
||||
.sidebar {
|
||||
@ -861,7 +891,15 @@
|
||||
<button type="submit" class="btn-primary" style="width:auto" id="trashSubmitBtn">Termin eintragen</button>
|
||||
</form>
|
||||
</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>
|
||||
|
||||
</div>
|
||||
@ -956,6 +994,7 @@
|
||||
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 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 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' };
|
||||
@ -1128,6 +1167,10 @@
|
||||
if (activeBtn) activeBtn.classList.add('active');
|
||||
closeSidebar();
|
||||
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() {
|
||||
@ -2413,6 +2456,8 @@
|
||||
trashSubmitBtn.textContent = 'Termin eintragen';
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById('trashSyncBtn').onclick = syncTrashCalendar;
|
||||
}
|
||||
|
||||
async function loadStorage() {
|
||||
@ -2499,39 +2544,65 @@
|
||||
}
|
||||
}
|
||||
|
||||
let trashCalendar = null;
|
||||
|
||||
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';
|
||||
upcoming.forEach(t => {
|
||||
const row = el('div', 'ticket-row');
|
||||
const left = el('div');
|
||||
left.appendChild(el('div', 'title', TRASH_TYPE_LABEL[t.type] || t.type));
|
||||
left.appendChild(el('div', 'meta-text', new Date(t.date).toLocaleDateString('de-DE')));
|
||||
row.appendChild(left);
|
||||
if (isLandlord) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'btn-secondary';
|
||||
btn.textContent = 'Löschen';
|
||||
btn.onclick = () => deleteTrashEntry(t.id);
|
||||
row.appendChild(btn);
|
||||
document.getElementById('trashSyncBtn').style.display = isLandlord ? 'inline-block' : 'none';
|
||||
|
||||
const legend = document.getElementById('trashCalendarLegend');
|
||||
legend.innerHTML = '';
|
||||
const typesPresent = new Set(entries.map(e => e.type));
|
||||
Object.keys(TRASH_TYPE_LABEL).filter(t => typesPresent.has(t)).forEach(t => {
|
||||
const item = el('span');
|
||||
const dot = el('span', 'dot');
|
||||
dot.style.background = TRASH_TYPE_COLOR[t] || '#888';
|
||||
item.appendChild(dot);
|
||||
item.appendChild(document.createTextNode(TRASH_TYPE_LABEL[t] || t));
|
||||
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 {
|
||||
await apiFetch(`/trash-schedule/${id}`, { method: 'DELETE' });
|
||||
loadStorage();
|
||||
const result = await apiFetch('/trash-schedule/sync', { method: 'POST' });
|
||||
status.textContent = `${result.fetched} Termine von der Kommunalen Abfallwirtschaft geladen, ${result.created} neu übernommen.`;
|
||||
status.style.display = 'block';
|
||||
await loadStorage();
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
status.textContent = `Aktualisierung fehlgeschlagen: ${err.message}`;
|
||||
status.style.display = 'block';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '↻ Kalender aktualisieren';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user