feat: Filter und Paginierung für den Werkzeugkatalog, Claude Code als Katalogeintrag

Beide Katalog-Ansichten (Betreiber + Mandant) haben jetzt eine
Filterleiste (Freitext über Name/Anbieter/Verarbeitungsländer,
Zweck-Dropdown) und serverseitige Paginierung (20/Seite) über ein
GET-Formular, kein JavaScript. pagination.go ist generisch gehalten
(Go-Generics) für Wiederverwendung in weiteren Listen.

Claude Code als eigener Katalogeintrag ergänzt (nicht identisch mit
Claude for Work: läuft lokal, hat Dateizugriff, lokale Sitzungs-
protokolle mit eigenem Aufbewahrungsrisiko).
This commit is contained in:
noroot
2026-08-31 15:09:23 +02:00
parent 08c7721165
commit 3aecbb446c
8 changed files with 390 additions and 44 deletions

View File

@@ -300,14 +300,39 @@ Aufbewahrung, Zertifizierungen, Geeignete Zwecke, Zuletzt geprüft) statt
als Liste — vorher waren nur Name/Anbieter/Ort auf einen Blick sichtbar,
der Rest war erst nach Klick auf den Detaileintrag zu sehen.
Der zentrale Katalog ist aktuell mit 19 recherchierten Einträgen
**Filter & Paginierung** (seit 2026-08-31, `pagination.go` +
`werkzeugFilter` in `betreiber_werkzeug_handlers.go`): beide
Katalog-Tabellen haben eine Filterleiste (Freitext über Name/Anbieter/
Verarbeitungsländer, Dropdown über die zwölf Zweck-Kategorien) und
serverseitige Paginierung (20 Einträge/Seite, `page`-Query-Parameter).
Alles über ein einziges GET-Formular, ohne JavaScript — Prev/Next-
Buttons setzen `page` per `<button type="submit" name="page"
value="...">`, damit q/zweck beim Blättern erhalten bleiben. Auf der
Mandanten-Seite (die pro Zeile zusätzlich Sperren/Entsperren-Formulare
hat) sind Filter und Paginierungs-Buttons bewusst in getrennte
`<form>`-Elemente aufgeteilt statt in eines um die ganze Tabelle —
verschachtelte `<form>`-Elemente sind ungültiges HTML, die
Paginierungs-Buttons tragen `q`/`zweck` deshalb als Hidden-Felder
nach. `pagination.go` ist bewusst generisch (Go-Generics,
`paginate[T any]`) für Wiederverwendung in weiteren Listen (Register,
Fälle, ...) — bisher nur auf den Werkzeugkatalog angewendet, das war
die von den beiden am dringendsten wachsende Tabelle.
Der zentrale Katalog ist aktuell mit 20 recherchierten Einträgen
befüllt (u. a. ChatGPT Enterprise, Microsoft 365 Copilot, Claude for
Work, GitHub Copilot Business, DeepL Pro/API, Google Gemini für Google
Workspace, Le Chat Enterprise, Perplexity Enterprise Pro, Adobe Firefly
Enterprise, Otter.ai Business, Notion AI, Grammarly Business,
Midjourney, Julius AI, Synthesia, ElevenLabs, Zendesk AI, Canva Magic
Studio, Zapier) — deckt damit alle zwölf Zweck-Kategorien mit
mindestens einem Eintrag ab. Jede Zusicherung ist mit einer echten,
Work, Claude Code, GitHub Copilot Business, DeepL Pro/API, Google
Gemini für Google Workspace, Le Chat Enterprise, Perplexity Enterprise
Pro, Adobe Firefly Enterprise, Otter.ai Business, Notion AI, Grammarly
Business, Midjourney, Julius AI, Synthesia, ElevenLabs, Zendesk AI,
Canva Magic Studio, Zapier) — deckt damit alle zwölf Zweck-Kategorien mit
mindestens einem Eintrag ab. **Claude Code ist bewusst ein eigener
Eintrag neben Claude for Work**, kein Duplikat: es läuft lokal auf
Entwicklerrechnern mit direktem Zugriff auf Dateien/Codebasen und
speichert Sitzungsprotokolle lokal im Klartext (bis zu 30 Tage,
konfigurierbar) — ein zusätzliches lokales Aufbewahrungsrisiko, das bei
einer reinen Web-Chat-Anwendung wie Claude for Work nicht existiert,
obwohl beide über dieselben Commercial Terms/DPA laufen. Jede
Zusicherung ist mit einer echten,
offiziellen Anbieter-Quelle belegt (Stand 2026-08-31), nicht geraten —
und zwar auch dann, wenn keine Quelle eine positive Zusicherung
hergibt: Synthesia und Zapier haben `training_standard`/

View File

@@ -14,6 +14,51 @@ import (
"github.com/netcell-it/deklarix/internal/store"
)
// werkzeugFilter bündelt die Filterkriterien für die Katalog-Listen
// (Betreiber wie Mandant) — ein einziges GET-Formular setzt "q"
// (Freitext über Name/Anbieter/Länder) und optional "zweck".
type werkzeugFilter struct {
Suche string
Zweck string
}
func werkzeugFilterFromRequest(r *http.Request) werkzeugFilter {
return werkzeugFilter{
Suche: strings.TrimSpace(r.URL.Query().Get("q")),
Zweck: r.URL.Query().Get("zweck"),
}
}
// Aktiv meldet, ob mindestens ein Filter gesetzt ist — steuert, ob ein
// "Zurücksetzen"-Link angezeigt wird. Exportiert, weil html/template
// nur exportierte Methoden per Reflection aufrufen kann.
func (f werkzeugFilter) Aktiv() bool {
return f.Suche != "" || f.Zweck != ""
}
func (f werkzeugFilter) matches(wz store.Werkzeug) bool {
if f.Zweck != "" {
gefunden := false
for _, z := range wz.GeeigneteZwecke {
if z == f.Zweck {
gefunden = true
break
}
}
if !gefunden {
return false
}
}
if f.Suche != "" {
q := strings.ToLower(f.Suche)
heuhaufen := strings.ToLower(wz.Name + " " + wz.Anbieter + " " + strings.Join(wz.Verarbeitungslaender, " "))
if !strings.Contains(heuhaufen, q) {
return false
}
}
return true
}
// istGueltigesLand validiert gegen weltLaenderliste (laenderliste.go)
// — dieselbe kontrollierte-Vokabular-Logik wie bei istGueltigerZweck,
// nur für Verarbeitungsländer statt Zwecke.
@@ -92,9 +137,12 @@ func formatAufbewahrung(tage *int) string {
}
type werkzeugListeData struct {
Title string
Nav navData
Werkzeuge []werkzeugListItem
Title string
Nav navData
Werkzeuge []werkzeugListItem
Filter werkzeugFilter
ZweckeOptionen []string
Pagination pagination
}
// werkzeugPruefungVeraltetNach ist die Schwelle, ab der ein
@@ -132,8 +180,21 @@ func (s *Server) handleBetreiberWerkzeugListe(w http.ResponseWriter, r *http.Req
http.Error(w, "Werkzeugkatalog konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := werkzeugListeData{Title: "Werkzeugkatalog", Nav: navFor(r)}
filter := werkzeugFilterFromRequest(r)
var gefiltert []store.Werkzeug
for _, wz := range werkzeuge {
if filter.matches(wz) {
gefiltert = append(gefiltert, wz)
}
}
pag := paginationFromRequest(r, len(gefiltert))
seite := paginate(gefiltert, pag)
data := werkzeugListeData{
Title: "Werkzeugkatalog", Nav: navFor(r),
Filter: filter, ZweckeOptionen: gueltigeZwecke, Pagination: pag,
}
for _, wz := range seite {
label, css := pruefStatus(wz.LetztePruefung)
data.Werkzeuge = append(data.Werkzeuge, werkzeugListItem{
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungslaender: strings.Join(wz.Verarbeitungslaender, ", "),

View File

@@ -2,10 +2,12 @@ package web_test
import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
"testing"
"time"
"github.com/netcell-it/deklarix/internal/store"
)
@@ -116,3 +118,71 @@ func TestBetreiberCannotEditMandantenEigenesWerkzeug(t *testing.T) {
t.Fatalf("status = %d, want 404 for a mandanten-eigenes werkzeug", resp.Code)
}
}
func TestBetreiberWerkzeugListeFiltertNachSucheUndZweck(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
fs.werkzeuge["a"] = store.Werkzeug{
ID: "a", Name: "ChatGPT Enterprise", Anbieter: "OpenAI",
Verarbeitungslaender: []string{"USA"}, GeeigneteZwecke: []string{"Textgenerierung"},
LetztePruefung: time.Now(),
}
fs.werkzeuge["b"] = store.Werkzeug{
ID: "b", Name: "DeepL Pro", Anbieter: "DeepL SE",
Verarbeitungslaender: []string{"Deutschland"}, GeeigneteZwecke: []string{"Übersetzung"},
LetztePruefung: time.Now(),
}
resp := getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge?q=deepl")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d", resp.Code)
}
if strings.Contains(resp.Body.String(), "ChatGPT Enterprise") || !strings.Contains(resp.Body.String(), "DeepL Pro") {
t.Fatalf("q=deepl sollte nur DeepL Pro zeigen, body: %s", resp.Body.String())
}
resp = getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge?zweck=Textgenerierung")
if !strings.Contains(resp.Body.String(), "ChatGPT Enterprise") || strings.Contains(resp.Body.String(), "DeepL Pro") {
t.Fatalf("zweck=Textgenerierung sollte nur ChatGPT Enterprise zeigen, body: %s", resp.Body.String())
}
resp = getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge?q=nichts-passt")
if !strings.Contains(resp.Body.String(), "Keine Treffer") {
t.Fatalf("erwartet 'Keine Treffer' bei erfolgloser Suche, body: %s", resp.Body.String())
}
}
func TestBetreiberWerkzeugListePaginiert(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
for i := 0; i < 25; i++ {
id := fmt.Sprintf("wz-%02d", i)
fs.werkzeuge[id] = store.Werkzeug{
ID: id, Name: fmt.Sprintf("Werkzeug %02d", i), Anbieter: "Testanbieter",
LetztePruefung: time.Now(),
}
}
resp := getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d", resp.Code)
}
if !strings.Contains(resp.Body.String(), "Seite 1 von 2") {
t.Fatalf("erwartet 'Seite 1 von 2' bei 25 Einträgen (20 pro Seite), body: %s", resp.Body.String())
}
if !strings.Contains(resp.Body.String(), "Weiter") || strings.Contains(resp.Body.String(), "Zurück") {
t.Fatalf("Seite 1 sollte einen Weiter-, aber keinen Zurück-Button zeigen")
}
resp = getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge?page=2")
if !strings.Contains(resp.Body.String(), "Seite 2 von 2") {
t.Fatalf("erwartet 'Seite 2 von 2', body: %s", resp.Body.String())
}
if strings.Contains(resp.Body.String(), "Weiter") || !strings.Contains(resp.Body.String(), "Zurück") {
t.Fatalf("Seite 2 sollte einen Zurück-, aber keinen Weiter-Button zeigen")
}
}

View File

@@ -11,6 +11,8 @@ package web
import (
"net/http"
"strings"
"github.com/netcell-it/deklarix/internal/store"
)
type mandantWerkzeugItem struct {
@@ -40,6 +42,9 @@ type mandantWerkzeugListeData struct {
Title string
Nav navData
Werkzeuge []mandantWerkzeugItem
Filter werkzeugFilter
ZweckeOptionen []string
Pagination pagination
EigeneWerkzeuge []eigenesWerkzeugItem
}
@@ -69,8 +74,21 @@ func (s *Server) handleMandantWerkzeugListe(w http.ResponseWriter, r *http.Reque
gesperrt[sp.WerkzeugID] = true
}
data := mandantWerkzeugListeData{Title: "Werkzeugkatalog", Nav: navFor(r)}
filter := werkzeugFilterFromRequest(r)
var gefiltert []store.Werkzeug
for _, wz := range zentral {
if filter.matches(wz) {
gefiltert = append(gefiltert, wz)
}
}
pag := paginationFromRequest(r, len(gefiltert))
seite := paginate(gefiltert, pag)
data := mandantWerkzeugListeData{
Title: "Werkzeugkatalog", Nav: navFor(r),
Filter: filter, ZweckeOptionen: gueltigeZwecke, Pagination: pag,
}
for _, wz := range seite {
label, css := pruefStatus(wz.LetztePruefung)
data.Werkzeuge = append(data.Werkzeuge, mandantWerkzeugItem{
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungslaender: strings.Join(wz.Verarbeitungslaender, ", "),

View File

@@ -0,0 +1,75 @@
// pagination.go — wiederverwendbare serverseitige Paginierung für
// Listen-Seiten (Werkzeugkatalog, Register, ...). Bewusst ohne
// JavaScript: Seiten-/Filterwechsel läuft über ein einziges GET-Formular,
// das Query-Parameter (q, zweck, page, ...) setzt — siehe CLAUDE.md,
// Frontend.
package web
import (
"net/http"
"strconv"
)
const defaultPerPage = 20
// pagination beschreibt die aktuelle Seite einer Liste. Templates lesen
// daraus Seitenzahl, Gesamtzahl und ob Vor/Zurück-Buttons sichtbar sein
// sollen.
type pagination struct {
Page int
PerPage int
Total int
TotalPages int
HasPrev bool
HasNext bool
PrevPage int
NextPage int
}
// paginationFromRequest liest "page" aus der Query (Standard: 1, nie
// kleiner 1, nie größer als die letzte tatsächlich vorhandene Seite).
// per_page ist bewusst nicht vom Nutzer einstellbar (kein Query-Param
// dafür) — eine feste Seitengröße hält die Filter-URLs einfach und
// verhindert versehentlich riesige Anfragen.
func paginationFromRequest(r *http.Request, total int) pagination {
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
perPage := defaultPerPage
totalPages := (total + perPage - 1) / perPage
if totalPages < 1 {
totalPages = 1
}
if page > totalPages {
page = totalPages
}
return pagination{
Page: page, PerPage: perPage, Total: total, TotalPages: totalPages,
HasPrev: page > 1, HasNext: page < totalPages,
PrevPage: page - 1, NextPage: page + 1,
}
}
// bounds liefert den Start-/End-Index für die aktuelle Seite innerhalb
// der (bereits gefilterten) Gesamtliste.
func (p pagination) bounds() (start, end int) {
start = (p.Page - 1) * p.PerPage
end = start + p.PerPage
if end > p.Total {
end = p.Total
}
if start > end {
start = end
}
return start, end
}
// paginate schneidet den Ausschnitt für die aktuelle Seite aus einer
// bereits gefilterten/sortierten Liste heraus — generisch, damit jede
// Listen-Seite (Werkzeuge, Register, ...) dieselbe Funktion nutzt statt
// eigene Slice-Arithmetik zu wiederholen.
func paginate[T any](items []T, p pagination) []T {
start, end := p.bounds()
return items[start:end]
}

View File

@@ -750,6 +750,48 @@ tbody tr:hover {
mask-image: linear-gradient(to right, black calc(100% - 24px), transparent 100%);
}
/* Filter-/Paginierungsleiste für Listen-Seiten (Werkzeugkatalog,
Register, ...) — ein einziges GET-Formular ohne JavaScript, siehe
pagination.go. */
.filter-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
.filter-bar input[type="search"] {
flex: 1 1 220px;
min-height: 40px;
}
.filter-bar select {
flex: 0 1 200px;
min-height: 40px;
}
.filter-bar button {
margin-top: 0;
min-height: 40px;
}
.pagination {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.pagination button {
margin-top: 0;
min-height: 36px;
padding: 6px 14px;
font-size: 0.875rem;
}
/* Ab hier mehr Platz (Tablet) — der Container bekommt spürbaren
Rand statt volle Breite, sonst bleibt alles identisch. */
@media (min-width: 640px) {

View File

@@ -6,37 +6,59 @@
<div class="page">
<p><a href="/betreiber">&larr; Plattform</a></p>
<h1>Werkzeugkatalog</h1>
{{if not .Werkzeuge}}
<p class="hinweis">Noch keine zentralen Katalogeinträge.</p>
{{else}}
<div class="table-scroll">
<table>
<thead>
<tr>
<th>Name</th><th>Anbieter</th><th>Verarbeitungsländer</th><th>DPF</th><th>AVV</th>
<th>Training aus</th><th>Aufbewahrung</th><th>Zertifizierungen</th><th>Geeignete Zwecke</th>
<th>Zuletzt geprüft</th>
</tr>
</thead>
<tbody>
{{range .Werkzeuge}}
<tr>
<td><a href="/betreiber/werkzeuge/{{.ID}}">{{.Name}}</a></td>
<td>{{.Anbieter}}</td>
<td>{{.Verarbeitungslaender}}</td>
<td>{{if .DPFZertifiziert}}ja{{else}}nein{{end}}</td>
<td>{{if .AVVVerfuegbar}}ja{{else}}nein{{end}}</td>
<td>{{if .TrainingStandard}}ja{{else}}nein{{end}}</td>
<td>{{.Aufbewahrung}}</td>
<td>{{.Zertifizierungen}}</td>
<td>{{.GeeigneteZwecke}}</td>
<td>{{.LetztePruefung}} <span class="status {{.PruefStatusCSS}}">{{.PruefStatusLabel}}</span></td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
<form method="get">
<div class="filter-bar">
<input type="search" name="q" value="{{.Filter.Suche}}" placeholder="Suche (Name, Anbieter, Land)">
<select name="zweck">
<option value="">Alle Zwecke</option>
{{$zweck := .Filter.Zweck}}
{{range .ZweckeOptionen}}
<option value="{{.}}" {{if eq . $zweck}}selected{{end}}>{{.}}</option>
{{end}}
</select>
<button type="submit">Filtern</button>
{{if .Filter.Aktiv}}<a href="/betreiber/werkzeuge">Zurücksetzen</a>{{end}}
</div>
{{if not .Werkzeuge}}
<p class="hinweis">{{if .Filter.Aktiv}}Keine Treffer für diese Filter.{{else}}Noch keine zentralen Katalogeinträge.{{end}}</p>
{{else}}
<div class="table-scroll">
<table>
<thead>
<tr>
<th>Name</th><th>Anbieter</th><th>Verarbeitungsländer</th><th>DPF</th><th>AVV</th>
<th>Training aus</th><th>Aufbewahrung</th><th>Zertifizierungen</th><th>Geeignete Zwecke</th>
<th>Zuletzt geprüft</th>
</tr>
</thead>
<tbody>
{{range .Werkzeuge}}
<tr>
<td><a href="/betreiber/werkzeuge/{{.ID}}">{{.Name}}</a></td>
<td>{{.Anbieter}}</td>
<td>{{.Verarbeitungslaender}}</td>
<td>{{if .DPFZertifiziert}}ja{{else}}nein{{end}}</td>
<td>{{if .AVVVerfuegbar}}ja{{else}}nein{{end}}</td>
<td>{{if .TrainingStandard}}ja{{else}}nein{{end}}</td>
<td>{{.Aufbewahrung}}</td>
<td>{{.Zertifizierungen}}</td>
<td>{{.GeeigneteZwecke}}</td>
<td>{{.LetztePruefung}} <span class="status {{.PruefStatusCSS}}">{{.PruefStatusLabel}}</span></td>
</tr>
{{end}}
</tbody>
</table>
</div>
<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}}
</form>
<p><a href="/betreiber/werkzeuge/neu">Werkzeug anlegen</a></p>
</div>
</body>

View File

@@ -12,8 +12,20 @@
zentralen Katalog unverändert — es wird nur für diesen Mandanten aus
der Werkzeugsuche ausgeblendet.
</p>
<form method="get" class="filter-bar">
<input type="search" name="q" value="{{.Filter.Suche}}" placeholder="Suche (Name, Anbieter, Land)">
<select name="zweck">
<option value="">Alle Zwecke</option>
{{$zweck := .Filter.Zweck}}
{{range .ZweckeOptionen}}
<option value="{{.}}" {{if eq . $zweck}}selected{{end}}>{{.}}</option>
{{end}}
</select>
<button type="submit">Filtern</button>
{{if .Filter.Aktiv}}<a href="/verwaltung/werkzeuge">Zurücksetzen</a>{{end}}
</form>
{{if not .Werkzeuge}}
<p class="hinweis">Der zentrale Katalog ist noch leer.</p>
<p class="hinweis">{{if .Filter.Aktiv}}Keine Treffer für diese Filter.{{else}}Der zentrale Katalog ist noch leer.{{end}}</p>
{{else}}
<div class="table-scroll">
<table>
@@ -52,6 +64,27 @@
</tbody>
</table>
</div>
<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="{{.Filter.Suche}}">
<input type="hidden" name="zweck" value="{{.Filter.Zweck}}">
<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="{{.Filter.Suche}}">
<input type="hidden" name="zweck" value="{{.Filter.Zweck}}">
<input type="hidden" name="page" value="{{.Pagination.NextPage}}">
<button type="submit">Weiter &rarr;</button>
</form>
{{end}}
</span>
</div>
{{end}}
<h2>Eigene Werkzeuge</h2>