Add ticket reporting UI and role-scoped rendering (tenant privacy)

This commit is contained in:
Giuseppe Lombardo 2026-08-12 18:39:17 +00:00
parent ff7e815704
commit e3ac1e9c4a

View File

@ -388,11 +388,41 @@
</div>
</section>
<section>
<h2>Miet-Ampel je Zimmer</h2>
<section id="rentSection">
<h2 id="rentSectionTitle">Miet-Ampel je Zimmer</h2>
<div class="room-grid" id="roomGrid"></div>
</section>
<section>
<h2>Schaden melden</h2>
<div class="invite-card">
<div id="ticketFormError" style="display:none" class="form-error"></div>
<form id="ticketForm" class="invite-form-row" style="flex-wrap:wrap;">
<div class="field" style="flex:1 1 220px;">
<label for="ticketTitle">Titel</label>
<input type="text" id="ticketTitle" required placeholder="z. B. Spülmaschine defekt" />
</div>
<div class="field" style="flex:1 1 100%;">
<label for="ticketDescription">Beschreibung</label>
<input type="text" id="ticketDescription" required placeholder="Was ist passiert?" />
</div>
<div class="field" style="flex:1 1 160px;">
<label for="ticketCategory">Kategorie</label>
<select id="ticketCategory"></select>
</div>
<div class="field" style="flex:1 1 140px;">
<label for="ticketPriority">Priorität</label>
<select id="ticketPriority"></select>
</div>
<div class="field" style="flex:1 1 200px;">
<label for="ticketRoom">Bereich</label>
<select id="ticketRoom"></select>
</div>
<button type="submit" class="btn-primary" style="width:auto" id="ticketSubmitBtn">Melden</button>
</form>
</div>
</section>
<section>
<h2>Offene Schadensmeldungen</h2>
<div class="ticket-list" id="ticketList"></div>
@ -409,6 +439,11 @@
const PRIORITY_LABEL = { LOW: 'Niedrig', MEDIUM: 'Mittel', HIGH: 'Hoch', EMERGENCY: 'Notfall' };
const ROLE_LABEL = { LANDLORD: 'Vermieter', TENANT: 'Mieter', CRAFTSMAN: 'Handwerker', ADMIN: 'Verwaltung' };
const INVITE_STATUS_LABEL = { PENDING: 'Offen', ACCEPTED: 'Angenommen', EXPIRED: 'Abgelaufen', REVOKED: 'Zurückgezogen' };
const CATEGORY_LABEL = {
SANITAER: 'Sanitär', ELEKTRIK: 'Elektrik', MOEBEL: 'Möbel', HEIZUNG: 'Heizung',
SCHIMMEL: 'Schimmel', SCHLUESSEL_SCHLOSS: 'Schlüssel/Schloss', SONSTIGES: 'Sonstiges',
};
const TICKET_STATUS_LABEL = { OPEN: 'Gemeldet', IN_PROGRESS: 'In Bearbeitung', RESOLVED: 'Erledigt', CLOSED: 'Geschlossen' };
function formatEuro(n) {
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(n || 0);
@ -573,8 +608,10 @@
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
document.getElementById('inviteSection').style.display = isLandlord ? 'block' : 'none';
document.getElementById('rentSectionTitle').textContent = isLandlord ? 'Miet-Ampel je Zimmer' : 'Meine Miete';
if (isLandlord) initInvitePanel();
initTicketForm();
loadData();
setInterval(loadData, REFRESH_INTERVAL_MS);
}
@ -687,7 +724,9 @@
try {
const data = await apiFetch('/cockpit/summary');
lastRooms = data.rooms || [];
lastMyRoom = data.myRoom || null;
populateRoomSelect();
populateTicketRoomSelect();
render(data);
} catch (err) {
if (err.message === 'Nicht authentifiziert' || err.message === 'Token ungültig oder abgelaufen') {
@ -704,16 +743,28 @@
document.getElementById('lastUpdated').textContent =
'Stand: ' + new Date(data.generatedAt).toLocaleTimeString('de-DE');
const isLandlord = data.role === 'LANDLORD' || data.role === 'ADMIN';
// --- Stat-Kacheln ---
const statRow = document.getElementById('statRow');
statRow.innerHTML = '';
const stats = [
{ label: 'Bezahlt', value: data.summary.rentGreen, cls: 'green' },
{ label: 'Fällig', value: data.summary.rentYellow, cls: 'yellow' },
{ label: 'Überfällig', value: data.summary.rentRed, cls: 'red' },
{ label: 'Soll-Miete gesamt / Monat', value: formatEuro(data.summary.totalMonthlyRentTarget), cls: '' },
{ label: 'Offene Tickets', value: data.summary.openTicketsCount, cls: '' },
];
let stats;
if (isLandlord) {
stats = [
{ label: 'Bezahlt', value: data.summary.rentGreen, cls: 'green' },
{ label: 'Fällig', value: data.summary.rentYellow, cls: 'yellow' },
{ label: 'Überfällig', value: data.summary.rentRed, cls: 'red' },
{ label: 'Soll-Miete gesamt / Monat', value: formatEuro(data.summary.totalMonthlyRentTarget), cls: '' },
{ label: 'Offene Tickets', value: data.summary.openTicketsCount, cls: '' },
];
} else {
const myStatusCls = data.myRent ? { GREEN: 'green', YELLOW: 'yellow', RED: 'red' }[data.myRent.status] : '';
stats = [
{ label: 'Mein Mietstatus', value: data.myRent ? STATUS_LABEL[data.myRent.status] : '', cls: myStatusCls },
{ label: 'Fällig', value: formatEuro(data.myRent ? data.myRent.amountDue : 0), cls: '' },
{ label: 'Offene Meldungen', value: data.openTickets.length, cls: '' },
];
}
stats.forEach(s => {
const tile = el('div', `stat-tile ${s.cls}`);
tile.appendChild(el('div', 'value', s.value));
@ -721,19 +772,21 @@
statRow.appendChild(tile);
});
// --- Zimmer-Ampeln ---
// --- Miet-Ampel(n) ---
const roomGrid = document.getElementById('roomGrid');
roomGrid.innerHTML = '';
if (!data.trafficLights.length) {
roomGrid.appendChild(el('div', 'empty-state', 'Keine aktiven Verträge gefunden.'));
const lights = isLandlord ? data.trafficLights : (data.myRent ? [data.myRent] : []);
if (!lights.length) {
roomGrid.appendChild(el('div', 'empty-state',
isLandlord ? 'Keine aktiven Verträge gefunden.' : 'Kein aktiver Mietvertrag gefunden.'));
}
data.trafficLights.forEach(light => {
lights.forEach(light => {
const card = el('div', `room-card ${light.status}`);
const top = el('div', 'room-card-top');
top.appendChild(el('div', 'room-name', light.roomNumber));
top.appendChild(el('div', `badge ${light.status}`, STATUS_LABEL[light.status]));
card.appendChild(top);
card.appendChild(el('div', 'tenant', light.tenantName));
if (isLandlord) card.appendChild(el('div', 'tenant', light.tenantName));
const amountRow = el('div', 'amount-row');
amountRow.appendChild(el('span', 'label', 'Fällig'));
@ -760,13 +813,148 @@
const left = el('div');
left.appendChild(el('div', 'title', t.title));
left.appendChild(el('div', 'meta-text',
`${t.room ? t.room.roomNumber : 'Gemeinschaftsbereich'} · ${new Date(t.createdAt).toLocaleDateString('de-DE')}`));
`${t.room ? t.room.roomNumber : 'Gemeinschaftsbereich'} · ${CATEGORY_LABEL[t.category] || t.category} · ` +
`${t.creator ? t.creator.fullName + ' · ' : ''}${new Date(t.createdAt).toLocaleDateString('de-DE')} · ` +
TICKET_STATUS_LABEL[t.status]));
row.appendChild(left);
row.appendChild(el('span', `priority-chip ${t.priority}`, PRIORITY_LABEL[t.priority] || t.priority));
const right = el('div');
right.style.display = 'flex';
right.style.gap = '8px';
right.style.alignItems = 'center';
right.appendChild(el('span', `priority-chip ${t.priority}`, PRIORITY_LABEL[t.priority] || t.priority));
if (isLandlord) {
if (t.status === 'OPEN') {
const btn = document.createElement('button');
btn.className = 'btn-secondary';
btn.textContent = 'In Bearbeitung';
btn.onclick = () => updateTicketStatus(t.id, 'IN_PROGRESS');
right.appendChild(btn);
}
if (t.status === 'OPEN' || t.status === 'IN_PROGRESS') {
const btn = document.createElement('button');
btn.className = 'btn-secondary';
btn.textContent = 'Erledigt';
btn.onclick = () => updateTicketStatus(t.id, 'RESOLVED');
right.appendChild(btn);
}
} else if (t.creator && t.creator.id === currentUser.id && t.status === 'OPEN') {
const btn = document.createElement('button');
btn.className = 'btn-secondary';
btn.textContent = 'Zurückziehen';
btn.onclick = () => withdrawTicket(t.id);
right.appendChild(btn);
}
row.appendChild(right);
ticketList.appendChild(row);
});
}
// ---------------------------------------------------------------------
// SCHADENSMELDUNGEN / TICKETS
// ---------------------------------------------------------------------
let lastMyRoom = null;
function populateTicketRoomSelect() {
const select = document.getElementById('ticketRoom');
if (!select) return;
select.innerHTML = '';
const generalOpt = document.createElement('option');
generalOpt.value = '';
generalOpt.textContent = 'Gemeinschaftsbereich (Küche, Bad, Flur, ...)';
select.appendChild(generalOpt);
const isLandlord = currentUser && (currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN');
if (isLandlord) {
lastRooms.forEach(r => {
const opt = document.createElement('option');
opt.value = r.id;
opt.textContent = r.roomNumber;
select.appendChild(opt);
});
} else if (lastMyRoom) {
const opt = document.createElement('option');
opt.value = lastMyRoom.id;
opt.textContent = `Mein Zimmer (${lastMyRoom.roomNumber})`;
select.appendChild(opt);
}
}
function populateTicketSelects() {
const catSelect = document.getElementById('ticketCategory');
catSelect.innerHTML = '';
Object.keys(CATEGORY_LABEL).forEach(key => {
const opt = document.createElement('option');
opt.value = key;
opt.textContent = CATEGORY_LABEL[key];
catSelect.appendChild(opt);
});
const prioSelect = document.getElementById('ticketPriority');
prioSelect.innerHTML = '';
Object.keys(PRIORITY_LABEL).forEach(key => {
const opt = document.createElement('option');
opt.value = key;
opt.textContent = PRIORITY_LABEL[key];
prioSelect.appendChild(opt);
});
prioSelect.value = 'MEDIUM';
}
function initTicketForm() {
populateTicketSelects();
const form = document.getElementById('ticketForm');
const errorBox = document.getElementById('ticketFormError');
form.onsubmit = async (e) => {
e.preventDefault();
errorBox.style.display = 'none';
const submitBtn = document.getElementById('ticketSubmitBtn');
submitBtn.disabled = true;
submitBtn.textContent = 'Meldet…';
try {
const title = document.getElementById('ticketTitle').value.trim();
const description = document.getElementById('ticketDescription').value.trim();
const category = document.getElementById('ticketCategory').value;
const priority = document.getElementById('ticketPriority').value;
const roomId = document.getElementById('ticketRoom').value || undefined;
await apiFetch('/tickets', {
method: 'POST',
body: JSON.stringify({ title, description, category, priority, roomId }),
});
form.reset();
populateTicketSelects();
loadData();
} catch (err) {
errorBox.textContent = err.message;
errorBox.style.display = 'block';
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Melden';
}
};
}
async function updateTicketStatus(id, status) {
try {
await apiFetch(`/tickets/${id}`, { method: 'PATCH', body: JSON.stringify({ status }) });
loadData();
} catch (err) {
alert(err.message);
}
}
async function withdrawTicket(id) {
try {
await apiFetch(`/tickets/${id}`, { method: 'DELETE' });
loadData();
} catch (err) {
alert(err.message);
}
}
bootstrap();
</script>