From 9b8d603e74e7d1c910423bdfc8015e9fb21ca21d Mon Sep 17 00:00:00 2001 From: Moe Date: Wed, 15 Jul 2026 05:23:26 +0200 Subject: [PATCH] feat(ui): Dashboard-Redesign nach Nutzer-Mockup + Kanban, Suche, Pagination, Bulk-Aktionen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Angleichung an das vorgegebene UI-Mockup (Nutzer-Vorgabe 2026-07-15): - Sidebar-Gruppen Favoriten (Dashboard, Meine/Offene/Ueberfaellige Tickets), Service Management (inkl. Kanban Board), Wissen (Artikel/FAQs), Assets (Asset-Liste/Konfigurationselemente/Software/Vertraege via CMDB-Typfilter), Administration; rundes Logo, Nutzer-Chip mit Avatar-Initialen + Rolle - Topbar mit zentraler Suche (fuehrt zur Ticket-Suche) - KPI-Karten mit Trend-Angabe und Sparkline (echte Tagesserien der letzten 8 Tage aus created/first_response/resolved/due-Zeitstempeln), zusaetzliche Karte 'Durchschn. Antwortzeit' (Ø erste Reaktion, 30 Tage) - Ticket-Tabelle: Checkbox-Spalte mit Alles-Auswahl, Aktionen-Dropdown (Bulk-Statuswechsel, ITIL-Uebergaenge werden serverseitig erzwungen und Verstoesse je Ticket gemeldet), Ticket-Nr.-Hervorhebung, Avatar-Initialen, Pagination (12/Seite, '1-12 von N Tickets') - Filter-Tabs inkl. Geschlossen; Suchfeld (Titel/Nr./Beschreibung), Filter bleiben ueber Tabs/Pagination erhalten - 'Neues Ticket' als Modal (Button oben rechts) statt Inline-Formular - Detail-Panel wie Mockup: Kopf mit Ticket-Nr., Service-Block, Feldraster, Tabs Details/Aktivitaeten/Anhaenge/Aufgaben (letzte zwei als Platzhalter gekennzeichnet), SLA-Chips mit Restzeit/Ueberschreitung ('1h 32m verbleibend' / 'um 2h ueberschritten'), Footer 'Status aendern'-Dropdown (nur erlaubte ITIL-Uebergaenge) + 'Kommentar hinzufuegen' - Kanban Board (/tickets/board): Spalten Offen/In Bearbeitung/Warten/Geloest - CSP: style-src um 'unsafe-inline' ergaenzt (Inline-style-Attribute der Templates, z. B. Fortschrittsbalken); Inline-Skripte bleiben verboten Tests: cargo test 7/7, E2E-Smoke 39/39 (inkl. neuer UI-/Such-/Board-Checks). --- src/main.rs | 1 + src/tickets.rs | 287 ++++++++++++++++++++++++++++++++++++---- src/web.rs | 11 +- static/app.js | 294 +++++++++++++++++++++++++---------------- static/style.css | 69 ++++++++++ templates/base.html | 32 ++++- templates/board.html | 26 ++++ templates/tickets.html | 136 ++++++++++++------- 8 files changed, 665 insertions(+), 191 deletions(-) create mode 100755 templates/board.html diff --git a/src/main.rs b/src/main.rs index d15201e..4cdf907 100755 --- a/src/main.rs +++ b/src/main.rs @@ -102,6 +102,7 @@ async fn main() -> anyhow::Result<()> { .route("/probleme", get(tickets::probleme_list)) .route("/aenderungen", get(tickets::aenderungen_list)) .route("/releases", get(tickets::releases_list)) + .route("/tickets/board", get(tickets::board)) .route("/tickets/new", post(tickets::ticket_new)) .route("/tickets/:id/status", post(tickets::ticket_status)) .route("/tickets/:id/comment", post(tickets::ticket_comment)) diff --git a/src/tickets.rs b/src/tickets.rs index 31e61fb..b0cc23e 100755 --- a/src/tickets.rs +++ b/src/tickets.rs @@ -58,10 +58,153 @@ fn prio_class(p: &str) -> &'static str { } } + +// ── KPI-Karten (Design 2026-07-15: Wert + Trend + Sparkline) ────────────────── +pub struct KpiCard { + pub label: String, + pub value: String, + pub delta: String, + pub delta_class: String, // "pos" | "neg" | "neutral" + pub spark: String, // SVG-Polyline-Punkte (70x20 Viewbox) +} + +/// Tagesserie (8 Tage) aus einem Zeitstempel-Selektor. +fn daily_series(tickets: &[Ticket], now: DateTime, pick: &dyn Fn(&Ticket) -> Option>) -> Vec { + let mut out = vec![0i64; 8]; + for t in tickets { + if let Some(ts) = pick(t) { + let age = (now - ts).num_days(); + if (0..8).contains(&age) { + out[7 - age as usize] += 1; + } + } + } + out +} + +fn spark_points(series: &[i64]) -> String { + let max = series.iter().copied().max().unwrap_or(0).max(1) as f64; + let n = series.len().max(2) as f64; + series.iter().enumerate().map(|(i, v)| { + let x = i as f64 * (70.0 / (n - 1.0)); + let y = 19.0 - (*v as f64 / max) * 16.0; + format!("{:.1},{:.1}", x, y) + }).collect::>().join(" ") +} + +fn fmt_minutes(mins: i64) -> String { + let m = mins.abs(); + if m >= 60 * 24 { + format!("{}d {}h", m / (60 * 24), (m % (60 * 24)) / 60) + } else if m >= 60 { + format!("{}h {:02}m", m / 60, m % 60) + } else { + format!("{}m", m) + } +} + +/// SLA-Anzeige: (Text, verletzt?) — "1h 32m verbleibend" / "3h 20m ueberschritten" +/// bzw. bei erledigten Tickets "eingehalten" / "um 1h 05m ueberschritten". +fn sla_label(due: Option>, done: Option>, now: DateTime) -> (String, bool) { + let Some(due) = due else { return ("--".into(), false) }; + match done { + Some(d) if d <= due => ("eingehalten".into(), false), + Some(d) => (format!("um {} ueberschritten", fmt_minutes((d - due).num_minutes())), true), + None if now <= due => (format!("{} verbleibend", fmt_minutes((due - now).num_minutes())), false), + None => (format!("{} ueberschritten", fmt_minutes((now - due).num_minutes())), true), + } +} + +fn initials(label: &str) -> String { + let local = label.split('@').next().unwrap_or(label); + let parts: Vec<&str> = local.split(['.', '-', '_', ' ']).filter(|s| !s.is_empty()).collect(); + let mut s = String::new(); + for p in parts.iter().take(2) { + if let Some(ch) = p.chars().next() { + s.extend(ch.to_uppercase()); + } + } + if s.is_empty() { s.push('?'); } + s +} + +fn kpi_cards(tickets: &[Ticket], now: DateTime) -> Vec { + let created_7d = tickets.iter().filter(|t| (now - t.created_at).num_days() < 7).count(); + let resolved_7d = tickets.iter().filter(|t| t.resolved_at.map(|r| (now - r).num_days() < 7).unwrap_or(false)).count(); + let resolved_prev7 = tickets.iter().filter(|t| t.resolved_at.map(|r| { + let d = (now - r).num_days(); (7..14).contains(&d) + }).unwrap_or(false)).count(); + let count_status = |s: &str| tickets.iter().filter(|t| t.status == s).count(); + let overdue = tickets.iter().filter(|t| is_overdue(t, now)).count(); + + // Durchschnittliche Antwortzeit (erste Reaktion) der letzten 30 Tage. + let responded: Vec = tickets.iter() + .filter_map(|t| t.first_response_at.map(|fr| (fr, t.created_at))) + .filter(|(fr, _)| (now - *fr).num_days() < 30) + .map(|(fr, cr)| (fr - cr).num_minutes().max(0)) + .collect(); + let avg_response = if responded.is_empty() { None } else { + Some(responded.iter().sum::() / responded.len() as i64) + }; + + let s_created = daily_series(tickets, now, &|t| Some(t.created_at)); + let s_started = daily_series(tickets, now, &|t| t.first_response_at); + let s_resolved = daily_series(tickets, now, &|t| t.resolved_at); + let s_updated = daily_series(tickets, now, &|t| Some(t.updated_at)); + let s_due = daily_series(tickets, now, &|t| resolve_due(t)); + + let pct = |cur: usize, prev: usize| -> (String, String) { + if prev == 0 && cur == 0 { return ("±0% vs. letzte 7 Tage".into(), "neutral".into()); } + if prev == 0 { return (format!("+{} vs. letzte 7 Tage", cur), "pos".into()); } + let p = ((cur as i64 - prev as i64) * 100) / prev as i64; + (format!("{}{}% vs. letzte 7 Tage", if p >= 0 { "↑ +" } else { "↓ " }, p), + if p >= 0 { "pos".into() } else { "neg".into() }) + }; + let (res_delta, res_class) = pct(resolved_7d, resolved_prev7); + + vec![ + KpiCard { label: "Gesamt".into(), value: tickets.len().to_string(), + delta: format!("↑ +{} in 7 Tagen", created_7d), delta_class: "pos".into(), + spark: spark_points(&s_created) }, + KpiCard { label: "Offen".into(), value: count_status("Offen").to_string(), + delta: format!("+{} neu (7 Tage)", created_7d), delta_class: "neutral".into(), + spark: spark_points(&s_created) }, + KpiCard { label: "In Bearbeitung".into(), value: count_status("In Bearbeitung").to_string(), + delta: format!("{} uebernommen (7 T)", s_started.iter().sum::()), delta_class: "neutral".into(), + spark: spark_points(&s_started) }, + KpiCard { label: "Warten auf Input".into(), value: count_status("Warten").to_string(), + delta: "aktueller Stand".into(), delta_class: "neutral".into(), + spark: spark_points(&s_updated) }, + KpiCard { label: "Ueberfaellig".into(), value: overdue.to_string(), + delta: if overdue > 0 { format!("↑ {} SLA-Verletzungen", overdue) } else { "keine".into() }, + delta_class: if overdue > 0 { "neg".into() } else { "pos".into() }, + spark: spark_points(&s_due) }, + KpiCard { label: "Geloest (7 Tage)".into(), value: resolved_7d.to_string(), + delta: res_delta, delta_class: res_class, + spark: spark_points(&s_resolved) }, + KpiCard { label: "Durchschn. Antwortzeit".into(), + value: avg_response.map(fmt_minutes).unwrap_or_else(|| "--".into()), + delta: "Ø letzte 30 Tage".into(), delta_class: "neutral".into(), + spark: spark_points(&s_started) }, + ] +} + +pub struct PageLink { + pub num: usize, + pub href: String, + pub active: bool, +} + +pub struct Pagination { + pub label: String, + pub pages: Vec, +} + // ── Listenansicht ────────────────────────────────────────────────────────────── pub struct TicketRow { pub id: i32, pub nr: String, + pub initials: String, pub titel: String, pub service: String, pub status_class: String, @@ -86,33 +229,23 @@ pub struct Tab { pub struct TicketsTemplate { pub title: String, pub ctx: PageCtx, - pub kpis: Vec<(String, String)>, + pub kpis: Vec, pub tabs: Vec, pub rows: Vec, pub services: Vec<(i32, String)>, pub categories: Vec, pub impact_levels: Vec, pub change_types: Vec, + pub q: String, + pub pagination: Pagination, } #[derive(Deserialize)] pub struct ListQuery { pub status: Option, -} - -fn kpis(tickets: &[Ticket], now: DateTime) -> Vec<(String, String)> { - let count = |f: &dyn Fn(&&Ticket) -> bool| tickets.iter().filter(f).count(); - let geloest_7d = tickets.iter().filter(|t| { - (t.status == "Geloest" || t.status == "Geschlossen") && (now - t.updated_at).num_days() <= 7 - }).count(); - vec![ - ("Gesamt".into(), tickets.len().to_string()), - ("Offen".into(), count(&|t| t.status == "Offen").to_string()), - ("In Bearbeitung".into(), count(&|t| t.status == "In Bearbeitung").to_string()), - ("Warten auf Input".into(), count(&|t| t.status == "Warten").to_string()), - ("Ueberfaellig".into(), count(&|t| is_overdue(t, now)).to_string()), - ("Geloest (7 Tage)".into(), geloest_7d.to_string()), - ] + pub q: Option, + pub mine: Option, + pub page: Option, } fn ticket_badge(t: &Ticket) -> String { @@ -135,35 +268,81 @@ fn ticket_badge(t: &Ticket) -> String { } async fn list_page(app: &AppState, auth: &AuthUser, base: &str, title: &str, - filter_kategorie: Option<&str>, filter_status: Option) -> WebResult { + filter_kategorie: Option<&str>, q: ListQuery) -> WebResult { + const PAGE_SIZE: usize = 12; let now = Utc::now(); // Rolle 'user' (Requester): nur eigene Tickets (Self-Service-Sicht). let ersteller = if itil::is_operative(&auth.role) { None } else { Some(auth.user_id) }; let all = app.db.list_tickets(auth.tenant_id, ersteller).await?; - let kpi_list = kpis(&all, now); + let kpi_list = kpi_cards(&all, now); + + let search = q.q.clone().unwrap_or_default().trim().to_string(); + let mine = q.mine.as_deref() == Some("1"); + let filter_status = q.status.clone(); let mut shown: Vec<&Ticket> = all.iter() .filter(|t| filter_kategorie.map(|k| t.kategorie == k).unwrap_or(true)) .collect(); + if mine { + shown.retain(|t| t.zugewiesen_an.as_deref() == Some(auth.email.as_str()) + || t.ersteller_id == Some(auth.user_id)); + } + if !search.is_empty() { + let needle = search.to_lowercase(); + shown.retain(|t| t.titel.to_lowercase().contains(&needle) + || t.ticket_nr.to_lowercase().contains(&needle) + || t.beschreibung.as_deref().unwrap_or("").to_lowercase().contains(&needle)); + } match filter_status.as_deref() { Some("Ueberfaellig") => shown.retain(|t| is_overdue(t, now)), Some(s) if !s.is_empty() => shown.retain(|t| t.status == s), _ => {} } + // Href-Bausteine, damit Tabs/Pagination Suche + Filter beibehalten. + let mut keep: Vec = Vec::new(); + if !search.is_empty() { keep.push(format!("q={}", urlencode_q(&search))); } + if mine { keep.push("mine=1".into()); } + let href_for = |status: &str, page: usize| -> String { + let mut parts = keep.clone(); + if !status.is_empty() { parts.push(format!("status={}", status.replace(' ', "+"))); } + if page > 1 { parts.push(format!("page={}", page)); } + if parts.is_empty() { base.to_string() } else { format!("{}?{}", base, parts.join("&")) } + }; + let tabs = [("", "Alle"), ("Offen", "Offen"), ("In Bearbeitung", "In Bearbeitung"), - ("Warten", "Warten"), ("Geloest", "Geloest"), ("Ueberfaellig", "Ueberfaellig")] + ("Warten", "Warten"), ("Geloest", "Geloest"), ("Geschlossen", "Geschlossen"), + ("Ueberfaellig", "Ueberfaellig")] .iter().map(|(s, label)| Tab { - href: if s.is_empty() { base.to_string() } else { format!("{}?status={}", base, s.replace(' ', "+")) }, + href: href_for(s, 1), label: label.to_string(), active: filter_status.as_deref().unwrap_or("") == *s, }).collect(); + // Pagination + let total = shown.len(); + let page_count = total.div_ceil(PAGE_SIZE).max(1); + let page = q.page.unwrap_or(1).clamp(1, page_count); + let from = (page - 1) * PAGE_SIZE; + let to = (from + PAGE_SIZE).min(total); + let cur_status = filter_status.clone().unwrap_or_default(); + let pagination = Pagination { + label: if total == 0 { "Keine Tickets".into() } + else { format!("{}-{} von {} Tickets", from + 1, to, total) }, + pages: (1..=page_count).map(|n| PageLink { + num: n, + href: href_for(&cur_status, n), + active: n == page, + }).collect(), + }; + let shown = &shown[from..to]; + let rows = shown.iter().map(|t| { let overdue = is_overdue(t, now); TicketRow { id: t.id, nr: t.ticket_nr.clone(), + initials: initials(t.zugewiesen_an.as_deref().unwrap_or("?")), titel: t.titel.clone(), service: t.service_name.clone().unwrap_or_default(), status_class: if overdue { "ueberfaellig".into() } else { status_class(&t.status).into() }, @@ -196,35 +375,41 @@ async fn list_page(app: &AppState, auth: &AuthUser, base: &str, title: &str, categories, impact_levels: itil::IMPACT_URGENCY_LEVELS.iter().map(|s| s.to_string()).collect(), change_types: itil::CHANGE_TYPES.iter().map(|s| s.to_string()).collect(), + q: search, + pagination, }; Ok(tpl.into_response()) } +fn urlencode_q(s: &str) -> String { + form_urlencoded::byte_serialize(s.as_bytes()).collect() +} + pub async fn tickets_list(State(app): State, Extension(ctx): Extension, Query(q): Query) -> WebResult { let auth = match need_auth(&ctx, "/tickets") { Ok(a) => a, Err(r) => return Ok(r) }; - list_page(&app, &auth, "/tickets", "Tickets", None, q.status).await + list_page(&app, &auth, "/tickets", "Tickets", None, q).await } pub async fn probleme_list(State(app): State, Extension(ctx): Extension, Query(q): Query) -> WebResult { let auth = match need_auth(&ctx, "/probleme") { Ok(a) => a, Err(r) => return Ok(r) }; if let Err(r) = crate::web::need_operative(&auth) { return Ok(r); } - list_page(&app, &auth, "/probleme", "Probleme", Some("Problem"), q.status).await + list_page(&app, &auth, "/probleme", "Probleme", Some("Problem"), q).await } pub async fn aenderungen_list(State(app): State, Extension(ctx): Extension, Query(q): Query) -> WebResult { let auth = match need_auth(&ctx, "/aenderungen") { Ok(a) => a, Err(r) => return Ok(r) }; if let Err(r) = crate::web::need_operative(&auth) { return Ok(r); } - list_page(&app, &auth, "/aenderungen", "Aenderungen", Some("Change"), q.status).await + list_page(&app, &auth, "/aenderungen", "Aenderungen", Some("Change"), q).await } pub async fn releases_list(State(app): State, Extension(ctx): Extension, Query(q): Query) -> WebResult { let auth = match need_auth(&ctx, "/releases") { Ok(a) => a, Err(r) => return Ok(r) }; if let Err(r) = crate::web::need_operative(&auth) { return Ok(r); } - list_page(&app, &auth, "/releases", "Releases", Some("Release"), q.status).await + list_page(&app, &auth, "/releases", "Releases", Some("Release"), q).await } // ── Ticket anlegen ───────────────────────────────────────────────────────────── @@ -520,6 +705,10 @@ pub async fn api_ticket(State(app): State, Extension(ctx): Extension, Extension(ctx): Extension, +} + +#[derive(Template)] +#[template(path = "board.html")] +pub struct BoardTemplate { + pub title: String, + pub ctx: PageCtx, + pub columns: Vec, +} + +pub async fn board(State(app): State, Extension(ctx): Extension) -> WebResult { + let auth = match need_auth(&ctx, "/tickets/board") { Ok(a) => a, Err(r) => return Ok(r) }; + if let Err(r) = crate::web::need_operative(&auth) { return Ok(r); } + let now = Utc::now(); + let all = app.db.list_tickets(auth.tenant_id, None).await?; + let columns = ["Offen", "In Bearbeitung", "Warten", "Geloest"].iter().map(|status| { + let cards: Vec = all.iter().filter(|t| t.status == *status).map(|t| { + let overdue = is_overdue(t, now); + TicketRow { + id: t.id, + nr: t.ticket_nr.clone(), + initials: initials(t.zugewiesen_an.as_deref().unwrap_or("?")), + titel: t.titel.clone(), + service: t.service_name.clone().unwrap_or_default(), + status_class: if overdue { "ueberfaellig".into() } else { status_class(&t.status).into() }, + status_label: if overdue { "Ueberfaellig".into() } else { t.status.clone() }, + prio_class: prio_class(&t.prioritaet).into(), + prio: t.prioritaet.clone(), + kategorie: t.kategorie.clone(), + badge: ticket_badge(t), + zugewiesen: t.zugewiesen_an.clone().unwrap_or_default(), + updated: t.updated_at.format("%Y-%m-%d %H:%M").to_string(), + fortschritt: t.fortschritt, + } + }).collect(); + BoardColumn { title: status.to_string(), count: cards.len(), cards } + }).collect(); + let tpl = BoardTemplate { + title: "Kanban Board".into(), + ctx: page_ctx(&auth, "/tickets/board"), + columns, + }; + Ok(tpl.into_response()) +} + // ── Repo-Bearbeitung aus Tickets (phase-008-itsm-repo-audit) ────────────────── /// Gemeinsames RBAC-Gate: nur admin/change_manager, nur aus einem umsetzbaren /// Change-Ticket heraus (ITIL Change Enablement: Repo-Aenderungen sind diff --git a/src/web.rs b/src/web.rs index 209127a..b43371b 100755 --- a/src/web.rs +++ b/src/web.rs @@ -105,6 +105,7 @@ pub fn need_change_approver(auth: &AuthUser) -> Result<(), Response> { // ── Seiten-Kontext fuer Templates ───────────────────────────────────────────── pub struct PageCtx { pub email: String, + pub initials: String, pub tenant_name: String, pub role_label: String, pub csrf: String, @@ -114,8 +115,12 @@ pub struct PageCtx { } pub fn page_ctx(auth: &AuthUser, active: &str) -> PageCtx { + let local = auth.email.split('@').next().unwrap_or("?"); + let initials: String = local.split(['.', '-', '_']).filter(|s| !s.is_empty()) + .take(2).filter_map(|p| p.chars().next()).flat_map(|c| c.to_uppercase()).collect(); PageCtx { email: auth.email.clone(), + initials: if initials.is_empty() { "?".into() } else { initials }, tenant_name: auth.tenant_name.clone(), role_label: itil::role_label(&auth.role).to_string(), csrf: auth.csrf_token.clone(), @@ -201,8 +206,10 @@ fn apply_security_headers(mut resp: Response, cfg: &Config) -> Response { let h = resp.headers_mut(); // CSP: Skripte/Styles nur aus /static (kein Inline-JS -- die fruehere // Version hatte onclick-Handler im HTML, alles nach static/app.js verlegt). - let csp = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; \ - frame-ancestors 'none'; base-uri 'self'; form-action 'self'"; + // style-src erlaubt Inline-STYLE-Attribute (Templates nutzen style="...", + // z. B. Fortschrittsbalken-Breiten); Inline-