ITSM/src/dashboard.rs

102 lines
3.6 KiB
Rust
Executable File

//! 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<CountRow>,
pub by_priority: Vec<CountRow>,
}
pub async fn dashboard(State(app): State<AppState>, Extension(ctx): Extension<ReqCtx>) -> 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<CountRow> {
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())
}