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.
This commit is contained in:
noroot
2026-08-31 12:28:57 +02:00
parent 4358dbaa0d
commit dbeaa45644
6 changed files with 64 additions and 20 deletions

View File

@@ -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

View File

@@ -67,7 +67,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 +91,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 +127,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,13 +71,14 @@ 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],
}) })
} }

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;
@@ -595,11 +605,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

@@ -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>