diff --git a/server/src/main/kotlin/de/krisenvorrat/server/model/InventoryStatsDto.kt b/server/src/main/kotlin/de/krisenvorrat/server/model/InventoryStatsDto.kt new file mode 100644 index 0000000..5be4fe9 --- /dev/null +++ b/server/src/main/kotlin/de/krisenvorrat/server/model/InventoryStatsDto.kt @@ -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 +) diff --git a/server/src/main/kotlin/de/krisenvorrat/server/repository/InventoryRepository.kt b/server/src/main/kotlin/de/krisenvorrat/server/repository/InventoryRepository.kt index c3f4593..e4f417e 100644 --- a/server/src/main/kotlin/de/krisenvorrat/server/repository/InventoryRepository.kt +++ b/server/src/main/kotlin/de/krisenvorrat/server/repository/InventoryRepository.kt @@ -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 + ) + } + } } diff --git a/server/src/main/kotlin/de/krisenvorrat/server/routes/AdminRoutes.kt b/server/src/main/kotlin/de/krisenvorrat/server/routes/AdminRoutes.kt index 734ceec..8870a8e 100644 --- a/server/src/main/kotlin/de/krisenvorrat/server/routes/AdminRoutes.kt +++ b/server/src/main/kotlin/de/krisenvorrat/server/routes/AdminRoutes.kt @@ -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() + ?: 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()) + } } diff --git a/server/src/main/resources/static/admin/index.html b/server/src/main/resources/static/admin/index.html index 69821c9..d7d31c3 100644 --- a/server/src/main/resources/static/admin/index.html +++ b/server/src/main/resources/static/admin/index.html @@ -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 @@