feat(plattform): Konzernplattform phase-001 (Module HR + CRM) + AES-Suite-Erweiterung committet

Konzernplattform (ADR-009, AES-Projekt projects/konzernplattform):
- Rollen hr_manager + crm_agent; PageCtx-Flags; Nav-Gruppen HR/CRM;
  Untertitel 'Konzernplattform'
- Modul HR (nur admin + hr_manager, DSGVO/BDSG-sensibel): Mitarbeiterakte
  (Stammdaten, Status Aktiv/Onboarding/Ausgeschieden), Abwesenheiten
  (Urlaub/Krankheit/Sonderurlaub/Fortbildung) mit Genehmigungsworkflow
- Modul CRM (admin + crm_agent): Firmen (Lead/Kunde/Partner/Inaktiv),
  Kontakte, Deals mit Pipeline-Stufen + Pipeline-Board, KPI-Kacheln
  (Pipeline-Wert, Gewonnen gesamt)
- Schema-Migration 2026-07-15b (hr_employees, hr_absences, crm_companies,
  crm_contacts, crm_deals; idempotent), alles tenant-gescoped + auditiert

Ausserdem: AES-Suite-Erweiterung (/erweiterungen/aes, Rolle aes_user,
src/extensions.rs) aus paralleler Session lag unkommittet im Arbeitsstand
und ist hier mit aufgenommen, damit das Clone-basierte Deployment sie nicht
verliert. Reparatur: schema.sql-Tail war erneut durch Sync-Abriss
verstuemmelt (Duplikat-Block entfernt).

Tests: cargo test 8/8, E2E-Smoke HR/CRM 19/19 (inkl. RBAC: agent ohne
HR/CRM-Zugriff, hr_manager ohne CRM/Admin-Zugriff).
This commit is contained in:
Moe 2026-07-28 13:06:50 +02:00
parent 97facdce18
commit fc6bc3028d
24 changed files with 3586 additions and 2 deletions

View File

@ -0,0 +1,30 @@
# ADR-009: Konzernplattform als Modul-Monolith
**Datum:** 2026-07-15 · **Status:** Akzeptiert · **Vorgabe:** "Wir machen aus dem ITSM eine Konzernplattform inkl. HR, CRM, usw."
## Kontext
Das ITSM soll zur Unternehmensplattform mit Fachmodulen (HR, CRM, weitere)
werden. Es existiert bereits ein Erweiterungs-Muster (AES-Suite unter
/erweiterungen mit eigener Rolle aes_user). Alternativen: separate Services
je Domaene mit SSO vs. Module im bestehenden Binary.
## Entscheidung
Modul-Monolith: Fachmodule sind Rust-Module im ITSM-Binary und teilen sich
Mandantentrennung, Auth/Sessions, CSRF, Audit-Log, Retention, UI-Shell und
Deployment. Je Modul: eigenes src/<modul>.rs, eigene Tabellen mit Praefix
(hr_*, crm_*), eigene Rolle(n) fuer den Zugriff (hr_manager, crm_agent),
eigene Nav-Gruppe. Der Kern (web.rs/db.rs/security.rs) bleibt modulagnostisch.
HR-Daten sind besonders schuetzenswert: Zugriff ausschliesslich admin +
hr_manager — operative ITSM-Rollen (change_manager, agent) sehen HR NICHT.
CRM: admin + crm_agent.
## Konsequenzen
+ Ein Deployment, eine DB, konsistente Sicherheit/Audit ueber alle Module;
schnellste Iterationsgeschwindigkeit bei aktueller Teamgroesse (1).
+ Modul-Verknuepfungen (Kunde<->Ticket, Onboarding->Service Request) sind
einfache Joins statt Service-APIs.
Ein-Rollen-Modell wird mit wachsender Modulzahl eng — Abloesung durch
Modul-Grants ist als Plattform-phase-004 eingeplant.
Bei stark divergenter Last oder Team-Skalierung waere ein Service-Split
ein neuer ADR; die Praefix-Trennung der Tabellen haelt diesen Weg offen.

View File

@ -212,3 +212,85 @@ CREATE TABLE IF NOT EXISTS sessions (
); );
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions (user_id); CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions (user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions (expires_at); CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions (expires_at);
-- ═══════════════════════════════════════════════════════════════════════════════
-- Migration 2026-07-15b: Konzernplattform phase-001 — Module HR + CRM (ADR-009)
-- HR-Daten: besonders schuetzenswert; Zugriff nur admin + hr_manager (App-RBAC).
-- ═══════════════════════════════════════════════════════════════════════════════
-- ── Modul HR ──────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS hr_employees (
id SERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
personalnr TEXT,
vorname TEXT NOT NULL,
nachname TEXT NOT NULL,
email TEXT,
abteilung TEXT,
position TEXT,
eintritt DATE,
austritt DATE,
status TEXT NOT NULL DEFAULT 'Aktiv', -- Aktiv | Onboarding | Ausgeschieden
notizen TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_hr_emp_tenant_status ON hr_employees (tenant_id, status);
CREATE TABLE IF NOT EXISTS hr_absences (
id SERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
employee_id INTEGER NOT NULL REFERENCES hr_employees(id) ON DELETE CASCADE,
typ TEXT NOT NULL DEFAULT 'Urlaub', -- Urlaub | Krankheit | Sonderurlaub | Fortbildung
von DATE NOT NULL,
bis DATE NOT NULL,
status TEXT NOT NULL DEFAULT 'Beantragt', -- Beantragt | Genehmigt | Abgelehnt
kommentar TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_hr_abs_tenant ON hr_absences (tenant_id, status);
CREATE INDEX IF NOT EXISTS idx_hr_abs_emp ON hr_absences (employee_id);
-- ── Modul CRM ─────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS crm_companies (
id SERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name TEXT NOT NULL,
branche TEXT,
website TEXT,
telefon TEXT,
adresse TEXT,
status TEXT NOT NULL DEFAULT 'Lead', -- Lead | Kunde | Partner | Inaktiv
notizen TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_crm_comp_tenant_status ON crm_companies (tenant_id, status);
CREATE TABLE IF NOT EXISTS crm_contacts (
id SERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
company_id INTEGER NOT NULL REFERENCES crm_companies(id) ON DELETE CASCADE,
vorname TEXT,
nachname TEXT NOT NULL,
email TEXT,
telefon TEXT,
position TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_crm_cont_company ON crm_contacts (company_id);
CREATE TABLE IF NOT EXISTS crm_deals (
id SERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
company_id INTEGER NOT NULL REFERENCES crm_companies(id) ON DELETE CASCADE,
titel TEXT NOT NULL,
wert_cent BIGINT NOT NULL DEFAULT 0,
stufe TEXT NOT NULL DEFAULT 'Lead', -- Lead | Qualifiziert | Angebot | Verhandlung | Gewonnen | Verloren
faellig DATE,
notizen TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_crm_deals_tenant_stufe ON crm_deals (tenant_id, stufe);
CREATE INDEX IF NOT EXISTS idx_crm_deals_company ON crm_deals (company_id);

View File

@ -14,6 +14,9 @@
//! RETENTION_INTERVAL_SECONDS Intervall Retention-Bereinigung (Default 24h) //! RETENTION_INTERVAL_SECONDS Intervall Retention-Bereinigung (Default 24h)
//! FORGE_BASE_URL Basis-URL des Forge-Git-Servers (optional) //! FORGE_BASE_URL Basis-URL des Forge-Git-Servers (optional)
//! FORGE_SERVICE_TOKEN API-Token fuer Forge-Contents-Zugriffe (optional) //! FORGE_SERVICE_TOKEN API-Token fuer Forge-Contents-Zugriffe (optional)
//! ITSM_AES_URL Basis-URL der AES-Suite (Modul /ext/aes), z.B.
//! http://host.docker.internal:8440 (leer = Modul aus)
//! ITSM_AES_TOKEN Bearer-Token der AES-Suite (== deren AES_TOKEN)
//! //!
//! Hinweis: anders als die fruehere Flask-Version braucht der Rust-Server //! Hinweis: anders als die fruehere Flask-Version braucht der Rust-Server
//! kein ITSM_SECRET_KEY mehr -- Sessions liegen serverseitig in PostgreSQL //! kein ITSM_SECRET_KEY mehr -- Sessions liegen serverseitig in PostgreSQL
@ -29,6 +32,17 @@ pub struct Config {
pub retention_interval_seconds: u64, pub retention_interval_seconds: u64,
pub forge_base_url: String, pub forge_base_url: String,
pub forge_service_token: String, pub forge_service_token: String,
/// AES-Suite-Erweiterung (Modul unter /ext/aes): Basis-URL + Bearer-Token.
pub aes_ext_url: String,
pub aes_ext_token: String,
/// AES-Dashboard: zusaetzliche Aggregationsquellen (LLM-Balancer, Forge).
pub aes_balancer_url: String,
pub aes_balancer_token: String,
pub aes_forge_url: String,
pub aes_forge_token: String,
pub aes_forge_owner: String,
/// Token zum Absichern der externen Metrics-Endpunkte (/aes/metrics*).
pub aes_metrics_token: String,
pub login_max_attempts: i64, pub login_max_attempts: i64,
pub login_window_minutes: i64, pub login_window_minutes: i64,
pub password_min_length: usize, pub password_min_length: usize,
@ -38,6 +52,11 @@ fn env(name: &str) -> String {
std::env::var(name).unwrap_or_default() std::env::var(name).unwrap_or_default()
} }
fn env_or(name: &str, default: &str) -> String {
let v = std::env::var(name).unwrap_or_default();
if v.is_empty() { default.to_string() } else { v }
}
fn env_num<T: std::str::FromStr>(name: &str, default: T) -> T { fn env_num<T: std::str::FromStr>(name: &str, default: T) -> T {
std::env::var(name).ok().and_then(|v| v.parse().ok()).unwrap_or(default) std::env::var(name).ok().and_then(|v| v.parse().ok()).unwrap_or(default)
} }
@ -54,6 +73,14 @@ impl Config {
retention_interval_seconds: env_num("RETENTION_INTERVAL_SECONDS", 24 * 60 * 60u64), retention_interval_seconds: env_num("RETENTION_INTERVAL_SECONDS", 24 * 60 * 60u64),
forge_base_url: env("FORGE_BASE_URL").trim_end_matches('/').to_string(), forge_base_url: env("FORGE_BASE_URL").trim_end_matches('/').to_string(),
forge_service_token: env("FORGE_SERVICE_TOKEN"), forge_service_token: env("FORGE_SERVICE_TOKEN"),
aes_ext_url: env("ITSM_AES_URL").trim_end_matches('/').to_string(),
aes_ext_token: env("ITSM_AES_TOKEN"),
aes_balancer_url: env_or("ITSM_AES_BALANCER_URL", "http://host.docker.internal:8442").trim_end_matches('/').to_string(),
aes_balancer_token: env("ITSM_AES_BALANCER_TOKEN"),
aes_forge_url: env_or("ITSM_AES_FORGE_URL", "http://host.docker.internal:8441").trim_end_matches('/').to_string(),
aes_forge_token: env("ITSM_AES_FORGE_TOKEN"),
aes_forge_owner: env_or("ITSM_AES_FORGE_OWNER", "forge"),
aes_metrics_token: env("ITSM_AES_METRICS_TOKEN"),
login_max_attempts: env_num("ITSM_LOGIN_MAX_ATTEMPTS", 5i64), login_max_attempts: env_num("ITSM_LOGIN_MAX_ATTEMPTS", 5i64),
login_window_minutes: env_num("ITSM_LOGIN_WINDOW_MINUTES", 15i64), login_window_minutes: env_num("ITSM_LOGIN_WINDOW_MINUTES", 15i64),
password_min_length: env_num("ITSM_PASSWORD_MIN_LENGTH", 12usize), password_min_length: env_num("ITSM_PASSWORD_MIN_LENGTH", 12usize),

346
src/crm.rs Executable file
View File

@ -0,0 +1,346 @@
//! Modul CRM (Konzernplattform phase-001, ADR-009).
//! Firmen, Kontakte, Deals mit Pipeline. Zugriff: admin + crm_agent.
use askama::Template;
use axum::extract::{Extension, Path, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Redirect};
use axum::Form;
use chrono::NaiveDate;
use serde::Deserialize;
use crate::web::{need_auth, need_crm, page_ctx, AppState, PageCtx, ReqCtx, WebResult};
pub const COMPANY_STATUS: [&str; 4] = ["Lead", "Kunde", "Partner", "Inaktiv"];
pub const DEAL_STAGES: [&str; 6] = ["Lead", "Qualifiziert", "Angebot", "Verhandlung", "Gewonnen", "Verloren"];
fn opt(s: &str) -> Option<&str> {
let t = s.trim();
if t.is_empty() { None } else { Some(t) }
}
fn status_class(s: &str) -> &'static str {
match s {
"Kunde" | "Gewonnen" => "geloest",
"Lead" | "Qualifiziert" => "offen",
"Angebot" | "Verhandlung" | "Partner" => "inbearbeitung",
"Inaktiv" | "Verloren" => "geschlossen",
_ => "offen",
}
}
fn eur(cent: i64) -> String {
format!("{},{:02}", cent / 100, (cent % 100).abs())
}
// ── Firmenliste ────────────────────────────────────────────────────────────────
pub struct CompanyRow {
pub id: i32,
pub name: String,
pub branche: String,
pub telefon: String,
pub status: String,
pub status_class: String,
}
#[derive(Template)]
#[template(path = "crm_list.html")]
pub struct CrmListTemplate {
pub title: String,
pub ctx: PageCtx,
pub kpis: Vec<(String, String)>,
pub tabs: Vec<(String, String, bool)>,
pub rows: Vec<CompanyRow>,
pub status_opts: Vec<String>,
}
#[derive(Deserialize)]
pub struct CrmQuery {
pub status: Option<String>,
}
pub async fn crm_list(State(app): State<AppState>, Extension(ctx): Extension<ReqCtx>,
Query(q): Query<CrmQuery>) -> WebResult {
let auth = match need_auth(&ctx, "/crm") { Ok(a) => a, Err(r) => return Ok(r) };
if let Err(r) = need_crm(&auth) { return Ok(r); }
let filter = q.status.as_deref().filter(|s| COMPANY_STATUS.contains(s));
let all = app.db.crm_list_companies(auth.tenant_id, None).await?;
let deals = app.db.crm_list_deals(auth.tenant_id, None).await?;
let open_stages = ["Lead", "Qualifiziert", "Angebot", "Verhandlung"];
let pipeline_cent: i64 = deals.iter().filter(|d| open_stages.contains(&d.stufe.as_str()))
.map(|d| d.wert_cent).sum();
let won_cent: i64 = deals.iter().filter(|d| d.stufe == "Gewonnen").map(|d| d.wert_cent).sum();
let kpis = vec![
("Firmen".into(), all.len().to_string()),
("Kunden".into(), all.iter().filter(|c| c.status == "Kunde").count().to_string()),
("Offene Deals".into(), deals.iter().filter(|d| open_stages.contains(&d.stufe.as_str())).count().to_string()),
("Pipeline-Wert".into(), eur(pipeline_cent)),
("Gewonnen (gesamt)".into(), eur(won_cent)),
];
let mut tabs = vec![("/crm".to_string(), "Alle".to_string(), filter.is_none())];
tabs.extend(COMPANY_STATUS.iter().map(|s| {
(format!("/crm?status={}", s), s.to_string(), filter == Some(s))
}));
let rows = all.iter()
.filter(|c| filter.map(|f| c.status == f).unwrap_or(true))
.map(|c| CompanyRow {
id: c.id,
name: c.name.clone(),
branche: c.branche.clone().unwrap_or_default(),
telefon: c.telefon.clone().unwrap_or_default(),
status: c.status.clone(),
status_class: status_class(&c.status).into(),
}).collect();
Ok(CrmListTemplate {
title: "CRM — Firmen".into(),
ctx: page_ctx(&auth, "/crm"),
kpis, tabs, rows,
status_opts: COMPANY_STATUS.iter().map(|s| s.to_string()).collect(),
}.into_response())
}
// ── Firma anlegen / Detail ─────────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct CompanyForm {
pub name: String,
#[serde(default)] pub branche: String,
#[serde(default)] pub website: String,
#[serde(default)] pub telefon: String,
#[serde(default)] pub adresse: String,
#[serde(default)] pub status: String,
#[serde(default)] pub notizen: String,
}
pub async fn crm_new(State(app): State<AppState>, Extension(ctx): Extension<ReqCtx>,
Form(f): Form<CompanyForm>) -> WebResult {
let auth = match need_auth(&ctx, "/crm") { Ok(a) => a, Err(r) => return Ok(r) };
if let Err(r) = need_crm(&auth) { return Ok(r); }
let name = f.name.trim();
if name.is_empty() {
return Ok(Redirect::to("/crm").into_response());
}
let status = if COMPANY_STATUS.contains(&f.status.as_str()) { f.status.as_str() } else { "Lead" };
let id = app.db.crm_create_company(auth.tenant_id, name, opt(&f.branche), opt(&f.website),
opt(&f.telefon), opt(&f.adresse), status, opt(&f.notizen)).await?;
app.db.log_audit(Some(auth.tenant_id), Some(auth.user_id), "crm_company_created", Some("crm_company"),
Some(&id.to_string()), Some(serde_json::json!({"name": name})),
ctx.ip.as_deref()).await?;
Ok(Redirect::to(&format!("/crm/{}", id)).into_response())
}
pub struct ContactRow {
pub name: String,
pub email: String,
pub telefon: String,
pub position: String,
}
pub struct DealRow {
pub id: i32,
pub titel: String,
pub company: String,
pub wert: String,
pub stufe: String,
pub stufe_class: String,
pub faellig: String,
}
#[derive(Template)]
#[template(path = "crm_detail.html")]
pub struct CrmDetailTemplate {
pub title: String,
pub ctx: PageCtx,
pub id: i32,
pub name: String,
pub branche: String,
pub website: String,
pub telefon: String,
pub adresse: String,
pub status: String,
pub notizen: String,
pub status_opts: Vec<(String, bool)>,
pub contacts: Vec<ContactRow>,
pub deals: Vec<DealRow>,
pub stage_opts: Vec<String>,
}
pub async fn crm_detail(State(app): State<AppState>, Extension(ctx): Extension<ReqCtx>,
Path(id): Path<i32>) -> WebResult {
let auth = match need_auth(&ctx, "/crm") { Ok(a) => a, Err(r) => return Ok(r) };
if let Err(r) = need_crm(&auth) { return Ok(r); }
let Some(c) = app.db.crm_get_company(auth.tenant_id, id).await? else {
return Ok(StatusCode::NOT_FOUND.into_response());
};
let contacts = app.db.crm_list_contacts(auth.tenant_id, id).await?;
let deals = app.db.crm_list_deals(auth.tenant_id, Some(id)).await?;
Ok(CrmDetailTemplate {
title: c.name.clone(),
ctx: page_ctx(&auth, "/crm"),
id: c.id,
name: c.name.clone(),
branche: c.branche.clone().unwrap_or_default(),
website: c.website.clone().unwrap_or_default(),
telefon: c.telefon.clone().unwrap_or_default(),
adresse: c.adresse.clone().unwrap_or_default(),
status: c.status.clone(),
notizen: c.notizen.clone().unwrap_or_default(),
status_opts: COMPANY_STATUS.iter().map(|s| (s.to_string(), *s == c.status)).collect(),
contacts: contacts.iter().map(|k| ContactRow {
name: format!("{} {}", k.vorname.clone().unwrap_or_default(), k.nachname).trim().to_string(),
email: k.email.clone().unwrap_or_default(),
telefon: k.telefon.clone().unwrap_or_default(),
position: k.position.clone().unwrap_or_default(),
}).collect(),
deals: deals.iter().map(|d| DealRow {
id: d.id,
titel: d.titel.clone(),
company: d.company_name.clone(),
wert: eur(d.wert_cent),
stufe: d.stufe.clone(),
stufe_class: status_class(&d.stufe).into(),
faellig: d.faellig.map(|x| x.format("%d.%m.%Y").to_string()).unwrap_or_default(),
}).collect(),
stage_opts: DEAL_STAGES.iter().map(|s| s.to_string()).collect(),
}.into_response())
}
pub async fn crm_update(State(app): State<AppState>, Extension(ctx): Extension<ReqCtx>,
Path(id): Path<i32>, Form(f): Form<CompanyForm>) -> WebResult {
let auth = match need_auth(&ctx, "/crm") { Ok(a) => a, Err(r) => return Ok(r) };
if let Err(r) = need_crm(&auth) { return Ok(r); }
if app.db.crm_get_company(auth.tenant_id, id).await?.is_none() {
return Ok(StatusCode::NOT_FOUND.into_response());
}
let status = if COMPANY_STATUS.contains(&f.status.as_str()) { f.status.as_str() } else { "Lead" };
app.db.crm_update_company(auth.tenant_id, id, f.name.trim(), opt(&f.branche), opt(&f.website),
opt(&f.telefon), opt(&f.adresse), status, opt(&f.notizen)).await?;
app.db.log_audit(Some(auth.tenant_id), Some(auth.user_id), "crm_company_updated", Some("crm_company"),
Some(&id.to_string()), None, ctx.ip.as_deref()).await?;
Ok(Redirect::to(&format!("/crm/{}", id)).into_response())
}
// ── Kontakte + Deals ───────────────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct ContactForm {
#[serde(default)] pub vorname: String,
pub nachname: String,
#[serde(default)] pub email: String,
#[serde(default)] pub telefon: String,
#[serde(default)] pub position: String,
}
pub async fn crm_contact_new(State(app): State<AppState>, Extension(ctx): Extension<ReqCtx>,
Path(id): Path<i32>, Form(f): Form<ContactForm>) -> WebResult {
let auth = match need_auth(&ctx, "/crm") { Ok(a) => a, Err(r) => return Ok(r) };
if let Err(r) = need_crm(&auth) { return Ok(r); }
if app.db.crm_get_company(auth.tenant_id, id).await?.is_none() {
return Ok(StatusCode::NOT_FOUND.into_response());
}
if !f.nachname.trim().is_empty() {
let kid = app.db.crm_create_contact(auth.tenant_id, id, opt(&f.vorname), f.nachname.trim(),
opt(&f.email), opt(&f.telefon), opt(&f.position)).await?;
app.db.log_audit(Some(auth.tenant_id), Some(auth.user_id), "crm_contact_created", Some("crm_contact"),
Some(&kid.to_string()), None, ctx.ip.as_deref()).await?;
}
Ok(Redirect::to(&format!("/crm/{}", id)).into_response())
}
#[derive(Deserialize)]
pub struct DealForm {
pub titel: String,
#[serde(default)] pub wert_eur: String,
#[serde(default)] pub stufe: String,
#[serde(default)] pub faellig: String,
}
pub async fn crm_deal_new(State(app): State<AppState>, Extension(ctx): Extension<ReqCtx>,
Path(id): Path<i32>, Form(f): Form<DealForm>) -> WebResult {
let auth = match need_auth(&ctx, "/crm") { Ok(a) => a, Err(r) => return Ok(r) };
if let Err(r) = need_crm(&auth) { return Ok(r); }
if app.db.crm_get_company(auth.tenant_id, id).await?.is_none() {
return Ok(StatusCode::NOT_FOUND.into_response());
}
let titel = f.titel.trim();
if titel.is_empty() {
return Ok(Redirect::to(&format!("/crm/{}", id)).into_response());
}
// "1234,56" oder "1234.56" oder "1234" -> Cent
let cleaned = f.wert_eur.trim().replace('.', "").replace(',', ".");
let wert_cent = (cleaned.parse::<f64>().unwrap_or(0.0) * 100.0).round() as i64;
let stufe = if DEAL_STAGES.contains(&f.stufe.as_str()) { f.stufe.as_str() } else { "Lead" };
let faellig = NaiveDate::parse_from_str(f.faellig.trim(), "%Y-%m-%d").ok();
let did = app.db.crm_create_deal(auth.tenant_id, id, titel, wert_cent.max(0), stufe, faellig).await?;
app.db.log_audit(Some(auth.tenant_id), Some(auth.user_id), "crm_deal_created", Some("crm_deal"),
Some(&did.to_string()), Some(serde_json::json!({"titel": titel, "wert_cent": wert_cent})),
ctx.ip.as_deref()).await?;
Ok(Redirect::to(&format!("/crm/{}", id)).into_response())
}
#[derive(Deserialize)]
pub struct DealStageForm {
pub stufe: String,
#[serde(default)] pub zurueck: String,
}
pub async fn crm_deal_stage(State(app): State<AppState>, Extension(ctx): Extension<ReqCtx>,
Path(did): Path<i32>, Form(f): Form<DealStageForm>) -> WebResult {
let auth = match need_auth(&ctx, "/crm") { Ok(a) => a, Err(r) => return Ok(r) };
if let Err(r) = need_crm(&auth) { return Ok(r); }
if !DEAL_STAGES.contains(&f.stufe.as_str()) {
return Ok(Redirect::to("/crm/pipeline").into_response());
}
app.db.crm_set_deal_stage(auth.tenant_id, did, &f.stufe).await?;
app.db.log_audit(Some(auth.tenant_id), Some(auth.user_id), "crm_deal_stage_changed", Some("crm_deal"),
Some(&did.to_string()), Some(serde_json::json!({"stufe": f.stufe})),
ctx.ip.as_deref()).await?;
let back = if f.zurueck.starts_with('/') && !f.zurueck.starts_with("//") { f.zurueck } else { "/crm/pipeline".into() };
Ok(Redirect::to(&back).into_response())
}
// ── Pipeline-Board ─────────────────────────────────────────────────────────────
pub struct PipelineColumn {
pub title: String,
pub count: usize,
pub summe: String,
pub deals: Vec<DealRow>,
}
#[derive(Template)]
#[template(path = "crm_pipeline.html")]
pub struct PipelineTemplate {
pub title: String,
pub ctx: PageCtx,
pub columns: Vec<PipelineColumn>,
pub stage_opts: Vec<String>,
}
pub async fn crm_pipeline(State(app): State<AppState>, Extension(ctx): Extension<ReqCtx>) -> WebResult {
let auth = match need_auth(&ctx, "/crm/pipeline") { Ok(a) => a, Err(r) => return Ok(r) };
if let Err(r) = need_crm(&auth) { return Ok(r); }
let deals = app.db.crm_list_deals(auth.tenant_id, None).await?;
let columns = DEAL_STAGES.iter().map(|stage| {
let ds: Vec<&crate::db::Deal> = deals.iter().filter(|d| d.stufe == *stage).collect();
PipelineColumn {
title: stage.to_string(),
count: ds.len(),
summe: eur(ds.iter().map(|d| d.wert_cent).sum()),
deals: ds.iter().map(|d| DealRow {
id: d.id,
titel: d.titel.clone(),
company: d.company_name.clone(),
wert: eur(d.wert_cent),
stufe: d.stufe.clone(),
stufe_class: status_class(&d.stufe).into(),
faellig: d.faellig.map(|x| x.format("%d.%m.%Y").to_string()).unwrap_or_default(),
}).collect(),
}
}).collect();
Ok(PipelineTemplate {
title: "CRM — Pipeline".into(),
ctx: page_ctx(&auth, "/crm/pipeline"),
columns,
stage_opts: DEAL_STAGES.iter().map(|s| s.to_string()).collect(),
}.into_response())
}

296
src/db.rs
View File

@ -908,3 +908,299 @@ fn ci_from_row(r: &tokio_postgres::Row) -> Ci {
attribute: r.get("attribute"), attribute: r.get("attribute"),
} }
} }
// ═════════════════════════════════════════════════════════════════════════════
// Konzernplattform phase-001 (ADR-009): Module HR + CRM
// HR-Daten sind besonders schuetzenswert — Aufrufer MUESSEN need_hr() pruefen.
// ═════════════════════════════════════════════════════════════════════════════
use chrono::NaiveDate;
#[derive(Debug, Clone)]
pub struct Employee {
pub id: i32,
pub personalnr: Option<String>,
pub vorname: String,
pub nachname: String,
pub email: Option<String>,
pub abteilung: Option<String>,
pub position: Option<String>,
pub eintritt: Option<NaiveDate>,
pub austritt: Option<NaiveDate>,
pub status: String,
pub notizen: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Absence {
pub id: i32,
pub employee_id: i32,
pub employee_name: String,
pub typ: String,
pub von: NaiveDate,
pub bis: NaiveDate,
pub status: String,
pub kommentar: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Company {
pub id: i32,
pub name: String,
pub branche: Option<String>,
pub website: Option<String>,
pub telefon: Option<String>,
pub adresse: Option<String>,
pub status: String,
pub notizen: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Contact {
pub id: i32,
pub vorname: Option<String>,
pub nachname: String,
pub email: Option<String>,
pub telefon: Option<String>,
pub position: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Deal {
pub id: i32,
pub company_id: i32,
pub company_name: String,
pub titel: String,
pub wert_cent: i64,
pub stufe: String,
pub faellig: Option<NaiveDate>,
}
fn employee_from_row(r: &tokio_postgres::Row) -> Employee {
Employee {
id: r.get("id"),
personalnr: r.get("personalnr"),
vorname: r.get("vorname"),
nachname: r.get("nachname"),
email: r.get("email"),
abteilung: r.get("abteilung"),
position: r.get("position"),
eintritt: r.get("eintritt"),
austritt: r.get("austritt"),
status: r.get("status"),
notizen: r.get("notizen"),
}
}
impl Db {
// ── HR ─────────────────────────────────────────────────────────────────────
#[allow(clippy::too_many_arguments)]
pub async fn hr_create_employee(&self, tenant_id: i32, personalnr: Option<&str>, vorname: &str,
nachname: &str, email: Option<&str>, abteilung: Option<&str>,
position: Option<&str>, eintritt: Option<NaiveDate>,
status: &str, notizen: Option<&str>) -> DbResult<i32> {
let c = self.conn().await?;
let row = c.query_one(
"INSERT INTO hr_employees (tenant_id, personalnr, vorname, nachname, email, abteilung,
position, eintritt, status, notizen) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING id",
&[&tenant_id, &personalnr, &vorname, &nachname, &email, &abteilung,
&position, &eintritt, &status, &notizen]).await?;
Ok(row.get(0))
}
pub async fn hr_list_employees(&self, tenant_id: i32, status: Option<&str>) -> DbResult<Vec<Employee>> {
let c = self.conn().await?;
let rows = match status {
Some(s) => c.query("SELECT * FROM hr_employees WHERE tenant_id=$1 AND status=$2 ORDER BY nachname, vorname",
&[&tenant_id, &s]).await?,
None => c.query("SELECT * FROM hr_employees WHERE tenant_id=$1 ORDER BY nachname, vorname",
&[&tenant_id]).await?,
};
Ok(rows.iter().map(employee_from_row).collect())
}
pub async fn hr_get_employee(&self, tenant_id: i32, id: i32) -> DbResult<Option<Employee>> {
let c = self.conn().await?;
Ok(c.query_opt("SELECT * FROM hr_employees WHERE tenant_id=$1 AND id=$2", &[&tenant_id, &id])
.await?.map(|r| employee_from_row(&r)))
}
#[allow(clippy::too_many_arguments)]
pub async fn hr_update_employee(&self, tenant_id: i32, id: i32, personalnr: Option<&str>,
vorname: &str, nachname: &str, email: Option<&str>,
abteilung: Option<&str>, position: Option<&str>,
eintritt: Option<NaiveDate>, austritt: Option<NaiveDate>,
status: &str, notizen: Option<&str>) -> DbResult<()> {
let c = self.conn().await?;
c.execute(
"UPDATE hr_employees SET personalnr=$1, vorname=$2, nachname=$3, email=$4, abteilung=$5,
position=$6, eintritt=$7, austritt=$8, status=$9, notizen=$10, updated_at=now()
WHERE tenant_id=$11 AND id=$12",
&[&personalnr, &vorname, &nachname, &email, &abteilung, &position,
&eintritt, &austritt, &status, &notizen, &tenant_id, &id]).await?;
Ok(())
}
pub async fn hr_create_absence(&self, tenant_id: i32, employee_id: i32, typ: &str,
von: NaiveDate, bis: NaiveDate, kommentar: Option<&str>) -> DbResult<i32> {
let c = self.conn().await?;
let row = c.query_one(
"INSERT INTO hr_absences (tenant_id, employee_id, typ, von, bis, kommentar)
VALUES ($1,$2,$3,$4,$5,$6) RETURNING id",
&[&tenant_id, &employee_id, &typ, &von, &bis, &kommentar]).await?;
Ok(row.get(0))
}
pub async fn hr_list_absences(&self, tenant_id: i32, employee_id: Option<i32>) -> DbResult<Vec<Absence>> {
let c = self.conn().await?;
let sql = "SELECT a.id, a.employee_id, a.typ, a.von, a.bis, a.status, a.kommentar,
(e.vorname || ' ' || e.nachname) AS employee_name
FROM hr_absences a JOIN hr_employees e ON e.id = a.employee_id
WHERE a.tenant_id=$1";
let rows = match employee_id {
Some(eid) => c.query(&format!("{} AND a.employee_id=$2 ORDER BY a.von DESC", sql),
&[&tenant_id, &eid]).await?,
None => c.query(&format!("{} ORDER BY a.von DESC LIMIT 200", sql), &[&tenant_id]).await?,
};
Ok(rows.iter().map(|r| Absence {
id: r.get("id"),
employee_id: r.get("employee_id"),
employee_name: r.get("employee_name"),
typ: r.get("typ"),
von: r.get("von"),
bis: r.get("bis"),
status: r.get("status"),
kommentar: r.get("kommentar"),
}).collect())
}
pub async fn hr_set_absence_status(&self, tenant_id: i32, absence_id: i32, status: &str) -> DbResult<()> {
let c = self.conn().await?;
c.execute("UPDATE hr_absences SET status=$1 WHERE tenant_id=$2 AND id=$3",
&[&status, &tenant_id, &absence_id]).await?;
Ok(())
}
// ── CRM ────────────────────────────────────────────────────────────────────
#[allow(clippy::too_many_arguments)]
pub async fn crm_create_company(&self, tenant_id: i32, name: &str, branche: Option<&str>,
website: Option<&str>, telefon: Option<&str>, adresse: Option<&str>,
status: &str, notizen: Option<&str>) -> DbResult<i32> {
let c = self.conn().await?;
let row = c.query_one(
"INSERT INTO crm_companies (tenant_id, name, branche, website, telefon, adresse, status, notizen)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id",
&[&tenant_id, &name, &branche, &website, &telefon, &adresse, &status, &notizen]).await?;
Ok(row.get(0))
}
pub async fn crm_list_companies(&self, tenant_id: i32, status: Option<&str>) -> DbResult<Vec<Company>> {
let c = self.conn().await?;
let rows = match status {
Some(s) => c.query("SELECT * FROM crm_companies WHERE tenant_id=$1 AND status=$2 ORDER BY name",
&[&tenant_id, &s]).await?,
None => c.query("SELECT * FROM crm_companies WHERE tenant_id=$1 ORDER BY name", &[&tenant_id]).await?,
};
Ok(rows.iter().map(|r| Company {
id: r.get("id"),
name: r.get("name"),
branche: r.get("branche"),
website: r.get("website"),
telefon: r.get("telefon"),
adresse: r.get("adresse"),
status: r.get("status"),
notizen: r.get("notizen"),
}).collect())
}
pub async fn crm_get_company(&self, tenant_id: i32, id: i32) -> DbResult<Option<Company>> {
let c = self.conn().await?;
Ok(c.query_opt("SELECT * FROM crm_companies WHERE tenant_id=$1 AND id=$2", &[&tenant_id, &id])
.await?.map(|r| Company {
id: r.get("id"),
name: r.get("name"),
branche: r.get("branche"),
website: r.get("website"),
telefon: r.get("telefon"),
adresse: r.get("adresse"),
status: r.get("status"),
notizen: r.get("notizen"),
}))
}
#[allow(clippy::too_many_arguments)]
pub async fn crm_update_company(&self, tenant_id: i32, id: i32, name: &str, branche: Option<&str>,
website: Option<&str>, telefon: Option<&str>, adresse: Option<&str>,
status: &str, notizen: Option<&str>) -> DbResult<()> {
let c = self.conn().await?;
c.execute(
"UPDATE crm_companies SET name=$1, branche=$2, website=$3, telefon=$4, adresse=$5,
status=$6, notizen=$7, updated_at=now() WHERE tenant_id=$8 AND id=$9",
&[&name, &branche, &website, &telefon, &adresse, &status, &notizen, &tenant_id, &id]).await?;
Ok(())
}
pub async fn crm_create_contact(&self, tenant_id: i32, company_id: i32, vorname: Option<&str>,
nachname: &str, email: Option<&str>, telefon: Option<&str>,
position: Option<&str>) -> DbResult<i32> {
let c = self.conn().await?;
let row = c.query_one(
"INSERT INTO crm_contacts (tenant_id, company_id, vorname, nachname, email, telefon, position)
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id",
&[&tenant_id, &company_id, &vorname, &nachname, &email, &telefon, &position]).await?;
Ok(row.get(0))
}
pub async fn crm_list_contacts(&self, tenant_id: i32, company_id: i32) -> DbResult<Vec<Contact>> {
let c = self.conn().await?;
let rows = c.query(
"SELECT * FROM crm_contacts WHERE tenant_id=$1 AND company_id=$2 ORDER BY nachname",
&[&tenant_id, &company_id]).await?;
Ok(rows.iter().map(|r| Contact {
id: r.get("id"),
vorname: r.get("vorname"),
nachname: r.get("nachname"),
email: r.get("email"),
telefon: r.get("telefon"),
position: r.get("position"),
}).collect())
}
pub async fn crm_create_deal(&self, tenant_id: i32, company_id: i32, titel: &str,
wert_cent: i64, stufe: &str, faellig: Option<NaiveDate>) -> DbResult<i32> {
let c = self.conn().await?;
let row = c.query_one(
"INSERT INTO crm_deals (tenant_id, company_id, titel, wert_cent, stufe, faellig)
VALUES ($1,$2,$3,$4,$5,$6) RETURNING id",
&[&tenant_id, &company_id, &titel, &wert_cent, &stufe, &faellig]).await?;
Ok(row.get(0))
}
pub async fn crm_list_deals(&self, tenant_id: i32, company_id: Option<i32>) -> DbResult<Vec<Deal>> {
let c = self.conn().await?;
let sql = "SELECT d.id, d.company_id, d.titel, d.wert_cent, d.stufe, d.faellig,
c.name AS company_name FROM crm_deals d
JOIN crm_companies c ON c.id = d.company_id WHERE d.tenant_id=$1";
let rows = match company_id {
Some(cid) => c.query(&format!("{} AND d.company_id=$2 ORDER BY d.updated_at DESC", sql),
&[&tenant_id, &cid]).await?,
None => c.query(&format!("{} ORDER BY d.updated_at DESC", sql), &[&tenant_id]).await?,
};
Ok(rows.iter().map(|r| Deal {
id: r.get("id"),
company_id: r.get("company_id"),
company_name: r.get("company_name"),
titel: r.get("titel"),
wert_cent: r.get("wert_cent"),
stufe: r.get("stufe"),
faellig: r.get("faellig"),
}).collect())
}
pub async fn crm_set_deal_stage(&self, tenant_id: i32, deal_id: i32, stufe: &str) -> DbResult<()> {
let c = self.conn().await?;
c.execute("UPDATE crm_deals SET stufe=$1, updated_at=now() WHERE tenant_id=$2 AND id=$3",
&[&stufe, &tenant_id, &deal_id]).await?;
Ok(())
}
}

1431
src/extensions.rs Executable file

File diff suppressed because it is too large Load Diff

292
src/hr.rs Executable file
View File

@ -0,0 +1,292 @@
//! Modul HR (Konzernplattform phase-001, ADR-009).
//! Mitarbeiterakte + Abwesenheiten mit Genehmigungsworkflow.
//! Zugriff AUSSCHLIESSLICH admin + hr_manager (besonders schuetzenswerte
//! Daten, DSGVO/BDSG §26) — jede Route prueft need_hr().
use askama::Template;
use axum::extract::{Extension, Path, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Redirect};
use axum::Form;
use chrono::NaiveDate;
use serde::Deserialize;
use crate::web::{need_auth, need_hr, page_ctx, AppState, PageCtx, ReqCtx, WebResult};
pub const HR_STATUS: [&str; 3] = ["Aktiv", "Onboarding", "Ausgeschieden"];
pub const ABSENCE_TYPES: [&str; 4] = ["Urlaub", "Krankheit", "Sonderurlaub", "Fortbildung"];
fn parse_date(s: &str) -> Option<NaiveDate> {
NaiveDate::parse_from_str(s.trim(), "%Y-%m-%d").ok()
}
fn opt(s: &str) -> Option<&str> {
let t = s.trim();
if t.is_empty() { None } else { Some(t) }
}
fn status_class(s: &str) -> &'static str {
match s {
"Aktiv" | "Genehmigt" => "geloest",
"Onboarding" | "Beantragt" => "inbearbeitung",
"Ausgeschieden" | "Abgelehnt" => "geschlossen",
_ => "offen",
}
}
// ── Liste ──────────────────────────────────────────────────────────────────────
pub struct EmployeeRow {
pub id: i32,
pub name: String,
pub initials: String,
pub personalnr: String,
pub abteilung: String,
pub position: String,
pub status: String,
pub status_class: String,
}
pub struct AbsenceRow {
pub id: i32,
pub employee: String,
pub typ: String,
pub zeitraum: String,
pub status: String,
pub status_class: String,
pub kommentar: String,
pub pending: bool,
}
#[derive(Template)]
#[template(path = "hr_list.html")]
pub struct HrListTemplate {
pub title: String,
pub ctx: PageCtx,
pub kpis: Vec<(String, String)>,
pub tabs: Vec<(String, String, bool)>, // href, label, active
pub rows: Vec<EmployeeRow>,
pub absences: Vec<AbsenceRow>,
}
#[derive(Deserialize)]
pub struct HrQuery {
pub status: Option<String>,
}
fn emp_initials(v: &str, n: &str) -> String {
format!("{}{}",
v.chars().next().map(|c| c.to_uppercase().to_string()).unwrap_or_default(),
n.chars().next().map(|c| c.to_uppercase().to_string()).unwrap_or_default())
}
pub async fn hr_list(State(app): State<AppState>, Extension(ctx): Extension<ReqCtx>,
Query(q): Query<HrQuery>) -> WebResult {
let auth = match need_auth(&ctx, "/hr") { Ok(a) => a, Err(r) => return Ok(r) };
if let Err(r) = need_hr(&auth) { return Ok(r); }
let filter = q.status.as_deref().filter(|s| HR_STATUS.contains(s));
let all = app.db.hr_list_employees(auth.tenant_id, None).await?;
let absences = app.db.hr_list_absences(auth.tenant_id, None).await?;
let count = |s: &str| all.iter().filter(|e| e.status == s).count();
let pending = absences.iter().filter(|a| a.status == "Beantragt").count();
let kpis = vec![
("Mitarbeiter gesamt".into(), all.len().to_string()),
("Aktiv".into(), count("Aktiv").to_string()),
("Onboarding".into(), count("Onboarding").to_string()),
("Offene Antraege".into(), pending.to_string()),
];
let mut tabs = vec![("/hr".to_string(), "Alle".to_string(), filter.is_none())];
tabs.extend(HR_STATUS.iter().map(|s| {
(format!("/hr?status={}", s), s.to_string(), filter == Some(s))
}));
let rows = all.iter()
.filter(|e| filter.map(|f| e.status == f).unwrap_or(true))
.map(|e| EmployeeRow {
id: e.id,
name: format!("{} {}", e.vorname, e.nachname),
initials: emp_initials(&e.vorname, &e.nachname),
personalnr: e.personalnr.clone().unwrap_or_default(),
abteilung: e.abteilung.clone().unwrap_or_default(),
position: e.position.clone().unwrap_or_default(),
status: e.status.clone(),
status_class: status_class(&e.status).into(),
}).collect();
let absences = absences.iter().take(30).map(|a| AbsenceRow {
id: a.id,
employee: a.employee_name.clone(),
typ: a.typ.clone(),
zeitraum: format!("{} {}", a.von.format("%d.%m.%Y"), a.bis.format("%d.%m.%Y")),
status: a.status.clone(),
status_class: status_class(&a.status).into(),
kommentar: a.kommentar.clone().unwrap_or_default(),
pending: a.status == "Beantragt",
}).collect();
Ok(HrListTemplate {
title: "HR — Mitarbeiter".into(),
ctx: page_ctx(&auth, "/hr"),
kpis, tabs, rows, absences,
}.into_response())
}
// ── Anlegen / Bearbeiten ───────────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct EmployeeForm {
#[serde(default)] pub personalnr: String,
pub vorname: String,
pub nachname: String,
#[serde(default)] pub email: String,
#[serde(default)] pub abteilung: String,
#[serde(default)] pub position: String,
#[serde(default)] pub eintritt: String,
#[serde(default)] pub austritt: String,
#[serde(default)] pub status: String,
#[serde(default)] pub notizen: String,
}
pub async fn hr_new(State(app): State<AppState>, Extension(ctx): Extension<ReqCtx>,
Form(f): Form<EmployeeForm>) -> WebResult {
let auth = match need_auth(&ctx, "/hr") { Ok(a) => a, Err(r) => return Ok(r) };
if let Err(r) = need_hr(&auth) { return Ok(r); }
let (vorname, nachname) = (f.vorname.trim(), f.nachname.trim());
if vorname.is_empty() || nachname.is_empty() {
return Ok(Redirect::to("/hr").into_response());
}
let status = if HR_STATUS.contains(&f.status.as_str()) { f.status.as_str() } else { "Onboarding" };
let id = app.db.hr_create_employee(
auth.tenant_id, opt(&f.personalnr), vorname, nachname, opt(&f.email),
opt(&f.abteilung), opt(&f.position), parse_date(&f.eintritt), status, opt(&f.notizen)).await?;
app.db.log_audit(Some(auth.tenant_id), Some(auth.user_id), "hr_employee_created", Some("hr_employee"),
Some(&id.to_string()), Some(serde_json::json!({"name": format!("{} {}", vorname, nachname)})),
ctx.ip.as_deref()).await?;
Ok(Redirect::to(&format!("/hr/{}", id)).into_response())
}
#[derive(Template)]
#[template(path = "hr_detail.html")]
pub struct HrDetailTemplate {
pub title: String,
pub ctx: PageCtx,
pub id: i32,
pub personalnr: String,
pub vorname: String,
pub nachname: String,
pub email: String,
pub abteilung: String,
pub position: String,
pub eintritt: String,
pub austritt: String,
pub status: String,
pub notizen: String,
pub status_opts: Vec<(String, bool)>,
pub absence_types: Vec<String>,
pub absences: Vec<AbsenceRow>,
}
pub async fn hr_detail(State(app): State<AppState>, Extension(ctx): Extension<ReqCtx>,
Path(id): Path<i32>) -> WebResult {
let auth = match need_auth(&ctx, "/hr") { Ok(a) => a, Err(r) => return Ok(r) };
if let Err(r) = need_hr(&auth) { return Ok(r); }
let Some(e) = app.db.hr_get_employee(auth.tenant_id, id).await? else {
return Ok(StatusCode::NOT_FOUND.into_response());
};
let absences = app.db.hr_list_absences(auth.tenant_id, Some(id)).await?;
let fmt = |d: Option<NaiveDate>| d.map(|d| d.format("%Y-%m-%d").to_string()).unwrap_or_default();
Ok(HrDetailTemplate {
title: format!("{} {}", e.vorname, e.nachname),
ctx: page_ctx(&auth, "/hr"),
id: e.id,
personalnr: e.personalnr.clone().unwrap_or_default(),
vorname: e.vorname.clone(),
nachname: e.nachname.clone(),
email: e.email.clone().unwrap_or_default(),
abteilung: e.abteilung.clone().unwrap_or_default(),
position: e.position.clone().unwrap_or_default(),
eintritt: fmt(e.eintritt),
austritt: fmt(e.austritt),
status: e.status.clone(),
notizen: e.notizen.clone().unwrap_or_default(),
status_opts: HR_STATUS.iter().map(|s| (s.to_string(), *s == e.status)).collect(),
absence_types: ABSENCE_TYPES.iter().map(|s| s.to_string()).collect(),
absences: absences.iter().map(|a| AbsenceRow {
id: a.id,
employee: a.employee_name.clone(),
typ: a.typ.clone(),
zeitraum: format!("{} {}", a.von.format("%d.%m.%Y"), a.bis.format("%d.%m.%Y")),
status: a.status.clone(),
status_class: status_class(&a.status).into(),
kommentar: a.kommentar.clone().unwrap_or_default(),
pending: a.status == "Beantragt",
}).collect(),
}.into_response())
}
pub async fn hr_update(State(app): State<AppState>, Extension(ctx): Extension<ReqCtx>,
Path(id): Path<i32>, Form(f): Form<EmployeeForm>) -> WebResult {
let auth = match need_auth(&ctx, "/hr") { Ok(a) => a, Err(r) => return Ok(r) };
if let Err(r) = need_hr(&auth) { return Ok(r); }
if app.db.hr_get_employee(auth.tenant_id, id).await?.is_none() {
return Ok(StatusCode::NOT_FOUND.into_response());
}
let status = if HR_STATUS.contains(&f.status.as_str()) { f.status.as_str() } else { "Aktiv" };
app.db.hr_update_employee(
auth.tenant_id, id, opt(&f.personalnr), f.vorname.trim(), f.nachname.trim(),
opt(&f.email), opt(&f.abteilung), opt(&f.position),
parse_date(&f.eintritt), parse_date(&f.austritt), status, opt(&f.notizen)).await?;
app.db.log_audit(Some(auth.tenant_id), Some(auth.user_id), "hr_employee_updated", Some("hr_employee"),
Some(&id.to_string()), None, ctx.ip.as_deref()).await?;
Ok(Redirect::to(&format!("/hr/{}", id)).into_response())
}
// ── Abwesenheiten ──────────────────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct AbsenceForm {
#[serde(default)] pub typ: String,
pub von: String,
pub bis: String,
#[serde(default)] pub kommentar: String,
}
pub async fn hr_absence_new(State(app): State<AppState>, Extension(ctx): Extension<ReqCtx>,
Path(id): Path<i32>, Form(f): Form<AbsenceForm>) -> WebResult {
let auth = match need_auth(&ctx, "/hr") { Ok(a) => a, Err(r) => return Ok(r) };
if let Err(r) = need_hr(&auth) { return Ok(r); }
if app.db.hr_get_employee(auth.tenant_id, id).await?.is_none() {
return Ok(StatusCode::NOT_FOUND.into_response());
}
let (Some(von), Some(bis)) = (parse_date(&f.von), parse_date(&f.bis)) else {
return Ok(Redirect::to(&format!("/hr/{}", id)).into_response());
};
if bis < von {
return Ok(Redirect::to(&format!("/hr/{}", id)).into_response());
}
let typ = if ABSENCE_TYPES.contains(&f.typ.as_str()) { f.typ.as_str() } else { "Urlaub" };
let aid = app.db.hr_create_absence(auth.tenant_id, id, typ, von, bis, opt(&f.kommentar)).await?;
app.db.log_audit(Some(auth.tenant_id), Some(auth.user_id), "hr_absence_created", Some("hr_absence"),
Some(&aid.to_string()), Some(serde_json::json!({"employee": id, "typ": typ})),
ctx.ip.as_deref()).await?;
Ok(Redirect::to(&format!("/hr/{}", id)).into_response())
}
#[derive(Deserialize)]
pub struct AbsenceStatusForm {
pub status: String,
#[serde(default)] pub zurueck: String,
}
pub async fn hr_absence_status(State(app): State<AppState>, Extension(ctx): Extension<ReqCtx>,
Path(aid): Path<i32>, Form(f): Form<AbsenceStatusForm>) -> WebResult {
let auth = match need_auth(&ctx, "/hr") { Ok(a) => a, Err(r) => return Ok(r) };
if let Err(r) = need_hr(&auth) { return Ok(r); }
let status = match f.status.as_str() {
"Genehmigt" => "Genehmigt",
"Abgelehnt" => "Abgelehnt",
_ => return Ok(Redirect::to("/hr").into_response()),
};
app.db.hr_set_absence_status(auth.tenant_id, aid, status).await?;
app.db.log_audit(Some(auth.tenant_id), Some(auth.user_id), "hr_absence_status_changed", Some("hr_absence"),
Some(&aid.to_string()), Some(serde_json::json!({"status": status})),
ctx.ip.as_deref()).await?;
let back = if f.zurueck.starts_with('/') && !f.zurueck.starts_with("//") { f.zurueck } else { "/hr".into() };
Ok(Redirect::to(&back).into_response())
}

View File

@ -14,7 +14,13 @@
/// - change_manager genehmigt Changes (CAB), darf Repo-Aenderungen aus Changes /// - change_manager genehmigt Changes (CAB), darf Repo-Aenderungen aus Changes
/// - agent Service Desk: Tickets, KB-Entwuerfe, CMDB /// - agent Service Desk: Tickets, KB-Entwuerfe, CMDB
/// - user Requester/Self-Service: eigene Tickets, freigegebene KB /// - user Requester/Self-Service: eigene Tickets, freigegebene KB
pub const ROLES: [&str; 4] = ["admin", "change_manager", "agent", "user"]; /// - aes_user AES-Suite-Erweiterung: darf die AES-Suite unter
/// "Erweiterungen" nutzen (keine operativen ITSM-Rechte)
/// - hr_manager Modul HR: Mitarbeiterakten + Abwesenheiten (besonders
/// schuetzenswerte Daten — KEIN Zugriff fuer operative
/// ITSM-Rollen, siehe ADR-009)
/// - crm_agent Modul CRM: Firmen, Kontakte, Deals
pub const ROLES: [&str; 7] = ["admin", "change_manager", "agent", "user", "aes_user", "hr_manager", "crm_agent"];
pub fn role_label(role: &str) -> &'static str { pub fn role_label(role: &str) -> &'static str {
match role { match role {
@ -22,6 +28,9 @@ pub fn role_label(role: &str) -> &'static str {
"change_manager" => "Change Manager", "change_manager" => "Change Manager",
"agent" => "Service-Desk-Agent", "agent" => "Service-Desk-Agent",
"user" => "Anwender", "user" => "Anwender",
"aes_user" => "AES-User",
"hr_manager" => "HR-Manager",
"crm_agent" => "CRM-Agent",
_ => "Unbekannt", _ => "Unbekannt",
} }
} }
@ -36,6 +45,23 @@ pub fn is_change_approver(role: &str) -> bool {
matches!(role, "admin" | "change_manager") matches!(role, "admin" | "change_manager")
} }
/// Zugriff auf die AES-Suite-Erweiterung ("Erweiterungen" -> AES-Suite).
/// Bewusst getrennt von `is_operative`: die AES-Rolle ist spezialisiert und
/// erhaelt dadurch keine operativen ITSM-Rechte (Tickets/CMDB/KB-Entwuerfe).
/// Modul HR (ADR-009): nur admin + hr_manager.
pub fn is_hr(role: &str) -> bool {
matches!(role, "admin" | "hr_manager")
}
/// Modul CRM (ADR-009): admin + crm_agent.
pub fn is_crm(role: &str) -> bool {
matches!(role, "admin" | "crm_agent")
}
pub fn is_aes_user(role: &str) -> bool {
matches!(role, "admin" | "aes_user")
}
/// Kategorien (v4-Practices: Incident, Service Request, Problem, Change, ...). /// Kategorien (v4-Practices: Incident, Service Request, Problem, Change, ...).
pub const CATEGORIES: [&str; 6] = ["Incident", "Service Request", "Problem", "Task", "Change", "Release"]; pub const CATEGORIES: [&str; 6] = ["Incident", "Service Request", "Problem", "Task", "Change", "Release"];
/// Kategorien, die die Rolle 'user' im Self-Service anlegen darf. /// Kategorien, die die Rolle 'user' im Self-Service anlegen darf.

View File

@ -17,8 +17,11 @@ mod admin;
mod auth; mod auth;
mod cmdb; mod cmdb;
mod config; mod config;
mod crm;
mod dashboard; mod dashboard;
mod hr;
mod db; mod db;
mod extensions;
mod forge; mod forge;
mod itil; mod itil;
mod kb; mod kb;
@ -64,10 +67,16 @@ async fn main() -> anyhow::Result<()> {
tracing::info!("FORGE_BASE_URL nicht gesetzt -- Repo-Bearbeitung aus Tickets deaktiviert."); tracing::info!("FORGE_BASE_URL nicht gesetzt -- Repo-Bearbeitung aus Tickets deaktiviert.");
} }
let aes = Arc::new(extensions::AesClient::from_config(&cfg));
if aes.is_none() {
tracing::info!("ITSM_AES_URL nicht gesetzt -- AES-Suite-Erweiterung deaktiviert.");
}
let state = AppState { let state = AppState {
cfg: cfg.clone(), cfg: cfg.clone(),
db: db.clone(), db: db.clone(),
forge, forge,
aes,
http: reqwest::Client::builder() http: reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5)) .timeout(std::time::Duration::from_secs(5))
.build()?, .build()?,
@ -114,6 +123,20 @@ async fn main() -> anyhow::Result<()> {
.route("/tickets/:id/repo-edit", post(tickets::ticket_repo_edit)) .route("/tickets/:id/repo-edit", post(tickets::ticket_repo_edit))
.route("/api/tickets/:id", get(tickets::api_ticket)) .route("/api/tickets/:id", get(tickets::api_ticket))
.route("/api/tickets/:id/repo-file", get(tickets::api_repo_file)) .route("/api/tickets/:id/repo-file", get(tickets::api_repo_file))
// Modul HR (Konzernplattform phase-001, nur admin + hr_manager)
.route("/hr", get(hr::hr_list))
.route("/hr/neu", post(hr::hr_new))
.route("/hr/:id", get(hr::hr_detail).post(hr::hr_update))
.route("/hr/:id/abwesenheiten", post(hr::hr_absence_new))
.route("/hr/abwesenheiten/:id/status", post(hr::hr_absence_status))
// Modul CRM (Konzernplattform phase-001, nur admin + crm_agent)
.route("/crm", get(crm::crm_list))
.route("/crm/neu", post(crm::crm_new))
.route("/crm/pipeline", get(crm::crm_pipeline))
.route("/crm/:id", get(crm::crm_detail).post(crm::crm_update))
.route("/crm/:id/kontakte", post(crm::crm_contact_new))
.route("/crm/:id/deals", post(crm::crm_deal_new))
.route("/crm/deals/:id/stufe", post(crm::crm_deal_stage))
// Service-Katalog // Service-Katalog
.route("/services", get(services::services_page)) .route("/services", get(services::services_page))
.route("/services/new", post(services::service_new)) .route("/services/new", post(services::service_new))
@ -130,6 +153,32 @@ async fn main() -> anyhow::Result<()> {
.route("/assets/:id", get(cmdb::ci_detail_get).post(cmdb::ci_detail_post)) .route("/assets/:id", get(cmdb::ci_detail_get).post(cmdb::ci_detail_post))
.route("/assets/:id/relationships", post(cmdb::ci_rel_add)) .route("/assets/:id/relationships", post(cmdb::ci_rel_add))
.route("/assets/:id/relationships/:rel_id/delete", post(cmdb::ci_rel_delete)) .route("/assets/:id/relationships/:rel_id/delete", post(cmdb::ci_rel_delete))
// Erweiterungen -> AES-Suite Dashboard (admin + Rolle aes_user)
.route("/erweiterungen/aes", get(extensions::aes_dashboard))
.route("/erweiterungen/aes/projekte", get(extensions::aes_projekte).post(extensions::aes_project_create))
.route("/erweiterungen/aes/projekte/:name", get(extensions::aes_project_detail))
.route("/erweiterungen/aes/projekte/:name/phasen/:phase/run", post(extensions::aes_phase_run))
.route("/erweiterungen/aes/projekte/:name/phasen/:phase/validate", post(extensions::aes_phase_validate))
.route("/erweiterungen/aes/forge", get(extensions::aes_forge).post(extensions::aes_forge_create))
.route("/erweiterungen/aes/forge/:name", get(extensions::aes_forge_repo))
.route("/erweiterungen/aes/forge/:name/delete", post(extensions::aes_forge_delete))
.route("/erweiterungen/aes/forge/:name/pulls", post(extensions::aes_forge_pull_create))
.route("/erweiterungen/aes/forge/:name/pulls/:number/merge", post(extensions::aes_forge_pull_merge))
.route("/erweiterungen/aes/runner", get(extensions::aes_runner))
.route("/erweiterungen/aes/llm", get(extensions::aes_llm))
.route("/erweiterungen/aes/llm/infer", post(extensions::aes_llm_infer))
.route("/erweiterungen/aes/llm/pull", post(extensions::aes_llm_pull))
.route("/erweiterungen/aes/llm/delete", post(extensions::aes_llm_delete))
.route("/erweiterungen/aes/llm/connections", post(extensions::aes_llm_conn_save))
.route("/erweiterungen/aes/agenten", get(extensions::aes_agenten))
.route("/erweiterungen/aes/monitoring", get(extensions::aes_monitoring))
.route("/erweiterungen/aes/monitor.json", get(extensions::aes_monitor_json))
.route("/erweiterungen/aes/admin", get(extensions::aes_admin))
.route("/erweiterungen/aes/einstellungen", get(extensions::aes_einstellungen))
.route("/erweiterungen/aes/einstellungen/save", post(extensions::aes_einstellungen_save))
// Externe Metrics-Schnittstelle (Token-geschuetzt, kein Session-Login)
.route("/aes/metrics", get(extensions::aes_metrics))
.route("/aes/metrics.json", get(extensions::aes_metrics_json))
// Administration // Administration
.route("/admin", get(admin::settings_get).post(admin::settings_post)) .route("/admin", get(admin::settings_get).post(admin::settings_post))
.route("/admin/retention/run", post(admin::retention_run)) .route("/admin/retention/run", post(admin::retention_run))

View File

@ -17,6 +17,7 @@ use tokio::sync::Mutex;
use crate::config::Config; use crate::config::Config;
use crate::db::{AuthUser, Db}; use crate::db::{AuthUser, Db};
use crate::extensions::AesClient;
use crate::forge::ForgeClient; use crate::forge::ForgeClient;
use crate::{itil, security}; use crate::{itil, security};
@ -27,6 +28,8 @@ pub struct AppState {
pub cfg: Arc<Config>, pub cfg: Arc<Config>,
pub db: Db, pub db: Db,
pub forge: Arc<Option<ForgeClient>>, pub forge: Arc<Option<ForgeClient>>,
/// AES-Suite-Erweiterung (Modul /ext/aes); None = nicht konfiguriert.
pub aes: Arc<Option<AesClient>>,
pub http: reqwest::Client, pub http: reqwest::Client,
/// Cache fuer Service-Erreichbarkeit (service_id -> (Zeitpunkt, Ergebnis)). /// Cache fuer Service-Erreichbarkeit (service_id -> (Zeitpunkt, Ergebnis)).
pub svc_status_cache: Arc<Mutex<HashMap<i32, (Instant, Option<bool>)>>>, pub svc_status_cache: Arc<Mutex<HashMap<i32, (Instant, Option<bool>)>>>,
@ -94,6 +97,22 @@ pub fn need_admin(auth: &AuthUser) -> Result<(), Response> {
} }
} }
pub fn need_hr(auth: &AuthUser) -> Result<(), Response> {
if itil::is_hr(&auth.role) {
Ok(())
} else {
Err(forbidden("das HR-Modul ist der Rolle HR-Manager vorbehalten."))
}
}
pub fn need_crm(auth: &AuthUser) -> Result<(), Response> {
if itil::is_crm(&auth.role) {
Ok(())
} else {
Err(forbidden("das CRM-Modul ist der Rolle CRM-Agent vorbehalten."))
}
}
pub fn need_change_approver(auth: &AuthUser) -> Result<(), Response> { pub fn need_change_approver(auth: &AuthUser) -> Result<(), Response> {
if itil::is_change_approver(&auth.role) { if itil::is_change_approver(&auth.role) {
Ok(()) Ok(())
@ -102,6 +121,14 @@ pub fn need_change_approver(auth: &AuthUser) -> Result<(), Response> {
} }
} }
pub fn need_aes(auth: &AuthUser) -> Result<(), Response> {
if itil::is_aes_user(&auth.role) {
Ok(())
} else {
Err(forbidden("erforderliche Rolle: AES-User oder Administrator."))
}
}
// ── Seiten-Kontext fuer Templates ───────────────────────────────────────────── // ── Seiten-Kontext fuer Templates ─────────────────────────────────────────────
pub struct PageCtx { pub struct PageCtx {
pub email: String, pub email: String,
@ -111,6 +138,11 @@ pub struct PageCtx {
pub csrf: String, pub csrf: String,
pub operative: bool, pub operative: bool,
pub admin: bool, pub admin: bool,
/// Darf die "Erweiterungen"-Gruppe (AES-Suite) sehen.
pub aes_ext: bool,
/// Modul-Sichtbarkeit (ADR-009 Konzernplattform).
pub hr: bool,
pub crm: bool,
pub active: String, pub active: String,
} }
@ -126,6 +158,9 @@ pub fn page_ctx(auth: &AuthUser, active: &str) -> PageCtx {
csrf: auth.csrf_token.clone(), csrf: auth.csrf_token.clone(),
operative: itil::is_operative(&auth.role), operative: itil::is_operative(&auth.role),
admin: auth.role == "admin", admin: auth.role == "admin",
aes_ext: itil::is_aes_user(&auth.role),
hr: itil::is_hr(&auth.role),
crm: itil::is_crm(&auth.role),
active: active.to_string(), active: active.to_string(),
} }
} }

22
static/aes-ext.js Executable file
View File

@ -0,0 +1,22 @@
/* AES-Erweiterung: CSP-konforme Bestaetigungsdialoge fuer Formulare mit
data-confirm="..." (ersetzt inline onsubmit). Von /static ausgeliefert. */
(function () {
"use strict";
function wire() {
var forms = document.querySelectorAll("form[data-confirm]");
for (var i = 0; i < forms.length; i++) {
(function (f) {
f.addEventListener("submit", function (e) {
if (!window.confirm(f.getAttribute("data-confirm"))) {
e.preventDefault();
}
});
})(forms[i]);
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", wire);
} else {
wire();
}
})();

120
static/aes-monitor.js Executable file
View File

@ -0,0 +1,120 @@
/* AES-Suite Live-Monitoring pollt monitor.json und rendert Dienste +
Backends grafisch. Von /static ausgeliefert (CSP script-src 'self'). */
(function () {
"use strict";
var root = document.getElementById("aes-monitor");
if (!root) return;
var src = root.getAttribute("data-src") || "/erweiterungen/aes/monitor.json";
var INTERVAL = 5000;
function el(tag, cls, text) {
var e = document.createElement(tag);
if (cls) e.className = cls;
if (text != null) e.textContent = text;
return e;
}
function bar(label, pct) {
var wrap = el("div", "sz");
wrap.style.margin = "2px 0";
var v = Math.max(0, Math.min(100, Math.round(pct || 0)));
wrap.appendChild(document.createTextNode(label + " "));
var track = el("span");
track.style.display = "inline-block";
track.style.width = "120px";
track.style.height = "8px";
track.style.borderRadius = "4px";
track.style.background = "var(--border, #333)";
track.style.verticalAlign = "middle";
track.style.margin = "0 6px";
var fill = el("span");
fill.style.display = "block";
fill.style.height = "8px";
fill.style.borderRadius = "4px";
fill.style.width = v + "%";
fill.style.background = v > 85 ? "#d9534f" : (v > 60 ? "#e0a800" : "#3fb950");
track.appendChild(fill);
wrap.appendChild(track);
wrap.appendChild(document.createTextNode(v + "%"));
return wrap;
}
function render(data) {
root.textContent = "";
// Dienst-Ampeln
var svcRow = el("div");
svcRow.style.display = "flex";
svcRow.style.flexWrap = "wrap";
svcRow.style.gap = "10px";
(data.services || []).forEach(function (s) {
var card = el("div", "card");
card.style.flex = "1";
card.style.minWidth = "180px";
var head = el("div");
head.style.display = "flex";
head.style.justifyContent = "space-between";
head.style.alignItems = "center";
head.appendChild(el("b", null, s.name));
head.appendChild(el("span", "badge " + (s.ok ? "ok" : "err"), s.ok ? "up" : "down"));
card.appendChild(head);
card.appendChild(el("div", "sz", s.detail || ""));
svcRow.appendChild(card);
});
root.appendChild(svcRow);
// Backends
var h = el("h3", null, "Backends / Agenten");
h.style.margin = "16px 0 6px";
root.appendChild(h);
var bes = data.backends || [];
if (!bes.length) {
root.appendChild(el("p", "sz", "Keine Backends online."));
} else {
bes.forEach(function (b) {
var card = el("div", "card");
var head = el("div");
head.style.display = "flex";
head.style.justifyContent = "space-between";
head.appendChild(el("b", null, b.url));
head.appendChild(el("span", "badge " + (b.healthy ? "ok" : "err"), b.healthy ? "healthy" : "offline"));
card.appendChild(head);
card.appendChild(bar("CPU", b.cpu));
card.appendChild(bar("RAM", b.ram));
card.appendChild(bar("VRAM", b.vram));
var meta = el("div", "sz");
meta.textContent = "Latenz " + (b.latency || 0) + "ms · Agent " + (b.agent_online ? "online" : "offline") +
" · Modelle: " + ((b.models && b.models.length) ? b.models.join(", ") : "-");
card.appendChild(meta);
root.appendChild(card);
});
}
// Runner + Projekte + Zeitstempel
var foot = el("p", "sz");
foot.style.marginTop = "10px";
var r = data.runner || {};
foot.textContent = "Runner: " + (r.available ? "aktiv" : "offline") +
" · beobachtete Repos: " + ((r.watched && r.watched.length) || 0) +
" · Projekte: " + (data.projects_total || 0) +
" · Stand: " + new Date().toLocaleTimeString();
root.appendChild(foot);
}
function tick() {
fetch(src, { headers: { "Accept": "application/json" }, credentials: "same-origin" })
.then(function (r) { return r.json(); })
.then(render)
.catch(function () {
var p = document.getElementById("aes-monitor-err");
if (!p) {
p = el("p", "err", "Live-Daten momentan nicht abrufbar — naechster Versuch laeuft.");
p.id = "aes-monitor-err";
root.appendChild(p);
}
});
}
tick();
setInterval(tick, INTERVAL);
})();

View File

@ -173,3 +173,6 @@ td.chk input,th.chk input{width:auto}
.sla-chip{display:inline-block;padding:2px 9px;border-radius:6px;font-size:11px;font-weight:600;background:#1a4a35;color:var(--ok)} .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)} .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} .dt-footer{display:flex;gap:8px;margin-top:16px;position:sticky;bottom:0;background:var(--panel);padding-top:10px}
.board.pipeline{grid-template-columns:repeat(6,1fr)}
.board-card-foot select{width:auto;font-size:11px;padding:3px 6px}

278
templates/aes_dashboard.html Executable file
View File

@ -0,0 +1,278 @@
{% extends "base.html" %}
{% block content %}
{% include "aes_nav.html" %}
{% if !configured %}
<div class="panel"><p class="err">{{ error }}</p></div>
{% else %}
{% if !error.is_empty() %}<div class="panel"><p class="err">{{ error }}</p></div>{% endif %}
{% if !note.is_empty() %}<div class="panel"><p class="sz">{{ note }}</p></div>{% endif %}
{% if section == "dashboard" %}
<div style="display:flex;flex-wrap:wrap;gap:10px">
{% for c in health %}
<div class="card" style="flex:1;min-width:200px">
<div class="svc-card"><div><b>{{ c.label }}</b><div class="sz" style="margin-top:4px">{{ c.sub }}</div></div><span class="badge {{ c.class }}">{{ c.value }}</span></div>
</div>
{% endfor %}
</div>
<div style="display:flex;flex-wrap:wrap;gap:10px;margin-top:10px">
{% for k in kpis %}
<div class="card" style="flex:1;min-width:150px">
<div class="sz">{{ k.label }}</div>
<div style="font-size:26px;font-weight:700;margin:2px 0">{{ k.value }}</div>
<div class="sz">{{ k.sub }}</div>
</div>
{% endfor %}
</div>
<h2 class="page-title" style="font-size:18px;margin-top:18px">Projekte</h2>
{% if projects.is_empty() %}<p class="sz">Keine Projekte.</p>{% else %}
<table class="tickets"><thead><tr><th>Projekt</th><th>Letzter Lauf</th></tr></thead><tbody>
{% for p in projects %}<tr><td><a href="/erweiterungen/aes/projekte/{{ p.name }}"><b>{{ p.name }}</b></a></td><td><span class="badge {{ p.status_class }}">{{ p.status }}</span></td></tr>{% endfor %}
</tbody></table>{% endif %}
<div style="display:flex;flex-wrap:wrap;gap:14px;margin-top:18px">
<div style="flex:1;min-width:300px">
<h3 style="margin:0 0 6px">Live Activity</h3>
{% if activity.is_empty() %}<p class="sz">Keine Aktivitaet.</p>{% else %}
<div class="panel">{% for a in activity %}<div class="sz" style="border-bottom:1px solid var(--border);padding:5px 0"><span class="badge {{ a.class }}" style="margin-right:6px">&bull;</span>{{ a.text }}<span style="float:right;opacity:.7">{{ a.when }}</span></div>{% endfor %}</div>{% endif %}
</div>
<div style="flex:1;min-width:300px">
<h3 style="margin:0 0 6px">Infrastruktur</h3>
<p class="sz">{{ bal_summary }}</p>
{% if backends.is_empty() %}<p class="sz">Keine Backends gemeldet.</p>{% endif %}
{% for b in backends %}<div class="card"><div class="svc-card"><div><b>{{ b.url }}</b><div class="sz" style="margin-top:4px">CPU {{ b.cpu }}% &middot; RAM {{ b.ram }}% &middot; VRAM {{ b.vram }}% &middot; {{ b.latency }}ms</div></div><span class="badge {{ b.badge_class }}">{{ b.badge }}</span></div></div>{% endfor %}
</div>
</div>
{% else if section == "projekte" %}
<h1 class="page-title">Projekte</h1>
{% if projects.is_empty() %}<p class="sz">Noch keine Projekte.</p>{% else %}
<table class="tickets"><thead><tr><th>Projekt</th><th></th></tr></thead><tbody>
{% for p in projects %}<tr><td><b>{{ p.name }}</b></td><td><a class="btn ghost mini" href="/erweiterungen/aes/projekte/{{ p.name }}">Oeffnen &rarr;</a></td></tr>{% endfor %}
</tbody></table>{% endif %}
<div class="panel" style="margin-top:14px">
<h3 style="margin-top:0">Projekt anlegen</h3>
<form method="post" action="/erweiterungen/aes/projekte">
<input type="hidden" name="_csrf" value="{{ ctx.csrf }}">
<div class="formrow"><input type="text" name="name" placeholder="Projektname (a-z, 0-9, - , _)" pattern="[A-Za-z0-9_-]+" required></div>
<button class="btn" type="submit">Anlegen</button>
</form>
</div>
{% else if section == "forge" %}
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px">
<h1 class="page-title" style="margin:0">Forge</h1>
<a class="btn ghost mini" href="/erweiterungen/aes/einstellungen?comp=forge" title="Forge-Einstellungen">&#9881; Einstellungen</a>
</div>
<p class="sz">Git-Server der AES-Suite. Zugriff erfolgt serverseitig mit dem AES-Token &mdash; kein eigenes Token noetig.</p>
{% if repos.is_empty() %}<p class="sz">Keine Repositories.</p>{% else %}
<table class="tickets"><thead><tr><th>Repository</th><th>Branch</th><th>Sichtbarkeit</th><th></th></tr></thead><tbody>
{% for r in repos %}<tr>
<td><a href="/erweiterungen/aes/forge/{{ r.label }}"><b>{{ r.label }}</b></a></td>
<td class="sz">{{ r.value }}</td>
<td><span class="badge {{ r.class }}">{{ r.sub }}</span></td>
<td>
<form method="post" action="/erweiterungen/aes/forge/{{ r.label }}/delete" class="inline-form" data-confirm="Repository {{ r.label }} wirklich loeschen?">
<input type="hidden" name="_csrf" value="{{ ctx.csrf }}">
<button class="btn ghost mini" type="submit">Loeschen</button>
</form>
</td>
</tr>{% endfor %}
</tbody></table>{% endif %}
<div class="panel" style="margin-top:14px">
<h3 style="margin-top:0">Repository anlegen</h3>
<form method="post" action="/erweiterungen/aes/forge">
<input type="hidden" name="_csrf" value="{{ ctx.csrf }}">
<div class="formrow"><input type="text" name="name" placeholder="repo-name (a-z, 0-9, - , _)" pattern="[A-Za-z0-9_.-]+" required></div>
<button class="btn" type="submit">Anlegen (privat)</button>
</form>
</div>
{% else if section == "runner" %}
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px">
<h1 class="page-title" style="margin:0">Runner</h1>
<a class="btn ghost mini" href="/erweiterungen/aes/einstellungen?comp=runner" title="Runner-Einstellungen">&#9881; Einstellungen</a>
</div>
{% if !runner_available %}
<div class="panel"><p class="sz">Runner-Status nicht verfuegbar. {{ runner_hint }}</p></div>
{% else %}
<div style="display:flex;flex-wrap:wrap;gap:10px">
<div class="card" style="flex:1;min-width:180px"><div class="sz">Version</div><div style="font-size:20px;font-weight:700">v{{ runner_version }}</div></div>
<div class="card" style="flex:1;min-width:180px"><div class="sz">Poll-Intervall</div><div style="font-size:20px;font-weight:700">{{ runner_poll }}s</div></div>
<div class="card" style="flex:2;min-width:220px"><div class="sz">Zuletzt aktiv</div><div style="font-size:15px;font-weight:600;margin-top:4px">{{ runner_updated }}</div></div>
</div>
<div class="sz" style="margin-top:10px">Forge/Gitea: <b>{{ runner_instance }}</b> &middot; Self-Update-Repo: <b>{{ runner_update_repo }}</b></div>
<h3 style="margin:16px 0 6px">Beobachtete Repositories</h3>
{% if runner_watched.is_empty() %}<p class="sz">Keine.</p>{% else %}
<div style="display:flex;flex-wrap:wrap;gap:6px">{% for w in runner_watched %}<span class="badge ok">{{ w }}</span>{% endfor %}</div>
{% endif %}
<h3 style="margin:16px 0 6px">Letzte CI-Laeufe</h3>
{% if runner_runs.is_empty() %}<p class="sz">Noch keine Laeufe aufgezeichnet (erscheinen beim naechsten Commit auf einem beobachteten Repo).</p>{% else %}
<table class="tickets"><thead><tr><th>Repository</th><th>Branch</th><th>Commit</th><th>Ergebnis</th><th>Beendet</th></tr></thead><tbody>
{% for r in runner_runs %}<tr><td class="sz">{{ r.repo }}</td><td class="sz">{{ r.branch }}</td><td class="sz" style="font-family:monospace">{{ r.sha }}</td><td><span class="badge {{ r.ok_class }}">{{ r.ok_label }}</span></td><td class="sz">{{ r.finished_at }}</td></tr>{% endfor %}
</tbody></table>{% endif %}
{% endif %}
{% else if section == "agenten" %}
<h1 class="page-title">Agenten</h1>
<p class="sz">Agenten sind <code>llm-agent</code>-Instanzen (je LLM-Host, mit lokalem Ollama), die sich beim Balancer anmelden und Inferenz + Host-Metriken liefern.</p>
<p class="sz">{{ bal_summary }}</p>
{% if backends.is_empty() %}
<div class="panel"><p class="sz">Kein Agent online. Starte einen Agenten auf einem GPU/CPU-Host: <code>aes-suite agent --token &lt;AGENT_TOKEN&gt;</code> (Ollama vorausgesetzt) und trage seine URL im Balancer (OLLAMA_HOSTS) ein.</p></div>
{% else %}
<table class="tickets"><thead><tr><th>Host</th><th>Agent</th><th>Backend</th><th>CPU</th><th>RAM</th><th>VRAM</th><th>Latenz</th></tr></thead><tbody>
{% for b in backends %}<tr><td class="sz">{{ b.url }}</td><td><span class="badge {% if b.agent == "online" %}ok{% else %}err{% endif %}">{{ b.agent }}</span></td><td><span class="badge {{ b.badge_class }}">{{ b.badge }}</span></td><td class="sz">{{ b.cpu }}%</td><td class="sz">{{ b.ram }}%</td><td class="sz">{{ b.vram }}%</td><td class="sz">{{ b.latency }}ms</td></tr>{% endfor %}
</tbody></table>{% endif %}
{% else if section == "llm" %}
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px">
<h1 class="page-title" style="margin:0">LLM &amp; Verbindungen</h1>
<a class="btn ghost mini" href="/erweiterungen/aes/einstellungen?comp=llm" title="Balancer-/Cloud-Einstellungen">&#9881; Einstellungen</a>
</div>
{% if !hinweis.is_empty() %}<div class="panel"><p class="sz">{{ hinweis }}</p></div>{% endif %}
{% if !fehler.is_empty() %}<div class="panel"><p class="err">{{ fehler }}</p></div>{% endif %}
<p class="sz">Der Balancer proxyt Inferenz OpenAI-/Ollama-kompatibel auf die Verbindungen; ohne gesundes Ollama-Backend greift der passende Cloud-/OpenAI-Provider.</p>
<div class="panel">
<h3 style="margin-top:0">LLM-Verbindungen (Provider)</h3>
<p class="sz">Ollama-Hosts und OpenAI-kompatible Provider (OpenAI, InferX, Together, Groq, Infercom, …). <b>base_url</b> = OpenAI-Basis (…/v1). API-Key leer lassen = unveraendert. Speichern startet die aes-suite neu.</p>
<p class="sz" style="opacity:.75">Presets base_url: InferX <code>https://model.inferx.net/&lt;endpoint&gt;/v1</code> &middot; Together <code>https://api.together.xyz/v1</code> &middot; Groq <code>https://api.groq.com/openai/v1</code> &middot; OpenAI <code>https://api.openai.com/v1</code></p>
<form method="post" action="/erweiterungen/aes/llm/connections">
<input type="hidden" name="_csrf" value="{{ ctx.csrf }}">
<input type="hidden" name="rows" value="{{ conn_rows }}">
<div style="overflow-x:auto"><table class="tickets"><thead><tr><th>Aktiv</th><th>Name</th><th>Typ</th><th>URL (ollama) / base_url (cloud)</th><th>Agent-URL</th><th>Modell</th><th>API-Key</th></tr></thead><tbody>
{% for c in conns %}
<tr>
<td><input type="checkbox" name="enabled_{{ c.idx }}"{% if c.enabled %} checked{% endif %}></td>
<td><input type="text" name="name_{{ c.idx }}" value="{{ c.name }}" placeholder="Name" style="width:8em"></td>
<td><select name="type_{{ c.idx }}">
<option value="ollama"{% if c.kind == "ollama" %} selected{% endif %}>ollama</option>
<option value="openai"{% if c.kind == "openai" %} selected{% endif %}>openai-kompat.</option>
<option value="anthropic"{% if c.kind == "anthropic" %} selected{% endif %}>anthropic</option>
</select></td>
<td><input type="text" name="url_{{ c.idx }}" value="{{ c.url }}" placeholder="http://host:11434" style="width:12em">&nbsp;<input type="text" name="base_url_{{ c.idx }}" value="{{ c.base_url }}" placeholder="https://…/v1" style="width:12em"></td>
<td><input type="text" name="agent_url_{{ c.idx }}" value="{{ c.agent_url }}" placeholder="(optional)" style="width:9em"></td>
<td><input type="text" name="model_{{ c.idx }}" value="{{ c.model }}" placeholder="Modell" style="width:9em"></td>
<td><input type="password" name="api_key_{{ c.idx }}" placeholder="{% if c.key_set %}gesetzt{% else %}—{% endif %}" style="width:8em"></td>
</tr>
{% endfor %}
</tbody></table></div>
<button class="btn" type="submit" style="margin-top:8px">Verbindungen speichern &amp; neu starten</button>
</form>
</div>
<div class="panel">
<h3 style="margin-top:0">Inferenz testen</h3>
<form method="post" action="/erweiterungen/aes/llm/infer">
<input type="hidden" name="_csrf" value="{{ ctx.csrf }}">
<div class="formrow"><input type="text" name="model" placeholder="Modell (z.B. llama3.2 oder claude-sonnet-4-6)" value="{{ infer_model }}" required></div>
<div class="formrow"><textarea name="prompt" rows="3" placeholder="Prompt" required>{{ infer_prompt }}</textarea></div>
<button class="btn" type="submit">Senden</button>
</form>
{% if infer_shown %}
<h4 style="margin:12px 0 4px">Antwort{% if !infer_model.is_empty() %} ({{ infer_model }}){% endif %}</h4>
{% if infer_result.is_empty() %}<p class="sz">Keine Antwort &mdash; Ursache siehe Fehlermeldung oben (kein gesundes Backend und kein Cloud-Fallback aktiv).</p>
{% else %}<div class="panel" style="white-space:pre-wrap">{{ infer_result }}</div>{% endif %}
{% endif %}
</div>
<div class="panel" style="margin-top:14px">
<h3 style="margin-top:0">Modelle verwalten</h3>
<form method="post" action="/erweiterungen/aes/llm/pull" style="margin-bottom:10px">
<input type="hidden" name="_csrf" value="{{ ctx.csrf }}">
<div class="formrow"><input type="text" name="model" placeholder="Modellname laden, z.B. llama3.2" required></div>
<button class="btn" type="submit">Laden (pull)</button>
</form>
{% if models.is_empty() %}<p class="sz">Keine Modelle geladen (Backend offline oder leer).</p>{% else %}
<table class="tickets"><thead><tr><th>Modell</th><th></th></tr></thead><tbody>
{% for m in models %}<tr><td class="sz">{{ m }}</td><td>
<form method="post" action="/erweiterungen/aes/llm/delete" class="inline-form" data-confirm="Modell {{ m }} loeschen?">
<input type="hidden" name="_csrf" value="{{ ctx.csrf }}">
<input type="hidden" name="model" value="{{ m }}">
<button class="btn ghost mini" type="submit">Loeschen</button>
</form></td></tr>{% endfor %}
</tbody></table>{% endif %}
</div>
<h3 style="margin:16px 0 6px">Backends</h3>
<p class="sz">{{ bal_summary }}</p>
{% if backends.is_empty() %}<p class="sz">Keine Backends. Sobald ein llm-agent online ist, erscheinen hier Auslastung und Modelle.</p>{% else %}
<table class="tickets"><thead><tr><th>Backend</th><th>Status</th><th>CPU</th><th>RAM</th><th>VRAM</th><th>Latenz</th><th>Modelle</th></tr></thead><tbody>
{% for b in backends %}<tr><td class="sz">{{ b.url }}</td><td><span class="badge {{ b.badge_class }}">{{ b.badge }}</span></td><td class="sz">{{ b.cpu }}%</td><td class="sz">{{ b.ram }}%</td><td class="sz">{{ b.vram }}%</td><td class="sz">{{ b.latency }}ms</td><td class="sz">{{ b.models }}</td></tr>{% endfor %}
</tbody></table>{% endif %}
{% else if section == "monitoring" %}
<h1 class="page-title">Monitoring</h1>
<p class="sz">Live-Ansicht aller Dienste (aktualisiert sich automatisch). Externe Anbindung siehe Admin &rarr; Metrics.</p>
<div id="aes-monitor" data-src="/erweiterungen/aes/monitor.json">
<p class="sz">Lade Live-Daten&hellip; (aktiviere JavaScript fuer die Live-Ansicht)</p>
</div>
<script src="/static/aes-monitor.js"></script>
<h3 style="margin:16px 0 4px">Logs (Events)</h3>
{% if logs.is_empty() %}<p class="sz">Keine Events.</p>{% else %}
<div class="panel" style="overflow-x:auto;max-height:320px;overflow-y:auto">{% for l in logs %}<div class="sz" style="font-family:monospace;white-space:pre-wrap;border-bottom:1px solid var(--border);padding:3px 0">{{ l }}</div>{% endfor %}</div>{% endif %}
{% else if section == "admin" %}
<h1 class="page-title">Admin</h1>
<h3 style="margin:6px 0 6px">Dienste &amp; Modul</h3>
<table class="tickets"><tbody>
{% for r in admin_rows %}<tr><td class="sz">{{ r.k }}</td><td>{% if r.class.is_empty() %}<span class="sz">{{ r.v }}</span>{% else %}<span class="badge {{ r.class }}">{{ r.v }}</span>{% endif %}</td></tr>{% endfor %}
</tbody></table>
<h3 style="margin:16px 0 6px">Externe Monitoring-Schnittstelle</h3>
<p class="sz">Zum Anbinden eines externen Monitorings (Prometheus/Grafana o.ae.). Aufruf mit Header <code>Authorization: Bearer &lt;ITSM_AES_METRICS_TOKEN&gt;</code>.</p>
<table class="tickets"><tbody>
{% for r in metrics_rows %}<tr><td class="sz">{{ r.k }}</td><td><span class="badge {{ r.class }}">{{ r.v }}</span></td></tr>{% endfor %}
</tbody></table>
{% else if section == "einstellungen" %}
<h1 class="page-title">Einstellungen{% if !comp.is_empty() %} &middot; {{ comp }}{% endif %}</h1>
{% if !comp.is_empty() %}<p class="sz"><a href="/erweiterungen/aes/einstellungen">&larr; alle Einstellungen</a></p>{% endif %}
{% if !hinweis.is_empty() %}<div class="panel"><p class="sz">{{ hinweis }}</p></div>{% endif %}
{% if !fehler.is_empty() %}<div class="panel"><p class="err">{{ fehler }}</p></div>{% endif %}
<p class="sz">Secrets werden nie im Klartext angezeigt; Feld leer lassen = unveraendert. Speichern startet den betroffenen Dienst automatisch neu.</p>
{% if comp != "runner" %}
<form method="post" action="/erweiterungen/aes/einstellungen/save" class="panel">
<input type="hidden" name="_csrf" value="{{ ctx.csrf }}">
<input type="hidden" name="file" value="aes">
<h3 style="margin-top:0">AES-Suite / Balancer / Cloud / Forge</h3>
{% for g in sett_aes %}
{% if comp.is_empty() || comp == "all" || (comp == "llm" && g.name == "LLM-Balancer / Cloud") || (comp == "forge" && g.name == "Forge") || (comp == "forge" && g.name == "AES-Kern") %}
<h4 style="margin:12px 0 4px">{{ g.name }}</h4>
{% for it in g.items %}
<div class="formrow">
<label class="sz" style="display:block">{{ it.label }} <span style="opacity:.6">({{ it.key }})</span></label>
{% if it.secret %}<input type="password" name="{{ it.key }}" placeholder="{% if it.set %}gesetzt &mdash; neuen Wert eingeben zum Aendern{% else %}nicht gesetzt{% endif %}">
{% else %}<input type="text" name="{{ it.key }}" value="{{ it.value }}">{% endif %}
</div>
{% endfor %}
{% endif %}
{% endfor %}
<button class="btn" type="submit">Speichern &amp; aes-suite neu starten</button>
</form>
{% endif %}
{% if comp.is_empty() || comp == "all" || comp == "runner" %}
<form method="post" action="/erweiterungen/aes/einstellungen/save" class="panel" style="margin-top:14px">
<input type="hidden" name="_csrf" value="{{ ctx.csrf }}">
<input type="hidden" name="file" value="runner">
<h3 style="margin-top:0">Runner</h3>
{% for g in sett_runner %}
{% for it in g.items %}
<div class="formrow">
<label class="sz" style="display:block">{{ it.label }} <span style="opacity:.6">({{ it.key }})</span></label>
{% if it.secret %}<input type="password" name="{{ it.key }}" placeholder="{% if it.set %}gesetzt &mdash; neuen Wert eingeben zum Aendern{% else %}nicht gesetzt{% endif %}">
{% else %}<input type="text" name="{{ it.key }}" value="{{ it.value }}">{% endif %}
</div>
{% endfor %}
{% endfor %}
<button class="btn" type="submit">Speichern &amp; aes-runner neu starten</button>
</form>
{% endif %}
{% endif %}
{% endif %}
{% endblock %}

51
templates/aes_forge_repo.html Executable file
View File

@ -0,0 +1,51 @@
{% extends "base.html" %}
{% block content %}
{% include "aes_nav.html" %}
<p class="sz"><a href="/erweiterungen/aes/forge">&larr; Forge</a></p>
<h1 class="page-title">Repository: {{ name }}</h1>
{% if !hinweis.is_empty() %}<div class="panel"><p class="sz">{{ hinweis }}</p></div>{% endif %}
{% if !fehler.is_empty() %}<div class="panel"><p class="err">{{ fehler }}</p></div>{% endif %}
<div style="display:flex;flex-wrap:wrap;gap:10px">
<div class="card" style="flex:1;min-width:180px"><div class="sz">Default-Branch</div><div style="font-size:18px;font-weight:700">{{ default_branch }}</div></div>
<div class="card" style="flex:1;min-width:180px"><div class="sz">Sichtbarkeit</div><div style="font-size:18px;font-weight:700">{% if private %}privat{% else %}oeffentlich{% endif %}</div></div>
<div class="card" style="flex:2;min-width:240px"><div class="sz">Clone-URL</div><div class="sz" style="font-family:monospace;margin-top:6px">{{ clone_url }}</div></div>
</div>
<h2 class="page-title" style="font-size:18px;margin-top:20px">Pull Requests</h2>
{% if pulls.is_empty() %}
<p class="sz">Keine Pull Requests.</p>
{% else %}
<table class="tickets">
<thead><tr><th>#</th><th>Titel</th><th>Quelle &rarr; Ziel</th><th>Status</th><th></th></tr></thead>
<tbody>
{% for p in pulls %}
<tr>
<td class="sz">{{ p.number }}</td>
<td><b>{{ p.title }}</b></td>
<td class="sz">{{ p.head }} &rarr; {{ p.base }}</td>
<td><span class="badge {% if p.state == "open" %}ok{% else %}unknown{% endif %}">{{ p.state }}</span></td>
<td>
<form method="post" action="/erweiterungen/aes/forge/{{ name }}/pulls/{{ p.number }}/merge" class="inline-form" data-confirm="PR #{{ p.number }} mergen?">
<input type="hidden" name="_csrf" value="{{ ctx.csrf }}">
<button class="btn ghost mini" type="submit">Mergen</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<div class="panel" style="margin-top:14px">
<h3 style="margin-top:0">Pull Request eroeffnen</h3>
<form method="post" action="/erweiterungen/aes/forge/{{ name }}/pulls">
<input type="hidden" name="_csrf" value="{{ ctx.csrf }}">
<div class="formrow"><input type="text" name="title" placeholder="Titel" required></div>
<div class="formrow"><input type="text" name="head" placeholder="head-Branch (Quelle)" pattern="[A-Za-z0-9_./-]+" required></div>
<div class="formrow"><input type="text" name="base" placeholder="base-Branch (Ziel, z.B. main)" pattern="[A-Za-z0-9_./-]+" required></div>
<button class="btn" type="submit">PR anlegen</button>
</form>
</div>
{% endblock %}

13
templates/aes_nav.html Executable file
View File

@ -0,0 +1,13 @@
<div class="card" style="margin-bottom:14px">
<div><b style="font-size:16px">AES Suite</b> <span class="sz" style="margin-left:6px">Autonomous Engineering System</span></div>
<div style="margin-top:10px;display:flex;flex-wrap:wrap;gap:6px">
<a class="{% if section == "dashboard" %}btn{% else %}btn ghost{% endif %} mini" href="/erweiterungen/aes">Dashboard</a>
<a class="{% if section == "projekte" %}btn{% else %}btn ghost{% endif %} mini" href="/erweiterungen/aes/projekte">Projekte</a>
<a class="{% if section == "forge" %}btn{% else %}btn ghost{% endif %} mini" href="/erweiterungen/aes/forge">Forge</a>
<a class="{% if section == "runner" %}btn{% else %}btn ghost{% endif %} mini" href="/erweiterungen/aes/runner">Runner</a>
<a class="{% if section == "llm" %}btn{% else %}btn ghost{% endif %} mini" href="/erweiterungen/aes/llm">LLM</a>
<a class="{% if section == "monitoring" %}btn{% else %}btn ghost{% endif %} mini" href="/erweiterungen/aes/monitoring">Monitoring</a>
<a class="{% if section == "admin" %}btn{% else %}btn ghost{% endif %} mini" href="/erweiterungen/aes/admin">Admin</a>
</div>
</div>
<script src="/static/aes-ext.js"></script>

View File

@ -12,7 +12,7 @@
<div class="sidebar"> <div class="sidebar">
<div class="brand"> <div class="brand">
<span class="brand-logo">ITSM</span> <span class="brand-logo">ITSM</span>
<div class="brand-text"><b>ITSM</b><div class="sz">IT Service Management</div></div> <div class="brand-text"><b>ITSM</b><div class="sz">Konzernplattform</div></div>
</div> </div>
<div class="navgroup"> <div class="navgroup">
<h4>Favoriten</h4> <h4>Favoriten</h4>
@ -41,6 +41,25 @@
<a href="/wissen?kategorie=Anleitung">Artikel</a> <a href="/wissen?kategorie=Anleitung">Artikel</a>
<a href="/wissen?kategorie=FAQ">FAQs</a> <a href="/wissen?kategorie=FAQ">FAQs</a>
</div> </div>
{% if ctx.hr %}
<div class="navgroup">
<h4>HR</h4>
<a href="/hr" class="{% if ctx.active == "/hr" %}active{% endif %}">Mitarbeiter</a>
</div>
{% endif %}
{% if ctx.crm %}
<div class="navgroup">
<h4>CRM</h4>
<a href="/crm" class="{% if ctx.active == "/crm" %}active{% endif %}">Firmen</a>
<a href="/crm/pipeline" class="{% if ctx.active == "/crm/pipeline" %}active{% endif %}">Pipeline</a>
</div>
{% endif %}
{% if ctx.aes_ext %}
<div class="navgroup">
<h4>Erweiterungen</h4>
<a href="/erweiterungen/aes" class="{% if ctx.active == "/erweiterungen/aes" %}active{% endif %}">AES-Suite</a>
</div>
{% endif %}
{% if ctx.operative %} {% if ctx.operative %}
<div class="navgroup"> <div class="navgroup">
<h4>Assets</h4> <h4>Assets</h4>

74
templates/crm_detail.html Executable file
View File

@ -0,0 +1,74 @@
{% extends "base.html" %}
{% block content %}
<div class="page-head">
<h1 class="page-title">{{ name }}</h1>
<div class="page-actions"><a class="btn ghost" href="/crm">Zurück</a><a class="btn ghost" href="/crm/pipeline">Pipeline</a></div>
</div>
<div class="grid-2">
<div class="panel">
<h3 style="margin-top:0">Firmendaten</h3>
<form method="post" action="/crm/{{ id }}">
<input type="hidden" name="_csrf" value="{{ ctx.csrf }}">
<div class="formrow"><label>Name</label><input name="name" value="{{ name }}" required></div>
<div class="grid-2">
<div class="formrow"><label>Branche</label><input name="branche" value="{{ branche }}"></div>
<div class="formrow"><label>Status</label>
<select name="status">
{% for o in status_opts %}<option value="{{ o.0 }}" {% if o.1 %}selected{% endif %}>{{ o.0 }}</option>{% endfor %}
</select>
</div>
</div>
<div class="grid-2">
<div class="formrow"><label>Telefon</label><input name="telefon" value="{{ telefon }}"></div>
<div class="formrow"><label>Website</label><input name="website" value="{{ website }}"></div>
</div>
<div class="formrow"><label>Adresse</label><input name="adresse" value="{{ adresse }}"></div>
<div class="formrow"><label>Notizen</label><textarea name="notizen">{{ notizen }}</textarea></div>
<button class="btn" type="submit">Speichern</button>
</form>
</div>
<div>
<div class="panel">
<h3 style="margin-top:0">Kontakte</h3>
{% for k in contacts %}
<div class="tl-item"><b>{{ k.name }}</b> <span class="sz">{{ k.position }}</span>
<div class="sz">{{ k.email }} {{ k.telefon }}</div></div>
{% endfor %}
{% if contacts.is_empty() %}<p class="sz">Keine Kontakte.</p>{% endif %}
<form method="post" action="/crm/{{ id }}/kontakte">
<input type="hidden" name="_csrf" value="{{ ctx.csrf }}">
<div class="grid-2">
<div class="formrow"><input name="vorname" placeholder="Vorname"></div>
<div class="formrow"><input name="nachname" placeholder="Nachname" required></div>
</div>
<div class="grid-2">
<div class="formrow"><input name="email" placeholder="E-Mail"></div>
<div class="formrow"><input name="telefon" placeholder="Telefon"></div>
</div>
<div class="formrow"><input name="position" placeholder="Position"></div>
<button class="btn ghost mini" type="submit">+ Kontakt</button>
</form>
</div>
<div class="panel">
<h3 style="margin-top:0">Deals</h3>
{% for d in deals %}
<div class="tl-item"><b>{{ d.titel }}</b> · {{ d.wert }}
<span class="pill {{ d.stufe_class }}">{{ d.stufe }}</span>
{% if !d.faellig.is_empty() %}<span class="sz">fällig {{ d.faellig }}</span>{% endif %}
</div>
{% endfor %}
{% if deals.is_empty() %}<p class="sz">Keine Deals.</p>{% endif %}
<form method="post" action="/crm/{{ id }}/deals">
<input type="hidden" name="_csrf" value="{{ ctx.csrf }}">
<div class="formrow"><input name="titel" placeholder="Deal-Titel" required></div>
<div class="grid-2">
<div class="formrow"><input name="wert_eur" placeholder="Wert in € (z.B. 12500,00)"></div>
<div class="formrow"><select name="stufe">{% for s in stage_opts %}<option>{{ s }}</option>{% endfor %}</select></div>
</div>
<div class="formrow"><label>Fällig</label><input name="faellig" type="date"></div>
<button class="btn ghost mini" type="submit">+ Deal</button>
</form>
</div>
</div>
</div>
{% endblock %}

54
templates/crm_list.html Executable file
View File

@ -0,0 +1,54 @@
{% extends "base.html" %}
{% block content %}
<div class="page-head">
<h1 class="page-title">CRM — Firmen</h1>
<div class="page-actions">
<button class="btn" id="open-new-ticket">Neue Firma</button>
<a class="btn ghost" href="/crm/pipeline">Pipeline</a>
</div>
</div>
<div class="kpi-row">
{% for k in kpis %}<div class="kpi"><div class="lbl">{{ k.0 }}</div><div class="val">{{ k.1 }}</div></div>{% endfor %}
</div>
<div class="tabs">
{% for t in tabs %}<a href="{{ t.0 }}" class="{% if t.2 %}active{% endif %}">{{ t.1 }}</a>{% endfor %}
</div>
<div id="new-ticket-modal" class="modal" hidden>
<div class="modal-box">
<div class="modal-head"><h3>Neue Firma</h3><button class="linklike" id="close-new-ticket"></button></div>
<form method="post" action="/crm/neu">
<input type="hidden" name="_csrf" value="{{ ctx.csrf }}">
<div class="formrow"><label>Name</label><input name="name" required></div>
<div class="grid-2">
<div class="formrow"><label>Branche</label><input name="branche"></div>
<div class="formrow"><label>Status</label>
<select name="status">{% for s in status_opts %}<option>{{ s }}</option>{% endfor %}</select>
</div>
</div>
<div class="grid-2">
<div class="formrow"><label>Telefon</label><input name="telefon"></div>
<div class="formrow"><label>Website</label><input name="website"></div>
</div>
<div class="formrow"><label>Adresse</label><input name="adresse"></div>
<div class="formrow"><label>Notizen</label><textarea name="notizen"></textarea></div>
<button class="btn" type="submit">Anlegen</button>
</form>
</div>
</div>
<table class="tickets">
<thead><tr><th>Firma</th><th>Branche</th><th>Telefon</th><th>Status</th></tr></thead>
<tbody>
{% for r in rows %}
<tr class="row" data-href="/crm/{{ r.id }}">
<td><b>{{ r.name }}</b></td>
<td>{{ r.branche }}</td>
<td class="sz">{{ r.telefon }}</td>
<td><span class="pill {{ r.status_class }}">{{ r.status }}</span></td>
</tr>
{% endfor %}
{% if rows.is_empty() %}<tr><td colspan="4" class="sz">Keine Firmen.</td></tr>{% endif %}
</tbody>
</table>
{% endblock %}

32
templates/crm_pipeline.html Executable file
View File

@ -0,0 +1,32 @@
{% extends "base.html" %}
{% block content %}
<div class="page-head">
<h1 class="page-title">CRM — Pipeline</h1>
<div class="page-actions"><a class="btn ghost" href="/crm">Zur Firmenliste</a></div>
</div>
<div class="board pipeline">
{% for col in columns %}
<div class="board-col">
<div class="board-col-head">{{ col.title }} <span class="pill offen">{{ col.count }}</span></div>
<div class="sz" style="padding:0 6px 8px">{{ col.summe }}</div>
{% for d in col.deals %}
<div class="board-card">
<div><b>{{ d.titel }}</b></div>
<div class="sz">{{ d.company }}</div>
<div class="board-card-foot">
<span class="sz">{{ d.wert }}</span>
<form method="post" action="/crm/deals/{{ d.id }}/stufe" class="inline-form" style="margin-left:auto">
<input type="hidden" name="_csrf" value="{{ ctx.csrf }}">
<input type="hidden" name="zurueck" value="/crm/pipeline">
<select name="stufe" data-autosubmit>
{% for s in stage_opts %}<option value="{{ s }}" {% if s.as_str() == d.stufe.as_str() %}selected{% endif %}>{{ s }}</option>{% endfor %}
</select>
</form>
</div>
</div>
{% endfor %}
{% if col.deals.is_empty() %}<div class="sz" style="padding:8px"></div>{% endif %}
</div>
{% endfor %}
</div>
{% endblock %}

59
templates/ext_aes.html Executable file
View File

@ -0,0 +1,59 @@
{% extends "base.html" %}
{% block content %}
<h1 class="page-title">AES-Suite</h1>
<p class="sz">Autonomous Engineering System &mdash; als buchbares Modul in der ITSM-Plattform.</p>
{% if !status_err.is_empty() %}
<div class="panel"><p class="err">{{ status_err }}</p></div>
{% endif %}
{% if configured && status_err.is_empty() %}
<div class="card">
<div class="svc-card">
<div>
<b>Modulstatus</b>
<div class="sz" style="margin-top:4px">Version {{ version }} &middot; Modus {{ mode }}</div>
</div>
{% if licensed %}
<span class="badge ok">gebucht</span>
{% else %}
<span class="badge unknown">nicht gebucht</span>
{% endif %}
</div>
{% if !capabilities.is_empty() %}
<div class="sz" style="margin-top:8px">
Faehigkeiten:
{% for c in capabilities %}<span class="badge" style="margin-right:4px">{{ c }}</span>{% endfor %}
</div>
{% endif %}
</div>
<h2 class="page-title" style="font-size:18px;margin-top:20px">Projekte</h2>
{% if projects.is_empty() %}
<p class="sz">Noch keine Projekte. Lege unten das erste an.</p>
{% else %}
<table class="tickets">
<thead><tr><th>Projekt</th><th>Aktion</th></tr></thead>
<tbody>
{% for p in projects %}
<tr>
<td><b>{{ p }}</b></td>
<td><a class="btn ghost mini" href="/erweiterungen/aes/projekte/{{ p }}">Oeffnen &rarr;</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<div class="panel" style="margin-top:16px">
<h3 style="margin-top:0">Projekt anlegen</h3>
<form method="post" action="/erweiterungen/aes/projekte">
<input type="hidden" name="_csrf" value="{{ ctx.csrf }}">
<div class="formrow">
<input type="text" name="name" placeholder="Projektname (a-z, 0-9, - , _)" pattern="[A-Za-z0-9_-]+" required>
</div>
<button class="btn" type="submit">Anlegen</button>
</form>
</div>
{% endif %}
{% endblock %}

70
templates/ext_aes_project.html Executable file
View File

@ -0,0 +1,70 @@
{% extends "base.html" %}
{% block content %}
{% include "aes_nav.html" %}
<p class="sz"><a href="/erweiterungen/aes/projekte">&larr; Projekte</a></p>
<h1 class="page-title">Projekt: {{ name }}</h1>
{% if !hinweis.is_empty() %}<div class="panel"><p class="sz">{{ hinweis }}</p></div>{% endif %}
{% if !fehler.is_empty() %}<div class="panel"><p class="err">{{ fehler }}</p></div>{% endif %}
<h2 class="page-title" style="font-size:18px;margin-top:8px">Phasen</h2>
{% if phases.is_empty() %}
<p class="sz">Keine Phasen gefunden.</p>
{% else %}
<table class="tickets">
<thead><tr><th>Phase</th><th>Beschreibung</th><th>Aktionen</th></tr></thead>
<tbody>
{% for p in phases %}
<tr>
<td><b>{{ p.name }}</b><div class="sz">{{ p.id }}</div></td>
<td class="sz">{{ p.description }}</td>
<td>
<form method="post" action="/erweiterungen/aes/projekte/{{ name }}/phasen/{{ p.id }}/run" class="inline-form">
<input type="hidden" name="_csrf" value="{{ ctx.csrf }}">
<button class="btn ghost mini" type="submit">Ausfuehren</button>
</form>
<form method="post" action="/erweiterungen/aes/projekte/{{ name }}/phasen/{{ p.id }}/validate" class="inline-form">
<input type="hidden" name="_csrf" value="{{ ctx.csrf }}">
<button class="btn ghost mini" type="submit">Validieren</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<h2 class="page-title" style="font-size:18px;margin-top:20px">Laeufe</h2>
{% if runs.is_empty() %}
<p class="sz">Noch keine Laeufe.</p>
{% else %}
<table class="tickets">
<thead><tr><th>Phase</th><th>Ergebnis</th><th>Retries</th><th>Fehlerhafter Schritt</th><th>Beendet</th></tr></thead>
<tbody>
{% for r in runs %}
<tr>
<td><b>{{ r.phase_id }}</b></td>
<td>
<span class="badge {{ r.badge_class }}">{{ r.badge_label }}</span>
{% if r.degraded %}<span class="badge unknown" style="margin-left:4px">degraded</span>{% endif %}
</td>
<td class="sz">{{ r.retries_used }}</td>
<td class="sz">{{ r.failed_step }}</td>
<td class="sz">{{ r.finished_at }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<details style="margin-top:20px">
<summary class="page-title" style="font-size:18px;cursor:pointer">Ereignisse ({{ events.len() }})</summary>
{% if events.is_empty() %}
<p class="sz">Keine Ereignisse.</p>
{% else %}
<div class="panel" style="margin-top:8px;overflow-x:auto">
{% for e in events %}<div class="sz" style="font-family:monospace;white-space:pre-wrap;border-bottom:1px solid var(--border);padding:4px 0">{{ e }}</div>{% endfor %}
</div>
{% endif %}
</details>
{% endblock %}

84
templates/hr_detail.html Executable file
View File

@ -0,0 +1,84 @@
{% extends "base.html" %}
{% block content %}
<div class="page-head">
<h1 class="page-title">{{ vorname }} {{ nachname }}</h1>
<div class="page-actions"><a class="btn ghost" href="/hr">Zurück zur Liste</a></div>
</div>
<div class="panel">
<h3 style="margin-top:0">Stammdaten</h3>
<form method="post" action="/hr/{{ id }}">
<input type="hidden" name="_csrf" value="{{ ctx.csrf }}">
<div class="grid-2">
<div class="formrow"><label>Vorname</label><input name="vorname" value="{{ vorname }}" required></div>
<div class="formrow"><label>Nachname</label><input name="nachname" value="{{ nachname }}" required></div>
</div>
<div class="grid-2">
<div class="formrow"><label>Personalnr.</label><input name="personalnr" value="{{ personalnr }}"></div>
<div class="formrow"><label>E-Mail</label><input name="email" type="email" value="{{ email }}"></div>
</div>
<div class="grid-2">
<div class="formrow"><label>Abteilung</label><input name="abteilung" value="{{ abteilung }}"></div>
<div class="formrow"><label>Position</label><input name="position" value="{{ position }}"></div>
</div>
<div class="grid-2">
<div class="formrow"><label>Eintritt</label><input name="eintritt" type="date" value="{{ eintritt }}"></div>
<div class="formrow"><label>Austritt</label><input name="austritt" type="date" value="{{ austritt }}"></div>
</div>
<div class="formrow"><label>Status</label>
<select name="status">
{% for o in status_opts %}<option value="{{ o.0 }}" {% if o.1 %}selected{% endif %}>{{ o.0 }}</option>{% endfor %}
</select>
</div>
<div class="formrow"><label>Notizen</label><textarea name="notizen">{{ notizen }}</textarea></div>
<button class="btn" type="submit">Speichern</button>
</form>
</div>
<h2 class="section-title">Abwesenheiten</h2>
<div class="panel">
<form method="post" action="/hr/{{ id }}/abwesenheiten">
<input type="hidden" name="_csrf" value="{{ ctx.csrf }}">
<div class="grid-2">
<div class="formrow"><label>Typ</label>
<select name="typ">{% for t in absence_types %}<option>{{ t }}</option>{% endfor %}</select>
</div>
<div class="formrow"><label>Kommentar</label><input name="kommentar"></div>
</div>
<div class="grid-2">
<div class="formrow"><label>Von</label><input name="von" type="date" required></div>
<div class="formrow"><label>Bis</label><input name="bis" type="date" required></div>
</div>
<button class="btn ghost" type="submit">Antrag anlegen</button>
</form>
</div>
<table class="tickets">
<thead><tr><th>Typ</th><th>Zeitraum</th><th>Status</th><th>Kommentar</th><th>Aktion</th></tr></thead>
<tbody>
{% for a in absences %}
<tr>
<td>{{ a.typ }}</td>
<td class="sz">{{ a.zeitraum }}</td>
<td><span class="pill {{ a.status_class }}">{{ a.status }}</span></td>
<td class="sz">{{ a.kommentar }}</td>
<td>
{% if a.pending %}
<form method="post" action="/hr/abwesenheiten/{{ a.id }}/status" class="inline-form">
<input type="hidden" name="_csrf" value="{{ ctx.csrf }}">
<input type="hidden" name="zurueck" value="/hr/{{ id }}">
<input type="hidden" name="status" value="Genehmigt">
<button class="btn mini" type="submit">Genehmigen</button>
</form>
<form method="post" action="/hr/abwesenheiten/{{ a.id }}/status" class="inline-form">
<input type="hidden" name="_csrf" value="{{ ctx.csrf }}">
<input type="hidden" name="zurueck" value="/hr/{{ id }}">
<input type="hidden" name="status" value="Abgelehnt">
<button class="btn ghost mini" type="submit">Ablehnen</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
{% if absences.is_empty() %}<tr><td colspan="5" class="sz">Keine Abwesenheiten.</td></tr>{% endif %}
</tbody>
</table>
{% endblock %}

91
templates/hr_list.html Executable file
View File

@ -0,0 +1,91 @@
{% extends "base.html" %}
{% block content %}
<div class="page-head">
<h1 class="page-title">HR — Mitarbeiter</h1>
<div class="page-actions"><button class="btn" id="open-new-ticket">Neuer Mitarbeiter</button></div>
</div>
<div class="kpi-row">
{% for k in kpis %}<div class="kpi"><div class="lbl">{{ k.0 }}</div><div class="val">{{ k.1 }}</div></div>{% endfor %}
</div>
<div class="tabs">
{% for t in tabs %}<a href="{{ t.0 }}" class="{% if t.2 %}active{% endif %}">{{ t.1 }}</a>{% endfor %}
</div>
<div id="new-ticket-modal" class="modal" hidden>
<div class="modal-box">
<div class="modal-head"><h3>Neuer Mitarbeiter</h3><button class="linklike" id="close-new-ticket"></button></div>
<form method="post" action="/hr/neu">
<input type="hidden" name="_csrf" value="{{ ctx.csrf }}">
<div class="grid-2">
<div class="formrow"><label>Vorname</label><input name="vorname" required></div>
<div class="formrow"><label>Nachname</label><input name="nachname" required></div>
</div>
<div class="grid-2">
<div class="formrow"><label>Personalnr.</label><input name="personalnr"></div>
<div class="formrow"><label>E-Mail</label><input name="email" type="email"></div>
</div>
<div class="grid-2">
<div class="formrow"><label>Abteilung</label><input name="abteilung"></div>
<div class="formrow"><label>Position</label><input name="position"></div>
</div>
<div class="grid-2">
<div class="formrow"><label>Eintritt</label><input name="eintritt" type="date"></div>
<div class="formrow"><label>Status</label>
<select name="status"><option>Onboarding</option><option>Aktiv</option></select>
</div>
</div>
<div class="formrow"><label>Notizen</label><textarea name="notizen"></textarea></div>
<button class="btn" type="submit">Anlegen</button>
</form>
</div>
</div>
<table class="tickets">
<thead><tr><th>Name</th><th>Personalnr.</th><th>Abteilung</th><th>Position</th><th>Status</th></tr></thead>
<tbody>
{% for r in rows %}
<tr class="row" data-href="/hr/{{ r.id }}">
<td><span class="avatar sm">{{ r.initials }}</span> <b>{{ r.name }}</b></td>
<td class="sz">{{ r.personalnr }}</td>
<td>{{ r.abteilung }}</td>
<td class="sz">{{ r.position }}</td>
<td><span class="pill {{ r.status_class }}">{{ r.status }}</span></td>
</tr>
{% endfor %}
{% if rows.is_empty() %}<tr><td colspan="5" class="sz">Keine Mitarbeiter.</td></tr>{% endif %}
</tbody>
</table>
<h2 class="section-title">Abwesenheitsanträge</h2>
<table class="tickets">
<thead><tr><th>Mitarbeiter</th><th>Typ</th><th>Zeitraum</th><th>Status</th><th>Kommentar</th><th>Aktion</th></tr></thead>
<tbody>
{% for a in absences %}
<tr>
<td><b>{{ a.employee }}</b></td>
<td>{{ a.typ }}</td>
<td class="sz">{{ a.zeitraum }}</td>
<td><span class="pill {{ a.status_class }}">{{ a.status }}</span></td>
<td class="sz">{{ a.kommentar }}</td>
<td>
{% if a.pending %}
<form method="post" action="/hr/abwesenheiten/{{ a.id }}/status" class="inline-form">
<input type="hidden" name="_csrf" value="{{ ctx.csrf }}">
<input type="hidden" name="zurueck" value="/hr">
<input type="hidden" name="status" value="Genehmigt">
<button class="btn mini" type="submit">Genehmigen</button>
</form>
<form method="post" action="/hr/abwesenheiten/{{ a.id }}/status" class="inline-form">
<input type="hidden" name="_csrf" value="{{ ctx.csrf }}">
<input type="hidden" name="zurueck" value="/hr">
<input type="hidden" name="status" value="Abgelehnt">
<button class="btn ghost mini" type="submit">Ablehnen</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
{% if absences.is_empty() %}<tr><td colspan="6" class="sz">Keine Anträge.</td></tr>{% endif %}
</tbody>
</table>
{% endblock %}