Add ticket reporting UI and role-scoped rendering (tenant privacy)
This commit is contained in:
parent
ff7e815704
commit
e3ac1e9c4a
@ -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 = [
|
||||
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>
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user