Ebene 2 ("Antrag stellen") als einseitiges Formular statt mehrseitigem
Assistenten — jede zusätzliche Seite kostet Zeit, und laut Spezifikation
wird ein Antrag umgangen, wenn er länger als fünf Minuten dauert.
- GET /antraege/neu: Fragebogen-Formular (Abschnitte A-D exakt nach
Spezifikation). Adaptive Folgefragen (C2: welche Art von Entscheidung,
C3: welche Art von Erkennung) werden rein per CSS :has() ein-/
ausgeblendet, kein JavaScript nötig — visuell mit Chromium-Screenshots
verifiziert (unchecked vs. checked).
- POST /antraege: legt an und reicht direkt ein (entwurf->eingereicht in
einem Schritt, kein Zwischenspeichern als Entwurf für v1). Die
antworten-JSON nutzt exakt dieselben Fakten-Schlüssel wie
rules/*.yaml (b1-b7, c1-c5, c2_folge, c3_art) — Schritt 3 kann sie
direkt auswerten, ohne Felder umzubenennen. "Unsicher" wird bewusst
NICHT zu "ja" normalisiert (das ist eine Auswertungsregel für Schritt
3, keine Speicherregel) — der Antrag hält fest, was der Mitarbeiter
tatsächlich geantwortet hat.
- GET /antraege: eigene Anträge (Ebene 2 sieht nur eigene, nicht die
des ganzen Mandanten — dafür neue Store-Methode
ListAntraegeForUser, getrennt von ListAntraegeForAccount für den
späteren Ebene-3-Posteingang).
- GET /antraege/{id}: Detail, fremder Antrag liefert 404 (nicht 403,
gleiches Muster wie überall sonst im Projekt).
- betreiber-Rolle wird von allen Antrags-Routen weggeleitet (Ebene 5
ist technisch getrennt vom Mandantenbereich).
rules/OPEN.md Punkt 3 (fehlende Fragebogen-Unterscheidung für die
"verboten"-Varianten) damit geklärt: c3_art existiert jetzt mit den
bereits in rules/kivo_einstufung.yaml erwarteten Werten.
Volle Testsuite inkl. echter Postgres-Tests grün; End-to-End gegen
einen laufenden Server verifiziert (Antrag anlegen, antworten-JSON in
der DB korrekt, Detail-/Listenansicht, Mandantentrennung).
214 lines
7.6 KiB
Go
214 lines
7.6 KiB
Go
package web
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
|
|
"github.com/netcell-it/deklarix/internal/store"
|
|
)
|
|
|
|
// antwortenB/C/D-Feldnamen sind bewusst identisch zu den Fakten-
|
|
// Schlüsseln in rules/datenklasse.yaml (b1..b7) und
|
|
// rules/kivo_einstufung.yaml (c1..c5, c2_folge, c3_art) — Schritt 3
|
|
// (Ableitungen) liest dieselben Schlüssel direkt aus antrag.antworten,
|
|
// ohne sie erst umbenennen zu müssen.
|
|
var datenfragen = []struct {
|
|
Key string
|
|
Label string
|
|
}{
|
|
{"b1", "Namen, E-Mail-Adressen oder andere Angaben zu Personen"},
|
|
{"b2", "Angaben zu Gesundheit, Religion, Herkunft, Gewerkschaft, Sexualleben"},
|
|
{"b3", "Daten von Patienten, Mandanten oder Versicherten"},
|
|
{"b4", "Bewerbungs-, Personal- oder Leistungsdaten von Beschäftigten"},
|
|
{"b5", "Daten, die uns ein Kunde anvertraut hat"},
|
|
{"b6", "Konstruktions-, Kalkulations- oder Vertragsunterlagen"},
|
|
{"b7", "Nur allgemein zugängliche oder selbst erfundene Inhalte"},
|
|
}
|
|
|
|
type antragFormData struct {
|
|
Title string
|
|
Nav navData
|
|
Abteilungen []store.Abteilung
|
|
Datenfragen []struct {
|
|
Key string
|
|
Label string
|
|
}
|
|
Error string
|
|
}
|
|
|
|
// handleAntragNewForm zeigt den Fragebogen (Ebene 2, "Antrag stellen").
|
|
// Bewusst eine einzelne Seite statt eines mehrseitigen Assistenten —
|
|
// jede zusätzliche Seite kostet Zeit, und "dauert der Antrag länger als
|
|
// fünf Minuten, wird er umgangen" (siehe CLAUDE.md). Adaptive
|
|
// Folgefragen (C2/C3) werden rein clientseitig per CSS ein-/ausgeblendet,
|
|
// ohne zusätzlichen Server-Roundtrip.
|
|
func (s *Server) handleAntragNewForm(w http.ResponseWriter, r *http.Request) {
|
|
if currentUser(r).Role == "betreiber" {
|
|
http.Redirect(w, r, "/betreiber", http.StatusSeeOther)
|
|
return
|
|
}
|
|
abteilungen, err := s.store.ListAbteilungenForAccount(r.Context(), currentUser(r).AccountID)
|
|
if err != nil {
|
|
http.Error(w, "Abteilungen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
data := antragFormData{Title: "Antrag stellen", Nav: navFor(r), Abteilungen: abteilungen, Datenfragen: datenfragen}
|
|
if err := s.templates.ExecuteTemplate(w, "antrag-neu", data); err != nil {
|
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// antworten ist die JSON-Struktur, die in antrag.antworten gespeichert
|
|
// wird — ein flaches Objekt aus den Fragebogen-Fakten-Schlüsseln.
|
|
// "Unsicher" wird für B unverändert als "unsicher" gespeichert, NICHT
|
|
// zu "ja" normalisiert: diese Normalisierung ("Unsicher zählt wie Ja")
|
|
// ist eine Auswertungsregel (Schritt 3), keine Speicherregel — was der
|
|
// Mitarbeiter tatsächlich geantwortet hat, bleibt im Antrag unverändert
|
|
// erhalten.
|
|
func antwortenFromForm(r *http.Request) []byte {
|
|
m := map[string]any{}
|
|
for _, f := range datenfragen {
|
|
v := r.FormValue(f.Key)
|
|
if v == "" {
|
|
v = "nein"
|
|
}
|
|
m[f.Key] = v
|
|
}
|
|
m["c1"] = r.FormValue("c1") == "ja"
|
|
m["c2"] = r.FormValue("c2") == "ja"
|
|
m["c2_folge"] = r.FormValue("c2_folge")
|
|
m["c3"] = r.FormValue("c3") == "ja"
|
|
m["c3_art"] = r.FormValue("c3_art")
|
|
m["c4"] = r.FormValue("c4") == "ja"
|
|
m["c5"] = r.FormValue("c5") == "ja"
|
|
m["d_werkzeug_freitext"] = r.FormValue("d_werkzeug_freitext")
|
|
m["d_zugang"] = r.FormValue("d_zugang")
|
|
m["d_geraet"] = r.FormValue("d_geraet")
|
|
|
|
data, _ := json.Marshal(m) // map[string]any mit nur primitiven Werten kann nicht scheitern
|
|
return data
|
|
}
|
|
|
|
// handleAntragCreate legt einen Antrag an und reicht ihn direkt ein
|
|
// (entwurf -> eingereicht in einem Schritt) — der Fragebogen ist eine
|
|
// einzelne Seite, ein Zwischenspeichern als Entwurf ist für v1 nicht
|
|
// vorgesehen.
|
|
func (s *Server) handleAntragCreate(w http.ResponseWriter, r *http.Request) {
|
|
if currentUser(r).Role == "betreiber" {
|
|
http.Redirect(w, r, "/betreiber", http.StatusSeeOther)
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
|
return
|
|
}
|
|
titel := r.FormValue("titel")
|
|
beschreibung := r.FormValue("beschreibung")
|
|
ergebnis := r.FormValue("ergebnis")
|
|
haeufigkeit := r.FormValue("haeufigkeit")
|
|
if titel == "" || beschreibung == "" || ergebnis == "" || haeufigkeit == "" {
|
|
http.Error(w, "Titel, Beschreibung, Ergebnis und Häufigkeit sind Pflichtfelder", http.StatusBadRequest)
|
|
return
|
|
}
|
|
var abteilungID *string
|
|
if v := r.FormValue("abteilung_id"); v != "" {
|
|
abteilungID = &v
|
|
}
|
|
|
|
ctx := r.Context()
|
|
user := currentUser(r)
|
|
|
|
antrag, err := s.store.CreateAntrag(ctx, user.AccountID, user.ID, abteilungID, titel)
|
|
if err != nil {
|
|
http.Error(w, "Antrag konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if _, err := s.store.UpdateAntragFelder(ctx, antrag.ID, titel, beschreibung, ergebnis, haeufigkeit, antwortenFromForm(r)); err != nil {
|
|
http.Error(w, "Antrag konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if err := s.store.SetAntragStatus(ctx, antrag.ID, "eingereicht"); err != nil {
|
|
http.Error(w, "Antrag konnte nicht eingereicht werden: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
http.Redirect(w, r, "/antraege/"+antrag.ID, http.StatusSeeOther)
|
|
}
|
|
|
|
type antragListItem struct {
|
|
ID string
|
|
Titel string
|
|
Status string
|
|
Haeufigkeit string
|
|
CreatedAt string
|
|
}
|
|
|
|
type antragListData struct {
|
|
Title string
|
|
Nav navData
|
|
Antraege []antragListItem
|
|
}
|
|
|
|
// handleAntragList zeigt die eigenen Anträge (Ebene 2 — "Eigene
|
|
// Anträge einsehen, Status verfolgen"), nicht die des ganzen Mandanten.
|
|
func (s *Server) handleAntragList(w http.ResponseWriter, r *http.Request) {
|
|
if currentUser(r).Role == "betreiber" {
|
|
http.Redirect(w, r, "/betreiber", http.StatusSeeOther)
|
|
return
|
|
}
|
|
list, err := s.store.ListAntraegeForUser(r.Context(), currentUser(r).ID)
|
|
if err != nil {
|
|
http.Error(w, "Anträge konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
data := antragListData{Title: "Meine Anträge", Nav: navFor(r)}
|
|
for _, a := range list {
|
|
data.Antraege = append(data.Antraege, antragListItem{
|
|
ID: a.ID, Titel: a.Titel, Status: a.Status, Haeufigkeit: a.Haeufigkeit,
|
|
CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"),
|
|
})
|
|
}
|
|
if err := s.templates.ExecuteTemplate(w, "antraege-liste", data); err != nil {
|
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
type antragDetailData struct {
|
|
Title string
|
|
Nav navData
|
|
ID string
|
|
Titel string
|
|
Beschreibung string
|
|
Ergebnis string
|
|
Haeufigkeit string
|
|
Status string
|
|
CreatedAt string
|
|
Antworten map[string]any
|
|
}
|
|
|
|
// handleAntragDetail zeigt einen einzelnen Antrag mit allen Antworten.
|
|
// Ein Antrag eines fremden Nutzers wird wie ein nicht existierender
|
|
// behandelt (404), nie mit einer expliziten 403 bestätigt — dasselbe
|
|
// Muster wie schon beim alten Produkt für fremde Mandanten.
|
|
func (s *Server) handleAntragDetail(w http.ResponseWriter, r *http.Request) {
|
|
antrag, err := s.store.GetAntrag(r.Context(), r.PathValue("id"))
|
|
if err != nil || antrag.ErstellerUserID != currentUser(r).ID {
|
|
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 := antragDetailData{
|
|
Title: "Antrag", 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: antworten,
|
|
}
|
|
if err := s.templates.ExecuteTemplate(w, "antrag-detail", data); err != nil {
|
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
|
}
|
|
}
|