diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d94969b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,77 @@
+### Go
+# Binaries for programs and plugins
+*.exe
+*.exe~
+*.dll
+*.so
+*.dylib
+
+# Test binary, built with `go test -c`
+*.test
+
+# Output of the go coverage tool
+*.out
+
+# Go workspace file
+go.work.sum
+
+# env file
+.env
+
+### Node
+# Dependencies
+node_modules/
+
+# Logs
+*.log
+
+# Runtime data
+*.pid
+*.pid.lock
+
+# Coverage
+coverage/
+*.lcov
+.nyc_output
+
+# Build output
+dist/
+build/Release
+
+# TypeScript cache
+*.tsbuildinfo
+
+# Framework build output and caches
+.cache
+.parcel-cache
+.next
+out/
+.nuxt
+
+# dotenv environment variable files
+.env
+.env.local
+.env.*.local
+
+# npm cache directory
+.npm
+*.tgz
+
+# yarn v2
+.yarn/cache
+.yarn/unplugged
+.yarn/install-state.gz
+.pnp.*
+
+### Windows
+# Windows thumbnail cache files
+Thumbs.db
+
+# Folder config file
+[Dd]esktop.ini
+
+# Recycle Bin used on file shares
+$RECYCLE.BIN/
+
+# Windows shortcuts
+*.lnk
\ No newline at end of file
diff --git a/backend/.env b/backend/.env
index 4781d23..8b9a39d 100644
--- a/backend/.env
+++ b/backend/.env
@@ -2,6 +2,12 @@ PORT=8080
DATABASE_URL=postgres://postgres:Timmy0104199%3F@localhost:5432/teg?sslmode=disable
JWT_SECRET=tegvideo7010!
FRONTEND_ORIGIN=http://localhost:5173
+ADDRESS_SEARCH_PROVIDER=photon
+ADDRESS_SEARCH_URL=https://photon.komoot.io/api
+ADDRESS_SEARCH_LANGUAGE=de
+ADDRESS_SEARCH_COUNTRY_CODE=de
+ADDRESS_SEARCH_LIMIT=5
+ADDRESS_SEARCH_USER_AGENT=TEG-App/1.0
ADMIN_USERNAME=admin
ADMIN_DISPLAY_NAME=Administrator
diff --git a/backend/address_search.go b/backend/address_search.go
new file mode 100644
index 0000000..e295988
--- /dev/null
+++ b/backend/address_search.go
@@ -0,0 +1,420 @@
+// backend\address_search.go
+
+package main
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/url"
+ "os"
+ "strconv"
+ "strings"
+ "time"
+)
+
+type AddressSuggestion struct {
+ ID string `json:"id"`
+ Label string `json:"label"`
+ Lat float64 `json:"lat"`
+ Lon float64 `json:"lon"`
+ Type string `json:"type"`
+}
+
+type photonResponse struct {
+ Features []photonFeature `json:"features"`
+}
+
+type photonFeature struct {
+ Properties photonProperties `json:"properties"`
+ Geometry photonGeometry `json:"geometry"`
+}
+
+type photonProperties struct {
+ OSMID int64 `json:"osm_id"`
+ OSMType string `json:"osm_type"`
+ OSMValue string `json:"osm_value"`
+ Name string `json:"name"`
+ Street string `json:"street"`
+ HouseNumber string `json:"housenumber"`
+ Postcode string `json:"postcode"`
+ City string `json:"city"`
+ District string `json:"district"`
+ State string `json:"state"`
+ Country string `json:"country"`
+ CountryCode string `json:"countrycode"`
+}
+
+type photonGeometry struct {
+ Coordinates []float64 `json:"coordinates"`
+}
+
+type nominatimSearchResult struct {
+ PlaceID int64 `json:"place_id"`
+ DisplayName string `json:"display_name"`
+ Lat string `json:"lat"`
+ Lon string `json:"lon"`
+ Type string `json:"type"`
+}
+
+func (s *Server) handleAddressSearch(w http.ResponseWriter, r *http.Request) {
+ query := strings.TrimSpace(r.URL.Query().Get("q"))
+
+ if len([]rune(query)) < 3 {
+ writeJSON(w, http.StatusOK, map[string]any{
+ "suggestions": []AddressSuggestion{},
+ })
+ return
+ }
+
+ searchURL := strings.TrimSpace(os.Getenv("ADDRESS_SEARCH_URL"))
+ if searchURL == "" {
+ writeError(w, http.StatusNotImplemented, "Adresssuche ist nicht konfiguriert")
+ return
+ }
+
+ provider := strings.ToLower(strings.TrimSpace(os.Getenv("ADDRESS_SEARCH_PROVIDER")))
+ if provider == "" {
+ provider = detectAddressSearchProvider(searchURL)
+ }
+
+ switch provider {
+ case "photon":
+ s.handlePhotonAddressSearch(w, r, searchURL, query)
+ case "nominatim":
+ s.handleNominatimAddressSearch(w, r, searchURL, query)
+ default:
+ writeError(w, http.StatusInternalServerError, "Adresssuche-Provider ist unbekannt")
+ }
+}
+
+func (s *Server) handlePhotonAddressSearch(
+ w http.ResponseWriter,
+ r *http.Request,
+ searchURL string,
+ query string,
+) {
+ parsedURL, err := url.Parse(searchURL)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Adresssuche ist falsch konfiguriert")
+ return
+ }
+
+ limit := getAddressSearchLimit()
+ language := envOrDefault("ADDRESS_SEARCH_LANGUAGE", "de")
+ countryCode := strings.ToLower(strings.TrimSpace(envOrDefault("ADDRESS_SEARCH_COUNTRY_CODE", "de")))
+
+ values := parsedURL.Query()
+ values.Set("q", query)
+ values.Set("limit", strconv.Itoa(limit))
+
+ if language != "" {
+ values.Set("lang", language)
+ }
+
+ parsedURL.RawQuery = values.Encode()
+
+ request, err := http.NewRequestWithContext(r.Context(), http.MethodGet, parsedURL.String(), nil)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Adresssuche konnte nicht vorbereitet werden")
+ return
+ }
+
+ request.Header.Set("Accept", "application/json")
+ request.Header.Set("User-Agent", addressSearchUserAgent())
+
+ response, err := httpClient().Do(request)
+ if err != nil {
+ writeError(w, http.StatusBadGateway, "Adresssuche konnte nicht geladen werden")
+ return
+ }
+ defer response.Body.Close()
+
+ if response.StatusCode < 200 || response.StatusCode >= 300 {
+ writeError(w, http.StatusBadGateway, "Adresssuche konnte nicht geladen werden")
+ return
+ }
+
+ var result photonResponse
+
+ if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
+ writeError(w, http.StatusBadGateway, "Adresssuche konnte nicht gelesen werden")
+ return
+ }
+
+ suggestions := make([]AddressSuggestion, 0, len(result.Features))
+
+ for _, feature := range result.Features {
+ if len(feature.Geometry.Coordinates) < 2 {
+ continue
+ }
+
+ if countryCode != "" &&
+ feature.Properties.CountryCode != "" &&
+ strings.ToLower(feature.Properties.CountryCode) != countryCode {
+ continue
+ }
+
+ lon := feature.Geometry.Coordinates[0]
+ lat := feature.Geometry.Coordinates[1]
+
+ label := buildPhotonLabel(feature.Properties)
+ if label == "" {
+ continue
+ }
+
+ suggestions = append(suggestions, AddressSuggestion{
+ ID: buildPhotonID(feature.Properties, label),
+ Label: label,
+ Lat: lat,
+ Lon: lon,
+ Type: buildPhotonType(feature.Properties),
+ })
+ }
+
+ writeJSON(w, http.StatusOK, map[string]any{
+ "suggestions": suggestions,
+ })
+}
+
+func (s *Server) handleNominatimAddressSearch(
+ w http.ResponseWriter,
+ r *http.Request,
+ searchURL string,
+ query string,
+) {
+ parsedURL, err := url.Parse(searchURL)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Adresssuche ist falsch konfiguriert")
+ return
+ }
+
+ limit := getAddressSearchLimit()
+ countryCode := strings.ToLower(strings.TrimSpace(envOrDefault("ADDRESS_SEARCH_COUNTRY_CODE", "de")))
+
+ values := parsedURL.Query()
+ values.Set("q", query)
+ values.Set("format", "jsonv2")
+ values.Set("addressdetails", "1")
+ values.Set("limit", strconv.Itoa(limit))
+
+ if countryCode != "" && values.Get("countrycodes") == "" {
+ values.Set("countrycodes", countryCode)
+ }
+
+ parsedURL.RawQuery = values.Encode()
+
+ request, err := http.NewRequestWithContext(r.Context(), http.MethodGet, parsedURL.String(), nil)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Adresssuche konnte nicht vorbereitet werden")
+ return
+ }
+
+ request.Header.Set("Accept", "application/json")
+ request.Header.Set("User-Agent", addressSearchUserAgent())
+
+ response, err := httpClient().Do(request)
+ if err != nil {
+ writeError(w, http.StatusBadGateway, "Adresssuche konnte nicht geladen werden")
+ return
+ }
+ defer response.Body.Close()
+
+ if response.StatusCode < 200 || response.StatusCode >= 300 {
+ writeError(w, http.StatusBadGateway, "Adresssuche konnte nicht geladen werden")
+ return
+ }
+
+ var results []nominatimSearchResult
+
+ if err := json.NewDecoder(response.Body).Decode(&results); err != nil {
+ writeError(w, http.StatusBadGateway, "Adresssuche konnte nicht gelesen werden")
+ return
+ }
+
+ suggestions := make([]AddressSuggestion, 0, len(results))
+
+ for _, result := range results {
+ label := strings.TrimSpace(result.DisplayName)
+ if label == "" {
+ continue
+ }
+
+ lat, latErr := strconv.ParseFloat(result.Lat, 64)
+ lon, lonErr := strconv.ParseFloat(result.Lon, 64)
+
+ if latErr != nil || lonErr != nil {
+ continue
+ }
+
+ suggestions = append(suggestions, AddressSuggestion{
+ ID: strconv.FormatInt(result.PlaceID, 10),
+ Label: label,
+ Lat: lat,
+ Lon: lon,
+ Type: result.Type,
+ })
+ }
+
+ writeJSON(w, http.StatusOK, map[string]any{
+ "suggestions": suggestions,
+ })
+}
+
+func buildPhotonLabel(properties photonProperties) string {
+ parts := []string{}
+
+ streetLine := strings.TrimSpace(
+ strings.Join(
+ []string{
+ strings.TrimSpace(properties.Street),
+ strings.TrimSpace(properties.HouseNumber),
+ },
+ " ",
+ ),
+ )
+
+ if streetLine != "" {
+ parts = append(parts, streetLine)
+ } else if strings.TrimSpace(properties.Name) != "" {
+ parts = append(parts, strings.TrimSpace(properties.Name))
+ }
+
+ cityLine := strings.TrimSpace(
+ strings.Join(
+ []string{
+ strings.TrimSpace(properties.Postcode),
+ firstNonEmpty(properties.City, properties.District),
+ },
+ " ",
+ ),
+ )
+
+ if cityLine != "" {
+ parts = append(parts, cityLine)
+ }
+
+ if strings.TrimSpace(properties.State) != "" {
+ parts = append(parts, strings.TrimSpace(properties.State))
+ }
+
+ if strings.TrimSpace(properties.Country) != "" {
+ parts = append(parts, strings.TrimSpace(properties.Country))
+ }
+
+ return strings.Join(uniqueNonEmpty(parts), ", ")
+}
+
+func buildPhotonID(properties photonProperties, fallback string) string {
+ if properties.OSMID != 0 {
+ if properties.OSMType != "" {
+ return properties.OSMType + ":" + strconv.FormatInt(properties.OSMID, 10)
+ }
+
+ return strconv.FormatInt(properties.OSMID, 10)
+ }
+
+ return fallback
+}
+
+func buildPhotonType(properties photonProperties) string {
+ if strings.TrimSpace(properties.OSMValue) != "" {
+ return strings.TrimSpace(properties.OSMValue)
+ }
+
+ if strings.TrimSpace(properties.OSMType) != "" {
+ return strings.TrimSpace(properties.OSMType)
+ }
+
+ return "Adresse"
+}
+
+func detectAddressSearchProvider(searchURL string) string {
+ lowerURL := strings.ToLower(searchURL)
+
+ if strings.Contains(lowerURL, "photon") {
+ return "photon"
+ }
+
+ if strings.Contains(lowerURL, "nominatim") {
+ return "nominatim"
+ }
+
+ return "photon"
+}
+
+func getAddressSearchLimit() int {
+ rawLimit := strings.TrimSpace(os.Getenv("ADDRESS_SEARCH_LIMIT"))
+ if rawLimit == "" {
+ return 5
+ }
+
+ limit, err := strconv.Atoi(rawLimit)
+ if err != nil {
+ return 5
+ }
+
+ if limit < 1 {
+ return 1
+ }
+
+ if limit > 10 {
+ return 10
+ }
+
+ return limit
+}
+
+func addressSearchUserAgent() string {
+ return envOrDefault("ADDRESS_SEARCH_USER_AGENT", "TEG-App/1.0")
+}
+
+func httpClient() *http.Client {
+ return &http.Client{
+ Timeout: 8 * time.Second,
+ }
+}
+
+func firstNonEmpty(values ...string) string {
+ for _, value := range values {
+ value = strings.TrimSpace(value)
+
+ if value != "" {
+ return value
+ }
+ }
+
+ return ""
+}
+
+func uniqueNonEmpty(values []string) []string {
+ seen := map[string]bool{}
+ result := []string{}
+
+ for _, value := range values {
+ value = strings.TrimSpace(value)
+
+ if value == "" {
+ continue
+ }
+
+ key := strings.ToLower(value)
+ if seen[key] {
+ continue
+ }
+
+ seen[key] = true
+ result = append(result, value)
+ }
+
+ return result
+}
+
+func envOrDefault(key string, fallback string) string {
+ value := strings.TrimSpace(os.Getenv(key))
+
+ if value == "" {
+ return fallback
+ }
+
+ return value
+}
diff --git a/backend/admin.go b/backend/admin.go
new file mode 100644
index 0000000..d5b02a8
--- /dev/null
+++ b/backend/admin.go
@@ -0,0 +1,557 @@
+// backend/admin.go
+
+package main
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgconn"
+ "golang.org/x/crypto/bcrypt"
+)
+
+type AdminTeam struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Initial string `json:"initial"`
+ Href string `json:"href"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+}
+
+type AdminUser struct {
+ ID string `json:"id"`
+ Username string `json:"username"`
+ DisplayName string `json:"displayName"`
+ Email string `json:"email"`
+ Avatar string `json:"avatar"`
+ Unit string `json:"unit"`
+ Group string `json:"group"`
+ Rights []string `json:"rights"`
+ Teams []AdminTeam `json:"teams"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+}
+
+type AdminUserRequest struct {
+ Username string `json:"username"`
+ DisplayName string `json:"displayName"`
+ Email string `json:"email"`
+ Password string `json:"password"`
+ Avatar string `json:"avatar"`
+ Unit string `json:"unit"`
+ Group string `json:"group"`
+ Rights []string `json:"rights"`
+ TeamIDs []string `json:"teamIds"`
+}
+
+type AdminTeamRequest struct {
+ Name string `json:"name"`
+ Initial string `json:"initial"`
+ Href string `json:"href"`
+}
+
+func normalizeAdminUserInput(input *AdminUserRequest) {
+ input.Username = strings.TrimSpace(input.Username)
+ input.DisplayName = strings.TrimSpace(input.DisplayName)
+ input.Email = strings.TrimSpace(input.Email)
+ input.Password = strings.TrimSpace(input.Password)
+ input.Avatar = strings.TrimSpace(input.Avatar)
+ input.Unit = strings.TrimSpace(input.Unit)
+ input.Group = strings.TrimSpace(input.Group)
+
+ if input.Group == "" {
+ input.Group = "user"
+ }
+
+ input.Rights = cleanStringList(input.Rights)
+ input.TeamIDs = cleanStringList(input.TeamIDs)
+}
+
+func normalizeAdminTeamInput(input *AdminTeamRequest) {
+ input.Name = strings.TrimSpace(input.Name)
+ input.Initial = strings.TrimSpace(input.Initial)
+ input.Href = strings.TrimSpace(input.Href)
+
+ if input.Initial == "" && input.Name != "" {
+ input.Initial = strings.ToUpper(string([]rune(input.Name)[0]))
+ }
+
+ if input.Href == "" {
+ input.Href = "#"
+ }
+}
+
+func cleanStringList(values []string) []string {
+ result := []string{}
+ seen := map[string]bool{}
+
+ for _, value := range values {
+ value = strings.TrimSpace(value)
+ if value == "" || seen[value] {
+ continue
+ }
+
+ seen[value] = true
+ result = append(result, value)
+ }
+
+ return result
+}
+
+func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
+ rows, err := s.db.Query(
+ r.Context(),
+ `
+ SELECT
+ id::TEXT,
+ COALESCE(username, ''),
+ COALESCE(display_name, ''),
+ email,
+ COALESCE(avatar, ''),
+ COALESCE(unit, ''),
+ COALESCE(user_group, ''),
+ rights,
+ created_at,
+ updated_at
+ FROM users
+ ORDER BY display_name ASC, email ASC
+ `,
+ )
+
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Benutzer konnten nicht geladen werden")
+ return
+ }
+ defer rows.Close()
+
+ users := []AdminUser{}
+ userByID := map[string]*AdminUser{}
+
+ for rows.Next() {
+ var user AdminUser
+
+ if err := rows.Scan(
+ &user.ID,
+ &user.Username,
+ &user.DisplayName,
+ &user.Email,
+ &user.Avatar,
+ &user.Unit,
+ &user.Group,
+ &user.Rights,
+ &user.CreatedAt,
+ &user.UpdatedAt,
+ ); err != nil {
+ writeError(w, http.StatusInternalServerError, "Benutzer konnten nicht gelesen werden")
+ return
+ }
+
+ user.Teams = []AdminTeam{}
+ users = append(users, user)
+ userByID[user.ID] = &users[len(users)-1]
+ }
+
+ teamRows, err := s.db.Query(
+ r.Context(),
+ `
+ SELECT
+ ut.user_id::TEXT,
+ t.id::TEXT,
+ t.name,
+ t.initial,
+ t.href,
+ t.created_at,
+ t.updated_at
+ FROM user_teams ut
+ JOIN teams t ON t.id = ut.team_id
+ ORDER BY t.name ASC
+ `,
+ )
+
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Team-Zuordnungen konnten nicht geladen werden")
+ return
+ }
+ defer teamRows.Close()
+
+ for teamRows.Next() {
+ var userID string
+ var team AdminTeam
+
+ if err := teamRows.Scan(
+ &userID,
+ &team.ID,
+ &team.Name,
+ &team.Initial,
+ &team.Href,
+ &team.CreatedAt,
+ &team.UpdatedAt,
+ ); err != nil {
+ writeError(w, http.StatusInternalServerError, "Team-Zuordnungen konnten nicht gelesen werden")
+ return
+ }
+
+ if user := userByID[userID]; user != nil {
+ user.Teams = append(user.Teams, team)
+ }
+ }
+
+ writeJSON(w, http.StatusOK, map[string]any{
+ "users": users,
+ })
+}
+
+func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
+ var input AdminUserRequest
+
+ if err := readJSON(r, &input); err != nil {
+ writeError(w, http.StatusBadRequest, "Invalid JSON")
+ return
+ }
+
+ normalizeAdminUserInput(&input)
+
+ if input.Email == "" {
+ writeError(w, http.StatusBadRequest, "E-Mail ist erforderlich")
+ return
+ }
+
+ if input.Password == "" {
+ writeError(w, http.StatusBadRequest, "Passwort ist erforderlich")
+ return
+ }
+
+ passwordHash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Passwort konnte nicht verarbeitet werden")
+ return
+ }
+
+ tx, err := s.db.Begin(r.Context())
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Benutzer konnte nicht erstellt werden")
+ return
+ }
+ defer tx.Rollback(r.Context())
+
+ var userID string
+
+ err = tx.QueryRow(
+ r.Context(),
+ `
+ INSERT INTO users (
+ username,
+ display_name,
+ email,
+ password_hash,
+ avatar,
+ unit,
+ user_group,
+ rights
+ )
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
+ RETURNING id::TEXT
+ `,
+ input.Username,
+ input.DisplayName,
+ input.Email,
+ string(passwordHash),
+ input.Avatar,
+ input.Unit,
+ input.Group,
+ input.Rights,
+ ).Scan(&userID)
+
+ if err != nil {
+ var pgErr *pgconn.PgError
+
+ if errors.As(err, &pgErr) && pgErr.Code == "23505" {
+ writeError(w, http.StatusConflict, "Benutzername oder E-Mail existiert bereits")
+ return
+ }
+
+ writeError(w, http.StatusInternalServerError, "Benutzer konnte nicht erstellt werden")
+ return
+ }
+
+ if err := replaceUserTeams(r.Context(), tx, userID, input.TeamIDs); err != nil {
+ writeError(w, http.StatusInternalServerError, "Teams konnten nicht zugewiesen werden")
+ return
+ }
+
+ if err := tx.Commit(r.Context()); err != nil {
+ writeError(w, http.StatusInternalServerError, "Benutzer konnte nicht erstellt werden")
+ return
+ }
+
+ writeJSON(w, http.StatusCreated, map[string]any{
+ "ok": true,
+ })
+}
+
+func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
+ userID := strings.TrimSpace(r.PathValue("id"))
+
+ if userID == "" {
+ writeError(w, http.StatusBadRequest, "Benutzer-ID fehlt")
+ return
+ }
+
+ var input AdminUserRequest
+
+ if err := readJSON(r, &input); err != nil {
+ writeError(w, http.StatusBadRequest, "Invalid JSON")
+ return
+ }
+
+ normalizeAdminUserInput(&input)
+
+ if input.Email == "" {
+ writeError(w, http.StatusBadRequest, "E-Mail ist erforderlich")
+ return
+ }
+
+ tx, err := s.db.Begin(r.Context())
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Benutzer konnte nicht aktualisiert werden")
+ return
+ }
+ defer tx.Rollback(r.Context())
+
+ if input.Password != "" {
+ passwordHash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Passwort konnte nicht verarbeitet werden")
+ return
+ }
+
+ _, err = tx.Exec(
+ r.Context(),
+ `
+ UPDATE users
+ SET
+ username = $2,
+ display_name = $3,
+ email = $4,
+ password_hash = $5,
+ avatar = $6,
+ unit = $7,
+ user_group = $8,
+ rights = $9,
+ updated_at = now()
+ WHERE id = $1
+ `,
+ userID,
+ input.Username,
+ input.DisplayName,
+ input.Email,
+ string(passwordHash),
+ input.Avatar,
+ input.Unit,
+ input.Group,
+ input.Rights,
+ )
+
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Benutzer konnte nicht aktualisiert werden")
+ return
+ }
+ } else {
+ _, err = tx.Exec(
+ r.Context(),
+ `
+ UPDATE users
+ SET
+ username = $2,
+ display_name = $3,
+ email = $4,
+ avatar = $5,
+ unit = $6,
+ user_group = $7,
+ rights = $8,
+ updated_at = now()
+ WHERE id = $1
+ `,
+ userID,
+ input.Username,
+ input.DisplayName,
+ input.Email,
+ input.Avatar,
+ input.Unit,
+ input.Group,
+ input.Rights,
+ )
+
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Benutzer konnte nicht aktualisiert werden")
+ return
+ }
+ }
+
+ if err := replaceUserTeams(r.Context(), tx, userID, input.TeamIDs); err != nil {
+ writeError(w, http.StatusInternalServerError, "Teams konnten nicht zugewiesen werden")
+ return
+ }
+
+ if err := tx.Commit(r.Context()); err != nil {
+ writeError(w, http.StatusInternalServerError, "Benutzer konnte nicht aktualisiert werden")
+ return
+ }
+
+ writeJSON(w, http.StatusOK, map[string]any{
+ "ok": true,
+ })
+}
+
+func replaceUserTeams(ctx context.Context, tx pgx.Tx, userID string, teamIDs []string) error {
+ if _, err := tx.Exec(ctx, `DELETE FROM user_teams WHERE user_id = $1`, userID); err != nil {
+ return err
+ }
+
+ for _, teamID := range teamIDs {
+ if _, err := tx.Exec(
+ ctx,
+ `
+ INSERT INTO user_teams (user_id, team_id)
+ VALUES ($1, $2)
+ ON CONFLICT (user_id, team_id) DO NOTHING
+ `,
+ userID,
+ teamID,
+ ); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func (s *Server) handleAdminListTeams(w http.ResponseWriter, r *http.Request) {
+ rows, err := s.db.Query(
+ r.Context(),
+ `
+ SELECT id::TEXT, name, initial, href, created_at, updated_at
+ FROM teams
+ ORDER BY name ASC
+ `,
+ )
+
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Teams konnten nicht geladen werden")
+ return
+ }
+ defer rows.Close()
+
+ teams := []AdminTeam{}
+
+ for rows.Next() {
+ var team AdminTeam
+
+ if err := rows.Scan(
+ &team.ID,
+ &team.Name,
+ &team.Initial,
+ &team.Href,
+ &team.CreatedAt,
+ &team.UpdatedAt,
+ ); err != nil {
+ writeError(w, http.StatusInternalServerError, "Teams konnten nicht gelesen werden")
+ return
+ }
+
+ teams = append(teams, team)
+ }
+
+ writeJSON(w, http.StatusOK, map[string]any{
+ "teams": teams,
+ })
+}
+
+func (s *Server) handleAdminCreateTeam(w http.ResponseWriter, r *http.Request) {
+ var input AdminTeamRequest
+
+ if err := readJSON(r, &input); err != nil {
+ writeError(w, http.StatusBadRequest, "Invalid JSON")
+ return
+ }
+
+ normalizeAdminTeamInput(&input)
+
+ if input.Name == "" {
+ writeError(w, http.StatusBadRequest, "Teamname ist erforderlich")
+ return
+ }
+
+ _, err := s.db.Exec(
+ r.Context(),
+ `
+ INSERT INTO teams (name, initial, href)
+ VALUES ($1, $2, $3)
+ `,
+ input.Name,
+ input.Initial,
+ input.Href,
+ )
+
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Team konnte nicht erstellt werden")
+ return
+ }
+
+ writeJSON(w, http.StatusCreated, map[string]any{
+ "ok": true,
+ })
+}
+
+func (s *Server) handleAdminUpdateTeam(w http.ResponseWriter, r *http.Request) {
+ teamID := strings.TrimSpace(r.PathValue("id"))
+
+ if teamID == "" {
+ writeError(w, http.StatusBadRequest, "Team-ID fehlt")
+ return
+ }
+
+ var input AdminTeamRequest
+
+ if err := readJSON(r, &input); err != nil {
+ writeError(w, http.StatusBadRequest, "Invalid JSON")
+ return
+ }
+
+ normalizeAdminTeamInput(&input)
+
+ if input.Name == "" {
+ writeError(w, http.StatusBadRequest, "Teamname ist erforderlich")
+ return
+ }
+
+ _, err := s.db.Exec(
+ r.Context(),
+ `
+ UPDATE teams
+ SET name = $2,
+ initial = $3,
+ href = $4,
+ updated_at = now()
+ WHERE id = $1
+ `,
+ teamID,
+ input.Name,
+ input.Initial,
+ input.Href,
+ )
+
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Team konnte nicht aktualisiert werden")
+ return
+ }
+
+ writeJSON(w, http.StatusOK, map[string]any{
+ "ok": true,
+ })
+}
diff --git a/backend/admin_milestone.go b/backend/admin_milestone.go
new file mode 100644
index 0000000..ca3d3bb
--- /dev/null
+++ b/backend/admin_milestone.go
@@ -0,0 +1,643 @@
+// backend\admin_milestone.go
+
+package main
+
+import (
+ "context"
+ "crypto/tls"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "net/url"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+)
+
+type milestoneSettingsResponse struct {
+ Host string `json:"host"`
+ Username string `json:"username"`
+ PasswordConfigured bool `json:"passwordConfigured"`
+ SkipTLSVerify bool `json:"skipTlsVerify"`
+
+ TokenConfigured bool `json:"tokenConfigured"`
+ TokenType string `json:"tokenType"`
+ TokenExpiresAt *time.Time `json:"tokenExpiresAt,omitempty"`
+ TokenUpdatedAt *time.Time `json:"tokenUpdatedAt,omitempty"`
+
+ ServerID string `json:"serverId"`
+ ServerName string `json:"serverName"`
+ ServerVersion string `json:"serverVersion"`
+ ServerUpdatedAt *time.Time `json:"serverUpdatedAt,omitempty"`
+
+ UpdatedAt *time.Time `json:"updatedAt,omitempty"`
+}
+
+type updateMilestoneSettingsRequest struct {
+ Host string `json:"host"`
+ Username string `json:"username"`
+ Password string `json:"password"`
+ SkipTLSVerify bool `json:"skipTlsVerify"`
+}
+
+type milestoneCredentials struct {
+ Host string
+ Username string
+ Password string
+ SkipTLSVerify bool
+}
+
+type milestoneTokenResponse struct {
+ AccessToken string `json:"access_token"`
+ TokenType string `json:"token_type"`
+ ExpiresIn int `json:"expires_in"`
+}
+
+type milestoneRecordingServersResponse struct {
+ Array []milestoneRecordingServer `json:"array"`
+}
+
+type milestoneRecordingServer struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ DisplayName string `json:"displayName"`
+ Version string `json:"version"`
+ ProductVersion string `json:"productVersion"`
+ RecordingServerVersion string `json:"recordingServerVersion"`
+}
+
+func (s *Server) handleGetMilestoneSettings(w http.ResponseWriter, r *http.Request) {
+ settings, err := s.getMilestoneSettings(r.Context())
+ if errors.Is(err, pgx.ErrNoRows) {
+ writeJSON(w, http.StatusOK, map[string]any{
+ "settings": milestoneSettingsResponse{
+ Host: "",
+ Username: "",
+ PasswordConfigured: false,
+ SkipTLSVerify: false,
+ TokenConfigured: false,
+ },
+ })
+ return
+ }
+
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Milestone-Einstellungen konnten nicht geladen werden")
+ return
+ }
+
+ writeJSON(w, http.StatusOK, map[string]any{
+ "settings": settings,
+ })
+}
+
+func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Request) {
+ var input updateMilestoneSettingsRequest
+
+ if err := readJSON(r, &input); err != nil {
+ writeError(w, http.StatusBadRequest, "Invalid JSON")
+ return
+ }
+
+ host := normalizeMilestoneHost(input.Host)
+ username := strings.TrimSpace(input.Username)
+ password := strings.TrimSpace(input.Password)
+ skipTLSVerify := input.SkipTLSVerify
+
+ if host == "" {
+ writeError(w, http.StatusBadRequest, "IP-Adresse oder Hostname ist erforderlich")
+ return
+ }
+
+ if username == "" {
+ writeError(w, http.StatusBadRequest, "Benutzername ist erforderlich")
+ return
+ }
+
+ currentSettings, err := s.getMilestoneSettings(r.Context())
+ passwordAlreadyConfigured := false
+
+ if err == nil {
+ passwordAlreadyConfigured = currentSettings.PasswordConfigured
+ } else if !errors.Is(err, pgx.ErrNoRows) {
+ writeError(w, http.StatusInternalServerError, "Milestone-Einstellungen konnten nicht geprüft werden")
+ return
+ }
+
+ if password == "" && !passwordAlreadyConfigured {
+ writeError(w, http.StatusBadRequest, "Kennwort ist erforderlich")
+ return
+ }
+
+ encryptionKey := s.milestoneEncryptionKey()
+
+ if password != "" {
+ _, err = s.db.Exec(
+ r.Context(),
+ `
+ INSERT INTO milestone_settings (
+ id,
+ host,
+ username,
+ password_encrypted,
+ skip_tls_verify,
+ access_token_encrypted,
+ token_type,
+ token_expires_at,
+ token_updated_at
+ )
+ VALUES (
+ 1,
+ $1,
+ $2,
+ pgp_sym_encrypt($3, $4),
+ $5,
+ NULL,
+ '',
+ NULL,
+ NULL
+ )
+ ON CONFLICT (id)
+ DO UPDATE SET
+ host = EXCLUDED.host,
+ username = EXCLUDED.username,
+ password_encrypted = EXCLUDED.password_encrypted,
+ skip_tls_verify = EXCLUDED.skip_tls_verify,
+ access_token_encrypted = NULL,
+ token_type = '',
+ token_expires_at = NULL,
+ token_updated_at = NULL,
+ updated_at = now()
+ `,
+ host,
+ username,
+ password,
+ encryptionKey,
+ skipTLSVerify,
+ )
+ } else {
+ _, err = s.db.Exec(
+ r.Context(),
+ `
+ UPDATE milestone_settings
+ SET
+ host = $1,
+ username = $2,
+ skip_tls_verify = $3,
+ access_token_encrypted = NULL,
+ token_type = '',
+ token_expires_at = NULL,
+ token_updated_at = NULL,
+ updated_at = now()
+ WHERE id = 1
+ `,
+ host,
+ username,
+ skipTLSVerify,
+ )
+ }
+
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Milestone-Einstellungen konnten nicht gespeichert werden")
+ return
+ }
+
+ syncWarning := ""
+
+ credentials, err := s.getMilestoneCredentials(r.Context())
+ if err == nil && credentials.Host != "" && credentials.Username != "" && credentials.Password != "" {
+ if err := s.refreshMilestoneTokenAndServerID(r.Context(), credentials); err != nil {
+ log.Printf("Milestone sync after settings save failed: %v", err)
+
+ syncWarning = "Milestone-Zugangsdaten wurden gespeichert, aber Token oder Server-ID konnten nicht abgerufen werden."
+ }
+ } else {
+ syncWarning = "Milestone-Zugangsdaten wurden gespeichert, konnten aber nicht vollständig geprüft werden."
+ }
+
+ settings, err := s.getMilestoneSettings(r.Context())
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Milestone-Einstellungen wurden gespeichert, konnten aber nicht neu geladen werden")
+ return
+ }
+
+ response := map[string]any{
+ "settings": settings,
+ }
+
+ if syncWarning != "" {
+ response["warning"] = syncWarning
+ }
+
+ writeJSON(w, http.StatusOK, response)
+}
+
+func (s *Server) handleFetchMilestoneToken(w http.ResponseWriter, r *http.Request) {
+ credentials, err := s.getMilestoneCredentials(r.Context())
+ if errors.Is(err, pgx.ErrNoRows) {
+ writeError(w, http.StatusBadRequest, "Milestone-Einstellungen sind noch nicht hinterlegt")
+ return
+ }
+
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Milestone-Zugangsdaten konnten nicht geladen werden")
+ return
+ }
+
+ if credentials.Host == "" || credentials.Username == "" || credentials.Password == "" {
+ writeError(w, http.StatusBadRequest, "Milestone-Zugangsdaten sind unvollständig")
+ return
+ }
+
+ if err := s.refreshMilestoneTokenAndServerID(r.Context(), credentials); err != nil {
+ log.Printf("Milestone token/server sync failed: %v", err)
+
+ writeError(w, http.StatusBadGateway, "Milestone-Token oder Server-ID konnten nicht abgerufen werden")
+ return
+ }
+
+ settings, err := s.getMilestoneSettings(r.Context())
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Milestone-Daten wurden gespeichert, konnten aber nicht neu geladen werden")
+ return
+ }
+
+ writeJSON(w, http.StatusOK, map[string]any{
+ "settings": settings,
+ })
+}
+
+func (s *Server) getMilestoneSettings(ctx context.Context) (milestoneSettingsResponse, error) {
+ var settings milestoneSettingsResponse
+ var updatedAt time.Time
+ var tokenExpiresAt *time.Time
+ var tokenUpdatedAt *time.Time
+ var serverUpdatedAt *time.Time
+
+ err := s.db.QueryRow(
+ ctx,
+ `
+ SELECT
+ host,
+ username,
+ password_encrypted IS NOT NULL,
+ skip_tls_verify,
+ access_token_encrypted IS NOT NULL,
+ token_type,
+ token_expires_at,
+ token_updated_at,
+ COALESCE(server_id, ''),
+ COALESCE(server_name, ''),
+ COALESCE(server_version, ''),
+ server_updated_at,
+ updated_at
+ FROM milestone_settings
+ WHERE id = 1
+ LIMIT 1
+ `,
+ ).Scan(
+ &settings.Host,
+ &settings.Username,
+ &settings.PasswordConfigured,
+ &settings.SkipTLSVerify,
+ &settings.TokenConfigured,
+ &settings.TokenType,
+ &tokenExpiresAt,
+ &tokenUpdatedAt,
+ &settings.ServerID,
+ &settings.ServerName,
+ &settings.ServerVersion,
+ &serverUpdatedAt,
+ &updatedAt,
+ )
+
+ if err != nil {
+ return settings, err
+ }
+
+ settings.TokenExpiresAt = tokenExpiresAt
+ settings.TokenUpdatedAt = tokenUpdatedAt
+ settings.ServerUpdatedAt = serverUpdatedAt
+ settings.UpdatedAt = &updatedAt
+
+ return settings, nil
+}
+
+func (s *Server) refreshMilestoneTokenAndServerID(
+ ctx context.Context,
+ credentials milestoneCredentials,
+) error {
+ tokenResponse, err := s.requestMilestoneToken(ctx, credentials)
+ if err != nil {
+ return err
+ }
+
+ accessToken := strings.TrimSpace(tokenResponse.AccessToken)
+ if accessToken == "" {
+ return errors.New("milestone returned empty access token")
+ }
+
+ tokenType := strings.TrimSpace(tokenResponse.TokenType)
+ if tokenType == "" {
+ tokenType = "Bearer"
+ }
+
+ expiresIn := tokenResponse.ExpiresIn
+ if expiresIn <= 0 {
+ expiresIn = 3600
+ }
+
+ tokenExpiresAt := time.Now().Add(time.Duration(expiresIn) * time.Second)
+
+ serverID, serverName, serverVersion, err := s.requestMilestoneServerID(
+ ctx,
+ credentials.Host,
+ tokenType,
+ accessToken,
+ credentials.SkipTLSVerify,
+ )
+
+ if err != nil {
+ return err
+ }
+
+ encryptionKey := s.milestoneEncryptionKey()
+
+ _, err = s.db.Exec(
+ ctx,
+ `
+ UPDATE milestone_settings
+ SET
+ access_token_encrypted = pgp_sym_encrypt($1, $2),
+ token_type = $3,
+ token_expires_at = $4,
+ token_updated_at = now(),
+ server_id = $5,
+ server_name = $6,
+ server_version = $7,
+ server_updated_at = now(),
+ updated_at = now()
+ WHERE id = 1
+ `,
+ accessToken,
+ encryptionKey,
+ tokenType,
+ tokenExpiresAt,
+ serverID,
+ serverName,
+ serverVersion,
+ )
+
+ return err
+}
+
+func (s *Server) requestMilestoneServerID(
+ ctx context.Context,
+ host string,
+ tokenType string,
+ accessToken string,
+ skipTLSVerify bool,
+) (string, string, string, error) {
+ requestURL := strings.TrimRight(host, "/") + "/API/rest/v1/recordingServers"
+
+ log.Printf(
+ "Requesting Milestone recording server id: url=%s skipTLSVerify=%t",
+ requestURL,
+ skipTLSVerify,
+ )
+
+ request, err := http.NewRequestWithContext(
+ ctx,
+ http.MethodGet,
+ requestURL,
+ nil,
+ )
+ if err != nil {
+ return "", "", "", err
+ }
+
+ request.Header.Set("Authorization", "Bearer "+accessToken)
+ request.Header.Set("Accept", "application/json")
+
+ client := milestoneHTTPClient(skipTLSVerify)
+
+ response, err := client.Do(request)
+ if err != nil {
+ return "", "", "", err
+ }
+ defer response.Body.Close()
+
+ body, err := io.ReadAll(response.Body)
+ if err != nil {
+ return "", "", "", err
+ }
+
+ if response.StatusCode < 200 || response.StatusCode >= 300 {
+ return "", "", "", fmt.Errorf(
+ "milestone recording servers request failed: url=%s status=%d body=%s",
+ requestURL,
+ response.StatusCode,
+ truncateMilestoneLogBody(body),
+ )
+ }
+
+ var recordingServersResponse milestoneRecordingServersResponse
+ if err := json.Unmarshal(body, &recordingServersResponse); err != nil {
+ return "", "", "", fmt.Errorf(
+ "milestone recording servers response could not be parsed: body=%s error=%w",
+ truncateMilestoneLogBody(body),
+ err,
+ )
+ }
+
+ if len(recordingServersResponse.Array) == 0 {
+ return "", "", "", fmt.Errorf(
+ "milestone returned no recording servers: body=%s",
+ truncateMilestoneLogBody(body),
+ )
+ }
+
+ recordingServer := recordingServersResponse.Array[0]
+
+ recordingServerID := strings.TrimSpace(recordingServer.ID)
+ recordingServerName := strings.TrimSpace(recordingServer.DisplayName)
+
+ if recordingServerName == "" {
+ recordingServerName = strings.TrimSpace(recordingServer.Name)
+ }
+
+ recordingServerVersion := strings.TrimSpace(recordingServer.Version)
+
+ if recordingServerVersion == "" {
+ recordingServerVersion = strings.TrimSpace(recordingServer.ProductVersion)
+ }
+
+ if recordingServerVersion == "" {
+ recordingServerVersion = strings.TrimSpace(recordingServer.RecordingServerVersion)
+ }
+
+ if recordingServerID == "" {
+ return "", "", "", fmt.Errorf(
+ "milestone returned recording server without id: body=%s",
+ truncateMilestoneLogBody(body),
+ )
+ }
+
+ log.Printf(
+ "Milestone recording server received successfully: id=%s displayName=%q version=%q",
+ recordingServerID,
+ recordingServerName,
+ recordingServerVersion,
+ )
+
+ return recordingServerID, recordingServerName, recordingServerVersion, nil
+}
+
+func (s *Server) getMilestoneCredentials(ctx context.Context) (milestoneCredentials, error) {
+ var credentials milestoneCredentials
+
+ err := s.db.QueryRow(
+ ctx,
+ `
+ SELECT
+ host,
+ username,
+ COALESCE(pgp_sym_decrypt(password_encrypted, $1), ''),
+ skip_tls_verify
+ FROM milestone_settings
+ WHERE id = 1
+ LIMIT 1
+ `,
+ s.milestoneEncryptionKey(),
+ ).Scan(
+ &credentials.Host,
+ &credentials.Username,
+ &credentials.Password,
+ &credentials.SkipTLSVerify,
+ )
+
+ return credentials, err
+}
+
+func milestoneHTTPClient(skipTLSVerify bool) *http.Client {
+ client := &http.Client{
+ Timeout: 15 * time.Second,
+ }
+
+ if skipTLSVerify {
+ client.Transport = &http.Transport{
+ TLSClientConfig: &tls.Config{
+ InsecureSkipVerify: true,
+ },
+ }
+ }
+
+ return client
+}
+
+func (s *Server) requestMilestoneToken(ctx context.Context, credentials milestoneCredentials) (milestoneTokenResponse, error) {
+ var tokenResponse milestoneTokenResponse
+
+ form := url.Values{}
+ form.Set("grant_type", "password")
+ form.Set("username", credentials.Username)
+ form.Set("password", credentials.Password)
+ form.Set("client_id", "GrantValidatorClient")
+
+ requestURL := strings.TrimRight(credentials.Host, "/") + "/API/IDP/connect/token"
+
+ log.Printf("Requesting Milestone token: url=%s username=%s", requestURL, credentials.Username)
+
+ request, err := http.NewRequestWithContext(
+ ctx,
+ http.MethodPost,
+ requestURL,
+ strings.NewReader(form.Encode()),
+ )
+ if err != nil {
+ return tokenResponse, err
+ }
+
+ request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ client := milestoneHTTPClient(credentials.SkipTLSVerify)
+
+ response, err := client.Do(request)
+ if err != nil {
+ return tokenResponse, err
+ }
+ defer response.Body.Close()
+
+ body, err := io.ReadAll(response.Body)
+ if err != nil {
+ return tokenResponse, err
+ }
+
+ if response.StatusCode < 200 || response.StatusCode >= 300 {
+ return tokenResponse, fmt.Errorf(
+ "milestone token request failed: url=%s status=%d body=%s",
+ requestURL,
+ response.StatusCode,
+ truncateMilestoneLogBody(body),
+ )
+ }
+
+ if err := json.Unmarshal(body, &tokenResponse); err != nil {
+ return tokenResponse, err
+ }
+
+ log.Printf(
+ "Milestone token received successfully: url=%s tokenType=%s expiresIn=%d hasAccessToken=%t",
+ requestURL,
+ tokenResponse.TokenType,
+ tokenResponse.ExpiresIn,
+ strings.TrimSpace(tokenResponse.AccessToken) != "",
+ )
+
+ return tokenResponse, nil
+}
+
+func normalizeMilestoneHost(value string) string {
+ value = strings.TrimSpace(value)
+ value = strings.TrimRight(value, "/")
+
+ if value == "" {
+ return ""
+ }
+
+ if strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") {
+ return value
+ }
+
+ return "https://" + value
+}
+
+func truncateMilestoneLogBody(body []byte) string {
+ value := strings.TrimSpace(string(body))
+
+ if value == "" {
+ return ""
+ }
+
+ const maxLength = 1000
+
+ if len(value) > maxLength {
+ return value[:maxLength] + "..."
+ }
+
+ return value
+}
+
+func (s *Server) milestoneEncryptionKey() string {
+ value := strings.TrimSpace(os.Getenv("MILESTONE_SETTINGS_KEY"))
+ if value != "" {
+ return value
+ }
+
+ return string(s.jwtSecret)
+}
diff --git a/backend/authz.go b/backend/authz.go
new file mode 100644
index 0000000..2171170
--- /dev/null
+++ b/backend/authz.go
@@ -0,0 +1,32 @@
+// backend/authz.go
+
+package main
+
+import "net/http"
+
+func userHasRight(user User, right string) bool {
+ for _, userRight := range user.Rights {
+ if userRight == "admin" || userRight == right {
+ return true
+ }
+ }
+
+ return false
+}
+
+func (s *Server) requireRight(right string, next http.HandlerFunc) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ user, ok := userFromContext(r.Context())
+ if !ok {
+ writeError(w, http.StatusUnauthorized, "Unauthorized")
+ return
+ }
+
+ if !userHasRight(user, right) {
+ writeError(w, http.StatusForbidden, "Keine Berechtigung")
+ return
+ }
+
+ next(w, r)
+ }
+}
diff --git a/backend/cors.go b/backend/cors.go
index 6253cd3..243e70f 100644
--- a/backend/cors.go
+++ b/backend/cors.go
@@ -11,8 +11,8 @@ func (s *Server) cors(next http.Handler) http.Handler {
if origin == s.frontendOrigin {
w.Header().Set("Access-Control-Allow-Origin", s.frontendOrigin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
- w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
- w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS")
+ w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
+ w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
}
if r.Method == http.MethodOptions {
diff --git a/backend/dashboard.go b/backend/dashboard.go
new file mode 100644
index 0000000..f7283ab
--- /dev/null
+++ b/backend/dashboard.go
@@ -0,0 +1,436 @@
+// backend/dashboard.go
+
+package main
+
+import (
+ "encoding/json"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+)
+
+type DashboardWidget struct {
+ ID string `json:"id"`
+ UserID string `json:"userId"`
+ Type string `json:"type"`
+ Title string `json:"title"`
+ X int `json:"x"`
+ Y int `json:"y"`
+ W int `json:"w"`
+ H int `json:"h"`
+ Config json.RawMessage `json:"config"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+}
+
+type DashboardWidgetRequest struct {
+ Type string `json:"type"`
+ Title string `json:"title"`
+ X int `json:"x"`
+ Y int `json:"y"`
+ W int `json:"w"`
+ H int `json:"h"`
+ Config json.RawMessage `json:"config"`
+}
+
+type DashboardOperationChange struct {
+ ID string `json:"id"`
+ OperationID string `json:"operationId"`
+ OperationNumber string `json:"operationNumber"`
+ OperationName string `json:"operationName"`
+ UserDisplayName string `json:"userDisplayName"`
+ Action string `json:"action"`
+ Changes []OperationHistoryChange `json:"changes"`
+ CreatedAt time.Time `json:"createdAt"`
+}
+
+func normalizeDashboardWidgetInput(input *DashboardWidgetRequest) {
+ input.Type = strings.TrimSpace(input.Type)
+ input.Title = strings.TrimSpace(input.Title)
+
+ if input.Type == "" {
+ input.Type = "note"
+ }
+
+ if input.Title == "" {
+ input.Title = "Widget"
+ }
+
+ if input.X < 0 {
+ input.X = 0
+ }
+
+ if input.Y < 0 {
+ input.Y = 0
+ }
+
+ if input.W < 2 {
+ input.W = 2
+ }
+
+ if input.W > 12 {
+ input.W = 12
+ }
+
+ if input.H < 1 {
+ input.H = 1
+ }
+
+ if input.H > 8 {
+ input.H = 8
+ }
+
+ if len(input.Config) == 0 || !json.Valid(input.Config) {
+ input.Config = json.RawMessage(`{}`)
+ }
+}
+
+func scanDashboardWidget(scan func(dest ...any) error) (DashboardWidget, error) {
+ var widget DashboardWidget
+ var configRaw []byte
+
+ err := scan(
+ &widget.ID,
+ &widget.UserID,
+ &widget.Type,
+ &widget.Title,
+ &widget.X,
+ &widget.Y,
+ &widget.W,
+ &widget.H,
+ &configRaw,
+ &widget.CreatedAt,
+ &widget.UpdatedAt,
+ )
+
+ if len(configRaw) == 0 {
+ configRaw = []byte(`{}`)
+ }
+
+ widget.Config = json.RawMessage(configRaw)
+
+ return widget, err
+}
+
+func (s *Server) handleListDashboardWidgets(w http.ResponseWriter, r *http.Request) {
+ user, ok := userFromContext(r.Context())
+ if !ok {
+ writeError(w, http.StatusUnauthorized, "Unauthorized")
+ return
+ }
+
+ rows, err := s.db.Query(
+ r.Context(),
+ `
+ SELECT
+ id::TEXT,
+ user_id::TEXT,
+ type,
+ title,
+ x,
+ y,
+ w,
+ h,
+ config,
+ created_at,
+ updated_at
+ FROM dashboard_widgets
+ WHERE user_id = $1
+ ORDER BY y ASC, x ASC, created_at ASC
+ `,
+ user.ID,
+ )
+
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Dashboard konnte nicht geladen werden")
+ return
+ }
+ defer rows.Close()
+
+ widgets := []DashboardWidget{}
+
+ for rows.Next() {
+ widget, err := scanDashboardWidget(rows.Scan)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Dashboard konnte nicht gelesen werden")
+ return
+ }
+
+ widgets = append(widgets, widget)
+ }
+
+ if err := rows.Err(); err != nil {
+ writeError(w, http.StatusInternalServerError, "Dashboard konnte nicht gelesen werden")
+ return
+ }
+
+ writeJSON(w, http.StatusOK, map[string]any{
+ "widgets": widgets,
+ })
+}
+
+func (s *Server) handleCreateDashboardWidget(w http.ResponseWriter, r *http.Request) {
+ user, ok := userFromContext(r.Context())
+ if !ok {
+ writeError(w, http.StatusUnauthorized, "Unauthorized")
+ return
+ }
+
+ var input DashboardWidgetRequest
+
+ if err := readJSON(r, &input); err != nil {
+ writeError(w, http.StatusBadRequest, "Invalid JSON")
+ return
+ }
+
+ normalizeDashboardWidgetInput(&input)
+
+ widget, err := scanDashboardWidget(func(dest ...any) error {
+ return s.db.QueryRow(
+ r.Context(),
+ `
+ INSERT INTO dashboard_widgets (
+ user_id,
+ type,
+ title,
+ x,
+ y,
+ w,
+ h,
+ config
+ )
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8::JSONB)
+ RETURNING
+ id::TEXT,
+ user_id::TEXT,
+ type,
+ title,
+ x,
+ y,
+ w,
+ h,
+ config,
+ created_at,
+ updated_at
+ `,
+ user.ID,
+ input.Type,
+ input.Title,
+ input.X,
+ input.Y,
+ input.W,
+ input.H,
+ string(input.Config),
+ ).Scan(dest...)
+ })
+
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Widget konnte nicht erstellt werden")
+ return
+ }
+
+ writeJSON(w, http.StatusCreated, map[string]any{
+ "widget": widget,
+ })
+}
+
+func (s *Server) handleUpdateDashboardWidget(w http.ResponseWriter, r *http.Request) {
+ user, ok := userFromContext(r.Context())
+ if !ok {
+ writeError(w, http.StatusUnauthorized, "Unauthorized")
+ return
+ }
+
+ widgetID := strings.TrimSpace(r.PathValue("id"))
+ if widgetID == "" {
+ writeError(w, http.StatusBadRequest, "Widget-ID fehlt")
+ return
+ }
+
+ var input DashboardWidgetRequest
+
+ if err := readJSON(r, &input); err != nil {
+ writeError(w, http.StatusBadRequest, "Invalid JSON")
+ return
+ }
+
+ normalizeDashboardWidgetInput(&input)
+
+ widget, err := scanDashboardWidget(func(dest ...any) error {
+ return s.db.QueryRow(
+ r.Context(),
+ `
+ UPDATE dashboard_widgets
+ SET
+ type = $3,
+ title = $4,
+ x = $5,
+ y = $6,
+ w = $7,
+ h = $8,
+ config = $9::JSONB,
+ updated_at = now()
+ WHERE id = $1
+ AND user_id = $2
+ RETURNING
+ id::TEXT,
+ user_id::TEXT,
+ type,
+ title,
+ x,
+ y,
+ w,
+ h,
+ config,
+ created_at,
+ updated_at
+ `,
+ widgetID,
+ user.ID,
+ input.Type,
+ input.Title,
+ input.X,
+ input.Y,
+ input.W,
+ input.H,
+ string(input.Config),
+ ).Scan(dest...)
+ })
+
+ if err != nil {
+ writeError(w, http.StatusNotFound, "Widget wurde nicht gefunden")
+ return
+ }
+
+ writeJSON(w, http.StatusOK, map[string]any{
+ "widget": widget,
+ })
+}
+
+func (s *Server) handleDeleteDashboardWidget(w http.ResponseWriter, r *http.Request) {
+ user, ok := userFromContext(r.Context())
+ if !ok {
+ writeError(w, http.StatusUnauthorized, "Unauthorized")
+ return
+ }
+
+ widgetID := strings.TrimSpace(r.PathValue("id"))
+ if widgetID == "" {
+ writeError(w, http.StatusBadRequest, "Widget-ID fehlt")
+ return
+ }
+
+ commandTag, err := s.db.Exec(
+ r.Context(),
+ `
+ DELETE FROM dashboard_widgets
+ WHERE id = $1
+ AND user_id = $2
+ `,
+ widgetID,
+ user.ID,
+ )
+
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Widget konnte nicht gelöscht werden")
+ return
+ }
+
+ if commandTag.RowsAffected() == 0 {
+ writeError(w, http.StatusNotFound, "Widget wurde nicht gefunden")
+ return
+ }
+
+ writeJSON(w, http.StatusOK, map[string]any{
+ "ok": true,
+ })
+}
+
+func (s *Server) handleDashboardOperationChanges(w http.ResponseWriter, r *http.Request) {
+ limit := 8
+
+ if rawLimit := strings.TrimSpace(r.URL.Query().Get("limit")); rawLimit != "" {
+ parsedLimit, err := strconv.Atoi(rawLimit)
+ if err == nil {
+ limit = parsedLimit
+ }
+ }
+
+ if limit < 1 {
+ limit = 1
+ }
+
+ if limit > 50 {
+ limit = 50
+ }
+
+ rows, err := s.db.Query(
+ r.Context(),
+ `
+ SELECT
+ h.id::TEXT,
+ h.operation_id::TEXT,
+ o.operation_number,
+ o.operation_name,
+ COALESCE(NULLIF(u.display_name, ''), NULLIF(u.username, ''), u.email, 'Unbekannt'),
+ h.action,
+ h.changes,
+ h.created_at
+ FROM operation_history h
+ INNER JOIN operations o ON o.id = h.operation_id
+ LEFT JOIN users u ON u.id = h.user_id
+ WHERE h.action <> 'journal'
+ ORDER BY h.created_at DESC
+ LIMIT $1
+ `,
+ limit,
+ )
+
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Einsatzänderungen konnten nicht geladen werden")
+ return
+ }
+ defer rows.Close()
+
+ changes := []DashboardOperationChange{}
+
+ for rows.Next() {
+ var item DashboardOperationChange
+ var changesRaw []byte
+
+ if err := rows.Scan(
+ &item.ID,
+ &item.OperationID,
+ &item.OperationNumber,
+ &item.OperationName,
+ &item.UserDisplayName,
+ &item.Action,
+ &changesRaw,
+ &item.CreatedAt,
+ ); err != nil {
+ writeError(w, http.StatusInternalServerError, "Einsatzänderungen konnten nicht gelesen werden")
+ return
+ }
+
+ item.Changes = []OperationHistoryChange{}
+
+ if len(changesRaw) > 0 {
+ if err := json.Unmarshal(changesRaw, &item.Changes); err != nil {
+ writeError(w, http.StatusInternalServerError, "Einsatzänderungen konnten nicht gelesen werden")
+ return
+ }
+ }
+
+ changes = append(changes, item)
+ }
+
+ if err := rows.Err(); err != nil {
+ writeError(w, http.StatusInternalServerError, "Einsatzänderungen konnten nicht gelesen werden")
+ return
+ }
+
+ writeJSON(w, http.StatusOK, map[string]any{
+ "changes": changes,
+ })
+}
diff --git a/backend/device_lookups.go b/backend/device_lookups.go
new file mode 100644
index 0000000..41663d8
--- /dev/null
+++ b/backend/device_lookups.go
@@ -0,0 +1,187 @@
+// backend\device_lookups.go
+
+package main
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "strings"
+
+ "github.com/jackc/pgx/v5"
+)
+
+type DeviceLookupOption struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+}
+
+type CreateDeviceLookupRequest struct {
+ Name string `json:"name"`
+}
+
+func (s *Server) handleDeviceLookups(w http.ResponseWriter, r *http.Request) {
+ manufacturers, err := listDeviceLookupOptions(r.Context(), s.db, "device_manufacturers")
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Hersteller konnten nicht geladen werden")
+ return
+ }
+
+ locations, err := listDeviceLookupOptions(r.Context(), s.db, "device_locations")
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Standorte konnten nicht geladen werden")
+ return
+ }
+
+ writeJSON(w, http.StatusOK, map[string]any{
+ "manufacturers": manufacturers,
+ "locations": locations,
+ })
+}
+
+func (s *Server) handleCreateDeviceManufacturer(w http.ResponseWriter, r *http.Request) {
+ s.handleCreateDeviceLookup(w, r, "device_manufacturers", "Hersteller")
+}
+
+func (s *Server) handleCreateDeviceLocation(w http.ResponseWriter, r *http.Request) {
+ s.handleCreateDeviceLookup(w, r, "device_locations", "Standort")
+}
+
+func (s *Server) handleCreateDeviceLookup(
+ w http.ResponseWriter,
+ r *http.Request,
+ tableName string,
+ label string,
+) {
+ var input CreateDeviceLookupRequest
+
+ if err := readJSON(r, &input); err != nil {
+ writeError(w, http.StatusBadRequest, "Invalid JSON")
+ return
+ }
+
+ name := strings.TrimSpace(input.Name)
+ if name == "" {
+ writeError(w, http.StatusBadRequest, label+" ist erforderlich")
+ return
+ }
+
+ option, err := upsertDeviceLookupOption(r.Context(), s.db, tableName, name)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, label+" konnte nicht gespeichert werden")
+ return
+ }
+
+ writeJSON(w, http.StatusCreated, map[string]any{
+ "option": option,
+ })
+}
+
+func listDeviceLookupOptions(
+ ctx context.Context,
+ db interface {
+ Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
+ },
+ tableName string,
+) ([]DeviceLookupOption, error) {
+ query := fmt.Sprintf(
+ `
+ SELECT id::TEXT, name
+ FROM %s
+ ORDER BY lower(name) ASC
+ `,
+ tableName,
+ )
+
+ rows, err := db.Query(ctx, query)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ options := []DeviceLookupOption{}
+
+ for rows.Next() {
+ var option DeviceLookupOption
+
+ if err := rows.Scan(&option.ID, &option.Name); err != nil {
+ return nil, err
+ }
+
+ options = append(options, option)
+ }
+
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+
+ return options, nil
+}
+
+func ensureDeviceLookupValues(
+ ctx context.Context,
+ tx pgx.Tx,
+ manufacturer string,
+ location string,
+) error {
+ if err := ensureOptionalDeviceLookupValue(ctx, tx, "device_manufacturers", manufacturer); err != nil {
+ return err
+ }
+
+ if err := ensureOptionalDeviceLookupValue(ctx, tx, "device_locations", location); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func ensureOptionalDeviceLookupValue(
+ ctx context.Context,
+ db deviceTransaction,
+ tableName string,
+ name string,
+) error {
+ name = strings.TrimSpace(name)
+ if name == "" {
+ return nil
+ }
+
+ _, err := upsertDeviceLookupOption(ctx, db, tableName, name)
+ return err
+}
+
+func upsertDeviceLookupOption(
+ ctx context.Context,
+ db deviceTransaction,
+ tableName string,
+ name string,
+) (DeviceLookupOption, error) {
+ name = strings.TrimSpace(name)
+ normalizedName := strings.ToLower(name)
+
+ query := fmt.Sprintf(
+ `
+ INSERT INTO %s (
+ name,
+ normalized_name
+ )
+ VALUES ($1, $2)
+ ON CONFLICT (normalized_name)
+ DO UPDATE SET
+ updated_at = now()
+ RETURNING id::TEXT, name
+ `,
+ tableName,
+ )
+
+ var option DeviceLookupOption
+
+ err := db.QueryRow(
+ ctx,
+ query,
+ name,
+ normalizedName,
+ ).Scan(&option.ID, &option.Name)
+
+ return option, err
+}
diff --git a/backend/devices.go b/backend/devices.go
index 7c2ddcc..af18c48 100644
--- a/backend/devices.go
+++ b/backend/devices.go
@@ -22,6 +22,7 @@ type Device struct {
MacAddress string `json:"macAddress"`
Location string `json:"location"`
LoanStatus string `json:"loanStatus"`
+ IsBaoDevice bool `json:"isBaoDevice"`
Comment string `json:"comment"`
RelatedDevices []DeviceShort `json:"relatedDevices"`
CreatedAt time.Time `json:"createdAt"`
@@ -43,6 +44,7 @@ type CreateDeviceRequest struct {
MacAddress string `json:"macAddress"`
Location string `json:"location"`
LoanStatus string `json:"loanStatus"`
+ IsBaoDevice bool `json:"isBaoDevice"`
Comment string `json:"comment"`
RelatedDeviceIDs []string `json:"relatedDeviceIds"`
}
@@ -75,6 +77,7 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
mac_address,
location,
loan_status,
+ is_bao_device,
comment,
created_at,
updated_at
@@ -104,6 +107,7 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
&device.MacAddress,
&device.Location,
&device.LoanStatus,
+ &device.IsBaoDevice,
&device.Comment,
&device.CreatedAt,
&device.UpdatedAt,
@@ -204,6 +208,11 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) {
}
defer tx.Rollback(r.Context())
+ if err := ensureDeviceLookupValues(r.Context(), tx, input.Manufacturer, input.Location); err != nil {
+ writeError(w, http.StatusInternalServerError, "Hersteller oder Standort konnte nicht gespeichert werden")
+ return
+ }
+
var device Device
err = tx.QueryRow(
@@ -217,9 +226,10 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) {
mac_address,
location,
loan_status,
+ is_bao_device,
comment
)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING
id,
inventory_number,
@@ -229,6 +239,7 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) {
mac_address,
location,
loan_status,
+ is_bao_device,
comment,
created_at,
updated_at
@@ -240,6 +251,7 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) {
input.MacAddress,
input.Location,
input.LoanStatus,
+ input.IsBaoDevice,
input.Comment,
).Scan(
&device.ID,
@@ -250,6 +262,7 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) {
&device.MacAddress,
&device.Location,
&device.LoanStatus,
+ &device.IsBaoDevice,
&device.Comment,
&device.CreatedAt,
&device.UpdatedAt,
@@ -335,6 +348,11 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
}
defer tx.Rollback(r.Context())
+ if err := ensureDeviceLookupValues(r.Context(), tx, input.Manufacturer, input.Location); err != nil {
+ writeError(w, http.StatusInternalServerError, "Hersteller oder Standort konnte nicht gespeichert werden")
+ return
+ }
+
oldDevice, oldRelatedDeviceIDs, err := getDeviceForHistory(r.Context(), tx, deviceID)
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "Gerät wurde nicht gefunden")
@@ -360,7 +378,8 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
mac_address = $6,
location = $7,
loan_status = $8,
- comment = $9,
+ is_bao_device = $9,
+ comment = $10,
updated_at = now()
WHERE id = $1
RETURNING
@@ -372,6 +391,7 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
mac_address,
location,
loan_status,
+ is_bao_device,
comment,
created_at,
updated_at
@@ -384,6 +404,7 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
input.MacAddress,
input.Location,
input.LoanStatus,
+ input.IsBaoDevice,
input.Comment,
).Scan(
&device.ID,
@@ -394,6 +415,7 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
&device.MacAddress,
&device.Location,
&device.LoanStatus,
+ &device.IsBaoDevice,
&device.Comment,
&device.CreatedAt,
&device.UpdatedAt,
diff --git a/backend/history.go b/backend/history.go
index e6b2654..f60f7e1 100644
--- a/backend/history.go
+++ b/backend/history.go
@@ -1,3 +1,5 @@
+// backend\history.go
+
package main
import (
@@ -28,6 +30,10 @@ type DeviceHistoryChange struct {
NewValue string `json:"newValue"`
}
+type CreateDeviceJournalEntryRequest struct {
+ Content string `json:"content"`
+}
+
func (s *Server) handleListDeviceHistory(w http.ResponseWriter, r *http.Request) {
deviceID := strings.TrimSpace(r.PathValue("id"))
@@ -100,6 +106,88 @@ func (s *Server) handleListDeviceHistory(w http.ResponseWriter, r *http.Request)
})
}
+func (s *Server) handleCreateDeviceJournalEntry(w http.ResponseWriter, r *http.Request) {
+ deviceID := strings.TrimSpace(r.PathValue("id"))
+
+ if deviceID == "" {
+ writeError(w, http.StatusBadRequest, "Geräte-ID fehlt")
+ return
+ }
+
+ var input CreateDeviceJournalEntryRequest
+
+ if err := readJSON(r, &input); err != nil {
+ writeError(w, http.StatusBadRequest, "Invalid JSON")
+ return
+ }
+
+ content := strings.TrimSpace(input.Content)
+
+ if content == "" {
+ writeError(w, http.StatusBadRequest, "Journal-Eintrag darf nicht leer sein")
+ return
+ }
+
+ user, ok := userFromContext(r.Context())
+ if !ok {
+ writeError(w, http.StatusUnauthorized, "Unauthorized")
+ return
+ }
+
+ tx, err := s.db.Begin(r.Context())
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht erstellt werden")
+ return
+ }
+ defer tx.Rollback(r.Context())
+
+ device, _, err := getDeviceForHistory(r.Context(), tx, deviceID)
+ if errors.Is(err, pgx.ErrNoRows) {
+ writeError(w, http.StatusNotFound, "Gerät wurde nicht gefunden")
+ return
+ }
+
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Gerät konnte nicht geladen werden")
+ return
+ }
+
+ changes := []DeviceHistoryChange{
+ {
+ Field: "journal",
+ Label: "Journal",
+ OldValue: "",
+ NewValue: content,
+ },
+ }
+
+ if err := recordDeviceHistory(r.Context(), tx, device.ID, user, "journal", changes); err != nil {
+ writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht gespeichert werden")
+ return
+ }
+
+ if err := tx.Commit(r.Context()); err != nil {
+ writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht gespeichert werden")
+ return
+ }
+
+ _ = s.createDeviceJournalEvent(
+ r.Context(),
+ user,
+ device,
+ map[string]any{
+ "inventoryNumber": device.InventoryNumber,
+ "manufacturer": device.Manufacturer,
+ "model": device.Model,
+ "action": "created",
+ },
+ )
+
+ writeJSON(w, http.StatusCreated, map[string]any{
+ "ok": true,
+ })
+}
+
func userFromContext(ctx context.Context) (User, bool) {
user, ok := ctx.Value(userContextKey).(User)
return user, ok
@@ -153,6 +241,7 @@ func getDeviceForHistory(ctx context.Context, tx pgx.Tx, deviceID string) (Devic
mac_address,
location,
loan_status,
+ is_bao_device,
comment,
created_at,
updated_at
@@ -170,6 +259,7 @@ func getDeviceForHistory(ctx context.Context, tx pgx.Tx, deviceID string) (Devic
&device.MacAddress,
&device.Location,
&device.LoanStatus,
+ &device.IsBaoDevice,
&device.Comment,
&device.CreatedAt,
&device.UpdatedAt,
@@ -214,6 +304,14 @@ func getDeviceForHistory(ctx context.Context, tx pgx.Tx, deviceID string) (Devic
return device, relatedDeviceIDs, nil
}
+func formatHistoryBool(value bool) string {
+ if value {
+ return "Ja"
+ }
+
+ return "Nein"
+}
+
func buildDeviceCreateHistoryChanges(
ctx context.Context,
tx pgx.Tx,
@@ -228,6 +326,7 @@ func buildDeviceCreateHistoryChanges(
changes = appendHistoryChange(changes, "macAddress", "MAC-Adresse", "", input.MacAddress)
changes = appendHistoryChange(changes, "location", "Standort", "", input.Location)
changes = appendHistoryChange(changes, "loanStatus", "Verleih-Status", "", input.LoanStatus)
+ changes = appendHistoryChange(changes, "isBaoDevice", "BAO-Gerät", "Nein", formatHistoryBool(input.IsBaoDevice))
changes = appendHistoryChange(changes, "comment", "Kommentar", "", input.Comment)
relatedDevices, err := formatRelatedDeviceLabels(ctx, tx, input.RelatedDeviceIDs)
@@ -256,6 +355,7 @@ func buildDeviceUpdateHistoryChanges(
changes = appendHistoryChange(changes, "macAddress", "MAC-Adresse", oldDevice.MacAddress, input.MacAddress)
changes = appendHistoryChange(changes, "location", "Standort", oldDevice.Location, input.Location)
changes = appendHistoryChange(changes, "loanStatus", "Verleih-Status", oldDevice.LoanStatus, input.LoanStatus)
+ changes = appendHistoryChange(changes, "isBaoDevice", "BAO-Gerät", formatHistoryBool(oldDevice.IsBaoDevice), formatHistoryBool(input.IsBaoDevice))
changes = appendHistoryChange(changes, "comment", "Kommentar", oldDevice.Comment, input.Comment)
oldRelatedDevices, err := formatRelatedDeviceLabels(ctx, tx, oldRelatedDeviceIDs)
diff --git a/backend/notifications.go b/backend/notifications.go
new file mode 100644
index 0000000..89f6edd
--- /dev/null
+++ b/backend/notifications.go
@@ -0,0 +1,559 @@
+// backend\notifications.go
+
+package main
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+)
+
+type Notification struct {
+ ID string `json:"id"`
+ UserID string `json:"userId"`
+ Type string `json:"type"`
+ Title string `json:"title"`
+ Message string `json:"message"`
+ EntityType string `json:"entityType"`
+ EntityID string `json:"entityId"`
+ Data json.RawMessage `json:"data"`
+ Visible bool `json:"visible"`
+ ReadAt *time.Time `json:"readAt"`
+ CreatedAt time.Time `json:"createdAt"`
+}
+
+type notificationClient struct {
+ userID string
+ ch chan Notification
+}
+
+type notificationBroker struct {
+ mu sync.RWMutex
+ clients map[*notificationClient]struct{}
+}
+
+var notificationsBroker = ¬ificationBroker{
+ clients: map[*notificationClient]struct{}{},
+}
+
+func (b *notificationBroker) subscribe(userID string) *notificationClient {
+ client := ¬ificationClient{
+ userID: userID,
+ ch: make(chan Notification, 16),
+ }
+
+ b.mu.Lock()
+ b.clients[client] = struct{}{}
+ b.mu.Unlock()
+
+ return client
+}
+
+func (b *notificationBroker) unsubscribe(client *notificationClient) {
+ b.mu.Lock()
+ delete(b.clients, client)
+ close(client.ch)
+ b.mu.Unlock()
+}
+
+func (b *notificationBroker) publish(notification Notification) {
+ b.mu.RLock()
+ defer b.mu.RUnlock()
+
+ for client := range b.clients {
+ if client.userID != notification.UserID {
+ continue
+ }
+
+ select {
+ case client.ch <- notification:
+ default:
+ }
+ }
+}
+
+func (s *Server) handleNotificationEvents(w http.ResponseWriter, r *http.Request) {
+ user, ok := userFromContext(r.Context())
+ if !ok {
+ writeError(w, http.StatusUnauthorized, "Unauthorized")
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Header().Set("Connection", "keep-alive")
+ w.Header().Set("X-Accel-Buffering", "no")
+
+ flusher, ok := w.(http.Flusher)
+ if !ok {
+ writeError(w, http.StatusInternalServerError, "Streaming wird nicht unterstützt")
+ return
+ }
+
+ client := notificationsBroker.subscribe(user.ID)
+ defer notificationsBroker.unsubscribe(client)
+
+ fmt.Fprintf(w, "event: connected\n")
+ fmt.Fprintf(w, "data: {}\n\n")
+ flusher.Flush()
+
+ for {
+ select {
+ case <-r.Context().Done():
+ return
+
+ case notification := <-client.ch:
+ payload, err := json.Marshal(notification)
+ if err != nil {
+ continue
+ }
+
+ eventName := "notification"
+
+ if !notification.Visible {
+ switch notification.EntityType {
+ case "operation":
+ eventName = "operation-event"
+ case "device":
+ eventName = "device-event"
+ default:
+ eventName = "notification-event"
+ }
+ }
+
+ fmt.Fprintf(w, "event: %s\n", eventName)
+ fmt.Fprintf(w, "data: %s\n\n", payload)
+ flusher.Flush()
+ }
+ }
+}
+
+func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request) {
+ user, ok := userFromContext(r.Context())
+ if !ok {
+ writeError(w, http.StatusUnauthorized, "Unauthorized")
+ return
+ }
+
+ rows, err := s.db.Query(
+ r.Context(),
+ `
+ SELECT
+ id,
+ user_id::TEXT,
+ type,
+ title,
+ message,
+ entity_type,
+ COALESCE(entity_id::TEXT, ''),
+ data,
+ read_at,
+ created_at
+ FROM notifications
+ WHERE user_id = $1
+ AND visible = true
+ ORDER BY created_at DESC
+ LIMIT 50
+ `,
+ user.ID,
+ )
+
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Benachrichtigungen konnten nicht geladen werden")
+ return
+ }
+ defer rows.Close()
+
+ notifications := []Notification{}
+
+ for rows.Next() {
+ var notification Notification
+ var readAt sql.NullTime
+ var data []byte
+
+ if err := rows.Scan(
+ ¬ification.ID,
+ ¬ification.UserID,
+ ¬ification.Type,
+ ¬ification.Title,
+ ¬ification.Message,
+ ¬ification.EntityType,
+ ¬ification.EntityID,
+ &data,
+ &readAt,
+ ¬ification.CreatedAt,
+ ); err != nil {
+ writeError(w, http.StatusInternalServerError, "Benachrichtigungen konnten nicht gelesen werden")
+ return
+ }
+
+ if readAt.Valid {
+ notification.ReadAt = &readAt.Time
+ }
+
+ notification.Data = json.RawMessage(data)
+ notifications = append(notifications, notification)
+ }
+
+ writeJSON(w, http.StatusOK, map[string]any{
+ "notifications": notifications,
+ })
+}
+
+func (s *Server) handleMarkNotificationRead(w http.ResponseWriter, r *http.Request) {
+ user, ok := userFromContext(r.Context())
+ if !ok {
+ writeError(w, http.StatusUnauthorized, "Unauthorized")
+ return
+ }
+
+ notificationID := strings.TrimSpace(r.PathValue("id"))
+ if notificationID == "" {
+ writeError(w, http.StatusBadRequest, "Benachrichtigungs-ID fehlt")
+ return
+ }
+
+ _, err := s.db.Exec(
+ r.Context(),
+ `
+ UPDATE notifications
+ SET read_at = COALESCE(read_at, now())
+ WHERE id = $1 AND user_id = $2
+ `,
+ notificationID,
+ user.ID,
+ )
+
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Benachrichtigung konnte nicht aktualisiert werden")
+ return
+ }
+
+ writeJSON(w, http.StatusOK, map[string]any{
+ "ok": true,
+ })
+}
+
+func (s *Server) handleMarkAllNotificationsRead(w http.ResponseWriter, r *http.Request) {
+ user, ok := userFromContext(r.Context())
+ if !ok {
+ writeError(w, http.StatusUnauthorized, "Unauthorized")
+ return
+ }
+
+ _, err := s.db.Exec(
+ r.Context(),
+ `
+ UPDATE notifications
+ SET read_at = COALESCE(read_at, now())
+ WHERE user_id = $1 AND read_at IS NULL
+ `,
+ user.ID,
+ )
+
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Benachrichtigungen konnten nicht aktualisiert werden")
+ return
+ }
+
+ writeJSON(w, http.StatusOK, map[string]any{
+ "ok": true,
+ })
+}
+
+func (s *Server) createOperationNotification(
+ ctx context.Context,
+ actor User,
+ operation Operation,
+ notificationType string,
+ title string,
+ message string,
+ data map[string]any,
+) error {
+ dataJSON, err := json.Marshal(data)
+ if err != nil {
+ return err
+ }
+
+ rows, err := s.db.Query(
+ ctx,
+ `
+ SELECT id
+ FROM users
+ WHERE id <> $1
+ `,
+ actor.ID,
+ )
+ if err != nil {
+ return err
+ }
+ defer rows.Close()
+
+ recipientIDs := []string{}
+
+ for rows.Next() {
+ var userID string
+ if err := rows.Scan(&userID); err != nil {
+ return err
+ }
+
+ recipientIDs = append(recipientIDs, userID)
+ }
+
+ for _, recipientID := range recipientIDs {
+ var notification Notification
+ var rawData []byte
+
+ err := s.db.QueryRow(
+ ctx,
+ `
+ INSERT INTO notifications (
+ user_id,
+ type,
+ title,
+ message,
+ entity_type,
+ entity_id,
+ data
+ )
+ VALUES ($1, $2, $3, $4, 'operation', $5, $6::JSONB)
+ RETURNING
+ id,
+ user_id::TEXT,
+ type,
+ title,
+ message,
+ entity_type,
+ COALESCE(entity_id::TEXT, ''),
+ data,
+ created_at
+ `,
+ recipientID,
+ notificationType,
+ title,
+ message,
+ operation.ID,
+ string(dataJSON),
+ ).Scan(
+ ¬ification.ID,
+ ¬ification.UserID,
+ ¬ification.Type,
+ ¬ification.Title,
+ ¬ification.Message,
+ ¬ification.EntityType,
+ ¬ification.EntityID,
+ &rawData,
+ ¬ification.CreatedAt,
+ )
+
+ if err != nil {
+ return err
+ }
+
+ notification.Data = json.RawMessage(rawData)
+ notificationsBroker.publish(notification)
+ }
+
+ return nil
+}
+
+func (s *Server) createOperationJournalEvent(
+ ctx context.Context,
+ actor User,
+ operation Operation,
+ data map[string]any,
+) error {
+ dataJSON, err := json.Marshal(data)
+ if err != nil {
+ return err
+ }
+
+ rows, err := s.db.Query(
+ ctx,
+ `
+ SELECT id
+ FROM users
+ WHERE id <> $1
+ `,
+ actor.ID,
+ )
+ if err != nil {
+ return err
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ var recipientID string
+ if err := rows.Scan(&recipientID); err != nil {
+ return err
+ }
+
+ var notification Notification
+ var rawData []byte
+
+ err := s.db.QueryRow(
+ ctx,
+ `
+ INSERT INTO notifications (
+ user_id,
+ type,
+ title,
+ message,
+ entity_type,
+ entity_id,
+ data,
+ visible
+ )
+ VALUES (
+ $1,
+ 'operation.journal',
+ 'Journal aktualisiert',
+ 'Ein Journal-Eintrag wurde aktualisiert.',
+ 'operation',
+ $2,
+ $3::JSONB,
+ false
+ )
+ RETURNING
+ id,
+ user_id::TEXT,
+ type,
+ title,
+ message,
+ entity_type,
+ COALESCE(entity_id::TEXT, ''),
+ data,
+ visible,
+ created_at
+ `,
+ recipientID,
+ operation.ID,
+ string(dataJSON),
+ ).Scan(
+ ¬ification.ID,
+ ¬ification.UserID,
+ ¬ification.Type,
+ ¬ification.Title,
+ ¬ification.Message,
+ ¬ification.EntityType,
+ ¬ification.EntityID,
+ &rawData,
+ ¬ification.Visible,
+ ¬ification.CreatedAt,
+ )
+
+ if err != nil {
+ return err
+ }
+
+ notification.Data = json.RawMessage(rawData)
+ notificationsBroker.publish(notification)
+ }
+
+ return rows.Err()
+}
+
+func (s *Server) createDeviceJournalEvent(
+ ctx context.Context,
+ actor User,
+ device Device,
+ data map[string]any,
+) error {
+ dataJSON, err := json.Marshal(data)
+ if err != nil {
+ return err
+ }
+
+ rows, err := s.db.Query(
+ ctx,
+ `
+ SELECT id
+ FROM users
+ WHERE id <> $1
+ AND (
+ 'admin' = ANY(rights)
+ OR 'devices:read' = ANY(rights)
+ )
+ `,
+ actor.ID,
+ )
+ if err != nil {
+ return err
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ var recipientID string
+ if err := rows.Scan(&recipientID); err != nil {
+ return err
+ }
+
+ var notification Notification
+ var rawData []byte
+
+ err := s.db.QueryRow(
+ ctx,
+ `
+ INSERT INTO notifications (
+ user_id,
+ type,
+ title,
+ message,
+ entity_type,
+ entity_id,
+ data,
+ visible
+ )
+ VALUES (
+ $1,
+ 'device.journal',
+ 'Geräte-Journal aktualisiert',
+ 'Ein Geräte-Journal-Eintrag wurde hinzugefügt.',
+ 'device',
+ $2,
+ $3::JSONB,
+ false
+ )
+ RETURNING
+ id,
+ user_id::TEXT,
+ type,
+ title,
+ message,
+ entity_type,
+ COALESCE(entity_id::TEXT, ''),
+ data,
+ visible,
+ created_at
+ `,
+ recipientID,
+ device.ID,
+ string(dataJSON),
+ ).Scan(
+ ¬ification.ID,
+ ¬ification.UserID,
+ ¬ification.Type,
+ ¬ification.Title,
+ ¬ification.Message,
+ ¬ification.EntityType,
+ ¬ification.EntityID,
+ &rawData,
+ ¬ification.Visible,
+ ¬ification.CreatedAt,
+ )
+
+ if err != nil {
+ return err
+ }
+
+ notification.Data = json.RawMessage(rawData)
+ notificationsBroker.publish(notification)
+ }
+
+ return rows.Err()
+}
diff --git a/backend/operations.go b/backend/operations.go
new file mode 100644
index 0000000..da0fa62
--- /dev/null
+++ b/backend/operations.go
@@ -0,0 +1,1125 @@
+// backend\operations.go
+
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgconn"
+)
+
+type Operation struct {
+ ID string `json:"id"`
+ OperationNumber string `json:"operationNumber"`
+ OperationName string `json:"operationName"`
+ Offense string `json:"offense"`
+ CaseWorker string `json:"caseWorker"`
+ TargetObject string `json:"targetObject"`
+ KwAddress string `json:"kwAddress"`
+ OperationLeader string `json:"operationLeader"`
+ OperationTeam string `json:"operationTeam"`
+ Legend string `json:"legend"`
+ Camera string `json:"camera"`
+ Router string `json:"router"`
+ Technology string `json:"technology"`
+ Switchbox string `json:"switchbox"`
+ HardDrive string `json:"hardDrive"`
+ Laptop string `json:"laptop"`
+ Remark string `json:"remark"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+}
+
+type CreateOperationJournalEntryRequest struct {
+ Content string `json:"content"`
+}
+
+type UpdateOperationJournalEntryRequest struct {
+ Content string `json:"content"`
+}
+
+type CreateOperationRequest struct {
+ OperationNumber string `json:"operationNumber"`
+ OperationName string `json:"operationName"`
+ Offense string `json:"offense"`
+ CaseWorker string `json:"caseWorker"`
+ TargetObject string `json:"targetObject"`
+ KwAddress string `json:"kwAddress"`
+ OperationLeader string `json:"operationLeader"`
+ OperationTeam string `json:"operationTeam"`
+ Legend string `json:"legend"`
+ Camera string `json:"camera"`
+ Router string `json:"router"`
+ Technology string `json:"technology"`
+ Switchbox string `json:"switchbox"`
+ HardDrive string `json:"hardDrive"`
+ Laptop string `json:"laptop"`
+ Remark string `json:"remark"`
+}
+
+type UpdateOperationRequest = CreateOperationRequest
+
+type OperationHistoryEntry struct {
+ ID string `json:"id"`
+ OperationID string `json:"operationId"`
+ UserID string `json:"userId"`
+ UserDisplayName string `json:"userDisplayName"`
+ Action string `json:"action"`
+ Changes []OperationHistoryChange `json:"changes"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+ CanEdit bool `json:"canEdit"`
+}
+
+type OperationHistoryChange struct {
+ Field string `json:"field"`
+ Label string `json:"label"`
+ OldValue string `json:"oldValue"`
+ NewValue string `json:"newValue"`
+}
+
+func (s *Server) handleOperations(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodGet:
+ s.handleListOperations(w, r)
+ case http.MethodPost:
+ s.handleCreateOperation(w, r)
+ default:
+ writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
+ }
+}
+
+func (s *Server) handleOperation(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodPut:
+ s.handleUpdateOperation(w, r)
+ default:
+ writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
+ }
+}
+
+func (s *Server) handleListOperations(w http.ResponseWriter, r *http.Request) {
+ rows, err := s.db.Query(
+ r.Context(),
+ `
+ SELECT
+ id,
+ operation_number,
+ operation_name,
+ offense,
+ case_worker,
+ target_object,
+ kw_address,
+ operation_leader,
+ operation_team,
+ legend,
+ camera,
+ router,
+ technology,
+ switchbox,
+ hard_drive,
+ laptop,
+ remark,
+ created_at,
+ updated_at
+ FROM operations
+ ORDER BY operation_number ASC
+ `,
+ )
+
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Could not load operations")
+ return
+ }
+ defer rows.Close()
+
+ operations := []Operation{}
+
+ for rows.Next() {
+ var operation Operation
+
+ if err := rows.Scan(
+ &operation.ID,
+ &operation.OperationNumber,
+ &operation.OperationName,
+ &operation.Offense,
+ &operation.CaseWorker,
+ &operation.TargetObject,
+ &operation.KwAddress,
+ &operation.OperationLeader,
+ &operation.OperationTeam,
+ &operation.Legend,
+ &operation.Camera,
+ &operation.Router,
+ &operation.Technology,
+ &operation.Switchbox,
+ &operation.HardDrive,
+ &operation.Laptop,
+ &operation.Remark,
+ &operation.CreatedAt,
+ &operation.UpdatedAt,
+ ); err != nil {
+ writeError(w, http.StatusInternalServerError, "Could not read operations")
+ return
+ }
+
+ operations = append(operations, operation)
+ }
+
+ if err := rows.Err(); err != nil {
+ writeError(w, http.StatusInternalServerError, "Could not read operations")
+ return
+ }
+
+ writeJSON(w, http.StatusOK, map[string]any{
+ "operations": operations,
+ })
+}
+
+func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) {
+ var input CreateOperationRequest
+
+ if err := readJSON(r, &input); err != nil {
+ writeError(w, http.StatusBadRequest, "Invalid JSON")
+ return
+ }
+
+ normalizeOperationInput(&input)
+
+ if input.OperationNumber == "" {
+ writeError(w, http.StatusBadRequest, "Einsatznummer ist erforderlich")
+ return
+ }
+
+ user, ok := userFromContext(r.Context())
+ if !ok {
+ writeError(w, http.StatusUnauthorized, "Unauthorized")
+ return
+ }
+
+ tx, err := s.db.Begin(r.Context())
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Could not create operation")
+ return
+ }
+ defer tx.Rollback(r.Context())
+
+ var operation Operation
+
+ err = tx.QueryRow(
+ r.Context(),
+ `
+ INSERT INTO operations (
+ operation_number,
+ operation_name,
+ offense,
+ case_worker,
+ target_object,
+ kw_address,
+ operation_leader,
+ operation_team,
+ legend,
+ camera,
+ router,
+ technology,
+ switchbox,
+ hard_drive,
+ laptop,
+ remark
+ )
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
+ RETURNING
+ id,
+ operation_number,
+ operation_name,
+ offense,
+ case_worker,
+ target_object,
+ kw_address,
+ operation_leader,
+ operation_team,
+ legend,
+ camera,
+ router,
+ technology,
+ switchbox,
+ hard_drive,
+ laptop,
+ remark,
+ created_at,
+ updated_at
+ `,
+ input.OperationNumber,
+ input.OperationName,
+ input.Offense,
+ input.CaseWorker,
+ input.TargetObject,
+ input.KwAddress,
+ input.OperationLeader,
+ input.OperationTeam,
+ input.Legend,
+ input.Camera,
+ input.Router,
+ input.Technology,
+ input.Switchbox,
+ input.HardDrive,
+ input.Laptop,
+ input.Remark,
+ ).Scan(
+ &operation.ID,
+ &operation.OperationNumber,
+ &operation.OperationName,
+ &operation.Offense,
+ &operation.CaseWorker,
+ &operation.TargetObject,
+ &operation.KwAddress,
+ &operation.OperationLeader,
+ &operation.OperationTeam,
+ &operation.Legend,
+ &operation.Camera,
+ &operation.Router,
+ &operation.Technology,
+ &operation.Switchbox,
+ &operation.HardDrive,
+ &operation.Laptop,
+ &operation.Remark,
+ &operation.CreatedAt,
+ &operation.UpdatedAt,
+ )
+
+ if err != nil {
+ var pgErr *pgconn.PgError
+
+ if errors.As(err, &pgErr) && pgErr.Code == "23505" {
+ writeError(w, http.StatusConflict, "Einsatz mit dieser Einsatznummer existiert bereits")
+ return
+ }
+
+ writeError(w, http.StatusInternalServerError, "Could not create operation")
+ return
+ }
+
+ changes := buildOperationCreateHistoryChanges(input)
+
+ if err := recordOperationHistory(r.Context(), tx, operation.ID, user, "created", changes); err != nil {
+ writeError(w, http.StatusInternalServerError, "Could not create operation history")
+ return
+ }
+
+ if err := tx.Commit(r.Context()); err != nil {
+ writeError(w, http.StatusInternalServerError, "Could not create operation")
+ return
+ }
+
+ if err := s.createOperationNotification(
+ r.Context(),
+ user,
+ operation,
+ "operation.created",
+ "Neuer Einsatz",
+ fmt.Sprintf("Einsatz %s wurde erstellt.", operation.OperationNumber),
+ map[string]any{
+ "operationNumber": operation.OperationNumber,
+ "operationName": operation.OperationName,
+ },
+ ); err != nil {
+ // nicht abbrechen, Einsatz wurde bereits erstellt
+ }
+
+ writeJSON(w, http.StatusCreated, map[string]any{
+ "operation": operation,
+ })
+}
+
+func (s *Server) handleUpdateOperation(w http.ResponseWriter, r *http.Request) {
+ operationID := strings.TrimSpace(r.PathValue("id"))
+
+ if operationID == "" {
+ writeError(w, http.StatusBadRequest, "Einsatz-ID fehlt")
+ return
+ }
+
+ var input UpdateOperationRequest
+
+ if err := readJSON(r, &input); err != nil {
+ writeError(w, http.StatusBadRequest, "Invalid JSON")
+ return
+ }
+
+ normalizeOperationInput(&input)
+
+ if input.OperationNumber == "" {
+ writeError(w, http.StatusBadRequest, "Einsatznummer ist erforderlich")
+ return
+ }
+
+ user, ok := userFromContext(r.Context())
+ if !ok {
+ writeError(w, http.StatusUnauthorized, "Unauthorized")
+ return
+ }
+
+ tx, err := s.db.Begin(r.Context())
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Could not update operation")
+ return
+ }
+ defer tx.Rollback(r.Context())
+
+ oldOperation, err := getOperationForHistory(r.Context(), tx, operationID)
+ if errors.Is(err, pgx.ErrNoRows) {
+ writeError(w, http.StatusNotFound, "Einsatz wurde nicht gefunden")
+ return
+ }
+
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Could not load operation history state")
+ return
+ }
+
+ var operation Operation
+
+ err = tx.QueryRow(
+ r.Context(),
+ `
+ UPDATE operations
+ SET
+ operation_number = $2,
+ operation_name = $3,
+ offense = $4,
+ case_worker = $5,
+ target_object = $6,
+ kw_address = $7,
+ operation_leader = $8,
+ operation_team = $9,
+ legend = $10,
+ camera = $11,
+ router = $12,
+ technology = $13,
+ switchbox = $14,
+ hard_drive = $15,
+ laptop = $16,
+ remark = $17,
+ updated_at = now()
+ WHERE id = $1
+ RETURNING
+ id,
+ operation_number,
+ operation_name,
+ offense,
+ case_worker,
+ target_object,
+ kw_address,
+ operation_leader,
+ operation_team,
+ legend,
+ camera,
+ router,
+ technology,
+ switchbox,
+ hard_drive,
+ laptop,
+ remark,
+ created_at,
+ updated_at
+ `,
+ operationID,
+ input.OperationNumber,
+ input.OperationName,
+ input.Offense,
+ input.CaseWorker,
+ input.TargetObject,
+ input.KwAddress,
+ input.OperationLeader,
+ input.OperationTeam,
+ input.Legend,
+ input.Camera,
+ input.Router,
+ input.Technology,
+ input.Switchbox,
+ input.HardDrive,
+ input.Laptop,
+ input.Remark,
+ ).Scan(
+ &operation.ID,
+ &operation.OperationNumber,
+ &operation.OperationName,
+ &operation.Offense,
+ &operation.CaseWorker,
+ &operation.TargetObject,
+ &operation.KwAddress,
+ &operation.OperationLeader,
+ &operation.OperationTeam,
+ &operation.Legend,
+ &operation.Camera,
+ &operation.Router,
+ &operation.Technology,
+ &operation.Switchbox,
+ &operation.HardDrive,
+ &operation.Laptop,
+ &operation.Remark,
+ &operation.CreatedAt,
+ &operation.UpdatedAt,
+ )
+
+ if errors.Is(err, pgx.ErrNoRows) {
+ writeError(w, http.StatusNotFound, "Einsatz wurde nicht gefunden")
+ return
+ }
+
+ if err != nil {
+ var pgErr *pgconn.PgError
+
+ if errors.As(err, &pgErr) && pgErr.Code == "23505" {
+ writeError(w, http.StatusConflict, "Einsatz mit dieser Einsatznummer existiert bereits")
+ return
+ }
+
+ writeError(w, http.StatusInternalServerError, "Could not update operation")
+ return
+ }
+
+ changes := buildOperationUpdateHistoryChanges(oldOperation, input)
+
+ if len(changes) > 0 {
+ if err := recordOperationHistory(r.Context(), tx, operation.ID, user, "updated", changes); err != nil {
+ writeError(w, http.StatusInternalServerError, "Could not create operation history")
+ return
+ }
+ }
+
+ if err := tx.Commit(r.Context()); err != nil {
+ writeError(w, http.StatusInternalServerError, "Could not update operation")
+ return
+ }
+
+ if err := s.createOperationNotification(
+ r.Context(),
+ user,
+ operation,
+ "operation.updated",
+ "Einsatz geändert",
+ fmt.Sprintf("Einsatz %s wurde geändert.", operation.OperationNumber),
+ map[string]any{
+ "operationNumber": operation.OperationNumber,
+ "operationName": operation.OperationName,
+ "changes": changes,
+ },
+ ); err != nil {
+ // nicht abbrechen, Änderung wurde bereits gespeichert
+ }
+
+ writeJSON(w, http.StatusOK, map[string]any{
+ "operation": operation,
+ })
+}
+
+func (s *Server) handleListOperationHistory(w http.ResponseWriter, r *http.Request) {
+ operationID := strings.TrimSpace(r.PathValue("id"))
+
+ if operationID == "" {
+ writeError(w, http.StatusBadRequest, "Einsatz-ID fehlt")
+ return
+ }
+
+ user, ok := userFromContext(r.Context())
+ if !ok {
+ writeError(w, http.StatusUnauthorized, "Unauthorized")
+ return
+ }
+
+ rows, err := s.db.Query(
+ r.Context(),
+ `
+ SELECT
+ h.id,
+ h.operation_id,
+ COALESCE(h.user_id::TEXT, ''),
+ COALESCE(NULLIF(u.display_name, ''), NULLIF(u.username, ''), u.email, 'Unbekannt'),
+ h.action,
+ h.changes,
+ h.created_at,
+ h.updated_at
+ FROM operation_history h
+ LEFT JOIN users u ON u.id = h.user_id
+ WHERE h.operation_id = $1
+ ORDER BY h.created_at DESC
+ `,
+ operationID,
+ )
+
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Could not load operation history")
+ return
+ }
+ defer rows.Close()
+
+ history := []OperationHistoryEntry{}
+
+ for rows.Next() {
+ var entry OperationHistoryEntry
+ var changesRaw []byte
+
+ if err := rows.Scan(
+ &entry.ID,
+ &entry.OperationID,
+ &entry.UserID,
+ &entry.UserDisplayName,
+ &entry.Action,
+ &changesRaw,
+ &entry.CreatedAt,
+ &entry.UpdatedAt,
+ ); err != nil {
+ writeError(w, http.StatusInternalServerError, "Could not read operation history")
+ return
+ }
+
+ if len(changesRaw) > 0 {
+ if err := json.Unmarshal(changesRaw, &entry.Changes); err != nil {
+ writeError(w, http.StatusInternalServerError, "Could not parse operation history")
+ return
+ }
+ }
+
+ entry.CanEdit =
+ entry.Action == "journal" &&
+ entry.UserID == user.ID &&
+ time.Since(entry.CreatedAt) <= 10*time.Minute
+
+ history = append(history, entry)
+ }
+
+ if err := rows.Err(); err != nil {
+ writeError(w, http.StatusInternalServerError, "Could not read operation history")
+ return
+ }
+
+ writeJSON(w, http.StatusOK, map[string]any{
+ "history": history,
+ })
+}
+
+func getOperationForHistory(ctx context.Context, tx pgx.Tx, operationID string) (Operation, error) {
+ var operation Operation
+
+ err := tx.QueryRow(
+ ctx,
+ `
+ SELECT
+ id,
+ operation_number,
+ operation_name,
+ offense,
+ case_worker,
+ target_object,
+ kw_address,
+ operation_leader,
+ operation_team,
+ legend,
+ camera,
+ router,
+ technology,
+ switchbox,
+ hard_drive,
+ laptop,
+ remark,
+ created_at,
+ updated_at
+ FROM operations
+ WHERE id = $1
+ LIMIT 1
+ `,
+ operationID,
+ ).Scan(
+ &operation.ID,
+ &operation.OperationNumber,
+ &operation.OperationName,
+ &operation.Offense,
+ &operation.CaseWorker,
+ &operation.TargetObject,
+ &operation.KwAddress,
+ &operation.OperationLeader,
+ &operation.OperationTeam,
+ &operation.Legend,
+ &operation.Camera,
+ &operation.Router,
+ &operation.Technology,
+ &operation.Switchbox,
+ &operation.HardDrive,
+ &operation.Laptop,
+ &operation.Remark,
+ &operation.CreatedAt,
+ &operation.UpdatedAt,
+ )
+
+ return operation, err
+}
+
+func recordOperationHistory(
+ ctx context.Context,
+ tx pgx.Tx,
+ operationID string,
+ user User,
+ action string,
+ changes []OperationHistoryChange,
+) error {
+ changesJSON, err := json.Marshal(changes)
+ if err != nil {
+ return err
+ }
+
+ _, err = tx.Exec(
+ ctx,
+ `
+ INSERT INTO operation_history (
+ operation_id,
+ user_id,
+ action,
+ changes
+ )
+ VALUES ($1, $2, $3, $4::JSONB)
+ `,
+ operationID,
+ user.ID,
+ action,
+ string(changesJSON),
+ )
+
+ return err
+}
+
+func buildOperationCreateHistoryChanges(input CreateOperationRequest) []OperationHistoryChange {
+ changes := []OperationHistoryChange{}
+
+ changes = appendOperationHistoryChange(changes, "operationNumber", "Einsatznummer", "", input.OperationNumber)
+ changes = appendOperationHistoryChange(changes, "operationName", "Einsatzname", "", input.OperationName)
+ changes = appendOperationHistoryChange(changes, "offense", "Delikt", "", input.Offense)
+ changes = appendOperationHistoryChange(changes, "caseWorker", "Sachbearbeitung", "", input.CaseWorker)
+ changes = appendOperationHistoryChange(changes, "targetObject", "Zielobjekt", "", input.TargetObject)
+ changes = appendOperationHistoryChange(changes, "kwAddress", "KW", "", input.KwAddress)
+ changes = appendOperationHistoryChange(changes, "operationLeader", "Einsatzleiter", "", input.OperationLeader)
+ changes = appendOperationHistoryChange(changes, "operationTeam", "Einsatzteam", "", input.OperationTeam)
+ changes = appendOperationHistoryChange(changes, "legend", "Legende", "", input.Legend)
+ changes = appendOperationHistoryChange(changes, "camera", "Kamera", "", input.Camera)
+ changes = appendOperationHistoryChange(changes, "router", "Router", "", input.Router)
+ changes = appendOperationHistoryChange(changes, "technology", "Technik", "", input.Technology)
+ changes = appendOperationHistoryChange(changes, "switchbox", "Switchbox", "", input.Switchbox)
+ changes = appendOperationHistoryChange(changes, "hardDrive", "Festplatte", "", input.HardDrive)
+ changes = appendOperationHistoryChange(changes, "laptop", "Laptop", "", input.Laptop)
+ changes = appendOperationHistoryChange(changes, "remark", "Bemerkung", "", input.Remark)
+
+ return changes
+}
+
+func buildOperationUpdateHistoryChanges(oldOperation Operation, input UpdateOperationRequest) []OperationHistoryChange {
+ changes := []OperationHistoryChange{}
+
+ changes = appendOperationHistoryChange(changes, "operationNumber", "Einsatznummer", oldOperation.OperationNumber, input.OperationNumber)
+ changes = appendOperationHistoryChange(changes, "operationName", "Einsatzname", oldOperation.OperationName, input.OperationName)
+ changes = appendOperationHistoryChange(changes, "offense", "Delikt", oldOperation.Offense, input.Offense)
+ changes = appendOperationHistoryChange(changes, "caseWorker", "Sachbearbeitung", oldOperation.CaseWorker, input.CaseWorker)
+ changes = appendOperationHistoryChange(changes, "targetObject", "Zielobjekt", oldOperation.TargetObject, input.TargetObject)
+ changes = appendOperationHistoryChange(changes, "kwAddress", "KW", oldOperation.KwAddress, input.KwAddress)
+ changes = appendOperationHistoryChange(changes, "operationLeader", "Einsatzleiter", oldOperation.OperationLeader, input.OperationLeader)
+ changes = appendOperationHistoryChange(changes, "operationTeam", "Einsatzteam", oldOperation.OperationTeam, input.OperationTeam)
+ changes = appendOperationHistoryChange(changes, "legend", "Legende", oldOperation.Legend, input.Legend)
+ changes = appendOperationHistoryChange(changes, "camera", "Kamera", oldOperation.Camera, input.Camera)
+ changes = appendOperationHistoryChange(changes, "router", "Router", oldOperation.Router, input.Router)
+ changes = appendOperationHistoryChange(changes, "technology", "Technik", oldOperation.Technology, input.Technology)
+ changes = appendOperationHistoryChange(changes, "switchbox", "Switchbox", oldOperation.Switchbox, input.Switchbox)
+ changes = appendOperationHistoryChange(changes, "hardDrive", "Festplatte", oldOperation.HardDrive, input.HardDrive)
+ changes = appendOperationHistoryChange(changes, "laptop", "Laptop", oldOperation.Laptop, input.Laptop)
+ changes = appendOperationHistoryChange(changes, "remark", "Bemerkung", oldOperation.Remark, input.Remark)
+
+ return changes
+}
+
+func appendOperationHistoryChange(
+ changes []OperationHistoryChange,
+ field string,
+ label string,
+ oldValue string,
+ newValue string,
+) []OperationHistoryChange {
+ oldValue = strings.TrimSpace(oldValue)
+ newValue = strings.TrimSpace(newValue)
+
+ if oldValue == newValue {
+ return changes
+ }
+
+ return append(changes, OperationHistoryChange{
+ Field: field,
+ Label: label,
+ OldValue: displayHistoryValue(oldValue),
+ NewValue: displayHistoryValue(newValue),
+ })
+}
+
+func normalizeOperationInput(input *CreateOperationRequest) {
+ input.OperationNumber = strings.TrimSpace(input.OperationNumber)
+ input.OperationName = strings.TrimSpace(input.OperationName)
+ input.Offense = strings.TrimSpace(input.Offense)
+ input.CaseWorker = strings.TrimSpace(input.CaseWorker)
+ input.TargetObject = strings.TrimSpace(input.TargetObject)
+ input.KwAddress = strings.TrimSpace(input.KwAddress)
+ input.OperationLeader = strings.TrimSpace(input.OperationLeader)
+ input.OperationTeam = strings.TrimSpace(input.OperationTeam)
+ input.Legend = strings.TrimSpace(input.Legend)
+ input.Camera = strings.TrimSpace(input.Camera)
+ input.Router = strings.TrimSpace(input.Router)
+ input.Technology = strings.TrimSpace(input.Technology)
+ input.Switchbox = strings.TrimSpace(input.Switchbox)
+ input.HardDrive = strings.TrimSpace(input.HardDrive)
+ input.Laptop = strings.TrimSpace(input.Laptop)
+ input.Remark = strings.TrimSpace(input.Remark)
+}
+
+func (s *Server) handleCreateOperationJournalEntry(w http.ResponseWriter, r *http.Request) {
+ operationID := strings.TrimSpace(r.PathValue("id"))
+
+ if operationID == "" {
+ writeError(w, http.StatusBadRequest, "Einsatz-ID fehlt")
+ return
+ }
+
+ var input CreateOperationJournalEntryRequest
+
+ if err := readJSON(r, &input); err != nil {
+ writeError(w, http.StatusBadRequest, "Invalid JSON")
+ return
+ }
+
+ content := strings.TrimSpace(input.Content)
+
+ if content == "" {
+ writeError(w, http.StatusBadRequest, "Journal-Eintrag darf nicht leer sein")
+ return
+ }
+
+ user, ok := userFromContext(r.Context())
+ if !ok {
+ writeError(w, http.StatusUnauthorized, "Unauthorized")
+ return
+ }
+
+ tx, err := s.db.Begin(r.Context())
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht erstellt werden")
+ return
+ }
+ defer tx.Rollback(r.Context())
+
+ operation, err := getOperationForHistory(r.Context(), tx, operationID)
+ if errors.Is(err, pgx.ErrNoRows) {
+ writeError(w, http.StatusNotFound, "Einsatz wurde nicht gefunden")
+ return
+ }
+
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Einsatz konnte nicht geladen werden")
+ return
+ }
+
+ changes := []OperationHistoryChange{
+ {
+ Field: "journal",
+ Label: "Journal",
+ OldValue: "",
+ NewValue: content,
+ },
+ }
+
+ if err := recordOperationHistory(r.Context(), tx, operation.ID, user, "journal", changes); err != nil {
+ writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht gespeichert werden")
+ return
+ }
+
+ if err := tx.Commit(r.Context()); err != nil {
+ writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht gespeichert werden")
+ return
+ }
+
+ _ = s.createOperationJournalEvent(
+ r.Context(),
+ user,
+ operation,
+ map[string]any{
+ "operationNumber": operation.OperationNumber,
+ "operationName": operation.OperationName,
+ },
+ )
+
+ writeJSON(w, http.StatusCreated, map[string]any{
+ "ok": true,
+ })
+}
+
+func (s *Server) handleUpdateOperationJournalEntry(w http.ResponseWriter, r *http.Request) {
+ operationID := strings.TrimSpace(r.PathValue("id"))
+ historyID := strings.TrimSpace(r.PathValue("historyId"))
+
+ if operationID == "" {
+ writeError(w, http.StatusBadRequest, "Einsatz-ID fehlt")
+ return
+ }
+
+ if historyID == "" {
+ writeError(w, http.StatusBadRequest, "Journal-ID fehlt")
+ return
+ }
+
+ var input UpdateOperationJournalEntryRequest
+
+ if err := readJSON(r, &input); err != nil {
+ writeError(w, http.StatusBadRequest, "Invalid JSON")
+ return
+ }
+
+ content := strings.TrimSpace(input.Content)
+
+ if content == "" {
+ writeError(w, http.StatusBadRequest, "Journal-Eintrag darf nicht leer sein")
+ return
+ }
+
+ user, ok := userFromContext(r.Context())
+ if !ok {
+ writeError(w, http.StatusUnauthorized, "Unauthorized")
+ return
+ }
+
+ tx, err := s.db.Begin(r.Context())
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht gespeichert werden")
+ return
+ }
+ defer tx.Rollback(r.Context())
+
+ operation, err := getOperationForHistory(r.Context(), tx, operationID)
+ if errors.Is(err, pgx.ErrNoRows) {
+ writeError(w, http.StatusNotFound, "Einsatz wurde nicht gefunden")
+ return
+ }
+
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Einsatz konnte nicht geladen werden")
+ return
+ }
+
+ var changesRaw []byte
+ var createdAt time.Time
+
+ err = tx.QueryRow(
+ r.Context(),
+ `
+ SELECT changes, created_at
+ FROM operation_history
+ WHERE id = $1
+ AND operation_id = $2
+ AND user_id = $3
+ AND action = 'journal'
+ LIMIT 1
+ `,
+ historyID,
+ operationID,
+ user.ID,
+ ).Scan(&changesRaw, &createdAt)
+
+ if errors.Is(err, pgx.ErrNoRows) {
+ writeError(w, http.StatusNotFound, "Journal-Eintrag wurde nicht gefunden oder darf nicht bearbeitet werden")
+ return
+ }
+
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht geladen werden")
+ return
+ }
+
+ if time.Since(createdAt) > 10*time.Minute {
+ writeError(w, http.StatusForbidden, "Journal-Eintrag kann nur innerhalb von 10 Minuten bearbeitet werden")
+ return
+ }
+
+ var oldChanges []OperationHistoryChange
+
+ if len(changesRaw) > 0 {
+ if err := json.Unmarshal(changesRaw, &oldChanges); err != nil {
+ writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht gelesen werden")
+ return
+ }
+ }
+
+ oldContent := ""
+
+ for _, change := range oldChanges {
+ if change.Field == "journal" {
+ oldContent = change.NewValue
+ break
+ }
+ }
+
+ if strings.TrimSpace(oldContent) == content {
+ writeJSON(w, http.StatusOK, map[string]any{
+ "ok": true,
+ })
+ return
+ }
+
+ changes := []OperationHistoryChange{
+ {
+ Field: "journal",
+ Label: "Journal",
+ OldValue: oldContent,
+ NewValue: content,
+ },
+ }
+
+ changesJSON, err := json.Marshal(changes)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht vorbereitet werden")
+ return
+ }
+
+ commandTag, err := tx.Exec(
+ r.Context(),
+ `
+ UPDATE operation_history
+ SET
+ changes = $4::JSONB,
+ updated_at = now()
+ WHERE id = $1
+ AND operation_id = $2
+ AND user_id = $3
+ AND action = 'journal'
+ AND created_at >= now() - interval '10 minutes'
+ `,
+ historyID,
+ operationID,
+ user.ID,
+ string(changesJSON),
+ )
+
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht gespeichert werden")
+ return
+ }
+
+ if commandTag.RowsAffected() == 0 {
+ writeError(w, http.StatusForbidden, "Journal-Eintrag kann nicht mehr bearbeitet werden")
+ return
+ }
+
+ if err := tx.Commit(r.Context()); err != nil {
+ writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht gespeichert werden")
+ return
+ }
+
+ _ = s.createOperationJournalEvent(
+ r.Context(),
+ user,
+ operation,
+ map[string]any{
+ "operationNumber": operation.OperationNumber,
+ "operationName": operation.OperationName,
+ "historyId": historyID,
+ "action": "updated",
+ },
+ )
+
+ writeJSON(w, http.StatusOK, map[string]any{
+ "ok": true,
+ })
+}
+
+func (s *Server) handleListUnreadOperationJournals(w http.ResponseWriter, r *http.Request) {
+ user, ok := userFromContext(r.Context())
+ if !ok {
+ writeError(w, http.StatusUnauthorized, "Unauthorized")
+ return
+ }
+
+ rows, err := s.db.Query(
+ r.Context(),
+ `
+ SELECT entity_id::TEXT
+ FROM notifications
+ WHERE user_id = $1
+ AND visible = false
+ AND type = 'operation.journal'
+ AND entity_type = 'operation'
+ AND entity_id IS NOT NULL
+ AND read_at IS NULL
+ GROUP BY entity_id
+ `,
+ user.ID,
+ )
+
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Ungelesene Journal-Einträge konnten nicht geladen werden")
+ return
+ }
+ defer rows.Close()
+
+ operationIds := []string{}
+
+ for rows.Next() {
+ var operationID string
+ if err := rows.Scan(&operationID); err != nil {
+ writeError(w, http.StatusInternalServerError, "Ungelesene Journal-Einträge konnten nicht gelesen werden")
+ return
+ }
+
+ operationIds = append(operationIds, operationID)
+ }
+
+ writeJSON(w, http.StatusOK, map[string]any{
+ "operationIds": operationIds,
+ })
+}
+
+func (s *Server) handleMarkOperationJournalRead(w http.ResponseWriter, r *http.Request) {
+ user, ok := userFromContext(r.Context())
+ if !ok {
+ writeError(w, http.StatusUnauthorized, "Unauthorized")
+ return
+ }
+
+ operationID := strings.TrimSpace(r.PathValue("id"))
+ if operationID == "" {
+ writeError(w, http.StatusBadRequest, "Einsatz-ID fehlt")
+ return
+ }
+
+ _, err := s.db.Exec(
+ r.Context(),
+ `
+ UPDATE notifications
+ SET read_at = COALESCE(read_at, now())
+ WHERE user_id = $1
+ AND visible = false
+ AND type = 'operation.journal'
+ AND entity_type = 'operation'
+ AND entity_id = $2
+ AND read_at IS NULL
+ `,
+ user.ID,
+ operationID,
+ )
+
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Journal-Status konnte nicht aktualisiert werden")
+ return
+ }
+
+ writeJSON(w, http.StatusOK, map[string]any{
+ "ok": true,
+ })
+}
diff --git a/backend/routes.go b/backend/routes.go
index c122b8a..6b6a71f 100644
--- a/backend/routes.go
+++ b/backend/routes.go
@@ -14,10 +14,64 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("POST /auth/logout", s.handleLogout)
mux.HandleFunc("GET /auth/me", s.requireAuth(s.handleMe))
+ mux.HandleFunc("GET /settings/sessions", s.requireAuth(s.handleListUserSessions))
+ mux.HandleFunc("DELETE /settings/sessions/{id}", s.requireAuth(s.handleRevokeUserSession))
+ mux.HandleFunc("PUT /settings/profile", s.requireAuth(s.handleUpdateSettingsProfile))
+ mux.HandleFunc("PUT /settings/password", s.requireAuth(s.handleUpdateSettingsPassword))
+ mux.HandleFunc("POST /settings/logout-other-sessions", s.requireAuth(s.handleLogoutOtherSessions))
+ mux.HandleFunc("DELETE /settings/account", s.requireAuth(s.handleDeleteOwnAccount))
+
mux.HandleFunc("GET /devices", s.requireAuth(s.handleListDevices))
mux.HandleFunc("POST /devices", s.requireAuth(s.handleCreateDevice))
mux.HandleFunc("PUT /devices/{id}", s.requireAuth(s.handleUpdateDevice))
mux.HandleFunc("GET /devices/{id}/history", s.requireAuth(s.handleListDeviceHistory))
+ mux.HandleFunc("POST /devices/{id}/history", s.requireAuth(s.handleCreateDeviceJournalEntry))
+
+ mux.HandleFunc("GET /device-lookups", s.handleDeviceLookups)
+ mux.HandleFunc("POST /device-manufacturers", s.handleCreateDeviceManufacturer)
+ mux.HandleFunc("POST /device-locations", s.handleCreateDeviceLocation)
+
+ mux.HandleFunc("GET /operations", s.requireAuth(s.requireRight("operations:read", s.handleListOperations)))
+ mux.HandleFunc("POST /operations", s.requireAuth(s.requireRight("operations:write", s.handleCreateOperation)))
+ mux.HandleFunc("PUT /operations/{id}", s.requireAuth(s.requireRight("operations:write", s.handleUpdateOperation)))
+ mux.HandleFunc("GET /operations/{id}/history", s.requireAuth(s.handleListOperationHistory))
+ mux.HandleFunc("POST /operations/{id}/history", s.requireAuth(s.handleCreateOperationJournalEntry))
+ mux.HandleFunc("PUT /operations/{id}/history/{historyId}", s.requireAuth(s.handleUpdateOperationJournalEntry))
+ mux.HandleFunc("GET /operations/journal-unread", s.requireAuth(s.handleListUnreadOperationJournals))
+ mux.HandleFunc("POST /operations/{id}/journal/read", s.requireAuth(s.handleMarkOperationJournalRead))
+
+ mux.HandleFunc("GET /search", s.requireAuth(s.handleGlobalSearch))
+
+ mux.HandleFunc("GET /address-search", s.requireAuth(s.handleAddressSearch))
+
+ mux.HandleFunc("GET /notifications", s.requireAuth(s.handleListNotifications))
+ mux.HandleFunc("POST /notifications/{id}/read", s.requireAuth(s.handleMarkNotificationRead))
+ mux.HandleFunc("POST /notifications/read-all", s.requireAuth(s.handleMarkAllNotificationsRead))
+
+ mux.HandleFunc("GET /events", s.requireAuth(s.handleNotificationEvents))
+
+ mux.HandleFunc("GET /dashboard/operation-changes", s.requireAuth(s.requireRight("operations:read", s.handleDashboardOperationChanges)))
+
+ mux.HandleFunc("GET /dashboard/widgets", s.requireAuth(s.handleListDashboardWidgets))
+ mux.HandleFunc("POST /dashboard/widgets", s.requireAuth(s.handleCreateDashboardWidget))
+ mux.HandleFunc("PUT /dashboard/widgets/{id}", s.requireAuth(s.handleUpdateDashboardWidget))
+ mux.HandleFunc("DELETE /dashboard/widgets/{id}", s.requireAuth(s.handleDeleteDashboardWidget))
+
+ /* admin settings */
+
+ mux.HandleFunc("GET /admin/users", s.requireAuth(s.requireRight("users:read", s.handleAdminListUsers)))
+ mux.HandleFunc("POST /admin/users", s.requireAuth(s.requireRight("users:write", s.handleAdminCreateUser)))
+ mux.HandleFunc("PUT /admin/users/{id}", s.requireAuth(s.requireRight("users:write", s.handleAdminUpdateUser)))
+
+ mux.HandleFunc("GET /admin/teams", s.requireAuth(s.requireRight("teams:read", s.handleAdminListTeams)))
+ mux.HandleFunc("POST /admin/teams", s.requireAuth(s.requireRight("teams:write", s.handleAdminCreateTeam)))
+ mux.HandleFunc("PUT /admin/teams/{id}", s.requireAuth(s.requireRight("teams:write", s.handleAdminUpdateTeam)))
+
+ /* milestone settings */
+
+ mux.HandleFunc("GET /admin/milestone", s.requireAuth(s.requireRight("administration:read", s.handleGetMilestoneSettings)))
+ mux.HandleFunc("PUT /admin/milestone", s.requireAuth(s.requireRight("administration:write", s.handleUpdateMilestoneSettings)))
+ mux.HandleFunc("POST /admin/milestone/token", s.requireAuth(s.requireRight("administration:write", s.handleFetchMilestoneToken)))
return s.cors(mux)
}
diff --git a/backend/search.go b/backend/search.go
new file mode 100644
index 0000000..46b8e94
--- /dev/null
+++ b/backend/search.go
@@ -0,0 +1,401 @@
+// backend\search.go
+
+package main
+
+import (
+ "context"
+ "net/http"
+ "strconv"
+ "strings"
+)
+
+type globalSearchResponse struct {
+ Query string `json:"query"`
+ Groups []globalSearchGroup `json:"groups"`
+}
+
+type globalSearchGroup struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ Items []globalSearchItem `json:"items"`
+}
+
+type globalSearchItem struct {
+ ID string `json:"id"`
+ EntityID string `json:"entityId"`
+ EntityType string `json:"entityType"`
+ Title string `json:"title"`
+ Subtitle string `json:"subtitle"`
+ Href string `json:"href"`
+ Data map[string]any `json:"data,omitempty"`
+}
+
+func (s *Server) handleGlobalSearch(w http.ResponseWriter, r *http.Request) {
+ user, ok := userFromContext(r.Context())
+ if !ok {
+ writeError(w, http.StatusUnauthorized, "Unauthorized")
+ return
+ }
+
+ query := strings.TrimSpace(r.URL.Query().Get("q"))
+ limit := parseSearchLimit(r.URL.Query().Get("limit"), 8)
+
+ if len([]rune(query)) < 2 {
+ writeJSON(w, http.StatusOK, globalSearchResponse{
+ Query: query,
+ Groups: []globalSearchGroup{},
+ })
+ return
+ }
+
+ groups := make([]globalSearchGroup, 0, 3)
+
+ if userCanSearch(user, "operations:read") {
+ items, err := s.searchOperations(r.Context(), query, limit)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Einsätze konnten nicht durchsucht werden")
+ return
+ }
+
+ if len(items) > 0 {
+ groups = append(groups, globalSearchGroup{
+ ID: "operations",
+ Title: "Einsätze",
+ Items: items,
+ })
+ }
+ }
+
+ if userCanSearch(user, "devices:read") {
+ items, err := s.searchDevices(r.Context(), query, limit)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Geräte konnten nicht durchsucht werden")
+ return
+ }
+
+ if len(items) > 0 {
+ groups = append(groups, globalSearchGroup{
+ ID: "devices",
+ Title: "Geräte",
+ Items: items,
+ })
+ }
+ }
+
+ if userCanSearch(user, "users:read") {
+ items, err := s.searchUsers(r.Context(), query, limit)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Benutzer konnten nicht durchsucht werden")
+ return
+ }
+
+ if len(items) > 0 {
+ groups = append(groups, globalSearchGroup{
+ ID: "users",
+ Title: "Benutzer",
+ Items: items,
+ })
+ }
+ }
+
+ writeJSON(w, http.StatusOK, globalSearchResponse{
+ Query: query,
+ Groups: groups,
+ })
+}
+
+func (s *Server) searchOperations(ctx context.Context, query string, limit int) ([]globalSearchItem, error) {
+ pattern := "%" + query + "%"
+
+ rows, err := s.db.Query(
+ ctx,
+ `
+ SELECT
+ id::TEXT,
+ operation_number,
+ operation_name,
+ offense,
+ kw_address,
+ operation_leader,
+ operation_team
+ FROM operations
+ WHERE CONCAT_WS(
+ ' ',
+ operation_number,
+ operation_name,
+ offense,
+ case_worker,
+ target_object,
+ kw_address,
+ operation_leader,
+ operation_team,
+ legend,
+ remark
+ ) ILIKE $1
+ ORDER BY updated_at DESC
+ LIMIT $2
+ `,
+ pattern,
+ limit,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ items := []globalSearchItem{}
+
+ for rows.Next() {
+ var id string
+ var operationNumber string
+ var operationName string
+ var offense string
+ var kwAddress string
+ var operationLeader string
+ var operationTeam string
+
+ if err := rows.Scan(
+ &id,
+ &operationNumber,
+ &operationName,
+ &offense,
+ &kwAddress,
+ &operationLeader,
+ &operationTeam,
+ ); err != nil {
+ return nil, err
+ }
+
+ title := operationName
+ if strings.TrimSpace(title) == "" {
+ title = operationNumber
+ }
+
+ subtitle := joinSearchSubtitle(
+ operationNumber,
+ offense,
+ kwAddress,
+ operationLeader,
+ operationTeam,
+ )
+
+ items = append(items, globalSearchItem{
+ ID: "operation-" + id,
+ EntityID: id,
+ EntityType: "operation",
+ Title: title,
+ Subtitle: subtitle,
+ Href: "/einsaetze",
+ Data: map[string]any{
+ "operationNumber": operationNumber,
+ },
+ })
+ }
+
+ return items, rows.Err()
+}
+
+func (s *Server) searchDevices(ctx context.Context, query string, limit int) ([]globalSearchItem, error) {
+ pattern := "%" + query + "%"
+
+ rows, err := s.db.Query(
+ ctx,
+ `
+ SELECT
+ id::TEXT,
+ inventory_number,
+ manufacturer,
+ model,
+ serial_number,
+ mac_address,
+ location,
+ loan_status
+ FROM devices
+ WHERE CONCAT_WS(
+ ' ',
+ inventory_number,
+ manufacturer,
+ model,
+ serial_number,
+ mac_address,
+ location,
+ loan_status,
+ comment
+ ) ILIKE $1
+ ORDER BY updated_at DESC
+ LIMIT $2
+ `,
+ pattern,
+ limit,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ items := []globalSearchItem{}
+
+ for rows.Next() {
+ var id string
+ var inventoryNumber string
+ var manufacturer string
+ var model string
+ var serialNumber string
+ var macAddress string
+ var location string
+ var loanStatus string
+
+ if err := rows.Scan(
+ &id,
+ &inventoryNumber,
+ &manufacturer,
+ &model,
+ &serialNumber,
+ &macAddress,
+ &location,
+ &loanStatus,
+ ); err != nil {
+ return nil, err
+ }
+
+ items = append(items, globalSearchItem{
+ ID: "device-" + id,
+ EntityID: id,
+ EntityType: "device",
+ Title: inventoryNumber,
+ Subtitle: joinSearchSubtitle(
+ manufacturer,
+ model,
+ serialNumber,
+ macAddress,
+ location,
+ loanStatus,
+ ),
+ Href: "/geraete",
+ Data: map[string]any{
+ "inventoryNumber": inventoryNumber,
+ },
+ })
+ }
+
+ return items, rows.Err()
+}
+
+func (s *Server) searchUsers(ctx context.Context, query string, limit int) ([]globalSearchItem, error) {
+ pattern := "%" + query + "%"
+
+ rows, err := s.db.Query(
+ ctx,
+ `
+ SELECT
+ id::TEXT,
+ COALESCE(username, ''),
+ COALESCE(display_name, ''),
+ email,
+ COALESCE(unit, ''),
+ COALESCE(user_group, '')
+ FROM users
+ WHERE CONCAT_WS(
+ ' ',
+ username,
+ display_name,
+ email,
+ unit,
+ user_group
+ ) ILIKE $1
+ ORDER BY display_name ASC, username ASC, email ASC
+ LIMIT $2
+ `,
+ pattern,
+ limit,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ items := []globalSearchItem{}
+
+ for rows.Next() {
+ var id string
+ var username string
+ var displayName string
+ var email string
+ var unit string
+ var group string
+
+ if err := rows.Scan(
+ &id,
+ &username,
+ &displayName,
+ &email,
+ &unit,
+ &group,
+ ); err != nil {
+ return nil, err
+ }
+
+ title := displayName
+ if strings.TrimSpace(title) == "" {
+ title = username
+ }
+ if strings.TrimSpace(title) == "" {
+ title = email
+ }
+
+ items = append(items, globalSearchItem{
+ ID: "user-" + id,
+ EntityID: id,
+ EntityType: "user",
+ Title: title,
+ Subtitle: joinSearchSubtitle(email, unit, group),
+ Href: "/administration",
+ Data: map[string]any{
+ "username": username,
+ "email": email,
+ },
+ })
+ }
+
+ return items, rows.Err()
+}
+
+func parseSearchLimit(value string, fallback int) int {
+ limit, err := strconv.Atoi(value)
+ if err != nil {
+ return fallback
+ }
+
+ if limit < 1 {
+ return fallback
+ }
+
+ if limit > 25 {
+ return 25
+ }
+
+ return limit
+}
+
+func userCanSearch(user User, right string) bool {
+ for _, userRight := range user.Rights {
+ if userRight == "admin" || userRight == right {
+ return true
+ }
+ }
+
+ return false
+}
+
+func joinSearchSubtitle(values ...string) string {
+ parts := make([]string, 0, len(values))
+
+ for _, value := range values {
+ value = strings.TrimSpace(value)
+ if value != "" {
+ parts = append(parts, value)
+ }
+ }
+
+ return strings.Join(parts, " · ")
+}
diff --git a/backend/server.go b/backend/server.go
index a44a688..fb5ad53 100644
--- a/backend/server.go
+++ b/backend/server.go
@@ -18,7 +18,10 @@ import (
type contextKey string
-const userContextKey contextKey = "user"
+const (
+ userContextKey contextKey = "user"
+ sessionContextKey contextKey = "session"
+)
type Server struct {
db *pgxpool.Pool
@@ -27,15 +30,16 @@ type Server struct {
}
type User struct {
- ID string `json:"id"`
- Username string `json:"username"`
- DisplayName string `json:"displayName"`
- Email string `json:"email"`
- Avatar string `json:"avatar"`
- Unit string `json:"unit"`
- Group string `json:"group"`
- Rights []string `json:"rights"`
- Teams []Team `json:"teams"`
+ ID string `json:"id"`
+ Username string `json:"username"`
+ DisplayName string `json:"displayName"`
+ Email string `json:"email"`
+ Avatar string `json:"avatar"`
+ Unit string `json:"unit"`
+ Group string `json:"group"`
+ Rights []string `json:"rights"`
+ Teams []Team `json:"teams"`
+ TokenVersion int `json:"-"`
}
type Team struct {
@@ -46,25 +50,35 @@ type Team struct {
Current bool `json:"current"`
}
+type ClientSessionInfoRequest struct {
+ Browser string `json:"browser"`
+ OperatingSystem string `json:"operatingSystem"`
+ DeviceType string `json:"deviceType"`
+}
+
type RegisterRequest struct {
- Username string `json:"username"`
- DisplayName string `json:"displayName"`
- Email string `json:"email"`
- Password string `json:"password"`
- Avatar string `json:"avatar"`
- Unit string `json:"unit"`
- Group string `json:"group"`
- Rights []string `json:"rights"`
+ Username string `json:"username"`
+ DisplayName string `json:"displayName"`
+ Email string `json:"email"`
+ Password string `json:"password"`
+ Avatar string `json:"avatar"`
+ Unit string `json:"unit"`
+ Group string `json:"group"`
+ Rights []string `json:"rights"`
+ SessionInfo ClientSessionInfoRequest `json:"sessionInfo"`
}
type LoginRequest struct {
- Identifier string `json:"identifier"`
- Password string `json:"password"`
+ Identifier string `json:"identifier"`
+ Password string `json:"password"`
+ SessionInfo ClientSessionInfoRequest `json:"sessionInfo"`
}
type Claims struct {
- UserID string `json:"user_id"`
- Email string `json:"email"`
+ UserID string `json:"user_id"`
+ Email string `json:"email"`
+ TokenVersion int `json:"token_version"`
+ SessionID string `json:"session_id"`
jwt.RegisteredClaims
}
@@ -143,7 +157,8 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
COALESCE(avatar, ''),
COALESCE(unit, ''),
COALESCE(user_group, ''),
- COALESCE(rights, '{}'::TEXT[])
+ COALESCE(rights, '{}'::TEXT[]),
+ token_version
`,
username,
displayName,
@@ -162,6 +177,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
&user.Unit,
&user.Group,
&user.Rights,
+ &user.TokenVersion,
)
if err != nil {
@@ -169,7 +185,18 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
return
}
- if err := s.setSessionCookie(w, user); err != nil {
+ sessionID, err := s.createUserSession(
+ r.Context(),
+ user.ID,
+ input.SessionInfo,
+ r,
+ )
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Could not create session")
+ return
+ }
+
+ if err := s.setSessionCookie(w, user, sessionID); err != nil {
writeError(w, http.StatusInternalServerError, "Could not create session")
return
}
@@ -211,7 +238,18 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
return
}
- if err := s.setSessionCookie(w, user); err != nil {
+ sessionID, err := s.createUserSession(
+ r.Context(),
+ user.ID,
+ input.SessionInfo,
+ r,
+ )
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Could not create session")
+ return
+ }
+
+ if err := s.setSessionCookie(w, user, sessionID); err != nil {
writeError(w, http.StatusInternalServerError, "Could not create session")
return
}
@@ -222,6 +260,19 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
+ if sessionID, ok := s.currentSessionIDFromCookie(r); ok {
+ _, _ = s.db.Exec(
+ r.Context(),
+ `
+ UPDATE user_sessions
+ SET revoked_at = now()
+ WHERE id = $1
+ AND revoked_at IS NULL
+ `,
+ sessionID,
+ )
+ }
+
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: "",
@@ -250,12 +301,14 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
})
}
-func (s *Server) setSessionCookie(w http.ResponseWriter, user User) error {
+func (s *Server) setSessionCookie(w http.ResponseWriter, user User, sessionID string) error {
now := time.Now()
claims := Claims{
- UserID: user.ID,
- Email: user.Email,
+ UserID: user.ID,
+ Email: user.Email,
+ TokenVersion: user.TokenVersion,
+ SessionID: sessionID,
RegisteredClaims: jwt.RegisteredClaims{
Subject: user.ID,
IssuedAt: jwt.NewNumericDate(now),
@@ -277,9 +330,7 @@ func (s *Server) setSessionCookie(w http.ResponseWriter, user User) error {
MaxAge: 60 * 60 * 24,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
-
- // In Produktion auf true setzen, wenn HTTPS aktiv ist.
- Secure: false,
+ Secure: false,
})
return nil
@@ -314,7 +365,25 @@ func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
return
}
+ if claims.SessionID == "" {
+ writeError(w, http.StatusUnauthorized, "Unauthorized")
+ return
+ }
+
+ sessionActive, err := s.isUserSessionActive(
+ r.Context(),
+ user.ID,
+ claims.SessionID,
+ )
+ if err != nil || !sessionActive {
+ writeError(w, http.StatusUnauthorized, "Unauthorized")
+ return
+ }
+
+ _ = s.touchUserSession(r.Context(), user.ID, claims.SessionID)
+
ctx := context.WithValue(r.Context(), userContextKey, user)
+ ctx = context.WithValue(ctx, sessionContextKey, claims.SessionID)
next(w, r.WithContext(ctx))
}
@@ -324,6 +393,8 @@ func (s *Server) getUserByIdentifier(ctx context.Context, identifier string) (Us
var user User
var passwordHash string
+ identifier = strings.ToLower(strings.TrimSpace(identifier))
+
err := s.db.QueryRow(
ctx,
`
@@ -336,7 +407,8 @@ func (s *Server) getUserByIdentifier(ctx context.Context, identifier string) (Us
COALESCE(avatar, ''),
COALESCE(unit, ''),
COALESCE(user_group, ''),
- COALESCE(rights, '{}'::TEXT[])
+ COALESCE(rights, '{}'::TEXT[]),
+ token_version
FROM users
WHERE lower(email) = $1 OR lower(username) = $1
LIMIT 1
@@ -352,6 +424,7 @@ func (s *Server) getUserByIdentifier(ctx context.Context, identifier string) (Us
&user.Unit,
&user.Group,
&user.Rights,
+ &user.TokenVersion,
)
if err != nil {
@@ -382,7 +455,8 @@ func (s *Server) getUserByID(ctx context.Context, userID string) (User, error) {
COALESCE(avatar, ''),
COALESCE(unit, ''),
COALESCE(user_group, ''),
- COALESCE(rights, '{}'::TEXT[])
+ COALESCE(rights, '{}'::TEXT[]),
+ token_version
FROM users
WHERE id = $1
LIMIT 1
@@ -397,6 +471,7 @@ func (s *Server) getUserByID(ctx context.Context, userID string) (User, error) {
&user.Unit,
&user.Group,
&user.Rights,
+ &user.TokenVersion,
)
if err != nil {
diff --git a/backend/setup/main.go b/backend/setup/main.go
index 4bf0da6..bd6ab7d 100644
--- a/backend/setup/main.go
+++ b/backend/setup/main.go
@@ -41,7 +41,7 @@ func main() {
log.Fatal("DATABASE_URL enthält keinen Datenbanknamen")
}
- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
if *reset {
@@ -177,21 +177,40 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
`
CREATE TABLE IF NOT EXISTS users (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ username TEXT UNIQUE,
+ display_name TEXT,
+
+ email TEXT NOT NULL UNIQUE,
+ password_hash TEXT NOT NULL,
+
+ avatar TEXT,
+ unit TEXT,
+ user_group TEXT,
+ rights TEXT[] NOT NULL DEFAULT '{}'::TEXT[],
+ token_version INTEGER NOT NULL DEFAULT 1,
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+ )
+ `,
+
+ `
+ CREATE TABLE IF NOT EXISTS user_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- username TEXT UNIQUE,
- display_name TEXT,
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
- email TEXT NOT NULL UNIQUE,
- password_hash TEXT NOT NULL,
-
- avatar TEXT,
- unit TEXT,
- user_group TEXT,
- rights TEXT[] NOT NULL DEFAULT '{}'::TEXT[],
+ browser TEXT NOT NULL DEFAULT '',
+ operating_system TEXT NOT NULL DEFAULT '',
+ device_type TEXT NOT NULL DEFAULT '',
+ user_agent TEXT NOT NULL DEFAULT '',
+ ip_address TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
- updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+ last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ revoked_at TIMESTAMPTZ
)
`,
@@ -219,6 +238,27 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
)
`,
+ `
+ CREATE TABLE IF NOT EXISTS dashboard_widgets (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+
+ type TEXT NOT NULL,
+ title TEXT NOT NULL DEFAULT '',
+
+ x INTEGER NOT NULL DEFAULT 0,
+ y INTEGER NOT NULL DEFAULT 0,
+ w INTEGER NOT NULL DEFAULT 4,
+ h INTEGER NOT NULL DEFAULT 2,
+
+ config JSONB NOT NULL DEFAULT '{}'::JSONB,
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+ )
+ `,
+
`
CREATE TABLE IF NOT EXISTS devices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -230,6 +270,7 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
mac_address TEXT NOT NULL DEFAULT '',
location TEXT NOT NULL DEFAULT '',
loan_status TEXT NOT NULL DEFAULT 'Verfügbar',
+ is_bao_device BOOLEAN NOT NULL DEFAULT false,
comment TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
@@ -237,6 +278,30 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
)
`,
+ `
+ CREATE TABLE IF NOT EXISTS device_manufacturers (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ name TEXT NOT NULL,
+ normalized_name TEXT NOT NULL UNIQUE,
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+ )
+ `,
+
+ `
+ CREATE TABLE IF NOT EXISTS device_locations (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ name TEXT NOT NULL,
+ normalized_name TEXT NOT NULL UNIQUE,
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+ )
+ `,
+
`
CREATE TABLE IF NOT EXISTS device_relations (
device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
@@ -263,13 +328,106 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
)
`,
+ `
+ CREATE TABLE IF NOT EXISTS operations (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ operation_number TEXT NOT NULL,
+ operation_name TEXT NOT NULL DEFAULT '',
+ offense TEXT NOT NULL DEFAULT '',
+ case_worker TEXT NOT NULL DEFAULT '',
+ target_object TEXT NOT NULL DEFAULT '',
+ kw_address TEXT NOT NULL DEFAULT '',
+ operation_leader TEXT NOT NULL DEFAULT '',
+ operation_team TEXT NOT NULL DEFAULT '',
+ legend TEXT NOT NULL DEFAULT '',
+ camera TEXT NOT NULL DEFAULT '',
+ router TEXT NOT NULL DEFAULT '',
+ technology TEXT NOT NULL DEFAULT '',
+ switchbox TEXT NOT NULL DEFAULT '',
+ hard_drive TEXT NOT NULL DEFAULT '',
+ laptop TEXT NOT NULL DEFAULT '',
+ remark TEXT NOT NULL DEFAULT '',
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+ )
+ `,
+
+ `
+ CREATE TABLE IF NOT EXISTS operation_history (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ operation_id UUID NOT NULL REFERENCES operations(id) ON DELETE CASCADE,
+ user_id UUID REFERENCES users(id) ON DELETE SET NULL,
+
+ action TEXT NOT NULL,
+ changes JSONB NOT NULL DEFAULT '[]'::JSONB,
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+ )
+ `,
+
+ `
+ CREATE TABLE IF NOT EXISTS notifications (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+
+ type TEXT NOT NULL DEFAULT 'info',
+ title TEXT NOT NULL,
+ message TEXT NOT NULL DEFAULT '',
+ visible BOOLEAN NOT NULL DEFAULT true,
+
+ entity_type TEXT NOT NULL DEFAULT '',
+ entity_id UUID,
+
+ data JSONB NOT NULL DEFAULT '{}'::JSONB,
+
+ read_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+ )
+ `,
+
+ `
+ CREATE TABLE IF NOT EXISTS milestone_settings (
+ id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1),
+
+ host TEXT NOT NULL DEFAULT '',
+ username TEXT NOT NULL DEFAULT '',
+ password_encrypted BYTEA,
+ skip_tls_verify BOOLEAN NOT NULL DEFAULT false,
+
+ access_token_encrypted BYTEA,
+ token_type TEXT NOT NULL DEFAULT '',
+ token_expires_at TIMESTAMPTZ,
+ token_updated_at TIMESTAMPTZ,
+
+ server_id TEXT NOT NULL DEFAULT '',
+ server_name TEXT NOT NULL DEFAULT '',
+ server_version TEXT NOT NULL DEFAULT '',
+ server_updated_at TIMESTAMPTZ,
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+ )
+ `,
+
`CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)`,
`CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)`,
+ `CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id)`,
+ `CREATE INDEX IF NOT EXISTS idx_user_sessions_active ON user_sessions(user_id, revoked_at)`,
+ `CREATE INDEX IF NOT EXISTS idx_user_sessions_last_seen_at ON user_sessions(last_seen_at)`,
+
`CREATE INDEX IF NOT EXISTS idx_teams_name ON teams(name)`,
`CREATE INDEX IF NOT EXISTS idx_user_teams_user_id ON user_teams(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_user_teams_team_id ON user_teams(team_id)`,
+ `CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_user_id ON dashboard_widgets(user_id)`,
+ `CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_position ON dashboard_widgets(user_id, y, x)`,
+
`CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_inventory_number_unique ON devices(lower(inventory_number))`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_serial_number_unique ON devices(lower(serial_number)) WHERE serial_number <> ''`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_mac_address_unique ON devices(lower(mac_address)) WHERE mac_address <> ''`,
@@ -279,12 +437,31 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
`CREATE INDEX IF NOT EXISTS idx_devices_location ON devices(location)`,
`CREATE INDEX IF NOT EXISTS idx_devices_loan_status ON devices(loan_status)`,
+ `CREATE INDEX IF NOT EXISTS idx_device_manufacturers_name ON device_manufacturers(name)`,
+ `CREATE INDEX IF NOT EXISTS idx_device_locations_name ON device_locations(name)`,
+
`CREATE INDEX IF NOT EXISTS idx_device_relations_device_id ON device_relations(device_id)`,
`CREATE INDEX IF NOT EXISTS idx_device_relations_related_device_id ON device_relations(related_device_id)`,
`CREATE INDEX IF NOT EXISTS idx_device_history_device_id ON device_history(device_id)`,
`CREATE INDEX IF NOT EXISTS idx_device_history_user_id ON device_history(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_device_history_created_at ON device_history(created_at)`,
+
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_operations_operation_number_unique ON operations(lower(operation_number))`,
+ `CREATE INDEX IF NOT EXISTS idx_operations_operation_name ON operations(operation_name)`,
+ `CREATE INDEX IF NOT EXISTS idx_operations_offense ON operations(offense)`,
+ `CREATE INDEX IF NOT EXISTS idx_operations_kw_address ON operations(kw_address)`,
+ `CREATE INDEX IF NOT EXISTS idx_operations_operation_leader ON operations(operation_leader)`,
+ `CREATE INDEX IF NOT EXISTS idx_operations_operation_team ON operations(operation_team)`,
+
+ `CREATE INDEX IF NOT EXISTS idx_operation_history_operation_id ON operation_history(operation_id)`,
+ `CREATE INDEX IF NOT EXISTS idx_operation_history_user_id ON operation_history(user_id)`,
+ `CREATE INDEX IF NOT EXISTS idx_operation_history_created_at ON operation_history(created_at)`,
+
+ `CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id)`,
+ `CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications(created_at)`,
+ `CREATE INDEX IF NOT EXISTS idx_notifications_read_at ON notifications(read_at)`,
+ `CREATE INDEX IF NOT EXISTS idx_notifications_entity ON notifications(entity_type, entity_id)`,
}
for _, query := range queries {
@@ -305,7 +482,10 @@ func createAdminIfNotExists(ctx context.Context, db *pgxpool.Pool) (string, bool
adminAvatar := env("ADMIN_AVATAR", "")
adminUnit := env("ADMIN_UNIT", "Administration")
adminGroup := env("ADMIN_GROUP", "admin")
- adminRights := splitCSV(env("ADMIN_RIGHTS", "admin,users:read,users:write,teams:read,teams:write,devices:read,devices:write"))
+ adminRights := splitCSV(env(
+ "ADMIN_RIGHTS",
+ "admin,dashboard:read,settings:read,administration:read,administration:write,users:read,users:write,teams:read,teams:write,devices:read,devices:write,operations:read,operations:write,calendar:read,documents:read,reports:read",
+ ))
adminTeamName := env("ADMIN_TEAM_NAME", "Administration")
adminTeamInitial := env("ADMIN_TEAM_INITIAL", "A")
diff --git a/backend/user_sessions.go b/backend/user_sessions.go
new file mode 100644
index 0000000..c408eb4
--- /dev/null
+++ b/backend/user_sessions.go
@@ -0,0 +1,333 @@
+// backend\user_sessions.go
+
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "net"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/golang-jwt/jwt/v5"
+)
+
+type userSessionResponse struct {
+ ID string `json:"id"`
+ Browser string `json:"browser"`
+ OperatingSystem string `json:"operatingSystem"`
+ DeviceType string `json:"deviceType"`
+ UserAgent string `json:"userAgent"`
+ IPAddress string `json:"ipAddress"`
+ CreatedAt time.Time `json:"createdAt"`
+ LastSeenAt time.Time `json:"lastSeenAt"`
+ Current bool `json:"current"`
+}
+
+func (s *Server) createUserSession(
+ ctx context.Context,
+ userID string,
+ input ClientSessionInfoRequest,
+ r *http.Request,
+) (string, error) {
+ browser := strings.TrimSpace(input.Browser)
+ operatingSystem := strings.TrimSpace(input.OperatingSystem)
+ deviceType := strings.TrimSpace(input.DeviceType)
+ userAgent := r.UserAgent()
+
+ if browser == "" {
+ browser = detectBrowserFromUserAgent(userAgent)
+ }
+
+ if operatingSystem == "" {
+ operatingSystem = detectOperatingSystemFromUserAgent(userAgent)
+ }
+
+ if deviceType == "" {
+ deviceType = detectDeviceTypeFromUserAgent(userAgent)
+ }
+
+ var sessionID string
+
+ err := s.db.QueryRow(
+ ctx,
+ `
+ INSERT INTO user_sessions (
+ user_id,
+ browser,
+ operating_system,
+ device_type,
+ user_agent,
+ ip_address
+ )
+ VALUES ($1, $2, $3, $4, $5, $6)
+ RETURNING id
+ `,
+ userID,
+ browser,
+ operatingSystem,
+ deviceType,
+ userAgent,
+ getRequestIP(r),
+ ).Scan(&sessionID)
+
+ return sessionID, err
+}
+
+func (s *Server) isUserSessionActive(
+ ctx context.Context,
+ userID string,
+ sessionID string,
+) (bool, error) {
+ var exists bool
+
+ err := s.db.QueryRow(
+ ctx,
+ `
+ SELECT EXISTS(
+ SELECT 1
+ FROM user_sessions
+ WHERE id = $1
+ AND user_id = $2
+ AND revoked_at IS NULL
+ )
+ `,
+ sessionID,
+ userID,
+ ).Scan(&exists)
+
+ return exists, err
+}
+
+func (s *Server) touchUserSession(
+ ctx context.Context,
+ userID string,
+ sessionID string,
+) error {
+ _, err := s.db.Exec(
+ ctx,
+ `
+ UPDATE user_sessions
+ SET last_seen_at = now()
+ WHERE id = $1
+ AND user_id = $2
+ AND revoked_at IS NULL
+ `,
+ sessionID,
+ userID,
+ )
+
+ return err
+}
+
+func (s *Server) handleListUserSessions(w http.ResponseWriter, r *http.Request) {
+ userID, ok := s.currentUserIDFromRequest(r)
+ if !ok {
+ writeSettingsError(w, http.StatusUnauthorized, "Nicht angemeldet")
+ return
+ }
+
+ currentSessionID, _ := s.currentSessionIDFromRequest(r)
+
+ rows, err := s.db.Query(
+ r.Context(),
+ `
+ SELECT
+ id,
+ browser,
+ operating_system,
+ device_type,
+ user_agent,
+ ip_address,
+ created_at,
+ last_seen_at
+ FROM user_sessions
+ WHERE user_id = $1
+ AND revoked_at IS NULL
+ ORDER BY
+ CASE WHEN id = $2 THEN 0 ELSE 1 END,
+ last_seen_at DESC
+ `,
+ userID,
+ currentSessionID,
+ )
+ if err != nil {
+ writeSettingsError(w, http.StatusInternalServerError, "Sitzungen konnten nicht geladen werden")
+ return
+ }
+ defer rows.Close()
+
+ sessions := []userSessionResponse{}
+
+ for rows.Next() {
+ var session userSessionResponse
+
+ if err := rows.Scan(
+ &session.ID,
+ &session.Browser,
+ &session.OperatingSystem,
+ &session.DeviceType,
+ &session.UserAgent,
+ &session.IPAddress,
+ &session.CreatedAt,
+ &session.LastSeenAt,
+ ); err != nil {
+ writeSettingsError(w, http.StatusInternalServerError, "Sitzungen konnten nicht gelesen werden")
+ return
+ }
+
+ session.Current = session.ID == currentSessionID
+ sessions = append(sessions, session)
+ }
+
+ if err := rows.Err(); err != nil {
+ writeSettingsError(w, http.StatusInternalServerError, "Sitzungen konnten nicht gelesen werden")
+ return
+ }
+
+ writeSettingsJSON(w, http.StatusOK, map[string]any{
+ "sessions": sessions,
+ })
+}
+
+func (s *Server) handleRevokeUserSession(w http.ResponseWriter, r *http.Request) {
+ userID, ok := s.currentUserIDFromRequest(r)
+ if !ok {
+ writeSettingsError(w, http.StatusUnauthorized, "Nicht angemeldet")
+ return
+ }
+
+ sessionID := r.PathValue("id")
+ currentSessionID, _ := s.currentSessionIDFromRequest(r)
+
+ if sessionID == "" {
+ writeSettingsError(w, http.StatusBadRequest, "Sitzung fehlt")
+ return
+ }
+
+ if sessionID == currentSessionID {
+ writeSettingsError(w, http.StatusBadRequest, "Die aktuelle Sitzung kann hier nicht abgemeldet werden")
+ return
+ }
+
+ commandTag, err := s.db.Exec(
+ r.Context(),
+ `
+ UPDATE user_sessions
+ SET revoked_at = now()
+ WHERE id = $1
+ AND user_id = $2
+ AND revoked_at IS NULL
+ `,
+ sessionID,
+ userID,
+ )
+ if err != nil {
+ writeSettingsError(w, http.StatusInternalServerError, "Sitzung konnte nicht abgemeldet werden")
+ return
+ }
+
+ if commandTag.RowsAffected() == 0 {
+ writeSettingsError(w, http.StatusNotFound, "Sitzung wurde nicht gefunden")
+ return
+ }
+
+ writeSettingsJSON(w, http.StatusOK, map[string]any{
+ "message": "Sitzung wurde abgemeldet",
+ })
+}
+
+func (s *Server) currentSessionIDFromRequest(r *http.Request) (string, bool) {
+ sessionID, ok := r.Context().Value(sessionContextKey).(string)
+
+ return sessionID, ok && sessionID != ""
+}
+
+func (s *Server) currentSessionIDFromCookie(r *http.Request) (string, bool) {
+ cookie, err := r.Cookie("session")
+ if err != nil {
+ return "", false
+ }
+
+ claims := &Claims{}
+
+ token, err := jwt.ParseWithClaims(cookie.Value, claims, func(token *jwt.Token) (any, error) {
+ return s.jwtSecret, nil
+ })
+ if err != nil || !token.Valid || claims.SessionID == "" {
+ return "", false
+ }
+
+ return claims.SessionID, true
+}
+
+func getRequestIP(r *http.Request) string {
+ forwardedFor := r.Header.Get("X-Forwarded-For")
+ if forwardedFor != "" {
+ parts := strings.Split(forwardedFor, ",")
+ return strings.TrimSpace(parts[0])
+ }
+
+ realIP := r.Header.Get("X-Real-IP")
+ if realIP != "" {
+ return strings.TrimSpace(realIP)
+ }
+
+ host, _, err := net.SplitHostPort(r.RemoteAddr)
+ if err == nil {
+ return host
+ }
+
+ return r.RemoteAddr
+}
+
+func detectBrowserFromUserAgent(userAgent string) string {
+ switch {
+ case strings.Contains(userAgent, "Edg/"):
+ return "Microsoft Edge"
+ case strings.Contains(userAgent, "Chrome/") && !strings.Contains(userAgent, "Edg/"):
+ return "Google Chrome"
+ case strings.Contains(userAgent, "Firefox/"):
+ return "Mozilla Firefox"
+ case strings.Contains(userAgent, "Safari/") && !strings.Contains(userAgent, "Chrome/"):
+ return "Safari"
+ default:
+ return "Unbekannter Browser"
+ }
+}
+
+func detectOperatingSystemFromUserAgent(userAgent string) string {
+ switch {
+ case strings.Contains(userAgent, "Windows"):
+ return "Windows"
+ case strings.Contains(userAgent, "Mac OS X"):
+ return "macOS"
+ case strings.Contains(userAgent, "iPhone") || strings.Contains(userAgent, "iPad"):
+ return "iOS"
+ case strings.Contains(userAgent, "Android"):
+ return "Android"
+ case strings.Contains(userAgent, "Linux"):
+ return "Linux"
+ default:
+ return "Unbekanntes Betriebssystem"
+ }
+}
+
+func detectDeviceTypeFromUserAgent(userAgent string) string {
+ switch {
+ case strings.Contains(userAgent, "iPhone") || strings.Contains(userAgent, "Android") && strings.Contains(userAgent, "Mobile"):
+ return "Smartphone"
+ case strings.Contains(userAgent, "iPad") || strings.Contains(userAgent, "Tablet"):
+ return "Tablet"
+ default:
+ return "Desktop"
+ }
+}
+
+func writeSessionJSON(w http.ResponseWriter, status int, payload any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+
+ _ = json.NewEncoder(w).Encode(payload)
+}
diff --git a/backend/user_settings.go b/backend/user_settings.go
new file mode 100644
index 0000000..7488aef
--- /dev/null
+++ b/backend/user_settings.go
@@ -0,0 +1,379 @@
+// backend/user_settings.go
+
+package main
+
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+ "strings"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgconn"
+ "golang.org/x/crypto/bcrypt"
+)
+
+type updateSettingsProfileRequest struct {
+ DisplayName string `json:"displayName"`
+ Username string `json:"username"`
+ Email string `json:"email"`
+ Avatar string `json:"avatar"`
+ Unit string `json:"unit"`
+ Group string `json:"group"`
+}
+
+type updateSettingsPasswordRequest struct {
+ CurrentPassword string `json:"currentPassword"`
+ NewPassword string `json:"newPassword"`
+ ConfirmPassword string `json:"confirmPassword"`
+}
+
+type confirmPasswordRequest struct {
+ Password string `json:"password"`
+}
+
+func (s *Server) handleUpdateSettingsProfile(w http.ResponseWriter, r *http.Request) {
+ userID, ok := s.currentUserIDFromRequest(r)
+ if !ok {
+ writeSettingsError(w, http.StatusUnauthorized, "Nicht angemeldet")
+ return
+ }
+
+ var payload updateSettingsProfileRequest
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ writeSettingsError(w, http.StatusBadRequest, "Ungültige Anfrage")
+ return
+ }
+
+ username := normalizeUsername(payload.Username)
+ email := normalizeEmail(payload.Email)
+ displayName := strings.TrimSpace(payload.DisplayName)
+ avatar := strings.TrimSpace(payload.Avatar)
+ unit := strings.TrimSpace(payload.Unit)
+ group := strings.TrimSpace(payload.Group)
+
+ if username == "" {
+ writeSettingsError(w, http.StatusBadRequest, "Benutzername ist erforderlich")
+ return
+ }
+
+ if email == "" {
+ writeSettingsError(w, http.StatusBadRequest, "E-Mail-Adresse ist erforderlich")
+ return
+ }
+
+ if displayName == "" {
+ displayName = username
+ }
+
+ if group == "" {
+ group = "user"
+ }
+
+ commandTag, err := s.db.Exec(
+ r.Context(),
+ `
+ UPDATE users
+ SET
+ username = $1,
+ display_name = $2,
+ email = $3,
+ avatar = $4,
+ unit = $5,
+ user_group = $6,
+ updated_at = now()
+ WHERE id = $7
+ `,
+ username,
+ displayName,
+ email,
+ avatar,
+ unit,
+ group,
+ userID,
+ )
+
+ if isPostgresUniqueViolation(err) {
+ writeSettingsError(w, http.StatusConflict, "Benutzername oder E-Mail-Adresse wird bereits verwendet")
+ return
+ }
+
+ if err != nil {
+ writeSettingsError(w, http.StatusInternalServerError, "Profil konnte nicht gespeichert werden")
+ return
+ }
+
+ if commandTag.RowsAffected() == 0 {
+ writeSettingsError(w, http.StatusNotFound, "Benutzer wurde nicht gefunden")
+ return
+ }
+
+ user, err := s.getUserByID(r.Context(), userID)
+ if err != nil {
+ writeSettingsError(w, http.StatusInternalServerError, "Profil wurde gespeichert, konnte aber nicht neu geladen werden")
+ return
+ }
+
+ writeSettingsJSON(w, http.StatusOK, map[string]any{
+ "message": "Profil gespeichert",
+ "user": user,
+ })
+}
+
+func (s *Server) handleUpdateSettingsPassword(w http.ResponseWriter, r *http.Request) {
+ userID, ok := s.currentUserIDFromRequest(r)
+ if !ok {
+ writeSettingsError(w, http.StatusUnauthorized, "Nicht angemeldet")
+ return
+ }
+
+ var payload updateSettingsPasswordRequest
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ writeSettingsError(w, http.StatusBadRequest, "Ungültige Anfrage")
+ return
+ }
+
+ payload.CurrentPassword = strings.TrimSpace(payload.CurrentPassword)
+ payload.NewPassword = strings.TrimSpace(payload.NewPassword)
+ payload.ConfirmPassword = strings.TrimSpace(payload.ConfirmPassword)
+
+ if payload.CurrentPassword == "" {
+ writeSettingsError(w, http.StatusBadRequest, "Aktuelles Passwort ist erforderlich")
+ return
+ }
+
+ if len(payload.NewPassword) < 8 {
+ writeSettingsError(w, http.StatusBadRequest, "Das neue Passwort muss mindestens 8 Zeichen lang sein")
+ return
+ }
+
+ if payload.NewPassword != payload.ConfirmPassword {
+ writeSettingsError(w, http.StatusBadRequest, "Die neuen Passwörter stimmen nicht überein")
+ return
+ }
+
+ var currentPasswordHash string
+
+ err := s.db.QueryRow(
+ r.Context(),
+ `
+ SELECT password_hash
+ FROM users
+ WHERE id = $1
+ LIMIT 1
+ `,
+ userID,
+ ).Scan(¤tPasswordHash)
+
+ if errors.Is(err, pgx.ErrNoRows) {
+ writeSettingsError(w, http.StatusNotFound, "Benutzer wurde nicht gefunden")
+ return
+ }
+
+ if err != nil {
+ writeSettingsError(w, http.StatusInternalServerError, "Passwort konnte nicht geprüft werden")
+ return
+ }
+
+ if err := bcrypt.CompareHashAndPassword([]byte(currentPasswordHash), []byte(payload.CurrentPassword)); err != nil {
+ writeSettingsError(w, http.StatusBadRequest, "Aktuelles Passwort ist nicht korrekt")
+ return
+ }
+
+ nextPasswordHash, err := bcrypt.GenerateFromPassword([]byte(payload.NewPassword), bcrypt.DefaultCost)
+ if err != nil {
+ writeSettingsError(w, http.StatusInternalServerError, "Passwort konnte nicht vorbereitet werden")
+ return
+ }
+
+ commandTag, err := s.db.Exec(
+ r.Context(),
+ `
+ UPDATE users
+ SET
+ password_hash = $1,
+ updated_at = now()
+ WHERE id = $2
+ `,
+ string(nextPasswordHash),
+ userID,
+ )
+
+ if err != nil {
+ writeSettingsError(w, http.StatusInternalServerError, "Passwort konnte nicht gespeichert werden")
+ return
+ }
+
+ if commandTag.RowsAffected() == 0 {
+ writeSettingsError(w, http.StatusNotFound, "Benutzer wurde nicht gefunden")
+ return
+ }
+
+ writeSettingsJSON(w, http.StatusOK, map[string]any{
+ "message": "Passwort gespeichert",
+ })
+}
+
+func (s *Server) handleLogoutOtherSessions(w http.ResponseWriter, r *http.Request) {
+ userID, ok := s.currentUserIDFromRequest(r)
+ if !ok {
+ writeSettingsError(w, http.StatusUnauthorized, "Nicht angemeldet")
+ return
+ }
+
+ currentSessionID, ok := s.currentSessionIDFromRequest(r)
+ if !ok {
+ writeSettingsError(w, http.StatusUnauthorized, "Aktuelle Sitzung wurde nicht gefunden")
+ return
+ }
+
+ var payload confirmPasswordRequest
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ writeSettingsError(w, http.StatusBadRequest, "Ungültige Anfrage")
+ return
+ }
+
+ if err := s.verifyUserPassword(r, userID, payload.Password); err != nil {
+ writeSettingsError(w, http.StatusBadRequest, "Passwort ist nicht korrekt")
+ return
+ }
+
+ _, err := s.db.Exec(
+ r.Context(),
+ `
+ UPDATE user_sessions
+ SET revoked_at = now()
+ WHERE user_id = $1
+ AND id <> $2
+ AND revoked_at IS NULL
+ `,
+ userID,
+ currentSessionID,
+ )
+ if err != nil {
+ writeSettingsError(w, http.StatusInternalServerError, "Andere Sitzungen konnten nicht abgemeldet werden")
+ return
+ }
+
+ writeSettingsJSON(w, http.StatusOK, map[string]any{
+ "message": "Andere Sitzungen wurden abgemeldet",
+ })
+}
+
+func (s *Server) handleDeleteOwnAccount(w http.ResponseWriter, r *http.Request) {
+ userID, ok := s.currentUserIDFromRequest(r)
+ if !ok {
+ writeSettingsError(w, http.StatusUnauthorized, "Nicht angemeldet")
+ return
+ }
+
+ var payload confirmPasswordRequest
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ writeSettingsError(w, http.StatusBadRequest, "Ungültige Anfrage")
+ return
+ }
+
+ if err := s.verifyUserPassword(r, userID, payload.Password); err != nil {
+ writeSettingsError(w, http.StatusBadRequest, "Passwort ist nicht korrekt")
+ return
+ }
+
+ tx, err := s.db.Begin(r.Context())
+ if err != nil {
+ writeSettingsError(w, http.StatusInternalServerError, "Konto konnte nicht gelöscht werden")
+ return
+ }
+ defer tx.Rollback(r.Context())
+
+ commandTag, err := tx.Exec(
+ r.Context(),
+ `
+ DELETE FROM users
+ WHERE id = $1
+ `,
+ userID,
+ )
+ if err != nil {
+ writeSettingsError(w, http.StatusInternalServerError, "Konto konnte nicht gelöscht werden")
+ return
+ }
+
+ if commandTag.RowsAffected() == 0 {
+ writeSettingsError(w, http.StatusNotFound, "Benutzer wurde nicht gefunden")
+ return
+ }
+
+ if err := tx.Commit(r.Context()); err != nil {
+ writeSettingsError(w, http.StatusInternalServerError, "Konto konnte nicht gelöscht werden")
+ return
+ }
+
+ http.SetCookie(w, &http.Cookie{
+ Name: "session",
+ Value: "",
+ Path: "/",
+ MaxAge: -1,
+ HttpOnly: true,
+ SameSite: http.SameSiteLaxMode,
+ Secure: false,
+ })
+
+ writeSettingsJSON(w, http.StatusOK, map[string]any{
+ "message": "Konto gelöscht",
+ })
+}
+
+func (s *Server) verifyUserPassword(r *http.Request, userID string, password string) error {
+ password = strings.TrimSpace(password)
+
+ if password == "" {
+ return errors.New("password required")
+ }
+
+ var passwordHash string
+
+ err := s.db.QueryRow(
+ r.Context(),
+ `
+ SELECT password_hash
+ FROM users
+ WHERE id = $1
+ LIMIT 1
+ `,
+ userID,
+ ).Scan(&passwordHash)
+ if err != nil {
+ return err
+ }
+
+ return bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password))
+}
+
+func (s *Server) currentUserIDFromRequest(r *http.Request) (string, bool) {
+ user, ok := r.Context().Value(userContextKey).(User)
+
+ if !ok || user.ID == "" {
+ return "", false
+ }
+
+ return user.ID, true
+}
+
+func isPostgresUniqueViolation(err error) bool {
+ var pgErr *pgconn.PgError
+
+ return errors.As(err, &pgErr) && pgErr.Code == "23505"
+}
+
+func writeSettingsJSON(w http.ResponseWriter, status int, payload any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+
+ _ = json.NewEncoder(w).Encode(payload)
+}
+
+func writeSettingsError(w http.ResponseWriter, status int, message string) {
+ writeSettingsJSON(w, status, map[string]any{
+ "error": message,
+ })
+}
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 8cf2ff7..b390f98 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -8,16 +8,21 @@
"name": "teg",
"version": "0.0.0",
"dependencies": {
+ "@arcgis/core": "^5.0.19",
"@headlessui/react": "^2.2.10",
"@heroicons/react": "^2.2.0",
"@tailwindcss/vite": "^4.3.0",
+ "leaflet": "^1.9.4",
+ "leaflet.heat": "^0.2.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
+ "react-leaflet": "^5.0.0",
"react-router": "^7.15.0",
"tailwindcss": "^4.3.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
+ "@types/leaflet": "^1.9.21",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
@@ -31,6 +36,85 @@
"vite": "^8.0.12"
}
},
+ "node_modules/@amcharts/amcharts5": {
+ "version": "5.15.6",
+ "resolved": "https://registry.npmjs.org/@amcharts/amcharts5/-/amcharts5-5.15.6.tgz",
+ "integrity": "sha512-6gVWngHgkMN+v6dJhZ4XJE3fcHjvD5hyjgyNnfvVE1H0ukmHpfFmhI7cCX0cgy0NRyZc4fwgxDMZsiNA7yYKjA==",
+ "license": "SEE LICENSE IN LICENSE",
+ "dependencies": {
+ "@types/d3": "^7.0.0",
+ "@types/d3-chord": "^3.0.0",
+ "@types/d3-hierarchy": "3.1.1",
+ "@types/d3-sankey": "^0.11.1",
+ "@types/d3-shape": "^3.0.0",
+ "@types/geojson": "^7946.0.8",
+ "@types/polylabel": "^1.0.5",
+ "@types/svg-arc-to-cubic-bezier": "^3.2.0",
+ "d3": "^7.0.0",
+ "d3-chord": "^3.0.0",
+ "d3-force": "^3.0.0",
+ "d3-geo": "^3.0.0",
+ "d3-hierarchy": "^3.0.0",
+ "d3-sankey": "^0.12.3",
+ "d3-selection": "^3.0.0",
+ "d3-shape": "^3.0.0",
+ "d3-transition": "^3.0.0",
+ "d3-voronoi-treemap": "^1.1.2",
+ "flatpickr": "^4.6.13",
+ "markerjs2": "^2.29.4",
+ "pdfmake": "^0.2.2",
+ "polylabel": "^1.1.0",
+ "seedrandom": "^3.0.5",
+ "svg-arc-to-cubic-bezier": "^3.2.0",
+ "tslib": "^2.2.0"
+ }
+ },
+ "node_modules/@arcgis/core": {
+ "version": "5.0.19",
+ "resolved": "https://registry.npmjs.org/@arcgis/core/-/core-5.0.19.tgz",
+ "integrity": "sha512-OciZxzB16sTxtyfESqjuTC6rkMpwcmAzx2iQip3r1NE0IW869Eo5beyonILcHGCx9werUUd/5UhgmOI9MlX4lw==",
+ "license": "SEE LICENSE IN LICENSE.md",
+ "dependencies": {
+ "@amcharts/amcharts5": "~5.15.5",
+ "@arcgis/toolkit": "^5.0.0",
+ "@esri/arcgis-html-sanitizer": "~4.1.0",
+ "@esri/calcite-components": "^5.0.2",
+ "@vaadin/grid": "~25.0.3",
+ "@zip.js/zip.js": "~2.8.16",
+ "luxon": "~3.7.2",
+ "marked": "~17.0.3",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@arcgis/lumina": {
+ "version": "5.0.19",
+ "resolved": "https://registry.npmjs.org/@arcgis/lumina/-/lumina-5.0.19.tgz",
+ "integrity": "sha512-L2WL/yYfR+xY8wpnhObxC/MCaOgImyrvZo5SbBm4tCJtfnpkzpjfGfQ5B5Wj2t+erWxpqVdNBBlQtmq9sipj5g==",
+ "license": "SEE LICENSE IN LICENSE.md",
+ "dependencies": {
+ "@arcgis/toolkit": "~5.0.19",
+ "csstype": "^3.1.3",
+ "tslib": "^2.8.1"
+ },
+ "peerDependencies": {
+ "@lit/context": "^1.1.6",
+ "lit": "^3.3.0"
+ },
+ "peerDependenciesMeta": {
+ "@lit/context": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@arcgis/toolkit": {
+ "version": "5.0.19",
+ "resolved": "https://registry.npmjs.org/@arcgis/toolkit/-/toolkit-5.0.19.tgz",
+ "integrity": "sha512-764RD+gII0r6o5tVXB8W5p+eOpjnfmlFnL/AXw4oPvby1PpwiSlFyhhzpjJIz7SD9ENn+gmK8+E6jMLT8Y1RFA==",
+ "license": "SEE LICENSE IN LICENSE.md",
+ "dependencies": {
+ "tslib": "^2.8.1"
+ }
+ },
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -430,6 +514,50 @@
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
+ "node_modules/@esri/arcgis-html-sanitizer": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/@esri/arcgis-html-sanitizer/-/arcgis-html-sanitizer-4.1.0.tgz",
+ "integrity": "sha512-einEveDJ/k1180NOp78PB/4Hje9eBy3dyOGLLtLn6bSkizpUfCwuYBIXOA7Y3F/k/BsTQXgKqUVwQ0eiscWMdA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "xss": "1.0.13"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@esri/calcite-components": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/@esri/calcite-components/-/calcite-components-5.0.2.tgz",
+ "integrity": "sha512-AE8AQBsdpWWAPJEBc/nabJbdN+H+iuVBJv215mm8xjlyqpHd6BLsD8nG/K8IywDFHeB9TyHLQdYCk/sNiyQo7w==",
+ "license": "SEE LICENSE.md",
+ "dependencies": {
+ "@arcgis/lumina": ">=5.0.0-next.144 <6.0.0",
+ "@arcgis/toolkit": ">=5.0.0-next.144 <6.0.0",
+ "@esri/calcite-ui-icons": "4.4.0",
+ "@floating-ui/dom": "^1.6.12",
+ "@floating-ui/utils": "^0.2.8",
+ "@types/sortablejs": "^1.15.8",
+ "color": "^5.0.3",
+ "composed-offset-position": "^0.0.6",
+ "es-toolkit": "^1.39.8",
+ "focus-trap": "^7.6.5",
+ "interactjs": "^1.10.27",
+ "lit": "^3.3.0",
+ "sortablejs": "^1.15.6",
+ "timezone-groups": "^0.10.4",
+ "type-fest": "^4.30.1"
+ }
+ },
+ "node_modules/@esri/calcite-ui-icons": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/@esri/calcite-ui-icons/-/calcite-ui-icons-4.4.0.tgz",
+ "integrity": "sha512-PcCGId6vKBysRuuoCIYrVsaC+YVv1lFMqVUQn3Qna0BHOIyf7Mursf3NStELNjYdmL0MRYfmLDEDYQwnAJ/tSA==",
+ "license": "SEE LICENSE.md",
+ "bin": {
+ "spriter": "bin/spriter.js"
+ }
+ },
"node_modules/@floating-ui/core": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
@@ -483,6 +611,51 @@
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT"
},
+ "node_modules/@foliojs-fork/fontkit": {
+ "version": "1.9.2",
+ "resolved": "https://registry.npmjs.org/@foliojs-fork/fontkit/-/fontkit-1.9.2.tgz",
+ "integrity": "sha512-IfB5EiIb+GZk+77TRB86AHroVaqfq8JRFlUbz0WEwsInyCG0epX2tCPOy+UfaWPju30DeVoUAXfzWXmhn753KA==",
+ "license": "MIT",
+ "dependencies": {
+ "@foliojs-fork/restructure": "^2.0.2",
+ "brotli": "^1.2.0",
+ "clone": "^1.0.4",
+ "deep-equal": "^1.0.0",
+ "dfa": "^1.2.0",
+ "tiny-inflate": "^1.0.2",
+ "unicode-properties": "^1.2.2",
+ "unicode-trie": "^2.0.0"
+ }
+ },
+ "node_modules/@foliojs-fork/linebreak": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@foliojs-fork/linebreak/-/linebreak-1.1.2.tgz",
+ "integrity": "sha512-ZPohpxxbuKNE0l/5iBJnOAfUaMACwvUIKCvqtWGKIMv1lPYoNjYXRfhi9FeeV9McBkBLxsMFWTVVhHJA8cyzvg==",
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "1.3.1",
+ "unicode-trie": "^2.0.0"
+ }
+ },
+ "node_modules/@foliojs-fork/pdfkit": {
+ "version": "0.15.3",
+ "resolved": "https://registry.npmjs.org/@foliojs-fork/pdfkit/-/pdfkit-0.15.3.tgz",
+ "integrity": "sha512-Obc0Wmy3bm7BINFVvPhcl2rnSSK61DQrlHU8aXnAqDk9LCjWdUOPwhgD8Ywz5VtuFjRxmVOM/kQ/XLIBjDvltw==",
+ "license": "MIT",
+ "dependencies": {
+ "@foliojs-fork/fontkit": "^1.9.2",
+ "@foliojs-fork/linebreak": "^1.1.1",
+ "crypto-js": "^4.2.0",
+ "jpeg-exif": "^1.1.4",
+ "png-js": "^1.0.0"
+ }
+ },
+ "node_modules/@foliojs-fork/restructure": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/@foliojs-fork/restructure/-/restructure-2.0.2.tgz",
+ "integrity": "sha512-59SgoZ3EXbkfSX7b63tsou/SDGzwUEK6MuB5sKqgVK1/XE0fxmpsOb9DQI8LXW3KfGnAjImCGhhEb7uPPAUVNA==",
+ "license": "MIT"
+ },
"node_modules/@headlessui/react": {
"version": "2.2.10",
"resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.10.tgz",
@@ -578,6 +751,12 @@
"url": "https://github.com/sponsors/nzakas"
}
},
+ "node_modules/@interactjs/types": {
+ "version": "1.10.27",
+ "resolved": "https://registry.npmjs.org/@interactjs/types/-/types-1.10.27.tgz",
+ "integrity": "sha512-BUdv0cvs4H5ODuwft2Xp4eL8Vmi3LcihK42z0Ft/FbVJZoRioBsxH+LlsBdK4tAie7PqlKGy+1oyOncu1nQ6eA==",
+ "license": "MIT"
+ },
"node_modules/@internationalized/date": {
"version": "3.12.1",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.1.tgz",
@@ -650,6 +829,21 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@lit-labs/ssr-dom-shim": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.5.1.tgz",
+ "integrity": "sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@lit/reactive-element": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.1.2.tgz",
+ "integrity": "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@lit-labs/ssr-dom-shim": "^1.5.0"
+ }
+ },
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
@@ -668,6 +862,12 @@
"@emnapi/runtime": "^1.7.1"
}
},
+ "node_modules/@open-wc/dedupe-mixin": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@open-wc/dedupe-mixin/-/dedupe-mixin-1.4.0.tgz",
+ "integrity": "sha512-Sj7gKl1TLcDbF7B6KUhtvr+1UCxdhMbNY5KxdU5IfMFWqL8oy1ZeAcCANjoB1TL0AJTcPmcCFsCbHf8X2jGDUA==",
+ "license": "MIT"
+ },
"node_modules/@oxc-project/types": {
"version": "0.129.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.129.0.tgz",
@@ -706,6 +906,17 @@
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
+ "node_modules/@react-leaflet/core": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
+ "integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
+ "license": "Hippocratic-2.1",
+ "peerDependencies": {
+ "leaflet": "^1.9.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ }
+ },
"node_modules/@react-types/shared": {
"version": "3.34.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.34.0.tgz",
@@ -1297,6 +1508,283 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@types/d3": {
+ "version": "7.4.3",
+ "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
+ "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-array": "*",
+ "@types/d3-axis": "*",
+ "@types/d3-brush": "*",
+ "@types/d3-chord": "*",
+ "@types/d3-color": "*",
+ "@types/d3-contour": "*",
+ "@types/d3-delaunay": "*",
+ "@types/d3-dispatch": "*",
+ "@types/d3-drag": "*",
+ "@types/d3-dsv": "*",
+ "@types/d3-ease": "*",
+ "@types/d3-fetch": "*",
+ "@types/d3-force": "*",
+ "@types/d3-format": "*",
+ "@types/d3-geo": "*",
+ "@types/d3-hierarchy": "*",
+ "@types/d3-interpolate": "*",
+ "@types/d3-path": "*",
+ "@types/d3-polygon": "*",
+ "@types/d3-quadtree": "*",
+ "@types/d3-random": "*",
+ "@types/d3-scale": "*",
+ "@types/d3-scale-chromatic": "*",
+ "@types/d3-selection": "*",
+ "@types/d3-shape": "*",
+ "@types/d3-time": "*",
+ "@types/d3-time-format": "*",
+ "@types/d3-timer": "*",
+ "@types/d3-transition": "*",
+ "@types/d3-zoom": "*"
+ }
+ },
+ "node_modules/@types/d3-array": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
+ "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-axis": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz",
+ "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-brush": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz",
+ "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-chord": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz",
+ "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-color": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
+ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-contour": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz",
+ "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-array": "*",
+ "@types/geojson": "*"
+ }
+ },
+ "node_modules/@types/d3-delaunay": {
+ "version": "6.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
+ "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-dispatch": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz",
+ "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-drag": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
+ "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-dsv": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz",
+ "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-ease": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
+ "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-fetch": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz",
+ "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-dsv": "*"
+ }
+ },
+ "node_modules/@types/d3-force": {
+ "version": "3.0.10",
+ "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
+ "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-format": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz",
+ "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-geo": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
+ "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/geojson": "*"
+ }
+ },
+ "node_modules/@types/d3-hierarchy": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.1.tgz",
+ "integrity": "sha512-QwjxA3+YCKH3N1Rs3uSiSy1bdxlLB1uUiENXeJudBoAFvtDuswUxLcanoOaR2JYn1melDTuIXR8VhnVyI3yG/A==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-interpolate": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+ "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-color": "*"
+ }
+ },
+ "node_modules/@types/d3-path": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
+ "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-polygon": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz",
+ "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-quadtree": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz",
+ "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-random": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz",
+ "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-sankey": {
+ "version": "0.11.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-sankey/-/d3-sankey-0.11.2.tgz",
+ "integrity": "sha512-U6SrTWUERSlOhnpSrgvMX64WblX1AxX6nEjI2t3mLK2USpQrnbwYYK+AS9SwiE7wgYmOsSSKoSdr8aoKBH0HgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-shape": "^1"
+ }
+ },
+ "node_modules/@types/d3-sankey/node_modules/@types/d3-path": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.11.tgz",
+ "integrity": "sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-sankey/node_modules/@types/d3-shape": {
+ "version": "1.3.12",
+ "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.12.tgz",
+ "integrity": "sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-path": "^1"
+ }
+ },
+ "node_modules/@types/d3-scale": {
+ "version": "4.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
+ "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-time": "*"
+ }
+ },
+ "node_modules/@types/d3-scale-chromatic": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
+ "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-selection": {
+ "version": "3.0.11",
+ "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
+ "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-shape": {
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
+ "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-path": "*"
+ }
+ },
+ "node_modules/@types/d3-time": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
+ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-time-format": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz",
+ "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-timer": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
+ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-transition": {
+ "version": "3.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
+ "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-zoom": {
+ "version": "3.0.8",
+ "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
+ "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-interpolate": "*",
+ "@types/d3-selection": "*"
+ }
+ },
"node_modules/@types/esrecurse": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
@@ -1311,6 +1799,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/geojson": {
+ "version": "7946.0.16",
+ "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
+ "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
+ "license": "MIT"
+ },
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -1318,6 +1812,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/leaflet": {
+ "version": "1.9.21",
+ "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
+ "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/geojson": "*"
+ }
+ },
"node_modules/@types/node": {
"version": "24.12.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz",
@@ -1328,6 +1832,12 @@
"undici-types": "~7.16.0"
}
},
+ "node_modules/@types/polylabel": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@types/polylabel/-/polylabel-1.1.3.tgz",
+ "integrity": "sha512-9Zw2KoDpi+T4PZz2G6pO2xArE0m/GSMTW1MIxF2s8ZY8x9XDO6fv9um0ydRGvcbkFLlaq8yNK6eZxnmMZtDgWQ==",
+ "license": "MIT"
+ },
"node_modules/@types/react": {
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@@ -1348,6 +1858,24 @@
"@types/react": "^19.2.0"
}
},
+ "node_modules/@types/sortablejs": {
+ "version": "1.15.9",
+ "resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.9.tgz",
+ "integrity": "sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==",
+ "license": "MIT"
+ },
+ "node_modules/@types/svg-arc-to-cubic-bezier": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/@types/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.3.tgz",
+ "integrity": "sha512-UNOnbTtl0nVTm8hwKaz5R5VZRvSulFMGojO5+Q7yucKxBoCaTtS4ibSQVRHo5VW5AaRo145U8p1Vfg5KrYe9Bg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "license": "MIT"
+ },
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz",
@@ -1591,6 +2119,136 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
+ "node_modules/@vaadin/a11y-base": {
+ "version": "25.0.12",
+ "resolved": "https://registry.npmjs.org/@vaadin/a11y-base/-/a11y-base-25.0.12.tgz",
+ "integrity": "sha512-ie5au1EoNIWOaLEroBF2K8V2PN9G+U52RVVGxEw8mWbe2RS537yac8wKk4tYGZgtv2pbNZSqzzW+/uxO2X5WgQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@open-wc/dedupe-mixin": "^1.3.0",
+ "@vaadin/component-base": "~25.0.12",
+ "lit": "^3.0.0"
+ }
+ },
+ "node_modules/@vaadin/checkbox": {
+ "version": "25.0.12",
+ "resolved": "https://registry.npmjs.org/@vaadin/checkbox/-/checkbox-25.0.12.tgz",
+ "integrity": "sha512-s9eHzZLqyxsj2CVzEksqJDttU2Mues7nDncx5KhUWV/Gk60lFXVvDfkQ9iqbvxOLCEC6VMvm1fme2XT6SgTdYg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@open-wc/dedupe-mixin": "^1.3.0",
+ "@vaadin/a11y-base": "~25.0.12",
+ "@vaadin/component-base": "~25.0.12",
+ "@vaadin/field-base": "~25.0.12",
+ "@vaadin/vaadin-themable-mixin": "~25.0.12",
+ "lit": "^3.0.0"
+ }
+ },
+ "node_modules/@vaadin/component-base": {
+ "version": "25.0.12",
+ "resolved": "https://registry.npmjs.org/@vaadin/component-base/-/component-base-25.0.12.tgz",
+ "integrity": "sha512-xoW/ssKuRO5ye9L0f8Ypwc3AcREF1qKgWHQ4qYa3KqfmmK0u+IvArCaXr6OpbjpOfK2nUSCBRFUyxQNH/9ISZA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@open-wc/dedupe-mixin": "^1.3.0",
+ "@vaadin/vaadin-development-mode-detector": "^2.0.0",
+ "@vaadin/vaadin-usage-statistics": "^2.1.0",
+ "lit": "^3.0.0"
+ }
+ },
+ "node_modules/@vaadin/field-base": {
+ "version": "25.0.12",
+ "resolved": "https://registry.npmjs.org/@vaadin/field-base/-/field-base-25.0.12.tgz",
+ "integrity": "sha512-/9aHsdGiAme63Ogg99XkSGn+nPeDxu/rZv0iRR0QpzNf/Da+IcQan56xg9NZ4dlR5RX1sjC8pmX22a9OjSm0Cw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@open-wc/dedupe-mixin": "^1.3.0",
+ "@vaadin/a11y-base": "~25.0.12",
+ "@vaadin/component-base": "~25.0.12",
+ "lit": "^3.0.0"
+ }
+ },
+ "node_modules/@vaadin/grid": {
+ "version": "25.0.12",
+ "resolved": "https://registry.npmjs.org/@vaadin/grid/-/grid-25.0.12.tgz",
+ "integrity": "sha512-IgX6L+twLxe3tA8VsWlYhTW48qfALDqQ9aDqsds6/z2ADn6i8dl39T2Dd83ayY3Oc/AROHV+M2ns6POmUwhZ0Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@open-wc/dedupe-mixin": "^1.3.0",
+ "@vaadin/a11y-base": "~25.0.12",
+ "@vaadin/checkbox": "~25.0.12",
+ "@vaadin/component-base": "~25.0.12",
+ "@vaadin/lit-renderer": "~25.0.12",
+ "@vaadin/text-field": "~25.0.12",
+ "@vaadin/vaadin-themable-mixin": "~25.0.12",
+ "lit": "^3.0.0"
+ }
+ },
+ "node_modules/@vaadin/input-container": {
+ "version": "25.0.12",
+ "resolved": "https://registry.npmjs.org/@vaadin/input-container/-/input-container-25.0.12.tgz",
+ "integrity": "sha512-csshn16IIkN7oqyM64r6o7UIoiLaKaC/1C9GJMyUDUafzCAPUSg/nq9+2TwIWzIcY2+vE8atOCJWgxlhmikv+A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@vaadin/component-base": "~25.0.12",
+ "@vaadin/vaadin-themable-mixin": "~25.0.12",
+ "lit": "^3.0.0"
+ }
+ },
+ "node_modules/@vaadin/lit-renderer": {
+ "version": "25.0.12",
+ "resolved": "https://registry.npmjs.org/@vaadin/lit-renderer/-/lit-renderer-25.0.12.tgz",
+ "integrity": "sha512-G1E7ooFhFpWUkp3dWunv2CYW4pwz0ADvSSETkQCnnIxBBn8vzfXrkloCGBYWmZachtAM/UdHnuVoue9FFsJzSg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "lit": "^3.0.0"
+ }
+ },
+ "node_modules/@vaadin/text-field": {
+ "version": "25.0.12",
+ "resolved": "https://registry.npmjs.org/@vaadin/text-field/-/text-field-25.0.12.tgz",
+ "integrity": "sha512-qkwA9WpPkDyhVjO/P/hHX+5O6jk16wEgPjxL9BJJsoRO8Eotr1b+u8N9n0G8DSgjJCeT4yxuGhWSQSWoKVSn/g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@open-wc/dedupe-mixin": "^1.3.0",
+ "@vaadin/a11y-base": "~25.0.12",
+ "@vaadin/component-base": "~25.0.12",
+ "@vaadin/field-base": "~25.0.12",
+ "@vaadin/input-container": "~25.0.12",
+ "@vaadin/vaadin-themable-mixin": "~25.0.12",
+ "lit": "^3.0.0"
+ }
+ },
+ "node_modules/@vaadin/vaadin-development-mode-detector": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@vaadin/vaadin-development-mode-detector/-/vaadin-development-mode-detector-2.0.7.tgz",
+ "integrity": "sha512-9FhVhr0ynSR3X2ao+vaIEttcNU5XfzCbxtmYOV8uIRnUCtNgbvMOIcyGBvntsX9I5kvIP2dV3cFAOG9SILJzEA==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@vaadin/vaadin-themable-mixin": {
+ "version": "25.0.12",
+ "resolved": "https://registry.npmjs.org/@vaadin/vaadin-themable-mixin/-/vaadin-themable-mixin-25.0.12.tgz",
+ "integrity": "sha512-yTgRgRR1Q9vpajXHqubyso8Nrxw9QmJekDFx8BHU4KkpuMciiAP0ZvXSmbXTK/SLyT5pQGGyMtkXaEcbIRBgtQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@open-wc/dedupe-mixin": "^1.3.0",
+ "@vaadin/component-base": "~25.0.12",
+ "lit": "^3.0.0"
+ }
+ },
+ "node_modules/@vaadin/vaadin-usage-statistics": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/@vaadin/vaadin-usage-statistics/-/vaadin-usage-statistics-2.1.3.tgz",
+ "integrity": "sha512-8r4TNknD7OJQADe3VygeofFR7UNAXZ2/jjBFP5dgI8+2uMfnuGYgbuHivasKr9WSQ64sPej6m8rDoM1uSllXjQ==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@vaadin/vaadin-development-mode-detector": "^2.0.0"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ }
+ },
"node_modules/@vitejs/plugin-react": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
@@ -1617,6 +2275,17 @@
}
}
},
+ "node_modules/@zip.js/zip.js": {
+ "version": "2.8.26",
+ "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.26.tgz",
+ "integrity": "sha512-RQ4h9F6DOiHxpdocUDrOl6xBM+yOtz+LkUol47AVWcfebGBDpZ7w7Xvz9PS24JgXvLGiXXzSAfdCdVy1tPlaFA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "bun": ">=0.7.0",
+ "deno": ">=1.0.0",
+ "node": ">=18.0.0"
+ }
+ },
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -1679,6 +2348,12 @@
"node": "18 || 20 || >=22"
}
},
+ "node_modules/base64-js": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz",
+ "integrity": "sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==",
+ "license": "MIT"
+ },
"node_modules/baseline-browser-mapping": {
"version": "2.10.29",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz",
@@ -1705,6 +2380,24 @@
"node": "18 || 20 || >=22"
}
},
+ "node_modules/brotli": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
+ "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.1.2"
+ }
+ },
+ "node_modules/browserify-zlib": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
+ "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
+ "license": "MIT",
+ "dependencies": {
+ "pako": "~1.0.5"
+ }
+ },
"node_modules/browserslist": {
"version": "4.28.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
@@ -1739,6 +2432,53 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
+ "node_modules/call-bind": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
+ "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "get-intrinsic": "^1.3.0",
+ "set-function-length": "^1.2.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/caniuse-lite": {
"version": "1.0.30001792",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz",
@@ -1760,6 +2500,15 @@
],
"license": "CC-BY-4.0"
},
+ "node_modules/clone": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz",
+ "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -1769,6 +2518,70 @@
"node": ">=6"
}
},
+ "node_modules/color": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz",
+ "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==",
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^3.1.3",
+ "color-string": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz",
+ "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=14.6"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz",
+ "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20"
+ }
+ },
+ "node_modules/color-string": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz",
+ "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/commander": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
+ "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/composed-offset-position": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/composed-offset-position/-/composed-offset-position-0.0.6.tgz",
+ "integrity": "sha512-Q7dLompI6lUwd7LWyIcP66r4WcS9u7AL2h8HaeipiRfCRPLMWqRx8fYsjb4OHi6UQFifO7XtNC2IlEJ1ozIFxw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@floating-ui/utils": "^0.2.5"
+ }
+ },
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1804,13 +2617,535 @@
"node": ">= 8"
}
},
+ "node_modules/crypto-js": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
+ "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
+ "license": "MIT"
+ },
+ "node_modules/cssfilter": {
+ "version": "0.0.10",
+ "resolved": "https://registry.npmjs.org/cssfilter/-/cssfilter-0.0.10.tgz",
+ "integrity": "sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==",
+ "license": "MIT"
+ },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "dev": true,
"license": "MIT"
},
+ "node_modules/d3": {
+ "version": "7.9.0",
+ "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
+ "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "3",
+ "d3-axis": "3",
+ "d3-brush": "3",
+ "d3-chord": "3",
+ "d3-color": "3",
+ "d3-contour": "4",
+ "d3-delaunay": "6",
+ "d3-dispatch": "3",
+ "d3-drag": "3",
+ "d3-dsv": "3",
+ "d3-ease": "3",
+ "d3-fetch": "3",
+ "d3-force": "3",
+ "d3-format": "3",
+ "d3-geo": "3",
+ "d3-hierarchy": "3",
+ "d3-interpolate": "3",
+ "d3-path": "3",
+ "d3-polygon": "3",
+ "d3-quadtree": "3",
+ "d3-random": "3",
+ "d3-scale": "4",
+ "d3-scale-chromatic": "3",
+ "d3-selection": "3",
+ "d3-shape": "3",
+ "d3-time": "3",
+ "d3-time-format": "4",
+ "d3-timer": "3",
+ "d3-transition": "3",
+ "d3-zoom": "3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-array": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
+ "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
+ "license": "ISC",
+ "dependencies": {
+ "internmap": "1 - 2"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-axis": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
+ "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-brush": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
+ "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-drag": "2 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-selection": "3",
+ "d3-transition": "3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-chord": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
+ "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-path": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-color": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-contour": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
+ "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "^3.2.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-delaunay": {
+ "version": "6.0.4",
+ "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
+ "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
+ "license": "ISC",
+ "dependencies": {
+ "delaunator": "5"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-dispatch": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
+ "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-drag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
+ "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-selection": "3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-dsv": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
+ "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
+ "license": "ISC",
+ "dependencies": {
+ "commander": "7",
+ "iconv-lite": "0.6",
+ "rw": "1"
+ },
+ "bin": {
+ "csv2json": "bin/dsv2json.js",
+ "csv2tsv": "bin/dsv2dsv.js",
+ "dsv2dsv": "bin/dsv2dsv.js",
+ "dsv2json": "bin/dsv2json.js",
+ "json2csv": "bin/json2dsv.js",
+ "json2dsv": "bin/json2dsv.js",
+ "json2tsv": "bin/json2dsv.js",
+ "tsv2csv": "bin/dsv2dsv.js",
+ "tsv2json": "bin/dsv2json.js"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-ease": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+ "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-fetch": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
+ "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dsv": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-force": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
+ "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-quadtree": "1 - 3",
+ "d3-timer": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-format": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
+ "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-geo": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
+ "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2.5.0 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-hierarchy": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
+ "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-interpolate": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-path": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
+ "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-polygon": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
+ "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-quadtree": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
+ "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-random": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
+ "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-sankey": {
+ "version": "0.12.3",
+ "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz",
+ "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "d3-array": "1 - 2",
+ "d3-shape": "^1.2.0"
+ }
+ },
+ "node_modules/d3-sankey/node_modules/d3-array": {
+ "version": "2.12.1",
+ "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz",
+ "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "internmap": "^1.0.0"
+ }
+ },
+ "node_modules/d3-sankey/node_modules/d3-path": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz",
+ "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/d3-sankey/node_modules/d3-shape": {
+ "version": "1.3.7",
+ "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz",
+ "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "d3-path": "1"
+ }
+ },
+ "node_modules/d3-sankey/node_modules/internmap": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz",
+ "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==",
+ "license": "ISC"
+ },
+ "node_modules/d3-scale": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
+ "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2.10.0 - 3",
+ "d3-format": "1 - 3",
+ "d3-interpolate": "1.2.0 - 3",
+ "d3-time": "2.1.1 - 3",
+ "d3-time-format": "2 - 4"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-scale-chromatic": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
+ "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3",
+ "d3-interpolate": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-selection": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
+ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-shape": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
+ "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-path": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
+ "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time-format": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
+ "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-time": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-timer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+ "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-transition": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
+ "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3",
+ "d3-dispatch": "1 - 3",
+ "d3-ease": "1 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-timer": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "peerDependencies": {
+ "d3-selection": "2 - 3"
+ }
+ },
+ "node_modules/d3-voronoi-map": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/d3-voronoi-map/-/d3-voronoi-map-2.1.1.tgz",
+ "integrity": "sha512-mCXfz/kD9IQxjHaU2IMjkO8fSo4J6oysPR2iL+omDsCy1i1Qn6BQ/e4hEAW8C6ms2kfuHwqtbNom80Hih94YsA==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "d3-dispatch": "2.*",
+ "d3-polygon": "2.*",
+ "d3-timer": "2.*",
+ "d3-weighted-voronoi": "1.*"
+ }
+ },
+ "node_modules/d3-voronoi-map/node_modules/d3-dispatch": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-2.0.0.tgz",
+ "integrity": "sha512-S/m2VsXI7gAti2pBoLClFFTMOO1HTtT0j99AuXLoGFKO6deHDdnv6ZGTxSTTUTgO1zVcv82fCOtDjYK4EECmWA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/d3-voronoi-map/node_modules/d3-polygon": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-2.0.0.tgz",
+ "integrity": "sha512-MsexrCK38cTGermELs0cO1d79DcTsQRN7IWMJKczD/2kBjzNXxLUWP33qRF6VDpiLV/4EI4r6Gs0DAWQkE8pSQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/d3-voronoi-map/node_modules/d3-timer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-2.0.0.tgz",
+ "integrity": "sha512-TO4VLh0/420Y/9dO3+f9abDEFYeCUr2WZRlxJvbp4HPTQcSylXNiL6yZa9FIUvV1yRiFufl1bszTCLDqv9PWNA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/d3-voronoi-treemap": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/d3-voronoi-treemap/-/d3-voronoi-treemap-1.1.2.tgz",
+ "integrity": "sha512-7odu9HdG/yLPWwzDteJq4yd9Q/NwgQV7IE/u36VQtcCK7k1sZwDqbkHCeMKNTBsq5mQjDwolTsrXcU0j8ZEMCA==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "d3-voronoi-map": "2.*"
+ }
+ },
+ "node_modules/d3-weighted-voronoi": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/d3-weighted-voronoi/-/d3-weighted-voronoi-1.1.3.tgz",
+ "integrity": "sha512-C3WdvSKl9aqhAy+f3QT3PPsQG6V+ajDfYO3BSclQDSD+araW2xDBFIH67aKzsSuuuKaX8K2y2dGq1fq/dWTVig==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "d3-array": "2",
+ "d3-polygon": "2"
+ }
+ },
+ "node_modules/d3-weighted-voronoi/node_modules/d3-array": {
+ "version": "2.12.1",
+ "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz",
+ "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "internmap": "^1.0.0"
+ }
+ },
+ "node_modules/d3-weighted-voronoi/node_modules/d3-polygon": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-2.0.0.tgz",
+ "integrity": "sha512-MsexrCK38cTGermELs0cO1d79DcTsQRN7IWMJKczD/2kBjzNXxLUWP33qRF6VDpiLV/4EI4r6Gs0DAWQkE8pSQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/d3-weighted-voronoi/node_modules/internmap": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz",
+ "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==",
+ "license": "ISC"
+ },
+ "node_modules/d3-zoom": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
+ "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-drag": "2 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-selection": "2 - 3",
+ "d3-transition": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1829,6 +3164,26 @@
}
}
},
+ "node_modules/deep-equal": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz",
+ "integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==",
+ "license": "MIT",
+ "dependencies": {
+ "is-arguments": "^1.1.1",
+ "is-date-object": "^1.0.5",
+ "is-regex": "^1.1.4",
+ "object-is": "^1.1.5",
+ "object-keys": "^1.1.1",
+ "regexp.prototype.flags": "^1.5.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -1836,6 +3191,49 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/define-data-property": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
+ "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/define-properties": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
+ "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.0.1",
+ "has-property-descriptors": "^1.0.0",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/delaunator": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz",
+ "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==",
+ "license": "ISC",
+ "dependencies": {
+ "robust-predicates": "^3.0.2"
+ }
+ },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -1845,6 +3243,26 @@
"node": ">=8"
}
},
+ "node_modules/dfa": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
+ "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
+ "license": "MIT"
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/electron-to-chromium": {
"version": "1.5.353",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz",
@@ -1865,6 +3283,46 @@
"node": ">=10.13.0"
}
},
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-toolkit": {
+ "version": "1.46.1",
+ "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz",
+ "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==",
+ "license": "MIT",
+ "workspaces": [
+ "docs",
+ "benchmarks"
+ ]
+ },
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -2152,6 +3610,12 @@
"node": ">=16"
}
},
+ "node_modules/flatpickr": {
+ "version": "4.6.13",
+ "resolved": "https://registry.npmjs.org/flatpickr/-/flatpickr-4.6.13.tgz",
+ "integrity": "sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw==",
+ "license": "MIT"
+ },
"node_modules/flatted": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
@@ -2159,6 +3623,15 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/focus-trap": {
+ "version": "7.8.0",
+ "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz",
+ "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==",
+ "license": "MIT",
+ "dependencies": {
+ "tabbable": "^6.4.0"
+ }
+ },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -2173,6 +3646,24 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/functions-have-names": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
+ "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -2183,6 +3674,43 @@
"node": ">=6.9.0"
}
},
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -2209,12 +3737,75 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
+ "node_modules/has-property-descriptors": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
+ "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
+ "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/hermes-estree": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
@@ -2232,6 +3823,18 @@
"hermes-estree": "0.25.1"
}
},
+ "node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -2252,6 +3855,56 @@
"node": ">=0.8.19"
}
},
+ "node_modules/interactjs": {
+ "version": "1.10.27",
+ "resolved": "https://registry.npmjs.org/interactjs/-/interactjs-1.10.27.tgz",
+ "integrity": "sha512-y/8RcCftGAF24gSp76X2JS3XpHiUvDQyhF8i7ujemBz77hwiHDuJzftHx7thY8cxGogwGiPJ+o97kWB6eAXnsA==",
+ "license": "MIT",
+ "dependencies": {
+ "@interactjs/types": "1.10.27"
+ }
+ },
+ "node_modules/internmap": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
+ "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/is-arguments": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
+ "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-date-object": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz",
+ "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -2275,6 +3928,24 @@
"node": ">=0.10.0"
}
},
+ "node_modules/is-regex": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
+ "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "gopd": "^1.2.0",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -2291,6 +3962,13 @@
"jiti": "lib/jiti-cli.mjs"
}
},
+ "node_modules/jpeg-exif": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz",
+ "integrity": "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==",
+ "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
+ "license": "MIT"
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -2355,6 +4033,17 @@
"json-buffer": "3.0.1"
}
},
+ "node_modules/leaflet": {
+ "version": "1.9.4",
+ "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
+ "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/leaflet.heat": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/leaflet.heat/-/leaflet.heat-0.2.0.tgz",
+ "integrity": "sha512-Cd5PbAA/rX3X3XKxfDoUGi9qp78FyhWYurFg3nsfhntcM/MCNK08pRkf4iEenO1KNqwVPKCmkyktjW3UD+h9bQ=="
+ },
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -2630,6 +4319,37 @@
"url": "https://opencollective.com/parcel"
}
},
+ "node_modules/lit": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.2.tgz",
+ "integrity": "sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@lit/reactive-element": "^2.1.0",
+ "lit-element": "^4.2.0",
+ "lit-html": "^3.3.0"
+ }
+ },
+ "node_modules/lit-element": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.2.2.tgz",
+ "integrity": "sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@lit-labs/ssr-dom-shim": "^1.5.0",
+ "@lit/reactive-element": "^2.1.0",
+ "lit-html": "^3.3.0"
+ }
+ },
+ "node_modules/lit-html": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.2.tgz",
+ "integrity": "sha512-Qy9hU88zcmaxBXcc10ZpdK7cOLXvXpRoBxERdtqV9QOrfpMZZ6pSYP91LhpPtap3sFMUiL7Tw2RImbe0Al2/kw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@types/trusted-types": "^2.0.2"
+ }
+ },
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -2656,6 +4376,15 @@
"yallist": "^3.0.2"
}
},
+ "node_modules/luxon": {
+ "version": "3.7.2",
+ "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
+ "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -2665,6 +4394,33 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
+ "node_modules/marked": {
+ "version": "17.0.6",
+ "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.6.tgz",
+ "integrity": "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==",
+ "license": "MIT",
+ "bin": {
+ "marked": "bin/marked.js"
+ },
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/markerjs2": {
+ "version": "2.32.7",
+ "resolved": "https://registry.npmjs.org/markerjs2/-/markerjs2-2.32.7.tgz",
+ "integrity": "sha512-HeFRZjmc43DOG3lSQp92z49cq2oCYpYn2pX++SkJAW1Dij4xJtRquVRf+cXeSZQWDX3ufns1Ry/bGk+zveP7rA==",
+ "license": "SEE LICENSE IN LICENSE"
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
@@ -2720,6 +4476,31 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/object-is": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
+ "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object-keys": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
+ "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -2770,6 +4551,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/pako": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+ "license": "(MIT AND Zlib)"
+ },
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -2790,6 +4577,37 @@
"node": ">=8"
}
},
+ "node_modules/pdfmake": {
+ "version": "0.2.23",
+ "resolved": "https://registry.npmjs.org/pdfmake/-/pdfmake-0.2.23.tgz",
+ "integrity": "sha512-A/IksoKb/ikOZH1edSDJ/2zBbqJKDghD4+fXn3rT7quvCJDlsZMs3NmIB3eajLMMFU9Bd3bZPVvlUMXhvFI+bQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@foliojs-fork/linebreak": "^1.1.2",
+ "@foliojs-fork/pdfkit": "^0.15.3",
+ "iconv-lite": "^0.7.1",
+ "xmldoc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/pdfmake/node_modules/iconv-lite": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
+ "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2808,6 +4626,23 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/png-js": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz",
+ "integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==",
+ "dependencies": {
+ "browserify-zlib": "^0.2.0"
+ }
+ },
+ "node_modules/polylabel": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/polylabel/-/polylabel-1.1.0.tgz",
+ "integrity": "sha512-bxaGcA40sL3d6M4hH72Z4NdLqxpXRsCFk8AITYg6x1rn1Ei3izf00UMLklerBZTO49aPA3CYrIwVulx2Bce2pA==",
+ "license": "ISC",
+ "dependencies": {
+ "tinyqueue": "^2.0.3"
+ }
+ },
"node_modules/postcss": {
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
@@ -2898,6 +4733,20 @@
"react": "^19.2.6"
}
},
+ "node_modules/react-leaflet": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
+ "integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
+ "license": "Hippocratic-2.1",
+ "dependencies": {
+ "@react-leaflet/core": "^3.0.0"
+ },
+ "peerDependencies": {
+ "leaflet": "^1.9.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ }
+ },
"node_modules/react-router": {
"version": "7.15.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.0.tgz",
@@ -2937,6 +4786,32 @@
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
+ "node_modules/regexp.prototype.flags": {
+ "version": "1.5.4",
+ "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
+ "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-errors": "^1.3.0",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "set-function-name": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/robust-predicates": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
+ "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==",
+ "license": "Unlicense"
+ },
"node_modules/rolldown": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0.tgz",
@@ -2976,12 +4851,39 @@
"integrity": "sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==",
"license": "MIT"
},
+ "node_modules/rw": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
+ "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/sax": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
+ "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=11.0.0"
+ }
+ },
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
+ "node_modules/seedrandom": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz",
+ "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==",
+ "license": "MIT"
+ },
"node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@@ -2998,6 +4900,38 @@
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
+ "node_modules/set-function-length": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
+ "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2",
+ "get-intrinsic": "^1.2.4",
+ "gopd": "^1.0.1",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/set-function-name": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz",
+ "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==",
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-errors": "^1.3.0",
+ "functions-have-names": "^1.2.3",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -3021,6 +4955,12 @@
"node": ">=8"
}
},
+ "node_modules/sortablejs": {
+ "version": "1.15.7",
+ "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz",
+ "integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==",
+ "license": "MIT"
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -3030,6 +4970,12 @@
"node": ">=0.10.0"
}
},
+ "node_modules/svg-arc-to-cubic-bezier": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz",
+ "integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==",
+ "license": "ISC"
+ },
"node_modules/tabbable": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
@@ -3055,6 +5001,21 @@
"url": "https://opencollective.com/webpack"
}
},
+ "node_modules/timezone-groups": {
+ "version": "0.10.4",
+ "resolved": "https://registry.npmjs.org/timezone-groups/-/timezone-groups-0.10.4.tgz",
+ "integrity": "sha512-AnkJYrbb7uPkDCEqGeVJiawZNiwVlSkkeX4jZg1gTEguClhyX+/Ezn07KB6DT29tG3UN418ldmS/W6KqGOTDjg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.12.0"
+ }
+ },
+ "node_modules/tiny-inflate": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
+ "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
+ "license": "MIT"
+ },
"node_modules/tinyglobby": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
@@ -3071,6 +5032,12 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
+ "node_modules/tinyqueue": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-2.0.3.tgz",
+ "integrity": "sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==",
+ "license": "ISC"
+ },
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@@ -3103,6 +5070,18 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/type-fest": {
+ "version": "4.41.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
+ "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
@@ -3148,6 +5127,32 @@
"devOptional": true,
"license": "MIT"
},
+ "node_modules/unicode-properties": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
+ "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.0",
+ "unicode-trie": "^2.0.0"
+ }
+ },
+ "node_modules/unicode-trie": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
+ "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "pako": "^0.2.5",
+ "tiny-inflate": "^1.0.0"
+ }
+ },
+ "node_modules/unicode-trie/node_modules/pako": {
+ "version": "0.2.9",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
+ "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
+ "license": "MIT"
+ },
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@@ -3301,6 +5306,40 @@
"node": ">=0.10.0"
}
},
+ "node_modules/xmldoc": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/xmldoc/-/xmldoc-2.0.3.tgz",
+ "integrity": "sha512-6gRk4NY/Jvg67xn7OzJuxLRsGgiXBaPUQplVJ/9l99uIugxh4FTOewYz5ic8WScj7Xx/2WvhENiQKwkK9RpE4w==",
+ "license": "MIT",
+ "dependencies": {
+ "sax": "^1.4.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/xss": {
+ "version": "1.0.13",
+ "resolved": "https://registry.npmjs.org/xss/-/xss-1.0.13.tgz",
+ "integrity": "sha512-clu7dxTm1e8Mo5fz3n/oW3UCXBfV89xZ72jM8yzo1vR/pIS0w3sgB3XV2H8Vm6zfGnHL0FzvLJPJEBhd86/z4Q==",
+ "license": "MIT",
+ "dependencies": {
+ "commander": "^2.20.3",
+ "cssfilter": "0.0.10"
+ },
+ "bin": {
+ "xss": "bin/xss"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ }
+ },
+ "node_modules/xss/node_modules/commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "license": "MIT"
+ },
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 1773b11..dfe403f 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -10,16 +10,21 @@
"preview": "vite preview"
},
"dependencies": {
+ "@arcgis/core": "^5.0.19",
"@headlessui/react": "^2.2.10",
"@heroicons/react": "^2.2.0",
"@tailwindcss/vite": "^4.3.0",
+ "leaflet": "^1.9.4",
+ "leaflet.heat": "^0.2.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
+ "react-leaflet": "^5.0.0",
"react-router": "^7.15.0",
"tailwindcss": "^4.3.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
+ "@types/leaflet": "^1.9.21",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 153385a..a83d4e4 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,30 +1,158 @@
-// frontend\src\App.tsx
+// frontend/src/App.tsx
-import { useEffect, useState, type FormEvent } from 'react'
+import { useEffect, useState } from 'react'
import { Navigate, Route, Routes } from 'react-router'
-import LoginForm from './components/LoginForm'
+import LoginPage from './pages/LoginPage'
import AppLayout from './components/AppLayout'
import DashboardPage from './pages/DashboardPage'
-import DevicesPage from './pages/DevicesPage'
-import IncidentsPage from './pages/IncidentsPage'
+import DevicesPage from './pages/devices/DevicesPage'
+import OperationsPage from './pages/operations/OperationsPage'
import CalendarPage from './pages/CalendarPage'
import DocumentsPage from './pages/DocumentsPage'
import ReportsPage from './pages/ReportsPage'
-import SettingsPage from './pages/SettingsPage'
-import type { Team, User } from './components/types'
+import SettingsPage from './pages/settings/SettingsPage'
import './App.css'
+import LoadingSpinner from './components/LoadingSpinner'
+import NotificationCenter from './components/NotificationCenter'
+import type { Notification, User } from './components/types'
+import AdministrationPage from './pages/administration/AdministrationPage'
+import { hasRight } from './components/permissions'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
+function parseNotificationEvent(event: MessageEvent) {
+ try {
+ return JSON.parse(event.data) as Notification
+ } catch {
+ return null
+ }
+}
+
+function RequireRight({
+ user,
+ right,
+ children,
+}: {
+ user: User
+ right: string
+ children: React.ReactNode
+}) {
+ if (!hasRight(user, right)) {
+ return
+ }
+
+ return children
+}
+
function App() {
const [user, setUser] = useState(null)
const [isLoading, setIsLoading] = useState(true)
- const [isLoggingIn, setIsLoggingIn] = useState(false)
+
+ const [notifications, setNotifications] = useState([])
+ const [unreadJournalOperationIds, setUnreadJournalOperationIds] = useState>(
+ () => new Set(),
+ )
+
+ const unreadNotificationCount = notifications.filter(
+ (notification) => !notification.readAt,
+ ).length
useEffect(() => {
- loadUser()
+ void loadUser()
}, [])
+ useEffect(() => {
+ if (!user) {
+ setNotifications([])
+ setUnreadJournalOperationIds(new Set())
+ return
+ }
+
+ void loadNotifications()
+ void loadUnreadJournalOperations()
+
+ const eventSource = new EventSource(`${API_URL}/events`, {
+ withCredentials: true,
+ })
+
+ eventSource.addEventListener('notification', (event) => {
+ const notification = parseNotificationEvent(event)
+
+ if (!notification) {
+ return
+ }
+
+ if (notification.visible !== false) {
+ setNotifications((currentNotifications) => [
+ notification,
+ ...currentNotifications.filter((item) => item.id !== notification.id),
+ ])
+ }
+
+ if (notification.entityType === 'operation') {
+ window.dispatchEvent(
+ new CustomEvent('operation-notification', {
+ detail: notification,
+ }),
+ )
+ }
+
+ if (notification.entityType === 'device') {
+ window.dispatchEvent(
+ new CustomEvent('device-notification', {
+ detail: notification,
+ }),
+ )
+ }
+ })
+
+ eventSource.addEventListener('operation-event', (event) => {
+ const notification = parseNotificationEvent(event)
+
+ if (!notification) {
+ return
+ }
+
+ if (
+ notification.type === 'operation.journal' &&
+ notification.entityType === 'operation' &&
+ notification.entityId
+ ) {
+ setUnreadJournalOperationIds((currentIds) => {
+ const nextIds = new Set(currentIds)
+ nextIds.add(notification.entityId)
+ return nextIds
+ })
+ }
+
+ if (notification.entityType === 'operation') {
+ window.dispatchEvent(
+ new CustomEvent('operation-notification', {
+ detail: notification,
+ }),
+ )
+ }
+ })
+
+ eventSource.addEventListener('device-event', (event) => {
+ const notification = parseNotificationEvent(event)
+
+ if (!notification) {
+ return
+ }
+
+ window.dispatchEvent(
+ new CustomEvent('device-notification', {
+ detail: notification,
+ }),
+ )
+ })
+
+ return () => {
+ eventSource.close()
+ }
+ }, [user])
+
async function loadUser() {
try {
const response = await fetch(`${API_URL}/auth/me`, {
@@ -45,41 +173,87 @@ function App() {
}
}
- async function handleLogin(event: FormEvent) {
- event.preventDefault()
-
- setIsLoggingIn(true)
-
- const formData = new FormData(event.currentTarget)
-
- const identifier = String(formData.get('identifier'))
- const password = String(formData.get('password'))
-
+ async function loadNotifications() {
try {
- const response = await fetch(`${API_URL}/auth/login`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
+ const response = await fetch(`${API_URL}/notifications`, {
credentials: 'include',
- body: JSON.stringify({ identifier, password }),
})
if (!response.ok) {
- const errorData = await response.json().catch(() => null)
- alert(errorData?.error ?? 'Login fehlgeschlagen')
return
}
const data = await response.json()
- setUser(data.user)
+ setNotifications(data.notifications ?? [])
} catch {
- alert('Backend ist nicht erreichbar')
- } finally {
- setIsLoggingIn(false)
+ // bewusst still
}
}
+ async function loadUnreadJournalOperations() {
+ try {
+ const response = await fetch(`${API_URL}/operations/journal-unread`, {
+ credentials: 'include',
+ })
+
+ if (!response.ok) {
+ return
+ }
+
+ const data = await response.json()
+ setUnreadJournalOperationIds(new Set(data.operationIds ?? []))
+ } catch {
+ // bewusst still
+ }
+ }
+
+ async function markOperationJournalRead(operationId: string) {
+ setUnreadJournalOperationIds((currentIds) => {
+ const nextIds = new Set(currentIds)
+ nextIds.delete(operationId)
+ return nextIds
+ })
+
+ await fetch(`${API_URL}/operations/${operationId}/journal/read`, {
+ method: 'POST',
+ credentials: 'include',
+ }).catch(() => undefined)
+ }
+
+ async function markNotificationRead(notificationId: string) {
+ setNotifications((currentNotifications) =>
+ currentNotifications.map((notification) =>
+ notification.id === notificationId
+ ? {
+ ...notification,
+ readAt: notification.readAt ?? new Date().toISOString(),
+ }
+ : notification,
+ ),
+ )
+
+ await fetch(`${API_URL}/notifications/${notificationId}/read`, {
+ method: 'POST',
+ credentials: 'include',
+ }).catch(() => undefined)
+ }
+
+ async function markAllNotificationsRead() {
+ const now = new Date().toISOString()
+
+ setNotifications((currentNotifications) =>
+ currentNotifications.map((notification) => ({
+ ...notification,
+ readAt: notification.readAt ?? now,
+ })),
+ )
+
+ await fetch(`${API_URL}/notifications/read-all`, {
+ method: 'POST',
+ credentials: 'include',
+ }).catch(() => undefined)
+ }
+
async function handleLogout() {
try {
await fetch(`${API_URL}/auth/logout`, {
@@ -88,39 +262,119 @@ function App() {
})
} finally {
setUser(null)
+ setNotifications([])
+ setUnreadJournalOperationIds(new Set())
}
}
if (isLoading) {
return (
)
}
if (!user) {
return (
-
)
}
return (
-
+
+ }
+ >
} />
- } />
- } />
- } />
- } />
- } />
- } />
+
+
+
+
+ }
+ />
+
+
+
+
+ }
+ />
+
+
+
+
+ }
+ />
+
+
+
+
+ }
+ />
+
+
+
+
+ }
+ />
+
+
+
+
+ }
+ />
+
+
+
+
+ }
+ />
+
} />
diff --git a/frontend/src/components/AddressCombobox.tsx b/frontend/src/components/AddressCombobox.tsx
new file mode 100644
index 0000000..fb3849c
--- /dev/null
+++ b/frontend/src/components/AddressCombobox.tsx
@@ -0,0 +1,251 @@
+// frontend\src\components\AddressCombobox.tsx
+
+import {
+ Combobox as HeadlessCombobox,
+ ComboboxButton,
+ ComboboxInput,
+ ComboboxOption,
+ ComboboxOptions,
+ Label,
+} from '@headlessui/react'
+import { ChevronDownIcon } from '@heroicons/react/20/solid'
+import { useEffect, useMemo, useRef, useState } from 'react'
+
+const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
+
+export type AddressSuggestion = {
+ id: string
+ label: string
+ lat: number
+ lon: number
+ type?: string
+}
+
+type AddressComboboxProps = {
+ id: string
+ name: string
+ label: string
+ value: string
+ onChange: (value: string) => void
+ placeholder?: string
+}
+
+const inputClassName =
+ 'block w-full rounded-md bg-white py-1.5 pr-12 pl-3 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
+
+function classNames(...classes: Array) {
+ return classes.filter(Boolean).join(' ')
+}
+
+function buildOpenStreetMapEmbedUrl(address: AddressSuggestion) {
+ const offset = 0.004
+
+ const params = new URLSearchParams({
+ bbox: [
+ address.lon - offset,
+ address.lat - offset,
+ address.lon + offset,
+ address.lat + offset,
+ ].join(','),
+ layer: 'mapnik',
+ marker: `${address.lat},${address.lon}`,
+ })
+
+ return `https://www.openstreetmap.org/export/embed.html?${params.toString()}`
+}
+
+export default function AddressCombobox({
+ id,
+ name,
+ label,
+ value,
+ onChange,
+ placeholder = 'Adresse suchen',
+}: AddressComboboxProps) {
+ const [query, setQuery] = useState(value)
+ const [suggestions, setSuggestions] = useState([])
+ const [selectedSuggestion, setSelectedSuggestion] = useState(null)
+ const [previewSuggestion, setPreviewSuggestion] = useState(null)
+ const [isSearching, setIsSearching] = useState(false)
+ const [searchError, setSearchError] = useState(null)
+
+ const previousValueRef = useRef(value)
+
+ useEffect(() => {
+ if (value !== previousValueRef.current && value !== query) {
+ setQuery(value)
+ setSelectedSuggestion(null)
+ setPreviewSuggestion(null)
+ }
+
+ previousValueRef.current = value
+ }, [value, query])
+
+ useEffect(() => {
+ const trimmedQuery = query.trim()
+
+ if (trimmedQuery.length < 3) {
+ setSuggestions([])
+ setPreviewSuggestion(null)
+ setSearchError(null)
+ setIsSearching(false)
+ return
+ }
+
+ const abortController = new AbortController()
+
+ const timeout = window.setTimeout(async () => {
+ setIsSearching(true)
+ setSearchError(null)
+
+ try {
+ const response = await fetch(
+ `${API_URL}/address-search?q=${encodeURIComponent(trimmedQuery)}`,
+ {
+ credentials: 'include',
+ signal: abortController.signal,
+ },
+ )
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => null)
+ throw new Error(errorData?.error ?? 'Adresssuche konnte nicht geladen werden')
+ }
+
+ const data = await response.json()
+ const nextSuggestions = (data.suggestions ?? []) as AddressSuggestion[]
+
+ setSuggestions(nextSuggestions)
+ setPreviewSuggestion((currentPreview) => currentPreview ?? nextSuggestions[0] ?? null)
+ } catch (error) {
+ if (error instanceof DOMException && error.name === 'AbortError') {
+ return
+ }
+
+ setSuggestions([])
+ setPreviewSuggestion(null)
+ setSearchError(
+ error instanceof Error
+ ? error.message
+ : 'Adresssuche konnte nicht geladen werden',
+ )
+ } finally {
+ setIsSearching(false)
+ }
+ }, 600)
+
+ return () => {
+ window.clearTimeout(timeout)
+ abortController.abort()
+ }
+ }, [query])
+
+ const mapUrl = useMemo(() => {
+ if (!previewSuggestion) {
+ return null
+ }
+
+ return buildOpenStreetMapEmbedUrl(previewSuggestion)
+ }, [previewSuggestion])
+
+ function handleInputChange(nextValue: string) {
+ setQuery(nextValue)
+ setSelectedSuggestion(null)
+ setPreviewSuggestion(null)
+ onChange(nextValue)
+ }
+
+ function handleSelect(suggestion: AddressSuggestion | null) {
+ setSelectedSuggestion(suggestion)
+
+ if (!suggestion) {
+ return
+ }
+
+ setQuery(suggestion.label)
+ setPreviewSuggestion(suggestion)
+ onChange(suggestion.label)
+ }
+
+ return (
+
+
+
+
+
+
+
+
suggestion?.label ?? value}
+ onChange={(event) => handleInputChange(event.target.value)}
+ />
+
+
+
+
+
+ {(suggestions.length > 0 || isSearching || searchError) && (
+
+ {isSearching && (
+
+ Adressen werden gesucht...
+
+ )}
+
+ {searchError && (
+
+ {searchError}
+
+ )}
+
+ {suggestions.map((suggestion) => (
+
+ {suggestion.label}
+
+ {suggestion.type && (
+
+ {suggestion.type}
+
+ )}
+
+ ))}
+
+ )}
+
+
+
+ {mapUrl && previewSuggestion && (
+
+
+
+
+ Vorschau: {previewSuggestion.label}
+ {' · '}
+ © OpenStreetMap-Mitwirkende
+
+
+ )}
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/components/AppLayout.tsx b/frontend/src/components/AppLayout.tsx
index bb70d37..c34c066 100644
--- a/frontend/src/components/AppLayout.tsx
+++ b/frontend/src/components/AppLayout.tsx
@@ -1,6 +1,8 @@
-// frontend\src\components\AppLayout.tsx
+// frontend/src/components/AppLayout.tsx
import { useState, type ReactNode } from 'react'
+import { Transition } from '@headlessui/react'
+import SearchOverlay from './SearchOverlay'
import Sidebar from './Sidebar'
import Topbar from './Topbar'
import type { User } from './types'
@@ -9,10 +11,23 @@ type AppLayoutProps = {
user: User
onLogout: () => void | Promise
children: ReactNode
+ notificationCenter?: ReactNode
}
-export default function AppLayout({ user, onLogout, children }: AppLayoutProps) {
+export default function AppLayout({
+ user,
+ onLogout,
+ children,
+ notificationCenter,
+}: AppLayoutProps) {
const [sidebarOpen, setSidebarOpen] = useState(false)
+ const [searchQuery, setSearchQuery] = useState('')
+
+ const showSearchOverlay = searchQuery.trim() !== ''
+
+ function closeSearchOverlay() {
+ setSearchQuery('')
+ }
return (
@@ -27,10 +42,31 @@ export default function AppLayout({ user, onLogout, children }: AppLayoutProps)
user={user}
onLogout={onLogout}
onOpenSidebar={() => setSidebarOpen(true)}
+ notificationCenter={notificationCenter}
+ searchQuery={searchQuery}
+ onSearchQueryChange={setSearchQuery}
/>
-
+
{children}
+
+
+
+
diff --git a/frontend/src/components/Button.tsx b/frontend/src/components/Button.tsx
index 2be2cc4..7d9c4f4 100644
--- a/frontend/src/components/Button.tsx
+++ b/frontend/src/components/Button.tsx
@@ -4,7 +4,7 @@ import { type ButtonHTMLAttributes, type ReactNode } from 'react'
type Variant = 'primary' | 'secondary' | 'soft'
type Size = 'xs' | 'sm' | 'md' | 'lg'
-type Color = 'indigo' | 'blue' | 'emerald' | 'red' | 'amber'
+type Color = 'indigo' | 'blue' | 'emerald' | 'red' | 'amber' | 'gray'
export type ButtonProps = Omit<
ButtonHTMLAttributes,
@@ -55,6 +55,21 @@ const sizeMap: Record = {
}
const colorMap: Record> = {
+ gray: {
+ primary:
+ '!bg-gray-500 !text-white shadow-sm hover:!bg-gray-400 focus-visible:outline-gray-500 ' +
+ 'dark:!bg-gray-500 dark:!text-white dark:hover:!bg-gray-400 dark:focus-visible:outline-gray-500 ' +
+ disabledPrimary,
+ secondary:
+ 'bg-white text-gray-700 shadow-xs ring-1 ring-gray-200 hover:bg-gray-50 hover:text-gray-900 hover:ring-gray-300 ' +
+ 'dark:bg-white/10 dark:text-gray-300 dark:shadow-none dark:ring-white/10 dark:hover:bg-white/15 dark:hover:text-white dark:hover:ring-white/20 ' +
+ disabledSecondary,
+ soft:
+ 'bg-gray-500/10 text-gray-700 shadow-xs inset-ring inset-ring-gray-500/15 hover:bg-gray-500/15 hover:inset-ring-gray-500/20 ' +
+ 'dark:bg-white/5 dark:text-gray-300 dark:shadow-none dark:inset-ring-0 dark:hover:bg-white/10 dark:hover:text-white ' +
+ disabledSoft,
+ },
+
indigo: {
primary:
'!bg-indigo-600 !text-white shadow-sm hover:!bg-indigo-500 focus-visible:outline-indigo-600 ' +
diff --git a/frontend/src/components/Camera.tsx b/frontend/src/components/Camera.tsx
index a58aa95..f34961d 100644
--- a/frontend/src/components/Camera.tsx
+++ b/frontend/src/components/Camera.tsx
@@ -46,14 +46,26 @@ export default function Camera({ onScan }: CameraProps) {
async function startCamera() {
setError(null)
setScannedValue(null)
+
+ if (!navigator.mediaDevices?.getUserMedia) {
+ setError('Dein Browser unterstützt keinen Kamera-Zugriff.')
+ return
+ }
+
+ const BarcodeDetector = (
+ window as Window & {
+ BarcodeDetector?: BarcodeDetectorConstructor
+ }
+ ).BarcodeDetector
+
+ if (!BarcodeDetector) {
+ setError('QR-Code-Erkennung wird in diesem Browser nicht unterstützt.')
+ return
+ }
+
setIsStarting(true)
try {
- if (!navigator.mediaDevices?.getUserMedia) {
- setError('Dein Browser unterstützt keinen Kamera-Zugriff.')
- return
- }
-
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: {
@@ -70,8 +82,13 @@ export default function Camera({ onScan }: CameraProps) {
await videoRef.current.play()
}
- startQrScanner()
+ const detector = new BarcodeDetector({
+ formats: ['qr_code'],
+ })
+
+ startQrScanner(detector)
} catch {
+ stopCamera()
setError('Kamera konnte nicht geöffnet werden. Bitte Berechtigung prüfen.')
} finally {
setIsStarting(false)
@@ -94,22 +111,7 @@ export default function Camera({ onScan }: CameraProps) {
}
}
- function startQrScanner() {
- const BarcodeDetector = (
- window as Window & {
- BarcodeDetector?: BarcodeDetectorConstructor
- }
- ).BarcodeDetector
-
- if (!BarcodeDetector) {
- setError('QR-Code-Erkennung wird in diesem Browser nicht unterstützt. Die Kamera-Vorschau funktioniert trotzdem.')
- return
- }
-
- const detector = new BarcodeDetector({
- formats: ['qr_code'],
- })
-
+ function startQrScanner(detector: BarcodeDetectorInstance) {
async function scan() {
if (!videoRef.current) return
@@ -194,27 +196,30 @@ export default function Camera({ onScan }: CameraProps) {
) : (
-
+
+
+
+ {(isStarting || error) && (
+
+
+ {error ?? 'Kamera wird gestartet...'}
+
+
+ )}
+
)}
-
- {isStarting && (
-
- Kamera wird gestartet...
-
- )}
-
- {error && (
-
- {error}
-
- )}
diff --git a/frontend/src/components/Card.tsx b/frontend/src/components/Card.tsx
index 62a3b1e..7cbd59a 100644
--- a/frontend/src/components/Card.tsx
+++ b/frontend/src/components/Card.tsx
@@ -1,4 +1,4 @@
-// frontend\src\components\Card.tsx
+// frontend/src/components/Card.tsx
import type { ReactNode } from 'react'
@@ -8,18 +8,42 @@ type CardVariant =
| 'with-header'
| 'with-footer'
| 'with-header-footer'
+ | 'with-gray-header'
+ | 'with-gray-footer'
+ | 'with-gray-header-footer'
| 'gray-footer'
| 'gray-body'
| 'well'
| 'well-on-gray'
| 'well-edge-to-edge-mobile'
+type CardHeaderVariant =
+ | 'simple'
+ | 'with-action'
+ | 'with-description'
+ | 'with-description-action'
+ | 'with-avatar-actions'
+ | 'with-avatar-meta-actions'
+ | 'custom'
+
type CardProps = {
children?: ReactNode
header?: ReactNode
footer?: ReactNode
+
+ headerVariant?: CardHeaderVariant
+ headerTitle?: ReactNode
+ headerDescription?: ReactNode
+ headerMeta?: ReactNode
+ headerAction?: ReactNode
+ headerActions?: ReactNode
+ headerAvatar?: ReactNode
+
variant?: CardVariant
className?: string
+ headerClassName?: string
+ bodyClassName?: string
+ footerClassName?: string
}
function classNames(...classes: Array
) {
@@ -30,8 +54,20 @@ export default function Card({
children,
header,
footer,
+
+ headerVariant = 'custom',
+ headerTitle,
+ headerDescription,
+ headerMeta,
+ headerAction,
+ headerActions,
+ headerAvatar,
+
variant = 'basic',
className,
+ headerClassName,
+ bodyClassName,
+ footerClassName,
}: CardProps) {
const isEdgeToEdgeMobile =
variant === 'edge-to-edge-mobile' || variant === 'well-edge-to-edge-mobile'
@@ -41,23 +77,181 @@ export default function Card({
variant === 'well-on-gray' ||
variant === 'well-edge-to-edge-mobile'
+ const hasGrayHeader =
+ variant === 'with-gray-header' ||
+ variant === 'with-gray-header-footer'
+
+ const hasGrayFooter =
+ variant === 'with-gray-footer' ||
+ variant === 'with-gray-header-footer' ||
+ variant === 'gray-footer'
+
const hasDivider =
variant === 'with-header' ||
variant === 'with-footer' ||
- variant === 'with-header-footer'
+ variant === 'with-header-footer' ||
+ variant === 'with-gray-header' ||
+ variant === 'with-gray-footer' ||
+ variant === 'with-gray-header-footer'
+
+ const renderedHeader = renderHeader()
const hasHeader =
- header &&
+ Boolean(renderedHeader) &&
(variant === 'with-header' ||
variant === 'with-header-footer' ||
+ variant === 'with-gray-header' ||
+ variant === 'with-gray-header-footer' ||
variant === 'gray-body')
const hasFooter =
- footer &&
+ Boolean(footer) &&
(variant === 'with-footer' ||
variant === 'with-header-footer' ||
+ variant === 'with-gray-footer' ||
+ variant === 'with-gray-header-footer' ||
variant === 'gray-footer')
+ function renderHeader() {
+ if (header) {
+ return header
+ }
+
+ if (!headerTitle && !headerDescription && !headerAction && !headerActions && !headerAvatar && !headerMeta) {
+ return null
+ }
+
+ const actions = headerActions ?? headerAction
+
+ if (headerVariant === 'with-action') {
+ return (
+
+
+
+ {headerTitle}
+
+
+
+ {actions && (
+
+ {actions}
+
+ )}
+
+ )
+ }
+
+ if (headerVariant === 'with-description-action') {
+ return (
+
+
+
+ {headerTitle}
+
+
+ {headerDescription && (
+
+ {headerDescription}
+
+ )}
+
+
+ {actions && (
+
+ {actions}
+
+ )}
+
+ )
+ }
+
+ if (headerVariant === 'with-description') {
+ return (
+ <>
+
+ {headerTitle}
+
+
+ {headerDescription && (
+
+ {headerDescription}
+
+ )}
+ >
+ )
+ }
+
+ if (headerVariant === 'with-avatar-actions') {
+ return (
+
+
+
+ {headerAvatar && (
+
+ {headerAvatar}
+
+ )}
+
+
+
+ {headerTitle}
+
+
+ {headerDescription && (
+
+ {headerDescription}
+
+ )}
+
+
+
+
+ {actions && (
+
+ {actions}
+
+ )}
+
+ )
+ }
+
+ if (headerVariant === 'with-avatar-meta-actions') {
+ return (
+
+ {headerAvatar && (
+
+ {headerAvatar}
+
+ )}
+
+
+
+ {headerTitle}
+
+
+ {headerMeta && (
+
+ {headerMeta}
+
+ )}
+
+
+ {actions && (
+
+ {actions}
+
+ )}
+
+ )
+ }
+
+ return (
+
+ {headerTitle}
+
+ )
+ }
+
return (
{hasHeader && (
-
- {header}
+
+ {renderedHeader}
)}
{children}
@@ -91,7 +292,8 @@ export default function Card({
{footer}
diff --git a/frontend/src/components/Checkbox.tsx b/frontend/src/components/Checkbox.tsx
new file mode 100644
index 0000000..4ffa959
--- /dev/null
+++ b/frontend/src/components/Checkbox.tsx
@@ -0,0 +1,175 @@
+// frontend\src\components\Checkbox.tsx
+
+import {
+ forwardRef,
+ useEffect,
+ useId,
+ useRef,
+ type InputHTMLAttributes,
+ type ReactNode,
+} from 'react'
+
+type CheckboxProps = Omit
, 'type'> & {
+ label?: ReactNode
+ description?: ReactNode
+ indeterminate?: boolean
+ wrapperClassName?: string
+ labelClassName?: string
+ descriptionClassName?: string
+ inputClassName?: string
+ checkboxPosition?: 'left' | 'right'
+}
+
+function classNames(...classes: Array) {
+ return classes.filter(Boolean).join(' ')
+}
+
+const checkboxInputClassName =
+ 'col-start-1 row-start-1 appearance-none rounded-sm border border-gray-300 bg-white checked:border-indigo-600 checked:bg-indigo-600 indeterminate:border-indigo-600 indeterminate:bg-indigo-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 dark:border-white/10 dark:bg-white/5 dark:checked:border-indigo-500 dark:checked:bg-indigo-500 dark:indeterminate:border-indigo-500 dark:indeterminate:bg-indigo-500 dark:focus-visible:outline-indigo-500 dark:disabled:border-white/5 dark:disabled:bg-white/10 dark:disabled:checked:bg-white/10 forced-colors:appearance-auto'
+
+const checkboxIconClassName =
+ 'pointer-events-none col-start-1 row-start-1 size-3.5 self-center justify-self-center stroke-white group-has-disabled:stroke-gray-950/25 dark:group-has-disabled:stroke-white/25'
+
+const Checkbox = forwardRef(function Checkbox(
+ {
+ id,
+ label,
+ description,
+ indeterminate = false,
+ wrapperClassName,
+ labelClassName,
+ descriptionClassName,
+ inputClassName,
+ checkboxPosition = 'left',
+ className,
+ disabled,
+ 'aria-describedby': ariaDescribedBy,
+ ...props
+ },
+ forwardedRef,
+) {
+ const generatedId = useId()
+ const inputId = id ?? `checkbox-${generatedId}`
+ const descriptionId = description ? `${inputId}-description` : undefined
+ const inputRef = useRef(null)
+
+ function setRefs(node: HTMLInputElement | null) {
+ inputRef.current = node
+
+ if (typeof forwardedRef === 'function') {
+ forwardedRef(node)
+ return
+ }
+
+ if (forwardedRef) {
+ forwardedRef.current = node
+ }
+ }
+
+ useEffect(() => {
+ if (inputRef.current) {
+ inputRef.current.indeterminate = indeterminate
+ }
+ }, [indeterminate])
+
+ const describedBy = [ariaDescribedBy, descriptionId]
+ .filter(Boolean)
+ .join(' ')
+
+ const checkbox = (
+
+ )
+
+ const text = label || description ? (
+
+ {label && (
+
+ )}
+
+ {description && (
+
+ {description}
+
+ )}
+
+ ) : null
+
+ return (
+
+ {checkboxPosition === 'left' ? (
+ <>
+ {checkbox}
+ {text}
+ >
+ ) : (
+ <>
+ {text}
+ {checkbox}
+ >
+ )}
+
+ )
+})
+
+export default Checkbox
\ No newline at end of file
diff --git a/frontend/src/components/Combobox.tsx b/frontend/src/components/Combobox.tsx
new file mode 100644
index 0000000..e3c95a4
--- /dev/null
+++ b/frontend/src/components/Combobox.tsx
@@ -0,0 +1,231 @@
+'use client'
+
+import { useState } from 'react'
+import {
+ Combobox as HeadlessCombobox,
+ ComboboxButton,
+ ComboboxInput,
+ ComboboxOption,
+ ComboboxOptions,
+ Label,
+} from '@headlessui/react'
+import { ChevronDownIcon, UserIcon } from '@heroicons/react/20/solid'
+
+export type ComboboxVariant = 'simple' | 'status' | 'image' | 'secondary'
+
+export type ComboboxItem = {
+ id: string | number | null
+ name: string
+ online?: boolean
+ imageUrl?: string
+ secondaryText?: string
+ username?: string
+}
+
+type ComboboxProps = {
+ label?: string
+ items: ComboboxItem[]
+ value: ComboboxItem | null
+ onChange: (item: ComboboxItem | null) => void
+ variant?: ComboboxVariant
+ placeholder?: string
+ allowCustomValue?: boolean
+ createOptionLabel?: (value: string) => string
+ emptyText?: string
+ disabled?: boolean
+ required?: boolean
+ className?: string
+ inputClassName?: string
+}
+
+function classNames(...classes: Array) {
+ return classes.filter(Boolean).join(' ')
+}
+
+function getSecondaryText(item: ComboboxItem) {
+ return item.secondaryText ?? item.username
+}
+
+export default function Combobox({
+ label,
+ items,
+ value,
+ onChange,
+ variant = 'simple',
+ placeholder,
+ allowCustomValue = false,
+ createOptionLabel,
+ emptyText = 'Keine Ergebnisse gefunden.',
+ disabled = false,
+ required = false,
+ className,
+ inputClassName,
+}: ComboboxProps) {
+ const [query, setQuery] = useState('')
+
+ const normalizedQuery = query.trim().toLowerCase()
+
+ const filteredItems =
+ normalizedQuery === ''
+ ? items
+ : items.filter((item) => {
+ const secondaryText = getSecondaryText(item)
+
+ return (
+ item.name.toLowerCase().includes(normalizedQuery) ||
+ secondaryText?.toLowerCase().includes(normalizedQuery)
+ )
+ })
+
+ function renderOptionContent(item: ComboboxItem, isCustomValue = false) {
+ if (variant === 'status') {
+ return (
+
+
+
+
+ {item.name}
+ {!isCustomValue && (
+
+ {' '}
+ ist {item.online ? 'online' : 'offline'}
+
+ )}
+
+
+ )
+ }
+
+ if (variant === 'image') {
+ return (
+
+ {item.imageUrl ? (
+

+ ) : (
+
+
+
+ )}
+
+
{item.name}
+
+ )
+ }
+
+ if (variant === 'secondary') {
+ const secondaryText = getSecondaryText(item)
+
+ return (
+
+ {item.name}
+
+ {secondaryText && (
+
+ {secondaryText}
+
+ )}
+
+ )
+ }
+
+ return {item.name}
+ }
+
+ return (
+ {
+ setQuery('')
+ onChange(item)
+ }}
+ disabled={disabled}
+ className={className}
+ >
+ {label && (
+
+ )}
+
+
+
setQuery(event.target.value)}
+ onBlur={() => setQuery('')}
+ displayValue={(item: ComboboxItem | null) => item?.name ?? ''}
+ />
+
+
+
+
+
+
+ {allowCustomValue && query.trim().length > 0 && (
+
+ {renderOptionContent(
+ {
+ id: null,
+ name: createOptionLabel
+ ? createOptionLabel(query.trim())
+ : query.trim(),
+ },
+ true,
+ )}
+
+ )}
+
+ {filteredItems.map((item) => (
+
+ {renderOptionContent(item)}
+
+ ))}
+
+ {filteredItems.length === 0 && !allowCustomValue && (
+
+ {emptyText}
+
+ )}
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/components/DeviceModal.tsx b/frontend/src/components/DeviceModal.tsx
deleted file mode 100644
index 04565ab..0000000
--- a/frontend/src/components/DeviceModal.tsx
+++ /dev/null
@@ -1,388 +0,0 @@
-import type { FormEvent } from 'react'
-import { DialogTitle } from '@headlessui/react'
-import { XMarkIcon } from '@heroicons/react/24/outline'
-import { PencilSquareIcon, PlusCircleIcon } from '@heroicons/react/20/solid'
-import Modal from './Modal'
-import Feed, { type FeedTimelineItem } from './Feed'
-import Button from './Button'
-import type { Device, DeviceHistoryChange, DeviceHistoryEntry } from './types'
-
-type DeviceModalProps = {
- open: boolean
- setOpen: (open: boolean) => void
- isSaving: boolean
- error: string | null
- devices: Device[]
- editingDevice: Device | null
- relatedDeviceIds: string[]
- onToggleRelatedDevice: (deviceId: string) => void
- onSubmit: (event: FormEvent) => void
- deviceHistory: DeviceHistoryEntry[]
- isHistoryLoading: boolean
- historyError: string | null
-}
-
-const inputClassName =
- 'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
-
-const selectClassName =
- 'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:*:bg-gray-800 dark:focus:outline-indigo-500'
-
-function classNames(...classes: Array) {
- return classes.filter(Boolean).join(' ')
-}
-
-function formatHistoryDate(value: string) {
- try {
- return new Intl.DateTimeFormat('de-DE', {
- dateStyle: 'short',
- timeStyle: 'short',
- }).format(new Date(value))
- } catch {
- return value
- }
-}
-
-function formatHistoryChange(change: DeviceHistoryChange) {
- const oldValue = change.oldValue || '—'
- const newValue = change.newValue || '—'
-
- return `${change.label}: ${oldValue} → ${newValue}`
-}
-
-function getHistoryContent(entry: DeviceHistoryEntry) {
- if (entry.action === 'created') {
- return 'Gerät erstellt durch'
- }
-
- if (!entry.changes.length) {
- return 'Gerät bearbeitet durch'
- }
-
- return `${entry.changes.map(formatHistoryChange).join(', ')} durch`
-}
-
-export default function DeviceModal({
- open,
- setOpen,
- isSaving,
- error,
- devices,
- editingDevice,
- relatedDeviceIds,
- onToggleRelatedDevice,
- onSubmit,
- deviceHistory,
- isHistoryLoading,
- historyError,
-}: DeviceModalProps) {
- const isEditing = editingDevice !== null
- const assignableDevices = devices.filter((device) => device.id !== editingDevice?.id)
-
- const historyTimeline: FeedTimelineItem[] = deviceHistory.map((entry) => ({
- id: entry.id,
- content: getHistoryContent(entry),
- target: entry.userDisplayName || 'Unbekannt',
- href: '#',
- date: formatHistoryDate(entry.createdAt),
- datetime: entry.createdAt,
- icon: entry.action === 'created' ? PlusCircleIcon : PencilSquareIcon,
- iconBackground: entry.action === 'created' ? 'bg-green-500' : 'bg-blue-500',
- }))
-
- function handleOpenChange(nextOpen: boolean) {
- if (!nextOpen && isSaving) {
- return
- }
-
- setOpen(nextOpen)
- }
-
- return (
-
-
-
- )
-}
\ No newline at end of file
diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx
deleted file mode 100644
index 06d94cb..0000000
--- a/frontend/src/components/Feed.tsx
+++ /dev/null
@@ -1,689 +0,0 @@
-// frontend/src/components/Feed.tsx
-
-import { Fragment, useState, type ComponentType, type FormEvent, type SVGProps } from 'react'
-import {
- ChatBubbleLeftEllipsisIcon,
- CheckIcon,
- FaceFrownIcon,
- FaceSmileIcon,
- FireIcon,
- HandThumbUpIcon,
- HeartIcon,
- PaperClipIcon,
- TagIcon,
- UserCircleIcon,
- UserIcon,
- XMarkIcon,
-} from '@heroicons/react/20/solid'
-import { CheckCircleIcon } from '@heroicons/react/24/solid'
-import { Label, Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react'
-import Button from './Button'
-
-type FeedIcon = ComponentType>
-
-type FeedVariant = 'icons' | 'comments' | 'multiple'
-
-export type FeedTimelineItem = {
- id: string | number
- content: string
- target: string
- href: string
- date: string
- datetime: string
- icon: FeedIcon
- iconBackground: string
-}
-
-export type FeedCommentActivityItem =
- | {
- id: string | number
- type: 'created' | 'edited' | 'sent' | 'viewed' | 'paid'
- person: {
- name: string
- }
- date: string
- dateTime: string
- }
- | {
- id: string | number
- type: 'commented'
- person: {
- name: string
- imageUrl: string
- }
- comment: string
- date: string
- dateTime: string
- }
-
-export type FeedMultipleActivityItem =
- | {
- id: string | number
- type: 'comment'
- person: {
- name: string
- href: string
- }
- imageUrl: string
- comment: string
- date: string
- }
- | {
- id: string | number
- type: 'assignment'
- person: {
- name: string
- href: string
- }
- assigned: {
- name: string
- href: string
- }
- date: string
- }
- | {
- id: string | number
- type: 'tags'
- person: {
- name: string
- href: string
- }
- tags: Array<{
- name: string
- href: string
- color: string
- }>
- date: string
- }
-
-type Mood = {
- name: string
- value: string | null
- icon: FeedIcon
- iconColor: string
- bgColor: string
-}
-
-type FeedProps = {
- variant?: FeedVariant
- timeline?: FeedTimelineItem[]
- activity?: FeedCommentActivityItem[]
- multipleActivity?: FeedMultipleActivityItem[]
- currentUserAvatar?: string
- showCommentForm?: boolean
- onCommentSubmit?: (comment: string, mood: Mood) => void
- className?: string
-}
-
-function classNames(...classes: Array) {
- return classes.filter(Boolean).join(' ')
-}
-
-const defaultTimeline: FeedTimelineItem[] = [
- {
- id: 1,
- content: 'Applied to',
- target: 'Front End Developer',
- href: '#',
- date: 'Sep 20',
- datetime: '2020-09-20',
- icon: UserIcon,
- iconBackground: 'bg-gray-400 dark:bg-gray-600',
- },
- {
- id: 2,
- content: 'Advanced to phone screening by',
- target: 'Bethany Blake',
- href: '#',
- date: 'Sep 22',
- datetime: '2020-09-22',
- icon: HandThumbUpIcon,
- iconBackground: 'bg-blue-500',
- },
- {
- id: 3,
- content: 'Completed phone screening with',
- target: 'Martha Gardner',
- href: '#',
- date: 'Sep 28',
- datetime: '2020-09-28',
- icon: CheckIcon,
- iconBackground: 'bg-green-500',
- },
- {
- id: 4,
- content: 'Advanced to interview by',
- target: 'Bethany Blake',
- href: '#',
- date: 'Sep 30',
- datetime: '2020-09-30',
- icon: HandThumbUpIcon,
- iconBackground: 'bg-blue-500',
- },
- {
- id: 5,
- content: 'Completed interview with',
- target: 'Katherine Snyder',
- href: '#',
- date: 'Oct 4',
- datetime: '2020-10-04',
- icon: CheckIcon,
- iconBackground: 'bg-green-500',
- },
-]
-
-const defaultCommentActivity: FeedCommentActivityItem[] = [
- {
- id: 1,
- type: 'created',
- person: { name: 'Chelsea Hagon' },
- date: '7d ago',
- dateTime: '2023-01-23T10:32',
- },
- {
- id: 2,
- type: 'edited',
- person: { name: 'Chelsea Hagon' },
- date: '6d ago',
- dateTime: '2023-01-23T11:03',
- },
- {
- id: 3,
- type: 'sent',
- person: { name: 'Chelsea Hagon' },
- date: '6d ago',
- dateTime: '2023-01-23T11:24',
- },
- {
- id: 4,
- type: 'commented',
- person: {
- name: 'Chelsea Hagon',
- imageUrl:
- 'https://images.unsplash.com/photo-1550525811-e5869dd03032?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
- },
- comment: 'Called client, they reassured me the invoice would be paid by the 25th.',
- date: '3d ago',
- dateTime: '2023-01-23T15:56',
- },
- {
- id: 5,
- type: 'viewed',
- person: { name: 'Alex Curren' },
- date: '2d ago',
- dateTime: '2023-01-24T09:12',
- },
- {
- id: 6,
- type: 'paid',
- person: { name: 'Alex Curren' },
- date: '1d ago',
- dateTime: '2023-01-24T09:20',
- },
-]
-
-const defaultMultipleActivity: FeedMultipleActivityItem[] = [
- {
- id: 1,
- type: 'comment',
- person: { name: 'Eduardo Benz', href: '#' },
- imageUrl:
- 'https://images.unsplash.com/photo-1520785643438-5bf77931f493?ixlib=rb-1.2.1&auto=format&fit=facearea&facepad=8&w=256&h=256&q=80',
- comment:
- 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Tincidunt nunc ipsum tempor purus vitae id.',
- date: '6d ago',
- },
- {
- id: 2,
- type: 'assignment',
- person: { name: 'Hilary Mahy', href: '#' },
- assigned: { name: 'Kristin Watson', href: '#' },
- date: '2d ago',
- },
- {
- id: 3,
- type: 'tags',
- person: { name: 'Hilary Mahy', href: '#' },
- tags: [
- { name: 'Bug', href: '#', color: 'fill-red-500' },
- { name: 'Accessibility', href: '#', color: 'fill-indigo-500' },
- ],
- date: '6h ago',
- },
- {
- id: 4,
- type: 'comment',
- person: { name: 'Jason Meyers', href: '#' },
- imageUrl:
- 'https://images.unsplash.com/photo-1531427186611-ecfd6d936c79?ixlib=rb-1.2.1&auto=format&fit=facearea&facepad=8&w=256&h=256&q=80',
- comment:
- 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Tincidunt nunc ipsum tempor purus vitae id.',
- date: '2h ago',
- },
-]
-
-const moods: Mood[] = [
- { name: 'Excited', value: 'excited', icon: FireIcon, iconColor: 'text-white', bgColor: 'bg-red-500' },
- { name: 'Loved', value: 'loved', icon: HeartIcon, iconColor: 'text-white', bgColor: 'bg-pink-400' },
- { name: 'Happy', value: 'happy', icon: FaceSmileIcon, iconColor: 'text-white', bgColor: 'bg-green-400' },
- { name: 'Sad', value: 'sad', icon: FaceFrownIcon, iconColor: 'text-white', bgColor: 'bg-yellow-400' },
- { name: 'Thumbsy', value: 'thumbsy', icon: HandThumbUpIcon, iconColor: 'text-white', bgColor: 'bg-blue-500' },
- {
- name: 'I feel nothing',
- value: null,
- icon: XMarkIcon,
- iconColor: 'text-gray-400 dark:text-gray-500',
- bgColor: 'bg-transparent',
- },
-]
-
-const defaultAvatar =
- 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80'
-
-function FeedWithIcons({ timeline }: { timeline: FeedTimelineItem[] }) {
- return (
-
-
- {timeline.map((event, eventIndex) => (
- -
-
- {eventIndex !== timeline.length - 1 && (
-
- )}
-
-
-
-
- ))}
-
-
- )
-}
-
-function FeedWithComments({
- activity,
- currentUserAvatar,
- showCommentForm,
- onCommentSubmit,
-}: {
- activity: FeedCommentActivityItem[]
- currentUserAvatar: string
- showCommentForm: boolean
- onCommentSubmit?: (comment: string, mood: Mood) => void
-}) {
- const [selectedMood, setSelectedMood] = useState(moods[5])
-
- function handleSubmit(event: FormEvent) {
- event.preventDefault()
-
- const formData = new FormData(event.currentTarget)
- const comment = String(formData.get('comment') ?? '').trim()
-
- if (!comment) {
- return
- }
-
- onCommentSubmit?.(comment, selectedMood)
- event.currentTarget.reset()
- }
-
- return (
- <>
-
-
- {showCommentForm && (
-
-

-
-
-
- )}
- >
- )
-}
-
-function FeedWithMultipleTypes({ activity }: { activity: FeedMultipleActivityItem[] }) {
- return (
-
-
- {activity.map((activityItem, activityIndex) => (
- -
-
- {activityIndex !== activity.length - 1 && (
-
- )}
-
-
- {activityItem.type === 'comment' && (
- <>
-
-

-
-
-
-
-
-
-
-
-
-
-
- Commented {activityItem.date}
-
-
-
-
-
{activityItem.comment}
-
-
- >
- )}
-
- {activityItem.type === 'assignment' && (
- <>
-
-
-
- >
- )}
-
- {activityItem.type === 'tags' && (
- <>
-
-
-
- >
- )}
-
-
-
- ))}
-
-
- )
-}
-
-export default function Feed({
- variant = 'icons',
- timeline = defaultTimeline,
- activity = defaultCommentActivity,
- multipleActivity = defaultMultipleActivity,
- currentUserAvatar = defaultAvatar,
- showCommentForm = true,
- onCommentSubmit,
- className,
-}: FeedProps) {
- return (
-
- {variant === 'icons' && }
-
- {variant === 'comments' && (
-
- )}
-
- {variant === 'multiple' && }
-
- )
-}
\ No newline at end of file
diff --git a/frontend/src/components/Journal.tsx b/frontend/src/components/Journal.tsx
new file mode 100644
index 0000000..65171cd
--- /dev/null
+++ b/frontend/src/components/Journal.tsx
@@ -0,0 +1,428 @@
+// frontend\src\components\Journal.tsx
+
+import { type ComponentType, type SVGProps, useState } from 'react'
+import {
+ ChatBubbleLeftEllipsisIcon,
+ PencilSquareIcon,
+ PlusCircleIcon,
+ UserCircleIcon,
+} from '@heroicons/react/20/solid'
+import Button from './Button'
+import NotificationDot from './NotificationDot'
+
+type JournalIcon = ComponentType>
+
+export type JournalChange = {
+ label: string
+ oldValue?: string
+ newValue?: string
+}
+
+export type JournalItem = {
+ id: string | number
+ type?: 'created' | 'edited' | 'journal' | 'comment'
+
+ content: string
+ description?: string
+ changes?: JournalChange[]
+
+ date: string
+ datetime?: string
+ dateTime?: string
+
+ editedDate?: string
+ editedDatetime?: string
+ editedDateTime?: string
+
+ canEdit?: boolean
+ hasNotification?: boolean
+
+ icon?: JournalIcon
+ iconBackground?: string
+
+ person?: {
+ name: string
+ href?: string
+ imageUrl?: string
+ }
+
+ imageUrl?: string
+}
+
+type JournalProps = {
+ items?: JournalItem[]
+ currentUserAvatar?: string
+ showEntryForm?: boolean
+ isSubmitting?: boolean
+ emptyText?: string
+ placeholder?: string
+ submitLabel?: string
+ onEntrySubmit?: (content: string) => void | Promise
+ onEntryUpdate?: (itemId: string | number, content: string) => void | Promise
+ className?: string
+}
+
+function classNames(...classes: Array) {
+ return classes.filter(Boolean).join(' ')
+}
+
+function getItemPersonName(item: JournalItem) {
+ return item.person?.name ?? 'Unbekannt'
+}
+
+function getItemDateTime(item: JournalItem) {
+ return item.dateTime ?? item.datetime ?? ''
+}
+
+function getItemEditedDateTime(item: JournalItem) {
+ return item.editedDateTime ?? item.editedDatetime ?? ''
+}
+
+function getItemImageUrl(item: JournalItem) {
+ return item.person?.imageUrl ?? item.imageUrl
+}
+
+function getItemDescription(item: JournalItem) {
+ return item.description ?? item.content
+}
+
+function getChangeValue(value?: string) {
+ return value?.trim() || '—'
+}
+
+function getChangeKey(change: JournalChange, index: number) {
+ return `${change.label}-${index}`
+}
+
+function getJournalIcon(item: JournalItem) {
+ if (item.icon) {
+ return item.icon
+ }
+
+ if (item.type === 'created') {
+ return PlusCircleIcon
+ }
+
+ if (item.type === 'edited') {
+ return PencilSquareIcon
+ }
+
+ return ChatBubbleLeftEllipsisIcon
+}
+
+function getJournalIconBackground(item: JournalItem) {
+ if (item.iconBackground) {
+ return item.iconBackground
+ }
+
+ if (item.type === 'created') {
+ return 'bg-green-500'
+ }
+
+ if (item.type === 'edited') {
+ return 'bg-blue-500'
+ }
+
+ return 'bg-indigo-500'
+}
+
+export default function Journal({
+ items = [],
+ currentUserAvatar,
+ showEntryForm = true,
+ isSubmitting = false,
+ emptyText = 'Noch keine Journal-Einträge vorhanden.',
+ placeholder = 'Journal-Eintrag hinzufügen...',
+ submitLabel = 'Eintrag hinzufügen',
+ onEntrySubmit,
+ onEntryUpdate,
+ className,
+}: JournalProps) {
+ const [entry, setEntry] = useState('')
+ const [editingItemId, setEditingItemId] = useState(null)
+ const [editEntry, setEditEntry] = useState('')
+
+ async function handleSubmit() {
+ const content = entry.trim()
+
+ if (!content || isSubmitting) {
+ return
+ }
+
+ await onEntrySubmit?.(content)
+ setEntry('')
+ }
+
+ function startEditing(item: JournalItem) {
+ setEditingItemId(item.id)
+ setEditEntry(getItemDescription(item))
+ }
+
+ function cancelEditing() {
+ setEditingItemId(null)
+ setEditEntry('')
+ }
+
+ async function handleUpdate(itemId: string | number) {
+ const content = editEntry.trim()
+
+ if (!content || isSubmitting) {
+ return
+ }
+
+ await onEntryUpdate?.(itemId, content)
+ cancelEditing()
+ }
+
+ return (
+
+ {showEntryForm && (
+
+ {currentUserAvatar ? (
+

+ ) : (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {items.length === 0 ? (
+
+ {emptyText}
+
+ ) : (
+
+
+ {items.map((item, itemIndex) => {
+ const personName = getItemPersonName(item)
+ const imageUrl = getItemImageUrl(item)
+ const dateTime = getItemDateTime(item)
+ const Icon = getJournalIcon(item)
+ const isManualEntry = item.type === 'journal' || item.type === 'comment'
+ const changes = item.changes ?? []
+ const hasChanges = !isManualEntry && changes.length > 0
+
+ return (
+ -
+
+ {itemIndex !== items.length - 1 && (
+
+ )}
+
+
+
+ {imageUrl ? (
+

+ ) : (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ {personName}
+
+ {item.hasNotification && (
+
+ )}
+ {' '}
+
+ {!isManualEntry && (
+
+ {getItemDescription(item)}
+
+ )}
+
+
+
+
+
+
+
+ {isManualEntry && editingItemId === item.id ? (
+
+ ) : isManualEntry ? (
+
+
+
+ {getItemDescription(item)}
+
+
+ {item.canEdit && onEntryUpdate && (
+
+ )}
+
+
+ {item.editedDate && (
+
+
+
+ Zuletzt bearbeitet:{' '}
+
+
+
+ )}
+
+ ) : hasChanges ? (
+
+ {changes.map((change, changeIndex) => (
+
+
+ {change.label}
+
+
+
+
+
+ Vorher
+
+
+ {getChangeValue(change.oldValue)}
+
+
+
+
+
+ Nachher
+
+
+ {getChangeValue(change.newValue)}
+
+
+
+
+ ))}
+
+ ) : null}
+
+
+
+
+ )
+ })}
+
+
+ )}
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/components/LoadingSpinner.tsx b/frontend/src/components/LoadingSpinner.tsx
new file mode 100644
index 0000000..ee9b9f2
--- /dev/null
+++ b/frontend/src/components/LoadingSpinner.tsx
@@ -0,0 +1,96 @@
+// frontend\src\components\LoadingSpinner.tsx
+
+'use client'
+
+import { type ReactNode } from 'react'
+
+type SpinnerSize = 'xs' | 'sm' | 'md' | 'lg' | number
+
+export type LoadingSpinnerProps = {
+ /** Größe als Preset oder px (number) */
+ size?: SpinnerSize
+ /** Farbe via Tailwind (z.B. "text-indigo-500") */
+ className?: string
+ /** Optionaler Text neben dem Spinner (sichtbar) */
+ label?: ReactNode
+ /** Screenreader-Text (wenn label nicht gesetzt ist) */
+ srLabel?: string
+ /** Zentriert Spinner + Label als Inline-Flex */
+ center?: boolean
+}
+
+function sizeToPx(size: SpinnerSize): number {
+ if (typeof size === 'number') return size
+ if (size === 'xs') return 12
+ if (size === 'sm') return 16
+ if (size === 'lg') return 28
+ return 20 // md default
+}
+
+function cn(...parts: Array) {
+ return parts.filter(Boolean).join(' ')
+}
+
+export default function LoadingSpinner({
+ size = 'md',
+ className,
+ label,
+ srLabel = 'Lädt…',
+ center = false,
+}: LoadingSpinnerProps) {
+ const px = sizeToPx(size)
+
+ return (
+
+
+
+ {label ? (
+ {label}
+ ) : (
+ {srLabel}
+ )}
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/components/LoginForm.tsx b/frontend/src/components/LoginForm.tsx
deleted file mode 100644
index 7fb27bb..0000000
--- a/frontend/src/components/LoginForm.tsx
+++ /dev/null
@@ -1,144 +0,0 @@
-// frontend\src\components\LoginForm.tsx
-
-import type { FormEvent } from 'react'
-import Button from './Button'
-import Card from './Card'
-
-type LoginFormProps = {
- title?: string
- submitText?: string
- isSubmitting?: boolean
- forgotPasswordHref?: string
- registerHref?: string
- googleHref?: string
- githubHref?: string
- onSubmit?: (event: FormEvent) => void
-}
-
-export default function LoginForm({
- title = 'Sign in to your account',
- submitText = 'Sign in',
- isSubmitting = false,
- forgotPasswordHref = '#',
- onSubmit,
-}: LoginFormProps) {
- return (
-
-
-

-

-
-
- {title}
-
-
-
-
-
- )
-}
\ No newline at end of file
diff --git a/frontend/src/components/Map.tsx b/frontend/src/components/Map.tsx
new file mode 100644
index 0000000..23d5066
--- /dev/null
+++ b/frontend/src/components/Map.tsx
@@ -0,0 +1,621 @@
+// frontend/src/components/Map.tsx
+
+import { useEffect, useRef, useState, type ReactNode } from 'react'
+import {
+ LayersControl,
+ MapContainer,
+ Marker,
+ Popup,
+ TileLayer,
+ Tooltip,
+ WMSTileLayer,
+ useMap,
+} from 'react-leaflet'
+import * as L from 'leaflet'
+import 'leaflet.heat'
+import 'leaflet/dist/leaflet.css'
+import { MapPinIcon } from '@heroicons/react/20/solid'
+
+export type MapMarker = {
+ id: string | number
+ lat: number
+ lon: number
+ popup?: ReactNode
+ title?: string
+ label?: ReactNode
+ labelOffset?: [number, number]
+ icon?: L.Icon | L.DivIcon
+}
+
+export type MapHeatPoint = {
+ id?: string | number
+ lat: number
+ lon: number
+ intensity?: number
+}
+
+export type MapHeatOptions = {
+ minOpacity?: number
+ maxZoom?: number
+ max?: number
+ radius?: number
+ blur?: number
+ gradient?: Record
+}
+
+export type MapTileLayer = {
+ id: string
+ name: string
+ url: string
+ attribution?: string
+ checked?: boolean
+ maxNativeZoom?: number
+}
+
+export type MapWmsLayer = {
+ id: string
+ name: string
+ url: string
+ layers: string
+ format?: string
+ transparent?: boolean
+ version?: string
+ attribution?: string
+ checked?: boolean
+ overlay?: boolean
+ opacity?: number
+ zIndex?: number
+}
+
+type MapProps = {
+ markers?: MapMarker[]
+ heatPoints?: MapHeatPoint[]
+ showHeatmap?: boolean
+ heatmapOptions?: MapHeatOptions
+
+ center?: [number, number]
+ zoom?: number
+ minZoom?: number
+ maxZoom?: number
+ scrollWheelZoom?: boolean
+ fitBounds?: boolean
+ fitBoundsPadding?: [number, number]
+
+ showLocateControl?: boolean
+ locateZoom?: number
+ showCurrentLocationMarker?: boolean
+
+ tileLayers?: MapTileLayer[]
+ wmsLayers?: MapWmsLayer[]
+ showLayerControl?: boolean
+
+ className?: string
+ mapClassName?: string
+ minHeightClassName?: string | false | null
+ children?: ReactNode
+}
+
+const defaultTileLayers: MapTileLayer[] = [
+ {
+ id: 'osm',
+ name: 'OpenStreetMap',
+ url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
+ attribution:
+ '© OpenStreetMap-Mitwirkende',
+ checked: true,
+ maxNativeZoom: 19,
+ },
+ {
+ id: 'osm-de',
+ name: 'OpenStreetMap DE',
+ url: 'https://{s}.tile.openstreetmap.de/{z}/{x}/{y}.png',
+ attribution:
+ '© OpenStreetMap-Mitwirkende',
+ maxNativeZoom: 19,
+ },
+]
+
+const defaultWmsLayers: MapWmsLayer[] = [
+ {
+ id: 'nrw-dop',
+ name: 'TIM / NRW Luftbild',
+ url: 'https://www.wms.nrw.de/geobasis/wms_nw_dop',
+ layers: 'nw_dop_rgb',
+ format: 'image/png',
+ transparent: false,
+ version: '1.3.0',
+ attribution: 'Geobasis NRW',
+ },
+ {
+ id: 'nrw-dop-overlay',
+ name: 'TIM / Straßen & Beschriftung',
+ url: 'https://www.wms.nrw.de/geobasis/wms_nw_dop_overlay',
+ layers: 'WMS_NW_DOP_OVERLAY',
+ format: 'image/png',
+ transparent: true,
+ version: '1.3.0',
+ attribution: 'Geobasis NRW',
+ overlay: true,
+ checked: true,
+ opacity: 1,
+ zIndex: 650,
+ },
+]
+
+const defaultPinIcon = L.divIcon({
+ className: '',
+ iconSize: [28, 28],
+ iconAnchor: [14, 28],
+ popupAnchor: [0, -28],
+ html: `
+
+
+
+ `,
+})
+
+const currentLocationIcon = L.divIcon({
+ className: '',
+ iconSize: [22, 22],
+ iconAnchor: [11, 11],
+ popupAnchor: [0, -11],
+ html: `
+
+
+
+ `,
+})
+
+function classNames(...classes: Array) {
+ return classes.filter(Boolean).join(' ')
+}
+
+function FitBounds({
+ points,
+ padding,
+ maxZoom,
+}: {
+ points: Array<{ lat: number; lon: number }>
+ padding: [number, number]
+ maxZoom: number
+}) {
+ const map = useMap()
+
+ useEffect(() => {
+ if (points.length === 0) {
+ return
+ }
+
+ const bounds = points.map(
+ (point) => [point.lat, point.lon] as [number, number],
+ )
+
+ map.fitBounds(bounds, {
+ padding,
+ maxZoom,
+ })
+ }, [map, points, padding, maxZoom])
+
+ return null
+}
+
+type LeafletWithHeat = typeof L & {
+ heatLayer: (
+ latlngs: Array<[number, number, number]>,
+ options?: MapHeatOptions,
+ ) => L.Layer
+}
+
+function HeatmapLayer({
+ points,
+ options,
+}: {
+ points: MapHeatPoint[]
+ options?: MapHeatOptions
+}) {
+ const map = useMap()
+
+ useEffect(() => {
+ if (points.length === 0) {
+ return
+ }
+
+ const heatLayer = (L as LeafletWithHeat).heatLayer(
+ points.map((point) => [
+ point.lat,
+ point.lon,
+ point.intensity ?? 1,
+ ]),
+ options,
+ )
+
+ heatLayer.addTo(map)
+
+ return () => {
+ heatLayer.remove()
+ }
+ }, [map, points, options])
+
+ return null
+}
+
+function LocateButton({
+ locateZoom,
+ showCurrentLocationMarker,
+ onLocationFound,
+}: {
+ locateZoom: number
+ showCurrentLocationMarker: boolean
+ onLocationFound: (position: [number, number] | null) => void
+}) {
+ const map = useMap()
+ const [isLocating, setIsLocating] = useState(false)
+ const [error, setError] = useState(null)
+
+ function handleLocate() {
+ setError(null)
+
+ if (!window.isSecureContext) {
+ setError('Standortbestimmung ist nur über HTTPS oder localhost verfügbar.')
+ return
+ }
+
+ if (!navigator.geolocation) {
+ setError('Standortbestimmung wird vom Browser nicht unterstützt.')
+ return
+ }
+
+ setIsLocating(true)
+
+ navigator.geolocation.getCurrentPosition(
+ (position) => {
+ const nextPosition: [number, number] = [
+ position.coords.latitude,
+ position.coords.longitude,
+ ]
+
+ if (showCurrentLocationMarker) {
+ onLocationFound(nextPosition)
+ }
+
+ map.setView(nextPosition, Math.max(map.getZoom(), locateZoom), {
+ animate: true,
+ })
+
+ setIsLocating(false)
+ },
+ (positionError) => {
+ if (positionError.code === positionError.PERMISSION_DENIED) {
+ setError('Standortzugriff wurde abgelehnt.')
+ } else if (positionError.code === positionError.POSITION_UNAVAILABLE) {
+ setError('Standort ist aktuell nicht verfügbar.')
+ } else if (positionError.code === positionError.TIMEOUT) {
+ setError('Standortbestimmung hat zu lange gedauert.')
+ } else {
+ setError(positionError.message || 'Standort konnte nicht bestimmt werden.')
+ }
+
+ setIsLocating(false)
+ },
+ {
+ enableHighAccuracy: true,
+ timeout: 10000,
+ maximumAge: 30000,
+ },
+ )
+ }
+
+ return (
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ )
+}
+
+function renderTileLayer(layer: MapTileLayer, maxZoom: number) {
+ return (
+
+ )
+}
+
+function renderWmsLayer(layer: MapWmsLayer, maxZoom: number) {
+ return (
+
+ )
+}
+
+function AppMarker({ marker }: { marker: MapMarker }) {
+ const markerRef = useRef(null)
+
+ function handleMarkerClick() {
+ markerRef.current?.openPopup()
+ }
+
+ return (
+
+ {marker.label ? (
+
+ {marker.label}
+
+ ) : null}
+
+ {marker.popup ? (
+
+ {marker.popup}
+
+ ) : null}
+
+ )
+}
+
+function InvalidateSizeOnResize() {
+ const map = useMap()
+
+ useEffect(() => {
+ const container = map.getContainer()
+ let frame: number | null = null
+
+ function invalidateSize() {
+ if (frame !== null) {
+ cancelAnimationFrame(frame)
+ }
+
+ frame = requestAnimationFrame(() => {
+ map.invalidateSize({
+ pan: false,
+ })
+ })
+ }
+
+ const resizeObserver = new ResizeObserver(invalidateSize)
+
+ resizeObserver.observe(container)
+
+ if (container.parentElement) {
+ resizeObserver.observe(container.parentElement)
+ }
+
+ invalidateSize()
+
+ return () => {
+ if (frame !== null) {
+ cancelAnimationFrame(frame)
+ }
+
+ resizeObserver.disconnect()
+ }
+ }, [map])
+
+ return null
+}
+
+export default function LeafletMap({
+ markers = [],
+ heatPoints = [],
+ showHeatmap = false,
+ heatmapOptions,
+
+ center = [51.1657, 10.4515],
+ zoom = 6,
+ minZoom = 4,
+ maxZoom = 20,
+ scrollWheelZoom = true,
+ fitBounds = true,
+ fitBoundsPadding = [40, 40],
+
+ showLocateControl = true,
+ locateZoom = 17,
+ showCurrentLocationMarker = true,
+
+ tileLayers = defaultTileLayers,
+ wmsLayers = defaultWmsLayers,
+ showLayerControl = true,
+
+ className,
+ mapClassName,
+ minHeightClassName = 'min-h-[34rem]',
+ children,
+}: MapProps) {
+
+ const [currentLocation, setCurrentLocation] = useState<[number, number] | null>(null)
+
+ const fallbackTileLayer =
+ tileLayers.find((layer) => layer.checked) ?? tileLayers[0]
+
+ const fallbackWmsLayer =
+ !fallbackTileLayer
+ ? wmsLayers.find((layer) => layer.checked) ?? wmsLayers[0]
+ : null
+
+ const fitPoints = showHeatmap && heatPoints.length > 0
+ ? heatPoints
+ : markers
+
+ return (
+
+
+
+ {showLayerControl ? (
+
+ {tileLayers.map((layer) => (
+
+ {renderTileLayer(layer, maxZoom)}
+
+ ))}
+
+ {wmsLayers
+ .filter((layer) => !layer.overlay)
+ .map((layer) => (
+
+ {renderWmsLayer(layer, maxZoom)}
+
+ ))}
+
+ {wmsLayers
+ .filter((layer) => layer.overlay)
+ .map((layer) => (
+
+ {renderWmsLayer(layer, maxZoom)}
+
+ ))}
+
+ ) : fallbackTileLayer ? (
+ renderTileLayer(fallbackTileLayer, maxZoom)
+ ) : fallbackWmsLayer ? (
+ renderWmsLayer(fallbackWmsLayer, maxZoom)
+ ) : null}
+
+ {showHeatmap && (
+
+ )}
+
+ {showLocateControl && (
+
+ )}
+
+ {fitBounds && (
+
+ )}
+
+ {markers.map((marker) => (
+
+ ))}
+
+ {currentLocation && showCurrentLocationMarker && (
+
+
+ Aktuelle Position
+
+
+ )}
+
+ {children}
+
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/components/Modal.tsx b/frontend/src/components/Modal.tsx
index c252b51..4913d00 100644
--- a/frontend/src/components/Modal.tsx
+++ b/frontend/src/components/Modal.tsx
@@ -81,8 +81,8 @@ export default function Modal({
className="fixed inset-0 bg-gray-500/75 transition-opacity data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in dark:bg-gray-900/50"
/>
-
-
+
+
void
+ onMarkAllRead: () => void
+}
+
+function formatNotificationDate(value: string) {
+ try {
+ return new Intl.DateTimeFormat('de-DE', {
+ dateStyle: 'short',
+ timeStyle: 'short',
+ }).format(new Date(value))
+ } catch {
+ return value
+ }
+}
+
+export default function NotificationCenter({
+ notifications,
+ unreadCount,
+ onMarkRead,
+ onMarkAllRead,
+}: NotificationCenterProps) {
+ const [open, setOpen] = useState(false)
+
+ return (
+
+
+
+ {open && (
+
+
+
+ Benachrichtigungen
+
+
+ {unreadCount > 0 && (
+
+ )}
+
+
+
+ {notifications.length === 0 ? (
+
+ Keine Benachrichtigungen vorhanden.
+
+ ) : (
+ notifications.map((notification) => {
+ const unread = !notification.readAt
+
+ return (
+
+ )
+ })
+ )}
+
+
+ )}
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/components/NotificationDot.tsx b/frontend/src/components/NotificationDot.tsx
new file mode 100644
index 0000000..2d67e08
--- /dev/null
+++ b/frontend/src/components/NotificationDot.tsx
@@ -0,0 +1,45 @@
+// frontend/src/components/NotificationDot.tsx
+
+type NotificationDotProps = {
+ title?: string
+ label?: string
+ className?: string
+ pingClassName?: string
+ dotClassName?: string
+}
+
+function classNames(...classes: Array) {
+ return classes.filter(Boolean).join(' ')
+}
+
+export default function NotificationDot({
+ title = 'Neue Benachrichtigung',
+ label = 'Neue Benachrichtigung',
+ className,
+ pingClassName,
+ dotClassName,
+}: NotificationDotProps) {
+ return (
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/components/Notifications.tsx b/frontend/src/components/Notifications.tsx
new file mode 100644
index 0000000..f13d209
--- /dev/null
+++ b/frontend/src/components/Notifications.tsx
@@ -0,0 +1,183 @@
+// frontend/src/components/Notifications.tsx
+
+import { useEffect } from 'react'
+import { Transition } from '@headlessui/react'
+import {
+ CheckCircleIcon,
+ ExclamationCircleIcon,
+ ExclamationTriangleIcon,
+ InformationCircleIcon,
+ XCircleIcon,
+} from '@heroicons/react/24/outline'
+import { XMarkIcon } from '@heroicons/react/20/solid'
+
+export type NotificationVariant =
+ | 'success'
+ | 'error'
+ | 'warning'
+ | 'info'
+
+export type ToastNotification = {
+ id: string
+ title: string
+ message?: string
+ variant?: NotificationVariant
+ actionLabel?: string
+ onAction?: () => void
+ autoClose?: boolean
+ duration?: number
+}
+
+type NotificationsProps = {
+ notifications: ToastNotification[]
+ onDismiss: (id: string) => void
+ position?: 'top-right' | 'bottom-right'
+}
+
+function classNames(...classes: Array) {
+ return classes.filter(Boolean).join(' ')
+}
+
+function getVariantStyles(variant: NotificationVariant) {
+ switch (variant) {
+ case 'success':
+ return {
+ icon: CheckCircleIcon,
+ iconClassName: 'text-green-400',
+ }
+
+ case 'error':
+ return {
+ icon: XCircleIcon,
+ iconClassName: 'text-red-400',
+ }
+
+ case 'warning':
+ return {
+ icon: ExclamationTriangleIcon,
+ iconClassName: 'text-amber-400',
+ }
+
+ case 'info':
+ default:
+ return {
+ icon: InformationCircleIcon,
+ iconClassName: 'text-blue-400',
+ }
+ }
+}
+
+function NotificationToast({
+ notification,
+ onDismiss,
+}: {
+ notification: ToastNotification
+ onDismiss: (id: string) => void
+}) {
+ const {
+ id,
+ title,
+ message,
+ variant = 'info',
+ actionLabel,
+ onAction,
+ autoClose = true,
+ duration = 5000,
+ } = notification
+
+ const { icon: Icon, iconClassName } = getVariantStyles(variant)
+
+ useEffect(() => {
+ if (!autoClose) {
+ return
+ }
+
+ const timeoutId = window.setTimeout(() => {
+ onDismiss(id)
+ }, duration)
+
+ return () => {
+ window.clearTimeout(timeoutId)
+ }
+ }, [autoClose, duration, id, onDismiss])
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {title}
+
+
+ {message && (
+
+ {message}
+
+ )}
+
+ {actionLabel && onAction && (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default function Notifications({
+ notifications,
+ onDismiss,
+ position = 'top-right',
+}: NotificationsProps) {
+ return (
+
+
+ {notifications.map((notification) => (
+
+ ))}
+
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/components/Search.tsx b/frontend/src/components/Search.tsx
index f7aca1d..cfc2a90 100644
--- a/frontend/src/components/Search.tsx
+++ b/frontend/src/components/Search.tsx
@@ -1,20 +1,55 @@
-// frontend\src\components\Search.tsx
+// frontend/src/components/Search.tsx
-import { MagnifyingGlassIcon } from '@heroicons/react/20/solid'
+import { MagnifyingGlassIcon, XMarkIcon } from '@heroicons/react/20/solid'
-export default function Search() {
+type SearchProps = {
+ value: string
+ onChange: (value: string) => void
+ placeholder?: string
+}
+
+export default function Search({
+ value,
+ onChange,
+ placeholder = 'Suche...',
+}: SearchProps) {
return (
-
)
}
\ No newline at end of file
diff --git a/frontend/src/components/SearchOverlay.tsx b/frontend/src/components/SearchOverlay.tsx
new file mode 100644
index 0000000..97eab18
--- /dev/null
+++ b/frontend/src/components/SearchOverlay.tsx
@@ -0,0 +1,339 @@
+// frontend/src/components/SearchOverlay.tsx
+
+import { useEffect, useMemo, useState } from 'react'
+import { Link } from 'react-router'
+import {
+ CircleStackIcon,
+ ClipboardDocumentListIcon,
+ ExclamationTriangleIcon,
+ MagnifyingGlassIcon,
+ UserGroupIcon,
+} from '@heroicons/react/24/outline'
+import type { User } from './types'
+
+const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
+
+type SearchResult = {
+ id: string
+ entityId: string
+ entityType: string
+ title: string
+ subtitle: string
+ href: string
+ data?: Record
+}
+
+type SearchGroupResponse = {
+ id: string
+ title: string
+ items: SearchResult[]
+}
+
+type GlobalSearchResponse = {
+ query: string
+ groups: SearchGroupResponse[]
+}
+
+type SearchGroup = SearchGroupResponse & {
+ icon: typeof ClipboardDocumentListIcon
+}
+
+type SearchOverlayProps = {
+ user: User
+ query: string
+ onClose: () => void
+}
+
+function getGroupIcon(groupId: string) {
+ switch (groupId) {
+ case 'operations':
+ return ClipboardDocumentListIcon
+ case 'devices':
+ return CircleStackIcon
+ case 'users':
+ return UserGroupIcon
+ default:
+ return ClipboardDocumentListIcon
+ }
+}
+
+function getEntityLabel(entityType: string) {
+ switch (entityType) {
+ case 'operation':
+ return 'Einsatz'
+ case 'device':
+ return 'Gerät'
+ case 'user':
+ return 'Benutzer'
+ default:
+ return entityType
+ }
+}
+
+async function readApiError(response: Response, fallback: string) {
+ const data = await response.json().catch(() => null)
+
+ return data?.error ?? fallback
+}
+
+export default function SearchOverlay({
+ user,
+ query,
+ onClose,
+}: SearchOverlayProps) {
+ void user
+
+ const search = query.trim()
+
+ const [groups, setGroups] = useState([])
+ const [isLoading, setIsLoading] = useState(false)
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ function handleKeyDown(event: KeyboardEvent) {
+ if (event.key === 'Escape') {
+ onClose()
+ }
+ }
+
+ window.addEventListener('keydown', handleKeyDown)
+
+ return () => {
+ window.removeEventListener('keydown', handleKeyDown)
+ }
+ }, [onClose])
+
+ useEffect(() => {
+ if (search.length < 2) {
+ setGroups([])
+ setError(null)
+ setIsLoading(false)
+ return
+ }
+
+ const abortController = new AbortController()
+
+ async function loadResults() {
+ setIsLoading(true)
+ setError(null)
+
+ try {
+ const response = await fetch(
+ `${API_URL}/search?q=${encodeURIComponent(search)}&limit=8`,
+ {
+ credentials: 'include',
+ signal: abortController.signal,
+ },
+ )
+
+ if (!response.ok) {
+ throw new Error(
+ await readApiError(
+ response,
+ 'Suche konnte nicht ausgeführt werden',
+ ),
+ )
+ }
+
+ const data = (await response.json()) as GlobalSearchResponse
+
+ const nextGroups: SearchGroup[] = (data.groups ?? []).map((group) => ({
+ ...group,
+ icon: getGroupIcon(group.id),
+ items: group.items ?? [],
+ }))
+
+ setGroups(nextGroups)
+ } catch (error) {
+ if (abortController.signal.aborted) {
+ return
+ }
+
+ setGroups([])
+ setError(
+ error instanceof Error
+ ? error.message
+ : 'Suche konnte nicht ausgeführt werden',
+ )
+ } finally {
+ if (!abortController.signal.aborted) {
+ setIsLoading(false)
+ }
+ }
+ }
+
+ const timeoutId = window.setTimeout(() => {
+ void loadResults()
+ }, 200)
+
+ return () => {
+ window.clearTimeout(timeoutId)
+ abortController.abort()
+ }
+ }, [search])
+
+ const resultCount = useMemo(
+ () => groups.reduce((count, group) => count + group.items.length, 0),
+ [groups],
+ )
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ Suche
+
+
+
+ {search.length >= 2
+ ? `${resultCount} Treffer für „${query}“`
+ : 'Gib mindestens 2 Zeichen ein.'}
+
+
+
+
+
+ {search.length < 2 && (
+
+
+
+
+ Suche starten
+
+
+
+ Gib mindestens 2 Zeichen ein.
+
+
+ )}
+
+ {search.length >= 2 && isLoading && (
+
+
+
+
+ Suche läuft...
+
+
+
+ Ergebnisse werden geladen.
+
+
+ )}
+
+ {search.length >= 2 && !isLoading && error && (
+
+
+
+
+ Suche fehlgeschlagen
+
+
+
+ {error}
+
+
+ )}
+
+ {search.length >= 2 && !isLoading && !error && resultCount === 0 && (
+
+
+
+
+ Keine Ergebnisse gefunden
+
+
+
+ Prüfe den Suchbegriff oder versuche es mit weniger Begriffen.
+
+
+ )}
+
+ {!isLoading && !error && groups.length > 0 && (
+
+ {groups.map((group) => {
+ const Icon = group.icon
+
+ return (
+
+
+
+
+
+
+
+
+
+ {group.title}
+
+
+
+ {group.items.length} Treffer
+
+
+
+
+
+
+ {group.items.map((item) => (
+ -
+
+
+
+
+ {item.title}
+
+
+ {item.subtitle && (
+
+ {item.subtitle}
+
+ )}
+
+
+
+ {getEntityLabel(item.entityType)}
+
+
+
+
+ ))}
+
+
+ )
+ })}
+
+ )}
+
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx
index 42ab680..236c369 100644
--- a/frontend/src/components/Sidebar.tsx
+++ b/frontend/src/components/Sidebar.tsx
@@ -12,41 +12,53 @@ import {
Cog6ToothIcon,
ComputerDesktopIcon,
DocumentDuplicateIcon,
- FireIcon,
+ ShieldCheckIcon,
+ VideoCameraIcon,
HomeIcon,
XMarkIcon,
} from '@heroicons/react/24/outline'
import { NavLink } from 'react-router'
+import { hasRight } from './permissions'
+import type { User, Team } from './types'
const navigation = [
- { name: 'Dashboard', href: '/', icon: HomeIcon },
- { name: 'Geräte', href: '/geraete', icon: ComputerDesktopIcon },
- { name: 'Einsätze', href: '/einsaetze', icon: FireIcon },
- { name: 'Kalender', href: '/kalender', icon: CalendarIcon },
- { name: 'Dokumente', href: '/dokumente', icon: DocumentDuplicateIcon },
- { name: 'Berichte', href: '/berichte', icon: ChartPieIcon },
+ {
+ name: 'Dashboard',
+ href: '/',
+ icon: HomeIcon,
+ },
+ {
+ name: 'Geräte',
+ href: '/geraete',
+ icon: ComputerDesktopIcon,
+ right: 'devices:read',
+ },
+ {
+ name: 'Einsätze',
+ href: '/einsaetze',
+ icon: VideoCameraIcon,
+ right: 'operations:read',
+ },
+ {
+ name: 'Kalender',
+ href: '/kalender',
+ icon: CalendarIcon,
+ right: 'calendar:read',
+ },
+ {
+ name: 'Dokumente',
+ href: '/dokumente',
+ icon: DocumentDuplicateIcon,
+ right: 'documents:read',
+ },
+ {
+ name: 'Berichte',
+ href: '/berichte',
+ icon: ChartPieIcon,
+ right: 'reports:read',
+ },
]
-type Team = {
- id: string
- name: string
- initial: string
- href: string
- current: boolean
-}
-
-type User = {
- id: string
- username: string
- displayName: string
- email: string
- avatar: string
- unit: string
- group: string
- rights: string[]
- teams: Team[]
-}
-
type SidebarProps = {
user: User
sidebarOpen: boolean
@@ -74,6 +86,10 @@ function SidebarContent({
}) {
const teams = user.teams ?? []
+ const visibleNavigation = navigation.filter((item) =>
+ !item.right || hasRight(user, item.right),
+ )
+
return (
<>
@@ -88,7 +104,7 @@ function SidebarContent({
-
- {navigation.map((item) => (
+ {visibleNavigation.map((item) => (
-
- Zugeordnete Teams
+ Teams
@@ -143,22 +159,42 @@ function SidebarContent({
- -
- setSidebarOpen(false)}
- className={({ isActive }) =>
- classNames(
- isActive
- ? 'bg-white/5 text-white'
- : 'text-gray-400 hover:bg-white/5 hover:text-white',
- 'group -mx-2 flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
- )
- }
- >
-
- Einstellungen
-
+
-
+ {hasRight(user, 'administration:read') && (
+ setSidebarOpen(false)}
+ className={({ isActive }) =>
+ classNames(
+ isActive
+ ? 'bg-white/5 text-white'
+ : 'text-gray-400 hover:bg-white/5 hover:text-white',
+ 'group -mx-2 flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
+ )
+ }
+ >
+
+ Administration
+
+ )}
+
+ {hasRight(user, 'settings:read') && (
+ setSidebarOpen(false)}
+ className={({ isActive }) =>
+ classNames(
+ isActive
+ ? 'bg-white/5 text-white'
+ : 'text-gray-400 hover:bg-white/5 hover:text-white',
+ 'group -mx-2 flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
+ )
+ }
+ >
+
+ Einstellungen
+
+ )}
diff --git a/frontend/src/components/Switch.tsx b/frontend/src/components/Switch.tsx
new file mode 100644
index 0000000..f7d9985
--- /dev/null
+++ b/frontend/src/components/Switch.tsx
@@ -0,0 +1,238 @@
+import {
+ forwardRef,
+ useId,
+ type InputHTMLAttributes,
+ type ReactNode,
+} from 'react'
+
+type SwitchVariant = 'simple' | 'short' | 'icon'
+type LabelPosition = 'none' | 'left' | 'right'
+
+type SwitchProps = Omit, 'type'> & {
+ variant?: SwitchVariant
+ label?: ReactNode
+ description?: ReactNode
+ labelPosition?: LabelPosition
+ className?: string
+ switchClassName?: string
+}
+
+function classNames(...classes: Array) {
+ return classes.filter(Boolean).join(' ')
+}
+
+function SwitchControl({
+ variant,
+ inputId,
+ switchClassName,
+ inputProps,
+ inputRef,
+}: {
+ variant: SwitchVariant
+ inputId: string
+ switchClassName?: string
+ inputProps: Omit, 'type' | 'id'>
+ inputRef: React.ForwardedRef
+}) {
+ if (variant === 'short') {
+ return (
+
+
+
+
+
+
+ )
+ }
+
+ if (variant === 'icon') {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+ )
+}
+
+const Switch = forwardRef(function Switch(
+ {
+ variant = 'simple',
+ label,
+ description,
+ labelPosition = label ? 'left' : 'none',
+ className,
+ switchClassName,
+ id,
+ 'aria-label': ariaLabel,
+ 'aria-labelledby': ariaLabelledBy,
+ 'aria-describedby': ariaDescribedBy,
+ ...inputProps
+ },
+ ref,
+) {
+ const generatedId = useId()
+ const inputId = id ?? `switch-${generatedId}`
+ const labelId = `${inputId}-label`
+ const descriptionId = `${inputId}-description`
+
+ const resolvedAriaLabelledBy =
+ ariaLabelledBy ?? (label ? labelId : undefined)
+
+ const resolvedAriaDescribedBy =
+ ariaDescribedBy ?? (description ? descriptionId : undefined)
+
+ const resolvedAriaLabel =
+ ariaLabel ?? (!label ? 'Schalter' : undefined)
+
+ const control = (
+
+ )
+
+ if (labelPosition === 'none' || !label) {
+ return control
+ }
+
+ if (labelPosition === 'right') {
+ return (
+
+ {control}
+
+
+
+
+ {description && (
+ <>
+ {' '}
+
+ {description}
+
+ >
+ )}
+
+
+ )
+ }
+
+ return (
+
+
+
+
+ {description && (
+
+ {description}
+
+ )}
+
+
+ {control}
+
+ )
+})
+
+export default Switch
\ No newline at end of file
diff --git a/frontend/src/components/Tables.tsx b/frontend/src/components/Tables.tsx
index ba6ccfd..fa5e0dc 100644
--- a/frontend/src/components/Tables.tsx
+++ b/frontend/src/components/Tables.tsx
@@ -74,6 +74,8 @@ type TablesProps = {
headerAction?: ReactNode
rowActions?: (row: T) => ReactNode
+ isLoading?: boolean
+ loadingContent?: ReactNode
selectable?: boolean
selectedRowIds?: Array
@@ -187,6 +189,8 @@ export default function Tables({
headerAction,
rowActions,
+ isLoading = false,
+ loadingContent,
selectable = false,
selectedRowIds,
@@ -302,22 +306,17 @@ export default function Tables({
return (
{title && (
-
+
{title}
)}
{description && (
-
+
{description}
)}
@@ -532,15 +531,38 @@ export default function Tables
({
))
}
- function renderEmptyRow() {
- if (allRows.length > 0) {
+ function renderLoadingRow() {
+ if (!isLoading) {
return null
}
return (
|
+
+ {loadingContent ?? (
+
+ Lädt...
+
+ )}
+
+ |
+
+ )
+ }
+
+ function renderEmptyRow() {
+ if (isLoading || allRows.length > 0) {
+ return null
+ }
+
+ return (
+
+
{emptyText}
@@ -613,8 +635,14 @@ export default function Tables({
: 'bg-white dark:bg-gray-900',
)}
>
- {renderEmptyRow()}
- {renderGroupedRows()}
+ {isLoading ? (
+ renderLoadingRow()
+ ) : (
+ <>
+ {renderEmptyRow()}
+ {renderGroupedRows()}
+ >
+ )}
{renderSummaryRows()}
diff --git a/frontend/src/components/Tabs.tsx b/frontend/src/components/Tabs.tsx
index 6449206..8d9b081 100644
--- a/frontend/src/components/Tabs.tsx
+++ b/frontend/src/components/Tabs.tsx
@@ -64,7 +64,7 @@ export default function Tabs({
variant = 'underline',
align = 'left',
sticky = false,
- stickyTopClassName = 'top-16',
+ stickyTopClassName = 'top-0',
ariaLabel = 'Tabs',
className,
}: TabsProps) {
diff --git a/frontend/src/components/Topbar.tsx b/frontend/src/components/Topbar.tsx
index e986f3a..cd2da0c 100644
--- a/frontend/src/components/Topbar.tsx
+++ b/frontend/src/components/Topbar.tsx
@@ -1,5 +1,6 @@
// frontend/src/components/Topbar.tsx
+import type { ReactNode } from 'react'
import {
Menu,
MenuButton,
@@ -20,13 +21,23 @@ type TopbarProps = {
user: User
onLogout: () => void | Promise
onOpenSidebar: () => void
+ notificationCenter?: ReactNode
+ searchQuery: string
+ onSearchQueryChange: (query: string) => void
}
const userNavigation = [
{ name: 'Mein Account', href: '/einstellungen' },
]
-export default function Topbar({ user, onLogout, onOpenSidebar }: TopbarProps) {
+export default function Topbar({
+ user,
+ onLogout,
+ onOpenSidebar,
+ notificationCenter,
+ searchQuery,
+ onSearchQueryChange,
+}: TopbarProps) {
function handleQrScan(value: string) {
console.log('QR-Code erkannt:', value)
}
@@ -49,25 +60,30 @@ export default function Topbar({ user, onLogout, onOpenSidebar }: TopbarProps) {
-
+
-
+
-
+ {notificationCenter ?? (
+
+ )}
+ )
+ }
+
+ if (widget.type === 'operationMap') {
+ return (
+
+ )
+ }
+
+ return (
+
+ Unbekannter Widget-Typ.
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/components/dashboard/WidgetPickerModal.tsx b/frontend/src/components/dashboard/WidgetPickerModal.tsx
new file mode 100644
index 0000000..09c9d18
--- /dev/null
+++ b/frontend/src/components/dashboard/WidgetPickerModal.tsx
@@ -0,0 +1,173 @@
+// frontend/src/components/dashboard/WidgetPickerModal.tsx
+
+import { DialogTitle } from '@headlessui/react'
+import {
+ ClockIcon,
+ MapIcon,
+ XMarkIcon,
+} from '@heroicons/react/20/solid'
+import Modal from '../Modal'
+import Button from '../Button'
+import type {
+ DashboardWidgetDefinition,
+ DashboardWidgetType,
+} from './types'
+
+type WidgetPickerModalProps = {
+ open: boolean
+ setOpen: (open: boolean) => void
+ widgetTypes: DashboardWidgetDefinition[]
+ isSaving: boolean
+ onCreateWidget: (widgetType: DashboardWidgetType) => void | Promise
+}
+
+function classNames(...classes: Array) {
+ return classes.filter(Boolean).join(' ')
+}
+
+function getWidgetIcon(type: DashboardWidgetType) {
+ if (type === 'operationMap') {
+ return MapIcon
+ }
+
+ return ClockIcon
+}
+
+function renderPreview(type: DashboardWidgetType) {
+ if (type === 'operationMap') {
+ return (
+
+
+
+
+
+
+
+
+ Einsatzkarte
+
+
+ )
+ }
+
+ return (
+
+ {[
+ ['000-24-0012', 'Adresse wurde geändert'],
+ ['000-24-0011', 'Einsatz wurde erstellt'],
+ ['000-24-0010', 'Technik wurde geändert'],
+ ].map(([number, text]) => (
+
+
+ {number}
+
+
+ {text}
+
+
+ ))}
+
+ )
+}
+
+export default function WidgetPickerModal({
+ open,
+ setOpen,
+ widgetTypes,
+ isSaving,
+ onCreateWidget,
+}: WidgetPickerModalProps) {
+ return (
+
+
+
+
+
+ Widget hinzufügen
+
+
+
+ Wähle ein Widget aus. Du kannst es danach im Dashboard verschieben und skalieren.
+
+
+
+
+
+
+
+
+ {widgetTypes.map((widgetType) => {
+ const Icon = getWidgetIcon(widgetType.id)
+
+ return (
+
+
+
+
+
+
+
+
+
+ {widgetType.label}
+
+
+
+ {widgetType.description}
+
+
+
+
+
+
+
+ {renderPreview(widgetType.id)}
+
+
+
+
+
+ )
+ })}
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/components/dashboard/WidgetSettingsModal.tsx b/frontend/src/components/dashboard/WidgetSettingsModal.tsx
new file mode 100644
index 0000000..2385a76
--- /dev/null
+++ b/frontend/src/components/dashboard/WidgetSettingsModal.tsx
@@ -0,0 +1,325 @@
+// frontend/src/components/dashboard/WidgetSettingsModal.tsx
+
+import { useEffect, useState, type FormEvent } from 'react'
+import { DialogTitle } from '@headlessui/react'
+import { XMarkIcon } from '@heroicons/react/20/solid'
+import Modal from '../Modal'
+import Button from '../Button'
+import type { DashboardWidget } from './types'
+import Checkbox from '../Checkbox'
+
+type WidgetSettingsModalProps = {
+ open: boolean
+ setOpen: (open: boolean) => void
+ widget: DashboardWidget | null
+ isSaving: boolean
+ onSave: (
+ widget: DashboardWidget,
+ nextValues: {
+ title: string
+ config: Record
+ },
+ ) => void | Promise
+}
+
+const inputClassName =
+ 'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
+
+function getConfigString(
+ config: Record,
+ key: string,
+ fallback: string,
+) {
+ const value = config[key]
+
+ return typeof value === 'string' ? value : fallback
+}
+
+function getConfigNumber(
+ config: Record,
+ key: string,
+ fallback: number,
+) {
+ const value = config[key]
+
+ return typeof value === 'number' && Number.isFinite(value)
+ ? value
+ : fallback
+}
+
+function getConfigBoolean(
+ config: Record,
+ key: string,
+ fallback: boolean,
+) {
+ const value = config[key]
+
+ return typeof value === 'boolean' ? value : fallback
+}
+
+export default function WidgetSettingsModal({
+ open,
+ setOpen,
+ widget,
+ isSaving,
+ onSave,
+}: WidgetSettingsModalProps) {
+ const [title, setTitle] = useState('')
+ const [limit, setLimit] = useState(8)
+
+ const [defaultLayer, setDefaultLayer] = useState('nrw-dop')
+ const [showLabelsOverlay, setShowLabelsOverlay] = useState(true)
+ const [showLocateControl, setShowLocateControl] = useState(false)
+ const [showCurrentLocationMarker, setShowCurrentLocationMarker] = useState(true)
+ const [initialAddress, setInitialAddress] = useState('')
+ const [initialZoom, setInitialZoom] = useState(14)
+
+ useEffect(() => {
+ if (!open || !widget) {
+ return
+ }
+
+ setTitle(widget.title)
+
+ setLimit(getConfigNumber(widget.config, 'limit', 8))
+
+ setDefaultLayer(getConfigString(widget.config, 'defaultLayer', 'nrw-dop'))
+ setShowLabelsOverlay(
+ getConfigBoolean(widget.config, 'showLabelsOverlay', true),
+ )
+ setShowLocateControl(
+ getConfigBoolean(widget.config, 'showLocateControl', false),
+ )
+ setShowCurrentLocationMarker(
+ getConfigBoolean(widget.config, 'showCurrentLocationMarker', true),
+ )
+ setInitialAddress(getConfigString(widget.config, 'initialAddress', ''))
+ setInitialZoom(getConfigNumber(widget.config, 'initialZoom', 14))
+ }, [open, widget])
+
+ function handleSubmit(event: FormEvent) {
+ event.preventDefault()
+
+ if (!widget) {
+ return
+ }
+
+ const nextConfig =
+ widget.type === 'operationChanges'
+ ? {
+ ...widget.config,
+ limit: Math.min(Math.max(limit, 1), 50),
+ }
+ : {
+ ...widget.config,
+ defaultLayer,
+ showLabelsOverlay,
+ showLocateControl,
+ showCurrentLocationMarker,
+ initialAddress: initialAddress.trim(),
+ initialZoom: Math.min(Math.max(initialZoom, 4), 20),
+ }
+
+ void onSave(widget, {
+ title: title.trim() || widget.title,
+ config: nextConfig,
+ })
+ }
+
+ return (
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/components/dashboard/types.ts b/frontend/src/components/dashboard/types.ts
new file mode 100644
index 0000000..9a9dd85
--- /dev/null
+++ b/frontend/src/components/dashboard/types.ts
@@ -0,0 +1,31 @@
+// frontend\src\components\dashboard\types.ts
+
+export type DashboardWidgetType =
+ | 'operationChanges'
+ | 'operationMap'
+
+export type DashboardWidget = {
+ id: string
+ userId: string
+ type: DashboardWidgetType
+ title: string
+ x: number
+ y: number
+ w: number
+ h: number
+ config: Record
+ createdAt: string
+ updatedAt: string
+}
+
+export type DashboardWidgetDefinition = {
+ id: DashboardWidgetType
+ label: string
+ description: string
+ defaultTitle: string
+ defaultSize: {
+ w: number
+ h: number
+ }
+ defaultConfig: Record
+}
\ No newline at end of file
diff --git a/frontend/src/components/dashboard/widgets/OperationChangesWidget.tsx b/frontend/src/components/dashboard/widgets/OperationChangesWidget.tsx
new file mode 100644
index 0000000..122fc75
--- /dev/null
+++ b/frontend/src/components/dashboard/widgets/OperationChangesWidget.tsx
@@ -0,0 +1,215 @@
+// frontend/src/components/dashboard/widgets/OperationChangesWidget.tsx
+
+import { useEffect, useState } from 'react'
+import type { DashboardWidget } from '../types'
+
+type OperationHistoryChange = {
+ field: string
+ label: string
+ oldValue: string
+ newValue: string
+}
+
+type OperationChange = {
+ id: string
+ operationId: string
+ operationNumber: string
+ operationName: string
+ userDisplayName: string
+ action: string
+ changes: OperationHistoryChange[]
+ createdAt: string
+}
+
+type OperationChangesWidgetProps = {
+ widget: DashboardWidget
+ apiUrl: string
+}
+
+function getConfigNumber(
+ config: Record,
+ key: string,
+ fallback: number,
+) {
+ const value = config[key]
+
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback
+}
+
+function formatDate(value: string) {
+ try {
+ return new Intl.DateTimeFormat('de-DE', {
+ dateStyle: 'short',
+ timeStyle: 'short',
+ }).format(new Date(value))
+ } catch {
+ return value
+ }
+}
+
+function getActionLabel(item: OperationChange) {
+ if (item.action === 'created') {
+ return 'hat den Einsatz erstellt.'
+ }
+
+ if (item.changes.length === 1) {
+ return 'hat 1 Feld geändert.'
+ }
+
+ return `hat ${item.changes.length} Felder geändert.`
+}
+
+function getOperationTitle(item: OperationChange) {
+ return item.operationName || item.operationNumber || 'Ohne Einsatzname'
+}
+
+export default function OperationChangesWidget({
+ widget,
+ apiUrl,
+}: OperationChangesWidgetProps) {
+ const [changes, setChanges] = useState([])
+ const [isLoading, setIsLoading] = useState(true)
+ const [error, setError] = useState(null)
+
+ const limit = getConfigNumber(widget.config, 'limit', 8)
+
+ useEffect(() => {
+ const abortController = new AbortController()
+
+ async function loadChanges() {
+ setIsLoading(true)
+ setError(null)
+
+ try {
+ const response = await fetch(
+ `${apiUrl}/dashboard/operation-changes?limit=${limit}`,
+ {
+ credentials: 'include',
+ signal: abortController.signal,
+ },
+ )
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => null)
+ throw new Error(
+ errorData?.error ?? 'Einsatzänderungen konnten nicht geladen werden',
+ )
+ }
+
+ const data = await response.json()
+ setChanges(data.changes ?? [])
+ } catch (error) {
+ if (error instanceof DOMException && error.name === 'AbortError') {
+ return
+ }
+
+ setError(
+ error instanceof Error
+ ? error.message
+ : 'Einsatzänderungen konnten nicht geladen werden',
+ )
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ void loadChanges()
+
+ return () => {
+ abortController.abort()
+ }
+ }, [apiUrl, limit])
+
+ if (isLoading) {
+ return (
+
+ Änderungen werden geladen...
+
+ )
+ }
+
+ if (error) {
+ return (
+
+ {error}
+
+ )
+ }
+
+ if (changes.length === 0) {
+ return (
+
+ Keine Einsatzänderungen vorhanden.
+
+ )
+ }
+
+ return (
+
+ {changes.map((item) => (
+
+
+
+
+ {getOperationTitle(item)}
+
+
+
+ {item.operationNumber}
+
+
+
+
+
+
+
+
+ {item.userDisplayName}
+ {' '}
+ {getActionLabel(item)}
+
+
+ {item.changes.length > 0 && (
+
+ {item.changes.slice(0, 2).map((change) => (
+
+
+ {change.label}
+
+
+
+
+ Alt: {change.oldValue || '—'}
+
+
+
+ Neu: {change.newValue || '—'}
+
+
+
+ ))}
+
+ {item.changes.length > 2 && (
+
+ +{item.changes.length - 2} weitere Änderung
+ {item.changes.length - 2 === 1 ? '' : 'en'}
+
+ )}
+
+ )}
+
+ ))}
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/components/dashboard/widgets/OperationMapWidget.tsx b/frontend/src/components/dashboard/widgets/OperationMapWidget.tsx
new file mode 100644
index 0000000..10772ca
--- /dev/null
+++ b/frontend/src/components/dashboard/widgets/OperationMapWidget.tsx
@@ -0,0 +1,388 @@
+// frontend/src/components/dashboard/widgets/OperationMapWidget.tsx
+
+import { useEffect, useMemo, useState } from 'react'
+import AppMap, {
+ type MapMarker,
+ type MapTileLayer,
+ type MapWmsLayer,
+} from '../../Map'
+import type { DashboardWidget } from '../types'
+
+type OperationMapWidgetProps = {
+ widget: DashboardWidget
+ apiUrl: string
+}
+
+type Operation = {
+ id: string
+ operationNumber: string
+ operationName: string
+ offense: string
+ kwAddress: string
+ targetObject: string
+}
+
+type AddressSuggestion = {
+ id: string
+ label: string
+ lat: number
+ lon: number
+}
+
+type OperationPoint = {
+ id: string
+ operation: Operation
+ label: string
+ lat: number
+ lon: number
+ addressType: 'KW' | 'Zielobjekt'
+}
+
+function getConfigString(
+ config: Record,
+ key: string,
+ fallback: string,
+) {
+ const value = config[key]
+
+ return typeof value === 'string' ? value : fallback
+}
+
+function getConfigNumber(
+ config: Record,
+ key: string,
+ fallback: number,
+) {
+ const value = config[key]
+
+ return typeof value === 'number' && Number.isFinite(value)
+ ? value
+ : fallback
+}
+
+function getConfigBoolean(
+ config: Record,
+ key: string,
+ fallback: boolean,
+) {
+ const value = config[key]
+
+ return typeof value === 'boolean' ? value : fallback
+}
+
+function getTileLayers(defaultLayer: string): MapTileLayer[] {
+ return [
+ {
+ id: 'osm',
+ name: 'OpenStreetMap',
+ url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
+ attribution:
+ '© OpenStreetMap-Mitwirkende',
+ checked: defaultLayer === 'osm',
+ maxNativeZoom: 19,
+ },
+ {
+ id: 'osm-de',
+ name: 'OpenStreetMap DE',
+ url: 'https://{s}.tile.openstreetmap.de/{z}/{x}/{y}.png',
+ attribution:
+ '© OpenStreetMap-Mitwirkende',
+ checked: defaultLayer === 'osm-de',
+ maxNativeZoom: 19,
+ },
+ ]
+}
+
+function getWmsLayers(
+ defaultLayer: string,
+ showLabelsOverlay: boolean,
+): MapWmsLayer[] {
+ return [
+ {
+ id: 'nrw-dop',
+ name: 'TIM / NRW Luftbild',
+ url: 'https://www.wms.nrw.de/geobasis/wms_nw_dop',
+ layers: 'nw_dop_rgb',
+ format: 'image/png',
+ transparent: false,
+ version: '1.3.0',
+ attribution: 'Geobasis NRW',
+ checked: defaultLayer === 'nrw-dop',
+ },
+ {
+ id: 'nrw-dop-overlay',
+ name: 'TIM / Straßen & Beschriftung',
+ url: 'https://www.wms.nrw.de/geobasis/wms_nw_dop_overlay',
+ layers: 'WMS_NW_DOP_OVERLAY',
+ format: 'image/png',
+ transparent: true,
+ version: '1.3.0',
+ attribution: 'Geobasis NRW',
+ overlay: true,
+ checked: showLabelsOverlay,
+ opacity: 1,
+ zIndex: 650,
+ },
+ ]
+}
+
+async function geocodeAddress(
+ apiUrl: string,
+ address: string,
+ signal: AbortSignal,
+): Promise {
+ const response = await fetch(
+ `${apiUrl}/address-search?q=${encodeURIComponent(address)}`,
+ {
+ credentials: 'include',
+ signal,
+ },
+ )
+
+ if (!response.ok) {
+ return null
+ }
+
+ const data = await response.json()
+ const suggestions = (data.suggestions ?? []) as AddressSuggestion[]
+
+ return suggestions[0] ?? null
+}
+
+function getOperationAddresses(operation: Operation) {
+ const addresses: Array<{
+ type: 'KW' | 'Zielobjekt'
+ address: string
+ }> = []
+
+ const kwAddress = operation.kwAddress?.trim()
+ const targetObject = operation.targetObject?.trim()
+
+ if (kwAddress) {
+ addresses.push({
+ type: 'KW',
+ address: kwAddress,
+ })
+ }
+
+ if (targetObject) {
+ addresses.push({
+ type: 'Zielobjekt',
+ address: targetObject,
+ })
+ }
+
+ return addresses
+}
+
+export default function OperationMapWidget({
+ widget,
+ apiUrl,
+}: OperationMapWidgetProps) {
+ const [operations, setOperations] = useState([])
+ const [points, setPoints] = useState([])
+ const [isLoading, setIsLoading] = useState(true)
+ const [error, setError] = useState(null)
+
+ const [initialCenter, setInitialCenter] = useState<[number, number] | null>(null)
+
+ const defaultLayer = getConfigString(widget.config, 'defaultLayer', 'nrw-dop')
+ const showLabelsOverlay = getConfigBoolean(
+ widget.config,
+ 'showLabelsOverlay',
+ true,
+ )
+ const showLocateControl = getConfigBoolean(
+ widget.config,
+ 'showLocateControl',
+ false,
+ )
+ const showCurrentLocationMarker = getConfigBoolean(
+ widget.config,
+ 'showCurrentLocationMarker',
+ true,
+ )
+ const initialAddress = getConfigString(widget.config, 'initialAddress', '').trim()
+ const initialZoom = getConfigNumber(widget.config, 'initialZoom', 14)
+
+ useEffect(() => {
+ const abortController = new AbortController()
+
+ async function loadOperations() {
+ setIsLoading(true)
+ setError(null)
+
+ try {
+ const response = await fetch(`${apiUrl}/operations`, {
+ credentials: 'include',
+ signal: abortController.signal,
+ })
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => null)
+ throw new Error(errorData?.error ?? 'Einsätze konnten nicht geladen werden')
+ }
+
+ const data = await response.json()
+ const nextOperations = (data.operations ?? []) as Operation[]
+ setOperations(nextOperations)
+
+ const uniqueAddresses = Array.from(
+ new Set(
+ nextOperations
+ .flatMap((operation) =>
+ getOperationAddresses(operation).map((item) => item.address),
+ )
+ .filter(Boolean),
+ ),
+ )
+
+ const geocodedByAddress = new globalThis.Map()
+
+ await Promise.all(
+ uniqueAddresses.map(async (address) => {
+ const suggestion = await geocodeAddress(
+ apiUrl,
+ address,
+ abortController.signal,
+ )
+
+ if (suggestion) {
+ geocodedByAddress.set(address, suggestion)
+ }
+ }),
+ )
+
+ const nextPoints = nextOperations.flatMap((operation) =>
+ getOperationAddresses(operation)
+ .map((addressItem) => {
+ const suggestion = geocodedByAddress.get(addressItem.address)
+
+ if (!suggestion) {
+ return null
+ }
+
+ return {
+ id: `${operation.id}-${addressItem.type}`,
+ operation,
+ label: suggestion.label,
+ lat: suggestion.lat,
+ lon: suggestion.lon,
+ addressType: addressItem.type,
+ }
+ })
+ .filter((point): point is OperationPoint => point !== null),
+ )
+
+ setPoints(nextPoints)
+
+ if (initialAddress) {
+ const suggestion = await geocodeAddress(
+ apiUrl,
+ initialAddress,
+ abortController.signal,
+ )
+
+ setInitialCenter(
+ suggestion ? [suggestion.lat, suggestion.lon] : null,
+ )
+ } else {
+ setInitialCenter(null)
+ }
+ } catch (error) {
+ if (error instanceof DOMException && error.name === 'AbortError') {
+ return
+ }
+
+ setError(
+ error instanceof Error
+ ? error.message
+ : 'Einsatzkarte konnte nicht geladen werden',
+ )
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ void loadOperations()
+
+ return () => {
+ abortController.abort()
+ }
+ }, [apiUrl, initialAddress])
+
+ const markers: MapMarker[] = useMemo(
+ () =>
+ points.map((point) => ({
+ id: point.id,
+ lat: point.lat,
+ lon: point.lon,
+ title: `${point.addressType} · ${
+ point.operation.operationName || point.operation.operationNumber
+ }`,
+ label: point.operation.operationName || point.operation.operationNumber,
+ labelOffset: [0, -34],
+ popup: (
+
+
+ {point.operation.operationName || 'Ohne Einsatzname'}
+
+
+ {point.operation.operationNumber}
+
+
+ {point.addressType}:{' '}
+ {point.label}
+
+ {point.operation.offense && (
+
+ Delikt:{' '}
+ {point.operation.offense}
+
+ )}
+
+ ),
+ })),
+ [points],
+ )
+
+ if (isLoading) {
+ return (
+
+ Einsatzkarte wird geladen...
+
+ )
+ }
+
+ if (error) {
+ return (
+
+ {error}
+
+ )
+ }
+
+ if (operations.length === 0 || markers.length === 0) {
+ return (
+
+ Keine Einsatzpositionen vorhanden.
+
+ )
+ }
+
+ return (
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/components/formatter.ts b/frontend/src/components/formatter.ts
new file mode 100644
index 0000000..3735e2d
--- /dev/null
+++ b/frontend/src/components/formatter.ts
@@ -0,0 +1,19 @@
+// frontend\src\components\formatter.ts
+
+export function formatOperationNumberInput(value: string) {
+ const digits = value.replace(/\D/g, '').slice(0, 9)
+
+ if (digits.length <= 3) {
+ return digits
+ }
+
+ if (digits.length <= 5) {
+ return `${digits.slice(0, 3)}-${digits.slice(3)}`
+ }
+
+ return `${digits.slice(0, 3)}-${digits.slice(3, 5)}-${digits.slice(5)}`
+}
+
+export function isCompleteOperationNumber(value: string) {
+ return /^\d{3}-\d{2}-\d{4}$/.test(value)
+}
\ No newline at end of file
diff --git a/frontend/src/components/permissions.ts b/frontend/src/components/permissions.ts
new file mode 100644
index 0000000..790aef9
--- /dev/null
+++ b/frontend/src/components/permissions.ts
@@ -0,0 +1,15 @@
+// frontend/src/components/permissions.ts
+
+import type { User } from './types'
+
+export function hasRight(user: User | null | undefined, right: string) {
+ if (!user) {
+ return false
+ }
+
+ return user.rights?.includes('admin') || user.rights?.includes(right)
+}
+
+export function hasAnyRight(user: User | null | undefined, rights: string[]) {
+ return rights.some((right) => hasRight(user, right))
+}
\ No newline at end of file
diff --git a/frontend/src/components/types.ts b/frontend/src/components/types.ts
index 0914a7b..8054879 100644
--- a/frontend/src/components/types.ts
+++ b/frontend/src/components/types.ts
@@ -27,12 +27,14 @@ export type DeviceHistoryChange = {
newValue: string
}
+export type DeviceHistoryAction = 'created' | 'updated' | 'journal'
+
export type DeviceHistoryEntry = {
id: string
deviceId: string
userId: string
userDisplayName: string
- action: 'created' | 'updated'
+ action: DeviceHistoryAction
changes: DeviceHistoryChange[]
createdAt: string
}
@@ -56,4 +58,60 @@ export type Device = {
loanStatus: string
comment: string
relatedDevices: RelatedDevice[]
+ isBaoDevice: boolean
+}
+
+export type Operation = {
+ id: string
+ operationNumber: string
+ operationName: string
+ offense: string
+ caseWorker: string
+ targetObject: string
+ kwAddress: string
+ operationLeader: string
+ operationTeam: string
+ legend: string
+ camera: string
+ router: string
+ technology: string
+ switchbox: string
+ hardDrive: string
+ laptop: string
+ remark: string
+ createdAt: string
+ updatedAt: string
+}
+
+export type OperationHistoryChange = {
+ field: string
+ label: string
+ oldValue: string
+ newValue: string
+}
+
+export type OperationHistoryEntry = {
+ id: string
+ operationId: string
+ userId: string
+ userDisplayName: string
+ action: string
+ changes: OperationHistoryChange[]
+ createdAt: string
+ updatedAt?: string
+ canEdit?: boolean
+}
+
+export type Notification = {
+ id: string
+ userId: string
+ type: string
+ title: string
+ message: string
+ entityType: string
+ entityId: string
+ data: Record
+ visible?: boolean
+ readAt?: string | null
+ createdAt: string
}
\ No newline at end of file
diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx
index f1ee377..c5b320d 100644
--- a/frontend/src/pages/DashboardPage.tsx
+++ b/frontend/src/pages/DashboardPage.tsx
@@ -1,19 +1,1056 @@
-// frontend\src\pages\DashboardPage.tsx
+// frontend/src/pages/DashboardPage.tsx
+
+import { useEffect, useMemo, useRef, useState, type PointerEvent } from 'react'
+import {
+ CheckIcon,
+ Cog6ToothIcon,
+ PencilSquareIcon,
+ PlusIcon,
+ TrashIcon,
+} from '@heroicons/react/20/solid'
+import WidgetSettingsModal from '../components/dashboard/WidgetSettingsModal'
+import Button from '../components/Button'
+import LoadingSpinner from '../components/LoadingSpinner'
+import DashboardWidgetRenderer from '../components/dashboard/DashboardWidgetRenderer'
+import WidgetPickerModal from '../components/dashboard/WidgetPickerModal'
+import type {
+ DashboardWidget,
+ DashboardWidgetDefinition,
+ DashboardWidgetType,
+} from '../components/dashboard/types'
+
+const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
+
+const GRID_COLUMNS = 12
+const GRID_ROW_HEIGHT = 88
+const GRID_GAP = 16
+
+type ResizeDirection = 'nw' | 'ne' | 'sw' | 'se'
+
+type GridPlacement = {
+ x: number
+ y: number
+ w: number
+ h: number
+}
+
+type GridCell = {
+ x: number
+ y: number
+}
+
+type DashboardInteraction =
+ | {
+ type: 'move'
+ pointerId: number
+ widgetId: string
+ offsetX: number
+ offsetY: number
+ }
+ | {
+ type: 'resize'
+ pointerId: number
+ widgetId: string
+ direction: ResizeDirection
+ startClientX: number
+ startClientY: number
+ startWidget: DashboardWidget
+ }
+ | {
+ type: 'draw'
+ pointerId: number
+ startCell: GridCell
+ }
+
+function getResizeHandleClass(direction: ResizeDirection) {
+ switch (direction) {
+ case 'nw':
+ return '-top-1.5 -left-1.5 cursor-nwse-resize'
+ case 'ne':
+ return '-top-1.5 -right-1.5 cursor-nesw-resize'
+ case 'sw':
+ return '-bottom-1.5 -left-1.5 cursor-nesw-resize'
+ case 'se':
+ return '-right-1.5 -bottom-1.5 cursor-nwse-resize'
+ }
+}
+
+function hasWidgetChanged(widget: DashboardWidget, nextWidget: DashboardWidget) {
+ return (
+ widget.x !== nextWidget.x ||
+ widget.y !== nextWidget.y ||
+ widget.w !== nextWidget.w ||
+ widget.h !== nextWidget.h
+ )
+}
type DashboardPageProps = {
username: string
}
+const widgetTypes: DashboardWidgetDefinition[] = [
+ {
+ id: 'operationChanges',
+ label: 'Letzte Einsatzänderungen',
+ description: 'Zeigt die letzten Änderungen an Einsätzen ohne Journal-Einträge.',
+ defaultTitle: 'Letzte Einsatzänderungen',
+ defaultSize: {
+ w: 6,
+ h: 4,
+ },
+ defaultConfig: {
+ limit: 8,
+ },
+ },
+ {
+ id: 'operationMap',
+ label: 'Einsatzkarte',
+ description: 'Zeigt Einsatzorte auf einer Karte inklusive KW und Zielobjekt.',
+ defaultTitle: 'Einsatzkarte',
+ defaultSize: {
+ w: 8,
+ h: 5,
+ },
+ defaultConfig: {
+ defaultLayer: 'nrw-dop',
+ showLabelsOverlay: true,
+ showLocateControl: false,
+ showCurrentLocationMarker: true,
+ initialAddress: 'Tiefenbroicher Weg 32, 40472 Düsseldorf',
+ initialZoom: 14,
+ },
+ },
+]
+
+function classNames(...classes: Array) {
+ return classes.filter(Boolean).join(' ')
+}
+
+function clamp(value: number, min: number, max: number) {
+ return Math.min(Math.max(value, min), max)
+}
+
+function widgetsCollide(a: DashboardWidget, b: DashboardWidget) {
+ return !(
+ a.x + a.w <= b.x ||
+ b.x + b.w <= a.x ||
+ a.y + a.h <= b.y ||
+ b.y + b.h <= a.y
+ )
+}
+
+function findFreePosition(
+ widgets: DashboardWidget[],
+ candidate: DashboardWidget,
+) {
+ let nextWidget = {
+ ...candidate,
+ x: clamp(candidate.x, 0, GRID_COLUMNS - candidate.w),
+ y: Math.max(0, candidate.y),
+ }
+
+ while (
+ widgets.some(
+ (widget) =>
+ widget.id !== nextWidget.id && widgetsCollide(widget, nextWidget),
+ )
+ ) {
+ nextWidget = {
+ ...nextWidget,
+ y: nextWidget.y + 1,
+ }
+ }
+
+ return nextWidget
+}
+
+function getNextY(widgets: DashboardWidget[]) {
+ if (widgets.length === 0) {
+ return 0
+ }
+
+ return Math.max(...widgets.map((widget) => widget.y + widget.h))
+}
+
+function getWidgetTypeDefinition(type: DashboardWidgetType) {
+ return widgetTypes.find((widgetType) => widgetType.id === type) ?? widgetTypes[0]
+}
+
+function getGridOverlayStyle() {
+ const columnWidth = `calc((100% - ${(GRID_COLUMNS - 1) * GRID_GAP}px) / ${GRID_COLUMNS})`
+ const lineColor = 'rgb(99 102 241 / 0.18)'
+
+ return {
+ backgroundImage: `
+ linear-gradient(
+ to right,
+ transparent calc(${columnWidth} - 1px),
+ ${lineColor} calc(${columnWidth} - 1px),
+ ${lineColor} ${columnWidth},
+ transparent ${columnWidth}
+ ),
+ linear-gradient(
+ to bottom,
+ transparent ${GRID_ROW_HEIGHT - 1}px,
+ ${lineColor} ${GRID_ROW_HEIGHT - 1}px,
+ ${lineColor} ${GRID_ROW_HEIGHT}px,
+ transparent ${GRID_ROW_HEIGHT}px
+ )
+ `,
+ backgroundSize: `calc(${columnWidth} + ${GRID_GAP}px) ${GRID_ROW_HEIGHT + GRID_GAP}px`,
+ }
+}
+
export default function DashboardPage({ username }: DashboardPageProps) {
+ const [widgets, setWidgets] = useState([])
+ const [isEditingDashboard, setIsEditingDashboard] = useState(false)
+ const [isWidgetPickerOpen, setIsWidgetPickerOpen] = useState(false)
+ const [pendingWidgetPlacement, setPendingWidgetPlacement] = useState(null)
+ const [drawPlacement, setDrawPlacement] = useState(null)
+ const [activeWidgetId, setActiveWidgetId] = useState(null)
+ const [previewWidget, setPreviewWidget] = useState(null)
+ const [settingsWidget, setSettingsWidget] = useState(null)
+ const [isLoading, setIsLoading] = useState(true)
+ const [isSaving, setIsSaving] = useState(false)
+ const [error, setError] = useState(null)
+
+ const gridRef = useRef(null)
+
+ const interactionRef = useRef(null)
+ const previewWidgetRef = useRef(null)
+
+ const drawPlacementRef = useRef(null)
+
+ useEffect(() => {
+ drawPlacementRef.current = drawPlacement
+ }, [drawPlacement])
+
+ useEffect(() => {
+ previewWidgetRef.current = previewWidget
+ }, [previewWidget])
+
+ const dashboardHeight = useMemo(() => {
+ const maxY = Math.max(
+ 4,
+ ...widgets.map((widget) => widget.y + widget.h),
+ drawPlacement ? drawPlacement.y + drawPlacement.h : 0,
+ previewWidget ? previewWidget.y + previewWidget.h : 0,
+ )
+
+ return maxY * GRID_ROW_HEIGHT + Math.max(0, maxY - 1) * GRID_GAP
+ }, [drawPlacement, previewWidget, widgets])
+
+ useEffect(() => {
+ void loadWidgets()
+ }, [])
+
+ async function loadWidgets() {
+ setIsLoading(true)
+ setError(null)
+
+ try {
+ const response = await fetch(`${API_URL}/dashboard/widgets`, {
+ credentials: 'include',
+ })
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => null)
+ throw new Error(errorData?.error ?? 'Dashboard konnte nicht geladen werden')
+ }
+
+ const data = await response.json()
+ setWidgets(data.widgets ?? [])
+ } catch (error) {
+ setError(
+ error instanceof Error
+ ? error.message
+ : 'Dashboard konnte nicht geladen werden',
+ )
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ async function saveWidget(widget: DashboardWidget) {
+ const response = await fetch(`${API_URL}/dashboard/widgets/${widget.id}`, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ credentials: 'include',
+ body: JSON.stringify({
+ type: widget.type,
+ title: widget.title,
+ x: widget.x,
+ y: widget.y,
+ w: widget.w,
+ h: widget.h,
+ config: widget.config,
+ }),
+ })
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => null)
+ throw new Error(errorData?.error ?? 'Widget konnte nicht gespeichert werden')
+ }
+
+ const data = await response.json()
+ return data.widget as DashboardWidget
+ }
+
+ async function createWidget(widgetTypeId: DashboardWidgetType) {
+ const widgetType = getWidgetTypeDefinition(widgetTypeId)
+ const placement = pendingWidgetPlacement
+
+ setIsSaving(true)
+ setError(null)
+
+ try {
+ const response = await fetch(`${API_URL}/dashboard/widgets`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ credentials: 'include',
+ body: JSON.stringify({
+ type: widgetType.id,
+ title: widgetType.defaultTitle,
+ x: placement?.x ?? 0,
+ y: placement?.y ?? getNextY(widgets),
+ w: placement?.w ?? widgetType.defaultSize.w,
+ h: placement?.h ?? widgetType.defaultSize.h,
+ config: widgetType.defaultConfig,
+ }),
+ })
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => null)
+ throw new Error(errorData?.error ?? 'Widget konnte nicht erstellt werden')
+ }
+
+ const data = await response.json()
+
+ setWidgets((currentWidgets) => [...currentWidgets, data.widget])
+ setPendingWidgetPlacement(null)
+ setIsWidgetPickerOpen(false)
+ } catch (error) {
+ setError(
+ error instanceof Error
+ ? error.message
+ : 'Widget konnte nicht erstellt werden',
+ )
+ } finally {
+ setIsSaving(false)
+ }
+ }
+
+ async function updateWidget(nextWidget: DashboardWidget) {
+ const adjustedWidget = findFreePosition(
+ widgets.filter((widget) => widget.id !== nextWidget.id),
+ nextWidget,
+ )
+
+ setWidgets((currentWidgets) =>
+ currentWidgets.map((widget) =>
+ widget.id === adjustedWidget.id ? adjustedWidget : widget,
+ ),
+ )
+
+ try {
+ const savedWidget = await saveWidget(adjustedWidget)
+
+ setWidgets((currentWidgets) =>
+ currentWidgets.map((widget) =>
+ widget.id === savedWidget.id ? savedWidget : widget,
+ ),
+ )
+ } catch (error) {
+ setError(
+ error instanceof Error
+ ? error.message
+ : 'Widget konnte nicht gespeichert werden',
+ )
+ void loadWidgets()
+ }
+ }
+
+ async function updateWidgetSettings(
+ widget: DashboardWidget,
+ nextValues: {
+ title: string
+ config: Record
+ },
+ ) {
+ const nextWidget: DashboardWidget = {
+ ...widget,
+ title: nextValues.title,
+ config: nextValues.config,
+ }
+
+ setIsSaving(true)
+ setError(null)
+
+ setWidgets((currentWidgets) =>
+ currentWidgets.map((currentWidget) =>
+ currentWidget.id === nextWidget.id ? nextWidget : currentWidget,
+ ),
+ )
+
+ try {
+ const savedWidget = await saveWidget(nextWidget)
+
+ setWidgets((currentWidgets) =>
+ currentWidgets.map((currentWidget) =>
+ currentWidget.id === savedWidget.id ? savedWidget : currentWidget,
+ ),
+ )
+
+ setSettingsWidget(null)
+ } catch (error) {
+ setError(
+ error instanceof Error
+ ? error.message
+ : 'Widget-Einstellungen konnten nicht gespeichert werden',
+ )
+
+ void loadWidgets()
+ } finally {
+ setIsSaving(false)
+ }
+ }
+
+ async function deleteWidget(widgetId: string) {
+ setError(null)
+
+ try {
+ const response = await fetch(`${API_URL}/dashboard/widgets/${widgetId}`, {
+ method: 'DELETE',
+ credentials: 'include',
+ })
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => null)
+ throw new Error(errorData?.error ?? 'Widget konnte nicht gelöscht werden')
+ }
+
+ setWidgets((currentWidgets) =>
+ currentWidgets.filter((widget) => widget.id !== widgetId),
+ )
+ } catch (error) {
+ setError(
+ error instanceof Error
+ ? error.message
+ : 'Widget konnte nicht gelöscht werden',
+ )
+ }
+ }
+
+ function getGridMetrics() {
+ if (!gridRef.current) {
+ return null
+ }
+
+ const rect = gridRef.current.getBoundingClientRect()
+ const styles = window.getComputedStyle(gridRef.current)
+
+ const paddingLeft = Number.parseFloat(styles.paddingLeft) || 0
+ const paddingTop = Number.parseFloat(styles.paddingTop) || 0
+ const paddingRight = Number.parseFloat(styles.paddingRight) || 0
+
+ const contentLeft = rect.left + paddingLeft
+ const contentTop = rect.top + paddingTop
+ const contentWidth = rect.width - paddingLeft - paddingRight
+ const columnWidth =
+ (contentWidth - (GRID_COLUMNS - 1) * GRID_GAP) / GRID_COLUMNS
+
+ return {
+ contentLeft,
+ contentTop,
+ columnWidth,
+ }
+ }
+
+ function getGridCellFromPointer(clientX: number, clientY: number): GridCell | null {
+ const metrics = getGridMetrics()
+
+ if (!metrics) {
+ return null
+ }
+
+ const relativeX = clientX - metrics.contentLeft
+ const relativeY = clientY - metrics.contentTop
+
+ return {
+ x: clamp(
+ Math.floor(relativeX / (metrics.columnWidth + GRID_GAP)),
+ 0,
+ GRID_COLUMNS - 1,
+ ),
+ y: Math.max(
+ 0,
+ Math.floor(relativeY / (GRID_ROW_HEIGHT + GRID_GAP)),
+ ),
+ }
+ }
+
+ function getDrawPlacement(startCell: GridCell, endCell: GridCell): GridPlacement {
+ const x = Math.min(startCell.x, endCell.x)
+ const y = Math.min(startCell.y, endCell.y)
+
+ const w = clamp(Math.abs(endCell.x - startCell.x) + 1, 2, GRID_COLUMNS - x)
+ const h = clamp(Math.abs(endCell.y - startCell.y) + 1, 1, 8)
+
+ return {
+ x,
+ y,
+ w,
+ h,
+ }
+ }
+
+ function handleGridPointerDown(event: PointerEvent) {
+ if (!isEditingDashboard) {
+ return
+ }
+
+ if (event.button !== 0) {
+ return
+ }
+
+ if (event.target !== event.currentTarget) {
+ return
+ }
+
+ const startCell = getGridCellFromPointer(event.clientX, event.clientY)
+
+ if (!startCell || !gridRef.current) {
+ return
+ }
+
+ interactionRef.current = {
+ type: 'draw',
+ pointerId: event.pointerId,
+ startCell,
+ }
+
+ const initialPlacement: GridPlacement = {
+ x: startCell.x,
+ y: startCell.y,
+ w: 2,
+ h: 1,
+ }
+
+ setDrawPlacement(initialPlacement)
+ gridRef.current.setPointerCapture(event.pointerId)
+ }
+
+ function openWidgetPickerForPlacement(placement: GridPlacement | null) {
+ setPendingWidgetPlacement(placement)
+ setIsWidgetPickerOpen(true)
+ }
+
+ function handleWidgetPickerOpenChange(open: boolean) {
+ setIsWidgetPickerOpen(open)
+
+ if (!open) {
+ setPendingWidgetPlacement(null)
+ }
+ }
+
+ function getMovedWidget(
+ widget: DashboardWidget,
+ clientX: number,
+ clientY: number,
+ offsetX: number,
+ offsetY: number,
+ ) {
+ const metrics = getGridMetrics()
+
+ if (!metrics) {
+ return widget
+ }
+
+ const relativeX = clientX - metrics.contentLeft - offsetX
+ const relativeY = clientY - metrics.contentTop - offsetY
+
+ const nextX = clamp(
+ Math.round(relativeX / (metrics.columnWidth + GRID_GAP)),
+ 0,
+ GRID_COLUMNS - widget.w,
+ )
+
+ const nextY = Math.max(
+ 0,
+ Math.round(relativeY / (GRID_ROW_HEIGHT + GRID_GAP)),
+ )
+
+ return {
+ ...widget,
+ x: nextX,
+ y: nextY,
+ }
+ }
+
+ function getResizedWidget(
+ interaction: Extract,
+ clientX: number,
+ clientY: number,
+ ) {
+ const metrics = getGridMetrics()
+
+ if (!metrics) {
+ return interaction.startWidget
+ }
+
+ const columnDelta = Math.round(
+ (clientX - interaction.startClientX) / (metrics.columnWidth + GRID_GAP),
+ )
+
+ const rowDelta = Math.round(
+ (clientY - interaction.startClientY) / (GRID_ROW_HEIGHT + GRID_GAP),
+ )
+
+ const startWidget = interaction.startWidget
+
+ let nextX = startWidget.x
+ let nextY = startWidget.y
+ let nextW = startWidget.w
+ let nextH = startWidget.h
+
+ if (interaction.direction.includes('e')) {
+ nextW = clamp(startWidget.w + columnDelta, 2, GRID_COLUMNS - startWidget.x)
+ }
+
+ if (interaction.direction.includes('s')) {
+ nextH = clamp(startWidget.h + rowDelta, 1, 8)
+ }
+
+ if (interaction.direction.includes('w')) {
+ const maxX = startWidget.x + startWidget.w - 2
+
+ nextX = clamp(startWidget.x + columnDelta, 0, maxX)
+ nextW = startWidget.x + startWidget.w - nextX
+ }
+
+ if (interaction.direction.includes('n')) {
+ const maxY = startWidget.y + startWidget.h - 1
+
+ nextY = clamp(startWidget.y + rowDelta, 0, maxY)
+ nextH = startWidget.y + startWidget.h - nextY
+ }
+
+ return {
+ ...startWidget,
+ x: nextX,
+ y: nextY,
+ w: nextW,
+ h: nextH,
+ }
+ }
+
+ function startMovingWidget(
+ event: PointerEvent,
+ widget: DashboardWidget,
+ ) {
+ if (event.button !== 0) {
+ return
+ }
+
+ if (!isEditingDashboard) {
+ return
+ }
+
+ const widgetElement = event.currentTarget.closest('[data-dashboard-widget]')
+
+ if (!widgetElement || !gridRef.current) {
+ return
+ }
+
+ const rect = widgetElement.getBoundingClientRect()
+
+ interactionRef.current = {
+ type: 'move',
+ pointerId: event.pointerId,
+ widgetId: widget.id,
+ offsetX: event.clientX - rect.left,
+ offsetY: event.clientY - rect.top,
+ }
+
+ setActiveWidgetId(widget.id)
+ setPreviewWidget(widget)
+
+ gridRef.current.setPointerCapture(event.pointerId)
+ }
+
+ function startResizingWidget(
+ event: PointerEvent,
+ widget: DashboardWidget,
+ direction: ResizeDirection,
+ ) {
+ event.preventDefault()
+ event.stopPropagation()
+
+ if (!isEditingDashboard) {
+ return
+ }
+
+ if (!gridRef.current) {
+ return
+ }
+
+ interactionRef.current = {
+ type: 'resize',
+ pointerId: event.pointerId,
+ widgetId: widget.id,
+ direction,
+ startClientX: event.clientX,
+ startClientY: event.clientY,
+ startWidget: widget,
+ }
+
+ setActiveWidgetId(widget.id)
+ setPreviewWidget(widget)
+
+ gridRef.current.setPointerCapture(event.pointerId)
+ }
+
+ function handleGridPointerMove(event: PointerEvent) {
+ const interaction = interactionRef.current
+
+ if (!interaction) {
+ return
+ }
+
+ if (interaction.type === 'draw') {
+ const currentCell = getGridCellFromPointer(event.clientX, event.clientY)
+
+ if (!currentCell) {
+ return
+ }
+
+ setDrawPlacement(getDrawPlacement(interaction.startCell, currentCell))
+ return
+ }
+
+ const widget =
+ interaction.type === 'resize'
+ ? interaction.startWidget
+ : widgets.find((item) => item.id === interaction.widgetId)
+
+ if (!widget) {
+ return
+ }
+
+ const rawNextWidget =
+ interaction.type === 'move'
+ ? getMovedWidget(
+ widget,
+ event.clientX,
+ event.clientY,
+ interaction.offsetX,
+ interaction.offsetY,
+ )
+ : getResizedWidget(interaction, event.clientX, event.clientY)
+
+ const adjustedWidget = findFreePosition(
+ widgets.filter((item) => item.id !== rawNextWidget.id),
+ rawNextWidget,
+ )
+
+ setPreviewWidget(adjustedWidget)
+ }
+
+ function handleGridPointerEnd() {
+ const interaction = interactionRef.current
+ const nextWidget = previewWidgetRef.current
+ const nextDrawPlacement = drawPlacementRef.current
+
+ if (interaction && gridRef.current?.hasPointerCapture(interaction.pointerId)) {
+ gridRef.current.releasePointerCapture(interaction.pointerId)
+ }
+
+ interactionRef.current = null
+ setActiveWidgetId(null)
+ setPreviewWidget(null)
+ setDrawPlacement(null)
+
+ if (interaction?.type === 'draw') {
+ if (nextDrawPlacement) {
+ openWidgetPickerForPlacement(nextDrawPlacement)
+ }
+
+ return
+ }
+
+ if (!nextWidget) {
+ return
+ }
+
+ const currentWidget = widgets.find((widget) => widget.id === nextWidget.id)
+
+ if (!currentWidget || !hasWidgetChanged(currentWidget, nextWidget)) {
+ return
+ }
+
+ void updateWidget(nextWidget)
+ }
+
+ if (isLoading) {
+ return (
+
+
+
+ )
+ }
+
return (
-
- Dashboard
-
+
+
+
+ Dashboard
+
-
- Willkommen zurück, {username}.
-
+
+ Willkommen zurück, {username}. Du kannst Widgets hinzufügen, verschieben und skalieren.
+
+
+
+
+
+ ) : (
+
+ )
+ }
+ onClick={() => {
+ setIsEditingDashboard((currentValue) => !currentValue)
+ setPreviewWidget(null)
+ setDrawPlacement(null)
+ setPendingWidgetPlacement(null)
+ }}
+ >
+ {isEditingDashboard ? 'Fertig' : 'Dashboard bearbeiten'}
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {widgets.length === 0 ? (
+
+
+ Noch keine Widgets
+
+
+
+ Füge dein erstes Widget hinzu und baue dir dein persönliches Dashboard.
+
+
+
+ }
+ onClick={() => {
+ setIsEditingDashboard(true)
+ openWidgetPickerForPlacement(null)
+ }}
+ >
+ Widget hinzufügen
+
+
+
+ ) : (
+
+ {isEditingDashboard && (
+
+ )}
+
+ {drawPlacement && (
+
+ )}
+
+ {previewWidget && (
+
+ )}
+ {widgets.map((widget) => (
+
+ startMovingWidget(event, widget)}
+ className={classNames(
+ 'flex shrink-0 touch-none items-center justify-between gap-x-3 border-b border-gray-200 px-4 py-3 select-none dark:border-white/10',
+ isEditingDashboard ? 'cursor-move' : 'cursor-default',
+ )}
+ >
+
+
+ {widget.title}
+
+
+
+ {getWidgetTypeDefinition(widget.type).label}
+
+
+
+
+
+
+
+ {isEditingDashboard && (
+
+
+ )}
+
+
+
+
+
+
+
+ {isEditingDashboard &&
+ (['nw', 'ne', 'sw', 'se'] as ResizeDirection[]).map((direction) => (
+
+ ))}
+
+ )}
+
+
+
+ {
+ if (!open) {
+ setSettingsWidget(null)
+ }
+ }}
+ widget={settingsWidget}
+ isSaving={isSaving}
+ onSave={updateWidgetSettings}
+ />
)
}
\ No newline at end of file
diff --git a/frontend/src/pages/IncidentsPage.tsx b/frontend/src/pages/IncidentsPage.tsx
deleted file mode 100644
index 4473aa6..0000000
--- a/frontend/src/pages/IncidentsPage.tsx
+++ /dev/null
@@ -1,11 +0,0 @@
-// frontend/src/pages/IncidentsPage.tsx
-
-export default function IncidentsPage() {
- return (
-
-
- Einsätze
-
-
- )
-}
\ No newline at end of file
diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx
new file mode 100644
index 0000000..82fa0fb
--- /dev/null
+++ b/frontend/src/pages/LoginPage.tsx
@@ -0,0 +1,228 @@
+// frontend/src/pages/LoginPage.tsx
+
+import { useState, type FormEvent } from 'react'
+import { Link, useLocation, useNavigate } from 'react-router'
+import Button from '../components/Button'
+import Card from '../components/Card'
+import Checkbox from '../components/Checkbox'
+import type { User } from '../components/types'
+import { getCurrentSessionInfo } from '../utils/sessionInfo'
+
+const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
+
+type LoginPageProps = {
+ onLogin?: (user: User) => void
+ redirectTo?: string
+}
+
+const inputClassName =
+ 'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
+
+async function readApiError(response: Response, fallback: string) {
+ const data = await response.json().catch(() => null)
+
+ return data?.error ?? fallback
+}
+
+export default function LoginPage({
+ onLogin,
+ redirectTo = '/',
+}: LoginPageProps) {
+ const navigate = useNavigate()
+ const location = useLocation()
+
+ const [error, setError] = useState(null)
+ const [isSubmitting, setIsSubmitting] = useState(false)
+
+ const state = location.state as
+ | {
+ from?: {
+ pathname?: string
+ }
+ }
+ | null
+
+ const nextPath = state?.from?.pathname ?? redirectTo
+
+ async function handleSubmit(event: FormEvent) {
+ event.preventDefault()
+
+ const form = event.currentTarget
+ const formData = new FormData(form)
+
+ const identifier = String(formData.get('identifier') ?? '')
+ const password = String(formData.get('password') ?? '')
+ const rememberMe = formData.get('rememberMe') === 'on'
+
+ setError(null)
+ setIsSubmitting(true)
+
+ try {
+ const sessionInfo = await getCurrentSessionInfo()
+
+ const response = await fetch(`${API_URL}/auth/login`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ credentials: 'include',
+ body: JSON.stringify({
+ identifier,
+ password,
+ rememberMe,
+ sessionInfo: {
+ browser: sessionInfo.browser,
+ operatingSystem: sessionInfo.operatingSystem,
+ deviceType: sessionInfo.deviceType,
+ },
+ }),
+ })
+
+ if (!response.ok) {
+ throw new Error(
+ await readApiError(
+ response,
+ 'Anmeldung fehlgeschlagen. Bitte prüfe deine Zugangsdaten.',
+ ),
+ )
+ }
+
+ const data = await response.json().catch(() => null)
+ const user = data?.user as User | undefined
+
+ if (user) {
+ onLogin?.(user)
+ }
+
+ navigate(nextPath, {
+ replace: true,
+ })
+ } catch (error) {
+ setError(
+ error instanceof Error
+ ? error.message
+ : 'Anmeldung fehlgeschlagen. Bitte versuche es erneut.',
+ )
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ return (
+
+
+ 
+
+ 
+
+
+ Anmelden
+
+
+
+ Melde dich mit deinem Benutzerkonto an.
+
+
+
+
+
+
+
+
+
+ Noch kein Konto?{' '}
+
+ Registrieren
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx
deleted file mode 100644
index 12f720b..0000000
--- a/frontend/src/pages/SettingsPage.tsx
+++ /dev/null
@@ -1,357 +0,0 @@
-// frontend\src\pages\SettingsPage.tsx
-
-import type { FormEvent, ReactNode } from 'react'
-import { useLocation } from 'react-router'
-import {
- BellIcon,
- CreditCardIcon,
- PuzzlePieceIcon,
- UserIcon,
- UsersIcon,
-} from '@heroicons/react/20/solid'
-import Tabs from '../components/Tabs'
-import Button from '../components/Button'
-import Container from '../components/Container'
-import type { User } from '../components/types'
-
-type SettingsPageProps = {
- user?: User
-}
-
-const inputClassName =
- 'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
-
-function Section({
- title,
- description,
- children,
-}: {
- title: string
- description: string
- children: ReactNode
-}) {
- return (
-
-
-
-
- {title}
-
-
- {description}
-
-
-
-
- {children}
-
-
-
- )
-}
-
-function PlaceholderSection({ title }: { title: string }) {
- return (
-
-
- Noch keine Einstellungen vorhanden.
-
-
- )
-}
-
-export default function SettingsPage({ user }: SettingsPageProps) {
- const location = useLocation()
-
- const pathname = location.pathname.replace(/\/$/, '')
- const section = pathname.split('/')[2] ?? 'konto'
-
- const settingsTabs = [
- {
- name: 'Konto',
- href: '/einstellungen',
- icon: UserIcon,
- current: pathname === '/einstellungen',
- },
- {
- name: 'Benachrichtigungen',
- href: '/einstellungen/benachrichtigungen',
- icon: BellIcon,
- current: pathname === '/einstellungen/benachrichtigungen',
- },
- {
- name: 'Abrechnung',
- href: '/einstellungen/abrechnung',
- icon: CreditCardIcon,
- current: pathname === '/einstellungen/abrechnung',
- },
- {
- name: 'Teams',
- href: '/einstellungen/teams',
- icon: UsersIcon,
- current: pathname === '/einstellungen/teams',
- },
- {
- name: 'Integrationen',
- href: '/einstellungen/integrationen',
- icon: PuzzlePieceIcon,
- current: pathname === '/einstellungen/integrationen',
- },
- ]
-
- const avatarUrl =
- user?.avatar ||
- 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80'
-
- function handleSubmit(event: FormEvent) {
- event.preventDefault()
- }
-
- return (
-
- Einstellungen
-
-
-
- {section === 'konto' && (
-
- )}
-
- {section === 'benachrichtigungen' && (
-
- )}
-
- {section === 'abrechnung' && (
-
- )}
-
- {section === 'teams' && (
-
- )}
-
- {section === 'integrationen' && (
-
- )}
-
- )
-}
\ No newline at end of file
diff --git a/frontend/src/pages/administration/AdministrationPage.tsx b/frontend/src/pages/administration/AdministrationPage.tsx
new file mode 100644
index 0000000..1769dcb
--- /dev/null
+++ b/frontend/src/pages/administration/AdministrationPage.tsx
@@ -0,0 +1,929 @@
+// frontend/src/pages/settings/AdministrationPage.tsx
+
+import { useEffect, useMemo, useState, type FormEvent } from 'react'
+import { useLocation } from 'react-router'
+import Button from '../../components/Button'
+import LoadingSpinner from '../../components/LoadingSpinner'
+import Tabs from '../../components/Tabs'
+import {
+ MagnifyingGlassIcon,
+ UserGroupIcon,
+ UserPlusIcon,
+ UsersIcon,
+ VideoCameraIcon,
+} from '@heroicons/react/20/solid'
+import Checkbox from '../../components/Checkbox'
+import Milestone from './Milestone'
+
+const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
+
+type AdminTeam = {
+ id: string
+ name: string
+ initial: string
+ href: string
+}
+
+type AdminUser = {
+ id: string
+ username: string
+ displayName: string
+ email: string
+ avatar: string
+ unit: string
+ group: string
+ rights: string[]
+ teams: AdminTeam[]
+}
+
+const adminRightId = 'admin'
+
+const rightGroups = [
+ {
+ id: 'devices',
+ label: 'Geräte',
+ rights: [
+ { id: 'devices:read', label: 'anzeigen' },
+ { id: 'devices:write', label: 'bearbeiten' },
+ ],
+ },
+ {
+ id: 'operations',
+ label: 'Einsätze',
+ rights: [
+ { id: 'operations:read', label: 'anzeigen' },
+ { id: 'operations:write', label: 'bearbeiten' },
+ ],
+ },
+ {
+ id: 'calendar',
+ label: 'Kalender',
+ rights: [
+ { id: 'calendar:read', label: 'anzeigen' },
+ ],
+ },
+ {
+ id: 'documents',
+ label: 'Dokumente',
+ rights: [
+ { id: 'documents:read', label: 'anzeigen' },
+ ],
+ },
+ {
+ id: 'reports',
+ label: 'Berichte',
+ rights: [
+ { id: 'reports:read', label: 'anzeigen' },
+ ],
+ },
+ {
+ id: 'settings',
+ label: 'Einstellungen',
+ rights: [
+ { id: 'settings:read', label: 'anzeigen' },
+ ],
+ },
+ {
+ id: 'administration',
+ label: 'Administration',
+ rights: [
+ { id: 'administration:read', label: 'anzeigen' },
+ { id: 'administration:write', label: 'bearbeiten' },
+ ],
+ },
+ {
+ id: 'users',
+ label: 'Benutzer',
+ rights: [
+ { id: 'users:read', label: 'anzeigen' },
+ { id: 'users:write', label: 'bearbeiten' },
+ ],
+ },
+ {
+ id: 'teams',
+ label: 'Teams',
+ rights: [
+ { id: 'teams:read', label: 'anzeigen' },
+ { id: 'teams:write', label: 'bearbeiten' },
+ ],
+ },
+]
+
+const availableRights = [
+ { id: adminRightId, label: 'Administrator' },
+ ...rightGroups.flatMap((group) => group.rights),
+]
+
+const adminRight = availableRights.find((right) => right.id === adminRightId)
+
+function getAllRightIds() {
+ return availableRights.map((right) => right.id)
+}
+
+const inputClassName =
+ 'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
+
+function classNames(...classes: Array) {
+ return classes.filter(Boolean).join(' ')
+}
+
+function getSelectedValues(formData: FormData, name: string) {
+ return formData
+ .getAll(name)
+ .map((value) => String(value))
+ .filter(Boolean)
+}
+
+function getUserDisplayName(user: AdminUser) {
+ return user.displayName || user.username || user.email
+}
+
+function getUserInitials(user: AdminUser) {
+ const displayName = getUserDisplayName(user).trim()
+
+ if (!displayName) {
+ return '?'
+ }
+
+ const parts = displayName
+ .split(/\s+/)
+ .map((part) => part.trim())
+ .filter(Boolean)
+
+ if (parts.length >= 2) {
+ return `${parts[0][0]}${parts[1][0]}`.toUpperCase()
+ }
+
+ return displayName.slice(0, 2).toUpperCase()
+}
+
+function getUserSearchText(user: AdminUser) {
+ return [
+ user.username,
+ user.displayName,
+ user.email,
+ user.unit,
+ user.group,
+ ...user.teams.map((team) => team.name),
+ ]
+ .join(' ')
+ .toLowerCase()
+}
+
+export default function AdministrationPage() {
+
+ const location = useLocation()
+
+ const pathname = location.pathname.replace(/\/$/, '')
+ const pathParts = pathname.split('/').filter(Boolean)
+ const lastPathPart = pathParts.at(-1)
+
+ const activeTab =
+ lastPathPart === 'teams'
+ ? 'teams'
+ : lastPathPart === 'milestone'
+ ? 'milestone'
+ : 'benutzer'
+
+ const administrationTabs = [
+ {
+ name: 'Benutzer',
+ href: '/administration',
+ icon: UsersIcon,
+ current: activeTab === 'benutzer',
+ },
+ {
+ name: 'Teams',
+ href: '/administration/teams',
+ icon: UserGroupIcon,
+ current: activeTab === 'teams',
+ },
+ {
+ name: 'Milestone',
+ href: '/administration/milestone',
+ icon: VideoCameraIcon,
+ current: activeTab === 'milestone',
+ },
+ ]
+
+ const [users, setUsers] = useState([])
+ const [teams, setTeams] = useState([])
+ const [selectedUserId, setSelectedUserId] = useState('new')
+ const [userSearch, setUserSearch] = useState('')
+ const [selectedRights, setSelectedRights] = useState([])
+ const [isLoading, setIsLoading] = useState(true)
+ const [isSaving, setIsSaving] = useState(false)
+ const [error, setError] = useState(null)
+
+ const selectedUser = useMemo(
+ () => users.find((user) => user.id === selectedUserId) ?? null,
+ [selectedUserId, users],
+ )
+
+ const filteredUsers = useMemo(() => {
+ const search = userSearch.trim().toLowerCase()
+
+ if (!search) {
+ return users
+ }
+
+ return users.filter((user) => getUserSearchText(user).includes(search))
+ }, [userSearch, users])
+
+ const isAdministratorSelected = selectedRights.includes(adminRightId)
+
+ useEffect(() => {
+ setSelectedRights(selectedUser?.rights ?? [])
+ }, [selectedUser])
+
+ useEffect(() => {
+ void loadAdministration()
+ }, [])
+
+ async function loadAdministration() {
+ setIsLoading(true)
+ setError(null)
+
+ try {
+ const [usersResponse, teamsResponse] = await Promise.all([
+ fetch(`${API_URL}/admin/users`, { credentials: 'include' }),
+ fetch(`${API_URL}/admin/teams`, { credentials: 'include' }),
+ ])
+
+ if (!usersResponse.ok || !teamsResponse.ok) {
+ throw new Error('Administration konnte nicht geladen werden')
+ }
+
+ const usersData = await usersResponse.json()
+ const teamsData = await teamsResponse.json()
+
+ setUsers(usersData.users ?? [])
+ setTeams(teamsData.teams ?? [])
+ } catch (error) {
+ setError(
+ error instanceof Error
+ ? error.message
+ : 'Administration konnte nicht geladen werden',
+ )
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ function handleRightChange(rightId: string, checked: boolean) {
+ if (rightId === adminRightId) {
+ setSelectedRights(checked ? getAllRightIds() : [])
+ return
+ }
+
+ setSelectedRights((currentRights) => {
+ if (checked) {
+ return [...new Set([...currentRights, rightId])]
+ }
+
+ return currentRights.filter((currentRight) => currentRight !== rightId)
+ })
+ }
+
+ function handleSelectNewUser() {
+ setSelectedUserId('new')
+ setSelectedRights([])
+ }
+
+ async function handleSaveUser(event: FormEvent) {
+ event.preventDefault()
+
+ const form = event.currentTarget
+ const formData = new FormData(form)
+ const isCreate = selectedUserId === 'new'
+
+ const payload = {
+ username: String(formData.get('username') ?? ''),
+ displayName: String(formData.get('displayName') ?? ''),
+ email: String(formData.get('email') ?? ''),
+ password: String(formData.get('password') ?? ''),
+ avatar: String(formData.get('avatar') ?? ''),
+ unit: String(formData.get('unit') ?? ''),
+ group: String(formData.get('group') ?? ''),
+ rights: isAdministratorSelected ? getAllRightIds() : selectedRights,
+ teamIds: getSelectedValues(formData, 'teamIds'),
+ }
+
+ setIsSaving(true)
+ setError(null)
+
+ try {
+ const response = await fetch(
+ isCreate
+ ? `${API_URL}/admin/users`
+ : `${API_URL}/admin/users/${selectedUserId}`,
+ {
+ method: isCreate ? 'POST' : 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ credentials: 'include',
+ body: JSON.stringify(payload),
+ },
+ )
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => null)
+ throw new Error(errorData?.error ?? 'Benutzer konnte nicht gespeichert werden')
+ }
+
+ await loadAdministration()
+ form.reset()
+ setSelectedUserId('new')
+ setSelectedRights([])
+ } catch (error) {
+ setError(
+ error instanceof Error
+ ? error.message
+ : 'Benutzer konnte nicht gespeichert werden',
+ )
+ } finally {
+ setIsSaving(false)
+ }
+ }
+
+ async function handleSaveTeam(event: FormEvent) {
+ event.preventDefault()
+
+ const form = event.currentTarget
+ const formData = new FormData(form)
+
+ const payload = {
+ name: String(formData.get('teamName') ?? ''),
+ initial: String(formData.get('teamInitial') ?? ''),
+ href: String(formData.get('teamHref') ?? '#'),
+ }
+
+ setIsSaving(true)
+ setError(null)
+
+ try {
+ const response = await fetch(`${API_URL}/admin/teams`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ credentials: 'include',
+ body: JSON.stringify(payload),
+ })
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => null)
+ throw new Error(errorData?.error ?? 'Team konnte nicht erstellt werden')
+ }
+
+ await loadAdministration()
+ form.reset()
+ } catch (error) {
+ setError(
+ error instanceof Error
+ ? error.message
+ : 'Team konnte nicht erstellt werden',
+ )
+ } finally {
+ setIsSaving(false)
+ }
+ }
+
+ if (isLoading) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+ Administration
+
+
+
+
+
+
+ Administration
+
+
+
+ Benutzer verwalten, Teams zuweisen und Seitenrechte vergeben.
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {activeTab === 'benutzer' && (
+
+
+
+
+
+
+
+ Benutzer
+
+
+
+ {filteredUsers.length} von {users.length} angezeigt
+
+
+
+ }
+ >
+ Neuer Benutzer
+
+
+
+
+
+
+ setUserSearch(event.target.value)}
+ placeholder="Benutzer suchen..."
+ className="block w-full rounded-md bg-white py-1.5 pr-3 pl-9 text-sm text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500"
+ />
+
+
+
+
+ {selectedUserId === 'new' && (
+
+ Neuer Benutzer wird angelegt
+
+ )}
+
+ {filteredUsers.length === 0 ? (
+
+ Keine Benutzer gefunden.
+
+ ) : (
+
+ {filteredUsers.map((user) => {
+ const isSelected = selectedUserId === user.id
+ const displayName = getUserDisplayName(user)
+
+ return (
+
+ )
+ })}
+
+ )}
+
+
+
+
+
+ )}
+
+ {activeTab === 'teams' && (
+
+
+
+ Teams
+
+
+
+ Übersicht der aktuell vorhandenen Teams.
+
+
+
+ {teams.length === 0 ? (
+
+ Keine Teams vorhanden.
+
+ ) : (
+ teams.map((team) => (
+
+
+
+ {team.initial}
+
+
+
+
+ {team.name}
+
+
+
+ {team.href}
+
+
+
+
+ ))
+ )}
+
+
+
+
+
+ )}
+
+ {activeTab === 'milestone' && (
+
+ )}
+
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/pages/administration/Milestone.tsx b/frontend/src/pages/administration/Milestone.tsx
new file mode 100644
index 0000000..d5ff1d9
--- /dev/null
+++ b/frontend/src/pages/administration/Milestone.tsx
@@ -0,0 +1,458 @@
+// frontend/src/pages/administration/Milestone.tsx
+
+import { useEffect, useState, type FormEvent } from 'react'
+import {
+ KeyIcon,
+ ServerStackIcon,
+ VideoCameraIcon,
+} from '@heroicons/react/20/solid'
+import Button from '../../components/Button'
+import LoadingSpinner from '../../components/LoadingSpinner'
+import Switch from '../../components/Switch'
+
+const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
+
+type MilestoneSettings = {
+ host: string
+ username: string
+ passwordConfigured: boolean
+ skipTlsVerify: boolean
+ tokenConfigured: boolean
+ tokenType: string
+ tokenExpiresAt?: string
+ tokenUpdatedAt?: string
+ serverId: string
+ serverName: string
+ serverVersion: string
+ serverUpdatedAt?: string
+ updatedAt?: string
+}
+
+const inputClassName =
+ 'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
+
+async function readApiError(response: Response, fallback: string) {
+ const data = await response.json().catch(() => null)
+
+ return data?.error ?? fallback
+}
+
+function formatUpdatedAt(value?: string) {
+ if (!value) {
+ return null
+ }
+
+ try {
+ return new Intl.DateTimeFormat('de-DE', {
+ dateStyle: 'short',
+ timeStyle: 'short',
+ }).format(new Date(value))
+ } catch {
+ return value
+ }
+}
+
+export default function Milestone() {
+ const [settings, setSettings] = useState(null)
+ const [isLoading, setIsLoading] = useState(true)
+ const [isSaving, setIsSaving] = useState(false)
+ const [error, setError] = useState(null)
+ const [success, setSuccess] = useState(null)
+ const [isFetchingToken, setIsFetchingToken] = useState(false)
+ const [skipTlsVerify, setSkipTlsVerify] = useState(false)
+
+ useEffect(() => {
+ void loadSettings()
+ }, [])
+
+ async function loadSettings() {
+ setIsLoading(true)
+ setError(null)
+
+ try {
+ const response = await fetch(`${API_URL}/admin/milestone`, {
+ credentials: 'include',
+ })
+
+ if (!response.ok) {
+ throw new Error(
+ await readApiError(
+ response,
+ 'Milestone-Einstellungen konnten nicht geladen werden',
+ ),
+ )
+ }
+
+ const data = await response.json()
+ setSettings(data.settings)
+ setSkipTlsVerify(Boolean(data.settings?.skipTlsVerify))
+ } catch (error) {
+ setError(
+ error instanceof Error
+ ? error.message
+ : 'Milestone-Einstellungen konnten nicht geladen werden',
+ )
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ async function handleSubmit(event: FormEvent) {
+ event.preventDefault()
+
+ const form = event.currentTarget
+ const formData = new FormData(form)
+
+ setIsSaving(true)
+ setError(null)
+ setSuccess(null)
+
+ try {
+ const response = await fetch(`${API_URL}/admin/milestone`, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ credentials: 'include',
+ body: JSON.stringify({
+ host: String(formData.get('host') ?? ''),
+ username: String(formData.get('username') ?? ''),
+ password: String(formData.get('password') ?? ''),
+ skipTlsVerify,
+ }),
+ })
+
+ if (!response.ok) {
+ throw new Error(
+ await readApiError(
+ response,
+ 'Milestone-Einstellungen konnten nicht gespeichert werden',
+ ),
+ )
+ }
+
+ const data = await response.json()
+ setSettings(data.settings)
+ form.reset()
+
+ if (data.warning) {
+ setSuccess(data.warning)
+ } else {
+ setSuccess('Milestone-Einstellungen wurden gespeichert, Token und Recording-Server wurden abgerufen.')
+ }
+ } catch (error) {
+ setError(
+ error instanceof Error
+ ? error.message
+ : 'Milestone-Einstellungen konnten nicht gespeichert werden',
+ )
+ } finally {
+ setIsSaving(false)
+ }
+ }
+
+ async function handleFetchToken() {
+ setIsFetchingToken(true)
+ setError(null)
+ setSuccess(null)
+
+ try {
+ const response = await fetch(`${API_URL}/admin/milestone/token`, {
+ method: 'POST',
+ credentials: 'include',
+ })
+
+ if (!response.ok) {
+ throw new Error(
+ await readApiError(
+ response,
+ 'Milestone-Token konnte nicht abgerufen werden',
+ ),
+ )
+ }
+
+ const data = await response.json()
+ setSettings(data.settings)
+ setSuccess('Milestone-Token wurde abgerufen und gespeichert.')
+ } catch (error) {
+ setError(
+ error instanceof Error
+ ? error.message
+ : 'Milestone-Token konnte nicht abgerufen werden',
+ )
+ } finally {
+ setIsFetchingToken(false)
+ }
+ }
+
+ if (isLoading) {
+ return (
+
+
+
+ )
+ }
+
+ const updatedAt = formatUpdatedAt(settings?.updatedAt)
+
+ return (
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/pages/devices/CreateDeviceModal.tsx b/frontend/src/pages/devices/CreateDeviceModal.tsx
new file mode 100644
index 0000000..7738cbc
--- /dev/null
+++ b/frontend/src/pages/devices/CreateDeviceModal.tsx
@@ -0,0 +1,156 @@
+// frontend\src\pages\devices\CreateDeviceModal.tsx
+
+import { useState, type FormEvent } from 'react'
+import { DialogTitle } from '@headlessui/react'
+import { XMarkIcon } from '@heroicons/react/24/outline'
+import { CheckIcon } from '@heroicons/react/20/solid'
+import Modal from '../../components/Modal'
+import Button from '../../components/Button'
+import type { Device } from '../../components/types'
+import DeviceFormFields from './DeviceFormFields'
+import DeviceModalTabs, { type DeviceModalTabId } from './DeviceModalTabs'
+
+type CreateDeviceModalProps = {
+ open: boolean
+ setOpen: (open: boolean) => void
+ isSaving: boolean
+ error: string | null
+ devices: Device[]
+ relatedDeviceIds: string[]
+ onToggleRelatedDevice: (deviceId: string) => void
+ onSubmit: (event: FormEvent) => void
+}
+
+const createDeviceTabs: Array<{ id: DeviceModalTabId; name: string }> = [
+ { id: 'masterData', name: 'Stammdaten' },
+ { id: 'location', name: 'Standort' },
+ { id: 'accessories', name: 'Zubehör' },
+]
+
+export default function CreateDeviceModal({
+ open,
+ setOpen,
+ isSaving,
+ error,
+ devices,
+ relatedDeviceIds,
+ onToggleRelatedDevice,
+ onSubmit,
+}: CreateDeviceModalProps) {
+ const [activeTab, setActiveTab] = useState('masterData')
+
+ function handleOpenChange(nextOpen: boolean) {
+ if (!nextOpen && isSaving) {
+ return
+ }
+
+ setOpen(nextOpen)
+
+ if (!nextOpen) {
+ setActiveTab('masterData')
+ }
+ }
+
+ function handleInvalid(event: FormEvent) {
+ const target = event.target as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
+
+ if (
+ ['inventoryNumber', 'manufacturer', 'model', 'serialNumber', 'macAddress', 'loanStatus'].includes(
+ target.name,
+ )
+ ) {
+ setActiveTab('masterData')
+ return
+ }
+
+ if (['location', 'comment', 'isBaoDevice'].includes(target.name)) {
+ setActiveTab('location')
+ return
+ }
+
+ setActiveTab('accessories')
+ }
+
+ return (
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/pages/devices/DeviceFormFields.tsx b/frontend/src/pages/devices/DeviceFormFields.tsx
new file mode 100644
index 0000000..202a07f
--- /dev/null
+++ b/frontend/src/pages/devices/DeviceFormFields.tsx
@@ -0,0 +1,563 @@
+// frontend\src\pages\devices\DeviceFormFields.tsx
+
+import { useEffect, useMemo, useState, type FormEvent } from 'react'
+import Combobox, { type ComboboxItem } from '../../components/Combobox'
+import Checkbox from '../../components/Checkbox'
+import Switch from '../../components/Switch'
+import type { DeviceModalTabId } from './DeviceModalTabs'
+import type { Device } from '../../components/types'
+
+const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
+
+type DeviceFormFieldsProps = {
+ open: boolean
+ activeTab: DeviceModalTabId
+ device?: Device | null
+ devices: Device[]
+ relatedDeviceIds: string[]
+ onToggleRelatedDevice: (deviceId: string) => void
+}
+
+export const inputClassName =
+ 'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
+
+export const selectClassName =
+ 'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:*:bg-gray-800 dark:focus:outline-indigo-500'
+
+export const sectionClassName =
+ 'rounded-lg border border-gray-200 bg-white p-4 shadow-xs dark:border-white/10 dark:bg-white/5'
+
+function classNames(...classes: Array) {
+ return classes.filter(Boolean).join(' ')
+}
+
+export function SectionHeader({
+ title,
+ description,
+}: {
+ title: string
+ description?: string
+}) {
+ return (
+
+
+ {title}
+
+
+ {description && (
+
+ {description}
+
+ )}
+
+ )
+}
+
+function formatMacAddress(value: string) {
+ const hexValue = value
+ .replace(/[^0-9a-fA-F]/g, '')
+ .slice(0, 12)
+ .toUpperCase()
+
+ return hexValue.match(/.{1,2}/g)?.join(':') ?? ''
+}
+
+function handleMacAddressInput(event: FormEvent) {
+ const input = event.currentTarget
+
+ input.setCustomValidity('')
+ input.value = formatMacAddress(input.value)
+}
+
+function handleMacAddressInvalid(event: FormEvent) {
+ event.currentTarget.setCustomValidity(
+ 'Bitte gib eine gültige MAC-Adresse im Format 00:11:22:33:44:55 ein.',
+ )
+}
+
+async function createLookupOption(
+ endpoint: 'device-manufacturers' | 'device-locations',
+ name: string,
+) {
+ const response = await fetch(`${API_URL}/${endpoint}`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ credentials: 'include',
+ body: JSON.stringify({ name }),
+ })
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => null)
+ throw new Error(errorData?.error ?? 'Eintrag konnte nicht gespeichert werden')
+ }
+
+ const data = await response.json()
+ return data.option as ComboboxItem
+}
+
+function getDeviceAccessorySearchText(device: Device) {
+ return [
+ device.inventoryNumber,
+ device.manufacturer,
+ device.model,
+ device.serialNumber,
+ device.macAddress,
+ device.location,
+ device.loanStatus,
+ device.comment,
+ ]
+ .filter(Boolean)
+ .join(' ')
+ .toLowerCase()
+}
+
+export default function DeviceFormFields({
+ open,
+ activeTab,
+ device,
+ devices,
+ relatedDeviceIds,
+ onToggleRelatedDevice,
+}: DeviceFormFieldsProps) {
+ const assignableDevices = useMemo(
+ () => devices.filter((item) => item.id !== device?.id),
+ [devices, device?.id],
+ )
+
+ const [accessorySearch, setAccessorySearch] = useState('')
+
+ const [manufacturerOptions, setManufacturerOptions] = useState([])
+ const [locationOptions, setLocationOptions] = useState([])
+ const [selectedManufacturer, setSelectedManufacturer] = useState(null)
+ const [selectedLocation, setSelectedLocation] = useState(null)
+ const [lookupError, setLookupError] = useState(null)
+
+ useEffect(() => {
+ if (!open) {
+ return
+ }
+
+ setSelectedManufacturer(
+ device?.manufacturer
+ ? {
+ id: device.manufacturer,
+ name: device.manufacturer,
+ }
+ : null,
+ )
+
+ setSelectedLocation(
+ device?.location
+ ? {
+ id: device.location,
+ name: device.location,
+ }
+ : null,
+ )
+
+ void loadDeviceLookups()
+ }, [open, device?.id])
+
+ useEffect(() => {
+ if (!open) {
+ return
+ }
+
+ setAccessorySearch('')
+ }, [open, device?.id])
+
+ const filteredAssignableDevices = useMemo(() => {
+ const search = accessorySearch.trim().toLowerCase()
+
+ if (!search) {
+ return assignableDevices
+ }
+
+ return assignableDevices.filter((assignableDevice) =>
+ getDeviceAccessorySearchText(assignableDevice).includes(search),
+ )
+ }, [accessorySearch, assignableDevices])
+
+ const selectedAccessoryCount = assignableDevices.filter((assignableDevice) =>
+ relatedDeviceIds.includes(assignableDevice.id),
+ ).length
+
+ async function loadDeviceLookups() {
+ setLookupError(null)
+
+ try {
+ const response = await fetch(`${API_URL}/device-lookups`, {
+ credentials: 'include',
+ })
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => null)
+ throw new Error(errorData?.error ?? 'Auswahllisten konnten nicht geladen werden')
+ }
+
+ const data = await response.json()
+
+ setManufacturerOptions(data.manufacturers ?? [])
+ setLocationOptions(data.locations ?? [])
+ } catch (error) {
+ setLookupError(
+ error instanceof Error
+ ? error.message
+ : 'Auswahllisten konnten nicht geladen werden',
+ )
+ }
+ }
+
+ async function handleManufacturerChange(item: ComboboxItem | null) {
+ setLookupError(null)
+
+ if (!item) {
+ setSelectedManufacturer(null)
+ return
+ }
+
+ if (item.id !== null) {
+ setSelectedManufacturer(item)
+ return
+ }
+
+ try {
+ const createdOption = await createLookupOption('device-manufacturers', item.name)
+
+ setManufacturerOptions((currentOptions) => [
+ createdOption,
+ ...currentOptions.filter((option) => option.id !== createdOption.id),
+ ])
+
+ setSelectedManufacturer(createdOption)
+ } catch (error) {
+ setLookupError(
+ error instanceof Error
+ ? error.message
+ : 'Hersteller konnte nicht gespeichert werden',
+ )
+ }
+ }
+
+ async function handleLocationChange(item: ComboboxItem | null) {
+ setLookupError(null)
+
+ if (!item) {
+ setSelectedLocation(null)
+ return
+ }
+
+ if (item.id !== null) {
+ setSelectedLocation(item)
+ return
+ }
+
+ try {
+ const createdOption = await createLookupOption('device-locations', item.name)
+
+ setLocationOptions((currentOptions) => [
+ createdOption,
+ ...currentOptions.filter((option) => option.id !== createdOption.id),
+ ])
+
+ setSelectedLocation(createdOption)
+ } catch (error) {
+ setLookupError(
+ error instanceof Error
+ ? error.message
+ : 'Standort konnte nicht gespeichert werden',
+ )
+ }
+ }
+
+ return (
+
+ {lookupError && (
+
+ {lookupError}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `"${value}" hinzufügen`}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `"${value}" hinzufügen`}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ setAccessorySearch(event.target.value)}
+ placeholder="Inventur-Nr., Hersteller, Modell, Standort..."
+ className={inputClassName}
+ />
+
+
+
+
+
+ {filteredAssignableDevices.length} von {assignableDevices.length} Geräten angezeigt
+
+
+
+ {selectedAccessoryCount} ausgewählt
+
+
+
+
+ {assignableDevices.length === 0 ? (
+
+ Noch keine anderen Geräte vorhanden.
+
+ ) : filteredAssignableDevices.length > 0 ? (
+
+ {filteredAssignableDevices.map((assignableDevice) => (
+ onToggleRelatedDevice(assignableDevice.id)}
+ label={
+
+ {assignableDevice.inventoryNumber}
+ {(assignableDevice.manufacturer || assignableDevice.model) && (
+
+ {' '}
+ — {[assignableDevice.manufacturer, assignableDevice.model]
+ .filter(Boolean)
+ .join(' ')}
+
+ )}
+
+ }
+ description={
+ [assignableDevice.location, assignableDevice.loanStatus]
+ .filter(Boolean)
+ .join(' · ') || undefined
+ }
+ />
+ ))}
+
+ ) : (
+
+ Keine Geräte passend zur Suche gefunden.
+
+ )}
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/pages/devices/DeviceModalTabs.tsx b/frontend/src/pages/devices/DeviceModalTabs.tsx
new file mode 100644
index 0000000..f2986fb
--- /dev/null
+++ b/frontend/src/pages/devices/DeviceModalTabs.tsx
@@ -0,0 +1,53 @@
+// frontend\src\pages\devices\DeviceModalTabs.tsx
+
+export type DeviceModalTabId = 'masterData' | 'location' | 'accessories' | 'history'
+
+type DeviceModalTab = {
+ id: DeviceModalTabId
+ name: string
+ disabled?: boolean
+}
+
+type DeviceModalTabsProps = {
+ tabs: DeviceModalTab[]
+ activeTab: DeviceModalTabId
+ onChange: (tabId: DeviceModalTabId) => void
+}
+
+function classNames(...classes: Array) {
+ return classes.filter(Boolean).join(' ')
+}
+
+export default function DeviceModalTabs({
+ tabs,
+ activeTab,
+ onChange,
+}: DeviceModalTabsProps) {
+ return (
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/pages/DevicesPage.tsx b/frontend/src/pages/devices/DevicesPage.tsx
similarity index 66%
rename from frontend/src/pages/DevicesPage.tsx
rename to frontend/src/pages/devices/DevicesPage.tsx
index c7a96fd..e89b059 100644
--- a/frontend/src/pages/DevicesPage.tsx
+++ b/frontend/src/pages/devices/DevicesPage.tsx
@@ -1,9 +1,13 @@
// frontend/src/pages/DevicesPage.tsx
-import { useEffect, useState, type FormEvent } from 'react'
-import DeviceModal from '../components/DeviceModal'
-import Tables, { type TableColumn } from '../components/Tables'
-import type { Device, DeviceHistoryEntry } from '../components/types'
+import { useEffect, useRef, useState, type FormEvent } from 'react'
+import { CheckCircleIcon, PencilSquareIcon, XCircleIcon } from '@heroicons/react/20/solid'
+import CreateDeviceModal from './CreateDeviceModal'
+import EditDeviceModal from './EditDeviceModal'
+import Tables, { type TableColumn } from '../../components/Tables'
+import LoadingSpinner from '../../components/LoadingSpinner'
+import Button from '../../components/Button'
+import type { Device, DeviceHistoryEntry } from '../../components/types'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
@@ -45,15 +49,81 @@ export default function DevicesPage() {
const [createError, setCreateError] = useState(null)
const [relatedDeviceIds, setRelatedDeviceIds] = useState([])
const [editingDevice, setEditingDevice] = useState(null)
+ const [isJournalSubmitting, setIsJournalSubmitting] = useState(false)
const [deviceHistory, setDeviceHistory] = useState([])
const [isHistoryLoading, setIsHistoryLoading] = useState(false)
const [historyError, setHistoryError] = useState(null)
+ const closeCleanupTimeoutRef = useRef(null)
useEffect(() => {
loadDevices()
+
+ return () => {
+ if (closeCleanupTimeoutRef.current) {
+ window.clearTimeout(closeCleanupTimeoutRef.current)
+ }
+ }
}, [])
+ useEffect(() => {
+ function handleDeviceNotification(event: Event) {
+ void loadDevices()
+
+ const notification = (event as CustomEvent).detail as {
+ entityId?: string
+ entityID?: string
+ }
+
+ const notificationDeviceId = notification.entityId ?? notification.entityID
+
+ if (editingDevice && notificationDeviceId === editingDevice.id) {
+ void loadDeviceHistory(editingDevice.id)
+ }
+ }
+
+ window.addEventListener('device-notification', handleDeviceNotification)
+
+ return () => {
+ window.removeEventListener('device-notification', handleDeviceNotification)
+ }
+ }, [editingDevice])
+
+ async function handleCreateDeviceJournalEntry(content: string) {
+ if (!editingDevice) {
+ return
+ }
+
+ setIsJournalSubmitting(true)
+ setHistoryError(null)
+
+ try {
+ const response = await fetch(`${API_URL}/devices/${editingDevice.id}/history`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ credentials: 'include',
+ body: JSON.stringify({ content }),
+ })
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => null)
+ throw new Error(errorData?.error ?? 'Journal-Eintrag konnte nicht gespeichert werden')
+ }
+
+ await loadDeviceHistory(editingDevice.id)
+ } catch (error) {
+ setHistoryError(
+ error instanceof Error
+ ? error.message
+ : 'Journal-Eintrag konnte nicht gespeichert werden',
+ )
+ } finally {
+ setIsJournalSubmitting(false)
+ }
+ }
+
async function loadDevices() {
setIsLoading(true)
setError(null)
@@ -105,9 +175,17 @@ export default function DevicesPage() {
}
function openCreateModal() {
+ if (closeCleanupTimeoutRef.current) {
+ window.clearTimeout(closeCleanupTimeoutRef.current)
+ closeCleanupTimeoutRef.current = null
+ }
+
setCreateError(null)
setEditingDevice(null)
setRelatedDeviceIds([])
+ setDeviceHistory([])
+ setHistoryError(null)
+ setIsHistoryLoading(false)
setIsCreateModalOpen(true)
}
@@ -117,11 +195,20 @@ export default function DevicesPage() {
}
setIsCreateModalOpen(false)
- setEditingDevice(null)
- setRelatedDeviceIds([])
- setDeviceHistory([])
- setHistoryError(null)
- setIsHistoryLoading(false)
+
+ if (closeCleanupTimeoutRef.current) {
+ window.clearTimeout(closeCleanupTimeoutRef.current)
+ }
+
+ closeCleanupTimeoutRef.current = window.setTimeout(() => {
+ setEditingDevice(null)
+ setRelatedDeviceIds([])
+ setDeviceHistory([])
+ setHistoryError(null)
+ setIsHistoryLoading(false)
+ setCreateError(null)
+ closeCleanupTimeoutRef.current = null
+ }, 220)
}
function openEditModal(device: Device) {
@@ -164,6 +251,7 @@ export default function DevicesPage() {
location: String(formData.get('location') ?? ''),
loanStatus: String(formData.get('loanStatus') ?? 'Verfügbar'),
comment: String(formData.get('comment') ?? ''),
+ isBaoDevice: formData.get('isBaoDevice') === 'on',
relatedDeviceIds,
}
@@ -194,9 +282,7 @@ export default function DevicesPage() {
}
form.reset()
- setRelatedDeviceIds([])
- setEditingDevice(null)
- setIsCreateModalOpen(false)
+ closeCreateModal()
await loadDevices()
} catch (error) {
@@ -260,6 +346,22 @@ export default function DevicesPage() {
),
},
+ {
+ key: 'isBaoDevice',
+ header: 'BAO',
+ render: (device) =>
+ device.isBaoDevice ? (
+
+ ) : (
+
+ ),
+ },
{
key: 'relatedDevices',
header: 'Zubehör',
@@ -305,31 +407,41 @@ export default function DevicesPage() {
device.id}
variant="card"
- emptyText={isLoading ? 'Geräte werden geladen...' : 'Keine Geräte vorhanden.'}
+ emptyText="Keine Geräte vorhanden."
+ isLoading={isLoading}
+ loadingContent={
+
+ }
rowActions={(device) => (
-
+
)}
/>
- {
if (open) {
setIsCreateModalOpen(true)
@@ -341,13 +453,33 @@ export default function DevicesPage() {
isSaving={isCreating}
error={createError}
devices={devices}
- editingDevice={editingDevice}
+ relatedDeviceIds={relatedDeviceIds}
+ onToggleRelatedDevice={toggleRelatedDevice}
+ onSubmit={handleSaveDevice}
+ />
+
+ {
+ if (open) {
+ setIsCreateModalOpen(true)
+ return
+ }
+
+ closeCreateModal()
+ }}
+ isSaving={isCreating}
+ error={createError}
+ device={editingDevice}
+ devices={devices}
relatedDeviceIds={relatedDeviceIds}
onToggleRelatedDevice={toggleRelatedDevice}
onSubmit={handleSaveDevice}
deviceHistory={deviceHistory}
isHistoryLoading={isHistoryLoading}
historyError={historyError}
+ isJournalSubmitting={isJournalSubmitting}
+ onJournalEntrySubmit={handleCreateDeviceJournalEntry}
/>
diff --git a/frontend/src/pages/devices/EditDeviceModal.tsx b/frontend/src/pages/devices/EditDeviceModal.tsx
new file mode 100644
index 0000000..12c4591
--- /dev/null
+++ b/frontend/src/pages/devices/EditDeviceModal.tsx
@@ -0,0 +1,296 @@
+// frontend\src\pages\devices\EditDeviceModal.tsx
+
+// frontend/src/pages/devices/EditDeviceModal.tsx
+
+import { useState, type ComponentProps, type FormEvent } from 'react'
+import { DialogTitle } from '@headlessui/react'
+import { XMarkIcon } from '@heroicons/react/24/outline'
+import {
+ ChatBubbleLeftEllipsisIcon,
+ CheckIcon,
+ PencilSquareIcon,
+ PlusCircleIcon,
+} from '@heroicons/react/20/solid'
+import Modal from '../../components/Modal'
+import Journal from '../../components/Journal'
+import Button from '../../components/Button'
+import LoadingSpinner from '../../components/LoadingSpinner'
+import type {
+ Device,
+ DeviceHistoryChange,
+ DeviceHistoryEntry,
+} from '../../components/types'
+import DeviceFormFields, {
+ SectionHeader,
+ sectionClassName,
+} from './DeviceFormFields'
+import DeviceModalTabs, { type DeviceModalTabId } from './DeviceModalTabs'
+
+type EditDeviceModalProps = {
+ open: boolean
+ setOpen: (open: boolean) => void
+ isSaving: boolean
+ error: string | null
+ device: Device | null
+ devices: Device[]
+ relatedDeviceIds: string[]
+ onToggleRelatedDevice: (deviceId: string) => void
+ onSubmit: (event: FormEvent ) => void
+ deviceHistory: DeviceHistoryEntry[]
+ isHistoryLoading: boolean
+ historyError: string | null
+ isJournalSubmitting?: boolean
+ onJournalEntrySubmit?: (content: string) => void | Promise
+}
+
+type JournalItems = ComponentProps['items']
+
+const editDeviceTabs: Array<{ id: DeviceModalTabId; name: string }> = [
+ { id: 'masterData', name: 'Stammdaten' },
+ { id: 'location', name: 'Standort' },
+ { id: 'accessories', name: 'Zubehör' },
+ { id: 'history', name: 'Verlauf' },
+]
+
+function formatHistoryDate(value: string) {
+ try {
+ return new Intl.DateTimeFormat('de-DE', {
+ dateStyle: 'short',
+ timeStyle: 'short',
+ }).format(new Date(value))
+ } catch {
+ return value
+ }
+}
+
+function formatHistoryChange(change: DeviceHistoryChange) {
+ const oldValue = change.oldValue || '—'
+ const newValue = change.newValue || '—'
+
+ return `${change.label}: ${oldValue} → ${newValue}`
+}
+
+function getTabForInvalidField(fieldName: string): DeviceModalTabId {
+ if (
+ [
+ 'inventoryNumber',
+ 'manufacturer',
+ 'model',
+ 'serialNumber',
+ 'macAddress',
+ 'loanStatus',
+ ].includes(fieldName)
+ ) {
+ return 'masterData'
+ }
+
+ if (['location', 'comment', 'isBaoDevice'].includes(fieldName)) {
+ return 'location'
+ }
+
+ return 'accessories'
+}
+
+function classNames(...classes: Array) {
+ return classes.filter(Boolean).join(' ')
+}
+
+export default function EditDeviceModal({
+ open,
+ setOpen,
+ isSaving,
+ error,
+ device,
+ devices,
+ relatedDeviceIds,
+ onToggleRelatedDevice,
+ onSubmit,
+ deviceHistory,
+ isHistoryLoading,
+ historyError,
+ isJournalSubmitting = false,
+ onJournalEntrySubmit
+}: EditDeviceModalProps) {
+ const [activeTab, setActiveTab] = useState('masterData')
+
+ function handleOpenChange(nextOpen: boolean) {
+ if (!nextOpen && isSaving) {
+ return
+ }
+
+ setOpen(nextOpen)
+
+ if (!nextOpen) {
+ setActiveTab('masterData')
+ }
+ }
+
+ function handleInvalid(event: FormEvent) {
+ const target = event.target as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
+
+ setActiveTab(getTabForInvalidField(target.name))
+ }
+
+ if (!device) {
+ return null
+ }
+
+ const historyFeedItems: JournalItems = deviceHistory.map((entry) => {
+ const journalChange = entry.changes.find((change) => change.field === 'journal')
+ const isJournalEntry = entry.action === 'journal' || journalChange !== undefined
+
+ return {
+ id: entry.id,
+ type: isJournalEntry
+ ? ('journal' as const)
+ : entry.action === 'created'
+ ? ('created' as const)
+ : ('edited' as const),
+ person: {
+ name: entry.userDisplayName || 'Unbekannt',
+ },
+ content: isJournalEntry
+ ? journalChange?.newValue || 'hat einen Journal-Eintrag hinzugefügt.'
+ : entry.action === 'created'
+ ? 'hat das Gerät erstellt.'
+ : entry.changes.length > 0
+ ? `hat ${entry.changes.map(formatHistoryChange).join(', ')} geändert.`
+ : 'hat das Gerät bearbeitet.',
+ date: formatHistoryDate(entry.createdAt),
+ datetime: entry.createdAt,
+ icon: isJournalEntry
+ ? ChatBubbleLeftEllipsisIcon
+ : entry.action === 'created'
+ ? PlusCircleIcon
+ : PencilSquareIcon,
+ iconBackground: isJournalEntry
+ ? 'bg-indigo-500'
+ : entry.action === 'created'
+ ? 'bg-green-500'
+ : 'bg-blue-500',
+ }
+ })
+
+ return (
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/pages/operations/CreateOperationModal.tsx b/frontend/src/pages/operations/CreateOperationModal.tsx
new file mode 100644
index 0000000..812f578
--- /dev/null
+++ b/frontend/src/pages/operations/CreateOperationModal.tsx
@@ -0,0 +1,727 @@
+// frontend\src\pages\operations\CreateOperationModal.tsx
+
+import { useEffect, useState, type FormEvent, type MouseEvent } from 'react'
+import { DialogTitle } from '@headlessui/react'
+import { XMarkIcon } from '@heroicons/react/24/outline'
+import {
+ CheckIcon,
+ ChevronLeftIcon,
+ ChevronRightIcon,
+} from '@heroicons/react/20/solid'
+import Modal from '../../components/Modal'
+import Button from '../../components/Button'
+import AddressCombobox from '../../components/AddressCombobox'
+import { formatOperationNumberInput, isCompleteOperationNumber } from '../../components/formatter'
+
+type CreateOperationModalProps = {
+ open: boolean
+ setOpen: (open: boolean) => void
+ isSaving: boolean
+ error: string | null
+ onSubmit: (event: FormEvent) => void
+}
+
+type OperationFormValues = {
+ operationNumber: string
+ operationName: string
+ offense: string
+ caseWorker: string
+ targetObject: string
+ kwAddress: string
+ operationLeader: string
+ operationTeam: string
+ legend: string
+ camera: string
+ router: string
+ technology: string
+ switchbox: string
+ hardDrive: string
+ laptop: string
+ remark: string
+}
+
+type SetupStepId =
+ | 'masterData'
+ | 'addresses'
+ | 'responsibilities'
+ | 'notes'
+ | 'equipment'
+ | 'review'
+
+const inputClassName =
+ 'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
+
+const textareaClassName =
+ 'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
+
+const initialFormValues: OperationFormValues = {
+ operationNumber: '',
+ operationName: '',
+ offense: '',
+ caseWorker: '',
+ targetObject: '',
+ kwAddress: '',
+ operationLeader: '',
+ operationTeam: '',
+ legend: '',
+ camera: '',
+ router: '',
+ technology: '',
+ switchbox: '',
+ hardDrive: '',
+ laptop: '',
+ remark: '',
+}
+
+const setupSteps: Array<{
+ id: SetupStepId
+ title: string
+ description: string
+ optional?: boolean
+}> = [
+ {
+ id: 'masterData',
+ title: 'Stammdaten',
+ description: 'Einsatznummer, Name und Delikt.',
+ },
+ {
+ id: 'addresses',
+ title: 'Adressen',
+ description: 'KW-Adresse und Zielobjekt mit Kartenvorschau.',
+ optional: true,
+ },
+ {
+ id: 'responsibilities',
+ title: 'Zuständigkeiten',
+ description: 'Sachbearbeitung, Einsatzleitung und Einsatzteam.',
+ optional: true,
+ },
+ {
+ id: 'notes',
+ title: 'Zusatzinfos',
+ description: 'Legende und Bemerkung.',
+ optional: true,
+ },
+ {
+ id: 'equipment',
+ title: 'Technik',
+ description: 'Kamera, Router, Switchbox und Laptop.',
+ optional: true,
+ },
+ {
+ id: 'review',
+ title: 'Prüfen',
+ description: 'Daten prüfen und Einsatz speichern.',
+ },
+]
+
+function classNames(...classes: Array) {
+ return classes.filter(Boolean).join(' ')
+}
+
+function getStepIndex(stepId: SetupStepId) {
+ return setupSteps.findIndex((step) => step.id === stepId)
+}
+
+function getDisplayValue(value: string) {
+ return value.trim() || '—'
+}
+
+function ReviewItem({
+ label,
+ value,
+}: {
+ label: string
+ value: string
+}) {
+ return (
+
+ -
+ {label}
+
+ -
+ {getDisplayValue(value)}
+
+
+ )
+}
+
+function StepHeader({
+ title,
+ description,
+}: {
+ title: string
+ description: string
+}) {
+ return (
+
+
+ {title}
+
+
+ {description}
+
+
+ )
+}
+
+export default function CreateOperationModal({
+ open,
+ setOpen,
+ isSaving,
+ error,
+ onSubmit,
+}: CreateOperationModalProps) {
+ const [activeStep, setActiveStep] = useState('masterData')
+ const [formValues, setFormValues] = useState(initialFormValues)
+ const [localError, setLocalError] = useState(null)
+
+ const activeStepIndex = getStepIndex(activeStep)
+ const isFirstStep = activeStepIndex === 0
+ const isLastStep = activeStepIndex === setupSteps.length - 1
+
+ useEffect(() => {
+ if (!open) {
+ return
+ }
+
+ setActiveStep('masterData')
+ setFormValues(initialFormValues)
+ setLocalError(null)
+ }, [open])
+
+ function handleOpenChange(nextOpen: boolean) {
+ if (!nextOpen && isSaving) {
+ return
+ }
+
+ setOpen(nextOpen)
+ }
+
+ function updateField(field: keyof OperationFormValues, value: string) {
+ const nextValue =
+ field === 'operationNumber'
+ ? formatOperationNumberInput(value)
+ : value
+
+ setFormValues((currentValues) => ({
+ ...currentValues,
+ [field]: nextValue,
+ }))
+
+ if (localError) {
+ setLocalError(null)
+ }
+ }
+
+ function goToStep(stepId: SetupStepId) {
+ setActiveStep(stepId)
+ }
+
+ function goToPreviousStep() {
+ if (isFirstStep) {
+ return
+ }
+
+ setActiveStep(setupSteps[activeStepIndex - 1].id)
+ }
+
+ function goToNextStep(event?: MouseEvent) {
+ event?.preventDefault()
+ event?.stopPropagation()
+
+ if (isLastStep) {
+ return
+ }
+
+ if (activeStep === 'masterData') {
+ if (formValues.operationNumber.trim() === '') {
+ setLocalError('Bitte zuerst eine Einsatznummer eintragen.')
+ return
+ }
+
+ if (!isCompleteOperationNumber(formValues.operationNumber)) {
+ setLocalError('Die Einsatznummer muss das Format 000-00-0000 haben.')
+ return
+ }
+ }
+
+ setActiveStep(setupSteps[activeStepIndex + 1].id)
+ }
+
+ function skipStep() {
+ if (isLastStep) {
+ return
+ }
+
+ setActiveStep(setupSteps[activeStepIndex + 1].id)
+ }
+
+ function handleSubmit(event: FormEvent) {
+ event.preventDefault()
+
+ if (activeStep !== 'review') {
+ goToNextStep()
+ return
+ }
+
+ if (formValues.operationNumber.trim() === '') {
+ setLocalError('Einsatznummer ist erforderlich.')
+ setActiveStep('masterData')
+ return
+ }
+
+ if (!isCompleteOperationNumber(formValues.operationNumber)) {
+ setLocalError('Die Einsatznummer muss das Format 000-00-0000 haben.')
+ setActiveStep('masterData')
+ return
+ }
+
+ onSubmit(event)
+ }
+
+ const combinedError = localError || error
+
+ return (
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/pages/operations/OperationMap.tsx b/frontend/src/pages/operations/OperationMap.tsx
new file mode 100644
index 0000000..d14151d
--- /dev/null
+++ b/frontend/src/pages/operations/OperationMap.tsx
@@ -0,0 +1,711 @@
+// frontend\src\pages\operations\OperationMap.tsx
+
+import { useEffect, useMemo, useState } from 'react'
+import { Polygon, Polyline } from 'react-leaflet'
+import * as L from 'leaflet'
+import { PencilSquareIcon } from '@heroicons/react/20/solid'
+import AppMap, { type MapHeatPoint, type MapMarker } from '../../components/Map'
+import Button from '../../components/Button'
+import LoadingSpinner from '../../components/LoadingSpinner'
+import type { Operation } from '../../components/types'
+
+const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
+
+type AddressSuggestion = {
+ id: string
+ label: string
+ lat: number
+ lon: number
+ type?: string
+}
+
+type OperationMarkerType = 'kwAddress' | 'targetObject'
+
+type OperationMapMode = 'markers' | 'heatmap'
+
+type OperationMarker = {
+ id: string
+ operation: Operation
+ type: OperationMarkerType
+ lat: number
+ lon: number
+ address: string
+ label: string
+ color: string
+}
+
+type OperationViewCone = {
+ id: string
+ operation: Operation
+ color: string
+ positions: Array<[number, number]>
+ centerLine: [[number, number], [number, number]]
+}
+
+type OperationMapProps = {
+ operations: Operation[]
+ isLoading: boolean
+ onEdit: (operation: Operation) => void
+ canEdit?: boolean
+ className?: string
+ mode?: 'markers' | 'heatmap'
+}
+
+function classNames(...classes: Array) {
+ return classes.filter(Boolean).join(' ')
+}
+
+const operationMarkerColors = [
+ '#4f46e5',
+ '#0891b2',
+ '#16a34a',
+ '#ca8a04',
+ '#dc2626',
+ '#9333ea',
+ '#ea580c',
+ '#0f766e',
+ '#be123c',
+ '#2563eb',
+]
+
+function getOperationColor(operationId: string) {
+ let hash = 0
+
+ for (let index = 0; index < operationId.length; index += 1) {
+ hash = operationId.charCodeAt(index) + ((hash << 5) - hash)
+ }
+
+ return operationMarkerColors[Math.abs(hash) % operationMarkerColors.length]
+}
+
+function getMarkerSvg(type: OperationMarkerType) {
+ if (type === 'kwAddress') {
+ return `
+
+ `
+ }
+
+ return `
+
+ `
+}
+
+function createPinIcon({
+ type,
+ background,
+}: {
+ type: OperationMarkerType
+ background: string
+}) {
+ return L.divIcon({
+ className: '',
+ iconSize: [34, 34],
+ iconAnchor: [17, 34],
+ popupAnchor: [0, -34],
+ html: `
+
+
+
+
+ ${getMarkerSvg(type)}
+
+
+ `,
+ })
+}
+
+function getMarkerIcon(marker: OperationMarker) {
+ return createPinIcon({
+ type: marker.type,
+ background: marker.color,
+ })
+}
+
+function getMarkerLabel(type: OperationMarkerType) {
+ return type === 'kwAddress' ? 'KW-Adresse' : 'Zielobjekt'
+}
+
+function formatShortAddress(value: string) {
+ const address = value.trim()
+
+ if (!address) {
+ return '-'
+ }
+
+ const parts = address
+ .split(',')
+ .map((part) => part.trim())
+ .filter(Boolean)
+ .filter(
+ (part) =>
+ !['Deutschland', 'Germany', 'Bundesrepublik Deutschland'].includes(part),
+ )
+
+ if (parts.length <= 3) {
+ return parts.join(', ')
+ }
+
+ const postcodeIndex = parts.findIndex((part) => /^\d{5}$/.test(part))
+
+ if (postcodeIndex > 0) {
+ const city = parts[postcodeIndex - 1]
+ const postcode = parts[postcodeIndex]
+
+ const street =
+ parts.length > 1 && /^\d+[a-zA-Z]?$/.test(parts[0])
+ ? `${parts[1]} ${parts[0]}`
+ : parts[0]
+
+ return `${street}, ${postcode} ${city}`
+ }
+
+ return parts.slice(0, 3).join(', ')
+}
+
+async function geocodeAddress(
+ address: string,
+ signal: AbortSignal,
+): Promise {
+ const response = await fetch(
+ `${API_URL}/address-search?q=${encodeURIComponent(address)}`,
+ {
+ credentials: 'include',
+ signal,
+ },
+ )
+
+ if (!response.ok) {
+ return null
+ }
+
+ const data = await response.json()
+ const suggestions = (data.suggestions ?? []) as AddressSuggestion[]
+
+ return suggestions[0] ?? null
+}
+
+function getOperationAddresses(operation: Operation) {
+ const addresses: Array<{
+ type: OperationMarkerType
+ address: string
+ }> = []
+
+ const kwAddress = operation.kwAddress?.trim()
+ const targetObject = operation.targetObject?.trim()
+
+ if (kwAddress) {
+ addresses.push({
+ type: 'kwAddress',
+ address: kwAddress,
+ })
+ }
+
+ if (targetObject) {
+ addresses.push({
+ type: 'targetObject',
+ address: targetObject,
+ })
+ }
+
+ return addresses
+}
+
+function renderOperationPopup(
+ marker: OperationMarker,
+ onEdit: (operation: Operation) => void,
+ canEdit: boolean,
+) {
+ return (
+
+
+ {marker.operation.operationName || 'Ohne Einsatzname'}
+
+
+
+ {marker.operation.operationNumber}
+
+
+
+ {getMarkerLabel(marker.type)}
+
+
+
+
+ -
+ {getMarkerLabel(marker.type)}
+
+ -
+ {formatShortAddress(marker.address)}
+
+
+
+ {marker.type !== 'kwAddress' && marker.operation.kwAddress && (
+
+ - KW
+ -
+ {formatShortAddress(marker.operation.kwAddress)}
+
+
+ )}
+
+ {marker.type !== 'targetObject' && marker.operation.targetObject && (
+
+ - Zielobjekt
+ -
+ {formatShortAddress(marker.operation.targetObject)}
+
+
+ )}
+
+ {marker.operation.offense && (
+
+ - Delikt
+ -
+ {marker.operation.offense}
+
+
+ )}
+
+
+ {canEdit && (
+
+
+
+ )}
+
+ )
+}
+
+function toRadians(value: number) {
+ return (value * Math.PI) / 180
+}
+
+function toDegrees(value: number) {
+ return (value * 180) / Math.PI
+}
+
+function getDistanceMeters(
+ start: { lat: number; lon: number },
+ end: { lat: number; lon: number },
+) {
+ const earthRadius = 6371000
+
+ const startLat = toRadians(start.lat)
+ const endLat = toRadians(end.lat)
+ const deltaLat = toRadians(end.lat - start.lat)
+ const deltaLon = toRadians(end.lon - start.lon)
+
+ const a =
+ Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) +
+ Math.cos(startLat) *
+ Math.cos(endLat) *
+ Math.sin(deltaLon / 2) *
+ Math.sin(deltaLon / 2)
+
+ const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
+
+ return earthRadius * c
+}
+
+function getBearingDegrees(
+ start: { lat: number; lon: number },
+ end: { lat: number; lon: number },
+) {
+ const startLat = toRadians(start.lat)
+ const endLat = toRadians(end.lat)
+ const deltaLon = toRadians(end.lon - start.lon)
+
+ const y = Math.sin(deltaLon) * Math.cos(endLat)
+ const x =
+ Math.cos(startLat) * Math.sin(endLat) -
+ Math.sin(startLat) * Math.cos(endLat) * Math.cos(deltaLon)
+
+ return (toDegrees(Math.atan2(y, x)) + 360) % 360
+}
+
+function getDestinationPoint(
+ start: { lat: number; lon: number },
+ distanceMeters: number,
+ bearingDegrees: number,
+): [number, number] {
+ const earthRadius = 6371000
+
+ const bearing = toRadians(bearingDegrees)
+ const startLat = toRadians(start.lat)
+ const startLon = toRadians(start.lon)
+ const angularDistance = distanceMeters / earthRadius
+
+ const lat = Math.asin(
+ Math.sin(startLat) * Math.cos(angularDistance) +
+ Math.cos(startLat) * Math.sin(angularDistance) * Math.cos(bearing),
+ )
+
+ const lon =
+ startLon +
+ Math.atan2(
+ Math.sin(bearing) * Math.sin(angularDistance) * Math.cos(startLat),
+ Math.cos(angularDistance) - Math.sin(startLat) * Math.sin(lat),
+ )
+
+ return [toDegrees(lat), ((toDegrees(lon) + 540) % 360) - 180]
+}
+
+function createViewConePositions({
+ from,
+ to,
+ halfAngle = 18,
+ steps = 16,
+}: {
+ from: { lat: number; lon: number }
+ to: { lat: number; lon: number }
+ halfAngle?: number
+ steps?: number
+}) {
+ const distance = getDistanceMeters(from, to)
+
+ if (distance < 2) {
+ return []
+ }
+
+ const bearing = getBearingDegrees(from, to)
+ const positions: Array<[number, number]> = [[from.lat, from.lon]]
+
+ for (let step = 0; step <= steps; step += 1) {
+ const angle = bearing - halfAngle + (halfAngle * 2 * step) / steps
+ positions.push(getDestinationPoint(from, distance, angle))
+ }
+
+ positions.push([from.lat, from.lon])
+
+ return positions
+}
+
+function getOperationViewCones(markers: OperationMarker[]) {
+ const markersByOperation = new globalThis.Map()
+
+ markers.forEach((marker) => {
+ const existingMarkers = markersByOperation.get(marker.operation.id) ?? []
+ existingMarkers.push(marker)
+ markersByOperation.set(marker.operation.id, existingMarkers)
+ })
+
+ const cones: OperationViewCone[] = []
+
+ markersByOperation.forEach((operationMarkers, operationId) => {
+ const kwMarker = operationMarkers.find((marker) => marker.type === 'kwAddress')
+ const targetMarker = operationMarkers.find((marker) => marker.type === 'targetObject')
+
+ if (!kwMarker || !targetMarker) {
+ return
+ }
+
+ const positions = createViewConePositions({
+ from: kwMarker,
+ to: targetMarker,
+ })
+
+ if (positions.length === 0) {
+ return
+ }
+
+ cones.push({
+ id: `${operationId}-view-cone`,
+ operation: kwMarker.operation,
+ color: kwMarker.color,
+ positions,
+ centerLine: [
+ [kwMarker.lat, kwMarker.lon],
+ [targetMarker.lat, targetMarker.lon],
+ ],
+ })
+ })
+
+ return cones
+}
+
+export default function OperationMap({
+ operations,
+ isLoading,
+ onEdit,
+ canEdit = false,
+ mode = 'markers',
+ className,
+}: OperationMapProps) {
+ const [markers, setMarkers] = useState([])
+ const [isGeocoding, setIsGeocoding] = useState(false)
+ const [geocodingError, setGeocodingError] = useState(null)
+
+ const operationsWithAddresses = useMemo(
+ () =>
+ operations.filter((operation) => getOperationAddresses(operation).length > 0),
+ [operations],
+ )
+
+ useEffect(() => {
+ if (isLoading) {
+ return
+ }
+
+ if (operationsWithAddresses.length === 0) {
+ setMarkers([])
+ setGeocodingError(null)
+ return
+ }
+
+ const abortController = new AbortController()
+ let ignoreResult = false
+
+ async function loadMarkers() {
+ setIsGeocoding(true)
+ setGeocodingError(null)
+
+ try {
+ const uniqueAddresses = Array.from(
+ new Set(
+ operationsWithAddresses
+ .flatMap((operation) =>
+ getOperationAddresses(operation).map((item) => item.address),
+ )
+ .filter(Boolean),
+ ),
+ )
+
+ const geocodedByAddress = new globalThis.Map()
+
+ await Promise.all(
+ uniqueAddresses.map(async (address) => {
+ try {
+ const suggestion = await geocodeAddress(
+ address,
+ abortController.signal,
+ )
+
+ if (suggestion) {
+ geocodedByAddress.set(address, suggestion)
+ }
+ } catch (error) {
+ if (error instanceof DOMException && error.name === 'AbortError') {
+ return
+ }
+ }
+ }),
+ )
+
+ if (ignoreResult) {
+ return
+ }
+
+ const nextMarkers = operationsWithAddresses
+ .flatMap((operation) =>
+ getOperationAddresses(operation).map((addressItem) => {
+ const suggestion = geocodedByAddress.get(addressItem.address)
+
+ if (!suggestion) {
+ return null
+ }
+
+ return {
+ id: `${operation.id}-${addressItem.type}`,
+ operation,
+ type: addressItem.type,
+ address: addressItem.address,
+ lat: suggestion.lat,
+ lon: suggestion.lon,
+ label: suggestion.label,
+ color: getOperationColor(operation.id),
+ }
+ }),
+ )
+ .filter((marker): marker is OperationMarker => marker !== null)
+
+ setMarkers(nextMarkers)
+
+ if (nextMarkers.length === 0) {
+ setGeocodingError(
+ 'Für KW-Adressen und Zielobjekte konnten keine Kartenpositionen gefunden werden.',
+ )
+ }
+ } catch {
+ if (!ignoreResult) {
+ setMarkers([])
+ setGeocodingError('Adressen konnten nicht geocodiert werden.')
+ }
+ } finally {
+ if (!ignoreResult) {
+ setIsGeocoding(false)
+ }
+ }
+ }
+
+ void loadMarkers()
+
+ return () => {
+ ignoreResult = true
+ abortController.abort()
+ }
+ }, [isLoading, operationsWithAddresses])
+
+ if (isLoading || isGeocoding) {
+ return (
+
+
+
+ )
+ }
+
+ if (operations.length === 0) {
+ return (
+
+ Keine Einsätze vorhanden.
+
+ )
+ }
+
+ const mapMarkers: MapMarker[] = markers.map((marker) => ({
+ id: marker.id,
+ lat: marker.lat,
+ lon: marker.lon,
+ title: `${getMarkerLabel(marker.type)} · ${
+ marker.operation.operationName || marker.operation.operationNumber
+ }`,
+ label: marker.operation.operationName || marker.operation.operationNumber,
+ labelOffset: [0, -42],
+ icon: getMarkerIcon(marker),
+ popup: renderOperationPopup(marker, onEdit, canEdit),
+ }))
+
+ const viewCones = getOperationViewCones(markers)
+
+ const heatPoints: MapHeatPoint[] = markers.map((marker) => ({
+ id: marker.id,
+ lat: marker.lat,
+ lon: marker.lon,
+ intensity: marker.type === 'kwAddress' ? 0.85 : 1,
+ }))
+
+ const visibleMapMarkers = mode === 'markers' ? mapMarkers : []
+ const visibleViewCones = mode === 'markers' ? viewCones : []
+
+ return (
+
+ {geocodingError && (
+
+ {geocodingError}
+
+ )}
+
+
+
+ {visibleViewCones.map((cone) => (
+
+ ))}
+
+ {visibleViewCones.map((cone) => (
+
+ ))}
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/pages/operations/OperationModal.tsx b/frontend/src/pages/operations/OperationModal.tsx
new file mode 100644
index 0000000..f3357a7
--- /dev/null
+++ b/frontend/src/pages/operations/OperationModal.tsx
@@ -0,0 +1,684 @@
+// frontend/src/pages/OperationModal.tsx
+
+import { useEffect, useMemo, useState, type FormEvent } from 'react'
+import { DialogTitle } from '@headlessui/react'
+import { XMarkIcon } from '@heroicons/react/24/outline'
+import { ChatBubbleLeftEllipsisIcon, CheckIcon, PencilSquareIcon, PlusCircleIcon } from '@heroicons/react/20/solid'
+import Modal from '../../components/Modal'
+import Journal, { type JournalItem } from '../../components/Journal'
+import Button from '../../components/Button'
+import LoadingSpinner from '../../components/LoadingSpinner'
+import AddressCombobox from '../../components/AddressCombobox'
+import type { Operation, OperationHistoryChange, OperationHistoryEntry } from '../../components/types'
+import { formatOperationNumberInput } from '../../components/formatter'
+import NotificationDot from '../../components/NotificationDot'
+
+type OperationModalProps = {
+ open: boolean
+ setOpen: (open: boolean) => void
+ isSaving: boolean
+ error: string | null
+ editingOperation: Operation | null
+ onSubmit: (event: FormEvent) => void
+ operationHistory: OperationHistoryEntry[]
+ isHistoryLoading: boolean
+ historyError: string | null
+ isJournalSubmitting?: boolean
+ onJournalEntrySubmit?: (content: string) => void | Promise
+ onJournalEntryUpdate?: (entryId: string | number, content: string) => void | Promise
+ hasUnreadJournal?: boolean
+ onJournalTabOpen?: () => void
+}
+
+type OperationTabId =
+ | 'masterData'
+ | 'addresses'
+ | 'responsibilities'
+ | 'notes'
+ | 'equipment'
+ | 'journal'
+
+const inputClassName =
+ 'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
+
+const sectionClassName =
+ 'rounded-lg border border-gray-200 bg-white p-4 shadow-xs dark:border-white/10 dark:bg-white/5'
+
+const operationTabs: Array<{
+ id: OperationTabId
+ title: string
+ description: string
+}> = [
+ {
+ id: 'masterData',
+ title: 'Stammdaten',
+ description: 'Einsatznummer, Einsatzname und Delikt.',
+ },
+ {
+ id: 'addresses',
+ title: 'Adressen',
+ description: 'Zielobjekt und KW-Adresse.',
+ },
+ {
+ id: 'responsibilities',
+ title: 'Zuständigkeiten',
+ description: 'Sachbearbeitung, Einsatzleitung und Team.',
+ },
+ {
+ id: 'notes',
+ title: 'Zusatzinfos',
+ description: 'Legende und Bemerkung.',
+ },
+ {
+ id: 'equipment',
+ title: 'Technik',
+ description: 'Kamera, Router, Switchbox und Laptop.',
+ },
+ {
+ id: 'journal',
+ title: 'Journal',
+ description: 'Bearbeitungsverlauf des Einsatzes.',
+ },
+]
+
+function classNames(...classes: Array) {
+ return classes.filter(Boolean).join(' ')
+}
+
+function SectionHeader({
+ title,
+ description,
+}: {
+ title: string
+ description?: string
+}) {
+ return (
+
+
+ {title}
+
+
+ {description && (
+
+ {description}
+
+ )}
+
+ )
+}
+
+function formatHistoryDate(value: string) {
+ try {
+ return new Intl.DateTimeFormat('de-DE', {
+ dateStyle: 'short',
+ timeStyle: 'short',
+ }).format(new Date(value))
+ } catch {
+ return value
+ }
+}
+
+function formatHistoryChangeSummary(entry: OperationHistoryEntry) {
+ if (entry.action === 'created') {
+ return 'hat den Einsatz erstellt.'
+ }
+
+ if (entry.changes.length === 0) {
+ return 'hat den Einsatz bearbeitet.'
+ }
+
+ if (entry.changes.length === 1) {
+ return `hat ${entry.changes[0].label} geändert.`
+ }
+
+ return `hat ${entry.changes.length} Felder geändert.`
+}
+
+export default function OperationModal({
+ open,
+ setOpen,
+ isSaving,
+ error,
+ editingOperation,
+ onSubmit,
+ operationHistory,
+ isHistoryLoading,
+ historyError,
+ isJournalSubmitting = false,
+ onJournalEntrySubmit,
+ onJournalEntryUpdate,
+ hasUnreadJournal = false,
+ onJournalTabOpen,
+}: OperationModalProps) {
+ const isEditing = editingOperation !== null
+ const [activeTab, setActiveTab] = useState('masterData')
+ const [targetObject, setTargetObject] = useState('')
+ const [kwAddress, setKwAddress] = useState('')
+
+ const visibleTabs = useMemo(
+ () => operationTabs.filter((tab) => isEditing || tab.id !== 'journal'),
+ [isEditing],
+ )
+
+ useEffect(() => {
+ if (!open) {
+ return
+ }
+
+ setActiveTab('masterData')
+ setTargetObject(editingOperation?.targetObject ?? '')
+ setKwAddress(editingOperation?.kwAddress ?? '')
+ }, [
+ open,
+ editingOperation?.id,
+ editingOperation?.targetObject,
+ editingOperation?.kwAddress,
+ ])
+
+ useEffect(() => {
+ if (!open || activeTab !== 'journal' || !hasUnreadJournal) {
+ return
+ }
+
+ onJournalTabOpen?.()
+ }, [open, activeTab, hasUnreadJournal, onJournalTabOpen])
+
+ const journalItems: JournalItem[] = operationHistory.map((entry) => {
+ const journalChange = entry.changes.find((change) => change.field === 'journal')
+ const isJournalEntry = entry.action === 'journal' || journalChange !== undefined
+ const updatedAt =
+ typeof entry.updatedAt === 'string' && entry.updatedAt.trim()
+ ? entry.updatedAt
+ : undefined
+
+ const createdAt =
+ typeof entry.createdAt === 'string' && entry.createdAt.trim()
+ ? entry.createdAt
+ : undefined
+
+ const hasOldJournalValue =
+ isJournalEntry && Boolean(journalChange?.oldValue?.trim())
+
+ const hasDifferentUpdatedAt =
+ Boolean(
+ updatedAt &&
+ createdAt &&
+ new Date(updatedAt).getTime() > new Date(createdAt).getTime() + 1000,
+ )
+
+ const wasEdited = hasOldJournalValue || hasDifferentUpdatedAt
+
+ const editedDate =
+ wasEdited && updatedAt ? formatHistoryDate(updatedAt) : undefined
+
+ const editedDatetime =
+ wasEdited && updatedAt ? updatedAt : undefined
+
+ return {
+ id: entry.id,
+ type: isJournalEntry
+ ? 'journal'
+ : entry.action === 'created'
+ ? 'created'
+ : 'edited',
+ person: {
+ name: entry.userDisplayName || 'Unbekannt',
+ },
+ content: isJournalEntry
+ ? journalChange?.newValue || 'hat einen Journal-Eintrag hinzugefügt.'
+ : formatHistoryChangeSummary(entry),
+
+ changes:
+ !isJournalEntry && entry.action !== 'created' && entry.changes.length > 0
+ ? entry.changes.map((change) => ({
+ label: change.label,
+ oldValue: change.oldValue,
+ newValue: change.newValue,
+ }))
+ : undefined,
+ hasNotification: isJournalEntry && hasUnreadJournal,
+ date: formatHistoryDate(entry.createdAt),
+ datetime: entry.createdAt,
+ editedDate,
+ editedDatetime,
+ canEdit: isJournalEntry && entry.canEdit,
+ icon: isJournalEntry
+ ? ChatBubbleLeftEllipsisIcon
+ : entry.action === 'created'
+ ? PlusCircleIcon
+ : PencilSquareIcon,
+ iconBackground: isJournalEntry
+ ? 'bg-indigo-500'
+ : entry.action === 'created'
+ ? 'bg-green-500'
+ : 'bg-blue-500',
+ }
+ })
+
+ function handleOpenChange(nextOpen: boolean) {
+ if (!nextOpen && isSaving) {
+ return
+ }
+
+ setOpen(nextOpen)
+ }
+
+ return (
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/pages/operations/OperationThreeDMap.tsx b/frontend/src/pages/operations/OperationThreeDMap.tsx
new file mode 100644
index 0000000..6baefbc
--- /dev/null
+++ b/frontend/src/pages/operations/OperationThreeDMap.tsx
@@ -0,0 +1,284 @@
+// frontend\src\pages\operations\OperationThreeDMap.tsx
+
+import { useEffect, useMemo, useState } from 'react'
+import ThreeDMap, { type ThreeDMapMarker } from '../../components/ThreeDMap'
+import LoadingSpinner from '../../components/LoadingSpinner'
+import type { Operation } from '../../components/types'
+
+const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
+
+type AddressSuggestion = {
+ id: string
+ label: string
+ lat: number
+ lon: number
+ type?: string
+}
+
+type OperationMarker = {
+ operation: Operation
+ lat: number
+ lon: number
+ label: string
+}
+
+type OperationThreeDMapProps = {
+ operations: Operation[]
+ isLoading: boolean
+ className?: string
+}
+
+function classNames(...classes: Array) {
+ return classes.filter(Boolean).join(' ')
+}
+
+function formatShortAddress(value: string) {
+ const address = value.trim()
+
+ if (!address) {
+ return '-'
+ }
+
+ const parts = address
+ .split(',')
+ .map((part) => part.trim())
+ .filter(Boolean)
+ .filter(
+ (part) =>
+ !['Deutschland', 'Germany', 'Bundesrepublik Deutschland'].includes(part),
+ )
+
+ if (parts.length <= 3) {
+ return parts.join(', ')
+ }
+
+ const postcodeIndex = parts.findIndex((part) => /^\d{5}$/.test(part))
+
+ if (postcodeIndex > 0) {
+ const city = parts[postcodeIndex - 1]
+ const postcode = parts[postcodeIndex]
+
+ const street =
+ parts.length > 1 && /^\d+[a-zA-Z]?$/.test(parts[0])
+ ? `${parts[1]} ${parts[0]}`
+ : parts[0]
+
+ return `${street}, ${postcode} ${city}`
+ }
+
+ return parts.slice(0, 3).join(', ')
+}
+
+async function geocodeAddress(
+ address: string,
+ signal: AbortSignal,
+): Promise {
+ const response = await fetch(
+ `${API_URL}/address-search?q=${encodeURIComponent(address)}`,
+ {
+ credentials: 'include',
+ signal,
+ },
+ )
+
+ if (!response.ok) {
+ return null
+ }
+
+ const data = await response.json()
+ const suggestions = (data.suggestions ?? []) as AddressSuggestion[]
+
+ return suggestions[0] ?? null
+}
+
+function buildDescription(operation: Operation) {
+ const rows = [
+ `Einsatznummer: ${operation.operationNumber || '-'}`,
+ `KW: ${formatShortAddress(operation.kwAddress)}`,
+ operation.targetObject
+ ? `Zielobjekt: ${formatShortAddress(operation.targetObject)}`
+ : '',
+ operation.offense ? `Delikt: ${operation.offense}` : '',
+ ].filter(Boolean)
+
+ return rows.join('\n')
+}
+
+export default function OperationThreeDMap({
+ operations,
+ isLoading,
+ className,
+}: OperationThreeDMapProps) {
+ const [markers, setMarkers] = useState([])
+ const [isGeocoding, setIsGeocoding] = useState(false)
+ const [geocodingError, setGeocodingError] = useState(null)
+
+ const operationsWithKwAddress = useMemo(
+ () =>
+ operations.filter((operation) => operation.kwAddress?.trim().length > 0),
+ [operations],
+ )
+
+ useEffect(() => {
+ if (isLoading) {
+ return
+ }
+
+ if (operationsWithKwAddress.length === 0) {
+ setMarkers([])
+ setGeocodingError(null)
+ return
+ }
+
+ const abortController = new AbortController()
+ let ignoreResult = false
+
+ async function loadMarkers() {
+ setIsGeocoding(true)
+ setGeocodingError(null)
+
+ try {
+ const uniqueAddresses = Array.from(
+ new Set(
+ operationsWithKwAddress
+ .map((operation) => operation.kwAddress.trim())
+ .filter(Boolean),
+ ),
+ )
+
+ const geocodedByAddress = new globalThis.Map()
+
+ await Promise.all(
+ uniqueAddresses.map(async (address) => {
+ try {
+ const suggestion = await geocodeAddress(
+ address,
+ abortController.signal,
+ )
+
+ if (suggestion) {
+ geocodedByAddress.set(address, suggestion)
+ }
+ } catch (error) {
+ if (error instanceof DOMException && error.name === 'AbortError') {
+ return
+ }
+ }
+ }),
+ )
+
+ if (ignoreResult) {
+ return
+ }
+
+ const nextMarkers = operationsWithKwAddress
+ .map((operation) => {
+ const address = operation.kwAddress.trim()
+ const suggestion = geocodedByAddress.get(address)
+
+ if (!suggestion) {
+ return null
+ }
+
+ return {
+ operation,
+ lat: suggestion.lat,
+ lon: suggestion.lon,
+ label: suggestion.label,
+ }
+ })
+ .filter((marker): marker is OperationMarker => marker !== null)
+
+ setMarkers(nextMarkers)
+
+ if (nextMarkers.length === 0) {
+ setGeocodingError(
+ 'Für die KW-Adressen konnten keine 3D-Kartenpositionen gefunden werden.',
+ )
+ }
+ } catch {
+ if (!ignoreResult) {
+ setMarkers([])
+ setGeocodingError('KW-Adressen konnten nicht geocodiert werden.')
+ }
+ } finally {
+ if (!ignoreResult) {
+ setIsGeocoding(false)
+ }
+ }
+ }
+
+ void loadMarkers()
+
+ return () => {
+ ignoreResult = true
+ abortController.abort()
+ }
+ }, [isLoading, operationsWithKwAddress])
+
+ if (isLoading || isGeocoding) {
+ return (
+
+
+
+ )
+ }
+
+ if (operations.length === 0) {
+ return (
+
+ Keine Einsätze vorhanden.
+
+ )
+ }
+
+ const threeDMarkers: ThreeDMapMarker[] = markers.map((marker) => ({
+ id: marker.operation.id,
+ lat: marker.lat,
+ lon: marker.lon,
+ title: marker.operation.operationName || marker.operation.operationNumber,
+ description: buildDescription(marker.operation),
+ }))
+
+ return (
+
+ {geocodingError && (
+
+ {geocodingError}
+
+ )}
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/pages/operations/OperationsPage.tsx b/frontend/src/pages/operations/OperationsPage.tsx
new file mode 100644
index 0000000..6f9313c
--- /dev/null
+++ b/frontend/src/pages/operations/OperationsPage.tsx
@@ -0,0 +1,869 @@
+// frontend/src/pages/OperationsPage.tsx
+
+import { useEffect, useRef, useState, type FormEvent } from 'react'
+import {
+ MapPinIcon,
+ FireIcon,
+ PencilSquareIcon,
+ Squares2X2Icon,
+ TableCellsIcon,
+} from '@heroicons/react/20/solid'
+import OperationMap from './OperationMap'
+import ButtonGroup from '../../components/ButtonGroup'
+import Card from '../../components/Card'
+import OperationModal from './OperationModal'
+import Tables, { type TableColumn } from '../../components/Tables'
+import LoadingSpinner from '../../components/LoadingSpinner'
+import Button from '../../components/Button'
+import type { Operation, OperationHistoryEntry } from '../../components/types'
+import CreateOperationModal from './CreateOperationModal'
+import NotificationDot from '../../components/NotificationDot'
+
+type OperationsPageProps = {
+ unreadJournalOperationIds?: Set
+ onJournalRead?: (operationId: string) => void | Promise
+ canWriteOperations?: boolean
+}
+
+type OperationsView = 'table' | 'cards' | 'map' | 'heatmap'
+
+const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
+
+function renderEquipment(operation: Operation) {
+ const equipment = [
+ { label: 'Kamera', value: operation.camera },
+ { label: 'Router', value: operation.router },
+ { label: 'Technik', value: operation.technology },
+ { label: 'Switchbox', value: operation.switchbox },
+ { label: 'Festplatte', value: operation.hardDrive },
+ { label: 'Laptop', value: operation.laptop },
+ ].filter((item) => item.value)
+
+ if (equipment.length === 0) {
+ return '-'
+ }
+
+ return (
+
+ {equipment.map((item) => (
+
+ {item.label}
+
+ ))}
+
+ )
+}
+
+function formatShortAddress(value: string) {
+ const address = value.trim()
+
+ if (!address) {
+ return '-'
+ }
+
+ const parts = address
+ .split(',')
+ .map((part) => part.trim())
+ .filter(Boolean)
+ .filter(
+ (part) =>
+ !['Deutschland', 'Germany', 'Bundesrepublik Deutschland'].includes(part),
+ )
+
+ if (parts.length <= 3) {
+ return parts.join(', ')
+ }
+
+ const postcodeIndex = parts.findIndex((part) => /^\d{5}$/.test(part))
+
+ if (postcodeIndex > 0) {
+ const city = parts[postcodeIndex - 1]
+ const postcode = parts[postcodeIndex]
+
+ const street =
+ parts.length > 1 && /^\d+[a-zA-Z]?$/.test(parts[0])
+ ? `${parts[1]} ${parts[0]}`
+ : parts[0]
+
+ return `${street}, ${postcode} ${city}`
+ }
+
+ return parts.slice(0, 3).join(', ')
+}
+
+function renderShortAddress(value: string) {
+ const shortAddress = formatShortAddress(value)
+
+ return (
+
+ {shortAddress}
+
+ )
+}
+
+export default function OperationsPage({
+ unreadJournalOperationIds = new Set(),
+ onJournalRead,
+ canWriteOperations = false,
+}: OperationsPageProps) {
+ const [operations, setOperations] = useState([])
+ const [isLoading, setIsLoading] = useState(true)
+ const [error, setError] = useState(null)
+
+ const [isJournalSubmitting, setIsJournalSubmitting] = useState(false)
+
+ const [isCreateOperationModalOpen, setIsCreateOperationModalOpen] = useState(false)
+ const [isOperationModalOpen, setIsOperationModalOpen] = useState(false)
+ const [isSaving, setIsSaving] = useState(false)
+ const [saveError, setSaveError] = useState(null)
+ const [editingOperation, setEditingOperation] = useState(null)
+
+ const [operationHistory, setOperationHistory] = useState([])
+ const [isHistoryLoading, setIsHistoryLoading] = useState(false)
+ const [historyError, setHistoryError] = useState(null)
+
+ const [view, setView] = useState('table')
+ const [heatmapOperations, setHeatmapOperations] = useState([])
+ const [isHeatmapLoading, setIsHeatmapLoading] = useState(false)
+ const [heatmapError, setHeatmapError] = useState(null)
+
+ const closeCleanupTimeoutRef = useRef(null)
+
+ useEffect(() => {
+ loadOperations()
+
+ return () => {
+ if (closeCleanupTimeoutRef.current) {
+ window.clearTimeout(closeCleanupTimeoutRef.current)
+ }
+ }
+ }, [])
+
+ useEffect(() => {
+ function handleOperationNotification() {
+ void loadOperations()
+
+ if (view === 'heatmap') {
+ void loadHeatmapOperations()
+ }
+
+ if (editingOperation) {
+ void loadOperationHistory(editingOperation.id)
+ }
+ }
+
+ window.addEventListener('operation-notification', handleOperationNotification)
+
+ return () => {
+ window.removeEventListener('operation-notification', handleOperationNotification)
+ }
+ }, [view, editingOperation])
+
+ async function handleUpdateJournalEntry(entryId: string | number, content: string) {
+ if (!editingOperation) {
+ return
+ }
+
+ setIsJournalSubmitting(true)
+ setHistoryError(null)
+
+ try {
+ const response = await fetch(
+ `${API_URL}/operations/${editingOperation.id}/history/${entryId}`,
+ {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ credentials: 'include',
+ body: JSON.stringify({ content }),
+ },
+ )
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => null)
+ throw new Error(errorData?.error ?? 'Journal-Eintrag konnte nicht aktualisiert werden')
+ }
+
+ await loadOperationHistory(editingOperation.id)
+ } catch (error) {
+ setHistoryError(
+ error instanceof Error
+ ? error.message
+ : 'Journal-Eintrag konnte nicht aktualisiert werden',
+ )
+
+ await loadOperationHistory(editingOperation.id)
+ } finally {
+ setIsJournalSubmitting(false)
+ }
+ }
+
+ async function loadOperations() {
+ setIsLoading(true)
+ setError(null)
+
+ try {
+ const response = await fetch(`${API_URL}/operations`, {
+ credentials: 'include',
+ })
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => null)
+ throw new Error(errorData?.error ?? 'Einsätze konnten nicht geladen werden')
+ }
+
+ const data = await response.json()
+ setOperations(data.operations ?? [])
+ } catch (error) {
+ setError(error instanceof Error ? error.message : 'Einsätze konnten nicht geladen werden')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ async function loadOperationHistory(operationId: string) {
+ setIsHistoryLoading(true)
+ setHistoryError(null)
+
+ try {
+ const response = await fetch(`${API_URL}/operations/${operationId}/history`, {
+ credentials: 'include',
+ })
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => null)
+ throw new Error(errorData?.error ?? 'Bearbeitungsverlauf konnte nicht geladen werden')
+ }
+
+ const data = await response.json()
+ setOperationHistory(data.history ?? [])
+ } catch (error) {
+ setHistoryError(
+ error instanceof Error
+ ? error.message
+ : 'Bearbeitungsverlauf konnte nicht geladen werden',
+ )
+ } finally {
+ setIsHistoryLoading(false)
+ }
+ }
+
+ async function handleCreateJournalEntry(content: string) {
+ if (!editingOperation) {
+ return
+ }
+
+ setIsJournalSubmitting(true)
+ setHistoryError(null)
+
+ try {
+ const response = await fetch(`${API_URL}/operations/${editingOperation.id}/history`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ credentials: 'include',
+ body: JSON.stringify({ content }),
+ })
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => null)
+ throw new Error(errorData?.error ?? 'Journal-Eintrag konnte nicht gespeichert werden')
+ }
+
+ await loadOperationHistory(editingOperation.id)
+ } catch (error) {
+ setHistoryError(
+ error instanceof Error
+ ? error.message
+ : 'Journal-Eintrag konnte nicht gespeichert werden',
+ )
+ } finally {
+ setIsJournalSubmitting(false)
+ }
+ }
+
+ async function loadHeatmapOperations() {
+ setIsHeatmapLoading(true)
+ setHeatmapError(null)
+
+ try {
+ const response = await fetch(`${API_URL}/operations?includeCompleted=true`, {
+ credentials: 'include',
+ })
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => null)
+ throw new Error(errorData?.error ?? 'Heatmap-Daten konnten nicht geladen werden')
+ }
+
+ const data = await response.json()
+ setHeatmapOperations(data.operations ?? [])
+ } catch (error) {
+ setHeatmapError(
+ error instanceof Error
+ ? error.message
+ : 'Heatmap-Daten konnten nicht geladen werden',
+ )
+ } finally {
+ setIsHeatmapLoading(false)
+ }
+ }
+
+ function cleanupModalState() {
+ if (closeCleanupTimeoutRef.current) {
+ window.clearTimeout(closeCleanupTimeoutRef.current)
+ }
+
+ closeCleanupTimeoutRef.current = window.setTimeout(() => {
+ setEditingOperation(null)
+ setOperationHistory([])
+ setHistoryError(null)
+ setIsHistoryLoading(false)
+ setSaveError(null)
+ closeCleanupTimeoutRef.current = null
+ }, 220)
+ }
+
+ function handleViewChange(nextView: string) {
+ const nextOperationsView = nextView as OperationsView
+
+ setView(nextOperationsView)
+
+ if (nextOperationsView === 'heatmap') {
+ void loadHeatmapOperations()
+ }
+ }
+
+ function openCreateModal() {
+ if (!canWriteOperations) {
+ return
+ }
+
+
+ if (closeCleanupTimeoutRef.current) {
+ window.clearTimeout(closeCleanupTimeoutRef.current)
+ closeCleanupTimeoutRef.current = null
+ }
+
+ setSaveError(null)
+ setEditingOperation(null)
+ setOperationHistory([])
+ setHistoryError(null)
+ setIsHistoryLoading(false)
+ setIsCreateOperationModalOpen(true)
+ }
+
+ function closeCreateOperationModal() {
+ if (isSaving) {
+ return
+ }
+
+ setIsCreateOperationModalOpen(false)
+ setSaveError(null)
+ }
+
+ function closeOperationModal() {
+ if (isSaving) {
+ return
+ }
+
+ setIsOperationModalOpen(false)
+ cleanupModalState()
+ }
+
+ function openEditModal(operation: Operation) {
+ if (!canWriteOperations) {
+ return
+ }
+
+
+ if (closeCleanupTimeoutRef.current) {
+ window.clearTimeout(closeCleanupTimeoutRef.current)
+ closeCleanupTimeoutRef.current = null
+ }
+
+ setSaveError(null)
+ setHistoryError(null)
+ setOperationHistory([])
+ setEditingOperation(operation)
+ setIsOperationModalOpen(true)
+
+ void loadOperationHistory(operation.id)
+ }
+
+ async function handleSaveOperation(event: FormEvent) {
+ event.preventDefault()
+ if (!canWriteOperations) {
+ setSaveError('Du hast keine Berechtigung, Einsätze zu bearbeiten.')
+ return
+ }
+
+ const form = event.currentTarget
+ const formData = new FormData(form)
+ const operationToEdit = editingOperation
+
+ setIsSaving(true)
+ setSaveError(null)
+
+ const payload = {
+ operationNumber: String(formData.get('operationNumber') ?? ''),
+ operationName: String(formData.get('operationName') ?? ''),
+ offense: String(formData.get('offense') ?? ''),
+ caseWorker: String(formData.get('caseWorker') ?? ''),
+ targetObject: String(formData.get('targetObject') ?? ''),
+ kwAddress: String(formData.get('kwAddress') ?? ''),
+ operationLeader: String(formData.get('operationLeader') ?? ''),
+ operationTeam: String(formData.get('operationTeam') ?? ''),
+ legend: String(formData.get('legend') ?? ''),
+ camera: String(formData.get('camera') ?? ''),
+ router: String(formData.get('router') ?? ''),
+ technology: String(formData.get('technology') ?? ''),
+ switchbox: String(formData.get('switchbox') ?? ''),
+ hardDrive: String(formData.get('hardDrive') ?? ''),
+ laptop: String(formData.get('laptop') ?? ''),
+ remark: String(formData.get('remark') ?? ''),
+ }
+
+ try {
+ const response = await fetch(
+ operationToEdit
+ ? `${API_URL}/operations/${operationToEdit.id}`
+ : `${API_URL}/operations`,
+ {
+ method: operationToEdit ? 'PUT' : 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ credentials: 'include',
+ body: JSON.stringify(payload),
+ },
+ )
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => null)
+
+ throw new Error(
+ errorData?.error ??
+ (operationToEdit
+ ? 'Einsatz konnte nicht aktualisiert werden'
+ : 'Einsatz konnte nicht erstellt werden'),
+ )
+ }
+
+ form.reset()
+ setIsOperationModalOpen(false)
+ setIsCreateOperationModalOpen(false)
+ cleanupModalState()
+
+ await loadOperations()
+
+ if (view === 'heatmap' || heatmapOperations.length > 0) {
+ await loadHeatmapOperations()
+ }
+ } catch (error) {
+ setSaveError(
+ error instanceof Error
+ ? error.message
+ : operationToEdit
+ ? 'Einsatz konnte nicht aktualisiert werden'
+ : 'Einsatz konnte nicht erstellt werden',
+ )
+ } finally {
+ setIsSaving(false)
+ }
+ }
+
+ function hasUnreadJournal(operation: Operation) {
+ return unreadJournalOperationIds.has(operation.id)
+ }
+
+ function renderOperationName(operation: Operation) {
+ const name = operation.operationName || 'Ohne Einsatzname'
+
+ return (
+
+ {name}
+
+ {hasUnreadJournal(operation) && (
+
+ )}
+
+ )
+ }
+
+ const columns: TableColumn[] = [
+ {
+ key: 'operationName',
+ header: 'Einsatzname',
+ isPrimary: true,
+ render: (operation) => renderOperationName(operation),
+ },
+ {
+ key: 'operationNumber',
+ header: 'Einsatznummer',
+ render: (operation) => operation.operationNumber,
+ },
+ {
+ key: 'offense',
+ header: 'Delikt',
+ render: (operation) => operation.offense || '-',
+ },
+ {
+ key: 'caseWorker',
+ header: 'Sachbearbeitung',
+ hideOnMobile: 'lg',
+ render: (operation) => operation.caseWorker || '-',
+ },
+ {
+ key: 'targetObject',
+ header: 'Zielobjekt',
+ render: (operation) => renderShortAddress(operation.targetObject),
+ },
+ {
+ key: 'kwAddress',
+ header: 'KW',
+ render: (operation) => renderShortAddress(operation.kwAddress),
+ },
+ {
+ key: 'operationLeader',
+ header: 'Einsatzleiter',
+ hideOnMobile: 'lg',
+ render: (operation) => operation.operationLeader || '-',
+ },
+ {
+ key: 'operationTeam',
+ header: 'Einsatzteam',
+ hideOnMobile: 'lg',
+ render: (operation) => operation.operationTeam || '-',
+ },
+ {
+ key: 'equipment',
+ header: 'Technik',
+ hideOnMobile: 'lg',
+ render: renderEquipment,
+ },
+ {
+ key: 'remark',
+ header: 'Bemerkung',
+ hideOnMobile: 'lg',
+ render: (operation) => (
+
+ {operation.remark || '-'}
+
+ ),
+ },
+ ]
+
+ function renderCardValue(label: string, value: string, title?: string) {
+ return (
+
+ -
+ {label}
+
+ -
+ {value || '-'}
+
+
+ )
+ }
+
+ function renderOperationCards() {
+ if (isLoading) {
+ return (
+
+
+
+ )
+ }
+
+ if (operations.length === 0) {
+ return (
+
+ Keine Einsätze vorhanden.
+
+ )
+ }
+
+ return (
+
+ {operations.map((operation) => (
+ openEditModal(operation)}
+ leadingIcon={}
+ >
+ Bearbeiten
+ , {operation.operationNumber}
+
+ ) : undefined
+ }
+ >
+
+ {renderCardValue('Delikt', operation.offense)}
+ {renderCardValue('Sachbearbeitung', operation.caseWorker)}
+
+ {renderCardValue(
+ 'Zielobjekt',
+ formatShortAddress(operation.targetObject),
+ operation.targetObject,
+ )}
+ {renderCardValue(
+ 'KW',
+ formatShortAddress(operation.kwAddress),
+ operation.kwAddress,
+ )}
+
+ {renderCardValue('Einsatzleiter', operation.operationLeader)}
+ {renderCardValue('Team', operation.operationTeam)}
+ {renderCardValue('Legende', operation.legend)}
+
+
+
+ -
+ Technik
+
+ -
+ {renderEquipment(operation)}
+
+
+
+ {operation.remark && (
+
+ -
+ Bemerkung
+
+ -
+ {operation.remark}
+
+
+ )}
+
+ ))}
+
+ )
+ }
+
+ const isMapView = view === 'map' || view === 'heatmap'
+
+ return (
+
+ {(error || (view === 'heatmap' && heatmapError)) && (
+
+ {view === 'heatmap' ? heatmapError : error}
+
+ )}
+
+
+
+
+ Einsätze
+
+
+
+ {isLoading
+ ? 'Einsätze werden geladen...'
+ : 'Eine Liste aller Einsätze inklusive Einsatznummer, Einsatzname, Delikt, Sachbearbeitung, Zielobjekt und Technik.'}
+
+
+
+
+ ,
+ },
+ {
+ id: 'cards',
+ label: 'Cards',
+ icon: ,
+ },
+ {
+ id: 'map',
+ label: 'Karte',
+ icon: ,
+ },
+ {
+ id: 'heatmap',
+ label: 'Heatmap',
+ icon: ,
+ },
+ ]}
+ />
+
+ {canWriteOperations && (
+
+ )}
+
+
+
+ {view === 'table' ? (
+ operation.id}
+ variant="card"
+ emptyText="Keine Einsätze vorhanden."
+ isLoading={isLoading}
+ loadingContent={
+
+ }
+ rowActions={
+ canWriteOperations
+ ? (operation) => (
+
+ )
+ : undefined
+ }
+ />
+ ) : view === 'cards' ? (
+ renderOperationCards()
+ ) : view === 'map' ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+ {
+ if (open) {
+ setIsCreateOperationModalOpen(true)
+ return
+ }
+
+ closeCreateOperationModal()
+ }}
+ isSaving={isSaving}
+ error={saveError}
+ onSubmit={handleSaveOperation}
+ />
+
+ {
+ if (open) {
+ setIsOperationModalOpen(true)
+ return
+ }
+
+ closeOperationModal()
+ }}
+ isSaving={isSaving}
+ error={saveError}
+ editingOperation={editingOperation}
+ onSubmit={handleSaveOperation}
+ operationHistory={operationHistory}
+ isHistoryLoading={isHistoryLoading}
+ historyError={historyError}
+ isJournalSubmitting={isJournalSubmitting}
+ onJournalEntrySubmit={handleCreateJournalEntry}
+ onJournalEntryUpdate={handleUpdateJournalEntry}
+ hasUnreadJournal={
+ editingOperation
+ ? unreadJournalOperationIds.has(editingOperation.id)
+ : false
+ }
+ onJournalTabOpen={() => {
+ if (editingOperation) {
+ void onJournalRead?.(editingOperation.id)
+ }
+ }}
+ />
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/pages/settings/Profile.tsx b/frontend/src/pages/settings/Profile.tsx
new file mode 100644
index 0000000..f916c19
--- /dev/null
+++ b/frontend/src/pages/settings/Profile.tsx
@@ -0,0 +1,831 @@
+// frontend\src\pages\settings\Profile.tsx
+
+import {
+ useEffect,
+ useState,
+ type FormEvent,
+ type ReactNode,
+} from 'react'
+import Button from '../../components/Button'
+import Container from '../../components/Container'
+import type { User } from '../../components/types'
+import {
+ ComputerDesktopIcon,
+ ShieldCheckIcon,
+} from '@heroicons/react/20/solid'
+
+const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
+
+type ProfileProps = {
+ user?: User
+ onUserUpdated?: (user: User) => void
+}
+
+type UserSession = {
+ id: string
+ browser: string
+ operatingSystem: string
+ deviceType: string
+ userAgent: string
+ ipAddress: string
+ createdAt: string
+ lastSeenAt: string
+ current: boolean
+}
+
+const inputClassName =
+ 'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
+
+function Section({
+ title,
+ description,
+ children,
+}: {
+ title: string
+ description: string
+ children: ReactNode
+}) {
+ return (
+
+
+
+
+ {title}
+
+
+
+ {description}
+
+
+
+
+ {children}
+
+
+
+ )
+}
+
+function Message({
+ type,
+ children,
+}: {
+ type: 'success' | 'error'
+ children: ReactNode
+}) {
+ return (
+
+ {children}
+
+ )
+}
+
+async function readApiError(response: Response, fallback: string) {
+ const data = await response.json().catch(() => null)
+
+ return data?.error ?? fallback
+}
+
+export default function Profile({
+ user,
+ onUserUpdated,
+}: ProfileProps) {
+ const [currentUser, setCurrentUser] = useState(user)
+ const [sessions, setSessions] = useState([])
+ const [isLoadingSessions, setIsLoadingSessions] = useState(false)
+ const [sessionsError, setSessionsError] = useState(null)
+ const [revokeSessionError, setRevokeSessionError] = useState(null)
+ const [revokingSessionId, setRevokingSessionId] = useState(null)
+
+ const [profileError, setProfileError] = useState(null)
+ const [profileSuccess, setProfileSuccess] = useState(null)
+ const [isSavingProfile, setIsSavingProfile] = useState(false)
+
+ const [passwordError, setPasswordError] = useState(null)
+ const [passwordSuccess, setPasswordSuccess] = useState(null)
+ const [isSavingPassword, setIsSavingPassword] = useState(false)
+
+ const [logoutSessionsError, setLogoutSessionsError] = useState(null)
+ const [logoutSessionsSuccess, setLogoutSessionsSuccess] = useState(null)
+ const [isLoggingOutSessions, setIsLoggingOutSessions] = useState(false)
+
+ const [deleteAccountError, setDeleteAccountError] = useState(null)
+ const [isDeletingAccount, setIsDeletingAccount] = useState(false)
+
+ useEffect(() => {
+ setCurrentUser(user)
+ }, [user])
+
+ useEffect(() => {
+ void loadSessions()
+ }, [])
+
+ const avatarUrl =
+ currentUser?.avatar ||
+ 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80'
+
+ async function loadSessions() {
+ setIsLoadingSessions(true)
+ setSessionsError(null)
+
+ try {
+ const response = await fetch(`${API_URL}/settings/sessions`, {
+ credentials: 'include',
+ })
+
+ if (!response.ok) {
+ throw new Error(
+ await readApiError(response, 'Sitzungen konnten nicht geladen werden'),
+ )
+ }
+
+ const data = await response.json()
+ setSessions(data.sessions ?? [])
+ } catch (error) {
+ setSessionsError(
+ error instanceof Error
+ ? error.message
+ : 'Sitzungen konnten nicht geladen werden',
+ )
+ } finally {
+ setIsLoadingSessions(false)
+ }
+ }
+
+ async function handleRevokeSession(sessionId: string) {
+ setRevokingSessionId(sessionId)
+ setRevokeSessionError(null)
+
+ try {
+ const response = await fetch(`${API_URL}/settings/sessions/${sessionId}`, {
+ method: 'DELETE',
+ credentials: 'include',
+ })
+
+ if (!response.ok) {
+ throw new Error(
+ await readApiError(response, 'Sitzung konnte nicht abgemeldet werden'),
+ )
+ }
+
+ await loadSessions()
+ } catch (error) {
+ setRevokeSessionError(
+ error instanceof Error
+ ? error.message
+ : 'Sitzung konnte nicht abgemeldet werden',
+ )
+ } finally {
+ setRevokingSessionId(null)
+ }
+ }
+
+ async function handleProfileSubmit(event: FormEvent) {
+ event.preventDefault()
+
+ const form = event.currentTarget
+ const formData = new FormData(form)
+
+ setIsSavingProfile(true)
+ setProfileError(null)
+ setProfileSuccess(null)
+
+ try {
+ const response = await fetch(`${API_URL}/settings/profile`, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ credentials: 'include',
+ body: JSON.stringify({
+ displayName: String(formData.get('displayName') ?? ''),
+ username: String(formData.get('username') ?? ''),
+ email: String(formData.get('email') ?? ''),
+ avatar: String(formData.get('avatar') ?? ''),
+ unit: String(formData.get('unit') ?? ''),
+ group: String(formData.get('group') ?? ''),
+ }),
+ })
+
+ if (!response.ok) {
+ throw new Error(
+ await readApiError(response, 'Profil konnte nicht gespeichert werden'),
+ )
+ }
+
+ const data = await response.json().catch(() => null)
+ const nextUser = data?.user as User | undefined
+
+ if (nextUser) {
+ setCurrentUser(nextUser)
+ onUserUpdated?.(nextUser)
+ }
+
+ setProfileSuccess('Profil wurde gespeichert.')
+ } catch (error) {
+ setProfileError(
+ error instanceof Error
+ ? error.message
+ : 'Profil konnte nicht gespeichert werden',
+ )
+ } finally {
+ setIsSavingProfile(false)
+ }
+ }
+
+ async function handlePasswordSubmit(event: FormEvent) {
+ event.preventDefault()
+
+ const form = event.currentTarget
+ const formData = new FormData(form)
+
+ setIsSavingPassword(true)
+ setPasswordError(null)
+ setPasswordSuccess(null)
+
+ try {
+ const response = await fetch(`${API_URL}/settings/password`, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ credentials: 'include',
+ body: JSON.stringify({
+ currentPassword: String(formData.get('currentPassword') ?? ''),
+ newPassword: String(formData.get('newPassword') ?? ''),
+ confirmPassword: String(formData.get('confirmPassword') ?? ''),
+ }),
+ })
+
+ if (!response.ok) {
+ throw new Error(
+ await readApiError(response, 'Passwort konnte nicht gespeichert werden'),
+ )
+ }
+
+ form.reset()
+ setPasswordSuccess('Passwort wurde gespeichert.')
+ } catch (error) {
+ setPasswordError(
+ error instanceof Error
+ ? error.message
+ : 'Passwort konnte nicht gespeichert werden',
+ )
+ } finally {
+ setIsSavingPassword(false)
+ }
+ }
+
+ async function handleLogoutSessionsSubmit(event: FormEvent) {
+ event.preventDefault()
+
+ const form = event.currentTarget
+ const formData = new FormData(form)
+
+ setIsLoggingOutSessions(true)
+ setLogoutSessionsError(null)
+ setLogoutSessionsSuccess(null)
+
+ try {
+ const response = await fetch(`${API_URL}/settings/logout-other-sessions`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ credentials: 'include',
+ body: JSON.stringify({
+ password: String(formData.get('password') ?? ''),
+ }),
+ })
+
+ if (!response.ok) {
+ throw new Error(
+ await readApiError(
+ response,
+ 'Andere Sitzungen konnten nicht abgemeldet werden',
+ ),
+ )
+ }
+
+ form.reset()
+ setLogoutSessionsSuccess('Andere Sitzungen wurden abgemeldet.')
+ await loadSessions()
+ } catch (error) {
+ setLogoutSessionsError(
+ error instanceof Error
+ ? error.message
+ : 'Andere Sitzungen konnten nicht abgemeldet werden',
+ )
+ } finally {
+ setIsLoggingOutSessions(false)
+ }
+ }
+
+ async function handleDeleteAccountSubmit(event: FormEvent) {
+ event.preventDefault()
+
+ const form = event.currentTarget
+ const formData = new FormData(form)
+
+ const confirmed = window.confirm(
+ 'Konto wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.',
+ )
+
+ if (!confirmed) {
+ return
+ }
+
+ setIsDeletingAccount(true)
+ setDeleteAccountError(null)
+
+ try {
+ const response = await fetch(`${API_URL}/settings/account`, {
+ method: 'DELETE',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ credentials: 'include',
+ body: JSON.stringify({
+ password: String(formData.get('password') ?? ''),
+ }),
+ })
+
+ if (!response.ok) {
+ throw new Error(
+ await readApiError(response, 'Konto konnte nicht gelöscht werden'),
+ )
+ }
+
+ window.location.href = '/login'
+ } catch (error) {
+ setDeleteAccountError(
+ error instanceof Error
+ ? error.message
+ : 'Konto konnte nicht gelöscht werden',
+ )
+ } finally {
+ setIsDeletingAccount(false)
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {isLoadingSessions && (
+
+ Sitzungen werden geladen...
+
+ )}
+
+ {sessionsError && (
+
+ {sessionsError}
+
+ )}
+
+ {revokeSessionError && (
+
+ {revokeSessionError}
+
+ )}
+
+ {!isLoadingSessions && !sessionsError && sessions.length > 0 && (
+
+ {sessions.map((session) => (
+
+
+
+
+
+
+
+
+
+
+ {session.current ? 'Aktuelle Sitzung' : 'Weitere Sitzung'}
+
+
+
+ {session.browser} auf {session.operatingSystem}
+
+
+
+ {session.deviceType}
+ {session.ipAddress ? ` · ${session.ipAddress}` : ''}
+
+
+
+ Zuletzt aktiv:{' '}
+ {new Intl.DateTimeFormat('de-DE', {
+ dateStyle: 'short',
+ timeStyle: 'short',
+ }).format(new Date(session.lastSeenAt))}
+
+
+
+ {session.current ? (
+
+
+ Aktiv
+
+ ) : (
+
+ )}
+
+
+
+
+ ))}
+
+ )}
+
+ {!isLoadingSessions && !sessionsError && sessions.length === 0 && (
+
+ Keine aktiven Sitzungen gefunden.
+
+ )}
+
+
+
+
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/pages/settings/SettingsPage.tsx b/frontend/src/pages/settings/SettingsPage.tsx
new file mode 100644
index 0000000..7c7b353
--- /dev/null
+++ b/frontend/src/pages/settings/SettingsPage.tsx
@@ -0,0 +1,144 @@
+// frontend/src/pages/settings/SettingsPage.tsx
+
+import type { ReactNode } from 'react'
+import { useLocation } from 'react-router'
+import {
+ BellIcon,
+ CreditCardIcon,
+ PuzzlePieceIcon,
+ UserIcon,
+ UsersIcon,
+} from '@heroicons/react/20/solid'
+import Tabs from '../../components/Tabs'
+import Container from '../../components/Container'
+import type { User } from '../../components/types'
+import Profile from './Profile'
+
+type SettingsPageProps = {
+ user?: User
+ onUserUpdated?: (user: User) => void
+}
+
+function PlaceholderSection({ title }: { title: string }) {
+ return (
+
+
+ Noch keine Einstellungen vorhanden.
+
+
+ )
+}
+
+function Section({
+ title,
+ description,
+ children,
+}: {
+ title: string
+ description: string
+ children: ReactNode
+}) {
+ return (
+
+
+
+
+ {title}
+
+
+
+ {description}
+
+
+
+
+ {children}
+
+
+
+ )
+}
+
+export default function SettingsPage({
+ user,
+ onUserUpdated,
+}: SettingsPageProps) {
+ const location = useLocation()
+
+ const pathname = location.pathname.replace(/\/$/, '')
+ const section = pathname.split('/')[2] ?? 'konto'
+
+ const settingsTabs = [
+ {
+ name: 'Konto',
+ href: '/einstellungen',
+ icon: UserIcon,
+ current: pathname === '/einstellungen',
+ },
+ {
+ name: 'Benachrichtigungen',
+ href: '/einstellungen/benachrichtigungen',
+ icon: BellIcon,
+ current: pathname === '/einstellungen/benachrichtigungen',
+ },
+ {
+ name: 'Abrechnung',
+ href: '/einstellungen/abrechnung',
+ icon: CreditCardIcon,
+ current: pathname === '/einstellungen/abrechnung',
+ },
+ {
+ name: 'Teams',
+ href: '/einstellungen/teams',
+ icon: UsersIcon,
+ current: pathname === '/einstellungen/teams',
+ },
+ {
+ name: 'Integrationen',
+ href: '/einstellungen/integrationen',
+ icon: PuzzlePieceIcon,
+ current: pathname === '/einstellungen/integrationen',
+ },
+ ]
+
+ return (
+
+ Einstellungen
+
+
+
+ {section === 'konto' && (
+
+ )}
+
+ {section === 'benachrichtigungen' && (
+
+ )}
+
+ {section === 'abrechnung' && (
+
+ )}
+
+ {section === 'teams' && (
+
+ )}
+
+ {section === 'integrationen' && (
+
+ )}
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/utils/sessionInfo.ts b/frontend/src/utils/sessionInfo.ts
new file mode 100644
index 0000000..c9d55d9
--- /dev/null
+++ b/frontend/src/utils/sessionInfo.ts
@@ -0,0 +1,174 @@
+// frontend/src/utils/sessionInfo.ts
+
+export type CurrentSessionInfo = {
+ browser: string
+ operatingSystem: string
+ deviceType: string
+}
+
+type UserAgentDataLike = {
+ platform?: string
+ brands?: Array<{
+ brand: string
+ version: string
+ }>
+ getHighEntropyValues?: (
+ hints: string[],
+ ) => Promise<{
+ platform?: string
+ platformVersion?: string
+ model?: string
+ }>
+}
+
+type NavigatorWithUserAgentData = Navigator & {
+ userAgentData?: UserAgentDataLike
+}
+
+function normalizeVersion(value: string) {
+ return value.replace(/_/g, '.')
+}
+
+function getBrowserName() {
+ const userAgent = window.navigator.userAgent
+
+ if (userAgent.includes('Edg/')) {
+ return 'Microsoft Edge'
+ }
+
+ if (userAgent.includes('Chrome/') && !userAgent.includes('Edg/')) {
+ return 'Google Chrome'
+ }
+
+ if (userAgent.includes('Firefox/')) {
+ return 'Mozilla Firefox'
+ }
+
+ if (userAgent.includes('Safari/') && !userAgent.includes('Chrome/')) {
+ return 'Safari'
+ }
+
+ return 'Unbekannter Browser'
+}
+
+function getOperatingSystemFromUserAgent() {
+ const userAgent = window.navigator.userAgent
+ const platform = window.navigator.platform
+
+ const androidMatch = userAgent.match(/Android\s+([0-9.]+)/i)
+ if (androidMatch?.[1]) {
+ return `Android ${androidMatch[1]}`
+ }
+
+ const iosMatch = userAgent.match(/(?:iPhone|iPad|iPod).*OS\s+([0-9_]+)/i)
+ if (iosMatch?.[1]) {
+ return `iOS ${normalizeVersion(iosMatch[1])}`
+ }
+
+ const macOsMatch = userAgent.match(/Mac OS X\s+([0-9_]+)/i)
+ if (macOsMatch?.[1] && !/iPhone|iPad|iPod/i.test(userAgent)) {
+ return `macOS ${normalizeVersion(macOsMatch[1])}`
+ }
+
+ if (/Windows/i.test(userAgent) || /^Win/i.test(platform)) {
+ return 'Windows'
+ }
+
+ if (/iPhone|iPad|iPod/i.test(userAgent)) {
+ return 'iOS'
+ }
+
+ if (/Macintosh|Mac OS X/i.test(userAgent) || /^Mac/i.test(platform)) {
+ return 'macOS'
+ }
+
+ if (/Linux/i.test(userAgent) || /Linux/i.test(platform)) {
+ return 'Linux'
+ }
+
+ return 'Unbekanntes Betriebssystem'
+}
+
+async function getOperatingSystem() {
+ const navigatorWithUserAgentData =
+ window.navigator as NavigatorWithUserAgentData
+
+ const userAgentData = navigatorWithUserAgentData.userAgentData
+
+ if (userAgentData?.getHighEntropyValues) {
+ try {
+ const values = await userAgentData.getHighEntropyValues([
+ 'platformVersion',
+ 'model',
+ ])
+
+ const platform = userAgentData.platform || values.platform
+
+ if (platform === 'Windows') {
+ const majorPlatformVersion = Number(
+ values.platformVersion?.split('.')[0] ?? 0,
+ )
+
+ if (majorPlatformVersion >= 13) {
+ return 'Windows 11'
+ }
+
+ if (majorPlatformVersion > 0) {
+ return 'Windows 10'
+ }
+
+ return 'Windows'
+ }
+
+ if (platform === 'Android') {
+ const fallback = getOperatingSystemFromUserAgent()
+
+ return fallback.startsWith('Android') ? fallback : 'Android'
+ }
+
+ if (platform === 'macOS') {
+ const fallback = getOperatingSystemFromUserAgent()
+
+ return fallback.startsWith('macOS') ? fallback : 'macOS'
+ }
+
+ if (platform === 'iOS') {
+ const fallback = getOperatingSystemFromUserAgent()
+
+ return fallback.startsWith('iOS') ? fallback : 'iOS'
+ }
+
+ if (platform === 'Linux') {
+ return 'Linux'
+ }
+ } catch {
+ return getOperatingSystemFromUserAgent()
+ }
+ }
+
+ return getOperatingSystemFromUserAgent()
+}
+
+function getDeviceType() {
+ const userAgent = window.navigator.userAgent
+
+ if (/iPhone|Android.*Mobile/i.test(userAgent)) {
+ return 'Smartphone'
+ }
+
+ if (/iPad|Tablet|Android/i.test(userAgent)) {
+ return 'Tablet'
+ }
+
+ return 'Desktop'
+}
+
+export async function getCurrentSessionInfo(): Promise {
+ const operatingSystem = await getOperatingSystem()
+
+ return {
+ browser: getBrowserName(),
+ operatingSystem,
+ deviceType: getDeviceType(),
+ }
+}
\ No newline at end of file
|