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

View File

@ -72,6 +72,10 @@ func normalizeAdminUserInput(input *AdminUserRequest) {
input.TeamIDs = cleanStringList(input.TeamIDs) 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) { func normalizeAdminTeamInput(input *AdminTeamRequest) {
input.Name = strings.TrimSpace(input.Name) input.Name = strings.TrimSpace(input.Name)
input.Initial = strings.TrimSpace(input.Initial) input.Initial = strings.TrimSpace(input.Initial)
@ -130,7 +134,7 @@ func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
defer rows.Close() defer rows.Close()
users := []AdminUser{} users := []AdminUser{}
userByID := map[string]*AdminUser{} userIndexByID := map[string]int{}
for rows.Next() { for rows.Next() {
var user AdminUser var user AdminUser
@ -152,8 +156,14 @@ func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
} }
user.Teams = []AdminTeam{} user.Teams = []AdminTeam{}
users = append(users, user) 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( teamRows, err := s.db.Query(
@ -196,9 +206,17 @@ func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
return return
} }
if user := userByID[userID]; user != nil { userIndex, ok := userIndexByID[userID]
user.Teams = append(user.Teams, team) 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{ 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()) 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 var userID string
err = tx.QueryRow( err = tx.QueryRow(
@ -289,6 +312,10 @@ func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
return return
} }
if createdUser, err := s.getUserByID(r.Context(), userID); err == nil {
s.publishUserProfileEvent("user.created", createdUser)
}
writeJSON(w, http.StatusCreated, map[string]any{ writeJSON(w, http.StatusCreated, map[string]any{
"ok": true, "ok": true,
}) })
@ -323,6 +350,11 @@ func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
} }
defer tx.Rollback(r.Context()) 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 != "" { if input.Password != "" {
passwordHash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost) passwordHash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
if err != nil { if err != nil {
@ -403,6 +435,10 @@ func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
return return
} }
if updatedUser, err := s.getUserByID(r.Context(), userID); err == nil {
s.publishUserProfileEvent("user.updated", updatedUser)
}
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"ok": true, "ok": true,
}) })

View File

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

View File

@ -1,4 +1,4 @@
// backend/camera_firmware.go // backend\camera_firmware.go
package main 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-Origin", s.frontendOrigin)
w.Header().Set("Access-Control-Allow-Credentials", "true") w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") 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 { if r.Method == http.MethodOptions {

View File

@ -50,68 +50,19 @@ func (s *Server) publishDeviceChangeEvent(
return err return err
} }
var notification Notification if err := s.createAndPublishNotification(
err = s.db.QueryRow(
ctx, 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, userID,
notificationType, notificationType,
title, title,
message, message,
"device",
deviceID, deviceID,
dataJSON, dataJSON,
).Scan( true,
&notification.ID, ); err != nil {
&notification.UserID,
&notification.Type,
&notification.Title,
&notification.Message,
&notification.EntityType,
&notification.EntityID,
&notification.Data,
&notification.Visible,
&notification.ReadAt,
&notification.CreatedAt,
)
if err != nil {
return err return err
} }
notificationsBroker.publish(notification)
} }
return rows.Err() return rows.Err()

View File

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

View File

@ -164,7 +164,7 @@ func (s *Server) handleCreateFeedback(w http.ResponseWriter, r *http.Request) {
"Neues Feedback", "Neues Feedback",
formatText("Neues Feedback von %s.", displayFeedbackUserName(feedback)), formatText("Neues Feedback von %s.", displayFeedbackUserName(feedback)),
feedback.ID, feedback.ID,
true, false,
map[string]any{ map[string]any{
"feedbackId": feedback.ID, "feedbackId": feedback.ID,
"userId": feedback.UserID, "userId": feedback.UserID,
@ -314,7 +314,7 @@ func (s *Server) handleDeleteFeedback(w http.ResponseWriter, r *http.Request) {
"Feedback gelöscht", "Feedback gelöscht",
formatText("Feedback von %s wurde gelöscht.", displayFeedbackUserName(deleted)), formatText("Feedback von %s wurde gelöscht.", displayFeedbackUserName(deleted)),
deleted.ID, deleted.ID,
false, true,
map[string]any{ map[string]any{
"feedbackId": deleted.ID, "feedbackId": deleted.ID,
"userId": deleted.UserID, "userId": deleted.UserID,
@ -375,70 +375,19 @@ func (s *Server) notifyAdministratorsAboutFeedback(
return err return err
} }
var notification Notification if err := s.createAndPublishNotification(
err = s.db.QueryRow(
ctx, 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, adminUserID,
notificationType, notificationType,
title, title,
message, message,
visible, "feedback",
feedbackID, feedbackID,
dataJSON, dataJSON,
).Scan( visible,
&notification.ID, ); err != nil {
&notification.UserID,
&notification.Type,
&notification.Title,
&notification.Message,
&notification.EntityType,
&notification.EntityID,
&notification.Data,
&notification.Visible,
&notification.ReadAt,
&notification.CreatedAt,
)
if err != nil {
return err return err
} }
notificationsBroker.publish(notification)
} }
return adminRows.Err() return adminRows.Err()

View File

@ -21,6 +21,8 @@ type DeviceHistoryEntry struct {
Action string `json:"action"` Action string `json:"action"`
Changes []DeviceHistoryChange `json:"changes"` Changes []DeviceHistoryChange `json:"changes"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
CanEdit bool `json:"canEdit"`
} }
type DeviceHistoryChange struct { type DeviceHistoryChange struct {
@ -42,6 +44,12 @@ func (s *Server) handleListDeviceHistory(w http.ResponseWriter, r *http.Request)
return return
} }
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
rows, err := s.db.Query( rows, err := s.db.Query(
r.Context(), 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'), COALESCE(NULLIF(u.display_name, ''), NULLIF(u.username, ''), u.email, 'Unbekannt'),
h.action, h.action,
h.changes, h.changes,
h.created_at h.created_at,
COALESCE(h.updated_at, h.created_at)
FROM device_history h FROM device_history h
LEFT JOIN users u ON u.id = h.user_id LEFT JOIN users u ON u.id = h.user_id
WHERE h.device_id = $1 WHERE h.device_id = $1
@ -81,6 +90,7 @@ func (s *Server) handleListDeviceHistory(w http.ResponseWriter, r *http.Request)
&entry.Action, &entry.Action,
&changesRaw, &changesRaw,
&entry.CreatedAt, &entry.CreatedAt,
&entry.UpdatedAt,
); err != nil { ); err != nil {
writeError(w, http.StatusInternalServerError, "Could not read device history") writeError(w, http.StatusInternalServerError, "Could not read device history")
return 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) history = append(history, entry)
} }
@ -239,6 +254,13 @@ func getDeviceForHistory(ctx context.Context, tx pgx.Tx, deviceID string) (Devic
model, model,
serial_number, serial_number,
mac_address, 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, location,
loan_status, loan_status,
is_bao_device, is_bao_device,
@ -257,6 +279,13 @@ func getDeviceForHistory(ctx context.Context, tx pgx.Tx, deviceID string) (Devic
&device.Model, &device.Model,
&device.SerialNumber, &device.SerialNumber,
&device.MacAddress, &device.MacAddress,
&device.IPAddress,
&device.FirmwareVersion,
&device.MilestoneRecordingServerID,
&device.MilestoneHardwareID,
&device.MilestoneDisplayName,
&device.MilestoneEnabled,
&device.MilestoneSyncedAt,
&device.Location, &device.Location,
&device.LoanStatus, &device.LoanStatus,
&device.IsBaoDevice, &device.IsBaoDevice,
@ -304,6 +333,190 @@ func getDeviceForHistory(ctx context.Context, tx pgx.Tx, deviceID string) (Devic
return device, relatedDeviceIDs, nil 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 { func formatHistoryBool(value bool) string {
if value { if value {
return "Ja" return "Ja"
@ -357,6 +570,17 @@ func buildDeviceUpdateHistoryChanges(
changes = appendHistoryChange(changes, "loanStatus", "Verleih-Status", oldDevice.LoanStatus, input.LoanStatus) 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, "isBaoDevice", "BAO-Gerät", formatHistoryBool(oldDevice.IsBaoDevice), formatHistoryBool(input.IsBaoDevice))
changes = appendHistoryChange(changes, "comment", "Kommentar", oldDevice.Comment, input.Comment) 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) oldRelatedDevices, err := formatRelatedDeviceLabels(ctx, tx, oldRelatedDeviceIDs)
if err != nil { 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 ( import (
"context" "context"
"fmt"
"strings" "strings"
"unicode" "unicode"
@ -28,6 +27,7 @@ func syncMilestoneHardwareDevices(
hardwareDevice.SerialNumber = strings.TrimSpace(hardwareDevice.SerialNumber) hardwareDevice.SerialNumber = strings.TrimSpace(hardwareDevice.SerialNumber)
hardwareDevice.MacAddress = strings.TrimSpace(hardwareDevice.MacAddress) hardwareDevice.MacAddress = strings.TrimSpace(hardwareDevice.MacAddress)
hardwareDevice.IPAddress = strings.TrimSpace(hardwareDevice.IPAddress) hardwareDevice.IPAddress = strings.TrimSpace(hardwareDevice.IPAddress)
hardwareDevice.Port = strings.TrimSpace(hardwareDevice.Port)
hardwareDevice.FirmwareVersion = strings.TrimSpace(hardwareDevice.FirmwareVersion) hardwareDevice.FirmwareVersion = strings.TrimSpace(hardwareDevice.FirmwareVersion)
if hardwareDevice.HardwareID == "" { if hardwareDevice.HardwareID == "" {
@ -78,6 +78,7 @@ func createOrUpdateMilestoneHardwareDevice(
serial_number, serial_number,
mac_address, mac_address,
ip_address, ip_address,
milestone_port,
location, location,
loan_status, loan_status,
is_bao_device, is_bao_device,
@ -96,10 +97,11 @@ func createOrUpdateMilestoneHardwareDevice(
$4, $4,
$5, $5,
$6, $6,
$7,
'', '',
'Verfügbar', 'Verfügbar',
false, false,
$7, '',
$8, $8,
$9, $9,
$10, $10,
@ -116,8 +118,7 @@ func createOrUpdateMilestoneHardwareDevice(
serial_number = EXCLUDED.serial_number, serial_number = EXCLUDED.serial_number,
mac_address = EXCLUDED.mac_address, mac_address = EXCLUDED.mac_address,
ip_address = EXCLUDED.ip_address, ip_address = EXCLUDED.ip_address,
location = EXCLUDED.location, milestone_port = EXCLUDED.milestone_port,
comment = EXCLUDED.comment,
firmware_version = EXCLUDED.firmware_version, firmware_version = EXCLUDED.firmware_version,
milestone_recording_server_id = EXCLUDED.milestone_recording_server_id, milestone_recording_server_id = EXCLUDED.milestone_recording_server_id,
milestone_display_name = EXCLUDED.milestone_display_name, milestone_display_name = EXCLUDED.milestone_display_name,
@ -131,7 +132,7 @@ func createOrUpdateMilestoneHardwareDevice(
hardwareDevice.SerialNumber, hardwareDevice.SerialNumber,
hardwareDevice.MacAddress, hardwareDevice.MacAddress,
hardwareDevice.IPAddress, hardwareDevice.IPAddress,
buildMilestoneDeviceComment(hardwareDevice), hardwareDevice.Port,
hardwareDevice.FirmwareVersion, hardwareDevice.FirmwareVersion,
hardwareDevice.RecordingServerID, hardwareDevice.RecordingServerID,
hardwareDevice.HardwareID, hardwareDevice.HardwareID,
@ -324,31 +325,3 @@ func buildGeneratedMilestoneInventoryNumber(hardwareID string) string {
return "MS-" + value 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 package main
import ( import (
"context"
"errors" "errors"
"net/http" "net/http"
"strings"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
) )
type notificationPreferencesResponse struct { type notificationPreferencesResponse struct {
InAppEnabled bool `json:"inAppEnabled"` InAppEnabled bool `json:"inAppEnabled"`
SoundEnabled bool `json:"soundEnabled"` SoundEnabled bool `json:"soundEnabled"`
EmailEnabled bool `json:"emailEnabled"` SoundName string `json:"soundName"`
OperationUpdates bool `json:"operationUpdates"` EmailEnabled bool `json:"emailEnabled"`
OperationJournals bool `json:"operationJournals"` OperationUpdates bool `json:"operationUpdates"`
DeviceUpdates bool `json:"deviceUpdates"` OperationJournals bool `json:"operationJournals"`
DeviceJournals bool `json:"deviceJournals"` DeviceUpdates bool `json:"deviceUpdates"`
SystemMessages bool `json:"systemMessages"` DeviceJournals bool `json:"deviceJournals"`
SystemMessages bool `json:"systemMessages"`
} }
func defaultNotificationPreferences() notificationPreferencesResponse { func defaultNotificationPreferences() notificationPreferencesResponse {
return notificationPreferencesResponse{ return notificationPreferencesResponse{
InAppEnabled: true, InAppEnabled: true,
SoundEnabled: false, SoundEnabled: false,
SoundName: "default",
EmailEnabled: false, EmailEnabled: false,
OperationUpdates: true, OperationUpdates: true,
OperationJournals: 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) { func (s *Server) handleGetNotificationPreferences(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context()) user, ok := userFromContext(r.Context())
if !ok { if !ok {
@ -40,7 +55,7 @@ func (s *Server) handleGetNotificationPreferences(w http.ResponseWriter, r *http
return return
} }
settings, err := s.getNotificationPreferences(r, user.ID) settings, err := s.getNotificationPreferences(r.Context(), user.ID)
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"settings": defaultNotificationPreferences(), "settings": defaultNotificationPreferences(),
@ -72,6 +87,8 @@ func (s *Server) handleUpdateNotificationPreferences(w http.ResponseWriter, r *h
return return
} }
input.SoundName = normalizeNotificationSoundName(input.SoundName)
_, err := s.db.Exec( _, err := s.db.Exec(
r.Context(), r.Context(),
` `
@ -79,6 +96,7 @@ func (s *Server) handleUpdateNotificationPreferences(w http.ResponseWriter, r *h
user_id, user_id,
in_app_enabled, in_app_enabled,
sound_enabled, sound_enabled,
sound_name,
email_enabled, email_enabled,
operation_updates, operation_updates,
operation_journals, operation_journals,
@ -86,11 +104,12 @@ func (s *Server) handleUpdateNotificationPreferences(w http.ResponseWriter, r *h
device_journals, device_journals,
system_messages 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) ON CONFLICT (user_id)
DO UPDATE SET DO UPDATE SET
in_app_enabled = EXCLUDED.in_app_enabled, in_app_enabled = EXCLUDED.in_app_enabled,
sound_enabled = EXCLUDED.sound_enabled, sound_enabled = EXCLUDED.sound_enabled,
sound_name = EXCLUDED.sound_name,
email_enabled = EXCLUDED.email_enabled, email_enabled = EXCLUDED.email_enabled,
operation_updates = EXCLUDED.operation_updates, operation_updates = EXCLUDED.operation_updates,
operation_journals = EXCLUDED.operation_journals, operation_journals = EXCLUDED.operation_journals,
@ -102,6 +121,7 @@ func (s *Server) handleUpdateNotificationPreferences(w http.ResponseWriter, r *h
user.ID, user.ID,
input.InAppEnabled, input.InAppEnabled,
input.SoundEnabled, input.SoundEnabled,
input.SoundName,
input.EmailEnabled, input.EmailEnabled,
input.OperationUpdates, input.OperationUpdates,
input.OperationJournals, input.OperationJournals,
@ -114,7 +134,7 @@ func (s *Server) handleUpdateNotificationPreferences(w http.ResponseWriter, r *h
return return
} }
settings, err := s.getNotificationPreferences(r, user.ID) settings, err := s.getNotificationPreferences(r.Context(), user.ID)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "Benachrichtigungseinstellungen wurden gespeichert, konnten aber nicht neu geladen werden") writeError(w, http.StatusInternalServerError, "Benachrichtigungseinstellungen wurden gespeichert, konnten aber nicht neu geladen werden")
return 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() settings := defaultNotificationPreferences()
err := s.db.QueryRow( err := s.db.QueryRow(
r.Context(), ctx,
` `
SELECT SELECT
in_app_enabled, in_app_enabled,
sound_enabled, sound_enabled,
COALESCE(sound_name, 'default'),
email_enabled, email_enabled,
operation_updates, operation_updates,
operation_journals, operation_journals,
@ -148,6 +169,7 @@ func (s *Server) getNotificationPreferences(r *http.Request, userID string) (not
).Scan( ).Scan(
&settings.InAppEnabled, &settings.InAppEnabled,
&settings.SoundEnabled, &settings.SoundEnabled,
&settings.SoundName,
&settings.EmailEnabled, &settings.EmailEnabled,
&settings.OperationUpdates, &settings.OperationUpdates,
&settings.OperationJournals, &settings.OperationJournals,
@ -156,5 +178,16 @@ func (s *Server) getNotificationPreferences(r *http.Request, userID string) (not
&settings.SystemMessages, &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 return settings, err
} }

View File

@ -1,4 +1,4 @@
// backend\notifications.go // backend/notifications.go
package main package main
@ -22,7 +22,8 @@ type Notification struct {
EntityType string `json:"entityType"` EntityType string `json:"entityType"`
EntityID string `json:"entityId"` EntityID string `json:"entityId"`
Data json.RawMessage `json:"data"` Data json.RawMessage `json:"data"`
Visible bool `json:"visible"` Silent bool `json:"silent"`
SoundName string `json:"soundName,omitempty"`
ReadAt *time.Time `json:"readAt"` ReadAt *time.Time `json:"readAt"`
CreatedAt time.Time `json:"createdAt"` 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) { func (s *Server) handleNotificationEvents(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context()) user, ok := userFromContext(r.Context())
if !ok { if !ok {
@ -115,12 +261,14 @@ func (s *Server) handleNotificationEvents(w http.ResponseWriter, r *http.Request
eventName := "notification" eventName := "notification"
if !notification.Visible { if notification.Silent {
switch notification.EntityType { switch notification.EntityType {
case "operation": case "operation":
eventName = "operation-event" eventName = "operation-event"
case "device": case "device":
eventName = "device-event" eventName = "device-event"
case "user":
eventName = "user-event"
default: default:
eventName = "notification-event" eventName = "notification-event"
} }
@ -152,12 +300,12 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
entity_type, entity_type,
COALESCE(entity_id::TEXT, ''), COALESCE(entity_id::TEXT, ''),
data, data,
visible, silent,
read_at, read_at,
created_at created_at
FROM notifications FROM notifications
WHERE user_id = $1 WHERE user_id = $1
AND visible = true AND silent = false
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT 50 LIMIT 50
`, `,
@ -186,7 +334,7 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
&notification.EntityType, &notification.EntityType,
&notification.EntityID, &notification.EntityID,
&data, &data,
&notification.Visible, &notification.Silent,
&readAt, &readAt,
&notification.CreatedAt, &notification.CreatedAt,
); err != nil { ); err != nil {
@ -307,76 +455,25 @@ func (s *Server) createOperationNotification(
return err return err
} }
var notification Notification if err := s.createAndPublishNotification(
var rawData []byte
err := s.db.QueryRow(
ctx, 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, recipientID,
notificationType, notificationType,
title, title,
message, message,
"operation",
operation.ID, operation.ID,
string(dataJSON), dataJSON,
).Scan( false,
&notification.ID, ); err != nil {
&notification.UserID,
&notification.Type,
&notification.Title,
&notification.Message,
&notification.EntityType,
&notification.EntityID,
&rawData,
&notification.Visible,
&notification.CreatedAt,
)
if err != nil {
return err return err
} }
notification.Data = json.RawMessage(rawData)
notificationsBroker.publish(notification)
} }
return rows.Err() return rows.Err()
} }
func (s *Server) createOperationUpdateEvent( func (s *Server) createOperationCreatedEvent(
ctx context.Context, ctx context.Context,
actor User, actor User,
operation Operation, operation Operation,
@ -390,7 +487,7 @@ func (s *Server) createOperationUpdateEvent(
rows, err := s.db.Query( rows, err := s.db.Query(
ctx, ctx,
` `
SELECT id SELECT id::TEXT
FROM users FROM users
WHERE id <> $1 WHERE id <> $1
AND ( AND (
@ -412,66 +509,73 @@ func (s *Server) createOperationUpdateEvent(
return err return err
} }
var notification Notification if err := s.createAndPublishNotification(
var rawData []byte
err := s.db.QueryRow(
ctx, 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, recipientID,
"operation.created",
"Einsatz erstellt",
"",
"operation",
operation.ID, operation.ID,
string(dataJSON), dataJSON,
).Scan( true,
&notification.ID, ); err != nil {
&notification.UserID, return err
&notification.Type, }
&notification.Title, }
&notification.Message,
&notification.EntityType,
&notification.EntityID,
&rawData,
&notification.Visible,
&notification.CreatedAt,
)
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 return err
} }
notification.Data = json.RawMessage(rawData) if err := s.createAndPublishNotification(
notificationsBroker.publish(notification) ctx,
recipientID,
"operation.updated",
"Einsatz geändert",
"",
"operation",
operation.ID,
dataJSON,
true,
); err != nil {
return err
}
} }
return rows.Err() return rows.Err()
@ -491,7 +595,7 @@ func (s *Server) createOperationJournalEvent(
rows, err := s.db.Query( rows, err := s.db.Query(
ctx, ctx,
` `
SELECT id SELECT id::TEXT
FROM users FROM users
WHERE id <> $1 WHERE id <> $1
`, `,
@ -504,70 +608,24 @@ func (s *Server) createOperationJournalEvent(
for rows.Next() { for rows.Next() {
var recipientID string var recipientID string
if err := rows.Scan(&recipientID); err != nil { if err := rows.Scan(&recipientID); err != nil {
return err return err
} }
var notification Notification if err := s.createAndPublishNotification(
var rawData []byte
err := s.db.QueryRow(
ctx, 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, recipientID,
"operation.journal",
"Journal aktualisiert",
"Ein Journal-Eintrag wurde aktualisiert.",
"operation",
operation.ID, operation.ID,
string(dataJSON), dataJSON,
).Scan( true,
&notification.ID, ); err != nil {
&notification.UserID,
&notification.Type,
&notification.Title,
&notification.Message,
&notification.EntityType,
&notification.EntityID,
&rawData,
&notification.Visible,
&notification.CreatedAt,
)
if err != nil {
return err return err
} }
notification.Data = json.RawMessage(rawData)
notificationsBroker.publish(notification)
} }
return rows.Err() return rows.Err()
@ -587,7 +645,7 @@ func (s *Server) createDeviceJournalEvent(
rows, err := s.db.Query( rows, err := s.db.Query(
ctx, ctx,
` `
SELECT id SELECT id::TEXT
FROM users FROM users
WHERE id <> $1 WHERE id <> $1
AND ( AND (
@ -604,70 +662,24 @@ func (s *Server) createDeviceJournalEvent(
for rows.Next() { for rows.Next() {
var recipientID string var recipientID string
if err := rows.Scan(&recipientID); err != nil { if err := rows.Scan(&recipientID); err != nil {
return err return err
} }
var notification Notification if err := s.createAndPublishNotification(
var rawData []byte
err := s.db.QueryRow(
ctx, 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, recipientID,
"device.journal",
"Geräte-Journal aktualisiert",
"Ein Geräte-Journal-Eintrag wurde hinzugefügt.",
"device",
device.ID, device.ID,
string(dataJSON), dataJSON,
).Scan( true,
&notification.ID, ); err != nil {
&notification.UserID,
&notification.Type,
&notification.Title,
&notification.Message,
&notification.EntityType,
&notification.EntityID,
&rawData,
&notification.Visible,
&notification.CreatedAt,
)
if err != nil {
return err return err
} }
notification.Data = json.RawMessage(rawData)
notificationsBroker.publish(notification)
} }
return rows.Err() 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 // 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{ writeJSON(w, http.StatusCreated, map[string]any{
"operation": operation, "operation": operation,
}) })
@ -1051,7 +1064,7 @@ func (s *Server) handleListUnreadOperationJournals(w http.ResponseWriter, r *htt
SELECT entity_id::TEXT SELECT entity_id::TEXT
FROM notifications FROM notifications
WHERE user_id = $1 WHERE user_id = $1
AND visible = false AND silent = true
AND type = 'operation.journal' AND type = 'operation.journal'
AND entity_type = 'operation' AND entity_type = 'operation'
AND entity_id IS NOT NULL AND entity_id IS NOT NULL
@ -1103,7 +1116,7 @@ func (s *Server) handleMarkOperationJournalRead(w http.ResponseWriter, r *http.R
UPDATE notifications UPDATE notifications
SET read_at = COALESCE(read_at, now()) SET read_at = COALESCE(read_at, now())
WHERE user_id = $1 WHERE user_id = $1
AND visible = false AND silent = true
AND type = 'operation.journal' AND type = 'operation.journal'
AND entity_type = 'operation' AND entity_type = 'operation'
AND entity_id = $2 AND entity_id = $2

View File

@ -1,4 +1,4 @@
// backend/routes.go // backend\routes.go
package main package main
@ -29,9 +29,12 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("GET /devices", s.requireAuth(s.handleListDevices)) mux.HandleFunc("GET /devices", s.requireAuth(s.handleListDevices))
mux.HandleFunc("POST /devices", s.requireAuth(s.handleCreateDevice)) mux.HandleFunc("POST /devices", s.requireAuth(s.handleCreateDevice))
mux.HandleFunc("PUT /devices/{id}", s.requireAuth(s.handleUpdateDevice)) 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("GET /devices/{id}/history", s.requireAuth(s.handleListDeviceHistory))
mux.HandleFunc("POST /devices/{id}/history", s.requireAuth(s.handleCreateDeviceJournalEntry)) 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("GET /devices/firmware-check", s.requireAuth(s.handleGetCameraFirmwareCheckState))
mux.HandleFunc("POST /devices/firmware-check", s.requireAuth(s.handleCheckCameraFirmware)) 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("GET /operations", s.requireAuth(s.requireRight("operations:read", s.handleListOperations)))
mux.HandleFunc("POST /operations", s.requireAuth(s.requireRight("operations:write", s.handleCreateOperation))) 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("PUT /operations/{id}", s.requireAuth(s.requireRight("operations:write", s.handleUpdateOperation)))
mux.HandleFunc("GET /operations/{id}/history", s.requireAuth(s.handleListOperationHistory)) mux.HandleFunc("GET /operations/{id}/history", s.requireAuth(s.handleListOperationHistory))
mux.HandleFunc("POST /operations/{id}/history", s.requireAuth(s.handleCreateOperationJournalEntry)) mux.HandleFunc("POST /operations/{id}/history", s.requireAuth(s.handleCreateOperationJournalEntry))
mux.HandleFunc("PUT /operations/{id}/history/{historyId}", s.requireAuth(s.handleUpdateOperationJournalEntry)) 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("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 /search", s.requireAuth(s.handleGlobalSearch))
mux.HandleFunc("GET /address-search", s.requireAuth(s.handleAddressSearch)) 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("GET /notifications", s.requireAuth(s.handleListNotifications))
mux.HandleFunc("POST /notifications/{id}/read", s.requireAuth(s.handleMarkNotificationRead)) 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("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("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("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("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))) 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 ( CREATE TABLE IF NOT EXISTS user_teams (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, 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_hardware_id TEXT NOT NULL DEFAULT '',
milestone_display_name TEXT NOT NULL DEFAULT '', milestone_display_name TEXT NOT NULL DEFAULT '',
firmware_version TEXT NOT NULL DEFAULT '', firmware_version TEXT NOT NULL DEFAULT '',
milestone_port TEXT NOT NULL DEFAULT '',
milestone_enabled BOOLEAN NOT NULL DEFAULT true, milestone_enabled BOOLEAN NOT NULL DEFAULT true,
milestone_synced_at TIMESTAMPTZ, milestone_synced_at TIMESTAMPTZ,
@ -380,7 +393,8 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
action TEXT NOT NULL, action TEXT NOT NULL,
changes JSONB NOT NULL DEFAULT '[]'::JSONB, 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', type TEXT NOT NULL DEFAULT 'info',
title TEXT NOT NULL, title TEXT NOT NULL,
message TEXT NOT NULL DEFAULT '', message TEXT NOT NULL DEFAULT '',
visible BOOLEAN NOT NULL DEFAULT true, silent BOOLEAN NOT NULL DEFAULT false,
entity_type TEXT NOT NULL DEFAULT '', entity_type TEXT NOT NULL DEFAULT '',
entity_id UUID, entity_id UUID,
@ -476,6 +490,7 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
in_app_enabled BOOLEAN NOT NULL DEFAULT true, in_app_enabled BOOLEAN NOT NULL DEFAULT true,
sound_enabled BOOLEAN NOT NULL DEFAULT false, sound_enabled BOOLEAN NOT NULL DEFAULT false,
sound_name TEXT NOT NULL DEFAULT 'default',
email_enabled BOOLEAN NOT NULL DEFAULT false, email_enabled BOOLEAN NOT NULL DEFAULT false,
operation_updates BOOLEAN NOT NULL DEFAULT true, 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_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_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_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_widgets_position ON dashboard_widgets(user_id, y, x)`,
`CREATE INDEX IF NOT EXISTS idx_dashboard_settings_user_id ON dashboard_settings(user_id)`, `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_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_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 <> ''`, `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 return
} }
s.publishUserProfileEvent("user.updated", user)
writeSettingsJSON(w, http.StatusOK, map[string]any{ writeSettingsJSON(w, http.StatusOK, map[string]any{
"message": "Profil gespeichert", "message": "Profil gespeichert",
"user": user, "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": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@types/geojson": "^7946.0.16",
"@types/leaflet": "^1.9.21", "@types/leaflet": "^1.9.21",
"@types/node": "^24.12.3", "@types/node": "^24.12.3",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",

View File

@ -24,6 +24,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@types/geojson": "^7946.0.16",
"@types/leaflet": "^1.9.21", "@types/leaflet": "^1.9.21",
"@types/node": "^24.12.3", "@types/node": "^24.12.3",
"@types/react": "^19.2.14", "@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 // frontend/src/App.tsx
import { useEffect, useState } from 'react' 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 LoginPage from './pages/LoginPage'
import AppLayout from './components/AppLayout' import AppLayout from './components/AppLayout'
import DashboardPage from './pages/DashboardPage' 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({ function RequireRight({
user, user,
right, right,
@ -46,6 +93,8 @@ function RequireRight({
} }
function App() { function App() {
const navigate = useNavigate()
const [user, setUser] = useState<User | null>(null) const [user, setUser] = useState<User | null>(null)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
@ -77,10 +126,12 @@ function App() {
}) })
function addVisibleNotification(notification: Notification) { function addVisibleNotification(notification: Notification) {
if (notification.visible === false) { if (notification.silent) {
return return
} }
playNotificationSound(notification.soundName)
setNotifications((currentNotifications) => [ setNotifications((currentNotifications) => [
notification, notification,
...currentNotifications.filter((item) => item.id !== notification.id), ...currentNotifications.filter((item) => item.id !== notification.id),
@ -151,14 +202,48 @@ function App() {
dispatchEntityEvent(notification) 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('notification', handleNotificationEvent)
eventSource.addEventListener('operation-event', handleOperationEvent) eventSource.addEventListener('operation-event', handleOperationEvent)
eventSource.addEventListener('device-event', handleDeviceEvent) eventSource.addEventListener('device-event', handleDeviceEvent)
eventSource.addEventListener('user-event', handleUserEvent)
return () => { return () => {
eventSource.removeEventListener('notification', handleNotificationEvent) eventSource.removeEventListener('notification', handleNotificationEvent)
eventSource.removeEventListener('operation-event', handleOperationEvent) eventSource.removeEventListener('operation-event', handleOperationEvent)
eventSource.removeEventListener('device-event', handleDeviceEvent) eventSource.removeEventListener('device-event', handleDeviceEvent)
eventSource.removeEventListener('user-event', handleUserEvent)
eventSource.close() eventSource.close()
} }
}, [user]) }, [user])
@ -264,6 +349,15 @@ function App() {
}).catch(() => undefined) }).catch(() => undefined)
} }
function handleNotificationClick(notification: Notification) {
if (
notification.type === 'operation.created' ||
notification.entityType === 'operation'
) {
navigate('/einsaetze')
}
}
async function handleLogout() { async function handleLogout() {
try { try {
await fetch(`${API_URL}/auth/logout`, { await fetch(`${API_URL}/auth/logout`, {
@ -300,101 +394,104 @@ function App() {
} }
return ( return (
<AppLayout <NotificationProvider>
user={user} <AppLayout
onLogout={handleLogout} user={user}
notificationCenter={ onLogout={handleLogout}
<NotificationCenter notificationCenter={
notifications={notifications} <NotificationCenter
unreadCount={unreadNotificationCount} notifications={notifications}
onMarkRead={markNotificationRead} unreadCount={unreadNotificationCount}
onMarkAllRead={markAllNotificationsRead} onMarkRead={markNotificationRead}
/> onMarkAllRead={markAllNotificationsRead}
} onNotificationClick={handleNotificationClick}
> />
<Routes> }
<Route path="/" element={<DashboardPage username={user.username} />} /> >
<Routes>
<Route path="/" element={<DashboardPage username={user.username} />} />
<Route <Route
path="/geraete" path="/geraete"
element={ element={
<RequireRight user={user} right="devices:read"> <RequireRight user={user} right="devices:read">
<DevicesPage /> <DevicesPage />
</RequireRight> </RequireRight>
} }
/> />
<Route <Route
path="/einsaetze" path="/einsaetze"
element={ element={
<RequireRight user={user} right="operations:read"> <RequireRight user={user} right="operations:read">
<OperationsPage <OperationsPage
unreadJournalOperationIds={unreadJournalOperationIds} unreadJournalOperationIds={unreadJournalOperationIds}
onJournalRead={markOperationJournalRead} onJournalRead={markOperationJournalRead}
canWriteOperations={hasRight(user, 'operations:write')} canWriteOperations={hasRight(user, 'operations:write')}
/> />
</RequireRight> </RequireRight>
} }
/> />
<Route <Route
path="/kalender" path="/kalender"
element={ element={
<RequireRight user={user} right="calendar:read"> <RequireRight user={user} right="calendar:read">
<CalendarPage /> <CalendarPage />
</RequireRight> </RequireRight>
} }
/> />
<Route <Route
path="/dokumente" path="/dokumente"
element={ element={
<RequireRight user={user} right="documents:read"> <RequireRight user={user} right="documents:read">
<DocumentsPage /> <DocumentsPage />
</RequireRight> </RequireRight>
} }
/> />
<Route <Route
path="/berichte" path="/berichte"
element={ element={
<RequireRight user={user} right="reports:read"> <RequireRight user={user} right="reports:read">
<ReportsPage /> <ReportsPage />
</RequireRight> </RequireRight>
} }
/> />
<Route <Route
path="/administration" path="/administration"
element={<Navigate to="/administration/benutzer" replace />} element={<Navigate to="/administration/benutzer" replace />}
/> />
<Route <Route
path="/administration/*" path="/administration/*"
element={ element={
<RequireRight user={user} right="administration:read"> <RequireRight user={user} right="administration:read">
<AdministrationPage /> <AdministrationPage />
</RequireRight> </RequireRight>
} }
/> />
<Route <Route
path="/einstellungen/*" path="/einstellungen/*"
element={ element={
<RequireRight user={user} right="settings:read"> <RequireRight user={user} right="settings:read">
<SettingsPage <SettingsPage
user={user} user={user}
onUserUpdated={setUser} onUserUpdated={setUser}
/> />
</RequireRight> </RequireRight>
} }
/> />
<Route path="/feedback" element={<Feedback />} /> <Route path="/feedback" element={<Feedback />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</AppLayout> </AppLayout>
</NotificationProvider>
) )
} }

View File

@ -8,8 +8,10 @@ import {
ComboboxOptions, ComboboxOptions,
Label, Label,
} from '@headlessui/react' } from '@headlessui/react'
import AddressMapPicker from './AddressMapPicker'
import type { GeoJsonObject } from 'geojson'
import { ChevronDownIcon } from '@heroicons/react/20/solid' 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' const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
@ -19,6 +21,16 @@ export type AddressSuggestion = {
lat: number lat: number
lon: number lon: number
type?: string type?: string
geojson?: GeoJsonObject | null
}
type Coordinates = {
lat: number
lon: number
}
type ReverseGeocodeResponse = {
suggestion?: AddressSuggestion
} }
type AddressComboboxProps = { type AddressComboboxProps = {
@ -28,6 +40,7 @@ type AddressComboboxProps = {
value: string value: string
onChange: (value: string) => void onChange: (value: string) => void
placeholder?: string placeholder?: string
mapVisible?: boolean
} }
const inputClassName = const inputClassName =
@ -37,21 +50,34 @@ function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(' ') return classes.filter(Boolean).join(' ')
} }
function buildOpenStreetMapEmbedUrl(address: AddressSuggestion) { function toNumber(value: unknown) {
const offset = 0.004 if (typeof value === 'number') {
return value
}
const params = new URLSearchParams({ if (typeof value === 'string') {
bbox: [ const parsed = Number(value)
address.lon - offset, return Number.isFinite(parsed) ? parsed : NaN
address.lat - offset, }
address.lon + offset,
address.lat + offset,
].join(','),
layer: 'mapnik',
marker: `${address.lat},${address.lon}`,
})
return `https://www.openstreetmap.org/export/embed.html?${params.toString()}` 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({ export default function AddressCombobox({
@ -61,26 +87,223 @@ export default function AddressCombobox({
value, value,
onChange, onChange,
placeholder = 'Adresse suchen', placeholder = 'Adresse suchen',
mapVisible = true,
}: AddressComboboxProps) { }: AddressComboboxProps) {
const [query, setQuery] = useState(value) const [query, setQuery] = useState(value)
const [suggestions, setSuggestions] = useState<AddressSuggestion[]>([]) const [suggestions, setSuggestions] = useState<AddressSuggestion[]>([])
const [selectedSuggestion, setSelectedSuggestion] = useState<AddressSuggestion | null>(null) const [selectedSuggestion, setSelectedSuggestion] = useState<AddressSuggestion | null>(null)
const [previewSuggestion, setPreviewSuggestion] = 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 [isSearching, setIsSearching] = useState(false)
const [searchError, setSearchError] = useState<string | null>(null) const [searchError, setSearchError] = useState<string | null>(null)
const previousValueRef = useRef(value) const previousValueRef = useRef(value)
const initializedAddressRef = useRef('')
const previousMapVisibleRef = useRef(mapVisible)
const reverseGeocodeAbortRef = useRef<AbortController | null>(null)
const internalValueUpdateRef = useRef(false)
useEffect(() => { useEffect(() => {
if (value !== previousValueRef.current && value !== query) { if (value !== previousValueRef.current) {
setQuery(value) 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 previousValueRef.current = value
}, [value, query]) }, [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(() => { useEffect(() => {
const trimmedQuery = query.trim() const trimmedQuery = query.trim()
@ -113,10 +336,14 @@ export default function AddressCombobox({
} }
const data = await response.json() 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) setSuggestions(nextSuggestions)
setPreviewSuggestion((currentPreview) => currentPreview ?? nextSuggestions[0] ?? null) setPreviewSuggestion((currentPreview) => currentPreview ?? nextPreview)
} catch (error) { } catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') { if (error instanceof DOMException && error.name === 'AbortError') {
return return
@ -140,33 +367,58 @@ export default function AddressCombobox({
} }
}, [query]) }, [query])
const mapUrl = useMemo(() => {
if (!previewSuggestion) {
return null
}
return buildOpenStreetMapEmbedUrl(previewSuggestion)
}, [previewSuggestion])
function handleInputChange(nextValue: string) { function handleInputChange(nextValue: string) {
reverseGeocodeAbortRef.current?.abort()
internalValueUpdateRef.current = false
initializedAddressRef.current = ''
setQuery(nextValue) setQuery(nextValue)
setSelectedSuggestion(null) setSelectedSuggestion(null)
setPreviewSuggestion(null) setPreviewSuggestion(null)
setMarkerPosition(null)
onChange(nextValue) onChange(nextValue)
} }
function handleSelect(suggestion: AddressSuggestion | null) { async function handleSelect(suggestion: AddressSuggestion | null) {
setSelectedSuggestion(suggestion) const normalizedSuggestion = suggestion
? normalizeAddressSuggestion(suggestion)
: null
if (!suggestion) { setSelectedSuggestion(normalizedSuggestion)
if (!normalizedSuggestion) {
return return
} }
setQuery(suggestion.label) setMarkerPosition({
setPreviewSuggestion(suggestion) lat: normalizedSuggestion.lat,
onChange(suggestion.label) 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 ( return (
<div> <div>
<input type="hidden" name={name} value={value} /> <input type="hidden" name={name} value={value} />
@ -196,7 +448,7 @@ export default function AddressCombobox({
{(suggestions.length > 0 || isSearching || searchError) && ( {(suggestions.length > 0 || isSearching || searchError) && (
<ComboboxOptions <ComboboxOptions
transition 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 && ( {isSearching && (
<div className="px-3 py-2 text-sm text-gray-500 dark:text-gray-400"> <div className="px-3 py-2 text-sm text-gray-500 dark:text-gray-400">
@ -230,22 +482,14 @@ export default function AddressCombobox({
</div> </div>
</HeadlessCombobox> </HeadlessCombobox>
{mapUrl && previewSuggestion && ( <AddressMapPicker
<div className="mt-3 overflow-hidden rounded-lg border border-gray-200 bg-gray-50 dark:border-white/10 dark:bg-white/5"> key={mapKey}
<iframe value={markerPosition}
title={`OpenStreetMap Vorschau: ${previewSuggestion.label}`} focusKey={mapFocusKey}
src={mapUrl} visible={mapVisible}
className="h-56 w-full border-0" highlightGeoJson={previewSuggestion?.geojson ?? null}
loading="lazy" onChange={handleMapPositionChange}
/> />
<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>
)}
</div> </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} 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 <Topbar
user={user} user={user}
onLogout={onLogout} onLogout={onLogout}
@ -61,7 +61,7 @@ export default function AppLayout({
onNavigate={handleNavigation} 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} {children}
<Transition <Transition
@ -73,7 +73,7 @@ export default function AppLayout({
leave="transition-opacity duration-150 ease-in" leave="transition-opacity duration-150 ease-in"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
className="absolute inset-0 z-30" className="absolute inset-0 z-[9000]"
> >
<SearchOverlay <SearchOverlay
user={user} user={user}

View File

@ -25,6 +25,7 @@ type BadgeProps = Omit<HTMLAttributes<HTMLSpanElement>, "color"> & {
dot?: boolean; dot?: boolean;
leadingIcon?: ReactNode; leadingIcon?: ReactNode;
trailingIcon?: ReactNode; trailingIcon?: ReactNode;
hover?: boolean;
onRemove?: () => void; onRemove?: () => void;
removeLabel?: string; 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> = { const dotClasses: Record<BadgeTone, string> = {
gray: "fill-gray-400", gray: "fill-gray-400",
red: "fill-red-500 dark:fill-red-400", red: "fill-red-500 dark:fill-red-400",
@ -132,6 +164,7 @@ export function Badge({
dot = false, dot = false,
leadingIcon, leadingIcon,
trailingIcon, trailingIcon,
hover = false,
onRemove, onRemove,
removeLabel = "Remove", removeLabel = "Remove",
className, className,
@ -150,6 +183,8 @@ export function Badge({
shape === "pill" ? "rounded-full" : "rounded-md", shape === "pill" ? "rounded-full" : "rounded-md",
size === "small" ? "px-1.5 py-0.5" : "px-2 py-1", size === "small" ? "px-1.5 py-0.5" : "px-2 py-1",
badgeClasses[variant][tone], badgeClasses[variant][tone],
hover && "cursor-pointer transition-colors",
hover && badgeHoverClasses[variant][tone],
className, className,
)} )}
{...props} {...props}

View File

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

View File

@ -1,6 +1,7 @@
// frontend\src\components\Camera.tsx // frontend\src\components\Camera.tsx
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Dialog, DialogBackdrop, DialogPanel, DialogTitle } from '@headlessui/react' import { Dialog, DialogBackdrop, DialogPanel, DialogTitle } from '@headlessui/react'
import { CameraIcon, XMarkIcon } from '@heroicons/react/24/outline' 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" /> <CameraIcon aria-hidden="true" className="size-6" />
</button> </button>
<Dialog open={open} onClose={closeModal} className="relative z-50"> {typeof document !== 'undefined'
<DialogBackdrop ? createPortal(
transition <Dialog
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" open={open}
/> onClose={closeModal}
className="fixed inset-0 z-[9999]"
<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"
> >
<div className="absolute top-0 right-0 pt-4 pr-4"> <DialogBackdrop
<button transition
type="button" 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"
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="px-4 pt-5 pb-4 sm:p-6"> <div className="fixed inset-0 z-10 w-screen overflow-y-auto">
<DialogTitle as="h3" className="text-base font-semibold text-gray-900 dark:text-white"> <div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
QR-Code scannen <DialogPanel
</DialogTitle> 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"
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400"> >
Richte die Kamera auf einen QR-Code. <div className="absolute top-0 right-0 pt-4 pr-4">
</p> <button
type="button"
<div className="mt-5 overflow-hidden rounded-lg bg-gray-950"> onClick={closeModal}
{scannedValue ? ( 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"
<div className="p-4"> >
<p className="text-sm font-medium text-white"> <span className="sr-only">Schließen</span>
QR-Code erkannt: <XMarkIcon aria-hidden="true" className="size-6" />
</p> </button>
<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="px-4 pt-5 pb-4 sm:p-6">
<div className="absolute inset-0 flex items-center justify-center bg-black/50 p-4"> <DialogTitle as="h3" className="text-base font-semibold text-gray-900 dark:text-white">
<div QR-Code scannen
className={[ </DialogTitle>
'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', <p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
].join(' ')} Richte die Kamera auf einen QR-Code.
> </p>
{error ?? 'Kamera wird gestartet...'}
<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> ) : (
)} <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>
)}
<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> </div>
</Dialog>,
<div className="bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6 dark:bg-gray-700/25"> document.body,
<button )
type="button" : null}
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>
</> </>
) )
} }

View File

@ -1,3 +1,5 @@
// frontend\src\components\Combobox.tsx
'use client' 'use client'
import { useState } from 'react' import { useState } from 'react'
@ -9,7 +11,8 @@ import {
ComboboxOptions, ComboboxOptions,
Label, Label,
} from '@headlessui/react' } 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' export type ComboboxVariant = 'simple' | 'status' | 'image' | 'secondary'
@ -109,20 +112,11 @@ export default function Combobox({
if (variant === 'image') { if (variant === 'image') {
return ( return (
<div className="flex items-center"> <div className="flex items-center">
{item.imageUrl ? ( <Avatar
<img src={item.imageUrl}
src={item.imageUrl} name={item.name}
alt="" size={6}
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>
)}
<span className="ml-3 block truncate">{item.name}</span> <span className="ml-3 block truncate">{item.name}</span>
</div> </div>
@ -166,11 +160,22 @@ export default function Combobox({
)} )}
<div className={classNames('relative', label && 'mt-2')}> <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 <ComboboxInput
required={required} required={required}
placeholder={placeholder} placeholder={placeholder}
className={classNames( 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', '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, inputClassName,
)} )}
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)}

View File

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

View File

@ -46,7 +46,7 @@ export default function Modal({
onConfirm, onConfirm,
children, children,
panelClassName, panelClassName,
zIndexClassName = 'z-10', zIndexClassName = 'z-[9999]',
}: ModalProps) { }: ModalProps) {
const isSuccess = variant === 'success-single' || variant === 'success-wide' const isSuccess = variant === 'success-single' || variant === 'success-wide'
const isGrayFooter = variant === 'alert-gray-footer' const isGrayFooter = variant === 'alert-gray-footer'
@ -75,10 +75,14 @@ export default function Modal({
if (children) { if (children) {
return ( return (
<Dialog open={open} onClose={setOpen} className={classNames('relative', zIndexClassName)}> <Dialog
open={open}
onClose={setOpen}
className={classNames('fixed inset-0', zIndexClassName)}
>
<DialogBackdrop <DialogBackdrop
transition 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"> <div className="fixed inset-0 z-10 w-screen overflow-hidden">
@ -100,10 +104,14 @@ export default function Modal({
if (isGrayFooter) { if (isGrayFooter) {
return ( return (
<Dialog open={open} onClose={setOpen} className="relative z-10"> <Dialog
open={open}
onClose={setOpen}
className={classNames('relative', zIndexClassName)}
>
<DialogBackdrop <DialogBackdrop
transition 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-y-auto">
@ -158,13 +166,17 @@ export default function Modal({
} }
return ( return (
<Dialog open={open} onClose={setOpen} className="relative z-10"> <Dialog
open={open}
onClose={setOpen}
className={classNames('relative', zIndexClassName)}
>
<DialogBackdrop <DialogBackdrop
transition 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"> <div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<DialogPanel <DialogPanel
transition 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 = { type NotificationCenterProps = {
notifications: Notification[] notifications: Notification[]
unreadCount: number unreadCount: number
onMarkRead: (notificationId: string) => void onMarkRead: (notificationId: string) => void | Promise<void>
onMarkAllRead: () => void onMarkAllRead: () => void | Promise<void>
onNotificationClick?: (notification: Notification) => void
} }
type NotificationTab = 'unread' | 'all' type NotificationTab = 'unread' | 'all'
@ -35,6 +36,7 @@ export default function NotificationCenter({
unreadCount, unreadCount,
onMarkRead, onMarkRead,
onMarkAllRead, onMarkAllRead,
onNotificationClick,
}: NotificationCenterProps) { }: NotificationCenterProps) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [activeTab, setActiveTab] = useState<NotificationTab>('unread') const [activeTab, setActiveTab] = useState<NotificationTab>('unread')
@ -107,6 +109,12 @@ export default function NotificationCenter({
return 'Keine Benachrichtigungen vorhanden.' return 'Keine Benachrichtigungen vorhanden.'
} }
function handleNotificationClick(notification: Notification) {
void onMarkRead(notification.id)
onNotificationClick?.(notification)
setOpen(false)
}
return ( return (
<div ref={containerRef} className="relative"> <div ref={containerRef} className="relative">
<button <button
@ -125,7 +133,7 @@ export default function NotificationCenter({
</button> </button>
{open && ( {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"> <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"> <h2 className="min-w-0 truncate px-4 py-3 text-sm font-semibold text-gray-900 dark:text-white">
Benachrichtigungen Benachrichtigungen
@ -183,7 +191,7 @@ export default function NotificationCenter({
<button <button
key={notification.id} key={notification.id}
type="button" 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" 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"> <div className="flex gap-x-3">

View File

@ -1,10 +1,18 @@
// frontend/src/components/Notifications.tsx // 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 { Transition } from '@headlessui/react'
import { import {
CheckCircleIcon, CheckCircleIcon,
ExclamationCircleIcon,
ExclamationTriangleIcon, ExclamationTriangleIcon,
InformationCircleIcon, InformationCircleIcon,
XCircleIcon, XCircleIcon,
@ -34,10 +42,38 @@ type NotificationsProps = {
position?: 'top-right' | 'bottom-right' 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>) { function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(' ') 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) { function getVariantStyles(variant: NotificationVariant) {
switch (variant) { switch (variant) {
case 'success': case 'success':
@ -82,7 +118,7 @@ function NotificationToast({
actionLabel, actionLabel,
onAction, onAction,
autoClose = true, autoClose = true,
duration = 5000, duration = 3000,
} = notification } = notification
const { icon: Icon, iconClassName } = getVariantStyles(variant) const { icon: Icon, iconClassName } = getVariantStyles(variant)
@ -154,18 +190,22 @@ function NotificationToast({
) )
} }
export default function Notifications({ function NotificationsViewport({
notifications, notifications,
onDismiss, onDismiss,
position = 'top-right', position = 'top-right',
}: NotificationsProps) { }: NotificationsProps) {
return ( if (typeof document === 'undefined') {
return null
}
return createPortal(
<div <div
aria-live="assertive" aria-live="assertive"
className={classNames( 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' position === 'top-right'
? 'items-end sm:items-start' ? 'items-start'
: 'items-end', : 'items-end',
)} )}
> >
@ -178,6 +218,97 @@ export default function Notifications({
/> />
))} ))}
</div> </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) { }: SidebarProps) {
return ( return (
<> <>
<Dialog open={sidebarOpen} onClose={setSidebarOpen} className="relative z-50 lg:hidden"> <Dialog open={sidebarOpen} onClose={setSidebarOpen} className="relative z-[2000] lg:hidden">
<DialogBackdrop <DialogBackdrop
transition transition
className="fixed inset-0 bg-gray-900/80 transition-opacity duration-300 ease-linear data-closed:opacity-0" 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> </div>
</Dialog> </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"> <div className="flex grow flex-col gap-y-5 overflow-y-auto bg-black/10 px-6 pb-4">
<SidebarContent <SidebarContent
user={user} user={user}

View File

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

View File

@ -15,6 +15,7 @@ import { ChevronDownIcon } from '@heroicons/react/20/solid'
import { Link } from 'react-router' import { Link } from 'react-router'
import Search from './Search' import Search from './Search'
import Camera from './Camera' import Camera from './Camera'
import Avatar from './Avatar'
import type { User } from './types' import type { User } from './types'
type TopbarProps = { type TopbarProps = {
@ -44,12 +45,8 @@ export default function Topbar({
console.log('QR-Code erkannt:', value) 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 ( 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 <button
type="button" type="button"
onClick={onOpenSidebar} onClick={onOpenSidebar}
@ -97,10 +94,10 @@ export default function Topbar({
<span className="absolute -inset-1.5" /> <span className="absolute -inset-1.5" />
<span className="sr-only">Open user menu</span> <span className="sr-only">Open user menu</span>
<img <Avatar
alt="" src={user.avatar}
src={avatarUrl} name={user.displayName || user.username || user.email}
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" size={8}
/> />
<span className="hidden lg:flex lg:items-center"> <span className="hidden lg:flex lg:items-center">

View File

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

View File

@ -8,9 +8,7 @@ import {
} from '@heroicons/react/20/solid' } from '@heroicons/react/20/solid'
import Button from '../../../components/Button' import Button from '../../../components/Button'
import LoadingSpinner from '../../../components/LoadingSpinner' import LoadingSpinner from '../../../components/LoadingSpinner'
import Notifications, { import { useNotifications } from '../../../components/Notifications'
type ToastNotification,
} from '../../../components/Notifications'
import Switch from '../../../components/Switch' import Switch from '../../../components/Switch'
import { DialogTitle } from '@headlessui/react' import { DialogTitle } from '@headlessui/react'
import { XMarkIcon } from '@heroicons/react/24/outline' import { XMarkIcon } from '@heroicons/react/24/outline'
@ -197,10 +195,10 @@ function markCurrentMilestoneSyncStepAsError(
} }
export default function Milestone() { export default function Milestone() {
const { showToast, showErrorToast } = useNotifications()
const [settings, setSettings] = useState<MilestoneSettings | null>(null) const [settings, setSettings] = useState<MilestoneSettings | null>(null)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [notifications, setNotifications] = useState<ToastNotification[]>([])
const [isFetchingToken, setIsFetchingToken] = useState(false) const [isFetchingToken, setIsFetchingToken] = useState(false)
const [skipTlsVerify, setSkipTlsVerify] = useState(false) const [skipTlsVerify, setSkipTlsVerify] = useState(false)
const [syncModalOpen, setSyncModalOpen] = useState(false) const [syncModalOpen, setSyncModalOpen] = useState(false)
@ -212,24 +210,6 @@ export default function Milestone() {
void loadSettings() 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) { function getHardwareCountMessage(count: number) {
if (count === 1) { if (count === 1) {
return '1 Hardware-Gerät wurde von Milestone geladen und gespeichert.' return '1 Hardware-Gerät wurde von Milestone geladen und gespeichert.'
@ -257,7 +237,7 @@ export default function Milestone() {
if (event.type === 'token') { if (event.type === 'token') {
const tokenExpiresAt = formatUpdatedAt(event.tokenExpiresAt) const tokenExpiresAt = formatUpdatedAt(event.tokenExpiresAt)
showNotification({ showToast({
variant: 'success', variant: 'success',
title: 'Token abgerufen', title: 'Token abgerufen',
message: tokenExpiresAt message: tokenExpiresAt
@ -269,7 +249,7 @@ export default function Milestone() {
} }
if (event.type === 'recordingServer') { if (event.type === 'recordingServer') {
showNotification({ showToast({
variant: 'success', variant: 'success',
title: 'Recording-Infos abgerufen', title: 'Recording-Infos abgerufen',
message: [ message: [
@ -289,7 +269,7 @@ export default function Milestone() {
} }
if (event.type === 'hardware') { if (event.type === 'hardware') {
showNotification({ showToast({
variant: 'success', variant: 'success',
title: 'Hardware synchronisiert', title: 'Hardware synchronisiert',
message: getHardwareCountMessage(event.hardwareCount ?? 0), message: getHardwareCountMessage(event.hardwareCount ?? 0),
@ -393,14 +373,10 @@ export default function Milestone() {
setSettings(data.settings) setSettings(data.settings)
setSkipTlsVerify(Boolean(data.settings?.skipTlsVerify)) setSkipTlsVerify(Boolean(data.settings?.skipTlsVerify))
} catch (error) { } catch (error) {
showNotification({ showErrorToast({
variant: 'error',
title: 'Einstellungen konnten nicht geladen werden', title: 'Einstellungen konnten nicht geladen werden',
message: error,
error instanceof Error fallback: 'Milestone-Einstellungen konnten nicht geladen werden',
? error.message
: 'Milestone-Einstellungen konnten nicht geladen werden',
autoClose: false,
}) })
} finally { } finally {
setIsLoading(false) setIsLoading(false)
@ -449,14 +425,10 @@ export default function Milestone() {
await runMilestoneSyncStream() await runMilestoneSyncStream()
} catch (error) { } catch (error) {
showNotification({ showErrorToast({
variant: 'error',
title: 'Speichern fehlgeschlagen', title: 'Speichern fehlgeschlagen',
message: error,
error instanceof Error fallback: 'Milestone-Einstellungen konnten nicht gespeichert werden',
? error.message
: 'Milestone-Einstellungen konnten nicht gespeichert werden',
autoClose: false,
}) })
} finally { } finally {
setIsSaving(false) setIsSaving(false)
@ -469,14 +441,10 @@ export default function Milestone() {
try { try {
await runMilestoneSyncStream() await runMilestoneSyncStream()
} catch (error) { } catch (error) {
showNotification({ showErrorToast({
variant: 'error',
title: 'Abruf fehlgeschlagen', title: 'Abruf fehlgeschlagen',
message: error,
error instanceof Error fallback: 'Milestone-Token konnte nicht abgerufen werden',
? error.message
: 'Milestone-Token konnte nicht abgerufen werden',
autoClose: false,
}) })
} finally { } finally {
setIsFetchingToken(false) setIsFetchingToken(false)
@ -500,12 +468,6 @@ export default function Milestone() {
return ( return (
<> <>
<Notifications
notifications={notifications}
onDismiss={dismissNotification}
position="top-right"
/>
<Modal <Modal
open={syncModalOpen} open={syncModalOpen}
setOpen={(open) => { setOpen={(open) => {
@ -548,12 +510,6 @@ export default function Milestone() {
steps={syncSteps} steps={syncSteps}
ariaLabel="Milestone-Synchronisierung" 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>
<div className="flex justify-end border-t border-gray-200 px-6 py-4 dark:border-white/10"> <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 // frontend/src/pages/administration/teams/TeamsAdministration.tsx
import { useEffect, useState, type FormEvent } from 'react' import { useEffect, useMemo, useState, type FormEvent } from 'react'
import { UserGroupIcon } from '@heroicons/react/20/solid' import {
MagnifyingGlassIcon,
PlusIcon,
UserGroupIcon,
} from '@heroicons/react/20/solid'
import Button from '../../../components/Button' import Button from '../../../components/Button'
import LoadingSpinner from '../../../components/LoadingSpinner' import LoadingSpinner from '../../../components/LoadingSpinner'
@ -19,18 +21,44 @@ type AdminTeam = {
const inputClassName = 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' '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) { async function readApiError(response: Response, fallback: string) {
const data = await response.json().catch(() => null) const data = await response.json().catch(() => null)
return data?.error ?? fallback return data?.error ?? fallback
} }
function getTeamSearchText(team: AdminTeam) {
return [
team.name,
team.initial,
team.href,
]
.join(' ')
.toLowerCase()
}
export default function TeamsAdministration() { export default function TeamsAdministration() {
const [teams, setTeams] = useState<AdminTeam[]>([]) const [teams, setTeams] = useState<AdminTeam[]>([])
const [selectedTeam, setSelectedTeam] = useState<AdminTeam | null>(null)
const [teamSearch, setTeamSearch] = useState('')
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState<string | null>(null) 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(() => { useEffect(() => {
void loadTeams() 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>) { async function handleSaveTeam(event: FormEvent<HTMLFormElement>) {
event.preventDefault() event.preventDefault()
const form = event.currentTarget const form = event.currentTarget
const formData = new FormData(form) const formData = new FormData(form)
const teamId = selectedTeam?.id
const isEditing = Boolean(teamId)
const payload = { const payload = {
name: String(formData.get('teamName') ?? ''), name: String(formData.get('teamName') ?? ''),
initial: String(formData.get('teamInitial') ?? ''), initial: String(formData.get('teamInitial') ?? ''),
@ -79,28 +120,41 @@ export default function TeamsAdministration() {
setError(null) setError(null)
try { try {
const response = await fetch(`${API_URL}/admin/teams`, { const response = await fetch(
method: 'POST', isEditing
headers: { ? `${API_URL}/admin/teams/${teamId}`
'Content-Type': 'application/json', : `${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) { if (!response.ok) {
throw new Error( 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() await loadTeams()
form.reset() form.reset()
setSelectedTeam(null)
} catch (error) { } catch (error) {
setError( setError(
error instanceof Error error instanceof Error
? error.message ? error.message
: 'Team konnte nicht erstellt werden', : isEditing
? 'Team konnte nicht aktualisiert werden'
: 'Team konnte nicht erstellt werden',
) )
} finally { } finally {
setIsSaving(false) setIsSaving(false)
@ -128,60 +182,116 @@ export default function TeamsAdministration() {
</div> </div>
)} )}
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1fr)_28rem]"> <div className="grid grid-cols-1 gap-6 xl:grid-cols-[19rem_minmax(0,1fr)]">
<div className="rounded-lg bg-white p-4 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10"> <aside className="overflow-hidden rounded-xl bg-white 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"> <div className="border-b border-gray-200 px-4 py-4 dark:border-white/10">
<UserGroupIcon aria-hidden="true" className="size-5 text-gray-400" /> <div className="flex items-center justify-between gap-x-3">
Teams <div className="min-w-0">
</h2> <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"> <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Übersicht der aktuell vorhandenen Teams. {filteredTeams.length} von {teams.length} angezeigt
</p> </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"> <Button
{teams.length === 0 ? ( type="button"
<div className="p-4 text-sm text-gray-500 dark:text-gray-400"> color="indigo"
Keine Teams vorhanden. 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> </div>
) : ( ) : (
teams.map((team) => ( <div className="space-y-1">
<div {filteredTeams.map((team) => {
key={team.id} const isSelected = selectedTeam?.id === 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="min-w-0"> return (
<p className="truncate text-sm font-medium text-gray-900 dark:text-white"> <button
{team.name} key={team.id}
</p> 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"> <span className="min-w-0 flex-1">
{team.href} <span className="block truncate font-medium">
</p> {team.name}
</div> </span>
</div>
</div> <span className="block truncate text-xs text-gray-500 dark:text-gray-400">
)) {team.href || '#'}
</span>
</span>
</button>
)
})}
</div>
)} )}
</div> </div>
</div> </aside>
<form <form
key={selectedTeam?.id ?? 'new'}
onSubmit={handleSaveTeam} onSubmit={handleSaveTeam}
className="rounded-lg bg-white p-4 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10" 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"> <div className="flex items-start justify-between gap-x-4">
Team anlegen <div>
</h2> <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"> <p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Erstelle ein neues Team, das anschließend Benutzern zugewiesen werden kann. {selectedTeam
</p> ? '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 className="mt-4 space-y-4">
<div> <div>
@ -198,6 +308,7 @@ export default function TeamsAdministration() {
name="teamName" name="teamName"
placeholder="Teamname" placeholder="Teamname"
required required
defaultValue={selectedTeam?.name ?? ''}
className={inputClassName} className={inputClassName}
/> />
</div> </div>
@ -217,6 +328,7 @@ export default function TeamsAdministration() {
name="teamInitial" name="teamInitial"
placeholder="Initial" placeholder="Initial"
maxLength={3} maxLength={3}
defaultValue={selectedTeam?.initial ?? ''}
className={inputClassName} className={inputClassName}
/> />
</div> </div>
@ -235,21 +347,33 @@ export default function TeamsAdministration() {
id="team-href" id="team-href"
name="teamHref" name="teamHref"
placeholder="Link, z. B. #" placeholder="Link, z. B. #"
defaultValue="#" defaultValue={selectedTeam?.href ?? '#'}
className={inputClassName} className={inputClassName}
/> />
</div> </div>
</div> </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 <Button
type="submit" type="submit"
variant="secondary" variant="secondary"
color="indigo" color="indigo"
isLoading={isSaving} isLoading={isSaving}
> >
Team erstellen {selectedTeam ? 'Änderungen speichern' : 'Team erstellen'}
</Button> </Button>
</div> </div>
</form> </form>

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -7,6 +7,7 @@ import Switch from '../../components/Switch'
import type { DeviceModalTabId } from './DeviceModalTabs' import type { DeviceModalTabId } from './DeviceModalTabs'
import Textarea from '../../components/Textarea' import Textarea from '../../components/Textarea'
import type { Device } from '../../components/types' 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' const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
@ -236,6 +237,11 @@ export default function DeviceFormFields({
const milestoneReadonlyClassName = 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' '!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() { async function loadDeviceLookups() {
setLookupError(null) setLookupError(null)
@ -378,25 +384,90 @@ export default function DeviceFormFields({
</div> </div>
</div> </div>
{isMilestoneDevice && device?.milestoneDisplayName && ( {isMilestoneDevice && (
<div className="sm:col-span-6"> <>
<label <input type="hidden" name="isMilestoneDevice" value="true" />
htmlFor="milestoneDisplayName"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Anzeigename
</label>
<div className="mt-2"> <div className="sm:col-span-6">
<input <label
id="milestoneDisplayName" htmlFor="milestoneDisplayName"
name="milestoneDisplayName" className="block text-sm/6 font-medium text-gray-900 dark:text-white"
type="text" >
defaultValue={device.milestoneDisplayName} Anzeigename
className={inputClassName} </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>
<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"> <div className="sm:col-span-3">
@ -463,7 +534,14 @@ export default function DeviceFormFields({
MAC-Adresse MAC-Adresse
</label> </label>
<div className="mt-2"> <div className="relative mt-2">
{isMilestoneDevice && (
<VideoCameraIcon
aria-hidden="true"
className={milestoneInputIconClassName}
/>
)}
<input <input
id="macAddress" id="macAddress"
name="macAddress" name="macAddress"
@ -483,6 +561,7 @@ export default function DeviceFormFields({
onInvalid={isMilestoneDevice ? undefined : handleMacAddressInvalid} onInvalid={isMilestoneDevice ? undefined : handleMacAddressInvalid}
className={classNames( className={classNames(
inputClassName, inputClassName,
isMilestoneDevice && milestoneInputWithIconClassName,
isMilestoneDevice && milestoneReadonlyClassName, isMilestoneDevice && milestoneReadonlyClassName,
)} )}
/> />
@ -497,18 +576,59 @@ export default function DeviceFormFields({
IP-Adresse IP-Adresse
</label> </label>
<div className="mt-2"> <div className="relative mt-2">
{isMilestoneDevice && (
<VideoCameraIcon
aria-hidden="true"
className={milestoneInputIconClassName}
/>
)}
<input <input
id="ipAddress" id="ipAddress"
name="ipAddress" name="ipAddress"
type="text" type="text"
placeholder="192.168.1.100" placeholder="192.168.1.100"
defaultValue={device?.ipAddress ?? ''} defaultValue={device?.ipAddress ?? ''}
className={inputClassName} className={classNames(
inputClassName,
isMilestoneDevice && milestoneInputWithIconClassName,
)}
/> />
</div> </div>
</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"> <div className="sm:col-span-3">
<label <label
htmlFor="firmwareVersion" htmlFor="firmwareVersion"
@ -517,7 +637,14 @@ export default function DeviceFormFields({
Firmware Firmware
</label> </label>
<div className="mt-2"> <div className="relative mt-2">
{isMilestoneDevice && (
<VideoCameraIcon
aria-hidden="true"
className={milestoneInputIconClassName}
/>
)}
<input <input
id="firmwareVersion" id="firmwareVersion"
name="firmwareVersion" name="firmwareVersion"
@ -528,6 +655,7 @@ export default function DeviceFormFields({
title={isMilestoneDevice ? 'Firmware wird aus Milestone synchronisiert.' : undefined} title={isMilestoneDevice ? 'Firmware wird aus Milestone synchronisiert.' : undefined}
className={classNames( className={classNames(
inputClassName, inputClassName,
isMilestoneDevice && milestoneInputWithIconClassName,
isMilestoneDevice && milestoneReadonlyClassName, isMilestoneDevice && milestoneReadonlyClassName,
)} )}
/> />
@ -583,7 +711,7 @@ export default function DeviceFormFields({
label="Kommentar" label="Kommentar"
variant="simple" variant="simple"
rows={3} rows={3}
defaultValue={device?.comment ?? ''} defaultValue={device?.comment ?? '-'}
/> />
</div> </div>
</div> </div>

View File

@ -1,10 +1,15 @@
// frontend\src\pages\devices\DeviceModalTabs.tsx // frontend\src\pages\devices\DeviceModalTabs.tsx
import { type ComponentType, type SVGProps } from 'react'
export type DeviceModalTabId = 'masterData' | 'location' | 'accessories' | 'history' export type DeviceModalTabId = 'masterData' | 'location' | 'accessories' | 'history'
type DeviceModalTabIcon = ComponentType<SVGProps<SVGSVGElement>>
type DeviceModalTab = { type DeviceModalTab = {
id: DeviceModalTabId id: DeviceModalTabId
name: string name: string
icon?: DeviceModalTabIcon
disabled?: boolean disabled?: boolean
} }
@ -25,9 +30,13 @@ export default function DeviceModalTabs({
}: DeviceModalTabsProps) { }: DeviceModalTabsProps) {
return ( return (
<div className="border-b border-gray-200 bg-white px-4 sm:px-6 dark:border-white/10 dark:bg-gray-900"> <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) => { {tabs.map((tab) => {
const isActive = activeTab === tab.id const isActive = activeTab === tab.id
const Icon = tab.icon
return ( return (
<button <button
@ -36,14 +45,21 @@ export default function DeviceModalTabs({
disabled={tab.disabled} disabled={tab.disabled}
onClick={() => onChange(tab.id)} onClick={() => onChange(tab.id)}
className={classNames( 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 isActive
? 'border-indigo-600 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400' ? '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', : '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.disabled && 'cursor-not-allowed opacity-50',
)} )}
> >
{tab.name} {Icon && (
<Icon
aria-hidden="true"
className="size-4 shrink-0"
/>
)}
<span>{tab.name}</span>
</button> </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 { useEffect, useRef, useState, type ComponentProps, type FormEvent, type MouseEvent } from 'react'
import { import {
@ -7,6 +7,7 @@ import {
CheckCircleIcon, CheckCircleIcon,
ClockIcon, ClockIcon,
ExclamationTriangleIcon, ExclamationTriangleIcon,
PlusIcon,
WrenchScrewdriverIcon, WrenchScrewdriverIcon,
XCircleIcon, XCircleIcon,
} from '@heroicons/react/20/solid' } from '@heroicons/react/20/solid'
@ -17,7 +18,7 @@ import EditDeviceModal from './EditDeviceModal'
import Tables, { type TableColumn } from '../../components/Tables' import Tables, { type TableColumn } from '../../components/Tables'
import LoadingSpinner from '../../components/LoadingSpinner' import LoadingSpinner from '../../components/LoadingSpinner'
import type { Device, DeviceHistoryEntry } from '../../components/types' 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' 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' 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', { return new Intl.DateTimeFormat('de-DE', {
dateStyle: 'short', dateStyle: 'short',
timeStyle: '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[]) { function getLatestFirmwareCheckedAt(devices: Device[]) {
@ -124,16 +164,15 @@ function getSupportBadgeTone(severity?: string): BadgeTone {
} }
export default function DevicesPage() { export default function DevicesPage() {
const { showToast, showErrorToast } = useNotifications()
const [devices, setDevices] = useState<Device[]>([]) const [devices, setDevices] = useState<Device[]>([])
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [activeModal, setActiveModal] = useState<'create' | 'edit' | null>(null) const [activeModal, setActiveModal] = useState<'create' | 'edit' | null>(null)
const [editingDevice, setEditingDevice] = useState<Device | null>(null) const [editingDevice, setEditingDevice] = useState<Device | null>(null)
const [isCreating, setIsCreating] = useState(false) const [isCreating, setIsCreating] = useState(false)
const [error, setError] = useState<string | null>(null)
const [createError, setCreateError] = useState<string | null>(null) const [createError, setCreateError] = useState<string | null>(null)
const [relatedDeviceIds, setRelatedDeviceIds] = useState<string[]>([]) const [relatedDeviceIds, setRelatedDeviceIds] = useState<string[]>([])
const [isJournalSubmitting, setIsJournalSubmitting] = useState(false) const [isJournalSubmitting, setIsJournalSubmitting] = useState(false)
const [notifications, setNotifications] = useState<ToastNotification[]>([])
const [togglingMilestoneDeviceIds, setTogglingMilestoneDeviceIds] = useState<Set<string>>( const [togglingMilestoneDeviceIds, setTogglingMilestoneDeviceIds] = useState<Set<string>>(
() => new Set(), () => new Set(),
) )
@ -215,7 +254,6 @@ export default function DevicesPage() {
async function handleCheckFirmware() { async function handleCheckFirmware() {
setIsCheckingFirmware(true) setIsCheckingFirmware(true)
setError(null)
try { try {
const response = await fetch(`${API_URL}/devices/firmware-check`, { const response = await fetch(`${API_URL}/devices/firmware-check`, {
@ -250,7 +288,7 @@ export default function DevicesPage() {
await loadDevices({ silent: true }) await loadDevices({ silent: true })
await loadFirmwareCheckState() await loadFirmwareCheckState()
showNotification({ showToast({
variant: data.failedModels > 0 ? 'warning' : 'success', variant: data.failedModels > 0 ? 'warning' : 'success',
title: 'Firmware geprüft', title: 'Firmware geprüft',
message: message:
@ -259,14 +297,10 @@ export default function DevicesPage() {
: `${data.checkedModels} Modelle wurden geprüft.`, : `${data.checkedModels} Modelle wurden geprüft.`,
}) })
} catch (error) { } catch (error) {
showNotification({ showErrorToast({
variant: 'error',
title: 'Firmware-Prüfung fehlgeschlagen', title: 'Firmware-Prüfung fehlgeschlagen',
message: error,
error instanceof Error fallback: 'Firmware konnte nicht geprüft werden',
? error.message
: 'Firmware konnte nicht geprüft werden',
autoClose: false,
}) })
} finally { } finally {
setIsCheckingFirmware(false) 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 }) { async function loadDevices(options?: { silent?: boolean }) {
if (!options?.silent) { if (!options?.silent) {
setIsLoading(true) setIsLoading(true)
} }
setError(null)
try { try {
const response = await fetch(`${API_URL}/devices`, { const response = await fetch(`${API_URL}/devices`, {
credentials: 'include', credentials: 'include',
@ -328,7 +401,11 @@ export default function DevicesPage() {
const data = await response.json() const data = await response.json()
setDevices(data.devices ?? []) setDevices(data.devices ?? [])
} catch (error) { } 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 { } finally {
if (!options?.silent) { if (!options?.silent) {
setIsLoading(false) 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) { async function readApiError(response: Response, fallback: string) {
const data = await response.json().catch(() => null) const data = await response.json().catch(() => null)
@ -465,9 +526,15 @@ export default function DevicesPage() {
}) })
try { try {
const response = await fetch(`${API_URL}/devices/${device.id}/milestone-toggle`, { const response = await fetch(`${API_URL}/devices/${device.id}/milestone`, {
method: 'POST', method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include', credentials: 'include',
body: JSON.stringify({
enabled: !device.milestoneEnabled,
}),
}) })
if (!response.ok) { if (!response.ok) {
@ -502,7 +569,7 @@ export default function DevicesPage() {
: currentDevice, : currentDevice,
) )
showNotification({ showToast({
variant: 'success', variant: 'success',
title: 'Milestone-Status geändert', title: 'Milestone-Status geändert',
message: `Gerät ${device.inventoryNumber} wurde in Milestone ${ message: `Gerät ${device.inventoryNumber} wurde in Milestone ${
@ -510,7 +577,7 @@ export default function DevicesPage() {
}.`, }.`,
}) })
} catch (error) { } catch (error) {
showNotification({ showToast({
variant: 'error', variant: 'error',
title: 'Status konnte nicht geändert werden', title: 'Status konnte nicht geändert werden',
message: message:
@ -538,6 +605,8 @@ export default function DevicesPage() {
setIsCreating(true) setIsCreating(true)
setCreateError(null) setCreateError(null)
const isMilestoneDevice = formData.get('isMilestoneDevice') === 'true'
const payload = { const payload = {
inventoryNumber: String(formData.get('inventoryNumber') ?? ''), inventoryNumber: String(formData.get('inventoryNumber') ?? ''),
manufacturer: String(formData.get('manufacturer') ?? ''), manufacturer: String(formData.get('manufacturer') ?? ''),
@ -545,8 +614,12 @@ export default function DevicesPage() {
serialNumber: String(formData.get('serialNumber') ?? ''), serialNumber: String(formData.get('serialNumber') ?? ''),
macAddress: String(formData.get('macAddress') ?? ''), macAddress: String(formData.get('macAddress') ?? ''),
ipAddress: String(formData.get('ipAddress') ?? ''), ipAddress: String(formData.get('ipAddress') ?? ''),
milestonePort: String(formData.get('milestonePort') ?? ''),
firmwareVersion: String(formData.get('firmwareVersion') ?? ''), firmwareVersion: String(formData.get('firmwareVersion') ?? ''),
milestoneDisplayName: String(formData.get('milestoneDisplayName') ?? ''), milestoneDisplayName: String(formData.get('milestoneDisplayName') ?? ''),
...(isMilestoneDevice
? { milestoneEnabled: formData.get('milestoneEnabled') === 'on' }
: {}),
location: String(formData.get('location') ?? ''), location: String(formData.get('location') ?? ''),
loanStatus: String(formData.get('loanStatus') ?? 'Verfügbar'), loanStatus: String(formData.get('loanStatus') ?? 'Verfügbar'),
comment: String(formData.get('comment') ?? ''), comment: String(formData.get('comment') ?? ''),
@ -555,6 +628,41 @@ export default function DevicesPage() {
} }
try { 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( const response = await fetch(
deviceToEdit deviceToEdit
? `${API_URL}/devices/${deviceToEdit.id}` ? `${API_URL}/devices/${deviceToEdit.id}`
@ -606,6 +714,15 @@ export default function DevicesPage() {
new Date(firmwareCheckNextAllowedAt as string).getTime() > firmwareCheckNow new Date(firmwareCheckNextAllowedAt as string).getTime() > firmwareCheckNow
const firmwareCheckButtonDisabled = isCheckingFirmware || isFirmwareCheckLocked 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>[] = [ const columns: TableColumn<Device>[] = [
{ {
@ -755,19 +872,36 @@ export default function DevicesPage() {
const isToggling = togglingMilestoneDeviceIds.has(device.id) const isToggling = togglingMilestoneDeviceIds.has(device.id)
const label = device.milestoneEnabled ? 'Aktiviert' : 'Deaktiviert' const label = device.milestoneEnabled ? 'Aktiviert' : 'Deaktiviert'
const milestoneIcon = isToggling ? (
<ClockIcon />
) : device.milestoneEnabled ? (
<CheckCircleIcon />
) : (
<XCircleIcon />
)
return ( return (
<button <button
type="button" type="button"
disabled={isToggling} disabled={isToggling}
onClick={(event) => handleToggleMilestoneDeviceStatus(device, event)} onClick={(event) => handleToggleMilestoneDeviceStatus(device, event)}
className="inline-flex cursor-pointer rounded-md transition disabled:cursor-wait disabled:opacity-60" className="inline-flex min-w-28 cursor-pointer justify-center rounded-md transition disabled:cursor-wait disabled:opacity-60"
title={`Milestone-Gerät ${ title={
device.milestoneEnabled ? 'deaktivieren' : 'aktivieren' isToggling
}`} ? 'Milestone-Status wird geändert...'
: `Milestone-Gerät ${
device.milestoneEnabled ? 'deaktivieren' : 'aktivieren'
}`
}
> >
<Badge tone={getLoanStatusBadgeTone(label)} variant="border"> <Badge
{isToggling ? 'Wird geändert...' : label} tone={getLoanStatusBadgeTone(label)}
variant="border"
hover={!isToggling}
leadingIcon={milestoneIcon}
className="w-full justify-center whitespace-nowrap"
>
{label}
</Badge> </Badge>
</button> </button>
) )
@ -794,44 +928,43 @@ export default function DevicesPage() {
return ( return (
<div className="h-full min-h-0 overflow-y-auto px-4 py-10 sm:px-6 lg:px-8 [scrollbar-gutter:stable]"> <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 <Tables
title="Geräte" 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." description="Eine Liste aller Geräte inklusive Inventur-Nr., Hersteller, Modell, Seriennummer, MAC-Adresse, IP-Adresse, Firmware, Standort, Verleih-Status und Zubehör."
headerAction={ headerAction={
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-col items-start gap-1.5">
<Button <div className="flex flex-wrap items-center justify-end gap-2">
type="button" <Button
variant="secondary" type="button"
color="gray" variant="secondary"
size="sm" color="gray"
disabled={firmwareCheckButtonDisabled} size="md"
isLoading={isCheckingFirmware} disabled={firmwareCheckButtonDisabled}
leadingIcon={<ArrowPathIcon aria-hidden="true" className="size-4" />} isLoading={isCheckingFirmware}
onClick={() => void handleCheckFirmware()} leadingIcon={<ArrowPathIcon aria-hidden="true" className="size-4" />}
> onClick={() => void handleCheckFirmware()}
Firmware prüfen title={firmwareCheckNextAllowedLabel ?? undefined}
</Button> >
Firmware prüfen
</Button>
<Button <Button
type="button" type="button"
color="indigo" color="indigo"
size="md" size="md"
onClick={openCreateModal} leadingIcon={<PlusIcon aria-hidden="true" className="size-4" />}
> onClick={openCreateModal}
Gerät hinzufügen >
</Button> 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> </div>
} }
onAction={openCreateModal} onAction={openCreateModal}
@ -895,6 +1028,7 @@ export default function DevicesPage() {
historyError={historyError} historyError={historyError}
isJournalSubmitting={isJournalSubmitting} isJournalSubmitting={isJournalSubmitting}
onJournalEntrySubmit={handleCreateDeviceJournalEntry} onJournalEntrySubmit={handleCreateDeviceJournalEntry}
onJournalEntryUpdate={handleUpdateDeviceJournalEntry}
/> />
</div> </div>

View File

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

View File

@ -1,19 +1,44 @@
// frontend\src\pages\operations\CreateOperationModal.tsx // 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 { DialogTitle } from '@headlessui/react'
import { XMarkIcon } from '@heroicons/react/24/outline' import { XMarkIcon } from '@heroicons/react/24/outline'
import { import {
CheckCircleIcon,
CheckIcon, CheckIcon,
ChevronLeftIcon, ChevronLeftIcon,
ChevronRightIcon, ChevronRightIcon,
ClipboardDocumentListIcon,
DocumentTextIcon,
MapPinIcon,
UserGroupIcon,
WrenchScrewdriverIcon,
} from '@heroicons/react/20/solid' } from '@heroicons/react/20/solid'
import {
CircleMarker,
MapContainer,
TileLayer,
} from 'react-leaflet'
import Modal from '../../components/Modal' import Modal from '../../components/Modal'
import Button from '../../components/Button' import Button from '../../components/Button'
import AddressCombobox from '../../components/AddressCombobox' import AddressCombobox from '../../components/AddressCombobox'
import Textarea from '../../components/Textarea' 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' import { formatOperationNumberInput, isCompleteOperationNumber } from '../../components/formatter'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
const OPERATION_UNIT = 'TEG'
type CreateOperationModalProps = { type CreateOperationModalProps = {
open: boolean open: boolean
setOpen: (open: boolean) => void setOpen: (open: boolean) => void
@ -41,6 +66,41 @@ type OperationFormValues = {
remark: string 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 = type SetupStepId =
| 'masterData' | 'masterData'
| 'addresses' | 'addresses'
@ -49,6 +109,8 @@ type SetupStepId =
| 'equipment' | 'equipment'
| 'review' | 'review'
type SetupStepIcon = ComponentType<SVGProps<SVGSVGElement>>
const inputClassName = 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' '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 id: SetupStepId
title: string title: string
description: string description: string
icon: SetupStepIcon
optional?: boolean optional?: boolean
}> = [ }> = [
{ {
id: 'masterData', id: 'masterData',
title: 'Stammdaten', title: 'Stammdaten',
description: 'Einsatznummer, Name und Delikt.', description: 'Einsatznummer, Name und Delikt.',
}, icon: ClipboardDocumentListIcon,
{ },
id: 'addresses', {
title: 'Adressen', id: 'addresses',
description: 'KW-Adresse und Zielobjekt mit Kartenvorschau.', title: 'Adressen',
optional: true, description: 'KW-Adresse und Zielobjekt mit Kartenvorschau.',
}, icon: MapPinIcon,
{ optional: true,
id: 'responsibilities', },
title: 'Zuständigkeiten', {
description: 'Sachbearbeitung, Einsatzleitung und Einsatzteam.', id: 'responsibilities',
optional: true, title: 'Zuständigkeiten',
}, description: 'Sachbearbeitung, Einsatzleitung und Einsatzteam.',
{ icon: UserGroupIcon,
id: 'notes', optional: true,
title: 'Zusatzinfos', },
description: 'Legende und Bemerkung.', {
optional: true, id: 'notes',
}, title: 'Zusatzinfos',
{ description: 'Legende und Bemerkung.',
id: 'equipment', icon: DocumentTextIcon,
title: 'Technik', optional: true,
description: 'Kamera, Router, Switchbox und Laptop.', },
optional: true, {
}, id: 'equipment',
{ title: 'Technik',
id: 'review', description: 'Kamera, Router, Switchbox und Laptop.',
title: 'Prüfen', icon: WrenchScrewdriverIcon,
description: 'Daten prüfen und Einsatz speichern.', optional: true,
}, },
{
id: 'review',
title: 'Prüfen',
description: 'Daten prüfen und Einsatz speichern.',
icon: CheckCircleIcon,
},
] ]
function classNames(...classes: Array<string | false | null | undefined>) { 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({ function StepHeader({
title, title,
description, description,
@ -174,6 +615,12 @@ export default function CreateOperationModal({
const [formValues, setFormValues] = useState<OperationFormValues>(initialFormValues) const [formValues, setFormValues] = useState<OperationFormValues>(initialFormValues)
const [localError, setLocalError] = useState<string | null>(null) 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 activeStepIndex = getStepIndex(activeStep)
const isFirstStep = activeStepIndex === 0 const isFirstStep = activeStepIndex === 0
const isLastStep = activeStepIndex === setupSteps.length - 1 const isLastStep = activeStepIndex === setupSteps.length - 1
@ -185,9 +632,80 @@ export default function CreateOperationModal({
setActiveStep('masterData') setActiveStep('masterData')
setFormValues(initialFormValues) setFormValues(initialFormValues)
setSelectedOperationLeader(null)
setSelectedOperationTeam([])
setLocalError(null) setLocalError(null)
}, [open]) }, [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) { function handleOpenChange(nextOpen: boolean) {
if (!nextOpen && isSaving) { if (!nextOpen && isSaving) {
return return
@ -337,6 +855,7 @@ export default function CreateOperationModal({
{setupSteps.map((step, index) => { {setupSteps.map((step, index) => {
const isActive = step.id === activeStep const isActive = step.id === activeStep
const isDone = index < activeStepIndex const isDone = index < activeStepIndex
const Icon = step.icon
return ( return (
<button <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', : '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"> <span className="flex items-center gap-x-1.5 text-[11px] font-medium">
Schritt {index + 1} <Icon aria-hidden="true" className="size-3.5 shrink-0" />
<span>Schritt {index + 1}</span>
</span> </span>
<span className="mt-0.5 block truncate text-sm font-semibold"> <span className="mt-0.5 block truncate text-sm font-semibold">
{step.title} {step.title}
</span> </span>
@ -438,7 +959,7 @@ export default function CreateOperationModal({
description="KW und Zielobjekt werden mit AddressCombobox und Karten-Vorschau erfasst." 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 <AddressCombobox
id="createKwAddress" id="createKwAddress"
name="createKwAddressDisplay" name="createKwAddressDisplay"
@ -461,59 +982,96 @@ export default function CreateOperationModal({
)} )}
{activeStep === 'responsibilities' && ( {activeStep === 'responsibilities' && (
<div className="space-y-6"> <div className="space-y-6">
<StepHeader <StepHeader
title="Zuständigkeiten" title="Zuständigkeiten"
description="Sachbearbeitung, Einsatzleitung und eingesetztes Team." 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>
<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' && ( {activeStep === 'notes' && (
@ -615,29 +1173,100 @@ export default function CreateOperationModal({
)} )}
{activeStep === 'review' && ( {activeStep === 'review' && (
<div className="space-y-6"> <div className="space-y-6">
<StepHeader <StepHeader
title="Einsatz prüfen" title="Einsatz prüfen"
description="Kontrolliere die Angaben. Danach kannst du den Einsatz speichern." 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"> <dl className="space-y-6 rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/5">
<ReviewItem label="Einsatznummer" value={formValues.operationNumber} /> <div>
<ReviewItem label="Einsatzname" value={formValues.operationName} /> <div className="border-b border-gray-200 pb-2 dark:border-white/10">
<ReviewItem label="Delikt" value={formValues.offense} /> <h4 className="text-xs font-semibold tracking-wide text-gray-500 uppercase dark:text-gray-400">
<ReviewItem label="Sachbearbeitung" value={formValues.caseWorker} /> Stammdaten
<ReviewItem label="KW" value={formValues.kwAddress} /> </h4>
<ReviewItem label="Zielobjekt" value={formValues.targetObject} /> </div>
<ReviewItem label="Einsatzleiter" value={formValues.operationLeader} />
<ReviewItem label="Einsatzteam" value={formValues.operationTeam} /> <div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-3">
<ReviewItem label="Kamera" value={formValues.camera} /> <ReviewItem label="Einsatznummer" value={formValues.operationNumber} />
<ReviewItem label="Router" value={formValues.router} /> <ReviewItem label="Einsatzname" value={formValues.operationName} />
<ReviewItem label="Switchbox" value={formValues.switchbox} /> <ReviewItem label="Delikt" value={formValues.offense} />
<ReviewItem label="Laptop" value={formValues.laptop} /> </div>
<ReviewItem label="Legende" value={formValues.legend} /> </div>
<ReviewItem label="Bemerkung" value={formValues.remark} />
<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> </dl>
</div> </div>
)} )}
</div> </div>

View File

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

View File

@ -1,9 +1,26 @@
// frontend/src/pages/OperationModal.tsx // 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 { DialogTitle } from '@headlessui/react'
import { XMarkIcon } from '@heroicons/react/24/outline' 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 Modal from '../../components/Modal'
import Journal, { type JournalItem } from '../../components/Journal' import Journal, { type JournalItem } from '../../components/Journal'
import Button from '../../components/Button' import Button from '../../components/Button'
@ -12,6 +29,8 @@ import AddressCombobox from '../../components/AddressCombobox'
import type { Operation, OperationHistoryChange, OperationHistoryEntry } from '../../components/types' import type { Operation, OperationHistoryChange, OperationHistoryEntry } from '../../components/types'
import { formatOperationNumberInput } from '../../components/formatter' import { formatOperationNumberInput } from '../../components/formatter'
import NotificationDot from '../../components/NotificationDot' import NotificationDot from '../../components/NotificationDot'
import Combobox, { type ComboboxItem } from '../../components/Combobox'
import MultiCombobox from '../../components/MultiCombobox'
type OperationModalProps = { type OperationModalProps = {
open: boolean open: boolean
@ -38,46 +57,136 @@ type OperationTabId =
| 'equipment' | 'equipment'
| 'journal' | 'journal'
type OperationTabIcon = ComponentType<SVGProps<SVGSVGElement>>
const inputClassName = 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' '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 = const sectionClassName =
'rounded-lg border border-gray-200 bg-white p-4 shadow-xs dark:border-white/10 dark:bg-white/5' '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<{ const operationTabs: Array<{
id: OperationTabId id: OperationTabId
title: string title: string
description: string description: string
icon: OperationTabIcon
}> = [ }> = [
{ {
id: 'masterData', id: 'masterData',
title: 'Stammdaten', title: 'Stammdaten',
description: 'Einsatznummer, Einsatzname und Delikt.', description: 'Einsatznummer, Einsatzname und Delikt.',
icon: ClipboardDocumentListIcon,
}, },
{ {
id: 'addresses', id: 'addresses',
title: 'Adressen', title: 'Adressen',
description: 'Zielobjekt und KW-Adresse.', description: 'Zielobjekt und KW-Adresse.',
icon: MapPinIcon,
}, },
{ {
id: 'responsibilities', id: 'responsibilities',
title: 'Zuständigkeiten', title: 'Zuständigkeiten',
description: 'Sachbearbeitung, Einsatzleitung und Team.', description: 'Sachbearbeitung, Einsatzleitung und Team.',
icon: UserGroupIcon,
}, },
{ {
id: 'notes', id: 'notes',
title: 'Zusatzinfos', title: 'Zusatzinfos',
description: 'Legende und Bemerkung.', description: 'Legende und Bemerkung.',
icon: DocumentTextIcon,
}, },
{ {
id: 'equipment', id: 'equipment',
title: 'Technik', title: 'Technik',
description: 'Kamera, Router, Switchbox und Laptop.', description: 'Kamera, Router, Switchbox und Laptop.',
icon: WrenchScrewdriverIcon,
}, },
{ {
id: 'journal', id: 'journal',
title: 'Journal', title: 'Journal',
description: 'Bearbeitungsverlauf des Einsatzes.', description: 'Bearbeitungsverlauf des Einsatzes.',
icon: ChatBubbleLeftEllipsisIcon,
}, },
] ]
@ -155,6 +264,12 @@ export default function OperationModal({
const [targetObject, setTargetObject] = useState('') const [targetObject, setTargetObject] = useState('')
const [kwAddress, setKwAddress] = 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( const visibleTabs = useMemo(
() => operationTabs.filter((tab) => isEditing || tab.id !== 'journal'), () => operationTabs.filter((tab) => isEditing || tab.id !== 'journal'),
[isEditing], [isEditing],
@ -175,6 +290,46 @@ export default function OperationModal({
editingOperation?.kwAddress, 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(() => { useEffect(() => {
if (!open || activeTab !== 'journal' || !hasUnreadJournal) { if (!open || activeTab !== 'journal' || !hasUnreadJournal) {
return 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) { function handleOpenChange(nextOpen: boolean) {
if (!nextOpen && isSaving) { if (!nextOpen && isSaving) {
return return
@ -286,6 +488,18 @@ export default function OperationModal({
value={editingOperation?.hardDrive ?? ''} 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 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> <div>
<DialogTitle className="text-base font-semibold text-gray-900 dark:text-white"> <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="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"> <div className="-mb-px flex gap-x-6 overflow-x-auto">
{visibleTabs.map((tab) => ( {visibleTabs.map((tab) => {
<button const Icon = tab.icon
key={tab.id}
type="button"
onClick={() => {
setActiveTab(tab.id)
if (tab.id === 'journal') { return (
onJournalTabOpen?.() <button
} key={tab.id}
}} type="button"
className={classNames( onClick={() => {
'whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium', setActiveTab(tab.id)
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}
{tab.id === 'journal' && hasUnreadJournal && ( if (tab.id === 'journal') {
<NotificationDot onJournalTabOpen?.()
title="Neue Journal-Einträge" }
label="Neue Journal-Einträge" }}
/> 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>
</div> </div>
@ -423,7 +643,7 @@ export default function OperationModal({
description="Zielobjekt und KW-Adresse mit Adresssuche und Karten-Vorschau." 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 <AddressCombobox
id="targetObject" id="targetObject"
name="targetObject" name="targetObject"
@ -431,6 +651,7 @@ export default function OperationModal({
value={targetObject} value={targetObject}
onChange={setTargetObject} onChange={setTargetObject}
placeholder="Adresse suchen oder Zielobjekt eingeben" placeholder="Adresse suchen oder Zielobjekt eingeben"
mapVisible={activeTab === 'addresses'}
/> />
<AddressCombobox <AddressCombobox
@ -440,6 +661,7 @@ export default function OperationModal({
value={kwAddress} value={kwAddress}
onChange={setKwAddress} onChange={setKwAddress}
placeholder="KW-Adresse suchen oder eingeben" placeholder="KW-Adresse suchen oder eingeben"
mapVisible={activeTab === 'addresses'}
/> />
</div> </div>
</section> </section>
@ -449,53 +671,90 @@ export default function OperationModal({
<section className={sectionClassName}> <section className={sectionClassName}>
<SectionHeader <SectionHeader
title="Zuständigkeiten" 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="mt-4 space-y-4">
<div className="sm:col-span-3"> <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<label htmlFor="caseWorker" className="block text-sm/6 font-medium text-gray-900 dark:text-white"> <div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/[0.03]">
Sachbearbeitung <div className="mb-3">
</label> <h4 className="text-sm font-semibold text-gray-900 dark:text-white">
<div className="mt-2"> Sachbearbeitung
</h4>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Interne sachbearbeitende Person oder Dienststelle.
</p>
</div>
<input <input
id="caseWorker" id="caseWorker"
name="caseWorker" name="caseWorker"
type="text" type="text"
defaultValue={editingOperation?.caseWorker ?? ''} defaultValue={editingOperation?.caseWorker ?? ''}
placeholder="Sachbearbeitung eintragen"
className={inputClassName} className={inputClassName}
/> />
</div> </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>
<div className="sm:col-span-3"> <div className="rounded-lg border border-indigo-200 bg-indigo-50/60 p-4 dark:border-indigo-500/20 dark:bg-indigo-500/10">
<label htmlFor="operationLeader" className="block text-sm/6 font-medium text-gray-900 dark:text-white"> <div className="mb-3 flex items-start justify-between gap-x-4">
Einsatzleiter <div>
</label> <h4 className="text-sm font-semibold text-indigo-900 dark:text-indigo-200">
<div className="mt-2"> Einsatzteam
<input </h4>
id="operationLeader"
name="operationLeader"
type="text"
defaultValue={editingOperation?.operationLeader ?? ''}
className={inputClassName}
/>
</div>
</div>
<div className="sm:col-span-3"> <p className="mt-1 text-xs text-indigo-700/80 dark:text-indigo-300/80">
<label htmlFor="operationTeam" className="block text-sm/6 font-medium text-gray-900 dark:text-white"> Wähle einen oder mehrere Einsatzbeamte der Einheit {OPERATION_UNIT}.
Einsatzteam </p>
</label> </div>
<div className="mt-2">
<input {selectedOperationTeam.length > 0 && (
id="operationTeam" <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">
name="operationTeam" {selectedOperationTeam.length} ausgewählt
type="text" </span>
defaultValue={editingOperation?.operationTeam ?? ''} )}
className={inputClassName}
/>
</div> </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>
</div> </div>
</section> </section>

File diff suppressed because it is too large Load Diff

View File

@ -85,24 +85,12 @@ export default function SettingsPage({
icon: BellIcon, icon: BellIcon,
current: pathname === '/einstellungen/benachrichtigungen', current: pathname === '/einstellungen/benachrichtigungen',
}, },
{
name: 'Abrechnung',
href: '/einstellungen/abrechnung',
icon: CreditCardIcon,
current: pathname === '/einstellungen/abrechnung',
},
{ {
name: 'Teams', name: 'Teams',
href: '/einstellungen/teams', href: '/einstellungen/teams',
icon: UsersIcon, icon: UsersIcon,
current: pathname === '/einstellungen/teams', current: pathname === '/einstellungen/teams',
}, },
{
name: 'Integrationen',
href: '/einstellungen/integrationen',
icon: PuzzlePieceIcon,
current: pathname === '/einstellungen/integrationen',
},
] ]
return ( return (
@ -130,17 +118,9 @@ export default function SettingsPage({
<Benachrichtigungen /> <Benachrichtigungen />
)} )}
{section === 'abrechnung' && (
<PlaceholderSection title="Abrechnung" />
)}
{section === 'teams' && ( {section === 'teams' && (
<PlaceholderSection title="Teams" /> <PlaceholderSection title="Teams" />
)} )}
{section === 'integrationen' && (
<PlaceholderSection title="Integrationen" />
)}
</div> </div>
</main> </main>
) )

View File

@ -9,20 +9,23 @@ import {
import { import {
BellIcon, BellIcon,
EnvelopeIcon, EnvelopeIcon,
PlayIcon,
SpeakerWaveIcon, SpeakerWaveIcon,
} from '@heroicons/react/20/solid' } from '@heroicons/react/20/solid'
import Container from '../../../components/Container' import Container from '../../../components/Container'
import LoadingSpinner from '../../../components/LoadingSpinner' import LoadingSpinner from '../../../components/LoadingSpinner'
import Switch from '../../../components/Switch' import Switch from '../../../components/Switch'
import Notifications, { import Button from '../../../components/Button'
type ToastNotification, import { useNotifications } from '../../../components/Notifications'
} from '../../../components/Notifications'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080' const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
type NotificationSoundName = 'default' | 'chime' | 'soft' | 'alert' | 'success'
type NotificationSettings = { type NotificationSettings = {
inAppEnabled: boolean inAppEnabled: boolean
soundEnabled: boolean soundEnabled: boolean
soundName: NotificationSoundName
emailEnabled: boolean emailEnabled: boolean
operationUpdates: boolean operationUpdates: boolean
operationJournals: boolean operationJournals: boolean
@ -34,6 +37,7 @@ type NotificationSettings = {
const defaultSettings: NotificationSettings = { const defaultSettings: NotificationSettings = {
inAppEnabled: true, inAppEnabled: true,
soundEnabled: false, soundEnabled: false,
soundName: 'default',
emailEnabled: false, emailEnabled: false,
operationUpdates: true, operationUpdates: true,
operationJournals: true, operationJournals: true,
@ -42,6 +46,17 @@ const defaultSettings: NotificationSettings = {
systemMessages: true, 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({ function Section({
title, title,
description, 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) { async function readApiError(response: Response, fallback: string) {
const data = await response.json().catch(() => null) const data = await response.json().catch(() => null)
return data?.error ?? fallback 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({ function NotificationCard({
icon, icon,
title, title,
@ -127,12 +139,12 @@ function NotificationCard({
} }
export default function Benachrichtigungen() { export default function Benachrichtigungen() {
const { showToast, showErrorToast } = useNotifications()
const [settings, setSettings] = const [settings, setSettings] =
useState<NotificationSettings>(defaultSettings) useState<NotificationSettings>(defaultSettings)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [toasts, setToasts] = useState<ToastNotification[]>([])
const settingsRef = useRef<NotificationSettings>(defaultSettings) const settingsRef = useRef<NotificationSettings>(defaultSettings)
const saveRequestIdRef = useRef(0) const saveRequestIdRef = useRef(0)
@ -141,25 +153,8 @@ export default function Benachrichtigungen() {
void loadSettings() 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() { async function loadSettings() {
setIsLoading(true) setIsLoading(true)
setError(null)
try { try {
const response = await fetch(`${API_URL}/settings/notifications/preferences`, { const response = await fetch(`${API_URL}/settings/notifications/preferences`, {
@ -184,11 +179,11 @@ export default function Benachrichtigungen() {
settingsRef.current = nextSettings settingsRef.current = nextSettings
setSettings(nextSettings) setSettings(nextSettings)
} catch (error) { } catch (error) {
setError( showErrorToast({
error instanceof Error title: 'Benachrichtigungseinstellungen konnten nicht geladen werden',
? error.message error,
: 'Benachrichtigungseinstellungen konnten nicht geladen werden', fallback: 'Benachrichtigungseinstellungen konnten nicht geladen werden',
) })
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }
@ -214,7 +209,6 @@ export default function Benachrichtigungen() {
saveRequestIdRef.current = requestId saveRequestIdRef.current = requestId
setIsSaving(true) setIsSaving(true)
setError(null)
try { try {
const response = await fetch(`${API_URL}/settings/notifications/preferences`, { const response = await fetch(`${API_URL}/settings/notifications/preferences`, {
@ -261,20 +255,10 @@ export default function Benachrichtigungen() {
return return
} }
setError( showErrorToast({
error instanceof Error
? error.message
: 'Benachrichtigungseinstellungen konnten nicht gespeichert werden',
)
showToast({
title: 'Speichern fehlgeschlagen', title: 'Speichern fehlgeschlagen',
message: error,
error instanceof Error fallback: 'Benachrichtigungseinstellungen konnten nicht gespeichert werden.',
? error.message
: 'Benachrichtigungseinstellungen konnten nicht gespeichert werden.',
variant: 'error',
autoClose: false,
}) })
} finally { } finally {
if (saveRequestIdRef.current === requestId) { if (saveRequestIdRef.current === requestId) {
@ -303,38 +287,17 @@ export default function Benachrichtigungen() {
return ( return (
<> <>
<Notifications
notifications={toasts}
onDismiss={dismissToast}
position="top-right"
/>
<div className="divide-y divide-gray-200 dark:divide-white/10"> <div className="divide-y divide-gray-200 dark:divide-white/10">
<Section <Section
title="Benachrichtigungen" title="Benachrichtigungen"
description="Lege fest, welche Hinweise du in der Anwendung erhalten möchtest." description="Lege fest, welche Hinweise du in der Anwendung erhalten möchtest."
> >
<div className="space-y-6 sm:max-w-2xl"> <div className="space-y-6 sm:max-w-2xl">
{error && (
<Message>
{error}
</Message>
)}
<NotificationCard <NotificationCard
icon={<BellIcon aria-hidden="true" className="size-5" />} icon={<BellIcon aria-hidden="true" className="size-5" />}
title="In-App-Benachrichtigungen" title="In-App-Benachrichtigungen"
description="Steuere Hinweise, die direkt in der Anwendung angezeigt werden." 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 <Switch
checked={settings.soundEnabled} checked={settings.soundEnabled}
onChange={(event) => onChange={(event) =>
@ -344,6 +307,52 @@ export default function Benachrichtigungen() {
label="Signalton abspielen" label="Signalton abspielen"
description="Spielt einen kurzen Ton bei neuen Benachrichtigungen ab." 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>
<NotificationCard <NotificationCard

View File

@ -2,7 +2,9 @@
import { import {
useEffect, useEffect,
useRef,
useState, useState,
type ChangeEvent,
type FormEvent, type FormEvent,
type ReactNode, type ReactNode,
} from 'react' } from 'react'
@ -13,9 +15,8 @@ import {
ComputerDesktopIcon, ComputerDesktopIcon,
ShieldCheckIcon, ShieldCheckIcon,
} from '@heroicons/react/20/solid' } from '@heroicons/react/20/solid'
import Notifications, { import { useNotifications } from '../../../components/Notifications'
type ToastNotification, import Avatar from '../../../components/Avatar'
} from '../../../components/Notifications'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080' const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
@ -79,8 +80,11 @@ export default function Konto({
user, user,
onUserUpdated, onUserUpdated,
}: KontoProps) { }: KontoProps) {
const [toasts, setToasts] = useState<ToastNotification[]>([]) const { showToast, showErrorToast } = useNotifications()
const [currentUser, setCurrentUser] = useState<User | undefined>(user) 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 [sessions, setSessions] = useState<UserSession[]>([])
const [isLoadingSessions, setIsLoadingSessions] = useState(false) const [isLoadingSessions, setIsLoadingSessions] = useState(false)
@ -92,32 +96,13 @@ export default function Konto({
useEffect(() => { useEffect(() => {
setCurrentUser(user) setCurrentUser(user)
setAvatarPreview(user?.avatar ?? '')
}, [user]) }, [user])
useEffect(() => { useEffect(() => {
void loadSessions() 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() { async function loadSessions() {
setIsLoadingSessions(true) 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>) { async function handleKontoSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault() event.preventDefault()
@ -220,6 +248,7 @@ export default function Konto({
if (nextUser) { if (nextUser) {
setCurrentUser(nextUser) setCurrentUser(nextUser)
setAvatarPreview(nextUser.avatar ?? '')
onUserUpdated?.(nextUser) onUserUpdated?.(nextUser)
} }
@ -229,14 +258,10 @@ export default function Konto({
variant: 'success', variant: 'success',
}) })
} catch (error) { } catch (error) {
showToast({ showErrorToast({
title: 'Profil konnte nicht gespeichert werden', title: 'Profil konnte nicht gespeichert werden',
message: error,
error instanceof Error fallback: 'Profil konnte nicht gespeichert werden',
? error.message
: 'Profil konnte nicht gespeichert werden',
variant: 'error',
autoClose: false,
}) })
} finally { } finally {
setIsSavingKonto(false) setIsSavingKonto(false)
@ -395,12 +420,6 @@ export default function Konto({
return ( return (
<> <>
<Notifications
notifications={toasts}
onDismiss={dismissToast}
position="top-right"
/>
<div className="divide-y divide-gray-200 dark:divide-white/10"> <div className="divide-y divide-gray-200 dark:divide-white/10">
<Section <Section
title="Persönliche Informationen" title="Persönliche Informationen"
@ -409,33 +428,54 @@ export default function Konto({
<form onSubmit={handleKontoSubmit}> <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="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"> <div className="col-span-full flex items-center gap-x-8">
<img <Avatar
alt="" src={avatarPreview}
src={avatarUrl} name={currentUser?.displayName || currentUser?.username || currentUser?.email}
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" size={16}
shape="rounded"
/> />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<label <input
htmlFor="avatar" type="hidden"
className="block text-sm/6 font-medium text-gray-900 dark:text-white" name="avatar"
> value={avatarPreview}
Avatar-URL />
</label>
<div className="mt-2"> <input
<input ref={avatarInputRef}
id="avatar" type="file"
name="avatar" accept="image/png,image/jpeg,image/webp,image/gif"
type="url" className="sr-only"
defaultValue={currentUser?.avatar ?? ''} onChange={handleAvatarFileChange}
placeholder="https://..." />
className={inputClassName}
/> <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> </div>
<p className="mt-2 text-xs/5 text-gray-500 dark:text-gray-400"> <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> </p>
</div> </div>
</div> </div>