Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c60603055 | ||
|
|
bc99858c94 | ||
|
|
3aecbb446c | ||
|
|
08c7721165 |
113
CLAUDE.md
113
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,
|
||||
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`/
|
||||
@@ -352,6 +377,13 @@ Das öffnet jetzt auch DeepSeek (Verarbeitung in China) für einen
|
||||
künftigen Katalogeintrag — die Sperre lag ausschließlich am alten
|
||||
Wertebereich, nicht an einer fachlichen Entscheidung.
|
||||
|
||||
**Offen:** `dpf_zertifiziert` ist bei allen 19 Einträgen noch `false` —
|
||||
für keinen wurde bisher gezielt recherchiert, ob der jeweilige
|
||||
US-Anbieter tatsächlich DPF-zertifiziert ist (das Feld wurde erst mit
|
||||
dieser Migration eingeführt, `false` heißt hier "nicht recherchiert",
|
||||
nicht "nachweislich nicht zertifiziert"). Nachrecherche pro Anbieter
|
||||
noch zu tun.
|
||||
|
||||
`CurrentKatalogVersion` liefert eine reproduzierbare Kennung des
|
||||
aktuellen Katalogzustands (Anzahl Einträge + letzte Änderung) — wird in
|
||||
jeder Bewertung/Entscheidung eingefroren, damit im Audit nachvollziehbar
|
||||
@@ -566,6 +598,73 @@ wie die Formular-Abschnitte angehoben (600/1rem statt 500/0.9375rem) —
|
||||
für eine einheitliche Überschriften-Hierarchie statt Zufallsgrößen je
|
||||
nach UA-Stylesheet.
|
||||
|
||||
**enconf-Patterns übertragen (2026-08-31):** enconf (Schwesterprojekt)
|
||||
ist React + Ant Design (`ProTable`/`StatCard`/`PageHeader`-Komponenten)
|
||||
— nicht 1:1 übernehmbar, da Deklarix bewusst kein SPA ist, aber die
|
||||
*Muster* wurden als reines CSS/HTML nachgebaut (recherchiert über die
|
||||
Architect-RAG-Suche, `ac_search_code_global`, da das enconf-Repo selbst
|
||||
außerhalb des Arbeitsverzeichnisses liegt):
|
||||
|
||||
- **`.table-responsive`** (ersetzt `.table-scroll` schrittweise) —
|
||||
enconfs `ProTable.tsx` schaltet auf schmalen Screens von Tabelle auf
|
||||
eine Card-Liste um (`renderMobileCard` via `Grid.useBreakpoint()`),
|
||||
statt horizontal zu scrollen. Nachgebaut rein in CSS: unter 640px
|
||||
werden `<tr>` zu Karten, `<td>` zu Label/Wert-Zeilen (`content:
|
||||
attr(data-label)` im `::before`) — jedes `<td>` braucht dafür ein
|
||||
`data-label="Spaltenname"`-Attribut. Ab 640px normale Tabelle mit
|
||||
Scroll-Fade als Sicherheitsnetz. Bisher angewendet auf beide
|
||||
Werkzeugkatalog-Tabellen; `.table-scroll` (nur Scroll-Fade, keine
|
||||
Card-Umschaltung) bleibt für noch nicht migrierte Tabellen (Register,
|
||||
Nutzer-Import-Ergebnis) bestehen.
|
||||
- **`.page-header`** (Titel+Unterzeile links, primäre Aktion rechts,
|
||||
stapelt auf Mobile) — ersetzt einen nackten `<h1>` mit einem Link
|
||||
irgendwo unten auf der Seite. Bisher auf den Werkzeugkatalog
|
||||
angewendet (Betreiber + Mandant, inkl. "Eigene Werkzeuge"-Abschnitt).
|
||||
- **`.stat-cards`/`.stat-card`** — Kennzahl-Kacheln mit farbigem oberem
|
||||
Akzentrand (blau normal, gelb/rot nur wenn der Wert > 0 ist — keine
|
||||
Farbe ohne Grund) statt einfacher Text-Links; `.stat-card-action` für
|
||||
die primäre Handlungs-Kachel (z. B. "Antrag stellen"). Kein
|
||||
Icon-Set/keine Icon-Bibliothek eingeführt (enconf nutzt
|
||||
`@ant-design/icons`, das ist React-spezifisch) — bewusste
|
||||
Vereinfachung. Angewendet auf die Startseite (`index.html`).
|
||||
|
||||
**Ausgerollt (2026-08-31):** Register, Nutzerverwaltung, Abteilungen,
|
||||
Fälle-Posteingang, Betreiber-Dashboard.
|
||||
|
||||
- **Register** (`register_liste.html`) und **Fälle-Posteingang**
|
||||
(`faelle_liste.html`) haben jetzt `.page-header` + Freitext-Filter
|
||||
(`q`) + Paginierung. Register zusätzlich `.table-responsive` (war
|
||||
vorher `.table-scroll`) mit `data-label` je `<td>`.
|
||||
- **Nutzerverwaltung** (`nutzer_liste.html`) hat `.page-header` +
|
||||
Freitext-Filter (E-Mail/Rolle) + Paginierung — blieb `.beitraege-liste`
|
||||
(schon Karten-artig), keine `.table-responsive`-Umstellung nötig.
|
||||
- **Abteilungen** (`abteilung_liste.html`) bekam nur `.page-header` —
|
||||
bewusst ohne Paginierung, Abteilungslisten sind typischerweise klein
|
||||
(Handvoll Stammdaten pro Mandant).
|
||||
- **Betreiber-Dashboard** (`betreiber_dashboard.html`) von
|
||||
`.admin-kacheln` auf `.stat-cards` umgestellt (analog zu `index.html`).
|
||||
|
||||
Neue gemeinsame Helfer in `pagination.go`: `matchesQuery(q string,
|
||||
felder ...string) bool` — eine einzige Freitext-Filter-Basis für alle
|
||||
Listen-Seiten, damit nicht jede ihre eigene Such-Logik erfindet.
|
||||
|
||||
**Beim Ausrollen gefundener, bereits live gewesener Bug:** `.filter-bar`
|
||||
direkt auf einem `<form>`-Element (statt auf einem `<div>` innerhalb
|
||||
des Formulars) verlor gegen die allgemeine `form { flex-direction:
|
||||
column }`-Regel — beide Selektoren haben für die Property
|
||||
`flex-direction` dieselbe Spezifität, aber nur `form` setzte sie
|
||||
explizit, `.filter-bar` überließ sie dem Initialwert. Ergebnis: die
|
||||
Filterleiste stapelte senkrecht statt in einer Zeile. Betraf bereits
|
||||
die im vorigen Schritt ausgelieferte Mandanten-Werkzeugkatalog-Seite.
|
||||
Behoben durch explizites `flex-direction: row` in `.filter-bar` selbst
|
||||
— jetzt unabhängig davon robust, ob die Klasse auf einem `<form>` oder
|
||||
einem `<div>` sitzt.
|
||||
|
||||
Noch nicht übertragen: Betreiber-Accounts, Betreiber-Audit-Log,
|
||||
Wiedervorlage, "Meine Anträge" — nächste Kandidaten, falls das Muster
|
||||
fortgesetzt werden soll (Audit-Log wird langfristig am ehesten groß
|
||||
genug für Paginierung).
|
||||
|
||||
**Verifikationsmethode für CSS-Änderungen:** Live-Cookie-Auth per
|
||||
Chromium-Headless/CDP ist im Sandbox-Environment nicht möglich (kein
|
||||
websocket-Python-Modul) — stattdessen: Seite per `curl -b cookies.txt`
|
||||
|
||||
@@ -8,8 +8,10 @@ package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/auth"
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
// mandantenRollen sind die Rollen, die ein Admin innerhalb des eigenen
|
||||
@@ -38,6 +40,8 @@ type nutzerListeData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Nutzer []nutzerListItem
|
||||
Suche string
|
||||
Pagination pagination
|
||||
}
|
||||
|
||||
// handleNutzerListe zeigt alle Logins des eigenen Mandanten.
|
||||
@@ -48,8 +52,16 @@ func (s *Server) handleNutzerListe(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Nutzer konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := nutzerListeData{Title: "Nutzerverwaltung", Nav: navFor(r)}
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
var gefiltert []store.User
|
||||
for _, u := range users {
|
||||
if matchesQuery(q, u.Email, u.Role) {
|
||||
gefiltert = append(gefiltert, u)
|
||||
}
|
||||
}
|
||||
pag := paginationFromRequest(r, len(gefiltert))
|
||||
data := nutzerListeData{Title: "Nutzerverwaltung", Nav: navFor(r), Suche: q, Pagination: pag}
|
||||
for _, u := range paginate(gefiltert, pag) {
|
||||
data.Nutzer = append(data.Nutzer, nutzerListItem{
|
||||
ID: u.ID, Email: u.Email, Role: u.Role, Active: u.Active, IstSelbst: u.ID == self.ID,
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
@@ -95,6 +140,9 @@ type werkzeugListeData struct {
|
||||
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, ", "),
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ package web
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
@@ -23,6 +24,8 @@ type fallListData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Faelle []fallListItem
|
||||
Suche string
|
||||
Pagination pagination
|
||||
}
|
||||
|
||||
// handleFaelleListe zeigt den Posteingang: alle noch offenen (Status
|
||||
@@ -34,16 +37,25 @@ func (s *Server) handleFaelleListe(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Anträge konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := fallListData{Title: "Posteingang", Nav: navFor(r)}
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
var offene []fallListItem
|
||||
for _, a := range antraege {
|
||||
if a.Status != "eingereicht" {
|
||||
continue
|
||||
}
|
||||
data.Faelle = append(data.Faelle, fallListItem{
|
||||
if !matchesQuery(q, a.Titel, a.Haeufigkeit) {
|
||||
continue
|
||||
}
|
||||
offene = append(offene, fallListItem{
|
||||
ID: a.ID, Titel: a.Titel, Haeufigkeit: a.Haeufigkeit,
|
||||
CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"),
|
||||
})
|
||||
}
|
||||
pag := paginationFromRequest(r, len(offene))
|
||||
data := fallListData{
|
||||
Title: "Posteingang", Nav: navFor(r),
|
||||
Faelle: paginate(offene, pag), Suche: q, Pagination: pag,
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "faelle-liste", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
@@ -14,8 +14,9 @@ func TestIndexZeigtEigeneOffeneAntraege(t *testing.T) {
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), `href="/antraege">Meine offenen Anträge <span class="status">1</span>`) {
|
||||
t.Errorf("expected 1 offenen Antrag auf der Startseite, got: %s", resp.Body.String())
|
||||
body := resp.Body.String()
|
||||
if !strings.Contains(body, `href="/antraege"`) || !strings.Contains(body, `stat-card-value">1</span>`) || !strings.Contains(body, "Meine offenen Anträge") {
|
||||
t.Errorf("expected 1 offenen Antrag auf der Startseite, got: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,8 +29,9 @@ func TestIndexZeigtPosteingangNurFuerFachebene(t *testing.T) {
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), `href="/faelle">Posteingang <span class="status">1</span>`) {
|
||||
t.Errorf("expected 1 offenen Fall im Posteingang auf der Startseite, got: %s", resp.Body.String())
|
||||
body := resp.Body.String()
|
||||
if !strings.Contains(body, `href="/faelle"`) || !strings.Contains(body, `stat-card-value">1</span>`) || !strings.Contains(body, "Posteingang") {
|
||||
t.Errorf("expected 1 offenen Fall im Posteingang auf der Startseite, got: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +56,7 @@ func TestIndexZeigtPlattformLinkFuerBetreiber(t *testing.T) {
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), `href="/betreiber">Zur Plattform`) {
|
||||
if !strings.Contains(resp.Body.String(), `href="/betreiber"`) || !strings.Contains(resp.Body.String(), "Zur Plattform") {
|
||||
t.Errorf("expected a Plattform link for betreiber, got: %s", resp.Body.String())
|
||||
}
|
||||
if strings.Contains(resp.Body.String(), "Antrag stellen") {
|
||||
|
||||
@@ -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, ", "),
|
||||
|
||||
95
internal/web/pagination.go
Normal file
95
internal/web/pagination.go
Normal file
@@ -0,0 +1,95 @@
|
||||
// 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"
|
||||
"strings"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// matchesQuery prüft, ob q (Freitext-Suche, ohne Beachtung von
|
||||
// Groß-/Kleinschreibung) in mindestens einem der übergebenen Felder
|
||||
// vorkommt — leeres q trifft immer zu. Gemeinsame Basis für die
|
||||
// Freitext-Filter mehrerer Listen-Seiten (Register, Nutzerverwaltung,
|
||||
// Posteingang, ...), damit nicht jede Liste ihre eigene Such-Logik
|
||||
// erfindet.
|
||||
func matchesQuery(q string, felder ...string) bool {
|
||||
if q == "" {
|
||||
return true
|
||||
}
|
||||
q = strings.ToLower(q)
|
||||
for _, f := range felder {
|
||||
if strings.Contains(strings.ToLower(f), q) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 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]
|
||||
}
|
||||
@@ -30,6 +30,8 @@ type registerListeData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Eintraege []registerEintragView
|
||||
Suche string
|
||||
Pagination pagination
|
||||
}
|
||||
|
||||
func (s *Server) registerEintraegeFuerAccount(r *http.Request) ([]registerEintragView, error) {
|
||||
@@ -61,7 +63,18 @@ func (s *Server) handleRegisterListe(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Register konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := registerListeData{Title: "Register", Nav: navFor(r), Eintraege: views}
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
var gefiltert []registerEintragView
|
||||
for _, e := range views {
|
||||
if matchesQuery(q, e.Zweck, e.Abteilung, e.Werkzeug, e.Verantwortlicher) {
|
||||
gefiltert = append(gefiltert, e)
|
||||
}
|
||||
}
|
||||
pag := paginationFromRequest(r, len(gefiltert))
|
||||
data := registerListeData{
|
||||
Title: "Register", Nav: navFor(r), Eintraege: paginate(gefiltert, pag),
|
||||
Suche: q, Pagination: pag,
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "register-liste", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
@@ -750,6 +750,231 @@ 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;
|
||||
/* explizit setzen, nicht dem initial-value überlassen: .filter-bar
|
||||
landet manchmal direkt auf einem <form>, und "form { flex-direction:
|
||||
column }" (oben) hat dieselbe Spezifität für DIESE Property, wenn
|
||||
.filter-bar sie nicht selbst deklariert — das Formular würde dann
|
||||
senkrecht stapeln statt in einer Zeile. */
|
||||
flex-direction: row;
|
||||
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;
|
||||
}
|
||||
|
||||
/* ── Responsive Tabellen — Card-Ansicht auf dem Handy ────────────────
|
||||
enconf-Pattern (ProTable.tsx): auf schmalen Screens wird nicht
|
||||
horizontal gescrollt, sondern jede Zeile zu einer eigenen Karte mit
|
||||
Label/Wert-Paaren. Braucht data-label="Spaltenname" auf jedem <td>.
|
||||
Ab 640px bleibt es eine normale Tabelle (mit Scroll-Fade als
|
||||
Sicherheitsnetz bei vielen Spalten). */
|
||||
.table-responsive {
|
||||
overflow-x: auto;
|
||||
box-shadow: var(--shadow);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 16px;
|
||||
-webkit-mask-image: linear-gradient(to right, black calc(100% - 24px), transparent 100%);
|
||||
mask-image: linear-gradient(to right, black calc(100% - 24px), transparent 100%);
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.table-responsive {
|
||||
overflow-x: visible;
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
}
|
||||
|
||||
.table-responsive table,
|
||||
.table-responsive tbody,
|
||||
.table-responsive tr,
|
||||
.table-responsive td {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.table-responsive thead {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
.table-responsive tbody tr {
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: #fff;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.table-responsive td {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
text-align: right;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.table-responsive td:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.table-responsive td::before {
|
||||
content: attr(data-label);
|
||||
font-weight: 600;
|
||||
color: var(--color-heading);
|
||||
text-align: left;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Seitenkopf (PageHeader-Pattern, enconf) ─────────────────────────
|
||||
Titel + Unterzeile links, primäre Aktion rechts — statt eines
|
||||
nackten <h1> mit einem Link irgendwo unten auf der Seite. */
|
||||
.page-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.page-header .hinweis {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.page-header .page-header-action {
|
||||
margin-top: 4px;
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
padding: 10px 18px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--branding-primary);
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
font-size: 0.9375rem;
|
||||
text-decoration: none;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.page-header .page-header-action:hover {
|
||||
filter: brightness(0.94);
|
||||
}
|
||||
|
||||
/* ── Kennzahl-Kacheln (StatCard-Pattern, enconf Dashboard) ───────────
|
||||
Vorher: einfache Text-Links in einer Liste ohne visuelle Priorität.
|
||||
Jetzt: farbiger oberer Akzentrand + große Zahl + Klick navigiert zur
|
||||
Liste, wie enconfs StatCard-Komponente — nur ohne Icon-Bibliothek. */
|
||||
.stat-cards {
|
||||
list-style: none;
|
||||
margin: 0 0 24px;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: block;
|
||||
background: #fff;
|
||||
border: 1px solid var(--color-border);
|
||||
border-top: 3px solid var(--branding-primary);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 16px 18px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.stat-card-value {
|
||||
display: block;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-heading);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.stat-card-label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.stat-card-mittel {
|
||||
border-top-color: var(--color-mittel);
|
||||
}
|
||||
|
||||
.stat-card-hoch {
|
||||
border-top-color: var(--color-hoch);
|
||||
}
|
||||
|
||||
.stat-card-action {
|
||||
background: var(--branding-primary);
|
||||
border-color: var(--branding-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.stat-card-action .stat-card-label {
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Ab hier mehr Platz (Tablet) — der Container bekommt spürbaren
|
||||
Rand statt volle Breite, sonst bleibt alles identisch. */
|
||||
@media (min-width: 640px) {
|
||||
|
||||
@@ -4,8 +4,12 @@
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Abteilungen</h1>
|
||||
<p class="hinweis">Stammdaten für den Fragebogen (Feld "Abteilung").</p>
|
||||
</div>
|
||||
</div>
|
||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||
{{if not .Abteilungen}}
|
||||
<p class="hinweis">Noch keine Abteilungen.</p>
|
||||
|
||||
@@ -5,10 +5,25 @@
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<h1>Plattform</h1>
|
||||
<ul class="admin-kacheln">
|
||||
<li><a href="/betreiber/accounts">Accounts <span class="status">{{.AccountCount}}</span></a></li>
|
||||
<li><a href="/betreiber/werkzeuge">Werkzeugkatalog <span class="status">{{.WerkzeugCount}}</span></a></li>
|
||||
<li><a href="/betreiber/audit-log">Audit-Log <span class="status">{{.RecentAuditCount}} neueste</span></a></li>
|
||||
<ul class="stat-cards">
|
||||
<li>
|
||||
<a class="stat-card" href="/betreiber/accounts">
|
||||
<span class="stat-card-value">{{.AccountCount}}</span>
|
||||
<span class="stat-card-label">Accounts</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="stat-card" href="/betreiber/werkzeuge">
|
||||
<span class="stat-card-value">{{.WerkzeugCount}}</span>
|
||||
<span class="stat-card-label">Werkzeugkatalog</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="stat-card" href="/betreiber/audit-log">
|
||||
<span class="stat-card-value">{{.RecentAuditCount}}</span>
|
||||
<span class="stat-card-label">Audit-Log (neueste)</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -5,11 +5,31 @@
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<p><a href="/betreiber">← Plattform</a></p>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Werkzeugkatalog</h1>
|
||||
<p class="hinweis">Zentral gepflegt, für alle Mandanten sichtbar.</p>
|
||||
</div>
|
||||
<a class="page-header-action" href="/betreiber/werkzeuge/neu">Werkzeug anlegen</a>
|
||||
</div>
|
||||
<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">Noch keine zentralen Katalogeinträge.</p>
|
||||
<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">
|
||||
<div class="table-responsive">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -21,23 +41,30 @@
|
||||
<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>
|
||||
<td data-label="Name"><a href="/betreiber/werkzeuge/{{.ID}}">{{.Name}}</a></td>
|
||||
<td data-label="Anbieter">{{.Anbieter}}</td>
|
||||
<td data-label="Verarbeitungsländer">{{.Verarbeitungslaender}}</td>
|
||||
<td data-label="DPF">{{if .DPFZertifiziert}}ja{{else}}nein{{end}}</td>
|
||||
<td data-label="AVV">{{if .AVVVerfuegbar}}ja{{else}}nein{{end}}</td>
|
||||
<td data-label="Training aus">{{if .TrainingStandard}}ja{{else}}nein{{end}}</td>
|
||||
<td data-label="Aufbewahrung">{{.Aufbewahrung}}</td>
|
||||
<td data-label="Zertifizierungen">{{.Zertifizierungen}}</td>
|
||||
<td data-label="Geeignete Zwecke">{{.GeeigneteZwecke}}</td>
|
||||
<td data-label="Zuletzt geprüft">{{.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}}">← Zurück</button>{{end}}
|
||||
{{if .Pagination.HasNext}}<button type="submit" name="page" value="{{.Pagination.NextPage}}">Weiter →</button>{{end}}
|
||||
</span>
|
||||
</div>
|
||||
{{end}}
|
||||
<p><a href="/betreiber/werkzeuge/neu">Werkzeug anlegen</a></p>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,9 +4,21 @@
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Posteingang</h1>
|
||||
<p class="hinweis">Alle noch offenen Anträge deines Mandanten.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form method="get">
|
||||
<div class="filter-bar">
|
||||
<input type="search" name="q" value="{{.Suche}}" placeholder="Suche (Titel, Häufigkeit)">
|
||||
<button type="submit">Filtern</button>
|
||||
{{if .Suche}}<a href="/faelle">Zurücksetzen</a>{{end}}
|
||||
</div>
|
||||
|
||||
{{if not .Faelle}}
|
||||
<p class="keine-findings">Keine offenen Anträge — alles bearbeitet.</p>
|
||||
<p class="keine-findings">{{if .Suche}}Keine Treffer für diese Suche.{{else}}Keine offenen Anträge — alles bearbeitet.{{end}}</p>
|
||||
{{else}}
|
||||
<ul class="beitraege-liste">
|
||||
{{range .Faelle}}
|
||||
@@ -17,7 +29,15 @@
|
||||
</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}}">← Zurück</button>{{end}}
|
||||
{{if .Pagination.HasNext}}<button type="submit" name="page" value="{{.Pagination.NextPage}}">Weiter →</button>{{end}}
|
||||
</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -6,15 +6,25 @@
|
||||
<div class="page">
|
||||
<h1>Willkommen bei Deklarix</h1>
|
||||
{{if .Nav.IsBetreiber}}
|
||||
<ul class="admin-kacheln">
|
||||
<li><a href="/betreiber">Zur Plattform</a></li>
|
||||
<ul class="stat-cards">
|
||||
<li><a class="stat-card stat-card-action" href="/betreiber"><span class="stat-card-label">Zur Plattform →</span></a></li>
|
||||
</ul>
|
||||
{{else}}
|
||||
<ul class="admin-kacheln">
|
||||
<li><a href="/antraege/neu">Antrag stellen</a></li>
|
||||
<li><a href="/antraege">Meine offenen Anträge <span class="status">{{.OffeneAntraege}}</span></a></li>
|
||||
<ul class="stat-cards">
|
||||
<li><a class="stat-card stat-card-action" href="/antraege/neu"><span class="stat-card-label">Antrag stellen →</span></a></li>
|
||||
<li>
|
||||
<a class="stat-card{{if .OffeneAntraege}} stat-card-mittel{{end}}" href="/antraege">
|
||||
<span class="stat-card-value">{{.OffeneAntraege}}</span>
|
||||
<span class="stat-card-label">Meine offenen Anträge</span>
|
||||
</a>
|
||||
</li>
|
||||
{{if .ZeigePosteingang}}
|
||||
<li><a href="/faelle">Posteingang <span class="status">{{.OffenerPosteingang}}</span></a></li>
|
||||
<li>
|
||||
<a class="stat-card{{if .OffenerPosteingang}} stat-card-hoch{{end}}" href="/faelle">
|
||||
<span class="stat-card-value">{{.OffenerPosteingang}}</span>
|
||||
<span class="stat-card-label">Posteingang</span>
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
|
||||
@@ -6,16 +6,32 @@
|
||||
<div class="page">
|
||||
<h1>Werkzeugkatalog</h1>
|
||||
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Zentraler Katalog</h2>
|
||||
<p class="hinweis">
|
||||
Wird von der Plattform gepflegt. Ein gesperrtes Werkzeug bleibt im
|
||||
zentralen Katalog unverändert — es wird nur für diesen Mandanten aus
|
||||
der Werkzeugsuche ausgeblendet.
|
||||
zentralen Katalog unverändert — es wird nur für diesen Mandanten
|
||||
aus der Werkzeugsuche ausgeblendet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<div class="table-responsive">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -26,15 +42,15 @@
|
||||
<tbody>
|
||||
{{range .Werkzeuge}}
|
||||
<tr>
|
||||
<td>{{.Name}}</td>
|
||||
<td>{{.Anbieter}}</td>
|
||||
<td>{{.Verarbeitungslaender}}</td>
|
||||
<td>{{if .AVVVerfuegbar}}ja{{else}}nein{{end}}</td>
|
||||
<td>{{if .TrainingStandard}}ja{{else}}nein{{end}}</td>
|
||||
<td>{{.Aufbewahrung}}</td>
|
||||
<td>{{.GeeigneteZwecke}}</td>
|
||||
<td>{{.LetztePruefung}} <span class="status {{.PruefStatusCSS}}">{{.PruefStatusLabel}}</span></td>
|
||||
<td>
|
||||
<td data-label="Name">{{.Name}}</td>
|
||||
<td data-label="Anbieter">{{.Anbieter}}</td>
|
||||
<td data-label="Verarbeitungsländer">{{.Verarbeitungslaender}}</td>
|
||||
<td data-label="AVV">{{if .AVVVerfuegbar}}ja{{else}}nein{{end}}</td>
|
||||
<td data-label="Training aus">{{if .TrainingStandard}}ja{{else}}nein{{end}}</td>
|
||||
<td data-label="Aufbewahrung">{{.Aufbewahrung}}</td>
|
||||
<td data-label="Geeignete Zwecke">{{.GeeigneteZwecke}}</td>
|
||||
<td data-label="Zuletzt geprüft">{{.LetztePruefung}} <span class="status {{.PruefStatusCSS}}">{{.PruefStatusLabel}}</span></td>
|
||||
<td data-label="Status">
|
||||
{{if .Gesperrt}}
|
||||
<span class="status">gesperrt</span>
|
||||
<form method="post" action="/verwaltung/werkzeuge/{{.ID}}/entsperren" style="display:inline">
|
||||
@@ -52,17 +68,43 @@
|
||||
</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">← 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}}
|
||||
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Eigene Werkzeuge</h2>
|
||||
<p class="hinweis">
|
||||
Zusätzliche Katalogeinträge nur für diesen Mandanten — sichtbar nur
|
||||
hier, nicht im zentralen Katalog der Plattform.
|
||||
</p>
|
||||
</div>
|
||||
<a class="page-header-action" href="/verwaltung/werkzeuge/neu">Eigenes Werkzeug anlegen</a>
|
||||
</div>
|
||||
{{if not .EigeneWerkzeuge}}
|
||||
<p class="hinweis">Noch keine eigenen Werkzeuge.</p>
|
||||
{{else}}
|
||||
<div class="table-scroll">
|
||||
<div class="table-responsive">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Anbieter</th><th>Verarbeitungsländer</th><th>Zuletzt geprüft</th></tr>
|
||||
@@ -70,17 +112,16 @@
|
||||
<tbody>
|
||||
{{range .EigeneWerkzeuge}}
|
||||
<tr>
|
||||
<td><a href="/verwaltung/werkzeuge/{{.ID}}">{{.Name}}</a></td>
|
||||
<td>{{.Anbieter}}</td>
|
||||
<td>{{.Verarbeitungslaender}}</td>
|
||||
<td>{{.LetztePruefung}}</td>
|
||||
<td data-label="Name"><a href="/verwaltung/werkzeuge/{{.ID}}">{{.Name}}</a></td>
|
||||
<td data-label="Anbieter">{{.Anbieter}}</td>
|
||||
<td data-label="Verarbeitungsländer">{{.Verarbeitungslaender}}</td>
|
||||
<td data-label="Zuletzt geprüft">{{.LetztePruefung}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
<p><a href="/verwaltung/werkzeuge/neu">Eigenes Werkzeug anlegen</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,7 +4,21 @@
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Nutzerverwaltung</h1>
|
||||
<p class="hinweis">Logins deines Mandanten — ein Login wird deaktiviert statt gelöscht.</p>
|
||||
</div>
|
||||
<a class="page-header-action" href="/verwaltung/nutzer/neu">Nutzer anlegen</a>
|
||||
</div>
|
||||
<form method="get" class="filter-bar">
|
||||
<input type="search" name="q" value="{{.Suche}}" placeholder="Suche (E-Mail, Rolle)">
|
||||
<button type="submit">Filtern</button>
|
||||
{{if .Suche}}<a href="/verwaltung/nutzer">Zurücksetzen</a>{{end}}
|
||||
</form>
|
||||
{{if not .Nutzer}}
|
||||
<p class="hinweis">{{if .Suche}}Keine Treffer für diese Suche.{{else}}Noch keine weiteren Nutzer.{{end}}</p>
|
||||
{{else}}
|
||||
<ul class="beitraege-liste">
|
||||
{{range .Nutzer}}
|
||||
<li>
|
||||
@@ -25,7 +39,27 @@
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
<p><a href="/verwaltung/nutzer/neu">Nutzer anlegen</a> · <a href="/verwaltung/nutzer/import">Per CSV importieren</a></p>
|
||||
<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}}
|
||||
<p><a href="/verwaltung/nutzer/import">Per CSV importieren</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,16 +4,27 @@
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Register</h1>
|
||||
<p class="hinweis">
|
||||
Jede Genehmigung erzeugt automatisch einen Registereintrag —
|
||||
abgelehnte Anträge und Rückfragen stehen nicht im Register.
|
||||
</p>
|
||||
<p><a href="/registereintraege/export.csv">CSV-Export</a> · <a href="/registereintraege/export.pdf">PDF-Export</a></p>
|
||||
</div>
|
||||
<span class="hinweis"><a href="/registereintraege/export.csv">CSV-Export</a> · <a href="/registereintraege/export.pdf">PDF-Export</a></span>
|
||||
</div>
|
||||
<form method="get">
|
||||
<div class="filter-bar">
|
||||
<input type="search" name="q" value="{{.Suche}}" placeholder="Suche (Zweck, Abteilung, Werkzeug, Verantwortliche/r)">
|
||||
<button type="submit">Filtern</button>
|
||||
{{if .Suche}}<a href="/registereintraege">Zurücksetzen</a>{{end}}
|
||||
</div>
|
||||
|
||||
{{if not .Eintraege}}
|
||||
<p class="hinweis">Noch keine Genehmigungen.</p>
|
||||
<p class="hinweis">{{if .Suche}}Keine Treffer für diese Suche.{{else}}Noch keine Genehmigungen.{{end}}</p>
|
||||
{{else}}
|
||||
<div class="table-scroll">
|
||||
<div class="table-responsive">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -25,15 +36,29 @@
|
||||
<tbody>
|
||||
{{range .Eintraege}}
|
||||
<tr>
|
||||
<td>{{.Zweck}}</td><td>{{.Abteilung}}</td><td>{{.Werkzeug}}</td><td>{{.Datenklasse}}</td>
|
||||
<td>{{.Einstufung}}</td><td>{{.Auflagen}}</td><td>{{.Verantwortlicher}}</td>
|
||||
<td>{{.EntschiedenAm}}</td><td>{{.GueltigBis}}</td>
|
||||
<td data-label="Zweck">{{.Zweck}}</td>
|
||||
<td data-label="Abteilung">{{.Abteilung}}</td>
|
||||
<td data-label="Werkzeug">{{.Werkzeug}}</td>
|
||||
<td data-label="Datenklasse">{{.Datenklasse}}</td>
|
||||
<td data-label="Einstufung">{{.Einstufung}}</td>
|
||||
<td data-label="Auflagen">{{.Auflagen}}</td>
|
||||
<td data-label="Verantwortliche/r">{{.Verantwortlicher}}</td>
|
||||
<td data-label="Entschieden am">{{.EntschiedenAm}}</td>
|
||||
<td data-label="Gültig bis">{{.GueltigBis}}</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}}">← Zurück</button>{{end}}
|
||||
{{if .Pagination.HasNext}}<button type="submit" name="page" value="{{.Pagination.NextPage}}">Weiter →</button>{{end}}
|
||||
</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user