102 lines
3.6 KiB
Rust
Executable File
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())
|
|
}
|