feat(ui): Dashboard-Redesign nach Nutzer-Mockup + Kanban, Suche, Pagination, Bulk-Aktionen

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).
This commit is contained in:
Moe 2026-07-15 05:23:26 +02:00
parent 7f9d2b431d
commit 9b8d603e74
8 changed files with 665 additions and 191 deletions

View File

@ -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))

View File

@ -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<Utc>, pick: &dyn Fn(&Ticket) -> Option<DateTime<Utc>>) -> Vec<i64> {
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::<Vec<_>>().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<DateTime<Utc>>, done: Option<DateTime<Utc>>, now: DateTime<Utc>) -> (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<Utc>) -> Vec<KpiCard> {
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<i64> = 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::<i64>() / 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::<i64>()), 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<PageLink>,
}
// ── 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<KpiCard>,
pub tabs: Vec<Tab>,
pub rows: Vec<TicketRow>,
pub services: Vec<(i32, String)>,
pub categories: Vec<String>,
pub impact_levels: Vec<String>,
pub change_types: Vec<String>,
pub q: String,
pub pagination: Pagination,
}
#[derive(Deserialize)]
pub struct ListQuery {
pub status: Option<String>,
}
fn kpis(tickets: &[Ticket], now: DateTime<Utc>) -> 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<String>,
pub mine: Option<String>,
pub page: Option<usize>,
}
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<String>) -> 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<String> = 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<AppState>, Extension(ctx): Extension<ReqCtx>,
Query(q): Query<ListQuery>) -> 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<AppState>, Extension(ctx): Extension<ReqCtx>,
Query(q): Query<ListQuery>) -> 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<AppState>, Extension(ctx): Extension<ReqCtx>,
Query(q): Query<ListQuery>) -> 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<AppState>, Extension(ctx): Extension<ReqCtx>,
Query(q): Query<ListQuery>) -> 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<AppState>, Extension(ctx): Extension<R
"first_response_at": fmt(t.first_response_at),
"resolved_at": fmt(t.resolved_at),
"overdue": is_overdue(&t, now),
"response_label": sla_label(response_due(&t), t.first_response_at, now).0,
"response_breached": sla_label(response_due(&t), t.first_response_at, now).1,
"resolve_label": sla_label(resolve_due(&t), t.resolved_at, now).0,
"resolve_breached": sla_label(resolve_due(&t), t.resolved_at, now).1,
},
"allowed_next": allowed_next,
"operative": operative,
@ -542,6 +731,56 @@ pub async fn api_ticket(State(app): State<AppState>, Extension(ctx): Extension<R
Ok(Json(body).into_response())
}
// ── Kanban-Board (Design 2026-07-15) ──────────────────────────────────────────
pub struct BoardColumn {
pub title: String,
pub count: usize,
pub cards: Vec<TicketRow>,
}
#[derive(Template)]
#[template(path = "board.html")]
pub struct BoardTemplate {
pub title: String,
pub ctx: PageCtx,
pub columns: Vec<BoardColumn>,
}
pub async fn board(State(app): State<AppState>, Extension(ctx): Extension<ReqCtx>) -> 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<TicketRow> = 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

View File

@ -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-<script> bleibt verboten.
let csp = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; \
img-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'";
h.entry("content-security-policy").or_insert(csp.parse().unwrap());
h.entry("x-content-type-options").or_insert("nosniff".parse().unwrap());
h.entry("x-frame-options").or_insert("DENY".parse().unwrap());

View File

@ -23,16 +23,23 @@
}).then(function (r) { return r.json().catch(function () { return { ok: r.ok }; }); });
}
function initials(label) {
var local = String(label || "?").split("@")[0];
var parts = local.split(/[._\- ]+/).filter(Boolean);
var s = parts.slice(0, 2).map(function (p) { return p[0].toUpperCase(); }).join("");
return s || "?";
}
// ── Zeilen-Navigation + Ticket-Detail ────────────────────────────────────────
document.addEventListener("click", function (ev) {
var row = ev.target.closest("tr.row");
var row = ev.target.closest(".row");
if (!row) return;
if (ev.target.closest("a, button, form, select, input")) return;
if (ev.target.closest("a, button, form, select, input, textarea")) return;
if (row.dataset.ticketId) openDetail(parseInt(row.dataset.ticketId, 10));
else if (row.dataset.href) location.href = row.dataset.href;
});
// Formulare mit Bestaetigung (z. B. Loeschen) und Auto-Submit-Selects.
// Formulare mit Bestaetigung + Auto-Submit-Selects + Change-Typ-Anzeige
document.addEventListener("submit", function (ev) {
var f = ev.target;
if (f.dataset && f.dataset.confirm && !window.confirm(f.dataset.confirm)) {
@ -45,19 +52,63 @@
var row = document.getElementById("change-typ-row");
if (row) row.hidden = ev.target.value !== "Change";
}
if (ev.target.id === "check-all") {
document.querySelectorAll(".row-check").forEach(function (c) { c.checked = ev.target.checked; });
}
});
// ── Modal "Neues Ticket" ─────────────────────────────────────────────────────
var modal = document.getElementById("new-ticket-modal");
var openBtn = document.getElementById("open-new-ticket");
var closeBtn = document.getElementById("close-new-ticket");
if (openBtn) openBtn.addEventListener("click", function () { modal.hidden = false; });
if (closeBtn) closeBtn.addEventListener("click", function () { modal.hidden = true; });
if (modal) modal.addEventListener("click", function (ev) { if (ev.target === modal) modal.hidden = true; });
// ── Aktionen-Dropdown + Bulk-Status ──────────────────────────────────────────
var bulkBtn = document.getElementById("bulk-actions-btn");
var bulkMenu = document.getElementById("bulk-menu");
if (bulkBtn) {
bulkBtn.addEventListener("click", function (ev) {
ev.stopPropagation();
bulkMenu.hidden = !bulkMenu.hidden;
});
document.addEventListener("click", function () { bulkMenu.hidden = true; });
bulkMenu.querySelectorAll("[data-bulk-status]").forEach(function (b) {
b.addEventListener("click", function () {
var ids = Array.prototype.map.call(document.querySelectorAll(".row-check:checked"), function (c) { return c.value; });
if (!ids.length) { alert("Keine Tickets ausgewaehlt."); return; }
var status = b.dataset.bulkStatus;
var done = 0, errors = [];
var next = function () {
if (!ids.length) {
if (errors.length) alert("Teilweise fehlgeschlagen (ITIL-Statusmodell):\n" + errors.join("\n"));
location.reload();
return;
}
var id = ids.shift();
post("/tickets/" + id + "/status", { status: status }).then(function (d) {
if (!d.ok) errors.push("#" + id + ": " + (d.error || "Fehler"));
done++; next();
});
};
next();
});
});
}
// ── Detail-Panel ─────────────────────────────────────────────────────────────
var overlay = document.getElementById("detail-overlay");
var panel = document.getElementById("detail-panel");
if (overlay) overlay.addEventListener("click", closeDetail);
function openDetail(id) {
function openDetail(id, tab) {
fetch("/api/tickets/" + id, { headers: { "Accept": "application/json" } })
.then(function (r) { return r.json(); })
.then(function (t) {
if (!t || t.ok === false) return;
panel.innerHTML = renderDetail(t);
bindDetail(t);
panel.innerHTML = renderDetail(t, tab || "details");
bindDetail(t, tab || "details");
panel.classList.add("open");
overlay.classList.add("open");
});
@ -68,161 +119,182 @@
overlay.classList.remove("open");
}
function pill(text) {
return '<span class="pill">' + esc(text) + "</span> ";
function pill(text, cls) {
return '<span class="pill ' + (cls || "offen") + '">' + esc(text) + "</span>";
}
function renderDetail(t) {
function statusCls(s) {
return { "In Bearbeitung": "inbearbeitung", "Warten": "warten", "Geloest": "geloest",
"Geschlossen": "geschlossen" }[s] || "offen";
}
function prioCls(p) {
return { "Kritisch": "krit", "Hoch": "hoch", "Niedrig": "niedrig" }[p] || "mittel";
}
function renderDetail(t, tab) {
var h = "";
h += "<h2>" + esc(t.ticket_nr) + "</h2>";
h += '<div class="sz" style="margin-bottom:10px">' + esc(t.titel) + "</div>";
h += "<div>" + pill(t.status) + pill(t.prioritaet) + pill(t.kategorie);
h += pill("Impact: " + t.impact) + pill("Urgency: " + t.urgency);
if (t.kategorie === "Change" && t.change_typ) h += pill(t.change_typ) + pill("Freigabe: " + (t.approval_status || "-"));
if (t.known_error) h += pill("Known Error");
h += "</div>";
h += '<div class="dt-head"><h2>' + esc(t.ticket_nr) + '</h2><button class="linklike" id="dt-close">✕</button></div>';
h += '<div class="dt-service"><span class="avatar">' + esc(initials(t.service_name || t.titel)) + '</span>';
h += '<div><b>' + esc(t.service_name || "(kein Service)") + '</b><div class="sz">' + esc(t.titel) + '</div></div></div>';
h += '<div class="sect">Beschreibung</div><div>' + esc(t.beschreibung || "") + "</div>";
h += '<div class="sect">Service</div><div>' + esc(t.service_name || "(kein Service)") + "</div>";
h += '<div class="dt-kv">';
h += '<span class="k">Status</span><span>' + pill(t.status, statusCls(t.status)) + '</span>';
h += '<span class="k">Prioritaet</span><span>' + pill(t.prioritaet, prioCls(t.prioritaet)) + '</span>';
h += '<span class="k">Kategorie</span><span>' + pill(t.kategorie) +
(t.kategorie === "Change" && t.change_typ ? " " + pill(t.change_typ) + " " + pill("Freigabe: " + (t.approval_status || "-")) : "") +
(t.known_error ? " " + pill("Known Error", "ueberfaellig") : "") + '</span>';
h += '<span class="k">Zugewiesen an</span><span><span class="avatar sm">' + esc(initials(t.zugewiesen_an)) + '</span> ' + esc(t.zugewiesen_an || "--") + '</span>';
h += '<span class="k">Impact / Urgency</span><span>' + esc(t.impact) + ' / ' + esc(t.urgency) + '</span>';
h += '<span class="k">Fortschritt</span><span>' + t.fortschritt + '%<div class="bar-track"><div class="bar-fill" style="width:' + t.fortschritt + '%"></div></div></span>';
if (t.problem_nr) h += '<span class="k">Problem</span><span>' + esc(t.problem_nr) + '</span>';
h += '</div>';
if (t.sla) {
h += '<div class="sect">SLA</div><div class="sz">';
h += "Antwort bis: " + esc(t.sla.response_due || "--");
h += " · Loesung bis: " + esc(t.sla.resolve_due || "--");
if (t.sla.overdue) h += ' · <span class="pill ueberfaellig">Ueberfaellig</span>';
h += "</div>";
}
if (t.problem_nr) {
h += '<div class="sect">Problem</div><div class="sz">Verknuepft mit ' + esc(t.problem_nr) + "</div>";
}
if (t.operative && t.allowed_next && t.allowed_next.length) {
h += '<div class="sect">Status aendern (erlaubte ITIL-Uebergaenge)</div><div class="btnrow">';
t.allowed_next.forEach(function (s) {
h += '<button class="btn ghost" data-status="' + esc(s) + '">' + esc(s) + "</button>";
});
h += "</div>";
}
if (t.can_approve) {
h += '<div class="sect">Change-Freigabe (CAB)</div><div class="btnrow">';
h += '<button class="btn" data-approve="Genehmigt">Genehmigen</button>';
h += '<button class="btn ghost" data-approve="Abgelehnt">Ablehnen</button></div>';
}
if (t.operative && t.kategorie === "Problem") {
h += '<div class="sect">Problem Management</div><div class="btnrow">';
h += '<button class="btn ghost" data-known-error="' + (t.known_error ? "0" : "1") + '">' +
(t.known_error ? "Known-Error-Markierung entfernen" : "Als Known Error markieren") + "</button></div>";
}
if (t.operative && t.kategorie !== "Problem" && t.problems && t.problems.length) {
h += '<div class="sect">Mit Problem verknuepfen</div><div class="btnrow">';
h += '<select id="dt-problem"><option value="">(keine Verknuepfung)</option>';
t.problems.forEach(function (p) {
var sel = t.problem_id === p.id ? " selected" : "";
h += '<option value="' + p.id + '"' + sel + ">" + esc(p.nr + " " + p.titel) + "</option>";
});
h += '</select> <button class="btn ghost" id="dt-problem-save">Speichern</button></div>';
}
var tabs = [["details", "Details"], ["akt", "Aktivitaeten"], ["anh", "Anhaenge"], ["auf", "Aufgaben"]];
h += '<div class="dt-tabs">' + tabs.map(function (x) {
return '<span data-dt-tab="' + x[0] + '" class="' + (tab === x[0] ? "active" : "") + '">' + x[1] + '</span>';
}).join("") + '</div>';
if (tab === "details") {
h += '<div class="sect">Beschreibung</div><div>' + esc(t.beschreibung || "--") + '</div>';
h += '<div class="sect">Betroffene Services</div><div>' + pill(t.service_name || "(kein Service)") + '</div>';
if (t.operative) {
h += '<div class="sect">Configuration Items (CMDB)</div><div>';
h += '<div class="sect">CI / Betroffene Konfigurationselemente</div><div>';
(t.cis || []).forEach(function (c) {
h += '<span class="pill agent tagpill">' + esc(c.name) +
' <button class="linklike" data-ci-unlink="' + c.id + '">x</button></span>';
});
if (!(t.cis || []).length) h += '<span class="sz">keine</span>';
if (t.cis_available && t.cis_available.length) {
h += '<div style="margin-top:6px"><select id="dt-ci">';
t.cis_available.forEach(function (c) {
h += '<option value="' + c.id + '">' + esc(c.name + " (" + c.typ + ")") + "</option>";
});
h += '</select> <button class="btn ghost" id="dt-ci-add">Verknuepfen</button></div>';
h += '</select> <button class="btn ghost mini" id="dt-ci-add">+ Verknuepfen</button></div>';
}
h += "</div>";
h += '</div>';
}
if (t.sla) {
h += '<div class="sect">SLAs</div><div class="dt-kv">';
h += '<span class="k">Antwortzeit</span><span><span class="sla-chip' + (t.sla.response_breached ? " breach" : "") + '">' + esc(t.sla.response_label) + '</span></span>';
h += '<span class="k">Loesungszeit</span><span><span class="sla-chip' + (t.sla.resolve_breached ? " breach" : "") + '">' + esc(t.sla.resolve_label) + '</span></span>';
h += '</div>';
}
if (t.operative && t.kategorie !== "Problem" && t.problems && t.problems.length) {
h += '<div class="sect">Mit Problem verknuepfen</div><div>';
h += '<select id="dt-problem"><option value="">(keine Verknuepfung)</option>';
t.problems.forEach(function (p) {
var sel = t.problem_id === p.id ? " selected" : "";
h += '<option value="' + p.id + '"' + sel + ">" + esc(p.nr + " " + p.titel) + "</option>";
});
h += '</select> <button class="btn ghost mini" id="dt-problem-save">Speichern</button></div>';
}
if (t.operative && t.kategorie === "Problem") {
h += '<div class="sect">Problem Management</div><div>';
h += '<button class="btn ghost mini" data-known-error="' + (t.known_error ? "0" : "1") + '">' +
(t.known_error ? "Known-Error entfernen" : "Als Known Error markieren") + "</button></div>";
}
if (t.can_approve) {
h += '<div class="sect">Change-Freigabe (CAB)</div><div>';
h += '<button class="btn mini" data-approve="Genehmigt">Genehmigen</button> ';
h += '<button class="btn ghost mini" data-approve="Abgelehnt">Ablehnen</button></div>';
}
if (t.can_repo_edit) {
h += '<div class="sect">Repo-Datei bearbeiten (Forge, dokumentationspflichtig)</div>';
h += '<div class="panel">';
h += '<div class="grid-2"><input type="text" id="repo-name" placeholder="Repo (z.B. demo)">';
h += '<input type="text" id="repo-branch" placeholder="Branch" value="main"></div>';
h += '<div class="formrow"><input type="text" id="repo-path" placeholder="Dateipfad (z.B. README.md)"></div>';
h += '<button class="btn ghost" id="repo-load">Datei laden</button>';
h += '<button class="btn ghost mini" id="repo-load">Datei laden</button>';
h += '<div id="repo-status" class="sz" style="margin:6px 0"></div>';
h += '<textarea id="repo-content" rows="8" class="mono" hidden></textarea>';
h += '<input type="hidden" id="repo-sha">';
h += '<div class="formrow" id="repo-msg-row" hidden><input type="text" id="repo-msg" placeholder="Commit-Nachricht"></div>';
h += '<button class="btn" id="repo-save" hidden>Speichern (im Repo committen)</button>';
h += "</div>";
} else if (t.operative && t.kategorie === "Change") {
h += '<div class="sz" style="margin-top:10px">Repo-Bearbeitung: erfordert Rolle Change Manager/Administrator und einen freigegebenen Change.</div>';
h += '<button class="btn mini" id="repo-save" hidden>Speichern (committen)</button>';
h += '</div>';
}
h += '<div class="sect">Kommentar (Worklog)</div>';
h += '<textarea id="dt-comment" rows="2" placeholder="Kommentar hinzufuegen ..."></textarea>';
h += '<div style="margin-top:6px"><button class="btn ghost" id="dt-comment-save">Hinzufuegen</button></div>';
} else if (tab === "akt") {
h += '<div class="sect">Zeitleiste</div>';
var tl = (t.timeline || []).map(function (e) {
h += ((t.timeline || []).map(function (e) {
return '<div class="tl-item"><b>' + esc(e.akteur || "") + '</b><div class="sz">' +
esc(e.zeit) + "</div><div>" + esc(e.text || "") + "</div></div>";
}).join("") || '<div class="sz">Keine Ereignisse.</div>';
h += tl;
h += '<div style="margin-top:16px"><button class="btn ghost" id="dt-close">Schliessen</button></div>';
}).join("")) || '<div class="sz">Keine Ereignisse.</div>';
} else {
h += '<div class="sz" style="margin-top:10px">' +
(tab === "anh" ? "Anhaenge werden in einer spaeteren Ausbaustufe unterstuetzt." :
"Aufgaben werden in einer spaeteren Ausbaustufe unterstuetzt.") + '</div>';
}
h += '<div class="dt-footer">';
if (t.operative && t.allowed_next && t.allowed_next.length) {
h += '<select id="dt-status"><option value="">Status aendern ...</option>' +
t.allowed_next.map(function (s) { return '<option value="' + esc(s) + '">' + esc(s) + '</option>'; }).join("") +
'</select>';
}
h += '<button class="btn" id="dt-comment-btn">Kommentar hinzufuegen</button>';
h += '</div>';
h += '<div id="dt-comment-box" hidden style="margin-top:8px">';
h += '<textarea id="dt-comment" rows="2" placeholder="Kommentar (Worklog) ..."></textarea>';
h += '<div style="margin-top:6px"><button class="btn ghost mini" id="dt-comment-save">Speichern</button></div></div>';
return h;
}
function bindDetail(t) {
function bindDetail(t, tab) {
var id = t.id;
panel.querySelectorAll("[data-status]").forEach(function (b) {
b.addEventListener("click", function () {
post("/tickets/" + id + "/status", { status: b.dataset.status }).then(function (d) {
if (d.ok) openDetail(id); else alert(d.error || "Fehler");
panel.querySelectorAll("[data-dt-tab]").forEach(function (el) {
el.addEventListener("click", function () { openDetail(id, el.dataset.dtTab); });
});
var statusSel = panel.querySelector("#dt-status");
if (statusSel) statusSel.addEventListener("change", function () {
if (!statusSel.value) return;
post("/tickets/" + id + "/status", { status: statusSel.value }).then(function (d) {
if (d.ok) openDetail(id, tab); else alert(d.error || "Fehler");
});
});
panel.querySelectorAll("[data-approve]").forEach(function (b) {
b.addEventListener("click", function () {
post("/tickets/" + id + "/approval", { decision: b.dataset.approve }).then(function (d) {
if (d.ok) openDetail(id); else alert(d.error || "Fehler");
if (d.ok) openDetail(id, tab); else alert(d.error || "Fehler");
});
});
});
panel.querySelectorAll("[data-known-error]").forEach(function (b) {
b.addEventListener("click", function () {
post("/tickets/" + id + "/known-error", { known_error: b.dataset.knownError }).then(function (d) {
if (d.ok) openDetail(id); else alert(d.error || "Fehler");
if (d.ok) openDetail(id, tab); else alert(d.error || "Fehler");
});
});
});
panel.querySelectorAll("[data-ci-unlink]").forEach(function (b) {
b.addEventListener("click", function () {
post("/tickets/" + id + "/ci-unlink", { ci_id: b.dataset.ciUnlink }).then(function () { openDetail(id); });
post("/tickets/" + id + "/ci-unlink", { ci_id: b.dataset.ciUnlink }).then(function () { openDetail(id, tab); });
});
});
var ciAdd = panel.querySelector("#dt-ci-add");
if (ciAdd) ciAdd.addEventListener("click", function () {
post("/tickets/" + id + "/ci-link", { ci_id: panel.querySelector("#dt-ci").value })
.then(function () { openDetail(id); });
.then(function () { openDetail(id, tab); });
});
var probSave = panel.querySelector("#dt-problem-save");
if (probSave) probSave.addEventListener("click", function () {
post("/tickets/" + id + "/problem-link", { problem_id: panel.querySelector("#dt-problem").value })
.then(function (d) { if (d.ok) openDetail(id); else alert(d.error || "Fehler"); });
.then(function (d) { if (d.ok) openDetail(id, tab); else alert(d.error || "Fehler"); });
});
var commentBtn = panel.querySelector("#dt-comment-btn");
if (commentBtn) commentBtn.addEventListener("click", function () {
panel.querySelector("#dt-comment-box").hidden = false;
panel.querySelector("#dt-comment").focus();
});
var commentSave = panel.querySelector("#dt-comment-save");
if (commentSave) commentSave.addEventListener("click", function () {
var text = panel.querySelector("#dt-comment").value.trim();
if (!text) return;
post("/tickets/" + id + "/comment", { text: text }).then(function (d) {
if (d.ok) openDetail(id); else alert(d.error || "Fehler");
if (d.ok) openDetail(id, "akt"); else alert(d.error || "Fehler");
});
});
var close = panel.querySelector("#dt-close");
if (close) close.addEventListener("click", closeDetail);
// Repo-Bearbeitung (nur wenn gerendert)
var repoLoad = panel.querySelector("#repo-load");
if (repoLoad) {
repoLoad.addEventListener("click", function () {
@ -262,7 +334,7 @@
}).then(function (d) {
if (!d.ok) { status.textContent = "Fehler: " + d.error; return; }
status.textContent = "Gespeichert -- Commit " + d.commit.slice(0, 10) + ", im Worklog dokumentiert.";
openDetail(id);
openDetail(id, "akt");
}).catch(function () { status.textContent = "Fehler beim Speichern."; });
});
}

View File

@ -104,3 +104,72 @@ details > summary::-webkit-details-marker{display:none}
.err{color:#E5534B;font-size:13px;margin:0 0 10px}
.hint{color:var(--sub);font-size:11px;margin:-6px 0 10px}
[hidden]{display:none !important}
/* ── Redesign 2026-07-15 (Mockup-Angleichung) ─────────────────────────────── */
.brand{display:flex;align-items:center;gap:10px}
.brand-logo{width:38px;height:38px;border-radius:50%;background:radial-gradient(circle at 35% 35%,#5db3d0,#1d5f7d);
display:inline-flex;align-items:center;justify-content:center;font-size:9px;font-weight:800;color:#0c1420;flex:0 0 38px}
.brand-text b{font-size:16px}
.topbar{gap:16px}
.topbar-left{min-width:120px}
.topsearch{flex:1;max-width:560px}
.topsearch input{background:var(--bg);border:1px solid var(--border);border-radius:8px;
padding:8px 14px;color:var(--text);font-size:13px;width:100%}
.topbar-right{display:flex;align-items:center;gap:14px}
.userchip{display:flex;align-items:center;gap:8px}
.userchip-text{font-size:12px;line-height:1.25;text-align:left}
.avatar{width:32px;height:32px;border-radius:50%;background:linear-gradient(135deg,#4c9eba,#7a5db3);
display:inline-flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;color:#0c1420}
.avatar.sm{width:24px;height:24px;font-size:10px;vertical-align:middle}
.page-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:18px;gap:12px;flex-wrap:wrap}
.page-head .page-title{margin:0}
.page-actions{display:flex;gap:8px;align-items:center}
.kpi-row{grid-template-columns:repeat(auto-fit,minmax(150px,1fr))}
.kpi .val{font-size:24px}
.kpi-foot{display:flex;align-items:flex-end;justify-content:space-between;gap:6px;margin-top:6px}
.delta{font-size:10px;color:var(--sub)}
.delta.pos{color:var(--ok)}
.delta.neg{color:var(--bad)}
.spark{width:70px;height:20px;flex:0 0 70px}
.spark polyline{fill:none;stroke:var(--accent2);stroke-width:1.5}
.toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px;flex-wrap:wrap}
.toolbar .tabs{margin-bottom:0}
.toolbar-search{display:flex;gap:6px}
.toolbar-search input{width:220px}
.ticket-nr{color:var(--accent2);font-weight:600}
th.chk,td.chk{width:28px}
td.chk input,th.chk input{width:auto}
.table-foot{display:flex;align-items:center;justify-content:space-between;margin-top:10px}
.pager{display:flex;gap:4px}
.pager a{padding:4px 10px;border:1px solid var(--border);border-radius:6px;font-size:12px;color:var(--sub)}
.pager a.active{background:var(--panel2);color:var(--text);border-color:var(--accent)}
.dropdown{position:relative;display:inline-block}
.dropdown-menu{position:absolute;right:0;top:calc(100% + 4px);background:var(--panel2);border:1px solid var(--border);
border-radius:8px;min-width:200px;z-index:30;padding:4px}
.dropdown-item{display:block;width:100%;text-align:left;background:none;border:none;color:var(--text);
padding:7px 10px;font-size:13px;cursor:pointer;border-radius:6px}
.dropdown-item:hover{background:var(--panel)}
.modal{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:50;display:flex;align-items:flex-start;justify-content:center;overflow-y:auto;padding:40px 16px}
.modal-box{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:22px;width:640px;max-width:100%}
.modal-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}
.modal-head h3{margin:0}
.board{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;align-items:start}
.board-col{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:10px}
.board-col-head{font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:var(--sub);
padding:4px 6px 10px;display:flex;align-items:center;justify-content:space-between}
.board-card{background:var(--panel2);border:1px solid var(--border);border-radius:8px;padding:10px;margin-bottom:8px;cursor:pointer}
.board-card:hover{border-color:var(--accent)}
.board-card-foot{display:flex;align-items:center;gap:6px;margin-top:8px}
.board-card-foot .avatar{margin-left:auto}
#detail-panel{width:420px}
.dt-head{display:flex;align-items:center;justify-content:space-between}
.dt-head h2{margin:0;color:var(--accent2);font-size:17px}
.dt-service{display:flex;align-items:center;gap:10px;margin:12px 0}
.dt-kv{display:grid;grid-template-columns:120px 1fr;gap:6px 10px;font-size:13px;margin:12px 0}
.dt-kv .k{color:var(--sub)}
.dt-tabs{display:flex;gap:14px;border-bottom:1px solid var(--border);margin:14px 0}
.dt-tabs span{padding-bottom:8px;color:var(--sub);font-size:13px;cursor:pointer}
.dt-tabs span.active{color:var(--accent2);border-bottom:2px solid var(--accent2)}
.sla-chip{display:inline-block;padding:2px 9px;border-radius:6px;font-size:11px;font-weight:600;background:#1a4a35;color:var(--ok)}
.sla-chip.breach{background:#4a1e22;color:var(--bad)}
.dt-footer{display:flex;gap:8px;margin-top:16px;position:sticky;bottom:0;background:var(--panel);padding-top:10px}

View File

@ -10,15 +10,22 @@
<body>
<div class="shell">
<div class="sidebar">
<div class="brand">ITSM<div class="sz">IT Service Management</div></div>
<div class="brand">
<span class="brand-logo">ITSM</span>
<div class="brand-text"><b>ITSM</b><div class="sz">IT Service Management</div></div>
</div>
<div class="navgroup">
<h4>Uebersicht</h4>
<h4>Favoriten</h4>
<a href="/dashboard" class="{% if ctx.active == "/dashboard" %}active{% endif %}">Dashboard</a>
<a href="/tickets?mine=1">Meine Tickets</a>
<a href="/tickets?status=Offen">Offene Tickets</a>
<a href="/tickets?status=Ueberfaellig">Ueberfaellige Tickets</a>
</div>
<div class="navgroup">
<h4>Service Management</h4>
<a href="/tickets" class="{% if ctx.active == "/tickets" %}active{% endif %}">Tickets</a>
{% if ctx.operative %}
<a href="/tickets/board" class="{% if ctx.active == "/tickets/board" %}active{% endif %}">Kanban Board</a>
<a href="/probleme" class="{% if ctx.active == "/probleme" %}active{% endif %}">Probleme</a>
<a href="/aenderungen" class="{% if ctx.active == "/aenderungen" %}active{% endif %}">Aenderungen</a>
<a href="/releases" class="{% if ctx.active == "/releases" %}active{% endif %}">Releases</a>
@ -31,27 +38,38 @@
<div class="navgroup">
<h4>Wissen</h4>
<a href="/wissen" class="{% if ctx.active == "/wissen" %}active{% endif %}">Wissensdatenbank</a>
<a href="/wissen?kategorie=Anleitung">Artikel</a>
<a href="/wissen?kategorie=FAQ">FAQs</a>
</div>
{% if ctx.operative %}
<div class="navgroup">
<h4>Assets</h4>
<a href="/assets" class="{% if ctx.active == "/assets" %}active{% endif %}">CMDB</a>
<a href="/assets" class="{% if ctx.active == "/assets" %}active{% endif %}">Asset-Liste</a>
<a href="/assets?typ=Server">Konfigurationselemente</a>
<a href="/assets?typ=Software">Software</a>
<a href="/assets?typ=Vertrag">Vertraege</a>
</div>
{% endif %}
{% if ctx.admin %}
<div class="navgroup">
<h4>Administration</h4>
<a href="/admin/users" class="{% if ctx.active == "/admin/users" %}active{% endif %}">Benutzer &amp; Gruppen</a>
<a href="/admin" class="{% if ctx.active == "/admin" %}active{% endif %}">Einstellungen</a>
<a href="/admin/users" class="{% if ctx.active == "/admin/users" %}active{% endif %}">Benutzer</a>
<a href="/admin/audit" class="{% if ctx.active == "/admin/audit" %}active{% endif %}">Audit-Log</a>
</div>
{% endif %}
</div>
<div class="main">
<div class="topbar">
<div class="sz">{{ ctx.tenant_name }}</div>
<div class="sz">
{{ ctx.email }} · {{ ctx.role_label }} ·
<div class="topbar-left"><span class="sz">{{ ctx.tenant_name }}</span></div>
<form class="topsearch" action="/tickets" method="get">
<input name="q" placeholder="Suche (Tickets, Projekte, KB, Services ...)">
</form>
<div class="topbar-right">
<div class="userchip">
<span class="avatar">{{ ctx.initials }}</span>
<span class="userchip-text">{{ ctx.email }}<br><span class="sz">{{ ctx.role_label }}</span></span>
</div>
<form method="post" action="/logout" class="inline-form">
<input type="hidden" name="_csrf" value="{{ ctx.csrf }}">
<button class="linklike" type="submit">Abmelden</button>

26
templates/board.html Executable file
View File

@ -0,0 +1,26 @@
{% extends "base.html" %}
{% block content %}
<div class="page-head">
<h1 class="page-title">Kanban Board</h1>
<div class="page-actions"><a class="btn ghost" href="/tickets">Zur Listenansicht</a></div>
</div>
<div class="board">
{% for col in columns %}
<div class="board-col">
<div class="board-col-head">{{ col.title }} <span class="pill offen">{{ col.count }}</span></div>
{% for c in col.cards %}
<div class="board-card row" data-ticket-id="{{ c.id }}">
<div class="sz ticket-nr">{{ c.nr }}</div>
<div><b>{{ c.titel }}</b></div>
<div class="board-card-foot">
<span class="pill {{ c.prio_class }}">{{ c.prio }}</span>
<span class="pill offen">{{ c.kategorie }}</span>
<span class="avatar sm" title="{{ c.zugewiesen }}">{{ c.initials }}</span>
</div>
</div>
{% endfor %}
{% if col.cards.is_empty() %}<div class="sz" style="padding:8px">Keine Tickets.</div>{% endif %}
</div>
{% endfor %}
</div>
{% endblock %}

View File

@ -1,14 +1,49 @@
{% extends "base.html" %}
{% block content %}
<h1 class="page-title">{{ title }}</h1>
<div class="page-head">
<h1 class="page-title">{{ title }} (Projekte)</h1>
<div class="page-actions">
<button class="btn" id="open-new-ticket">Neues Ticket</button>
<div class="dropdown">
<button class="btn ghost" id="bulk-actions-btn">Aktionen ▾</button>
<div class="dropdown-menu" id="bulk-menu" hidden>
<div class="sz" style="padding:6px 10px">Status fuer Auswahl setzen:</div>
<button class="dropdown-item" data-bulk-status="In Bearbeitung">In Bearbeitung</button>
<button class="dropdown-item" data-bulk-status="Warten">Warten</button>
<button class="dropdown-item" data-bulk-status="Geloest">Geloest</button>
<button class="dropdown-item" data-bulk-status="Geschlossen">Geschlossen</button>
</div>
</div>
<a class="btn ghost" href=""></a>
</div>
</div>
<div class="kpi-row">
{% for kpi in kpis %}
<div class="kpi"><div class="lbl">{{ kpi.0 }}</div><div class="val">{{ kpi.1 }}</div></div>
{% for k in kpis %}
<div class="kpi">
<div class="lbl">{{ k.label }}</div>
<div class="val">{{ k.value }}</div>
<div class="kpi-foot">
<span class="delta {{ k.delta_class }}">{{ k.delta }}</span>
<svg class="spark" viewBox="0 0 70 20" preserveAspectRatio="none"><polyline points="{{ k.spark }}"/></svg>
</div>
</div>
{% endfor %}
</div>
<div class="panel">
<h3 style="margin-top:0">Neues Ticket</h3>
<div class="toolbar">
<div class="tabs">
{% for t in tabs %}<a href="{{ t.href }}" class="{% if t.active %}active{% endif %}">{{ t.label }}</a>{% endfor %}
</div>
<form class="toolbar-search" method="get" action="">
<input type="text" name="q" placeholder="Tickets suchen..." value="{{ q }}">
<button class="btn ghost" type="submit">Filter</button>
</form>
</div>
<div id="new-ticket-modal" class="modal" hidden>
<div class="modal-box">
<div class="modal-head"><h3>Neues Ticket</h3><button class="linklike" id="close-new-ticket"></button></div>
<form method="post" action="/tickets/new">
<input type="hidden" name="_csrf" value="{{ ctx.csrf }}">
<div class="formrow"><input type="text" name="titel" placeholder="Titel" required></div>
@ -19,7 +54,7 @@
{% for c in categories %}<option value="{{ c }}">{{ c }}</option>{% endfor %}
</select>
</div>
<div class="formrow" id="change-typ-row" hidden><label>Change-Typ (Freigabe: Standard = vorautorisiert, Normal = CAB, Emergency = nachtraeglich)</label>
<div class="formrow" id="change-typ-row" hidden><label>Change-Typ</label>
<select name="change_typ">
{% for t in change_types %}<option value="{{ t }}">{{ t }}</option>{% endfor %}
</select>
@ -46,31 +81,38 @@
</div>
<button class="btn" type="submit">Anlegen</button>
</form>
</div>
<div class="tabs">
{% for t in tabs %}<a href="{{ t.href }}" class="{% if t.active %}active{% endif %}">{{ t.label }}</a>{% endfor %}
</div>
</div>
<table class="tickets">
<thead><tr>
<th>Ticket-Nr</th><th>Titel (Service)</th><th>Status</th><th>Prioritaet</th>
<th class="chk"><input type="checkbox" id="check-all"></th>
<th>Ticket-Nr.</th><th>Titel (Projekt)</th><th>Status</th><th>Prioritaet</th>
<th>Kategorie</th><th>Zugewiesen an</th><th>Aktualisiert</th><th>Fortschritt</th>
</tr></thead>
<tbody>
{% for r in rows %}
<tr class="row" data-ticket-id="{{ r.id }}">
<td>{{ r.nr }}</td>
<td class="chk"><input type="checkbox" class="row-check" value="{{ r.id }}"></td>
<td><span class="ticket-nr">{{ r.nr }}</span></td>
<td><b>{{ r.titel }}</b><div class="sz">{{ r.service }}</div></td>
<td><span class="pill {{ r.status_class }}">{{ r.status_label }}</span></td>
<td><span class="pill {{ r.prio_class }}">{{ r.prio }}</span></td>
<td>{{ r.kategorie }}{% if !r.badge.is_empty() %}<div class="sz">{{ r.badge }}</div>{% endif %}</td>
<td class="sz">{{ r.zugewiesen }}</td>
<td><span class="avatar sm">{{ r.initials }}</span> <span class="sz">{{ r.zugewiesen }}</span></td>
<td class="sz">{{ r.updated }}</td>
<td><div class="bar-track"><div class="bar-fill" style="width:{{ r.fortschritt }}%"></div></div> {{ r.fortschritt }}%</td>
</tr>
{% endfor %}
{% if rows.is_empty() %}<tr><td colspan="8" class="sz">Keine Tickets.</td></tr>{% endif %}
{% if rows.is_empty() %}<tr><td colspan="9" class="sz">Keine Tickets.</td></tr>{% endif %}
</tbody>
</table>
<div class="table-foot">
<span class="sz">{{ pagination.label }}</span>
<div class="pager">
{% for p in pagination.pages %}
<a href="{{ p.href }}" class="{% if p.active %}active{% endif %}">{{ p.num }}</a>
{% endfor %}
</div>
</div>
{% endblock %}