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"
@@ -334,9 +335,11 @@ type antragListItem struct {
} }
type antragListData struct { 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 {
@@ -52,9 +54,11 @@ type betreiberAccountListItem struct {
} }
type betreiberAccountListData struct { 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"),
}) })
@@ -218,20 +230,37 @@ type betreiberAuditEntryView struct {
} }
type betreiberAuditLogData struct { 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,22 +4,42 @@
<body> <body>
{{template "nav" .Nav}} {{template "nav" .Nav}}
<div class="page"> <div class="page">
<h1>Meine Anträge</h1> <div class="page-header">
{{if not .Antraege}} <div>
<p class="hinweis">Noch keine Anträge gestellt.</p> <h1>Meine Anträge</h1>
{{else}} <p class="hinweis">Eigene Anträge und ihr Bearbeitungsstand.</p>
<ul class="beitraege-liste"> </div>
{{range .Antraege}} <a class="page-header-action" href="/antraege/neu">Neuen Antrag stellen</a>
<li> </div>
<a href="/antraege/{{.ID}}"> <form method="get">
<strong>{{.Titel}}</strong> · {{.Haeufigkeit}} · {{.CreatedAt}} <div class="filter-bar">
<span class="status status-{{.Status}}">{{.Status}}</span> <input type="search" name="q" value="{{.Suche}}" placeholder="Suche (Titel, Status, Häufigkeit)">
</a> <button type="submit">Filtern</button>
</li> {{if .Suche}}<a href="/antraege">Zurücksetzen</a>{{end}}
</div>
{{if not .Antraege}}
<p class="hinweis">{{if .Suche}}Keine Treffer für diese Suche.{{else}}Noch keine Anträge gestellt.{{end}}</p>
{{else}}
<ul class="beitraege-liste">
{{range .Antraege}}
<li>
<a href="/antraege/{{.ID}}">
<strong>{{.Titel}}</strong> · {{.Haeufigkeit}} · {{.CreatedAt}}
<span class="status status-{{.Status}}">{{.Status}}</span>
</a>
</li>
{{end}}
</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}}
</ul> </form>
{{end}}
<p><a href="/antraege/neu">Neuen Antrag stellen</a></p>
</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>
<h1>Accounts</h1> <div class="page-header">
<div>
<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>
<h1>Audit-Log</h1> <div class="page-header">
<div>
<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">
<h1>Wiedervorlage</h1> <div class="page-header">
<p class="hinweis"> <div>
Genehmigungen, die abgelaufen sind, bald ablaufen, oder deren <h1>Wiedervorlage</h1>
Werkzeug sich seit der Entscheidung im Katalog geändert hat. <p class="hinweis">
</p> Genehmigungen, die abgelaufen sind, bald ablaufen, oder deren
Werkzeug sich seit der Entscheidung im Katalog geändert hat.
</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}}