add login + invite UI to web dashboard
This commit is contained in:
parent
00eaa3d7d6
commit
6d8cbd7f42
@ -156,36 +156,249 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
.error-banner code { background: rgba(0,0,0,0.06); padding: 1px 5px; border-radius: 4px; }
|
.error-banner code { background: rgba(0,0,0,0.06); padding: 1px 5px; border-radius: 4px; }
|
||||||
|
|
||||||
|
/* --- Auth / Invite views --- */
|
||||||
|
.auth-wrap {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.auth-card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 32px 28px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 380px;
|
||||||
|
}
|
||||||
|
.auth-card h1 { font-size: 19px; margin: 0 0 4px; }
|
||||||
|
.auth-card p.sub { color: var(--text-muted); font-size: 13.5px; margin: 0 0 22px; }
|
||||||
|
.field { margin-bottom: 14px; }
|
||||||
|
.field label { display: block; font-size: 12.5px; font-weight: 600; color: var(--text-muted); margin-bottom: 5px; }
|
||||||
|
.field input, .field select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.field input:focus, .field select:focus { outline: 2px solid var(--green); outline-offset: -1px; }
|
||||||
|
.btn-primary {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--green);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 11px 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.btn-primary:hover { opacity: 0.92; }
|
||||||
|
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||||
|
.btn-secondary {
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.form-error {
|
||||||
|
background: var(--red-bg);
|
||||||
|
color: var(--red);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.form-success {
|
||||||
|
background: var(--green-bg);
|
||||||
|
color: var(--green);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.view { display: none; }
|
||||||
|
.view.active { display: block; }
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 32px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.invite-form-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
.invite-form-row .field { flex: 1 1 200px; margin-bottom: 0; }
|
||||||
|
.invite-form-row button { flex: 0 0 auto; height: 40px; padding: 0 18px; }
|
||||||
|
|
||||||
|
.invite-link-box {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin-top: 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.invite-link-box code { flex: 1; }
|
||||||
|
|
||||||
|
.invite-table { width: 100%; border-collapse: collapse; margin-top: 16px; font-size: 13.5px; }
|
||||||
|
.invite-table th, .invite-table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); }
|
||||||
|
.invite-table th { color: var(--text-muted); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.03em; }
|
||||||
|
.invite-status-chip {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 3px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.invite-status-chip.PENDING { background: var(--yellow-bg); color: var(--yellow); }
|
||||||
|
.invite-status-chip.ACCEPTED { background: var(--green-bg); color: var(--green); }
|
||||||
|
.invite-status-chip.EXPIRED, .invite-status-chip.REVOKED { background: #EEF0F2; color: var(--text-muted); }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<header>
|
<!-- ================= LOGIN VIEW ================= -->
|
||||||
<div>
|
<div id="loginView" class="view">
|
||||||
<h1>WG Nackenheim — Vermieter-Cockpit</h1>
|
<div class="auth-wrap">
|
||||||
<p>170 qm · 4 Zimmer · Einzelzimmervermietung</p>
|
<div class="auth-card">
|
||||||
|
<h1>WG Nackenheim — Anmelden</h1>
|
||||||
|
<p class="sub">Vermieter-Cockpit & Mieterbereich</p>
|
||||||
|
<div id="loginError" style="display:none" class="form-error"></div>
|
||||||
|
<form id="loginForm">
|
||||||
|
<div class="field">
|
||||||
|
<label for="loginEmail">E-Mail-Adresse</label>
|
||||||
|
<input type="email" id="loginEmail" required autocomplete="email" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="loginPassword">Passwort</label>
|
||||||
|
<input type="password" id="loginPassword" required autocomplete="current-password" />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary" id="loginSubmitBtn">Anmelden</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex; align-items:center; gap:12px;">
|
</div>
|
||||||
<div class="meta" id="lastUpdated">–</div>
|
|
||||||
<button class="refresh-btn" onclick="loadData()">↻ Aktualisieren</button>
|
<!-- ================= ACCEPT INVITE VIEW ================= -->
|
||||||
|
<div id="acceptInviteView" class="view">
|
||||||
|
<div class="auth-wrap">
|
||||||
|
<div class="auth-card">
|
||||||
|
<h1>Einladung annehmen</h1>
|
||||||
|
<p class="sub" id="acceptInviteSub">Lade Einladung…</p>
|
||||||
|
<div id="acceptInviteError" style="display:none" class="form-error"></div>
|
||||||
|
<form id="acceptInviteForm" style="display:none">
|
||||||
|
<div class="field">
|
||||||
|
<label for="acceptEmail">E-Mail-Adresse</label>
|
||||||
|
<input type="email" id="acceptEmail" disabled />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="acceptFullName">Vollständiger Name</label>
|
||||||
|
<input type="text" id="acceptFullName" required autocomplete="name" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="acceptPassword">Passwort wählen (min. 8 Zeichen)</label>
|
||||||
|
<input type="password" id="acceptPassword" required minlength="8" autocomplete="new-password" />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary" id="acceptSubmitBtn">Konto erstellen & anmelden</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</div>
|
||||||
|
|
||||||
<main>
|
<!-- ================= APP VIEW ================= -->
|
||||||
<div id="errorBanner" style="display:none" class="error-banner"></div>
|
<div id="appView" class="view">
|
||||||
|
|
||||||
<div class="stat-row" id="statRow"></div>
|
<div class="topbar">
|
||||||
|
<span id="whoAmI"></span>
|
||||||
|
<button class="btn-secondary" onclick="logout()">Abmelden</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section>
|
<header>
|
||||||
<h2>Miet-Ampel je Zimmer</h2>
|
<div>
|
||||||
<div class="room-grid" id="roomGrid"></div>
|
<h1>WG Nackenheim — Vermieter-Cockpit</h1>
|
||||||
</section>
|
<p>170 qm · 4 Zimmer · Einzelzimmervermietung</p>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; align-items:center; gap:12px;">
|
||||||
|
<div class="meta" id="lastUpdated">–</div>
|
||||||
|
<button class="refresh-btn" onclick="loadData()">↻ Aktualisieren</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<section>
|
<main>
|
||||||
<h2>Offene Schadensmeldungen</h2>
|
<div id="errorBanner" style="display:none" class="error-banner"></div>
|
||||||
<div class="ticket-list" id="ticketList"></div>
|
|
||||||
</section>
|
<div class="stat-row" id="statRow"></div>
|
||||||
</main>
|
|
||||||
|
<section id="inviteSection" style="display:none">
|
||||||
|
<h2>Mieter einladen</h2>
|
||||||
|
<div class="invite-card">
|
||||||
|
<div id="inviteFormError" style="display:none" class="form-error"></div>
|
||||||
|
<form id="inviteForm" class="invite-form-row">
|
||||||
|
<div class="field">
|
||||||
|
<label for="inviteEmail">E-Mail-Adresse des Mieters</label>
|
||||||
|
<input type="email" id="inviteEmail" required placeholder="mieter@beispiel.de" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="inviteRoom">Zimmer (optional)</label>
|
||||||
|
<select id="inviteRoom">
|
||||||
|
<option value="">– kein Zimmer zuordnen –</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary" style="width:auto" id="inviteSubmitBtn">Einladen</button>
|
||||||
|
</form>
|
||||||
|
<div id="inviteLinkResult" style="display:none" class="invite-link-box">
|
||||||
|
<code id="inviteLinkText"></code>
|
||||||
|
<button class="btn-secondary" onclick="copyInviteLink()">Kopieren</button>
|
||||||
|
</div>
|
||||||
|
<table class="invite-table" id="inviteTable" style="display:none">
|
||||||
|
<thead>
|
||||||
|
<tr><th>E-Mail</th><th>Zimmer</th><th>Status</th><th>Eingeladen am</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="inviteTableBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Miet-Ampel je Zimmer</h2>
|
||||||
|
<div class="room-grid" id="roomGrid"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Offene Schadensmeldungen</h2>
|
||||||
|
<div class="ticket-list" id="ticketList"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const API_BASE_URL = window.WG_API_BASE_URL ||
|
const API_BASE_URL = window.WG_API_BASE_URL ||
|
||||||
@ -194,6 +407,8 @@
|
|||||||
|
|
||||||
const STATUS_LABEL = { GREEN: 'Bezahlt', YELLOW: 'Fällig', RED: 'Überfällig' };
|
const STATUS_LABEL = { GREEN: 'Bezahlt', YELLOW: 'Fällig', RED: 'Überfällig' };
|
||||||
const PRIORITY_LABEL = { LOW: 'Niedrig', MEDIUM: 'Mittel', HIGH: 'Hoch', EMERGENCY: 'Notfall' };
|
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' };
|
||||||
|
|
||||||
function formatEuro(n) {
|
function formatEuro(n) {
|
||||||
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(n || 0);
|
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(n || 0);
|
||||||
@ -206,18 +421,282 @@
|
|||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// AUTH — Login, Session-Wiederherstellung, Einladungen annehmen
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
let currentUser = null;
|
||||||
|
|
||||||
|
function getToken() { return localStorage.getItem('wg_token'); }
|
||||||
|
function setSession(token, user) {
|
||||||
|
localStorage.setItem('wg_token', token);
|
||||||
|
localStorage.setItem('wg_user', JSON.stringify(user));
|
||||||
|
currentUser = user;
|
||||||
|
}
|
||||||
|
function clearSession() {
|
||||||
|
localStorage.removeItem('wg_token');
|
||||||
|
localStorage.removeItem('wg_user');
|
||||||
|
currentUser = null;
|
||||||
|
}
|
||||||
|
function authHeaders() {
|
||||||
|
const token = getToken();
|
||||||
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function showView(id) {
|
||||||
|
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
|
||||||
|
document.getElementById(id).classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiFetch(path, options = {}) {
|
||||||
|
const res = await fetch(`${API_BASE_URL}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...authHeaders(),
|
||||||
|
...(options.headers || {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let body = null;
|
||||||
|
try { body = await res.json(); } catch { /* kein JSON-Body */ }
|
||||||
|
if (!res.ok) {
|
||||||
|
const message = (body && body.error) || `Server antwortete mit Status ${res.status}`;
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const inviteToken = new URLSearchParams(location.search).get('invite');
|
||||||
|
if (inviteToken) {
|
||||||
|
showView('acceptInviteView');
|
||||||
|
initAcceptInvite(inviteToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const storedUser = localStorage.getItem('wg_user');
|
||||||
|
if (getToken() && storedUser) {
|
||||||
|
try {
|
||||||
|
const data = await apiFetch('/auth/me');
|
||||||
|
currentUser = data.user;
|
||||||
|
enterApp();
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
clearSession();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showView('loginView');
|
||||||
|
initLogin();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initLogin() {
|
||||||
|
const form = document.getElementById('loginForm');
|
||||||
|
const errorBox = document.getElementById('loginError');
|
||||||
|
form.onsubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
errorBox.style.display = 'none';
|
||||||
|
const submitBtn = document.getElementById('loginSubmitBtn');
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.textContent = 'Anmelden…';
|
||||||
|
try {
|
||||||
|
const email = document.getElementById('loginEmail').value.trim();
|
||||||
|
const password = document.getElementById('loginPassword').value;
|
||||||
|
const data = await apiFetch('/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
setSession(data.token, data.user);
|
||||||
|
enterApp();
|
||||||
|
} catch (err) {
|
||||||
|
errorBox.textContent = err.message;
|
||||||
|
errorBox.style.display = 'block';
|
||||||
|
} finally {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = 'Anmelden';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initAcceptInvite(token) {
|
||||||
|
const sub = document.getElementById('acceptInviteSub');
|
||||||
|
const errorBox = document.getElementById('acceptInviteError');
|
||||||
|
const form = document.getElementById('acceptInviteForm');
|
||||||
|
try {
|
||||||
|
const preview = await apiFetch(`/invitations/${token}/preview`);
|
||||||
|
sub.textContent = `Einladung als ${ROLE_LABEL[preview.role] || preview.role}` +
|
||||||
|
(preview.room ? ` für ${preview.room.roomNumber}` : '') + '. Bitte Passwort festlegen.';
|
||||||
|
document.getElementById('acceptEmail').value = preview.email;
|
||||||
|
form.style.display = 'flex';
|
||||||
|
form.style.flexDirection = 'column';
|
||||||
|
|
||||||
|
form.onsubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
errorBox.style.display = 'none';
|
||||||
|
const submitBtn = document.getElementById('acceptSubmitBtn');
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.textContent = 'Wird erstellt…';
|
||||||
|
try {
|
||||||
|
const fullName = document.getElementById('acceptFullName').value.trim();
|
||||||
|
const password = document.getElementById('acceptPassword').value;
|
||||||
|
const data = await apiFetch(`/invitations/${token}/accept`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ fullName, password }),
|
||||||
|
});
|
||||||
|
setSession(data.token, data.user);
|
||||||
|
history.replaceState(null, '', location.pathname);
|
||||||
|
enterApp();
|
||||||
|
} catch (err) {
|
||||||
|
errorBox.textContent = err.message;
|
||||||
|
errorBox.style.display = 'block';
|
||||||
|
} finally {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = 'Konto erstellen & anmelden';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
sub.textContent = '';
|
||||||
|
errorBox.textContent = err.message;
|
||||||
|
errorBox.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
clearSession();
|
||||||
|
history.replaceState(null, '', location.pathname);
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
function enterApp() {
|
||||||
|
showView('appView');
|
||||||
|
document.getElementById('whoAmI').textContent =
|
||||||
|
`${currentUser.fullName} · ${ROLE_LABEL[currentUser.role] || currentUser.role}`;
|
||||||
|
|
||||||
|
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
|
||||||
|
document.getElementById('inviteSection').style.display = isLandlord ? 'block' : 'none';
|
||||||
|
if (isLandlord) initInvitePanel();
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
setInterval(loadData, REFRESH_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// MIETER EINLADEN (nur Vermieter/Admin)
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
let lastRooms = [];
|
||||||
|
|
||||||
|
function populateRoomSelect() {
|
||||||
|
const select = document.getElementById('inviteRoom');
|
||||||
|
select.innerHTML = '<option value="">– kein Zimmer zuordnen –</option>';
|
||||||
|
lastRooms.forEach(r => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = r.id;
|
||||||
|
opt.textContent = r.roomNumber;
|
||||||
|
select.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initInvitePanel() {
|
||||||
|
const form = document.getElementById('inviteForm');
|
||||||
|
const errorBox = document.getElementById('inviteFormError');
|
||||||
|
|
||||||
|
form.onsubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
errorBox.style.display = 'none';
|
||||||
|
const submitBtn = document.getElementById('inviteSubmitBtn');
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.textContent = 'Lädt ein…';
|
||||||
|
try {
|
||||||
|
const email = document.getElementById('inviteEmail').value.trim();
|
||||||
|
const roomId = document.getElementById('inviteRoom').value || undefined;
|
||||||
|
const data = await apiFetch('/invitations', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ email, roomId }),
|
||||||
|
});
|
||||||
|
document.getElementById('inviteLinkText').textContent = data.inviteLink;
|
||||||
|
document.getElementById('inviteLinkResult').style.display = 'flex';
|
||||||
|
document.getElementById('inviteEmail').value = '';
|
||||||
|
loadInvitations();
|
||||||
|
} catch (err) {
|
||||||
|
errorBox.textContent = err.message;
|
||||||
|
errorBox.style.display = 'block';
|
||||||
|
} finally {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = 'Einladen';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadInvitations();
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyInviteLink() {
|
||||||
|
const text = document.getElementById('inviteLinkText').textContent;
|
||||||
|
navigator.clipboard?.writeText(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadInvitations() {
|
||||||
|
try {
|
||||||
|
const data = await apiFetch('/invitations');
|
||||||
|
const table = document.getElementById('inviteTable');
|
||||||
|
const body = document.getElementById('inviteTableBody');
|
||||||
|
body.innerHTML = '';
|
||||||
|
if (!data.invitations.length) {
|
||||||
|
table.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
table.style.display = 'table';
|
||||||
|
data.invitations.forEach(inv => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.appendChild(el('td', '', inv.email));
|
||||||
|
tr.appendChild(el('td', '', inv.room ? inv.room.roomNumber : '–'));
|
||||||
|
tr.appendChild(el('td', '', `<span class="invite-status-chip ${inv.status}">${INVITE_STATUS_LABEL[inv.status] || inv.status}</span>`));
|
||||||
|
tr.appendChild(el('td', '', new Date(inv.createdAt).toLocaleDateString('de-DE')));
|
||||||
|
const actionsTd = el('td', '');
|
||||||
|
if (inv.status === 'PENDING') {
|
||||||
|
const revokeBtn = document.createElement('button');
|
||||||
|
revokeBtn.className = 'btn-secondary';
|
||||||
|
revokeBtn.textContent = 'Zurückziehen';
|
||||||
|
revokeBtn.onclick = () => revokeInvitation(inv.id);
|
||||||
|
actionsTd.appendChild(revokeBtn);
|
||||||
|
}
|
||||||
|
tr.appendChild(actionsTd);
|
||||||
|
body.appendChild(tr);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// Einladungsliste ist nicht kritisch für die Kernansicht — still fehlschlagen lassen.
|
||||||
|
console.error('[invitations] Laden fehlgeschlagen:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeInvitation(id) {
|
||||||
|
try {
|
||||||
|
await apiFetch(`/invitations/${id}`, { method: 'DELETE' });
|
||||||
|
loadInvitations();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// COCKPIT-DATEN
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
const errorBanner = document.getElementById('errorBanner');
|
const errorBanner = document.getElementById('errorBanner');
|
||||||
errorBanner.style.display = 'none';
|
errorBanner.style.display = 'none';
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE_URL}/cockpit/summary`);
|
const data = await apiFetch('/cockpit/summary');
|
||||||
if (!res.ok) throw new Error(`Server antwortete mit Status ${res.status}`);
|
lastRooms = data.rooms || [];
|
||||||
const data = await res.json();
|
populateRoomSelect();
|
||||||
render(data);
|
render(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err.message === 'Nicht authentifiziert' || err.message === 'Token ungültig oder abgelaufen') {
|
||||||
|
clearSession();
|
||||||
|
location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
errorBanner.style.display = 'block';
|
errorBanner.style.display = 'block';
|
||||||
errorBanner.innerHTML = `Backend nicht erreichbar unter <code>${API_BASE_URL}</code>. ` +
|
errorBanner.innerHTML = `Backend nicht erreichbar unter <code>${API_BASE_URL}</code>. (${err.message})`;
|
||||||
`Läuft <code>setup-and-start.bat</code> noch? (${err.message})`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -288,8 +767,7 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
loadData();
|
bootstrap();
|
||||||
setInterval(loadData, REFRESH_INTERVAL_MS);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user