feat: PageHeader/Filter/Paginierung auf Betreiber-Accounts, Audit-Log, Meine Anträge, Wiedervorlage ausgerollt

Vervollständigt den Rollout aus dem vorigen Schritt: Betreiber-Accounts
und Audit-Log (Betreiber-Bereich) sowie "Meine Anträge" bekommen
.page-header, Freitext-Filter (matchesQuery) und Paginierung.
Wiedervorlage nur .page-header, bewusst ohne Paginierung (natürlich
begrenzte Liste).

Audit-Log holt weiterhin nur die neuesten 1000 Einträge aus der DB
und paginiert im Go-Code darüber (auditLogFetchLimit) - keine echte
DB-Offset-Paginierung, als bekannte Einschränkung dokumentiert.
This commit is contained in:
noroot
2026-08-31 19:33:27 +02:00
parent 4c60603055
commit 9e2c7f92ff
7 changed files with 178 additions and 42 deletions

View File

@@ -660,10 +660,24 @@ Behoben durch explizites `flex-direction: row` in `.filter-bar` selbst
— jetzt unabhängig davon robust, ob die Klasse auf einem `<form>` oder — jetzt unabhängig davon robust, ob die Klasse auf einem `<form>` oder
einem `<div>` sitzt. einem `<div>` sitzt.
Noch nicht übertragen: Betreiber-Accounts, Betreiber-Audit-Log, **Ebenfalls ausgerollt (2026-08-31, zweite Runde):** Betreiber-Accounts
Wiedervorlage, "Meine Anträge" — nächste Kandidaten, falls das Muster (`.page-header` + Filter über Firmenname + Paginierung), Betreiber-
fortgesetzt werden soll (Audit-Log wird langfristig am ehesten groß Audit-Log (`.page-header` + Filter über Aktion/Zieltyp/Details +
genug für Paginierung). Paginierung), "Meine Anträge" (`.page-header` + Filter über Titel/
Status/Häufigkeit + Paginierung), Wiedervorlage (nur `.page-header`,
bewusst ohne Paginierung — natürlich begrenzt auf "braucht gerade
Aufmerksamkeit").
**Bekannte Einschränkung beim Audit-Log:** `ListAuditLog` holt weiterhin
nur die neuesten `auditLogFetchLimit` (1000) Einträge aus der DB, dann
wird in Go gefiltert/paginiert (`auditLogFetchLimit`-Konstante in
`betreiber_handlers.go`) — keine echte DB-seitige Offset-Paginierung.
Für ein potenziell sehr groß werdendes, append-only Audit-Log ist das
mittelfristig nicht die saubere Lösung (Einträge jenseits der 1000
neuesten sind über die Such-/Paginierungs-UI nicht erreichbar), aber
proportional zum aktuellen Datenvolumen und konsistent mit dem
In-Memory-Paginierungsmuster der übrigen Listen. Bei echtem Bedarf:
`store.ListAuditLog` um `offset`/echtes SQL-`LIMIT`/`OFFSET` erweitern.
**Verifikationsmethode für CSS-Änderungen:** Live-Cookie-Auth per **Verifikationsmethode für CSS-Änderungen:** Live-Cookie-Auth per
Chromium-Headless/CDP ist im Sandbox-Environment nicht möglich (kein Chromium-Headless/CDP ist im Sandbox-Environment nicht möglich (kein

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"net/http" "net/http"
"strings"
"github.com/netcell-it/deklarix/internal/rules" "github.com/netcell-it/deklarix/internal/rules"
"github.com/netcell-it/deklarix/internal/store" "github.com/netcell-it/deklarix/internal/store"
@@ -337,6 +338,8 @@ type antragListData struct {
Title string Title string
Nav navData Nav navData
Antraege []antragListItem Antraege []antragListItem
Suche string
Pagination pagination
} }
// handleAntragList zeigt die eigenen Anträge (Ebene 2 — "Eigene // handleAntragList zeigt die eigenen Anträge (Ebene 2 — "Eigene
@@ -351,8 +354,16 @@ func (s *Server) handleAntragList(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Anträge konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError) http.Error(w, "Anträge konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return return
} }
data := antragListData{Title: "Meine Anträge", Nav: navFor(r)} q := strings.TrimSpace(r.URL.Query().Get("q"))
var gefiltert []store.Antrag
for _, a := range list { for _, a := range list {
if matchesQuery(q, a.Titel, a.Status, a.Haeufigkeit) {
gefiltert = append(gefiltert, a)
}
}
pag := paginationFromRequest(r, len(gefiltert))
data := antragListData{Title: "Meine Anträge", Nav: navFor(r), Suche: q, Pagination: pag}
for _, a := range paginate(gefiltert, pag) {
data.Antraege = append(data.Antraege, antragListItem{ data.Antraege = append(data.Antraege, antragListItem{
ID: a.ID, Titel: a.Titel, Status: a.Status, Haeufigkeit: a.Haeufigkeit, ID: a.ID, Titel: a.Titel, Status: a.Status, Haeufigkeit: a.Haeufigkeit,
CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"), CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"),

View File

@@ -2,9 +2,11 @@ package web
import ( import (
"net/http" "net/http"
"strings"
"time" "time"
"github.com/netcell-it/deklarix/internal/auth" "github.com/netcell-it/deklarix/internal/auth"
"github.com/netcell-it/deklarix/internal/store"
) )
type betreiberDashboardData struct { type betreiberDashboardData struct {
@@ -55,6 +57,8 @@ type betreiberAccountListData struct {
Title string Title string
Nav navData Nav navData
Accounts []betreiberAccountListItem Accounts []betreiberAccountListItem
Suche string
Pagination pagination
} }
// handleBetreiberAccountList listet alle Mandanten der Plattform. // handleBetreiberAccountList listet alle Mandanten der Plattform.
@@ -64,8 +68,16 @@ func (s *Server) handleBetreiberAccountList(w http.ResponseWriter, r *http.Reque
http.Error(w, "Accounts konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError) http.Error(w, "Accounts konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return return
} }
data := betreiberAccountListData{Title: "Accounts", Nav: navFor(r)} q := strings.TrimSpace(r.URL.Query().Get("q"))
var gefiltert []store.Account
for _, a := range accounts { for _, a := range accounts {
if matchesQuery(q, a.Name) {
gefiltert = append(gefiltert, a)
}
}
pag := paginationFromRequest(r, len(gefiltert))
data := betreiberAccountListData{Title: "Accounts", Nav: navFor(r), Suche: q, Pagination: pag}
for _, a := range paginate(gefiltert, pag) {
data.Accounts = append(data.Accounts, betreiberAccountListItem{ data.Accounts = append(data.Accounts, betreiberAccountListItem{
ID: a.ID, Name: a.Name, CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"), ID: a.ID, Name: a.Name, CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"),
}) })
@@ -221,17 +233,34 @@ type betreiberAuditLogData struct {
Title string Title string
Nav navData Nav navData
Entries []betreiberAuditEntryView Entries []betreiberAuditEntryView
Suche string
Pagination pagination
} }
// auditLogFetchLimit begrenzt, wie viele der neuesten Einträge aus der
// DB geholt werden, bevor im Go-Code gefiltert/paginiert wird — echte
// DB-seitige Offset-Paginierung wäre für ein potenziell sehr groß
// werdendes Audit-Log der sauberere Ansatz, ist hier aber (noch) nicht
// umgesetzt (siehe CLAUDE.md, Offene Punkte).
const auditLogFetchLimit = 1000
// handleBetreiberAuditLog zeigt das plattformweite Audit-Log. // handleBetreiberAuditLog zeigt das plattformweite Audit-Log.
func (s *Server) handleBetreiberAuditLog(w http.ResponseWriter, r *http.Request) { func (s *Server) handleBetreiberAuditLog(w http.ResponseWriter, r *http.Request) {
entries, err := s.store.ListAuditLog(r.Context(), 200) entries, err := s.store.ListAuditLog(r.Context(), auditLogFetchLimit)
if err != nil { if err != nil {
http.Error(w, "Audit-Log konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError) http.Error(w, "Audit-Log konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return return
} }
data := betreiberAuditLogData{Title: "Audit-Log", Nav: navFor(r)} q := strings.TrimSpace(r.URL.Query().Get("q"))
var gefiltert []store.AuditEntry
for _, e := range entries { for _, e := range entries {
if matchesQuery(q, e.Action, e.TargetType, e.Details) {
gefiltert = append(gefiltert, e)
}
}
pag := paginationFromRequest(r, len(gefiltert))
data := betreiberAuditLogData{Title: "Audit-Log", Nav: navFor(r), Suche: q, Pagination: pag}
for _, e := range paginate(gefiltert, pag) {
data.Entries = append(data.Entries, betreiberAuditEntryView{ data.Entries = append(data.Entries, betreiberAuditEntryView{
CreatedAt: e.CreatedAt.Format("02.01.2006 15:04:05"), Action: e.Action, CreatedAt: e.CreatedAt.Format("02.01.2006 15:04:05"), Action: e.Action,
TargetType: e.TargetType, TargetID: e.TargetID, Details: e.Details, TargetType: e.TargetType, TargetID: e.TargetID, Details: e.Details,

View File

@@ -4,9 +4,22 @@
<body> <body>
{{template "nav" .Nav}} {{template "nav" .Nav}}
<div class="page"> <div class="page">
<div class="page-header">
<div>
<h1>Meine Anträge</h1> <h1>Meine Anträge</h1>
<p class="hinweis">Eigene Anträge und ihr Bearbeitungsstand.</p>
</div>
<a class="page-header-action" href="/antraege/neu">Neuen Antrag stellen</a>
</div>
<form method="get">
<div class="filter-bar">
<input type="search" name="q" value="{{.Suche}}" placeholder="Suche (Titel, Status, Häufigkeit)">
<button type="submit">Filtern</button>
{{if .Suche}}<a href="/antraege">Zurücksetzen</a>{{end}}
</div>
{{if not .Antraege}} {{if not .Antraege}}
<p class="hinweis">Noch keine Anträge gestellt.</p> <p class="hinweis">{{if .Suche}}Keine Treffer für diese Suche.{{else}}Noch keine Anträge gestellt.{{end}}</p>
{{else}} {{else}}
<ul class="beitraege-liste"> <ul class="beitraege-liste">
{{range .Antraege}} {{range .Antraege}}
@@ -18,8 +31,15 @@
</li> </li>
{{end}} {{end}}
</ul> </ul>
<div class="pagination">
<span class="hinweis">Seite {{.Pagination.Page}} von {{.Pagination.TotalPages}} ({{.Pagination.Total}} Einträge)</span>
<span>
{{if .Pagination.HasPrev}}<button type="submit" name="page" value="{{.Pagination.PrevPage}}">&larr; Zurück</button>{{end}}
{{if .Pagination.HasNext}}<button type="submit" name="page" value="{{.Pagination.NextPage}}">Weiter &rarr;</button>{{end}}
</span>
</div>
{{end}} {{end}}
<p><a href="/antraege/neu">Neuen Antrag stellen</a></p> </form>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -5,9 +5,20 @@
{{template "nav" .Nav}} {{template "nav" .Nav}}
<div class="page"> <div class="page">
<p><a href="/betreiber">&larr; Plattform</a></p> <p><a href="/betreiber">&larr; Plattform</a></p>
<div class="page-header">
<div>
<h1>Accounts</h1> <h1>Accounts</h1>
<p class="hinweis">Alle Mandanten der Plattform.</p>
</div>
<a class="page-header-action" href="/betreiber/accounts/neu">Firma anlegen</a>
</div>
<form method="get" class="filter-bar">
<input type="search" name="q" value="{{.Suche}}" placeholder="Suche (Firmenname)">
<button type="submit">Filtern</button>
{{if .Suche}}<a href="/betreiber/accounts">Zurücksetzen</a>{{end}}
</form>
{{if not .Accounts}} {{if not .Accounts}}
<p class="hinweis">Noch keine Accounts.</p> <p class="hinweis">{{if .Suche}}Keine Treffer für diese Suche.{{else}}Noch keine Accounts.{{end}}</p>
{{else}} {{else}}
<ul class="beitraege-liste"> <ul class="beitraege-liste">
{{range .Accounts}} {{range .Accounts}}
@@ -18,8 +29,26 @@
</li> </li>
{{end}} {{end}}
</ul> </ul>
<div class="pagination">
<span class="hinweis">Seite {{.Pagination.Page}} von {{.Pagination.TotalPages}} ({{.Pagination.Total}} Einträge)</span>
<span>
{{if .Pagination.HasPrev}}
<form method="get" style="display:inline">
<input type="hidden" name="q" value="{{.Suche}}">
<input type="hidden" name="page" value="{{.Pagination.PrevPage}}">
<button type="submit">&larr; Zurück</button>
</form>
{{end}}
{{if .Pagination.HasNext}}
<form method="get" style="display:inline">
<input type="hidden" name="q" value="{{.Suche}}">
<input type="hidden" name="page" value="{{.Pagination.NextPage}}">
<button type="submit">Weiter &rarr;</button>
</form>
{{end}}
</span>
</div>
{{end}} {{end}}
<p><a href="/betreiber/accounts/neu">Firma anlegen</a></p>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -5,9 +5,19 @@
{{template "nav" .Nav}} {{template "nav" .Nav}}
<div class="page"> <div class="page">
<p><a href="/betreiber">&larr; Plattform</a></p> <p><a href="/betreiber">&larr; Plattform</a></p>
<div class="page-header">
<div>
<h1>Audit-Log</h1> <h1>Audit-Log</h1>
<p class="hinweis">Plattformweit, append-only — zeigt die neuesten Einträge.</p>
</div>
</div>
<form method="get" class="filter-bar">
<input type="search" name="q" value="{{.Suche}}" placeholder="Suche (Aktion, Zieltyp, Details)">
<button type="submit">Filtern</button>
{{if .Suche}}<a href="/betreiber/audit-log">Zurücksetzen</a>{{end}}
</form>
{{if not .Entries}} {{if not .Entries}}
<p class="hinweis">Noch keine Einträge.</p> <p class="hinweis">{{if .Suche}}Keine Treffer für diese Suche.{{else}}Noch keine Einträge.{{end}}</p>
{{else}} {{else}}
<ul class="beteiligte"> <ul class="beteiligte">
{{range .Entries}} {{range .Entries}}
@@ -17,6 +27,25 @@
</li> </li>
{{end}} {{end}}
</ul> </ul>
<div class="pagination">
<span class="hinweis">Seite {{.Pagination.Page}} von {{.Pagination.TotalPages}} ({{.Pagination.Total}} Einträge)</span>
<span>
{{if .Pagination.HasPrev}}
<form method="get" style="display:inline">
<input type="hidden" name="q" value="{{.Suche}}">
<input type="hidden" name="page" value="{{.Pagination.PrevPage}}">
<button type="submit">&larr; Zurück</button>
</form>
{{end}}
{{if .Pagination.HasNext}}
<form method="get" style="display:inline">
<input type="hidden" name="q" value="{{.Suche}}">
<input type="hidden" name="page" value="{{.Pagination.NextPage}}">
<button type="submit">Weiter &rarr;</button>
</form>
{{end}}
</span>
</div>
{{end}} {{end}}
</div> </div>
</body> </body>

View File

@@ -4,11 +4,15 @@
<body> <body>
{{template "nav" .Nav}} {{template "nav" .Nav}}
<div class="page"> <div class="page">
<div class="page-header">
<div>
<h1>Wiedervorlage</h1> <h1>Wiedervorlage</h1>
<p class="hinweis"> <p class="hinweis">
Genehmigungen, die abgelaufen sind, bald ablaufen, oder deren Genehmigungen, die abgelaufen sind, bald ablaufen, oder deren
Werkzeug sich seit der Entscheidung im Katalog geändert hat. Werkzeug sich seit der Entscheidung im Katalog geändert hat.
</p> </p>
</div>
</div>
{{if not .Eintraege}} {{if not .Eintraege}}
<p class="keine-findings">Nichts zur Wiedervorlage.</p> <p class="keine-findings">Nichts zur Wiedervorlage.</p>
{{else}} {{else}}