diff --git a/CLAUDE.md b/CLAUDE.md index ee89bd4..8e5b7ae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -205,23 +205,45 @@ mehrfacher Neubewertung eines Antrags erhalten bleibt. --- -## Entscheidung, Register, Wiedervorlage (geplant, Schritt 5-7) +## Entscheidung, Register, Wiedervorlage (Schritt 5 erledigt, Schritt 6-7 geplant) + +**Schritt 5 (Entscheidung, Snapshot, Audit-Log) ist umgesetzt.** Ebene 3 +(Fachebene) hat einen Posteingang (`GET /faelle`, alle offenen — +Status "eingereicht" — Anträge des Mandanten, nicht nur die eigenen) +und eine Entscheiden-Seite (`GET /faelle/{id}`, `POST +/faelle/{id}/entscheiden`, siehe `internal/web/fachebene_handlers.go`). +Rollen `verantwortlicher` und `pruefer` sehen dieselbe Sicht +(`requireFachebene`), nur `verantwortlicher` hat Entscheidungsrecht — +ein `pruefer`-POST auf `/entscheiden` bekommt 403 (er darf wissen, dass +der Fall existiert, nur nicht entscheiden; anders als bei +`requireFachebene`/`requireBetreiber`, die bei falscher Rolle 404 +liefern, um die Existenz der Seite selbst zu verbergen). Der/die Verantwortliche wählt: genehmigt / genehmigt mit Auflagen / -abgelehnt / Rückfrage. Begründungsfeld ist bei Abweichung vom Vorschlag -Pflicht. **Snapshot bei Entscheidung:** Regelwerk-Version, Katalog- -Version und der vollständige Werkzeugdatensatz werden eingefroren — ein -späterer Katalog-Wandel darf nicht rückwirkend verändern, worauf eine -Entscheidung beruhte. +abgelehnt / Rückfrage. **Vorschlag-Vergleich:** `vorschlagFuer` leitet +aus der Bewertung ab, was das System vorschlagen würde (`abgelehnt` bei +`verboten`, sonst `genehmigt` wenn mindestens ein zulässiges Werkzeug +existiert, sonst `rueckfrage`) — weicht die tatsächliche Entscheidung +davon ab, ist das Begründungsfeld Pflicht (sonst 400). **Snapshot bei +Entscheidung:** `entscheidung.werkzeug_snapshot` (JSONB) friert den +vollständigen `store.Werkzeug`-Datensatz zum Entscheidungszeitpunkt ein +— ein späterer Katalog-Wandel darf nicht rückwirkend verändern, worauf +eine Entscheidung beruhte. Bei "genehmigt"/"genehmigt mit Auflagen" ist +ein Werkzeug aus der zulässigen Liste der Bewertung Pflicht (hart +geprüft, kein Override eines ausgeschlossenen Werkzeugs). `entscheidung` +ist append-only (Migration 0010) wie `bewertung`; jede Entscheidung +erzeugt zusätzlich einen `audit_log`-Eintrag (Action +`antrag_entschieden`). -Genehmigungen erhalten ein Ablaufdatum (Vorschlag: 12 Monate, bei -`hochrisiko` 6). Ändert sich im Katalog eine Eigenschaft, auf der eine -aktive Genehmigung beruht, wird der/die Verantwortliche benachrichtigt -(Wiedervorlage). +Genehmigungen erhalten ein Ablaufdatum (`gueltig_bis`: 12 Monate, bei +`hochrisiko` 6 — `gueltigkeitFuer`). **Noch nicht gebaut (Schritt 7):** +die Benachrichtigung, wenn sich im Katalog eine Eigenschaft ändert, auf +der eine aktive Genehmigung beruht (Wiedervorlage). -Jede Genehmigung erzeugt automatisch einen Registereintrag: Zweck, -Abteilung, Werkzeug, Datenklasse, Einstufung, Auflagen, Verantwortliche/ -r, Datum, Gültigkeit. Export als PDF und CSV. +**Noch nicht gebaut (Schritt 6):** ein `registereintrag` wird bislang +NICHT automatisch aus einer Genehmigung erzeugt, und es gibt keinen +PDF-/CSV-Export. Geplant: Zweck, Abteilung, Werkzeug, Datenklasse, +Einstufung, Auflagen, Verantwortliche/r, Datum, Gültigkeit. --- @@ -305,12 +327,16 @@ nicht anzulegen. Noch nicht gebaut (kein Abo-System). NICHT append-only (normale Zustandsänderung `entwurf` → `eingereicht` → `entschieden`, wie `submission` es im alten Produkt war). -- `bewertung`, `entscheidung`, `registereintrag` — **noch nicht - gebaut**, geplant für Schritt 5/6 der Baureihenfolge. +- `bewertung` — der berechnete Vorschlag (Schritt 4). +- `entscheidung` — die Entscheidung eines/einer Verantwortlichen über + einen Antrag, mit Snapshot des gewählten Werkzeugs (`werkzeug_snapshot`, + JSONB) und Ablaufdatum (`gueltig_bis`) bei Genehmigung (Schritt 5). +- `registereintrag` — **noch nicht gebaut**, geplant für Schritt 6. **Append-only:** kein UPDATE auf `audit_log` (Trigger `forbid_update_delete`, -wiederverwendet aus dem alten Produkt). `bewertung`/`entscheidung`/ -`registereintrag` werden bei ihrer Einführung ebenfalls append-only. +wiederverwendet aus dem alten Produkt), ebenso `bewertung` und +`entscheidung`. `registereintrag` wird bei seiner Einführung ebenfalls +append-only. --- @@ -341,7 +367,15 @@ wiederverwendet aus dem alten Produkt). `bewertung`/`entscheidung`/ `bewertung` ist append-only. Getestet inkl. K.-o.-Prüfung (`verboten` überspringt die Werkzeugsuche) und hartem Filter gegen den Katalog.) -5. Entscheidung, Snapshot, Audit-Log +5. ~~Entscheidung, Snapshot, Audit-Log~~ — **erledigt** (Ebene 3: + `GET /faelle` Posteingang, `GET /faelle/{id}` + `POST + /faelle/{id}/entscheiden`. `verantwortlicher` entscheidet, + `pruefer` sieht dieselbe Seite ohne Entscheidungsrecht [403 bei + Entscheidungsversuch]. Begründung ist Pflicht bei Abweichung vom + abgeleiteten Vorschlag (`vorschlagFuer`), Genehmigung friert den + vollständigen Werkzeugdatensatz ein und braucht ein zulässiges + Werkzeug aus der Bewertung, `entscheidung` ist append-only, jede + Entscheidung erzeugt einen `audit_log`-Eintrag.) 6. Registereintrag und Export 7. Wiedervorlage und Katalog-Benachrichtigung diff --git a/internal/store/entscheidung.go b/internal/store/entscheidung.go new file mode 100644 index 0000000..71174ed --- /dev/null +++ b/internal/store/entscheidung.go @@ -0,0 +1,111 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +// Entscheidung ist die Entscheidung eines/einer Verantwortlichen über +// einen Antrag, auf Basis einer Bewertung (Vorschlag). Append-only — +// siehe Migration. WerkzeugSnapshot ist der eingefrorene, vollständige +// Werkzeug-Datensatz zum Entscheidungszeitpunkt (nil, wenn kein +// Werkzeug gewählt wurde, z. B. bei "abgelehnt") — ein späterer +// Katalog-Wandel darf nicht rückwirkend verändern, worauf die +// Entscheidung beruhte. +type Entscheidung struct { + ID string + AntragID string + BewertungID string + EntscheiderUserID string + Entscheidung string + WerkzeugID *string + WerkzeugSnapshot *Werkzeug + Begruendung string + GueltigBis *time.Time + CreatedAt time.Time +} + +const entscheidungColumns = `id, antrag_id, bewertung_id, entscheider_user_id, entscheidung, + werkzeug_id, werkzeug_snapshot, begruendung, gueltig_bis, created_at` + +func scanEntscheidung(row interface { + Scan(dest ...any) error +}) (Entscheidung, error) { + var e Entscheidung + var snapshotRaw []byte + err := row.Scan( + &e.ID, &e.AntragID, &e.BewertungID, &e.EntscheiderUserID, &e.Entscheidung, + &e.WerkzeugID, &snapshotRaw, &e.Begruendung, &e.GueltigBis, &e.CreatedAt, + ) + if err != nil { + return Entscheidung{}, err + } + if snapshotRaw != nil { + var w Werkzeug + if err := json.Unmarshal(snapshotRaw, &w); err != nil { + return Entscheidung{}, fmt.Errorf("store: werkzeug_snapshot unmarshal: %w", err) + } + e.WerkzeugSnapshot = &w + } + return e, nil +} + +// EntscheidungInput bündelt die Felder einer neuen Entscheidung. +type EntscheidungInput struct { + AntragID string + BewertungID string + EntscheiderUserID string + Entscheidung string + WerkzeugID *string + WerkzeugSnapshot *Werkzeug + Begruendung string + GueltigBis *time.Time +} + +// CreateEntscheidung speichert eine Entscheidung über einen Antrag. +func (s *Store) CreateEntscheidung(ctx context.Context, in EntscheidungInput) (Entscheidung, error) { + var snapshotJSON []byte + if in.WerkzeugSnapshot != nil { + var err error + snapshotJSON, err = json.Marshal(in.WerkzeugSnapshot) + if err != nil { + return Entscheidung{}, fmt.Errorf("store: werkzeug_snapshot marshal: %w", err) + } + } + + row := s.Pool.QueryRow(ctx, ` + INSERT INTO entscheidung ( + antrag_id, bewertung_id, entscheider_user_id, entscheidung, werkzeug_id, werkzeug_snapshot, begruendung, gueltig_bis + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING `+entscheidungColumns, + in.AntragID, in.BewertungID, in.EntscheiderUserID, in.Entscheidung, in.WerkzeugID, snapshotJSON, in.Begruendung, in.GueltigBis, + ) + e, err := scanEntscheidung(row) + if err != nil { + return Entscheidung{}, fmt.Errorf("store: create entscheidung: %w", err) + } + return e, nil +} + +// GetLatestEntscheidungForAntrag liefert die zuletzt getroffene +// Entscheidung eines Antrags. Liefert ErrNotFound, wenn noch keine +// Entscheidung existiert. +func (s *Store) GetLatestEntscheidungForAntrag(ctx context.Context, antragID string) (Entscheidung, error) { + row := s.Pool.QueryRow(ctx, ` + SELECT `+entscheidungColumns+` + FROM entscheidung WHERE antrag_id = $1 ORDER BY created_at DESC LIMIT 1 + `, antragID) + e, err := scanEntscheidung(row) + if errors.Is(err, pgx.ErrNoRows) { + return Entscheidung{}, ErrNotFound + } + if err != nil { + return Entscheidung{}, fmt.Errorf("store: get latest entscheidung: %w", err) + } + return e, nil +} diff --git a/internal/store/entscheidung_test.go b/internal/store/entscheidung_test.go new file mode 100644 index 0000000..0878b47 --- /dev/null +++ b/internal/store/entscheidung_test.go @@ -0,0 +1,115 @@ +package store_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/netcell-it/deklarix/internal/store" +) + +func testAntragMitBewertung(t *testing.T, s *store.Store, accountID, userID string) (store.Antrag, store.Bewertung) { + t.Helper() + ctx := context.Background() + antrag, err := s.CreateAntrag(ctx, accountID, userID, nil, "Testantrag") + if err != nil { + t.Fatalf("CreateAntrag: %v", err) + } + b, err := s.CreateBewertung(ctx, testBewertungInput(antrag.ID)) + if err != nil { + t.Fatalf("CreateBewertung: %v", err) + } + return antrag, b +} + +func TestEntscheidungCreateAndGetLatest(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + accID := testAccountID(t, s) + userID := testUserID(t, s, accID) + antrag, b := testAntragMitBewertung(t, s, accID, userID) + w, err := s.CreateWerkzeug(ctx, testWerkzeugInput(nil, "ChatGPT Enterprise")) + if err != nil { + t.Fatalf("CreateWerkzeug: %v", err) + } + gueltigBis := time.Now().Add(365 * 24 * time.Hour).Truncate(time.Millisecond) + + e, err := s.CreateEntscheidung(ctx, store.EntscheidungInput{ + AntragID: antrag.ID, BewertungID: b.ID, EntscheiderUserID: userID, + Entscheidung: "genehmigt", WerkzeugID: &w.ID, WerkzeugSnapshot: &w, + Begruendung: "", GueltigBis: &gueltigBis, + }) + if err != nil { + t.Fatalf("CreateEntscheidung: %v", err) + } + if e.Entscheidung != "genehmigt" || e.WerkzeugSnapshot == nil || e.WerkzeugSnapshot.Name != "ChatGPT Enterprise" { + t.Fatalf("CreateEntscheidung = %+v, unerwartete Werte", e) + } + + got, err := s.GetLatestEntscheidungForAntrag(ctx, antrag.ID) + if err != nil { + t.Fatalf("GetLatestEntscheidungForAntrag: %v", err) + } + if got.ID != e.ID { + t.Fatalf("GetLatestEntscheidungForAntrag returned a different row than CreateEntscheidung") + } +} + +func TestEntscheidungOhneWerkzeugHatKeinenSnapshot(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + accID := testAccountID(t, s) + userID := testUserID(t, s, accID) + antrag, b := testAntragMitBewertung(t, s, accID, userID) + + e, err := s.CreateEntscheidung(ctx, store.EntscheidungInput{ + AntragID: antrag.ID, BewertungID: b.ID, EntscheiderUserID: userID, + Entscheidung: "abgelehnt", Begruendung: "Kein geeignetes Werkzeug im Katalog", + }) + if err != nil { + t.Fatalf("CreateEntscheidung: %v", err) + } + if e.WerkzeugID != nil || e.WerkzeugSnapshot != nil { + t.Fatalf("expected no werkzeug/snapshot for an abgelehnt Entscheidung, got %+v", e) + } +} + +func TestGetLatestEntscheidungForAntragNotFound(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + accID := testAccountID(t, s) + userID := testUserID(t, s, accID) + antrag, err := s.CreateAntrag(ctx, accID, userID, nil, "Ohne Entscheidung") + if err != nil { + t.Fatalf("CreateAntrag: %v", err) + } + + _, err = s.GetLatestEntscheidungForAntrag(ctx, antrag.ID) + if !errors.Is(err, store.ErrNotFound) { + t.Fatalf("err = %v, want store.ErrNotFound", err) + } +} + +func TestEntscheidungIsAppendOnly(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + accID := testAccountID(t, s) + userID := testUserID(t, s, accID) + antrag, b := testAntragMitBewertung(t, s, accID, userID) + e, err := s.CreateEntscheidung(ctx, store.EntscheidungInput{ + AntragID: antrag.ID, BewertungID: b.ID, EntscheiderUserID: userID, Entscheidung: "rueckfrage", + }) + if err != nil { + t.Fatalf("CreateEntscheidung: %v", err) + } + + _, err = s.Pool.Exec(ctx, `UPDATE entscheidung SET entscheidung = 'genehmigt' WHERE id = $1`, e.ID) + if err == nil { + t.Fatal("expected UPDATE on entscheidung to be rejected by the append-only trigger") + } + _, err = s.Pool.Exec(ctx, `DELETE FROM entscheidung WHERE id = $1`, e.ID) + if err == nil { + t.Fatal("expected DELETE on entscheidung to be rejected by the append-only trigger") + } +} diff --git a/internal/store/migrations/0010_entscheidung.down.sql b/internal/store/migrations/0010_entscheidung.down.sql new file mode 100644 index 0000000..9c9db5d --- /dev/null +++ b/internal/store/migrations/0010_entscheidung.down.sql @@ -0,0 +1 @@ +DROP TABLE entscheidung; diff --git a/internal/store/migrations/0010_entscheidung.up.sql b/internal/store/migrations/0010_entscheidung.up.sql new file mode 100644 index 0000000..803ee1a --- /dev/null +++ b/internal/store/migrations/0010_entscheidung.up.sql @@ -0,0 +1,25 @@ +-- Entscheidung: der Mensch entscheidet, das System bereitet nur vor +-- (siehe CLAUDE.md, Grundregel). Eine Entscheidung bezieht sich auf +-- genau eine Bewertung (den Vorschlag, auf dessen Basis entschieden +-- wurde) und friert optional ein gewähltes Werkzeug als vollständigen +-- Datensatz ein (werkzeug_snapshot) — ein späterer Katalog-Wandel darf +-- nicht rückwirkend verändern, worauf eine Entscheidung beruhte. +-- Append-only wie bewertung/audit_log: eine Entscheidung wird nicht +-- korrigiert, sondern durch eine neue Entscheidung auf Basis einer +-- neuen Bewertung ersetzt (Neubewertung + erneute Entscheidung). +CREATE TABLE entscheidung ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + antrag_id UUID NOT NULL REFERENCES antrag (id), + bewertung_id UUID NOT NULL REFERENCES bewertung (id), + entscheider_user_id UUID NOT NULL REFERENCES app_user (id), + entscheidung TEXT NOT NULL CHECK (entscheidung IN ('genehmigt', 'genehmigt_mit_auflagen', 'abgelehnt', 'rueckfrage')), + werkzeug_id UUID REFERENCES werkzeug (id), + werkzeug_snapshot JSONB, + begruendung TEXT NOT NULL DEFAULT '', + gueltig_bis TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TRIGGER entscheidung_append_only + BEFORE UPDATE OR DELETE ON entscheidung + FOR EACH ROW EXECUTE FUNCTION forbid_update_delete(); diff --git a/internal/web/fachebene_handlers.go b/internal/web/fachebene_handlers.go new file mode 100644 index 0000000..cbce60f --- /dev/null +++ b/internal/web/fachebene_handlers.go @@ -0,0 +1,236 @@ +// Ebene 3 (Fachebene) — Posteingang offener Anträge und Entscheiden. +// Rollen "verantwortlicher" (Entscheidungsrecht) und "pruefer" +// (identische Sicht, kein Entscheidungsrecht) sehen dieselben Seiten; +// siehe requireFachebene in middleware.go und CLAUDE.md, Fünf Ebenen. +package web + +import ( + "net/http" + "time" + + "github.com/netcell-it/deklarix/internal/store" +) + +type fallListItem struct { + ID string + Titel string + Haeufigkeit string + CreatedAt string +} + +type fallListData struct { + Title string + Nav navData + Faelle []fallListItem +} + +// handleFaelleListe zeigt den Posteingang: alle noch offenen (Status +// "eingereicht") Anträge des ganzen Mandanten — im Unterschied zu +// GET /antraege, das nur die eigenen Anträge eines Nutzers zeigt. +func (s *Server) handleFaelleListe(w http.ResponseWriter, r *http.Request) { + antraege, err := s.store.ListAntraegeForAccount(r.Context(), currentUser(r).AccountID) + if err != nil { + http.Error(w, "Anträge konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError) + return + } + data := fallListData{Title: "Posteingang", Nav: navFor(r)} + for _, a := range antraege { + if a.Status != "eingereicht" { + continue + } + data.Faelle = append(data.Faelle, fallListItem{ + ID: a.ID, Titel: a.Titel, Haeufigkeit: a.Haeufigkeit, + CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"), + }) + } + if err := s.templates.ExecuteTemplate(w, "faelle-liste", data); err != nil { + http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) + } +} + +type entscheidungView struct { + Entscheidung string + Begruendung string + WerkzeugName string + GueltigBis string + CreatedAt string +} + +type werkzeugOption struct { + ID string + Name string +} + +type fallDetailData struct { + Title string + Nav navData + ID string + Titel string + Beschreibung string + Ergebnis string + Haeufigkeit string + Status string + CreatedAt string + Bewertung *bewertungView + Entscheidung *entscheidungView + CanEntscheiden bool + WerkzeugOptionen []werkzeugOption + Error string +} + +// vorschlagFuer leitet aus einer Bewertung ab, welche Entscheidung das +// System vorschlagen würde — nicht gespeichert, nur zur Bestimmung, ob +// eine tatsächliche Entscheidung davon abweicht (dann ist die +// Begründung Pflicht, siehe CLAUDE.md, Grundregel). +func vorschlagFuer(b store.Bewertung) string { + if b.Verboten { + return "abgelehnt" + } + if len(b.ZulaessigeWerkzeuge) > 0 { + return "genehmigt" + } + return "rueckfrage" +} + +// handleFallDetail zeigt einen Antrag mit Bewertung für die Fachebene +// — anders als GET /antraege/{id} (nur der eigene Antrag) ist hier +// jeder Antrag des Mandanten sichtbar, unabhängig vom Ersteller. +func (s *Server) handleFallDetail(w http.ResponseWriter, r *http.Request) { + antrag, err := s.store.GetAntrag(r.Context(), r.PathValue("id")) + if err != nil || antrag.AccountID != currentUser(r).AccountID { + http.Error(w, "Antrag nicht gefunden", http.StatusNotFound) + return + } + + data := fallDetailData{ + Title: "Fall", Nav: navFor(r), ID: antrag.ID, Titel: antrag.Titel, + Beschreibung: antrag.Beschreibung, Ergebnis: antrag.Ergebnis, Haeufigkeit: antrag.Haeufigkeit, + Status: antrag.Status, CreatedAt: antrag.CreatedAt.Format("02.01.2006 15:04"), + } + + bewertung, err := s.store.GetLatestBewertungForAntrag(r.Context(), antrag.ID) + if err == nil { + data.Bewertung = s.toBewertungView(r.Context(), bewertung) + for _, id := range bewertung.ZulaessigeWerkzeuge { + data.WerkzeugOptionen = append(data.WerkzeugOptionen, werkzeugOption{ID: id, Name: s.werkzeugName(r.Context(), id)}) + } + } + data.CanEntscheiden = currentUser(r).Role == "verantwortlicher" && antrag.Status == "eingereicht" && data.Bewertung != nil + + entscheidung, err := s.store.GetLatestEntscheidungForAntrag(r.Context(), antrag.ID) + if err == nil { + v := &entscheidungView{ + Entscheidung: entscheidung.Entscheidung, Begruendung: entscheidung.Begruendung, + CreatedAt: entscheidung.CreatedAt.Format("02.01.2006 15:04"), + } + if entscheidung.WerkzeugSnapshot != nil { + v.WerkzeugName = entscheidung.WerkzeugSnapshot.Name + } + if entscheidung.GueltigBis != nil { + v.GueltigBis = entscheidung.GueltigBis.Format("02.01.2006") + } + data.Entscheidung = v + } + + if err := s.templates.ExecuteTemplate(w, "fall-detail", data); err != nil { + http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) + } +} + +// gueltigkeitFuer liefert das Ablaufdatum einer Genehmigung — 12 Monate, +// bei hochrisiko 6 (siehe CLAUDE.md, Entscheidung/Register/Wiedervorlage). +func gueltigkeitFuer(einstufung string) time.Time { + if einstufung == "hochrisiko" { + return time.Now().AddDate(0, 6, 0) + } + return time.Now().AddDate(1, 0, 0) +} + +// handleFallEntscheiden speichert die Entscheidung eines/einer +// Verantwortlichen. Prüfer haben dieselbe Sicht, aber kein +// Entscheidungsrecht — anders als bei requireFachebene (404, weil die +// Seite für sie gar nicht existieren soll) ist das hier ein 403: ein +// Prüfer darf wissen, dass der Fall existiert, nur nicht entscheiden. +func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) { + user := currentUser(r) + if user.Role != "verantwortlicher" { + http.Error(w, "kein Entscheidungsrecht", http.StatusForbidden) + return + } + + antrag, err := s.store.GetAntrag(r.Context(), r.PathValue("id")) + if err != nil || antrag.AccountID != user.AccountID { + http.Error(w, "Antrag nicht gefunden", http.StatusNotFound) + return + } + if antrag.Status != "eingereicht" { + http.Error(w, "Antrag ist bereits entschieden", http.StatusBadRequest) + return + } + bewertung, err := s.store.GetLatestBewertungForAntrag(r.Context(), antrag.ID) + if err != nil { + http.Error(w, "Bewertung nicht gefunden", http.StatusNotFound) + return + } + + if err := r.ParseForm(); err != nil { + http.Error(w, "ungültiges Formular", http.StatusBadRequest) + return + } + entscheidungTyp := r.FormValue("entscheidung") + begruendung := r.FormValue("begruendung") + werkzeugID := r.FormValue("werkzeug_id") + + switch entscheidungTyp { + case "genehmigt", "genehmigt_mit_auflagen", "abgelehnt", "rueckfrage": + default: + http.Error(w, "ungültige Entscheidung", http.StatusBadRequest) + return + } + if vorschlagFuer(bewertung) != entscheidungTyp && begruendung == "" { + http.Error(w, "Begründung erforderlich, wenn die Entscheidung vom Vorschlag abweicht", http.StatusBadRequest) + return + } + + in := store.EntscheidungInput{ + AntragID: antrag.ID, BewertungID: bewertung.ID, EntscheiderUserID: user.ID, + Entscheidung: entscheidungTyp, Begruendung: begruendung, + } + if entscheidungTyp == "genehmigt" || entscheidungTyp == "genehmigt_mit_auflagen" { + zulaessig := false + for _, id := range bewertung.ZulaessigeWerkzeuge { + if id == werkzeugID { + zulaessig = true + break + } + } + if werkzeugID == "" || !zulaessig { + http.Error(w, "bei Genehmigung muss ein zulässiges Werkzeug gewählt werden", http.StatusBadRequest) + return + } + werkzeug, err := s.store.GetWerkzeug(r.Context(), werkzeugID) + if err != nil { + http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound) + return + } + in.WerkzeugID = &werkzeugID + in.WerkzeugSnapshot = &werkzeug + gueltigBis := gueltigkeitFuer(bewertung.Einstufung) + in.GueltigBis = &gueltigBis + } + + if _, err := s.store.CreateEntscheidung(r.Context(), in); err != nil { + http.Error(w, "Entscheidung konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError) + return + } + if err := s.store.SetAntragStatus(r.Context(), antrag.ID, "entschieden"); err != nil { + http.Error(w, "Antrag konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError) + return + } + if _, err := s.store.CreateAuditEntry(r.Context(), user.ID, "antrag_entschieden", "antrag", antrag.ID, entscheidungTyp); err != nil { + http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError) + return + } + + http.Redirect(w, r, "/faelle/"+antrag.ID, http.StatusSeeOther) +} diff --git a/internal/web/fachebene_handlers_test.go b/internal/web/fachebene_handlers_test.go new file mode 100644 index 0000000..df4fad9 --- /dev/null +++ b/internal/web/fachebene_handlers_test.go @@ -0,0 +1,216 @@ +package web_test + +import ( + "context" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/netcell-it/deklarix/internal/store" + "github.com/netcell-it/deklarix/internal/web" +) + +// seedFallImAccount legt einen Mandanten mit einem Mitarbeiter an, der +// einen Antrag einreicht (mit personenbezogener Datenklasse, Einstufung +// minimal — siehe fullAntragForm), und liefert dessen ID sowie eine +// zweite Cookie für einen weiteren Nutzer der Rolle role im selben +// Mandanten (verantwortlicher/pruefer). +func seedFallImAccount(t *testing.T, fs *fakeStore, s *web.Server, role string) (antragID string, fachebeneCookie *http.Cookie) { + t.Helper() + ctx := context.Background() + acc, err := fs.CreateAccount(ctx, "Fachebene-Mandant") + if err != nil { + t.Fatalf("CreateAccount: %v", err) + } + mitarbeiterCookie := seedUserInAccount(t, fs, acc.ID, "mitarbeiter@example.com", "mitarbeiter") + resp := postForm(t, s, mitarbeiterCookie, "/antraege", fullAntragForm()) + if resp.Code != http.StatusSeeOther { + t.Fatalf("Antrag anlegen: status = %d, body: %s", resp.Code, resp.Body.String()) + } + for id := range fs.antraege { + if fs.antraege[id].AccountID == acc.ID { + antragID = id + } + } + fachebeneCookie = seedUserInAccount(t, fs, acc.ID, role+"@example.com", role) + return antragID, fachebeneCookie +} + +func TestMitarbeiterCannotAccessFaelle(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + cookie := seedAccount(t, fs, "Test-Mandant", "mitarbeiter@example.com") + + resp := getWithCookie(t, s, cookie, "/faelle") + if resp.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404 for role mitarbeiter on /faelle", resp.Code) + } +} + +func TestFaelleListeShowsOpenAntraegeForAccount(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + _, verantwortlicherCookie := seedFallImAccount(t, fs, s, "verantwortlicher") + + resp := getWithCookie(t, s, verantwortlicherCookie, "/faelle") + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String()) + } + if !strings.Contains(resp.Body.String(), "Angebotstexte generieren") { + t.Errorf("expected the open antrag in the Posteingang, got: %s", resp.Body.String()) + } + + // Ein Verantwortlicher eines anderen Mandanten darf ihn nicht sehen. + otherAcc, err := fs.CreateAccount(context.Background(), "Anderer Mandant") + if err != nil { + t.Fatalf("CreateAccount: %v", err) + } + otherCookie := seedUserInAccount(t, fs, otherAcc.ID, "other-verantwortlicher@example.com", "verantwortlicher") + otherResp := getWithCookie(t, s, otherCookie, "/faelle") + if strings.Contains(otherResp.Body.String(), "Angebotstexte generieren") { + t.Errorf("expected no cross-tenant antrag in the Posteingang, got: %s", otherResp.Body.String()) + } +} + +func TestFallDetailShowsDecideFormOnlyForVerantwortlicher(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + antragID, verantwortlicherCookie := seedFallImAccount(t, fs, s, "verantwortlicher") + + resp := getWithCookie(t, s, verantwortlicherCookie, "/faelle/"+antragID) + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String()) + } + if !strings.Contains(resp.Body.String(), `name="entscheidung"`) { + t.Errorf("expected the Entscheiden form for verantwortlicher, got: %s", resp.Body.String()) + } +} + +func TestFallDetailHidesDecideFormForPruefer(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + antragID, prueferCookie := seedFallImAccount(t, fs, s, "pruefer") + + resp := getWithCookie(t, s, prueferCookie, "/faelle/"+antragID) + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String()) + } + if strings.Contains(resp.Body.String(), `name="entscheidung"`) { + t.Errorf("expected no Entscheiden form for pruefer, got: %s", resp.Body.String()) + } +} + +func TestFallEntscheidenRejectsPruefer(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + antragID, prueferCookie := seedFallImAccount(t, fs, s, "pruefer") + + resp := postForm(t, s, prueferCookie, "/faelle/"+antragID+"/entscheiden", url.Values{"entscheidung": {"rueckfrage"}}) + if resp.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403 for pruefer", resp.Code) + } +} + +// TestFallEntscheidenRequiresBegruendungOnAbweichung: fullAntragForm +// liefert keine passenden Werkzeuge im (leeren) Katalog, der Vorschlag +// ist also "rueckfrage" (siehe vorschlagFuer). "abgelehnt" weicht davon +// ab und braucht eine Begründung. +func TestFallEntscheidenRequiresBegruendungOnAbweichung(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + antragID, verantwortlicherCookie := seedFallImAccount(t, fs, s, "verantwortlicher") + + resp := postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{"entscheidung": {"abgelehnt"}}) + if resp.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 ohne Begründung bei Abweichung, body: %s", resp.Code, resp.Body.String()) + } + + resp = postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{ + "entscheidung": {"abgelehnt"}, "begruendung": {"Kein Werkzeug im Katalog verfügbar, trotzdem vertretbar"}, + }) + if resp.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303 mit Begründung, body: %s", resp.Code, resp.Body.String()) + } +} + +func TestFallEntscheidenRueckfrageOhneAbweichungBrauchtKeineBegruendung(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + antragID, verantwortlicherCookie := seedFallImAccount(t, fs, s, "verantwortlicher") + + resp := postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{"entscheidung": {"rueckfrage"}}) + if resp.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303 (rueckfrage entspricht dem Vorschlag bei leerem Katalog), body: %s", resp.Code, resp.Body.String()) + } + + detail := getWithCookie(t, s, verantwortlicherCookie, "/faelle/"+antragID) + if !strings.Contains(detail.Body.String(), "rueckfrage") { + t.Errorf("expected the Entscheidung on the fall-detail page, got: %s", detail.Body.String()) + } +} + +func TestFallEntscheidenGenehmigtBrauchtZulaessigesWerkzeug(t *testing.T) { + fs := newFakeStore() + acc, err := fs.CreateAccount(context.Background(), "Mit Katalog") + if err != nil { + t.Fatalf("CreateAccount: %v", err) + } + mitarbeiterCookie := seedUserInAccount(t, fs, acc.ID, "mitarbeiter@example.com", "mitarbeiter") + fs.werkzeuge["werkzeug-ok"] = store.Werkzeug{ID: "werkzeug-ok", Name: "ChatGPT Enterprise", AVVVerfuegbar: true, TrainingStandard: true} + s := newServer(t, fs) + createResp := postForm(t, s, mitarbeiterCookie, "/antraege", fullAntragForm()) + if createResp.Code != http.StatusSeeOther { + t.Fatalf("Antrag anlegen: status = %d, body: %s", createResp.Code, createResp.Body.String()) + } + var antragID string + for id := range fs.antraege { + if fs.antraege[id].AccountID == acc.ID { + antragID = id + } + } + verantwortlicherCookie := seedUserInAccount(t, fs, acc.ID, "verantwortlicher@example.com", "verantwortlicher") + + // Ohne werkzeug_id: abgelehnt. + resp := postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{"entscheidung": {"genehmigt"}}) + if resp.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 ohne Werkzeug bei Genehmigung", resp.Code) + } + + // Mit zulässigem Werkzeug: erfolgreich, Antrag wird "entschieden". + resp = postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{ + "entscheidung": {"genehmigt"}, "werkzeug_id": {"werkzeug-ok"}, + }) + if resp.Code != http.StatusSeeOther { + t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String()) + } + antrag, err := fs.GetAntrag(context.Background(), antragID) + if err != nil { + t.Fatalf("GetAntrag: %v", err) + } + if antrag.Status != "entschieden" { + t.Errorf("Status = %q, want entschieden", antrag.Status) + } + + detail := getWithCookie(t, s, verantwortlicherCookie, "/faelle/"+antragID) + if !strings.Contains(detail.Body.String(), "ChatGPT Enterprise") { + t.Errorf("expected the chosen werkzeug name on the fall-detail page, got: %s", detail.Body.String()) + } +} + +func TestFallDetailRejectsForeignAccount(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + antragID, _ := seedFallImAccount(t, fs, s, "verantwortlicher") + + otherAcc, err := fs.CreateAccount(context.Background(), "Fremder Mandant") + if err != nil { + t.Fatalf("CreateAccount: %v", err) + } + otherCookie := seedUserInAccount(t, fs, otherAcc.ID, "fremd@example.com", "verantwortlicher") + + resp := getWithCookie(t, s, otherCookie, "/faelle/"+antragID) + if resp.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404 for a foreign account's fall", resp.Code) + } +} diff --git a/internal/web/middleware.go b/internal/web/middleware.go index f3cac7b..86cb158 100644 --- a/internal/web/middleware.go +++ b/internal/web/middleware.go @@ -89,19 +89,44 @@ func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc { } } +// requireFachebene schützt die Fachebene (Ebene 3 — Posteingang und +// Entscheiden, Rollen "verantwortlicher" und "pruefer", siehe +// CLAUDE.md). Beide Rollen sehen dieselbe Sicht; ob innerhalb der +// Seite tatsächlich entschieden werden darf, prüft der einzelne +// Handler (nur "verantwortlicher" hat Entscheidungsrecht). Wie bei +// requireBetreiber: 404 statt 403 bei falscher Rolle, damit ein +// angemeldeter, aber unprivilegierter Nutzer (z. B. "mitarbeiter") +// nicht erfährt, dass es die Seite überhaupt gibt. +func (s *Server) requireFachebene(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + user, ok := s.authenticate(r) + if !ok { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + if user.Role != "verantwortlicher" && user.Role != "pruefer" { + http.Error(w, "nicht gefunden", http.StatusNotFound) + return + } + next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user))) + } +} + // navData steuert die gemeinsame Navigation (layout.html, "nav"-Block). // Eigenes, kleines Struct statt jeder Seite Zugriff auf den vollen // currentUser zu geben — die Navigation braucht nur, ob ein // Plattform-Link gezeigt werden soll. type navData struct { IsBetreiber bool + IsFachebene bool } // navFor liefert die Nav-Daten für den angemeldeten Nutzer der Anfrage. // Nur für Seiten hinter requirePage/requireBetreiber aufrufbar (braucht // currentUser). func navFor(r *http.Request) navData { - return navData{IsBetreiber: currentUser(r).Role == "betreiber"} + role := currentUser(r).Role + return navData{IsBetreiber: role == "betreiber", IsFachebene: role == "verantwortlicher" || role == "pruefer"} } // currentUser liest den Nutzer, den requirePage/requireAPI in den diff --git a/internal/web/server.go b/internal/web/server.go index 7147e44..2bc0cd5 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -47,12 +47,16 @@ type Store interface { UpdateAntragFelder(ctx context.Context, id, titel, beschreibung, ergebnis, haeufigkeit string, antworten []byte) (store.Antrag, error) SetAntragStatus(ctx context.Context, id, status string) error ListAntraegeForUser(ctx context.Context, erstellerUserID string) ([]store.Antrag, error) + ListAntraegeForAccount(ctx context.Context, accountID string) ([]store.Antrag, error) ListWerkzeugeForAccount(ctx context.Context, accountID string) ([]store.Werkzeug, error) GetWerkzeug(ctx context.Context, id string) (store.Werkzeug, error) CurrentKatalogVersion(ctx context.Context) (string, error) CreateBewertung(ctx context.Context, in store.BewertungInput) (store.Bewertung, error) GetLatestBewertungForAntrag(ctx context.Context, antragID string) (store.Bewertung, error) + + CreateEntscheidung(ctx context.Context, in store.EntscheidungInput) (store.Entscheidung, error) + GetLatestEntscheidungForAntrag(ctx context.Context, antragID string) (store.Entscheidung, error) } // Regelwerk bündelt die drei geladenen Regelwerk-Dateien, die die @@ -99,6 +103,9 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) { mux.HandleFunc("GET /antraege/neu", s.requirePage(s.handleAntragNewForm)) mux.HandleFunc("POST /antraege", s.requirePage(s.handleAntragCreate)) mux.HandleFunc("GET /antraege/{id}", s.requirePage(s.handleAntragDetail)) + mux.HandleFunc("GET /faelle", s.requireFachebene(s.handleFaelleListe)) + mux.HandleFunc("GET /faelle/{id}", s.requireFachebene(s.handleFallDetail)) + mux.HandleFunc("POST /faelle/{id}/entscheiden", s.requireFachebene(s.handleFallEntscheiden)) mux.HandleFunc("GET /betreiber", s.requireBetreiber(s.handleBetreiberDashboard)) mux.HandleFunc("GET /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountList)) mux.HandleFunc("GET /betreiber/accounts/{id}", s.requireBetreiber(s.handleBetreiberAccountDetail)) diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 8ad9c9e..b5bfc8d 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -24,29 +24,31 @@ const testSessionCookie = "deklarix_session" // fakeStore ist eine In-Memory-Implementierung von web.Store, damit die // Handler-Tests keine echte Postgres-Instanz brauchen. type fakeStore struct { - mu sync.Mutex - nextID int - accounts map[string]store.Account - users map[string]store.User - usersByEmail map[string]string // email -> user id - sessions map[string]store.Session - auditLog []store.AuditEntry - abteilungen map[string][]store.Abteilung // accountID -> Abteilungen - antraege map[string]store.Antrag - werkzeuge map[string]store.Werkzeug - bewertungen map[string][]store.Bewertung // antragID -> Bewertungen, älteste zuerst + mu sync.Mutex + nextID int + accounts map[string]store.Account + users map[string]store.User + usersByEmail map[string]string // email -> user id + sessions map[string]store.Session + auditLog []store.AuditEntry + abteilungen map[string][]store.Abteilung // accountID -> Abteilungen + antraege map[string]store.Antrag + werkzeuge map[string]store.Werkzeug + bewertungen map[string][]store.Bewertung // antragID -> Bewertungen, älteste zuerst + entscheidungen map[string][]store.Entscheidung // antragID -> Entscheidungen, älteste zuerst } func newFakeStore() *fakeStore { return &fakeStore{ - accounts: map[string]store.Account{}, - users: map[string]store.User{}, - usersByEmail: map[string]string{}, - sessions: map[string]store.Session{}, - abteilungen: map[string][]store.Abteilung{}, - antraege: map[string]store.Antrag{}, - werkzeuge: map[string]store.Werkzeug{}, - bewertungen: map[string][]store.Bewertung{}, + accounts: map[string]store.Account{}, + users: map[string]store.User{}, + usersByEmail: map[string]string{}, + sessions: map[string]store.Session{}, + abteilungen: map[string][]store.Abteilung{}, + antraege: map[string]store.Antrag{}, + werkzeuge: map[string]store.Werkzeug{}, + bewertungen: map[string][]store.Bewertung{}, + entscheidungen: map[string][]store.Entscheidung{}, } } @@ -243,6 +245,18 @@ func (f *fakeStore) ListAntraegeForUser(ctx context.Context, erstellerUserID str return out, nil } +func (f *fakeStore) ListAntraegeForAccount(ctx context.Context, accountID string) ([]store.Antrag, error) { + f.mu.Lock() + defer f.mu.Unlock() + var out []store.Antrag + for _, a := range f.antraege { + if a.AccountID == accountID { + out = append(out, a) + } + } + return out, nil +} + func (f *fakeStore) ListWerkzeugeForAccount(ctx context.Context, accountID string) ([]store.Werkzeug, error) { f.mu.Lock() defer f.mu.Unlock() @@ -297,6 +311,28 @@ func (f *fakeStore) GetLatestBewertungForAntrag(ctx context.Context, antragID st return bs[len(bs)-1], nil } +func (f *fakeStore) CreateEntscheidung(ctx context.Context, in store.EntscheidungInput) (store.Entscheidung, error) { + f.mu.Lock() + defer f.mu.Unlock() + e := store.Entscheidung{ + ID: f.newID(), AntragID: in.AntragID, BewertungID: in.BewertungID, EntscheiderUserID: in.EntscheiderUserID, + Entscheidung: in.Entscheidung, WerkzeugID: in.WerkzeugID, WerkzeugSnapshot: in.WerkzeugSnapshot, + Begruendung: in.Begruendung, GueltigBis: in.GueltigBis, CreatedAt: time.Now(), + } + f.entscheidungen[in.AntragID] = append(f.entscheidungen[in.AntragID], e) + return e, nil +} + +func (f *fakeStore) GetLatestEntscheidungForAntrag(ctx context.Context, antragID string) (store.Entscheidung, error) { + f.mu.Lock() + defer f.mu.Unlock() + es := f.entscheidungen[antragID] + if len(es) == 0 { + return store.Entscheidung{}, store.ErrNotFound + } + return es[len(es)-1], nil +} + // ─── Test-Setup ─────────────────────────────────────────────────────── // loadTestRegelwerk lädt die echten rules/*.yaml-Dateien — dieselben, @@ -358,6 +394,31 @@ func seedAccountWithRole(t *testing.T, fs *fakeStore, accountName, email, role s return &http.Cookie{Name: testSessionCookie, Value: token} } +// seedUserInAccount legt einen weiteren Nutzer in einem bereits +// existierenden Account an — für Tests, die mehrere Rollen im selben +// Mandanten brauchen (z. B. mitarbeiter stellt einen Antrag, +// verantwortlicher entscheidet darüber). +func seedUserInAccount(t *testing.T, fs *fakeStore, accountID, email, role string) *http.Cookie { + t.Helper() + ctx := context.Background() + hash, err := auth.HashPassword("test-passwort-123") + if err != nil { + t.Fatalf("HashPassword: %v", err) + } + user, err := fs.CreateUser(ctx, accountID, email, hash, role) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + token, err := auth.NewSessionToken() + if err != nil { + t.Fatalf("NewSessionToken: %v", err) + } + if _, err := fs.CreateSession(ctx, token, user.ID, time.Now().Add(time.Hour)); err != nil { + t.Fatalf("CreateSession: %v", err) + } + return &http.Cookie{Name: testSessionCookie, Value: token} +} + func seedAccount(t *testing.T, fs *fakeStore, accountName, email string) *http.Cookie { t.Helper() return seedAccountWithRole(t, fs, accountName, email, "mitarbeiter") diff --git a/internal/web/templates/antrag_detail.html b/internal/web/templates/antrag_detail.html index 48a025e..774f6e6 100644 --- a/internal/web/templates/antrag_detail.html +++ b/internal/web/templates/antrag_detail.html @@ -14,60 +14,7 @@

Häufigkeit: {{.Haeufigkeit}}

{{if .Bewertung}} -

Bewertung

- -{{if .Bewertung.Verboten}} -

Verboten. Diese Nutzung fällt laut Einstufung unter ein verbotenes KI-Praktiken-Muster der KI-VO ({{.Bewertung.Einstufung}}). {{.Bewertung.EinstufungHerleitung}}

-{{end}} - -

Datenklasse: {{.Bewertung.Datenklasse}}
-{{.Bewertung.DatenklasseHerleitung}}

- -

KI-VO-Einstufung: {{.Bewertung.Einstufung}}
-{{.Bewertung.EinstufungHerleitung}}

- -{{if .Bewertung.Anforderungen}} -

Anforderungen

- -{{end}} - -{{if not .Bewertung.Verboten}} -

Zulässige Werkzeuge

-{{if .Bewertung.ZulaessigeWerkzeuge}} - -{{else}} -

Kein Werkzeug im Katalog erfüllt aktuell alle Anforderungen dieses Antrags.

-{{end}} - -{{if .Bewertung.AusgeschlosseneWerkzeuge}} -

Ausgeschlossene Werkzeuge

- -{{end}} -{{end}} - -

- Das System entscheidet nicht. Es bereitet vor. Diese Einstufung ist ein - Vorschlag zur Herleitung von Datenklasse, KI-VO-Einstufung und - Anforderungen (Regelwerk {{.Bewertung.RegelwerkVersion}}, Katalog - {{.Bewertung.KatalogVersion}}, berechnet am {{.Bewertung.CreatedAt}}) — - die Entscheidung trifft ein Mensch. -

+{{template "bewertung-block" .Bewertung}} {{else}}

Die Bewertung wird berechnet, sobald der Antrag eingereicht ist.

{{end}} diff --git a/internal/web/templates/faelle_liste.html b/internal/web/templates/faelle_liste.html new file mode 100644 index 0000000..380565c --- /dev/null +++ b/internal/web/templates/faelle_liste.html @@ -0,0 +1,24 @@ +{{define "faelle-liste"}} + +{{template "head" .}} + +{{template "nav" .Nav}} +
+

Posteingang

+{{if not .Faelle}} +

Keine offenen Anträge — alles bearbeitet.

+{{else}} + +{{end}} +
+ + +{{end}} diff --git a/internal/web/templates/fall_detail.html b/internal/web/templates/fall_detail.html new file mode 100644 index 0000000..e74924e --- /dev/null +++ b/internal/web/templates/fall_detail.html @@ -0,0 +1,60 @@ +{{define "fall-detail"}} + +{{template "head" .}} + +{{template "nav" .Nav}} +
+

← Posteingang

+

{{.Titel}}

+

Eingereicht am {{.CreatedAt}} — Status: {{.Status}}

+ +

Vorhaben

+

Was soll die KI tun?
{{.Beschreibung}}

+

Was soll herauskommen?
{{.Ergebnis}}

+

Häufigkeit: {{.Haeufigkeit}}

+ +{{if .Bewertung}} +{{template "bewertung-block" .Bewertung}} +{{else}} +

Für diesen Antrag liegt noch keine Bewertung vor.

+{{end}} + +{{if .Entscheidung}} +

Entscheidung

+

{{.Entscheidung.Entscheidung}}{{if .Entscheidung.WerkzeugName}} — {{.Entscheidung.WerkzeugName}}{{end}}
+am {{.Entscheidung.CreatedAt}}{{if .Entscheidung.GueltigBis}}, gültig bis {{.Entscheidung.GueltigBis}}{{end}}

+{{if .Entscheidung.Begruendung}}

{{.Entscheidung.Begruendung}}

{{end}} +{{else if .CanEntscheiden}} +

Entscheiden

+{{if .Error}}

{{.Error}}

{{end}} +
+
+ Entscheidung + + + + +
+ + {{if .WerkzeugOptionen}} + + {{end}} + + + + +
+{{else}} +

Noch keine Entscheidung getroffen. Nur die Rolle "Verantwortlicher" kann entscheiden.

+{{end}} +
+ + +{{end}} diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index d50e930..5f4d40d 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -10,9 +10,72 @@ {{end}} + +{{/* bewertung-block rendert eine Bewertung (Datenklasse, KI-VO- + Einstufung, Anforderungen, Werkzeuge, Pflicht-Disclaimer) — gemeinsam + genutzt von antrag_detail.html (Ebene 2, nur Ansicht) und + fall_detail.html (Ebene 3, mit Entscheiden-Formular). Erwartet eine + *bewertungView als . */}} +{{define "bewertung-block"}} +

Bewertung

+ +{{if .Verboten}} +

Verboten. Diese Nutzung fällt laut Einstufung unter ein verbotenes KI-Praktiken-Muster der KI-VO ({{.Einstufung}}). {{.EinstufungHerleitung}}

+{{end}} + +

Datenklasse: {{.Datenklasse}}
+{{.DatenklasseHerleitung}}

+ +

KI-VO-Einstufung: {{.Einstufung}}
+{{.EinstufungHerleitung}}

+ +{{if .Anforderungen}} +

Anforderungen

+ +{{end}} + +{{if not .Verboten}} +

Zulässige Werkzeuge

+{{if .ZulaessigeWerkzeuge}} + +{{else}} +

Kein Werkzeug im Katalog erfüllt aktuell alle Anforderungen dieses Antrags.

+{{end}} + +{{if .AusgeschlosseneWerkzeuge}} +

Ausgeschlossene Werkzeuge

+ +{{end}} +{{end}} + +

+ Das System entscheidet nicht. Es bereitet vor. Diese Einstufung ist ein + Vorschlag zur Herleitung von Datenklasse, KI-VO-Einstufung und + Anforderungen (Regelwerk {{.RegelwerkVersion}}, Katalog + {{.KatalogVersion}}, berechnet am {{.CreatedAt}}) — + die Entscheidung trifft ein Mensch. +

+{{end}}