735 lines
No EOL
40 KiB
HTML
735 lines
No EOL
40 KiB
HTML
<!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 · © 2026 faenocasul</footer>
|
||
</body>
|
||
</html> |