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:
parent
7f9d2b431d
commit
9b8d603e74
|
|
@ -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))
|
||||
|
|
|
|||
287
src/tickets.rs
287
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<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
|
||||
|
|
|
|||
11
src/web.rs
11
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-<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());
|
||||
|
|
|
|||
294
static/app.js
294
static/app.js
|
|
@ -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>";
|
||||
}
|
||||
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 (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>';
|
||||
}
|
||||
|
||||
if (t.operative) {
|
||||
h += '<div class="sect">Configuration Items (CMDB)</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_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>";
|
||||
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">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>';
|
||||
});
|
||||
h += '</select> <button class="btn ghost" id="dt-ci-add">Verknuepfen</button></div>';
|
||||
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 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 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 mini" id="repo-save" hidden>Speichern (committen)</button>';
|
||||
h += '</div>';
|
||||
}
|
||||
} else if (tab === "akt") {
|
||||
h += '<div class="sect">Zeitleiste</div>';
|
||||
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>';
|
||||
} 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>';
|
||||
}
|
||||
|
||||
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 += '<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 += '<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 += '<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>';
|
||||
|
||||
h += '<div class="sect">Zeitleiste</div>';
|
||||
var tl = (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>';
|
||||
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."; });
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
@ -1,76 +1,118 @@
|
|||
{% 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>
|
||||
<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>
|
||||
<div class="formrow"><textarea name="beschreibung" placeholder="Beschreibung"></textarea></div>
|
||||
<div class="grid-2">
|
||||
<div class="formrow"><label>Kategorie</label>
|
||||
<select name="kategorie" id="new-kategorie">
|
||||
{% 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>
|
||||
<select name="change_typ">
|
||||
{% for t in change_types %}<option value="{{ t }}">{{ t }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div class="formrow"><label>Impact (Auswirkung)</label>
|
||||
<select name="impact">
|
||||
{% for l in impact_levels %}<option value="{{ l }}" {% if l == "Mittel" %}selected{% endif %}>{{ l }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="formrow"><label>Urgency (Dringlichkeit)</label>
|
||||
<select name="urgency">
|
||||
{% for l in impact_levels %}<option value="{{ l }}" {% if l == "Mittel" %}selected{% endif %}>{{ l }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hint">Die Prioritaet ergibt sich automatisch aus der ITIL-Matrix Impact x Urgency.</div>
|
||||
<div class="formrow">
|
||||
<select name="service_id">
|
||||
<option value="">(kein Service)</option>
|
||||
{% for s in services %}<option value="{{ s.0 }}">{{ s.1 }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn" type="submit">Anlegen</button>
|
||||
<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 class="tabs">
|
||||
{% for t in tabs %}<a href="{{ t.href }}" class="{% if t.active %}active{% endif %}">{{ t.label }}</a>{% endfor %}
|
||||
<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>
|
||||
<div class="formrow"><textarea name="beschreibung" placeholder="Beschreibung"></textarea></div>
|
||||
<div class="grid-2">
|
||||
<div class="formrow"><label>Kategorie</label>
|
||||
<select name="kategorie" id="new-kategorie">
|
||||
{% for c in categories %}<option value="{{ c }}">{{ c }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div class="formrow"><label>Impact (Auswirkung)</label>
|
||||
<select name="impact">
|
||||
{% for l in impact_levels %}<option value="{{ l }}" {% if l == "Mittel" %}selected{% endif %}>{{ l }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="formrow"><label>Urgency (Dringlichkeit)</label>
|
||||
<select name="urgency">
|
||||
{% for l in impact_levels %}<option value="{{ l }}" {% if l == "Mittel" %}selected{% endif %}>{{ l }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hint">Die Prioritaet ergibt sich automatisch aus der ITIL-Matrix Impact x Urgency.</div>
|
||||
<div class="formrow">
|
||||
<select name="service_id">
|
||||
<option value="">(kein Service)</option>
|
||||
{% for s in services %}<option value="{{ s.0 }}">{{ s.1 }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn" type="submit">Anlegen</button>
|
||||
</form>
|
||||
</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 %}
|
||||
|
|
|
|||
Loading…
Reference in New Issue