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:
parent
549e4c916e
commit
033b0fae61
6 changed files with 268 additions and 2 deletions
|
|
@ -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
|
||||
)
|
||||
|
|
@ -6,6 +6,7 @@ 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.server.model.InventoryStatsDto
|
||||
import de.krisenvorrat.server.model.InventoryWithUsersDto
|
||||
import de.krisenvorrat.server.model.UserDto
|
||||
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.deleteWhere
|
||||
import org.jetbrains.exposed.sql.insert
|
||||
import org.jetbrains.exposed.sql.max
|
||||
import org.jetbrains.exposed.sql.selectAll
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.jetbrains.exposed.sql.update
|
||||
|
|
@ -270,4 +272,31 @@ internal class InventoryRepository {
|
|||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -164,5 +164,16 @@ internal fun Route.adminRoutes(userRepository: UserRepository, inventoryReposito
|
|||
}
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,10 @@
|
|||
.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 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.open { display: block; }
|
||||
#add-user-form h3 { margin-bottom: 14px; font-size: 1rem; }
|
||||
|
|
@ -58,6 +62,18 @@
|
|||
</section>
|
||||
|
||||
<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 -->
|
||||
<section class="card">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px">
|
||||
|
|
@ -181,9 +197,10 @@
|
|||
}
|
||||
|
||||
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/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; }
|
||||
renderUsers(await usersRes.json());
|
||||
|
|
@ -191,6 +208,22 @@
|
|||
allInventories = await inventoriesRes.json();
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import de.krisenvorrat.shared.model.LocationDto
|
|||
import de.krisenvorrat.shared.model.SettingDto
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
|
|
@ -169,4 +170,62 @@ class InventoryRepositoryTest {
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue