Compare commits

...

2 Commits

Author SHA1 Message Date
noroot
d77b19f61f feat: Zweck-Vokabular auf 12 Kategorien erweitert, Werkzeug-Formular im Enterprise-Look überarbeitet
Sechs neue Zweck-Kategorien (Datenanalyse, Video, Audio/Sprache,
Kundenservice/Chatbot, Präsentation/Design, Automatisierung/Agenten)
ergänzen die ursprünglichen sechs — deckt jetzt die tatsächliche
Bandbreite an Einsatzgebieten ab statt nur klassischer Wissensarbeit.

Werkzeug-Formular (Betreiber + Mandant, gleiche Vorlage) neu strukturiert:
Karten-Layout mit thematischen Abschnitten, 2-Spalten-Grid für kurze
Felder, Chip-Gruppen statt langer Checkbox-Listen, abgesetzte
Gefahrenzone für den Löschen-Button. Dabei einen vorbestehenden Bug
gefunden und behoben: das Lösch-Formular war fest auf die
Betreiber-Route verdrahtet, obwohl dieselbe Vorlage auch vom
Mandanten-Formular genutzt wird.
2026-08-31 13:12:07 +02:00
noroot
dbeaa45644 feat: Ampel-Kennzeichnung für Prüfalter im Werkzeugkatalog, Scroll-Fade für breite Tabellen
Katalogtabellen zeigten bisher nur "nicht aktuell geprüft" als
Binärflag; jetzt eine dreistufige Ampel (aktuell/bald fällig/
abgelaufen) mit denselben Farbtoken wie die KI-VO-Risikoampel.
Zusätzlich: .table-scroll bekommt einen rechten Fade-Rand, damit eine
am Fensterrand abgeschnittene breite Tabelle als "hier geht's mit
Scrollen weiter" erkennbar ist statt wie ein Rendering-Fehler
auszusehen.
2026-08-31 12:28:57 +02:00
7 changed files with 341 additions and 82 deletions

View File

@@ -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.
--- ---
@@ -253,10 +253,13 @@ 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, Verarbeitungsort, 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
werden im zentralen (`GET /betreiber/werkzeuge`) und im mandanten- eine dreistufige Ampel zum Prüfalter (`pruefStatus()` in
seitigen Katalog (`GET /verwaltung/werkzeuge`) als "nicht aktuell `betreiber_werkzeug_handlers.go`, CSS-Klassen `status-niedrig`/
geprüft" markiert (`werkzeugPruefungVeraltetNach`, 180 Tage). `-mittel`/`-hoch`, dieselben Farbtoken wie die KI-VO-Risikoampel):
**aktuell** (grün, < 150 Tage), **bald fällig** (gelb, 150180 Tage —
dasselbe 30-Tage-Vorwarnfenster wie bei der Wiedervorlage), **abgelaufen**
(rot, > 180 Tage).
`aufbewahrung_tage` ist NULLABLE (Migration 0015) — `NULL` bedeutet `aufbewahrung_tage` ist NULLABLE (Migration 0015) — `NULL` bedeutet
"vom Anbieter nicht beziffert" und ist bewusst von `0` ("belegte "vom Anbieter nicht beziffert" und ist bewusst von `0` ("belegte
@@ -266,13 +269,23 @@ aussahen — `formatAufbewahrung()` zeigt entsprechend "unbekannt" statt
"0 Tage". "0 Tage".
`geeignete_zwecke` ist seit derselben Migration ein kontrolliertes `geeignete_zwecke` ist seit derselben Migration ein kontrolliertes
Vokabular statt Freitext: das Formular zeigt Checkboxen für genau die Vokabular statt Freitext: das Formular zeigt Checkboxen statt eines
sechs Zweck-Kategorien aus dem Fragebogen (Textgenerierung, Freitextfelds (`gueltigeZwecke` in `betreiber_werkzeug_handlers.go`,
Übersetzung, Code, Bild, Transkription, Recherche — `gueltigeZwecke` in serverseitig validiert via `istGueltigerZweck`). Grund: ein künftiger
`betreiber_werkzeug_handlers.go`), serverseitig validiert automatischer Zweckabgleich (Bewertungslogik Schritt 3, weiterhin nicht
(`istGueltigerZweck`). Grund: ein künftiger automatischer Zweckabgleich gebaut) würde an inkonsistentem Freitext ("Text" statt
(Bewertungslogik Schritt 3, weiterhin nicht gebaut) würde an "Textgenerierung") scheitern. Am 2026-08-31 von sechs auf zwölf
inkonsistentem Freitext ("Text" statt "Textgenerierung") scheitern. 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 Neues Feld `subprozessoren` (TEXT[], Migration 0015) macht
Unterauftragsverarbeiter (z. B. Anthropic bei Microsoft 365 Copilot, Unterauftragsverarbeiter (z. B. Anthropic bei Microsoft 365 Copilot,
@@ -316,8 +329,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.
@@ -464,7 +477,41 @@ 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-/Radio-Gruppen mit vielen Optionen (Verarbeitungsort, 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.
**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

View File

@@ -26,12 +26,17 @@ func istGueltigerVerarbeitungsort(v string) bool {
} }
// gueltigeZwecke ist das kontrollierte Vokabular für "geeignete Zwecke" // gueltigeZwecke ist das kontrollierte Vokabular für "geeignete Zwecke"
// — exakt die sechs Zweck-Kategorien aus dem Fragebogen/der geplanten // (siehe CLAUDE.md, Bewertungslogik Schritt 3). Vorher war das Feld
// Zweckabgleich-Stufe (siehe CLAUDE.md, Bewertungslogik Schritt 3). // Freitext; ein Tippfehler ("Text" statt "Textgenerierung") hätte den
// Vorher war das Feld Freitext; ein Tippfehler ("Text" statt // künftigen automatischen Zweckabgleich unbemerkt kaputt gemacht. Am
// "Textgenerierung") hätte den künftigen automatischen Zweckabgleich // 2026-08-31 von sechs auf zwölf Kategorien erweitert (bewusste
// unbemerkt kaputt gemacht. // v1-Scope-Entscheidung überarbeitet, nicht stillschweigend geändert)
var gueltigeZwecke = []string{"Textgenerierung", "Übersetzung", "Code", "Bild", "Transkription", "Recherche"} // — 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 { func istGueltigerZweck(v string) bool {
for _, z := range gueltigeZwecke { for _, z := range gueltigeZwecke {
@@ -67,7 +72,8 @@ type werkzeugListItem struct {
Zertifizierungen string Zertifizierungen string
GeeigneteZwecke string GeeigneteZwecke string
LetztePruefung string LetztePruefung string
NichtAktuell bool PruefStatusLabel string
PruefStatusCSS string
} }
// formatAufbewahrung stellt sicher, dass "unbekannt" (Anbieter hat // formatAufbewahrung stellt sicher, dass "unbekannt" (Anbieter hat
@@ -90,10 +96,32 @@ type werkzeugListeData struct {
} }
// werkzeugPruefungVeraltetNach ist die Schwelle, ab der ein // werkzeugPruefungVeraltetNach ist die Schwelle, ab der ein
// Katalogeintrag im Ergebnis als "nicht aktuell geprüft" markiert wird // Katalogeintrag im Ergebnis als "abgelaufen" markiert wird (siehe
// (siehe CLAUDE.md, Werkzeugkatalog) — jede Zusicherung im Katalog muss // CLAUDE.md, Werkzeugkatalog) — jede Zusicherung im Katalog muss
// belegbar UND aktuell sein. // belegbar UND aktuell sein. werkzeugPruefungBaldFaelligNach liegt
const werkzeugPruefungVeraltetNach = 180 * 24 * time.Hour // 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.
func (s *Server) handleBetreiberWerkzeugListe(w http.ResponseWriter, r *http.Request) { func (s *Server) handleBetreiberWerkzeugListe(w http.ResponseWriter, r *http.Request) {
@@ -104,13 +132,14 @@ func (s *Server) handleBetreiberWerkzeugListe(w http.ResponseWriter, r *http.Req
} }
data := werkzeugListeData{Title: "Werkzeugkatalog", Nav: navFor(r)} data := werkzeugListeData{Title: "Werkzeugkatalog", Nav: navFor(r)}
for _, wz := range werkzeuge { for _, wz := range werkzeuge {
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, Verarbeitungsort: wz.Verarbeitungsort,
AVVVerfuegbar: wz.AVVVerfuegbar, TrainingStandard: wz.TrainingStandard, AVVVerfuegbar: wz.AVVVerfuegbar, TrainingStandard: wz.TrainingStandard,
Aufbewahrung: formatAufbewahrung(wz.AufbewahrungTage), Aufbewahrung: formatAufbewahrung(wz.AufbewahrungTage),
Zertifizierungen: strings.Join(wz.Zertifizierungen, ", "), GeeigneteZwecke: strings.Join(wz.GeeigneteZwecke, ", "), Zertifizierungen: strings.Join(wz.Zertifizierungen, ", "), GeeigneteZwecke: strings.Join(wz.GeeigneteZwecke, ", "),
LetztePruefung: wz.LetztePruefung.Format("02.01.2006"), LetztePruefung: wz.LetztePruefung.Format("02.01.2006"),
NichtAktuell: time.Since(wz.LetztePruefung) > werkzeugPruefungVeraltetNach, 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 {

View File

@@ -11,7 +11,6 @@ package web
import ( import (
"net/http" "net/http"
"strings" "strings"
"time"
) )
type mandantWerkzeugItem struct { type mandantWerkzeugItem struct {
@@ -24,7 +23,8 @@ type mandantWerkzeugItem struct {
Aufbewahrung string Aufbewahrung string
GeeigneteZwecke string GeeigneteZwecke string
LetztePruefung string LetztePruefung string
NichtAktuell bool PruefStatusLabel string
PruefStatusCSS string
Gesperrt bool Gesperrt bool
} }
@@ -71,14 +71,15 @@ func (s *Server) handleMandantWerkzeugListe(w http.ResponseWriter, r *http.Reque
data := mandantWerkzeugListeData{Title: "Werkzeugkatalog", Nav: navFor(r)} data := mandantWerkzeugListeData{Title: "Werkzeugkatalog", Nav: navFor(r)}
for _, wz := range zentral { for _, wz := range zentral {
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, Verarbeitungsort: wz.Verarbeitungsort, ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungsort: wz.Verarbeitungsort,
AVVVerfuegbar: wz.AVVVerfuegbar, TrainingStandard: wz.TrainingStandard, AVVVerfuegbar: wz.AVVVerfuegbar, TrainingStandard: wz.TrainingStandard,
Aufbewahrung: formatAufbewahrung(wz.AufbewahrungTage), Aufbewahrung: formatAufbewahrung(wz.AufbewahrungTage),
GeeigneteZwecke: strings.Join(wz.GeeigneteZwecke, ", "), GeeigneteZwecke: strings.Join(wz.GeeigneteZwecke, ", "),
LetztePruefung: wz.LetztePruefung.Format("02.01.2006"), LetztePruefung: wz.LetztePruefung.Format("02.01.2006"),
NichtAktuell: time.Since(wz.LetztePruefung) > werkzeugPruefungVeraltetNach, PruefStatusLabel: label, PruefStatusCSS: css,
Gesperrt: gesperrt[wz.ID], Gesperrt: gesperrt[wz.ID],
}) })
} }
for _, wz := range eigene { for _, wz := range eigene {

View File

@@ -392,6 +392,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;
@@ -545,6 +555,126 @@ 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);
}
.form-section > legend {
padding: 0 0 12px;
font-size: 0.9375rem;
font-weight: 600;
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 +725,17 @@ 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%);
} }
/* Ab hier mehr Platz (Tablet) — der Container bekommt spürbaren /* Ab hier mehr Platz (Tablet) — der Container bekommt spürbaren

View File

@@ -12,61 +12,107 @@
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}}">
<label for="name">Name</label> <fieldset class="form-section">
<input type="text" id="name" name="name" value="{{.Name}}" required> <legend>Grunddaten</legend>
<div class="form-grid">
<label for="anbieter">Anbieter</label> <div>
<input type="text" id="anbieter" name="anbieter" value="{{.Anbieter}}" required> <label for="name">Name</label>
<input type="text" id="name" name="name" value="{{.Name}}" required>
<fieldset> </div>
<legend>Verarbeitungsort</legend> <div>
{{$gewaehlt := .Verarbeitungsort}} <label for="anbieter">Anbieter</label>
{{range .Verarbeitungsorte}} <input type="text" id="anbieter" name="anbieter" value="{{.Anbieter}}" required>
<label><input type="radio" name="verarbeitungsort" value="{{.}}" {{if eq . $gewaehlt}}checked{{end}} required> {{.}}</label> </div>
{{end}} <div class="form-full">
<label>Verarbeitungsort</label>
{{$gewaehlt := .Verarbeitungsort}}
<div class="chip-group">
{{range .Verarbeitungsorte}}
<label><input type="radio" name="verarbeitungsort" value="{{.}}" {{if eq . $gewaehlt}}checked{{end}} required> {{.}}</label>
{{end}}
</div>
</div>
</div>
</fieldset> </fieldset>
<label><input type="checkbox" name="avv_verfuegbar" {{if .AVVVerfuegbar}}checked{{end}}> AVV verfügbar</label> <fieldset class="form-section">
<label for="avv_url">AVV-URL</label> <legend>Rechtliches &amp; Datenschutz</legend>
<input type="text" id="avv_url" name="avv_url" value="{{.AVVURL}}"> <div class="form-grid">
<div class="form-full">
<label><input type="checkbox" name="training_opt_out" {{if .TrainingOptOut}}checked{{end}}> Training-Opt-out möglich</label> <label>Zusicherungen</label>
<label><input type="checkbox" name="training_standard" {{if .TrainingStandard}}checked{{end}}> Training standardmäßig aus</label> <div class="chip-group">
<label><input type="checkbox" name="avv_verfuegbar" {{if .AVVVerfuegbar}}checked{{end}}> AVV verfügbar</label>
<label for="aufbewahrung_tage">Aufbewahrung (Tage, leer lassen wenn vom Anbieter nicht beziffert)</label> <label><input type="checkbox" name="training_opt_out" {{if .TrainingOptOut}}checked{{end}}> Training-Opt-out möglich</label>
<input type="number" id="aufbewahrung_tage" name="aufbewahrung_tage" min="0" value="{{.AufbewahrungTage}}"> <label><input type="checkbox" name="training_standard" {{if .TrainingStandard}}checked{{end}}> Training standardmäßig aus</label>
</div>
<label for="zertifizierungen">Zertifizierungen (kommagetrennt)</label> </div>
<input type="text" id="zertifizierungen" name="zertifizierungen" value="{{.Zertifizierungen}}"> <div class="form-full">
<label for="avv_url">AVV-URL</label>
<label for="subprozessoren">Subprozessoren (kommagetrennt, z. B. Anthropic, AWS)</label> <input type="text" id="avv_url" name="avv_url" value="{{.AVVURL}}">
<input type="text" id="subprozessoren" name="subprozessoren" value="{{.Subprozessoren}}"> </div>
<div>
<fieldset> <label for="aufbewahrung_tage">Aufbewahrung (Tage, leer lassen wenn vom Anbieter nicht beziffert)</label>
<legend>Geeignete Zwecke</legend> <input type="number" id="aufbewahrung_tage" name="aufbewahrung_tage" min="0" value="{{.AufbewahrungTage}}">
{{range .ZweckeOptionen}} </div>
<label><input type="checkbox" name="geeignete_zwecke" value="{{.}}" {{if index $.ZweckeAusgewaehlt .}}checked{{end}}> {{.}}</label> <div>
{{end}} <label for="zertifizierungen">Zertifizierungen (kommagetrennt)</label>
<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> </fieldset>
<label for="einschraenkungen">Einschränkungen (kommagetrennt)</label> <fieldset class="form-section">
<input type="text" id="einschraenkungen" name="einschraenkungen" value="{{.Einschraenkungen}}"> <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>
<input type="text" id="einschraenkungen" name="einschraenkungen" value="{{.Einschraenkungen}}">
</div>
</div>
</fieldset>
<label for="letzte_pruefung">Letzte Prüfung</label> <fieldset class="form-section">
<input type="date" id="letzte_pruefung" name="letzte_pruefung" value="{{.LetztePruefung}}" required> <legend>Beleg</legend>
<div class="form-grid">
<div>
<label for="letzte_pruefung">Letzte Prüfung</label>
<input type="date" id="letzte_pruefung" name="letzte_pruefung" value="{{.LetztePruefung}}" required>
</div>
<div>
<label for="quelle">Quelle (Beleg)</label>
<input type="text" id="quelle" name="quelle" value="{{.Quelle}}" required>
</div>
</div>
</fieldset>
<label for="quelle">Quelle (Beleg)</label> <div class="form-actions">
<input type="text" id="quelle" name="quelle" value="{{.Quelle}}" required> <button type="submit">Speichern</button>
</div>
<button type="submit">Speichern</button>
</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">
<button type="submit" class="entfernen">Werkzeug löschen</button> <span class="hinweis">Ein gelöschtes Werkzeug kann nicht wiederhergestellt werden.</span>
</form> <form method="post" action="{{.ActionBase}}/{{.ID}}/loeschen" onsubmit="return confirm('Werkzeug wirklich löschen?')">
<button type="submit" class="entfernen">Werkzeug löschen</button>
</form>
</div>
{{end}} {{end}}
</div> </div>
</div>
</body> </body>
</html> </html>
{{end}} {{end}}

View File

@@ -29,7 +29,7 @@
<td>{{.Aufbewahrung}}</td> <td>{{.Aufbewahrung}}</td>
<td>{{.Zertifizierungen}}</td> <td>{{.Zertifizierungen}}</td>
<td>{{.GeeigneteZwecke}}</td> <td>{{.GeeigneteZwecke}}</td>
<td>{{.LetztePruefung}}{{if .NichtAktuell}} <span class="status">nicht aktuell geprüft</span>{{end}}</td> <td>{{.LetztePruefung}} <span class="status {{.PruefStatusCSS}}">{{.PruefStatusLabel}}</span></td>
</tr> </tr>
{{end}} {{end}}
</tbody> </tbody>

View File

@@ -33,7 +33,7 @@
<td>{{if .TrainingStandard}}ja{{else}}nein{{end}}</td> <td>{{if .TrainingStandard}}ja{{else}}nein{{end}}</td>
<td>{{.Aufbewahrung}}</td> <td>{{.Aufbewahrung}}</td>
<td>{{.GeeigneteZwecke}}</td> <td>{{.GeeigneteZwecke}}</td>
<td>{{.LetztePruefung}}{{if .NichtAktuell}} <span class="status">nicht aktuell geprüft</span>{{end}}</td> <td>{{.LetztePruefung}} <span class="status {{.PruefStatusCSS}}">{{.PruefStatusLabel}}</span></td>
<td> <td>
{{if .Gesperrt}} {{if .Gesperrt}}
<span class="status">gesperrt</span> <span class="status">gesperrt</span>