diff --git a/web-dashboard/index.html b/web-dashboard/index.html
index eea30a5..06b42bd 100644
--- a/web-dashboard/index.html
+++ b/web-dashboard/index.html
@@ -319,6 +319,15 @@
}
.calendar-badge-task { background: var(--green-bg, #E4F3EA); color: var(--green, #2f8f5b); }
.calendar-badge-absence { background: var(--yellow-bg, #FDF3DC); color: var(--yellow, #B4780A); }
+
+ .inventory-thumb {
+ width: 48px;
+ height: 48px;
+ object-fit: cover;
+ border-radius: 6px;
+ border: 1px solid var(--border);
+ }
+ .inventory-thumb-row { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px; }
@@ -535,6 +544,169 @@
Abwesenheiten im angezeigten Monat
+
+
+ WG-Kasse (Ausgaben-Teiler)
+
+ Auslagen wie Spülmittel oder Klopapier eintragen — der Betrag wird automatisch unter allen Mitbewohnern geteilt.
+
+
+
+
+
+
+
+ Inventarverwaltung
+
+
+
+
+
+ Übergabeprotokolle
+
+
+
+
+
+ Smart-Lock-Gastcodes
+
+
+
+
+
+ Küchen-/Schrankplaner
+
+
+
+ Müll-Kalender
+
+
+
+
+
+ Dokumenten-Safe & Tutorials
+
+
+
@@ -554,6 +726,13 @@
const TICKET_STATUS_LABEL = { OPEN: 'Gemeldet', IN_PROGRESS: 'In Bearbeitung', RESOLVED: 'Erledigt', CLOSED: 'Geschlossen' };
const CLEANING_AREA_LABEL = { BATHROOM_1: 'Bad 1', BATHROOM_2: 'Bad 2', KITCHEN: 'Wohnküche', HALLWAY_LAUNDRY: 'Waschraum/Flur' };
const CLEANING_STATUS_LABEL = { PENDING: 'Offen', COMPLETED: 'Erledigt', VERIFIED: 'Bestätigt', MISSED: 'Verpasst' };
+ const EXPENSE_CATEGORY_LABEL = { HAUSHALT: 'Haushalt', LEBENSMITTEL_GEMEINSAM: 'Lebensmittel (gemeinsam)', REPARATUR_VORAUSLAGE: 'Reparatur-Vorauslage', SONSTIGES: 'Sonstiges' };
+ const ITEM_CONDITION_LABEL = { NEW: 'Neu', GOOD: 'Gut', WEAR_AND_TEAR: 'Abgenutzt', DAMAGED: 'Beschädigt' };
+ const HANDOVER_TYPE_LABEL = { MOVE_IN: 'Einzug', MOVE_OUT: 'Auszug' };
+ 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 DOCUMENT_CATEGORY_LABEL = { HAUSORDNUNG: 'Hausordnung', WLAN: 'WLAN', VERTRAG: 'Vertrag', TUTORIAL: 'Tutorial', SONSTIGES: 'Sonstiges' };
function formatEuro(n) {
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(n || 0);
@@ -726,14 +905,40 @@
document.getElementById('absenceUserField').style.display = isLandlord ? 'flex' : 'none';
+ document.getElementById('inventoryFormCard').style.display = isLandlord ? 'block' : 'none';
+ document.getElementById('handoverFormCard').style.display = isLandlord ? 'block' : 'none';
+ document.getElementById('storageFormCard').style.display = isLandlord ? 'block' : 'none';
+ document.getElementById('trashFormCard').style.display = isLandlord ? 'block' : 'none';
+ document.getElementById('documentFormCard').style.display = isLandlord ? 'block' : 'none';
+
initTicketForm();
initAbsenceForm();
+ initExpenseForm();
+ if (isLandlord) initInventoryForm();
+ if (isLandlord) initHandoverForm();
+ initGuestCodeForm();
+ if (isLandlord) initStorageForms();
+ if (isLandlord) initDocumentForm();
+
loadData();
loadCleaningTasks();
loadCalendar();
+ loadExpenses();
+ loadInventory();
+ loadHandoverProtocols();
+ loadGuestCodes();
+ loadStorage();
+ loadDocuments();
+
setInterval(loadData, REFRESH_INTERVAL_MS);
setInterval(loadCleaningTasks, REFRESH_INTERVAL_MS);
setInterval(loadCalendar, REFRESH_INTERVAL_MS);
+ setInterval(loadExpenses, REFRESH_INTERVAL_MS);
+ setInterval(loadInventory, REFRESH_INTERVAL_MS);
+ setInterval(loadHandoverProtocols, REFRESH_INTERVAL_MS);
+ setInterval(loadGuestCodes, REFRESH_INTERVAL_MS);
+ setInterval(loadStorage, REFRESH_INTERVAL_MS);
+ setInterval(loadDocuments, REFRESH_INTERVAL_MS);
}
// ---------------------------------------------------------------------
@@ -847,6 +1052,7 @@
lastMyRoom = data.myRoom || null;
populateRoomSelect();
populateTicketRoomSelect();
+ populateInventoryRoomSelect();
render(data);
} catch (err) {
if (err.message === 'Nicht authentifiziert' || err.message === 'Token ungültig oder abgelaufen') {
@@ -1362,6 +1568,760 @@
}
}
+ // ---------------------------------------------------------------------
+ // WG-KASSE (AUSGABEN-TEILER)
+ // ---------------------------------------------------------------------
+
+ function populateExpenseCategorySelect() {
+ const select = document.getElementById('expenseCategory');
+ select.innerHTML = '';
+ Object.keys(EXPENSE_CATEGORY_LABEL).forEach(key => {
+ const opt = document.createElement('option');
+ opt.value = key;
+ opt.textContent = EXPENSE_CATEGORY_LABEL[key];
+ select.appendChild(opt);
+ });
+ }
+
+ function initExpenseForm() {
+ populateExpenseCategorySelect();
+ const form = document.getElementById('expenseForm');
+ const errorBox = document.getElementById('expenseFormError');
+ form.onsubmit = async (e) => {
+ e.preventDefault();
+ errorBox.style.display = 'none';
+ const submitBtn = document.getElementById('expenseSubmitBtn');
+ submitBtn.disabled = true;
+ submitBtn.textContent = 'Speichert…';
+ try {
+ const amount = document.getElementById('expenseAmount').value;
+ const description = document.getElementById('expenseDescription').value.trim();
+ const category = document.getElementById('expenseCategory').value;
+ await apiFetch('/expenses', { method: 'POST', body: JSON.stringify({ amount, description, category }) });
+ form.reset();
+ populateExpenseCategorySelect();
+ loadExpenses();
+ } catch (err) {
+ errorBox.textContent = err.message;
+ errorBox.style.display = 'block';
+ } finally {
+ submitBtn.disabled = false;
+ submitBtn.textContent = 'Eintragen';
+ }
+ };
+ }
+
+ async function loadExpenses() {
+ if (!currentUser) return;
+ try {
+ const [expensesData, balanceData] = await Promise.all([
+ apiFetch('/expenses'),
+ apiFetch('/expenses/balance'),
+ ]);
+ renderExpenseBalance(balanceData.balance);
+ renderExpenses(expensesData.expenses);
+ } catch (err) {
+ console.error('[expenses] Laden fehlgeschlagen:', err.message);
+ }
+ }
+
+ function renderExpenseBalance(balance) {
+ const grid = document.getElementById('expenseBalanceGrid');
+ grid.innerHTML = '';
+ if (!balance.length) {
+ grid.appendChild(el('div', 'empty-state', 'Aktuell keine offenen Salden. 🎉'));
+ return;
+ }
+ balance.forEach(b => {
+ const net = b.isOwed - b.owes;
+ const card = el('div', `room-card ${net > 0 ? 'GREEN' : (net < 0 ? 'RED' : '')}`);
+ const top = el('div', 'room-card-top');
+ top.appendChild(el('div', 'room-name', b.user.fullName));
+ card.appendChild(top);
+ const owesRow = el('div', 'amount-row');
+ owesRow.appendChild(el('span', 'label', 'Schuldet'));
+ owesRow.appendChild(el('span', 'value', formatEuro(b.owes)));
+ card.appendChild(owesRow);
+ const owedRow = el('div', 'amount-row');
+ owedRow.appendChild(el('span', 'label', 'Bekommt'));
+ owedRow.appendChild(el('span', 'value', formatEuro(b.isOwed)));
+ card.appendChild(owedRow);
+ grid.appendChild(card);
+ });
+ }
+
+ function renderExpenses(expenses) {
+ const list = document.getElementById('expenseList');
+ list.innerHTML = '';
+ if (!expenses.length) {
+ list.appendChild(el('div', 'empty-state', 'Noch keine Ausgaben erfasst.'));
+ return;
+ }
+ expenses.forEach(x => {
+ const row = el('div', 'ticket-row');
+ const left = el('div');
+ left.appendChild(el('div', 'title', `${x.description} · ${formatEuro(x.amount)}`));
+ left.appendChild(el('div', 'meta-text',
+ `${x.payer.fullName} · ${EXPENSE_CATEGORY_LABEL[x.category] || x.category} · ${new Date(x.createdAt).toLocaleDateString('de-DE')}`));
+ const openShares = x.shares.filter(s => s.settlementStatus === 'OPEN');
+ if (openShares.length) {
+ const shareText = openShares.map(s => `${s.user.fullName}: ${formatEuro(s.shareAmount)}`).join(', ');
+ left.appendChild(el('div', 'meta-text', 'Offen: ' + shareText));
+ }
+ row.appendChild(left);
+
+ const right = el('div');
+ right.style.display = 'flex';
+ right.style.gap = '8px';
+ right.style.flexWrap = 'wrap';
+ const myShare = x.shares.find(s => s.user.id === currentUser.id && s.settlementStatus === 'OPEN');
+ if (myShare) {
+ const btn = document.createElement('button');
+ btn.className = 'btn-secondary';
+ btn.textContent = 'Meinen Anteil begleichen';
+ btn.onclick = () => settleExpenseShare(myShare.id);
+ right.appendChild(btn);
+ }
+ const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
+ if (isLandlord || x.payer.id === currentUser.id) {
+ openShares.forEach(s => {
+ if (s.user.id === currentUser.id) return; // schon oben abgedeckt
+ const btn = document.createElement('button');
+ btn.className = 'btn-secondary';
+ btn.textContent = `${s.user.fullName.split(' ')[0]}: als bezahlt markieren`;
+ btn.onclick = () => settleExpenseShare(s.id);
+ right.appendChild(btn);
+ });
+ }
+ row.appendChild(right);
+ list.appendChild(row);
+ });
+ }
+
+ async function settleExpenseShare(id) {
+ try {
+ await apiFetch(`/expense-shares/${id}`, { method: 'PATCH' });
+ loadExpenses();
+ } catch (err) {
+ alert(err.message);
+ }
+ }
+
+ // ---------------------------------------------------------------------
+ // INVENTARVERWALTUNG
+ // ---------------------------------------------------------------------
+
+ function populateInventoryRoomSelect() {
+ const roomSelect = document.getElementById('inventoryRoom');
+ if (!roomSelect) return;
+ roomSelect.innerHTML = '';
+ const noneOpt = document.createElement('option');
+ noneOpt.value = '';
+ noneOpt.textContent = 'Gemeinschaftsfläche';
+ roomSelect.appendChild(noneOpt);
+ lastRooms.forEach(r => {
+ const opt = document.createElement('option');
+ opt.value = r.id;
+ opt.textContent = r.roomNumber;
+ roomSelect.appendChild(opt);
+ });
+ }
+
+ function populateInventoryConditionSelect() {
+ const condSelect = document.getElementById('inventoryCondition');
+ condSelect.innerHTML = '';
+ Object.keys(ITEM_CONDITION_LABEL).forEach(key => {
+ const opt = document.createElement('option');
+ opt.value = key;
+ opt.textContent = ITEM_CONDITION_LABEL[key];
+ condSelect.appendChild(opt);
+ });
+ }
+
+ const MAX_INVENTORY_PHOTOS = 6;
+ let inventoryPhotoDataUrls = [];
+
+ function readFileAsDataUrl(file) {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(reader.result);
+ reader.onerror = () => reject(reader.error);
+ reader.readAsDataURL(file);
+ });
+ }
+
+ function renderInventoryPhotoPreview() {
+ const preview = document.getElementById('inventoryPhotoPreview');
+ preview.innerHTML = '';
+ inventoryPhotoDataUrls.forEach((dataUrl, idx) => {
+ const wrap = el('div');
+ wrap.style.position = 'relative';
+ const img = document.createElement('img');
+ img.src = dataUrl;
+ img.className = 'inventory-thumb';
+ wrap.appendChild(img);
+ const removeBtn = document.createElement('button');
+ removeBtn.type = 'button';
+ removeBtn.textContent = '×';
+ removeBtn.title = 'Entfernen';
+ removeBtn.style.cssText = 'position:absolute; top:-6px; right:-6px; width:18px; height:18px; border-radius:50%; border:1px solid var(--border); background:#fff; cursor:pointer; font-size:11px; line-height:1; padding:0;';
+ removeBtn.onclick = () => {
+ inventoryPhotoDataUrls.splice(idx, 1);
+ renderInventoryPhotoPreview();
+ };
+ wrap.appendChild(removeBtn);
+ preview.appendChild(wrap);
+ });
+ }
+
+ function initInventoryForm() {
+ populateInventoryRoomSelect();
+ populateInventoryConditionSelect();
+ inventoryPhotoDataUrls = [];
+ renderInventoryPhotoPreview();
+
+ const photoInput = document.getElementById('inventoryPhotos');
+ photoInput.onchange = async () => {
+ const files = Array.from(photoInput.files || []).slice(0, MAX_INVENTORY_PHOTOS - inventoryPhotoDataUrls.length);
+ for (const file of files) {
+ try {
+ const dataUrl = await readFileAsDataUrl(file);
+ inventoryPhotoDataUrls.push(dataUrl);
+ } catch (err) {
+ console.error('[inventory] Foto konnte nicht gelesen werden:', err);
+ }
+ }
+ photoInput.value = '';
+ renderInventoryPhotoPreview();
+ };
+
+ const form = document.getElementById('inventoryForm');
+ const errorBox = document.getElementById('inventoryFormError');
+ form.onsubmit = async (e) => {
+ e.preventDefault();
+ errorBox.style.display = 'none';
+ const submitBtn = document.getElementById('inventorySubmitBtn');
+ submitBtn.disabled = true;
+ submitBtn.textContent = 'Speichert…';
+ try {
+ const itemName = document.getElementById('inventoryItemName').value.trim();
+ const roomId = document.getElementById('inventoryRoom').value || undefined;
+ const condition = document.getElementById('inventoryCondition').value;
+ const purchasePriceRaw = document.getElementById('inventoryPrice').value;
+ const purchasePrice = purchasePriceRaw ? Number(purchasePriceRaw) : undefined;
+ await apiFetch('/inventory', {
+ method: 'POST',
+ body: JSON.stringify({ itemName, roomId, condition, purchasePrice, photoUrls: inventoryPhotoDataUrls }),
+ });
+ form.reset();
+ populateInventoryConditionSelect();
+ inventoryPhotoDataUrls = [];
+ renderInventoryPhotoPreview();
+ loadInventory();
+ } catch (err) {
+ errorBox.textContent = err.message;
+ errorBox.style.display = 'block';
+ } finally {
+ submitBtn.disabled = false;
+ submitBtn.textContent = 'Hinzufügen';
+ }
+ };
+ }
+
+ async function loadInventory() {
+ if (!currentUser) return;
+ try {
+ const data = await apiFetch('/inventory');
+ renderInventory(data.items);
+ } catch (err) {
+ console.error('[inventory] Laden fehlgeschlagen:', err.message);
+ }
+ }
+
+ function renderInventory(items) {
+ const list = document.getElementById('inventoryList');
+ list.innerHTML = '';
+ if (!items.length) {
+ list.appendChild(el('div', 'empty-state', 'Noch kein Inventar erfasst.'));
+ return;
+ }
+ const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
+ items.forEach(i => {
+ const row = el('div', 'ticket-row');
+ const left = el('div');
+ left.appendChild(el('div', 'title', i.itemName));
+ left.appendChild(el('div', 'meta-text',
+ `${i.room ? i.room.roomNumber : 'Gemeinschaftsfläche'} · ${ITEM_CONDITION_LABEL[i.condition] || i.condition}` +
+ (i.purchasePrice ? ` · ${formatEuro(i.purchasePrice)}` : '')));
+ if (i.photoUrls && i.photoUrls.length) {
+ const thumbRow = el('div', 'inventory-thumb-row');
+ i.photoUrls.forEach(url => {
+ const img = document.createElement('img');
+ img.src = url;
+ img.className = 'inventory-thumb';
+ thumbRow.appendChild(img);
+ });
+ left.appendChild(thumbRow);
+ }
+ row.appendChild(left);
+ if (isLandlord) {
+ const btn = document.createElement('button');
+ btn.className = 'btn-secondary';
+ btn.textContent = 'Löschen';
+ btn.onclick = () => deleteInventoryItem(i.id);
+ row.appendChild(btn);
+ }
+ list.appendChild(row);
+ });
+ }
+
+ async function deleteInventoryItem(id) {
+ try {
+ await apiFetch(`/inventory/${id}`, { method: 'DELETE' });
+ loadInventory();
+ } catch (err) {
+ alert(err.message);
+ }
+ }
+
+ // ---------------------------------------------------------------------
+ // ÜBERGABEPROTOKOLLE
+ // ---------------------------------------------------------------------
+
+ async function populateHandoverContractSelect() {
+ const select = document.getElementById('handoverContract');
+ select.innerHTML = '';
+ try {
+ const data = await apiFetch('/contracts');
+ data.contracts.forEach(c => {
+ const opt = document.createElement('option');
+ opt.value = c.id;
+ opt.textContent = `${c.user.fullName} · ${c.room.roomNumber}`;
+ select.appendChild(opt);
+ });
+ } catch (err) {
+ console.error('[handover] Verträge laden fehlgeschlagen:', err.message);
+ }
+ }
+
+ function initHandoverForm() {
+ populateHandoverContractSelect();
+ const form = document.getElementById('handoverForm');
+ const errorBox = document.getElementById('handoverFormError');
+ form.onsubmit = async (e) => {
+ e.preventDefault();
+ errorBox.style.display = 'none';
+ const submitBtn = document.getElementById('handoverSubmitBtn');
+ submitBtn.disabled = true;
+ submitBtn.textContent = 'Speichert…';
+ try {
+ const contractId = document.getElementById('handoverContract').value;
+ const type = document.getElementById('handoverType').value;
+ const protocolDate = document.getElementById('handoverDate').value;
+ await apiFetch('/handover-protocols', {
+ method: 'POST',
+ body: JSON.stringify({ contractId, type, protocolDate, items: [], meterReadings: [] }),
+ });
+ form.reset();
+ loadHandoverProtocols();
+ } catch (err) {
+ errorBox.textContent = err.message;
+ errorBox.style.display = 'block';
+ } finally {
+ submitBtn.disabled = false;
+ submitBtn.textContent = 'Protokoll anlegen';
+ }
+ };
+ }
+
+ async function loadHandoverProtocols() {
+ if (!currentUser) return;
+ try {
+ const data = await apiFetch('/handover-protocols');
+ renderHandoverProtocols(data.protocols);
+ } catch (err) {
+ console.error('[handover] Laden fehlgeschlagen:', err.message);
+ }
+ }
+
+ function renderHandoverProtocols(protocols) {
+ const list = document.getElementById('handoverList');
+ list.innerHTML = '';
+ if (!protocols.length) {
+ list.appendChild(el('div', 'empty-state', 'Noch keine Übergabeprotokolle vorhanden.'));
+ return;
+ }
+ protocols.forEach(p => {
+ const row = el('div', 'ticket-row');
+ const left = el('div');
+ left.appendChild(el('div', 'title', `${HANDOVER_TYPE_LABEL[p.type] || p.type} · ${p.contract.room.roomNumber}`));
+ left.appendChild(el('div', 'meta-text',
+ `${p.user.fullName} · ${new Date(p.protocolDate).toLocaleDateString('de-DE')} · ${p.items.length} Positionen, ${p.meterReadings.length} Zählerstände`));
+ row.appendChild(left);
+ list.appendChild(row);
+ });
+ }
+
+ // ---------------------------------------------------------------------
+ // SMART-LOCK-GASTCODES
+ // ---------------------------------------------------------------------
+
+ function initGuestCodeForm() {
+ const form = document.getElementById('guestCodeForm');
+ const errorBox = document.getElementById('guestCodeFormError');
+ form.onsubmit = async (e) => {
+ e.preventDefault();
+ errorBox.style.display = 'none';
+ const submitBtn = document.getElementById('guestCodeSubmitBtn');
+ submitBtn.disabled = true;
+ submitBtn.textContent = 'Generiert…';
+ try {
+ const validFrom = document.getElementById('guestCodeFrom').value;
+ const validUntil = document.getElementById('guestCodeUntil').value;
+ await apiFetch('/guest-codes', { method: 'POST', body: JSON.stringify({ validFrom, validUntil }) });
+ form.reset();
+ loadGuestCodes();
+ } catch (err) {
+ errorBox.textContent = err.message;
+ errorBox.style.display = 'block';
+ } finally {
+ submitBtn.disabled = false;
+ submitBtn.textContent = 'Code generieren';
+ }
+ };
+ }
+
+ async function loadGuestCodes() {
+ if (!currentUser) return;
+ try {
+ const data = await apiFetch('/guest-codes');
+ renderGuestCodes(data.guestCodes);
+ } catch (err) {
+ console.error('[guest-codes] Laden fehlgeschlagen:', err.message);
+ }
+ }
+
+ function renderGuestCodes(codes) {
+ const list = document.getElementById('guestCodeList');
+ list.innerHTML = '';
+ if (!codes.length) {
+ list.appendChild(el('div', 'empty-state', 'Noch keine Gästecodes ausgestellt.'));
+ return;
+ }
+ codes.forEach(g => {
+ const row = el('div', 'ticket-row');
+ const left = el('div');
+ left.appendChild(el('div', 'title', `Code: ${g.code}`));
+ left.appendChild(el('div', 'meta-text',
+ `${g.issuedBy.fullName} · ${new Date(g.validFrom).toLocaleString('de-DE')} – ${new Date(g.validUntil).toLocaleString('de-DE')} · ${GUEST_CODE_STATUS_LABEL[g.status] || g.status}`));
+ row.appendChild(left);
+ if (g.status === 'ACTIVE') {
+ const btn = document.createElement('button');
+ btn.className = 'btn-secondary';
+ btn.textContent = 'Widerrufen';
+ btn.onclick = () => revokeGuestCode(g.id);
+ row.appendChild(btn);
+ }
+ list.appendChild(row);
+ });
+ }
+
+ async function revokeGuestCode(id) {
+ try {
+ await apiFetch(`/guest-codes/${id}`, { method: 'DELETE' });
+ loadGuestCodes();
+ } catch (err) {
+ alert(err.message);
+ }
+ }
+
+ // ---------------------------------------------------------------------
+ // KÜCHEN-/SCHRANKPLANER & MÜLL-KALENDER
+ // ---------------------------------------------------------------------
+
+ function populateStorageSelects() {
+ const locSelect = document.getElementById('storageLocation');
+ locSelect.innerHTML = '';
+ Object.keys(STORAGE_LOCATION_LABEL).forEach(key => {
+ const opt = document.createElement('option');
+ opt.value = key;
+ opt.textContent = STORAGE_LOCATION_LABEL[key];
+ locSelect.appendChild(opt);
+ });
+
+ const trashSelect = document.getElementById('trashType');
+ trashSelect.innerHTML = '';
+ Object.keys(TRASH_TYPE_LABEL).forEach(key => {
+ const opt = document.createElement('option');
+ opt.value = key;
+ opt.textContent = TRASH_TYPE_LABEL[key];
+ trashSelect.appendChild(opt);
+ });
+ }
+
+ function initStorageForms() {
+ populateStorageSelects();
+ const form = document.getElementById('storageForm');
+ const errorBox = document.getElementById('storageFormError');
+ form.onsubmit = async (e) => {
+ e.preventDefault();
+ errorBox.style.display = 'none';
+ const submitBtn = document.getElementById('storageSubmitBtn');
+ submitBtn.disabled = true;
+ submitBtn.textContent = 'Speichert…';
+ try {
+ const location = document.getElementById('storageLocation').value;
+ const label = document.getElementById('storageLabel').value.trim();
+ await apiFetch('/storage-slots', { method: 'POST', body: JSON.stringify({ location, label }) });
+ form.reset();
+ loadStorage();
+ } catch (err) {
+ errorBox.textContent = err.message;
+ errorBox.style.display = 'block';
+ } finally {
+ submitBtn.disabled = false;
+ submitBtn.textContent = 'Fach anlegen';
+ }
+ };
+
+ const trashForm = document.getElementById('trashForm');
+ const trashErrorBox = document.getElementById('trashFormError');
+ trashForm.onsubmit = async (e) => {
+ e.preventDefault();
+ trashErrorBox.style.display = 'none';
+ const trashSubmitBtn = document.getElementById('trashSubmitBtn');
+ trashSubmitBtn.disabled = true;
+ trashSubmitBtn.textContent = 'Speichert…';
+ try {
+ const type = document.getElementById('trashType').value;
+ const date = document.getElementById('trashDate').value;
+ await apiFetch('/trash-schedule', { method: 'POST', body: JSON.stringify({ type, date }) });
+ trashForm.reset();
+ loadStorage();
+ } catch (err) {
+ trashErrorBox.textContent = err.message;
+ trashErrorBox.style.display = 'block';
+ } finally {
+ trashSubmitBtn.disabled = false;
+ trashSubmitBtn.textContent = 'Termin eintragen';
+ }
+ };
+ }
+
+ async function loadStorage() {
+ if (!currentUser) return;
+ try {
+ const [slotsData, trashData] = await Promise.all([
+ apiFetch('/storage-slots'),
+ apiFetch('/trash-schedule'),
+ ]);
+ renderStorageSlots(slotsData.slots);
+ renderTrashSchedule(trashData.entries);
+ } catch (err) {
+ console.error('[storage] Laden fehlgeschlagen:', err.message);
+ }
+ }
+
+ function renderStorageSlots(slots) {
+ const list = document.getElementById('storageList');
+ list.innerHTML = '';
+ if (!slots.length) {
+ list.appendChild(el('div', 'empty-state', 'Noch keine Fächer angelegt.'));
+ return;
+ }
+ const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
+ slots.forEach(s => {
+ const row = el('div', 'ticket-row');
+ const left = el('div');
+ left.appendChild(el('div', 'title', `${STORAGE_LOCATION_LABEL[s.location] || s.location} · ${s.label}`));
+ left.appendChild(el('div', 'meta-text', s.user ? `Beansprucht von ${s.user.fullName}` : 'Frei'));
+ row.appendChild(left);
+
+ const right = el('div');
+ right.style.display = 'flex';
+ right.style.gap = '8px';
+ if (!s.user) {
+ const btn = document.createElement('button');
+ btn.className = 'btn-secondary';
+ btn.textContent = 'Beanspruchen';
+ btn.onclick = () => claimStorageSlot(s.id);
+ right.appendChild(btn);
+ } else if (isLandlord || s.user.id === currentUser.id) {
+ const btn = document.createElement('button');
+ btn.className = 'btn-secondary';
+ btn.textContent = 'Freigeben';
+ btn.onclick = () => releaseStorageSlot(s.id);
+ right.appendChild(btn);
+ }
+ if (isLandlord) {
+ const delBtn = document.createElement('button');
+ delBtn.className = 'btn-secondary';
+ delBtn.textContent = 'Löschen';
+ delBtn.onclick = () => deleteStorageSlot(s.id);
+ right.appendChild(delBtn);
+ }
+ row.appendChild(right);
+ list.appendChild(row);
+ });
+ }
+
+ async function claimStorageSlot(id) {
+ try {
+ await apiFetch(`/storage-slots/${id}`, { method: 'PATCH', body: JSON.stringify({ userId: currentUser.id }) });
+ loadStorage();
+ } catch (err) {
+ alert(err.message);
+ }
+ }
+
+ async function releaseStorageSlot(id) {
+ try {
+ await apiFetch(`/storage-slots/${id}`, { method: 'PATCH', body: JSON.stringify({ userId: null }) });
+ loadStorage();
+ } catch (err) {
+ alert(err.message);
+ }
+ }
+
+ async function deleteStorageSlot(id) {
+ try {
+ await apiFetch(`/storage-slots/${id}`, { method: 'DELETE' });
+ loadStorage();
+ } catch (err) {
+ alert(err.message);
+ }
+ }
+
+ 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);
+ }
+ list.appendChild(row);
+ });
+ }
+
+ async function deleteTrashEntry(id) {
+ try {
+ await apiFetch(`/trash-schedule/${id}`, { method: 'DELETE' });
+ loadStorage();
+ } catch (err) {
+ alert(err.message);
+ }
+ }
+
+ // ---------------------------------------------------------------------
+ // DOKUMENTEN-SAFE & TUTORIALS
+ // ---------------------------------------------------------------------
+
+ function populateDocumentCategorySelect() {
+ const select = document.getElementById('documentCategory');
+ select.innerHTML = '';
+ Object.keys(DOCUMENT_CATEGORY_LABEL).forEach(key => {
+ const opt = document.createElement('option');
+ opt.value = key;
+ opt.textContent = DOCUMENT_CATEGORY_LABEL[key];
+ select.appendChild(opt);
+ });
+ }
+
+ function initDocumentForm() {
+ populateDocumentCategorySelect();
+ const form = document.getElementById('documentForm');
+ const errorBox = document.getElementById('documentFormError');
+ form.onsubmit = async (e) => {
+ e.preventDefault();
+ errorBox.style.display = 'none';
+ const submitBtn = document.getElementById('documentSubmitBtn');
+ submitBtn.disabled = true;
+ submitBtn.textContent = 'Speichert…';
+ try {
+ const title = document.getElementById('documentTitle').value.trim();
+ const category = document.getElementById('documentCategory').value;
+ const url = document.getElementById('documentUrl').value.trim();
+ await apiFetch('/documents', { method: 'POST', body: JSON.stringify({ title, category, url }) });
+ form.reset();
+ populateDocumentCategorySelect();
+ loadDocuments();
+ } catch (err) {
+ errorBox.textContent = err.message;
+ errorBox.style.display = 'block';
+ } finally {
+ submitBtn.disabled = false;
+ submitBtn.textContent = 'Hinzufügen';
+ }
+ };
+ }
+
+ async function loadDocuments() {
+ if (!currentUser) return;
+ try {
+ const data = await apiFetch('/documents');
+ renderDocuments(data.documents);
+ } catch (err) {
+ console.error('[documents] Laden fehlgeschlagen:', err.message);
+ }
+ }
+
+ function renderDocuments(documents) {
+ const list = document.getElementById('documentList');
+ list.innerHTML = '';
+ if (!documents.length) {
+ list.appendChild(el('div', 'empty-state', 'Noch keine Dokumente hinterlegt.'));
+ return;
+ }
+ const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
+ documents.forEach(d => {
+ const row = el('div', 'ticket-row');
+ const left = el('div');
+ const link = document.createElement('a');
+ link.href = d.url;
+ link.target = '_blank';
+ link.rel = 'noopener noreferrer';
+ link.textContent = d.title;
+ link.style.fontWeight = '600';
+ link.style.color = 'var(--text)';
+ left.appendChild(link);
+ left.appendChild(el('div', 'meta-text', `${DOCUMENT_CATEGORY_LABEL[d.category] || d.category} · ${d.uploadedBy.fullName}`));
+ row.appendChild(left);
+ if (isLandlord) {
+ const btn = document.createElement('button');
+ btn.className = 'btn-secondary';
+ btn.textContent = 'Löschen';
+ btn.onclick = () => deleteDocument(d.id);
+ row.appendChild(btn);
+ }
+ list.appendChild(row);
+ });
+ }
+
+ async function deleteDocument(id) {
+ try {
+ await apiFetch(`/documents/${id}`, { method: 'DELETE' });
+ loadDocuments();
+ } catch (err) {
+ alert(err.message);
+ }
+ }
+
bootstrap();