feat: Admin-Statistiken pro Inventar & Inventar-Tabelle mit Paging/Sortierung/Filter/Suche
- Neues DTO: InventoryStatsPerInventoryDto (inventoryId, inventoryName, totalItems, totalLocations, totalCategories, recentTransactions, lastUpdated, userCount) - InventoryRepository.getStatsPerInventory(): Stats pro Inventar - Neuer Endpoint: GET /api/admin/stats/inventories (Admin-only) - Admin-UI: aufklappbarer Bereich Statistiken pro Inventar (sortierbar) - Admin-UI: Inventar-Karten durch Tabelle ersetzt mit - Paging (10/25/50 Eintraege pro Seite) - Sortierung per Klick auf Spaltenheader - Filter (alle / mit Benutzern / ohne Benutzer) - Freitextsuche nach Name oder Inventar-ID - Tests: 3 neue InventoryRepositoryTests, 3 neue InventoryStatsTests (401/403/Inhalt fuer neuen Endpoint), setUp bereinigt alle Tabellen - Alle 148 Tests gruen
This commit is contained in:
parent
11d2094eef
commit
32ed321df2
8 changed files with 407 additions and 42 deletions
|
|
@ -97,7 +97,7 @@ internal fun MainScreen() {
|
|||
contentDescription = destination.label
|
||||
)
|
||||
},
|
||||
label = { Text(destination.label) }
|
||||
label = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,9 +7,10 @@ import androidx.compose.material.icons.filled.Home
|
|||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material.icons.outlined.Home
|
||||
import androidx.compose.material.icons.outlined.Inventory2
|
||||
import androidx.compose.material.icons.filled.Warehouse
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material.icons.outlined.Warning
|
||||
import androidx.compose.material.icons.outlined.Warehouse
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
||||
internal enum class TopLevelDestination(
|
||||
|
|
@ -22,30 +23,30 @@ internal enum class TopLevelDestination(
|
|||
route = Screen.Dashboard,
|
||||
selectedIcon = Icons.Filled.Home,
|
||||
unselectedIcon = Icons.Outlined.Home,
|
||||
label = "Übersicht"
|
||||
label = "Overview"
|
||||
),
|
||||
INVENTORY(
|
||||
route = Screen.ItemList,
|
||||
selectedIcon = Icons.Outlined.Inventory2,
|
||||
unselectedIcon = Icons.Outlined.Inventory2,
|
||||
label = "Inventur"
|
||||
selectedIcon = Icons.Filled.Warehouse,
|
||||
unselectedIcon = Icons.Outlined.Warehouse,
|
||||
label = "Storage"
|
||||
),
|
||||
WARNINGS(
|
||||
route = Screen.Warnings,
|
||||
selectedIcon = Icons.Filled.Warning,
|
||||
unselectedIcon = Icons.Outlined.Warning,
|
||||
label = "Warnungen"
|
||||
label = "Warnings"
|
||||
),
|
||||
MESSAGES(
|
||||
route = Screen.UserList,
|
||||
selectedIcon = Icons.AutoMirrored.Filled.Message,
|
||||
unselectedIcon = Icons.AutoMirrored.Outlined.Message,
|
||||
label = "Nachrichten"
|
||||
label = "Chat"
|
||||
),
|
||||
SETTINGS(
|
||||
route = Screen.Settings,
|
||||
selectedIcon = Icons.Filled.Settings,
|
||||
unselectedIcon = Icons.Outlined.Settings,
|
||||
label = "Einstellungen"
|
||||
label = "Settings"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
package de.krisenvorrat.server.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
internal data class InventoryStatsPerInventoryDto(
|
||||
val inventoryId: String,
|
||||
val inventoryName: String,
|
||||
val totalItems: Long,
|
||||
val totalLocations: Long,
|
||||
val totalCategories: Long,
|
||||
val recentTransactions: Long,
|
||||
val lastUpdated: Long?,
|
||||
val userCount: Long
|
||||
)
|
||||
|
|
@ -8,6 +8,7 @@ import de.krisenvorrat.server.db.Locations
|
|||
import de.krisenvorrat.server.db.Settings
|
||||
import de.krisenvorrat.server.db.Users
|
||||
import de.krisenvorrat.server.model.InventoryStatsDto
|
||||
import de.krisenvorrat.server.model.InventoryStatsPerInventoryDto
|
||||
import de.krisenvorrat.server.model.InventoryWithUsersDto
|
||||
import de.krisenvorrat.server.model.UserDto
|
||||
import de.krisenvorrat.shared.model.CategoryDto
|
||||
|
|
@ -370,6 +371,39 @@ internal class InventoryRepository {
|
|||
}
|
||||
}
|
||||
|
||||
// TODO: N+1 query pattern – 6 queries per inventory. Acceptable for small admin datasets;
|
||||
// consider consolidating with JOIN queries if inventory count grows.
|
||||
fun getStatsPerInventory(): List<InventoryStatsPerInventoryDto> = transaction {
|
||||
val thirtyDaysAgo = System.currentTimeMillis() - 30L * 24 * 60 * 60 * 1000
|
||||
Inventories.selectAll().map { invRow ->
|
||||
val invId = invRow[Inventories.id]
|
||||
val invName = invRow[Inventories.name]
|
||||
val totalItems = Items.selectAll().where { Items.inventoryId eq invId }.count()
|
||||
val totalLocations = Locations.selectAll().where { Locations.inventoryId eq invId }.count()
|
||||
val totalCategories = Categories.selectAll().where { Categories.inventoryId eq invId }.count()
|
||||
val recentTransactions = Items.selectAll()
|
||||
.where { (Items.inventoryId eq invId) and (Items.lastUpdated greaterEq thirtyDaysAgo) }
|
||||
.count()
|
||||
val lastUpdatedMax = Items.lastUpdated.max()
|
||||
val lastUpdated = Items
|
||||
.select(lastUpdatedMax)
|
||||
.where { Items.inventoryId eq invId }
|
||||
.map { it[lastUpdatedMax] }
|
||||
.singleOrNull()
|
||||
val userCount = Users.selectAll().where { Users.inventoryId eq invId }.count()
|
||||
InventoryStatsPerInventoryDto(
|
||||
inventoryId = invId,
|
||||
inventoryName = invName,
|
||||
totalItems = totalItems,
|
||||
totalLocations = totalLocations,
|
||||
totalCategories = totalCategories,
|
||||
recentTransactions = recentTransactions,
|
||||
lastUpdated = lastUpdated,
|
||||
userCount = userCount
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getAggregatedStats(): InventoryStatsDto {
|
||||
return transaction {
|
||||
val totalItems = Items.selectAll().count()
|
||||
|
|
|
|||
|
|
@ -175,5 +175,16 @@ internal fun Route.adminRoutes(userRepository: UserRepository, inventoryReposito
|
|||
}
|
||||
call.respond(HttpStatusCode.OK, inventoryRepository.getAggregatedStats())
|
||||
}
|
||||
|
||||
// Per-inventory statistics for admin dashboard
|
||||
get("/api/admin/stats/inventories") {
|
||||
val principal = call.principal<UserPrincipal>()
|
||||
?: return@get call.respond(HttpStatusCode.Unauthorized, ErrorResponse(status = 401, message = "Unauthorized"))
|
||||
if (!principal.isAdmin) {
|
||||
call.respond(HttpStatusCode.Forbidden, ErrorResponse(status = 403, message = "Admin access required"))
|
||||
return@get
|
||||
}
|
||||
call.respond(HttpStatusCode.OK, inventoryRepository.getStatsPerInventory())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -42,6 +42,23 @@
|
|||
.modal { background: #fff; border-radius: 8px; padding: 28px; width: 400px; max-width: 95vw; }
|
||||
.modal h3 { margin-bottom: 16px; }
|
||||
.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: #2980b9; padding: 0; margin-top: 12px; text-decoration: underline; }
|
||||
#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: #eef; }
|
||||
#inv-stats-table th .sort-arrow { margin-left: 4px; color: #aaa; 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: #eef; }
|
||||
#inv-table th .sort-arrow { margin-left: 4px; color: #aaa; 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: #666; }
|
||||
footer { text-align: center; padding: 24px 16px; margin-top: 40px; font-size: .8rem; color: #999; }
|
||||
</style>
|
||||
</head>
|
||||
|
|
@ -72,6 +89,23 @@
|
|||
<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 -->
|
||||
|
|
@ -103,7 +137,38 @@
|
|||
<!-- Inventory Overview -->
|
||||
<section class="card">
|
||||
<h2>Inventar-Übersicht</h2>
|
||||
<div id="inventories-body"></div>
|
||||
<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>
|
||||
</main>
|
||||
|
|
@ -153,6 +218,19 @@
|
|||
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();
|
||||
|
||||
|
|
@ -197,20 +275,26 @@
|
|||
}
|
||||
|
||||
async function loadAll() {
|
||||
const [usersRes, inventoriesRes, statsRes] = await Promise.all([
|
||||
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', { 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();
|
||||
renderInventories(allInventories);
|
||||
}
|
||||
if (statsRes.ok) {
|
||||
renderStats(await statsRes.json());
|
||||
}
|
||||
if (invStatsRes.ok) {
|
||||
allInvStats = await invStatsRes.json();
|
||||
renderInvStatsTable();
|
||||
invData = allInvStats;
|
||||
applyInvFilter();
|
||||
}
|
||||
}
|
||||
|
||||
function renderStats(stats) {
|
||||
|
|
@ -226,6 +310,142 @@
|
|||
}
|
||||
}
|
||||
|
||||
// ── 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 name = inv.inventoryName ? inv.inventoryName : '—';
|
||||
const lastUpdFmt = inv.lastUpdated ? new Date(inv.lastUpdated).toLocaleDateString('de-DE') : '–';
|
||||
|
||||
const tdName = document.createElement('td');
|
||||
tdName.textContent = name;
|
||||
if (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);
|
||||
}
|
||||
|
||||
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 = '';
|
||||
|
|
@ -260,30 +480,6 @@
|
|||
});
|
||||
}
|
||||
|
||||
function renderInventories(inventories) {
|
||||
const container = document.getElementById('inventories-body');
|
||||
container.innerHTML = '';
|
||||
if (!inventories.length) {
|
||||
container.textContent = 'Keine Inventare vorhanden.';
|
||||
return;
|
||||
}
|
||||
inventories.forEach(inv => {
|
||||
const div = document.createElement('div'); div.className = 'inv-group';
|
||||
const h3 = document.createElement('h3');
|
||||
h3.textContent = 'Inventar ' + inv.inventoryId.slice(0, 8) + '… (' + inv.users.length + ' ' + (inv.users.length === 1 ? 'Benutzer' : 'Benutzer') + ')';
|
||||
const ul = document.createElement('ul');
|
||||
if (inv.users.length === 0) {
|
||||
const li = document.createElement('li'); li.textContent = '(keine Benutzer)'; ul.appendChild(li);
|
||||
} else {
|
||||
inv.users.forEach(u => {
|
||||
const li = document.createElement('li'); li.textContent = u.username + (u.isAdmin ? ' [Admin]' : ''); ul.appendChild(li);
|
||||
});
|
||||
}
|
||||
div.append(h3, ul);
|
||||
container.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleAddForm() {
|
||||
document.getElementById('add-user-form').classList.toggle('open');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package de.krisenvorrat.server
|
|||
|
||||
import de.krisenvorrat.server.db.DatabaseFactory
|
||||
import de.krisenvorrat.server.model.InventoryStatsDto
|
||||
import de.krisenvorrat.server.model.InventoryStatsPerInventoryDto
|
||||
import de.krisenvorrat.shared.model.CategoryDto
|
||||
import de.krisenvorrat.shared.model.InventoryDto
|
||||
import de.krisenvorrat.shared.model.ItemDto
|
||||
|
|
@ -14,6 +15,7 @@ import io.ktor.server.testing.*
|
|||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class InventoryStatsTest {
|
||||
|
|
@ -119,4 +121,35 @@ class InventoryStatsTest {
|
|||
assertEquals(2L, stats.totalCategories)
|
||||
assertEquals(2L, stats.recentTransactions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_getStatsPerInventory_asAdmin_returnsListWithEntry() = testApp {
|
||||
// When
|
||||
val response = client.get("/api/admin/stats/inventories") { bearerAuth(adminToken) }
|
||||
|
||||
// Then
|
||||
assertEquals(HttpStatusCode.OK, response.status)
|
||||
val statsList = json.decodeFromString<List<InventoryStatsPerInventoryDto>>(response.bodyAsText())
|
||||
// Admin user's inventory is created during DB seeding, so at least one entry exists
|
||||
assertTrue(statsList.isNotEmpty())
|
||||
assertTrue(statsList.all { it.userCount >= 0 })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_getStatsPerInventory_asNonAdmin_returns403() = testApp {
|
||||
// When
|
||||
val response = client.get("/api/admin/stats/inventories") { bearerAuth(userToken) }
|
||||
|
||||
// Then
|
||||
assertEquals(HttpStatusCode.Forbidden, response.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_getStatsPerInventory_noToken_returns401() = testApp {
|
||||
// When
|
||||
val response = client.get("/api/admin/stats/inventories")
|
||||
|
||||
// Then
|
||||
assertEquals(HttpStatusCode.Unauthorized, response.status)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,22 @@
|
|||
package de.krisenvorrat.server.repository
|
||||
|
||||
import de.krisenvorrat.server.db.Categories
|
||||
import de.krisenvorrat.server.db.DatabaseFactory
|
||||
import de.krisenvorrat.server.db.DeletedItems
|
||||
import de.krisenvorrat.server.db.Inventories
|
||||
import de.krisenvorrat.server.db.Items
|
||||
import de.krisenvorrat.server.db.Locations
|
||||
import de.krisenvorrat.server.db.Settings
|
||||
import de.krisenvorrat.server.db.Users
|
||||
import de.krisenvorrat.shared.model.CategoryDto
|
||||
import de.krisenvorrat.shared.model.InventoryDto
|
||||
import de.krisenvorrat.shared.model.ItemDto
|
||||
import de.krisenvorrat.shared.model.LocationDto
|
||||
import de.krisenvorrat.shared.model.SettingDto
|
||||
import org.jetbrains.exposed.sql.deleteAll
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
|
|
@ -26,11 +36,16 @@ class InventoryRepositoryTest {
|
|||
adminPassword = "test-admin-pw"
|
||||
)
|
||||
repository = InventoryRepository()
|
||||
// Clear any leftover data from previous test
|
||||
repository.saveInventory(
|
||||
testUserId,
|
||||
InventoryDto(categories = emptyList(), locations = emptyList(), items = emptyList(), settings = emptyList())
|
||||
)
|
||||
// Clear all data for a clean test environment
|
||||
transaction {
|
||||
DeletedItems.deleteAll()
|
||||
Items.deleteAll()
|
||||
Settings.deleteAll()
|
||||
Categories.deleteAll()
|
||||
Locations.deleteAll()
|
||||
Users.deleteAll()
|
||||
Inventories.deleteAll()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -286,4 +301,64 @@ class InventoryRepositoryTest {
|
|||
val delta = repository.loadInventorySince(testUserId, 0L)
|
||||
assertTrue(delta.deletedItemIds.contains("item-1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_getStatsPerInventory_emptyDatabase_returnsEmptyList() {
|
||||
// setUp clears all tables, so no inventories exist
|
||||
val stats = repository.getStatsPerInventory()
|
||||
assertTrue(stats.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_getStatsPerInventory_withData_returnsCorrectCounts() {
|
||||
// Given – create a dedicated inventory so we control the data precisely
|
||||
val invId = repository.createInventory("Testvorrat")
|
||||
val inventory = InventoryDto(
|
||||
categories = listOf(CategoryDto(id = 1, name = "Kat1"), CategoryDto(id = 2, name = "Kat2")),
|
||||
locations = listOf(LocationDto(id = 1, name = "Keller")),
|
||||
items = listOf(
|
||||
ItemDto(
|
||||
id = "inv-item-1", name = "Dosenbrot", categoryId = 1, quantity = 3.0,
|
||||
unit = "Stk", unitPrice = 1.5, kcalPerKg = null, expiryDate = null,
|
||||
locationId = 1, notes = "", lastUpdated = 1715000000L
|
||||
)
|
||||
),
|
||||
settings = emptyList()
|
||||
)
|
||||
repository.saveInventory(invId, inventory)
|
||||
|
||||
// When
|
||||
val statsList = repository.getStatsPerInventory()
|
||||
val stats = statsList.find { it.inventoryId == invId }
|
||||
|
||||
// Then
|
||||
assertNotNull(stats)
|
||||
assertEquals("Testvorrat", stats!!.inventoryName)
|
||||
assertEquals(1L, stats.totalItems)
|
||||
assertEquals(1L, stats.totalLocations)
|
||||
assertEquals(2L, stats.totalCategories)
|
||||
assertEquals(1715000000L, stats.lastUpdated)
|
||||
// lastUpdated = 1715000000L is ~May 2024, more than 30 days ago
|
||||
assertEquals(0L, stats.recentTransactions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_getStatsPerInventory_userCount_reflectsAssignedUsers() {
|
||||
// Given
|
||||
val invId = repository.createInventory("UserCountTest")
|
||||
val userRepo = UserRepository()
|
||||
val uid1 = "stats-user-1"
|
||||
val uid2 = "stats-user-2"
|
||||
userRepo.create(uid1, "statsuser1", "hash1")
|
||||
userRepo.create(uid2, "statsuser2", "hash2")
|
||||
repository.assignUserToInventory(uid1, invId)
|
||||
repository.assignUserToInventory(uid2, invId)
|
||||
|
||||
// When
|
||||
val stats = repository.getStatsPerInventory().find { it.inventoryId == invId }
|
||||
|
||||
// Then
|
||||
assertNotNull(stats)
|
||||
assertEquals(2L, stats!!.userCount)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue