diff --git a/CLAUDE.md b/CLAUDE.md
index af1adad..39bd47f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -246,7 +246,7 @@ mehrfacher Neubewertung eines Antrags erhalten bleibt.
---
-## Entscheidung, Register, Wiedervorlage (Schritt 5+6 erledigt, Schritt 7 geplant)
+## Entscheidung, Register, Wiedervorlage (Schritt 5-7 erledigt)
**Schritt 5 (Entscheidung, Snapshot, Audit-Log) ist umgesetzt.** Ebene 3
(Fachebene) hat einen Posteingang (`GET /faelle`, alle offenen —
@@ -277,9 +277,22 @@ erzeugt zusätzlich einen `audit_log`-Eintrag (Action
`antrag_entschieden`).
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).
+`hochrisiko` 6 — `gueltigkeitFuer`).
+
+**Schritt 7 (Wiedervorlage, Katalog-Benachrichtigung) ist umgesetzt**
+(`internal/web/wiedervorlage_handlers.go`, `GET /wiedervorlage`, Ebene
+3). Es gibt keine E-Mail-Infrastruktur — "Benachrichtigung" ist bewusst
+eine In-App-Liste, kein Versand. Eine aktive Genehmigung erscheint dort,
+wenn mindestens einer zutrifft: (1) `gueltig_bis` ist erreicht oder
+liegt innerhalb von 30 Tagen, (2) das zugesagte Werkzeug wurde aus dem
+Katalog entfernt, (3) `werkzeugDiff` erkennt eine Abweichung zwischen
+dem eingefrorenen `werkzeug_snapshot` und dem aktuellen Katalogeintrag
+bei AVV-Verfügbarkeit, Training-Standard oder Verarbeitungsort. Keine
+gefundene Abweichung → die Genehmigung erscheint nicht (kein stiller
+Blanko-Eintrag für jede Genehmigung). `store.ListAktiveGenehmigungenForAccount`
+liefert dafür alle `genehmigt`/`genehmigt_mit_auflagen`-Entscheidungen
+eines Mandanten (Join über `antrag.account_id`, da `entscheidung`
+selbst kein `account_id` trägt).
**Schritt 6 (Registereintrag, Export) ist umgesetzt.** Jede Genehmigung
erzeugt automatisch einen `registereintrag` (append-only, Migration
@@ -438,7 +451,10 @@ wiederverwendet aus dem alten Produkt), ebenso `bewertung`,
Der PDF-Export nutzt das bereits vorhandene, bis dahin ungenutzte
`go-pdf/fpdf` [keine neue Abhängigkeit] mit `UnicodeTranslatorFromDescriptor`
für deutsche Umlaute.)
-7. Wiedervorlage und Katalog-Benachrichtigung
+7. ~~Wiedervorlage und Katalog-Benachrichtigung~~ — **erledigt**
+ (`GET /wiedervorlage`: abgelaufene/bald ablaufende Genehmigungen und
+ Genehmigungen, deren Werkzeug sich seither im Katalog geändert hat
+ oder entfernt wurde. In-App-Liste, kein E-Mail-Versand.)
Nicht bauen (v1): automatische Genehmigung ohne Mensch, Erkennung
tatsächlicher Werkzeug-Nutzung, Mitarbeiterüberwachung (nichts, was
diff --git a/internal/web/server.go b/internal/web/server.go
index eb86b63..bc673b8 100644
--- a/internal/web/server.go
+++ b/internal/web/server.go
@@ -124,6 +124,7 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
mux.HandleFunc("GET /registereintraege", s.requireFachebene(s.handleRegisterListe))
mux.HandleFunc("GET /registereintraege/export.csv", s.requireFachebene(s.handleRegisterExportCSV))
mux.HandleFunc("GET /registereintraege/export.pdf", s.requireFachebene(s.handleRegisterExportPDF))
+ mux.HandleFunc("GET /wiedervorlage", s.requireFachebene(s.handleWiedervorlageListe))
mux.HandleFunc("GET /verwaltung/nutzer", s.requireAdmin(s.handleNutzerListe))
mux.HandleFunc("GET /verwaltung/nutzer/neu", s.requireAdmin(s.handleNutzerNeuForm))
mux.HandleFunc("POST /verwaltung/nutzer", s.requireAdmin(s.handleNutzerCreate))
diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html
index 6e33805..625cb89 100644
--- a/internal/web/templates/layout.html
+++ b/internal/web/templates/layout.html
@@ -12,6 +12,7 @@
{{if not .IsBetreiber}}Meine Anträge{{end}}
{{if .IsFachebene}}Posteingang{{end}}
{{if .IsFachebene}}Register{{end}}
+ {{if .IsFachebene}}Wiedervorlage{{end}}
{{if .IsAdmin}}Nutzerverwaltung{{end}}
{{if .IsAdmin}}Abteilungen{{end}}
{{if .IsAdmin}}Werkzeugkatalog{{end}}
diff --git a/internal/web/templates/wiedervorlage_liste.html b/internal/web/templates/wiedervorlage_liste.html
new file mode 100644
index 0000000..6fc2c30
--- /dev/null
+++ b/internal/web/templates/wiedervorlage_liste.html
@@ -0,0 +1,29 @@
+{{define "wiedervorlage-liste"}}
+
+
{{template "head" .}}
+
+{{template "nav" .Nav}}
+
+
Wiedervorlage
+
+ Genehmigungen, die abgelaufen sind, bald ablaufen, oder deren
+ Werkzeug sich seit der Entscheidung im Katalog geändert hat.
+
+{{if not .Eintraege}}
+
Nichts zur Wiedervorlage.
+{{else}}
+
+ {{range .Eintraege}}
+ -
+ {{.Titel}}{{if .Werkzeug}} — {{.Werkzeug}}{{end}}
+
+ {{range .Gruende}}- {{.}}
{{end}}
+
+
+ {{end}}
+
+{{end}}
+
+
+
+{{end}}
diff --git a/internal/web/wiedervorlage_handlers.go b/internal/web/wiedervorlage_handlers.go
new file mode 100644
index 0000000..6ff6dbd
--- /dev/null
+++ b/internal/web/wiedervorlage_handlers.go
@@ -0,0 +1,104 @@
+// Ebene 3 — Wiedervorlage (Schritt 7 der Baureihenfolge, siehe
+// CLAUDE.md). Eine aktive Genehmigung muss erneut geprüft werden, wenn
+// entweder ihr Ablaufdatum erreicht/nah ist, oder sich im Katalog eine
+// Eigenschaft geändert hat, auf der sie beruhte. Es gibt keine E-Mail-
+// Infrastruktur — "Benachrichtigung" ist v1 eine In-App-Liste, kein
+// Versand.
+package web
+
+import (
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/netcell-it/deklarix/internal/store"
+)
+
+// wiedervorlageSchwelle: eine Genehmigung erscheint schon vor ihrem
+// Ablauf in der Wiedervorlage, damit der/die Verantwortliche Zeit zum
+// Reagieren hat.
+const wiedervorlageSchwelle = 30 * 24 * time.Hour
+
+// werkzeugDiff vergleicht den zum Entscheidungszeitpunkt eingefrorenen
+// Werkzeug-Snapshot mit dem aktuellen Katalogstand und benennt jede für
+// die Bewertung relevante Eigenschaft, die sich seitdem geändert hat.
+func werkzeugDiff(snapshot, current store.Werkzeug) []string {
+ var diffs []string
+ if snapshot.AVVVerfuegbar != current.AVVVerfuegbar {
+ diffs = append(diffs, fmt.Sprintf("AVV verfügbar: %v → %v", snapshot.AVVVerfuegbar, current.AVVVerfuegbar))
+ }
+ if snapshot.TrainingStandard != current.TrainingStandard {
+ diffs = append(diffs, fmt.Sprintf("Training standardmäßig aus: %v → %v", snapshot.TrainingStandard, current.TrainingStandard))
+ }
+ if snapshot.Verarbeitungsort != current.Verarbeitungsort {
+ diffs = append(diffs, fmt.Sprintf("Verarbeitungsort: %s → %s", snapshot.Verarbeitungsort, current.Verarbeitungsort))
+ }
+ return diffs
+}
+
+type wiedervorlageItem struct {
+ AntragID string
+ Titel string
+ Werkzeug string
+ Gruende []string
+}
+
+type wiedervorlageListeData struct {
+ Title string
+ Nav navData
+ Eintraege []wiedervorlageItem
+}
+
+// handleWiedervorlageListe zeigt alle aktiven Genehmigungen des
+// Mandanten, die erneut geprüft werden sollten — abgelaufen/bald
+// ablaufend oder mit einem seither im Katalog geänderten Werkzeug.
+// Genehmigungen ohne Grund zur Wiedervorlage werden nicht gezeigt.
+func (s *Server) handleWiedervorlageListe(w http.ResponseWriter, r *http.Request) {
+ entscheidungen, err := s.store.ListAktiveGenehmigungenForAccount(r.Context(), currentUser(r).AccountID)
+ if err != nil {
+ http.Error(w, "Genehmigungen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ var data wiedervorlageListeData
+ data.Title, data.Nav = "Wiedervorlage", navFor(r)
+ for _, e := range entscheidungen {
+ var gruende []string
+
+ if e.GueltigBis != nil {
+ if time.Now().After(*e.GueltigBis) {
+ gruende = append(gruende, "Genehmigung ist abgelaufen (seit "+e.GueltigBis.Format("02.01.2006")+")")
+ } else if time.Until(*e.GueltigBis) < wiedervorlageSchwelle {
+ gruende = append(gruende, "Genehmigung läuft am "+e.GueltigBis.Format("02.01.2006")+" ab")
+ }
+ }
+
+ werkzeugName := ""
+ if e.WerkzeugID != nil {
+ current, err := s.store.GetWerkzeug(r.Context(), *e.WerkzeugID)
+ if err != nil {
+ gruende = append(gruende, "Werkzeug wurde aus dem Katalog entfernt")
+ } else {
+ werkzeugName = current.Name
+ if e.WerkzeugSnapshot != nil {
+ gruende = append(gruende, werkzeugDiff(*e.WerkzeugSnapshot, current)...)
+ }
+ }
+ }
+
+ if len(gruende) == 0 {
+ continue
+ }
+ antrag, err := s.store.GetAntrag(r.Context(), e.AntragID)
+ if err != nil {
+ continue
+ }
+ data.Eintraege = append(data.Eintraege, wiedervorlageItem{
+ AntragID: antrag.ID, Titel: antrag.Titel, Werkzeug: werkzeugName, Gruende: gruende,
+ })
+ }
+
+ if err := s.templates.ExecuteTemplate(w, "wiedervorlage-liste", data); err != nil {
+ http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
+ }
+}
diff --git a/internal/web/wiedervorlage_handlers_test.go b/internal/web/wiedervorlage_handlers_test.go
new file mode 100644
index 0000000..022120a
--- /dev/null
+++ b/internal/web/wiedervorlage_handlers_test.go
@@ -0,0 +1,95 @@
+package web_test
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestWiedervorlageZeigtAbgelaufeneGenehmigung(t *testing.T) {
+ fs := newFakeStore()
+ s := newServer(t, fs)
+ accountID := genehmigeFall(t, fs, s)
+
+ var antragID string
+ for id, a := range fs.antraege {
+ if a.AccountID == accountID {
+ antragID = id
+ }
+ }
+ es := fs.entscheidungen[antragID]
+ abgelaufen := time.Now().Add(-24 * time.Hour)
+ es[len(es)-1].GueltigBis = &abgelaufen
+ fs.entscheidungen[antragID] = es
+
+ prueferCookie := seedUserInAccount(t, fs, accountID, "reviewer@example.com", "pruefer")
+ resp := getWithCookie(t, s, prueferCookie, "/wiedervorlage")
+ if resp.Code != http.StatusOK {
+ t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
+ }
+ if !strings.Contains(resp.Body.String(), "abgelaufen") {
+ t.Errorf("expected the abgelaufene Genehmigung to appear, got: %s", resp.Body.String())
+ }
+}
+
+func TestWiedervorlageZeigtGeaenderteWerkzeugeigenschaft(t *testing.T) {
+ fs := newFakeStore()
+ s := newServer(t, fs)
+ accountID := genehmigeFall(t, fs, s)
+
+ wz := fs.werkzeuge["werkzeug-ok"]
+ wz.AVVVerfuegbar = false // Katalog hat sich seit der Genehmigung geändert
+ fs.werkzeuge["werkzeug-ok"] = wz
+
+ prueferCookie := seedUserInAccount(t, fs, accountID, "reviewer@example.com", "pruefer")
+ resp := getWithCookie(t, s, prueferCookie, "/wiedervorlage")
+ if resp.Code != http.StatusOK {
+ t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
+ }
+ if !strings.Contains(resp.Body.String(), "AVV verfügbar") {
+ t.Errorf("expected the changed AVV property to be flagged, got: %s", resp.Body.String())
+ }
+}
+
+func TestWiedervorlageZeigtEntferntesWerkzeug(t *testing.T) {
+ fs := newFakeStore()
+ s := newServer(t, fs)
+ accountID := genehmigeFall(t, fs, s)
+ delete(fs.werkzeuge, "werkzeug-ok")
+
+ prueferCookie := seedUserInAccount(t, fs, accountID, "reviewer@example.com", "pruefer")
+ resp := getWithCookie(t, s, prueferCookie, "/wiedervorlage")
+ if resp.Code != http.StatusOK {
+ t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
+ }
+ if !strings.Contains(resp.Body.String(), "aus dem Katalog entfernt") {
+ t.Errorf("expected the removed werkzeug to be flagged, got: %s", resp.Body.String())
+ }
+}
+
+func TestWiedervorlageIstLeerOhneAenderung(t *testing.T) {
+ fs := newFakeStore()
+ s := newServer(t, fs)
+ accountID := genehmigeFall(t, fs, s)
+
+ prueferCookie := seedUserInAccount(t, fs, accountID, "reviewer@example.com", "pruefer")
+ resp := getWithCookie(t, s, prueferCookie, "/wiedervorlage")
+ if resp.Code != http.StatusOK {
+ t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
+ }
+ if !strings.Contains(resp.Body.String(), "Nichts zur Wiedervorlage") {
+ t.Errorf("expected no entries when nothing changed, got: %s", resp.Body.String())
+ }
+}
+
+func TestMitarbeiterCannotAccessWiedervorlage(t *testing.T) {
+ fs := newFakeStore()
+ s := newServer(t, fs)
+ cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter")
+
+ resp := getWithCookie(t, s, cookie, "/wiedervorlage")
+ if resp.Code != http.StatusNotFound {
+ t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code)
+ }
+}