feat: Werkzeugkatalog als Tabelle, Aufbewahrung nullable, kontrolliertes Zweck-Vokabular, Subprozessoren
Katalog (zentral + mandantenseitig) zeigt jetzt eine mehrspaltige Tabelle statt einer Liste. aufbewahrung_tage ist nullable (NULL = vom Anbieter nicht beziffert, unterscheidbar von echter 0-Tage- Zusicherung). geeignete_zwecke ist Checkbox-Auswahl aus den sechs Fragebogen-Zweck-Kategorien statt Freitext. Neues Feld subprozessoren macht Unterauftragsverarbeiter durchsuchbar statt nur Freitext in Einschränkungen. 180-Tage-Frische-Markierung in beiden Katalogansichten ergänzt.
This commit is contained in:
72
CLAUDE.md
72
CLAUDE.md
@@ -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
|
||||
noch nicht, siehe unten).
|
||||
|
||||
**Weiterhin nicht gebaut:** Anmeldeverfahren-Konfiguration, ein echtes
|
||||
Abo-System (Preismodell und Zahlungsanbieter mit dem Nutzer am
|
||||
2026-08-30 grundsätzlich geklärt — 3 €/Mitarbeiter/Monat, Mindestabnahme
|
||||
10 Mitarbeiter, 14 Tage Testphase, Stripe mit SEPA-Lastschrift — die
|
||||
eigentliche Umsetzung wartet noch auf einen Stripe-Account/Testmodus-
|
||||
Zugangsdaten), Sperren/Deaktivieren eines Accounts durch den Betreiber
|
||||
(hängt weiter an der Abrechnungs-/Freischaltungs-Architektur, siehe
|
||||
Offene Punkte: `account.verified` wurde beim Produktwechsel sogar
|
||||
entfernt).
|
||||
**Weiterhin nicht gebaut:** Anmeldeverfahren-Konfiguration, das
|
||||
Abo-System. **Wichtige Korrektur (2026-08-31):** die eigentliche
|
||||
Zahlungsabwicklung/Rechnungsstellung läuft über ein **anderes,
|
||||
externes System** — Deklarix baut dafür **kein Stripe/Zahlungs-SDK**.
|
||||
Deklarix bildet nur den **Subskriptionsstatus** ab (Plan, gebuchte
|
||||
Mitarbeiterzahl, Status wie `test`/`aktiv`/`gekündigt`, Ablaufdatum),
|
||||
der von außen gesetzt wird — noch offen, wie genau ("von außen" heißt
|
||||
aktuell: manuell durch den Betreiber, eine Schnittstelle zum externen
|
||||
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
|
||||
Isolation in der Anwendungsschicht erzwungen (Handler vergleichen
|
||||
@@ -248,8 +254,52 @@ zentralen Katalog zu verändern.
|
||||
Zusicherung im Katalog (AVV verfügbar, Verarbeitungsort, Training-Opt-
|
||||
out, Zertifizierungen) muss belegbar sein, sonst haftet Deklarix für
|
||||
eine Aussage, die nicht nachweisbar ist. Einträge älter als 180 Tage
|
||||
sollen im Ergebnis als "nicht aktuell geprüft" markiert werden (noch
|
||||
nicht in der UI umgesetzt, siehe Offene Punkte).
|
||||
werden im zentralen (`GET /betreiber/werkzeuge`) und im mandanten-
|
||||
seitigen Katalog (`GET /verwaltung/werkzeuge`) als "nicht aktuell
|
||||
geprüft" markiert (`werkzeugPruefungVeraltetNach`, 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 für genau die
|
||||
sechs Zweck-Kategorien aus dem Fragebogen (Textgenerierung,
|
||||
Übersetzung, Code, Bild, Transkription, Recherche — `gueltigeZwecke` in
|
||||
`betreiber_werkzeug_handlers.go`), serverseitig validiert
|
||||
(`istGueltigerZweck`). Grund: ein künftiger automatischer Zweckabgleich
|
||||
(Bewertungslogik Schritt 3, weiterhin nicht gebaut) würde an
|
||||
inkonsistentem Freitext ("Text" statt "Textgenerierung") scheitern.
|
||||
|
||||
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, Verarbeitungsort, 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.
|
||||
|
||||
Der zentrale Katalog ist aktuell mit 10 recherchierten Einträgen befüllt
|
||||
(ChatGPT Enterprise, Microsoft 365 Copilot, Claude for Work, GitHub
|
||||
Copilot Business, DeepL Pro/API, Google Gemini für Google Workspace,
|
||||
Le Chat Enterprise, Perplexity Enterprise Pro, Adobe Firefly Enterprise,
|
||||
Otter.ai Business) — deckt damit erstmals alle sechs Zweck-Kategorien
|
||||
ab (vorher fehlten Bild und Transkription vollständig, Recherche hatte
|
||||
kein dediziertes Werkzeug). Jede Zusicherung ist mit einer echten,
|
||||
offiziellen Anbieter-Quelle belegt (Stand 2026-08-31), nicht geraten —
|
||||
zwei Einträge (Adobe Firefly: Trainingsausschluss ist nicht Standard;
|
||||
Otter.ai: Training auf de-identifizierten Aufnahmen, laufende
|
||||
Rechtsstreitigkeiten) sind bewusst mit kritischen Einschränkungen
|
||||
versehen statt schöngefärbt, das ist die vorgesehene Funktion des
|
||||
Katalogs.
|
||||
|
||||
`CurrentKatalogVersion` liefert eine reproduzierbare Kennung des
|
||||
aktuellen Katalogzustands (Anzahl Einträge + letzte Änderung) — wird in
|
||||
|
||||
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 '{}';
|
||||
@@ -25,8 +25,9 @@ type Werkzeug struct {
|
||||
AVVURL string
|
||||
TrainingOptOut bool
|
||||
TrainingStandard bool
|
||||
AufbewahrungTage int
|
||||
AufbewahrungTage *int // nil = vom Anbieter nicht beziffert, NICHT gleichbedeutend mit 0 Tagen
|
||||
Zertifizierungen []string
|
||||
Subprozessoren []string
|
||||
GeeigneteZwecke []string
|
||||
Einschraenkungen []string
|
||||
LetztePruefung time.Time
|
||||
@@ -36,7 +37,7 @@ type Werkzeug struct {
|
||||
}
|
||||
|
||||
// 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.
|
||||
type WerkzeugInput struct {
|
||||
AccountID *string
|
||||
@@ -47,8 +48,9 @@ type WerkzeugInput struct {
|
||||
AVVURL string
|
||||
TrainingOptOut bool
|
||||
TrainingStandard bool
|
||||
AufbewahrungTage int
|
||||
AufbewahrungTage *int
|
||||
Zertifizierungen []string
|
||||
Subprozessoren []string
|
||||
GeeigneteZwecke []string
|
||||
Einschraenkungen []string
|
||||
LetztePruefung time.Time
|
||||
@@ -56,8 +58,8 @@ type WerkzeugInput struct {
|
||||
}
|
||||
|
||||
const werkzeugColumns = `id, account_id, name, anbieter, verarbeitungsort, avv_verfuegbar, avv_url,
|
||||
training_opt_out, training_standard, aufbewahrung_tage, zertifizierungen, geeignete_zwecke,
|
||||
einschraenkungen, letzte_pruefung, quelle, created_at, updated_at`
|
||||
training_opt_out, training_standard, aufbewahrung_tage, zertifizierungen, subprozessoren,
|
||||
geeignete_zwecke, einschraenkungen, letzte_pruefung, quelle, created_at, updated_at`
|
||||
|
||||
func scanWerkzeug(row interface {
|
||||
Scan(dest ...any) error
|
||||
@@ -65,8 +67,8 @@ func scanWerkzeug(row interface {
|
||||
var w Werkzeug
|
||||
err := row.Scan(
|
||||
&w.ID, &w.AccountID, &w.Name, &w.Anbieter, &w.Verarbeitungsort, &w.AVVVerfuegbar, &w.AVVURL,
|
||||
&w.TrainingOptOut, &w.TrainingStandard, &w.AufbewahrungTage, &w.Zertifizierungen, &w.GeeigneteZwecke,
|
||||
&w.Einschraenkungen, &w.LetztePruefung, &w.Quelle, &w.CreatedAt, &w.UpdatedAt,
|
||||
&w.TrainingOptOut, &w.TrainingStandard, &w.AufbewahrungTage, &w.Zertifizierungen, &w.Subprozessoren,
|
||||
&w.GeeigneteZwecke, &w.Einschraenkungen, &w.LetztePruefung, &w.Quelle, &w.CreatedAt, &w.UpdatedAt,
|
||||
)
|
||||
return w, err
|
||||
}
|
||||
@@ -86,6 +88,9 @@ func normalizeWerkzeugSlices(in *WerkzeugInput) {
|
||||
if in.Einschraenkungen == nil {
|
||||
in.Einschraenkungen = []string{}
|
||||
}
|
||||
if in.Subprozessoren == nil {
|
||||
in.Subprozessoren = []string{}
|
||||
}
|
||||
}
|
||||
|
||||
// CreateWerkzeug legt einen Katalogeintrag an.
|
||||
@@ -94,12 +99,12 @@ func (s *Store) CreateWerkzeug(ctx context.Context, in WerkzeugInput) (Werkzeug,
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO werkzeug (
|
||||
account_id, name, anbieter, verarbeitungsort, avv_verfuegbar, avv_url,
|
||||
training_opt_out, training_standard, aufbewahrung_tage, zertifizierungen,
|
||||
training_opt_out, training_standard, aufbewahrung_tage, zertifizierungen, subprozessoren,
|
||||
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)
|
||||
RETURNING `+werkzeugColumns,
|
||||
in.AccountID, in.Name, in.Anbieter, in.Verarbeitungsort, in.AVVVerfuegbar, in.AVVURL,
|
||||
in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage, in.Zertifizierungen,
|
||||
in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage, in.Zertifizierungen, in.Subprozessoren,
|
||||
in.GeeigneteZwecke, in.Einschraenkungen, in.LetztePruefung, in.Quelle,
|
||||
)
|
||||
w, err := scanWerkzeug(row)
|
||||
@@ -120,12 +125,12 @@ func (s *Store) UpdateWerkzeug(ctx context.Context, id string, in WerkzeugInput)
|
||||
UPDATE werkzeug SET
|
||||
name = $2, anbieter = $3, verarbeitungsort = $4, avv_verfuegbar = $5, avv_url = $6,
|
||||
training_opt_out = $7, training_standard = $8, aufbewahrung_tage = $9,
|
||||
zertifizierungen = $10, geeignete_zwecke = $11, einschraenkungen = $12,
|
||||
letzte_pruefung = $13, quelle = $14, updated_at = now()
|
||||
zertifizierungen = $10, subprozessoren = $11, geeignete_zwecke = $12, einschraenkungen = $13,
|
||||
letzte_pruefung = $14, quelle = $15, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING `+werkzeugColumns,
|
||||
id, in.Name, in.Anbieter, in.Verarbeitungsort, in.AVVVerfuegbar, in.AVVURL,
|
||||
in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage, in.Zertifizierungen,
|
||||
in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage, in.Zertifizierungen, in.Subprozessoren,
|
||||
in.GeeigneteZwecke, in.Einschraenkungen, in.LetztePruefung, in.Quelle,
|
||||
)
|
||||
w, err := scanWerkzeug(row)
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
func intPtr(v int) *int { return &v }
|
||||
|
||||
func testWerkzeugInput(accountID *string, name string) store.WerkzeugInput {
|
||||
return store.WerkzeugInput{
|
||||
AccountID: accountID,
|
||||
@@ -19,8 +21,9 @@ func testWerkzeugInput(accountID *string, name string) store.WerkzeugInput {
|
||||
AVVURL: "https://beispiel.example/avv",
|
||||
TrainingOptOut: true,
|
||||
TrainingStandard: true,
|
||||
AufbewahrungTage: 30,
|
||||
AufbewahrungTage: intPtr(30),
|
||||
Zertifizierungen: []string{"ISO 27001"},
|
||||
Subprozessoren: []string{"Beispiel-Subprozessor Inc."},
|
||||
GeeigneteZwecke: []string{"Textgenerierung"},
|
||||
Einschraenkungen: nil,
|
||||
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" {
|
||||
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)
|
||||
if err != nil {
|
||||
@@ -52,12 +73,12 @@ func TestWerkzeugCRUD(t *testing.T) {
|
||||
}
|
||||
|
||||
updateInput := testWerkzeugInput(nil, "Zentrales Werkzeug (aktualisiert)")
|
||||
updateInput.AufbewahrungTage = 14
|
||||
updateInput.AufbewahrungTage = intPtr(14)
|
||||
updated, err := s.UpdateWerkzeug(ctx, w.ID, updateInput)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -289,9 +289,13 @@ func (s *Server) bewerteAntrag(ctx context.Context, antragID, accountID string,
|
||||
}
|
||||
kandidaten := make([]rules.WerkzeugKandidat, len(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{
|
||||
AVVVerfuegbar: w.AVVVerfuegbar, Verarbeitungsort: w.Verarbeitungsort,
|
||||
TrainingOptOut: w.TrainingOptOut, TrainingStandard: w.TrainingStandard, AufbewahrungTage: w.AufbewahrungTage,
|
||||
TrainingOptOut: w.TrainingOptOut, TrainingStandard: w.TrainingStandard, AufbewahrungTage: aufbewahrungTage,
|
||||
}}
|
||||
}
|
||||
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, anforderungIDs)
|
||||
|
||||
@@ -25,6 +25,23 @@ func istGueltigerVerarbeitungsort(v string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// gueltigeZwecke ist das kontrollierte Vokabular für "geeignete Zwecke"
|
||||
// — exakt die sechs Zweck-Kategorien aus dem Fragebogen/der geplanten
|
||||
// Zweckabgleich-Stufe (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.
|
||||
var gueltigeZwecke = []string{"Textgenerierung", "Übersetzung", "Code", "Bild", "Transkription", "Recherche"}
|
||||
|
||||
func istGueltigerZweck(v string) bool {
|
||||
for _, z := range gueltigeZwecke {
|
||||
if z == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// splitListe zerlegt ein kommasepariertes Formularfeld (Zertifizierungen,
|
||||
// geeignete Zwecke, Einschränkungen) in eine bereinigte Liste — ohne
|
||||
// führende/folgende Leerzeichen und ohne leere Einträge.
|
||||
@@ -44,7 +61,26 @@ type werkzeugListItem struct {
|
||||
Name string
|
||||
Anbieter string
|
||||
Verarbeitungsort string
|
||||
AVVVerfuegbar bool
|
||||
TrainingStandard bool
|
||||
Aufbewahrung string
|
||||
Zertifizierungen string
|
||||
GeeigneteZwecke string
|
||||
LetztePruefung string
|
||||
NichtAktuell bool
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -53,6 +89,12 @@ type werkzeugListeData struct {
|
||||
Werkzeuge []werkzeugListItem
|
||||
}
|
||||
|
||||
// werkzeugPruefungVeraltetNach ist die Schwelle, ab der ein
|
||||
// Katalogeintrag im Ergebnis als "nicht aktuell geprüft" markiert wird
|
||||
// (siehe CLAUDE.md, Werkzeugkatalog) — jede Zusicherung im Katalog muss
|
||||
// belegbar UND aktuell sein.
|
||||
const werkzeugPruefungVeraltetNach = 180 * 24 * time.Hour
|
||||
|
||||
// handleBetreiberWerkzeugListe zeigt den zentralen Katalog.
|
||||
func (s *Server) handleBetreiberWerkzeugListe(w http.ResponseWriter, r *http.Request) {
|
||||
werkzeuge, err := s.store.ListZentraleWerkzeuge(r.Context())
|
||||
@@ -64,7 +106,11 @@ func (s *Server) handleBetreiberWerkzeugListe(w http.ResponseWriter, r *http.Req
|
||||
for _, wz := range werkzeuge {
|
||||
data.Werkzeuge = append(data.Werkzeuge, werkzeugListItem{
|
||||
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungsort: wz.Verarbeitungsort,
|
||||
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"),
|
||||
NichtAktuell: time.Since(wz.LetztePruefung) > werkzeugPruefungVeraltetNach,
|
||||
})
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeuge", data); err != nil {
|
||||
@@ -89,17 +135,52 @@ type werkzeugFormData struct {
|
||||
AVVURL string
|
||||
TrainingOptOut bool
|
||||
TrainingStandard bool
|
||||
AufbewahrungTage int
|
||||
AufbewahrungTage string // leer = vom Anbieter nicht beziffert (unbekannt), NICHT "0"
|
||||
Zertifizierungen string
|
||||
GeeigneteZwecke string
|
||||
Subprozessoren string
|
||||
ZweckeOptionen []string
|
||||
ZweckeAusgewaehlt map[string]bool
|
||||
Einschraenkungen string
|
||||
LetztePruefung string
|
||||
Quelle string
|
||||
Error string
|
||||
}
|
||||
|
||||
// zweckeAusgewaehltVon baut die Checkbox-Vorbelegung aus einer bereits
|
||||
// gespeicherten oder erneut eingereichten Liste von Zwecken.
|
||||
func zweckeAusgewaehltVon(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, Verarbeitungsorte: gueltigeVerarbeitungsorte,
|
||||
Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungsort: wz.Verarbeitungsort,
|
||||
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: zweckeAusgewaehltVon(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) {
|
||||
data := werkzeugFormData{Title: "Werkzeug anlegen", Nav: navFor(r), ActionBase: "/betreiber/werkzeuge", Verarbeitungsorte: gueltigeVerarbeitungsorte}
|
||||
data := werkzeugFormData{
|
||||
Title: "Werkzeug anlegen", Nav: navFor(r), ActionBase: "/betreiber/werkzeuge",
|
||||
Verarbeitungsorte: gueltigeVerarbeitungsorte, ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: map[string]bool{},
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
@@ -123,7 +204,21 @@ func werkzeugInputFromForm(r *http.Request, accountID *string) (store.WerkzeugIn
|
||||
if name == "" || anbieter == "" || quelle == "" {
|
||||
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{
|
||||
AccountID: accountID,
|
||||
@@ -136,7 +231,8 @@ func werkzeugInputFromForm(r *http.Request, accountID *string) (store.WerkzeugIn
|
||||
TrainingStandard: r.FormValue("training_standard") == "on",
|
||||
AufbewahrungTage: aufbewahrungTage,
|
||||
Zertifizierungen: splitListe(r.FormValue("zertifizierungen")),
|
||||
GeeigneteZwecke: splitListe(r.FormValue("geeignete_zwecke")),
|
||||
Subprozessoren: splitListe(r.FormValue("subprozessoren")),
|
||||
GeeigneteZwecke: geeigneteZwecke,
|
||||
Einschraenkungen: splitListe(r.FormValue("einschraenkungen")),
|
||||
LetztePruefung: letztePruefung,
|
||||
Quelle: quelle,
|
||||
@@ -152,7 +248,9 @@ func werkzeugFormDataFromRequest(r *http.Request, title, actionBase, id, errMsg
|
||||
Name: r.FormValue("name"), Anbieter: r.FormValue("anbieter"), Verarbeitungsort: r.FormValue("verarbeitungsort"),
|
||||
AVVVerfuegbar: r.FormValue("avv_verfuegbar") == "on", AVVURL: r.FormValue("avv_url"),
|
||||
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: zweckeAusgewaehltVon(r.Form["geeignete_zwecke"]),
|
||||
Einschraenkungen: r.FormValue("einschraenkungen"), LetztePruefung: r.FormValue("letzte_pruefung"),
|
||||
Quelle: r.FormValue("quelle"), Error: errMsg,
|
||||
}
|
||||
@@ -192,15 +290,7 @@ func (s *Server) handleBetreiberWerkzeugEditForm(w http.ResponseWriter, r *http.
|
||||
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
data := werkzeugFormData{
|
||||
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,
|
||||
}
|
||||
data := werkzeugFormDataFromWerkzeug(r, wz, "Werkzeug bearbeiten", "/betreiber/werkzeuge")
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
@@ -11,19 +11,29 @@ package web
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type mandantWerkzeugItem struct {
|
||||
ID string
|
||||
Name string
|
||||
Anbieter string
|
||||
Gesperrt bool
|
||||
ID string
|
||||
Name string
|
||||
Anbieter string
|
||||
Verarbeitungsort string
|
||||
AVVVerfuegbar bool
|
||||
TrainingStandard bool
|
||||
Aufbewahrung string
|
||||
GeeigneteZwecke string
|
||||
LetztePruefung string
|
||||
NichtAktuell bool
|
||||
Gesperrt bool
|
||||
}
|
||||
|
||||
type eigenesWerkzeugItem struct {
|
||||
ID string
|
||||
Name string
|
||||
Anbieter string
|
||||
ID string
|
||||
Name string
|
||||
Anbieter string
|
||||
Verarbeitungsort string
|
||||
LetztePruefung string
|
||||
}
|
||||
|
||||
type mandantWerkzeugListeData struct {
|
||||
@@ -62,14 +72,23 @@ func (s *Server) handleMandantWerkzeugListe(w http.ResponseWriter, r *http.Reque
|
||||
data := mandantWerkzeugListeData{Title: "Werkzeugkatalog", Nav: navFor(r)}
|
||||
for _, wz := range zentral {
|
||||
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, Verarbeitungsort: wz.Verarbeitungsort,
|
||||
AVVVerfuegbar: wz.AVVVerfuegbar, TrainingStandard: wz.TrainingStandard,
|
||||
Aufbewahrung: formatAufbewahrung(wz.AufbewahrungTage),
|
||||
GeeigneteZwecke: strings.Join(wz.GeeigneteZwecke, ", "),
|
||||
LetztePruefung: wz.LetztePruefung.Format("02.01.2006"),
|
||||
NichtAktuell: time.Since(wz.LetztePruefung) > werkzeugPruefungVeraltetNach,
|
||||
Gesperrt: gesperrt[wz.ID],
|
||||
})
|
||||
}
|
||||
for _, wz := range eigene {
|
||||
if wz.AccountID == nil {
|
||||
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, Verarbeitungsort: wz.Verarbeitungsort,
|
||||
LetztePruefung: wz.LetztePruefung.Format("02.01.2006"),
|
||||
})
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "mandant-werkzeuge", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
@@ -77,7 +96,10 @@ func (s *Server) handleMandantWerkzeugListe(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
|
||||
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",
|
||||
Verarbeitungsorte: gueltigeVerarbeitungsorte, ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: map[string]bool{},
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
@@ -121,15 +143,7 @@ func (s *Server) handleMandantWerkzeugEditForm(w http.ResponseWriter, r *http.Re
|
||||
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
data := werkzeugFormData{
|
||||
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,
|
||||
}
|
||||
data := werkzeugFormDataFromWerkzeug(r, wz, "Eigenes Werkzeug bearbeiten", "/verwaltung/werkzeuge")
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
@@ -34,14 +34,21 @@
|
||||
<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 for="aufbewahrung_tage">Aufbewahrung (Tage)</label>
|
||||
<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}}">
|
||||
|
||||
<label for="zertifizierungen">Zertifizierungen (kommagetrennt)</label>
|
||||
<input type="text" id="zertifizierungen" name="zertifizierungen" value="{{.Zertifizierungen}}">
|
||||
|
||||
<label for="geeignete_zwecke">Geeignete Zwecke (kommagetrennt)</label>
|
||||
<input type="text" id="geeignete_zwecke" name="geeignete_zwecke" value="{{.GeeigneteZwecke}}">
|
||||
<label for="subprozessoren">Subprozessoren (kommagetrennt, z. B. Anthropic, AWS)</label>
|
||||
<input type="text" id="subprozessoren" name="subprozessoren" value="{{.Subprozessoren}}">
|
||||
|
||||
<fieldset>
|
||||
<legend>Geeignete Zwecke</legend>
|
||||
{{range .ZweckeOptionen}}
|
||||
<label><input type="checkbox" name="geeignete_zwecke" value="{{.}}" {{if index $.ZweckeAusgewaehlt .}}checked{{end}}> {{.}}</label>
|
||||
{{end}}
|
||||
</fieldset>
|
||||
|
||||
<label for="einschraenkungen">Einschränkungen (kommagetrennt)</label>
|
||||
<input type="text" id="einschraenkungen" name="einschraenkungen" value="{{.Einschraenkungen}}">
|
||||
|
||||
@@ -9,15 +9,32 @@
|
||||
{{if not .Werkzeuge}}
|
||||
<p class="hinweis">Noch keine zentralen Katalogeinträge.</p>
|
||||
{{else}}
|
||||
<ul class="beitraege-liste">
|
||||
{{range .Werkzeuge}}
|
||||
<li>
|
||||
<a href="/betreiber/werkzeuge/{{.ID}}">
|
||||
<strong>{{.Name}}</strong> · {{.Anbieter}} · {{.Verarbeitungsort}} · zuletzt geprüft {{.LetztePruefung}}
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th><th>Anbieter</th><th>Verarbeitungsort</th><th>AVV</th>
|
||||
<th>Training aus</th><th>Aufbewahrung</th><th>Zertifizierungen</th><th>Geeignete Zwecke</th>
|
||||
<th>Zuletzt geprüft</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Werkzeuge}}
|
||||
<tr>
|
||||
<td><a href="/betreiber/werkzeuge/{{.ID}}">{{.Name}}</a></td>
|
||||
<td>{{.Anbieter}}</td>
|
||||
<td>{{.Verarbeitungsort}}</td>
|
||||
<td>{{if .AVVVerfuegbar}}ja{{else}}nein{{end}}</td>
|
||||
<td>{{if .TrainingStandard}}ja{{else}}nein{{end}}</td>
|
||||
<td>{{.Aufbewahrung}}</td>
|
||||
<td>{{.Zertifizierungen}}</td>
|
||||
<td>{{.GeeigneteZwecke}}</td>
|
||||
<td>{{.LetztePruefung}}{{if .NichtAktuell}} <span class="status">nicht aktuell geprüft</span>{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
<p><a href="/betreiber/werkzeuge/neu">Werkzeug anlegen</a></p>
|
||||
</div>
|
||||
|
||||
@@ -15,24 +15,43 @@
|
||||
{{if not .Werkzeuge}}
|
||||
<p class="hinweis">Der zentrale Katalog ist noch leer.</p>
|
||||
{{else}}
|
||||
<ul class="beitraege-liste">
|
||||
{{range .Werkzeuge}}
|
||||
<li>
|
||||
{{.Name}} · {{.Anbieter}}
|
||||
{{if .Gesperrt}}
|
||||
<span class="status">gesperrt</span>
|
||||
<form method="post" action="/verwaltung/werkzeuge/{{.ID}}/entsperren" style="display:inline">
|
||||
<button type="submit">Entsperren</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<form method="post" action="/verwaltung/werkzeuge/{{.ID}}/sperren" style="display:inline">
|
||||
<input type="text" name="grund" placeholder="Grund (optional)">
|
||||
<button type="submit">Sperren</button>
|
||||
</form>
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th><th>Anbieter</th><th>Verarbeitungsort</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}}
|
||||
<tr>
|
||||
<td>{{.Name}}</td>
|
||||
<td>{{.Anbieter}}</td>
|
||||
<td>{{.Verarbeitungsort}}</td>
|
||||
<td>{{if .AVVVerfuegbar}}ja{{else}}nein{{end}}</td>
|
||||
<td>{{if .TrainingStandard}}ja{{else}}nein{{end}}</td>
|
||||
<td>{{.Aufbewahrung}}</td>
|
||||
<td>{{.GeeigneteZwecke}}</td>
|
||||
<td>{{.LetztePruefung}}{{if .NichtAktuell}} <span class="status">nicht aktuell geprüft</span>{{end}}</td>
|
||||
<td>
|
||||
{{if .Gesperrt}}
|
||||
<span class="status">gesperrt</span>
|
||||
<form method="post" action="/verwaltung/werkzeuge/{{.ID}}/entsperren" style="display:inline">
|
||||
<button type="submit">Entsperren</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<form method="post" action="/verwaltung/werkzeuge/{{.ID}}/sperren" style="display:inline">
|
||||
<input type="text" name="grund" placeholder="Grund (optional)">
|
||||
<button type="submit">Sperren</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<h2>Eigene Werkzeuge</h2>
|
||||
@@ -43,13 +62,23 @@
|
||||
{{if not .EigeneWerkzeuge}}
|
||||
<p class="hinweis">Noch keine eigenen Werkzeuge.</p>
|
||||
{{else}}
|
||||
<ul class="beitraege-liste">
|
||||
{{range .EigeneWerkzeuge}}
|
||||
<li>
|
||||
<a href="/verwaltung/werkzeuge/{{.ID}}">{{.Name}} · {{.Anbieter}}</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Anbieter</th><th>Verarbeitungsort</th><th>Zuletzt geprüft</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .EigeneWerkzeuge}}
|
||||
<tr>
|
||||
<td><a href="/verwaltung/werkzeuge/{{.ID}}">{{.Name}}</a></td>
|
||||
<td>{{.Anbieter}}</td>
|
||||
<td>{{.Verarbeitungsort}}</td>
|
||||
<td>{{.LetztePruefung}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
<p><a href="/verwaltung/werkzeuge/neu">Eigenes Werkzeug anlegen</a></p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user