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:
22
CLAUDE.md
22
CLAUDE.md
@@ -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
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -4,11 +4,24 @@
|
|||||||
<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>
|
||||||
|
<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}}
|
||||||
|
<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}}
|
{{range .Antraege}}
|
||||||
<li>
|
<li>
|
||||||
<a href="/antraege/{{.ID}}">
|
<a href="/antraege/{{.ID}}">
|
||||||
@@ -17,9 +30,16 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{{end}}
|
{{end}}
|
||||||
</ul>
|
</ul>
|
||||||
{{end}}
|
<div class="pagination">
|
||||||
<p><a href="/antraege/neu">Neuen Antrag stellen</a></p>
|
<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}}">← Zurück</button>{{end}}
|
||||||
|
{{if .Pagination.HasNext}}<button type="submit" name="page" value="{{.Pagination.NextPage}}">Weiter →</button>{{end}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -5,9 +5,20 @@
|
|||||||
{{template "nav" .Nav}}
|
{{template "nav" .Nav}}
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<p><a href="/betreiber">← Plattform</a></p>
|
<p><a href="/betreiber">← 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">← 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 →</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
<p><a href="/betreiber/accounts/neu">Firma anlegen</a></p>
|
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -5,9 +5,19 @@
|
|||||||
{{template "nav" .Nav}}
|
{{template "nav" .Nav}}
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<p><a href="/betreiber">← Plattform</a></p>
|
<p><a href="/betreiber">← 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">← 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 →</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
<h1>Wiedervorlage</h1>
|
||||||
|
<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}}
|
||||||
|
|||||||
Reference in New Issue
Block a user