feat(server): Statistik-Kacheln auf Admin-Inventarübersicht

- InventoryStatsDto: neues DTO mit totalItems, totalLocations,
  totalCategories, lastUpdated, recentTransactions
- InventoryRepository.getAggregatedStats(): Aggregierte Statistiken
  über alle Inventare (COUNT, MAX, 30-Tage-Filter)
- AdminRoutes: GET /api/admin/stats Endpoint (admin-only)
- Admin-UI: Stats-Grid mit 5 Kacheln (Artikel, Orte, Kategorien,
  Änderungen 30 Tage, letzte Änderung) oben auf der Übersichtsseite
- 7 neue Tests: 4 Endpoint-Tests (InventoryStatsTest),
  3 Repository-Tests (getAggregatedStats)
- Alle 87 Tests grün

Closes #68
This commit is contained in:
Jens Reinemann 2026-05-17 02:19:18 +02:00
parent 549e4c916e
commit 033b0fae61
6 changed files with 268 additions and 2 deletions

View file

@ -0,0 +1,12 @@
package de.krisenvorrat.server.model
import kotlinx.serialization.Serializable
@Serializable
internal data class InventoryStatsDto(
val totalItems: Long,
val totalLocations: Long,
val totalCategories: Long,
val lastUpdated: Long?,
val recentTransactions: Long
)

View file

@ -6,6 +6,7 @@ import de.krisenvorrat.server.db.Items
import de.krisenvorrat.server.db.Locations import de.krisenvorrat.server.db.Locations
import de.krisenvorrat.server.db.Settings import de.krisenvorrat.server.db.Settings
import de.krisenvorrat.server.db.Users import de.krisenvorrat.server.db.Users
import de.krisenvorrat.server.model.InventoryStatsDto
import de.krisenvorrat.server.model.InventoryWithUsersDto import de.krisenvorrat.server.model.InventoryWithUsersDto
import de.krisenvorrat.server.model.UserDto import de.krisenvorrat.server.model.UserDto
import de.krisenvorrat.shared.model.CategoryDto import de.krisenvorrat.shared.model.CategoryDto
@ -23,6 +24,7 @@ import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.deleteWhere import org.jetbrains.exposed.sql.deleteWhere
import org.jetbrains.exposed.sql.insert import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.max
import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update import org.jetbrains.exposed.sql.update
@ -270,4 +272,31 @@ internal class InventoryRepository {
deleted > 0 deleted > 0
} }
} }
fun getAggregatedStats(): InventoryStatsDto {
return transaction {
val totalItems = Items.selectAll().count()
val totalLocations = Locations.selectAll().count()
val totalCategories = Categories.selectAll().count()
val lastUpdatedMax = Items.lastUpdated.max()
val lastUpdated = Items
.select(lastUpdatedMax)
.map { it[lastUpdatedMax] }
.singleOrNull()
val thirtyDaysAgo = System.currentTimeMillis() - 30L * 24 * 60 * 60 * 1000
val recentTransactions = Items.selectAll()
.where { Items.lastUpdated greaterEq thirtyDaysAgo }
.count()
InventoryStatsDto(
totalItems = totalItems,
totalLocations = totalLocations,
totalCategories = totalCategories,
lastUpdated = lastUpdated,
recentTransactions = recentTransactions
)
}
}
} }

View file

@ -164,5 +164,16 @@ internal fun Route.adminRoutes(userRepository: UserRepository, inventoryReposito
} }
call.respond(HttpStatusCode.OK, inventoryRepository.listInventoriesWithUsers()) call.respond(HttpStatusCode.OK, inventoryRepository.listInventoriesWithUsers())
} }
// Aggregated inventory statistics for admin dashboard
get("/api/admin/stats") {
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.getAggregatedStats())
}
} }

View file

@ -30,6 +30,10 @@
.inv-group h3 { font-size: .85rem; color: #555; margin-bottom: 8px; font-weight: 600; } .inv-group h3 { font-size: .85rem; color: #555; margin-bottom: 8px; font-weight: 600; }
.inv-group ul { list-style: none; display: flex; flex-wrap: wrap; gap: 8px; } .inv-group ul { list-style: none; display: flex; flex-wrap: wrap; gap: 8px; }
.inv-group ul li { background: #fff; border: 1px solid #ddd; border-radius: 4px; padding: 4px 10px; font-size: .875rem; } .inv-group ul li { background: #fff; border: 1px solid #ddd; border-radius: 4px; padding: 4px 10px; font-size: .875rem; }
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 16px; margin-bottom: 8px; }
.stat-card { background: #f0f7ff; border: 1px solid #c3dff7; border-radius: 8px; padding: 16px; text-align: center; }
.stat-card .stat-value { font-size: 1.8rem; font-weight: 700; color: #1a1a2e; margin-bottom: 4px; }
.stat-card .stat-label { font-size: .8rem; color: #666; text-transform: uppercase; letter-spacing: .5px; }
#add-user-form { display: none; background: #f9f9f9; border: 1px solid #ddd; border-radius: 6px; padding: 20px; margin-top: 16px; } #add-user-form { display: none; background: #f9f9f9; border: 1px solid #ddd; border-radius: 6px; padding: 20px; margin-top: 16px; }
#add-user-form.open { display: block; } #add-user-form.open { display: block; }
#add-user-form h3 { margin-bottom: 14px; font-size: 1rem; } #add-user-form h3 { margin-bottom: 14px; font-size: 1rem; }
@ -58,6 +62,18 @@
</section> </section>
<div id="admin-section" style="display:none"> <div id="admin-section" style="display:none">
<!-- 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>
</section>
<!-- User Management --> <!-- User Management -->
<section class="card"> <section class="card">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px"> <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px">
@ -181,9 +197,10 @@
} }
async function loadAll() { async function loadAll() {
const [usersRes, inventoriesRes] = await Promise.all([ const [usersRes, inventoriesRes, statsRes] = await Promise.all([
fetch('/api/admin/users', { headers: { 'Authorization': 'Bearer ' + accessToken } }), fetch('/api/admin/users', { headers: { 'Authorization': 'Bearer ' + accessToken } }),
fetch('/api/admin/inventories', { headers: { 'Authorization': 'Bearer ' + accessToken } }) fetch('/api/admin/inventories', { headers: { 'Authorization': 'Bearer ' + accessToken } }),
fetch('/api/admin/stats', { headers: { 'Authorization': 'Bearer ' + accessToken } })
]); ]);
if (!usersRes.ok) { logout(); return; } if (!usersRes.ok) { logout(); return; }
renderUsers(await usersRes.json()); renderUsers(await usersRes.json());
@ -191,6 +208,22 @@
allInventories = await inventoriesRes.json(); allInventories = await inventoriesRes.json();
renderInventories(allInventories); renderInventories(allInventories);
} }
if (statsRes.ok) {
renderStats(await statsRes.json());
}
}
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 = '';
}
} }
function renderUsers(users) { function renderUsers(users) {

View file

@ -0,0 +1,122 @@
package de.krisenvorrat.server
import de.krisenvorrat.server.db.DatabaseFactory
import de.krisenvorrat.server.model.InventoryStatsDto
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 io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.config.*
import io.ktor.server.testing.*
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class InventoryStatsTest {
private val json = Json { ignoreUnknownKeys = true }
private val adminToken = createTestAccessToken(
userId = TEST_ADMIN_ID,
username = TEST_ADMIN_USERNAME,
isAdmin = true
)
private val userToken = createTestAccessToken(
userId = TEST_USER_ID,
username = TEST_USERNAME,
isAdmin = false
)
private fun testApp(block: suspend ApplicationTestBuilder.() -> Unit) = testApplication {
environment {
config = MapApplicationConfig(*testMapConfig().toTypedArray())
}
application {
DatabaseFactory.init(
jdbcUrl = "jdbc:h2:mem:stats_test_${System.nanoTime()};DB_CLOSE_DELAY=-1",
driver = "org.h2.Driver",
adminPassword = "test-admin-pw"
)
configurePlugins()
}
block()
}
@Test
fun test_getStats_asAdmin_returnsStats() = testApp {
// When
val response = client.get("/api/admin/stats") { bearerAuth(adminToken) }
// Then
assertEquals(HttpStatusCode.OK, response.status)
val stats = json.decodeFromString<InventoryStatsDto>(response.bodyAsText())
assertEquals(0L, stats.totalItems)
assertEquals(0L, stats.totalLocations)
assertEquals(0L, stats.totalCategories)
assertNull(stats.lastUpdated)
assertEquals(0L, stats.recentTransactions)
}
@Test
fun test_getStats_asNonAdmin_returns403() = testApp {
// When
val response = client.get("/api/admin/stats") { bearerAuth(userToken) }
// Then
assertEquals(HttpStatusCode.Forbidden, response.status)
}
@Test
fun test_getStats_noToken_returns401() = testApp {
// When
val response = client.get("/api/admin/stats")
// Then
assertEquals(HttpStatusCode.Unauthorized, response.status)
}
@Test
fun test_getStats_withInventoryData_returnsCorrectCounts() = testApp {
// Given upload inventory data
val inventory = InventoryDto(
categories = listOf(
CategoryDto(id = 1, name = "Konserven"),
CategoryDto(id = 2, name = "Getränke")
),
locations = listOf(
LocationDto(id = 1, name = "Keller"),
LocationDto(id = 2, name = "Speisekammer"),
LocationDto(id = 3, name = "Garage")
),
items = listOf(
ItemDto(id = "i1", name = "Dosenbrot", categoryId = 1, quantity = 5.0, unit = "Stk",
unitPrice = 3.99, kcalPerKg = null, expiryDate = null, locationId = 1, notes = "",
lastUpdated = System.currentTimeMillis()),
ItemDto(id = "i2", name = "Wasser", categoryId = 2, quantity = 10.0, unit = "L",
unitPrice = 0.5, kcalPerKg = null, expiryDate = null, locationId = 2, notes = "",
lastUpdated = System.currentTimeMillis())
),
settings = emptyList()
)
val putResponse = client.put("/api/inventory") {
bearerAuth(createTestAccessToken(userId = TEST_ADMIN_ID, username = TEST_ADMIN_USERNAME, isAdmin = true))
contentType(ContentType.Application.Json)
setBody(Json.encodeToString(InventoryDto.serializer(), inventory))
}
assertEquals(HttpStatusCode.OK, putResponse.status)
// When
val response = client.get("/api/admin/stats") { bearerAuth(adminToken) }
// Then
assertEquals(HttpStatusCode.OK, response.status)
val stats = json.decodeFromString<InventoryStatsDto>(response.bodyAsText())
assertEquals(2L, stats.totalItems)
assertEquals(3L, stats.totalLocations)
assertEquals(2L, stats.totalCategories)
assertEquals(2L, stats.recentTransactions)
}
}

View file

@ -8,6 +8,7 @@ import de.krisenvorrat.shared.model.LocationDto
import de.krisenvorrat.shared.model.SettingDto import de.krisenvorrat.shared.model.SettingDto
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
@ -169,4 +170,62 @@ class InventoryRepositoryTest {
SettingDto(key = "theme", value = "dark") SettingDto(key = "theme", value = "dark")
) )
) )
@Test
fun test_getAggregatedStats_emptyDatabase_returnsZeros() {
// When
val stats = repository.getAggregatedStats()
// Then
assertEquals(0L, stats.totalItems)
assertEquals(0L, stats.totalLocations)
assertEquals(0L, stats.totalCategories)
assertNull(stats.lastUpdated)
assertEquals(0L, stats.recentTransactions)
}
@Test
fun test_getAggregatedStats_withData_returnsCounts() {
// Given
repository.saveInventory(testUserId, createTestInventory())
// When
val stats = repository.getAggregatedStats()
// Then
assertEquals(1L, stats.totalItems)
assertEquals(2L, stats.totalLocations)
assertEquals(2L, stats.totalCategories)
assertEquals(1715000000L, stats.lastUpdated)
}
@Test
fun test_getAggregatedStats_recentTransactions_countsOnlyLast30Days() {
// Given
val now = System.currentTimeMillis()
val oldTimestamp = now - 31L * 24 * 60 * 60 * 1000 // 31 days ago
val recentTimestamp = now - 5L * 24 * 60 * 60 * 1000 // 5 days ago
val inventory = InventoryDto(
categories = listOf(CategoryDto(id = 1, name = "Test")),
locations = listOf(LocationDto(id = 1, name = "Test")),
items = listOf(
ItemDto(id = "old", name = "Old", categoryId = 1, quantity = 1.0, unit = "Stk", unitPrice = 0.0,
kcalPerKg = null, expiryDate = null, locationId = 1, notes = "", lastUpdated = oldTimestamp),
ItemDto(id = "recent1", name = "Recent1", categoryId = 1, quantity = 1.0, unit = "Stk", unitPrice = 0.0,
kcalPerKg = null, expiryDate = null, locationId = 1, notes = "", lastUpdated = recentTimestamp),
ItemDto(id = "recent2", name = "Recent2", categoryId = 1, quantity = 1.0, unit = "Stk", unitPrice = 0.0,
kcalPerKg = null, expiryDate = null, locationId = 1, notes = "", lastUpdated = now)
),
settings = emptyList()
)
repository.saveInventory(testUserId, inventory)
// When
val stats = repository.getAggregatedStats()
// Then
assertEquals(3L, stats.totalItems)
assertEquals(2L, stats.recentTransactions)
assertTrue(stats.lastUpdated!! >= recentTimestamp)
}
} }