Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c60603055 | ||
|
|
bc99858c94 | ||
|
|
3aecbb446c | ||
|
|
08c7721165 | ||
|
|
59e1bcc8f7 | ||
|
|
2e459f1dd1 | ||
|
|
d77b19f61f | ||
|
|
dbeaa45644 | ||
|
|
4358dbaa0d |
287
CLAUDE.md
287
CLAUDE.md
@@ -39,8 +39,8 @@ Regelbasiert, nicht ML: die Ableitungsregeln (Datenklasse, KI-VO-
|
|||||||
Einstufung, Anforderungsprofil) liegen als versionierte YAML-Dateien
|
Einstufung, Anforderungsprofil) liegen als versionierte YAML-Dateien
|
||||||
vor, nicht im Code — ein fachlich Verantwortlicher soll sie anpassen
|
vor, nicht im Code — ein fachlich Verantwortlicher soll sie anpassen
|
||||||
können, ohne Go-Code anzufassen. Ein LLM ist v1 höchstens optional zur
|
können, ohne Go-Code anzufassen. Ein LLM ist v1 höchstens optional zur
|
||||||
Zweckerkennung aus dem Freitext vorgesehen (Textgenerierung/Übersetzung/
|
Zweckerkennung aus dem Freitext vorgesehen (die zwölf Zweck-Kategorien
|
||||||
Code/Bild/Transkription/Recherche erkennen), nie für die Einstufung
|
erkennen, siehe `gueltigeZwecke` weiter unten), nie für die Einstufung
|
||||||
selbst — und auch dort nur als Vorschlag, den der Mensch bestätigt.
|
selbst — und auch dort nur als Vorschlag, den der Mensch bestätigt.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -141,15 +141,21 @@ Historie zerstören (dasselbe Muster wie bei Nutzern: deaktivieren statt
|
|||||||
löschen, aber ein Sperren/Deaktivieren-Zustand für Accounts existiert
|
löschen, aber ein Sperren/Deaktivieren-Zustand für Accounts existiert
|
||||||
noch nicht, siehe unten).
|
noch nicht, siehe unten).
|
||||||
|
|
||||||
**Weiterhin nicht gebaut:** Anmeldeverfahren-Konfiguration, ein echtes
|
**Weiterhin nicht gebaut:** Anmeldeverfahren-Konfiguration, das
|
||||||
Abo-System (Preismodell und Zahlungsanbieter mit dem Nutzer am
|
Abo-System. **Wichtige Korrektur (2026-08-31):** die eigentliche
|
||||||
2026-08-30 grundsätzlich geklärt — 3 €/Mitarbeiter/Monat, Mindestabnahme
|
Zahlungsabwicklung/Rechnungsstellung läuft über ein **anderes,
|
||||||
10 Mitarbeiter, 14 Tage Testphase, Stripe mit SEPA-Lastschrift — die
|
externes System** — Deklarix baut dafür **kein Stripe/Zahlungs-SDK**.
|
||||||
eigentliche Umsetzung wartet noch auf einen Stripe-Account/Testmodus-
|
Deklarix bildet nur den **Subskriptionsstatus** ab (Plan, gebuchte
|
||||||
Zugangsdaten), Sperren/Deaktivieren eines Accounts durch den Betreiber
|
Mitarbeiterzahl, Status wie `test`/`aktiv`/`gekündigt`, Ablaufdatum),
|
||||||
(hängt weiter an der Abrechnungs-/Freischaltungs-Architektur, siehe
|
der von außen gesetzt wird — noch offen, wie genau ("von außen" heißt
|
||||||
Offene Punkte: `account.verified` wurde beim Produktwechsel sogar
|
aktuell: manuell durch den Betreiber, eine Schnittstelle zum externen
|
||||||
entfernt).
|
Abrechnungssystem ist nicht spezifiziert). Das Preismodell selbst
|
||||||
|
(3 €/Mitarbeiter/Monat, Mindestabnahme 10 Mitarbeiter, 14 Tage
|
||||||
|
Testphase) bleibt als Orientierung gültig, nur der Stripe-Teil der
|
||||||
|
Empfehlung vom 2026-08-29 entfällt. Sperren/Deaktivieren eines Accounts
|
||||||
|
durch den Betreiber hängt weiter an dieser (jetzt kleineren)
|
||||||
|
Subskriptions-Architektur (siehe Offene Punkte: `account.verified`
|
||||||
|
wurde beim Produktwechsel sogar entfernt).
|
||||||
|
|
||||||
**Mandantenfähigkeit:** jede Tabelle trägt `account_id`. Aktuell wird
|
**Mandantenfähigkeit:** jede Tabelle trägt `account_id`. Aktuell wird
|
||||||
Isolation in der Anwendungsschicht erzwungen (Handler vergleichen
|
Isolation in der Anwendungsschicht erzwungen (Handler vergleichen
|
||||||
@@ -245,11 +251,138 @@ Sperrungen zentraler Einträge (`werkzeug_sperre`) führen, ohne den
|
|||||||
zentralen Katalog zu verändern.
|
zentralen Katalog zu verändern.
|
||||||
|
|
||||||
**Pflicht:** `letzte_pruefung` und `quelle` sind NOT NULL — jede
|
**Pflicht:** `letzte_pruefung` und `quelle` sind NOT NULL — jede
|
||||||
Zusicherung im Katalog (AVV verfügbar, Verarbeitungsort, Training-Opt-
|
Zusicherung im Katalog (AVV verfügbar, Verarbeitungsländer, Training-Opt-
|
||||||
out, Zertifizierungen) muss belegbar sein, sonst haftet Deklarix für
|
out, Zertifizierungen) muss belegbar sein, sonst haftet Deklarix für
|
||||||
eine Aussage, die nicht nachweisbar ist. Einträge älter als 180 Tage
|
eine Aussage, die nicht nachweisbar ist. Beide Katalogansichten zeigen
|
||||||
sollen im Ergebnis als "nicht aktuell geprüft" markiert werden (noch
|
eine dreistufige Ampel zum Prüfalter (`pruefStatus()` in
|
||||||
nicht in der UI umgesetzt, siehe Offene Punkte).
|
`betreiber_werkzeug_handlers.go`, CSS-Klassen `status-niedrig`/
|
||||||
|
`-mittel`/`-hoch`, dieselben Farbtoken wie die KI-VO-Risikoampel):
|
||||||
|
**aktuell** (grün, < 150 Tage), **bald fällig** (gelb, 150–180 Tage —
|
||||||
|
dasselbe 30-Tage-Vorwarnfenster wie bei der Wiedervorlage), **abgelaufen**
|
||||||
|
(rot, > 180 Tage).
|
||||||
|
|
||||||
|
`aufbewahrung_tage` ist NULLABLE (Migration 0015) — `NULL` bedeutet
|
||||||
|
"vom Anbieter nicht beziffert" und ist bewusst von `0` ("belegte
|
||||||
|
Zusicherung: sofortige Löschung", z. B. DeepL) unterschieden. Vorher
|
||||||
|
war die Spalte `NOT NULL DEFAULT 0`, wodurch beide Fälle identisch
|
||||||
|
aussahen — `formatAufbewahrung()` zeigt entsprechend "unbekannt" statt
|
||||||
|
"0 Tage".
|
||||||
|
|
||||||
|
`geeignete_zwecke` ist seit derselben Migration ein kontrolliertes
|
||||||
|
Vokabular statt Freitext: das Formular zeigt Checkboxen statt eines
|
||||||
|
Freitextfelds (`gueltigeZwecke` in `betreiber_werkzeug_handlers.go`,
|
||||||
|
serverseitig validiert via `istGueltigerZweck`). Grund: ein künftiger
|
||||||
|
automatischer Zweckabgleich (Bewertungslogik Schritt 3, weiterhin nicht
|
||||||
|
gebaut) würde an inkonsistentem Freitext ("Text" statt
|
||||||
|
"Textgenerierung") scheitern. Am 2026-08-31 von sechs auf zwölf
|
||||||
|
Kategorien erweitert, nachdem der Nutzer zurecht einwandte, dass sechs
|
||||||
|
Kategorien die tatsächliche Bandbreite an Einsatzgebieten nicht
|
||||||
|
abdecken: Textgenerierung, Übersetzung, Code, Bild, Transkription,
|
||||||
|
Recherche, **Datenanalyse, Video, Audio/Sprache,
|
||||||
|
Kundenservice/Chatbot, Präsentation/Design, Automatisierung/Agenten**.
|
||||||
|
Es gibt keinen einheitlichen Branchenstandard für KI-Funktions-
|
||||||
|
kategorien (regulatorische Taxonomien wie die KI-VO selbst oder OECD/
|
||||||
|
NIST klassifizieren nach Risiko, nicht nach Funktion; Marktplatz-
|
||||||
|
Kategorien wie bei G2 sind Marketing, kein Standard) — diese zwölf
|
||||||
|
sind Deklarix' eigene, bewusst begrenzte Liste für den künftigen
|
||||||
|
Zweckabgleich, keine allgemeine KI-Taxonomie.
|
||||||
|
|
||||||
|
Neues Feld `subprozessoren` (TEXT[], Migration 0015) macht
|
||||||
|
Unterauftragsverarbeiter (z. B. Anthropic bei Microsoft 365 Copilot,
|
||||||
|
AWS bei DeepL) als eigenes, durchsuchbares Feld sichtbar statt nur als
|
||||||
|
Freitext in `einschraenkungen` — das ist oft der Grund, warum ein
|
||||||
|
scheinbar EU-verarbeitetes Werkzeug die USA doch nicht ausschließt.
|
||||||
|
|
||||||
|
Sowohl der zentrale Katalog (`GET /betreiber/werkzeuge`) als auch die
|
||||||
|
mandantenseitige Sicht (`GET /verwaltung/werkzeuge`) zeigen den Katalog
|
||||||
|
als Tabelle (Name, Anbieter, Verarbeitungsländer, DPF, AVV, Training aus,
|
||||||
|
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.
|
||||||
|
|
||||||
|
**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, 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`/
|
||||||
|
`training_opt_out` bewusst NICHT gesetzt, weil keine belegte Aussage
|
||||||
|
zum Trainingsausschluss gefunden wurde (nicht mit "trainiert
|
||||||
|
nachweislich" verwechseln — es ist ein "wissen wir nicht, deshalb
|
||||||
|
keine Haken"). Mehrere Einträge sind bewusst mit kritischen
|
||||||
|
Einschränkungen versehen statt schöngefärbt, das ist die vorgesehene
|
||||||
|
Funktion des Katalogs: Adobe Firefly (Trainingsausschluss ist nicht
|
||||||
|
Standard), Otter.ai (Training auf de-identifizierten Aufnahmen,
|
||||||
|
laufende Rechtsstreitigkeiten), **Midjourney (kein AVV, keine
|
||||||
|
EU-Datenresidenz, unbegrenzte Speicherung inkl. Training — laut
|
||||||
|
CLAUDE.md-Leitplanken für personenbezogene/vertrauliche Daten nicht
|
||||||
|
geeignet, absichtlich als Negativbeispiel im Katalog, nicht
|
||||||
|
weggelassen)**.
|
||||||
|
|
||||||
|
**Schema-Lücke behoben (2026-08-31, Migration 0016):** der grobe
|
||||||
|
`verarbeitungsort`-Eimer (`EU`/`USA`/`gemischt`/`on-prem`) wurde durch
|
||||||
|
`verarbeitungslaender TEXT[]` ersetzt — echte Länder statt Buckets.
|
||||||
|
Grund: "USA" war schon immer ein Drittland im Sinne der DSGVO (Art. 44
|
||||||
|
ff.) wie jedes andere auch, das Wort "Drittland" für einen fünften
|
||||||
|
Bucket-Wert wäre also verwirrend gewesen, nicht erhellend. Das Formular
|
||||||
|
bietet dafür eine echte Weltländerliste (`weltLaenderliste` in
|
||||||
|
`laenderliste.go`, `<select multiple>`, Mehrfachauswahl per Strg/Cmd-
|
||||||
|
Klick — kein JavaScript nötig) plus zwei Pseudo-Einträge ("EU-Region
|
||||||
|
(kein einzelnes Land benannt)" für Anbieter, die nur eine Region statt
|
||||||
|
eines Staates zusichern, z. B. Microsofts EU Data Boundary; "On-Premise
|
||||||
|
(selbst gehostet)"). Der harte Filter `eu_verarbeitung`
|
||||||
|
(`internal/rules/evaluate.go`, `alleLaenderInEUEWR`) verlangt jetzt,
|
||||||
|
dass ALLE genannten Länder EU/EWR-Mitgliedstaaten sind — eine leere
|
||||||
|
Liste gilt als nicht erfüllt, nicht als Freifahrtschein. Zusätzlich neues
|
||||||
|
Feld `dpf_zertifiziert` (Checkbox "EU-US Data Privacy Framework
|
||||||
|
zertifiziert"): DPF ist eine schmalere, gerichtlich schon zweimal
|
||||||
|
gekippte Rechtsgrundlage (Safe Harbor, Privacy Shield) für USA-Transfers,
|
||||||
|
bisher nur als Fließtext in "Einschränkungen" erwähnt, jetzt strukturiert
|
||||||
|
und damit künftig filterbar. Alle 19 Katalogeinträge wurden mit den
|
||||||
|
tatsächlichen, bereits recherchierten Ländern nachgepflegt (z. B.
|
||||||
|
Mistral → Frankreich, DeepL → Deutschland, Synthesia → Irland, Claude
|
||||||
|
for Work → USA) statt der pauschalen alten Buckets.
|
||||||
|
|
||||||
|
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
|
`CurrentKatalogVersion` liefert eine reproduzierbare Kennung des
|
||||||
aktuellen Katalogzustands (Anzahl Einträge + letzte Änderung) — wird in
|
aktuellen Katalogzustands (Anzahl Einträge + letzte Änderung) — wird in
|
||||||
@@ -266,8 +399,8 @@ bleibt, mit welchem Katalogstand ein Vorschlag erzeugt wurde.
|
|||||||
nicht erfüllen — Grund je Werkzeug festhalten, auch aussortierte
|
nicht erfüllen — Grund je Werkzeug festhalten, auch aussortierte
|
||||||
Werkzeuge werden im Ergebnis mit Begründung gezeigt.
|
Werkzeuge werden im Ergebnis mit Begründung gezeigt.
|
||||||
3. **Zweckabgleich.** Aus `beschreibung`/`ergebnis` den Zweck ableiten
|
3. **Zweckabgleich.** Aus `beschreibung`/`ergebnis` den Zweck ableiten
|
||||||
(Textgenerierung, Übersetzung, Code, Bild, Transkription, Recherche)
|
(die zwölf Kategorien aus `gueltigeZwecke`, siehe Werkzeugkatalog
|
||||||
und gegen `geeignete_zwecke` filtern.
|
weiter unten) und gegen `geeignete_zwecke` filtern.
|
||||||
4. **Rangfolge.** Verbleibende Werkzeuge sortieren: EU-Verarbeitung,
|
4. **Rangfolge.** Verbleibende Werkzeuge sortieren: EU-Verarbeitung,
|
||||||
Training standardmäßig aus, kurze Aufbewahrung, Aktualität der
|
Training standardmäßig aus, kurze Aufbewahrung, Aktualität der
|
||||||
Prüfung.
|
Prüfung.
|
||||||
@@ -325,7 +458,7 @@ wenn mindestens einer zutrifft: (1) `gueltig_bis` ist erreicht oder
|
|||||||
liegt innerhalb von 30 Tagen, (2) das zugesagte Werkzeug wurde aus dem
|
liegt innerhalb von 30 Tagen, (2) das zugesagte Werkzeug wurde aus dem
|
||||||
Katalog entfernt, (3) `werkzeugDiff` erkennt eine Abweichung zwischen
|
Katalog entfernt, (3) `werkzeugDiff` erkennt eine Abweichung zwischen
|
||||||
dem eingefrorenen `werkzeug_snapshot` und dem aktuellen Katalogeintrag
|
dem eingefrorenen `werkzeug_snapshot` und dem aktuellen Katalogeintrag
|
||||||
bei AVV-Verfügbarkeit, Training-Standard oder Verarbeitungsort. Keine
|
bei AVV-Verfügbarkeit, Training-Standard oder Verarbeitungsländern. Keine
|
||||||
gefundene Abweichung → die Genehmigung erscheint nicht (kein stiller
|
gefundene Abweichung → die Genehmigung erscheint nicht (kein stiller
|
||||||
Blanko-Eintrag für jede Genehmigung). `store.ListAktiveGenehmigungenForAccount`
|
Blanko-Eintrag für jede Genehmigung). `store.ListAktiveGenehmigungenForAccount`
|
||||||
liefert dafür alle `genehmigt`/`genehmigt_mit_auflagen`-Entscheidungen
|
liefert dafür alle `genehmigt`/`genehmigt_mit_auflagen`-Entscheidungen
|
||||||
@@ -414,7 +547,123 @@ kein separates Desktop-Template.
|
|||||||
**Tabellen** (`table`/`th`/`td` in `app.css`) sind seit dieser Änderung
|
**Tabellen** (`table`/`th`/`td` in `app.css`) sind seit dieser Änderung
|
||||||
gestylt (vorher komplett ungestylt, betraf v. a. `register_liste.html`);
|
gestylt (vorher komplett ungestylt, betraf v. a. `register_liste.html`);
|
||||||
breite Tabellen stehen in einem `.table-scroll`-Wrapper (horizontales
|
breite Tabellen stehen in einem `.table-scroll`-Wrapper (horizontales
|
||||||
Scrollen auf dem Handy statt gequetschter Spalten).
|
Scrollen auf dem Handy statt gequetschter Spalten). Seit 2026-08-31 hat
|
||||||
|
`.table-scroll` zusätzlich einen rechten Fade-Rand (`mask-image`) —
|
||||||
|
ohne den sah eine am Fensterrand abgeschnittene breite Tabelle wie ein
|
||||||
|
Rendering-Fehler aus statt wie "hier geht's mit Scrollen weiter" (per
|
||||||
|
Screenshot am Werkzeugkatalog beobachtet: die letzten zwei Spalten
|
||||||
|
waren nur als einzelne Buchstaben sichtbar, ganz ohne Hinweis auf mehr
|
||||||
|
Inhalt).
|
||||||
|
|
||||||
|
**Formulare** (`.form-card`/`.form-section`/`.form-grid`/`.chip-group`
|
||||||
|
in `app.css`, seit 2026-08-31, erstmals angewendet auf
|
||||||
|
`betreiber_werkzeug_form.html`): vorher war jedes Formular eine flache
|
||||||
|
Spalte aus `<label>`+`<input>` ohne Gruppierung — bei einem Formular
|
||||||
|
mit über einem Dutzend Feldern (Werkzeug anlegen) wirkte das "klobig,
|
||||||
|
einfach nur untereinander" (O-Ton Nutzer). Jetzt: `.form-card` (weiße
|
||||||
|
Karte mit Rahmen/Schatten, Ant-Design-Tokens aus `design/enterprise.css`
|
||||||
|
übernommen) enthält mehrere `<fieldset class="form-section">`
|
||||||
|
(thematische Abschnitte mit Titel, per `border-top` getrennt), darin
|
||||||
|
`.form-grid` (2 Spalten ab 640px für kurze Felder wie Name/Anbieter
|
||||||
|
nebeneinander, `.form-full` erzwingt volle Breite für lange Felder).
|
||||||
|
Checkbox-Gruppen mit überschaubar vielen Optionen (Zusicherungen, die
|
||||||
|
zwölf Zwecke) sind `.chip-group` — abgerundete Pillen statt einer
|
||||||
|
langen Untereinander-Liste, ausgewählte Chips werden über
|
||||||
|
`:has(input:checked)` blau hervorgehoben (kein JavaScript). Ein
|
||||||
|
Löschen-Button steht nicht mehr einfach unter dem Formular, sondern in
|
||||||
|
einer abgesetzten `.form-gefahrenzone`-Fußzeile der Karte. Dieselben
|
||||||
|
Klassen sind bewusst generisch (nicht an ein Formular gebunden) — noch
|
||||||
|
nicht auf andere Formulare (Login/Registrierung, Fragebogen, übrige
|
||||||
|
Admin-Formulare) angewendet, das wäre der nächste Schritt, falls
|
||||||
|
gewünscht. Beim Umbau auffällig gewordener, vorbestehender Bug: das
|
||||||
|
Lösch-Formular in `betreiber_werkzeug_form.html` war fest auf
|
||||||
|
`/betreiber/werkzeuge/{id}/loeschen` verdrahtet, obwohl dieselbe
|
||||||
|
Vorlage auch vom Mandanten-Formular (`/verwaltung/werkzeuge/...`)
|
||||||
|
genutzt wird — ein Mandant hätte beim Löschen eines eigenen Werkzeugs
|
||||||
|
403 bekommen. Jetzt `{{.ActionBase}}/{{.ID}}/loeschen`, wie die
|
||||||
|
Formular-`action` selbst.
|
||||||
|
|
||||||
|
**Typografie-Nachbesserung (2026-08-31):** Nutzer-Feedback nach dem
|
||||||
|
ersten Formular-Redesign: Überschriften wirkten zu klein und zu dicht
|
||||||
|
am Rand. Ursache für Letzteres: `<legend>` in `<fieldset
|
||||||
|
class="form-section">` positioniert sich per Spec an der Border-Box
|
||||||
|
des Fieldsets, nicht an dessen Padding-Box — ohne eigenes Padding sitzt
|
||||||
|
die Abschnittsüberschrift optisch enger am Kartenrand als der übrige,
|
||||||
|
per `.form-section { padding: 20px }` eingerückte Inhalt. `.form-section
|
||||||
|
> legend` hat jetzt selbst `padding: 0 0 16px 0` und eine größere
|
||||||
|
Schrift (1.0625rem/700 statt 0.9375rem/600). Gleichzeitig `h2`
|
||||||
|
(1.25rem) und `h3` (1.0625rem) erstmals explizit gesetzt (vorher reiner
|
||||||
|
Browser-Default) und `.fragebogen legend` auf dieselbe Gewichtsstufe
|
||||||
|
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
|
**Verifikationsmethode für CSS-Änderungen:** Live-Cookie-Auth per
|
||||||
Chromium-Headless/CDP ist im Sandbox-Environment nicht möglich (kein
|
Chromium-Headless/CDP ist im Sandbox-Environment nicht möglich (kein
|
||||||
|
|||||||
@@ -187,12 +187,48 @@ func containsString(list []string, v string) bool {
|
|||||||
// unabhängig von internal/store und für sich allein testbar.
|
// unabhängig von internal/store und für sich allein testbar.
|
||||||
type WerkzeugEigenschaften struct {
|
type WerkzeugEigenschaften struct {
|
||||||
AVVVerfuegbar bool
|
AVVVerfuegbar bool
|
||||||
Verarbeitungsort string
|
// Verarbeitungslaender nennt die tatsächlichen Länder statt eines
|
||||||
|
// groben "EU"/"USA"-Eimers (siehe CLAUDE.md, Werkzeugkatalog) — die
|
||||||
|
// eu_verarbeitung-Anforderung gilt nur als erfüllt, wenn ALLE
|
||||||
|
// genannten Länder EU/EWR-Mitgliedstaaten sind.
|
||||||
|
Verarbeitungslaender []string
|
||||||
TrainingOptOut bool
|
TrainingOptOut bool
|
||||||
TrainingStandard bool
|
TrainingStandard bool
|
||||||
AufbewahrungTage int
|
AufbewahrungTage int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// euEwrLaender sind die Staaten, für die eine Verarbeitung nicht als
|
||||||
|
// Drittlandtransfer gilt (Art. 44 ff. DSGVO) — die 27 EU-Mitgliedstaaten
|
||||||
|
// plus die drei über den EWR-Vertrag gleichgestellten Staaten. Die USA
|
||||||
|
// gehören bewusst NICHT dazu — sie sind ein Drittland wie jedes andere,
|
||||||
|
// unabhängig vom EU-US Data Privacy Framework (das mildert die
|
||||||
|
// Transfer-Grundlage, macht die USA aber nicht zum EU/EWR-Gebiet).
|
||||||
|
var euEwrLaender = map[string]bool{
|
||||||
|
"Belgien": true, "Bulgarien": true, "Dänemark": true, "Deutschland": true,
|
||||||
|
"Estland": true, "Finnland": true, "Frankreich": true, "Griechenland": true,
|
||||||
|
"Irland": true, "Italien": true, "Kroatien": true, "Lettland": true,
|
||||||
|
"Litauen": true, "Luxemburg": true, "Malta": true, "Niederlande": true,
|
||||||
|
"Österreich": true, "Polen": true, "Portugal": true, "Rumänien": true,
|
||||||
|
"Schweden": true, "Slowakei": true, "Slowenien": true, "Spanien": true,
|
||||||
|
"Tschechien": true, "Ungarn": true, "Zypern": true,
|
||||||
|
"Island": true, "Liechtenstein": true, "Norwegen": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// alleLaenderInEUEWR prüft, ob jedes genannte Land EU/EWR ist. Eine
|
||||||
|
// leere Liste (kein Land benannt) gilt als NICHT erfüllt — "wissen wir
|
||||||
|
// nicht" darf nie stillschweigend als "ist okay" durchgehen.
|
||||||
|
func alleLaenderInEUEWR(laender []string) bool {
|
||||||
|
if len(laender) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, l := range laender {
|
||||||
|
if !euEwrLaender[l] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// ErfuelltAnforderung prüft, ob ein Werkzeug eine einzelne Anforderung
|
// ErfuelltAnforderung prüft, ob ein Werkzeug eine einzelne Anforderung
|
||||||
// erfüllt. Anforderungen, die einen Prozess statt eine technische
|
// erfüllt. Anforderungen, die einen Prozess statt eine technische
|
||||||
// Werkzeug-Eigenschaft betreffen (menschliche Aufsicht, Kennzeichnung,
|
// Werkzeug-Eigenschaft betreffen (menschliche Aufsicht, Kennzeichnung,
|
||||||
@@ -206,7 +242,7 @@ func ErfuelltAnforderung(anforderungID string, w WerkzeugEigenschaften) bool {
|
|||||||
case "avv_erforderlich":
|
case "avv_erforderlich":
|
||||||
return w.AVVVerfuegbar
|
return w.AVVVerfuegbar
|
||||||
case "eu_verarbeitung":
|
case "eu_verarbeitung":
|
||||||
return w.Verarbeitungsort == "EU"
|
return alleLaenderInEUEWR(w.Verarbeitungslaender)
|
||||||
case "kein_training_auf_eingabe":
|
case "kein_training_auf_eingabe":
|
||||||
return w.TrainingStandard
|
return w.TrainingStandard
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -158,6 +158,21 @@ func TestErfuelltAnforderungAVV(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestErfuelltAnforderungEUVerarbeitung(t *testing.T) {
|
||||||
|
if !rules.ErfuelltAnforderung("eu_verarbeitung", rules.WerkzeugEigenschaften{Verarbeitungslaender: []string{"Irland"}}) {
|
||||||
|
t.Error("erwartet: erfuellt bei einem einzelnen EU/EWR-Land")
|
||||||
|
}
|
||||||
|
if rules.ErfuelltAnforderung("eu_verarbeitung", rules.WerkzeugEigenschaften{Verarbeitungslaender: []string{"USA"}}) {
|
||||||
|
t.Error("erwartet: nicht erfuellt bei USA - die USA sind ein Drittland wie jedes andere")
|
||||||
|
}
|
||||||
|
if rules.ErfuelltAnforderung("eu_verarbeitung", rules.WerkzeugEigenschaften{Verarbeitungslaender: []string{"Irland", "USA"}}) {
|
||||||
|
t.Error("erwartet: nicht erfuellt, sobald auch nur ein Land nicht EU/EWR ist")
|
||||||
|
}
|
||||||
|
if rules.ErfuelltAnforderung("eu_verarbeitung", rules.WerkzeugEigenschaften{}) {
|
||||||
|
t.Error("erwartet: nicht erfuellt ohne benanntes Land - 'wissen wir nicht' darf nicht als 'ist okay' durchgehen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestErfuelltAnforderungUnbekannteAnforderungIstUnkritisch(t *testing.T) {
|
func TestErfuelltAnforderungUnbekannteAnforderungIstUnkritisch(t *testing.T) {
|
||||||
if !rules.ErfuelltAnforderung("menschliche_aufsicht", rules.WerkzeugEigenschaften{}) {
|
if !rules.ErfuelltAnforderung("menschliche_aufsicht", rules.WerkzeugEigenschaften{}) {
|
||||||
t.Error("Prozess-Anforderungen duerfen ein Werkzeug nicht hart aussortieren")
|
t.Error("Prozess-Anforderungen duerfen ein Werkzeug nicht hart aussortieren")
|
||||||
@@ -166,8 +181,8 @@ func TestErfuelltAnforderungUnbekannteAnforderungIstUnkritisch(t *testing.T) {
|
|||||||
|
|
||||||
func TestFilterWerkzeugeSortsOutMissingAVV(t *testing.T) {
|
func TestFilterWerkzeugeSortsOutMissingAVV(t *testing.T) {
|
||||||
kandidaten := []rules.WerkzeugKandidat{
|
kandidaten := []rules.WerkzeugKandidat{
|
||||||
{ID: "mit-avv", Eigenschaften: rules.WerkzeugEigenschaften{AVVVerfuegbar: true, Verarbeitungsort: "EU", TrainingStandard: true}},
|
{ID: "mit-avv", Eigenschaften: rules.WerkzeugEigenschaften{AVVVerfuegbar: true, Verarbeitungslaender: []string{"Deutschland"}, TrainingStandard: true}},
|
||||||
{ID: "ohne-avv", Eigenschaften: rules.WerkzeugEigenschaften{AVVVerfuegbar: false, Verarbeitungsort: "EU", TrainingStandard: true}},
|
{ID: "ohne-avv", Eigenschaften: rules.WerkzeugEigenschaften{AVVVerfuegbar: false, Verarbeitungslaender: []string{"Deutschland"}, TrainingStandard: true}},
|
||||||
}
|
}
|
||||||
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, []string{"avv_erforderlich"})
|
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, []string{"avv_erforderlich"})
|
||||||
if len(zulaessig) != 1 || zulaessig[0] != "mit-avv" {
|
if len(zulaessig) != 1 || zulaessig[0] != "mit-avv" {
|
||||||
|
|||||||
5
internal/store/migrations/0015_werkzeug_details.down.sql
Normal file
5
internal/store/migrations/0015_werkzeug_details.down.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE werkzeug DROP COLUMN subprozessoren;
|
||||||
|
|
||||||
|
UPDATE werkzeug SET aufbewahrung_tage = 0 WHERE aufbewahrung_tage IS NULL;
|
||||||
|
ALTER TABLE werkzeug ALTER COLUMN aufbewahrung_tage SET DEFAULT 0;
|
||||||
|
ALTER TABLE werkzeug ALTER COLUMN aufbewahrung_tage SET NOT NULL;
|
||||||
12
internal/store/migrations/0015_werkzeug_details.up.sql
Normal file
12
internal/store/migrations/0015_werkzeug_details.up.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
-- aufbewahrung_tage war bisher NOT NULL DEFAULT 0 — das machte "0" doppeldeutig:
|
||||||
|
-- mal eine echte, belegte Zusicherung (z. B. DeepL: sofortige Löschung),
|
||||||
|
-- mal ein Platzhalter für "vom Anbieter nicht beziffert". NULL trennt das
|
||||||
|
-- sauber: "unbekannt" statt "angeblich sofort gelöscht".
|
||||||
|
ALTER TABLE werkzeug ALTER COLUMN aufbewahrung_tage DROP NOT NULL;
|
||||||
|
ALTER TABLE werkzeug ALTER COLUMN aufbewahrung_tage DROP DEFAULT;
|
||||||
|
|
||||||
|
-- Subprozessoren (z. B. Anthropic als Unterauftragsverarbeiter bei
|
||||||
|
-- Microsoft 365 Copilot, AWS bei DeepL) verändern die EU/USA-Einstufung
|
||||||
|
-- eines Werkzeugs materiell — bisher nur als Freitext in
|
||||||
|
-- "einschraenkungen" erfasst, jetzt als eigenes, durchsuchbares Feld.
|
||||||
|
ALTER TABLE werkzeug ADD COLUMN subprozessoren TEXT[] NOT NULL DEFAULT '{}';
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
ALTER TABLE werkzeug ADD COLUMN verarbeitungsort TEXT;
|
||||||
|
|
||||||
|
-- Ein Rückbau von Freitext-Ländern auf den alten Vier-Werte-Eimer ist
|
||||||
|
-- verlustbehaftet (welches Land genau "gemischt" war, geht verloren) —
|
||||||
|
-- bestmögliche Näherung statt Datenverlust durch NULL.
|
||||||
|
UPDATE werkzeug SET verarbeitungsort =
|
||||||
|
CASE
|
||||||
|
WHEN verarbeitungslaender = ARRAY['On-Premise (selbst gehostet)'] THEN 'on-prem'
|
||||||
|
WHEN verarbeitungslaender = ARRAY['USA'] THEN 'USA'
|
||||||
|
WHEN array_length(verarbeitungslaender, 1) IS NULL OR array_length(verarbeitungslaender, 1) = 0 THEN 'gemischt'
|
||||||
|
WHEN 'USA' = ANY(verarbeitungslaender) THEN 'gemischt'
|
||||||
|
ELSE 'EU'
|
||||||
|
END;
|
||||||
|
|
||||||
|
ALTER TABLE werkzeug ALTER COLUMN verarbeitungsort SET NOT NULL;
|
||||||
|
ALTER TABLE werkzeug ADD CONSTRAINT werkzeug_verarbeitungsort_check
|
||||||
|
CHECK (verarbeitungsort IN ('EU', 'USA', 'gemischt', 'on-prem'));
|
||||||
|
|
||||||
|
ALTER TABLE werkzeug DROP COLUMN verarbeitungslaender;
|
||||||
|
ALTER TABLE werkzeug DROP COLUMN dpf_zertifiziert;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
-- "EU"/"USA"/"gemischt"/"on-prem" als grobe Eimer verschleierten, dass
|
||||||
|
-- auch die USA bereits ein Drittland sind (DSGVO Art. 44 ff. kennt nur
|
||||||
|
-- EU/EWR vs. Drittland) und dass "gemischt" nichts über die tatsächlich
|
||||||
|
-- beteiligten Länder aussagt (z. B. Irland+USA bei Microsoft 365
|
||||||
|
-- Copilot vs. Frankreich bei Mistral) — beides macht einen Unterschied
|
||||||
|
-- für die Einschätzung, nicht nur für die Anzeige. Ersetzt durch eine
|
||||||
|
-- Liste tatsächlicher Länder (oder einer Region, wenn der Anbieter
|
||||||
|
-- selbst keinen einzelnen Staat benennt, z. B. "EU-Region" bei
|
||||||
|
-- OpenAI/Microsoft).
|
||||||
|
ALTER TABLE werkzeug ADD COLUMN verarbeitungslaender TEXT[] NOT NULL DEFAULT '{}';
|
||||||
|
|
||||||
|
ALTER TABLE werkzeug DROP CONSTRAINT werkzeug_verarbeitungsort_check;
|
||||||
|
|
||||||
|
UPDATE werkzeug SET verarbeitungslaender =
|
||||||
|
CASE verarbeitungsort
|
||||||
|
WHEN 'EU' THEN ARRAY['EU-Region (Land nicht migriert, bitte prüfen)']
|
||||||
|
WHEN 'USA' THEN ARRAY['USA']
|
||||||
|
WHEN 'on-prem' THEN ARRAY['On-Premise (selbst gehostet)']
|
||||||
|
ELSE ARRAY['nicht spezifiziert (bitte prüfen)']
|
||||||
|
END;
|
||||||
|
|
||||||
|
ALTER TABLE werkzeug DROP COLUMN verarbeitungsort;
|
||||||
|
|
||||||
|
-- Ob ein US-Anbieter unter dem EU-US Data Privacy Framework zertifiziert
|
||||||
|
-- ist (schmalere, für Schrems-Nachfolgeverfahren anfällige Rechtsgrundlage)
|
||||||
|
-- oder sich rein auf Standardvertragsklauseln stützt, stand bisher nur als
|
||||||
|
-- Fließtext in "einschraenkungen" — jetzt strukturiert und damit künftig
|
||||||
|
-- filterbar.
|
||||||
|
ALTER TABLE werkzeug ADD COLUMN dpf_zertifiziert BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -20,13 +20,26 @@ type Werkzeug struct {
|
|||||||
AccountID *string
|
AccountID *string
|
||||||
Name string
|
Name string
|
||||||
Anbieter string
|
Anbieter string
|
||||||
Verarbeitungsort string
|
// Verarbeitungslaender nennt die tatsächlichen Länder (oder, wenn
|
||||||
|
// der Anbieter selbst keinen einzelnen Staat zusichert, eine Region
|
||||||
|
// wie "EU-Region") statt eines groben EU/USA/gemischt-Eimers — die
|
||||||
|
// USA sind DSGVO-rechtlich bereits ein Drittland wie jedes andere,
|
||||||
|
// "gemischt" verschleierte, welche Länder konkret beteiligt sind.
|
||||||
|
Verarbeitungslaender []string
|
||||||
AVVVerfuegbar bool
|
AVVVerfuegbar bool
|
||||||
AVVURL string
|
AVVURL string
|
||||||
TrainingOptOut bool
|
TrainingOptOut bool
|
||||||
TrainingStandard bool
|
TrainingStandard bool
|
||||||
AufbewahrungTage int
|
// DPFZertifiziert: EU-US Data Privacy Framework — schmalere,
|
||||||
|
// gerichtlich schon zweimal gekippte Rechtsgrundlage (Safe Harbor,
|
||||||
|
// Privacy Shield) für Transfers in die USA, gilt nur für zertifizierte
|
||||||
|
// Unternehmen. false bedeutet nicht "nicht zertifiziert" im Sinne von
|
||||||
|
// widerlegt, sondern "nicht als Zusicherung belegt" — wie bei den
|
||||||
|
// übrigen Booleans hier.
|
||||||
|
DPFZertifiziert bool
|
||||||
|
AufbewahrungTage *int // nil = vom Anbieter nicht beziffert, NICHT gleichbedeutend mit 0 Tagen
|
||||||
Zertifizierungen []string
|
Zertifizierungen []string
|
||||||
|
Subprozessoren []string
|
||||||
GeeigneteZwecke []string
|
GeeigneteZwecke []string
|
||||||
Einschraenkungen []string
|
Einschraenkungen []string
|
||||||
LetztePruefung time.Time
|
LetztePruefung time.Time
|
||||||
@@ -36,37 +49,39 @@ type Werkzeug struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WerkzeugInput bündelt die Felder eines Werkzeug-Eintrags für
|
// WerkzeugInput bündelt die Felder eines Werkzeug-Eintrags für
|
||||||
// Create/Update — bei 14 Feldern lesbarer als eine positionale
|
// Create/Update — bei 15 Feldern lesbarer als eine positionale
|
||||||
// Parameterliste.
|
// Parameterliste.
|
||||||
type WerkzeugInput struct {
|
type WerkzeugInput struct {
|
||||||
AccountID *string
|
AccountID *string
|
||||||
Name string
|
Name string
|
||||||
Anbieter string
|
Anbieter string
|
||||||
Verarbeitungsort string
|
Verarbeitungslaender []string
|
||||||
AVVVerfuegbar bool
|
AVVVerfuegbar bool
|
||||||
AVVURL string
|
AVVURL string
|
||||||
TrainingOptOut bool
|
TrainingOptOut bool
|
||||||
TrainingStandard bool
|
TrainingStandard bool
|
||||||
AufbewahrungTage int
|
DPFZertifiziert bool
|
||||||
|
AufbewahrungTage *int
|
||||||
Zertifizierungen []string
|
Zertifizierungen []string
|
||||||
|
Subprozessoren []string
|
||||||
GeeigneteZwecke []string
|
GeeigneteZwecke []string
|
||||||
Einschraenkungen []string
|
Einschraenkungen []string
|
||||||
LetztePruefung time.Time
|
LetztePruefung time.Time
|
||||||
Quelle string
|
Quelle string
|
||||||
}
|
}
|
||||||
|
|
||||||
const werkzeugColumns = `id, account_id, name, anbieter, verarbeitungsort, avv_verfuegbar, avv_url,
|
const werkzeugColumns = `id, account_id, name, anbieter, verarbeitungslaender, avv_verfuegbar, avv_url,
|
||||||
training_opt_out, training_standard, aufbewahrung_tage, zertifizierungen, geeignete_zwecke,
|
training_opt_out, training_standard, dpf_zertifiziert, aufbewahrung_tage, zertifizierungen, subprozessoren,
|
||||||
einschraenkungen, letzte_pruefung, quelle, created_at, updated_at`
|
geeignete_zwecke, einschraenkungen, letzte_pruefung, quelle, created_at, updated_at`
|
||||||
|
|
||||||
func scanWerkzeug(row interface {
|
func scanWerkzeug(row interface {
|
||||||
Scan(dest ...any) error
|
Scan(dest ...any) error
|
||||||
}) (Werkzeug, error) {
|
}) (Werkzeug, error) {
|
||||||
var w Werkzeug
|
var w Werkzeug
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&w.ID, &w.AccountID, &w.Name, &w.Anbieter, &w.Verarbeitungsort, &w.AVVVerfuegbar, &w.AVVURL,
|
&w.ID, &w.AccountID, &w.Name, &w.Anbieter, &w.Verarbeitungslaender, &w.AVVVerfuegbar, &w.AVVURL,
|
||||||
&w.TrainingOptOut, &w.TrainingStandard, &w.AufbewahrungTage, &w.Zertifizierungen, &w.GeeigneteZwecke,
|
&w.TrainingOptOut, &w.TrainingStandard, &w.DPFZertifiziert, &w.AufbewahrungTage, &w.Zertifizierungen, &w.Subprozessoren,
|
||||||
&w.Einschraenkungen, &w.LetztePruefung, &w.Quelle, &w.CreatedAt, &w.UpdatedAt,
|
&w.GeeigneteZwecke, &w.Einschraenkungen, &w.LetztePruefung, &w.Quelle, &w.CreatedAt, &w.UpdatedAt,
|
||||||
)
|
)
|
||||||
return w, err
|
return w, err
|
||||||
}
|
}
|
||||||
@@ -77,6 +92,9 @@ func scanWerkzeug(row interface {
|
|||||||
// einer wenig hilfreichen Constraint-Fehlermeldung abgelehnt (derselbe
|
// einer wenig hilfreichen Constraint-Fehlermeldung abgelehnt (derselbe
|
||||||
// Fall wie früher bei finding.sources).
|
// Fall wie früher bei finding.sources).
|
||||||
func normalizeWerkzeugSlices(in *WerkzeugInput) {
|
func normalizeWerkzeugSlices(in *WerkzeugInput) {
|
||||||
|
if in.Verarbeitungslaender == nil {
|
||||||
|
in.Verarbeitungslaender = []string{}
|
||||||
|
}
|
||||||
if in.Zertifizierungen == nil {
|
if in.Zertifizierungen == nil {
|
||||||
in.Zertifizierungen = []string{}
|
in.Zertifizierungen = []string{}
|
||||||
}
|
}
|
||||||
@@ -86,6 +104,9 @@ func normalizeWerkzeugSlices(in *WerkzeugInput) {
|
|||||||
if in.Einschraenkungen == nil {
|
if in.Einschraenkungen == nil {
|
||||||
in.Einschraenkungen = []string{}
|
in.Einschraenkungen = []string{}
|
||||||
}
|
}
|
||||||
|
if in.Subprozessoren == nil {
|
||||||
|
in.Subprozessoren = []string{}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateWerkzeug legt einen Katalogeintrag an.
|
// CreateWerkzeug legt einen Katalogeintrag an.
|
||||||
@@ -93,13 +114,13 @@ func (s *Store) CreateWerkzeug(ctx context.Context, in WerkzeugInput) (Werkzeug,
|
|||||||
normalizeWerkzeugSlices(&in)
|
normalizeWerkzeugSlices(&in)
|
||||||
row := s.Pool.QueryRow(ctx, `
|
row := s.Pool.QueryRow(ctx, `
|
||||||
INSERT INTO werkzeug (
|
INSERT INTO werkzeug (
|
||||||
account_id, name, anbieter, verarbeitungsort, avv_verfuegbar, avv_url,
|
account_id, name, anbieter, verarbeitungslaender, avv_verfuegbar, avv_url,
|
||||||
training_opt_out, training_standard, aufbewahrung_tage, zertifizierungen,
|
training_opt_out, training_standard, dpf_zertifiziert, aufbewahrung_tage, zertifizierungen, subprozessoren,
|
||||||
geeignete_zwecke, einschraenkungen, letzte_pruefung, quelle
|
geeignete_zwecke, einschraenkungen, letzte_pruefung, quelle
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||||||
RETURNING `+werkzeugColumns,
|
RETURNING `+werkzeugColumns,
|
||||||
in.AccountID, in.Name, in.Anbieter, in.Verarbeitungsort, in.AVVVerfuegbar, in.AVVURL,
|
in.AccountID, in.Name, in.Anbieter, in.Verarbeitungslaender, in.AVVVerfuegbar, in.AVVURL,
|
||||||
in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage, in.Zertifizierungen,
|
in.TrainingOptOut, in.TrainingStandard, in.DPFZertifiziert, in.AufbewahrungTage, in.Zertifizierungen, in.Subprozessoren,
|
||||||
in.GeeigneteZwecke, in.Einschraenkungen, in.LetztePruefung, in.Quelle,
|
in.GeeigneteZwecke, in.Einschraenkungen, in.LetztePruefung, in.Quelle,
|
||||||
)
|
)
|
||||||
w, err := scanWerkzeug(row)
|
w, err := scanWerkzeug(row)
|
||||||
@@ -118,14 +139,14 @@ func (s *Store) UpdateWerkzeug(ctx context.Context, id string, in WerkzeugInput)
|
|||||||
normalizeWerkzeugSlices(&in)
|
normalizeWerkzeugSlices(&in)
|
||||||
row := s.Pool.QueryRow(ctx, `
|
row := s.Pool.QueryRow(ctx, `
|
||||||
UPDATE werkzeug SET
|
UPDATE werkzeug SET
|
||||||
name = $2, anbieter = $3, verarbeitungsort = $4, avv_verfuegbar = $5, avv_url = $6,
|
name = $2, anbieter = $3, verarbeitungslaender = $4, avv_verfuegbar = $5, avv_url = $6,
|
||||||
training_opt_out = $7, training_standard = $8, aufbewahrung_tage = $9,
|
training_opt_out = $7, training_standard = $8, dpf_zertifiziert = $9, aufbewahrung_tage = $10,
|
||||||
zertifizierungen = $10, geeignete_zwecke = $11, einschraenkungen = $12,
|
zertifizierungen = $11, subprozessoren = $12, geeignete_zwecke = $13, einschraenkungen = $14,
|
||||||
letzte_pruefung = $13, quelle = $14, updated_at = now()
|
letzte_pruefung = $15, quelle = $16, updated_at = now()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING `+werkzeugColumns,
|
RETURNING `+werkzeugColumns,
|
||||||
id, in.Name, in.Anbieter, in.Verarbeitungsort, in.AVVVerfuegbar, in.AVVURL,
|
id, in.Name, in.Anbieter, in.Verarbeitungslaender, in.AVVVerfuegbar, in.AVVURL,
|
||||||
in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage, in.Zertifizierungen,
|
in.TrainingOptOut, in.TrainingStandard, in.DPFZertifiziert, in.AufbewahrungTage, in.Zertifizierungen, in.Subprozessoren,
|
||||||
in.GeeigneteZwecke, in.Einschraenkungen, in.LetztePruefung, in.Quelle,
|
in.GeeigneteZwecke, in.Einschraenkungen, in.LetztePruefung, in.Quelle,
|
||||||
)
|
)
|
||||||
w, err := scanWerkzeug(row)
|
w, err := scanWerkzeug(row)
|
||||||
|
|||||||
@@ -9,18 +9,21 @@ import (
|
|||||||
"github.com/netcell-it/deklarix/internal/store"
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func intPtr(v int) *int { return &v }
|
||||||
|
|
||||||
func testWerkzeugInput(accountID *string, name string) store.WerkzeugInput {
|
func testWerkzeugInput(accountID *string, name string) store.WerkzeugInput {
|
||||||
return store.WerkzeugInput{
|
return store.WerkzeugInput{
|
||||||
AccountID: accountID,
|
AccountID: accountID,
|
||||||
Name: name,
|
Name: name,
|
||||||
Anbieter: "Beispiel-Anbieter GmbH",
|
Anbieter: "Beispiel-Anbieter GmbH",
|
||||||
Verarbeitungsort: "EU",
|
Verarbeitungslaender: []string{"Deutschland"},
|
||||||
AVVVerfuegbar: true,
|
AVVVerfuegbar: true,
|
||||||
AVVURL: "https://beispiel.example/avv",
|
AVVURL: "https://beispiel.example/avv",
|
||||||
TrainingOptOut: true,
|
TrainingOptOut: true,
|
||||||
TrainingStandard: true,
|
TrainingStandard: true,
|
||||||
AufbewahrungTage: 30,
|
AufbewahrungTage: intPtr(30),
|
||||||
Zertifizierungen: []string{"ISO 27001"},
|
Zertifizierungen: []string{"ISO 27001"},
|
||||||
|
Subprozessoren: []string{"Beispiel-Subprozessor Inc."},
|
||||||
GeeigneteZwecke: []string{"Textgenerierung"},
|
GeeigneteZwecke: []string{"Textgenerierung"},
|
||||||
Einschraenkungen: nil,
|
Einschraenkungen: nil,
|
||||||
LetztePruefung: time.Now().Add(-24 * time.Hour).Truncate(time.Millisecond),
|
LetztePruefung: time.Now().Add(-24 * time.Hour).Truncate(time.Millisecond),
|
||||||
@@ -42,6 +45,24 @@ func TestWerkzeugCRUD(t *testing.T) {
|
|||||||
if len(w.Zertifizierungen) != 1 || w.Zertifizierungen[0] != "ISO 27001" {
|
if len(w.Zertifizierungen) != 1 || w.Zertifizierungen[0] != "ISO 27001" {
|
||||||
t.Fatalf("Zertifizierungen = %v, want [ISO 27001]", w.Zertifizierungen)
|
t.Fatalf("Zertifizierungen = %v, want [ISO 27001]", w.Zertifizierungen)
|
||||||
}
|
}
|
||||||
|
if len(w.Subprozessoren) != 1 || w.Subprozessoren[0] != "Beispiel-Subprozessor Inc." {
|
||||||
|
t.Fatalf("Subprozessoren = %v, want [Beispiel-Subprozessor Inc.]", w.Subprozessoren)
|
||||||
|
}
|
||||||
|
if w.AufbewahrungTage == nil || *w.AufbewahrungTage != 30 {
|
||||||
|
t.Fatalf("AufbewahrungTage = %v, want 30", w.AufbewahrungTage)
|
||||||
|
}
|
||||||
|
|
||||||
|
unbekannt, err := s.CreateWerkzeug(ctx, func() store.WerkzeugInput {
|
||||||
|
in := testWerkzeugInput(nil, "Werkzeug ohne Aufbewahrungsangabe")
|
||||||
|
in.AufbewahrungTage = nil
|
||||||
|
return in
|
||||||
|
}())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateWerkzeug (ohne Aufbewahrungsangabe): %v", err)
|
||||||
|
}
|
||||||
|
if unbekannt.AufbewahrungTage != nil {
|
||||||
|
t.Fatalf("AufbewahrungTage = %v, want nil (unbekannt)", *unbekannt.AufbewahrungTage)
|
||||||
|
}
|
||||||
|
|
||||||
got, err := s.GetWerkzeug(ctx, w.ID)
|
got, err := s.GetWerkzeug(ctx, w.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -52,12 +73,12 @@ func TestWerkzeugCRUD(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateInput := testWerkzeugInput(nil, "Zentrales Werkzeug (aktualisiert)")
|
updateInput := testWerkzeugInput(nil, "Zentrales Werkzeug (aktualisiert)")
|
||||||
updateInput.AufbewahrungTage = 14
|
updateInput.AufbewahrungTage = intPtr(14)
|
||||||
updated, err := s.UpdateWerkzeug(ctx, w.ID, updateInput)
|
updated, err := s.UpdateWerkzeug(ctx, w.ID, updateInput)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("UpdateWerkzeug: %v", err)
|
t.Fatalf("UpdateWerkzeug: %v", err)
|
||||||
}
|
}
|
||||||
if updated.Name != "Zentrales Werkzeug (aktualisiert)" || updated.AufbewahrungTage != 14 {
|
if updated.Name != "Zentrales Werkzeug (aktualisiert)" || updated.AufbewahrungTage == nil || *updated.AufbewahrungTage != 14 {
|
||||||
t.Fatalf("UpdateWerkzeug = %+v, unerwartete Werte", updated)
|
t.Fatalf("UpdateWerkzeug = %+v, unerwartete Werte", updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ package web
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/netcell-it/deklarix/internal/auth"
|
"github.com/netcell-it/deklarix/internal/auth"
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mandantenRollen sind die Rollen, die ein Admin innerhalb des eigenen
|
// mandantenRollen sind die Rollen, die ein Admin innerhalb des eigenen
|
||||||
@@ -38,6 +40,8 @@ type nutzerListeData struct {
|
|||||||
Title string
|
Title string
|
||||||
Nav navData
|
Nav navData
|
||||||
Nutzer []nutzerListItem
|
Nutzer []nutzerListItem
|
||||||
|
Suche string
|
||||||
|
Pagination pagination
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleNutzerListe zeigt alle Logins des eigenen Mandanten.
|
// 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)
|
http.Error(w, "Nutzer konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data := nutzerListeData{Title: "Nutzerverwaltung", Nav: navFor(r)}
|
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||||
|
var gefiltert []store.User
|
||||||
for _, u := range users {
|
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{
|
data.Nutzer = append(data.Nutzer, nutzerListItem{
|
||||||
ID: u.ID, Email: u.Email, Role: u.Role, Active: u.Active, IstSelbst: u.ID == self.ID,
|
ID: u.ID, Email: u.Email, Role: u.Role, Active: u.Active, IstSelbst: u.ID == self.ID,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -289,9 +289,13 @@ func (s *Server) bewerteAntrag(ctx context.Context, antragID, accountID string,
|
|||||||
}
|
}
|
||||||
kandidaten := make([]rules.WerkzeugKandidat, len(werkzeuge))
|
kandidaten := make([]rules.WerkzeugKandidat, len(werkzeuge))
|
||||||
for i, w := range werkzeuge {
|
for i, w := range werkzeuge {
|
||||||
|
aufbewahrungTage := 0
|
||||||
|
if w.AufbewahrungTage != nil {
|
||||||
|
aufbewahrungTage = *w.AufbewahrungTage
|
||||||
|
}
|
||||||
kandidaten[i] = rules.WerkzeugKandidat{ID: w.ID, Eigenschaften: rules.WerkzeugEigenschaften{
|
kandidaten[i] = rules.WerkzeugKandidat{ID: w.ID, Eigenschaften: rules.WerkzeugEigenschaften{
|
||||||
AVVVerfuegbar: w.AVVVerfuegbar, Verarbeitungsort: w.Verarbeitungsort,
|
AVVVerfuegbar: w.AVVVerfuegbar, Verarbeitungslaender: w.Verarbeitungslaender,
|
||||||
TrainingOptOut: w.TrainingOptOut, TrainingStandard: w.TrainingStandard, AufbewahrungTage: w.AufbewahrungTage,
|
TrainingOptOut: w.TrainingOptOut, TrainingStandard: w.TrainingStandard, AufbewahrungTage: aufbewahrungTage,
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, anforderungIDs)
|
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, anforderungIDs)
|
||||||
|
|||||||
@@ -14,11 +14,79 @@ import (
|
|||||||
"github.com/netcell-it/deklarix/internal/store"
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
var gueltigeVerarbeitungsorte = []string{"EU", "USA", "gemischt", "on-prem"}
|
// 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 istGueltigerVerarbeitungsort(v string) bool {
|
func werkzeugFilterFromRequest(r *http.Request) werkzeugFilter {
|
||||||
for _, o := range gueltigeVerarbeitungsorte {
|
return werkzeugFilter{
|
||||||
if o == v {
|
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.
|
||||||
|
func istGueltigesLand(v string) bool {
|
||||||
|
for _, l := range weltLaenderliste {
|
||||||
|
if l == v {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// gueltigeZwecke ist das kontrollierte Vokabular für "geeignete Zwecke"
|
||||||
|
// (siehe CLAUDE.md, Bewertungslogik Schritt 3). Vorher war das Feld
|
||||||
|
// Freitext; ein Tippfehler ("Text" statt "Textgenerierung") hätte den
|
||||||
|
// künftigen automatischen Zweckabgleich unbemerkt kaputt gemacht. Am
|
||||||
|
// 2026-08-31 von sechs auf zwölf Kategorien erweitert (bewusste
|
||||||
|
// v1-Scope-Entscheidung überarbeitet, nicht stillschweigend geändert)
|
||||||
|
// — die ursprünglichen sechs deckten nur klassische Wissensarbeit ab,
|
||||||
|
// nicht Datenanalyse/Video/Audio/Kundenservice/Design/Automatisierung.
|
||||||
|
var gueltigeZwecke = []string{
|
||||||
|
"Textgenerierung", "Übersetzung", "Code", "Bild", "Transkription", "Recherche",
|
||||||
|
"Datenanalyse", "Video", "Audio/Sprache", "Kundenservice/Chatbot", "Präsentation/Design", "Automatisierung/Agenten",
|
||||||
|
}
|
||||||
|
|
||||||
|
func istGueltigerZweck(v string) bool {
|
||||||
|
for _, z := range gueltigeZwecke {
|
||||||
|
if z == v {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -43,14 +111,66 @@ type werkzeugListItem struct {
|
|||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
Anbieter string
|
Anbieter string
|
||||||
Verarbeitungsort string
|
Verarbeitungslaender string
|
||||||
|
DPFZertifiziert bool
|
||||||
|
AVVVerfuegbar bool
|
||||||
|
TrainingStandard bool
|
||||||
|
Aufbewahrung string
|
||||||
|
Zertifizierungen string
|
||||||
|
GeeigneteZwecke string
|
||||||
LetztePruefung string
|
LetztePruefung string
|
||||||
|
PruefStatusLabel string
|
||||||
|
PruefStatusCSS string
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatAufbewahrung stellt sicher, dass "unbekannt" (Anbieter hat
|
||||||
|
// keine Angabe gemacht) nie mit "0 Tage" (belegte Zusicherung einer
|
||||||
|
// sofortigen Löschung) verwechselt wird.
|
||||||
|
func formatAufbewahrung(tage *int) string {
|
||||||
|
if tage == nil {
|
||||||
|
return "unbekannt"
|
||||||
|
}
|
||||||
|
if *tage == 0 {
|
||||||
|
return "0 Tage (sofort)"
|
||||||
|
}
|
||||||
|
return strconv.Itoa(*tage) + " Tage"
|
||||||
}
|
}
|
||||||
|
|
||||||
type werkzeugListeData struct {
|
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
|
||||||
|
// Katalogeintrag im Ergebnis als "abgelaufen" markiert wird (siehe
|
||||||
|
// CLAUDE.md, Werkzeugkatalog) — jede Zusicherung im Katalog muss
|
||||||
|
// belegbar UND aktuell sein. werkzeugPruefungBaldFaelligNach liegt
|
||||||
|
// 30 Tage davor (dasselbe Vorwarnfenster wie die Wiedervorlage,
|
||||||
|
// wiedervorlageSchwelle) — "bald fällig" soll rechtzeitig auffallen,
|
||||||
|
// nicht erst wenn die Frist schon gerissen ist.
|
||||||
|
const (
|
||||||
|
werkzeugPruefungBaldFaelligNach = 150 * 24 * time.Hour
|
||||||
|
werkzeugPruefungVeraltetNach = 180 * 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
// pruefStatus liefert eine Ampel-Kennzeichnung für das Alter einer
|
||||||
|
// letzten Werkzeug-Prüfung: aktuell (grün) / bald fällig (gelb) /
|
||||||
|
// abgelaufen (rot) — die CSS-Klassen sind dieselben wie für die
|
||||||
|
// Risiko-Ampel anderswo im Produkt (status-niedrig/-mittel/-hoch).
|
||||||
|
func pruefStatus(letztePruefung time.Time) (label, css string) {
|
||||||
|
alter := time.Since(letztePruefung)
|
||||||
|
switch {
|
||||||
|
case alter > werkzeugPruefungVeraltetNach:
|
||||||
|
return "abgelaufen", "status-hoch"
|
||||||
|
case alter > werkzeugPruefungBaldFaelligNach:
|
||||||
|
return "bald fällig", "status-mittel"
|
||||||
|
default:
|
||||||
|
return "aktuell", "status-niedrig"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleBetreiberWerkzeugListe zeigt den zentralen Katalog.
|
// handleBetreiberWerkzeugListe zeigt den zentralen Katalog.
|
||||||
@@ -60,11 +180,30 @@ 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)
|
||||||
data.Werkzeuge = append(data.Werkzeuge, werkzeugListItem{
|
data.Werkzeuge = append(data.Werkzeuge, werkzeugListItem{
|
||||||
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungsort: wz.Verarbeitungsort,
|
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungslaender: strings.Join(wz.Verarbeitungslaender, ", "),
|
||||||
|
DPFZertifiziert: wz.DPFZertifiziert,
|
||||||
|
AVVVerfuegbar: wz.AVVVerfuegbar, TrainingStandard: wz.TrainingStandard,
|
||||||
|
Aufbewahrung: formatAufbewahrung(wz.AufbewahrungTage),
|
||||||
|
Zertifizierungen: strings.Join(wz.Zertifizierungen, ", "), GeeigneteZwecke: strings.Join(wz.GeeigneteZwecke, ", "),
|
||||||
LetztePruefung: wz.LetztePruefung.Format("02.01.2006"),
|
LetztePruefung: wz.LetztePruefung.Format("02.01.2006"),
|
||||||
|
PruefStatusLabel: label, PruefStatusCSS: css,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeuge", data); err != nil {
|
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeuge", data); err != nil {
|
||||||
@@ -83,23 +222,61 @@ type werkzeugFormData struct {
|
|||||||
ID string // leer bei "neu"
|
ID string // leer bei "neu"
|
||||||
Name string
|
Name string
|
||||||
Anbieter string
|
Anbieter string
|
||||||
Verarbeitungsorte []string
|
LaenderOptionen []string
|
||||||
Verarbeitungsort string
|
LaenderAusgewaehlt map[string]bool
|
||||||
|
DPFZertifiziert bool
|
||||||
AVVVerfuegbar bool
|
AVVVerfuegbar bool
|
||||||
AVVURL string
|
AVVURL string
|
||||||
TrainingOptOut bool
|
TrainingOptOut bool
|
||||||
TrainingStandard bool
|
TrainingStandard bool
|
||||||
AufbewahrungTage int
|
AufbewahrungTage string // leer = vom Anbieter nicht beziffert (unbekannt), NICHT "0"
|
||||||
Zertifizierungen string
|
Zertifizierungen string
|
||||||
GeeigneteZwecke string
|
Subprozessoren string
|
||||||
|
ZweckeOptionen []string
|
||||||
|
ZweckeAusgewaehlt map[string]bool
|
||||||
Einschraenkungen string
|
Einschraenkungen string
|
||||||
LetztePruefung string
|
LetztePruefung string
|
||||||
Quelle string
|
Quelle string
|
||||||
Error string
|
Error string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// auswahlSet baut die Checkbox-Vorbelegung aus einer bereits
|
||||||
|
// gespeicherten oder erneut eingereichten Liste von Zwecken.
|
||||||
|
func auswahlSet(ausgewaehlt []string) map[string]bool {
|
||||||
|
m := make(map[string]bool, len(ausgewaehlt))
|
||||||
|
for _, z := range ausgewaehlt {
|
||||||
|
m[z] = true
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// werkzeugFormDataFromWerkzeug baut die Formular-Vorbelegung zum
|
||||||
|
// Bearbeiten eines bestehenden Katalogeintrags — gemeinsam für Betreiber-
|
||||||
|
// und Mandanten-Formular, damit beide bei neuen Feldern nicht auseinanderlaufen.
|
||||||
|
func werkzeugFormDataFromWerkzeug(r *http.Request, wz store.Werkzeug, title, actionBase string) werkzeugFormData {
|
||||||
|
aufbewahrung := ""
|
||||||
|
if wz.AufbewahrungTage != nil {
|
||||||
|
aufbewahrung = strconv.Itoa(*wz.AufbewahrungTage)
|
||||||
|
}
|
||||||
|
return werkzeugFormData{
|
||||||
|
Title: title, Nav: navFor(r), ActionBase: actionBase, ID: wz.ID,
|
||||||
|
LaenderOptionen: weltLaenderliste, LaenderAusgewaehlt: auswahlSet(wz.Verarbeitungslaender), DPFZertifiziert: wz.DPFZertifiziert,
|
||||||
|
Name: wz.Name, Anbieter: wz.Anbieter,
|
||||||
|
AVVVerfuegbar: wz.AVVVerfuegbar, AVVURL: wz.AVVURL,
|
||||||
|
TrainingOptOut: wz.TrainingOptOut, TrainingStandard: wz.TrainingStandard, AufbewahrungTage: aufbewahrung,
|
||||||
|
Zertifizierungen: strings.Join(wz.Zertifizierungen, ", "), Subprozessoren: strings.Join(wz.Subprozessoren, ", "),
|
||||||
|
ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: auswahlSet(wz.GeeigneteZwecke),
|
||||||
|
Einschraenkungen: strings.Join(wz.Einschraenkungen, ", "), LetztePruefung: wz.LetztePruefung.Format("2006-01-02"),
|
||||||
|
Quelle: wz.Quelle,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleBetreiberWerkzeugNeuForm(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleBetreiberWerkzeugNeuForm(w http.ResponseWriter, r *http.Request) {
|
||||||
data := werkzeugFormData{Title: "Werkzeug anlegen", Nav: navFor(r), ActionBase: "/betreiber/werkzeuge", Verarbeitungsorte: gueltigeVerarbeitungsorte}
|
data := werkzeugFormData{
|
||||||
|
Title: "Werkzeug anlegen", Nav: navFor(r), ActionBase: "/betreiber/werkzeuge",
|
||||||
|
LaenderOptionen: weltLaenderliste, LaenderAusgewaehlt: map[string]bool{},
|
||||||
|
ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: map[string]bool{},
|
||||||
|
}
|
||||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
@@ -113,9 +290,14 @@ func werkzeugInputFromForm(r *http.Request, accountID *string) (store.WerkzeugIn
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return store.WerkzeugInput{}, "Datum der letzten Prüfung ist ungültig oder fehlt"
|
return store.WerkzeugInput{}, "Datum der letzten Prüfung ist ungültig oder fehlt"
|
||||||
}
|
}
|
||||||
verarbeitungsort := r.FormValue("verarbeitungsort")
|
var verarbeitungslaender []string
|
||||||
if !istGueltigerVerarbeitungsort(verarbeitungsort) {
|
for _, l := range r.Form["verarbeitungslaender"] {
|
||||||
return store.WerkzeugInput{}, "ungültiger Verarbeitungsort"
|
if istGueltigesLand(l) {
|
||||||
|
verarbeitungslaender = append(verarbeitungslaender, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(verarbeitungslaender) == 0 {
|
||||||
|
return store.WerkzeugInput{}, "mindestens ein Verarbeitungsland ist Pflicht"
|
||||||
}
|
}
|
||||||
name := r.FormValue("name")
|
name := r.FormValue("name")
|
||||||
anbieter := r.FormValue("anbieter")
|
anbieter := r.FormValue("anbieter")
|
||||||
@@ -123,20 +305,36 @@ func werkzeugInputFromForm(r *http.Request, accountID *string) (store.WerkzeugIn
|
|||||||
if name == "" || anbieter == "" || quelle == "" {
|
if name == "" || anbieter == "" || quelle == "" {
|
||||||
return store.WerkzeugInput{}, "Name, Anbieter und Quelle sind Pflicht"
|
return store.WerkzeugInput{}, "Name, Anbieter und Quelle sind Pflicht"
|
||||||
}
|
}
|
||||||
aufbewahrungTage, _ := strconv.Atoi(r.FormValue("aufbewahrung_tage"))
|
var aufbewahrungTage *int
|
||||||
|
if rohwert := strings.TrimSpace(r.FormValue("aufbewahrung_tage")); rohwert != "" {
|
||||||
|
wert, err := strconv.Atoi(rohwert)
|
||||||
|
if err != nil || wert < 0 {
|
||||||
|
return store.WerkzeugInput{}, "Aufbewahrung in Tagen muss eine Zahl ≥ 0 sein (leer lassen, wenn unbekannt)"
|
||||||
|
}
|
||||||
|
aufbewahrungTage = &wert
|
||||||
|
}
|
||||||
|
|
||||||
|
var geeigneteZwecke []string
|
||||||
|
for _, z := range r.Form["geeignete_zwecke"] {
|
||||||
|
if istGueltigerZweck(z) {
|
||||||
|
geeigneteZwecke = append(geeigneteZwecke, z)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return store.WerkzeugInput{
|
return store.WerkzeugInput{
|
||||||
AccountID: accountID,
|
AccountID: accountID,
|
||||||
Name: name,
|
Name: name,
|
||||||
Anbieter: anbieter,
|
Anbieter: anbieter,
|
||||||
Verarbeitungsort: verarbeitungsort,
|
Verarbeitungslaender: verarbeitungslaender,
|
||||||
AVVVerfuegbar: r.FormValue("avv_verfuegbar") == "on",
|
AVVVerfuegbar: r.FormValue("avv_verfuegbar") == "on",
|
||||||
AVVURL: r.FormValue("avv_url"),
|
AVVURL: r.FormValue("avv_url"),
|
||||||
TrainingOptOut: r.FormValue("training_opt_out") == "on",
|
TrainingOptOut: r.FormValue("training_opt_out") == "on",
|
||||||
TrainingStandard: r.FormValue("training_standard") == "on",
|
TrainingStandard: r.FormValue("training_standard") == "on",
|
||||||
|
DPFZertifiziert: r.FormValue("dpf_zertifiziert") == "on",
|
||||||
AufbewahrungTage: aufbewahrungTage,
|
AufbewahrungTage: aufbewahrungTage,
|
||||||
Zertifizierungen: splitListe(r.FormValue("zertifizierungen")),
|
Zertifizierungen: splitListe(r.FormValue("zertifizierungen")),
|
||||||
GeeigneteZwecke: splitListe(r.FormValue("geeignete_zwecke")),
|
Subprozessoren: splitListe(r.FormValue("subprozessoren")),
|
||||||
|
GeeigneteZwecke: geeigneteZwecke,
|
||||||
Einschraenkungen: splitListe(r.FormValue("einschraenkungen")),
|
Einschraenkungen: splitListe(r.FormValue("einschraenkungen")),
|
||||||
LetztePruefung: letztePruefung,
|
LetztePruefung: letztePruefung,
|
||||||
Quelle: quelle,
|
Quelle: quelle,
|
||||||
@@ -148,11 +346,15 @@ func werkzeugInputFromForm(r *http.Request, accountID *string) (store.WerkzeugIn
|
|||||||
// statt das Formular zu leeren.
|
// statt das Formular zu leeren.
|
||||||
func werkzeugFormDataFromRequest(r *http.Request, title, actionBase, id, errMsg string) werkzeugFormData {
|
func werkzeugFormDataFromRequest(r *http.Request, title, actionBase, id, errMsg string) werkzeugFormData {
|
||||||
return werkzeugFormData{
|
return werkzeugFormData{
|
||||||
Title: title, Nav: navFor(r), ActionBase: actionBase, ID: id, Verarbeitungsorte: gueltigeVerarbeitungsorte,
|
Title: title, Nav: navFor(r), ActionBase: actionBase, ID: id,
|
||||||
Name: r.FormValue("name"), Anbieter: r.FormValue("anbieter"), Verarbeitungsort: r.FormValue("verarbeitungsort"),
|
LaenderOptionen: weltLaenderliste, LaenderAusgewaehlt: auswahlSet(r.Form["verarbeitungslaender"]),
|
||||||
|
DPFZertifiziert: r.FormValue("dpf_zertifiziert") == "on",
|
||||||
|
Name: r.FormValue("name"), Anbieter: r.FormValue("anbieter"),
|
||||||
AVVVerfuegbar: r.FormValue("avv_verfuegbar") == "on", AVVURL: r.FormValue("avv_url"),
|
AVVVerfuegbar: r.FormValue("avv_verfuegbar") == "on", AVVURL: r.FormValue("avv_url"),
|
||||||
TrainingOptOut: r.FormValue("training_opt_out") == "on", TrainingStandard: r.FormValue("training_standard") == "on",
|
TrainingOptOut: r.FormValue("training_opt_out") == "on", TrainingStandard: r.FormValue("training_standard") == "on",
|
||||||
Zertifizierungen: r.FormValue("zertifizierungen"), GeeigneteZwecke: r.FormValue("geeignete_zwecke"),
|
AufbewahrungTage: strings.TrimSpace(r.FormValue("aufbewahrung_tage")),
|
||||||
|
Zertifizierungen: r.FormValue("zertifizierungen"), Subprozessoren: r.FormValue("subprozessoren"),
|
||||||
|
ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: auswahlSet(r.Form["geeignete_zwecke"]),
|
||||||
Einschraenkungen: r.FormValue("einschraenkungen"), LetztePruefung: r.FormValue("letzte_pruefung"),
|
Einschraenkungen: r.FormValue("einschraenkungen"), LetztePruefung: r.FormValue("letzte_pruefung"),
|
||||||
Quelle: r.FormValue("quelle"), Error: errMsg,
|
Quelle: r.FormValue("quelle"), Error: errMsg,
|
||||||
}
|
}
|
||||||
@@ -192,15 +394,7 @@ func (s *Server) handleBetreiberWerkzeugEditForm(w http.ResponseWriter, r *http.
|
|||||||
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
|
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data := werkzeugFormData{
|
data := werkzeugFormDataFromWerkzeug(r, wz, "Werkzeug bearbeiten", "/betreiber/werkzeuge")
|
||||||
Title: "Werkzeug bearbeiten", Nav: navFor(r), ActionBase: "/betreiber/werkzeuge", ID: wz.ID, Verarbeitungsorte: gueltigeVerarbeitungsorte,
|
|
||||||
Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungsort: wz.Verarbeitungsort,
|
|
||||||
AVVVerfuegbar: wz.AVVVerfuegbar, AVVURL: wz.AVVURL,
|
|
||||||
TrainingOptOut: wz.TrainingOptOut, TrainingStandard: wz.TrainingStandard, AufbewahrungTage: wz.AufbewahrungTage,
|
|
||||||
Zertifizierungen: strings.Join(wz.Zertifizierungen, ", "), GeeigneteZwecke: strings.Join(wz.GeeigneteZwecke, ", "),
|
|
||||||
Einschraenkungen: strings.Join(wz.Einschraenkungen, ", "), LetztePruefung: wz.LetztePruefung.Format("2006-01-02"),
|
|
||||||
Quelle: wz.Quelle,
|
|
||||||
}
|
|
||||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,19 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
func werkzeugForm() url.Values {
|
func werkzeugForm() url.Values {
|
||||||
return url.Values{
|
return url.Values{
|
||||||
"name": {"ChatGPT Enterprise"}, "anbieter": {"OpenAI"}, "verarbeitungsort": {"EU"},
|
"name": {"ChatGPT Enterprise"}, "anbieter": {"OpenAI"}, "verarbeitungslaender": {"Irland"},
|
||||||
"avv_verfuegbar": {"on"}, "avv_url": {"https://example.com/avv"},
|
"avv_verfuegbar": {"on"}, "avv_url": {"https://example.com/avv"},
|
||||||
"training_opt_out": {"on"}, "training_standard": {"on"}, "aufbewahrung_tage": {"30"},
|
"training_opt_out": {"on"}, "training_standard": {"on"}, "aufbewahrung_tage": {"30"},
|
||||||
"zertifizierungen": {"ISO 27001, SOC 2"}, "geeignete_zwecke": {"Textgenerierung"},
|
"zertifizierungen": {"ISO 27001, SOC 2"}, "geeignete_zwecke": {"Textgenerierung"},
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ package web
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/netcell-it/deklarix/internal/store"
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
@@ -23,6 +24,8 @@ type fallListData struct {
|
|||||||
Title string
|
Title string
|
||||||
Nav navData
|
Nav navData
|
||||||
Faelle []fallListItem
|
Faelle []fallListItem
|
||||||
|
Suche string
|
||||||
|
Pagination pagination
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleFaelleListe zeigt den Posteingang: alle noch offenen (Status
|
// 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)
|
http.Error(w, "Anträge konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data := fallListData{Title: "Posteingang", Nav: navFor(r)}
|
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||||
|
var offene []fallListItem
|
||||||
for _, a := range antraege {
|
for _, a := range antraege {
|
||||||
if a.Status != "eingereicht" {
|
if a.Status != "eingereicht" {
|
||||||
continue
|
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,
|
ID: a.ID, Titel: a.Titel, Haeufigkeit: a.Haeufigkeit,
|
||||||
CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"),
|
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 {
|
if err := s.templates.ExecuteTemplate(w, "faelle-liste", data); err != nil {
|
||||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ func TestIndexZeigtEigeneOffeneAntraege(t *testing.T) {
|
|||||||
if resp.Code != http.StatusOK {
|
if resp.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
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>`) {
|
body := resp.Body.String()
|
||||||
t.Errorf("expected 1 offenen Antrag auf der Startseite, got: %s", 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 {
|
if resp.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
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>`) {
|
body := resp.Body.String()
|
||||||
t.Errorf("expected 1 offenen Fall im Posteingang auf der Startseite, got: %s", 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 {
|
if resp.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
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())
|
t.Errorf("expected a Plattform link for betreiber, got: %s", resp.Body.String())
|
||||||
}
|
}
|
||||||
if strings.Contains(resp.Body.String(), "Antrag stellen") {
|
if strings.Contains(resp.Body.String(), "Antrag stellen") {
|
||||||
|
|||||||
53
internal/web/laenderliste.go
Normal file
53
internal/web/laenderliste.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
// weltLaenderliste ist die kontrollierte Auswahl für "Verarbeitungsländer"
|
||||||
|
// im Werkzeugkatalog — eine echte Länderliste statt grober Eimer wie
|
||||||
|
// "EU"/"USA"/"gemischt" (die USA sind DSGVO-rechtlich bereits ein
|
||||||
|
// Drittland wie jedes andere, siehe CLAUDE.md). Enthält zusätzlich zwei
|
||||||
|
// Pseudo-Einträge für Fälle, in denen der Anbieter selbst keinen
|
||||||
|
// einzelnen Staat zusichert bzw. keine Cloud-Verarbeitung stattfindet.
|
||||||
|
var weltLaenderliste = []string{
|
||||||
|
"EU-Region (kein einzelnes Land benannt)",
|
||||||
|
"On-Premise (selbst gehostet)",
|
||||||
|
|
||||||
|
"Afghanistan", "Ägypten", "Albanien", "Algerien", "Andorra", "Angola",
|
||||||
|
"Antigua und Barbuda", "Äquatorialguinea", "Argentinien", "Armenien",
|
||||||
|
"Aserbaidschan", "Äthiopien", "Australien", "Bahamas", "Bahrain",
|
||||||
|
"Bangladesch", "Barbados", "Belgien", "Belize", "Benin", "Bhutan",
|
||||||
|
"Bolivien", "Bosnien und Herzegowina", "Botsuana", "Brasilien",
|
||||||
|
"Brunei", "Bulgarien", "Burkina Faso", "Burundi", "Chile", "China",
|
||||||
|
"Costa Rica", "Dänemark", "Deutschland", "Dominica",
|
||||||
|
"Dominikanische Republik", "Dschibuti", "Ecuador", "El Salvador",
|
||||||
|
"Elfenbeinküste", "Eritrea", "Estland", "Eswatini", "Fidschi",
|
||||||
|
"Finnland", "Frankreich", "Gabun", "Gambia", "Georgien", "Ghana",
|
||||||
|
"Grenada", "Griechenland", "Guatemala", "Guinea", "Guinea-Bissau",
|
||||||
|
"Guyana", "Haiti", "Honduras", "Indien", "Indonesien", "Irak",
|
||||||
|
"Iran", "Irland", "Island", "Israel", "Italien", "Jamaika", "Japan",
|
||||||
|
"Jemen", "Jordanien", "Kambodscha", "Kamerun", "Kanada", "Kap Verde",
|
||||||
|
"Kasachstan", "Katar", "Kenia", "Kirgisistan", "Kiribati",
|
||||||
|
"Kolumbien", "Komoren", "Kongo (Republik)",
|
||||||
|
"Kongo (Demokratische Republik)", "Kosovo", "Kroatien", "Kuba",
|
||||||
|
"Kuwait", "Laos", "Lesotho", "Lettland", "Libanon", "Liberia",
|
||||||
|
"Libyen", "Liechtenstein", "Litauen", "Luxemburg", "Madagaskar",
|
||||||
|
"Malawi", "Malaysia", "Malediven", "Mali", "Malta", "Marokko",
|
||||||
|
"Marshallinseln", "Mauretanien", "Mauritius", "Mexiko",
|
||||||
|
"Mikronesien", "Moldau", "Monaco", "Mongolei", "Montenegro",
|
||||||
|
"Mosambik", "Myanmar", "Namibia", "Nauru", "Nepal", "Neuseeland",
|
||||||
|
"Nicaragua", "Niederlande", "Niger", "Nigeria", "Nordkorea",
|
||||||
|
"Nordmazedonien", "Norwegen", "Oman", "Österreich", "Osttimor",
|
||||||
|
"Pakistan", "Palau", "Palästina", "Panama", "Papua-Neuguinea",
|
||||||
|
"Paraguay", "Peru", "Philippinen", "Polen", "Portugal", "Ruanda",
|
||||||
|
"Rumänien", "Russland", "Salomonen", "Sambia", "Samoa",
|
||||||
|
"San Marino", "São Tomé und Príncipe", "Saudi-Arabien", "Schweden",
|
||||||
|
"Schweiz", "Senegal", "Serbien", "Seychellen", "Sierra Leone",
|
||||||
|
"Simbabwe", "Singapur", "Slowakei", "Slowenien", "Somalia",
|
||||||
|
"Spanien", "Sri Lanka", "St. Kitts und Nevis", "St. Lucia",
|
||||||
|
"St. Vincent und die Grenadinen", "Südafrika", "Sudan", "Südkorea",
|
||||||
|
"Südsudan", "Suriname", "Syrien", "Tadschikistan", "Taiwan",
|
||||||
|
"Tansania", "Thailand", "Togo", "Tonga", "Trinidad und Tobago",
|
||||||
|
"Tschad", "Tschechien", "Tunesien", "Türkei", "Turkmenistan",
|
||||||
|
"Tuvalu", "Uganda", "Ukraine", "Ungarn", "Uruguay", "USA",
|
||||||
|
"Usbekistan", "Vanuatu", "Vatikanstadt", "Venezuela",
|
||||||
|
"Vereinigte Arabische Emirate", "Vereinigtes Königreich", "Vietnam",
|
||||||
|
"Zentralafrikanische Republik", "Zypern",
|
||||||
|
}
|
||||||
@@ -11,12 +11,22 @@ package web
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type mandantWerkzeugItem struct {
|
type mandantWerkzeugItem struct {
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
Anbieter string
|
Anbieter string
|
||||||
|
Verarbeitungslaender string
|
||||||
|
AVVVerfuegbar bool
|
||||||
|
TrainingStandard bool
|
||||||
|
Aufbewahrung string
|
||||||
|
GeeigneteZwecke string
|
||||||
|
LetztePruefung string
|
||||||
|
PruefStatusLabel string
|
||||||
|
PruefStatusCSS string
|
||||||
Gesperrt bool
|
Gesperrt bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,12 +34,17 @@ type eigenesWerkzeugItem struct {
|
|||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
Anbieter string
|
Anbieter string
|
||||||
|
Verarbeitungslaender string
|
||||||
|
LetztePruefung string
|
||||||
}
|
}
|
||||||
|
|
||||||
type mandantWerkzeugListeData struct {
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,17 +74,40 @@ 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)
|
||||||
data.Werkzeuge = append(data.Werkzeuge, mandantWerkzeugItem{
|
data.Werkzeuge = append(data.Werkzeuge, mandantWerkzeugItem{
|
||||||
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Gesperrt: gesperrt[wz.ID],
|
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungslaender: strings.Join(wz.Verarbeitungslaender, ", "),
|
||||||
|
AVVVerfuegbar: wz.AVVVerfuegbar, TrainingStandard: wz.TrainingStandard,
|
||||||
|
Aufbewahrung: formatAufbewahrung(wz.AufbewahrungTage),
|
||||||
|
GeeigneteZwecke: strings.Join(wz.GeeigneteZwecke, ", "),
|
||||||
|
LetztePruefung: wz.LetztePruefung.Format("02.01.2006"),
|
||||||
|
PruefStatusLabel: label, PruefStatusCSS: css,
|
||||||
|
Gesperrt: gesperrt[wz.ID],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
for _, wz := range eigene {
|
for _, wz := range eigene {
|
||||||
if wz.AccountID == nil {
|
if wz.AccountID == nil {
|
||||||
continue // zentral, oben schon gelistet
|
continue // zentral, oben schon gelistet
|
||||||
}
|
}
|
||||||
data.EigeneWerkzeuge = append(data.EigeneWerkzeuge, eigenesWerkzeugItem{ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter})
|
data.EigeneWerkzeuge = append(data.EigeneWerkzeuge, eigenesWerkzeugItem{
|
||||||
|
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungslaender: strings.Join(wz.Verarbeitungslaender, ", "),
|
||||||
|
LetztePruefung: wz.LetztePruefung.Format("02.01.2006"),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if err := s.templates.ExecuteTemplate(w, "mandant-werkzeuge", data); err != nil {
|
if err := s.templates.ExecuteTemplate(w, "mandant-werkzeuge", data); err != nil {
|
||||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
@@ -77,7 +115,11 @@ func (s *Server) handleMandantWerkzeugListe(w http.ResponseWriter, r *http.Reque
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleMandantWerkzeugNeuForm(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleMandantWerkzeugNeuForm(w http.ResponseWriter, r *http.Request) {
|
||||||
data := werkzeugFormData{Title: "Eigenes Werkzeug anlegen", Nav: navFor(r), ActionBase: "/verwaltung/werkzeuge", Verarbeitungsorte: gueltigeVerarbeitungsorte}
|
data := werkzeugFormData{
|
||||||
|
Title: "Eigenes Werkzeug anlegen", Nav: navFor(r), ActionBase: "/verwaltung/werkzeuge",
|
||||||
|
LaenderOptionen: weltLaenderliste, LaenderAusgewaehlt: map[string]bool{},
|
||||||
|
ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: map[string]bool{},
|
||||||
|
}
|
||||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
@@ -121,15 +163,7 @@ func (s *Server) handleMandantWerkzeugEditForm(w http.ResponseWriter, r *http.Re
|
|||||||
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
|
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data := werkzeugFormData{
|
data := werkzeugFormDataFromWerkzeug(r, wz, "Eigenes Werkzeug bearbeiten", "/verwaltung/werkzeuge")
|
||||||
Title: "Eigenes Werkzeug bearbeiten", Nav: navFor(r), ActionBase: "/verwaltung/werkzeuge", ID: wz.ID, Verarbeitungsorte: gueltigeVerarbeitungsorte,
|
|
||||||
Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungsort: wz.Verarbeitungsort,
|
|
||||||
AVVVerfuegbar: wz.AVVVerfuegbar, AVVURL: wz.AVVURL,
|
|
||||||
TrainingOptOut: wz.TrainingOptOut, TrainingStandard: wz.TrainingStandard, AufbewahrungTage: wz.AufbewahrungTage,
|
|
||||||
Zertifizierungen: strings.Join(wz.Zertifizierungen, ", "), GeeigneteZwecke: strings.Join(wz.GeeigneteZwecke, ", "),
|
|
||||||
Einschraenkungen: strings.Join(wz.Einschraenkungen, ", "), LetztePruefung: wz.LetztePruefung.Format("2006-01-02"),
|
|
||||||
Quelle: wz.Quelle,
|
|
||||||
}
|
|
||||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
Title string
|
||||||
Nav navData
|
Nav navData
|
||||||
Eintraege []registerEintragView
|
Eintraege []registerEintragView
|
||||||
|
Suche string
|
||||||
|
Pagination pagination
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) registerEintraegeFuerAccount(r *http.Request) ([]registerEintragView, error) {
|
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)
|
http.Error(w, "Register konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
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 {
|
if err := s.templates.ExecuteTemplate(w, "register-liste", data); err != nil {
|
||||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -408,9 +408,9 @@ func (f *fakeStore) CreateWerkzeug(ctx context.Context, in store.WerkzeugInput)
|
|||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
w := store.Werkzeug{
|
w := store.Werkzeug{
|
||||||
ID: f.newID(), AccountID: in.AccountID, Name: in.Name, Anbieter: in.Anbieter,
|
ID: f.newID(), AccountID: in.AccountID, Name: in.Name, Anbieter: in.Anbieter,
|
||||||
Verarbeitungsort: in.Verarbeitungsort, AVVVerfuegbar: in.AVVVerfuegbar, AVVURL: in.AVVURL,
|
Verarbeitungslaender: in.Verarbeitungslaender, AVVVerfuegbar: in.AVVVerfuegbar, AVVURL: in.AVVURL,
|
||||||
TrainingOptOut: in.TrainingOptOut, TrainingStandard: in.TrainingStandard, AufbewahrungTage: in.AufbewahrungTage,
|
TrainingOptOut: in.TrainingOptOut, TrainingStandard: in.TrainingStandard, DPFZertifiziert: in.DPFZertifiziert, AufbewahrungTage: in.AufbewahrungTage,
|
||||||
Zertifizierungen: in.Zertifizierungen, GeeigneteZwecke: in.GeeigneteZwecke, Einschraenkungen: in.Einschraenkungen,
|
Zertifizierungen: in.Zertifizierungen, Subprozessoren: in.Subprozessoren, GeeigneteZwecke: in.GeeigneteZwecke, Einschraenkungen: in.Einschraenkungen,
|
||||||
LetztePruefung: in.LetztePruefung, Quelle: in.Quelle, CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
LetztePruefung: in.LetztePruefung, Quelle: in.Quelle, CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
f.werkzeuge[w.ID] = w
|
f.werkzeuge[w.ID] = w
|
||||||
@@ -424,10 +424,10 @@ func (f *fakeStore) UpdateWerkzeug(ctx context.Context, id string, in store.Werk
|
|||||||
if !ok {
|
if !ok {
|
||||||
return store.Werkzeug{}, store.ErrNotFound
|
return store.Werkzeug{}, store.ErrNotFound
|
||||||
}
|
}
|
||||||
w.Name, w.Anbieter, w.Verarbeitungsort = in.Name, in.Anbieter, in.Verarbeitungsort
|
w.Name, w.Anbieter, w.Verarbeitungslaender = in.Name, in.Anbieter, in.Verarbeitungslaender
|
||||||
w.AVVVerfuegbar, w.AVVURL = in.AVVVerfuegbar, in.AVVURL
|
w.AVVVerfuegbar, w.AVVURL = in.AVVVerfuegbar, in.AVVURL
|
||||||
w.TrainingOptOut, w.TrainingStandard, w.AufbewahrungTage = in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage
|
w.TrainingOptOut, w.TrainingStandard, w.DPFZertifiziert, w.AufbewahrungTage = in.TrainingOptOut, in.TrainingStandard, in.DPFZertifiziert, in.AufbewahrungTage
|
||||||
w.Zertifizierungen, w.GeeigneteZwecke, w.Einschraenkungen = in.Zertifizierungen, in.GeeigneteZwecke, in.Einschraenkungen
|
w.Zertifizierungen, w.Subprozessoren, w.GeeigneteZwecke, w.Einschraenkungen = in.Zertifizierungen, in.Subprozessoren, in.GeeigneteZwecke, in.Einschraenkungen
|
||||||
w.LetztePruefung, w.Quelle, w.UpdatedAt = in.LetztePruefung, in.Quelle, time.Now()
|
w.LetztePruefung, w.Quelle, w.UpdatedAt = in.LetztePruefung, in.Quelle, time.Now()
|
||||||
f.werkzeuge[id] = w
|
f.werkzeuge[id] = w
|
||||||
return w, nil
|
return w, nil
|
||||||
|
|||||||
@@ -59,7 +59,15 @@ h1, h2, h3 {
|
|||||||
color: var(--color-heading);
|
color: var(--color-heading);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
margin: 0 0 0.5em;
|
margin: 0 0 0.6em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.0625rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
@@ -392,6 +400,16 @@ nav button {
|
|||||||
color: var(--color-mittel);
|
color: var(--color-mittel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-niedrig {
|
||||||
|
background: var(--color-niedrig-bg);
|
||||||
|
color: var(--color-niedrig);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-hoch {
|
||||||
|
background: var(--color-hoch-bg);
|
||||||
|
color: var(--color-hoch);
|
||||||
|
}
|
||||||
|
|
||||||
.beitraege-liste {
|
.beitraege-liste {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0 0 16px;
|
margin: 0 0 16px;
|
||||||
@@ -532,8 +550,8 @@ button.entfernen {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.fragebogen legend {
|
.fragebogen legend {
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
font-size: 0.9375rem;
|
font-size: 1rem;
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -545,6 +563,130 @@ button.entfernen {
|
|||||||
margin: 4px 12px 4px 0;
|
margin: 4px 12px 4px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Formular-Karten (Enterprise-Look, siehe design/enterprise.css
|
||||||
|
.ant-card/.ant-form-item) ─────────────────────────────────────────
|
||||||
|
Ersetzt "ein Formular = eine flache, ununterbrochene Spalte" durch
|
||||||
|
klar abgegrenzte Abschnitte mit Titel und ein Grid für kurze Felder
|
||||||
|
nebeneinander statt jedes Feld einzeln untereinander. */
|
||||||
|
.form-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-card form {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section {
|
||||||
|
border: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section + .form-section {
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* <legend> ignoriert das padding des umschließenden <fieldset> (es
|
||||||
|
positioniert sich an dessen Border-Box, nicht an der Padding-Box) —
|
||||||
|
ohne eigenes padding-left säße die Abschnittsüberschrift bündig am
|
||||||
|
Kartenrand, während der restliche Inhalt eingerückt ist. */
|
||||||
|
.form-section > legend {
|
||||||
|
padding: 0 0 16px 0;
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-heading);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid .form-full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkbox-/Radio-Gruppen als Chips statt einer langen
|
||||||
|
Untereinander-Liste — v. a. bei den zwölf Zweck-Kategorien war das
|
||||||
|
vorher eine kaum überblickbare Spalte. */
|
||||||
|
.chip-group {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-group label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 0;
|
||||||
|
padding: 7px 14px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--color-bg);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-group input[type="checkbox"],
|
||||||
|
.chip-group input[type="radio"] {
|
||||||
|
width: auto;
|
||||||
|
min-height: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-group label:has(input:checked) {
|
||||||
|
border-color: var(--branding-primary);
|
||||||
|
background: rgba(22, 119, 255, 0.08);
|
||||||
|
color: var(--branding-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions form,
|
||||||
|
.form-actions button {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-gefahrenzone {
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
padding: 16px 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-gefahrenzone form {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.fragebogen fieldset input[type="radio"] {
|
.fragebogen fieldset input[type="radio"] {
|
||||||
width: auto;
|
width: auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -595,11 +737,242 @@ tbody tr:hover {
|
|||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Ohne dieses Signal sieht eine breite, am Fensterrand abgeschnittene
|
||||||
|
Tabelle wie ein Rendering-Fehler aus statt wie "hier geht's mit
|
||||||
|
Scrollen weiter" — der Fade ist bewusst immer sichtbar (kein JS, das
|
||||||
|
den Scroll-Zustand prüfen könnte), auch am Ende der Tabelle. */
|
||||||
.table-scroll {
|
.table-scroll {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
margin-bottom: 16px;
|
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%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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
|
/* Ab hier mehr Platz (Tablet) — der Container bekommt spürbaren
|
||||||
|
|||||||
@@ -4,8 +4,12 @@
|
|||||||
<body>
|
<body>
|
||||||
{{template "nav" .Nav}}
|
{{template "nav" .Nav}}
|
||||||
<div class="page">
|
<div class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
<h1>Abteilungen</h1>
|
<h1>Abteilungen</h1>
|
||||||
<p class="hinweis">Stammdaten für den Fragebogen (Feld "Abteilung").</p>
|
<p class="hinweis">Stammdaten für den Fragebogen (Feld "Abteilung").</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||||
{{if not .Abteilungen}}
|
{{if not .Abteilungen}}
|
||||||
<p class="hinweis">Noch keine Abteilungen.</p>
|
<p class="hinweis">Noch keine Abteilungen.</p>
|
||||||
|
|||||||
@@ -5,10 +5,25 @@
|
|||||||
{{template "nav" .Nav}}
|
{{template "nav" .Nav}}
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<h1>Plattform</h1>
|
<h1>Plattform</h1>
|
||||||
<ul class="admin-kacheln">
|
<ul class="stat-cards">
|
||||||
<li><a href="/betreiber/accounts">Accounts <span class="status">{{.AccountCount}}</span></a></li>
|
<li>
|
||||||
<li><a href="/betreiber/werkzeuge">Werkzeugkatalog <span class="status">{{.WerkzeugCount}}</span></a></li>
|
<a class="stat-card" href="/betreiber/accounts">
|
||||||
<li><a href="/betreiber/audit-log">Audit-Log <span class="status">{{.RecentAuditCount}} neueste</span></a></li>
|
<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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -7,59 +7,113 @@
|
|||||||
<p><a href="{{.ActionBase}}">← Werkzeugkatalog</a></p>
|
<p><a href="{{.ActionBase}}">← Werkzeugkatalog</a></p>
|
||||||
<h1>{{.Title}}</h1>
|
<h1>{{.Title}}</h1>
|
||||||
<p class="hinweis">
|
<p class="hinweis">
|
||||||
Jede Zusicherung hier (AVV, Verarbeitungsort, Training-Opt-out,
|
Jede Zusicherung hier (AVV, Verarbeitungsländer, Training-Opt-out,
|
||||||
Zertifizierungen) muss belegbar sein — Quelle und letzte Prüfung sind
|
Zertifizierungen) muss belegbar sein — Quelle und letzte Prüfung sind
|
||||||
Pflicht.
|
Pflicht.
|
||||||
</p>
|
</p>
|
||||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||||
|
<div class="form-card">
|
||||||
<form method="post" action="{{if .ID}}{{.ActionBase}}/{{.ID}}{{else}}{{.ActionBase}}{{end}}">
|
<form method="post" action="{{if .ID}}{{.ActionBase}}/{{.ID}}{{else}}{{.ActionBase}}{{end}}">
|
||||||
|
<fieldset class="form-section">
|
||||||
|
<legend>Grunddaten</legend>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div>
|
||||||
<label for="name">Name</label>
|
<label for="name">Name</label>
|
||||||
<input type="text" id="name" name="name" value="{{.Name}}" required>
|
<input type="text" id="name" name="name" value="{{.Name}}" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
<label for="anbieter">Anbieter</label>
|
<label for="anbieter">Anbieter</label>
|
||||||
<input type="text" id="anbieter" name="anbieter" value="{{.Anbieter}}" required>
|
<input type="text" id="anbieter" name="anbieter" value="{{.Anbieter}}" required>
|
||||||
|
</div>
|
||||||
<fieldset>
|
<div class="form-full">
|
||||||
<legend>Verarbeitungsort</legend>
|
<label for="verarbeitungslaender">Verarbeitungsländer (Mehrfachauswahl: Strg/Cmd-Klick)</label>
|
||||||
{{$gewaehlt := .Verarbeitungsort}}
|
<select id="verarbeitungslaender" name="verarbeitungslaender" multiple size="8" required>
|
||||||
{{range .Verarbeitungsorte}}
|
{{range .LaenderOptionen}}
|
||||||
<label><input type="radio" name="verarbeitungsort" value="{{.}}" {{if eq . $gewaehlt}}checked{{end}} required> {{.}}</label>
|
<option value="{{.}}" {{if index $.LaenderAusgewaehlt .}}selected{{end}}>{{.}}</option>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
</select>
|
||||||
|
<p class="hinweis">Tatsächliche Länder benennen, nicht "EU"/"Drittland" — die USA sind DSGVO-rechtlich bereits ein Drittland wie jedes andere.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset class="form-section">
|
||||||
|
<legend>Rechtliches & Datenschutz</legend>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-full">
|
||||||
|
<label>Zusicherungen</label>
|
||||||
|
<div class="chip-group">
|
||||||
<label><input type="checkbox" name="avv_verfuegbar" {{if .AVVVerfuegbar}}checked{{end}}> AVV verfügbar</label>
|
<label><input type="checkbox" name="avv_verfuegbar" {{if .AVVVerfuegbar}}checked{{end}}> AVV verfügbar</label>
|
||||||
<label for="avv_url">AVV-URL</label>
|
|
||||||
<input type="text" id="avv_url" name="avv_url" value="{{.AVVURL}}">
|
|
||||||
|
|
||||||
<label><input type="checkbox" name="training_opt_out" {{if .TrainingOptOut}}checked{{end}}> Training-Opt-out möglich</label>
|
<label><input type="checkbox" name="training_opt_out" {{if .TrainingOptOut}}checked{{end}}> Training-Opt-out möglich</label>
|
||||||
<label><input type="checkbox" name="training_standard" {{if .TrainingStandard}}checked{{end}}> Training standardmäßig aus</label>
|
<label><input type="checkbox" name="training_standard" {{if .TrainingStandard}}checked{{end}}> Training standardmäßig aus</label>
|
||||||
|
<label><input type="checkbox" name="dpf_zertifiziert" {{if .DPFZertifiziert}}checked{{end}}> EU-US Data Privacy Framework (DPF) zertifiziert</label>
|
||||||
<label for="aufbewahrung_tage">Aufbewahrung (Tage)</label>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-full">
|
||||||
|
<label for="avv_url">AVV-URL</label>
|
||||||
|
<input type="text" id="avv_url" name="avv_url" value="{{.AVVURL}}">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="aufbewahrung_tage">Aufbewahrung (Tage, leer lassen wenn vom Anbieter nicht beziffert)</label>
|
||||||
<input type="number" id="aufbewahrung_tage" name="aufbewahrung_tage" min="0" value="{{.AufbewahrungTage}}">
|
<input type="number" id="aufbewahrung_tage" name="aufbewahrung_tage" min="0" value="{{.AufbewahrungTage}}">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
<label for="zertifizierungen">Zertifizierungen (kommagetrennt)</label>
|
<label for="zertifizierungen">Zertifizierungen (kommagetrennt)</label>
|
||||||
<input type="text" id="zertifizierungen" name="zertifizierungen" value="{{.Zertifizierungen}}">
|
<input type="text" id="zertifizierungen" name="zertifizierungen" value="{{.Zertifizierungen}}">
|
||||||
|
</div>
|
||||||
|
<div class="form-full">
|
||||||
|
<label for="subprozessoren">Subprozessoren (kommagetrennt, z. B. Anthropic, AWS)</label>
|
||||||
|
<input type="text" id="subprozessoren" name="subprozessoren" value="{{.Subprozessoren}}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<label for="geeignete_zwecke">Geeignete Zwecke (kommagetrennt)</label>
|
<fieldset class="form-section">
|
||||||
<input type="text" id="geeignete_zwecke" name="geeignete_zwecke" value="{{.GeeigneteZwecke}}">
|
<legend>Einsatzbereich</legend>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-full">
|
||||||
|
<label>Geeignete Zwecke</label>
|
||||||
|
<div class="chip-group">
|
||||||
|
{{range .ZweckeOptionen}}
|
||||||
|
<label><input type="checkbox" name="geeignete_zwecke" value="{{.}}" {{if index $.ZweckeAusgewaehlt .}}checked{{end}}> {{.}}</label>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-full">
|
||||||
<label for="einschraenkungen">Einschränkungen (kommagetrennt)</label>
|
<label for="einschraenkungen">Einschränkungen (kommagetrennt)</label>
|
||||||
<input type="text" id="einschraenkungen" name="einschraenkungen" value="{{.Einschraenkungen}}">
|
<input type="text" id="einschraenkungen" name="einschraenkungen" value="{{.Einschraenkungen}}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset class="form-section">
|
||||||
|
<legend>Beleg</legend>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div>
|
||||||
<label for="letzte_pruefung">Letzte Prüfung</label>
|
<label for="letzte_pruefung">Letzte Prüfung</label>
|
||||||
<input type="date" id="letzte_pruefung" name="letzte_pruefung" value="{{.LetztePruefung}}" required>
|
<input type="date" id="letzte_pruefung" name="letzte_pruefung" value="{{.LetztePruefung}}" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
<label for="quelle">Quelle (Beleg)</label>
|
<label for="quelle">Quelle (Beleg)</label>
|
||||||
<input type="text" id="quelle" name="quelle" value="{{.Quelle}}" required>
|
<input type="text" id="quelle" name="quelle" value="{{.Quelle}}" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
<button type="submit">Speichern</button>
|
<button type="submit">Speichern</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{{if .ID}}
|
{{if .ID}}
|
||||||
<form method="post" action="/betreiber/werkzeuge/{{.ID}}/loeschen" onsubmit="return confirm('Werkzeug wirklich löschen?')">
|
<div class="form-gefahrenzone">
|
||||||
|
<span class="hinweis">Ein gelöschtes Werkzeug kann nicht wiederhergestellt werden.</span>
|
||||||
|
<form method="post" action="{{.ActionBase}}/{{.ID}}/loeschen" onsubmit="return confirm('Werkzeug wirklich löschen?')">
|
||||||
<button type="submit" class="entfernen">Werkzeug löschen</button>
|
<button type="submit" class="entfernen">Werkzeug löschen</button>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -5,21 +5,66 @@
|
|||||||
{{template "nav" .Nav}}
|
{{template "nav" .Nav}}
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<p><a href="/betreiber">← Plattform</a></p>
|
<p><a href="/betreiber">← Plattform</a></p>
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
<h1>Werkzeugkatalog</h1>
|
<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}}
|
{{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}}
|
{{else}}
|
||||||
<ul class="beitraege-liste">
|
<div class="table-responsive">
|
||||||
|
<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}}
|
{{range .Werkzeuge}}
|
||||||
<li>
|
<tr>
|
||||||
<a href="/betreiber/werkzeuge/{{.ID}}">
|
<td data-label="Name"><a href="/betreiber/werkzeuge/{{.ID}}">{{.Name}}</a></td>
|
||||||
<strong>{{.Name}}</strong> · {{.Anbieter}} · {{.Verarbeitungsort}} · zuletzt geprüft {{.LetztePruefung}}
|
<td data-label="Anbieter">{{.Anbieter}}</td>
|
||||||
</a>
|
<td data-label="Verarbeitungsländer">{{.Verarbeitungslaender}}</td>
|
||||||
</li>
|
<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}}
|
{{end}}
|
||||||
</ul>
|
</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}}
|
{{end}}
|
||||||
<p><a href="/betreiber/werkzeuge/neu">Werkzeug anlegen</a></p>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -4,9 +4,21 @@
|
|||||||
<body>
|
<body>
|
||||||
{{template "nav" .Nav}}
|
{{template "nav" .Nav}}
|
||||||
<div class="page">
|
<div class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
<h1>Posteingang</h1>
|
<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}}
|
{{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}}
|
{{else}}
|
||||||
<ul class="beitraege-liste">
|
<ul class="beitraege-liste">
|
||||||
{{range .Faelle}}
|
{{range .Faelle}}
|
||||||
@@ -17,7 +29,15 @@
|
|||||||
</li>
|
</li>
|
||||||
{{end}}
|
{{end}}
|
||||||
</ul>
|
</ul>
|
||||||
|
<div class="pagination">
|
||||||
|
<span class="hinweis">Seite {{.Pagination.Page}} von {{.Pagination.TotalPages}} ({{.Pagination.Total}} Einträge)</span>
|
||||||
|
<span>
|
||||||
|
{{if .Pagination.HasPrev}}<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}}
|
{{end}}
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -6,15 +6,25 @@
|
|||||||
<div class="page">
|
<div class="page">
|
||||||
<h1>Willkommen bei Deklarix</h1>
|
<h1>Willkommen bei Deklarix</h1>
|
||||||
{{if .Nav.IsBetreiber}}
|
{{if .Nav.IsBetreiber}}
|
||||||
<ul class="admin-kacheln">
|
<ul class="stat-cards">
|
||||||
<li><a href="/betreiber">Zur Plattform</a></li>
|
<li><a class="stat-card stat-card-action" href="/betreiber"><span class="stat-card-label">Zur Plattform →</span></a></li>
|
||||||
</ul>
|
</ul>
|
||||||
{{else}}
|
{{else}}
|
||||||
<ul class="admin-kacheln">
|
<ul class="stat-cards">
|
||||||
<li><a href="/antraege/neu">Antrag stellen</a></li>
|
<li><a class="stat-card stat-card-action" href="/antraege/neu"><span class="stat-card-label">Antrag stellen →</span></a></li>
|
||||||
<li><a href="/antraege">Meine offenen Anträge <span class="status">{{.OffeneAntraege}}</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}}
|
{{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}}
|
{{end}}
|
||||||
</ul>
|
</ul>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -6,19 +6,51 @@
|
|||||||
<div class="page">
|
<div class="page">
|
||||||
<h1>Werkzeugkatalog</h1>
|
<h1>Werkzeugkatalog</h1>
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
<h2>Zentraler Katalog</h2>
|
<h2>Zentraler Katalog</h2>
|
||||||
<p class="hinweis">
|
<p class="hinweis">
|
||||||
Wird von der Plattform gepflegt. Ein gesperrtes Werkzeug bleibt im
|
Wird von der Plattform gepflegt. Ein gesperrtes Werkzeug bleibt im
|
||||||
zentralen Katalog unverändert — es wird nur für diesen Mandanten aus
|
zentralen Katalog unverändert — es wird nur für diesen Mandanten
|
||||||
der Werkzeugsuche ausgeblendet.
|
aus der Werkzeugsuche ausgeblendet.
|
||||||
</p>
|
</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}}
|
{{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}}
|
||||||
<ul class="beitraege-liste">
|
<div class="table-responsive">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th><th>Anbieter</th><th>Verarbeitungsländer</th><th>AVV</th>
|
||||||
|
<th>Training aus</th><th>Aufbewahrung</th><th>Geeignete Zwecke</th><th>Zuletzt geprüft</th><th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
{{range .Werkzeuge}}
|
{{range .Werkzeuge}}
|
||||||
<li>
|
<tr>
|
||||||
{{.Name}} · {{.Anbieter}}
|
<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}}
|
{{if .Gesperrt}}
|
||||||
<span class="status">gesperrt</span>
|
<span class="status">gesperrt</span>
|
||||||
<form method="post" action="/verwaltung/werkzeuge/{{.ID}}/entsperren" style="display:inline">
|
<form method="post" action="/verwaltung/werkzeuge/{{.ID}}/entsperren" style="display:inline">
|
||||||
@@ -30,28 +62,66 @@
|
|||||||
<button type="submit">Sperren</button>
|
<button type="submit">Sperren</button>
|
||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
</li>
|
</td>
|
||||||
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</ul>
|
</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}}
|
{{end}}
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
<h2>Eigene Werkzeuge</h2>
|
<h2>Eigene Werkzeuge</h2>
|
||||||
<p class="hinweis">
|
<p class="hinweis">
|
||||||
Zusätzliche Katalogeinträge nur für diesen Mandanten — sichtbar nur
|
Zusätzliche Katalogeinträge nur für diesen Mandanten — sichtbar nur
|
||||||
hier, nicht im zentralen Katalog der Plattform.
|
hier, nicht im zentralen Katalog der Plattform.
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
<a class="page-header-action" href="/verwaltung/werkzeuge/neu">Eigenes Werkzeug anlegen</a>
|
||||||
|
</div>
|
||||||
{{if not .EigeneWerkzeuge}}
|
{{if not .EigeneWerkzeuge}}
|
||||||
<p class="hinweis">Noch keine eigenen Werkzeuge.</p>
|
<p class="hinweis">Noch keine eigenen Werkzeuge.</p>
|
||||||
{{else}}
|
{{else}}
|
||||||
<ul class="beitraege-liste">
|
<div class="table-responsive">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Name</th><th>Anbieter</th><th>Verarbeitungsländer</th><th>Zuletzt geprüft</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
{{range .EigeneWerkzeuge}}
|
{{range .EigeneWerkzeuge}}
|
||||||
<li>
|
<tr>
|
||||||
<a href="/verwaltung/werkzeuge/{{.ID}}">{{.Name}} · {{.Anbieter}}</a>
|
<td data-label="Name"><a href="/verwaltung/werkzeuge/{{.ID}}">{{.Name}}</a></td>
|
||||||
</li>
|
<td data-label="Anbieter">{{.Anbieter}}</td>
|
||||||
|
<td data-label="Verarbeitungsländer">{{.Verarbeitungslaender}}</td>
|
||||||
|
<td data-label="Zuletzt geprüft">{{.LetztePruefung}}</td>
|
||||||
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</ul>
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
<p><a href="/verwaltung/werkzeuge/neu">Eigenes Werkzeug anlegen</a></p>
|
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -4,7 +4,21 @@
|
|||||||
<body>
|
<body>
|
||||||
{{template "nav" .Nav}}
|
{{template "nav" .Nav}}
|
||||||
<div class="page">
|
<div class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
<h1>Nutzerverwaltung</h1>
|
<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">
|
<ul class="beitraege-liste">
|
||||||
{{range .Nutzer}}
|
{{range .Nutzer}}
|
||||||
<li>
|
<li>
|
||||||
@@ -25,7 +39,27 @@
|
|||||||
</li>
|
</li>
|
||||||
{{end}}
|
{{end}}
|
||||||
</ul>
|
</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>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -4,16 +4,27 @@
|
|||||||
<body>
|
<body>
|
||||||
{{template "nav" .Nav}}
|
{{template "nav" .Nav}}
|
||||||
<div class="page">
|
<div class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
<h1>Register</h1>
|
<h1>Register</h1>
|
||||||
<p class="hinweis">
|
<p class="hinweis">
|
||||||
Jede Genehmigung erzeugt automatisch einen Registereintrag —
|
Jede Genehmigung erzeugt automatisch einen Registereintrag —
|
||||||
abgelehnte Anträge und Rückfragen stehen nicht im Register.
|
abgelehnte Anträge und Rückfragen stehen nicht im Register.
|
||||||
</p>
|
</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}}
|
{{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}}
|
{{else}}
|
||||||
<div class="table-scroll">
|
<div class="table-responsive">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -25,15 +36,29 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{{range .Eintraege}}
|
{{range .Eintraege}}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{.Zweck}}</td><td>{{.Abteilung}}</td><td>{{.Werkzeug}}</td><td>{{.Datenklasse}}</td>
|
<td data-label="Zweck">{{.Zweck}}</td>
|
||||||
<td>{{.Einstufung}}</td><td>{{.Auflagen}}</td><td>{{.Verantwortlicher}}</td>
|
<td data-label="Abteilung">{{.Abteilung}}</td>
|
||||||
<td>{{.EntschiedenAm}}</td><td>{{.GueltigBis}}</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>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</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}}<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}}
|
{{end}}
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ package web
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/netcell-it/deklarix/internal/store"
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
@@ -30,8 +32,9 @@ func werkzeugDiff(snapshot, current store.Werkzeug) []string {
|
|||||||
if snapshot.TrainingStandard != current.TrainingStandard {
|
if snapshot.TrainingStandard != current.TrainingStandard {
|
||||||
diffs = append(diffs, fmt.Sprintf("Training standardmäßig aus: %v → %v", snapshot.TrainingStandard, current.TrainingStandard))
|
diffs = append(diffs, fmt.Sprintf("Training standardmäßig aus: %v → %v", snapshot.TrainingStandard, current.TrainingStandard))
|
||||||
}
|
}
|
||||||
if snapshot.Verarbeitungsort != current.Verarbeitungsort {
|
if !slices.Equal(snapshot.Verarbeitungslaender, current.Verarbeitungslaender) {
|
||||||
diffs = append(diffs, fmt.Sprintf("Verarbeitungsort: %s → %s", snapshot.Verarbeitungsort, current.Verarbeitungsort))
|
diffs = append(diffs, fmt.Sprintf("Verarbeitungsländer: %s → %s",
|
||||||
|
strings.Join(snapshot.Verarbeitungslaender, ", "), strings.Join(current.Verarbeitungslaender, ", ")))
|
||||||
}
|
}
|
||||||
return diffs
|
return diffs
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user