Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb99891e38 | ||
|
|
36af1bf288 |
74
CLAUDE.md
74
CLAUDE.md
@@ -98,20 +98,26 @@ reiner Anzeige-Screen reicht nicht):
|
|||||||
reine Stammdaten für das Fragebogen-Feld "Abteilung" — ohne diese
|
reine Stammdaten für das Fragebogen-Feld "Abteilung" — ohne diese
|
||||||
Seite blieb die Abteilung-Auswahl im Fragebogen faktisch leer und
|
Seite blieb die Abteilung-Auswahl im Fragebogen faktisch leer und
|
||||||
unbenutzbar, das war kein Komfort-, sondern ein Funktionsdefizit.
|
unbenutzbar, das war kein Komfort-, sondern ein Funktionsdefizit.
|
||||||
- **Eigene Werkzeug-Sperrungen** (`internal/web/mandant_werkzeug_handlers.go`,
|
- **Eigene Werkzeug-Sperrungen und -Einträge** (`internal/web/mandant_werkzeug_handlers.go`,
|
||||||
`GET /verwaltung/werkzeuge`): ein Mandant kann einen zentralen
|
`GET /verwaltung/werkzeuge`): ein Mandant kann einen zentralen
|
||||||
Katalogeintrag für sich sperren/entsperren (`werkzeug_sperre`), ohne
|
Katalogeintrag für sich sperren/entsperren (`werkzeug_sperre`), ohne
|
||||||
den zentralen Katalog selbst zu verändern — das bleibt Sache der
|
den zentralen Katalog selbst zu verändern — das bleibt Sache der
|
||||||
Plattform (Ebene 5, `betreiber_werkzeug_handlers.go`).
|
Plattform (Ebene 5, `betreiber_werkzeug_handlers.go`). Zusätzlich kann
|
||||||
|
ein Mandant eigene, nur für sich sichtbare Katalogeinträge anlegen/
|
||||||
|
bearbeiten/löschen (`GET/POST /verwaltung/werkzeuge/neu`, `GET/POST
|
||||||
|
/verwaltung/werkzeuge/{id}`, `POST /verwaltung/werkzeuge/{id}/loeschen`
|
||||||
|
— `werkzeug.account_id` = der eigene Account). Nutzt dasselbe Formular
|
||||||
|
wie der zentrale Katalog (`werkzeugFormData`/`betreiber-werkzeug-form.html`,
|
||||||
|
ein `ActionBase`-Feld unterscheidet Betreiber- von Mandanten-Ziel-URL)
|
||||||
|
— ein zentraler oder fremder Eintrag ist über diese Route nicht
|
||||||
|
erreichbar (404).
|
||||||
|
|
||||||
**Weiterhin nicht gebaut:** Anmeldeverfahren-Konfiguration, eigene
|
**Weiterhin nicht gebaut:** Anmeldeverfahren-Konfiguration, Rechnungsdaten/
|
||||||
Werkzeug-EINTRÄGE eines Mandanten (nur Sperrungen zentraler Einträge
|
Abrechnung (kein Abo-System, bewusst "Nicht bauen v1"), Account-
|
||||||
sind umgesetzt, `account_id`-gesetzte eigene Katalogeinträge fehlen
|
Verwaltung durch den Betreiber (Ebene 5 zeigt Accounts nur lesend an —
|
||||||
noch), Rechnungsdaten/Abrechnung (kein Abo-System, bewusst "Nicht bauen
|
Bearbeiten/Sperren hängt an der noch nicht getroffenen Abrechnungs-/
|
||||||
v1"), Account-Verwaltung durch den Betreiber (Ebene 5 zeigt Accounts
|
Freischaltungs-Architektur, siehe Offene Punkte: `account.verified`
|
||||||
nur lesend an — Bearbeiten/Sperren hängt an der noch nicht getroffenen
|
wurde beim Produktwechsel sogar entfernt).
|
||||||
Abrechnungs-/Freischaltungs-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
|
||||||
@@ -332,6 +338,49 @@ nicht anzulegen. Noch nicht gebaut (kein Abo-System).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
Anspruch (2026-08-29 explizit vom Nutzer bestätigt, da Deklarix ein
|
||||||
|
buchbarer Service werden soll): **für Browser UND Mobile optimiert,
|
||||||
|
und jede Entität im Datenmodell muss über das Frontend steuerbar sein**
|
||||||
|
— eine reine Anzeige-Seite ohne CRUD ist ein Defizit, kein v1-Kompromiss.
|
||||||
|
Stack bleibt `html/template` + htmx, kein React/Vue/SPA (siehe
|
||||||
|
Baureihenfolge/Architektur-Entscheidungen) — die responsive Optik wird
|
||||||
|
rein mit CSS erreicht, ohne das Template-Modell zu ändern.
|
||||||
|
|
||||||
|
**Navigation** (`internal/web/templates/layout.html`, `"nav"`-Block,
|
||||||
|
einmal definiert und von jeder Seite eingebunden — Änderungen hier
|
||||||
|
wirken auf das ganze Produkt): ein Checkbox-Hack (versteckte Checkbox +
|
||||||
|
`<label for=...>` als Klick-Ziel) klappt auf dem Handy ein Menü auf,
|
||||||
|
ganz ohne JavaScript. **Nicht** das naheliegendere `<details>`/`<summary>`
|
||||||
|
verwenden — aktuelle Chromium-Versionen steuern dessen Auf-/Zuklapp-
|
||||||
|
Zustand über eine interne, mit CSS (auch nicht mit `!important`)
|
||||||
|
nicht überschreibbare Animation; ein Versuch, `.app-nav { display:
|
||||||
|
flex !important }` bei geschlossenem `<details>` zu erzwingen, bleibt
|
||||||
|
wirkungslos (mit Chromium-Headless-Screenshot verifiziert). Ab
|
||||||
|
960px Breite (`@media (min-width: 960px)`, `app.css`) wird exakt
|
||||||
|
dieselbe Markup-Struktur per CSS zu einer permanenten, dunklen
|
||||||
|
Seitenleiste (Farb-Tokens aus `design/enterprise.css` übernommen) —
|
||||||
|
kein separates Desktop-Template.
|
||||||
|
|
||||||
|
**Tabellen** (`table`/`th`/`td` in `app.css`) sind seit dieser Änderung
|
||||||
|
gestylt (vorher komplett ungestylt, betraf v. a. `register_liste.html`);
|
||||||
|
breite Tabellen stehen in einem `.table-scroll`-Wrapper (horizontales
|
||||||
|
Scrollen auf dem Handy statt gequetschter Spalten).
|
||||||
|
|
||||||
|
**Verifikationsmethode für CSS-Änderungen:** Live-Cookie-Auth per
|
||||||
|
Chromium-Headless/CDP ist im Sandbox-Environment nicht möglich (kein
|
||||||
|
websocket-Python-Modul) — stattdessen: Seite per `curl -b cookies.txt`
|
||||||
|
authentifiziert abrufen, `/static/...`-Pfade im HTML auf eine absolute
|
||||||
|
`http://localhost:<port>/static/...`-URL umschreiben (Assets brauchen
|
||||||
|
kein Auth-Cookie), dann `chromium --headless --disable-gpu --no-sandbox
|
||||||
|
--window-size=<W>,<H> --screenshot=out.png file:///pfad/zur/seite.html`
|
||||||
|
bei mobiler (z. B. 390×844) und Desktop-Breite (z. B. 1440×900)
|
||||||
|
screenshotten. Für einen Menü-Aufklapp-Zustand: `checked`-Attribut
|
||||||
|
manuell ins gespeicherte HTML einfügen vor dem Screenshot.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Projektstruktur
|
## Projektstruktur
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -346,7 +395,10 @@ nicht anzulegen. Noch nicht gebaut (kein Abo-System).
|
|||||||
│ └── web/ # Handler, Templates
|
│ └── web/ # Handler, Templates
|
||||||
├── rules/ # YAML-Regelwerk + OPEN.md (offene fachliche Fragen)
|
├── rules/ # YAML-Regelwerk + OPEN.md (offene fachliche Fragen)
|
||||||
├── design/
|
├── design/
|
||||||
│ └── enterprise.css # Design-System (enconf-Basis, ungenutzt seit Pivot)
|
│ └── enterprise.css # enconf Ant-Design-Theme — Referenz für Design-Tokens
|
||||||
|
│ (Farben/Radius/Schatten), nicht direkt eingebunden
|
||||||
|
│ (Ant-Design-Klassennamen passen nicht zu Deklarix'
|
||||||
|
│ reinem HTML/htmx-Stack, siehe internal/web/static/app.css)
|
||||||
├── packaging/
|
├── packaging/
|
||||||
│ └── DEBIAN/
|
│ └── DEBIAN/
|
||||||
│ └── control.tmpl # .deb Package-Control-Template
|
│ └── control.tmpl # .deb Package-Control-Template
|
||||||
|
|||||||
@@ -72,9 +72,14 @@ func (s *Server) handleBetreiberWerkzeugListe(w http.ResponseWriter, r *http.Req
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// werkzeugFormData bedient sowohl das Betreiber-Formular (zentraler
|
||||||
|
// Katalog, ActionBase "/betreiber/werkzeuge") als auch das Mandanten-
|
||||||
|
// Formular für eigene Katalogeinträge (ActionBase "/verwaltung/werkzeuge")
|
||||||
|
// — dieselbe Template-Datei, siehe betreiber_werkzeug_form.html.
|
||||||
type werkzeugFormData struct {
|
type werkzeugFormData struct {
|
||||||
Title string
|
Title string
|
||||||
Nav navData
|
Nav navData
|
||||||
|
ActionBase string
|
||||||
ID string // leer bei "neu"
|
ID string // leer bei "neu"
|
||||||
Name string
|
Name string
|
||||||
Anbieter string
|
Anbieter string
|
||||||
@@ -94,16 +99,16 @@ type werkzeugFormData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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), Verarbeitungsorte: gueltigeVerarbeitungsorte}
|
data := werkzeugFormData{Title: "Werkzeug anlegen", Nav: navFor(r), ActionBase: "/betreiber/werkzeuge", Verarbeitungsorte: gueltigeVerarbeitungsorte}
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// werkzeugInputFromForm liest ein WerkzeugInput aus dem Formular.
|
// werkzeugInputFromForm liest ein WerkzeugInput aus dem Formular.
|
||||||
// AccountID ist immer nil — diese Seiten pflegen ausschließlich den
|
// accountID ist nil für den zentralen Katalog (Betreiber) oder gesetzt
|
||||||
// zentralen Katalog.
|
// für einen mandantenspezifischen Eintrag (Ebene 4).
|
||||||
func werkzeugInputFromForm(r *http.Request) (store.WerkzeugInput, string) {
|
func werkzeugInputFromForm(r *http.Request, accountID *string) (store.WerkzeugInput, string) {
|
||||||
letztePruefung, err := time.Parse("2006-01-02", r.FormValue("letzte_pruefung"))
|
letztePruefung, err := time.Parse("2006-01-02", r.FormValue("letzte_pruefung"))
|
||||||
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"
|
||||||
@@ -121,7 +126,7 @@ func werkzeugInputFromForm(r *http.Request) (store.WerkzeugInput, string) {
|
|||||||
aufbewahrungTage, _ := strconv.Atoi(r.FormValue("aufbewahrung_tage"))
|
aufbewahrungTage, _ := strconv.Atoi(r.FormValue("aufbewahrung_tage"))
|
||||||
|
|
||||||
return store.WerkzeugInput{
|
return store.WerkzeugInput{
|
||||||
AccountID: nil,
|
AccountID: accountID,
|
||||||
Name: name,
|
Name: name,
|
||||||
Anbieter: anbieter,
|
Anbieter: anbieter,
|
||||||
Verarbeitungsort: verarbeitungsort,
|
Verarbeitungsort: verarbeitungsort,
|
||||||
@@ -141,9 +146,9 @@ func werkzeugInputFromForm(r *http.Request) (store.WerkzeugInput, string) {
|
|||||||
// formDataFromForm baut die Formular-Daten für eine erneute Anzeige bei
|
// formDataFromForm baut die Formular-Daten für eine erneute Anzeige bei
|
||||||
// einem Validierungsfehler — die Eingaben des Nutzers bleiben erhalten,
|
// einem Validierungsfehler — die Eingaben des Nutzers bleiben erhalten,
|
||||||
// statt das Formular zu leeren.
|
// statt das Formular zu leeren.
|
||||||
func werkzeugFormDataFromRequest(r *http.Request, title, id, errMsg string) werkzeugFormData {
|
func werkzeugFormDataFromRequest(r *http.Request, title, actionBase, id, errMsg string) werkzeugFormData {
|
||||||
return werkzeugFormData{
|
return werkzeugFormData{
|
||||||
Title: title, Nav: navFor(r), ID: id, Verarbeitungsorte: gueltigeVerarbeitungsorte,
|
Title: title, Nav: navFor(r), ActionBase: actionBase, ID: id, Verarbeitungsorte: gueltigeVerarbeitungsorte,
|
||||||
Name: r.FormValue("name"), Anbieter: r.FormValue("anbieter"), Verarbeitungsort: r.FormValue("verarbeitungsort"),
|
Name: r.FormValue("name"), Anbieter: r.FormValue("anbieter"), Verarbeitungsort: r.FormValue("verarbeitungsort"),
|
||||||
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",
|
||||||
@@ -159,16 +164,16 @@ func (s *Server) handleBetreiberWerkzeugCreate(w http.ResponseWriter, r *http.Re
|
|||||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
in, errMsg := werkzeugInputFromForm(r)
|
in, errMsg := werkzeugInputFromForm(r, nil)
|
||||||
if errMsg != "" {
|
if errMsg != "" {
|
||||||
data := werkzeugFormDataFromRequest(r, "Werkzeug anlegen", "", errMsg)
|
data := werkzeugFormDataFromRequest(r, "Werkzeug anlegen", "/betreiber/werkzeuge", "", errMsg)
|
||||||
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)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := s.store.CreateWerkzeug(r.Context(), in); err != nil {
|
if _, err := s.store.CreateWerkzeug(r.Context(), in); err != nil {
|
||||||
data := werkzeugFormDataFromRequest(r, "Werkzeug anlegen", "", "Werkzeug konnte nicht angelegt werden: "+err.Error())
|
data := werkzeugFormDataFromRequest(r, "Werkzeug anlegen", "/betreiber/werkzeuge", "", "Werkzeug konnte nicht angelegt werden: "+err.Error())
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -188,7 +193,7 @@ func (s *Server) handleBetreiberWerkzeugEditForm(w http.ResponseWriter, r *http.
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
data := werkzeugFormData{
|
data := werkzeugFormData{
|
||||||
Title: "Werkzeug bearbeiten", Nav: navFor(r), ID: wz.ID, Verarbeitungsorte: gueltigeVerarbeitungsorte,
|
Title: "Werkzeug bearbeiten", Nav: navFor(r), ActionBase: "/betreiber/werkzeuge", ID: wz.ID, Verarbeitungsorte: gueltigeVerarbeitungsorte,
|
||||||
Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungsort: wz.Verarbeitungsort,
|
Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungsort: wz.Verarbeitungsort,
|
||||||
AVVVerfuegbar: wz.AVVVerfuegbar, AVVURL: wz.AVVURL,
|
AVVVerfuegbar: wz.AVVVerfuegbar, AVVURL: wz.AVVURL,
|
||||||
TrainingOptOut: wz.TrainingOptOut, TrainingStandard: wz.TrainingStandard, AufbewahrungTage: wz.AufbewahrungTage,
|
TrainingOptOut: wz.TrainingOptOut, TrainingStandard: wz.TrainingStandard, AufbewahrungTage: wz.AufbewahrungTage,
|
||||||
@@ -216,16 +221,16 @@ func (s *Server) handleBetreiberWerkzeugUpdate(w http.ResponseWriter, r *http.Re
|
|||||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
in, errMsg := werkzeugInputFromForm(r)
|
in, errMsg := werkzeugInputFromForm(r, nil)
|
||||||
if errMsg != "" {
|
if errMsg != "" {
|
||||||
data := werkzeugFormDataFromRequest(r, "Werkzeug bearbeiten", id, errMsg)
|
data := werkzeugFormDataFromRequest(r, "Werkzeug bearbeiten", "/betreiber/werkzeuge", id, errMsg)
|
||||||
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)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := s.store.UpdateWerkzeug(r.Context(), id, in); err != nil {
|
if _, err := s.store.UpdateWerkzeug(r.Context(), id, in); err != nil {
|
||||||
data := werkzeugFormDataFromRequest(r, "Werkzeug bearbeiten", id, "Werkzeug konnte nicht gespeichert werden: "+err.Error())
|
data := werkzeugFormDataFromRequest(r, "Werkzeug bearbeiten", "/betreiber/werkzeuge", id, "Werkzeug konnte nicht gespeichert werden: "+err.Error())
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
103
internal/web/mandant_eigenes_werkzeug_test.go
Normal file
103
internal/web/mandant_eigenes_werkzeug_test.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAdminCanCreateEditAndDeleteEigenesWerkzeug(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||||
|
|
||||||
|
resp := postForm(t, s, adminCookie, "/verwaltung/werkzeuge", werkzeugForm())
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("create status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var id string
|
||||||
|
admin, err := fs.GetUserByEmail(context.Background(), "admin@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
for wid, w := range fs.werkzeuge {
|
||||||
|
if w.AccountID != nil && *w.AccountID == admin.AccountID {
|
||||||
|
id = wid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if id == "" {
|
||||||
|
t.Fatal("expected an eigenes werkzeug to exist")
|
||||||
|
}
|
||||||
|
|
||||||
|
list := getWithCookie(t, s, adminCookie, "/verwaltung/werkzeuge")
|
||||||
|
if !strings.Contains(list.Body.String(), "ChatGPT Enterprise") {
|
||||||
|
t.Errorf("expected the eigenes werkzeug in the list, got: %s", list.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
editForm := getWithCookie(t, s, adminCookie, "/verwaltung/werkzeuge/"+id)
|
||||||
|
if editForm.Code != http.StatusOK || !strings.Contains(editForm.Body.String(), "ChatGPT Enterprise") {
|
||||||
|
t.Fatalf("edit form status = %d, body: %s", editForm.Code, editForm.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
updateForm := werkzeugForm()
|
||||||
|
updateForm.Set("name", "ChatGPT Enterprise (eigene Version)")
|
||||||
|
updateResp := postForm(t, s, adminCookie, "/verwaltung/werkzeuge/"+id, updateForm)
|
||||||
|
if updateResp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("update status = %d, body: %s", updateResp.Code, updateResp.Body.String())
|
||||||
|
}
|
||||||
|
if fs.werkzeuge[id].Name != "ChatGPT Enterprise (eigene Version)" {
|
||||||
|
t.Errorf("Name = %q nach Update, unerwartet", fs.werkzeuge[id].Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteResp := postForm(t, s, adminCookie, "/verwaltung/werkzeuge/"+id+"/loeschen", url.Values{})
|
||||||
|
if deleteResp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("delete status = %d, body: %s", deleteResp.Code, deleteResp.Body.String())
|
||||||
|
}
|
||||||
|
if _, ok := fs.werkzeuge[id]; ok {
|
||||||
|
t.Error("expected the eigenes werkzeug to be deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminCannotEditZentralesWerkzeugUeberMandantenRoute(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||||
|
fs.werkzeuge["werkzeug-central"] = store.Werkzeug{ID: "werkzeug-central", Name: "ChatGPT Enterprise", Anbieter: "OpenAI"}
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, adminCookie, "/verwaltung/werkzeuge/werkzeug-central")
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404 for a central werkzeug via the tenant route", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminCannotEditFremdesEigenesWerkzeug(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
adminA := seedAccountWithRole(t, fs, "Mandant A", "admin-a@example.com", "admin")
|
||||||
|
postForm(t, s, adminA, "/verwaltung/werkzeuge", werkzeugForm())
|
||||||
|
adminAUser, err := fs.GetUserByEmail(context.Background(), "admin-a@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
var id string
|
||||||
|
for wid, w := range fs.werkzeuge {
|
||||||
|
if w.AccountID != nil && *w.AccountID == adminAUser.AccountID {
|
||||||
|
id = wid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
adminB := seedAccountWithRole(t, fs, "Mandant B", "admin-b@example.com", "admin")
|
||||||
|
resp := getWithCookie(t, s, adminB, "/verwaltung/werkzeuge/"+id)
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404 for a foreign account's eigenes werkzeug", resp.Code)
|
||||||
|
}
|
||||||
|
deleteResp := postForm(t, s, adminB, "/verwaltung/werkzeuge/"+id+"/loeschen", url.Values{})
|
||||||
|
if deleteResp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("delete status = %d, want 404 for a foreign account's eigenes werkzeug", deleteResp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,17 @@
|
|||||||
// Ebene 4 — "eigene Werkzeug-Freigaben/-Sperrungen" (siehe CLAUDE.md,
|
// Ebene 4 — "eigene Werkzeug-Freigaben/-Sperrungen" (siehe CLAUDE.md,
|
||||||
// Fünf Ebenen): ein Mandant kann einen zentralen Katalogeintrag für
|
// Fünf Ebenen): ein Mandant kann einen zentralen Katalogeintrag für
|
||||||
// sich sperren, ohne den zentralen Katalog selbst zu verändern (das
|
// sich sperren, ohne den zentralen Katalog selbst zu verändern (das
|
||||||
// bleibt Sache der Plattform, siehe betreiber_werkzeug_handlers.go).
|
// bleibt Sache der Plattform, siehe betreiber_werkzeug_handlers.go),
|
||||||
|
// UND eigene, mandantenspezifische Katalogeinträge pflegen
|
||||||
|
// (`werkzeug.account_id` = der eigene Account) — dieselbe
|
||||||
|
// Formular-Vorlage wie der Betreiber nutzt (werkzeugFormData,
|
||||||
|
// betreiber-werkzeug-form.html, ActionBase unterscheidet die Ziel-URL).
|
||||||
package web
|
package web
|
||||||
|
|
||||||
import "net/http"
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
type mandantWerkzeugItem struct {
|
type mandantWerkzeugItem struct {
|
||||||
ID string
|
ID string
|
||||||
@@ -13,14 +20,23 @@ type mandantWerkzeugItem struct {
|
|||||||
Gesperrt bool
|
Gesperrt bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type eigenesWerkzeugItem struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Anbieter string
|
||||||
|
}
|
||||||
|
|
||||||
type mandantWerkzeugListeData struct {
|
type mandantWerkzeugListeData struct {
|
||||||
Title string
|
Title string
|
||||||
Nav navData
|
Nav navData
|
||||||
Werkzeuge []mandantWerkzeugItem
|
Werkzeuge []mandantWerkzeugItem
|
||||||
|
EigeneWerkzeuge []eigenesWerkzeugItem
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleMandantWerkzeugListe zeigt den zentralen Katalog aus Sicht des
|
// handleMandantWerkzeugListe zeigt den zentralen Katalog aus Sicht des
|
||||||
// eigenen Mandanten mit Sperren/Entsperren-Aktion je Eintrag.
|
// eigenen Mandanten mit Sperren/Entsperren-Aktion je Eintrag, sowie
|
||||||
|
// die eigenen (mandantenspezifischen) Katalogeinträge mit Bearbeiten/
|
||||||
|
// Löschen.
|
||||||
func (s *Server) handleMandantWerkzeugListe(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleMandantWerkzeugListe(w http.ResponseWriter, r *http.Request) {
|
||||||
accountID := currentUser(r).AccountID
|
accountID := currentUser(r).AccountID
|
||||||
zentral, err := s.store.ListZentraleWerkzeuge(r.Context())
|
zentral, err := s.store.ListZentraleWerkzeuge(r.Context())
|
||||||
@@ -33,6 +49,11 @@ func (s *Server) handleMandantWerkzeugListe(w http.ResponseWriter, r *http.Reque
|
|||||||
http.Error(w, "Sperrungen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "Sperrungen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
eigene, err := s.store.ListWerkzeugeForAccount(r.Context(), accountID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Werkzeuge konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
gesperrt := make(map[string]bool, len(sperren))
|
gesperrt := make(map[string]bool, len(sperren))
|
||||||
for _, sp := range sperren {
|
for _, sp := range sperren {
|
||||||
gesperrt[sp.WerkzeugID] = true
|
gesperrt[sp.WerkzeugID] = true
|
||||||
@@ -44,11 +65,124 @@ func (s *Server) handleMandantWerkzeugListe(w http.ResponseWriter, r *http.Reque
|
|||||||
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Gesperrt: gesperrt[wz.ID],
|
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, 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})
|
||||||
|
}
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleMandantWerkzeugNeuForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data := werkzeugFormData{Title: "Eigenes Werkzeug anlegen", Nav: navFor(r), ActionBase: "/verwaltung/werkzeuge", Verarbeitungsorte: gueltigeVerarbeitungsorte}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMandantWerkzeugCreate legt einen mandantenspezifischen
|
||||||
|
// Katalogeintrag an (`werkzeug.account_id` = eigener Account) — sichtbar
|
||||||
|
// nur für diesen Mandanten, verändert den zentralen Katalog nicht.
|
||||||
|
func (s *Server) handleMandantWerkzeugCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
accountID := currentUser(r).AccountID
|
||||||
|
in, errMsg := werkzeugInputFromForm(r, &accountID)
|
||||||
|
if errMsg != "" {
|
||||||
|
data := werkzeugFormDataFromRequest(r, "Eigenes Werkzeug anlegen", "/verwaltung/werkzeuge", "", errMsg)
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.store.CreateWerkzeug(r.Context(), in); err != nil {
|
||||||
|
data := werkzeugFormDataFromRequest(r, "Eigenes Werkzeug anlegen", "/verwaltung/werkzeuge", "", "Werkzeug konnte nicht angelegt werden: "+err.Error())
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/verwaltung/werkzeuge", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMandantWerkzeugEditForm zeigt einen eigenen Katalogeintrag zum
|
||||||
|
// Bearbeiten. Ein zentraler oder fremder Eintrag ist hier nicht
|
||||||
|
// erreichbar (404) — das bleibt Sache der Plattform bzw. des jeweils
|
||||||
|
// anderen Mandanten.
|
||||||
|
func (s *Server) handleMandantWerkzeugEditForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
wz, err := s.store.GetWerkzeug(r.Context(), r.PathValue("id"))
|
||||||
|
accountID := currentUser(r).AccountID
|
||||||
|
if err != nil || wz.AccountID == nil || *wz.AccountID != accountID {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMandantWerkzeugUpdate speichert Änderungen an einem eigenen
|
||||||
|
// Katalogeintrag.
|
||||||
|
func (s *Server) handleMandantWerkzeugUpdate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
accountID := currentUser(r).AccountID
|
||||||
|
wz, err := s.store.GetWerkzeug(r.Context(), id)
|
||||||
|
if err != nil || wz.AccountID == nil || *wz.AccountID != accountID {
|
||||||
|
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in, errMsg := werkzeugInputFromForm(r, &accountID)
|
||||||
|
if errMsg != "" {
|
||||||
|
data := werkzeugFormDataFromRequest(r, "Eigenes Werkzeug bearbeiten", "/verwaltung/werkzeuge", id, errMsg)
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.store.UpdateWerkzeug(r.Context(), id, in); err != nil {
|
||||||
|
data := werkzeugFormDataFromRequest(r, "Eigenes Werkzeug bearbeiten", "/verwaltung/werkzeuge", id, "Werkzeug konnte nicht gespeichert werden: "+err.Error())
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/verwaltung/werkzeuge", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMandantWerkzeugDelete entfernt einen eigenen Katalogeintrag.
|
||||||
|
func (s *Server) handleMandantWerkzeugDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
accountID := currentUser(r).AccountID
|
||||||
|
wz, err := s.store.GetWerkzeug(r.Context(), id)
|
||||||
|
if err != nil || wz.AccountID == nil || *wz.AccountID != accountID {
|
||||||
|
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.DeleteWerkzeug(r.Context(), id); err != nil {
|
||||||
|
http.Error(w, "Werkzeug konnte nicht gelöscht werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/verwaltung/werkzeuge", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
// handleMandantWerkzeugSperren sperrt einen zentralen Katalogeintrag
|
// handleMandantWerkzeugSperren sperrt einen zentralen Katalogeintrag
|
||||||
// für den eigenen Mandanten — der zentrale Eintrag selbst bleibt
|
// für den eigenen Mandanten — der zentrale Eintrag selbst bleibt
|
||||||
// unverändert, er verschwindet nur aus ListWerkzeugeForAccount für
|
// unverändert, er verschwindet nur aus ListWerkzeugeForAccount für
|
||||||
|
|||||||
@@ -136,6 +136,11 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
|
|||||||
mux.HandleFunc("GET /verwaltung/werkzeuge", s.requireAdmin(s.handleMandantWerkzeugListe))
|
mux.HandleFunc("GET /verwaltung/werkzeuge", s.requireAdmin(s.handleMandantWerkzeugListe))
|
||||||
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}/sperren", s.requireAdmin(s.handleMandantWerkzeugSperren))
|
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}/sperren", s.requireAdmin(s.handleMandantWerkzeugSperren))
|
||||||
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}/entsperren", s.requireAdmin(s.handleMandantWerkzeugEntsperren))
|
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}/entsperren", s.requireAdmin(s.handleMandantWerkzeugEntsperren))
|
||||||
|
mux.HandleFunc("GET /verwaltung/werkzeuge/neu", s.requireAdmin(s.handleMandantWerkzeugNeuForm))
|
||||||
|
mux.HandleFunc("POST /verwaltung/werkzeuge", s.requireAdmin(s.handleMandantWerkzeugCreate))
|
||||||
|
mux.HandleFunc("GET /verwaltung/werkzeuge/{id}", s.requireAdmin(s.handleMandantWerkzeugEditForm))
|
||||||
|
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}", s.requireAdmin(s.handleMandantWerkzeugUpdate))
|
||||||
|
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}/loeschen", s.requireAdmin(s.handleMandantWerkzeugDelete))
|
||||||
mux.HandleFunc("GET /betreiber", s.requireBetreiber(s.handleBetreiberDashboard))
|
mux.HandleFunc("GET /betreiber", s.requireBetreiber(s.handleBetreiberDashboard))
|
||||||
mux.HandleFunc("GET /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountList))
|
mux.HandleFunc("GET /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountList))
|
||||||
mux.HandleFunc("GET /betreiber/accounts/{id}", s.requireBetreiber(s.handleBetreiberAccountDetail))
|
mux.HandleFunc("GET /betreiber/accounts/{id}", s.requireBetreiber(s.handleBetreiberAccountDetail))
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
/* Deklarix — eigenes, schlankes Stylesheet auf Basis der Design-Tokens
|
/* Deklarix — eigenes, schlankes Stylesheet auf Basis der Design-Tokens
|
||||||
aus dem enconf Enterprise Light Theme (design/enterprise.css): gleiche
|
aus dem enconf Enterprise Light Theme (design/enterprise.css): gleiche
|
||||||
Marke (Primärblau #1677ff, Inter, Radius-Skala), aber ohne dessen
|
Marke (Primärblau #1677ff, Inter, Radius-Skala). Mobile-first:
|
||||||
Ant-Design-/Desktop-Sidebar-Layout, das für Deklarix nicht passt.
|
Basis-Stile gelten fürs Telefon (Nav als aufklappbares <details>-Menü
|
||||||
Mobile-first: Basis-Stile gelten fürs Telefon, @media (min-width)
|
ohne JavaScript), @media (min-width: 960px) verwandelt dieselbe
|
||||||
erweitert für größere Bildschirme. */
|
Nav-Markup-Struktur per CSS in eine feste Seitenleiste (siehe
|
||||||
|
.topbar/.app-nav) — kein separates Desktop-Template nötig. */
|
||||||
|
|
||||||
@import url('/static/inter.css');
|
@import url('/static/inter.css');
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@
|
|||||||
--radius-lg: 10px;
|
--radius-lg: 10px;
|
||||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
|
--shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||||
|
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
|
||||||
--color-bg: #f8fafc;
|
--color-bg: #f8fafc;
|
||||||
--color-text: #334155;
|
--color-text: #334155;
|
||||||
@@ -27,6 +29,12 @@
|
|||||||
--color-mittel-bg: #fffbeb;
|
--color-mittel-bg: #fffbeb;
|
||||||
--color-niedrig: #166534;
|
--color-niedrig: #166534;
|
||||||
--color-niedrig-bg: #f0fdf4;
|
--color-niedrig-bg: #f0fdf4;
|
||||||
|
|
||||||
|
--sidebar-width: 220px;
|
||||||
|
--sidebar-bg: linear-gradient(180deg, #0b1426 0%, #101d33 50%, #0d1829 100%);
|
||||||
|
--sidebar-text: #b6c2d9;
|
||||||
|
--sidebar-text-hover: #ffffff;
|
||||||
|
--sidebar-hover-bg: rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -74,23 +82,109 @@ a {
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
nav {
|
/* ── Navigation ──────────────────────────────────────────────────────
|
||||||
|
Mobil: .topbar ist eine schmale, fixierte Kopfleiste mit einem
|
||||||
|
aufklappbaren <details>-Menü (siehe layout.html) — kein JavaScript,
|
||||||
|
das <details>-Element liefert Auf-/Zuklappen nativ. Ab Desktop-Breite
|
||||||
|
(siehe Media Query unten) wird dieselbe Markup-Struktur per CSS in
|
||||||
|
eine permanent sichtbare, dunkle Seitenleiste verwandelt. */
|
||||||
|
.topbar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: space-between;
|
||||||
gap: 16px;
|
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
nav a {
|
.brand {
|
||||||
color: var(--color-muted);
|
font-weight: 600;
|
||||||
|
color: var(--color-heading);
|
||||||
|
text-decoration: none;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkbox-Hack: eine unsichtbare Checkbox plus ein <label for=...>
|
||||||
|
als Klick-/Tap-Ziel steuern per CSS-Geschwister-Selektor, ob .app-nav
|
||||||
|
sichtbar ist — funktioniert ohne JavaScript und ohne die Falle von
|
||||||
|
<details> (siehe Kommentar in layout.html). */
|
||||||
|
.nav-toggle {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-toggle-label {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-toggle:focus-visible ~ .nav-toggle-label {
|
||||||
|
outline: 2px solid var(--branding-primary);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-toggle:checked ~ .nav-toggle-label {
|
||||||
|
border-color: var(--branding-primary);
|
||||||
|
color: var(--branding-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
right: 16px;
|
||||||
|
z-index: 20;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 220px;
|
||||||
|
padding: 8px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-toggle:checked ~ .app-nav {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav a {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 500;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
nav a:hover {
|
.app-nav a:hover {
|
||||||
color: var(--color-text);
|
background: var(--color-bg);
|
||||||
|
color: var(--branding-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav form {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav button {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-hoch);
|
||||||
|
border: 1px solid var(--color-hoch-bg);
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Formulare: großzügige Touch-Ziele (min. 44px Höhe), volle Breite auf
|
/* Formulare: großzügige Touch-Ziele (min. 44px Höhe), volle Breite auf
|
||||||
@@ -275,7 +369,12 @@ nav button {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.beitraege-liste li a {
|
.beitraege-liste li a,
|
||||||
|
/* Einträge ohne umschließenden Link (Zeilen mit Badge/Aktions-Buttons
|
||||||
|
statt Navigation, z. B. Nutzer-/Abteilungsliste) bekommen dieselbe
|
||||||
|
Karte direkt auf dem <li> — :has() unterscheidet beide Fälle, damit
|
||||||
|
ein verlinkter Eintrag nicht doppelt (li UND a) gerahmt wird. */
|
||||||
|
.beitraege-liste > li:not(:has(> a)) {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -288,6 +387,15 @@ nav button {
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.beitraege-liste > li:not(:has(> a)) form {
|
||||||
|
margin: 0;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beitraege-liste > li:not(:has(> a)) button {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.beteiligte {
|
.beteiligte {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0 0 16px;
|
margin: 0 0 16px;
|
||||||
@@ -424,7 +532,45 @@ button.entfernen {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ab hier mehr Platz (Tablet/Desktop) — der Container bekommt spürbaren
|
/* Tabellen (z. B. Register-Export-Ansicht) — bewusst horizontal
|
||||||
|
scrollbar in einem eigenen Wrapper statt schrumpfender Spalten: bei
|
||||||
|
neun Spalten wie im Register würde Schrumpfen nur unlesbaren Text
|
||||||
|
erzeugen, siehe .table-scroll in register_liste.html. */
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-heading);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:hover {
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ab hier mehr Platz (Tablet) — der Container bekommt spürbaren
|
||||||
Rand statt volle Breite, sonst bleibt alles identisch. */
|
Rand statt volle Breite, sonst bleibt alles identisch. */
|
||||||
@media (min-width: 640px) {
|
@media (min-width: 640px) {
|
||||||
.page {
|
.page {
|
||||||
@@ -435,3 +581,74 @@ button.entfernen {
|
|||||||
font-size: 1.75rem;
|
font-size: 1.75rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Ab Desktop-Breite: dieselbe .topbar/.app-nav-Markup wird zu einer
|
||||||
|
permanent sichtbaren, dunklen Seitenleiste — kein separates
|
||||||
|
Desktop-Template, nur CSS. Der `details`/`summary`-Aufklapp-
|
||||||
|
Mechanismus wird hier überschrieben (Inhalt immer sichtbar,
|
||||||
|
summary versteckt), das Handy-Verhalten bleibt darunter unverändert. */
|
||||||
|
@media (min-width: 960px) {
|
||||||
|
.topbar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: var(--sidebar-width);
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 20px 16px;
|
||||||
|
background: var(--sidebar-bg);
|
||||||
|
border-bottom: none;
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-toggle-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav {
|
||||||
|
display: flex;
|
||||||
|
position: static;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav a {
|
||||||
|
color: var(--sidebar-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav a:hover {
|
||||||
|
background: var(--sidebar-hover-bg);
|
||||||
|
color: var(--sidebar-text-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav button {
|
||||||
|
color: var(--sidebar-text);
|
||||||
|
border-color: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav button:hover {
|
||||||
|
background: var(--sidebar-hover-bg);
|
||||||
|
color: var(--sidebar-text-hover);
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
margin-left: var(--sidebar-width);
|
||||||
|
max-width: 1100px;
|
||||||
|
padding: 32px 40px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<li>
|
<li>
|
||||||
{{.Name}}
|
{{.Name}}
|
||||||
<form method="post" action="/verwaltung/abteilungen/{{.ID}}/loeschen" style="display:inline" onsubmit="return confirm('Abteilung wirklich löschen?')">
|
<form method="post" action="/verwaltung/abteilungen/{{.ID}}/loeschen" style="display:inline" onsubmit="return confirm('Abteilung wirklich löschen?')">
|
||||||
<button type="submit">Löschen</button>
|
<button type="submit" class="entfernen">Löschen</button>
|
||||||
</form>
|
</form>
|
||||||
</li>
|
</li>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<body>
|
<body>
|
||||||
{{template "nav" .Nav}}
|
{{template "nav" .Nav}}
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<p><a href="/betreiber/werkzeuge">← 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, Verarbeitungsort, Training-Opt-out,
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
Pflicht.
|
Pflicht.
|
||||||
</p>
|
</p>
|
||||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||||
<form method="post" action="{{if .ID}}/betreiber/werkzeuge/{{.ID}}{{else}}/betreiber/werkzeuge{{end}}">
|
<form method="post" action="{{if .ID}}{{.ActionBase}}/{{.ID}}{{else}}{{.ActionBase}}{{end}}">
|
||||||
<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>
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
</form>
|
</form>
|
||||||
{{if .ID}}
|
{{if .ID}}
|
||||||
<form method="post" action="/betreiber/werkzeuge/{{.ID}}/loeschen" onsubmit="return confirm('Werkzeug wirklich löschen?')">
|
<form method="post" action="/betreiber/werkzeuge/{{.ID}}/loeschen" onsubmit="return confirm('Werkzeug wirklich löschen?')">
|
||||||
<button type="submit">Werkzeug löschen</button>
|
<button type="submit" class="entfernen">Werkzeug löschen</button>
|
||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,8 +6,22 @@
|
|||||||
<script src="/static/htmx.min.js"></script>
|
<script src="/static/htmx.min.js"></script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{/* Eine einzige Nav-Definition für alle Seiten (siehe navData in
|
||||||
|
middleware.go) — auf dem Handy ein aufklappbares Menü (Checkbox-
|
||||||
|
Hack, kein JavaScript: das <details>-Element scheitert daran, dass
|
||||||
|
aktuelle Chromium-Versionen dessen Auf-/Zuklapp-Zustand über eine
|
||||||
|
interne, nicht per CSS überschreibbare Animation steuern), ab
|
||||||
|
Desktop-Breite per CSS in eine permanent sichtbare Seitenleiste
|
||||||
|
verwandelt. Dieselbe Markup-Struktur bedient beide Layouts, siehe
|
||||||
|
app.css .app-nav. Die Checkbox ist bei jedem Seitenaufruf frisch
|
||||||
|
unchecked (serverseitig gerendert, kein Zustand über Navigation
|
||||||
|
hinweg nötig). */}}
|
||||||
{{define "nav"}}
|
{{define "nav"}}
|
||||||
<nav>
|
<header class="topbar">
|
||||||
|
<a class="brand" href="/">Deklarix</a>
|
||||||
|
<input type="checkbox" id="nav-toggle" class="nav-toggle">
|
||||||
|
<label for="nav-toggle" class="nav-toggle-label" aria-label="Menü öffnen">Menü</label>
|
||||||
|
<nav class="app-nav">
|
||||||
<a href="/">Start</a>
|
<a href="/">Start</a>
|
||||||
{{if not .IsBetreiber}}<a href="/antraege">Meine Anträge</a>{{end}}
|
{{if not .IsBetreiber}}<a href="/antraege">Meine Anträge</a>{{end}}
|
||||||
{{if .IsFachebene}}<a href="/faelle">Posteingang</a>{{end}}
|
{{if .IsFachebene}}<a href="/faelle">Posteingang</a>{{end}}
|
||||||
@@ -17,10 +31,11 @@
|
|||||||
{{if .IsAdmin}}<a href="/verwaltung/abteilungen">Abteilungen</a>{{end}}
|
{{if .IsAdmin}}<a href="/verwaltung/abteilungen">Abteilungen</a>{{end}}
|
||||||
{{if .IsAdmin}}<a href="/verwaltung/werkzeuge">Werkzeugkatalog</a>{{end}}
|
{{if .IsAdmin}}<a href="/verwaltung/werkzeuge">Werkzeugkatalog</a>{{end}}
|
||||||
{{if .IsBetreiber}}<a href="/betreiber">Plattform</a>{{end}}
|
{{if .IsBetreiber}}<a href="/betreiber">Plattform</a>{{end}}
|
||||||
<form method="post" action="/logout" style="display:inline">
|
<form method="post" action="/logout">
|
||||||
<button type="submit">Abmelden</button>
|
<button type="submit">Abmelden</button>
|
||||||
</form>
|
</form>
|
||||||
</nav>
|
</nav>
|
||||||
|
</header>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{/* bewertung-block rendert eine Bewertung (Datenklasse, KI-VO-
|
{{/* bewertung-block rendert eine Bewertung (Datenklasse, KI-VO-
|
||||||
|
|||||||
@@ -5,10 +5,12 @@
|
|||||||
{{template "nav" .Nav}}
|
{{template "nav" .Nav}}
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<h1>Werkzeugkatalog</h1>
|
<h1>Werkzeugkatalog</h1>
|
||||||
|
|
||||||
|
<h2>Zentraler Katalog</h2>
|
||||||
<p class="hinweis">
|
<p class="hinweis">
|
||||||
Der zentrale Katalog wird von der Plattform gepflegt. Ein gesperrtes
|
Wird von der Plattform gepflegt. Ein gesperrtes Werkzeug bleibt im
|
||||||
Werkzeug bleibt im zentralen Katalog unverändert — es wird nur für
|
zentralen Katalog unverändert — es wird nur für diesen Mandanten aus
|
||||||
diesen Mandanten aus der Werkzeugsuche ausgeblendet.
|
der Werkzeugsuche ausgeblendet.
|
||||||
</p>
|
</p>
|
||||||
{{if not .Werkzeuge}}
|
{{if not .Werkzeuge}}
|
||||||
<p class="hinweis">Der zentrale Katalog ist noch leer.</p>
|
<p class="hinweis">Der zentrale Katalog ist noch leer.</p>
|
||||||
@@ -32,6 +34,24 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</ul>
|
</ul>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
<h2>Eigene Werkzeuge</h2>
|
||||||
|
<p class="hinweis">
|
||||||
|
Zusätzliche Katalogeinträge nur für diesen Mandanten — sichtbar nur
|
||||||
|
hier, nicht im zentralen Katalog der Plattform.
|
||||||
|
</p>
|
||||||
|
{{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>
|
||||||
|
{{end}}
|
||||||
|
<p><a href="/verwaltung/werkzeuge/neu">Eigenes Werkzeug anlegen</a></p>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<span class="status status-published">aktiv</span>
|
<span class="status status-published">aktiv</span>
|
||||||
{{if not .IstSelbst}}
|
{{if not .IstSelbst}}
|
||||||
<form method="post" action="/verwaltung/nutzer/{{.ID}}/deaktivieren" style="display:inline">
|
<form method="post" action="/verwaltung/nutzer/{{.ID}}/deaktivieren" style="display:inline">
|
||||||
<button type="submit">Deaktivieren</button>
|
<button type="submit" class="entfernen">Deaktivieren</button>
|
||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{else}}
|
{{else}}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
{{if not .Eintraege}}
|
{{if not .Eintraege}}
|
||||||
<p class="hinweis">Noch keine Genehmigungen.</p>
|
<p class="hinweis">Noch keine Genehmigungen.</p>
|
||||||
{{else}}
|
{{else}}
|
||||||
|
<div class="table-scroll">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -31,6 +32,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user