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 { 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' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@ -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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user