//! KPI-Dashboard (ITIL v4: Continual Improvement / v3: CSI). //! Kennzahlen: offene Tickets, SLA-Erfuellung, MTTR, Verteilung nach //! Kategorie und Prioritaet -- Basis fuer Service-Reviews. use askama::Template; use axum::extract::{Extension, State}; use axum::response::IntoResponse; use chrono::{Duration, Utc}; use crate::tickets::{is_overdue, resolve_due}; use crate::web::{need_auth, page_ctx, AppState, PageCtx, ReqCtx, WebResult}; use crate::itil; pub struct CountRow { pub label: String, pub count: usize, pub pct: i32, } #[derive(Template)] #[template(path = "dashboard.html")] pub struct DashboardTemplate { pub title: String, pub ctx: PageCtx, pub kpis: Vec<(String, String)>, pub by_category: Vec, pub by_priority: Vec, } pub async fn dashboard(State(app): State, Extension(ctx): Extension) -> WebResult { let auth = match need_auth(&ctx, "/dashboard") { Ok(a) => a, Err(r) => return Ok(r) }; let ersteller = if itil::is_operative(&auth.role) { None } else { Some(auth.user_id) }; let tickets = app.db.list_tickets(auth.tenant_id, ersteller).await?; let now = Utc::now(); let open = tickets.iter().filter(|t| t.status != "Geloest" && t.status != "Geschlossen").count(); let overdue = tickets.iter().filter(|t| is_overdue(t, now)).count(); // SLA-Erfuellung: Anteil geloester Tickets, die innerhalb der Loesungsfrist // geloest wurden (letzte 30 Tage). let recent_resolved: Vec<_> = tickets.iter() .filter(|t| t.resolved_at.map(|r| now - r <= Duration::days(30)).unwrap_or(false)) .collect(); let sla_met = recent_resolved.iter() .filter(|t| match (t.resolved_at, resolve_due(t)) { (Some(r), Some(due)) => r <= due, _ => true, }).count(); let sla_pct = if recent_resolved.is_empty() { "--".to_string() } else { format!("{}%", (sla_met * 100) / recent_resolved.len()) }; // MTTR (Mean Time To Resolve) ueber die letzten 30 Tage. let mttr = if recent_resolved.is_empty() { "--".to_string() } else { let total_min: i64 = recent_resolved.iter() .filter_map(|t| t.resolved_at.map(|r| (r - t.created_at).num_minutes())) .sum(); let avg = total_min / recent_resolved.len() as i64; if avg >= 60 * 24 { format!("{:.1} Tage", avg as f64 / (60.0 * 24.0)) } else if avg >= 60 { format!("{:.1} Std", avg as f64 / 60.0) } else { format!("{} Min", avg) } }; let kpis = vec![ ("Tickets gesamt".to_string(), tickets.len().to_string()), ("Offen / laufend".to_string(), open.to_string()), ("Ueberfaellig (SLA)".to_string(), overdue.to_string()), ("SLA-Erfuellung (30 T)".to_string(), sla_pct), ("MTTR (30 T)".to_string(), mttr), ("Geloest (30 T)".to_string(), recent_resolved.len().to_string()), ]; let dist = |values: Vec<&str>, get: &dyn Fn(&crate::db::Ticket) -> String| -> Vec { let total = tickets.len().max(1); values.iter().map(|v| { let count = tickets.iter().filter(|t| get(t) == *v).count(); CountRow { label: v.to_string(), count, pct: ((count * 100) / total) as i32, } }).collect() }; let tpl = DashboardTemplate { title: "Dashboard".into(), ctx: page_ctx(&auth, "/dashboard"), kpis, by_category: dist(itil::CATEGORIES.to_vec(), &|t| t.kategorie.clone()), by_priority: dist(itil::PRIORITIES.to_vec(), &|t| t.prioritaet.clone()), }; Ok(tpl.into_response()) }