// 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 ( "encoding/json" "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 Antworten []antwortZeile Bewertung *bewertungView Entscheidung *entscheidungView CanEntscheiden bool WerkzeugOptionen []werkzeugOption Error string } // hatEntscheidungsrecht — "admin" ist bewusst mit eingeschlossen, siehe // requireFachebene: die Spezifikation will admin+verantwortlicher auf // derselben Person, ohne dass app_user.role zwei Werte tragen kann. func hatEntscheidungsrecht(role string) bool { return role == "verantwortlicher" || role == "admin" } // 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 } var antworten map[string]any if len(antrag.Antworten) > 0 { _ = json.Unmarshal(antrag.Antworten, &antworten) } 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"), Antworten: antwortenAnzeige(antworten), } 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 = hatEntscheidungsrecht(currentUser(r).Role) && 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 (oder admin, siehe hatEntscheidungsrecht). 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 !hatEntscheidungsrecht(user.Role) { 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 } istGenehmigung := entscheidungTyp == "genehmigt" || entscheidungTyp == "genehmigt_mit_auflagen" in := store.EntscheidungInput{ AntragID: antrag.ID, BewertungID: bewertung.ID, EntscheiderUserID: user.ID, Entscheidung: entscheidungTyp, Begruendung: begruendung, } var werkzeugName string if istGenehmigung { 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 } werkzeugName = werkzeug.Name in.WerkzeugID = &werkzeugID in.WerkzeugSnapshot = &werkzeug gueltigBis := gueltigkeitFuer(bewertung.Einstufung) in.GueltigBis = &gueltigBis } entscheidung, err := s.store.CreateEntscheidung(r.Context(), in) if 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 } // Jede Genehmigung erzeugt automatisch einen Registereintrag (siehe // CLAUDE.md) — bei "abgelehnt"/"rueckfrage" gibt es nichts // Genehmigtes, das im Register stehen müsste. if istGenehmigung { abteilungName := "" if antrag.AbteilungID != nil { if abt, err := s.store.GetAbteilung(r.Context(), *antrag.AbteilungID); err == nil { abteilungName = abt.Name } } var auflagen []string for _, a := range bewertung.Anforderungen { auflagen = append(auflagen, a.Beschreibung) } if _, err := s.store.CreateRegistereintrag(r.Context(), store.RegistereintragInput{ AccountID: user.AccountID, AntragID: antrag.ID, EntscheidungID: entscheidung.ID, Zweck: antrag.Titel, Abteilung: abteilungName, Werkzeug: werkzeugName, Datenklasse: bewertung.Datenklasse, Einstufung: bewertung.Einstufung, Auflagen: auflagen, Verantwortlicher: user.Email, EntschiedenAm: entscheidung.CreatedAt, GueltigBis: entscheidung.GueltigBis, }); err != nil { http.Error(w, "Registereintrag konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError) return } } http.Redirect(w, r, "/faelle/"+antrag.ID, http.StatusSeeOther) }