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:
parent
97facdce18
commit
fc6bc3028d
|
|
@ -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.
|
||||||
82
schema.sql
82
schema.sql
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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
296
src/db.rs
|
|
@ -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, ¬izen]).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, ¬izen, &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, ¬izen]).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, ¬izen, &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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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())
|
||||||
|
}
|
||||||
28
src/itil.rs
28
src/itil.rs
|
|
@ -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.
|
||||||
|
|
|
||||||
49
src/main.rs
49
src/main.rs
|
|
@ -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))
|
||||||
|
|
|
||||||
35
src/web.rs
35
src/web.rs
|
|
@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
@ -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);
|
||||||
|
})();
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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">•</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 }}% · RAM {{ b.ram }}% · VRAM {{ b.vram }}% · {{ 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 →</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">⚙ Einstellungen</a>
|
||||||
|
</div>
|
||||||
|
<p class="sz">Git-Server der AES-Suite. Zugriff erfolgt serverseitig mit dem AES-Token — 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">⚙ 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> · 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 <AGENT_TOKEN></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 & Verbindungen</h1>
|
||||||
|
<a class="btn ghost mini" href="/erweiterungen/aes/einstellungen?comp=llm" title="Balancer-/Cloud-Einstellungen">⚙ 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/<endpoint>/v1</code> · Together <code>https://api.together.xyz/v1</code> · Groq <code>https://api.groq.com/openai/v1</code> · 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"> <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 & 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 — 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 → Metrics.</p>
|
||||||
|
<div id="aes-monitor" data-src="/erweiterungen/aes/monitor.json">
|
||||||
|
<p class="sz">Lade Live-Daten… (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 & 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 <ITSM_AES_METRICS_TOKEN></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() %} · {{ comp }}{% endif %}</h1>
|
||||||
|
{% if !comp.is_empty() %}<p class="sz"><a href="/erweiterungen/aes/einstellungen">← 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 — 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 & 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 — 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 & aes-runner neu starten</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
{% include "aes_nav.html" %}
|
||||||
|
<p class="sz"><a href="/erweiterungen/aes/forge">← 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 → 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 }} → {{ 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 %}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
@ -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 %}
|
||||||
|
|
@ -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 %}
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<h1 class="page-title">AES-Suite</h1>
|
||||||
|
<p class="sz">Autonomous Engineering System — 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 }} · 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 →</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 %}
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
{% include "aes_nav.html" %}
|
||||||
|
<p class="sz"><a href="/erweiterungen/aes/projekte">← 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 %}
|
||||||
|
|
@ -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 %}
|
||||||
|
|
@ -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 %}
|
||||||
Loading…
Reference in New Issue