bollwerk/server/src/main/resources/static/admin/index.html

735 lines
No EOL
40 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bollwerk Admin</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #16140F; color: #E8D5B0; position: relative; }
body::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
opacity: 0.04;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23n)' opacity='1'/%3E%3C/svg%3E");
background-repeat: repeat;
background-size: 200px 200px;
}
body > * { position: relative; z-index: 1; }
h1, h2, h3, label, .stat-label, .inv-stats-toggle { font-family: 'Share Tech Mono', 'Courier New', monospace; }
h1, h2 { letter-spacing: 0.05em; }
h2::before { content: '▣ '; color: #C1440E; }
header { background: #1A1815; color: #E8D5B0; padding: 16px 24px; display: flex; justify-content: space-between; align-items: center; border-bottom: 2px solid #C1440E; }
header h1 { font-size: 1.2rem; text-transform: uppercase; }
#logout-btn { background: transparent; border: 1px solid #4A3F2F; color: #A89070; padding: 6px 14px; border-radius: 3px; cursor: pointer; font-family: 'Share Tech Mono', 'Courier New', monospace; text-transform: uppercase; font-size: .85rem; }
#logout-btn:hover { border-color: #C1440E; color: #E8D5B0; }
main { max-width: 960px; margin: 32px auto; padding: 0 16px; }
.card { background: #242119; border: 1px solid #4A3F2F; border-left: 3px solid #8B3A0F; border-radius: 3px; padding: 32px; box-shadow: 0 2px 8px rgba(0,0,0,0.6); margin-bottom: 24px; }
h2 { margin-bottom: 20px; font-size: 1.1rem; color: #E8D5B0; }
label { display: block; margin-bottom: 4px; font-size: .875rem; color: #A89070; text-transform: uppercase; }
input, select { width: 100%; padding: 8px 12px; border: 1px solid #4A3F2F; border-radius: 3px; margin-bottom: 14px; font-size: 1rem; background: #1C1A14; color: #E8D5B0; }
input:focus, select:focus { outline: none; border-color: #C1440E; box-shadow: 0 0 0 3px rgba(193,68,14,0.4); }
button.primary { background: #C1440E; color: #E8D5B0; border: none; padding: 10px 20px; border-radius: 3px; cursor: pointer; font-size: .9rem; font-family: 'Share Tech Mono', 'Courier New', monospace; text-transform: uppercase; text-shadow: 0 1px 2px rgba(0,0,0,0.4); }
button.primary:hover { background: #D95A20; }
button.danger { background: #8B1A1A; color: #E8D5B0; border: none; padding: 6px 12px; border-radius: 3px; cursor: pointer; font-family: 'Share Tech Mono', 'Courier New', monospace; text-transform: uppercase; font-size: .85rem; }
button.danger:hover { background: #A52020; }
button.secondary { background: transparent; color: #A89070; border: 1px solid #4A3F2F; padding: 6px 12px; border-radius: 3px; cursor: pointer; font-family: 'Share Tech Mono', 'Courier New', monospace; text-transform: uppercase; font-size: .85rem; }
button.secondary:hover { border-color: #A89070; color: #E8D5B0; }
button.info { background: #C1440E; color: #E8D5B0; border: none; padding: 6px 12px; border-radius: 3px; cursor: pointer; font-family: 'Share Tech Mono', 'Courier New', monospace; text-transform: uppercase; font-size: .85rem; }
button.info:hover { background: #D95A20; }
.error { color: #D95A20; margin-bottom: 12px; font-size: .875rem; }
table { width: 100%; border-collapse: collapse; margin-top: 16px; }
th, td { padding: 10px 14px; text-align: left; border-bottom: 1px solid #4A3F2F; font-size: .9rem; color: #E8D5B0; }
th { background: #2E2B22; font-weight: 600; font-family: 'Share Tech Mono', 'Courier New', monospace; text-transform: uppercase; font-size: .8rem; color: #A89070; }
tr:nth-child(even) { background: #1E1C17; }
tr:nth-child(odd) { background: #242119; }
tr:hover { background: #2E2B22; }
.actions { display: flex; gap: 8px; flex-wrap: wrap; }
.inv-badge { display: inline-block; font-size: .75rem; background: #3A2E10; color: #C8A840; border: 1px solid #8B6914; border-radius: 3px; padding: 2px 7px; font-family: monospace; cursor: default; }
.inv-group { background: #1E1C17; border: 1px solid #4A3F2F; border-radius: 3px; padding: 14px 18px; margin-bottom: 12px; }
.inv-group h3 { font-size: .85rem; color: #A89070; margin-bottom: 8px; font-weight: 600; }
.inv-group ul { list-style: none; display: flex; flex-wrap: wrap; gap: 8px; }
.inv-group ul li { background: #242119; border: 1px solid #4A3F2F; border-radius: 3px; padding: 4px 10px; font-size: .875rem; color: #E8D5B0; }
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 16px; margin-bottom: 8px; }
.stat-card { background: #1E1C17; border: 1px solid #4A3F2F; border-radius: 3px; padding: 16px; text-align: center; }
.stat-card .stat-value { font-size: 1.8rem; font-weight: 700; color: #C1440E; margin-bottom: 4px; }
.stat-card .stat-label { font-size: .8rem; color: #A89070; text-transform: uppercase; letter-spacing: .5px; }
#add-user-form { display: none; background: #1E1C17; border: 1px solid #4A3F2F; border-radius: 3px; padding: 20px; margin-top: 16px; }
#add-user-form.open { display: block; }
#add-user-form h3 { margin-bottom: 14px; font-size: 1rem; color: #E8D5B0; }
.modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.7); z-index: 100; justify-content: center; align-items: center; }
.modal-overlay.open { display: flex; }
.modal { background: #242119; border: 2px solid #C1440E; border-radius: 3px; padding: 28px; width: 400px; max-width: 95vw; }
.modal h3 { margin-bottom: 16px; color: #E8D5B0; }
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 16px; }
/* Per-inventory stats */
.inv-stats-toggle { background: none; border: none; cursor: pointer; font-size: .85rem; color: #C1440E; padding: 0; margin-top: 12px; text-decoration: underline; }
.inv-stats-toggle:hover { color: #D95A20; }
#inv-stats-section { display: none; margin-top: 16px; }
#inv-stats-section.open { display: block; }
#inv-stats-table th { cursor: pointer; user-select: none; white-space: nowrap; }
#inv-stats-table th:hover { background: #3A3428; }
#inv-stats-table th .sort-arrow { margin-left: 4px; color: #6B5B3E; font-size: .8rem; }
/* Inventory list table */
.table-controls { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; margin-bottom: 12px; }
.table-controls input[type=text] { flex: 1; min-width: 180px; margin: 0; }
.table-controls select { width: auto; margin: 0; }
#inv-table th { cursor: pointer; user-select: none; white-space: nowrap; }
#inv-table th:hover { background: #3A3428; }
#inv-table th .sort-arrow { margin-left: 4px; color: #6B5B3E; font-size: .8rem; }
.paging-bar { display: flex; gap: 8px; align-items: center; margin-top: 12px; flex-wrap: wrap; }
.paging-bar button { padding: 4px 10px; font-size: .85rem; }
.paging-bar span { font-size: .85rem; color: #A89070; }
footer { text-align: center; padding: 24px 16px; margin-top: 40px; font-size: .8rem; color: #6B5B3E; border-top: 1px solid #4A3F2F; }
/* Tab navigation */
.tab-bar { display: flex; gap: 0; margin-bottom: 24px; border-bottom: 2px solid #4A3F2F; }
.tab-btn { background: transparent; border: none; border-bottom: 3px solid transparent; color: #6B5B3E; padding: 12px 24px; cursor: pointer; font-family: 'Share Tech Mono', 'Courier New', monospace; text-transform: uppercase; font-size: .95rem; letter-spacing: .05em; transition: color .2s, border-color .2s; }
.tab-btn:hover { color: #E8D5B0; }
.tab-btn.active { color: #E8D5B0; border-bottom-color: #C1440E; }
.tab-content { display: none; }
.tab-content.active { display: block; }
/* Login */
#login-section { max-width: 360px; margin-left: auto; margin-right: auto; }
/* Scrollbar styling */
::-webkit-scrollbar { width: 10px; }
::-webkit-scrollbar-track { background: #1A1815; }
::-webkit-scrollbar-thumb { background: #4A3F2F; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #C1440E; }
</style>
</head>
<body>
<header>
<h1>Bollwerk Admin</h1>
<button id="logout-btn" style="display:none" onclick="logout()">Abmelden</button>
</header>
<main>
<section id="login-section" class="card">
<h2>Anmelden</h2>
<div id="login-error" class="error" style="display:none"></div>
<label>Benutzername</label>
<input id="login-username" type="text" autocomplete="username">
<label>Passwort</label>
<input id="login-password" type="password" autocomplete="current-password">
<button class="primary" onclick="login()">Anmelden</button>
</section>
<div id="admin-section" style="display:none">
<!-- Tab Navigation -->
<nav class="tab-bar">
<button class="tab-btn active" data-tab="tab-users" onclick="switchTab('tab-users')">User</button>
<button class="tab-btn" data-tab="tab-inventories" onclick="switchTab('tab-inventories')">Inventare</button>
<button class="tab-btn" data-tab="tab-backups" onclick="switchTab('tab-backups')">Backups</button>
</nav>
<!-- Tab: User -->
<div id="tab-users" class="tab-content active">
<!-- Stats Dashboard -->
<section class="card">
<h2>Statistiken</h2>
<div id="stats-grid" class="stats-grid">
<div class="stat-card"><div class="stat-value" id="stat-items"></div><div class="stat-label">Artikel</div></div>
<div class="stat-card"><div class="stat-value" id="stat-locations"></div><div class="stat-label">Lagerorte</div></div>
<div class="stat-card"><div class="stat-value" id="stat-categories"></div><div class="stat-label">Kategorien</div></div>
<div class="stat-card"><div class="stat-value" id="stat-recent"></div><div class="stat-label">Änderungen (30 Tage)</div></div>
<div class="stat-card"><div class="stat-value" id="stat-last-updated" style="font-size:1rem"></div><div class="stat-label">Letzte Änderung</div></div>
</div>
<button class="inv-stats-toggle" onclick="toggleInvStats()">▶ Statistiken pro Inventar anzeigen</button>
<div id="inv-stats-section">
<table id="inv-stats-table">
<thead>
<tr>
<th onclick="sortInvStats('inventoryName')">Name <span class="sort-arrow" id="isort-inventoryName"></span></th>
<th onclick="sortInvStats('totalItems')">Artikel <span class="sort-arrow" id="isort-totalItems"></span></th>
<th onclick="sortInvStats('totalLocations')">Lagerorte <span class="sort-arrow" id="isort-totalLocations"></span></th>
<th onclick="sortInvStats('totalCategories')">Kategorien <span class="sort-arrow" id="isort-totalCategories"></span></th>
<th onclick="sortInvStats('recentTransactions')">Änd. 30T <span class="sort-arrow" id="isort-recentTransactions"></span></th>
<th onclick="sortInvStats('lastUpdated')">Letzte Änd. <span class="sort-arrow" id="isort-lastUpdated"></span></th>
<th onclick="sortInvStats('userCount')">Benutzer <span class="sort-arrow" id="isort-userCount"></span></th>
</tr>
</thead>
<tbody id="inv-stats-body"></tbody>
</table>
</div>
</section>
<!-- User Management -->
<section class="card">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px">
<h2>Benutzerverwaltung</h2>
<button class="primary" onclick="toggleAddForm()">+ Benutzer anlegen</button>
</div>
<div id="add-user-form">
<h3>Neuen Benutzer anlegen</h3>
<div id="add-user-error" class="error" style="display:none"></div>
<label>Benutzername</label>
<input id="new-username" type="text">
<label>Passwort</label>
<input id="new-password" type="password">
<div style="display:flex; gap:10px">
<button class="primary" onclick="createUser()">Anlegen</button>
<button class="secondary" onclick="toggleAddForm()">Abbrechen</button>
</div>
</div>
<table id="users-table">
<thead><tr><th>Benutzername</th><th>Erstellt</th><th>Admin</th><th>Inventar</th><th>Aktionen</th></tr></thead>
<tbody id="users-body"></tbody>
</table>
</section>
</div><!-- /tab-users -->
<!-- Tab: Inventare -->
<div id="tab-inventories" class="tab-content">
<!-- Inventory Overview -->
<section class="card">
<h2>Inventar-Übersicht</h2>
<div class="table-controls">
<input type="text" id="inv-search" placeholder="Suche nach Name oder ID…" oninput="applyInvFilter()">
<select id="inv-filter" onchange="applyInvFilter()">
<option value="all">Alle Inventare</option>
<option value="with-users">Mit Benutzern</option>
<option value="without-users">Ohne Benutzer</option>
</select>
<select id="inv-page-size" onchange="invPageSize=+this.value; invPage=0; renderInvTable()">
<option value="10">10 / Seite</option>
<option value="25">25 / Seite</option>
<option value="50">50 / Seite</option>
</select>
</div>
<table id="inv-table">
<thead>
<tr>
<th onclick="sortInv('inventoryName')">Name <span class="sort-arrow" id="sort-inventoryName"></span></th>
<th onclick="sortInv('totalItems')">Artikel <span class="sort-arrow" id="sort-totalItems"></span></th>
<th onclick="sortInv('totalLocations')">Lagerorte <span class="sort-arrow" id="sort-totalLocations"></span></th>
<th onclick="sortInv('totalCategories')">Kategorien <span class="sort-arrow" id="sort-totalCategories"></span></th>
<th onclick="sortInv('userCount')">Benutzer <span class="sort-arrow" id="sort-userCount"></span></th>
<th onclick="sortInv('lastUpdated')">Letzte Änderung <span class="sort-arrow" id="sort-lastUpdated"></span></th>
<th>Aktionen</th>
</tr>
</thead>
<tbody id="inv-table-body"></tbody>
</table>
<div class="paging-bar">
<button class="secondary" onclick="invPage--; renderInvTable()" id="inv-prev"> Zurück</button>
<span id="inv-page-info"></span>
<button class="secondary" onclick="invPage++; renderInvTable()" id="inv-next">Weiter </button>
</div>
</section>
</div><!-- /tab-inventories -->
<!-- Tab: Backups -->
<div id="tab-backups" class="tab-content">
<section class="card">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px">
<h2>Backups</h2>
<button class="secondary" onclick="loadBackups()">↻ Aktualisieren</button>
</div>
<table id="backups-table">
<thead><tr><th>Dateiname</th><th>Größe</th><th>Erstellt</th></tr></thead>
<tbody id="backups-body"><tr><td colspan="3" style="text-align:center;color:#6B5B3E">Wird geladen…</td></tr></tbody>
</table>
</section>
</div><!-- /tab-backups -->
</div>
</main>
<!-- Change Password Modal -->
<div id="pw-modal" class="modal-overlay">
<div class="modal">
<h3>Passwort ändern</h3>
<div id="pw-error" class="error" style="display:none"></div>
<label>Neues Passwort</label>
<input id="pw-input" type="password">
<div class="modal-actions">
<button class="secondary" onclick="closeModal()">Abbrechen</button>
<button class="primary" onclick="confirmPasswordChange()">Speichern</button>
</div>
</div>
</div>
<!-- Delete Confirm Modal -->
<div id="del-modal" class="modal-overlay">
<div class="modal">
<h3>Benutzer löschen?</h3>
<p id="del-confirm-text" style="margin-bottom:16px; font-size:.9rem; color:#A89070"></p>
<div class="modal-actions">
<button class="secondary" onclick="closeModal()">Abbrechen</button>
<button class="danger" onclick="confirmDelete()">Löschen</button>
</div>
</div>
</div>
<!-- Assign Inventory Modal -->
<div id="assign-modal" class="modal-overlay">
<div class="modal">
<h3>Inventar zuweisen</h3>
<p style="margin-bottom:14px; font-size:.875rem; color:#A89070">Wähle ein bestehendes Inventar für diesen Benutzer:</p>
<div id="assign-error" class="error" style="display:none"></div>
<label>Inventar</label>
<select id="assign-inventory-select"></select>
<div class="modal-actions">
<button class="secondary" onclick="closeModal()">Abbrechen</button>
<button class="primary" onclick="confirmAssign()">Zuweisen</button>
</div>
</div>
</div>
<script>
let accessToken = sessionStorage.getItem('accessToken') || '';
let pendingUserId = null;
let allInventories = [];
let allInvStats = [];
// Inventory table state
let invData = [];
let invFiltered = [];
let invPage = 0;
let invPageSize = 10;
let invSortCol = 'inventoryName';
let invSortAsc = true;
// Per-inventory stats table state
let invStatsSortCol = 'inventoryName';
let invStatsSortAsc = true;
if (accessToken) tryLoadUsers();
async function login() {
const username = document.getElementById('login-username').value;
const password = document.getElementById('login-password').value;
const err = document.getElementById('login-error');
err.style.display = 'none';
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
if (!res.ok) {
err.textContent = (await res.json()).message || 'Login fehlgeschlagen';
err.style.display = 'block';
return;
}
const data = await res.json();
accessToken = data.accessToken;
sessionStorage.setItem('accessToken', accessToken);
showAdmin();
} catch (e) {
err.textContent = 'Verbindungsfehler';
err.style.display = 'block';
}
}
function showAdmin() {
document.getElementById('login-section').style.display = 'none';
document.getElementById('admin-section').style.display = 'block';
document.getElementById('logout-btn').style.display = 'inline-block';
loadAll();
}
function tryLoadUsers() {
fetch('/api/admin/users', { headers: { 'Authorization': 'Bearer ' + accessToken } })
.then(r => r.ok ? (showAdmin(), r.json()) : Promise.reject())
.then(renderUsers)
.catch(() => { accessToken = ''; sessionStorage.removeItem('accessToken'); });
}
async function loadAll() {
const [usersRes, inventoriesRes, statsRes, invStatsRes] = await Promise.all([
fetch('/api/admin/users', { headers: { 'Authorization': 'Bearer ' + accessToken } }),
fetch('/api/admin/inventories', { headers: { 'Authorization': 'Bearer ' + accessToken } }),
fetch('/api/admin/stats', { headers: { 'Authorization': 'Bearer ' + accessToken } }),
fetch('/api/admin/stats/inventories', { headers: { 'Authorization': 'Bearer ' + accessToken } })
]);
if (!usersRes.ok) { logout(); return; }
renderUsers(await usersRes.json());
if (inventoriesRes.ok) {
allInventories = await inventoriesRes.json();
}
if (statsRes.ok) {
renderStats(await statsRes.json());
}
if (invStatsRes.ok) {
allInvStats = await invStatsRes.json();
renderInvStatsTable();
invData = allInvStats;
applyInvFilter();
}
}
function renderStats(stats) {
document.getElementById('stat-items').textContent = stats.totalItems;
document.getElementById('stat-locations').textContent = stats.totalLocations;
document.getElementById('stat-categories').textContent = stats.totalCategories;
document.getElementById('stat-recent').textContent = stats.recentTransactions;
if (stats.lastUpdated) {
const d = new Date(stats.lastUpdated);
document.getElementById('stat-last-updated').textContent = d.toLocaleDateString('de-DE') + ' ' + d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
} else {
document.getElementById('stat-last-updated').textContent = '';
}
}
// ── Per-inventory stats table ────────────────────────────────────────────
function toggleInvStats() {
const section = document.getElementById('inv-stats-section');
const btn = document.querySelector('.inv-stats-toggle');
const open = section.classList.toggle('open');
btn.textContent = (open ? '▼' : '▶') + ' Statistiken pro Inventar ' + (open ? 'ausblenden' : 'anzeigen');
}
function sortInvStats(col) {
if (invStatsSortCol === col) { invStatsSortAsc = !invStatsSortAsc; }
else { invStatsSortCol = col; invStatsSortAsc = true; }
renderInvStatsTable();
}
function renderInvStatsTable() {
const data = [...allInvStats].sort((a, b) => {
const va = a[invStatsSortCol] ?? '';
const vb = b[invStatsSortCol] ?? '';
if (typeof va === 'number' && typeof vb === 'number') return invStatsSortAsc ? va - vb : vb - va;
return invStatsSortAsc ? String(va).localeCompare(String(vb)) : String(vb).localeCompare(String(va));
});
const cols = ['inventoryName', 'totalItems', 'totalLocations', 'totalCategories', 'recentTransactions', 'lastUpdated', 'userCount'];
cols.forEach(c => {
const el = document.getElementById('isort-' + c);
if (el) el.textContent = invStatsSortCol === c ? (invStatsSortAsc ? '▲' : '▼') : '';
});
const tbody = document.getElementById('inv-stats-body');
tbody.innerHTML = '';
if (!data.length) {
const tr = document.createElement('tr');
const td = document.createElement('td'); td.colSpan = 7; td.textContent = 'Keine Inventare vorhanden.'; td.style.textAlign = 'center'; td.style.color = '#999';
tr.appendChild(td); tbody.appendChild(tr);
return;
}
data.forEach(inv => {
const tr = document.createElement('tr');
const name = inv.inventoryName || inv.inventoryId.slice(0, 8) + '…';
const lastUpdFmt = inv.lastUpdated ? new Date(inv.lastUpdated).toLocaleDateString('de-DE') : '';
[name, inv.totalItems, inv.totalLocations, inv.totalCategories, inv.recentTransactions, lastUpdFmt, inv.userCount].forEach(v => {
const td = document.createElement('td'); td.textContent = v; tr.appendChild(td);
});
tbody.appendChild(tr);
});
}
// ── Inventory list table ─────────────────────────────────────────────────
function applyInvFilter() {
const search = document.getElementById('inv-search').value.toLowerCase();
const filter = document.getElementById('inv-filter').value;
invFiltered = invData.filter(inv => {
const name = (inv.inventoryName || inv.inventoryId || '').toLowerCase();
const id = (inv.inventoryId || '').toLowerCase();
if (search && !name.includes(search) && !id.includes(search)) return false;
if (filter === 'with-users' && inv.userCount === 0) return false;
if (filter === 'without-users' && inv.userCount > 0) return false;
return true;
});
invPage = 0;
renderInvTable();
}
function sortInv(col) {
if (invSortCol === col) { invSortAsc = !invSortAsc; }
else { invSortCol = col; invSortAsc = true; }
renderInvTable();
}
function renderInvTable() {
const sorted = [...invFiltered].sort((a, b) => {
const va = a[invSortCol] ?? '';
const vb = b[invSortCol] ?? '';
if (typeof va === 'number' && typeof vb === 'number') return invSortAsc ? va - vb : vb - va;
return invSortAsc ? String(va).localeCompare(String(vb)) : String(vb).localeCompare(String(va));
});
const cols = ['inventoryName', 'totalItems', 'totalLocations', 'totalCategories', 'userCount', 'lastUpdated'];
cols.forEach(c => {
const el = document.getElementById('sort-' + c);
if (el) el.textContent = invSortCol === c ? (invSortAsc ? '▲' : '▼') : '';
});
const total = sorted.length;
const totalPages = Math.max(1, Math.ceil(total / invPageSize));
if (invPage >= totalPages) invPage = totalPages - 1;
const start = invPage * invPageSize;
const page = sorted.slice(start, start + invPageSize);
const tbody = document.getElementById('inv-table-body');
tbody.innerHTML = '';
if (!page.length) {
const tr = document.createElement('tr');
const td = document.createElement('td'); td.colSpan = 7; td.textContent = 'Keine Inventare gefunden.'; td.style.textAlign = 'center'; td.style.color = '#999';
tr.appendChild(td); tbody.appendChild(tr);
} else {
page.forEach(inv => {
const tr = document.createElement('tr');
const hasName = inv.inventoryName && inv.inventoryName.trim();
const lastUpdFmt = inv.lastUpdated ? new Date(inv.lastUpdated).toLocaleDateString('de-DE') : '';
const tdName = document.createElement('td');
if (hasName) {
tdName.textContent = inv.inventoryName;
const badge = document.createElement('span'); badge.className = 'inv-badge'; badge.title = inv.inventoryId;
badge.textContent = inv.inventoryId.slice(0, 8) + '…'; badge.style.marginLeft = '6px';
tdName.appendChild(badge);
} else {
const badge = document.createElement('span'); badge.className = 'inv-badge'; badge.title = inv.inventoryId;
badge.textContent = inv.inventoryId;
tdName.appendChild(badge);
}
const tds = [tdName];
[inv.totalItems, inv.totalLocations, inv.totalCategories, inv.userCount, lastUpdFmt].forEach(v => {
const td = document.createElement('td'); td.textContent = v; tds.push(td);
});
const tdAct = document.createElement('td'); tdAct.className = 'actions';
const btnUsers = document.createElement('button'); btnUsers.className = 'info'; btnUsers.textContent = 'Benutzer';
btnUsers.onclick = () => showInvUsers(inv.inventoryId);
tdAct.appendChild(btnUsers);
tds.push(tdAct);
tds.forEach(td => tr.appendChild(td));
tbody.appendChild(tr);
});
}
document.getElementById('inv-page-info').textContent = total === 0 ? 'Keine Einträge' : `Seite ${invPage + 1} von ${totalPages} (${total} Inventare)`;
document.getElementById('inv-prev').disabled = invPage === 0;
document.getElementById('inv-next').disabled = invPage >= totalPages - 1;
}
function showInvUsers(inventoryId) {
const inv = allInventories.find(i => i.inventoryId === inventoryId);
if (!inv) { alert('Keine Benutzerinformationen verfügbar.'); return; }
const names = inv.users.length ? inv.users.map(u => u.username + (u.isAdmin ? ' [Admin]' : '')).join('\n') : '(keine Benutzer)';
alert('Inventar ' + inventoryId.slice(0, 8) + '…\n\nBenutzer:\n' + names);
}
function renderUsers(users) {
const tbody = document.getElementById('users-body');
tbody.innerHTML = '';
users.forEach(u => {
const tr = document.createElement('tr');
const tdName = document.createElement('td'); tdName.textContent = u.username;
const tdDate = document.createElement('td'); tdDate.textContent = new Date(u.createdAt).toLocaleDateString('de-DE');
const tdAdmin = document.createElement('td'); tdAdmin.textContent = u.isAdmin ? '✓' : '';
const tdInv = document.createElement('td');
if (u.inventoryId) {
const badge = document.createElement('span');
badge.className = 'inv-badge';
badge.title = u.inventoryId;
badge.textContent = u.inventoryId.slice(0, 8) + '…';
tdInv.appendChild(badge);
} else {
tdInv.textContent = '—';
}
const tdActions = document.createElement('td'); tdActions.className = 'actions';
const btnPw = document.createElement('button'); btnPw.className = 'secondary'; btnPw.textContent = 'PW ändern'; btnPw.onclick = () => openPasswordModal(u.id);
const btnAssign = document.createElement('button'); btnAssign.className = 'info'; btnAssign.textContent = 'Inventar wechseln'; btnAssign.onclick = () => openAssignModal(u.id);
const btnNew = document.createElement('button'); btnNew.className = 'secondary'; btnNew.textContent = 'Neues Inventar'; btnNew.onclick = () => createNewInventory(u.id);
const btnDel = document.createElement('button'); btnDel.className = 'danger'; btnDel.textContent = 'Löschen'; btnDel.onclick = () => openDeleteModal(u.id, u.username);
tdActions.append(btnPw, btnAssign, btnNew, btnDel);
tr.append(tdName, tdDate, tdAdmin, tdInv, tdActions);
tbody.appendChild(tr);
});
}
function toggleAddForm() {
document.getElementById('add-user-form').classList.toggle('open');
}
async function createUser() {
const username = document.getElementById('new-username').value;
const password = document.getElementById('new-password').value;
const err = document.getElementById('add-user-error');
err.style.display = 'none';
const res = await fetch('/api/admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + accessToken },
body: JSON.stringify({ username, password })
});
if (!res.ok) {
err.textContent = (await res.json()).message || 'Fehler';
err.style.display = 'block';
return;
}
document.getElementById('new-username').value = '';
document.getElementById('new-password').value = '';
toggleAddForm();
loadAll();
}
function openPasswordModal(userId) {
pendingUserId = userId;
document.getElementById('pw-input').value = '';
document.getElementById('pw-error').style.display = 'none';
document.getElementById('pw-modal').classList.add('open');
}
async function confirmPasswordChange() {
const password = document.getElementById('pw-input').value;
const err = document.getElementById('pw-error');
err.style.display = 'none';
const res = await fetch('/api/admin/users/' + pendingUserId, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + accessToken },
body: JSON.stringify({ password })
});
if (!res.ok) {
err.textContent = (await res.json()).message || 'Fehler';
err.style.display = 'block';
return;
}
closeModal();
}
function openDeleteModal(userId, username) {
pendingUserId = userId;
document.getElementById('del-confirm-text').textContent = `Benutzer "${username}" wirklich löschen?`;
document.getElementById('del-modal').classList.add('open');
}
async function confirmDelete() {
await fetch('/api/admin/users/' + pendingUserId, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + accessToken }
});
closeModal();
loadAll();
}
function openAssignModal(userId) {
pendingUserId = userId;
const select = document.getElementById('assign-inventory-select');
select.innerHTML = '';
allInventories.forEach(inv => {
const opt = document.createElement('option');
opt.value = inv.inventoryId;
const names = inv.users.map(u => u.username).join(', ') || '(leer)';
opt.textContent = inv.inventoryId.slice(0, 8) + '… ' + names;
select.appendChild(opt);
});
document.getElementById('assign-error').style.display = 'none';
document.getElementById('assign-modal').classList.add('open');
}
async function confirmAssign() {
const inventoryId = document.getElementById('assign-inventory-select').value;
const err = document.getElementById('assign-error');
err.style.display = 'none';
const res = await fetch('/api/admin/users/' + pendingUserId + '/inventory', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + accessToken },
body: JSON.stringify({ inventoryId })
});
if (!res.ok) {
err.textContent = (await res.json()).message || 'Fehler';
err.style.display = 'block';
return;
}
closeModal();
loadAll();
}
async function createNewInventory(userId) {
if (!confirm('Für diesen Benutzer ein neues, leeres Inventar erstellen?')) return;
await fetch('/api/admin/users/' + userId + '/inventory/new', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + accessToken }
});
loadAll();
}
function closeModal() {
document.querySelectorAll('.modal-overlay').forEach(m => m.classList.remove('open'));
pendingUserId = null;
}
function logout() {
accessToken = '';
sessionStorage.removeItem('accessToken');
document.getElementById('login-username').value = '';
document.getElementById('login-password').value = '';
document.getElementById('login-section').style.display = 'block';
document.getElementById('admin-section').style.display = 'none';
document.getElementById('logout-btn').style.display = 'none';
}
document.getElementById('login-password').addEventListener('keydown', e => { if (e.key === 'Enter') login(); });
// ── Tab Navigation ───────────────────────────────────────────────────────
let backupsLoaded = false;
function switchTab(tabId) {
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.tab === tabId));
document.querySelectorAll('.tab-content').forEach(tc => tc.classList.toggle('active', tc.id === tabId));
if (tabId === 'tab-backups' && !backupsLoaded) {
loadBackups();
}
}
// ── Backups ──────────────────────────────────────────────────────────────
async function loadBackups() {
const tbody = document.getElementById('backups-body');
tbody.innerHTML = '<tr><td colspan="3" style="text-align:center;color:#6B5B3E">Wird geladen…</td></tr>';
try {
const res = await fetch('/api/admin/backups', { headers: { 'Authorization': 'Bearer ' + accessToken } });
if (!res.ok) { tbody.innerHTML = '<tr><td colspan="3" class="error">Fehler beim Laden</td></tr>'; return; }
const backups = await res.json();
backupsLoaded = true;
if (!backups.length) {
tbody.innerHTML = '<tr><td colspan="3" style="text-align:center;color:#6B5B3E">Keine Backups vorhanden</td></tr>';
return;
}
tbody.innerHTML = '';
backups.forEach(b => {
const tr = document.createElement('tr');
const tdName = document.createElement('td'); tdName.textContent = b.name;
const tdSize = document.createElement('td'); tdSize.textContent = formatBytes(b.sizeBytes);
const tdDate = document.createElement('td');
const d = new Date(b.createdAt);
tdDate.textContent = d.toLocaleDateString('de-DE') + ' ' + d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
tr.append(tdName, tdSize, tdDate);
tbody.appendChild(tr);
});
} catch (e) {
tbody.innerHTML = '<tr><td colspan="3" class="error">Verbindungsfehler</td></tr>';
}
}
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const val = bytes / Math.pow(1024, i);
return val.toFixed(i === 0 ? 0 : 1).replace('.', ',') + ' ' + units[i];
}
</script>
<footer>Bollwerk Server v0.2 &middot; &copy; 2026 faenocasul</footer>
</body>
</html>