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:
39
CLAUDE.md
39
CLAUDE.md
@@ -300,14 +300,39 @@ Aufbewahrung, Zertifizierungen, Geeignete Zwecke, Zuletzt geprüft) statt
|
|||||||
als Liste — vorher waren nur Name/Anbieter/Ort auf einen Blick sichtbar,
|
als Liste — vorher waren nur Name/Anbieter/Ort auf einen Blick sichtbar,
|
||||||
der Rest war erst nach Klick auf den Detaileintrag zu sehen.
|
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
|
befüllt (u. a. ChatGPT Enterprise, Microsoft 365 Copilot, Claude for
|
||||||
Work, GitHub Copilot Business, DeepL Pro/API, Google Gemini für Google
|
Work, Claude Code, GitHub Copilot Business, DeepL Pro/API, Google
|
||||||
Workspace, Le Chat Enterprise, Perplexity Enterprise Pro, Adobe Firefly
|
Gemini für Google Workspace, Le Chat Enterprise, Perplexity Enterprise
|
||||||
Enterprise, Otter.ai Business, Notion AI, Grammarly Business,
|
Pro, Adobe Firefly Enterprise, Otter.ai Business, Notion AI, Grammarly
|
||||||
Midjourney, Julius AI, Synthesia, ElevenLabs, Zendesk AI, Canva Magic
|
Business, Midjourney, Julius AI, Synthesia, ElevenLabs, Zendesk AI,
|
||||||
Studio, Zapier) — deckt damit alle zwölf Zweck-Kategorien mit
|
Canva Magic Studio, Zapier) — deckt damit alle zwölf Zweck-Kategorien mit
|
||||||
mindestens einem Eintrag ab. Jede Zusicherung ist mit einer echten,
|
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 —
|
offiziellen Anbieter-Quelle belegt (Stand 2026-08-31), nicht geraten —
|
||||||
und zwar auch dann, wenn keine Quelle eine positive Zusicherung
|
und zwar auch dann, wenn keine Quelle eine positive Zusicherung
|
||||||
hergibt: Synthesia und Zapier haben `training_standard`/
|
hergibt: Synthesia und Zapier haben `training_standard`/
|
||||||
|
|||||||
@@ -14,6 +14,51 @@ import (
|
|||||||
"github.com/netcell-it/deklarix/internal/store"
|
"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)
|
// istGueltigesLand validiert gegen weltLaenderliste (laenderliste.go)
|
||||||
// — dieselbe kontrollierte-Vokabular-Logik wie bei istGueltigerZweck,
|
// — dieselbe kontrollierte-Vokabular-Logik wie bei istGueltigerZweck,
|
||||||
// nur für Verarbeitungsländer statt Zwecke.
|
// nur für Verarbeitungsländer statt Zwecke.
|
||||||
@@ -95,6 +140,9 @@ type werkzeugListeData struct {
|
|||||||
Title string
|
Title string
|
||||||
Nav navData
|
Nav navData
|
||||||
Werkzeuge []werkzeugListItem
|
Werkzeuge []werkzeugListItem
|
||||||
|
Filter werkzeugFilter
|
||||||
|
ZweckeOptionen []string
|
||||||
|
Pagination pagination
|
||||||
}
|
}
|
||||||
|
|
||||||
// werkzeugPruefungVeraltetNach ist die Schwelle, ab der ein
|
// 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)
|
http.Error(w, "Werkzeugkatalog konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data := werkzeugListeData{Title: "Werkzeugkatalog", Nav: navFor(r)}
|
filter := werkzeugFilterFromRequest(r)
|
||||||
|
var gefiltert []store.Werkzeug
|
||||||
for _, wz := range werkzeuge {
|
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)
|
label, css := pruefStatus(wz.LetztePruefung)
|
||||||
data.Werkzeuge = append(data.Werkzeuge, werkzeugListItem{
|
data.Werkzeuge = append(data.Werkzeuge, werkzeugListItem{
|
||||||
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungslaender: strings.Join(wz.Verarbeitungslaender, ", "),
|
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungslaender: strings.Join(wz.Verarbeitungslaender, ", "),
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ package web_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/netcell-it/deklarix/internal/store"
|
"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)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ package web
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type mandantWerkzeugItem struct {
|
type mandantWerkzeugItem struct {
|
||||||
@@ -40,6 +42,9 @@ type mandantWerkzeugListeData struct {
|
|||||||
Title string
|
Title string
|
||||||
Nav navData
|
Nav navData
|
||||||
Werkzeuge []mandantWerkzeugItem
|
Werkzeuge []mandantWerkzeugItem
|
||||||
|
Filter werkzeugFilter
|
||||||
|
ZweckeOptionen []string
|
||||||
|
Pagination pagination
|
||||||
EigeneWerkzeuge []eigenesWerkzeugItem
|
EigeneWerkzeuge []eigenesWerkzeugItem
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,8 +74,21 @@ func (s *Server) handleMandantWerkzeugListe(w http.ResponseWriter, r *http.Reque
|
|||||||
gesperrt[sp.WerkzeugID] = true
|
gesperrt[sp.WerkzeugID] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
data := mandantWerkzeugListeData{Title: "Werkzeugkatalog", Nav: navFor(r)}
|
filter := werkzeugFilterFromRequest(r)
|
||||||
|
var gefiltert []store.Werkzeug
|
||||||
for _, wz := range zentral {
|
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)
|
label, css := pruefStatus(wz.LetztePruefung)
|
||||||
data.Werkzeuge = append(data.Werkzeuge, mandantWerkzeugItem{
|
data.Werkzeuge = append(data.Werkzeuge, mandantWerkzeugItem{
|
||||||
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungslaender: strings.Join(wz.Verarbeitungslaender, ", "),
|
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungslaender: strings.Join(wz.Verarbeitungslaender, ", "),
|
||||||
|
|||||||
75
internal/web/pagination.go
Normal file
75
internal/web/pagination.go
Normal 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]
|
||||||
|
}
|
||||||
@@ -750,6 +750,48 @@ tbody tr:hover {
|
|||||||
mask-image: linear-gradient(to right, black calc(100% - 24px), transparent 100%);
|
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
|
/* Ab hier mehr Platz (Tablet) — der Container bekommt spürbaren
|
||||||
Rand statt volle Breite, sonst bleibt alles identisch. */
|
Rand statt volle Breite, sonst bleibt alles identisch. */
|
||||||
@media (min-width: 640px) {
|
@media (min-width: 640px) {
|
||||||
|
|||||||
@@ -6,11 +6,25 @@
|
|||||||
<div class="page">
|
<div class="page">
|
||||||
<p><a href="/betreiber">← Plattform</a></p>
|
<p><a href="/betreiber">← Plattform</a></p>
|
||||||
<h1>Werkzeugkatalog</h1>
|
<h1>Werkzeugkatalog</h1>
|
||||||
{{if not .Werkzeuge}}
|
<form method="get">
|
||||||
<p class="hinweis">Noch keine zentralen Katalogeinträge.</p>
|
<div class="filter-bar">
|
||||||
{{else}}
|
<input type="search" name="q" value="{{.Filter.Suche}}" placeholder="Suche (Name, Anbieter, Land)">
|
||||||
<div class="table-scroll">
|
<select name="zweck">
|
||||||
<table>
|
<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>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th><th>Anbieter</th><th>Verarbeitungsländer</th><th>DPF</th><th>AVV</th>
|
<th>Name</th><th>Anbieter</th><th>Verarbeitungsländer</th><th>DPF</th><th>AVV</th>
|
||||||
@@ -34,9 +48,17 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
<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}}">← Zurück</button>{{end}}
|
||||||
|
{{if .Pagination.HasNext}}<button type="submit" name="page" value="{{.Pagination.NextPage}}">Weiter →</button>{{end}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</form>
|
||||||
<p><a href="/betreiber/werkzeuge/neu">Werkzeug anlegen</a></p>
|
<p><a href="/betreiber/werkzeuge/neu">Werkzeug anlegen</a></p>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -12,8 +12,20 @@
|
|||||||
zentralen Katalog unverändert — es wird nur für diesen Mandanten aus
|
zentralen Katalog unverändert — es wird nur für diesen Mandanten aus
|
||||||
der Werkzeugsuche ausgeblendet.
|
der Werkzeugsuche ausgeblendet.
|
||||||
</p>
|
</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}}
|
{{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}}
|
{{else}}
|
||||||
<div class="table-scroll">
|
<div class="table-scroll">
|
||||||
<table>
|
<table>
|
||||||
@@ -52,6 +64,27 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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">← 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 →</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<h2>Eigene Werkzeuge</h2>
|
<h2>Eigene Werkzeuge</h2>
|
||||||
|
|||||||
Reference in New Issue
Block a user