This commit is contained in:
Linrador 2026-05-28 15:06:37 +02:00
parent 9ecb3acb14
commit 47bf652b56
63 changed files with 7312 additions and 2170 deletions

View File

@ -17,4 +17,4 @@ ADMIN_GROUP=admin
ADMIN_RIGHTS=admin,users:read,users:write
ADMIN_TEAM_NAME=Administration
ADMIN_TEAM_INITIAL=A
ADMIN_TEAM_HREF=#
ADMIN_TEAM_HREF=#administration

192
backend/address_reverse.go Normal file
View File

@ -0,0 +1,192 @@
// backend\address_reverse.go
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
type reverseAddressSuggestion struct {
ID string `json:"id"`
Label string `json:"label"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
Type string `json:"type,omitempty"`
GeoJSON json.RawMessage `json:"geojson,omitempty"`
}
type nominatimReverseAddress struct {
HouseNumber string `json:"house_number"`
Road string `json:"road"`
Footway string `json:"footway"`
Pedestrian string `json:"pedestrian"`
Path string `json:"path"`
Suburb string `json:"suburb"`
Quarter string `json:"quarter"`
CityDistrict string `json:"city_district"`
Borough string `json:"borough"`
City string `json:"city"`
Town string `json:"town"`
Village string `json:"village"`
Municipality string `json:"municipality"`
County string `json:"county"`
State string `json:"state"`
Postcode string `json:"postcode"`
Country string `json:"country"`
}
type nominatimReverseResponse struct {
PlaceID int64 `json:"place_id"`
OSMType string `json:"osm_type"`
OSMID int64 `json:"osm_id"`
DisplayName string `json:"display_name"`
Lat string `json:"lat"`
Lon string `json:"lon"`
Type string `json:"type"`
Address nominatimReverseAddress `json:"address"`
GeoJSON json.RawMessage `json:"geojson,omitempty"`
}
func appendUnique(parts []string, value string) []string {
value = strings.TrimSpace(value)
if value == "" {
return parts
}
for _, part := range parts {
if strings.EqualFold(part, value) {
return parts
}
}
return append(parts, value)
}
func formatReverseAddressLabel(result nominatimReverseResponse) string {
address := result.Address
street := firstNonEmpty(
address.Road,
address.Footway,
address.Pedestrian,
address.Path,
)
streetLine := street
if street != "" && strings.TrimSpace(address.HouseNumber) != "" {
streetLine = fmt.Sprintf("%s %s", street, strings.TrimSpace(address.HouseNumber))
}
city := firstNonEmpty(
address.City,
address.Town,
address.Village,
address.Municipality,
)
postcodeCity := city
if strings.TrimSpace(address.Postcode) != "" && city != "" {
postcodeCity = fmt.Sprintf("%s %s", strings.TrimSpace(address.Postcode), city)
}
parts := []string{}
parts = appendUnique(parts, streetLine)
parts = appendUnique(parts, postcodeCity)
parts = appendUnique(parts, address.State)
parts = appendUnique(parts, address.Country)
if len(parts) > 0 {
return strings.Join(parts, ", ")
}
return strings.TrimSpace(result.DisplayName)
}
func (s *Server) handleAddressReverse(w http.ResponseWriter, r *http.Request) {
latRaw := strings.TrimSpace(r.URL.Query().Get("lat"))
lonRaw := strings.TrimSpace(r.URL.Query().Get("lon"))
lat, err := strconv.ParseFloat(latRaw, 64)
if err != nil || lat < -90 || lat > 90 {
writeError(w, http.StatusBadRequest, "Ungültige Latitude")
return
}
lon, err := strconv.ParseFloat(lonRaw, 64)
if err != nil || lon < -180 || lon > 180 {
writeError(w, http.StatusBadRequest, "Ungültige Longitude")
return
}
params := url.Values{}
params.Set("format", "jsonv2")
params.Set("addressdetails", "1")
params.Set("polygon_geojson", "1")
params.Set("lat", strconv.FormatFloat(lat, 'f', 7, 64))
params.Set("lon", strconv.FormatFloat(lon, 'f', 7, 64))
params.Set("zoom", "18")
params.Set("accept-language", "de")
reverseURL := fmt.Sprintf(
"https://nominatim.openstreetmap.org/reverse?%s",
params.Encode(),
)
request, err := http.NewRequestWithContext(r.Context(), http.MethodGet, reverseURL, nil)
if err != nil {
writeError(w, http.StatusInternalServerError, "Adresse konnte nicht vorbereitet werden")
return
}
request.Header.Set("User-Agent", "TEG/1.0 address reverse lookup")
client := &http.Client{
Timeout: 8 * time.Second,
}
response, err := client.Do(request)
if err != nil {
writeError(w, http.StatusBadGateway, "Adresse konnte nicht ermittelt werden")
return
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
writeError(w, http.StatusBadGateway, "Adresse konnte nicht ermittelt werden")
return
}
var result nominatimReverseResponse
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
writeError(w, http.StatusBadGateway, "Adresse konnte nicht gelesen werden")
return
}
label := formatReverseAddressLabel(result)
if label == "" {
writeError(w, http.StatusNotFound, "Für diese Position wurde keine Adresse gefunden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"suggestion": reverseAddressSuggestion{
ID: fmt.Sprintf("reverse-%d-%d", result.PlaceID, time.Now().UnixNano()),
Label: label,
Lat: lat,
Lon: lon,
Type: result.Type,
GeoJSON: result.GeoJSON,
},
})
}

View File

@ -49,11 +49,12 @@ type photonGeometry struct {
}
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"`
PlaceID int64 `json:"place_id"`
DisplayName string `json:"display_name"`
Lat string `json:"lat"`
Lon string `json:"lon"`
Type string `json:"type"`
Address nominatimReverseAddress `json:"address"`
}
func (s *Server) handleAddressSearch(w http.ResponseWriter, r *http.Request) {
@ -234,7 +235,11 @@ func (s *Server) handleNominatimAddressSearch(
suggestions := make([]AddressSuggestion, 0, len(results))
for _, result := range results {
label := strings.TrimSpace(result.DisplayName)
label := formatReverseAddressLabel(nominatimReverseResponse{
DisplayName: result.DisplayName,
Address: result.Address,
})
if label == "" {
continue
}

View File

@ -72,6 +72,10 @@ func normalizeAdminUserInput(input *AdminUserRequest) {
input.TeamIDs = cleanStringList(input.TeamIDs)
}
func ensureAdminUserLookupValues(ctx context.Context, tx pgx.Tx, unit string) error {
return ensureOptionalDeviceLookupValue(ctx, tx, "user_units", unit)
}
func normalizeAdminTeamInput(input *AdminTeamRequest) {
input.Name = strings.TrimSpace(input.Name)
input.Initial = strings.TrimSpace(input.Initial)
@ -130,7 +134,7 @@ func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
defer rows.Close()
users := []AdminUser{}
userByID := map[string]*AdminUser{}
userIndexByID := map[string]int{}
for rows.Next() {
var user AdminUser
@ -152,8 +156,14 @@ func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
}
user.Teams = []AdminTeam{}
users = append(users, user)
userByID[user.ID] = &users[len(users)-1]
userIndexByID[user.ID] = len(users) - 1
}
if err := rows.Err(); err != nil {
writeError(w, http.StatusInternalServerError, "Benutzer konnten nicht vollständig gelesen werden")
return
}
teamRows, err := s.db.Query(
@ -196,9 +206,17 @@ func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
return
}
if user := userByID[userID]; user != nil {
user.Teams = append(user.Teams, team)
userIndex, ok := userIndexByID[userID]
if !ok {
continue
}
users[userIndex].Teams = append(users[userIndex].Teams, team)
}
if err := teamRows.Err(); err != nil {
writeError(w, http.StatusInternalServerError, "Team-Zuordnungen konnten nicht vollständig gelesen werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
@ -239,6 +257,11 @@ func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
}
defer tx.Rollback(r.Context())
if err := ensureAdminUserLookupValues(r.Context(), tx, input.Unit); err != nil {
writeError(w, http.StatusInternalServerError, "Einheit konnte nicht gespeichert werden")
return
}
var userID string
err = tx.QueryRow(
@ -289,6 +312,10 @@ func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
return
}
if createdUser, err := s.getUserByID(r.Context(), userID); err == nil {
s.publishUserProfileEvent("user.created", createdUser)
}
writeJSON(w, http.StatusCreated, map[string]any{
"ok": true,
})
@ -323,6 +350,11 @@ func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
}
defer tx.Rollback(r.Context())
if err := ensureAdminUserLookupValues(r.Context(), tx, input.Unit); err != nil {
writeError(w, http.StatusInternalServerError, "Einheit konnte nicht gespeichert werden")
return
}
if input.Password != "" {
passwordHash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
if err != nil {
@ -403,6 +435,10 @@ func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
return
}
if updatedUser, err := s.getUserByID(r.Context(), userID); err == nil {
s.publishUserProfileEvent("user.updated", updatedUser)
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
})

View File

@ -149,6 +149,7 @@ type milestoneHardwareDevice struct {
Manufacturer string
MacAddress string
IPAddress string
Port string
DeviceModelName string
SerialNumber string
FirmwareVersion string
@ -830,7 +831,7 @@ func (s *Server) requestMilestoneHardwareDevices(
enabled = !*hardware.Disabled
}
ipAddress := extractMilestoneHardwareIPAddress(hardware.Address)
ipAddress, port := extractMilestoneHardwareAddressParts(hardware.Address)
result = append(result, milestoneHardwareDevice{
HardwareID: hardwareID,
@ -840,6 +841,7 @@ func (s *Server) requestMilestoneHardwareDevices(
Manufacturer: manufacturer,
MacAddress: strings.TrimSpace(settings.MacAddress),
IPAddress: ipAddress,
Port: port,
DeviceModelName: model,
SerialNumber: strings.TrimSpace(settings.SerialNumber),
FirmwareVersion: strings.TrimSpace(settings.FirmwareVersion),
@ -857,25 +859,35 @@ func (s *Server) requestMilestoneHardwareDevices(
}
func extractMilestoneHardwareIPAddress(value string) string {
host, _ := extractMilestoneHardwareAddressParts(value)
return host
}
func extractMilestoneHardwarePort(value string) string {
_, port := extractMilestoneHardwareAddressParts(value)
return port
}
func extractMilestoneHardwareAddressParts(value string) (string, string) {
value = strings.TrimSpace(value)
if value == "" {
return ""
return "", ""
}
parsedURL, err := url.Parse(value)
if err == nil && parsedURL.Hostname() != "" {
return parsedURL.Hostname()
return strings.TrimSpace(parsedURL.Hostname()), strings.TrimSpace(parsedURL.Port())
}
value = strings.TrimPrefix(value, "http://")
value = strings.TrimPrefix(value, "https://")
value = strings.TrimRight(value, "/")
if host, _, found := strings.Cut(value, ":"); found {
return strings.TrimSpace(host)
if host, port, found := strings.Cut(value, ":"); found {
return strings.TrimSpace(host), strings.TrimSpace(port)
}
return strings.TrimSpace(value)
return strings.TrimSpace(value), ""
}
func (s *Server) requestMilestoneHardware(

View File

@ -1,4 +1,4 @@
// backend/camera_firmware.go
// backend\camera_firmware.go
package main

View File

@ -12,7 +12,7 @@ func (s *Server) cors(next http.Handler) http.Handler {
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, Authorization")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
}
if r.Method == http.MethodOptions {

View File

@ -50,68 +50,19 @@ func (s *Server) publishDeviceChangeEvent(
return err
}
var notification Notification
err = s.db.QueryRow(
if err := s.createAndPublishNotification(
ctx,
`
INSERT INTO notifications (
user_id,
type,
title,
message,
visible,
entity_type,
entity_id,
data
)
VALUES (
$1,
$2,
$3,
$4,
false,
'device',
$5,
$6
)
RETURNING
id::TEXT,
user_id::TEXT,
type,
title,
message,
entity_type,
COALESCE(entity_id::TEXT, ''),
data,
visible,
read_at,
created_at
`,
userID,
notificationType,
title,
message,
"device",
deviceID,
dataJSON,
).Scan(
&notification.ID,
&notification.UserID,
&notification.Type,
&notification.Title,
&notification.Message,
&notification.EntityType,
&notification.EntityID,
&notification.Data,
&notification.Visible,
&notification.ReadAt,
&notification.CreatedAt,
)
if err != nil {
true,
); err != nil {
return err
}
notificationsBroker.publish(notification)
}
return rows.Err()

View File

@ -1,4 +1,4 @@
// backend/devices.go
// backend\devices.go
package main
@ -21,6 +21,7 @@ type Device struct {
SerialNumber string `json:"serialNumber"`
MacAddress string `json:"macAddress"`
IPAddress string `json:"ipAddress"`
MilestonePort string `json:"milestonePort"`
Location string `json:"location"`
LoanStatus string `json:"loanStatus"`
IsBaoDevice bool `json:"isBaoDevice"`
@ -69,8 +70,10 @@ type CreateDeviceRequest struct {
SerialNumber string `json:"serialNumber"`
MacAddress string `json:"macAddress"`
IPAddress string `json:"ipAddress"`
MilestonePort string `json:"milestonePort"`
FirmwareVersion string `json:"firmwareVersion"`
MilestoneDisplayName string `json:"milestoneDisplayName"`
MilestoneEnabled *bool `json:"milestoneEnabled,omitempty"`
Location string `json:"location"`
LoanStatus string `json:"loanStatus"`
IsBaoDevice bool `json:"isBaoDevice"`
@ -80,6 +83,10 @@ type CreateDeviceRequest struct {
type UpdateDeviceRequest = CreateDeviceRequest
type UpdateDeviceJournalEntryRequest struct {
Content string `json:"content"`
}
var errDeviceNotFound = errors.New("device does not exist")
func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) {
@ -105,6 +112,7 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
serial_number,
mac_address,
COALESCE(ip_address, ''),
COALESCE(milestone_port, ''),
location,
loan_status,
is_bao_device,
@ -142,6 +150,7 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
&device.SerialNumber,
&device.MacAddress,
&device.IPAddress,
&device.MilestonePort,
&device.Location,
&device.LoanStatus,
&device.IsBaoDevice,
@ -270,13 +279,14 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) {
serial_number,
mac_address,
ip_address,
milestone_port,
location,
loan_status,
is_bao_device,
comment,
firmware_version
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING
id,
inventory_number,
@ -285,6 +295,7 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) {
serial_number,
mac_address,
COALESCE(ip_address, ''),
COALESCE(milestone_port, ''),
location,
loan_status,
is_bao_device,
@ -304,6 +315,7 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) {
input.SerialNumber,
input.MacAddress,
input.IPAddress,
input.MilestonePort,
input.Location,
input.LoanStatus,
input.IsBaoDevice,
@ -317,6 +329,7 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) {
&device.SerialNumber,
&device.MacAddress,
&device.IPAddress,
&device.MilestonePort,
&device.Location,
&device.LoanStatus,
&device.IsBaoDevice,
@ -449,6 +462,11 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
return
}
nextMilestoneEnabled := oldDevice.MilestoneEnabled
if input.MilestoneEnabled != nil {
nextMilestoneEnabled = *input.MilestoneEnabled
}
var device Device
err = tx.QueryRow(
@ -462,12 +480,25 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
serial_number = $5,
mac_address = $6,
ip_address = $7,
location = $8,
loan_status = $9,
is_bao_device = $10,
comment = $11,
firmware_version = $12,
milestone_display_name = $13,
milestone_port = $8,
location = $9,
loan_status = $10,
is_bao_device = $11,
comment = $12,
firmware_version = $13,
milestone_display_name = $14,
milestone_enabled = $15,
milestone_synced_at = CASE
WHEN COALESCE(milestone_hardware_id, '') <> ''
AND (
COALESCE(milestone_display_name, '') <> $14
OR COALESCE(ip_address, '') <> $7
OR COALESCE(milestone_port, '') <> $8
OR milestone_enabled <> $15
)
THEN now()
ELSE milestone_synced_at
END,
updated_at = now()
WHERE id = $1
RETURNING
@ -478,6 +509,7 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
serial_number,
mac_address,
COALESCE(ip_address, ''),
COALESCE(milestone_port, ''),
location,
loan_status,
is_bao_device,
@ -498,12 +530,14 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
input.SerialNumber,
input.MacAddress,
input.IPAddress,
input.MilestonePort,
input.Location,
input.LoanStatus,
input.IsBaoDevice,
input.Comment,
input.FirmwareVersion,
input.MilestoneDisplayName,
nextMilestoneEnabled,
).Scan(
&device.ID,
&device.InventoryNumber,
@ -512,6 +546,7 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
&device.SerialNumber,
&device.MacAddress,
&device.IPAddress,
&device.MilestonePort,
&device.Location,
&device.LoanStatus,
&device.IsBaoDevice,
@ -634,6 +669,7 @@ func normalizeDeviceInput(input *CreateDeviceRequest) {
input.SerialNumber = strings.TrimSpace(input.SerialNumber)
input.MacAddress = strings.TrimSpace(input.MacAddress)
input.IPAddress = strings.TrimSpace(input.IPAddress)
input.MilestonePort = strings.TrimSpace(input.MilestonePort)
input.FirmwareVersion = strings.TrimSpace(input.FirmwareVersion)
input.MilestoneDisplayName = strings.TrimSpace(input.MilestoneDisplayName)
input.Location = strings.TrimSpace(input.Location)

View File

@ -164,7 +164,7 @@ func (s *Server) handleCreateFeedback(w http.ResponseWriter, r *http.Request) {
"Neues Feedback",
formatText("Neues Feedback von %s.", displayFeedbackUserName(feedback)),
feedback.ID,
true,
false,
map[string]any{
"feedbackId": feedback.ID,
"userId": feedback.UserID,
@ -314,7 +314,7 @@ func (s *Server) handleDeleteFeedback(w http.ResponseWriter, r *http.Request) {
"Feedback gelöscht",
formatText("Feedback von %s wurde gelöscht.", displayFeedbackUserName(deleted)),
deleted.ID,
false,
true,
map[string]any{
"feedbackId": deleted.ID,
"userId": deleted.UserID,
@ -375,70 +375,19 @@ func (s *Server) notifyAdministratorsAboutFeedback(
return err
}
var notification Notification
err = s.db.QueryRow(
if err := s.createAndPublishNotification(
ctx,
`
INSERT INTO notifications (
user_id,
type,
title,
message,
visible,
entity_type,
entity_id,
data
)
VALUES (
$1,
$2,
$3,
$4,
true,
'feedback',
$5,
$6,
$7
)
RETURNING
id::TEXT,
user_id::TEXT,
type,
title,
message,
entity_type,
COALESCE(entity_id::TEXT, ''),
data,
visible,
read_at,
created_at
`,
adminUserID,
notificationType,
title,
message,
visible,
"feedback",
feedbackID,
dataJSON,
).Scan(
&notification.ID,
&notification.UserID,
&notification.Type,
&notification.Title,
&notification.Message,
&notification.EntityType,
&notification.EntityID,
&notification.Data,
&notification.Visible,
&notification.ReadAt,
&notification.CreatedAt,
)
if err != nil {
visible,
); err != nil {
return err
}
notificationsBroker.publish(notification)
}
return adminRows.Err()

View File

@ -21,6 +21,8 @@ type DeviceHistoryEntry struct {
Action string `json:"action"`
Changes []DeviceHistoryChange `json:"changes"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
CanEdit bool `json:"canEdit"`
}
type DeviceHistoryChange struct {
@ -42,6 +44,12 @@ func (s *Server) handleListDeviceHistory(w http.ResponseWriter, r *http.Request)
return
}
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
rows, err := s.db.Query(
r.Context(),
`
@ -52,7 +60,8 @@ func (s *Server) handleListDeviceHistory(w http.ResponseWriter, r *http.Request)
COALESCE(NULLIF(u.display_name, ''), NULLIF(u.username, ''), u.email, 'Unbekannt'),
h.action,
h.changes,
h.created_at
h.created_at,
COALESCE(h.updated_at, h.created_at)
FROM device_history h
LEFT JOIN users u ON u.id = h.user_id
WHERE h.device_id = $1
@ -81,6 +90,7 @@ func (s *Server) handleListDeviceHistory(w http.ResponseWriter, r *http.Request)
&entry.Action,
&changesRaw,
&entry.CreatedAt,
&entry.UpdatedAt,
); err != nil {
writeError(w, http.StatusInternalServerError, "Could not read device history")
return
@ -93,6 +103,11 @@ func (s *Server) handleListDeviceHistory(w http.ResponseWriter, r *http.Request)
}
}
entry.CanEdit =
entry.Action == "journal" &&
entry.UserID == user.ID &&
time.Since(entry.CreatedAt) <= 10*time.Minute
history = append(history, entry)
}
@ -239,6 +254,13 @@ func getDeviceForHistory(ctx context.Context, tx pgx.Tx, deviceID string) (Devic
model,
serial_number,
mac_address,
COALESCE(ip_address, ''),
COALESCE(firmware_version, ''),
COALESCE(milestone_recording_server_id, ''),
COALESCE(milestone_hardware_id, ''),
COALESCE(milestone_display_name, ''),
milestone_enabled,
milestone_synced_at,
location,
loan_status,
is_bao_device,
@ -257,6 +279,13 @@ func getDeviceForHistory(ctx context.Context, tx pgx.Tx, deviceID string) (Devic
&device.Model,
&device.SerialNumber,
&device.MacAddress,
&device.IPAddress,
&device.FirmwareVersion,
&device.MilestoneRecordingServerID,
&device.MilestoneHardwareID,
&device.MilestoneDisplayName,
&device.MilestoneEnabled,
&device.MilestoneSyncedAt,
&device.Location,
&device.LoanStatus,
&device.IsBaoDevice,
@ -304,6 +333,190 @@ func getDeviceForHistory(ctx context.Context, tx pgx.Tx, deviceID string) (Devic
return device, relatedDeviceIDs, nil
}
func (s *Server) handleUpdateDeviceJournalEntry(w http.ResponseWriter, r *http.Request) {
deviceID := strings.TrimSpace(r.PathValue("id"))
historyID := strings.TrimSpace(r.PathValue("historyId"))
if deviceID == "" {
writeError(w, http.StatusBadRequest, "Geräte-ID fehlt")
return
}
if historyID == "" {
writeError(w, http.StatusBadRequest, "Journal-ID fehlt")
return
}
var input UpdateDeviceJournalEntryRequest
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())
if err := ensureDeviceExists(r.Context(), tx, deviceID); err != nil {
if errors.Is(err, errDeviceNotFound) {
writeError(w, http.StatusNotFound, "Gerät wurde nicht gefunden")
return
}
writeError(w, http.StatusInternalServerError, "Gerät konnte nicht geladen werden")
return
}
var changesRaw []byte
var createdAt time.Time
err = tx.QueryRow(
r.Context(),
`
SELECT changes, created_at
FROM device_history
WHERE id = $1
AND device_id = $2
AND user_id = $3
AND action = 'journal'
LIMIT 1
`,
historyID,
deviceID,
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 []DeviceHistoryChange
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 := []DeviceHistoryChange{
{
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 device_history
SET
changes = $4::JSONB,
updated_at = now()
WHERE id = $1
AND device_id = $2
AND user_id = $3
AND action = 'journal'
AND created_at >= now() - interval '10 minutes'
`,
historyID,
deviceID,
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
}
if err := s.publishDeviceChangeEvent(
r.Context(),
user.ID,
deviceID,
"device.journal",
"Geräte-Journal aktualisiert",
"Ein Geräte-Journal-Eintrag wurde aktualisiert.",
LogFields{
"deviceId": deviceID,
"historyId": historyID,
"action": "updated",
},
); err != nil {
logError("Device-Journal-SSE-Event konnte nicht gesendet werden", err, LogFields{
"deviceId": deviceID,
"historyId": historyID,
"type": "device.journal",
})
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
})
}
func formatHistoryBool(value bool) string {
if value {
return "Ja"
@ -357,6 +570,17 @@ func buildDeviceUpdateHistoryChanges(
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)
changes = appendHistoryChange(changes, "milestoneDisplayName", "Milestone-Anzeigename", oldDevice.MilestoneDisplayName, input.MilestoneDisplayName)
changes = appendHistoryChange(changes, "ipAddress", "IP-Adresse", oldDevice.IPAddress, input.IPAddress)
if input.MilestoneEnabled != nil {
changes = appendHistoryChange(
changes,
"milestoneEnabled",
"Milestone-Status",
formatHistoryBool(oldDevice.MilestoneEnabled),
formatHistoryBool(*input.MilestoneEnabled),
)
}
oldRelatedDevices, err := formatRelatedDeviceLabels(ctx, tx, oldRelatedDeviceIDs)
if err != nil {

614
backend/milestone_api.go Normal file
View File

@ -0,0 +1,614 @@
// backend\milestone_api.go
package main
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
type milestoneRuntimeConfig struct {
Host string
AccessToken string
TokenType string
SkipTLSVerify bool
TokenExpiresAt *time.Time
}
type milestoneHardwarePatchDevice struct {
ID string
InventoryNumber string
MilestoneHardwareID string
MilestoneDisplayName string
IPAddress string
MilestonePort string
MilestoneEnabled bool
}
type updateMilestoneHardwareRequest struct {
Name *string `json:"name"`
DisplayName *string `json:"displayName"`
MilestoneDisplayName *string `json:"milestoneDisplayName"`
IPAddress *string `json:"ipAddress"`
Address *string `json:"address"`
Port *string `json:"port"`
MilestonePort *string `json:"milestonePort"`
Enabled *bool `json:"enabled"`
}
func (s *Server) getMilestoneHardwarePatchDevice(
ctx context.Context,
deviceID string,
) (milestoneHardwarePatchDevice, error) {
var device milestoneHardwarePatchDevice
err := s.db.QueryRow(
ctx,
`
SELECT
id::TEXT,
inventory_number,
COALESCE(milestone_hardware_id, ''),
COALESCE(milestone_display_name, ''),
COALESCE(ip_address, ''),
COALESCE(milestone_port, ''),
milestone_enabled
FROM devices
WHERE id = $1
LIMIT 1
`,
deviceID,
).Scan(
&device.ID,
&device.InventoryNumber,
&device.MilestoneHardwareID,
&device.MilestoneDisplayName,
&device.IPAddress,
&device.MilestonePort,
&device.MilestoneEnabled,
)
return device, err
}
func (s *Server) handleUpdateMilestoneHardware(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
deviceID := strings.TrimSpace(r.PathValue("id"))
if deviceID == "" {
writeError(w, http.StatusBadRequest, "Geräte-ID fehlt")
return
}
var input updateMilestoneHardwareRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON")
return
}
if input.Name == nil &&
input.DisplayName == nil &&
input.MilestoneDisplayName == nil &&
input.IPAddress == nil &&
input.Address == nil &&
input.Port == nil &&
input.MilestonePort == nil &&
input.Enabled == nil {
writeError(w, http.StatusBadRequest, "Keine Milestone-Felder zum Aktualisieren übergeben")
return
}
device, err := s.getMilestoneHardwarePatchDevice(r.Context(), 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
}
if strings.TrimSpace(device.MilestoneHardwareID) == "" {
writeError(w, http.StatusBadRequest, "Dieses Gerät ist kein Milestone-Gerät")
return
}
nextDisplayName := strings.TrimSpace(device.MilestoneDisplayName)
nextIPAddress := strings.TrimSpace(device.IPAddress)
nextPort := strings.TrimSpace(device.MilestonePort)
nextEnabled := device.MilestoneEnabled
payload := map[string]any{}
changes := []DeviceHistoryChange{}
requestedDisplayName := input.Name
if requestedDisplayName == nil {
requestedDisplayName = input.DisplayName
}
if requestedDisplayName == nil {
requestedDisplayName = input.MilestoneDisplayName
}
if requestedDisplayName != nil {
nextDisplayName = strings.TrimSpace(*requestedDisplayName)
// Wenn der Client einen Namen sendet, immer an Milestone weitergeben.
payload["name"] = nextDisplayName
if nextDisplayName != strings.TrimSpace(device.MilestoneDisplayName) {
changes = appendHistoryChange(
changes,
"milestoneDisplayName",
"Milestone-Anzeigename",
device.MilestoneDisplayName,
nextDisplayName,
)
}
}
requestedAddress := input.Address
if requestedAddress == nil {
requestedAddress = input.IPAddress
}
requestedPort := input.Port
if requestedPort == nil {
requestedPort = input.MilestonePort
}
if requestedAddress != nil || requestedPort != nil {
addressValue := device.IPAddress
if requestedAddress != nil {
addressValue = *requestedAddress
}
portValue := device.MilestonePort
if requestedPort != nil {
portValue = *requestedPort
}
nextAddress := normalizeMilestoneHardwareAddress(addressValue, portValue)
nextIPAddress, parsedPort := extractMilestoneHardwareAddressParts(nextAddress)
nextPort = normalizeMilestonePort(portValue)
if nextPort == "" {
nextPort = parsedPort
}
if nextIPAddress == "" {
nextIPAddress = strings.TrimSpace(addressValue)
}
// Wenn der Client Adresse oder Port sendet, immer an Milestone weitergeben.
payload["address"] = nextAddress
if nextIPAddress != strings.TrimSpace(device.IPAddress) {
changes = appendHistoryChange(
changes,
"ipAddress",
"IP-Adresse",
device.IPAddress,
nextIPAddress,
)
}
if nextPort != strings.TrimSpace(device.MilestonePort) {
changes = appendHistoryChange(
changes,
"milestonePort",
"Milestone-Port",
device.MilestonePort,
nextPort,
)
}
}
if input.Enabled != nil {
nextEnabled = *input.Enabled
// Wenn der Client den Status sendet, immer an Milestone weitergeben.
payload["enabled"] = nextEnabled
if nextEnabled != device.MilestoneEnabled {
changes = appendHistoryChange(
changes,
"milestoneEnabled",
"Milestone-Status",
formatHistoryBool(device.MilestoneEnabled),
formatHistoryBool(nextEnabled),
)
}
}
if len(payload) == 0 {
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"id": device.ID,
"milestoneDisplayName": device.MilestoneDisplayName,
"ipAddress": device.IPAddress,
"milestonePort": device.MilestonePort,
"milestoneEnabled": device.MilestoneEnabled,
})
return
}
config, err := s.getMilestoneRuntimeConfig(r.Context())
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusBadRequest, "Milestone-Einstellungen sind noch nicht hinterlegt")
return
}
if err != nil {
s.logAndWriteUserError(
w,
r,
user,
http.StatusBadGateway,
"milestone_hardware",
"load_or_refresh_token",
"Milestone-Token konnte nicht geladen oder erneuert werden",
err,
LogFields{
"deviceId": device.ID,
"inventoryNumber": device.InventoryNumber,
"milestoneHardwareId": device.MilestoneHardwareID,
},
)
return
}
if strings.TrimSpace(config.Host) == "" {
writeError(w, http.StatusBadRequest, "Milestone Host fehlt")
return
}
if strings.TrimSpace(config.AccessToken) == "" {
writeError(w, http.StatusBadRequest, "Milestone-Token fehlt und konnte nicht automatisch erneuert werden")
return
}
if err := patchMilestoneHardware(
r.Context(),
config,
device.MilestoneHardwareID,
payload,
); err != nil {
s.logAndWriteUserError(
w,
r,
user,
http.StatusBadGateway,
"milestone_hardware",
"patch_hardware",
"Milestone-Gerät konnte nicht aktualisiert werden",
err,
LogFields{
"deviceId": device.ID,
"inventoryNumber": device.InventoryNumber,
"milestoneHardwareId": device.MilestoneHardwareID,
"payload": payload,
},
)
return
}
tx, err := s.db.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "Milestone-Gerät konnte lokal nicht aktualisiert werden")
return
}
defer tx.Rollback(r.Context())
_, err = tx.Exec(
r.Context(),
`
UPDATE devices
SET
milestone_display_name = $2,
ip_address = $3,
milestone_port = $4,
milestone_enabled = $5,
milestone_synced_at = now(),
updated_at = now()
WHERE id = $1
`,
device.ID,
nextDisplayName,
nextIPAddress,
nextPort,
nextEnabled,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Milestone-Gerät konnte lokal nicht aktualisiert werden")
return
}
if len(changes) > 0 {
if err := recordDeviceHistory(r.Context(), tx, device.ID, user, "updated", changes); err != nil {
writeError(w, http.StatusInternalServerError, "Geräteverlauf konnte nicht gespeichert werden")
return
}
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "Milestone-Gerät konnte lokal nicht aktualisiert werden")
return
}
if err := s.publishDeviceChangeEvent(
r.Context(),
user.ID,
device.ID,
"device.milestone.hardware",
"Milestone-Gerät aktualisiert",
formatText("Gerät %s wurde in Milestone aktualisiert.", device.InventoryNumber),
LogFields{
"inventoryNumber": device.InventoryNumber,
"milestoneHardwareId": device.MilestoneHardwareID,
"payload": payload,
},
); err != nil {
logError("Device-SSE-Event konnte nicht gesendet werden", err, LogFields{
"deviceId": device.ID,
"type": "device.milestone.hardware",
})
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"id": device.ID,
"milestoneDisplayName": nextDisplayName,
"ipAddress": nextIPAddress,
"milestonePort": nextPort,
"milestoneEnabled": nextEnabled,
})
}
func normalizeMilestoneHardwareAddress(value string, port string) string {
value = strings.TrimSpace(value)
port = normalizeMilestonePort(port)
if value == "" || value == "-" {
return ""
}
if !strings.HasPrefix(value, "http://") && !strings.HasPrefix(value, "https://") {
value = "http://" + strings.TrimRight(value, "/")
}
parsedURL, err := url.Parse(value)
if err != nil || parsedURL.Hostname() == "" {
value = strings.TrimRight(value, "/")
if port != "" && !strings.Contains(value, ":") {
value += ":" + port
}
return value + "/"
}
scheme := strings.TrimSpace(parsedURL.Scheme)
if scheme == "" {
scheme = "http"
}
host := parsedURL.Hostname()
if port == "" {
port = parsedURL.Port()
}
hostPort := host
if port != "" {
hostPort += ":" + port
}
return scheme + "://" + hostPort + "/"
}
func normalizeMilestonePort(value string) string {
value = strings.TrimSpace(value)
value = strings.TrimPrefix(value, ":")
if value == "-" {
return ""
}
return value
}
const milestoneTokenRefreshBeforeExpiry = 2 * time.Minute
func (s *Server) getMilestoneRuntimeConfig(
ctx context.Context,
) (milestoneRuntimeConfig, error) {
config, err := s.loadMilestoneRuntimeConfig(ctx)
if err != nil {
return config, err
}
if shouldRefreshMilestoneToken(config) {
if err := s.refreshMilestoneRuntimeToken(ctx); err != nil {
return config, err
}
return s.loadMilestoneRuntimeConfig(ctx)
}
return config, nil
}
func shouldRefreshMilestoneToken(config milestoneRuntimeConfig) bool {
if strings.TrimSpace(config.AccessToken) == "" {
return true
}
if config.TokenExpiresAt == nil {
return true
}
return time.Now().After(config.TokenExpiresAt.Add(-milestoneTokenRefreshBeforeExpiry))
}
func (s *Server) loadMilestoneRuntimeConfig(
ctx context.Context,
) (milestoneRuntimeConfig, error) {
var config milestoneRuntimeConfig
var tokenExpiresAt sql.NullTime
err := s.db.QueryRow(
ctx,
`
SELECT
host,
COALESCE(pgp_sym_decrypt(access_token_encrypted, $1), ''),
COALESCE(token_type, ''),
skip_tls_verify,
token_expires_at
FROM milestone_settings
WHERE id = 1
LIMIT 1
`,
s.milestoneEncryptionKey(),
).Scan(
&config.Host,
&config.AccessToken,
&config.TokenType,
&config.SkipTLSVerify,
&tokenExpiresAt,
)
if err != nil {
return config, err
}
if tokenExpiresAt.Valid {
config.TokenExpiresAt = &tokenExpiresAt.Time
}
return config, nil
}
func (s *Server) refreshMilestoneRuntimeToken(ctx context.Context) error {
credentials, err := s.getMilestoneCredentials(ctx)
if err != nil {
return err
}
if credentials.Host == "" || credentials.Username == "" || credentials.Password == "" {
return errors.New("Milestone-Zugangsdaten sind unvollständig")
}
tokenResponse, err := s.requestMilestoneToken(ctx, credentials)
if err != nil {
return err
}
accessToken := strings.TrimSpace(tokenResponse.AccessToken)
if accessToken == "" {
return errors.New("Milestone hat keinen Access-Token zurückgegeben")
}
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)
_, 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(),
updated_at = now()
WHERE id = 1
`,
accessToken,
s.milestoneEncryptionKey(),
tokenType,
tokenExpiresAt,
)
return err
}
func patchMilestoneHardware(
ctx context.Context,
config milestoneRuntimeConfig,
hardwareID string,
payload any,
) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
requestURL := strings.TrimRight(config.Host, "/") +
"/API/rest/v1/hardware/" +
url.PathEscape(hardwareID)
request, err := http.NewRequestWithContext(
ctx,
http.MethodPatch,
requestURL,
bytes.NewReader(body),
)
if err != nil {
return err
}
tokenType := strings.TrimSpace(config.TokenType)
if tokenType == "" {
tokenType = "Bearer"
}
request.Header.Set("Authorization", tokenType+" "+config.AccessToken)
request.Header.Set("Accept", "application/json")
request.Header.Set("Content-Type", "application/json")
response, err := milestoneHTTPClient(config.SkipTLSVerify).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
responseBody, _ := io.ReadAll(response.Body)
if response.StatusCode < 200 || response.StatusCode >= 300 {
return newAppErrorf(
"milestone hardware patch failed",
LogFields{
"statusCode": response.StatusCode,
"responseBody": string(responseBody),
"hardwareId": hardwareID,
},
"milestone hardware patch failed: status=%d body=%s",
response.StatusCode,
string(responseBody),
)
}
return nil
}

View File

@ -4,7 +4,6 @@ package main
import (
"context"
"fmt"
"strings"
"unicode"
@ -28,6 +27,7 @@ func syncMilestoneHardwareDevices(
hardwareDevice.SerialNumber = strings.TrimSpace(hardwareDevice.SerialNumber)
hardwareDevice.MacAddress = strings.TrimSpace(hardwareDevice.MacAddress)
hardwareDevice.IPAddress = strings.TrimSpace(hardwareDevice.IPAddress)
hardwareDevice.Port = strings.TrimSpace(hardwareDevice.Port)
hardwareDevice.FirmwareVersion = strings.TrimSpace(hardwareDevice.FirmwareVersion)
if hardwareDevice.HardwareID == "" {
@ -78,6 +78,7 @@ func createOrUpdateMilestoneHardwareDevice(
serial_number,
mac_address,
ip_address,
milestone_port,
location,
loan_status,
is_bao_device,
@ -96,10 +97,11 @@ func createOrUpdateMilestoneHardwareDevice(
$4,
$5,
$6,
$7,
'',
'Verfügbar',
false,
$7,
'',
$8,
$9,
$10,
@ -116,8 +118,7 @@ func createOrUpdateMilestoneHardwareDevice(
serial_number = EXCLUDED.serial_number,
mac_address = EXCLUDED.mac_address,
ip_address = EXCLUDED.ip_address,
location = EXCLUDED.location,
comment = EXCLUDED.comment,
milestone_port = EXCLUDED.milestone_port,
firmware_version = EXCLUDED.firmware_version,
milestone_recording_server_id = EXCLUDED.milestone_recording_server_id,
milestone_display_name = EXCLUDED.milestone_display_name,
@ -131,7 +132,7 @@ func createOrUpdateMilestoneHardwareDevice(
hardwareDevice.SerialNumber,
hardwareDevice.MacAddress,
hardwareDevice.IPAddress,
buildMilestoneDeviceComment(hardwareDevice),
hardwareDevice.Port,
hardwareDevice.FirmwareVersion,
hardwareDevice.RecordingServerID,
hardwareDevice.HardwareID,
@ -324,31 +325,3 @@ func buildGeneratedMilestoneInventoryNumber(hardwareID string) string {
return "MS-" + value
}
func buildMilestoneDeviceComment(hardwareDevice milestoneHardwareDevice) string {
parts := []string{}
if strings.TrimSpace(hardwareDevice.DisplayName) != "" {
parts = append(parts, strings.TrimSpace(hardwareDevice.DisplayName))
}
if strings.TrimSpace(hardwareDevice.DeviceName) != "" &&
strings.TrimSpace(hardwareDevice.DeviceName) != strings.TrimSpace(hardwareDevice.DisplayName) {
parts = append(parts, "DeviceName: "+strings.TrimSpace(hardwareDevice.DeviceName))
}
if hardwareDevice.Enabled {
parts = append(parts, "Status: aktiviert")
} else {
parts = append(parts, "Status: deaktiviert")
}
if len(parts) == 0 {
return "Automatisch aus Milestone synchronisiert."
}
return fmt.Sprintf(
"Automatisch aus Milestone synchronisiert: %s",
strings.Join(parts, " · "),
)
}

View File

@ -1,338 +0,0 @@
// backend/milestone_hardware.go
package main
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
type milestoneRuntimeConfig struct {
Host string
AccessToken string
TokenType string
SkipTLSVerify bool
TokenExpiresAt *time.Time
}
type milestoneToggleDevice struct {
ID string
InventoryNumber string
MilestoneHardwareID string
MilestoneEnabled bool
}
func (s *Server) handleToggleMilestoneDeviceStatus(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
deviceID := strings.TrimSpace(r.PathValue("id"))
if deviceID == "" {
writeError(w, http.StatusBadRequest, "Geräte-ID fehlt")
return
}
device, err := s.getMilestoneToggleDevice(r.Context(), 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
}
if device.MilestoneHardwareID == "" {
writeError(w, http.StatusBadRequest, "Dieses Gerät ist kein Milestone-Gerät")
return
}
config, err := s.getMilestoneRuntimeConfig(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-Einstellungen konnten nicht geladen werden")
return
}
if config.Host == "" || config.AccessToken == "" {
writeError(w, http.StatusBadRequest, "Milestone Host oder Token fehlt")
return
}
if config.TokenExpiresAt != nil && time.Now().After(config.TokenExpiresAt.Add(-30*time.Second)) {
writeError(w, http.StatusBadRequest, "Milestone-Token ist abgelaufen. Bitte Token neu abrufen.")
return
}
nextEnabled := !device.MilestoneEnabled
if err := patchMilestoneHardware(
r.Context(),
config,
device.MilestoneHardwareID,
map[string]bool{
"enabled": nextEnabled,
},
); err != nil {
s.logAndWriteUserError(
w,
r,
user,
http.StatusBadGateway,
"milestone_hardware",
"patch_hardware",
"Milestone-Status konnte nicht geändert werden",
err,
LogFields{
"deviceId": device.ID,
"inventoryNumber": device.InventoryNumber,
"milestoneHardwareId": device.MilestoneHardwareID,
"currentEnabled": device.MilestoneEnabled,
"nextEnabled": nextEnabled,
"payload": LogFields{
"enabled": nextEnabled,
},
},
)
return
}
_, err = s.db.Exec(
r.Context(),
`
UPDATE devices
SET
milestone_enabled = $2,
milestone_synced_at = now(),
updated_at = now()
WHERE id = $1
`,
device.ID,
nextEnabled,
)
if err != nil {
s.logAndWriteUserError(
w,
r,
user,
http.StatusInternalServerError,
"milestone_hardware",
"update_local_device",
"Milestone-Status konnte lokal nicht aktualisiert werden",
err,
LogFields{
"deviceId": device.ID,
"inventoryNumber": device.InventoryNumber,
"milestoneHardwareId": device.MilestoneHardwareID,
"nextEnabled": nextEnabled,
},
)
return
}
oldValue := "Aktiviert"
newValue := "Deaktiviert"
if nextEnabled {
oldValue = "Deaktiviert"
newValue = "Aktiviert"
}
changes, _ := json.Marshal([]map[string]string{
{
"field": "milestoneEnabled",
"label": "Milestone-Status",
"oldValue": oldValue,
"newValue": newValue,
},
})
_, _ = s.db.Exec(
r.Context(),
`
INSERT INTO device_history (
device_id,
user_id,
action,
changes
)
VALUES ($1, $2, 'updated', $3)
`,
device.ID,
user.ID,
changes,
)
if err := s.publishDeviceChangeEvent(
r.Context(),
user.ID,
device.ID,
"device.milestone.status",
"Milestone-Status geändert",
formatText(
"Gerät %s wurde in Milestone %s.",
device.InventoryNumber,
strings.ToLower(newValue),
),
LogFields{
"inventoryNumber": device.InventoryNumber,
"milestoneHardwareId": device.MilestoneHardwareID,
"milestoneEnabled": nextEnabled,
},
); err != nil {
logError("Device-SSE-Event konnte nicht gesendet werden", err, LogFields{
"deviceId": device.ID,
"type": "device.milestone.status",
})
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"id": device.ID,
"milestoneEnabled": nextEnabled,
})
}
func (s *Server) getMilestoneToggleDevice(
ctx context.Context,
deviceID string,
) (milestoneToggleDevice, error) {
var device milestoneToggleDevice
err := s.db.QueryRow(
ctx,
`
SELECT
id::TEXT,
inventory_number,
COALESCE(milestone_hardware_id, ''),
milestone_enabled
FROM devices
WHERE id = $1
LIMIT 1
`,
deviceID,
).Scan(
&device.ID,
&device.InventoryNumber,
&device.MilestoneHardwareID,
&device.MilestoneEnabled,
)
return device, err
}
func (s *Server) getMilestoneRuntimeConfig(
ctx context.Context,
) (milestoneRuntimeConfig, error) {
var config milestoneRuntimeConfig
var tokenExpiresAt sql.NullTime
err := s.db.QueryRow(
ctx,
`
SELECT
host,
COALESCE(pgp_sym_decrypt(access_token_encrypted, $1), ''),
COALESCE(token_type, ''),
skip_tls_verify,
token_expires_at
FROM milestone_settings
WHERE id = 1
LIMIT 1
`,
s.milestoneEncryptionKey(),
).Scan(
&config.Host,
&config.AccessToken,
&config.TokenType,
&config.SkipTLSVerify,
&tokenExpiresAt,
)
if err != nil {
return config, err
}
if tokenExpiresAt.Valid {
config.TokenExpiresAt = &tokenExpiresAt.Time
}
return config, nil
}
func patchMilestoneHardware(
ctx context.Context,
config milestoneRuntimeConfig,
hardwareID string,
payload any,
) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
requestURL := strings.TrimRight(config.Host, "/") +
"/API/rest/v1/hardware/" +
url.PathEscape(hardwareID)
request, err := http.NewRequestWithContext(
ctx,
http.MethodPatch,
requestURL,
bytes.NewReader(body),
)
if err != nil {
return err
}
tokenType := strings.TrimSpace(config.TokenType)
if tokenType == "" {
tokenType = "Bearer"
}
request.Header.Set("Authorization", tokenType+" "+config.AccessToken)
request.Header.Set("Accept", "application/json")
request.Header.Set("Content-Type", "application/json")
response, err := milestoneHTTPClient(config.SkipTLSVerify).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
responseBody, _ := io.ReadAll(response.Body)
if response.StatusCode < 200 || response.StatusCode >= 300 {
return newAppErrorf(
"milestone hardware patch failed",
LogFields{
"statusCode": response.StatusCode,
"responseBody": string(responseBody),
"hardwareId": hardwareID,
},
"milestone hardware patch failed: status=%d body=%s",
response.StatusCode,
string(responseBody),
)
}
return nil
}

View File

@ -1,29 +1,33 @@
// backend\notification_preferences.go
// backend/notification_preferences.go
package main
import (
"context"
"errors"
"net/http"
"strings"
"github.com/jackc/pgx/v5"
)
type notificationPreferencesResponse struct {
InAppEnabled bool `json:"inAppEnabled"`
SoundEnabled bool `json:"soundEnabled"`
EmailEnabled bool `json:"emailEnabled"`
OperationUpdates bool `json:"operationUpdates"`
OperationJournals bool `json:"operationJournals"`
DeviceUpdates bool `json:"deviceUpdates"`
DeviceJournals bool `json:"deviceJournals"`
SystemMessages bool `json:"systemMessages"`
InAppEnabled bool `json:"inAppEnabled"`
SoundEnabled bool `json:"soundEnabled"`
SoundName string `json:"soundName"`
EmailEnabled bool `json:"emailEnabled"`
OperationUpdates bool `json:"operationUpdates"`
OperationJournals bool `json:"operationJournals"`
DeviceUpdates bool `json:"deviceUpdates"`
DeviceJournals bool `json:"deviceJournals"`
SystemMessages bool `json:"systemMessages"`
}
func defaultNotificationPreferences() notificationPreferencesResponse {
return notificationPreferencesResponse{
InAppEnabled: true,
SoundEnabled: false,
SoundName: "default",
EmailEnabled: false,
OperationUpdates: true,
OperationJournals: true,
@ -33,6 +37,17 @@ func defaultNotificationPreferences() notificationPreferencesResponse {
}
}
func normalizeNotificationSoundName(value string) string {
value = strings.TrimSpace(strings.ToLower(value))
switch value {
case "default", "chime", "soft", "alert", "success":
return value
default:
return "default"
}
}
func (s *Server) handleGetNotificationPreferences(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
@ -40,7 +55,7 @@ func (s *Server) handleGetNotificationPreferences(w http.ResponseWriter, r *http
return
}
settings, err := s.getNotificationPreferences(r, user.ID)
settings, err := s.getNotificationPreferences(r.Context(), user.ID)
if errors.Is(err, pgx.ErrNoRows) {
writeJSON(w, http.StatusOK, map[string]any{
"settings": defaultNotificationPreferences(),
@ -72,6 +87,8 @@ func (s *Server) handleUpdateNotificationPreferences(w http.ResponseWriter, r *h
return
}
input.SoundName = normalizeNotificationSoundName(input.SoundName)
_, err := s.db.Exec(
r.Context(),
`
@ -79,6 +96,7 @@ func (s *Server) handleUpdateNotificationPreferences(w http.ResponseWriter, r *h
user_id,
in_app_enabled,
sound_enabled,
sound_name,
email_enabled,
operation_updates,
operation_journals,
@ -86,11 +104,12 @@ func (s *Server) handleUpdateNotificationPreferences(w http.ResponseWriter, r *h
device_journals,
system_messages
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (user_id)
DO UPDATE SET
in_app_enabled = EXCLUDED.in_app_enabled,
sound_enabled = EXCLUDED.sound_enabled,
sound_name = EXCLUDED.sound_name,
email_enabled = EXCLUDED.email_enabled,
operation_updates = EXCLUDED.operation_updates,
operation_journals = EXCLUDED.operation_journals,
@ -102,6 +121,7 @@ func (s *Server) handleUpdateNotificationPreferences(w http.ResponseWriter, r *h
user.ID,
input.InAppEnabled,
input.SoundEnabled,
input.SoundName,
input.EmailEnabled,
input.OperationUpdates,
input.OperationJournals,
@ -114,7 +134,7 @@ func (s *Server) handleUpdateNotificationPreferences(w http.ResponseWriter, r *h
return
}
settings, err := s.getNotificationPreferences(r, user.ID)
settings, err := s.getNotificationPreferences(r.Context(), user.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "Benachrichtigungseinstellungen wurden gespeichert, konnten aber nicht neu geladen werden")
return
@ -125,15 +145,16 @@ func (s *Server) handleUpdateNotificationPreferences(w http.ResponseWriter, r *h
})
}
func (s *Server) getNotificationPreferences(r *http.Request, userID string) (notificationPreferencesResponse, error) {
func (s *Server) getNotificationPreferences(ctx context.Context, userID string) (notificationPreferencesResponse, error) {
settings := defaultNotificationPreferences()
err := s.db.QueryRow(
r.Context(),
ctx,
`
SELECT
in_app_enabled,
sound_enabled,
COALESCE(sound_name, 'default'),
email_enabled,
operation_updates,
operation_journals,
@ -148,6 +169,7 @@ func (s *Server) getNotificationPreferences(r *http.Request, userID string) (not
).Scan(
&settings.InAppEnabled,
&settings.SoundEnabled,
&settings.SoundName,
&settings.EmailEnabled,
&settings.OperationUpdates,
&settings.OperationJournals,
@ -156,5 +178,16 @@ func (s *Server) getNotificationPreferences(r *http.Request, userID string) (not
&settings.SystemMessages,
)
settings.SoundName = normalizeNotificationSoundName(settings.SoundName)
return settings, err
}
func (s *Server) getNotificationPreferencesOrDefault(ctx context.Context, userID string) (notificationPreferencesResponse, error) {
settings, err := s.getNotificationPreferences(ctx, userID)
if errors.Is(err, pgx.ErrNoRows) {
return defaultNotificationPreferences(), nil
}
return settings, err
}

View File

@ -1,4 +1,4 @@
// backend\notifications.go
// backend/notifications.go
package main
@ -22,7 +22,8 @@ type Notification struct {
EntityType string `json:"entityType"`
EntityID string `json:"entityId"`
Data json.RawMessage `json:"data"`
Visible bool `json:"visible"`
Silent bool `json:"silent"`
SoundName string `json:"soundName,omitempty"`
ReadAt *time.Time `json:"readAt"`
CreatedAt time.Time `json:"createdAt"`
}
@ -77,6 +78,151 @@ func (b *notificationBroker) publish(notification Notification) {
}
}
func (b *notificationBroker) publishToAll(notification Notification) {
b.mu.RLock()
defer b.mu.RUnlock()
for client := range b.clients {
select {
case client.ch <- notification:
default:
}
}
}
func shouldDeliverNotification(
preferences notificationPreferencesResponse,
notificationType string,
entityType string,
) bool {
if !preferences.InAppEnabled {
return false
}
switch {
case notificationType == "operation.journal":
return preferences.OperationJournals
case strings.HasPrefix(notificationType, "operation.") || entityType == "operation":
return preferences.OperationUpdates
case notificationType == "device.journal":
return preferences.DeviceJournals
case strings.HasPrefix(notificationType, "device.") || entityType == "device":
return preferences.DeviceUpdates
case strings.HasPrefix(notificationType, "system.") || entityType == "system":
return preferences.SystemMessages
default:
return true
}
}
func notificationSoundName(preferences notificationPreferencesResponse) string {
if !preferences.SoundEnabled {
return ""
}
return normalizeNotificationSoundName(preferences.SoundName)
}
func (s *Server) createAndPublishNotification(
ctx context.Context,
recipientID string,
notificationType string,
title string,
message string,
entityType string,
entityID string,
dataJSON []byte,
silent bool,
) error {
preferences, err := s.getNotificationPreferencesOrDefault(ctx, recipientID)
if err != nil {
return err
}
// Sichtbare Benachrichtigungen respektieren die User-Preferences.
// Stille Events werden immer erzeugt, weil sie das UI synchronisieren.
if !silent && !shouldDeliverNotification(preferences, notificationType, entityType) {
return nil
}
var notification Notification
var rawData []byte
err = s.db.QueryRow(
ctx,
`
INSERT INTO notifications (
user_id,
type,
title,
message,
entity_type,
entity_id,
data,
silent
)
VALUES (
$1,
$2,
$3,
$4,
$5,
NULLIF($6, '')::UUID,
$7::JSONB,
$8
)
RETURNING
id::TEXT,
user_id::TEXT,
type,
title,
message,
entity_type,
COALESCE(entity_id::TEXT, ''),
data,
silent,
created_at
`,
recipientID,
notificationType,
title,
message,
entityType,
entityID,
string(dataJSON),
silent,
).Scan(
&notification.ID,
&notification.UserID,
&notification.Type,
&notification.Title,
&notification.Message,
&notification.EntityType,
&notification.EntityID,
&rawData,
&notification.Silent,
&notification.CreatedAt,
)
if err != nil {
return err
}
notification.Data = json.RawMessage(rawData)
if !notification.Silent {
notification.SoundName = notificationSoundName(preferences)
}
notificationsBroker.publish(notification)
return nil
}
func (s *Server) handleNotificationEvents(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
@ -115,12 +261,14 @@ func (s *Server) handleNotificationEvents(w http.ResponseWriter, r *http.Request
eventName := "notification"
if !notification.Visible {
if notification.Silent {
switch notification.EntityType {
case "operation":
eventName = "operation-event"
case "device":
eventName = "device-event"
case "user":
eventName = "user-event"
default:
eventName = "notification-event"
}
@ -152,12 +300,12 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
entity_type,
COALESCE(entity_id::TEXT, ''),
data,
visible,
silent,
read_at,
created_at
FROM notifications
WHERE user_id = $1
AND visible = true
AND silent = false
ORDER BY created_at DESC
LIMIT 50
`,
@ -186,7 +334,7 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
&notification.EntityType,
&notification.EntityID,
&data,
&notification.Visible,
&notification.Silent,
&readAt,
&notification.CreatedAt,
); err != nil {
@ -307,76 +455,25 @@ func (s *Server) createOperationNotification(
return err
}
var notification Notification
var rawData []byte
err := s.db.QueryRow(
if err := s.createAndPublishNotification(
ctx,
`
INSERT INTO notifications (
user_id,
type,
title,
message,
entity_type,
entity_id,
data,
visible
)
VALUES (
$1,
$2,
$3,
$4,
'operation',
$5,
$6::JSONB,
true
)
RETURNING
id::TEXT,
user_id::TEXT,
type,
title,
message,
entity_type,
COALESCE(entity_id::TEXT, ''),
data,
visible,
created_at
`,
recipientID,
notificationType,
title,
message,
"operation",
operation.ID,
string(dataJSON),
).Scan(
&notification.ID,
&notification.UserID,
&notification.Type,
&notification.Title,
&notification.Message,
&notification.EntityType,
&notification.EntityID,
&rawData,
&notification.Visible,
&notification.CreatedAt,
)
if err != nil {
dataJSON,
false,
); err != nil {
return err
}
notification.Data = json.RawMessage(rawData)
notificationsBroker.publish(notification)
}
return rows.Err()
}
func (s *Server) createOperationUpdateEvent(
func (s *Server) createOperationCreatedEvent(
ctx context.Context,
actor User,
operation Operation,
@ -390,7 +487,7 @@ func (s *Server) createOperationUpdateEvent(
rows, err := s.db.Query(
ctx,
`
SELECT id
SELECT id::TEXT
FROM users
WHERE id <> $1
AND (
@ -412,66 +509,73 @@ func (s *Server) createOperationUpdateEvent(
return err
}
var notification Notification
var rawData []byte
err := s.db.QueryRow(
if err := s.createAndPublishNotification(
ctx,
`
INSERT INTO notifications (
user_id,
type,
title,
message,
entity_type,
entity_id,
data,
visible
)
VALUES (
$1,
'operation.updated',
'Einsatz geändert',
'',
'operation',
$2,
$3::JSONB,
false
)
RETURNING
id::TEXT,
user_id::TEXT,
type,
title,
message,
entity_type,
COALESCE(entity_id::TEXT, ''),
data,
visible,
created_at
`,
recipientID,
"operation.created",
"Einsatz erstellt",
"",
"operation",
operation.ID,
string(dataJSON),
).Scan(
&notification.ID,
&notification.UserID,
&notification.Type,
&notification.Title,
&notification.Message,
&notification.EntityType,
&notification.EntityID,
&rawData,
&notification.Visible,
&notification.CreatedAt,
)
dataJSON,
true,
); err != nil {
return err
}
}
if err != nil {
return rows.Err()
}
func (s *Server) createOperationUpdateEvent(
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::TEXT
FROM users
WHERE id <> $1
AND (
'admin' = ANY(rights)
OR 'operations: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
}
notification.Data = json.RawMessage(rawData)
notificationsBroker.publish(notification)
if err := s.createAndPublishNotification(
ctx,
recipientID,
"operation.updated",
"Einsatz geändert",
"",
"operation",
operation.ID,
dataJSON,
true,
); err != nil {
return err
}
}
return rows.Err()
@ -491,7 +595,7 @@ func (s *Server) createOperationJournalEvent(
rows, err := s.db.Query(
ctx,
`
SELECT id
SELECT id::TEXT
FROM users
WHERE id <> $1
`,
@ -504,70 +608,24 @@ func (s *Server) createOperationJournalEvent(
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(
if err := s.createAndPublishNotification(
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.journal",
"Journal aktualisiert",
"Ein Journal-Eintrag wurde aktualisiert.",
"operation",
operation.ID,
string(dataJSON),
).Scan(
&notification.ID,
&notification.UserID,
&notification.Type,
&notification.Title,
&notification.Message,
&notification.EntityType,
&notification.EntityID,
&rawData,
&notification.Visible,
&notification.CreatedAt,
)
if err != nil {
dataJSON,
true,
); err != nil {
return err
}
notification.Data = json.RawMessage(rawData)
notificationsBroker.publish(notification)
}
return rows.Err()
@ -587,7 +645,7 @@ func (s *Server) createDeviceJournalEvent(
rows, err := s.db.Query(
ctx,
`
SELECT id
SELECT id::TEXT
FROM users
WHERE id <> $1
AND (
@ -604,70 +662,24 @@ func (s *Server) createDeviceJournalEvent(
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(
if err := s.createAndPublishNotification(
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.journal",
"Geräte-Journal aktualisiert",
"Ein Geräte-Journal-Eintrag wurde hinzugefügt.",
"device",
device.ID,
string(dataJSON),
).Scan(
&notification.ID,
&notification.UserID,
&notification.Type,
&notification.Title,
&notification.Message,
&notification.EntityType,
&notification.EntityID,
&rawData,
&notification.Visible,
&notification.CreatedAt,
)
if err != nil {
dataJSON,
true,
); err != nil {
return err
}
notification.Data = json.RawMessage(rawData)
notificationsBroker.publish(notification)
}
return rows.Err()

View File

@ -0,0 +1,101 @@
// backend\operation_assignees.go
package main
import (
"context"
"net/http"
"strings"
)
type operationAssigneeUser 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"`
}
func (s *Server) handleListOperationAssignees(w http.ResponseWriter, r *http.Request) {
unit := strings.TrimSpace(r.URL.Query().Get("unit"))
if unit == "" {
unit = "TEG"
}
leaders, err := s.listOperationAssigneeUsers(r.Context(), unit, "operation-leader")
if err != nil {
writeError(w, http.StatusInternalServerError, "Einsatzleiter konnten nicht geladen werden")
return
}
officers, err := s.listOperationAssigneeUsers(r.Context(), unit, "operation-officer")
if err != nil {
writeError(w, http.StatusInternalServerError, "Einsatzbeamte konnten nicht geladen werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"leaders": leaders,
"officers": officers,
})
}
func (s *Server) listOperationAssigneeUsers(
ctx context.Context,
unit string,
group string,
) ([]operationAssigneeUser, error) {
rows, err := s.db.Query(
ctx,
`
SELECT
id::TEXT,
COALESCE(username, ''),
COALESCE(display_name, ''),
email,
COALESCE(avatar, ''),
COALESCE(unit, ''),
COALESCE(user_group, '')
FROM users
WHERE upper(COALESCE(unit, '')) = upper($1)
AND COALESCE(user_group, '') = $2
ORDER BY
lower(COALESCE(NULLIF(display_name, ''), NULLIF(username, ''), email)) ASC,
lower(email) ASC
`,
unit,
group,
)
if err != nil {
return nil, err
}
defer rows.Close()
users := []operationAssigneeUser{}
for rows.Next() {
var user operationAssigneeUser
if err := rows.Scan(
&user.ID,
&user.Username,
&user.DisplayName,
&user.Email,
&user.Avatar,
&user.Unit,
&user.Group,
); err != nil {
return nil, err
}
users = append(users, user)
}
if err := rows.Err(); err != nil {
return nil, err
}
return users, nil
}

View File

@ -333,6 +333,19 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) {
// nicht abbrechen, Einsatz wurde bereits erstellt
}
if err := s.createOperationCreatedEvent(
r.Context(),
user,
operation,
map[string]any{
"operationNumber": operation.OperationNumber,
"operationName": operation.OperationName,
"action": "created",
},
); err != nil {
// nicht abbrechen, Einsatz wurde bereits erstellt
}
writeJSON(w, http.StatusCreated, map[string]any{
"operation": operation,
})
@ -1051,7 +1064,7 @@ func (s *Server) handleListUnreadOperationJournals(w http.ResponseWriter, r *htt
SELECT entity_id::TEXT
FROM notifications
WHERE user_id = $1
AND visible = false
AND silent = true
AND type = 'operation.journal'
AND entity_type = 'operation'
AND entity_id IS NOT NULL
@ -1103,7 +1116,7 @@ func (s *Server) handleMarkOperationJournalRead(w http.ResponseWriter, r *http.R
UPDATE notifications
SET read_at = COALESCE(read_at, now())
WHERE user_id = $1
AND visible = false
AND silent = true
AND type = 'operation.journal'
AND entity_type = 'operation'
AND entity_id = $2

View File

@ -1,4 +1,4 @@
// backend/routes.go
// backend\routes.go
package main
@ -29,9 +29,12 @@ func (s *Server) Routes() http.Handler {
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("POST /devices/{id}/milestone-toggle", s.requireAuth(s.handleToggleMilestoneDeviceStatus))
mux.HandleFunc("GET /devices/{id}/history", s.requireAuth(s.handleListDeviceHistory))
mux.HandleFunc("POST /devices/{id}/history", s.requireAuth(s.handleCreateDeviceJournalEntry))
mux.HandleFunc("PUT /devices/{id}/history/{historyId}", s.requireAuth(s.handleUpdateDeviceJournalEntry))
mux.HandleFunc("PATCH /devices/{id}/milestone", s.requireAuth(s.handleUpdateMilestoneHardware))
mux.HandleFunc("GET /devices/firmware-check", s.requireAuth(s.handleGetCameraFirmwareCheckState))
mux.HandleFunc("POST /devices/firmware-check", s.requireAuth(s.handleCheckCameraFirmware))
@ -42,16 +45,21 @@ func (s *Server) Routes() http.Handler {
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 /operations/journal-unread", s.requireAuth(s.handleListUnreadOperationJournals))
mux.HandleFunc("GET /operations/assignees", s.requireAuth(s.requireRight("operations:read", s.handleListOperationAssignees)))
mux.HandleFunc("GET /search", s.requireAuth(s.handleGlobalSearch))
mux.HandleFunc("GET /address-search", s.requireAuth(s.handleAddressSearch))
mux.HandleFunc("GET /address-reverse", s.requireAuth(s.handleAddressReverse))
mux.HandleFunc("GET /notifications", s.requireAuth(s.handleListNotifications))
mux.HandleFunc("POST /notifications/{id}/read", s.requireAuth(s.handleMarkNotificationRead))
@ -75,6 +83,9 @@ func (s *Server) Routes() http.Handler {
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/user-units", s.requireAuth(s.requireRight("users:read", s.handleListUserUnits)))
mux.HandleFunc("POST /admin/user-units", s.requireAuth(s.requireRight("users:write", s.handleCreateUserUnit)))
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)))

View File

@ -264,6 +264,18 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
)
`,
`
CREATE TABLE IF NOT EXISTS user_units (
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 user_teams (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@ -326,6 +338,7 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
milestone_hardware_id TEXT NOT NULL DEFAULT '',
milestone_display_name TEXT NOT NULL DEFAULT '',
firmware_version TEXT NOT NULL DEFAULT '',
milestone_port TEXT NOT NULL DEFAULT '',
milestone_enabled BOOLEAN NOT NULL DEFAULT true,
milestone_synced_at TIMESTAMPTZ,
@ -380,7 +393,8 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
action TEXT NOT NULL,
changes JSONB NOT NULL DEFAULT '[]'::JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`,
@ -434,7 +448,7 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
type TEXT NOT NULL DEFAULT 'info',
title TEXT NOT NULL,
message TEXT NOT NULL DEFAULT '',
visible BOOLEAN NOT NULL DEFAULT true,
silent BOOLEAN NOT NULL DEFAULT false,
entity_type TEXT NOT NULL DEFAULT '',
entity_id UUID,
@ -476,6 +490,7 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
in_app_enabled BOOLEAN NOT NULL DEFAULT true,
sound_enabled BOOLEAN NOT NULL DEFAULT false,
sound_name TEXT NOT NULL DEFAULT 'default',
email_enabled BOOLEAN NOT NULL DEFAULT false,
operation_updates BOOLEAN NOT NULL DEFAULT true,
@ -564,11 +579,15 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
`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_user_units_name ON user_units(name)`,
`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 INDEX IF NOT EXISTS idx_dashboard_settings_user_id ON dashboard_settings(user_id)`,
`ALTER TABLE devices ADD COLUMN IF NOT EXISTS milestone_port TEXT NOT NULL DEFAULT ''`,
`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 <> ''`,

33
backend/user_events.go Normal file
View File

@ -0,0 +1,33 @@
// backend\user_events.go
package main
import (
"encoding/json"
"fmt"
"time"
)
func (s *Server) publishUserProfileEvent(eventType string, user User) {
data, err := json.Marshal(map[string]any{
"user": user,
})
if err != nil {
return
}
now := time.Now()
notificationsBroker.publishToAll(Notification{
ID: fmt.Sprintf("%s-%s-%d", eventType, user.ID, now.UnixNano()),
UserID: user.ID,
Type: eventType,
Title: "",
Message: "",
EntityType: "user",
EntityID: user.ID,
Data: json.RawMessage(data),
Silent: true,
CreatedAt: now,
})
}

View File

@ -114,6 +114,8 @@ func (s *Server) handleUpdateSettingsAccount(w http.ResponseWriter, r *http.Requ
return
}
s.publishUserProfileEvent("user.updated", user)
writeSettingsJSON(w, http.StatusOK, map[string]any{
"message": "Profil gespeichert",
"user": user,

View File

@ -0,0 +1,21 @@
// backend\user_unit_lookups.go
package main
import "net/http"
func (s *Server) handleListUserUnits(w http.ResponseWriter, r *http.Request) {
units, err := listDeviceLookupOptions(r.Context(), s.db, "user_units")
if err != nil {
writeError(w, http.StatusInternalServerError, "Einheiten konnten nicht geladen werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"units": units,
})
}
func (s *Server) handleCreateUserUnit(w http.ResponseWriter, r *http.Request) {
s.handleCreateDeviceLookup(w, r, "user_units", "Einheit")
}

View File

@ -22,6 +22,7 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/geojson": "^7946.0.16",
"@types/leaflet": "^1.9.21",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",

View File

@ -24,6 +24,7 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/geojson": "^7946.0.16",
"@types/leaflet": "^1.9.21",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,7 +1,8 @@
// frontend/src/App.tsx
import { useEffect, useState } from 'react'
import { Navigate, Route, Routes } from 'react-router'
import { Navigate, Route, Routes, useNavigate } from 'react-router'
import { NotificationProvider } from './components/Notifications'
import LoginPage from './pages/LoginPage'
import AppLayout from './components/AppLayout'
import DashboardPage from './pages/DashboardPage'
@ -29,6 +30,52 @@ function parseNotificationEvent(event: MessageEvent) {
}
}
function getNotificationData(notification: Notification) {
const data = (notification as { data?: unknown }).data
if (!data) {
return {}
}
if (typeof data === 'string') {
try {
return JSON.parse(data) as Record<string, unknown>
} catch {
return {}
}
}
if (typeof data === 'object') {
return data as Record<string, unknown>
}
return {}
}
function getUserFromNotification(notification: Notification) {
const data = getNotificationData(notification)
const user = data.user
if (!user || typeof user !== 'object') {
return null
}
return user as User
}
function playNotificationSound(soundName?: string) {
if (!soundName) {
return
}
const audio = new Audio(`/sounds/notifications/${soundName}.mp3`)
audio.volume = 0.6
void audio.play().catch(() => {
// Browser blockiert Audio ggf., bis der User einmal mit der Seite interagiert hat.
})
}
function RequireRight({
user,
right,
@ -46,6 +93,8 @@ function RequireRight({
}
function App() {
const navigate = useNavigate()
const [user, setUser] = useState<User | null>(null)
const [isLoading, setIsLoading] = useState(true)
@ -77,10 +126,12 @@ function App() {
})
function addVisibleNotification(notification: Notification) {
if (notification.visible === false) {
if (notification.silent) {
return
}
playNotificationSound(notification.soundName)
setNotifications((currentNotifications) => [
notification,
...currentNotifications.filter((item) => item.id !== notification.id),
@ -151,14 +202,48 @@ function App() {
dispatchEntityEvent(notification)
}
function handleUserEvent(event: MessageEvent) {
const notification = parseNotificationEvent(event)
if (!notification) {
return
}
window.dispatchEvent(
new CustomEvent('user-notification', {
detail: notification,
}),
)
const updatedUser = getUserFromNotification(notification)
if (!updatedUser) {
void loadUser()
return
}
setUser((currentUser) => {
if (!currentUser || currentUser.id !== updatedUser.id) {
return currentUser
}
return {
...currentUser,
...updatedUser,
}
})
}
eventSource.addEventListener('notification', handleNotificationEvent)
eventSource.addEventListener('operation-event', handleOperationEvent)
eventSource.addEventListener('device-event', handleDeviceEvent)
eventSource.addEventListener('user-event', handleUserEvent)
return () => {
eventSource.removeEventListener('notification', handleNotificationEvent)
eventSource.removeEventListener('operation-event', handleOperationEvent)
eventSource.removeEventListener('device-event', handleDeviceEvent)
eventSource.removeEventListener('user-event', handleUserEvent)
eventSource.close()
}
}, [user])
@ -264,6 +349,15 @@ function App() {
}).catch(() => undefined)
}
function handleNotificationClick(notification: Notification) {
if (
notification.type === 'operation.created' ||
notification.entityType === 'operation'
) {
navigate('/einsaetze')
}
}
async function handleLogout() {
try {
await fetch(`${API_URL}/auth/logout`, {
@ -300,101 +394,104 @@ function App() {
}
return (
<AppLayout
user={user}
onLogout={handleLogout}
notificationCenter={
<NotificationCenter
notifications={notifications}
unreadCount={unreadNotificationCount}
onMarkRead={markNotificationRead}
onMarkAllRead={markAllNotificationsRead}
/>
}
>
<Routes>
<Route path="/" element={<DashboardPage username={user.username} />} />
<NotificationProvider>
<AppLayout
user={user}
onLogout={handleLogout}
notificationCenter={
<NotificationCenter
notifications={notifications}
unreadCount={unreadNotificationCount}
onMarkRead={markNotificationRead}
onMarkAllRead={markAllNotificationsRead}
onNotificationClick={handleNotificationClick}
/>
}
>
<Routes>
<Route path="/" element={<DashboardPage username={user.username} />} />
<Route
path="/geraete"
element={
<RequireRight user={user} right="devices:read">
<DevicesPage />
</RequireRight>
}
/>
<Route
path="/geraete"
element={
<RequireRight user={user} right="devices:read">
<DevicesPage />
</RequireRight>
}
/>
<Route
path="/einsaetze"
element={
<RequireRight user={user} right="operations:read">
<OperationsPage
unreadJournalOperationIds={unreadJournalOperationIds}
onJournalRead={markOperationJournalRead}
canWriteOperations={hasRight(user, 'operations:write')}
/>
</RequireRight>
}
/>
<Route
path="/einsaetze"
element={
<RequireRight user={user} right="operations:read">
<OperationsPage
unreadJournalOperationIds={unreadJournalOperationIds}
onJournalRead={markOperationJournalRead}
canWriteOperations={hasRight(user, 'operations:write')}
/>
</RequireRight>
}
/>
<Route
path="/kalender"
element={
<RequireRight user={user} right="calendar:read">
<CalendarPage />
</RequireRight>
}
/>
<Route
path="/kalender"
element={
<RequireRight user={user} right="calendar:read">
<CalendarPage />
</RequireRight>
}
/>
<Route
path="/dokumente"
element={
<RequireRight user={user} right="documents:read">
<DocumentsPage />
</RequireRight>
}
/>
<Route
path="/dokumente"
element={
<RequireRight user={user} right="documents:read">
<DocumentsPage />
</RequireRight>
}
/>
<Route
path="/berichte"
element={
<RequireRight user={user} right="reports:read">
<ReportsPage />
</RequireRight>
}
/>
<Route
path="/berichte"
element={
<RequireRight user={user} right="reports:read">
<ReportsPage />
</RequireRight>
}
/>
<Route
path="/administration"
element={<Navigate to="/administration/benutzer" replace />}
/>
<Route
path="/administration"
element={<Navigate to="/administration/benutzer" replace />}
/>
<Route
path="/administration/*"
element={
<RequireRight user={user} right="administration:read">
<AdministrationPage />
</RequireRight>
}
/>
<Route
path="/administration/*"
element={
<RequireRight user={user} right="administration:read">
<AdministrationPage />
</RequireRight>
}
/>
<Route
path="/einstellungen/*"
element={
<RequireRight user={user} right="settings:read">
<SettingsPage
user={user}
onUserUpdated={setUser}
/>
</RequireRight>
}
/>
<Route
path="/einstellungen/*"
element={
<RequireRight user={user} right="settings:read">
<SettingsPage
user={user}
onUserUpdated={setUser}
/>
</RequireRight>
}
/>
<Route path="/feedback" element={<Feedback />} />
<Route path="/feedback" element={<Feedback />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</AppLayout>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</AppLayout>
</NotificationProvider>
)
}

View File

@ -8,8 +8,10 @@ import {
ComboboxOptions,
Label,
} from '@headlessui/react'
import AddressMapPicker from './AddressMapPicker'
import type { GeoJsonObject } from 'geojson'
import { ChevronDownIcon } from '@heroicons/react/20/solid'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
@ -19,6 +21,16 @@ export type AddressSuggestion = {
lat: number
lon: number
type?: string
geojson?: GeoJsonObject | null
}
type Coordinates = {
lat: number
lon: number
}
type ReverseGeocodeResponse = {
suggestion?: AddressSuggestion
}
type AddressComboboxProps = {
@ -28,6 +40,7 @@ type AddressComboboxProps = {
value: string
onChange: (value: string) => void
placeholder?: string
mapVisible?: boolean
}
const inputClassName =
@ -37,21 +50,34 @@ function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(' ')
}
function buildOpenStreetMapEmbedUrl(address: AddressSuggestion) {
const offset = 0.004
function toNumber(value: unknown) {
if (typeof value === 'number') {
return value
}
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}`,
})
if (typeof value === 'string') {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : NaN
}
return `https://www.openstreetmap.org/export/embed.html?${params.toString()}`
return NaN
}
function normalizeAddressSuggestion(
suggestion: AddressSuggestion,
): AddressSuggestion | null {
const lat = toNumber(suggestion.lat)
const lon = toNumber(suggestion.lon)
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
return null
}
return {
...suggestion,
lat,
lon,
}
}
export default function AddressCombobox({
@ -61,26 +87,223 @@ export default function AddressCombobox({
value,
onChange,
placeholder = 'Adresse suchen',
mapVisible = true,
}: AddressComboboxProps) {
const [query, setQuery] = useState(value)
const [suggestions, setSuggestions] = useState<AddressSuggestion[]>([])
const [selectedSuggestion, setSelectedSuggestion] = useState<AddressSuggestion | null>(null)
const [previewSuggestion, setPreviewSuggestion] = useState<AddressSuggestion | null>(null)
const [markerPosition, setMarkerPosition] = useState<{
lat: number
lon: number
} | null>(null)
const [mapFocusKey, setMapFocusKey] = useState(0)
const [isSearching, setIsSearching] = useState(false)
const [searchError, setSearchError] = useState<string | null>(null)
const previousValueRef = useRef(value)
const initializedAddressRef = useRef('')
const previousMapVisibleRef = useRef(mapVisible)
const reverseGeocodeAbortRef = useRef<AbortController | null>(null)
const internalValueUpdateRef = useRef(false)
useEffect(() => {
if (value !== previousValueRef.current && value !== query) {
if (value !== previousValueRef.current) {
setQuery(value)
setSelectedSuggestion(null)
setPreviewSuggestion(null)
if (internalValueUpdateRef.current) {
internalValueUpdateRef.current = false
previousValueRef.current = value
return
}
if (value !== query) {
initializedAddressRef.current = ''
setSelectedSuggestion(null)
setPreviewSuggestion(null)
setMarkerPosition(null)
setMapFocusKey((currentKey) => currentKey + 1)
}
}
previousValueRef.current = value
}, [value, query])
useEffect(() => {
const becameVisible = mapVisible && !previousMapVisibleRef.current
previousMapVisibleRef.current = mapVisible
if (!becameVisible) {
return
}
if (markerPosition) {
setMapFocusKey((currentKey) => currentKey + 1)
return
}
initializedAddressRef.current = ''
void loadInitialMarkerForAddress(value)
}, [mapVisible, markerPosition, value])
useEffect(() => {
const trimmedValue = value.trim()
if (trimmedValue.length < 3) {
initializedAddressRef.current = ''
return
}
if (markerPosition) {
return
}
const abortController = new AbortController()
void loadInitialMarkerForAddress(trimmedValue, abortController.signal)
return () => {
abortController.abort()
}
}, [value, markerPosition])
async function loadInitialMarkerForAddress(
address: string,
signal?: AbortSignal,
) {
const trimmedAddress = address.trim()
if (trimmedAddress.length < 3) {
return
}
if (initializedAddressRef.current === trimmedAddress && markerPosition) {
return
}
try {
const response = await fetch(
`${API_URL}/address-search?q=${encodeURIComponent(trimmedAddress)}`,
{
credentials: 'include',
signal,
},
)
if (!response.ok) {
return
}
const data = await response.json()
const firstSuggestion = ((data.suggestions ?? []) as AddressSuggestion[])
.map(normalizeAddressSuggestion)
.find((suggestion): suggestion is AddressSuggestion => suggestion !== null)
if (!firstSuggestion) {
return
}
const reverseSuggestion = await reverseGeocodeCoordinates({
lat: firstSuggestion.lat,
lon: firstSuggestion.lon,
})
const nextSuggestion: AddressSuggestion = {
...firstSuggestion,
geojson: reverseSuggestion?.geojson ?? firstSuggestion.geojson ?? null,
}
initializedAddressRef.current = trimmedAddress
setSelectedSuggestion(nextSuggestion)
setPreviewSuggestion(nextSuggestion)
setMarkerPosition({
lat: nextSuggestion.lat,
lon: nextSuggestion.lon,
})
setMapFocusKey((currentKey) => currentKey + 1)
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
return
}
}
}
async function reverseGeocodeCoordinates(
coordinates: Coordinates,
): Promise<AddressSuggestion | null> {
reverseGeocodeAbortRef.current?.abort()
const abortController = new AbortController()
reverseGeocodeAbortRef.current = abortController
try {
const response = await fetch(
`${API_URL}/address-reverse?lat=${encodeURIComponent(
coordinates.lat,
)}&lon=${encodeURIComponent(coordinates.lon)}`,
{
credentials: 'include',
signal: abortController.signal,
},
)
if (!response.ok) {
return null
}
const data = (await response.json()) as ReverseGeocodeResponse
if (!data.suggestion) {
return null
}
const normalizedSuggestion = normalizeAddressSuggestion(data.suggestion)
if (!normalizedSuggestion) {
return null
}
return {
...normalizedSuggestion,
lat: coordinates.lat,
lon: coordinates.lon,
}
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
return null
}
return null
}
}
async function handleMapPositionChange(coordinates: Coordinates) {
setMarkerPosition(coordinates)
setSelectedSuggestion(null)
setPreviewSuggestion(null)
const suggestion = await reverseGeocodeCoordinates(coordinates)
if (!suggestion) {
return
}
initializedAddressRef.current = suggestion.label
internalValueUpdateRef.current = true
setQuery(suggestion.label)
setSelectedSuggestion(suggestion)
setPreviewSuggestion(suggestion)
onChange(suggestion.label)
}
useEffect(() => {
const trimmedQuery = query.trim()
@ -113,10 +336,14 @@ export default function AddressCombobox({
}
const data = await response.json()
const nextSuggestions = (data.suggestions ?? []) as AddressSuggestion[]
const nextSuggestions = ((data.suggestions ?? []) as AddressSuggestion[])
.map(normalizeAddressSuggestion)
.filter((suggestion): suggestion is AddressSuggestion => suggestion !== null)
const nextPreview = nextSuggestions[0] ?? null
setSuggestions(nextSuggestions)
setPreviewSuggestion((currentPreview) => currentPreview ?? nextSuggestions[0] ?? null)
setPreviewSuggestion((currentPreview) => currentPreview ?? nextPreview)
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
return
@ -140,33 +367,58 @@ export default function AddressCombobox({
}
}, [query])
const mapUrl = useMemo(() => {
if (!previewSuggestion) {
return null
}
return buildOpenStreetMapEmbedUrl(previewSuggestion)
}, [previewSuggestion])
function handleInputChange(nextValue: string) {
reverseGeocodeAbortRef.current?.abort()
internalValueUpdateRef.current = false
initializedAddressRef.current = ''
setQuery(nextValue)
setSelectedSuggestion(null)
setPreviewSuggestion(null)
setMarkerPosition(null)
onChange(nextValue)
}
function handleSelect(suggestion: AddressSuggestion | null) {
setSelectedSuggestion(suggestion)
async function handleSelect(suggestion: AddressSuggestion | null) {
const normalizedSuggestion = suggestion
? normalizeAddressSuggestion(suggestion)
: null
if (!suggestion) {
setSelectedSuggestion(normalizedSuggestion)
if (!normalizedSuggestion) {
return
}
setQuery(suggestion.label)
setPreviewSuggestion(suggestion)
onChange(suggestion.label)
setMarkerPosition({
lat: normalizedSuggestion.lat,
lon: normalizedSuggestion.lon,
})
setMapFocusKey((currentKey) => currentKey + 1)
const reverseSuggestion = await reverseGeocodeCoordinates({
lat: normalizedSuggestion.lat,
lon: normalizedSuggestion.lon,
})
const nextSuggestion: AddressSuggestion = {
...normalizedSuggestion,
label: reverseSuggestion?.label ?? normalizedSuggestion.label,
geojson: reverseSuggestion?.geojson ?? normalizedSuggestion.geojson ?? null,
}
initializedAddressRef.current = nextSuggestion.label
internalValueUpdateRef.current = true
setQuery(nextSuggestion.label)
setSelectedSuggestion(nextSuggestion)
setPreviewSuggestion(nextSuggestion)
onChange(nextSuggestion.label)
}
const mapKey = `${mapVisible ? 'visible' : 'hidden'}-${mapFocusKey}`
return (
<div>
<input type="hidden" name={name} value={value} />
@ -196,7 +448,7 @@ export default function AddressCombobox({
{(suggestions.length > 0 || isSearching || searchError) && (
<ComboboxOptions
transition
className="absolute z-20 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg outline outline-black/5 data-leave:transition data-leave:duration-100 data-leave:ease-in data-closed:data-leave:opacity-0 sm:text-sm dark:bg-gray-800 dark:shadow-none dark:-outline-offset-1 dark:outline-white/10"
className="absolute z-[1000] mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg outline outline-black/5 data-leave:transition data-leave:duration-100 data-leave:ease-in data-closed:data-leave:opacity-0 sm:text-sm dark:bg-gray-800 dark:shadow-none dark:-outline-offset-1 dark:outline-white/10"
>
{isSearching && (
<div className="px-3 py-2 text-sm text-gray-500 dark:text-gray-400">
@ -230,22 +482,14 @@ export default function AddressCombobox({
</div>
</HeadlessCombobox>
{mapUrl && previewSuggestion && (
<div className="mt-3 overflow-hidden rounded-lg border border-gray-200 bg-gray-50 dark:border-white/10 dark:bg-white/5">
<iframe
title={`OpenStreetMap Vorschau: ${previewSuggestion.label}`}
src={mapUrl}
className="h-56 w-full border-0"
loading="lazy"
/>
<div className="border-t border-gray-200 px-3 py-2 text-xs text-gray-500 dark:border-white/10 dark:text-gray-400">
Vorschau: {previewSuggestion.label}
{' · '}
© OpenStreetMap-Mitwirkende
</div>
</div>
)}
<AddressMapPicker
key={mapKey}
value={markerPosition}
focusKey={mapFocusKey}
visible={mapVisible}
highlightGeoJson={previewSuggestion?.geojson ?? null}
onChange={handleMapPositionChange}
/>
</div>
)
}

View File

@ -0,0 +1,332 @@
// frontend/src/components/AddressMapPicker.tsx
import { useEffect, useMemo, useRef, useState } from 'react'
import {
GeoJSON,
MapContainer,
Marker,
TileLayer,
useMap,
useMapEvents,
} from 'react-leaflet'
import type {
Feature,
GeoJsonObject,
Geometry,
MultiPolygon,
Polygon,
} from 'geojson'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
type Coordinates = {
lat: number
lon: number
}
type AddressMapPickerProps = {
value: Coordinates | null
fallbackCenter?: Coordinates
focusKey?: string | number
visible?: boolean
highlightGeoJson?: GeoJsonObject | null
onChange: (coordinates: Coordinates) => void
}
const markerIcon = new L.Icon({
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41],
})
const defaultCenter: Coordinates = {
lat: 51.1657,
lon: 10.4515,
}
function isValidCoordinate(
value: Coordinates | null | undefined,
): value is Coordinates {
return (
Boolean(value) &&
Number.isFinite(value?.lat) &&
Number.isFinite(value?.lon)
)
}
type HighlightGeometry = Polygon | MultiPolygon
type HighlightGeoJson = HighlightGeometry | Feature<HighlightGeometry>
function isPolygonGeometry(
geometry: Geometry | null | undefined,
): geometry is HighlightGeometry {
return (
geometry?.type === 'Polygon' ||
geometry?.type === 'MultiPolygon'
)
}
function isHighlightableGeoJson(
value: GeoJsonObject | null | undefined,
): value is HighlightGeoJson {
if (!value) {
return false
}
if (value.type === 'Polygon' || value.type === 'MultiPolygon') {
return true
}
if (value.type !== 'Feature') {
return false
}
const feature = value as Feature
return isPolygonGeometry(feature.geometry)
}
function FocusMapOnSelection({
position,
focusKey,
visible,
}: {
position: Coordinates | null
focusKey?: string | number
visible: boolean
}) {
const map = useMap()
useEffect(() => {
if (
!visible ||
!isValidCoordinate(position) ||
focusKey === undefined ||
focusKey === null
) {
return
}
let timeout: number | undefined
const frame = window.requestAnimationFrame(() => {
map.invalidateSize({
pan: false,
})
timeout = window.setTimeout(() => {
map.invalidateSize({
pan: false,
})
map.setView([position.lat, position.lon], 16, {
animate: false,
})
}, 80)
})
return () => {
window.cancelAnimationFrame(frame)
if (timeout !== undefined) {
window.clearTimeout(timeout)
}
}
}, [visible, focusKey, position?.lat, position?.lon, map])
return null
}
function MapClickHandler({
onChange,
}: {
onChange: (coordinates: Coordinates) => void
}) {
useMapEvents({
click(event) {
const coordinates = {
lat: event.latlng.lat,
lon: event.latlng.lng,
}
if (!isValidCoordinate(coordinates)) {
return
}
onChange(coordinates)
},
})
return null
}
function InvalidateMapSize({ visible }: { visible: boolean }) {
const map = useMap()
useEffect(() => {
if (!visible) {
return
}
const frame = window.requestAnimationFrame(() => {
map.invalidateSize({
pan: false,
})
})
const firstTimeout = window.setTimeout(() => {
map.invalidateSize({
pan: false,
})
}, 100)
const secondTimeout = window.setTimeout(() => {
map.invalidateSize({
pan: false,
})
}, 300)
return () => {
window.cancelAnimationFrame(frame)
window.clearTimeout(firstTimeout)
window.clearTimeout(secondTimeout)
}
}, [visible, map])
return null
}
function DraggableMarker({
position,
onChange,
}: {
position: Coordinates
onChange: (coordinates: Coordinates) => void
}) {
const markerRef = useRef<L.Marker | null>(null)
const [localPosition, setLocalPosition] = useState(position)
useEffect(() => {
if (!isValidCoordinate(position)) {
return
}
setLocalPosition(position)
markerRef.current?.setLatLng([position.lat, position.lon])
}, [position.lat, position.lon])
const eventHandlers = useMemo(
() => ({
dragstart() {
markerRef.current?.closePopup()
},
dragend() {
const marker = markerRef.current
if (!marker) {
return
}
const nextPosition = marker.getLatLng()
const coordinates = {
lat: nextPosition.lat,
lon: nextPosition.lng,
}
if (!isValidCoordinate(coordinates)) {
return
}
setLocalPosition(coordinates)
onChange(coordinates)
},
}),
[onChange],
)
if (!isValidCoordinate(localPosition)) {
return null
}
return (
<Marker
ref={markerRef}
draggable
autoPan
position={[localPosition.lat, localPosition.lon]}
icon={markerIcon}
eventHandlers={eventHandlers}
/>
)
}
export default function AddressMapPicker({
value,
fallbackCenter = defaultCenter,
focusKey,
visible = true,
highlightGeoJson,
onChange,
}: AddressMapPickerProps) {
const safeFallbackCenter = isValidCoordinate(fallbackCenter)
? fallbackCenter
: defaultCenter
const position = isValidCoordinate(value) ? value : safeFallbackCenter
const hasSelectedPosition = isValidCoordinate(value)
return (
<div className="relative z-0 mt-3 overflow-hidden rounded-lg border border-gray-200 bg-gray-50 dark:border-white/10 dark:bg-white/5">
<MapContainer
center={[position.lat, position.lon]}
zoom={hasSelectedPosition ? 16 : 6}
scrollWheelZoom={false}
className="h-56 w-full"
>
<TileLayer
attribution="© OpenStreetMap"
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<InvalidateMapSize visible={visible} />
<FocusMapOnSelection
position={hasSelectedPosition ? value : null}
focusKey={focusKey}
visible={visible}
/>
{isHighlightableGeoJson(highlightGeoJson) && (
<GeoJSON
key={JSON.stringify(highlightGeoJson)}
data={highlightGeoJson}
interactive={false}
style={{
color: '#4f46e5',
weight: 3,
opacity: 0.95,
fillColor: '#6366f1',
fillOpacity: 0.22,
}}
/>
)}
<MapClickHandler onChange={onChange} />
<DraggableMarker
position={position}
onChange={onChange}
/>
</MapContainer>
<div className="border-t border-gray-200 px-3 py-2 text-xs text-gray-500 dark:border-white/10 dark:text-gray-400">
Pin ziehen oder in die Karte klicken, um die Position anzupassen.
</div>
</div>
)
}

View File

@ -50,7 +50,7 @@ export default function AppLayout({
onNavigate={handleNavigation}
/>
<div className="flex h-dvh min-w-0 flex-col lg:pl-72">
<div className="relative z-0 flex h-dvh min-w-0 flex-col lg:pl-72">
<Topbar
user={user}
onLogout={onLogout}
@ -61,7 +61,7 @@ export default function AppLayout({
onNavigate={handleNavigation}
/>
<main className="relative min-h-0 flex-1 overflow-hidden">
<main className="relative z-0 isolate min-h-0 flex-1 overflow-hidden">
{children}
<Transition
@ -73,7 +73,7 @@ export default function AppLayout({
leave="transition-opacity duration-150 ease-in"
leaveFrom="opacity-100"
leaveTo="opacity-0"
className="absolute inset-0 z-30"
className="absolute inset-0 z-[9000]"
>
<SearchOverlay
user={user}

View File

@ -25,6 +25,7 @@ type BadgeProps = Omit<HTMLAttributes<HTMLSpanElement>, "color"> & {
dot?: boolean;
leadingIcon?: ReactNode;
trailingIcon?: ReactNode;
hover?: boolean;
onRemove?: () => void;
removeLabel?: string;
};
@ -64,6 +65,37 @@ const badgeClasses: Record<BadgeVariant, Record<BadgeTone, string>> = {
},
};
const badgeHoverClasses: Record<BadgeVariant, Record<BadgeTone, string>> = {
border: {
gray: "hover:bg-gray-100 hover:text-gray-700 hover:inset-ring-gray-500/20 dark:hover:bg-gray-400/20 dark:hover:text-gray-300",
red: "hover:bg-red-100 hover:text-red-800 hover:inset-ring-red-600/20 dark:hover:bg-red-400/20 dark:hover:text-red-300",
yellow:
"hover:bg-yellow-100 hover:text-yellow-900 hover:inset-ring-yellow-600/30 dark:hover:bg-yellow-400/20 dark:hover:text-yellow-300",
green:
"hover:bg-green-100 hover:text-green-800 hover:inset-ring-green-600/30 dark:hover:bg-green-400/20 dark:hover:text-green-300",
blue: "hover:bg-blue-100 hover:text-blue-800 hover:inset-ring-blue-700/20 dark:hover:bg-blue-400/20 dark:hover:text-blue-300",
indigo:
"hover:bg-indigo-100 hover:text-indigo-800 hover:inset-ring-indigo-700/20 dark:hover:bg-indigo-400/20 dark:hover:text-indigo-300",
purple:
"hover:bg-purple-100 hover:text-purple-800 hover:inset-ring-purple-700/20 dark:hover:bg-purple-400/20 dark:hover:text-purple-300",
pink: "hover:bg-pink-100 hover:text-pink-800 hover:inset-ring-pink-700/20 dark:hover:bg-pink-400/20 dark:hover:text-pink-300",
},
flat: {
gray: "hover:bg-gray-200 hover:text-gray-700 dark:hover:bg-gray-400/20 dark:hover:text-gray-300",
red: "hover:bg-red-200 hover:text-red-800 dark:hover:bg-red-400/20 dark:hover:text-red-300",
yellow:
"hover:bg-yellow-200 hover:text-yellow-900 dark:hover:bg-yellow-400/20 dark:hover:text-yellow-300",
green:
"hover:bg-green-200 hover:text-green-800 dark:hover:bg-green-400/20 dark:hover:text-green-300",
blue: "hover:bg-blue-200 hover:text-blue-800 dark:hover:bg-blue-400/20 dark:hover:text-blue-300",
indigo:
"hover:bg-indigo-200 hover:text-indigo-800 dark:hover:bg-indigo-400/20 dark:hover:text-indigo-300",
purple:
"hover:bg-purple-200 hover:text-purple-800 dark:hover:bg-purple-400/20 dark:hover:text-purple-300",
pink: "hover:bg-pink-200 hover:text-pink-800 dark:hover:bg-pink-400/20 dark:hover:text-pink-300",
},
};
const dotClasses: Record<BadgeTone, string> = {
gray: "fill-gray-400",
red: "fill-red-500 dark:fill-red-400",
@ -132,6 +164,7 @@ export function Badge({
dot = false,
leadingIcon,
trailingIcon,
hover = false,
onRemove,
removeLabel = "Remove",
className,
@ -150,6 +183,8 @@ export function Badge({
shape === "pill" ? "rounded-full" : "rounded-md",
size === "small" ? "px-1.5 py-0.5" : "px-2 py-1",
badgeClasses[variant][tone],
hover && "cursor-pointer transition-colors",
hover && badgeHoverClasses[variant][tone],
className,
)}
{...props}

View File

@ -20,16 +20,40 @@ export type ButtonGroupProps = {
size?: Size
className?: string
ariaLabel?: string
iconOnlyOnMobile?: boolean
}
function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ')
}
const sizeMap: Record<Size, { btn: string; icon: string; iconOnly: string }> = {
sm: { btn: 'px-2.5 py-1.5 text-sm', icon: 'size-5', iconOnly: 'h-9 w-9' },
md: { btn: 'px-3 py-2 text-sm', icon: 'size-5', iconOnly: 'h-10 w-10' },
lg: { btn: 'px-3.5 py-2.5 text-sm', icon: 'size-5', iconOnly: 'h-11 w-11' },
const sizeMap: Record<
Size,
{
btn: string
icon: string
iconOnly: string
mobileIconOnly: string
}
> = {
sm: {
btn: 'px-2.5 py-1.5 text-sm',
icon: 'size-5',
iconOnly: 'h-9 w-9',
mobileIconOnly: 'h-9 w-9 p-0 text-sm sm:h-auto sm:w-auto sm:px-2.5 sm:py-1.5',
},
md: {
btn: 'px-3 py-2 text-sm',
icon: 'size-5',
iconOnly: 'h-10 w-10',
mobileIconOnly: 'h-10 w-10 p-0 text-sm sm:h-auto sm:w-auto sm:px-3 sm:py-2',
},
lg: {
btn: 'px-3.5 py-2.5 text-sm',
icon: 'size-5',
iconOnly: 'h-11 w-11',
mobileIconOnly: 'h-11 w-11 p-0 text-sm sm:h-auto sm:w-auto sm:px-3.5 sm:py-2.5',
},
}
export default function ButtonGroup({
@ -39,6 +63,7 @@ export default function ButtonGroup({
size = 'md',
className,
ariaLabel = 'Optionen',
iconOnlyOnMobile = false,
}: ButtonGroupProps) {
const s = sizeMap[size]
@ -56,6 +81,7 @@ export default function ButtonGroup({
const isFirst = idx === 0
const isLast = idx === items.length - 1
const iconOnly = !it.label && !!it.icon
const mobileIconOnly = iconOnlyOnMobile && !!it.icon && !!it.label
return (
<button
@ -87,7 +113,11 @@ export default function ButtonGroup({
].join(' '),
'disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:shadow-none',
iconOnly ? `p-0 ${s.iconOnly}` : s.btn
iconOnly
? `p-0 ${s.iconOnly}`
: mobileIconOnly
? s.mobileIconOnly
: s.btn
)}
title={typeof it.label === 'string' ? it.label : it.srLabel}
>
@ -97,7 +127,7 @@ export default function ButtonGroup({
<span
className={cn(
'shrink-0 transition-colors duration-150',
iconOnly ? '' : '-ml-0.5',
iconOnly ? '' : mobileIconOnly ? 'sm:-ml-0.5' : '-ml-0.5',
active
? 'text-indigo-700 dark:text-indigo-200'
: 'text-gray-500 dark:text-gray-500'
@ -107,7 +137,21 @@ export default function ButtonGroup({
</span>
) : null}
{it.label ? <span className={it.icon ? 'ml-1.5' : ''}>{it.label}</span> : null}
{it.label ? (
<span
className={
it.icon
? mobileIconOnly
? 'sr-only sm:not-sr-only sm:ml-1.5'
: 'ml-1.5'
: mobileIconOnly
? 'sr-only sm:not-sr-only'
: ''
}
>
{it.label}
</span>
) : null}
</button>
)
})}

View File

@ -1,6 +1,7 @@
// frontend\src\components\Camera.tsx
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Dialog, DialogBackdrop, DialogPanel, DialogTitle } from '@headlessui/react'
import { CameraIcon, XMarkIcon } from '@heroicons/react/24/outline'
@ -153,88 +154,97 @@ export default function Camera({ onScan }: CameraProps) {
<CameraIcon aria-hidden="true" className="size-6" />
</button>
<Dialog open={open} onClose={closeModal} className="relative z-50">
<DialogBackdrop
transition
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/70"
/>
<div className="fixed inset-0 z-50 w-screen overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<DialogPanel
transition
className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all data-closed:translate-y-4 data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in sm:my-8 sm:w-full sm:max-w-lg data-closed:sm:translate-y-0 data-closed:sm:scale-95 dark:bg-gray-800 dark:outline dark:-outline-offset-1 dark:outline-white/10"
{typeof document !== 'undefined'
? createPortal(
<Dialog
open={open}
onClose={closeModal}
className="fixed inset-0 z-[9999]"
>
<div className="absolute top-0 right-0 pt-4 pr-4">
<button
type="button"
onClick={closeModal}
className="rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-2 focus:outline-offset-2 focus:outline-indigo-600 dark:bg-gray-800 dark:hover:text-gray-300 dark:focus:outline-white"
>
<span className="sr-only">Schließen</span>
<XMarkIcon aria-hidden="true" className="size-6" />
</button>
</div>
<DialogBackdrop
transition
className="fixed inset-0 z-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/70"
/>
<div className="px-4 pt-5 pb-4 sm:p-6">
<DialogTitle as="h3" className="text-base font-semibold text-gray-900 dark:text-white">
QR-Code scannen
</DialogTitle>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
Richte die Kamera auf einen QR-Code.
</p>
<div className="mt-5 overflow-hidden rounded-lg bg-gray-950">
{scannedValue ? (
<div className="p-4">
<p className="text-sm font-medium text-white">
QR-Code erkannt:
</p>
<p className="mt-2 break-all rounded-md bg-white/10 p-3 text-sm text-white">
{scannedValue}
</p>
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<DialogPanel
transition
className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all data-closed:translate-y-4 data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in sm:my-8 sm:w-full sm:max-w-lg data-closed:sm:translate-y-0 data-closed:sm:scale-95 dark:bg-gray-800 dark:outline dark:-outline-offset-1 dark:outline-white/10"
>
<div className="absolute top-0 right-0 pt-4 pr-4">
<button
type="button"
onClick={closeModal}
className="rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-2 focus:outline-offset-2 focus:outline-indigo-600 dark:bg-gray-800 dark:hover:text-gray-300 dark:focus:outline-white"
>
<span className="sr-only">Schließen</span>
<XMarkIcon aria-hidden="true" className="size-6" />
</button>
</div>
) : (
<div className="relative">
<video
ref={videoRef}
muted
playsInline
autoPlay
className="aspect-video w-full object-cover"
/>
{(isStarting || error) && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50 p-4">
<div
className={[
'max-w-sm rounded-md px-4 py-2 text-center text-sm font-medium text-white',
error ? 'bg-red-600/90' : 'bg-black/70',
].join(' ')}
>
{error ?? 'Kamera wird gestartet...'}
<div className="px-4 pt-5 pb-4 sm:p-6">
<DialogTitle as="h3" className="text-base font-semibold text-gray-900 dark:text-white">
QR-Code scannen
</DialogTitle>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
Richte die Kamera auf einen QR-Code.
</p>
<div className="mt-5 overflow-hidden rounded-lg bg-gray-950">
{scannedValue ? (
<div className="p-4">
<p className="text-sm font-medium text-white">
QR-Code erkannt:
</p>
<p className="mt-2 break-all rounded-md bg-white/10 p-3 text-sm text-white">
{scannedValue}
</p>
</div>
</div>
)}
) : (
<div className="relative">
<video
ref={videoRef}
muted
playsInline
autoPlay
className="aspect-video w-full object-cover"
/>
{(isStarting || error) && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50 p-4">
<div
className={[
'max-w-sm rounded-md px-4 py-2 text-center text-sm font-medium text-white',
error ? 'bg-red-600/90' : 'bg-black/70',
].join(' ')}
>
{error ?? 'Kamera wird gestartet...'}
</div>
</div>
)}
</div>
)}
</div>
</div>
)}
<div className="bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6 dark:bg-gray-700/25">
<button
type="button"
onClick={closeModal}
className="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 sm:w-auto dark:bg-indigo-500 dark:shadow-none dark:hover:bg-indigo-400"
>
Schließen
</button>
</div>
</DialogPanel>
</div>
</div>
<div className="bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6 dark:bg-gray-700/25">
<button
type="button"
onClick={closeModal}
className="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 sm:w-auto dark:bg-indigo-500 dark:shadow-none dark:hover:bg-indigo-400"
>
Schließen
</button>
</div>
</DialogPanel>
</div>
</div>
</Dialog>
</Dialog>,
document.body,
)
: null}
</>
)
}

View File

@ -1,3 +1,5 @@
// frontend\src\components\Combobox.tsx
'use client'
import { useState } from 'react'
@ -9,7 +11,8 @@ import {
ComboboxOptions,
Label,
} from '@headlessui/react'
import { ChevronDownIcon, UserIcon } from '@heroicons/react/20/solid'
import { ChevronDownIcon } from '@heroicons/react/20/solid'
import Avatar from './Avatar'
export type ComboboxVariant = 'simple' | 'status' | 'image' | 'secondary'
@ -109,20 +112,11 @@ export default function Combobox({
if (variant === 'image') {
return (
<div className="flex items-center">
{item.imageUrl ? (
<img
src={item.imageUrl}
alt=""
className="size-6 shrink-0 rounded-full bg-gray-100 object-cover outline -outline-offset-1 outline-black/5 dark:bg-gray-700 dark:outline-white/10"
/>
) : (
<div className="grid size-6 shrink-0 place-items-center rounded-full bg-gray-300 in-data-focus:bg-white dark:bg-gray-600">
<UserIcon
aria-hidden="true"
className="size-4 fill-white in-data-focus:fill-indigo-600 dark:in-data-focus:fill-indigo-500"
/>
</div>
)}
<Avatar
src={item.imageUrl}
name={item.name}
size={6}
/>
<span className="ml-3 block truncate">{item.name}</span>
</div>
@ -166,11 +160,22 @@ export default function Combobox({
)}
<div className={classNames('relative', label && 'mt-2')}>
{variant === 'image' && value && query.trim() === '' && (
<div className="pointer-events-none absolute inset-y-0 left-3 flex items-center">
<Avatar
src={value.imageUrl}
name={value.name}
size={6}
/>
</div>
)}
<ComboboxInput
required={required}
placeholder={placeholder}
className={classNames(
'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 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:text-gray-500 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 dark:disabled:bg-white/5 dark:disabled:text-white/40',
variant === 'image' && value && query.trim() === '' && 'pl-11',
inputClassName,
)}
onChange={(event) => setQuery(event.target.value)}

View File

@ -77,6 +77,11 @@ type MapProps = {
heatmapLayerName?: string
heatmapChecked?: boolean
markerLayerName?: string
markersChecked?: boolean
childrenLayerName?: string
childrenChecked?: boolean
center?: [number, number]
zoom?: number
minZoom?: number
@ -142,7 +147,7 @@ const defaultWmsLayers: MapWmsLayer[] = [
overlay: true,
checked: true,
opacity: 1,
zIndex: 650,
zIndex: 2,
},
]
@ -200,6 +205,19 @@ const currentLocationIcon = L.divIcon({
`,
})
const leafletZIndexClassName = [
'[&_.leaflet-pane]:!z-0',
'[&_.leaflet-tile-pane]:!z-0',
'[&_.leaflet-overlay-pane]:!z-[1]',
'[&_.leaflet-shadow-pane]:!z-[2]',
'[&_.leaflet-marker-pane]:!z-[3]',
'[&_.leaflet-tooltip-pane]:!z-[4]',
'[&_.leaflet-popup-pane]:!z-[5]',
'[&_.leaflet-control-container]:!z-[6]',
'[&_.leaflet-top]:!z-[6]',
'[&_.leaflet-bottom]:!z-[6]',
].join(' ')
function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(' ')
}
@ -275,28 +293,40 @@ function HeatmapLayer({
return null
}
function HeatmapLayerControlEvents({
function MapLayerControlEvents({
heatmapLayerName,
onVisibilityChange,
onHeatmapVisibilityChange,
markerLayerName,
onMarkerVisibilityChange,
childrenLayerName,
onChildrenVisibilityChange,
}: {
heatmapLayerName: string
onVisibilityChange: (visible: boolean) => void
onHeatmapVisibilityChange: (visible: boolean) => void
markerLayerName?: string
onMarkerVisibilityChange?: (visible: boolean) => void
childrenLayerName?: string
onChildrenVisibilityChange?: (visible: boolean) => void
}) {
function handleOverlayChange(event: L.LeafletEvent, visible: boolean) {
const layerEvent = event as L.LeafletEvent & { name?: string }
if (layerEvent.name === heatmapLayerName) {
onHeatmapVisibilityChange(visible)
}
if (markerLayerName && layerEvent.name === markerLayerName) {
onMarkerVisibilityChange?.(visible)
}
if (childrenLayerName && layerEvent.name === childrenLayerName) {
onChildrenVisibilityChange?.(visible)
}
}
useMapEvents({
overlayadd: (event) => {
const layerEvent = event as L.LeafletEvent & { name?: string }
if (layerEvent.name === heatmapLayerName) {
onVisibilityChange(true)
}
},
overlayremove: (event) => {
const layerEvent = event as L.LeafletEvent & { name?: string }
if (layerEvent.name === heatmapLayerName) {
onVisibilityChange(false)
}
},
overlayadd: (event) => handleOverlayChange(event, true),
overlayremove: (event) => handleOverlayChange(event, false),
})
return null
@ -369,7 +399,7 @@ function LocateButton({
}
return (
<div className="absolute top-20 left-3 z-[500]">
<div className="absolute top-20 left-3 z-[10]">
<button
type="button"
onClick={handleLocate}
@ -518,6 +548,11 @@ export default function LeafletMap({
heatmapLayerName = 'Heatmap',
heatmapChecked = false,
markerLayerName,
markersChecked = true,
childrenLayerName,
childrenChecked = true,
center = [51.1657, 10.4515],
zoom = 6,
minZoom = 4,
@ -542,6 +577,8 @@ export default function LeafletMap({
const [currentLocation, setCurrentLocation] = useState<[number, number] | null>(null)
const [isHeatmapLayerVisible, setIsHeatmapLayerVisible] = useState(heatmapChecked)
const [isMarkerLayerVisible, setIsMarkerLayerVisible] = useState(markersChecked)
const [isChildrenLayerVisible, setIsChildrenLayerVisible] = useState(childrenChecked)
const fallbackTileLayer =
tileLayers.find((layer) => layer.checked) ?? tileLayers[0]
@ -559,6 +596,24 @@ export default function LeafletMap({
setIsHeatmapLayerVisible(heatmapChecked)
}, [heatmapChecked])
useEffect(() => {
setIsMarkerLayerVisible(markersChecked)
}, [markersChecked])
useEffect(() => {
setIsChildrenLayerVisible(childrenChecked)
}, [childrenChecked])
const hasMarkerLayerControl = showLayerControl && Boolean(markerLayerName)
const hasChildrenLayerControl =
showLayerControl && Boolean(childrenLayerName) && children !== null && children !== undefined
const visibleMarkers =
hasMarkerLayerControl && !isMarkerLayerVisible ? [] : markers
const visibleChildren =
hasChildrenLayerControl && !isChildrenLayerVisible ? null : children
return (
<div className={classNames('relative z-0 h-full', minHeightClassName, className)}>
<MapContainer
@ -567,13 +622,21 @@ export default function LeafletMap({
minZoom={minZoom}
maxZoom={maxZoom}
scrollWheelZoom={scrollWheelZoom}
className={classNames('relative z-0 h-full w-full', mapClassName)}
className={classNames(
'relative z-0 h-full w-full',
leafletZIndexClassName,
mapClassName,
)}
>
<InvalidateSizeOnResize />
<HeatmapLayerControlEvents
<MapLayerControlEvents
heatmapLayerName={heatmapLayerName}
onVisibilityChange={setIsHeatmapLayerVisible}
onHeatmapVisibilityChange={setIsHeatmapLayerVisible}
markerLayerName={markerLayerName}
onMarkerVisibilityChange={setIsMarkerLayerVisible}
childrenLayerName={childrenLayerName}
onChildrenVisibilityChange={setIsChildrenLayerVisible}
/>
{showLayerControl ? (
@ -620,6 +683,24 @@ export default function LeafletMap({
<LayerGroup />
</LayersControl.Overlay>
)}
{markerLayerName && markers.length > 0 && (
<LayersControl.Overlay
name={markerLayerName}
checked={markersChecked}
>
<LayerGroup />
</LayersControl.Overlay>
)}
{childrenLayerName && children !== null && children !== undefined && (
<LayersControl.Overlay
name={childrenLayerName}
checked={childrenChecked}
>
<LayerGroup />
</LayersControl.Overlay>
)}
</LayersControl>
) : fallbackTileLayer ? (
renderTileLayer(fallbackTileLayer, maxZoom)
@ -649,7 +730,7 @@ export default function LeafletMap({
/>
)}
{markers.map((marker) => (
{visibleMarkers.map((marker) => (
<AppMarker
key={marker.id}
marker={marker}
@ -668,7 +749,7 @@ export default function LeafletMap({
</Marker>
)}
{children}
{visibleChildren}
</MapContainer>
</div>
)

View File

@ -46,7 +46,7 @@ export default function Modal({
onConfirm,
children,
panelClassName,
zIndexClassName = 'z-10',
zIndexClassName = 'z-[9999]',
}: ModalProps) {
const isSuccess = variant === 'success-single' || variant === 'success-wide'
const isGrayFooter = variant === 'alert-gray-footer'
@ -75,10 +75,14 @@ export default function Modal({
if (children) {
return (
<Dialog open={open} onClose={setOpen} className={classNames('relative', zIndexClassName)}>
<Dialog
open={open}
onClose={setOpen}
className={classNames('fixed inset-0', zIndexClassName)}
>
<DialogBackdrop
transition
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"
className="fixed inset-0 z-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"
/>
<div className="fixed inset-0 z-10 w-screen overflow-hidden">
@ -100,10 +104,14 @@ export default function Modal({
if (isGrayFooter) {
return (
<Dialog open={open} onClose={setOpen} className="relative z-10">
<Dialog
open={open}
onClose={setOpen}
className={classNames('relative', zIndexClassName)}
>
<DialogBackdrop
transition
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"
className="fixed inset-0 z-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"
/>
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
@ -158,13 +166,17 @@ export default function Modal({
}
return (
<Dialog open={open} onClose={setOpen} className="relative z-10">
<Dialog
open={open}
onClose={setOpen}
className={classNames('relative', zIndexClassName)}
>
<DialogBackdrop
transition
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"
className="fixed inset-0 z-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"
/>
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
<div className="fixed inset-0 z-10 w-screen overflow-hidden">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<DialogPanel
transition

View File

@ -0,0 +1,201 @@
// frontend\src\components\MultiCombobox.tsx
'use client'
import { useState } from 'react'
import {
Combobox as HeadlessCombobox,
ComboboxButton,
ComboboxInput,
ComboboxOption,
ComboboxOptions,
Label,
} from '@headlessui/react'
import {
CheckIcon,
ChevronDownIcon,
XMarkIcon,
} from '@heroicons/react/20/solid'
import Avatar from './Avatar'
import type { ComboboxItem } from './Combobox'
type MultiComboboxProps = {
label?: string
items: ComboboxItem[]
value: ComboboxItem[]
onChange: (items: ComboboxItem[]) => void
placeholder?: string
emptyText?: string
disabled?: boolean
className?: string
}
function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(' ')
}
function getSecondaryText(item: ComboboxItem) {
return item.secondaryText ?? item.username
}
function isSelected(items: ComboboxItem[], item: ComboboxItem) {
return items.some((selectedItem) => selectedItem.id === item.id)
}
function getInitials(name: string) {
return name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part.charAt(0))
.join('')
.toUpperCase()
}
export default function MultiCombobox({
label,
items,
value,
onChange,
placeholder,
emptyText = 'Keine Ergebnisse gefunden.',
disabled = false,
className,
}: MultiComboboxProps) {
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 removeItem(item: ComboboxItem) {
onChange(value.filter((selectedItem) => selectedItem.id !== item.id))
}
return (
<HeadlessCombobox
as="div"
value={value}
onChange={(items: ComboboxItem[]) => {
setQuery('')
onChange(items)
}}
multiple
disabled={disabled}
className={className}
>
{label && (
<Label className="block text-sm/6 font-medium text-gray-900 dark:text-white">
{label}
</Label>
)}
<div className={classNames('relative', label && 'mt-2')}>
<ComboboxInput
placeholder={placeholder}
displayValue={() => query}
onChange={(event) => setQuery(event.target.value)}
className="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 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:text-gray-500 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 dark:disabled:bg-white/5 dark:disabled:text-white/40"
/>
<ComboboxButton className="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-hidden disabled:cursor-not-allowed">
<ChevronDownIcon
className="size-5 text-gray-400"
aria-hidden="true"
/>
</ComboboxButton>
<ComboboxOptions
transition
className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg outline outline-black/5 data-leave:transition data-leave:duration-100 data-leave:ease-in data-closed:data-leave:opacity-0 sm:text-sm dark:bg-gray-800 dark:shadow-none dark:-outline-offset-1 dark:outline-white/10"
>
{filteredItems.map((item) => {
const selected = isSelected(value, item)
const secondaryText = getSecondaryText(item)
return (
<ComboboxOption
key={item.id}
value={item}
className="cursor-default px-3 py-2 text-gray-900 select-none data-focus:bg-indigo-600 data-focus:text-white data-focus:outline-hidden dark:text-white dark:data-focus:bg-indigo-500"
>
<div className="flex items-center justify-between gap-x-3">
<div className="flex min-w-0 items-center gap-x-3">
<Avatar
src={item.imageUrl}
name={item.name}
initials={getInitials(item.name)}
size={8}
/>
<div className="min-w-0">
<span className="block truncate">{item.name}</span>
{secondaryText && (
<span className="block truncate text-xs text-gray-500 in-data-focus:text-indigo-100 dark:text-gray-400">
{secondaryText}
</span>
)}
</div>
</div>
{selected && (
<CheckIcon
aria-hidden="true"
className="size-4 shrink-0 text-indigo-600 in-data-focus:text-white dark:text-indigo-400"
/>
)}
</div>
</ComboboxOption>
)
})}
{filteredItems.length === 0 && (
<div className="px-3 py-2 text-sm text-gray-500 dark:text-gray-400">
{emptyText}
</div>
)}
</ComboboxOptions>
</div>
{value.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2">
{value.map((item) => (
<span
key={item.id}
className="inline-flex items-center gap-x-2 rounded-md bg-indigo-50 px-2 py-1 text-xs font-medium text-indigo-700 ring-1 ring-indigo-600/20 ring-inset dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20"
>
<Avatar
src={item.imageUrl}
name={item.name}
initials={getInitials(item.name)}
size={6}
/>
<span className="max-w-40 truncate">{item.name}</span>
<button
type="button"
onClick={() => removeItem(item)}
className="-mr-0.5 rounded hover:bg-indigo-100 dark:hover:bg-indigo-500/20"
>
<span className="sr-only">{item.name} entfernen</span>
<XMarkIcon aria-hidden="true" className="size-3.5" />
</button>
</span>
))}
</div>
)}
</HeadlessCombobox>
)
}

View File

@ -13,8 +13,9 @@ import type { Notification } from './types'
type NotificationCenterProps = {
notifications: Notification[]
unreadCount: number
onMarkRead: (notificationId: string) => void
onMarkAllRead: () => void
onMarkRead: (notificationId: string) => void | Promise<void>
onMarkAllRead: () => void | Promise<void>
onNotificationClick?: (notification: Notification) => void
}
type NotificationTab = 'unread' | 'all'
@ -35,6 +36,7 @@ export default function NotificationCenter({
unreadCount,
onMarkRead,
onMarkAllRead,
onNotificationClick,
}: NotificationCenterProps) {
const [open, setOpen] = useState(false)
const [activeTab, setActiveTab] = useState<NotificationTab>('unread')
@ -107,6 +109,12 @@ export default function NotificationCenter({
return 'Keine Benachrichtigungen vorhanden.'
}
function handleNotificationClick(notification: Notification) {
void onMarkRead(notification.id)
onNotificationClick?.(notification)
setOpen(false)
}
return (
<div ref={containerRef} className="relative">
<button
@ -125,7 +133,7 @@ export default function NotificationCenter({
</button>
{open && (
<div className="absolute right-0 z-50 mt-2 w-96 overflow-hidden rounded-lg bg-white shadow-lg ring-1 ring-black/5 dark:bg-gray-900 dark:ring-white/10">
<div className="absolute right-0 z-[9600] mt-2 w-96 overflow-hidden rounded-lg bg-white shadow-lg ring-1 ring-black/5 dark:bg-gray-900 dark:ring-white/10">
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 border-b border-gray-200 dark:border-white/10">
<h2 className="min-w-0 truncate px-4 py-3 text-sm font-semibold text-gray-900 dark:text-white">
Benachrichtigungen
@ -183,7 +191,7 @@ export default function NotificationCenter({
<button
key={notification.id}
type="button"
onClick={() => onMarkRead(notification.id)}
onClick={() => handleNotificationClick(notification)}
className="block w-full border-b border-gray-100 px-4 py-3 text-left hover:bg-gray-50 dark:border-white/10 dark:hover:bg-white/5"
>
<div className="flex gap-x-3">

View File

@ -1,10 +1,18 @@
// frontend/src/components/Notifications.tsx
import { useEffect } from 'react'
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react'
import { createPortal } from 'react-dom'
import { Transition } from '@headlessui/react'
import {
CheckCircleIcon,
ExclamationCircleIcon,
ExclamationTriangleIcon,
InformationCircleIcon,
XCircleIcon,
@ -34,10 +42,38 @@ type NotificationsProps = {
position?: 'top-right' | 'bottom-right'
}
type ShowToastInput = Omit<ToastNotification, 'id'>
type ShowErrorToastInput = {
title: string
error: unknown
fallback: string
autoClose?: boolean
duration?: number
}
type NotificationsContextValue = {
notifications: ToastNotification[]
showToast: (notification: ShowToastInput) => void
showErrorToast: (input: ShowErrorToastInput) => void
dismissToast: (id: string) => void
clearToasts: () => void
}
const NotificationsContext = createContext<NotificationsContextValue | null>(null)
function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(' ')
}
function createNotificationId() {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID()
}
return `${Date.now()}-${Math.random()}`
}
function getVariantStyles(variant: NotificationVariant) {
switch (variant) {
case 'success':
@ -82,7 +118,7 @@ function NotificationToast({
actionLabel,
onAction,
autoClose = true,
duration = 5000,
duration = 3000,
} = notification
const { icon: Icon, iconClassName } = getVariantStyles(variant)
@ -154,18 +190,22 @@ function NotificationToast({
)
}
export default function Notifications({
function NotificationsViewport({
notifications,
onDismiss,
position = 'top-right',
}: NotificationsProps) {
return (
if (typeof document === 'undefined') {
return null
}
return createPortal(
<div
aria-live="assertive"
className={classNames(
'pointer-events-none fixed inset-0 z-[99999] flex px-4 py-6 sm:p-6',
'pointer-events-none fixed inset-x-0 top-16 z-[99999] flex px-4 py-6 sm:p-6',
position === 'top-right'
? 'items-end sm:items-start'
? 'items-start'
: 'items-end',
)}
>
@ -178,6 +218,97 @@ export default function Notifications({
/>
))}
</div>
</div>
</div>,
document.body,
)
}
}
export function NotificationProvider({
children,
position = 'top-right',
}: {
children: ReactNode
position?: 'top-right' | 'bottom-right'
}) {
const [notifications, setNotifications] = useState<ToastNotification[]>([])
const dismissToast = useCallback((id: string) => {
setNotifications((currentNotifications) =>
currentNotifications.filter((notification) => notification.id !== id),
)
}, [])
const clearToasts = useCallback(() => {
setNotifications([])
}, [])
const showToast = useCallback((notification: ShowToastInput) => {
const id = createNotificationId()
setNotifications((currentNotifications) => [
{
id,
autoClose: true,
duration: 3500,
...notification,
},
...currentNotifications,
])
}, [])
const showErrorToast = useCallback((input: ShowErrorToastInput) => {
showToast({
variant: 'error',
title: input.title,
message:
input.error instanceof Error
? input.error.message
: input.fallback,
autoClose: input.autoClose ?? false,
duration: input.duration,
})
}, [showToast])
const value = useMemo<NotificationsContextValue>(
() => ({
notifications,
showToast,
showErrorToast,
dismissToast,
clearToasts,
}),
[
notifications,
showToast,
showErrorToast,
dismissToast,
clearToasts,
],
)
return (
<NotificationsContext.Provider value={value}>
{children}
<NotificationsViewport
notifications={notifications}
onDismiss={dismissToast}
position={position}
/>
</NotificationsContext.Provider>
)
}
export function useNotifications() {
const context = useContext(NotificationsContext)
if (!context) {
throw new Error(
'useNotifications muss innerhalb von NotificationProvider verwendet werden.',
)
}
return context
}
export default NotificationsViewport

View File

@ -0,0 +1,369 @@
// frontend/src/components/RadioGroup.tsx
'use client'
import { type ReactNode } from 'react'
type RadioValue = string | number | boolean | null
export type RadioGroupOption<TValue extends RadioValue = string> = {
id: string
value: TValue
label: ReactNode
description?: ReactNode
disabled?: boolean
/**
* Für table/card-ähnliche Layouts, z.B. Preis, Limit, Zusatzinfos.
*/
meta?: readonly ReactNode[]
}
type RadioGroupVariant = 'simple' | 'bordered' | 'table'
type RadioGroupOrientation = 'vertical' | 'inline'
type RadioGroupDescriptionLayout = 'block' | 'inline'
type RadioGroupRadioPosition = 'left' | 'right'
export type RadioGroupProps<TValue extends RadioValue = string> = {
name: string
options: readonly RadioGroupOption<TValue>[]
value?: TValue
defaultValue?: TValue
onChange?: (value: TValue, option: RadioGroupOption<TValue>) => void
legend?: ReactNode
description?: ReactNode
ariaLabel?: string
variant?: RadioGroupVariant
orientation?: RadioGroupOrientation
descriptionLayout?: RadioGroupDescriptionLayout
radioPosition?: RadioGroupRadioPosition
disabled?: boolean
required?: boolean
className?: string
}
const radioClassName =
'relative size-4 shrink-0 appearance-none rounded-full border border-gray-300 bg-white before:absolute before:inset-1 before:rounded-full before:bg-white not-checked:before:hidden checked:border-indigo-600 checked: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:before:bg-gray-400 dark:border-white/10 dark:bg-white/5 dark:checked:border-indigo-500 dark:checked:bg-indigo-500 dark:focus-visible:outline-indigo-500 dark:disabled:border-white/5 dark:disabled:bg-white/10 dark:disabled:before:bg-white/20 forced-colors:appearance-auto forced-colors:before:hidden'
function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(' ')
}
function serializeValue(value: RadioValue) {
if (value === null) {
return ''
}
return String(value)
}
function isSameValue(left: RadioValue | undefined, right: RadioValue) {
return left === right
}
export default function RadioGroup<TValue extends RadioValue = string>({
name,
options,
value,
defaultValue,
onChange,
legend,
description,
ariaLabel,
variant = 'simple',
orientation = 'vertical',
descriptionLayout = 'block',
radioPosition = 'left',
disabled = false,
required = false,
className,
}: RadioGroupProps<TValue>) {
const isControlled = value !== undefined
function getInputProps(option: RadioGroupOption<TValue>) {
const checkedProps = isControlled
? {
checked: isSameValue(value, option.value),
}
: {
defaultChecked: isSameValue(defaultValue, option.value),
}
return {
...checkedProps,
id: `${name}-${option.id}`,
name,
type: 'radio' as const,
value: serializeValue(option.value),
disabled: disabled || option.disabled,
required,
onChange: () => onChange?.(option.value, option),
className: radioClassName,
}
}
function renderHeader() {
if (!legend && !description) {
return null
}
return (
<>
{legend && (
<legend className="text-sm/6 font-semibold text-gray-900 dark:text-white">
{legend}
</legend>
)}
{description && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{description}
</p>
)}
</>
)
}
function renderSimpleOptions() {
return (
<div
className={classNames(
legend || description ? 'mt-6' : undefined,
orientation === 'inline'
? 'space-y-6 sm:flex sm:items-center sm:space-y-0 sm:space-x-10'
: 'space-y-6',
)}
>
{options.map((option) => {
const inputProps = getInputProps(option)
const descriptionId = option.description
? `${inputProps.id}-description`
: undefined
const isOptionDisabled = disabled || option.disabled
return (
<label
key={option.id}
className={classNames(
'relative flex',
option.description ? 'items-start' : 'items-center',
radioPosition === 'right' && 'justify-between',
isOptionDisabled
? 'cursor-not-allowed opacity-60'
: 'cursor-pointer',
)}
>
{radioPosition === 'left' && (
<div
className={classNames(
Boolean(option.description) && 'flex h-6 items-center',
)}
>
<input
{...inputProps}
aria-describedby={descriptionId}
/>
</div>
)}
<div
className={classNames(
radioPosition === 'left' && 'ml-3',
radioPosition === 'right' && 'min-w-0 flex-1',
'text-sm/6',
)}
>
<span className="font-medium text-gray-900 select-none dark:text-white">
{option.label}
</span>
{option.description && descriptionLayout === 'block' && (
<p
id={descriptionId}
className="text-gray-500 dark:text-gray-400"
>
{option.description}
</p>
)}
{option.description && descriptionLayout === 'inline' && (
<>
{' '}
<span
id={descriptionId}
className="text-gray-500 dark:text-gray-400"
>
{option.description}
</span>
</>
)}
</div>
{radioPosition === 'right' && (
<div className="ml-3 flex h-6 items-center">
<input
{...inputProps}
aria-describedby={descriptionId}
/>
</div>
)}
</label>
)
})}
</div>
)
}
function renderBorderedOptions() {
return (
<div
className={classNames(
legend || description ? 'mt-4' : undefined,
'overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-white/10 dark:bg-white/5',
)}
>
{options.map((option) => {
const inputProps = getInputProps(option)
const descriptionId = option.description
? `${inputProps.id}-description`
: undefined
const isOptionDisabled = disabled || option.disabled
return (
<label
key={option.id}
className={classNames(
'group relative flex items-start px-4 py-3 transition-colors',
'has-checked:bg-indigo-50 has-checked:ring-1 has-checked:ring-indigo-200 has-checked:ring-inset',
'dark:has-checked:bg-indigo-500/10 dark:has-checked:ring-indigo-500/20',
isOptionDisabled
? 'cursor-not-allowed opacity-60'
: 'cursor-pointer hover:bg-gray-50 dark:hover:bg-white/[0.04]',
)}
>
{radioPosition === 'left' && (
<div className="flex h-6 items-center">
<input
{...inputProps}
aria-describedby={descriptionId}
/>
</div>
)}
<div
className={classNames(
'min-w-0 flex-1 text-sm/6',
radioPosition === 'left' && 'ml-3',
)}
>
<span className="font-medium text-gray-900 select-none group-has-checked:text-indigo-700 dark:text-white dark:group-has-checked:text-indigo-300">
{option.label}
</span>
{option.description && (
<p
id={descriptionId}
className="mt-0.5 text-xs text-gray-500 group-has-checked:text-indigo-700/80 dark:text-gray-400 dark:group-has-checked:text-indigo-300/80"
>
{option.description}
</p>
)}
</div>
{radioPosition === 'right' && (
<div className="ml-3 flex h-6 items-center">
<input
{...inputProps}
aria-describedby={descriptionId}
/>
</div>
)}
</label>
)
})}
</div>
)
}
function renderTableOptions() {
return (
<div
className={classNames(
legend || description ? 'mt-4' : undefined,
'relative -space-y-px rounded-md bg-white dark:bg-gray-800/50',
)}
>
{options.map((option) => {
const inputProps = getInputProps(option)
const descriptionText = [
option.description,
...(option.meta ?? []),
]
.filter(Boolean)
.join(', ')
return (
<label
key={option.id}
aria-label={
typeof option.label === 'string' ? option.label : undefined
}
aria-description={descriptionText || undefined}
className={classNames(
'group flex flex-col border border-gray-200 p-4 first:rounded-tl-md first:rounded-tr-md last:rounded-br-md last:rounded-bl-md focus:outline-hidden has-checked:relative has-checked:border-indigo-200 has-checked:bg-indigo-50 md:grid md:grid-cols-3 md:items-center md:gap-x-4 md:pr-6 md:pl-4 dark:border-gray-700 dark:has-checked:border-indigo-800 dark:has-checked:bg-indigo-600/10',
disabled || option.disabled
? 'cursor-not-allowed opacity-60'
: 'cursor-pointer',
)}
>
<span className="flex items-center gap-3 text-sm">
<input {...inputProps} />
<span className="font-medium text-gray-900 group-has-checked:text-indigo-900 dark:text-white dark:group-has-checked:text-indigo-300">
{option.label}
</span>
</span>
{option.meta?.slice(0, 2).map((meta, index) => (
<span
key={index}
className={classNames(
'mt-2 ml-7 text-sm text-gray-500 group-has-checked:text-indigo-700 md:mt-0 md:ml-0 dark:text-gray-400 dark:group-has-checked:text-indigo-300/75',
index === 0 && 'md:text-center',
index === 1 && 'md:text-right',
)}
>
{meta}
</span>
))}
{!option.meta?.length && option.description && (
<span className="mt-2 ml-7 text-sm text-gray-500 group-has-checked:text-indigo-700 md:mt-0 md:ml-0 md:col-span-2 dark:text-gray-400 dark:group-has-checked:text-indigo-300/75">
{option.description}
</span>
)}
</label>
)
})}
</div>
)
}
return (
<fieldset
aria-label={ariaLabel}
className={className}
disabled={disabled}
>
{renderHeader()}
{variant === 'simple' && renderSimpleOptions()}
{variant === 'bordered' && renderBorderedOptions()}
{variant === 'table' && renderTableOptions()}
</fieldset>
)
}

View File

@ -249,7 +249,7 @@ export default function Sidebar({
}: SidebarProps) {
return (
<>
<Dialog open={sidebarOpen} onClose={setSidebarOpen} className="relative z-50 lg:hidden">
<Dialog open={sidebarOpen} onClose={setSidebarOpen} className="relative z-[2000] lg:hidden">
<DialogBackdrop
transition
className="fixed inset-0 bg-gray-900/80 transition-opacity duration-300 ease-linear data-closed:opacity-0"
@ -280,7 +280,7 @@ export default function Sidebar({
</div>
</Dialog>
<div className="hidden bg-gray-900 ring-1 ring-white/10 lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-72 lg:flex-col">
<div className="hidden bg-gray-900 ring-1 ring-white/10 lg:fixed lg:inset-y-0 lg:z-[2000] lg:flex lg:w-72 lg:flex-col">
<div className="flex grow flex-col gap-y-5 overflow-y-auto bg-black/10 px-6 pb-4">
<SidebarContent
user={user}

View File

@ -76,7 +76,7 @@ function TaskListItemComplete({
<TaskListItemWrapper step={step}>
<span className="flex h-9 items-center">
<span className="relative z-10 flex size-8 items-center justify-center rounded-full bg-indigo-600 group-hover:bg-indigo-800 dark:bg-indigo-500 dark:group-hover:bg-indigo-600">
<span className="relative z-10 flex size-8 items-center justify-center rounded-full bg-indigo-600 dark:bg-indigo-500">
<CheckIcon aria-hidden="true" className="size-5 text-white" />
</span>
</span>
@ -108,10 +108,10 @@ function TaskListItemCurrent({
<TaskListItemWrapper step={step} ariaCurrent="step">
<span className="flex h-9 items-center">
<span className="relative z-10 flex size-8 items-center justify-center rounded-full border-2 border-indigo-600 bg-white dark:border-indigo-500 dark:bg-gray-900">
<span className="relative z-10 flex size-8 items-center justify-center rounded-full bg-indigo-600 dark:bg-indigo-500">
<LoadingSpinner
size="sm"
className="text-indigo-600 dark:text-indigo-500"
size={18}
className="text-white"
srLabel="Aktueller Schritt läuft"
/>
</span>
@ -176,8 +176,8 @@ function TaskListItemUpcoming({
<TaskListItemWrapper step={step}>
<span aria-hidden="true" className="flex h-9 items-center">
<span className="relative z-10 flex size-8 items-center justify-center rounded-full border-2 border-gray-300 bg-white group-hover:border-gray-400 dark:border-white/15 dark:bg-gray-900 dark:group-hover:border-white/25">
<span className="size-2.5 rounded-full bg-transparent group-hover:bg-gray-300 dark:group-hover:bg-white/15" />
<span className="relative z-10 flex size-8 items-center justify-center rounded-full border-2 border-gray-300 bg-white dark:border-white/15 dark:bg-gray-900">
<span className="size-2.5 rounded-full bg-transparent" />
</span>
</span>
@ -204,7 +204,7 @@ function TaskListItemWrapper({
<a
href={step.href}
aria-current={ariaCurrent}
className="group relative flex items-start"
className="relative flex items-start"
>
{children}
</a>
@ -214,7 +214,7 @@ function TaskListItemWrapper({
return (
<div
aria-current={ariaCurrent}
className="group relative flex items-start"
className="relative flex items-start"
>
{children}
</div>

View File

@ -15,6 +15,7 @@ import { ChevronDownIcon } from '@heroicons/react/20/solid'
import { Link } from 'react-router'
import Search from './Search'
import Camera from './Camera'
import Avatar from './Avatar'
import type { User } from './types'
type TopbarProps = {
@ -44,12 +45,8 @@ export default function Topbar({
console.log('QR-Code erkannt:', value)
}
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'
return (
<div className="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-gray-200 bg-white px-4 shadow-xs sm:gap-x-6 sm:px-6 lg:px-8 dark:border-white/10 dark:bg-gray-900">
<div className="sticky top-0 z-[9500] flex h-16 shrink-0 items-center gap-x-4 border-b border-gray-200 bg-white px-4 shadow-xs sm:gap-x-6 sm:px-6 lg:px-8 dark:border-white/10 dark:bg-gray-900">
<button
type="button"
onClick={onOpenSidebar}
@ -97,10 +94,10 @@ export default function Topbar({
<span className="absolute -inset-1.5" />
<span className="sr-only">Open user menu</span>
<img
alt=""
src={avatarUrl}
className="size-8 rounded-full bg-gray-50 object-cover outline -outline-offset-1 outline-black/5 dark:bg-gray-800 dark:outline-white/10"
<Avatar
src={user.avatar}
name={user.displayName || user.username || user.email}
size={8}
/>
<span className="hidden lg:flex lg:items-center">

View File

@ -34,12 +34,13 @@ export type DeviceHistoryEntry = {
deviceId: string
userId: string
userDisplayName: string
action: DeviceHistoryAction
action: string
changes: DeviceHistoryChange[]
createdAt: string
updatedAt: string
canEdit: boolean
}
export type RelatedDevice = {
id: string
inventoryNumber: string
@ -136,8 +137,9 @@ export type Notification = {
message: string
entityType: string
entityId: string
data: Record<string, unknown>
visible?: boolean
data: unknown
silent: boolean
soundName?: string
readAt?: string | null
createdAt: string
}

View File

@ -8,9 +8,7 @@ import {
} from '@heroicons/react/20/solid'
import Button from '../../../components/Button'
import LoadingSpinner from '../../../components/LoadingSpinner'
import Notifications, {
type ToastNotification,
} from '../../../components/Notifications'
import { useNotifications } from '../../../components/Notifications'
import Switch from '../../../components/Switch'
import { DialogTitle } from '@headlessui/react'
import { XMarkIcon } from '@heroicons/react/24/outline'
@ -197,10 +195,10 @@ function markCurrentMilestoneSyncStepAsError(
}
export default function Milestone() {
const { showToast, showErrorToast } = useNotifications()
const [settings, setSettings] = useState<MilestoneSettings | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [notifications, setNotifications] = useState<ToastNotification[]>([])
const [isFetchingToken, setIsFetchingToken] = useState(false)
const [skipTlsVerify, setSkipTlsVerify] = useState(false)
const [syncModalOpen, setSyncModalOpen] = useState(false)
@ -212,24 +210,6 @@ export default function Milestone() {
void loadSettings()
}, [])
function dismissNotification(id: string) {
setNotifications((currentNotifications) =>
currentNotifications.filter((notification) => notification.id !== id),
)
}
function showNotification(
notification: Omit<ToastNotification, 'id'>,
) {
setNotifications((currentNotifications) => [
{
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
...notification,
},
...currentNotifications,
])
}
function getHardwareCountMessage(count: number) {
if (count === 1) {
return '1 Hardware-Gerät wurde von Milestone geladen und gespeichert.'
@ -257,7 +237,7 @@ export default function Milestone() {
if (event.type === 'token') {
const tokenExpiresAt = formatUpdatedAt(event.tokenExpiresAt)
showNotification({
showToast({
variant: 'success',
title: 'Token abgerufen',
message: tokenExpiresAt
@ -269,7 +249,7 @@ export default function Milestone() {
}
if (event.type === 'recordingServer') {
showNotification({
showToast({
variant: 'success',
title: 'Recording-Infos abgerufen',
message: [
@ -289,7 +269,7 @@ export default function Milestone() {
}
if (event.type === 'hardware') {
showNotification({
showToast({
variant: 'success',
title: 'Hardware synchronisiert',
message: getHardwareCountMessage(event.hardwareCount ?? 0),
@ -393,14 +373,10 @@ export default function Milestone() {
setSettings(data.settings)
setSkipTlsVerify(Boolean(data.settings?.skipTlsVerify))
} catch (error) {
showNotification({
variant: 'error',
showErrorToast({
title: 'Einstellungen konnten nicht geladen werden',
message:
error instanceof Error
? error.message
: 'Milestone-Einstellungen konnten nicht geladen werden',
autoClose: false,
error,
fallback: 'Milestone-Einstellungen konnten nicht geladen werden',
})
} finally {
setIsLoading(false)
@ -449,14 +425,10 @@ export default function Milestone() {
await runMilestoneSyncStream()
} catch (error) {
showNotification({
variant: 'error',
showErrorToast({
title: 'Speichern fehlgeschlagen',
message:
error instanceof Error
? error.message
: 'Milestone-Einstellungen konnten nicht gespeichert werden',
autoClose: false,
error,
fallback: 'Milestone-Einstellungen konnten nicht gespeichert werden',
})
} finally {
setIsSaving(false)
@ -469,14 +441,10 @@ export default function Milestone() {
try {
await runMilestoneSyncStream()
} catch (error) {
showNotification({
variant: 'error',
showErrorToast({
title: 'Abruf fehlgeschlagen',
message:
error instanceof Error
? error.message
: 'Milestone-Token konnte nicht abgerufen werden',
autoClose: false,
error,
fallback: 'Milestone-Token konnte nicht abgerufen werden',
})
} finally {
setIsFetchingToken(false)
@ -500,12 +468,6 @@ export default function Milestone() {
return (
<>
<Notifications
notifications={notifications}
onDismiss={dismissNotification}
position="top-right"
/>
<Modal
open={syncModalOpen}
setOpen={(open) => {
@ -548,12 +510,6 @@ export default function Milestone() {
steps={syncSteps}
ariaLabel="Milestone-Synchronisierung"
/>
{syncError && (
<div className="mt-5 rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
{syncError}
</div>
)}
</div>
<div className="flex justify-end border-t border-gray-200 px-6 py-4 dark:border-white/10">

View File

@ -1,9 +1,11 @@
// frontend\src\pages\administration\teams\TeamsAdministration.tsx
// frontend/src/pages/administration/teams/TeamsAdministration.tsx
import { useEffect, useState, type FormEvent } from 'react'
import { UserGroupIcon } from '@heroicons/react/20/solid'
import { useEffect, useMemo, useState, type FormEvent } from 'react'
import {
MagnifyingGlassIcon,
PlusIcon,
UserGroupIcon,
} from '@heroicons/react/20/solid'
import Button from '../../../components/Button'
import LoadingSpinner from '../../../components/LoadingSpinner'
@ -19,18 +21,44 @@ type AdminTeam = {
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<string | false | null | undefined>) {
return classes.filter(Boolean).join(' ')
}
async function readApiError(response: Response, fallback: string) {
const data = await response.json().catch(() => null)
return data?.error ?? fallback
}
function getTeamSearchText(team: AdminTeam) {
return [
team.name,
team.initial,
team.href,
]
.join(' ')
.toLowerCase()
}
export default function TeamsAdministration() {
const [teams, setTeams] = useState<AdminTeam[]>([])
const [selectedTeam, setSelectedTeam] = useState<AdminTeam | null>(null)
const [teamSearch, setTeamSearch] = useState('')
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const filteredTeams = useMemo(() => {
const search = teamSearch.trim().toLowerCase()
if (!search) {
return teams
}
return teams.filter((team) => getTeamSearchText(team).includes(search))
}, [teamSearch, teams])
useEffect(() => {
void loadTeams()
}, [])
@ -63,12 +91,25 @@ export default function TeamsAdministration() {
}
}
function handleCreateNewTeam() {
setSelectedTeam(null)
setError(null)
}
function handleEditTeam(team: AdminTeam) {
setSelectedTeam(team)
setError(null)
}
async function handleSaveTeam(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const form = event.currentTarget
const formData = new FormData(form)
const teamId = selectedTeam?.id
const isEditing = Boolean(teamId)
const payload = {
name: String(formData.get('teamName') ?? ''),
initial: String(formData.get('teamInitial') ?? ''),
@ -79,28 +120,41 @@ export default function TeamsAdministration() {
setError(null)
try {
const response = await fetch(`${API_URL}/admin/teams`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
const response = await fetch(
isEditing
? `${API_URL}/admin/teams/${teamId}`
: `${API_URL}/admin/teams`,
{
method: isEditing ? 'PUT' : 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(payload),
},
credentials: 'include',
body: JSON.stringify(payload),
})
)
if (!response.ok) {
throw new Error(
await readApiError(response, 'Team konnte nicht erstellt werden'),
await readApiError(
response,
isEditing
? 'Team konnte nicht aktualisiert werden'
: 'Team konnte nicht erstellt werden',
),
)
}
await loadTeams()
form.reset()
setSelectedTeam(null)
} catch (error) {
setError(
error instanceof Error
? error.message
: 'Team konnte nicht erstellt werden',
: isEditing
? 'Team konnte nicht aktualisiert werden'
: 'Team konnte nicht erstellt werden',
)
} finally {
setIsSaving(false)
@ -128,60 +182,116 @@ export default function TeamsAdministration() {
</div>
)}
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1fr)_28rem]">
<div className="rounded-lg bg-white p-4 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
<h2 className="flex items-center gap-x-2 text-sm font-semibold text-gray-900 dark:text-white">
<UserGroupIcon aria-hidden="true" className="size-5 text-gray-400" />
Teams
</h2>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[19rem_minmax(0,1fr)]">
<aside className="overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
<div className="border-b border-gray-200 px-4 py-4 dark:border-white/10">
<div className="flex items-center justify-between gap-x-3">
<div className="min-w-0">
<h2 className="flex items-center gap-x-2 text-sm font-semibold text-gray-900 dark:text-white">
<UserGroupIcon aria-hidden="true" className="size-5 text-gray-400" />
Teams
</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Übersicht der aktuell vorhandenen Teams.
</p>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{filteredTeams.length} von {teams.length} angezeigt
</p>
</div>
<div className="mt-4 divide-y divide-gray-200 rounded-lg border border-gray-200 dark:divide-white/10 dark:border-white/10">
{teams.length === 0 ? (
<div className="p-4 text-sm text-gray-500 dark:text-gray-400">
Keine Teams vorhanden.
<Button
type="button"
color="indigo"
size="sm"
onClick={handleCreateNewTeam}
leadingIcon={<PlusIcon aria-hidden="true" className="size-4" />}
>
Neu
</Button>
</div>
<div className="relative mt-4">
<MagnifyingGlassIcon
aria-hidden="true"
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-gray-400"
/>
<input
type="search"
value={teamSearch}
onChange={(event) => setTeamSearch(event.target.value)}
placeholder="Teams 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"
/>
</div>
</div>
<div className="max-h-[calc(100dvh-20rem)] overflow-y-auto p-2">
{filteredTeams.length === 0 ? (
<div className="px-3 py-8 text-center text-sm text-gray-500 dark:text-gray-400">
Keine Teams gefunden.
</div>
) : (
teams.map((team) => (
<div
key={team.id}
className="flex items-center justify-between gap-x-4 p-4"
>
<div className="flex min-w-0 items-center gap-x-3">
<span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-indigo-600 text-sm font-semibold text-white">
{team.initial}
</span>
<div className="space-y-1">
{filteredTeams.map((team) => {
const isSelected = selectedTeam?.id === team.id
<div className="min-w-0">
<p className="truncate text-sm font-medium text-gray-900 dark:text-white">
{team.name}
</p>
return (
<button
key={team.id}
type="button"
onClick={() => handleEditTeam(team)}
className={classNames(
isSelected
? 'bg-indigo-50 text-indigo-700 ring-1 ring-indigo-200 ring-inset dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-500/20'
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-white/5',
'flex w-full gap-x-3 rounded-md px-3 py-2 text-left text-sm',
)}
>
<span
className={classNames(
isSelected
? 'bg-indigo-600 text-white'
: 'bg-gray-100 text-gray-600 dark:bg-white/10 dark:text-gray-300',
'flex size-9 shrink-0 items-center justify-center rounded-full text-xs font-semibold',
)}
>
{team.initial || team.name.slice(0, 2).toUpperCase()}
</span>
<p className="truncate text-xs text-gray-500 dark:text-gray-400">
{team.href}
</p>
</div>
</div>
</div>
))
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">
{team.name}
</span>
<span className="block truncate text-xs text-gray-500 dark:text-gray-400">
{team.href || '#'}
</span>
</span>
</button>
)
})}
</div>
)}
</div>
</div>
</aside>
<form
key={selectedTeam?.id ?? 'new'}
onSubmit={handleSaveTeam}
className="rounded-lg bg-white p-4 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10"
>
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
Team anlegen
</h2>
<div className="flex items-start justify-between gap-x-4">
<div>
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
{selectedTeam ? 'Team bearbeiten' : 'Team anlegen'}
</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Erstelle ein neues Team, das anschließend Benutzern zugewiesen werden kann.
</p>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{selectedTeam
? 'Passe Name, Initial und Link des ausgewählten Teams an.'
: 'Erstelle ein neues Team, das anschließend Benutzern zugewiesen werden kann.'}
</p>
</div>
</div>
<div className="mt-4 space-y-4">
<div>
@ -198,6 +308,7 @@ export default function TeamsAdministration() {
name="teamName"
placeholder="Teamname"
required
defaultValue={selectedTeam?.name ?? ''}
className={inputClassName}
/>
</div>
@ -217,6 +328,7 @@ export default function TeamsAdministration() {
name="teamInitial"
placeholder="Initial"
maxLength={3}
defaultValue={selectedTeam?.initial ?? ''}
className={inputClassName}
/>
</div>
@ -235,21 +347,33 @@ export default function TeamsAdministration() {
id="team-href"
name="teamHref"
placeholder="Link, z. B. #"
defaultValue="#"
defaultValue={selectedTeam?.href ?? '#'}
className={inputClassName}
/>
</div>
</div>
</div>
<div className="mt-4 flex justify-end">
<div className="mt-4 flex justify-end gap-x-3">
{selectedTeam && (
<Button
type="button"
variant="secondary"
color="gray"
disabled={isSaving}
onClick={handleCreateNewTeam}
>
Abbrechen
</Button>
)}
<Button
type="submit"
variant="secondary"
color="indigo"
isLoading={isSaving}
>
Team erstellen
{selectedTeam ? 'Änderungen speichern' : 'Team erstellen'}
</Button>
</div>
</form>

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,12 @@
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 {
CheckIcon,
IdentificationIcon,
MapPinIcon,
WrenchScrewdriverIcon,
} from '@heroicons/react/20/solid'
import Modal from '../../components/Modal'
import Button from '../../components/Button'
import type { Device } from '../../components/types'
@ -21,10 +26,26 @@ type CreateDeviceModalProps = {
onSubmit: (event: FormEvent<HTMLFormElement>) => void
}
const createDeviceTabs: Array<{ id: DeviceModalTabId; name: string }> = [
{ id: 'masterData', name: 'Stammdaten' },
{ id: 'location', name: 'Standort' },
{ id: 'accessories', name: 'Zubehör' },
const createDeviceTabs: Array<{
id: DeviceModalTabId
name: string
icon: typeof IdentificationIcon
}> = [
{
id: 'masterData',
name: 'Stammdaten',
icon: IdentificationIcon,
},
{
id: 'location',
name: 'Standort',
icon: MapPinIcon,
},
{
id: 'accessories',
name: 'Zubehör',
icon: WrenchScrewdriverIcon,
},
]
export default function CreateDeviceModal({

View File

@ -7,6 +7,7 @@ import Switch from '../../components/Switch'
import type { DeviceModalTabId } from './DeviceModalTabs'
import Textarea from '../../components/Textarea'
import type { Device } from '../../components/types'
import { VideoCameraIcon } from '@heroicons/react/20/solid'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
@ -236,6 +237,11 @@ export default function DeviceFormFields({
const milestoneReadonlyClassName =
'!cursor-not-allowed !bg-gray-50 !text-gray-500 !outline-gray-200 dark:!bg-white/10 dark:!text-gray-400 dark:!outline-white/10'
const milestoneInputIconClassName =
'pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-indigo-500 dark:text-indigo-400'
const milestoneInputWithIconClassName = 'pl-9'
async function loadDeviceLookups() {
setLookupError(null)
@ -378,25 +384,90 @@ export default function DeviceFormFields({
</div>
</div>
{isMilestoneDevice && device?.milestoneDisplayName && (
<div className="sm:col-span-6">
<label
htmlFor="milestoneDisplayName"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Anzeigename
</label>
{isMilestoneDevice && (
<>
<input type="hidden" name="isMilestoneDevice" value="true" />
<div className="mt-2">
<input
id="milestoneDisplayName"
name="milestoneDisplayName"
type="text"
defaultValue={device.milestoneDisplayName}
className={inputClassName}
/>
<div className="sm:col-span-6">
<label
htmlFor="milestoneDisplayName"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Anzeigename
</label>
<div className="mt-2">
<div className="relative mt-2">
<VideoCameraIcon
aria-hidden="true"
className={milestoneInputIconClassName}
/>
<input
id="milestoneDisplayName"
name="milestoneDisplayName"
type="text"
defaultValue={device?.milestoneDisplayName ?? ''}
className={classNames(inputClassName, milestoneInputWithIconClassName)}
/>
</div>
</div>
</div>
</div>
<div className="sm:col-span-3">
<label
htmlFor="milestoneHardwareId"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Milestone-Geräte-ID
</label>
<div className="relative mt-2">
<VideoCameraIcon
aria-hidden="true"
className={milestoneInputIconClassName}
/>
<input
id="milestoneHardwareId"
type="text"
value={device?.milestoneHardwareId ?? ''}
readOnly
aria-readonly="true"
title="Die Milestone-Geräte-ID wird aus Milestone synchronisiert."
className={classNames(
inputClassName,
milestoneInputWithIconClassName,
milestoneReadonlyClassName,
)}
/>
</div>
</div>
<div className="sm:col-span-3">
<label
htmlFor="milestoneEnabled"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Milestone-Status
</label>
<div className="mt-2 flex min-h-[38px] items-center gap-3 rounded-md border border-gray-200 bg-white px-3 py-1.5 dark:border-white/10 dark:bg-white/5">
<VideoCameraIcon
aria-hidden="true"
className="size-4 shrink-0 text-indigo-500 dark:text-indigo-400"
/>
<Switch
id="milestoneEnabled"
name="milestoneEnabled"
label="Aktiviert"
defaultChecked={device?.milestoneEnabled ?? true}
className="min-w-0 flex-1"
/>
</div>
</div>
</>
)}
<div className="sm:col-span-3">
@ -463,7 +534,14 @@ export default function DeviceFormFields({
MAC-Adresse
</label>
<div className="mt-2">
<div className="relative mt-2">
{isMilestoneDevice && (
<VideoCameraIcon
aria-hidden="true"
className={milestoneInputIconClassName}
/>
)}
<input
id="macAddress"
name="macAddress"
@ -483,6 +561,7 @@ export default function DeviceFormFields({
onInvalid={isMilestoneDevice ? undefined : handleMacAddressInvalid}
className={classNames(
inputClassName,
isMilestoneDevice && milestoneInputWithIconClassName,
isMilestoneDevice && milestoneReadonlyClassName,
)}
/>
@ -497,18 +576,59 @@ export default function DeviceFormFields({
IP-Adresse
</label>
<div className="mt-2">
<div className="relative mt-2">
{isMilestoneDevice && (
<VideoCameraIcon
aria-hidden="true"
className={milestoneInputIconClassName}
/>
)}
<input
id="ipAddress"
name="ipAddress"
type="text"
placeholder="192.168.1.100"
defaultValue={device?.ipAddress ?? ''}
className={inputClassName}
className={classNames(
inputClassName,
isMilestoneDevice && milestoneInputWithIconClassName,
)}
/>
</div>
</div>
{isMilestoneDevice && (
<div className="sm:col-span-3">
<label
htmlFor="milestonePort"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Port
</label>
<div className="relative mt-2">
<VideoCameraIcon
aria-hidden="true"
className={milestoneInputIconClassName}
/>
<input
id="milestonePort"
name="milestonePort"
type="text"
inputMode="numeric"
placeholder="4107"
defaultValue={device?.milestonePort ?? ''}
className={classNames(
inputClassName,
milestoneInputWithIconClassName,
)}
/>
</div>
</div>
)}
<div className="sm:col-span-3">
<label
htmlFor="firmwareVersion"
@ -517,7 +637,14 @@ export default function DeviceFormFields({
Firmware
</label>
<div className="mt-2">
<div className="relative mt-2">
{isMilestoneDevice && (
<VideoCameraIcon
aria-hidden="true"
className={milestoneInputIconClassName}
/>
)}
<input
id="firmwareVersion"
name="firmwareVersion"
@ -528,6 +655,7 @@ export default function DeviceFormFields({
title={isMilestoneDevice ? 'Firmware wird aus Milestone synchronisiert.' : undefined}
className={classNames(
inputClassName,
isMilestoneDevice && milestoneInputWithIconClassName,
isMilestoneDevice && milestoneReadonlyClassName,
)}
/>
@ -583,7 +711,7 @@ export default function DeviceFormFields({
label="Kommentar"
variant="simple"
rows={3}
defaultValue={device?.comment ?? ''}
defaultValue={device?.comment ?? '-'}
/>
</div>
</div>

View File

@ -1,10 +1,15 @@
// frontend\src\pages\devices\DeviceModalTabs.tsx
import { type ComponentType, type SVGProps } from 'react'
export type DeviceModalTabId = 'masterData' | 'location' | 'accessories' | 'history'
type DeviceModalTabIcon = ComponentType<SVGProps<SVGSVGElement>>
type DeviceModalTab = {
id: DeviceModalTabId
name: string
icon?: DeviceModalTabIcon
disabled?: boolean
}
@ -25,9 +30,13 @@ export default function DeviceModalTabs({
}: DeviceModalTabsProps) {
return (
<div className="border-b border-gray-200 bg-white px-4 sm:px-6 dark:border-white/10 dark:bg-gray-900">
<nav aria-label="Geräteformular" className="-mb-px flex gap-x-6 overflow-x-auto">
<nav
aria-label="Geräteformular"
className="-mb-px flex gap-x-6 overflow-x-auto"
>
{tabs.map((tab) => {
const isActive = activeTab === tab.id
const Icon = tab.icon
return (
<button
@ -36,14 +45,21 @@ export default function DeviceModalTabs({
disabled={tab.disabled}
onClick={() => onChange(tab.id)}
className={classNames(
'whitespace-nowrap border-b-2 px-1 py-3 text-sm font-medium',
'inline-flex items-center gap-x-2 whitespace-nowrap border-b-2 px-1 py-3 text-sm font-medium',
isActive
? 'border-indigo-600 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-white/20 dark:hover:text-gray-200',
tab.disabled && 'cursor-not-allowed opacity-50',
)}
>
{tab.name}
{Icon && (
<Icon
aria-hidden="true"
className="size-4 shrink-0"
/>
)}
<span>{tab.name}</span>
</button>
)
})}

View File

@ -1,4 +1,4 @@
// frontend/src/pages/DevicesPage.tsx
// frontend\src\pages\devices\DevicesPage.tsx
import { useEffect, useRef, useState, type ComponentProps, type FormEvent, type MouseEvent } from 'react'
import {
@ -7,6 +7,7 @@ import {
CheckCircleIcon,
ClockIcon,
ExclamationTriangleIcon,
PlusIcon,
WrenchScrewdriverIcon,
XCircleIcon,
} from '@heroicons/react/20/solid'
@ -17,7 +18,7 @@ import EditDeviceModal from './EditDeviceModal'
import Tables, { type TableColumn } from '../../components/Tables'
import LoadingSpinner from '../../components/LoadingSpinner'
import type { Device, DeviceHistoryEntry } from '../../components/types'
import Notifications, { type ToastNotification } from '../../components/Notifications'
import { useNotifications } from '../../components/Notifications'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
@ -59,10 +60,49 @@ function formatFirmwareCheckedAt(value?: string | null) {
return 'Noch nicht geprüft'
}
const date = new Date(value)
if (!Number.isFinite(date.getTime())) {
return 'Noch nicht geprüft'
}
return new Intl.DateTimeFormat('de-DE', {
dateStyle: 'short',
timeStyle: 'short',
}).format(new Date(value))
}).format(date)
}
function formatFirmwareCheckRemaining(nextAllowedAt?: string | null, now = Date.now()) {
if (!nextAllowedAt) {
return null
}
const nextAllowedTimestamp = new Date(nextAllowedAt).getTime()
if (!Number.isFinite(nextAllowedTimestamp)) {
return null
}
const remainingSeconds = Math.ceil((nextAllowedTimestamp - now) / 1000)
if (remainingSeconds <= 0) {
return null
}
const minutes = Math.ceil(remainingSeconds / 60)
if (minutes < 60) {
return `in ${minutes} Min.`
}
const hours = Math.floor(minutes / 60)
const remainingMinutes = minutes % 60
if (remainingMinutes === 0) {
return `in ${hours} Std.`
}
return `in ${hours} Std. ${remainingMinutes} Min.`
}
function getLatestFirmwareCheckedAt(devices: Device[]) {
@ -124,16 +164,15 @@ function getSupportBadgeTone(severity?: string): BadgeTone {
}
export default function DevicesPage() {
const { showToast, showErrorToast } = useNotifications()
const [devices, setDevices] = useState<Device[]>([])
const [isLoading, setIsLoading] = useState(true)
const [activeModal, setActiveModal] = useState<'create' | 'edit' | null>(null)
const [editingDevice, setEditingDevice] = useState<Device | null>(null)
const [isCreating, setIsCreating] = useState(false)
const [error, setError] = useState<string | null>(null)
const [createError, setCreateError] = useState<string | null>(null)
const [relatedDeviceIds, setRelatedDeviceIds] = useState<string[]>([])
const [isJournalSubmitting, setIsJournalSubmitting] = useState(false)
const [notifications, setNotifications] = useState<ToastNotification[]>([])
const [togglingMilestoneDeviceIds, setTogglingMilestoneDeviceIds] = useState<Set<string>>(
() => new Set(),
)
@ -215,7 +254,6 @@ export default function DevicesPage() {
async function handleCheckFirmware() {
setIsCheckingFirmware(true)
setError(null)
try {
const response = await fetch(`${API_URL}/devices/firmware-check`, {
@ -250,7 +288,7 @@ export default function DevicesPage() {
await loadDevices({ silent: true })
await loadFirmwareCheckState()
showNotification({
showToast({
variant: data.failedModels > 0 ? 'warning' : 'success',
title: 'Firmware geprüft',
message:
@ -259,14 +297,10 @@ export default function DevicesPage() {
: `${data.checkedModels} Modelle wurden geprüft.`,
})
} catch (error) {
showNotification({
variant: 'error',
showErrorToast({
title: 'Firmware-Prüfung fehlgeschlagen',
message:
error instanceof Error
? error.message
: 'Firmware konnte nicht geprüft werden',
autoClose: false,
error,
fallback: 'Firmware konnte nicht geprüft werden',
})
} finally {
setIsCheckingFirmware(false)
@ -308,13 +342,52 @@ export default function DevicesPage() {
}
}
async function handleUpdateDeviceJournalEntry(
itemId: string | number,
content: string,
) {
if (!editingDevice) {
return
}
setIsJournalSubmitting(true)
setHistoryError(null)
try {
const response = await fetch(
`${API_URL}/devices/${editingDevice.id}/history/${itemId}`,
{
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 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(options?: { silent?: boolean }) {
if (!options?.silent) {
setIsLoading(true)
}
setError(null)
try {
const response = await fetch(`${API_URL}/devices`, {
credentials: 'include',
@ -328,7 +401,11 @@ export default function DevicesPage() {
const data = await response.json()
setDevices(data.devices ?? [])
} catch (error) {
setError(error instanceof Error ? error.message : 'Geräte konnten nicht geladen werden')
showErrorToast({
title: 'Geräte konnten nicht geladen werden',
error,
fallback: 'Geräte konnten nicht geladen werden',
})
} finally {
if (!options?.silent) {
setIsLoading(false)
@ -426,22 +503,6 @@ export default function DevicesPage() {
})
}
function dismissNotification(id: string) {
setNotifications((currentNotifications) =>
currentNotifications.filter((notification) => notification.id !== id),
)
}
function showNotification(notification: Omit<ToastNotification, 'id'>) {
setNotifications((currentNotifications) => [
{
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
...notification,
},
...currentNotifications,
])
}
async function readApiError(response: Response, fallback: string) {
const data = await response.json().catch(() => null)
@ -465,9 +526,15 @@ export default function DevicesPage() {
})
try {
const response = await fetch(`${API_URL}/devices/${device.id}/milestone-toggle`, {
method: 'POST',
const response = await fetch(`${API_URL}/devices/${device.id}/milestone`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
enabled: !device.milestoneEnabled,
}),
})
if (!response.ok) {
@ -502,7 +569,7 @@ export default function DevicesPage() {
: currentDevice,
)
showNotification({
showToast({
variant: 'success',
title: 'Milestone-Status geändert',
message: `Gerät ${device.inventoryNumber} wurde in Milestone ${
@ -510,7 +577,7 @@ export default function DevicesPage() {
}.`,
})
} catch (error) {
showNotification({
showToast({
variant: 'error',
title: 'Status konnte nicht geändert werden',
message:
@ -538,6 +605,8 @@ export default function DevicesPage() {
setIsCreating(true)
setCreateError(null)
const isMilestoneDevice = formData.get('isMilestoneDevice') === 'true'
const payload = {
inventoryNumber: String(formData.get('inventoryNumber') ?? ''),
manufacturer: String(formData.get('manufacturer') ?? ''),
@ -545,8 +614,12 @@ export default function DevicesPage() {
serialNumber: String(formData.get('serialNumber') ?? ''),
macAddress: String(formData.get('macAddress') ?? ''),
ipAddress: String(formData.get('ipAddress') ?? ''),
milestonePort: String(formData.get('milestonePort') ?? ''),
firmwareVersion: String(formData.get('firmwareVersion') ?? ''),
milestoneDisplayName: String(formData.get('milestoneDisplayName') ?? ''),
...(isMilestoneDevice
? { milestoneEnabled: formData.get('milestoneEnabled') === 'on' }
: {}),
location: String(formData.get('location') ?? ''),
loanStatus: String(formData.get('loanStatus') ?? 'Verfügbar'),
comment: String(formData.get('comment') ?? ''),
@ -555,6 +628,41 @@ export default function DevicesPage() {
}
try {
if (deviceToEdit?.milestoneHardwareId && isMilestoneDevice) {
const milestoneResponse = await fetch(`${API_URL}/devices/${deviceToEdit.id}/milestone`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
name: String(formData.get('milestoneDisplayName') ?? ''),
address: String(formData.get('ipAddress') ?? ''),
port: String(formData.get('milestonePort') ?? ''),
enabled: formData.get('milestoneEnabled') === 'on',
}),
})
if (!milestoneResponse.ok) {
const errorData = await milestoneResponse.json().catch(() => null)
const error = new Error(errorData?.error ?? 'Milestone-Gerät konnte nicht aktualisiert werden')
showErrorToast({
title: 'Milestone-Aktualisierung fehlgeschlagen',
error,
fallback: 'Milestone-Gerät konnte nicht aktualisiert werden',
})
throw error
}
showToast({
variant: 'success',
title: 'Milestone aktualisiert',
message: `Gerät ${payload.inventoryNumber || deviceToEdit.inventoryNumber} wurde erfolgreich in Milestone aktualisiert.`,
})
}
const response = await fetch(
deviceToEdit
? `${API_URL}/devices/${deviceToEdit.id}`
@ -606,6 +714,15 @@ export default function DevicesPage() {
new Date(firmwareCheckNextAllowedAt as string).getTime() > firmwareCheckNow
const firmwareCheckButtonDisabled = isCheckingFirmware || isFirmwareCheckLocked
const firmwareCheckRemainingLabel = isFirmwareCheckLocked
? formatFirmwareCheckRemaining(firmwareCheckNextAllowedAt, firmwareCheckNow)
: null
const firmwareCheckNextAllowedLabel =
isFirmwareCheckLocked && firmwareCheckNextAllowedAt
? `Wieder möglich: ${formatFirmwareCheckedAt(firmwareCheckNextAllowedAt)}${
firmwareCheckRemainingLabel ? ` (${firmwareCheckRemainingLabel})` : ''
}`
: null
const columns: TableColumn<Device>[] = [
{
@ -755,19 +872,36 @@ export default function DevicesPage() {
const isToggling = togglingMilestoneDeviceIds.has(device.id)
const label = device.milestoneEnabled ? 'Aktiviert' : 'Deaktiviert'
const milestoneIcon = isToggling ? (
<ClockIcon />
) : device.milestoneEnabled ? (
<CheckCircleIcon />
) : (
<XCircleIcon />
)
return (
<button
type="button"
disabled={isToggling}
onClick={(event) => handleToggleMilestoneDeviceStatus(device, event)}
className="inline-flex cursor-pointer rounded-md transition disabled:cursor-wait disabled:opacity-60"
title={`Milestone-Gerät ${
device.milestoneEnabled ? 'deaktivieren' : 'aktivieren'
}`}
className="inline-flex min-w-28 cursor-pointer justify-center rounded-md transition disabled:cursor-wait disabled:opacity-60"
title={
isToggling
? 'Milestone-Status wird geändert...'
: `Milestone-Gerät ${
device.milestoneEnabled ? 'deaktivieren' : 'aktivieren'
}`
}
>
<Badge tone={getLoanStatusBadgeTone(label)} variant="border">
{isToggling ? 'Wird geändert...' : label}
<Badge
tone={getLoanStatusBadgeTone(label)}
variant="border"
hover={!isToggling}
leadingIcon={milestoneIcon}
className="w-full justify-center whitespace-nowrap"
>
{label}
</Badge>
</button>
)
@ -794,44 +928,43 @@ export default function DevicesPage() {
return (
<div className="h-full min-h-0 overflow-y-auto px-4 py-10 sm:px-6 lg:px-8 [scrollbar-gutter:stable]">
<Notifications
notifications={notifications}
onDismiss={dismissNotification}
position="top-right"
/>
{error && (
<div className="mb-6 rounded-md bg-red-50 p-4 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
{error}
</div>
)}
<Tables
title="Geräte"
description="Eine Liste aller Geräte inklusive Inventur-Nr., Hersteller, Modell, Seriennummer, MAC-Adresse, IP-Adresse, Firmware, Standort, Verleih-Status und Zubehör."
headerAction={
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="secondary"
color="gray"
size="sm"
disabled={firmwareCheckButtonDisabled}
isLoading={isCheckingFirmware}
leadingIcon={<ArrowPathIcon aria-hidden="true" className="size-4" />}
onClick={() => void handleCheckFirmware()}
>
Firmware prüfen
</Button>
<div className="flex flex-col items-start gap-1.5">
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
type="button"
variant="secondary"
color="gray"
size="md"
disabled={firmwareCheckButtonDisabled}
isLoading={isCheckingFirmware}
leadingIcon={<ArrowPathIcon aria-hidden="true" className="size-4" />}
onClick={() => void handleCheckFirmware()}
title={firmwareCheckNextAllowedLabel ?? undefined}
>
Firmware prüfen
</Button>
<Button
type="button"
color="indigo"
size="md"
onClick={openCreateModal}
>
Gerät hinzufügen
</Button>
<Button
type="button"
color="indigo"
size="md"
leadingIcon={<PlusIcon aria-hidden="true" className="size-4" />}
onClick={openCreateModal}
>
Gerät hinzufügen
</Button>
</div>
{firmwareCheckNextAllowedLabel && (
<div className="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 px-2.5 py-1 text-xs text-gray-600 shadow-sm dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300">
<ClockIcon aria-hidden="true" className="size-3.5 shrink-0" />
<span>{firmwareCheckNextAllowedLabel}</span>
</div>
)}
</div>
}
onAction={openCreateModal}
@ -895,6 +1028,7 @@ export default function DevicesPage() {
historyError={historyError}
isJournalSubmitting={isJournalSubmitting}
onJournalEntrySubmit={handleCreateDeviceJournalEntry}
onJournalEntryUpdate={handleUpdateDeviceJournalEntry}
/>
</div>

View File

@ -1,15 +1,17 @@
// 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,
IdentificationIcon,
MapPinIcon,
PencilSquareIcon,
PlusCircleIcon,
VideoCameraIcon,
WrenchScrewdriverIcon,
} from '@heroicons/react/20/solid'
import Modal from '../../components/Modal'
import Journal from '../../components/Journal'
@ -41,15 +43,32 @@ type EditDeviceModalProps = {
historyError: string | null
isJournalSubmitting?: boolean
onJournalEntrySubmit?: (content: string) => void | Promise<void>
onJournalEntryUpdate?: (itemId: string | number, content: string) => void | Promise<void>
}
type JournalItems = ComponentProps<typeof Journal>['items']
const editDeviceTabs: Array<{ id: DeviceModalTabId; name: string }> = [
{ id: 'masterData', name: 'Stammdaten' },
{ id: 'location', name: 'Standort' },
{ id: 'accessories', name: 'Zubehör' },
{ id: 'history', name: 'Journal' },
const editDeviceTabs = [
{
id: 'masterData' as const,
name: 'Stammdaten',
icon: IdentificationIcon,
},
{
id: 'location' as const,
name: 'Standort',
icon: MapPinIcon,
},
{
id: 'accessories' as const,
name: 'Zubehör',
icon: WrenchScrewdriverIcon,
},
{
id: 'history' as const,
name: 'Journal',
icon: ChatBubbleLeftEllipsisIcon,
},
]
function formatHistoryDate(value: string) {
@ -111,7 +130,8 @@ export default function EditDeviceModal({
isHistoryLoading,
historyError,
isJournalSubmitting = false,
onJournalEntrySubmit
onJournalEntrySubmit,
onJournalEntryUpdate,
}: EditDeviceModalProps) {
const [activeTab, setActiveTab] = useState<DeviceModalTabId>('masterData')
@ -133,6 +153,15 @@ export default function EditDeviceModal({
setActiveTab(getTabForInvalidField(target.name))
}
function handleFormSubmit(event: FormEvent<HTMLFormElement>) {
if (activeTab === 'history') {
event.preventDefault()
return
}
onSubmit(event)
}
const historyFeedItems: JournalItems = device
? deviceHistory.map((entry) => {
const journalChange = entry.changes.find((change) => change.field === 'journal')
@ -157,6 +186,15 @@ export default function EditDeviceModal({
: 'hat das Gerät bearbeitet.',
date: formatHistoryDate(entry.createdAt),
datetime: entry.createdAt,
editedDate:
isJournalEntry && entry.updatedAt && entry.updatedAt !== entry.createdAt
? formatHistoryDate(entry.updatedAt)
: undefined,
editedDatetime:
isJournalEntry && entry.updatedAt && entry.updatedAt !== entry.createdAt
? entry.updatedAt
: undefined,
canEdit: isJournalEntry && entry.canEdit,
icon: isJournalEntry
? ChatBubbleLeftEllipsisIcon
: entry.action === 'created'
@ -180,7 +218,7 @@ export default function EditDeviceModal({
>
<form
key={device?.id ?? 'edit-device'}
onSubmit={onSubmit}
onSubmit={handleFormSubmit}
onInvalidCapture={handleInvalid}
className="flex h-[calc(100dvh-2rem)] max-h-[calc(100dvh-2rem)] flex-col"
>
@ -200,9 +238,22 @@ export default function EditDeviceModal({
)}
</DialogTitle>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Bearbeite Gerätedaten, Standort, Verleih-Status und Zubehör.
</p>
<div className="mt-1 space-y-1">
<p className="text-sm text-gray-500 dark:text-gray-400">
Bearbeite Gerätedaten, Standort, Verleih-Status und Zubehör.
</p>
{device?.milestoneHardwareId && (
<p className="flex items-center gap-1.5 text-xs text-indigo-600 dark:text-indigo-400">
<VideoCameraIcon aria-hidden="true" className="size-4 shrink-0" />
<span>
{device.milestoneSyncedAt
? `Zuletzt mit Milestone synchronisiert: ${formatHistoryDate(device.milestoneSyncedAt)}`
: 'Noch nicht mit Milestone synchronisiert.'}
</span>
</p>
)}
</div>
</div>
<Button
@ -264,6 +315,7 @@ export default function EditDeviceModal({
placeholder="Journal-Eintrag zum Gerät hinzufügen..."
submitLabel="Eintrag hinzufügen"
onEntrySubmit={onJournalEntrySubmit}
onEntryUpdate={onJournalEntryUpdate}
/>
)}
</div>
@ -288,17 +340,19 @@ export default function EditDeviceModal({
disabled={isSaving}
onClick={() => handleOpenChange(false)}
>
Abbrechen
{activeTab === 'history' ? 'Schließen' : 'Abbrechen'}
</Button>
<Button
type="submit"
color="indigo"
isLoading={isSaving}
leadingIcon={<CheckIcon aria-hidden="true" className="size-4" />}
>
Änderungen speichern
</Button>
{activeTab !== 'history' && (
<Button
type="submit"
color="indigo"
isLoading={isSaving}
leadingIcon={<CheckIcon aria-hidden="true" className="size-4" />}
>
Änderungen speichern
</Button>
)}
</div>
</form>
</Modal>

View File

@ -1,19 +1,44 @@
// frontend\src\pages\operations\CreateOperationModal.tsx
import { useEffect, useState, type FormEvent, type MouseEvent } from 'react'
import {
useEffect,
useState,
type ComponentType,
type FormEvent,
type MouseEvent,
type ReactNode,
type SVGProps,
} from 'react'
import { DialogTitle } from '@headlessui/react'
import { XMarkIcon } from '@heroicons/react/24/outline'
import {
CheckCircleIcon,
CheckIcon,
ChevronLeftIcon,
ChevronRightIcon,
ClipboardDocumentListIcon,
DocumentTextIcon,
MapPinIcon,
UserGroupIcon,
WrenchScrewdriverIcon,
} from '@heroicons/react/20/solid'
import {
CircleMarker,
MapContainer,
TileLayer,
} from 'react-leaflet'
import Modal from '../../components/Modal'
import Button from '../../components/Button'
import AddressCombobox from '../../components/AddressCombobox'
import Textarea from '../../components/Textarea'
import Combobox, { type ComboboxItem } from '../../components/Combobox'
import MultiCombobox from '../../components/MultiCombobox'
import Avatar from '../../components/Avatar'
import { formatOperationNumberInput, isCompleteOperationNumber } from '../../components/formatter'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
const OPERATION_UNIT = 'TEG'
type CreateOperationModalProps = {
open: boolean
setOpen: (open: boolean) => void
@ -41,6 +66,41 @@ type OperationFormValues = {
remark: string
}
type OperationAssigneeUser = {
id: string
username: string
displayName: string
email: string
avatar: string
unit: string
group: string
}
type OperationAssigneesResponse = {
leaders?: OperationAssigneeUser[]
officers?: OperationAssigneeUser[]
}
function getOperationUserName(user: OperationAssigneeUser) {
return user.displayName || user.username || user.email
}
function mapOperationUserToComboboxItem(user: OperationAssigneeUser): ComboboxItem {
return {
id: user.id,
name: getOperationUserName(user),
secondaryText: user.email,
username: user.username,
imageUrl: user.avatar,
}
}
async function readApiError(response: Response, fallback: string) {
const data = await response.json().catch(() => null)
return data?.error ?? fallback
}
type SetupStepId =
| 'masterData'
| 'addresses'
@ -49,6 +109,8 @@ type SetupStepId =
| 'equipment'
| 'review'
type SetupStepIcon = ComponentType<SVGProps<SVGSVGElement>>
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'
@ -75,42 +137,49 @@ const setupSteps: Array<{
id: SetupStepId
title: string
description: string
icon: SetupStepIcon
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.',
},
{
id: 'masterData',
title: 'Stammdaten',
description: 'Einsatznummer, Name und Delikt.',
icon: ClipboardDocumentListIcon,
},
{
id: 'addresses',
title: 'Adressen',
description: 'KW-Adresse und Zielobjekt mit Kartenvorschau.',
icon: MapPinIcon,
optional: true,
},
{
id: 'responsibilities',
title: 'Zuständigkeiten',
description: 'Sachbearbeitung, Einsatzleitung und Einsatzteam.',
icon: UserGroupIcon,
optional: true,
},
{
id: 'notes',
title: 'Zusatzinfos',
description: 'Legende und Bemerkung.',
icon: DocumentTextIcon,
optional: true,
},
{
id: 'equipment',
title: 'Technik',
description: 'Kamera, Router, Switchbox und Laptop.',
icon: WrenchScrewdriverIcon,
optional: true,
},
{
id: 'review',
title: 'Prüfen',
description: 'Daten prüfen und Einsatz speichern.',
icon: CheckCircleIcon,
},
]
function classNames(...classes: Array<string | false | null | undefined>) {
@ -144,6 +213,378 @@ function ReviewItem({
)
}
function parseReviewTeamMembers(value: string) {
return value
.split(',')
.map((part) => part.trim())
.filter(Boolean)
}
function ReviewPersonItem({
label,
item,
fallbackValue,
}: {
label: string
item: ComboboxItem | null
fallbackValue: string
}) {
const displayName = item?.name ?? fallbackValue.trim()
return (
<div className="min-w-0">
<dt className="text-xs font-medium text-gray-500 dark:text-gray-400">
{label}
</dt>
<dd className="mt-1">
{displayName ? (
<div className="flex min-w-0 items-center gap-x-2">
<Avatar
src={item?.imageUrl}
name={displayName}
size={8}
/>
<div className="min-w-0">
<p
title={displayName}
className="truncate text-sm font-medium text-gray-900 dark:text-white"
>
{displayName}
</p>
{item?.secondaryText && (
<p
title={item.secondaryText}
className="truncate text-xs text-gray-500 dark:text-gray-400"
>
{item.secondaryText}
</p>
)}
</div>
</div>
) : (
<span className="text-sm text-gray-400 dark:text-gray-500"></span>
)}
</dd>
</div>
)
}
function ReviewTeamItem({
label,
items,
fallbackValue,
}: {
label: string
items: ComboboxItem[]
fallbackValue: string
}) {
const fallbackNames = parseReviewTeamMembers(fallbackValue)
const fallbackItems: ComboboxItem[] = fallbackNames.map((name) => ({
id: `fallback:${name}`,
name,
}))
const displayItems: ComboboxItem[] =
items.length > 0 ? items : fallbackItems
return (
<div className="min-w-0">
<dt className="text-xs font-medium text-gray-500 dark:text-gray-400">
{label}
</dt>
<dd className="mt-1">
{displayItems.length > 0 ? (
<div className="flex flex-wrap gap-2">
{displayItems.map((item) => (
<span
key={item.id}
className="inline-flex max-w-full items-center gap-x-2 rounded-md bg-indigo-50 px-2 py-1 text-xs font-medium text-indigo-700 ring-1 ring-indigo-600/20 ring-inset dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20"
>
<Avatar
src={item.imageUrl}
name={item.name}
size={6}
/>
<span className="max-w-44 truncate">{item.name}</span>
</span>
))}
</div>
) : (
<span className="text-sm text-gray-400 dark:text-gray-500"></span>
)}
</dd>
</div>
)
}
function ReviewSection({
icon: Icon,
title,
description,
children,
className,
}: {
icon: SetupStepIcon
title: string
description?: string
children: ReactNode
className?: string
}) {
return (
<section
className={classNames(
'overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xs dark:border-white/10 dark:bg-white/[0.03]',
className,
)}
>
<div className="border-b border-gray-200 bg-gray-50 px-4 py-3 dark:border-white/10 dark:bg-white/[0.03]">
<div className="flex items-start gap-x-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600 dark:bg-indigo-500/10 dark:text-indigo-300">
<Icon aria-hidden="true" className="size-5" />
</div>
<div className="min-w-0">
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
{title}
</h4>
{description && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{description}
</p>
)}
</div>
</div>
</div>
<div className="p-4">
{children}
</div>
</section>
)
}
function ReviewField({
label,
value,
title,
className,
}: {
label: string
value: string
title?: string
className?: string
}) {
return (
<div
className={classNames(
'min-w-0 rounded-lg bg-gray-50 px-3 py-2 ring-1 ring-gray-200 ring-inset dark:bg-white/5 dark:ring-white/10',
className,
)}
>
<dt className="text-[11px] font-medium tracking-wide text-gray-500 uppercase dark:text-gray-400">
{label}
</dt>
<dd
title={title || value || undefined}
className="mt-1 truncate text-sm font-medium text-gray-900 dark:text-white"
>
{getDisplayValue(value)}
</dd>
</div>
)
}
function ReviewLongField({
label,
value,
}: {
label: string
value: string
}) {
const displayValue = value.trim()
return (
<div className="min-w-0 rounded-lg bg-gray-50 px-3 py-2 ring-1 ring-gray-200 ring-inset dark:bg-white/5 dark:ring-white/10">
<dt className="text-[11px] font-medium tracking-wide text-gray-500 uppercase dark:text-gray-400">
{label}
</dt>
<dd className="mt-1">
{displayValue ? (
<p
title={displayValue}
className="line-clamp-4 whitespace-pre-wrap text-sm text-gray-900 dark:text-white"
>
{displayValue}
</p>
) : (
<span className="text-sm text-gray-400 dark:text-gray-500"></span>
)}
</dd>
</div>
)
}
type ReviewMapCoordinates = {
lat: number
lon: number
}
function toCoordinateNumber(value: unknown) {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : NaN
}
if (typeof value === 'string') {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : NaN
}
return NaN
}
function ReviewAddressMap({ address }: { address: string }) {
const [coordinates, setCoordinates] = useState<ReviewMapCoordinates | null>(null)
const [isLoading, setIsLoading] = useState(false)
useEffect(() => {
const trimmedAddress = address.trim()
if (trimmedAddress.length < 3) {
setCoordinates(null)
return
}
const abortController = new AbortController()
async function loadCoordinates() {
setIsLoading(true)
try {
const response = await fetch(
`${API_URL}/address-search?q=${encodeURIComponent(trimmedAddress)}`,
{
credentials: 'include',
signal: abortController.signal,
},
)
if (!response.ok) {
setCoordinates(null)
return
}
const data = await response.json()
const firstSuggestion = data.suggestions?.[0]
if (!firstSuggestion) {
setCoordinates(null)
return
}
const lat = toCoordinateNumber(firstSuggestion.lat)
const lon = toCoordinateNumber(firstSuggestion.lon)
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
setCoordinates(null)
return
}
setCoordinates({
lat,
lon,
})
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
return
}
setCoordinates(null)
} finally {
setIsLoading(false)
}
}
void loadCoordinates()
return () => {
abortController.abort()
}
}, [address])
if (!address.trim()) {
return null
}
if (isLoading) {
return (
<div className="mt-2 flex h-28 items-center justify-center rounded-md border border-gray-200 bg-white text-xs text-gray-500 dark:border-white/10 dark:bg-white/5 dark:text-gray-400">
Karte wird geladen...
</div>
)
}
if (!coordinates) {
return (
<div className="mt-2 flex h-28 items-center justify-center rounded-md border border-gray-200 bg-white text-xs text-gray-500 dark:border-white/10 dark:bg-white/5 dark:text-gray-400">
Keine Kartenposition gefunden.
</div>
)
}
return (
<div className="mt-2 overflow-hidden rounded-md border border-gray-200 bg-white dark:border-white/10 dark:bg-white/5">
<MapContainer
center={[coordinates.lat, coordinates.lon]}
zoom={16}
scrollWheelZoom={false}
dragging={false}
doubleClickZoom={false}
zoomControl={false}
attributionControl={false}
className="h-28 w-full"
>
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
<CircleMarker
center={[coordinates.lat, coordinates.lon]}
radius={7}
pathOptions={{
fillOpacity: 0.9,
weight: 2,
}}
/>
</MapContainer>
</div>
)
}
function ReviewAddressItem({
label,
value,
}: {
label: string
value: string
}) {
return (
<div className="min-w-0">
<ReviewItem
label={label}
value={value}
/>
<ReviewAddressMap address={value} />
</div>
)
}
function StepHeader({
title,
description,
@ -174,6 +615,12 @@ export default function CreateOperationModal({
const [formValues, setFormValues] = useState<OperationFormValues>(initialFormValues)
const [localError, setLocalError] = useState<string | null>(null)
const [operationLeaderOptions, setOperationLeaderOptions] = useState<ComboboxItem[]>([])
const [operationTeamOptions, setOperationTeamOptions] = useState<ComboboxItem[]>([])
const [selectedOperationLeader, setSelectedOperationLeader] = useState<ComboboxItem | null>(null)
const [selectedOperationTeam, setSelectedOperationTeam] = useState<ComboboxItem[]>([])
const [isLoadingOperationAssignees, setIsLoadingOperationAssignees] = useState(false)
const activeStepIndex = getStepIndex(activeStep)
const isFirstStep = activeStepIndex === 0
const isLastStep = activeStepIndex === setupSteps.length - 1
@ -185,9 +632,80 @@ export default function CreateOperationModal({
setActiveStep('masterData')
setFormValues(initialFormValues)
setSelectedOperationLeader(null)
setSelectedOperationTeam([])
setLocalError(null)
}, [open])
useEffect(() => {
if (!open) {
return
}
const abortController = new AbortController()
void loadOperationAssignees(abortController.signal)
return () => {
abortController.abort()
}
}, [open])
async function loadOperationAssignees(signal?: AbortSignal) {
setIsLoadingOperationAssignees(true)
try {
const response = await fetch(
`${API_URL}/operations/assignees?unit=${encodeURIComponent(OPERATION_UNIT)}`,
{
credentials: 'include',
signal,
},
)
if (!response.ok) {
throw new Error(
await readApiError(response, 'Einsatzpersonen konnten nicht geladen werden'),
)
}
const data = (await response.json()) as OperationAssigneesResponse
setOperationLeaderOptions(
(data.leaders ?? []).map(mapOperationUserToComboboxItem),
)
setOperationTeamOptions(
(data.officers ?? []).map(mapOperationUserToComboboxItem),
)
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
return
}
setLocalError(
error instanceof Error
? error.message
: 'Einsatzpersonen konnten nicht geladen werden',
)
} finally {
setIsLoadingOperationAssignees(false)
}
}
function handleOperationLeaderChange(item: ComboboxItem | null) {
setSelectedOperationLeader(item)
updateField('operationLeader', item?.name ?? '')
}
function handleOperationTeamChange(items: ComboboxItem[]) {
setSelectedOperationTeam(items)
updateField(
'operationTeam',
items.map((item) => item.name).join(', '),
)
}
function handleOpenChange(nextOpen: boolean) {
if (!nextOpen && isSaving) {
return
@ -337,6 +855,7 @@ export default function CreateOperationModal({
{setupSteps.map((step, index) => {
const isActive = step.id === activeStep
const isDone = index < activeStepIndex
const Icon = step.icon
return (
<button
@ -352,9 +871,11 @@ export default function CreateOperationModal({
: 'border-gray-200 bg-white text-gray-600 hover:bg-gray-50 dark:border-white/10 dark:bg-white/5 dark:text-gray-300 dark:hover:bg-white/10',
)}
>
<span className="block text-[11px] font-medium">
Schritt {index + 1}
<span className="flex items-center gap-x-1.5 text-[11px] font-medium">
<Icon aria-hidden="true" className="size-3.5 shrink-0" />
<span>Schritt {index + 1}</span>
</span>
<span className="mt-0.5 block truncate text-sm font-semibold">
{step.title}
</span>
@ -438,7 +959,7 @@ export default function CreateOperationModal({
description="KW und Zielobjekt werden mit AddressCombobox und Karten-Vorschau erfasst."
/>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
<div className="grid grid-cols-2 gap-4 xl:grid-cols-2">
<AddressCombobox
id="createKwAddress"
name="createKwAddressDisplay"
@ -461,59 +982,96 @@ export default function CreateOperationModal({
)}
{activeStep === 'responsibilities' && (
<div className="space-y-6">
<StepHeader
title="Zuständigkeiten"
description="Sachbearbeitung, Einsatzleitung und eingesetztes Team."
<div className="space-y-6">
<StepHeader
title="Zuständigkeiten"
description="Lege fest, wer den Einsatz bearbeitet, leitet und im Einsatzteam mitwirkt."
/>
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/[0.03]">
<div className="mb-3">
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
Sachbearbeitung
</h4>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Interne sachbearbeitende Person oder Dienststelle.
</p>
</div>
<input
id="createCaseWorker"
type="text"
value={formValues.caseWorker}
onChange={(event) => updateField('caseWorker', event.target.value)}
placeholder="Sachbearbeitung eintragen"
className={inputClassName}
/>
</div>
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/[0.03]">
<div className="mb-3">
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
Einsatzleiter
</h4>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Wähle einen Einsatzleiter der {OPERATION_UNIT}.
</p>
</div>
<Combobox
items={operationLeaderOptions}
value={selectedOperationLeader}
onChange={handleOperationLeaderChange}
variant="image"
placeholder={
isLoadingOperationAssignees
? 'Einsatzleiter werden geladen...'
: 'Einsatzleiter auswählen'
}
emptyText={`Keine Einsatzleiter der ${OPERATION_UNIT} gefunden.`}
disabled={isLoadingOperationAssignees}
/>
</div>
</div>
<div className="rounded-lg border border-indigo-200 bg-indigo-50/60 p-4 dark:border-indigo-500/20 dark:bg-indigo-500/10">
<div className="mb-3 flex items-start justify-between gap-x-4">
<div>
<h4 className="text-sm font-semibold text-indigo-900 dark:text-indigo-200">
Einsatzteam
</h4>
<p className="mt-1 text-xs text-indigo-700/80 dark:text-indigo-300/80">
Wähle einen oder mehrere Einsatzbeamte der {OPERATION_UNIT}.
</p>
</div>
{selectedOperationTeam.length > 0 && (
<span className="shrink-0 rounded-full bg-white px-2.5 py-1 text-xs font-medium text-indigo-700 ring-1 ring-indigo-600/20 ring-inset dark:bg-indigo-500/10 dark:text-indigo-200 dark:ring-indigo-300/20">
{selectedOperationTeam.length} ausgewählt
</span>
)}
</div>
<MultiCombobox
items={operationTeamOptions}
value={selectedOperationTeam}
onChange={handleOperationTeamChange}
placeholder={
isLoadingOperationAssignees
? 'Einsatzbeamte werden geladen...'
: 'Einsatzbeamte auswählen'
}
emptyText={`Keine Einsatzbeamten der ${OPERATION_UNIT} gefunden.`}
disabled={isLoadingOperationAssignees}
/>
<div className="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-6">
<div className="sm:col-span-3 xl:col-span-2">
<label htmlFor="createCaseWorker" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Sachbearbeitung
</label>
<div className="mt-2">
<input
id="createCaseWorker"
type="text"
value={formValues.caseWorker}
onChange={(event) => updateField('caseWorker', event.target.value)}
className={inputClassName}
/>
</div>
</div>
<div className="sm:col-span-3 xl:col-span-2">
<label htmlFor="createOperationLeader" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Einsatzleiter
</label>
<div className="mt-2">
<input
id="createOperationLeader"
type="text"
value={formValues.operationLeader}
onChange={(event) => updateField('operationLeader', event.target.value)}
className={inputClassName}
/>
</div>
</div>
<div className="sm:col-span-3 xl:col-span-2">
<label htmlFor="createOperationTeam" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Einsatzteam
</label>
<div className="mt-2">
<input
id="createOperationTeam"
type="text"
value={formValues.operationTeam}
onChange={(event) => updateField('operationTeam', event.target.value)}
className={inputClassName}
/>
</div>
</div>
</div>
</div>
</div>
</div>
)}
{activeStep === 'notes' && (
@ -615,29 +1173,100 @@ export default function CreateOperationModal({
)}
{activeStep === 'review' && (
<div className="space-y-6">
<div className="space-y-6">
<StepHeader
title="Einsatz prüfen"
description="Kontrolliere die Angaben. Danach kannst du den Einsatz speichern."
title="Einsatz prüfen"
description="Kontrolliere die Angaben. Danach kannst du den Einsatz speichern."
/>
<dl className="grid grid-cols-1 gap-4 rounded-lg border border-gray-200 bg-gray-50 p-4 sm:grid-cols-2 lg:grid-cols-3 dark:border-white/10 dark:bg-white/5">
<ReviewItem label="Einsatznummer" value={formValues.operationNumber} />
<ReviewItem label="Einsatzname" value={formValues.operationName} />
<ReviewItem label="Delikt" value={formValues.offense} />
<ReviewItem label="Sachbearbeitung" value={formValues.caseWorker} />
<ReviewItem label="KW" value={formValues.kwAddress} />
<ReviewItem label="Zielobjekt" value={formValues.targetObject} />
<ReviewItem label="Einsatzleiter" value={formValues.operationLeader} />
<ReviewItem label="Einsatzteam" value={formValues.operationTeam} />
<ReviewItem label="Kamera" value={formValues.camera} />
<ReviewItem label="Router" value={formValues.router} />
<ReviewItem label="Switchbox" value={formValues.switchbox} />
<ReviewItem label="Laptop" value={formValues.laptop} />
<ReviewItem label="Legende" value={formValues.legend} />
<ReviewItem label="Bemerkung" value={formValues.remark} />
<dl className="space-y-6 rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/5">
<div>
<div className="border-b border-gray-200 pb-2 dark:border-white/10">
<h4 className="text-xs font-semibold tracking-wide text-gray-500 uppercase dark:text-gray-400">
Stammdaten
</h4>
</div>
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-3">
<ReviewItem label="Einsatznummer" value={formValues.operationNumber} />
<ReviewItem label="Einsatzname" value={formValues.operationName} />
<ReviewItem label="Delikt" value={formValues.offense} />
</div>
</div>
<div>
<div className="border-b border-gray-200 pb-2 dark:border-white/10">
<h4 className="text-xs font-semibold tracking-wide text-gray-500 uppercase dark:text-gray-400">
Adressen
</h4>
</div>
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
<ReviewAddressItem
label="KW"
value={formValues.kwAddress}
/>
<ReviewAddressItem
label="Zielobjekt"
value={formValues.targetObject}
/>
</div>
</div>
<div>
<div className="border-b border-gray-200 pb-2 dark:border-white/10">
<h4 className="text-xs font-semibold tracking-wide text-gray-500 uppercase dark:text-gray-400">
Zuständigkeiten
</h4>
</div>
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-3">
<ReviewItem label="Sachbearbeitung" value={formValues.caseWorker} />
<ReviewPersonItem
label="Einsatzleiter"
item={selectedOperationLeader}
fallbackValue={formValues.operationLeader}
/>
<ReviewTeamItem
label="Einsatzteam"
items={selectedOperationTeam}
fallbackValue={formValues.operationTeam}
/>
</div>
</div>
<div>
<div className="border-b border-gray-200 pb-2 dark:border-white/10">
<h4 className="text-xs font-semibold tracking-wide text-gray-500 uppercase dark:text-gray-400">
Technik
</h4>
</div>
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<ReviewItem label="Kamera" value={formValues.camera} />
<ReviewItem label="Router" value={formValues.router} />
<ReviewItem label="Switchbox" value={formValues.switchbox} />
<ReviewItem label="Laptop" value={formValues.laptop} />
</div>
</div>
<div>
<div className="border-b border-gray-200 pb-2 dark:border-white/10">
<h4 className="text-xs font-semibold tracking-wide text-gray-500 uppercase dark:text-gray-400">
Zusatzinformationen
</h4>
</div>
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
<ReviewItem label="Legende" value={formValues.legend} />
<ReviewItem label="Bemerkung" value={formValues.remark} />
</div>
</div>
</dl>
</div>
</div>
)}
</div>

View File

@ -561,7 +561,7 @@ export default function OperationMap({
if (nextMarkers.length === 0) {
setGeocodingError(
'Für KW-Adressen und Zielobjekte konnten keine Kartenpositionen gefunden werden.',
'Für die Einsatzadressen konnten keine Kartenpositionen gefunden werden.',
)
}
} catch {
@ -630,15 +630,18 @@ export default function OperationMap({
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 heatPoints: MapHeatPoint[] = markers
.filter((marker) => marker.type === 'targetObject')
.map((marker) => ({
id: marker.id,
lat: marker.lat,
lon: marker.lon,
intensity: 1,
}))
const visibleMapMarkers = mode === 'markers' ? mapMarkers : []
const visibleViewCones = mode === 'markers' ? viewCones : []
const showOperationLayerControls = mode === 'markers'
return (
<div
@ -656,6 +659,10 @@ export default function OperationMap({
<div className="relative h-full min-h-[34rem]">
<AppMap
markers={visibleMapMarkers}
markerLayerName={showOperationLayerControls ? 'Einsätze' : undefined}
markersChecked={showOperationLayerControls}
childrenLayerName={showOperationLayerControls ? 'Sichtwinkel' : undefined}
childrenChecked={showOperationLayerControls}
heatPoints={heatPoints}
showHeatmap={heatPoints.length > 0}
heatmapLayerName="Einsatz-Heatmap"

View File

@ -1,9 +1,26 @@
// frontend/src/pages/OperationModal.tsx
import { useEffect, useMemo, useState, type FormEvent } from 'react'
import {
useEffect,
useMemo,
useState,
type ComponentType,
type FormEvent,
type SVGProps,
} 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 {
ChatBubbleLeftEllipsisIcon,
CheckIcon,
ClipboardDocumentListIcon,
DocumentTextIcon,
MapPinIcon,
PencilSquareIcon,
PlusCircleIcon,
UserGroupIcon,
WrenchScrewdriverIcon,
} from '@heroicons/react/20/solid'
import Modal from '../../components/Modal'
import Journal, { type JournalItem } from '../../components/Journal'
import Button from '../../components/Button'
@ -12,6 +29,8 @@ import AddressCombobox from '../../components/AddressCombobox'
import type { Operation, OperationHistoryChange, OperationHistoryEntry } from '../../components/types'
import { formatOperationNumberInput } from '../../components/formatter'
import NotificationDot from '../../components/NotificationDot'
import Combobox, { type ComboboxItem } from '../../components/Combobox'
import MultiCombobox from '../../components/MultiCombobox'
type OperationModalProps = {
open: boolean
@ -38,46 +57,136 @@ type OperationTabId =
| 'equipment'
| 'journal'
type OperationTabIcon = ComponentType<SVGProps<SVGSVGElement>>
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 API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
const OPERATION_UNIT = 'TEG'
type OperationAssigneeUser = {
id: string
username: string
displayName: string
email: string
avatar: string
unit: string
group: string
}
type OperationAssigneesResponse = {
leaders?: OperationAssigneeUser[]
officers?: OperationAssigneeUser[]
}
function getOperationAssigneeName(user: OperationAssigneeUser) {
return user.displayName || user.username || user.email
}
function mapOperationAssigneeToComboboxItem(
user: OperationAssigneeUser,
): ComboboxItem {
return {
id: user.id,
name: getOperationAssigneeName(user),
secondaryText: user.email,
username: user.username,
imageUrl: user.avatar,
}
}
function normalizePersonName(value: string) {
return value.trim().toLowerCase()
}
function parseOperationTeamMembers(value: string) {
return value
.split(',')
.map((part) => part.trim())
.filter(Boolean)
}
function findComboboxItemByName(items: ComboboxItem[], name: string) {
const normalizedName = normalizePersonName(name)
if (!normalizedName) {
return null
}
return (
items.find((item) => normalizePersonName(item.name) === normalizedName) ??
null
)
}
function createFallbackComboboxItem(name: string): ComboboxItem | null {
const trimmedName = name.trim()
if (!trimmedName) {
return null
}
return {
id: `fallback:${trimmedName}`,
name: trimmedName,
}
}
function getSelectionItem(items: ComboboxItem[], name: string) {
return findComboboxItemByName(items, name) ?? createFallbackComboboxItem(name)
}
async function readApiError(response: Response, fallback: string) {
const data = await response.json().catch(() => null)
return data?.error ?? fallback
}
const operationTabs: Array<{
id: OperationTabId
title: string
description: string
icon: OperationTabIcon
}> = [
{
id: 'masterData',
title: 'Stammdaten',
description: 'Einsatznummer, Einsatzname und Delikt.',
icon: ClipboardDocumentListIcon,
},
{
id: 'addresses',
title: 'Adressen',
description: 'Zielobjekt und KW-Adresse.',
icon: MapPinIcon,
},
{
id: 'responsibilities',
title: 'Zuständigkeiten',
description: 'Sachbearbeitung, Einsatzleitung und Team.',
icon: UserGroupIcon,
},
{
id: 'notes',
title: 'Zusatzinfos',
description: 'Legende und Bemerkung.',
icon: DocumentTextIcon,
},
{
id: 'equipment',
title: 'Technik',
description: 'Kamera, Router, Switchbox und Laptop.',
icon: WrenchScrewdriverIcon,
},
{
id: 'journal',
title: 'Journal',
description: 'Bearbeitungsverlauf des Einsatzes.',
icon: ChatBubbleLeftEllipsisIcon,
},
]
@ -155,6 +264,12 @@ export default function OperationModal({
const [targetObject, setTargetObject] = useState('')
const [kwAddress, setKwAddress] = useState('')
const [operationLeaderOptions, setOperationLeaderOptions] = useState<ComboboxItem[]>([])
const [operationTeamOptions, setOperationTeamOptions] = useState<ComboboxItem[]>([])
const [selectedOperationLeader, setSelectedOperationLeader] = useState<ComboboxItem | null>(null)
const [selectedOperationTeam, setSelectedOperationTeam] = useState<ComboboxItem[]>([])
const [isLoadingOperationAssignees, setIsLoadingOperationAssignees] = useState(false)
const visibleTabs = useMemo(
() => operationTabs.filter((tab) => isEditing || tab.id !== 'journal'),
[isEditing],
@ -175,6 +290,46 @@ export default function OperationModal({
editingOperation?.kwAddress,
])
useEffect(() => {
if (!open) {
return
}
const abortController = new AbortController()
void loadOperationAssignees(abortController.signal)
return () => {
abortController.abort()
}
}, [open])
useEffect(() => {
if (!open) {
return
}
setSelectedOperationLeader(
getSelectionItem(
operationLeaderOptions,
editingOperation?.operationLeader ?? '',
),
)
setSelectedOperationTeam(
parseOperationTeamMembers(editingOperation?.operationTeam ?? '')
.map((name) => getSelectionItem(operationTeamOptions, name))
.filter((item): item is ComboboxItem => item !== null),
)
}, [
open,
editingOperation?.id,
editingOperation?.operationLeader,
editingOperation?.operationTeam,
operationLeaderOptions,
operationTeamOptions,
])
useEffect(() => {
if (!open || activeTab !== 'journal' || !hasUnreadJournal) {
return
@ -255,6 +410,53 @@ export default function OperationModal({
}
})
async function loadOperationAssignees(signal?: AbortSignal) {
setIsLoadingOperationAssignees(true)
try {
const response = await fetch(
`${API_URL}/operations/assignees?unit=${encodeURIComponent(OPERATION_UNIT)}`,
{
credentials: 'include',
signal,
},
)
if (!response.ok) {
throw new Error(
await readApiError(response, 'Einsatzpersonen konnten nicht geladen werden'),
)
}
const data = (await response.json()) as OperationAssigneesResponse
setOperationLeaderOptions(
(data.leaders ?? []).map(mapOperationAssigneeToComboboxItem),
)
setOperationTeamOptions(
(data.officers ?? []).map(mapOperationAssigneeToComboboxItem),
)
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
return
}
// Modal soll trotzdem nutzbar bleiben.
console.error(error)
} finally {
setIsLoadingOperationAssignees(false)
}
}
function handleOperationLeaderChange(item: ComboboxItem | null) {
setSelectedOperationLeader(item)
}
function handleOperationTeamChange(items: ComboboxItem[]) {
setSelectedOperationTeam(items)
}
function handleOpenChange(nextOpen: boolean) {
if (!nextOpen && isSaving) {
return
@ -286,6 +488,18 @@ export default function OperationModal({
value={editingOperation?.hardDrive ?? ''}
/>
<input
type="hidden"
name="operationLeader"
value={selectedOperationLeader?.name ?? ''}
/>
<input
type="hidden"
name="operationTeam"
value={selectedOperationTeam.map((item) => item.name).join(', ')}
/>
<div className="flex items-start justify-between border-b border-gray-200 bg-gray-100 px-4 py-4 sm:px-6 dark:border-white/10 dark:bg-gray-800">
<div>
<DialogTitle className="text-base font-semibold text-gray-900 dark:text-white">
@ -313,36 +527,42 @@ export default function OperationModal({
<div className="shrink-0 border-b border-gray-200 bg-white px-4 sm:px-6 dark:border-white/10 dark:bg-gray-900">
<div className="-mb-px flex gap-x-6 overflow-x-auto">
{visibleTabs.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => {
setActiveTab(tab.id)
{visibleTabs.map((tab) => {
const Icon = tab.icon
if (tab.id === 'journal') {
onJournalTabOpen?.()
}
}}
className={classNames(
'whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium',
activeTab === tab.id
? 'border-indigo-600 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-white/20 dark:hover:text-gray-200',
)}
>
<span className="inline-flex items-center gap-x-2">
{tab.title}
return (
<button
key={tab.id}
type="button"
onClick={() => {
setActiveTab(tab.id)
{tab.id === 'journal' && hasUnreadJournal && (
<NotificationDot
title="Neue Journal-Einträge"
label="Neue Journal-Einträge"
/>
if (tab.id === 'journal') {
onJournalTabOpen?.()
}
}}
className={classNames(
'whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium',
activeTab === tab.id
? 'border-indigo-600 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-white/20 dark:hover:text-gray-200',
)}
</span>
</button>
))}
>
<span className="inline-flex items-center gap-x-2">
<Icon aria-hidden="true" className="size-4 shrink-0" />
<span>{tab.title}</span>
{tab.id === 'journal' && hasUnreadJournal && (
<NotificationDot
title="Neue Journal-Einträge"
label="Neue Journal-Einträge"
/>
)}
</span>
</button>
)
})}
</div>
</div>
@ -423,7 +643,7 @@ export default function OperationModal({
description="Zielobjekt und KW-Adresse mit Adresssuche und Karten-Vorschau."
/>
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-2">
<div className="mt-4 grid grid-cols-2 gap-4 xl:grid-cols-2">
<AddressCombobox
id="targetObject"
name="targetObject"
@ -431,6 +651,7 @@ export default function OperationModal({
value={targetObject}
onChange={setTargetObject}
placeholder="Adresse suchen oder Zielobjekt eingeben"
mapVisible={activeTab === 'addresses'}
/>
<AddressCombobox
@ -440,6 +661,7 @@ export default function OperationModal({
value={kwAddress}
onChange={setKwAddress}
placeholder="KW-Adresse suchen oder eingeben"
mapVisible={activeTab === 'addresses'}
/>
</div>
</section>
@ -449,53 +671,90 @@ export default function OperationModal({
<section className={sectionClassName}>
<SectionHeader
title="Zuständigkeiten"
description="Sachbearbeitung, Einsatzleitung und eingesetztes Team."
description="Lege fest, wer den Einsatz bearbeitet, leitet und im Einsatzteam mitwirkt."
/>
<div className="mt-4 grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-6">
<div className="sm:col-span-3">
<label htmlFor="caseWorker" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Sachbearbeitung
</label>
<div className="mt-2">
<div className="mt-4 space-y-4">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/[0.03]">
<div className="mb-3">
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
Sachbearbeitung
</h4>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Interne sachbearbeitende Person oder Dienststelle.
</p>
</div>
<input
id="caseWorker"
name="caseWorker"
type="text"
defaultValue={editingOperation?.caseWorker ?? ''}
placeholder="Sachbearbeitung eintragen"
className={inputClassName}
/>
</div>
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/[0.03]">
<div className="mb-3">
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
Einsatzleiter
</h4>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Nutzer der Einheit {OPERATION_UNIT} mit Rolle Einsatzleiter.
</p>
</div>
<Combobox
items={operationLeaderOptions}
value={selectedOperationLeader}
onChange={handleOperationLeaderChange}
variant="image"
placeholder={
isLoadingOperationAssignees
? 'Einsatzleiter werden geladen...'
: 'Einsatzleiter auswählen'
}
emptyText={`Keine Einsatzleiter der ${OPERATION_UNIT} gefunden.`}
disabled={isLoadingOperationAssignees}
/>
</div>
</div>
<div className="sm:col-span-3">
<label htmlFor="operationLeader" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Einsatzleiter
</label>
<div className="mt-2">
<input
id="operationLeader"
name="operationLeader"
type="text"
defaultValue={editingOperation?.operationLeader ?? ''}
className={inputClassName}
/>
</div>
</div>
<div className="rounded-lg border border-indigo-200 bg-indigo-50/60 p-4 dark:border-indigo-500/20 dark:bg-indigo-500/10">
<div className="mb-3 flex items-start justify-between gap-x-4">
<div>
<h4 className="text-sm font-semibold text-indigo-900 dark:text-indigo-200">
Einsatzteam
</h4>
<div className="sm:col-span-3">
<label htmlFor="operationTeam" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Einsatzteam
</label>
<div className="mt-2">
<input
id="operationTeam"
name="operationTeam"
type="text"
defaultValue={editingOperation?.operationTeam ?? ''}
className={inputClassName}
/>
<p className="mt-1 text-xs text-indigo-700/80 dark:text-indigo-300/80">
Wähle einen oder mehrere Einsatzbeamte der Einheit {OPERATION_UNIT}.
</p>
</div>
{selectedOperationTeam.length > 0 && (
<span className="shrink-0 rounded-full bg-white px-2.5 py-1 text-xs font-medium text-indigo-700 ring-1 ring-indigo-600/20 ring-inset dark:bg-indigo-500/10 dark:text-indigo-200 dark:ring-indigo-300/20">
{selectedOperationTeam.length} ausgewählt
</span>
)}
</div>
<MultiCombobox
items={operationTeamOptions}
value={selectedOperationTeam}
onChange={handleOperationTeamChange}
placeholder={
isLoadingOperationAssignees
? 'Einsatzbeamte werden geladen...'
: 'Einsatzbeamte auswählen'
}
emptyText={`Keine Einsatzbeamten der ${OPERATION_UNIT} gefunden.`}
disabled={isLoadingOperationAssignees}
/>
</div>
</div>
</section>

File diff suppressed because it is too large Load Diff

View File

@ -85,24 +85,12 @@ export default function SettingsPage({
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 (
@ -130,17 +118,9 @@ export default function SettingsPage({
<Benachrichtigungen />
)}
{section === 'abrechnung' && (
<PlaceholderSection title="Abrechnung" />
)}
{section === 'teams' && (
<PlaceholderSection title="Teams" />
)}
{section === 'integrationen' && (
<PlaceholderSection title="Integrationen" />
)}
</div>
</main>
)

View File

@ -9,20 +9,23 @@ import {
import {
BellIcon,
EnvelopeIcon,
PlayIcon,
SpeakerWaveIcon,
} from '@heroicons/react/20/solid'
import Container from '../../../components/Container'
import LoadingSpinner from '../../../components/LoadingSpinner'
import Switch from '../../../components/Switch'
import Notifications, {
type ToastNotification,
} from '../../../components/Notifications'
import Button from '../../../components/Button'
import { useNotifications } from '../../../components/Notifications'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
type NotificationSoundName = 'default' | 'chime' | 'soft' | 'alert' | 'success'
type NotificationSettings = {
inAppEnabled: boolean
soundEnabled: boolean
soundName: NotificationSoundName
emailEnabled: boolean
operationUpdates: boolean
operationJournals: boolean
@ -34,6 +37,7 @@ type NotificationSettings = {
const defaultSettings: NotificationSettings = {
inAppEnabled: true,
soundEnabled: false,
soundName: 'default',
emailEnabled: false,
operationUpdates: true,
operationJournals: true,
@ -42,6 +46,17 @@ const defaultSettings: NotificationSettings = {
systemMessages: true,
}
const notificationSounds: Array<{
value: NotificationSoundName
label: string
}> = [
{ value: 'default', label: 'Standard' },
{ value: 'chime', label: 'Klang' },
{ value: 'soft', label: 'Dezent' },
{ value: 'alert', label: 'Alarm' },
{ value: 'success', label: 'Erfolg' },
]
function Section({
title,
description,
@ -72,24 +87,21 @@ function Section({
)
}
function Message({
children,
}: {
children: ReactNode
}) {
return (
<div className="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
{children}
</div>
)
}
async function readApiError(response: Response, fallback: string) {
const data = await response.json().catch(() => null)
return data?.error ?? fallback
}
function playNotificationSound(soundName: NotificationSoundName) {
const audio = new Audio(`/sounds/notifications/${soundName}.mp3`)
audio.volume = 0.6
void audio.play().catch(() => {
// Browser blockiert Audio ggf., bis der User einmal mit der Seite interagiert hat.
})
}
function NotificationCard({
icon,
title,
@ -127,12 +139,12 @@ function NotificationCard({
}
export default function Benachrichtigungen() {
const { showToast, showErrorToast } = useNotifications()
const [settings, setSettings] =
useState<NotificationSettings>(defaultSettings)
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [toasts, setToasts] = useState<ToastNotification[]>([])
const settingsRef = useRef<NotificationSettings>(defaultSettings)
const saveRequestIdRef = useRef(0)
@ -141,25 +153,8 @@ export default function Benachrichtigungen() {
void loadSettings()
}, [])
function dismissToast(id: string) {
setToasts((currentToasts) =>
currentToasts.filter((toast) => toast.id !== id),
)
}
function showToast(notification: Omit<ToastNotification, 'id'>) {
setToasts((currentToasts) => [
...currentToasts,
{
id: crypto.randomUUID(),
...notification,
},
])
}
async function loadSettings() {
setIsLoading(true)
setError(null)
try {
const response = await fetch(`${API_URL}/settings/notifications/preferences`, {
@ -184,11 +179,11 @@ export default function Benachrichtigungen() {
settingsRef.current = nextSettings
setSettings(nextSettings)
} catch (error) {
setError(
error instanceof Error
? error.message
: 'Benachrichtigungseinstellungen konnten nicht geladen werden',
)
showErrorToast({
title: 'Benachrichtigungseinstellungen konnten nicht geladen werden',
error,
fallback: 'Benachrichtigungseinstellungen konnten nicht geladen werden',
})
} finally {
setIsLoading(false)
}
@ -214,7 +209,6 @@ export default function Benachrichtigungen() {
saveRequestIdRef.current = requestId
setIsSaving(true)
setError(null)
try {
const response = await fetch(`${API_URL}/settings/notifications/preferences`, {
@ -261,20 +255,10 @@ export default function Benachrichtigungen() {
return
}
setError(
error instanceof Error
? error.message
: 'Benachrichtigungseinstellungen konnten nicht gespeichert werden',
)
showToast({
showErrorToast({
title: 'Speichern fehlgeschlagen',
message:
error instanceof Error
? error.message
: 'Benachrichtigungseinstellungen konnten nicht gespeichert werden.',
variant: 'error',
autoClose: false,
error,
fallback: 'Benachrichtigungseinstellungen konnten nicht gespeichert werden.',
})
} finally {
if (saveRequestIdRef.current === requestId) {
@ -303,38 +287,17 @@ export default function Benachrichtigungen() {
return (
<>
<Notifications
notifications={toasts}
onDismiss={dismissToast}
position="top-right"
/>
<div className="divide-y divide-gray-200 dark:divide-white/10">
<Section
title="Benachrichtigungen"
description="Lege fest, welche Hinweise du in der Anwendung erhalten möchtest."
>
<div className="space-y-6 sm:max-w-2xl">
{error && (
<Message>
{error}
</Message>
)}
<NotificationCard
icon={<BellIcon aria-hidden="true" className="size-5" />}
title="In-App-Benachrichtigungen"
description="Steuere Hinweise, die direkt in der Anwendung angezeigt werden."
>
<Switch
checked={settings.inAppEnabled}
onChange={(event) =>
updateSetting('inAppEnabled', event.target.checked)
}
label="Benachrichtigungen anzeigen"
description="Aktiviert Hinweise in der Topbar und im Benachrichtigungscenter."
/>
<Switch
checked={settings.soundEnabled}
onChange={(event) =>
@ -344,6 +307,52 @@ export default function Benachrichtigungen() {
label="Signalton abspielen"
description="Spielt einen kurzen Ton bei neuen Benachrichtigungen ab."
/>
<div>
<label
htmlFor="notification-sound"
className="block text-sm font-medium text-gray-900 dark:text-white"
>
Benachrichtigungssound
</label>
<div className="mt-2 flex items-center gap-2">
<select
id="notification-sound"
value={settings.soundName}
disabled={!settings.inAppEnabled || !settings.soundEnabled}
onChange={(event) =>
updateSetting(
'soundName',
event.target.value as NotificationSoundName,
)
}
className="block w-full rounded-md bg-white px-3 py-1.5 text-sm text-gray-900 outline-1 -outline-offset-1 outline-gray-300 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:disabled:bg-white/5 dark:disabled:text-gray-500 dark:focus:outline-indigo-500"
>
{notificationSounds.map((sound) => (
<option key={sound.value} value={sound.value}>
{sound.label}
</option>
))}
</select>
<Button
type="button"
variant="secondary"
color="gray"
size="sm"
disabled={!settings.inAppEnabled || !settings.soundEnabled}
leadingIcon={<PlayIcon aria-hidden="true" className="size-4" />}
onClick={() => playNotificationSound(settings.soundName)}
>
Abspielen
</Button>
</div>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
Wird bei neuen In-App-Benachrichtigungen verwendet, wenn der Signalton aktiviert ist.
</p>
</div>
</NotificationCard>
<NotificationCard

View File

@ -2,7 +2,9 @@
import {
useEffect,
useRef,
useState,
type ChangeEvent,
type FormEvent,
type ReactNode,
} from 'react'
@ -13,9 +15,8 @@ import {
ComputerDesktopIcon,
ShieldCheckIcon,
} from '@heroicons/react/20/solid'
import Notifications, {
type ToastNotification,
} from '../../../components/Notifications'
import { useNotifications } from '../../../components/Notifications'
import Avatar from '../../../components/Avatar'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
@ -79,8 +80,11 @@ export default function Konto({
user,
onUserUpdated,
}: KontoProps) {
const [toasts, setToasts] = useState<ToastNotification[]>([])
const { showToast, showErrorToast } = useNotifications()
const [currentUser, setCurrentUser] = useState<User | undefined>(user)
const [avatarPreview, setAvatarPreview] = useState(user?.avatar ?? '')
const avatarInputRef = useRef<HTMLInputElement | null>(null)
const [sessions, setSessions] = useState<UserSession[]>([])
const [isLoadingSessions, setIsLoadingSessions] = useState(false)
@ -92,32 +96,13 @@ export default function Konto({
useEffect(() => {
setCurrentUser(user)
setAvatarPreview(user?.avatar ?? '')
}, [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'
function dismissToast(id: string) {
setToasts((currentToasts) =>
currentToasts.filter((toast) => toast.id !== id),
)
}
function showToast(notification: Omit<ToastNotification, 'id'>) {
setToasts((currentToasts) => [
...currentToasts,
{
id: crypto.randomUUID(),
...notification,
},
])
}
async function loadSessions() {
setIsLoadingSessions(true)
@ -184,6 +169,49 @@ export default function Konto({
}
}
function handleAvatarFileChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]
if (!file) {
return
}
if (!file.type.startsWith('image/')) {
showToast({
title: 'Ungültige Datei',
message: 'Bitte wähle eine gültige Bilddatei aus.',
variant: 'error',
})
event.target.value = ''
return
}
if (file.size > 2 * 1024 * 1024) {
showToast({
title: 'Bild zu groß',
message: 'Das Profilbild darf maximal 2 MB groß sein.',
variant: 'error',
})
event.target.value = ''
return
}
const reader = new FileReader()
reader.onload = () => {
if (typeof reader.result === 'string') {
setAvatarPreview(reader.result)
}
}
reader.readAsDataURL(file)
event.target.value = ''
}
function handleRemoveAvatar() {
setAvatarPreview('')
}
async function handleKontoSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
@ -220,6 +248,7 @@ export default function Konto({
if (nextUser) {
setCurrentUser(nextUser)
setAvatarPreview(nextUser.avatar ?? '')
onUserUpdated?.(nextUser)
}
@ -229,14 +258,10 @@ export default function Konto({
variant: 'success',
})
} catch (error) {
showToast({
showErrorToast({
title: 'Profil konnte nicht gespeichert werden',
message:
error instanceof Error
? error.message
: 'Profil konnte nicht gespeichert werden',
variant: 'error',
autoClose: false,
error,
fallback: 'Profil konnte nicht gespeichert werden',
})
} finally {
setIsSavingKonto(false)
@ -395,12 +420,6 @@ export default function Konto({
return (
<>
<Notifications
notifications={toasts}
onDismiss={dismissToast}
position="top-right"
/>
<div className="divide-y divide-gray-200 dark:divide-white/10">
<Section
title="Persönliche Informationen"
@ -409,33 +428,54 @@ export default function Konto({
<form onSubmit={handleKontoSubmit}>
<div className="grid grid-cols-1 gap-x-6 gap-y-8 sm:max-w-xl sm:grid-cols-6">
<div className="col-span-full flex items-center gap-x-8">
<img
alt=""
src={avatarUrl}
className="size-24 flex-none rounded-lg bg-gray-100 object-cover outline -outline-offset-1 outline-black/5 dark:bg-gray-800 dark:outline-white/10"
<Avatar
src={avatarPreview}
name={currentUser?.displayName || currentUser?.username || currentUser?.email}
size={16}
shape="rounded"
/>
<div className="min-w-0 flex-1">
<label
htmlFor="avatar"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Avatar-URL
</label>
<input
type="hidden"
name="avatar"
value={avatarPreview}
/>
<div className="mt-2">
<input
id="avatar"
name="avatar"
type="url"
defaultValue={currentUser?.avatar ?? ''}
placeholder="https://..."
className={inputClassName}
/>
<input
ref={avatarInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
className="sr-only"
onChange={handleAvatarFileChange}
/>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="secondary"
color="gray"
size="sm"
onClick={() => avatarInputRef.current?.click()}
>
Bild hochladen
</Button>
{avatarPreview && (
<Button
type="button"
variant="secondary"
color="red"
size="sm"
onClick={handleRemoveAvatar}
>
Entfernen
</Button>
)}
</div>
<p className="mt-2 text-xs/5 text-gray-500 dark:text-gray-400">
Gib eine Bild-URL für dein Profilbild an.
PNG, JPG, WEBP oder GIF bis maximal 2 MB.
</p>
</div>
</div>