add login + invite UI to web dashboard
This commit is contained in:
parent
00eaa3d7d6
commit
6d8cbd7f42
@ -156,36 +156,249 @@
|
||||
font-size: 14px;
|
||||
}
|
||||
.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>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div>
|
||||
<h1>WG Nackenheim — Vermieter-Cockpit</h1>
|
||||
<p>170 qm · 4 Zimmer · Einzelzimmervermietung</p>
|
||||
<!-- ================= LOGIN VIEW ================= -->
|
||||
<div id="loginView" class="view">
|
||||
<div class="auth-wrap">
|
||||
<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 style="display:flex; align-items:center; gap:12px;">
|
||||
<div class="meta" id="lastUpdated">–</div>
|
||||
<button class="refresh-btn" onclick="loadData()">↻ Aktualisieren</button>
|
||||
</div>
|
||||
|
||||
<!-- ================= 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>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<div id="errorBanner" style="display:none" class="error-banner"></div>
|
||||
<!-- ================= APP VIEW ================= -->
|
||||
<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>
|
||||
<h2>Miet-Ampel je Zimmer</h2>
|
||||
<div class="room-grid" id="roomGrid"></div>
|
||||
</section>
|
||||
<header>
|
||||
<div>
|
||||
<h1>WG Nackenheim — Vermieter-Cockpit</h1>
|
||||
<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>
|
||||
<h2>Offene Schadensmeldungen</h2>
|
||||
<div class="ticket-list" id="ticketList"></div>
|
||||
</section>
|
||||
</main>
|
||||
<main>
|
||||
<div id="errorBanner" style="display:none" class="error-banner"></div>
|
||||
|
||||
<div class="stat-row" id="statRow"></div>
|
||||
|
||||
<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>
|
||||
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 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) {
|
||||
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(n || 0);
|
||||
@ -206,18 +421,282 @@
|
||||
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() {
|
||||
const errorBanner = document.getElementById('errorBanner');
|
||||
errorBanner.style.display = 'none';
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/cockpit/summary`);
|
||||
if (!res.ok) throw new Error(`Server antwortete mit Status ${res.status}`);
|
||||
const data = await res.json();
|
||||
const data = await apiFetch('/cockpit/summary');
|
||||
lastRooms = data.rooms || [];
|
||||
populateRoomSelect();
|
||||
render(data);
|
||||
} catch (err) {
|
||||
if (err.message === 'Nicht authentifiziert' || err.message === 'Token ungültig oder abgelaufen') {
|
||||
clearSession();
|
||||
location.reload();
|
||||
return;
|
||||
}
|
||||
errorBanner.style.display = 'block';
|
||||
errorBanner.innerHTML = `Backend nicht erreichbar unter <code>${API_BASE_URL}</code>. ` +
|
||||
`Läuft <code>setup-and-start.bat</code> noch? (${err.message})`;
|
||||
errorBanner.innerHTML = `Backend nicht erreichbar unter <code>${API_BASE_URL}</code>. (${err.message})`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -288,8 +767,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
loadData();
|
||||
setInterval(loadData, REFRESH_INTERVAL_MS);
|
||||
bootstrap();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user