updated
This commit is contained in:
parent
9ecb3acb14
commit
47bf652b56
@ -17,4 +17,4 @@ ADMIN_GROUP=admin
|
||||
ADMIN_RIGHTS=admin,users:read,users:write
|
||||
ADMIN_TEAM_NAME=Administration
|
||||
ADMIN_TEAM_INITIAL=A
|
||||
ADMIN_TEAM_HREF=#
|
||||
ADMIN_TEAM_HREF=#administration
|
||||
192
backend/address_reverse.go
Normal file
192
backend/address_reverse.go
Normal 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@ -49,11 +49,12 @@ type photonGeometry struct {
|
||||
}
|
||||
|
||||
type nominatimSearchResult struct {
|
||||
PlaceID int64 `json:"place_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Lat string `json:"lat"`
|
||||
Lon string `json:"lon"`
|
||||
Type string `json:"type"`
|
||||
PlaceID int64 `json:"place_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Lat string `json:"lat"`
|
||||
Lon string `json:"lon"`
|
||||
Type string `json:"type"`
|
||||
Address nominatimReverseAddress `json:"address"`
|
||||
}
|
||||
|
||||
func (s *Server) handleAddressSearch(w http.ResponseWriter, r *http.Request) {
|
||||
@ -234,7 +235,11 @@ func (s *Server) handleNominatimAddressSearch(
|
||||
suggestions := make([]AddressSuggestion, 0, len(results))
|
||||
|
||||
for _, result := range results {
|
||||
label := strings.TrimSpace(result.DisplayName)
|
||||
label := formatReverseAddressLabel(nominatimReverseResponse{
|
||||
DisplayName: result.DisplayName,
|
||||
Address: result.Address,
|
||||
})
|
||||
|
||||
if label == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
@ -72,6 +72,10 @@ func normalizeAdminUserInput(input *AdminUserRequest) {
|
||||
input.TeamIDs = cleanStringList(input.TeamIDs)
|
||||
}
|
||||
|
||||
func ensureAdminUserLookupValues(ctx context.Context, tx pgx.Tx, unit string) error {
|
||||
return ensureOptionalDeviceLookupValue(ctx, tx, "user_units", unit)
|
||||
}
|
||||
|
||||
func normalizeAdminTeamInput(input *AdminTeamRequest) {
|
||||
input.Name = strings.TrimSpace(input.Name)
|
||||
input.Initial = strings.TrimSpace(input.Initial)
|
||||
@ -130,7 +134,7 @@ func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
defer rows.Close()
|
||||
|
||||
users := []AdminUser{}
|
||||
userByID := map[string]*AdminUser{}
|
||||
userIndexByID := map[string]int{}
|
||||
|
||||
for rows.Next() {
|
||||
var user AdminUser
|
||||
@ -152,8 +156,14 @@ func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
user.Teams = []AdminTeam{}
|
||||
|
||||
users = append(users, user)
|
||||
userByID[user.ID] = &users[len(users)-1]
|
||||
userIndexByID[user.ID] = len(users) - 1
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Benutzer konnten nicht vollständig gelesen werden")
|
||||
return
|
||||
}
|
||||
|
||||
teamRows, err := s.db.Query(
|
||||
@ -196,9 +206,17 @@ func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if user := userByID[userID]; user != nil {
|
||||
user.Teams = append(user.Teams, team)
|
||||
userIndex, ok := userIndexByID[userID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
users[userIndex].Teams = append(users[userIndex].Teams, team)
|
||||
}
|
||||
|
||||
if err := teamRows.Err(); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Team-Zuordnungen konnten nicht vollständig gelesen werden")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
@ -239,6 +257,11 @@ func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
defer tx.Rollback(r.Context())
|
||||
|
||||
if err := ensureAdminUserLookupValues(r.Context(), tx, input.Unit); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Einheit konnte nicht gespeichert werden")
|
||||
return
|
||||
}
|
||||
|
||||
var userID string
|
||||
|
||||
err = tx.QueryRow(
|
||||
@ -289,6 +312,10 @@ func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if createdUser, err := s.getUserByID(r.Context(), userID); err == nil {
|
||||
s.publishUserProfileEvent("user.created", createdUser)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"ok": true,
|
||||
})
|
||||
@ -323,6 +350,11 @@ func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
defer tx.Rollback(r.Context())
|
||||
|
||||
if err := ensureAdminUserLookupValues(r.Context(), tx, input.Unit); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Einheit konnte nicht gespeichert werden")
|
||||
return
|
||||
}
|
||||
|
||||
if input.Password != "" {
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
@ -403,6 +435,10 @@ func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if updatedUser, err := s.getUserByID(r.Context(), userID); err == nil {
|
||||
s.publishUserProfileEvent("user.updated", updatedUser)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
})
|
||||
|
||||
@ -149,6 +149,7 @@ type milestoneHardwareDevice struct {
|
||||
Manufacturer string
|
||||
MacAddress string
|
||||
IPAddress string
|
||||
Port string
|
||||
DeviceModelName string
|
||||
SerialNumber string
|
||||
FirmwareVersion string
|
||||
@ -830,7 +831,7 @@ func (s *Server) requestMilestoneHardwareDevices(
|
||||
enabled = !*hardware.Disabled
|
||||
}
|
||||
|
||||
ipAddress := extractMilestoneHardwareIPAddress(hardware.Address)
|
||||
ipAddress, port := extractMilestoneHardwareAddressParts(hardware.Address)
|
||||
|
||||
result = append(result, milestoneHardwareDevice{
|
||||
HardwareID: hardwareID,
|
||||
@ -840,6 +841,7 @@ func (s *Server) requestMilestoneHardwareDevices(
|
||||
Manufacturer: manufacturer,
|
||||
MacAddress: strings.TrimSpace(settings.MacAddress),
|
||||
IPAddress: ipAddress,
|
||||
Port: port,
|
||||
DeviceModelName: model,
|
||||
SerialNumber: strings.TrimSpace(settings.SerialNumber),
|
||||
FirmwareVersion: strings.TrimSpace(settings.FirmwareVersion),
|
||||
@ -857,25 +859,35 @@ func (s *Server) requestMilestoneHardwareDevices(
|
||||
}
|
||||
|
||||
func extractMilestoneHardwareIPAddress(value string) string {
|
||||
host, _ := extractMilestoneHardwareAddressParts(value)
|
||||
return host
|
||||
}
|
||||
|
||||
func extractMilestoneHardwarePort(value string) string {
|
||||
_, port := extractMilestoneHardwareAddressParts(value)
|
||||
return port
|
||||
}
|
||||
|
||||
func extractMilestoneHardwareAddressParts(value string) (string, string) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
return "", ""
|
||||
}
|
||||
|
||||
parsedURL, err := url.Parse(value)
|
||||
if err == nil && parsedURL.Hostname() != "" {
|
||||
return parsedURL.Hostname()
|
||||
return strings.TrimSpace(parsedURL.Hostname()), strings.TrimSpace(parsedURL.Port())
|
||||
}
|
||||
|
||||
value = strings.TrimPrefix(value, "http://")
|
||||
value = strings.TrimPrefix(value, "https://")
|
||||
value = strings.TrimRight(value, "/")
|
||||
|
||||
if host, _, found := strings.Cut(value, ":"); found {
|
||||
return strings.TrimSpace(host)
|
||||
if host, port, found := strings.Cut(value, ":"); found {
|
||||
return strings.TrimSpace(host), strings.TrimSpace(port)
|
||||
}
|
||||
|
||||
return strings.TrimSpace(value)
|
||||
return strings.TrimSpace(value), ""
|
||||
}
|
||||
|
||||
func (s *Server) requestMilestoneHardware(
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// backend/camera_firmware.go
|
||||
// backend\camera_firmware.go
|
||||
|
||||
package main
|
||||
|
||||
|
||||
@ -12,7 +12,7 @@ func (s *Server) cors(next http.Handler) http.Handler {
|
||||
w.Header().Set("Access-Control-Allow-Origin", s.frontendOrigin)
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
}
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
|
||||
@ -50,68 +50,19 @@ func (s *Server) publishDeviceChangeEvent(
|
||||
return err
|
||||
}
|
||||
|
||||
var notification Notification
|
||||
|
||||
err = s.db.QueryRow(
|
||||
if err := s.createAndPublishNotification(
|
||||
ctx,
|
||||
`
|
||||
INSERT INTO notifications (
|
||||
user_id,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
visible,
|
||||
entity_type,
|
||||
entity_id,
|
||||
data
|
||||
)
|
||||
VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
false,
|
||||
'device',
|
||||
$5,
|
||||
$6
|
||||
)
|
||||
RETURNING
|
||||
id::TEXT,
|
||||
user_id::TEXT,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
entity_type,
|
||||
COALESCE(entity_id::TEXT, ''),
|
||||
data,
|
||||
visible,
|
||||
read_at,
|
||||
created_at
|
||||
`,
|
||||
userID,
|
||||
notificationType,
|
||||
title,
|
||||
message,
|
||||
"device",
|
||||
deviceID,
|
||||
dataJSON,
|
||||
).Scan(
|
||||
¬ification.ID,
|
||||
¬ification.UserID,
|
||||
¬ification.Type,
|
||||
¬ification.Title,
|
||||
¬ification.Message,
|
||||
¬ification.EntityType,
|
||||
¬ification.EntityID,
|
||||
¬ification.Data,
|
||||
¬ification.Visible,
|
||||
¬ification.ReadAt,
|
||||
¬ification.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
true,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
notificationsBroker.publish(notification)
|
||||
}
|
||||
|
||||
return rows.Err()
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// backend/devices.go
|
||||
// backend\devices.go
|
||||
|
||||
package main
|
||||
|
||||
@ -21,6 +21,7 @@ type Device struct {
|
||||
SerialNumber string `json:"serialNumber"`
|
||||
MacAddress string `json:"macAddress"`
|
||||
IPAddress string `json:"ipAddress"`
|
||||
MilestonePort string `json:"milestonePort"`
|
||||
Location string `json:"location"`
|
||||
LoanStatus string `json:"loanStatus"`
|
||||
IsBaoDevice bool `json:"isBaoDevice"`
|
||||
@ -69,8 +70,10 @@ type CreateDeviceRequest struct {
|
||||
SerialNumber string `json:"serialNumber"`
|
||||
MacAddress string `json:"macAddress"`
|
||||
IPAddress string `json:"ipAddress"`
|
||||
MilestonePort string `json:"milestonePort"`
|
||||
FirmwareVersion string `json:"firmwareVersion"`
|
||||
MilestoneDisplayName string `json:"milestoneDisplayName"`
|
||||
MilestoneEnabled *bool `json:"milestoneEnabled,omitempty"`
|
||||
Location string `json:"location"`
|
||||
LoanStatus string `json:"loanStatus"`
|
||||
IsBaoDevice bool `json:"isBaoDevice"`
|
||||
@ -80,6 +83,10 @@ type CreateDeviceRequest struct {
|
||||
|
||||
type UpdateDeviceRequest = CreateDeviceRequest
|
||||
|
||||
type UpdateDeviceJournalEntryRequest struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
var errDeviceNotFound = errors.New("device does not exist")
|
||||
|
||||
func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) {
|
||||
@ -105,6 +112,7 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
|
||||
serial_number,
|
||||
mac_address,
|
||||
COALESCE(ip_address, ''),
|
||||
COALESCE(milestone_port, ''),
|
||||
location,
|
||||
loan_status,
|
||||
is_bao_device,
|
||||
@ -142,6 +150,7 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
|
||||
&device.SerialNumber,
|
||||
&device.MacAddress,
|
||||
&device.IPAddress,
|
||||
&device.MilestonePort,
|
||||
&device.Location,
|
||||
&device.LoanStatus,
|
||||
&device.IsBaoDevice,
|
||||
@ -270,13 +279,14 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) {
|
||||
serial_number,
|
||||
mac_address,
|
||||
ip_address,
|
||||
milestone_port,
|
||||
location,
|
||||
loan_status,
|
||||
is_bao_device,
|
||||
comment,
|
||||
firmware_version
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
RETURNING
|
||||
id,
|
||||
inventory_number,
|
||||
@ -285,6 +295,7 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) {
|
||||
serial_number,
|
||||
mac_address,
|
||||
COALESCE(ip_address, ''),
|
||||
COALESCE(milestone_port, ''),
|
||||
location,
|
||||
loan_status,
|
||||
is_bao_device,
|
||||
@ -304,6 +315,7 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) {
|
||||
input.SerialNumber,
|
||||
input.MacAddress,
|
||||
input.IPAddress,
|
||||
input.MilestonePort,
|
||||
input.Location,
|
||||
input.LoanStatus,
|
||||
input.IsBaoDevice,
|
||||
@ -317,6 +329,7 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) {
|
||||
&device.SerialNumber,
|
||||
&device.MacAddress,
|
||||
&device.IPAddress,
|
||||
&device.MilestonePort,
|
||||
&device.Location,
|
||||
&device.LoanStatus,
|
||||
&device.IsBaoDevice,
|
||||
@ -449,6 +462,11 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
nextMilestoneEnabled := oldDevice.MilestoneEnabled
|
||||
if input.MilestoneEnabled != nil {
|
||||
nextMilestoneEnabled = *input.MilestoneEnabled
|
||||
}
|
||||
|
||||
var device Device
|
||||
|
||||
err = tx.QueryRow(
|
||||
@ -462,12 +480,25 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
|
||||
serial_number = $5,
|
||||
mac_address = $6,
|
||||
ip_address = $7,
|
||||
location = $8,
|
||||
loan_status = $9,
|
||||
is_bao_device = $10,
|
||||
comment = $11,
|
||||
firmware_version = $12,
|
||||
milestone_display_name = $13,
|
||||
milestone_port = $8,
|
||||
location = $9,
|
||||
loan_status = $10,
|
||||
is_bao_device = $11,
|
||||
comment = $12,
|
||||
firmware_version = $13,
|
||||
milestone_display_name = $14,
|
||||
milestone_enabled = $15,
|
||||
milestone_synced_at = CASE
|
||||
WHEN COALESCE(milestone_hardware_id, '') <> ''
|
||||
AND (
|
||||
COALESCE(milestone_display_name, '') <> $14
|
||||
OR COALESCE(ip_address, '') <> $7
|
||||
OR COALESCE(milestone_port, '') <> $8
|
||||
OR milestone_enabled <> $15
|
||||
)
|
||||
THEN now()
|
||||
ELSE milestone_synced_at
|
||||
END,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING
|
||||
@ -478,6 +509,7 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
|
||||
serial_number,
|
||||
mac_address,
|
||||
COALESCE(ip_address, ''),
|
||||
COALESCE(milestone_port, ''),
|
||||
location,
|
||||
loan_status,
|
||||
is_bao_device,
|
||||
@ -498,12 +530,14 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
|
||||
input.SerialNumber,
|
||||
input.MacAddress,
|
||||
input.IPAddress,
|
||||
input.MilestonePort,
|
||||
input.Location,
|
||||
input.LoanStatus,
|
||||
input.IsBaoDevice,
|
||||
input.Comment,
|
||||
input.FirmwareVersion,
|
||||
input.MilestoneDisplayName,
|
||||
nextMilestoneEnabled,
|
||||
).Scan(
|
||||
&device.ID,
|
||||
&device.InventoryNumber,
|
||||
@ -512,6 +546,7 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
|
||||
&device.SerialNumber,
|
||||
&device.MacAddress,
|
||||
&device.IPAddress,
|
||||
&device.MilestonePort,
|
||||
&device.Location,
|
||||
&device.LoanStatus,
|
||||
&device.IsBaoDevice,
|
||||
@ -634,6 +669,7 @@ func normalizeDeviceInput(input *CreateDeviceRequest) {
|
||||
input.SerialNumber = strings.TrimSpace(input.SerialNumber)
|
||||
input.MacAddress = strings.TrimSpace(input.MacAddress)
|
||||
input.IPAddress = strings.TrimSpace(input.IPAddress)
|
||||
input.MilestonePort = strings.TrimSpace(input.MilestonePort)
|
||||
input.FirmwareVersion = strings.TrimSpace(input.FirmwareVersion)
|
||||
input.MilestoneDisplayName = strings.TrimSpace(input.MilestoneDisplayName)
|
||||
input.Location = strings.TrimSpace(input.Location)
|
||||
|
||||
@ -164,7 +164,7 @@ func (s *Server) handleCreateFeedback(w http.ResponseWriter, r *http.Request) {
|
||||
"Neues Feedback",
|
||||
formatText("Neues Feedback von %s.", displayFeedbackUserName(feedback)),
|
||||
feedback.ID,
|
||||
true,
|
||||
false,
|
||||
map[string]any{
|
||||
"feedbackId": feedback.ID,
|
||||
"userId": feedback.UserID,
|
||||
@ -314,7 +314,7 @@ func (s *Server) handleDeleteFeedback(w http.ResponseWriter, r *http.Request) {
|
||||
"Feedback gelöscht",
|
||||
formatText("Feedback von %s wurde gelöscht.", displayFeedbackUserName(deleted)),
|
||||
deleted.ID,
|
||||
false,
|
||||
true,
|
||||
map[string]any{
|
||||
"feedbackId": deleted.ID,
|
||||
"userId": deleted.UserID,
|
||||
@ -375,70 +375,19 @@ func (s *Server) notifyAdministratorsAboutFeedback(
|
||||
return err
|
||||
}
|
||||
|
||||
var notification Notification
|
||||
|
||||
err = s.db.QueryRow(
|
||||
if err := s.createAndPublishNotification(
|
||||
ctx,
|
||||
`
|
||||
INSERT INTO notifications (
|
||||
user_id,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
visible,
|
||||
entity_type,
|
||||
entity_id,
|
||||
data
|
||||
)
|
||||
VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
true,
|
||||
'feedback',
|
||||
$5,
|
||||
$6,
|
||||
$7
|
||||
)
|
||||
RETURNING
|
||||
id::TEXT,
|
||||
user_id::TEXT,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
entity_type,
|
||||
COALESCE(entity_id::TEXT, ''),
|
||||
data,
|
||||
visible,
|
||||
read_at,
|
||||
created_at
|
||||
`,
|
||||
adminUserID,
|
||||
notificationType,
|
||||
title,
|
||||
message,
|
||||
visible,
|
||||
"feedback",
|
||||
feedbackID,
|
||||
dataJSON,
|
||||
).Scan(
|
||||
¬ification.ID,
|
||||
¬ification.UserID,
|
||||
¬ification.Type,
|
||||
¬ification.Title,
|
||||
¬ification.Message,
|
||||
¬ification.EntityType,
|
||||
¬ification.EntityID,
|
||||
¬ification.Data,
|
||||
¬ification.Visible,
|
||||
¬ification.ReadAt,
|
||||
¬ification.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
visible,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
notificationsBroker.publish(notification)
|
||||
}
|
||||
|
||||
return adminRows.Err()
|
||||
|
||||
@ -21,6 +21,8 @@ type DeviceHistoryEntry struct {
|
||||
Action string `json:"action"`
|
||||
Changes []DeviceHistoryChange `json:"changes"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
CanEdit bool `json:"canEdit"`
|
||||
}
|
||||
|
||||
type DeviceHistoryChange struct {
|
||||
@ -42,6 +44,12 @@ func (s *Server) handleListDeviceHistory(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := userFromContext(r.Context())
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(
|
||||
r.Context(),
|
||||
`
|
||||
@ -52,7 +60,8 @@ func (s *Server) handleListDeviceHistory(w http.ResponseWriter, r *http.Request)
|
||||
COALESCE(NULLIF(u.display_name, ''), NULLIF(u.username, ''), u.email, 'Unbekannt'),
|
||||
h.action,
|
||||
h.changes,
|
||||
h.created_at
|
||||
h.created_at,
|
||||
COALESCE(h.updated_at, h.created_at)
|
||||
FROM device_history h
|
||||
LEFT JOIN users u ON u.id = h.user_id
|
||||
WHERE h.device_id = $1
|
||||
@ -81,6 +90,7 @@ func (s *Server) handleListDeviceHistory(w http.ResponseWriter, r *http.Request)
|
||||
&entry.Action,
|
||||
&changesRaw,
|
||||
&entry.CreatedAt,
|
||||
&entry.UpdatedAt,
|
||||
); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Could not read device history")
|
||||
return
|
||||
@ -93,6 +103,11 @@ func (s *Server) handleListDeviceHistory(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
}
|
||||
|
||||
entry.CanEdit =
|
||||
entry.Action == "journal" &&
|
||||
entry.UserID == user.ID &&
|
||||
time.Since(entry.CreatedAt) <= 10*time.Minute
|
||||
|
||||
history = append(history, entry)
|
||||
}
|
||||
|
||||
@ -239,6 +254,13 @@ func getDeviceForHistory(ctx context.Context, tx pgx.Tx, deviceID string) (Devic
|
||||
model,
|
||||
serial_number,
|
||||
mac_address,
|
||||
COALESCE(ip_address, ''),
|
||||
COALESCE(firmware_version, ''),
|
||||
COALESCE(milestone_recording_server_id, ''),
|
||||
COALESCE(milestone_hardware_id, ''),
|
||||
COALESCE(milestone_display_name, ''),
|
||||
milestone_enabled,
|
||||
milestone_synced_at,
|
||||
location,
|
||||
loan_status,
|
||||
is_bao_device,
|
||||
@ -257,6 +279,13 @@ func getDeviceForHistory(ctx context.Context, tx pgx.Tx, deviceID string) (Devic
|
||||
&device.Model,
|
||||
&device.SerialNumber,
|
||||
&device.MacAddress,
|
||||
&device.IPAddress,
|
||||
&device.FirmwareVersion,
|
||||
&device.MilestoneRecordingServerID,
|
||||
&device.MilestoneHardwareID,
|
||||
&device.MilestoneDisplayName,
|
||||
&device.MilestoneEnabled,
|
||||
&device.MilestoneSyncedAt,
|
||||
&device.Location,
|
||||
&device.LoanStatus,
|
||||
&device.IsBaoDevice,
|
||||
@ -304,6 +333,190 @@ func getDeviceForHistory(ctx context.Context, tx pgx.Tx, deviceID string) (Devic
|
||||
return device, relatedDeviceIDs, nil
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateDeviceJournalEntry(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := strings.TrimSpace(r.PathValue("id"))
|
||||
historyID := strings.TrimSpace(r.PathValue("historyId"))
|
||||
|
||||
if deviceID == "" {
|
||||
writeError(w, http.StatusBadRequest, "Geräte-ID fehlt")
|
||||
return
|
||||
}
|
||||
|
||||
if historyID == "" {
|
||||
writeError(w, http.StatusBadRequest, "Journal-ID fehlt")
|
||||
return
|
||||
}
|
||||
|
||||
var input UpdateDeviceJournalEntryRequest
|
||||
|
||||
if err := readJSON(r, &input); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
content := strings.TrimSpace(input.Content)
|
||||
|
||||
if content == "" {
|
||||
writeError(w, http.StatusBadRequest, "Journal-Eintrag darf nicht leer sein")
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := userFromContext(r.Context())
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
tx, err := s.db.Begin(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht gespeichert werden")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(r.Context())
|
||||
|
||||
if err := ensureDeviceExists(r.Context(), tx, deviceID); err != nil {
|
||||
if errors.Is(err, errDeviceNotFound) {
|
||||
writeError(w, http.StatusNotFound, "Gerät wurde nicht gefunden")
|
||||
return
|
||||
}
|
||||
|
||||
writeError(w, http.StatusInternalServerError, "Gerät konnte nicht geladen werden")
|
||||
return
|
||||
}
|
||||
|
||||
var changesRaw []byte
|
||||
var createdAt time.Time
|
||||
|
||||
err = tx.QueryRow(
|
||||
r.Context(),
|
||||
`
|
||||
SELECT changes, created_at
|
||||
FROM device_history
|
||||
WHERE id = $1
|
||||
AND device_id = $2
|
||||
AND user_id = $3
|
||||
AND action = 'journal'
|
||||
LIMIT 1
|
||||
`,
|
||||
historyID,
|
||||
deviceID,
|
||||
user.ID,
|
||||
).Scan(&changesRaw, &createdAt)
|
||||
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeError(w, http.StatusNotFound, "Journal-Eintrag wurde nicht gefunden oder darf nicht bearbeitet werden")
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht geladen werden")
|
||||
return
|
||||
}
|
||||
|
||||
if time.Since(createdAt) > 10*time.Minute {
|
||||
writeError(w, http.StatusForbidden, "Journal-Eintrag kann nur innerhalb von 10 Minuten bearbeitet werden")
|
||||
return
|
||||
}
|
||||
|
||||
var oldChanges []DeviceHistoryChange
|
||||
|
||||
if len(changesRaw) > 0 {
|
||||
if err := json.Unmarshal(changesRaw, &oldChanges); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht gelesen werden")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
oldContent := ""
|
||||
|
||||
for _, change := range oldChanges {
|
||||
if change.Field == "journal" {
|
||||
oldContent = change.NewValue
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(oldContent) == content {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
changes := []DeviceHistoryChange{
|
||||
{
|
||||
Field: "journal",
|
||||
Label: "Journal",
|
||||
OldValue: oldContent,
|
||||
NewValue: content,
|
||||
},
|
||||
}
|
||||
|
||||
changesJSON, err := json.Marshal(changes)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht vorbereitet werden")
|
||||
return
|
||||
}
|
||||
|
||||
commandTag, err := tx.Exec(
|
||||
r.Context(),
|
||||
`
|
||||
UPDATE device_history
|
||||
SET
|
||||
changes = $4::JSONB,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
AND device_id = $2
|
||||
AND user_id = $3
|
||||
AND action = 'journal'
|
||||
AND created_at >= now() - interval '10 minutes'
|
||||
`,
|
||||
historyID,
|
||||
deviceID,
|
||||
user.ID,
|
||||
string(changesJSON),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht gespeichert werden")
|
||||
return
|
||||
}
|
||||
|
||||
if commandTag.RowsAffected() == 0 {
|
||||
writeError(w, http.StatusForbidden, "Journal-Eintrag kann nicht mehr bearbeitet werden")
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Commit(r.Context()); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht gespeichert werden")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.publishDeviceChangeEvent(
|
||||
r.Context(),
|
||||
user.ID,
|
||||
deviceID,
|
||||
"device.journal",
|
||||
"Geräte-Journal aktualisiert",
|
||||
"Ein Geräte-Journal-Eintrag wurde aktualisiert.",
|
||||
LogFields{
|
||||
"deviceId": deviceID,
|
||||
"historyId": historyID,
|
||||
"action": "updated",
|
||||
},
|
||||
); err != nil {
|
||||
logError("Device-Journal-SSE-Event konnte nicht gesendet werden", err, LogFields{
|
||||
"deviceId": deviceID,
|
||||
"historyId": historyID,
|
||||
"type": "device.journal",
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
})
|
||||
}
|
||||
|
||||
func formatHistoryBool(value bool) string {
|
||||
if value {
|
||||
return "Ja"
|
||||
@ -357,6 +570,17 @@ func buildDeviceUpdateHistoryChanges(
|
||||
changes = appendHistoryChange(changes, "loanStatus", "Verleih-Status", oldDevice.LoanStatus, input.LoanStatus)
|
||||
changes = appendHistoryChange(changes, "isBaoDevice", "BAO-Gerät", formatHistoryBool(oldDevice.IsBaoDevice), formatHistoryBool(input.IsBaoDevice))
|
||||
changes = appendHistoryChange(changes, "comment", "Kommentar", oldDevice.Comment, input.Comment)
|
||||
changes = appendHistoryChange(changes, "milestoneDisplayName", "Milestone-Anzeigename", oldDevice.MilestoneDisplayName, input.MilestoneDisplayName)
|
||||
changes = appendHistoryChange(changes, "ipAddress", "IP-Adresse", oldDevice.IPAddress, input.IPAddress)
|
||||
if input.MilestoneEnabled != nil {
|
||||
changes = appendHistoryChange(
|
||||
changes,
|
||||
"milestoneEnabled",
|
||||
"Milestone-Status",
|
||||
formatHistoryBool(oldDevice.MilestoneEnabled),
|
||||
formatHistoryBool(*input.MilestoneEnabled),
|
||||
)
|
||||
}
|
||||
|
||||
oldRelatedDevices, err := formatRelatedDeviceLabels(ctx, tx, oldRelatedDeviceIDs)
|
||||
if err != nil {
|
||||
|
||||
614
backend/milestone_api.go
Normal file
614
backend/milestone_api.go
Normal 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
|
||||
}
|
||||
@ -4,7 +4,6 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
@ -28,6 +27,7 @@ func syncMilestoneHardwareDevices(
|
||||
hardwareDevice.SerialNumber = strings.TrimSpace(hardwareDevice.SerialNumber)
|
||||
hardwareDevice.MacAddress = strings.TrimSpace(hardwareDevice.MacAddress)
|
||||
hardwareDevice.IPAddress = strings.TrimSpace(hardwareDevice.IPAddress)
|
||||
hardwareDevice.Port = strings.TrimSpace(hardwareDevice.Port)
|
||||
hardwareDevice.FirmwareVersion = strings.TrimSpace(hardwareDevice.FirmwareVersion)
|
||||
|
||||
if hardwareDevice.HardwareID == "" {
|
||||
@ -78,6 +78,7 @@ func createOrUpdateMilestoneHardwareDevice(
|
||||
serial_number,
|
||||
mac_address,
|
||||
ip_address,
|
||||
milestone_port,
|
||||
location,
|
||||
loan_status,
|
||||
is_bao_device,
|
||||
@ -96,10 +97,11 @@ func createOrUpdateMilestoneHardwareDevice(
|
||||
$4,
|
||||
$5,
|
||||
$6,
|
||||
$7,
|
||||
'',
|
||||
'Verfügbar',
|
||||
false,
|
||||
$7,
|
||||
'',
|
||||
$8,
|
||||
$9,
|
||||
$10,
|
||||
@ -116,8 +118,7 @@ func createOrUpdateMilestoneHardwareDevice(
|
||||
serial_number = EXCLUDED.serial_number,
|
||||
mac_address = EXCLUDED.mac_address,
|
||||
ip_address = EXCLUDED.ip_address,
|
||||
location = EXCLUDED.location,
|
||||
comment = EXCLUDED.comment,
|
||||
milestone_port = EXCLUDED.milestone_port,
|
||||
firmware_version = EXCLUDED.firmware_version,
|
||||
milestone_recording_server_id = EXCLUDED.milestone_recording_server_id,
|
||||
milestone_display_name = EXCLUDED.milestone_display_name,
|
||||
@ -131,7 +132,7 @@ func createOrUpdateMilestoneHardwareDevice(
|
||||
hardwareDevice.SerialNumber,
|
||||
hardwareDevice.MacAddress,
|
||||
hardwareDevice.IPAddress,
|
||||
buildMilestoneDeviceComment(hardwareDevice),
|
||||
hardwareDevice.Port,
|
||||
hardwareDevice.FirmwareVersion,
|
||||
hardwareDevice.RecordingServerID,
|
||||
hardwareDevice.HardwareID,
|
||||
@ -324,31 +325,3 @@ func buildGeneratedMilestoneInventoryNumber(hardwareID string) string {
|
||||
|
||||
return "MS-" + value
|
||||
}
|
||||
|
||||
func buildMilestoneDeviceComment(hardwareDevice milestoneHardwareDevice) string {
|
||||
parts := []string{}
|
||||
|
||||
if strings.TrimSpace(hardwareDevice.DisplayName) != "" {
|
||||
parts = append(parts, strings.TrimSpace(hardwareDevice.DisplayName))
|
||||
}
|
||||
|
||||
if strings.TrimSpace(hardwareDevice.DeviceName) != "" &&
|
||||
strings.TrimSpace(hardwareDevice.DeviceName) != strings.TrimSpace(hardwareDevice.DisplayName) {
|
||||
parts = append(parts, "DeviceName: "+strings.TrimSpace(hardwareDevice.DeviceName))
|
||||
}
|
||||
|
||||
if hardwareDevice.Enabled {
|
||||
parts = append(parts, "Status: aktiviert")
|
||||
} else {
|
||||
parts = append(parts, "Status: deaktiviert")
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
return "Automatisch aus Milestone synchronisiert."
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
"Automatisch aus Milestone synchronisiert: %s",
|
||||
strings.Join(parts, " · "),
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -1,29 +1,33 @@
|
||||
// backend\notification_preferences.go
|
||||
// backend/notification_preferences.go
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type notificationPreferencesResponse struct {
|
||||
InAppEnabled bool `json:"inAppEnabled"`
|
||||
SoundEnabled bool `json:"soundEnabled"`
|
||||
EmailEnabled bool `json:"emailEnabled"`
|
||||
OperationUpdates bool `json:"operationUpdates"`
|
||||
OperationJournals bool `json:"operationJournals"`
|
||||
DeviceUpdates bool `json:"deviceUpdates"`
|
||||
DeviceJournals bool `json:"deviceJournals"`
|
||||
SystemMessages bool `json:"systemMessages"`
|
||||
InAppEnabled bool `json:"inAppEnabled"`
|
||||
SoundEnabled bool `json:"soundEnabled"`
|
||||
SoundName string `json:"soundName"`
|
||||
EmailEnabled bool `json:"emailEnabled"`
|
||||
OperationUpdates bool `json:"operationUpdates"`
|
||||
OperationJournals bool `json:"operationJournals"`
|
||||
DeviceUpdates bool `json:"deviceUpdates"`
|
||||
DeviceJournals bool `json:"deviceJournals"`
|
||||
SystemMessages bool `json:"systemMessages"`
|
||||
}
|
||||
|
||||
func defaultNotificationPreferences() notificationPreferencesResponse {
|
||||
return notificationPreferencesResponse{
|
||||
InAppEnabled: true,
|
||||
SoundEnabled: false,
|
||||
SoundName: "default",
|
||||
EmailEnabled: false,
|
||||
OperationUpdates: true,
|
||||
OperationJournals: true,
|
||||
@ -33,6 +37,17 @@ func defaultNotificationPreferences() notificationPreferencesResponse {
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeNotificationSoundName(value string) string {
|
||||
value = strings.TrimSpace(strings.ToLower(value))
|
||||
|
||||
switch value {
|
||||
case "default", "chime", "soft", "alert", "success":
|
||||
return value
|
||||
default:
|
||||
return "default"
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleGetNotificationPreferences(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := userFromContext(r.Context())
|
||||
if !ok {
|
||||
@ -40,7 +55,7 @@ func (s *Server) handleGetNotificationPreferences(w http.ResponseWriter, r *http
|
||||
return
|
||||
}
|
||||
|
||||
settings, err := s.getNotificationPreferences(r, user.ID)
|
||||
settings, err := s.getNotificationPreferences(r.Context(), user.ID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"settings": defaultNotificationPreferences(),
|
||||
@ -72,6 +87,8 @@ func (s *Server) handleUpdateNotificationPreferences(w http.ResponseWriter, r *h
|
||||
return
|
||||
}
|
||||
|
||||
input.SoundName = normalizeNotificationSoundName(input.SoundName)
|
||||
|
||||
_, err := s.db.Exec(
|
||||
r.Context(),
|
||||
`
|
||||
@ -79,6 +96,7 @@ func (s *Server) handleUpdateNotificationPreferences(w http.ResponseWriter, r *h
|
||||
user_id,
|
||||
in_app_enabled,
|
||||
sound_enabled,
|
||||
sound_name,
|
||||
email_enabled,
|
||||
operation_updates,
|
||||
operation_journals,
|
||||
@ -86,11 +104,12 @@ func (s *Server) handleUpdateNotificationPreferences(w http.ResponseWriter, r *h
|
||||
device_journals,
|
||||
system_messages
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT (user_id)
|
||||
DO UPDATE SET
|
||||
in_app_enabled = EXCLUDED.in_app_enabled,
|
||||
sound_enabled = EXCLUDED.sound_enabled,
|
||||
sound_name = EXCLUDED.sound_name,
|
||||
email_enabled = EXCLUDED.email_enabled,
|
||||
operation_updates = EXCLUDED.operation_updates,
|
||||
operation_journals = EXCLUDED.operation_journals,
|
||||
@ -102,6 +121,7 @@ func (s *Server) handleUpdateNotificationPreferences(w http.ResponseWriter, r *h
|
||||
user.ID,
|
||||
input.InAppEnabled,
|
||||
input.SoundEnabled,
|
||||
input.SoundName,
|
||||
input.EmailEnabled,
|
||||
input.OperationUpdates,
|
||||
input.OperationJournals,
|
||||
@ -114,7 +134,7 @@ func (s *Server) handleUpdateNotificationPreferences(w http.ResponseWriter, r *h
|
||||
return
|
||||
}
|
||||
|
||||
settings, err := s.getNotificationPreferences(r, user.ID)
|
||||
settings, err := s.getNotificationPreferences(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Benachrichtigungseinstellungen wurden gespeichert, konnten aber nicht neu geladen werden")
|
||||
return
|
||||
@ -125,15 +145,16 @@ func (s *Server) handleUpdateNotificationPreferences(w http.ResponseWriter, r *h
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) getNotificationPreferences(r *http.Request, userID string) (notificationPreferencesResponse, error) {
|
||||
func (s *Server) getNotificationPreferences(ctx context.Context, userID string) (notificationPreferencesResponse, error) {
|
||||
settings := defaultNotificationPreferences()
|
||||
|
||||
err := s.db.QueryRow(
|
||||
r.Context(),
|
||||
ctx,
|
||||
`
|
||||
SELECT
|
||||
in_app_enabled,
|
||||
sound_enabled,
|
||||
COALESCE(sound_name, 'default'),
|
||||
email_enabled,
|
||||
operation_updates,
|
||||
operation_journals,
|
||||
@ -148,6 +169,7 @@ func (s *Server) getNotificationPreferences(r *http.Request, userID string) (not
|
||||
).Scan(
|
||||
&settings.InAppEnabled,
|
||||
&settings.SoundEnabled,
|
||||
&settings.SoundName,
|
||||
&settings.EmailEnabled,
|
||||
&settings.OperationUpdates,
|
||||
&settings.OperationJournals,
|
||||
@ -156,5 +178,16 @@ func (s *Server) getNotificationPreferences(r *http.Request, userID string) (not
|
||||
&settings.SystemMessages,
|
||||
)
|
||||
|
||||
settings.SoundName = normalizeNotificationSoundName(settings.SoundName)
|
||||
|
||||
return settings, err
|
||||
}
|
||||
|
||||
func (s *Server) getNotificationPreferencesOrDefault(ctx context.Context, userID string) (notificationPreferencesResponse, error) {
|
||||
settings, err := s.getNotificationPreferences(ctx, userID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return defaultNotificationPreferences(), nil
|
||||
}
|
||||
|
||||
return settings, err
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// backend\notifications.go
|
||||
// backend/notifications.go
|
||||
|
||||
package main
|
||||
|
||||
@ -22,7 +22,8 @@ type Notification struct {
|
||||
EntityType string `json:"entityType"`
|
||||
EntityID string `json:"entityId"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
Visible bool `json:"visible"`
|
||||
Silent bool `json:"silent"`
|
||||
SoundName string `json:"soundName,omitempty"`
|
||||
ReadAt *time.Time `json:"readAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
@ -77,6 +78,151 @@ func (b *notificationBroker) publish(notification Notification) {
|
||||
}
|
||||
}
|
||||
|
||||
func (b *notificationBroker) publishToAll(notification Notification) {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
|
||||
for client := range b.clients {
|
||||
select {
|
||||
case client.ch <- notification:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func shouldDeliverNotification(
|
||||
preferences notificationPreferencesResponse,
|
||||
notificationType string,
|
||||
entityType string,
|
||||
) bool {
|
||||
if !preferences.InAppEnabled {
|
||||
return false
|
||||
}
|
||||
|
||||
switch {
|
||||
case notificationType == "operation.journal":
|
||||
return preferences.OperationJournals
|
||||
|
||||
case strings.HasPrefix(notificationType, "operation.") || entityType == "operation":
|
||||
return preferences.OperationUpdates
|
||||
|
||||
case notificationType == "device.journal":
|
||||
return preferences.DeviceJournals
|
||||
|
||||
case strings.HasPrefix(notificationType, "device.") || entityType == "device":
|
||||
return preferences.DeviceUpdates
|
||||
|
||||
case strings.HasPrefix(notificationType, "system.") || entityType == "system":
|
||||
return preferences.SystemMessages
|
||||
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func notificationSoundName(preferences notificationPreferencesResponse) string {
|
||||
if !preferences.SoundEnabled {
|
||||
return ""
|
||||
}
|
||||
|
||||
return normalizeNotificationSoundName(preferences.SoundName)
|
||||
}
|
||||
|
||||
func (s *Server) createAndPublishNotification(
|
||||
ctx context.Context,
|
||||
recipientID string,
|
||||
notificationType string,
|
||||
title string,
|
||||
message string,
|
||||
entityType string,
|
||||
entityID string,
|
||||
dataJSON []byte,
|
||||
silent bool,
|
||||
) error {
|
||||
preferences, err := s.getNotificationPreferencesOrDefault(ctx, recipientID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Sichtbare Benachrichtigungen respektieren die User-Preferences.
|
||||
// Stille Events werden immer erzeugt, weil sie das UI synchronisieren.
|
||||
if !silent && !shouldDeliverNotification(preferences, notificationType, entityType) {
|
||||
return nil
|
||||
}
|
||||
|
||||
var notification Notification
|
||||
var rawData []byte
|
||||
|
||||
err = s.db.QueryRow(
|
||||
ctx,
|
||||
`
|
||||
INSERT INTO notifications (
|
||||
user_id,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
entity_type,
|
||||
entity_id,
|
||||
data,
|
||||
silent
|
||||
)
|
||||
VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
NULLIF($6, '')::UUID,
|
||||
$7::JSONB,
|
||||
$8
|
||||
)
|
||||
RETURNING
|
||||
id::TEXT,
|
||||
user_id::TEXT,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
entity_type,
|
||||
COALESCE(entity_id::TEXT, ''),
|
||||
data,
|
||||
silent,
|
||||
created_at
|
||||
`,
|
||||
recipientID,
|
||||
notificationType,
|
||||
title,
|
||||
message,
|
||||
entityType,
|
||||
entityID,
|
||||
string(dataJSON),
|
||||
silent,
|
||||
).Scan(
|
||||
¬ification.ID,
|
||||
¬ification.UserID,
|
||||
¬ification.Type,
|
||||
¬ification.Title,
|
||||
¬ification.Message,
|
||||
¬ification.EntityType,
|
||||
¬ification.EntityID,
|
||||
&rawData,
|
||||
¬ification.Silent,
|
||||
¬ification.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
notification.Data = json.RawMessage(rawData)
|
||||
|
||||
if !notification.Silent {
|
||||
notification.SoundName = notificationSoundName(preferences)
|
||||
}
|
||||
|
||||
notificationsBroker.publish(notification)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handleNotificationEvents(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := userFromContext(r.Context())
|
||||
if !ok {
|
||||
@ -115,12 +261,14 @@ func (s *Server) handleNotificationEvents(w http.ResponseWriter, r *http.Request
|
||||
|
||||
eventName := "notification"
|
||||
|
||||
if !notification.Visible {
|
||||
if notification.Silent {
|
||||
switch notification.EntityType {
|
||||
case "operation":
|
||||
eventName = "operation-event"
|
||||
case "device":
|
||||
eventName = "device-event"
|
||||
case "user":
|
||||
eventName = "user-event"
|
||||
default:
|
||||
eventName = "notification-event"
|
||||
}
|
||||
@ -152,12 +300,12 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
|
||||
entity_type,
|
||||
COALESCE(entity_id::TEXT, ''),
|
||||
data,
|
||||
visible,
|
||||
silent,
|
||||
read_at,
|
||||
created_at
|
||||
FROM notifications
|
||||
WHERE user_id = $1
|
||||
AND visible = true
|
||||
AND silent = false
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 50
|
||||
`,
|
||||
@ -186,7 +334,7 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
|
||||
¬ification.EntityType,
|
||||
¬ification.EntityID,
|
||||
&data,
|
||||
¬ification.Visible,
|
||||
¬ification.Silent,
|
||||
&readAt,
|
||||
¬ification.CreatedAt,
|
||||
); err != nil {
|
||||
@ -307,76 +455,25 @@ func (s *Server) createOperationNotification(
|
||||
return err
|
||||
}
|
||||
|
||||
var notification Notification
|
||||
var rawData []byte
|
||||
|
||||
err := s.db.QueryRow(
|
||||
if err := s.createAndPublishNotification(
|
||||
ctx,
|
||||
`
|
||||
INSERT INTO notifications (
|
||||
user_id,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
entity_type,
|
||||
entity_id,
|
||||
data,
|
||||
visible
|
||||
)
|
||||
VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
'operation',
|
||||
$5,
|
||||
$6::JSONB,
|
||||
true
|
||||
)
|
||||
RETURNING
|
||||
id::TEXT,
|
||||
user_id::TEXT,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
entity_type,
|
||||
COALESCE(entity_id::TEXT, ''),
|
||||
data,
|
||||
visible,
|
||||
created_at
|
||||
`,
|
||||
recipientID,
|
||||
notificationType,
|
||||
title,
|
||||
message,
|
||||
"operation",
|
||||
operation.ID,
|
||||
string(dataJSON),
|
||||
).Scan(
|
||||
¬ification.ID,
|
||||
¬ification.UserID,
|
||||
¬ification.Type,
|
||||
¬ification.Title,
|
||||
¬ification.Message,
|
||||
¬ification.EntityType,
|
||||
¬ification.EntityID,
|
||||
&rawData,
|
||||
¬ification.Visible,
|
||||
¬ification.CreatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
dataJSON,
|
||||
false,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
notification.Data = json.RawMessage(rawData)
|
||||
|
||||
notificationsBroker.publish(notification)
|
||||
}
|
||||
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func (s *Server) createOperationUpdateEvent(
|
||||
func (s *Server) createOperationCreatedEvent(
|
||||
ctx context.Context,
|
||||
actor User,
|
||||
operation Operation,
|
||||
@ -390,7 +487,7 @@ func (s *Server) createOperationUpdateEvent(
|
||||
rows, err := s.db.Query(
|
||||
ctx,
|
||||
`
|
||||
SELECT id
|
||||
SELECT id::TEXT
|
||||
FROM users
|
||||
WHERE id <> $1
|
||||
AND (
|
||||
@ -412,66 +509,73 @@ func (s *Server) createOperationUpdateEvent(
|
||||
return err
|
||||
}
|
||||
|
||||
var notification Notification
|
||||
var rawData []byte
|
||||
|
||||
err := s.db.QueryRow(
|
||||
if err := s.createAndPublishNotification(
|
||||
ctx,
|
||||
`
|
||||
INSERT INTO notifications (
|
||||
user_id,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
entity_type,
|
||||
entity_id,
|
||||
data,
|
||||
visible
|
||||
)
|
||||
VALUES (
|
||||
$1,
|
||||
'operation.updated',
|
||||
'Einsatz geändert',
|
||||
'',
|
||||
'operation',
|
||||
$2,
|
||||
$3::JSONB,
|
||||
false
|
||||
)
|
||||
RETURNING
|
||||
id::TEXT,
|
||||
user_id::TEXT,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
entity_type,
|
||||
COALESCE(entity_id::TEXT, ''),
|
||||
data,
|
||||
visible,
|
||||
created_at
|
||||
`,
|
||||
recipientID,
|
||||
"operation.created",
|
||||
"Einsatz erstellt",
|
||||
"",
|
||||
"operation",
|
||||
operation.ID,
|
||||
string(dataJSON),
|
||||
).Scan(
|
||||
¬ification.ID,
|
||||
¬ification.UserID,
|
||||
¬ification.Type,
|
||||
¬ification.Title,
|
||||
¬ification.Message,
|
||||
¬ification.EntityType,
|
||||
¬ification.EntityID,
|
||||
&rawData,
|
||||
¬ification.Visible,
|
||||
¬ification.CreatedAt,
|
||||
)
|
||||
dataJSON,
|
||||
true,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func (s *Server) createOperationUpdateEvent(
|
||||
ctx context.Context,
|
||||
actor User,
|
||||
operation Operation,
|
||||
data map[string]any,
|
||||
) error {
|
||||
dataJSON, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(
|
||||
ctx,
|
||||
`
|
||||
SELECT id::TEXT
|
||||
FROM users
|
||||
WHERE id <> $1
|
||||
AND (
|
||||
'admin' = ANY(rights)
|
||||
OR 'operations:read' = ANY(rights)
|
||||
)
|
||||
`,
|
||||
actor.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var recipientID string
|
||||
|
||||
if err := rows.Scan(&recipientID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
notification.Data = json.RawMessage(rawData)
|
||||
notificationsBroker.publish(notification)
|
||||
if err := s.createAndPublishNotification(
|
||||
ctx,
|
||||
recipientID,
|
||||
"operation.updated",
|
||||
"Einsatz geändert",
|
||||
"",
|
||||
"operation",
|
||||
operation.ID,
|
||||
dataJSON,
|
||||
true,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return rows.Err()
|
||||
@ -491,7 +595,7 @@ func (s *Server) createOperationJournalEvent(
|
||||
rows, err := s.db.Query(
|
||||
ctx,
|
||||
`
|
||||
SELECT id
|
||||
SELECT id::TEXT
|
||||
FROM users
|
||||
WHERE id <> $1
|
||||
`,
|
||||
@ -504,70 +608,24 @@ func (s *Server) createOperationJournalEvent(
|
||||
|
||||
for rows.Next() {
|
||||
var recipientID string
|
||||
|
||||
if err := rows.Scan(&recipientID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var notification Notification
|
||||
var rawData []byte
|
||||
|
||||
err := s.db.QueryRow(
|
||||
if err := s.createAndPublishNotification(
|
||||
ctx,
|
||||
`
|
||||
INSERT INTO notifications (
|
||||
user_id,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
entity_type,
|
||||
entity_id,
|
||||
data,
|
||||
visible
|
||||
)
|
||||
VALUES (
|
||||
$1,
|
||||
'operation.journal',
|
||||
'Journal aktualisiert',
|
||||
'Ein Journal-Eintrag wurde aktualisiert.',
|
||||
'operation',
|
||||
$2,
|
||||
$3::JSONB,
|
||||
false
|
||||
)
|
||||
RETURNING
|
||||
id,
|
||||
user_id::TEXT,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
entity_type,
|
||||
COALESCE(entity_id::TEXT, ''),
|
||||
data,
|
||||
visible,
|
||||
created_at
|
||||
`,
|
||||
recipientID,
|
||||
"operation.journal",
|
||||
"Journal aktualisiert",
|
||||
"Ein Journal-Eintrag wurde aktualisiert.",
|
||||
"operation",
|
||||
operation.ID,
|
||||
string(dataJSON),
|
||||
).Scan(
|
||||
¬ification.ID,
|
||||
¬ification.UserID,
|
||||
¬ification.Type,
|
||||
¬ification.Title,
|
||||
¬ification.Message,
|
||||
¬ification.EntityType,
|
||||
¬ification.EntityID,
|
||||
&rawData,
|
||||
¬ification.Visible,
|
||||
¬ification.CreatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
dataJSON,
|
||||
true,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
notification.Data = json.RawMessage(rawData)
|
||||
notificationsBroker.publish(notification)
|
||||
}
|
||||
|
||||
return rows.Err()
|
||||
@ -587,7 +645,7 @@ func (s *Server) createDeviceJournalEvent(
|
||||
rows, err := s.db.Query(
|
||||
ctx,
|
||||
`
|
||||
SELECT id
|
||||
SELECT id::TEXT
|
||||
FROM users
|
||||
WHERE id <> $1
|
||||
AND (
|
||||
@ -604,70 +662,24 @@ func (s *Server) createDeviceJournalEvent(
|
||||
|
||||
for rows.Next() {
|
||||
var recipientID string
|
||||
|
||||
if err := rows.Scan(&recipientID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var notification Notification
|
||||
var rawData []byte
|
||||
|
||||
err := s.db.QueryRow(
|
||||
if err := s.createAndPublishNotification(
|
||||
ctx,
|
||||
`
|
||||
INSERT INTO notifications (
|
||||
user_id,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
entity_type,
|
||||
entity_id,
|
||||
data,
|
||||
visible
|
||||
)
|
||||
VALUES (
|
||||
$1,
|
||||
'device.journal',
|
||||
'Geräte-Journal aktualisiert',
|
||||
'Ein Geräte-Journal-Eintrag wurde hinzugefügt.',
|
||||
'device',
|
||||
$2,
|
||||
$3::JSONB,
|
||||
false
|
||||
)
|
||||
RETURNING
|
||||
id,
|
||||
user_id::TEXT,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
entity_type,
|
||||
COALESCE(entity_id::TEXT, ''),
|
||||
data,
|
||||
visible,
|
||||
created_at
|
||||
`,
|
||||
recipientID,
|
||||
"device.journal",
|
||||
"Geräte-Journal aktualisiert",
|
||||
"Ein Geräte-Journal-Eintrag wurde hinzugefügt.",
|
||||
"device",
|
||||
device.ID,
|
||||
string(dataJSON),
|
||||
).Scan(
|
||||
¬ification.ID,
|
||||
¬ification.UserID,
|
||||
¬ification.Type,
|
||||
¬ification.Title,
|
||||
¬ification.Message,
|
||||
¬ification.EntityType,
|
||||
¬ification.EntityID,
|
||||
&rawData,
|
||||
¬ification.Visible,
|
||||
¬ification.CreatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
dataJSON,
|
||||
true,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
notification.Data = json.RawMessage(rawData)
|
||||
notificationsBroker.publish(notification)
|
||||
}
|
||||
|
||||
return rows.Err()
|
||||
|
||||
101
backend/operation_assignees.go
Normal file
101
backend/operation_assignees.go
Normal 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
|
||||
}
|
||||
@ -333,6 +333,19 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) {
|
||||
// nicht abbrechen, Einsatz wurde bereits erstellt
|
||||
}
|
||||
|
||||
if err := s.createOperationCreatedEvent(
|
||||
r.Context(),
|
||||
user,
|
||||
operation,
|
||||
map[string]any{
|
||||
"operationNumber": operation.OperationNumber,
|
||||
"operationName": operation.OperationName,
|
||||
"action": "created",
|
||||
},
|
||||
); err != nil {
|
||||
// nicht abbrechen, Einsatz wurde bereits erstellt
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"operation": operation,
|
||||
})
|
||||
@ -1051,7 +1064,7 @@ func (s *Server) handleListUnreadOperationJournals(w http.ResponseWriter, r *htt
|
||||
SELECT entity_id::TEXT
|
||||
FROM notifications
|
||||
WHERE user_id = $1
|
||||
AND visible = false
|
||||
AND silent = true
|
||||
AND type = 'operation.journal'
|
||||
AND entity_type = 'operation'
|
||||
AND entity_id IS NOT NULL
|
||||
@ -1103,7 +1116,7 @@ func (s *Server) handleMarkOperationJournalRead(w http.ResponseWriter, r *http.R
|
||||
UPDATE notifications
|
||||
SET read_at = COALESCE(read_at, now())
|
||||
WHERE user_id = $1
|
||||
AND visible = false
|
||||
AND silent = true
|
||||
AND type = 'operation.journal'
|
||||
AND entity_type = 'operation'
|
||||
AND entity_id = $2
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// backend/routes.go
|
||||
// backend\routes.go
|
||||
|
||||
package main
|
||||
|
||||
@ -29,9 +29,12 @@ func (s *Server) Routes() http.Handler {
|
||||
mux.HandleFunc("GET /devices", s.requireAuth(s.handleListDevices))
|
||||
mux.HandleFunc("POST /devices", s.requireAuth(s.handleCreateDevice))
|
||||
mux.HandleFunc("PUT /devices/{id}", s.requireAuth(s.handleUpdateDevice))
|
||||
mux.HandleFunc("POST /devices/{id}/milestone-toggle", s.requireAuth(s.handleToggleMilestoneDeviceStatus))
|
||||
|
||||
mux.HandleFunc("GET /devices/{id}/history", s.requireAuth(s.handleListDeviceHistory))
|
||||
mux.HandleFunc("POST /devices/{id}/history", s.requireAuth(s.handleCreateDeviceJournalEntry))
|
||||
mux.HandleFunc("PUT /devices/{id}/history/{historyId}", s.requireAuth(s.handleUpdateDeviceJournalEntry))
|
||||
|
||||
mux.HandleFunc("PATCH /devices/{id}/milestone", s.requireAuth(s.handleUpdateMilestoneHardware))
|
||||
|
||||
mux.HandleFunc("GET /devices/firmware-check", s.requireAuth(s.handleGetCameraFirmwareCheckState))
|
||||
mux.HandleFunc("POST /devices/firmware-check", s.requireAuth(s.handleCheckCameraFirmware))
|
||||
@ -42,16 +45,21 @@ func (s *Server) Routes() http.Handler {
|
||||
|
||||
mux.HandleFunc("GET /operations", s.requireAuth(s.requireRight("operations:read", s.handleListOperations)))
|
||||
mux.HandleFunc("POST /operations", s.requireAuth(s.requireRight("operations:write", s.handleCreateOperation)))
|
||||
|
||||
mux.HandleFunc("PUT /operations/{id}", s.requireAuth(s.requireRight("operations:write", s.handleUpdateOperation)))
|
||||
mux.HandleFunc("GET /operations/{id}/history", s.requireAuth(s.handleListOperationHistory))
|
||||
mux.HandleFunc("POST /operations/{id}/history", s.requireAuth(s.handleCreateOperationJournalEntry))
|
||||
mux.HandleFunc("PUT /operations/{id}/history/{historyId}", s.requireAuth(s.handleUpdateOperationJournalEntry))
|
||||
mux.HandleFunc("GET /operations/journal-unread", s.requireAuth(s.handleListUnreadOperationJournals))
|
||||
mux.HandleFunc("POST /operations/{id}/journal/read", s.requireAuth(s.handleMarkOperationJournalRead))
|
||||
|
||||
mux.HandleFunc("GET /operations/journal-unread", s.requireAuth(s.handleListUnreadOperationJournals))
|
||||
|
||||
mux.HandleFunc("GET /operations/assignees", s.requireAuth(s.requireRight("operations:read", s.handleListOperationAssignees)))
|
||||
|
||||
mux.HandleFunc("GET /search", s.requireAuth(s.handleGlobalSearch))
|
||||
|
||||
mux.HandleFunc("GET /address-search", s.requireAuth(s.handleAddressSearch))
|
||||
mux.HandleFunc("GET /address-reverse", s.requireAuth(s.handleAddressReverse))
|
||||
|
||||
mux.HandleFunc("GET /notifications", s.requireAuth(s.handleListNotifications))
|
||||
mux.HandleFunc("POST /notifications/{id}/read", s.requireAuth(s.handleMarkNotificationRead))
|
||||
@ -75,6 +83,9 @@ func (s *Server) Routes() http.Handler {
|
||||
mux.HandleFunc("POST /admin/users", s.requireAuth(s.requireRight("users:write", s.handleAdminCreateUser)))
|
||||
mux.HandleFunc("PUT /admin/users/{id}", s.requireAuth(s.requireRight("users:write", s.handleAdminUpdateUser)))
|
||||
|
||||
mux.HandleFunc("GET /admin/user-units", s.requireAuth(s.requireRight("users:read", s.handleListUserUnits)))
|
||||
mux.HandleFunc("POST /admin/user-units", s.requireAuth(s.requireRight("users:write", s.handleCreateUserUnit)))
|
||||
|
||||
mux.HandleFunc("GET /admin/teams", s.requireAuth(s.requireRight("teams:read", s.handleAdminListTeams)))
|
||||
mux.HandleFunc("POST /admin/teams", s.requireAuth(s.requireRight("teams:write", s.handleAdminCreateTeam)))
|
||||
mux.HandleFunc("PUT /admin/teams/{id}", s.requireAuth(s.requireRight("teams:write", s.handleAdminUpdateTeam)))
|
||||
|
||||
@ -264,6 +264,18 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
||||
)
|
||||
`,
|
||||
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS user_units (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
name TEXT NOT NULL,
|
||||
normalized_name TEXT NOT NULL UNIQUE,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
`,
|
||||
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS user_teams (
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
@ -326,6 +338,7 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
||||
milestone_hardware_id TEXT NOT NULL DEFAULT '',
|
||||
milestone_display_name TEXT NOT NULL DEFAULT '',
|
||||
firmware_version TEXT NOT NULL DEFAULT '',
|
||||
milestone_port TEXT NOT NULL DEFAULT '',
|
||||
milestone_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
milestone_synced_at TIMESTAMPTZ,
|
||||
|
||||
@ -380,7 +393,8 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
||||
action TEXT NOT NULL,
|
||||
changes JSONB NOT NULL DEFAULT '[]'::JSONB,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
`,
|
||||
|
||||
@ -434,7 +448,7 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
||||
type TEXT NOT NULL DEFAULT 'info',
|
||||
title TEXT NOT NULL,
|
||||
message TEXT NOT NULL DEFAULT '',
|
||||
visible BOOLEAN NOT NULL DEFAULT true,
|
||||
silent BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
entity_type TEXT NOT NULL DEFAULT '',
|
||||
entity_id UUID,
|
||||
@ -476,6 +490,7 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
||||
|
||||
in_app_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
sound_enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
sound_name TEXT NOT NULL DEFAULT 'default',
|
||||
email_enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
operation_updates BOOLEAN NOT NULL DEFAULT true,
|
||||
@ -564,11 +579,15 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
||||
`CREATE INDEX IF NOT EXISTS idx_user_teams_user_id ON user_teams(user_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_user_teams_team_id ON user_teams(team_id)`,
|
||||
|
||||
`CREATE INDEX IF NOT EXISTS idx_user_units_name ON user_units(name)`,
|
||||
|
||||
`CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_user_id ON dashboard_widgets(user_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_position ON dashboard_widgets(user_id, y, x)`,
|
||||
|
||||
`CREATE INDEX IF NOT EXISTS idx_dashboard_settings_user_id ON dashboard_settings(user_id)`,
|
||||
|
||||
`ALTER TABLE devices ADD COLUMN IF NOT EXISTS milestone_port TEXT NOT NULL DEFAULT ''`,
|
||||
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_inventory_number_unique ON devices(lower(inventory_number))`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_serial_number_unique ON devices(lower(serial_number)) WHERE serial_number <> ''`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_mac_address_unique ON devices(lower(mac_address)) WHERE mac_address <> ''`,
|
||||
|
||||
33
backend/user_events.go
Normal file
33
backend/user_events.go
Normal 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,
|
||||
})
|
||||
}
|
||||
@ -114,6 +114,8 @@ func (s *Server) handleUpdateSettingsAccount(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
|
||||
s.publishUserProfileEvent("user.updated", user)
|
||||
|
||||
writeSettingsJSON(w, http.StatusOK, map[string]any{
|
||||
"message": "Profil gespeichert",
|
||||
"user": user,
|
||||
|
||||
21
backend/user_unit_lookups.go
Normal file
21
backend/user_unit_lookups.go
Normal 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")
|
||||
}
|
||||
1
frontend/package-lock.json
generated
1
frontend/package-lock.json
generated
@ -22,6 +22,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^24.12.3",
|
||||
"@types/react": "^19.2.14",
|
||||
|
||||
@ -24,6 +24,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^24.12.3",
|
||||
"@types/react": "^19.2.14",
|
||||
|
||||
BIN
frontend/public/sounds/notifications/alert.mp3
Normal file
BIN
frontend/public/sounds/notifications/alert.mp3
Normal file
Binary file not shown.
BIN
frontend/public/sounds/notifications/chime.mp3
Normal file
BIN
frontend/public/sounds/notifications/chime.mp3
Normal file
Binary file not shown.
BIN
frontend/public/sounds/notifications/default.mp3
Normal file
BIN
frontend/public/sounds/notifications/default.mp3
Normal file
Binary file not shown.
BIN
frontend/public/sounds/notifications/soft.mp3
Normal file
BIN
frontend/public/sounds/notifications/soft.mp3
Normal file
Binary file not shown.
BIN
frontend/public/sounds/notifications/success.mp3
Normal file
BIN
frontend/public/sounds/notifications/success.mp3
Normal file
Binary file not shown.
@ -1,7 +1,8 @@
|
||||
// frontend/src/App.tsx
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Navigate, Route, Routes } from 'react-router'
|
||||
import { Navigate, Route, Routes, useNavigate } from 'react-router'
|
||||
import { NotificationProvider } from './components/Notifications'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import AppLayout from './components/AppLayout'
|
||||
import DashboardPage from './pages/DashboardPage'
|
||||
@ -29,6 +30,52 @@ function parseNotificationEvent(event: MessageEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
function getNotificationData(notification: Notification) {
|
||||
const data = (notification as { data?: unknown }).data
|
||||
|
||||
if (!data) {
|
||||
return {}
|
||||
}
|
||||
|
||||
if (typeof data === 'string') {
|
||||
try {
|
||||
return JSON.parse(data) as Record<string, unknown>
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof data === 'object') {
|
||||
return data as Record<string, unknown>
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
function getUserFromNotification(notification: Notification) {
|
||||
const data = getNotificationData(notification)
|
||||
const user = data.user
|
||||
|
||||
if (!user || typeof user !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
return user as User
|
||||
}
|
||||
|
||||
function playNotificationSound(soundName?: string) {
|
||||
if (!soundName) {
|
||||
return
|
||||
}
|
||||
|
||||
const audio = new Audio(`/sounds/notifications/${soundName}.mp3`)
|
||||
audio.volume = 0.6
|
||||
|
||||
void audio.play().catch(() => {
|
||||
// Browser blockiert Audio ggf., bis der User einmal mit der Seite interagiert hat.
|
||||
})
|
||||
}
|
||||
|
||||
function RequireRight({
|
||||
user,
|
||||
right,
|
||||
@ -46,6 +93,8 @@ function RequireRight({
|
||||
}
|
||||
|
||||
function App() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
@ -77,10 +126,12 @@ function App() {
|
||||
})
|
||||
|
||||
function addVisibleNotification(notification: Notification) {
|
||||
if (notification.visible === false) {
|
||||
if (notification.silent) {
|
||||
return
|
||||
}
|
||||
|
||||
playNotificationSound(notification.soundName)
|
||||
|
||||
setNotifications((currentNotifications) => [
|
||||
notification,
|
||||
...currentNotifications.filter((item) => item.id !== notification.id),
|
||||
@ -151,14 +202,48 @@ function App() {
|
||||
dispatchEntityEvent(notification)
|
||||
}
|
||||
|
||||
function handleUserEvent(event: MessageEvent) {
|
||||
const notification = parseNotificationEvent(event)
|
||||
|
||||
if (!notification) {
|
||||
return
|
||||
}
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('user-notification', {
|
||||
detail: notification,
|
||||
}),
|
||||
)
|
||||
|
||||
const updatedUser = getUserFromNotification(notification)
|
||||
|
||||
if (!updatedUser) {
|
||||
void loadUser()
|
||||
return
|
||||
}
|
||||
|
||||
setUser((currentUser) => {
|
||||
if (!currentUser || currentUser.id !== updatedUser.id) {
|
||||
return currentUser
|
||||
}
|
||||
|
||||
return {
|
||||
...currentUser,
|
||||
...updatedUser,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
eventSource.addEventListener('notification', handleNotificationEvent)
|
||||
eventSource.addEventListener('operation-event', handleOperationEvent)
|
||||
eventSource.addEventListener('device-event', handleDeviceEvent)
|
||||
eventSource.addEventListener('user-event', handleUserEvent)
|
||||
|
||||
return () => {
|
||||
eventSource.removeEventListener('notification', handleNotificationEvent)
|
||||
eventSource.removeEventListener('operation-event', handleOperationEvent)
|
||||
eventSource.removeEventListener('device-event', handleDeviceEvent)
|
||||
eventSource.removeEventListener('user-event', handleUserEvent)
|
||||
eventSource.close()
|
||||
}
|
||||
}, [user])
|
||||
@ -264,6 +349,15 @@ function App() {
|
||||
}).catch(() => undefined)
|
||||
}
|
||||
|
||||
function handleNotificationClick(notification: Notification) {
|
||||
if (
|
||||
notification.type === 'operation.created' ||
|
||||
notification.entityType === 'operation'
|
||||
) {
|
||||
navigate('/einsaetze')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await fetch(`${API_URL}/auth/logout`, {
|
||||
@ -300,101 +394,104 @@ function App() {
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayout
|
||||
user={user}
|
||||
onLogout={handleLogout}
|
||||
notificationCenter={
|
||||
<NotificationCenter
|
||||
notifications={notifications}
|
||||
unreadCount={unreadNotificationCount}
|
||||
onMarkRead={markNotificationRead}
|
||||
onMarkAllRead={markAllNotificationsRead}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/" element={<DashboardPage username={user.username} />} />
|
||||
<NotificationProvider>
|
||||
<AppLayout
|
||||
user={user}
|
||||
onLogout={handleLogout}
|
||||
notificationCenter={
|
||||
<NotificationCenter
|
||||
notifications={notifications}
|
||||
unreadCount={unreadNotificationCount}
|
||||
onMarkRead={markNotificationRead}
|
||||
onMarkAllRead={markAllNotificationsRead}
|
||||
onNotificationClick={handleNotificationClick}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/" element={<DashboardPage username={user.username} />} />
|
||||
|
||||
<Route
|
||||
path="/geraete"
|
||||
element={
|
||||
<RequireRight user={user} right="devices:read">
|
||||
<DevicesPage />
|
||||
</RequireRight>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/geraete"
|
||||
element={
|
||||
<RequireRight user={user} right="devices:read">
|
||||
<DevicesPage />
|
||||
</RequireRight>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/einsaetze"
|
||||
element={
|
||||
<RequireRight user={user} right="operations:read">
|
||||
<OperationsPage
|
||||
unreadJournalOperationIds={unreadJournalOperationIds}
|
||||
onJournalRead={markOperationJournalRead}
|
||||
canWriteOperations={hasRight(user, 'operations:write')}
|
||||
/>
|
||||
</RequireRight>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/einsaetze"
|
||||
element={
|
||||
<RequireRight user={user} right="operations:read">
|
||||
<OperationsPage
|
||||
unreadJournalOperationIds={unreadJournalOperationIds}
|
||||
onJournalRead={markOperationJournalRead}
|
||||
canWriteOperations={hasRight(user, 'operations:write')}
|
||||
/>
|
||||
</RequireRight>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/kalender"
|
||||
element={
|
||||
<RequireRight user={user} right="calendar:read">
|
||||
<CalendarPage />
|
||||
</RequireRight>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/kalender"
|
||||
element={
|
||||
<RequireRight user={user} right="calendar:read">
|
||||
<CalendarPage />
|
||||
</RequireRight>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/dokumente"
|
||||
element={
|
||||
<RequireRight user={user} right="documents:read">
|
||||
<DocumentsPage />
|
||||
</RequireRight>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/dokumente"
|
||||
element={
|
||||
<RequireRight user={user} right="documents:read">
|
||||
<DocumentsPage />
|
||||
</RequireRight>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/berichte"
|
||||
element={
|
||||
<RequireRight user={user} right="reports:read">
|
||||
<ReportsPage />
|
||||
</RequireRight>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/berichte"
|
||||
element={
|
||||
<RequireRight user={user} right="reports:read">
|
||||
<ReportsPage />
|
||||
</RequireRight>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/administration"
|
||||
element={<Navigate to="/administration/benutzer" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="/administration"
|
||||
element={<Navigate to="/administration/benutzer" replace />}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/administration/*"
|
||||
element={
|
||||
<RequireRight user={user} right="administration:read">
|
||||
<AdministrationPage />
|
||||
</RequireRight>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/administration/*"
|
||||
element={
|
||||
<RequireRight user={user} right="administration:read">
|
||||
<AdministrationPage />
|
||||
</RequireRight>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/einstellungen/*"
|
||||
element={
|
||||
<RequireRight user={user} right="settings:read">
|
||||
<SettingsPage
|
||||
user={user}
|
||||
onUserUpdated={setUser}
|
||||
/>
|
||||
</RequireRight>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/einstellungen/*"
|
||||
element={
|
||||
<RequireRight user={user} right="settings:read">
|
||||
<SettingsPage
|
||||
user={user}
|
||||
onUserUpdated={setUser}
|
||||
/>
|
||||
</RequireRight>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path="/feedback" element={<Feedback />} />
|
||||
<Route path="/feedback" element={<Feedback />} />
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</AppLayout>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</AppLayout>
|
||||
</NotificationProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -8,8 +8,10 @@ import {
|
||||
ComboboxOptions,
|
||||
Label,
|
||||
} from '@headlessui/react'
|
||||
import AddressMapPicker from './AddressMapPicker'
|
||||
import type { GeoJsonObject } from 'geojson'
|
||||
import { ChevronDownIcon } from '@heroicons/react/20/solid'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||
|
||||
@ -19,6 +21,16 @@ export type AddressSuggestion = {
|
||||
lat: number
|
||||
lon: number
|
||||
type?: string
|
||||
geojson?: GeoJsonObject | null
|
||||
}
|
||||
|
||||
type Coordinates = {
|
||||
lat: number
|
||||
lon: number
|
||||
}
|
||||
|
||||
type ReverseGeocodeResponse = {
|
||||
suggestion?: AddressSuggestion
|
||||
}
|
||||
|
||||
type AddressComboboxProps = {
|
||||
@ -28,6 +40,7 @@ type AddressComboboxProps = {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
mapVisible?: boolean
|
||||
}
|
||||
|
||||
const inputClassName =
|
||||
@ -37,21 +50,34 @@ function classNames(...classes: Array<string | false | null | undefined>) {
|
||||
return classes.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
function buildOpenStreetMapEmbedUrl(address: AddressSuggestion) {
|
||||
const offset = 0.004
|
||||
function toNumber(value: unknown) {
|
||||
if (typeof value === 'number') {
|
||||
return value
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
bbox: [
|
||||
address.lon - offset,
|
||||
address.lat - offset,
|
||||
address.lon + offset,
|
||||
address.lat + offset,
|
||||
].join(','),
|
||||
layer: 'mapnik',
|
||||
marker: `${address.lat},${address.lon}`,
|
||||
})
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : NaN
|
||||
}
|
||||
|
||||
return `https://www.openstreetmap.org/export/embed.html?${params.toString()}`
|
||||
return NaN
|
||||
}
|
||||
|
||||
function normalizeAddressSuggestion(
|
||||
suggestion: AddressSuggestion,
|
||||
): AddressSuggestion | null {
|
||||
const lat = toNumber(suggestion.lat)
|
||||
const lon = toNumber(suggestion.lon)
|
||||
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
...suggestion,
|
||||
lat,
|
||||
lon,
|
||||
}
|
||||
}
|
||||
|
||||
export default function AddressCombobox({
|
||||
@ -61,26 +87,223 @@ export default function AddressCombobox({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Adresse suchen',
|
||||
mapVisible = true,
|
||||
}: AddressComboboxProps) {
|
||||
const [query, setQuery] = useState(value)
|
||||
const [suggestions, setSuggestions] = useState<AddressSuggestion[]>([])
|
||||
const [selectedSuggestion, setSelectedSuggestion] = useState<AddressSuggestion | null>(null)
|
||||
const [previewSuggestion, setPreviewSuggestion] = useState<AddressSuggestion | null>(null)
|
||||
const [markerPosition, setMarkerPosition] = useState<{
|
||||
lat: number
|
||||
lon: number
|
||||
} | null>(null)
|
||||
const [mapFocusKey, setMapFocusKey] = useState(0)
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [searchError, setSearchError] = useState<string | null>(null)
|
||||
|
||||
const previousValueRef = useRef(value)
|
||||
const initializedAddressRef = useRef('')
|
||||
const previousMapVisibleRef = useRef(mapVisible)
|
||||
|
||||
const reverseGeocodeAbortRef = useRef<AbortController | null>(null)
|
||||
const internalValueUpdateRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (value !== previousValueRef.current && value !== query) {
|
||||
if (value !== previousValueRef.current) {
|
||||
setQuery(value)
|
||||
setSelectedSuggestion(null)
|
||||
setPreviewSuggestion(null)
|
||||
|
||||
if (internalValueUpdateRef.current) {
|
||||
internalValueUpdateRef.current = false
|
||||
previousValueRef.current = value
|
||||
return
|
||||
}
|
||||
|
||||
if (value !== query) {
|
||||
initializedAddressRef.current = ''
|
||||
|
||||
setSelectedSuggestion(null)
|
||||
setPreviewSuggestion(null)
|
||||
setMarkerPosition(null)
|
||||
setMapFocusKey((currentKey) => currentKey + 1)
|
||||
}
|
||||
}
|
||||
|
||||
previousValueRef.current = value
|
||||
}, [value, query])
|
||||
|
||||
useEffect(() => {
|
||||
const becameVisible = mapVisible && !previousMapVisibleRef.current
|
||||
|
||||
previousMapVisibleRef.current = mapVisible
|
||||
|
||||
if (!becameVisible) {
|
||||
return
|
||||
}
|
||||
|
||||
if (markerPosition) {
|
||||
setMapFocusKey((currentKey) => currentKey + 1)
|
||||
return
|
||||
}
|
||||
|
||||
initializedAddressRef.current = ''
|
||||
|
||||
void loadInitialMarkerForAddress(value)
|
||||
}, [mapVisible, markerPosition, value])
|
||||
|
||||
useEffect(() => {
|
||||
const trimmedValue = value.trim()
|
||||
|
||||
if (trimmedValue.length < 3) {
|
||||
initializedAddressRef.current = ''
|
||||
return
|
||||
}
|
||||
|
||||
if (markerPosition) {
|
||||
return
|
||||
}
|
||||
|
||||
const abortController = new AbortController()
|
||||
|
||||
void loadInitialMarkerForAddress(trimmedValue, abortController.signal)
|
||||
|
||||
return () => {
|
||||
abortController.abort()
|
||||
}
|
||||
}, [value, markerPosition])
|
||||
|
||||
async function loadInitialMarkerForAddress(
|
||||
address: string,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const trimmedAddress = address.trim()
|
||||
|
||||
if (trimmedAddress.length < 3) {
|
||||
return
|
||||
}
|
||||
|
||||
if (initializedAddressRef.current === trimmedAddress && markerPosition) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_URL}/address-search?q=${encodeURIComponent(trimmedAddress)}`,
|
||||
{
|
||||
credentials: 'include',
|
||||
signal,
|
||||
},
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
return
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
const firstSuggestion = ((data.suggestions ?? []) as AddressSuggestion[])
|
||||
.map(normalizeAddressSuggestion)
|
||||
.find((suggestion): suggestion is AddressSuggestion => suggestion !== null)
|
||||
|
||||
if (!firstSuggestion) {
|
||||
return
|
||||
}
|
||||
|
||||
const reverseSuggestion = await reverseGeocodeCoordinates({
|
||||
lat: firstSuggestion.lat,
|
||||
lon: firstSuggestion.lon,
|
||||
})
|
||||
|
||||
const nextSuggestion: AddressSuggestion = {
|
||||
...firstSuggestion,
|
||||
geojson: reverseSuggestion?.geojson ?? firstSuggestion.geojson ?? null,
|
||||
}
|
||||
|
||||
initializedAddressRef.current = trimmedAddress
|
||||
|
||||
setSelectedSuggestion(nextSuggestion)
|
||||
setPreviewSuggestion(nextSuggestion)
|
||||
setMarkerPosition({
|
||||
lat: nextSuggestion.lat,
|
||||
lon: nextSuggestion.lon,
|
||||
})
|
||||
setMapFocusKey((currentKey) => currentKey + 1)
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function reverseGeocodeCoordinates(
|
||||
coordinates: Coordinates,
|
||||
): Promise<AddressSuggestion | null> {
|
||||
reverseGeocodeAbortRef.current?.abort()
|
||||
|
||||
const abortController = new AbortController()
|
||||
reverseGeocodeAbortRef.current = abortController
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_URL}/address-reverse?lat=${encodeURIComponent(
|
||||
coordinates.lat,
|
||||
)}&lon=${encodeURIComponent(coordinates.lon)}`,
|
||||
{
|
||||
credentials: 'include',
|
||||
signal: abortController.signal,
|
||||
},
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
return null
|
||||
}
|
||||
|
||||
const data = (await response.json()) as ReverseGeocodeResponse
|
||||
|
||||
if (!data.suggestion) {
|
||||
return null
|
||||
}
|
||||
|
||||
const normalizedSuggestion = normalizeAddressSuggestion(data.suggestion)
|
||||
|
||||
if (!normalizedSuggestion) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
...normalizedSuggestion,
|
||||
lat: coordinates.lat,
|
||||
lon: coordinates.lon,
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
return null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMapPositionChange(coordinates: Coordinates) {
|
||||
setMarkerPosition(coordinates)
|
||||
setSelectedSuggestion(null)
|
||||
setPreviewSuggestion(null)
|
||||
|
||||
const suggestion = await reverseGeocodeCoordinates(coordinates)
|
||||
|
||||
if (!suggestion) {
|
||||
return
|
||||
}
|
||||
|
||||
initializedAddressRef.current = suggestion.label
|
||||
internalValueUpdateRef.current = true
|
||||
|
||||
setQuery(suggestion.label)
|
||||
setSelectedSuggestion(suggestion)
|
||||
setPreviewSuggestion(suggestion)
|
||||
|
||||
onChange(suggestion.label)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const trimmedQuery = query.trim()
|
||||
|
||||
@ -113,10 +336,14 @@ export default function AddressCombobox({
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const nextSuggestions = (data.suggestions ?? []) as AddressSuggestion[]
|
||||
const nextSuggestions = ((data.suggestions ?? []) as AddressSuggestion[])
|
||||
.map(normalizeAddressSuggestion)
|
||||
.filter((suggestion): suggestion is AddressSuggestion => suggestion !== null)
|
||||
|
||||
const nextPreview = nextSuggestions[0] ?? null
|
||||
|
||||
setSuggestions(nextSuggestions)
|
||||
setPreviewSuggestion((currentPreview) => currentPreview ?? nextSuggestions[0] ?? null)
|
||||
setPreviewSuggestion((currentPreview) => currentPreview ?? nextPreview)
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
return
|
||||
@ -140,33 +367,58 @@ export default function AddressCombobox({
|
||||
}
|
||||
}, [query])
|
||||
|
||||
const mapUrl = useMemo(() => {
|
||||
if (!previewSuggestion) {
|
||||
return null
|
||||
}
|
||||
|
||||
return buildOpenStreetMapEmbedUrl(previewSuggestion)
|
||||
}, [previewSuggestion])
|
||||
|
||||
function handleInputChange(nextValue: string) {
|
||||
reverseGeocodeAbortRef.current?.abort()
|
||||
internalValueUpdateRef.current = false
|
||||
initializedAddressRef.current = ''
|
||||
|
||||
setQuery(nextValue)
|
||||
setSelectedSuggestion(null)
|
||||
setPreviewSuggestion(null)
|
||||
setMarkerPosition(null)
|
||||
onChange(nextValue)
|
||||
}
|
||||
|
||||
function handleSelect(suggestion: AddressSuggestion | null) {
|
||||
setSelectedSuggestion(suggestion)
|
||||
async function handleSelect(suggestion: AddressSuggestion | null) {
|
||||
const normalizedSuggestion = suggestion
|
||||
? normalizeAddressSuggestion(suggestion)
|
||||
: null
|
||||
|
||||
if (!suggestion) {
|
||||
setSelectedSuggestion(normalizedSuggestion)
|
||||
|
||||
if (!normalizedSuggestion) {
|
||||
return
|
||||
}
|
||||
|
||||
setQuery(suggestion.label)
|
||||
setPreviewSuggestion(suggestion)
|
||||
onChange(suggestion.label)
|
||||
setMarkerPosition({
|
||||
lat: normalizedSuggestion.lat,
|
||||
lon: normalizedSuggestion.lon,
|
||||
})
|
||||
setMapFocusKey((currentKey) => currentKey + 1)
|
||||
|
||||
const reverseSuggestion = await reverseGeocodeCoordinates({
|
||||
lat: normalizedSuggestion.lat,
|
||||
lon: normalizedSuggestion.lon,
|
||||
})
|
||||
|
||||
const nextSuggestion: AddressSuggestion = {
|
||||
...normalizedSuggestion,
|
||||
label: reverseSuggestion?.label ?? normalizedSuggestion.label,
|
||||
geojson: reverseSuggestion?.geojson ?? normalizedSuggestion.geojson ?? null,
|
||||
}
|
||||
|
||||
initializedAddressRef.current = nextSuggestion.label
|
||||
internalValueUpdateRef.current = true
|
||||
|
||||
setQuery(nextSuggestion.label)
|
||||
setSelectedSuggestion(nextSuggestion)
|
||||
setPreviewSuggestion(nextSuggestion)
|
||||
|
||||
onChange(nextSuggestion.label)
|
||||
}
|
||||
|
||||
const mapKey = `${mapVisible ? 'visible' : 'hidden'}-${mapFocusKey}`
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input type="hidden" name={name} value={value} />
|
||||
@ -196,7 +448,7 @@ export default function AddressCombobox({
|
||||
{(suggestions.length > 0 || isSearching || searchError) && (
|
||||
<ComboboxOptions
|
||||
transition
|
||||
className="absolute z-20 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg outline outline-black/5 data-leave:transition data-leave:duration-100 data-leave:ease-in data-closed:data-leave:opacity-0 sm:text-sm dark:bg-gray-800 dark:shadow-none dark:-outline-offset-1 dark:outline-white/10"
|
||||
className="absolute z-[1000] mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg outline outline-black/5 data-leave:transition data-leave:duration-100 data-leave:ease-in data-closed:data-leave:opacity-0 sm:text-sm dark:bg-gray-800 dark:shadow-none dark:-outline-offset-1 dark:outline-white/10"
|
||||
>
|
||||
{isSearching && (
|
||||
<div className="px-3 py-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
@ -230,22 +482,14 @@ export default function AddressCombobox({
|
||||
</div>
|
||||
</HeadlessCombobox>
|
||||
|
||||
{mapUrl && previewSuggestion && (
|
||||
<div className="mt-3 overflow-hidden rounded-lg border border-gray-200 bg-gray-50 dark:border-white/10 dark:bg-white/5">
|
||||
<iframe
|
||||
title={`OpenStreetMap Vorschau: ${previewSuggestion.label}`}
|
||||
src={mapUrl}
|
||||
className="h-56 w-full border-0"
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
<div className="border-t border-gray-200 px-3 py-2 text-xs text-gray-500 dark:border-white/10 dark:text-gray-400">
|
||||
Vorschau: {previewSuggestion.label}
|
||||
{' · '}
|
||||
© OpenStreetMap-Mitwirkende
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<AddressMapPicker
|
||||
key={mapKey}
|
||||
value={markerPosition}
|
||||
focusKey={mapFocusKey}
|
||||
visible={mapVisible}
|
||||
highlightGeoJson={previewSuggestion?.geojson ?? null}
|
||||
onChange={handleMapPositionChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
332
frontend/src/components/AddressMapPicker.tsx
Normal file
332
frontend/src/components/AddressMapPicker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -50,7 +50,7 @@ export default function AppLayout({
|
||||
onNavigate={handleNavigation}
|
||||
/>
|
||||
|
||||
<div className="flex h-dvh min-w-0 flex-col lg:pl-72">
|
||||
<div className="relative z-0 flex h-dvh min-w-0 flex-col lg:pl-72">
|
||||
<Topbar
|
||||
user={user}
|
||||
onLogout={onLogout}
|
||||
@ -61,7 +61,7 @@ export default function AppLayout({
|
||||
onNavigate={handleNavigation}
|
||||
/>
|
||||
|
||||
<main className="relative min-h-0 flex-1 overflow-hidden">
|
||||
<main className="relative z-0 isolate min-h-0 flex-1 overflow-hidden">
|
||||
{children}
|
||||
|
||||
<Transition
|
||||
@ -73,7 +73,7 @@ export default function AppLayout({
|
||||
leave="transition-opacity duration-150 ease-in"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
className="absolute inset-0 z-30"
|
||||
className="absolute inset-0 z-[9000]"
|
||||
>
|
||||
<SearchOverlay
|
||||
user={user}
|
||||
|
||||
@ -25,6 +25,7 @@ type BadgeProps = Omit<HTMLAttributes<HTMLSpanElement>, "color"> & {
|
||||
dot?: boolean;
|
||||
leadingIcon?: ReactNode;
|
||||
trailingIcon?: ReactNode;
|
||||
hover?: boolean;
|
||||
onRemove?: () => void;
|
||||
removeLabel?: string;
|
||||
};
|
||||
@ -64,6 +65,37 @@ const badgeClasses: Record<BadgeVariant, Record<BadgeTone, string>> = {
|
||||
},
|
||||
};
|
||||
|
||||
const badgeHoverClasses: Record<BadgeVariant, Record<BadgeTone, string>> = {
|
||||
border: {
|
||||
gray: "hover:bg-gray-100 hover:text-gray-700 hover:inset-ring-gray-500/20 dark:hover:bg-gray-400/20 dark:hover:text-gray-300",
|
||||
red: "hover:bg-red-100 hover:text-red-800 hover:inset-ring-red-600/20 dark:hover:bg-red-400/20 dark:hover:text-red-300",
|
||||
yellow:
|
||||
"hover:bg-yellow-100 hover:text-yellow-900 hover:inset-ring-yellow-600/30 dark:hover:bg-yellow-400/20 dark:hover:text-yellow-300",
|
||||
green:
|
||||
"hover:bg-green-100 hover:text-green-800 hover:inset-ring-green-600/30 dark:hover:bg-green-400/20 dark:hover:text-green-300",
|
||||
blue: "hover:bg-blue-100 hover:text-blue-800 hover:inset-ring-blue-700/20 dark:hover:bg-blue-400/20 dark:hover:text-blue-300",
|
||||
indigo:
|
||||
"hover:bg-indigo-100 hover:text-indigo-800 hover:inset-ring-indigo-700/20 dark:hover:bg-indigo-400/20 dark:hover:text-indigo-300",
|
||||
purple:
|
||||
"hover:bg-purple-100 hover:text-purple-800 hover:inset-ring-purple-700/20 dark:hover:bg-purple-400/20 dark:hover:text-purple-300",
|
||||
pink: "hover:bg-pink-100 hover:text-pink-800 hover:inset-ring-pink-700/20 dark:hover:bg-pink-400/20 dark:hover:text-pink-300",
|
||||
},
|
||||
flat: {
|
||||
gray: "hover:bg-gray-200 hover:text-gray-700 dark:hover:bg-gray-400/20 dark:hover:text-gray-300",
|
||||
red: "hover:bg-red-200 hover:text-red-800 dark:hover:bg-red-400/20 dark:hover:text-red-300",
|
||||
yellow:
|
||||
"hover:bg-yellow-200 hover:text-yellow-900 dark:hover:bg-yellow-400/20 dark:hover:text-yellow-300",
|
||||
green:
|
||||
"hover:bg-green-200 hover:text-green-800 dark:hover:bg-green-400/20 dark:hover:text-green-300",
|
||||
blue: "hover:bg-blue-200 hover:text-blue-800 dark:hover:bg-blue-400/20 dark:hover:text-blue-300",
|
||||
indigo:
|
||||
"hover:bg-indigo-200 hover:text-indigo-800 dark:hover:bg-indigo-400/20 dark:hover:text-indigo-300",
|
||||
purple:
|
||||
"hover:bg-purple-200 hover:text-purple-800 dark:hover:bg-purple-400/20 dark:hover:text-purple-300",
|
||||
pink: "hover:bg-pink-200 hover:text-pink-800 dark:hover:bg-pink-400/20 dark:hover:text-pink-300",
|
||||
},
|
||||
};
|
||||
|
||||
const dotClasses: Record<BadgeTone, string> = {
|
||||
gray: "fill-gray-400",
|
||||
red: "fill-red-500 dark:fill-red-400",
|
||||
@ -132,6 +164,7 @@ export function Badge({
|
||||
dot = false,
|
||||
leadingIcon,
|
||||
trailingIcon,
|
||||
hover = false,
|
||||
onRemove,
|
||||
removeLabel = "Remove",
|
||||
className,
|
||||
@ -150,6 +183,8 @@ export function Badge({
|
||||
shape === "pill" ? "rounded-full" : "rounded-md",
|
||||
size === "small" ? "px-1.5 py-0.5" : "px-2 py-1",
|
||||
badgeClasses[variant][tone],
|
||||
hover && "cursor-pointer transition-colors",
|
||||
hover && badgeHoverClasses[variant][tone],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@ -20,16 +20,40 @@ export type ButtonGroupProps = {
|
||||
size?: Size
|
||||
className?: string
|
||||
ariaLabel?: string
|
||||
iconOnlyOnMobile?: boolean
|
||||
}
|
||||
|
||||
function cn(...parts: Array<string | false | null | undefined>) {
|
||||
return parts.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
const sizeMap: Record<Size, { btn: string; icon: string; iconOnly: string }> = {
|
||||
sm: { btn: 'px-2.5 py-1.5 text-sm', icon: 'size-5', iconOnly: 'h-9 w-9' },
|
||||
md: { btn: 'px-3 py-2 text-sm', icon: 'size-5', iconOnly: 'h-10 w-10' },
|
||||
lg: { btn: 'px-3.5 py-2.5 text-sm', icon: 'size-5', iconOnly: 'h-11 w-11' },
|
||||
const sizeMap: Record<
|
||||
Size,
|
||||
{
|
||||
btn: string
|
||||
icon: string
|
||||
iconOnly: string
|
||||
mobileIconOnly: string
|
||||
}
|
||||
> = {
|
||||
sm: {
|
||||
btn: 'px-2.5 py-1.5 text-sm',
|
||||
icon: 'size-5',
|
||||
iconOnly: 'h-9 w-9',
|
||||
mobileIconOnly: 'h-9 w-9 p-0 text-sm sm:h-auto sm:w-auto sm:px-2.5 sm:py-1.5',
|
||||
},
|
||||
md: {
|
||||
btn: 'px-3 py-2 text-sm',
|
||||
icon: 'size-5',
|
||||
iconOnly: 'h-10 w-10',
|
||||
mobileIconOnly: 'h-10 w-10 p-0 text-sm sm:h-auto sm:w-auto sm:px-3 sm:py-2',
|
||||
},
|
||||
lg: {
|
||||
btn: 'px-3.5 py-2.5 text-sm',
|
||||
icon: 'size-5',
|
||||
iconOnly: 'h-11 w-11',
|
||||
mobileIconOnly: 'h-11 w-11 p-0 text-sm sm:h-auto sm:w-auto sm:px-3.5 sm:py-2.5',
|
||||
},
|
||||
}
|
||||
|
||||
export default function ButtonGroup({
|
||||
@ -39,6 +63,7 @@ export default function ButtonGroup({
|
||||
size = 'md',
|
||||
className,
|
||||
ariaLabel = 'Optionen',
|
||||
iconOnlyOnMobile = false,
|
||||
}: ButtonGroupProps) {
|
||||
const s = sizeMap[size]
|
||||
|
||||
@ -56,6 +81,7 @@ export default function ButtonGroup({
|
||||
const isFirst = idx === 0
|
||||
const isLast = idx === items.length - 1
|
||||
const iconOnly = !it.label && !!it.icon
|
||||
const mobileIconOnly = iconOnlyOnMobile && !!it.icon && !!it.label
|
||||
|
||||
return (
|
||||
<button
|
||||
@ -87,7 +113,11 @@ export default function ButtonGroup({
|
||||
].join(' '),
|
||||
|
||||
'disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:shadow-none',
|
||||
iconOnly ? `p-0 ${s.iconOnly}` : s.btn
|
||||
iconOnly
|
||||
? `p-0 ${s.iconOnly}`
|
||||
: mobileIconOnly
|
||||
? s.mobileIconOnly
|
||||
: s.btn
|
||||
)}
|
||||
title={typeof it.label === 'string' ? it.label : it.srLabel}
|
||||
>
|
||||
@ -97,7 +127,7 @@ export default function ButtonGroup({
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 transition-colors duration-150',
|
||||
iconOnly ? '' : '-ml-0.5',
|
||||
iconOnly ? '' : mobileIconOnly ? 'sm:-ml-0.5' : '-ml-0.5',
|
||||
active
|
||||
? 'text-indigo-700 dark:text-indigo-200'
|
||||
: 'text-gray-500 dark:text-gray-500'
|
||||
@ -107,7 +137,21 @@ export default function ButtonGroup({
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{it.label ? <span className={it.icon ? 'ml-1.5' : ''}>{it.label}</span> : null}
|
||||
{it.label ? (
|
||||
<span
|
||||
className={
|
||||
it.icon
|
||||
? mobileIconOnly
|
||||
? 'sr-only sm:not-sr-only sm:ml-1.5'
|
||||
: 'ml-1.5'
|
||||
: mobileIconOnly
|
||||
? 'sr-only sm:not-sr-only'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{it.label}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
// frontend\src\components\Camera.tsx
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Dialog, DialogBackdrop, DialogPanel, DialogTitle } from '@headlessui/react'
|
||||
import { CameraIcon, XMarkIcon } from '@heroicons/react/24/outline'
|
||||
|
||||
@ -153,88 +154,97 @@ export default function Camera({ onScan }: CameraProps) {
|
||||
<CameraIcon aria-hidden="true" className="size-6" />
|
||||
</button>
|
||||
|
||||
<Dialog open={open} onClose={closeModal} className="relative z-50">
|
||||
<DialogBackdrop
|
||||
transition
|
||||
className="fixed inset-0 bg-gray-500/75 transition-opacity data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in dark:bg-gray-900/70"
|
||||
/>
|
||||
|
||||
<div className="fixed inset-0 z-50 w-screen overflow-y-auto">
|
||||
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||
<DialogPanel
|
||||
transition
|
||||
className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all data-closed:translate-y-4 data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in sm:my-8 sm:w-full sm:max-w-lg data-closed:sm:translate-y-0 data-closed:sm:scale-95 dark:bg-gray-800 dark:outline dark:-outline-offset-1 dark:outline-white/10"
|
||||
{typeof document !== 'undefined'
|
||||
? createPortal(
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={closeModal}
|
||||
className="fixed inset-0 z-[9999]"
|
||||
>
|
||||
<div className="absolute top-0 right-0 pt-4 pr-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-2 focus:outline-offset-2 focus:outline-indigo-600 dark:bg-gray-800 dark:hover:text-gray-300 dark:focus:outline-white"
|
||||
>
|
||||
<span className="sr-only">Schließen</span>
|
||||
<XMarkIcon aria-hidden="true" className="size-6" />
|
||||
</button>
|
||||
</div>
|
||||
<DialogBackdrop
|
||||
transition
|
||||
className="fixed inset-0 z-0 bg-gray-500/75 transition-opacity data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in dark:bg-gray-900/70"
|
||||
/>
|
||||
|
||||
<div className="px-4 pt-5 pb-4 sm:p-6">
|
||||
<DialogTitle as="h3" className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
QR-Code scannen
|
||||
</DialogTitle>
|
||||
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Richte die Kamera auf einen QR-Code.
|
||||
</p>
|
||||
|
||||
<div className="mt-5 overflow-hidden rounded-lg bg-gray-950">
|
||||
{scannedValue ? (
|
||||
<div className="p-4">
|
||||
<p className="text-sm font-medium text-white">
|
||||
QR-Code erkannt:
|
||||
</p>
|
||||
<p className="mt-2 break-all rounded-md bg-white/10 p-3 text-sm text-white">
|
||||
{scannedValue}
|
||||
</p>
|
||||
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
|
||||
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||
<DialogPanel
|
||||
transition
|
||||
className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all data-closed:translate-y-4 data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in sm:my-8 sm:w-full sm:max-w-lg data-closed:sm:translate-y-0 data-closed:sm:scale-95 dark:bg-gray-800 dark:outline dark:-outline-offset-1 dark:outline-white/10"
|
||||
>
|
||||
<div className="absolute top-0 right-0 pt-4 pr-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-2 focus:outline-offset-2 focus:outline-indigo-600 dark:bg-gray-800 dark:hover:text-gray-300 dark:focus:outline-white"
|
||||
>
|
||||
<span className="sr-only">Schließen</span>
|
||||
<XMarkIcon aria-hidden="true" className="size-6" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<video
|
||||
ref={videoRef}
|
||||
muted
|
||||
playsInline
|
||||
autoPlay
|
||||
className="aspect-video w-full object-cover"
|
||||
/>
|
||||
|
||||
{(isStarting || error) && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50 p-4">
|
||||
<div
|
||||
className={[
|
||||
'max-w-sm rounded-md px-4 py-2 text-center text-sm font-medium text-white',
|
||||
error ? 'bg-red-600/90' : 'bg-black/70',
|
||||
].join(' ')}
|
||||
>
|
||||
{error ?? 'Kamera wird gestartet...'}
|
||||
<div className="px-4 pt-5 pb-4 sm:p-6">
|
||||
<DialogTitle as="h3" className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
QR-Code scannen
|
||||
</DialogTitle>
|
||||
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Richte die Kamera auf einen QR-Code.
|
||||
</p>
|
||||
|
||||
<div className="mt-5 overflow-hidden rounded-lg bg-gray-950">
|
||||
{scannedValue ? (
|
||||
<div className="p-4">
|
||||
<p className="text-sm font-medium text-white">
|
||||
QR-Code erkannt:
|
||||
</p>
|
||||
<p className="mt-2 break-all rounded-md bg-white/10 p-3 text-sm text-white">
|
||||
{scannedValue}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<div className="relative">
|
||||
<video
|
||||
ref={videoRef}
|
||||
muted
|
||||
playsInline
|
||||
autoPlay
|
||||
className="aspect-video w-full object-cover"
|
||||
/>
|
||||
|
||||
{(isStarting || error) && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50 p-4">
|
||||
<div
|
||||
className={[
|
||||
'max-w-sm rounded-md px-4 py-2 text-center text-sm font-medium text-white',
|
||||
error ? 'bg-red-600/90' : 'bg-black/70',
|
||||
].join(' ')}
|
||||
>
|
||||
{error ?? 'Kamera wird gestartet...'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6 dark:bg-gray-700/25">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 sm:w-auto dark:bg-indigo-500 dark:shadow-none dark:hover:bg-indigo-400"
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6 dark:bg-gray-700/25">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 sm:w-auto dark:bg-indigo-500 dark:shadow-none dark:hover:bg-indigo-400"
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Dialog>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -1,3 +1,5 @@
|
||||
// frontend\src\components\Combobox.tsx
|
||||
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
@ -9,7 +11,8 @@ import {
|
||||
ComboboxOptions,
|
||||
Label,
|
||||
} from '@headlessui/react'
|
||||
import { ChevronDownIcon, UserIcon } from '@heroicons/react/20/solid'
|
||||
import { ChevronDownIcon } from '@heroicons/react/20/solid'
|
||||
import Avatar from './Avatar'
|
||||
|
||||
export type ComboboxVariant = 'simple' | 'status' | 'image' | 'secondary'
|
||||
|
||||
@ -109,20 +112,11 @@ export default function Combobox({
|
||||
if (variant === 'image') {
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
{item.imageUrl ? (
|
||||
<img
|
||||
src={item.imageUrl}
|
||||
alt=""
|
||||
className="size-6 shrink-0 rounded-full bg-gray-100 object-cover outline -outline-offset-1 outline-black/5 dark:bg-gray-700 dark:outline-white/10"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid size-6 shrink-0 place-items-center rounded-full bg-gray-300 in-data-focus:bg-white dark:bg-gray-600">
|
||||
<UserIcon
|
||||
aria-hidden="true"
|
||||
className="size-4 fill-white in-data-focus:fill-indigo-600 dark:in-data-focus:fill-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Avatar
|
||||
src={item.imageUrl}
|
||||
name={item.name}
|
||||
size={6}
|
||||
/>
|
||||
|
||||
<span className="ml-3 block truncate">{item.name}</span>
|
||||
</div>
|
||||
@ -166,11 +160,22 @@ export default function Combobox({
|
||||
)}
|
||||
|
||||
<div className={classNames('relative', label && 'mt-2')}>
|
||||
{variant === 'image' && value && query.trim() === '' && (
|
||||
<div className="pointer-events-none absolute inset-y-0 left-3 flex items-center">
|
||||
<Avatar
|
||||
src={value.imageUrl}
|
||||
name={value.name}
|
||||
size={6}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ComboboxInput
|
||||
required={required}
|
||||
placeholder={placeholder}
|
||||
className={classNames(
|
||||
'block w-full rounded-md bg-white py-1.5 pr-12 pl-3 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:text-gray-500 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500 dark:disabled:bg-white/5 dark:disabled:text-white/40',
|
||||
variant === 'image' && value && query.trim() === '' && 'pl-11',
|
||||
inputClassName,
|
||||
)}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
|
||||
@ -77,6 +77,11 @@ type MapProps = {
|
||||
heatmapLayerName?: string
|
||||
heatmapChecked?: boolean
|
||||
|
||||
markerLayerName?: string
|
||||
markersChecked?: boolean
|
||||
childrenLayerName?: string
|
||||
childrenChecked?: boolean
|
||||
|
||||
center?: [number, number]
|
||||
zoom?: number
|
||||
minZoom?: number
|
||||
@ -142,7 +147,7 @@ const defaultWmsLayers: MapWmsLayer[] = [
|
||||
overlay: true,
|
||||
checked: true,
|
||||
opacity: 1,
|
||||
zIndex: 650,
|
||||
zIndex: 2,
|
||||
},
|
||||
]
|
||||
|
||||
@ -200,6 +205,19 @@ const currentLocationIcon = L.divIcon({
|
||||
`,
|
||||
})
|
||||
|
||||
const leafletZIndexClassName = [
|
||||
'[&_.leaflet-pane]:!z-0',
|
||||
'[&_.leaflet-tile-pane]:!z-0',
|
||||
'[&_.leaflet-overlay-pane]:!z-[1]',
|
||||
'[&_.leaflet-shadow-pane]:!z-[2]',
|
||||
'[&_.leaflet-marker-pane]:!z-[3]',
|
||||
'[&_.leaflet-tooltip-pane]:!z-[4]',
|
||||
'[&_.leaflet-popup-pane]:!z-[5]',
|
||||
'[&_.leaflet-control-container]:!z-[6]',
|
||||
'[&_.leaflet-top]:!z-[6]',
|
||||
'[&_.leaflet-bottom]:!z-[6]',
|
||||
].join(' ')
|
||||
|
||||
function classNames(...classes: Array<string | false | null | undefined>) {
|
||||
return classes.filter(Boolean).join(' ')
|
||||
}
|
||||
@ -275,28 +293,40 @@ function HeatmapLayer({
|
||||
return null
|
||||
}
|
||||
|
||||
function HeatmapLayerControlEvents({
|
||||
function MapLayerControlEvents({
|
||||
heatmapLayerName,
|
||||
onVisibilityChange,
|
||||
onHeatmapVisibilityChange,
|
||||
markerLayerName,
|
||||
onMarkerVisibilityChange,
|
||||
childrenLayerName,
|
||||
onChildrenVisibilityChange,
|
||||
}: {
|
||||
heatmapLayerName: string
|
||||
onVisibilityChange: (visible: boolean) => void
|
||||
onHeatmapVisibilityChange: (visible: boolean) => void
|
||||
markerLayerName?: string
|
||||
onMarkerVisibilityChange?: (visible: boolean) => void
|
||||
childrenLayerName?: string
|
||||
onChildrenVisibilityChange?: (visible: boolean) => void
|
||||
}) {
|
||||
function handleOverlayChange(event: L.LeafletEvent, visible: boolean) {
|
||||
const layerEvent = event as L.LeafletEvent & { name?: string }
|
||||
|
||||
if (layerEvent.name === heatmapLayerName) {
|
||||
onHeatmapVisibilityChange(visible)
|
||||
}
|
||||
|
||||
if (markerLayerName && layerEvent.name === markerLayerName) {
|
||||
onMarkerVisibilityChange?.(visible)
|
||||
}
|
||||
|
||||
if (childrenLayerName && layerEvent.name === childrenLayerName) {
|
||||
onChildrenVisibilityChange?.(visible)
|
||||
}
|
||||
}
|
||||
|
||||
useMapEvents({
|
||||
overlayadd: (event) => {
|
||||
const layerEvent = event as L.LeafletEvent & { name?: string }
|
||||
|
||||
if (layerEvent.name === heatmapLayerName) {
|
||||
onVisibilityChange(true)
|
||||
}
|
||||
},
|
||||
overlayremove: (event) => {
|
||||
const layerEvent = event as L.LeafletEvent & { name?: string }
|
||||
|
||||
if (layerEvent.name === heatmapLayerName) {
|
||||
onVisibilityChange(false)
|
||||
}
|
||||
},
|
||||
overlayadd: (event) => handleOverlayChange(event, true),
|
||||
overlayremove: (event) => handleOverlayChange(event, false),
|
||||
})
|
||||
|
||||
return null
|
||||
@ -369,7 +399,7 @@ function LocateButton({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute top-20 left-3 z-[500]">
|
||||
<div className="absolute top-20 left-3 z-[10]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLocate}
|
||||
@ -518,6 +548,11 @@ export default function LeafletMap({
|
||||
heatmapLayerName = 'Heatmap',
|
||||
heatmapChecked = false,
|
||||
|
||||
markerLayerName,
|
||||
markersChecked = true,
|
||||
childrenLayerName,
|
||||
childrenChecked = true,
|
||||
|
||||
center = [51.1657, 10.4515],
|
||||
zoom = 6,
|
||||
minZoom = 4,
|
||||
@ -542,6 +577,8 @@ export default function LeafletMap({
|
||||
|
||||
const [currentLocation, setCurrentLocation] = useState<[number, number] | null>(null)
|
||||
const [isHeatmapLayerVisible, setIsHeatmapLayerVisible] = useState(heatmapChecked)
|
||||
const [isMarkerLayerVisible, setIsMarkerLayerVisible] = useState(markersChecked)
|
||||
const [isChildrenLayerVisible, setIsChildrenLayerVisible] = useState(childrenChecked)
|
||||
|
||||
const fallbackTileLayer =
|
||||
tileLayers.find((layer) => layer.checked) ?? tileLayers[0]
|
||||
@ -559,6 +596,24 @@ export default function LeafletMap({
|
||||
setIsHeatmapLayerVisible(heatmapChecked)
|
||||
}, [heatmapChecked])
|
||||
|
||||
useEffect(() => {
|
||||
setIsMarkerLayerVisible(markersChecked)
|
||||
}, [markersChecked])
|
||||
|
||||
useEffect(() => {
|
||||
setIsChildrenLayerVisible(childrenChecked)
|
||||
}, [childrenChecked])
|
||||
|
||||
const hasMarkerLayerControl = showLayerControl && Boolean(markerLayerName)
|
||||
const hasChildrenLayerControl =
|
||||
showLayerControl && Boolean(childrenLayerName) && children !== null && children !== undefined
|
||||
|
||||
const visibleMarkers =
|
||||
hasMarkerLayerControl && !isMarkerLayerVisible ? [] : markers
|
||||
|
||||
const visibleChildren =
|
||||
hasChildrenLayerControl && !isChildrenLayerVisible ? null : children
|
||||
|
||||
return (
|
||||
<div className={classNames('relative z-0 h-full', minHeightClassName, className)}>
|
||||
<MapContainer
|
||||
@ -567,13 +622,21 @@ export default function LeafletMap({
|
||||
minZoom={minZoom}
|
||||
maxZoom={maxZoom}
|
||||
scrollWheelZoom={scrollWheelZoom}
|
||||
className={classNames('relative z-0 h-full w-full', mapClassName)}
|
||||
className={classNames(
|
||||
'relative z-0 h-full w-full',
|
||||
leafletZIndexClassName,
|
||||
mapClassName,
|
||||
)}
|
||||
>
|
||||
<InvalidateSizeOnResize />
|
||||
|
||||
<HeatmapLayerControlEvents
|
||||
<MapLayerControlEvents
|
||||
heatmapLayerName={heatmapLayerName}
|
||||
onVisibilityChange={setIsHeatmapLayerVisible}
|
||||
onHeatmapVisibilityChange={setIsHeatmapLayerVisible}
|
||||
markerLayerName={markerLayerName}
|
||||
onMarkerVisibilityChange={setIsMarkerLayerVisible}
|
||||
childrenLayerName={childrenLayerName}
|
||||
onChildrenVisibilityChange={setIsChildrenLayerVisible}
|
||||
/>
|
||||
|
||||
{showLayerControl ? (
|
||||
@ -620,6 +683,24 @@ export default function LeafletMap({
|
||||
<LayerGroup />
|
||||
</LayersControl.Overlay>
|
||||
)}
|
||||
|
||||
{markerLayerName && markers.length > 0 && (
|
||||
<LayersControl.Overlay
|
||||
name={markerLayerName}
|
||||
checked={markersChecked}
|
||||
>
|
||||
<LayerGroup />
|
||||
</LayersControl.Overlay>
|
||||
)}
|
||||
|
||||
{childrenLayerName && children !== null && children !== undefined && (
|
||||
<LayersControl.Overlay
|
||||
name={childrenLayerName}
|
||||
checked={childrenChecked}
|
||||
>
|
||||
<LayerGroup />
|
||||
</LayersControl.Overlay>
|
||||
)}
|
||||
</LayersControl>
|
||||
) : fallbackTileLayer ? (
|
||||
renderTileLayer(fallbackTileLayer, maxZoom)
|
||||
@ -649,7 +730,7 @@ export default function LeafletMap({
|
||||
/>
|
||||
)}
|
||||
|
||||
{markers.map((marker) => (
|
||||
{visibleMarkers.map((marker) => (
|
||||
<AppMarker
|
||||
key={marker.id}
|
||||
marker={marker}
|
||||
@ -668,7 +749,7 @@ export default function LeafletMap({
|
||||
</Marker>
|
||||
)}
|
||||
|
||||
{children}
|
||||
{visibleChildren}
|
||||
</MapContainer>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -46,7 +46,7 @@ export default function Modal({
|
||||
onConfirm,
|
||||
children,
|
||||
panelClassName,
|
||||
zIndexClassName = 'z-10',
|
||||
zIndexClassName = 'z-[9999]',
|
||||
}: ModalProps) {
|
||||
const isSuccess = variant === 'success-single' || variant === 'success-wide'
|
||||
const isGrayFooter = variant === 'alert-gray-footer'
|
||||
@ -75,10 +75,14 @@ export default function Modal({
|
||||
|
||||
if (children) {
|
||||
return (
|
||||
<Dialog open={open} onClose={setOpen} className={classNames('relative', zIndexClassName)}>
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={setOpen}
|
||||
className={classNames('fixed inset-0', zIndexClassName)}
|
||||
>
|
||||
<DialogBackdrop
|
||||
transition
|
||||
className="fixed inset-0 bg-gray-500/75 transition-opacity data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in dark:bg-gray-900/50"
|
||||
className="fixed inset-0 z-0 bg-gray-500/75 transition-opacity data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in dark:bg-gray-900/50"
|
||||
/>
|
||||
|
||||
<div className="fixed inset-0 z-10 w-screen overflow-hidden">
|
||||
@ -100,10 +104,14 @@ export default function Modal({
|
||||
|
||||
if (isGrayFooter) {
|
||||
return (
|
||||
<Dialog open={open} onClose={setOpen} className="relative z-10">
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={setOpen}
|
||||
className={classNames('relative', zIndexClassName)}
|
||||
>
|
||||
<DialogBackdrop
|
||||
transition
|
||||
className="fixed inset-0 bg-gray-500/75 transition-opacity data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in dark:bg-gray-900/50"
|
||||
className="fixed inset-0 z-0 bg-gray-500/75 transition-opacity data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in dark:bg-gray-900/50"
|
||||
/>
|
||||
|
||||
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
|
||||
@ -158,13 +166,17 @@ export default function Modal({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={setOpen} className="relative z-10">
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={setOpen}
|
||||
className={classNames('relative', zIndexClassName)}
|
||||
>
|
||||
<DialogBackdrop
|
||||
transition
|
||||
className="fixed inset-0 bg-gray-500/75 transition-opacity data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in dark:bg-gray-900/50"
|
||||
className="fixed inset-0 z-0 bg-gray-500/75 transition-opacity data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in dark:bg-gray-900/50"
|
||||
/>
|
||||
|
||||
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
|
||||
<div className="fixed inset-0 z-10 w-screen overflow-hidden">
|
||||
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||
<DialogPanel
|
||||
transition
|
||||
|
||||
201
frontend/src/components/MultiCombobox.tsx
Normal file
201
frontend/src/components/MultiCombobox.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -13,8 +13,9 @@ import type { Notification } from './types'
|
||||
type NotificationCenterProps = {
|
||||
notifications: Notification[]
|
||||
unreadCount: number
|
||||
onMarkRead: (notificationId: string) => void
|
||||
onMarkAllRead: () => void
|
||||
onMarkRead: (notificationId: string) => void | Promise<void>
|
||||
onMarkAllRead: () => void | Promise<void>
|
||||
onNotificationClick?: (notification: Notification) => void
|
||||
}
|
||||
|
||||
type NotificationTab = 'unread' | 'all'
|
||||
@ -35,6 +36,7 @@ export default function NotificationCenter({
|
||||
unreadCount,
|
||||
onMarkRead,
|
||||
onMarkAllRead,
|
||||
onNotificationClick,
|
||||
}: NotificationCenterProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<NotificationTab>('unread')
|
||||
@ -107,6 +109,12 @@ export default function NotificationCenter({
|
||||
return 'Keine Benachrichtigungen vorhanden.'
|
||||
}
|
||||
|
||||
function handleNotificationClick(notification: Notification) {
|
||||
void onMarkRead(notification.id)
|
||||
onNotificationClick?.(notification)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<button
|
||||
@ -125,7 +133,7 @@ export default function NotificationCenter({
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 z-50 mt-2 w-96 overflow-hidden rounded-lg bg-white shadow-lg ring-1 ring-black/5 dark:bg-gray-900 dark:ring-white/10">
|
||||
<div className="absolute right-0 z-[9600] mt-2 w-96 overflow-hidden rounded-lg bg-white shadow-lg ring-1 ring-black/5 dark:bg-gray-900 dark:ring-white/10">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 border-b border-gray-200 dark:border-white/10">
|
||||
<h2 className="min-w-0 truncate px-4 py-3 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Benachrichtigungen
|
||||
@ -183,7 +191,7 @@ export default function NotificationCenter({
|
||||
<button
|
||||
key={notification.id}
|
||||
type="button"
|
||||
onClick={() => onMarkRead(notification.id)}
|
||||
onClick={() => handleNotificationClick(notification)}
|
||||
className="block w-full border-b border-gray-100 px-4 py-3 text-left hover:bg-gray-50 dark:border-white/10 dark:hover:bg-white/5"
|
||||
>
|
||||
<div className="flex gap-x-3">
|
||||
|
||||
@ -1,10 +1,18 @@
|
||||
// frontend/src/components/Notifications.tsx
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Transition } from '@headlessui/react'
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ExclamationCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
InformationCircleIcon,
|
||||
XCircleIcon,
|
||||
@ -34,10 +42,38 @@ type NotificationsProps = {
|
||||
position?: 'top-right' | 'bottom-right'
|
||||
}
|
||||
|
||||
type ShowToastInput = Omit<ToastNotification, 'id'>
|
||||
|
||||
type ShowErrorToastInput = {
|
||||
title: string
|
||||
error: unknown
|
||||
fallback: string
|
||||
autoClose?: boolean
|
||||
duration?: number
|
||||
}
|
||||
|
||||
type NotificationsContextValue = {
|
||||
notifications: ToastNotification[]
|
||||
showToast: (notification: ShowToastInput) => void
|
||||
showErrorToast: (input: ShowErrorToastInput) => void
|
||||
dismissToast: (id: string) => void
|
||||
clearToasts: () => void
|
||||
}
|
||||
|
||||
const NotificationsContext = createContext<NotificationsContextValue | null>(null)
|
||||
|
||||
function classNames(...classes: Array<string | false | null | undefined>) {
|
||||
return classes.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
function createNotificationId() {
|
||||
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
|
||||
return `${Date.now()}-${Math.random()}`
|
||||
}
|
||||
|
||||
function getVariantStyles(variant: NotificationVariant) {
|
||||
switch (variant) {
|
||||
case 'success':
|
||||
@ -82,7 +118,7 @@ function NotificationToast({
|
||||
actionLabel,
|
||||
onAction,
|
||||
autoClose = true,
|
||||
duration = 5000,
|
||||
duration = 3000,
|
||||
} = notification
|
||||
|
||||
const { icon: Icon, iconClassName } = getVariantStyles(variant)
|
||||
@ -154,18 +190,22 @@ function NotificationToast({
|
||||
)
|
||||
}
|
||||
|
||||
export default function Notifications({
|
||||
function NotificationsViewport({
|
||||
notifications,
|
||||
onDismiss,
|
||||
position = 'top-right',
|
||||
}: NotificationsProps) {
|
||||
return (
|
||||
if (typeof document === 'undefined') {
|
||||
return null
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
aria-live="assertive"
|
||||
className={classNames(
|
||||
'pointer-events-none fixed inset-0 z-[99999] flex px-4 py-6 sm:p-6',
|
||||
'pointer-events-none fixed inset-x-0 top-16 z-[99999] flex px-4 py-6 sm:p-6',
|
||||
position === 'top-right'
|
||||
? 'items-end sm:items-start'
|
||||
? 'items-start'
|
||||
: 'items-end',
|
||||
)}
|
||||
>
|
||||
@ -178,6 +218,97 @@ export default function Notifications({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function NotificationProvider({
|
||||
children,
|
||||
position = 'top-right',
|
||||
}: {
|
||||
children: ReactNode
|
||||
position?: 'top-right' | 'bottom-right'
|
||||
}) {
|
||||
const [notifications, setNotifications] = useState<ToastNotification[]>([])
|
||||
|
||||
const dismissToast = useCallback((id: string) => {
|
||||
setNotifications((currentNotifications) =>
|
||||
currentNotifications.filter((notification) => notification.id !== id),
|
||||
)
|
||||
}, [])
|
||||
|
||||
const clearToasts = useCallback(() => {
|
||||
setNotifications([])
|
||||
}, [])
|
||||
|
||||
const showToast = useCallback((notification: ShowToastInput) => {
|
||||
const id = createNotificationId()
|
||||
|
||||
setNotifications((currentNotifications) => [
|
||||
{
|
||||
id,
|
||||
autoClose: true,
|
||||
duration: 3500,
|
||||
...notification,
|
||||
},
|
||||
...currentNotifications,
|
||||
])
|
||||
}, [])
|
||||
|
||||
const showErrorToast = useCallback((input: ShowErrorToastInput) => {
|
||||
showToast({
|
||||
variant: 'error',
|
||||
title: input.title,
|
||||
message:
|
||||
input.error instanceof Error
|
||||
? input.error.message
|
||||
: input.fallback,
|
||||
autoClose: input.autoClose ?? false,
|
||||
duration: input.duration,
|
||||
})
|
||||
}, [showToast])
|
||||
|
||||
const value = useMemo<NotificationsContextValue>(
|
||||
() => ({
|
||||
notifications,
|
||||
showToast,
|
||||
showErrorToast,
|
||||
dismissToast,
|
||||
clearToasts,
|
||||
}),
|
||||
[
|
||||
notifications,
|
||||
showToast,
|
||||
showErrorToast,
|
||||
dismissToast,
|
||||
clearToasts,
|
||||
],
|
||||
)
|
||||
|
||||
return (
|
||||
<NotificationsContext.Provider value={value}>
|
||||
{children}
|
||||
|
||||
<NotificationsViewport
|
||||
notifications={notifications}
|
||||
onDismiss={dismissToast}
|
||||
position={position}
|
||||
/>
|
||||
</NotificationsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useNotifications() {
|
||||
const context = useContext(NotificationsContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useNotifications muss innerhalb von NotificationProvider verwendet werden.',
|
||||
)
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
export default NotificationsViewport
|
||||
369
frontend/src/components/RadioGroup.tsx
Normal file
369
frontend/src/components/RadioGroup.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -249,7 +249,7 @@ export default function Sidebar({
|
||||
}: SidebarProps) {
|
||||
return (
|
||||
<>
|
||||
<Dialog open={sidebarOpen} onClose={setSidebarOpen} className="relative z-50 lg:hidden">
|
||||
<Dialog open={sidebarOpen} onClose={setSidebarOpen} className="relative z-[2000] lg:hidden">
|
||||
<DialogBackdrop
|
||||
transition
|
||||
className="fixed inset-0 bg-gray-900/80 transition-opacity duration-300 ease-linear data-closed:opacity-0"
|
||||
@ -280,7 +280,7 @@ export default function Sidebar({
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<div className="hidden bg-gray-900 ring-1 ring-white/10 lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-72 lg:flex-col">
|
||||
<div className="hidden bg-gray-900 ring-1 ring-white/10 lg:fixed lg:inset-y-0 lg:z-[2000] lg:flex lg:w-72 lg:flex-col">
|
||||
<div className="flex grow flex-col gap-y-5 overflow-y-auto bg-black/10 px-6 pb-4">
|
||||
<SidebarContent
|
||||
user={user}
|
||||
|
||||
@ -76,7 +76,7 @@ function TaskListItemComplete({
|
||||
|
||||
<TaskListItemWrapper step={step}>
|
||||
<span className="flex h-9 items-center">
|
||||
<span className="relative z-10 flex size-8 items-center justify-center rounded-full bg-indigo-600 group-hover:bg-indigo-800 dark:bg-indigo-500 dark:group-hover:bg-indigo-600">
|
||||
<span className="relative z-10 flex size-8 items-center justify-center rounded-full bg-indigo-600 dark:bg-indigo-500">
|
||||
<CheckIcon aria-hidden="true" className="size-5 text-white" />
|
||||
</span>
|
||||
</span>
|
||||
@ -108,10 +108,10 @@ function TaskListItemCurrent({
|
||||
|
||||
<TaskListItemWrapper step={step} ariaCurrent="step">
|
||||
<span className="flex h-9 items-center">
|
||||
<span className="relative z-10 flex size-8 items-center justify-center rounded-full border-2 border-indigo-600 bg-white dark:border-indigo-500 dark:bg-gray-900">
|
||||
<span className="relative z-10 flex size-8 items-center justify-center rounded-full bg-indigo-600 dark:bg-indigo-500">
|
||||
<LoadingSpinner
|
||||
size="sm"
|
||||
className="text-indigo-600 dark:text-indigo-500"
|
||||
size={18}
|
||||
className="text-white"
|
||||
srLabel="Aktueller Schritt läuft"
|
||||
/>
|
||||
</span>
|
||||
@ -176,8 +176,8 @@ function TaskListItemUpcoming({
|
||||
|
||||
<TaskListItemWrapper step={step}>
|
||||
<span aria-hidden="true" className="flex h-9 items-center">
|
||||
<span className="relative z-10 flex size-8 items-center justify-center rounded-full border-2 border-gray-300 bg-white group-hover:border-gray-400 dark:border-white/15 dark:bg-gray-900 dark:group-hover:border-white/25">
|
||||
<span className="size-2.5 rounded-full bg-transparent group-hover:bg-gray-300 dark:group-hover:bg-white/15" />
|
||||
<span className="relative z-10 flex size-8 items-center justify-center rounded-full border-2 border-gray-300 bg-white dark:border-white/15 dark:bg-gray-900">
|
||||
<span className="size-2.5 rounded-full bg-transparent" />
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@ -204,7 +204,7 @@ function TaskListItemWrapper({
|
||||
<a
|
||||
href={step.href}
|
||||
aria-current={ariaCurrent}
|
||||
className="group relative flex items-start"
|
||||
className="relative flex items-start"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
@ -214,7 +214,7 @@ function TaskListItemWrapper({
|
||||
return (
|
||||
<div
|
||||
aria-current={ariaCurrent}
|
||||
className="group relative flex items-start"
|
||||
className="relative flex items-start"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@ -15,6 +15,7 @@ import { ChevronDownIcon } from '@heroicons/react/20/solid'
|
||||
import { Link } from 'react-router'
|
||||
import Search from './Search'
|
||||
import Camera from './Camera'
|
||||
import Avatar from './Avatar'
|
||||
import type { User } from './types'
|
||||
|
||||
type TopbarProps = {
|
||||
@ -44,12 +45,8 @@ export default function Topbar({
|
||||
console.log('QR-Code erkannt:', value)
|
||||
}
|
||||
|
||||
const avatarUrl =
|
||||
user.avatar ||
|
||||
'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80'
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-gray-200 bg-white px-4 shadow-xs sm:gap-x-6 sm:px-6 lg:px-8 dark:border-white/10 dark:bg-gray-900">
|
||||
<div className="sticky top-0 z-[9500] flex h-16 shrink-0 items-center gap-x-4 border-b border-gray-200 bg-white px-4 shadow-xs sm:gap-x-6 sm:px-6 lg:px-8 dark:border-white/10 dark:bg-gray-900">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenSidebar}
|
||||
@ -97,10 +94,10 @@ export default function Topbar({
|
||||
<span className="absolute -inset-1.5" />
|
||||
<span className="sr-only">Open user menu</span>
|
||||
|
||||
<img
|
||||
alt=""
|
||||
src={avatarUrl}
|
||||
className="size-8 rounded-full bg-gray-50 object-cover outline -outline-offset-1 outline-black/5 dark:bg-gray-800 dark:outline-white/10"
|
||||
<Avatar
|
||||
src={user.avatar}
|
||||
name={user.displayName || user.username || user.email}
|
||||
size={8}
|
||||
/>
|
||||
|
||||
<span className="hidden lg:flex lg:items-center">
|
||||
|
||||
@ -34,12 +34,13 @@ export type DeviceHistoryEntry = {
|
||||
deviceId: string
|
||||
userId: string
|
||||
userDisplayName: string
|
||||
action: DeviceHistoryAction
|
||||
action: string
|
||||
changes: DeviceHistoryChange[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
canEdit: boolean
|
||||
}
|
||||
|
||||
|
||||
export type RelatedDevice = {
|
||||
id: string
|
||||
inventoryNumber: string
|
||||
@ -136,8 +137,9 @@ export type Notification = {
|
||||
message: string
|
||||
entityType: string
|
||||
entityId: string
|
||||
data: Record<string, unknown>
|
||||
visible?: boolean
|
||||
data: unknown
|
||||
silent: boolean
|
||||
soundName?: string
|
||||
readAt?: string | null
|
||||
createdAt: string
|
||||
}
|
||||
@ -8,9 +8,7 @@ import {
|
||||
} from '@heroicons/react/20/solid'
|
||||
import Button from '../../../components/Button'
|
||||
import LoadingSpinner from '../../../components/LoadingSpinner'
|
||||
import Notifications, {
|
||||
type ToastNotification,
|
||||
} from '../../../components/Notifications'
|
||||
import { useNotifications } from '../../../components/Notifications'
|
||||
import Switch from '../../../components/Switch'
|
||||
import { DialogTitle } from '@headlessui/react'
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline'
|
||||
@ -197,10 +195,10 @@ function markCurrentMilestoneSyncStepAsError(
|
||||
}
|
||||
|
||||
export default function Milestone() {
|
||||
const { showToast, showErrorToast } = useNotifications()
|
||||
const [settings, setSettings] = useState<MilestoneSettings | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [notifications, setNotifications] = useState<ToastNotification[]>([])
|
||||
const [isFetchingToken, setIsFetchingToken] = useState(false)
|
||||
const [skipTlsVerify, setSkipTlsVerify] = useState(false)
|
||||
const [syncModalOpen, setSyncModalOpen] = useState(false)
|
||||
@ -212,24 +210,6 @@ export default function Milestone() {
|
||||
void loadSettings()
|
||||
}, [])
|
||||
|
||||
function dismissNotification(id: string) {
|
||||
setNotifications((currentNotifications) =>
|
||||
currentNotifications.filter((notification) => notification.id !== id),
|
||||
)
|
||||
}
|
||||
|
||||
function showNotification(
|
||||
notification: Omit<ToastNotification, 'id'>,
|
||||
) {
|
||||
setNotifications((currentNotifications) => [
|
||||
{
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
...notification,
|
||||
},
|
||||
...currentNotifications,
|
||||
])
|
||||
}
|
||||
|
||||
function getHardwareCountMessage(count: number) {
|
||||
if (count === 1) {
|
||||
return '1 Hardware-Gerät wurde von Milestone geladen und gespeichert.'
|
||||
@ -257,7 +237,7 @@ export default function Milestone() {
|
||||
if (event.type === 'token') {
|
||||
const tokenExpiresAt = formatUpdatedAt(event.tokenExpiresAt)
|
||||
|
||||
showNotification({
|
||||
showToast({
|
||||
variant: 'success',
|
||||
title: 'Token abgerufen',
|
||||
message: tokenExpiresAt
|
||||
@ -269,7 +249,7 @@ export default function Milestone() {
|
||||
}
|
||||
|
||||
if (event.type === 'recordingServer') {
|
||||
showNotification({
|
||||
showToast({
|
||||
variant: 'success',
|
||||
title: 'Recording-Infos abgerufen',
|
||||
message: [
|
||||
@ -289,7 +269,7 @@ export default function Milestone() {
|
||||
}
|
||||
|
||||
if (event.type === 'hardware') {
|
||||
showNotification({
|
||||
showToast({
|
||||
variant: 'success',
|
||||
title: 'Hardware synchronisiert',
|
||||
message: getHardwareCountMessage(event.hardwareCount ?? 0),
|
||||
@ -393,14 +373,10 @@ export default function Milestone() {
|
||||
setSettings(data.settings)
|
||||
setSkipTlsVerify(Boolean(data.settings?.skipTlsVerify))
|
||||
} catch (error) {
|
||||
showNotification({
|
||||
variant: 'error',
|
||||
showErrorToast({
|
||||
title: 'Einstellungen konnten nicht geladen werden',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Milestone-Einstellungen konnten nicht geladen werden',
|
||||
autoClose: false,
|
||||
error,
|
||||
fallback: 'Milestone-Einstellungen konnten nicht geladen werden',
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
@ -449,14 +425,10 @@ export default function Milestone() {
|
||||
|
||||
await runMilestoneSyncStream()
|
||||
} catch (error) {
|
||||
showNotification({
|
||||
variant: 'error',
|
||||
showErrorToast({
|
||||
title: 'Speichern fehlgeschlagen',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Milestone-Einstellungen konnten nicht gespeichert werden',
|
||||
autoClose: false,
|
||||
error,
|
||||
fallback: 'Milestone-Einstellungen konnten nicht gespeichert werden',
|
||||
})
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
@ -469,14 +441,10 @@ export default function Milestone() {
|
||||
try {
|
||||
await runMilestoneSyncStream()
|
||||
} catch (error) {
|
||||
showNotification({
|
||||
variant: 'error',
|
||||
showErrorToast({
|
||||
title: 'Abruf fehlgeschlagen',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Milestone-Token konnte nicht abgerufen werden',
|
||||
autoClose: false,
|
||||
error,
|
||||
fallback: 'Milestone-Token konnte nicht abgerufen werden',
|
||||
})
|
||||
} finally {
|
||||
setIsFetchingToken(false)
|
||||
@ -500,12 +468,6 @@ export default function Milestone() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Notifications
|
||||
notifications={notifications}
|
||||
onDismiss={dismissNotification}
|
||||
position="top-right"
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={syncModalOpen}
|
||||
setOpen={(open) => {
|
||||
@ -548,12 +510,6 @@ export default function Milestone() {
|
||||
steps={syncSteps}
|
||||
ariaLabel="Milestone-Synchronisierung"
|
||||
/>
|
||||
|
||||
{syncError && (
|
||||
<div className="mt-5 rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
||||
{syncError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end border-t border-gray-200 px-6 py-4 dark:border-white/10">
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
// frontend\src\pages\administration\teams\TeamsAdministration.tsx
|
||||
|
||||
// frontend/src/pages/administration/teams/TeamsAdministration.tsx
|
||||
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { UserGroupIcon } from '@heroicons/react/20/solid'
|
||||
import { useEffect, useMemo, useState, type FormEvent } from 'react'
|
||||
import {
|
||||
MagnifyingGlassIcon,
|
||||
PlusIcon,
|
||||
UserGroupIcon,
|
||||
} from '@heroicons/react/20/solid'
|
||||
import Button from '../../../components/Button'
|
||||
import LoadingSpinner from '../../../components/LoadingSpinner'
|
||||
|
||||
@ -19,18 +21,44 @@ type AdminTeam = {
|
||||
const inputClassName =
|
||||
'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
|
||||
|
||||
function classNames(...classes: Array<string | false | null | undefined>) {
|
||||
return classes.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
async function readApiError(response: Response, fallback: string) {
|
||||
const data = await response.json().catch(() => null)
|
||||
|
||||
return data?.error ?? fallback
|
||||
}
|
||||
|
||||
function getTeamSearchText(team: AdminTeam) {
|
||||
return [
|
||||
team.name,
|
||||
team.initial,
|
||||
team.href,
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
export default function TeamsAdministration() {
|
||||
const [teams, setTeams] = useState<AdminTeam[]>([])
|
||||
const [selectedTeam, setSelectedTeam] = useState<AdminTeam | null>(null)
|
||||
const [teamSearch, setTeamSearch] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const filteredTeams = useMemo(() => {
|
||||
const search = teamSearch.trim().toLowerCase()
|
||||
|
||||
if (!search) {
|
||||
return teams
|
||||
}
|
||||
|
||||
return teams.filter((team) => getTeamSearchText(team).includes(search))
|
||||
}, [teamSearch, teams])
|
||||
|
||||
useEffect(() => {
|
||||
void loadTeams()
|
||||
}, [])
|
||||
@ -63,12 +91,25 @@ export default function TeamsAdministration() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleCreateNewTeam() {
|
||||
setSelectedTeam(null)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
function handleEditTeam(team: AdminTeam) {
|
||||
setSelectedTeam(team)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
async function handleSaveTeam(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
||||
const form = event.currentTarget
|
||||
const formData = new FormData(form)
|
||||
|
||||
const teamId = selectedTeam?.id
|
||||
const isEditing = Boolean(teamId)
|
||||
|
||||
const payload = {
|
||||
name: String(formData.get('teamName') ?? ''),
|
||||
initial: String(formData.get('teamInitial') ?? ''),
|
||||
@ -79,28 +120,41 @@ export default function TeamsAdministration() {
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/admin/teams`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
const response = await fetch(
|
||||
isEditing
|
||||
? `${API_URL}/admin/teams/${teamId}`
|
||||
: `${API_URL}/admin/teams`,
|
||||
{
|
||||
method: isEditing ? 'PUT' : 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await readApiError(response, 'Team konnte nicht erstellt werden'),
|
||||
await readApiError(
|
||||
response,
|
||||
isEditing
|
||||
? 'Team konnte nicht aktualisiert werden'
|
||||
: 'Team konnte nicht erstellt werden',
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
await loadTeams()
|
||||
form.reset()
|
||||
setSelectedTeam(null)
|
||||
} catch (error) {
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Team konnte nicht erstellt werden',
|
||||
: isEditing
|
||||
? 'Team konnte nicht aktualisiert werden'
|
||||
: 'Team konnte nicht erstellt werden',
|
||||
)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
@ -128,60 +182,116 @@ export default function TeamsAdministration() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1fr)_28rem]">
|
||||
<div className="rounded-lg bg-white p-4 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
|
||||
<h2 className="flex items-center gap-x-2 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
<UserGroupIcon aria-hidden="true" className="size-5 text-gray-400" />
|
||||
Teams
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[19rem_minmax(0,1fr)]">
|
||||
<aside className="overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
|
||||
<div className="border-b border-gray-200 px-4 py-4 dark:border-white/10">
|
||||
<div className="flex items-center justify-between gap-x-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="flex items-center gap-x-2 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
<UserGroupIcon aria-hidden="true" className="size-5 text-gray-400" />
|
||||
Teams
|
||||
</h2>
|
||||
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Übersicht der aktuell vorhandenen Teams.
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{filteredTeams.length} von {teams.length} angezeigt
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 divide-y divide-gray-200 rounded-lg border border-gray-200 dark:divide-white/10 dark:border-white/10">
|
||||
{teams.length === 0 ? (
|
||||
<div className="p-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
Keine Teams vorhanden.
|
||||
<Button
|
||||
type="button"
|
||||
color="indigo"
|
||||
size="sm"
|
||||
onClick={handleCreateNewTeam}
|
||||
leadingIcon={<PlusIcon aria-hidden="true" className="size-4" />}
|
||||
>
|
||||
Neu
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="relative mt-4">
|
||||
<MagnifyingGlassIcon
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-gray-400"
|
||||
/>
|
||||
|
||||
<input
|
||||
type="search"
|
||||
value={teamSearch}
|
||||
onChange={(event) => setTeamSearch(event.target.value)}
|
||||
placeholder="Teams suchen..."
|
||||
className="block w-full rounded-md bg-white py-1.5 pr-3 pl-9 text-sm text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[calc(100dvh-20rem)] overflow-y-auto p-2">
|
||||
{filteredTeams.length === 0 ? (
|
||||
<div className="px-3 py-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
Keine Teams gefunden.
|
||||
</div>
|
||||
) : (
|
||||
teams.map((team) => (
|
||||
<div
|
||||
key={team.id}
|
||||
className="flex items-center justify-between gap-x-4 p-4"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-x-3">
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-indigo-600 text-sm font-semibold text-white">
|
||||
{team.initial}
|
||||
</span>
|
||||
<div className="space-y-1">
|
||||
{filteredTeams.map((team) => {
|
||||
const isSelected = selectedTeam?.id === team.id
|
||||
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-gray-900 dark:text-white">
|
||||
{team.name}
|
||||
</p>
|
||||
return (
|
||||
<button
|
||||
key={team.id}
|
||||
type="button"
|
||||
onClick={() => handleEditTeam(team)}
|
||||
className={classNames(
|
||||
isSelected
|
||||
? 'bg-indigo-50 text-indigo-700 ring-1 ring-indigo-200 ring-inset dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-500/20'
|
||||
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-white/5',
|
||||
'flex w-full gap-x-3 rounded-md px-3 py-2 text-left text-sm',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={classNames(
|
||||
isSelected
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 dark:bg-white/10 dark:text-gray-300',
|
||||
'flex size-9 shrink-0 items-center justify-center rounded-full text-xs font-semibold',
|
||||
)}
|
||||
>
|
||||
{team.initial || team.name.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
|
||||
<p className="truncate text-xs text-gray-500 dark:text-gray-400">
|
||||
{team.href}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium">
|
||||
{team.name}
|
||||
</span>
|
||||
|
||||
<span className="block truncate text-xs text-gray-500 dark:text-gray-400">
|
||||
{team.href || '#'}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<form
|
||||
key={selectedTeam?.id ?? 'new'}
|
||||
onSubmit={handleSaveTeam}
|
||||
className="rounded-lg bg-white p-4 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10"
|
||||
>
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Team anlegen
|
||||
</h2>
|
||||
<div className="flex items-start justify-between gap-x-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{selectedTeam ? 'Team bearbeiten' : 'Team anlegen'}
|
||||
</h2>
|
||||
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Erstelle ein neues Team, das anschließend Benutzern zugewiesen werden kann.
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{selectedTeam
|
||||
? 'Passe Name, Initial und Link des ausgewählten Teams an.'
|
||||
: 'Erstelle ein neues Team, das anschließend Benutzern zugewiesen werden kann.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<div>
|
||||
@ -198,6 +308,7 @@ export default function TeamsAdministration() {
|
||||
name="teamName"
|
||||
placeholder="Teamname"
|
||||
required
|
||||
defaultValue={selectedTeam?.name ?? ''}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
@ -217,6 +328,7 @@ export default function TeamsAdministration() {
|
||||
name="teamInitial"
|
||||
placeholder="Initial"
|
||||
maxLength={3}
|
||||
defaultValue={selectedTeam?.initial ?? ''}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
@ -235,21 +347,33 @@ export default function TeamsAdministration() {
|
||||
id="team-href"
|
||||
name="teamHref"
|
||||
placeholder="Link, z. B. #"
|
||||
defaultValue="#"
|
||||
defaultValue={selectedTeam?.href ?? '#'}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<div className="mt-4 flex justify-end gap-x-3">
|
||||
{selectedTeam && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
color="gray"
|
||||
disabled={isSaving}
|
||||
onClick={handleCreateNewTeam}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
color="indigo"
|
||||
isLoading={isSaving}
|
||||
>
|
||||
Team erstellen
|
||||
{selectedTeam ? 'Änderungen speichern' : 'Team erstellen'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -3,7 +3,12 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { DialogTitle } from '@headlessui/react'
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline'
|
||||
import { CheckIcon } from '@heroicons/react/20/solid'
|
||||
import {
|
||||
CheckIcon,
|
||||
IdentificationIcon,
|
||||
MapPinIcon,
|
||||
WrenchScrewdriverIcon,
|
||||
} from '@heroicons/react/20/solid'
|
||||
import Modal from '../../components/Modal'
|
||||
import Button from '../../components/Button'
|
||||
import type { Device } from '../../components/types'
|
||||
@ -21,10 +26,26 @@ type CreateDeviceModalProps = {
|
||||
onSubmit: (event: FormEvent<HTMLFormElement>) => void
|
||||
}
|
||||
|
||||
const createDeviceTabs: Array<{ id: DeviceModalTabId; name: string }> = [
|
||||
{ id: 'masterData', name: 'Stammdaten' },
|
||||
{ id: 'location', name: 'Standort' },
|
||||
{ id: 'accessories', name: 'Zubehör' },
|
||||
const createDeviceTabs: Array<{
|
||||
id: DeviceModalTabId
|
||||
name: string
|
||||
icon: typeof IdentificationIcon
|
||||
}> = [
|
||||
{
|
||||
id: 'masterData',
|
||||
name: 'Stammdaten',
|
||||
icon: IdentificationIcon,
|
||||
},
|
||||
{
|
||||
id: 'location',
|
||||
name: 'Standort',
|
||||
icon: MapPinIcon,
|
||||
},
|
||||
{
|
||||
id: 'accessories',
|
||||
name: 'Zubehör',
|
||||
icon: WrenchScrewdriverIcon,
|
||||
},
|
||||
]
|
||||
|
||||
export default function CreateDeviceModal({
|
||||
|
||||
@ -7,6 +7,7 @@ import Switch from '../../components/Switch'
|
||||
import type { DeviceModalTabId } from './DeviceModalTabs'
|
||||
import Textarea from '../../components/Textarea'
|
||||
import type { Device } from '../../components/types'
|
||||
import { VideoCameraIcon } from '@heroicons/react/20/solid'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||
|
||||
@ -236,6 +237,11 @@ export default function DeviceFormFields({
|
||||
const milestoneReadonlyClassName =
|
||||
'!cursor-not-allowed !bg-gray-50 !text-gray-500 !outline-gray-200 dark:!bg-white/10 dark:!text-gray-400 dark:!outline-white/10'
|
||||
|
||||
const milestoneInputIconClassName =
|
||||
'pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-indigo-500 dark:text-indigo-400'
|
||||
|
||||
const milestoneInputWithIconClassName = 'pl-9'
|
||||
|
||||
async function loadDeviceLookups() {
|
||||
setLookupError(null)
|
||||
|
||||
@ -378,25 +384,90 @@ export default function DeviceFormFields({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isMilestoneDevice && device?.milestoneDisplayName && (
|
||||
<div className="sm:col-span-6">
|
||||
<label
|
||||
htmlFor="milestoneDisplayName"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Anzeigename
|
||||
</label>
|
||||
{isMilestoneDevice && (
|
||||
<>
|
||||
<input type="hidden" name="isMilestoneDevice" value="true" />
|
||||
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="milestoneDisplayName"
|
||||
name="milestoneDisplayName"
|
||||
type="text"
|
||||
defaultValue={device.milestoneDisplayName}
|
||||
className={inputClassName}
|
||||
/>
|
||||
<div className="sm:col-span-6">
|
||||
<label
|
||||
htmlFor="milestoneDisplayName"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Anzeigename
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<div className="relative mt-2">
|
||||
<VideoCameraIcon
|
||||
aria-hidden="true"
|
||||
className={milestoneInputIconClassName}
|
||||
/>
|
||||
|
||||
<input
|
||||
id="milestoneDisplayName"
|
||||
name="milestoneDisplayName"
|
||||
type="text"
|
||||
defaultValue={device?.milestoneDisplayName ?? ''}
|
||||
className={classNames(inputClassName, milestoneInputWithIconClassName)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<label
|
||||
htmlFor="milestoneHardwareId"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Milestone-Geräte-ID
|
||||
</label>
|
||||
|
||||
<div className="relative mt-2">
|
||||
<VideoCameraIcon
|
||||
aria-hidden="true"
|
||||
className={milestoneInputIconClassName}
|
||||
/>
|
||||
|
||||
<input
|
||||
id="milestoneHardwareId"
|
||||
type="text"
|
||||
value={device?.milestoneHardwareId ?? ''}
|
||||
readOnly
|
||||
aria-readonly="true"
|
||||
title="Die Milestone-Geräte-ID wird aus Milestone synchronisiert."
|
||||
className={classNames(
|
||||
inputClassName,
|
||||
milestoneInputWithIconClassName,
|
||||
milestoneReadonlyClassName,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<label
|
||||
htmlFor="milestoneEnabled"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Milestone-Status
|
||||
</label>
|
||||
|
||||
<div className="mt-2 flex min-h-[38px] items-center gap-3 rounded-md border border-gray-200 bg-white px-3 py-1.5 dark:border-white/10 dark:bg-white/5">
|
||||
<VideoCameraIcon
|
||||
aria-hidden="true"
|
||||
className="size-4 shrink-0 text-indigo-500 dark:text-indigo-400"
|
||||
/>
|
||||
|
||||
<Switch
|
||||
id="milestoneEnabled"
|
||||
name="milestoneEnabled"
|
||||
label="Aktiviert"
|
||||
defaultChecked={device?.milestoneEnabled ?? true}
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
@ -463,7 +534,14 @@ export default function DeviceFormFields({
|
||||
MAC-Adresse
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<div className="relative mt-2">
|
||||
{isMilestoneDevice && (
|
||||
<VideoCameraIcon
|
||||
aria-hidden="true"
|
||||
className={milestoneInputIconClassName}
|
||||
/>
|
||||
)}
|
||||
|
||||
<input
|
||||
id="macAddress"
|
||||
name="macAddress"
|
||||
@ -483,6 +561,7 @@ export default function DeviceFormFields({
|
||||
onInvalid={isMilestoneDevice ? undefined : handleMacAddressInvalid}
|
||||
className={classNames(
|
||||
inputClassName,
|
||||
isMilestoneDevice && milestoneInputWithIconClassName,
|
||||
isMilestoneDevice && milestoneReadonlyClassName,
|
||||
)}
|
||||
/>
|
||||
@ -497,18 +576,59 @@ export default function DeviceFormFields({
|
||||
IP-Adresse
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<div className="relative mt-2">
|
||||
{isMilestoneDevice && (
|
||||
<VideoCameraIcon
|
||||
aria-hidden="true"
|
||||
className={milestoneInputIconClassName}
|
||||
/>
|
||||
)}
|
||||
|
||||
<input
|
||||
id="ipAddress"
|
||||
name="ipAddress"
|
||||
type="text"
|
||||
placeholder="192.168.1.100"
|
||||
defaultValue={device?.ipAddress ?? ''}
|
||||
className={inputClassName}
|
||||
className={classNames(
|
||||
inputClassName,
|
||||
isMilestoneDevice && milestoneInputWithIconClassName,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isMilestoneDevice && (
|
||||
<div className="sm:col-span-3">
|
||||
<label
|
||||
htmlFor="milestonePort"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Port
|
||||
</label>
|
||||
|
||||
<div className="relative mt-2">
|
||||
<VideoCameraIcon
|
||||
aria-hidden="true"
|
||||
className={milestoneInputIconClassName}
|
||||
/>
|
||||
|
||||
<input
|
||||
id="milestonePort"
|
||||
name="milestonePort"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
placeholder="4107"
|
||||
defaultValue={device?.milestonePort ?? ''}
|
||||
className={classNames(
|
||||
inputClassName,
|
||||
milestoneInputWithIconClassName,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<label
|
||||
htmlFor="firmwareVersion"
|
||||
@ -517,7 +637,14 @@ export default function DeviceFormFields({
|
||||
Firmware
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<div className="relative mt-2">
|
||||
{isMilestoneDevice && (
|
||||
<VideoCameraIcon
|
||||
aria-hidden="true"
|
||||
className={milestoneInputIconClassName}
|
||||
/>
|
||||
)}
|
||||
|
||||
<input
|
||||
id="firmwareVersion"
|
||||
name="firmwareVersion"
|
||||
@ -528,6 +655,7 @@ export default function DeviceFormFields({
|
||||
title={isMilestoneDevice ? 'Firmware wird aus Milestone synchronisiert.' : undefined}
|
||||
className={classNames(
|
||||
inputClassName,
|
||||
isMilestoneDevice && milestoneInputWithIconClassName,
|
||||
isMilestoneDevice && milestoneReadonlyClassName,
|
||||
)}
|
||||
/>
|
||||
@ -583,7 +711,7 @@ export default function DeviceFormFields({
|
||||
label="Kommentar"
|
||||
variant="simple"
|
||||
rows={3}
|
||||
defaultValue={device?.comment ?? ''}
|
||||
defaultValue={device?.comment ?? '-'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,10 +1,15 @@
|
||||
// frontend\src\pages\devices\DeviceModalTabs.tsx
|
||||
|
||||
import { type ComponentType, type SVGProps } from 'react'
|
||||
|
||||
export type DeviceModalTabId = 'masterData' | 'location' | 'accessories' | 'history'
|
||||
|
||||
type DeviceModalTabIcon = ComponentType<SVGProps<SVGSVGElement>>
|
||||
|
||||
type DeviceModalTab = {
|
||||
id: DeviceModalTabId
|
||||
name: string
|
||||
icon?: DeviceModalTabIcon
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
@ -25,9 +30,13 @@ export default function DeviceModalTabs({
|
||||
}: DeviceModalTabsProps) {
|
||||
return (
|
||||
<div className="border-b border-gray-200 bg-white px-4 sm:px-6 dark:border-white/10 dark:bg-gray-900">
|
||||
<nav aria-label="Geräteformular" className="-mb-px flex gap-x-6 overflow-x-auto">
|
||||
<nav
|
||||
aria-label="Geräteformular"
|
||||
className="-mb-px flex gap-x-6 overflow-x-auto"
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = activeTab === tab.id
|
||||
const Icon = tab.icon
|
||||
|
||||
return (
|
||||
<button
|
||||
@ -36,14 +45,21 @@ export default function DeviceModalTabs({
|
||||
disabled={tab.disabled}
|
||||
onClick={() => onChange(tab.id)}
|
||||
className={classNames(
|
||||
'whitespace-nowrap border-b-2 px-1 py-3 text-sm font-medium',
|
||||
'inline-flex items-center gap-x-2 whitespace-nowrap border-b-2 px-1 py-3 text-sm font-medium',
|
||||
isActive
|
||||
? 'border-indigo-600 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400'
|
||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-white/20 dark:hover:text-gray-200',
|
||||
tab.disabled && 'cursor-not-allowed opacity-50',
|
||||
)}
|
||||
>
|
||||
{tab.name}
|
||||
{Icon && (
|
||||
<Icon
|
||||
aria-hidden="true"
|
||||
className="size-4 shrink-0"
|
||||
/>
|
||||
)}
|
||||
|
||||
<span>{tab.name}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// frontend/src/pages/DevicesPage.tsx
|
||||
// frontend\src\pages\devices\DevicesPage.tsx
|
||||
|
||||
import { useEffect, useRef, useState, type ComponentProps, type FormEvent, type MouseEvent } from 'react'
|
||||
import {
|
||||
@ -7,6 +7,7 @@ import {
|
||||
CheckCircleIcon,
|
||||
ClockIcon,
|
||||
ExclamationTriangleIcon,
|
||||
PlusIcon,
|
||||
WrenchScrewdriverIcon,
|
||||
XCircleIcon,
|
||||
} from '@heroicons/react/20/solid'
|
||||
@ -17,7 +18,7 @@ import EditDeviceModal from './EditDeviceModal'
|
||||
import Tables, { type TableColumn } from '../../components/Tables'
|
||||
import LoadingSpinner from '../../components/LoadingSpinner'
|
||||
import type { Device, DeviceHistoryEntry } from '../../components/types'
|
||||
import Notifications, { type ToastNotification } from '../../components/Notifications'
|
||||
import { useNotifications } from '../../components/Notifications'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||
|
||||
@ -59,10 +60,49 @@ function formatFirmwareCheckedAt(value?: string | null) {
|
||||
return 'Noch nicht geprüft'
|
||||
}
|
||||
|
||||
const date = new Date(value)
|
||||
|
||||
if (!Number.isFinite(date.getTime())) {
|
||||
return 'Noch nicht geprüft'
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(value))
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
function formatFirmwareCheckRemaining(nextAllowedAt?: string | null, now = Date.now()) {
|
||||
if (!nextAllowedAt) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nextAllowedTimestamp = new Date(nextAllowedAt).getTime()
|
||||
|
||||
if (!Number.isFinite(nextAllowedTimestamp)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const remainingSeconds = Math.ceil((nextAllowedTimestamp - now) / 1000)
|
||||
|
||||
if (remainingSeconds <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const minutes = Math.ceil(remainingSeconds / 60)
|
||||
|
||||
if (minutes < 60) {
|
||||
return `in ${minutes} Min.`
|
||||
}
|
||||
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const remainingMinutes = minutes % 60
|
||||
|
||||
if (remainingMinutes === 0) {
|
||||
return `in ${hours} Std.`
|
||||
}
|
||||
|
||||
return `in ${hours} Std. ${remainingMinutes} Min.`
|
||||
}
|
||||
|
||||
function getLatestFirmwareCheckedAt(devices: Device[]) {
|
||||
@ -124,16 +164,15 @@ function getSupportBadgeTone(severity?: string): BadgeTone {
|
||||
}
|
||||
|
||||
export default function DevicesPage() {
|
||||
const { showToast, showErrorToast } = useNotifications()
|
||||
const [devices, setDevices] = useState<Device[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [activeModal, setActiveModal] = useState<'create' | 'edit' | null>(null)
|
||||
const [editingDevice, setEditingDevice] = useState<Device | null>(null)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [createError, setCreateError] = useState<string | null>(null)
|
||||
const [relatedDeviceIds, setRelatedDeviceIds] = useState<string[]>([])
|
||||
const [isJournalSubmitting, setIsJournalSubmitting] = useState(false)
|
||||
const [notifications, setNotifications] = useState<ToastNotification[]>([])
|
||||
const [togglingMilestoneDeviceIds, setTogglingMilestoneDeviceIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
)
|
||||
@ -215,7 +254,6 @@ export default function DevicesPage() {
|
||||
|
||||
async function handleCheckFirmware() {
|
||||
setIsCheckingFirmware(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/devices/firmware-check`, {
|
||||
@ -250,7 +288,7 @@ export default function DevicesPage() {
|
||||
await loadDevices({ silent: true })
|
||||
await loadFirmwareCheckState()
|
||||
|
||||
showNotification({
|
||||
showToast({
|
||||
variant: data.failedModels > 0 ? 'warning' : 'success',
|
||||
title: 'Firmware geprüft',
|
||||
message:
|
||||
@ -259,14 +297,10 @@ export default function DevicesPage() {
|
||||
: `${data.checkedModels} Modelle wurden geprüft.`,
|
||||
})
|
||||
} catch (error) {
|
||||
showNotification({
|
||||
variant: 'error',
|
||||
showErrorToast({
|
||||
title: 'Firmware-Prüfung fehlgeschlagen',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Firmware konnte nicht geprüft werden',
|
||||
autoClose: false,
|
||||
error,
|
||||
fallback: 'Firmware konnte nicht geprüft werden',
|
||||
})
|
||||
} finally {
|
||||
setIsCheckingFirmware(false)
|
||||
@ -308,13 +342,52 @@ export default function DevicesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdateDeviceJournalEntry(
|
||||
itemId: string | number,
|
||||
content: string,
|
||||
) {
|
||||
if (!editingDevice) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsJournalSubmitting(true)
|
||||
setHistoryError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_URL}/devices/${editingDevice.id}/history/${itemId}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ content }),
|
||||
},
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null)
|
||||
throw new Error(errorData?.error ?? 'Journal-Eintrag konnte nicht gespeichert werden')
|
||||
}
|
||||
|
||||
await loadDeviceHistory(editingDevice.id)
|
||||
} catch (error) {
|
||||
setHistoryError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Journal-Eintrag konnte nicht gespeichert werden',
|
||||
)
|
||||
} finally {
|
||||
setIsJournalSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDevices(options?: { silent?: boolean }) {
|
||||
if (!options?.silent) {
|
||||
setIsLoading(true)
|
||||
}
|
||||
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/devices`, {
|
||||
credentials: 'include',
|
||||
@ -328,7 +401,11 @@ export default function DevicesPage() {
|
||||
const data = await response.json()
|
||||
setDevices(data.devices ?? [])
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'Geräte konnten nicht geladen werden')
|
||||
showErrorToast({
|
||||
title: 'Geräte konnten nicht geladen werden',
|
||||
error,
|
||||
fallback: 'Geräte konnten nicht geladen werden',
|
||||
})
|
||||
} finally {
|
||||
if (!options?.silent) {
|
||||
setIsLoading(false)
|
||||
@ -426,22 +503,6 @@ export default function DevicesPage() {
|
||||
})
|
||||
}
|
||||
|
||||
function dismissNotification(id: string) {
|
||||
setNotifications((currentNotifications) =>
|
||||
currentNotifications.filter((notification) => notification.id !== id),
|
||||
)
|
||||
}
|
||||
|
||||
function showNotification(notification: Omit<ToastNotification, 'id'>) {
|
||||
setNotifications((currentNotifications) => [
|
||||
{
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
...notification,
|
||||
},
|
||||
...currentNotifications,
|
||||
])
|
||||
}
|
||||
|
||||
async function readApiError(response: Response, fallback: string) {
|
||||
const data = await response.json().catch(() => null)
|
||||
|
||||
@ -465,9 +526,15 @@ export default function DevicesPage() {
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/devices/${device.id}/milestone-toggle`, {
|
||||
method: 'POST',
|
||||
const response = await fetch(`${API_URL}/devices/${device.id}/milestone`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
enabled: !device.milestoneEnabled,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
@ -502,7 +569,7 @@ export default function DevicesPage() {
|
||||
: currentDevice,
|
||||
)
|
||||
|
||||
showNotification({
|
||||
showToast({
|
||||
variant: 'success',
|
||||
title: 'Milestone-Status geändert',
|
||||
message: `Gerät ${device.inventoryNumber} wurde in Milestone ${
|
||||
@ -510,7 +577,7 @@ export default function DevicesPage() {
|
||||
}.`,
|
||||
})
|
||||
} catch (error) {
|
||||
showNotification({
|
||||
showToast({
|
||||
variant: 'error',
|
||||
title: 'Status konnte nicht geändert werden',
|
||||
message:
|
||||
@ -538,6 +605,8 @@ export default function DevicesPage() {
|
||||
setIsCreating(true)
|
||||
setCreateError(null)
|
||||
|
||||
const isMilestoneDevice = formData.get('isMilestoneDevice') === 'true'
|
||||
|
||||
const payload = {
|
||||
inventoryNumber: String(formData.get('inventoryNumber') ?? ''),
|
||||
manufacturer: String(formData.get('manufacturer') ?? ''),
|
||||
@ -545,8 +614,12 @@ export default function DevicesPage() {
|
||||
serialNumber: String(formData.get('serialNumber') ?? ''),
|
||||
macAddress: String(formData.get('macAddress') ?? ''),
|
||||
ipAddress: String(formData.get('ipAddress') ?? ''),
|
||||
milestonePort: String(formData.get('milestonePort') ?? ''),
|
||||
firmwareVersion: String(formData.get('firmwareVersion') ?? ''),
|
||||
milestoneDisplayName: String(formData.get('milestoneDisplayName') ?? ''),
|
||||
...(isMilestoneDevice
|
||||
? { milestoneEnabled: formData.get('milestoneEnabled') === 'on' }
|
||||
: {}),
|
||||
location: String(formData.get('location') ?? ''),
|
||||
loanStatus: String(formData.get('loanStatus') ?? 'Verfügbar'),
|
||||
comment: String(formData.get('comment') ?? ''),
|
||||
@ -555,6 +628,41 @@ export default function DevicesPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
if (deviceToEdit?.milestoneHardwareId && isMilestoneDevice) {
|
||||
const milestoneResponse = await fetch(`${API_URL}/devices/${deviceToEdit.id}/milestone`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
name: String(formData.get('milestoneDisplayName') ?? ''),
|
||||
address: String(formData.get('ipAddress') ?? ''),
|
||||
port: String(formData.get('milestonePort') ?? ''),
|
||||
enabled: formData.get('milestoneEnabled') === 'on',
|
||||
}),
|
||||
})
|
||||
|
||||
if (!milestoneResponse.ok) {
|
||||
const errorData = await milestoneResponse.json().catch(() => null)
|
||||
const error = new Error(errorData?.error ?? 'Milestone-Gerät konnte nicht aktualisiert werden')
|
||||
|
||||
showErrorToast({
|
||||
title: 'Milestone-Aktualisierung fehlgeschlagen',
|
||||
error,
|
||||
fallback: 'Milestone-Gerät konnte nicht aktualisiert werden',
|
||||
})
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
showToast({
|
||||
variant: 'success',
|
||||
title: 'Milestone aktualisiert',
|
||||
message: `Gerät ${payload.inventoryNumber || deviceToEdit.inventoryNumber} wurde erfolgreich in Milestone aktualisiert.`,
|
||||
})
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
deviceToEdit
|
||||
? `${API_URL}/devices/${deviceToEdit.id}`
|
||||
@ -606,6 +714,15 @@ export default function DevicesPage() {
|
||||
new Date(firmwareCheckNextAllowedAt as string).getTime() > firmwareCheckNow
|
||||
|
||||
const firmwareCheckButtonDisabled = isCheckingFirmware || isFirmwareCheckLocked
|
||||
const firmwareCheckRemainingLabel = isFirmwareCheckLocked
|
||||
? formatFirmwareCheckRemaining(firmwareCheckNextAllowedAt, firmwareCheckNow)
|
||||
: null
|
||||
const firmwareCheckNextAllowedLabel =
|
||||
isFirmwareCheckLocked && firmwareCheckNextAllowedAt
|
||||
? `Wieder möglich: ${formatFirmwareCheckedAt(firmwareCheckNextAllowedAt)}${
|
||||
firmwareCheckRemainingLabel ? ` (${firmwareCheckRemainingLabel})` : ''
|
||||
}`
|
||||
: null
|
||||
|
||||
const columns: TableColumn<Device>[] = [
|
||||
{
|
||||
@ -755,19 +872,36 @@ export default function DevicesPage() {
|
||||
|
||||
const isToggling = togglingMilestoneDeviceIds.has(device.id)
|
||||
const label = device.milestoneEnabled ? 'Aktiviert' : 'Deaktiviert'
|
||||
const milestoneIcon = isToggling ? (
|
||||
<ClockIcon />
|
||||
) : device.milestoneEnabled ? (
|
||||
<CheckCircleIcon />
|
||||
) : (
|
||||
<XCircleIcon />
|
||||
)
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isToggling}
|
||||
onClick={(event) => handleToggleMilestoneDeviceStatus(device, event)}
|
||||
className="inline-flex cursor-pointer rounded-md transition disabled:cursor-wait disabled:opacity-60"
|
||||
title={`Milestone-Gerät ${
|
||||
device.milestoneEnabled ? 'deaktivieren' : 'aktivieren'
|
||||
}`}
|
||||
className="inline-flex min-w-28 cursor-pointer justify-center rounded-md transition disabled:cursor-wait disabled:opacity-60"
|
||||
title={
|
||||
isToggling
|
||||
? 'Milestone-Status wird geändert...'
|
||||
: `Milestone-Gerät ${
|
||||
device.milestoneEnabled ? 'deaktivieren' : 'aktivieren'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Badge tone={getLoanStatusBadgeTone(label)} variant="border">
|
||||
{isToggling ? 'Wird geändert...' : label}
|
||||
<Badge
|
||||
tone={getLoanStatusBadgeTone(label)}
|
||||
variant="border"
|
||||
hover={!isToggling}
|
||||
leadingIcon={milestoneIcon}
|
||||
className="w-full justify-center whitespace-nowrap"
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
</button>
|
||||
)
|
||||
@ -794,44 +928,43 @@ export default function DevicesPage() {
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0 overflow-y-auto px-4 py-10 sm:px-6 lg:px-8 [scrollbar-gutter:stable]">
|
||||
<Notifications
|
||||
notifications={notifications}
|
||||
onDismiss={dismissNotification}
|
||||
position="top-right"
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 rounded-md bg-red-50 p-4 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Tables
|
||||
title="Geräte"
|
||||
description="Eine Liste aller Geräte inklusive Inventur-Nr., Hersteller, Modell, Seriennummer, MAC-Adresse, IP-Adresse, Firmware, Standort, Verleih-Status und Zubehör."
|
||||
headerAction={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
color="gray"
|
||||
size="sm"
|
||||
disabled={firmwareCheckButtonDisabled}
|
||||
isLoading={isCheckingFirmware}
|
||||
leadingIcon={<ArrowPathIcon aria-hidden="true" className="size-4" />}
|
||||
onClick={() => void handleCheckFirmware()}
|
||||
>
|
||||
Firmware prüfen
|
||||
</Button>
|
||||
<div className="flex flex-col items-start gap-1.5">
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
color="gray"
|
||||
size="md"
|
||||
disabled={firmwareCheckButtonDisabled}
|
||||
isLoading={isCheckingFirmware}
|
||||
leadingIcon={<ArrowPathIcon aria-hidden="true" className="size-4" />}
|
||||
onClick={() => void handleCheckFirmware()}
|
||||
title={firmwareCheckNextAllowedLabel ?? undefined}
|
||||
>
|
||||
Firmware prüfen
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
color="indigo"
|
||||
size="md"
|
||||
onClick={openCreateModal}
|
||||
>
|
||||
Gerät hinzufügen
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
color="indigo"
|
||||
size="md"
|
||||
leadingIcon={<PlusIcon aria-hidden="true" className="size-4" />}
|
||||
onClick={openCreateModal}
|
||||
>
|
||||
Gerät hinzufügen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{firmwareCheckNextAllowedLabel && (
|
||||
<div className="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 px-2.5 py-1 text-xs text-gray-600 shadow-sm dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||||
<ClockIcon aria-hidden="true" className="size-3.5 shrink-0" />
|
||||
<span>{firmwareCheckNextAllowedLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
onAction={openCreateModal}
|
||||
@ -895,6 +1028,7 @@ export default function DevicesPage() {
|
||||
historyError={historyError}
|
||||
isJournalSubmitting={isJournalSubmitting}
|
||||
onJournalEntrySubmit={handleCreateDeviceJournalEntry}
|
||||
onJournalEntryUpdate={handleUpdateDeviceJournalEntry}
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
@ -1,15 +1,17 @@
|
||||
// frontend\src\pages\devices\EditDeviceModal.tsx
|
||||
|
||||
// frontend/src/pages/devices/EditDeviceModal.tsx
|
||||
|
||||
import { useState, type ComponentProps, type FormEvent } from 'react'
|
||||
import { DialogTitle } from '@headlessui/react'
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline'
|
||||
import {
|
||||
ChatBubbleLeftEllipsisIcon,
|
||||
CheckIcon,
|
||||
IdentificationIcon,
|
||||
MapPinIcon,
|
||||
PencilSquareIcon,
|
||||
PlusCircleIcon,
|
||||
VideoCameraIcon,
|
||||
WrenchScrewdriverIcon,
|
||||
} from '@heroicons/react/20/solid'
|
||||
import Modal from '../../components/Modal'
|
||||
import Journal from '../../components/Journal'
|
||||
@ -41,15 +43,32 @@ type EditDeviceModalProps = {
|
||||
historyError: string | null
|
||||
isJournalSubmitting?: boolean
|
||||
onJournalEntrySubmit?: (content: string) => void | Promise<void>
|
||||
onJournalEntryUpdate?: (itemId: string | number, content: string) => void | Promise<void>
|
||||
}
|
||||
|
||||
type JournalItems = ComponentProps<typeof Journal>['items']
|
||||
|
||||
const editDeviceTabs: Array<{ id: DeviceModalTabId; name: string }> = [
|
||||
{ id: 'masterData', name: 'Stammdaten' },
|
||||
{ id: 'location', name: 'Standort' },
|
||||
{ id: 'accessories', name: 'Zubehör' },
|
||||
{ id: 'history', name: 'Journal' },
|
||||
const editDeviceTabs = [
|
||||
{
|
||||
id: 'masterData' as const,
|
||||
name: 'Stammdaten',
|
||||
icon: IdentificationIcon,
|
||||
},
|
||||
{
|
||||
id: 'location' as const,
|
||||
name: 'Standort',
|
||||
icon: MapPinIcon,
|
||||
},
|
||||
{
|
||||
id: 'accessories' as const,
|
||||
name: 'Zubehör',
|
||||
icon: WrenchScrewdriverIcon,
|
||||
},
|
||||
{
|
||||
id: 'history' as const,
|
||||
name: 'Journal',
|
||||
icon: ChatBubbleLeftEllipsisIcon,
|
||||
},
|
||||
]
|
||||
|
||||
function formatHistoryDate(value: string) {
|
||||
@ -111,7 +130,8 @@ export default function EditDeviceModal({
|
||||
isHistoryLoading,
|
||||
historyError,
|
||||
isJournalSubmitting = false,
|
||||
onJournalEntrySubmit
|
||||
onJournalEntrySubmit,
|
||||
onJournalEntryUpdate,
|
||||
}: EditDeviceModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<DeviceModalTabId>('masterData')
|
||||
|
||||
@ -133,6 +153,15 @@ export default function EditDeviceModal({
|
||||
setActiveTab(getTabForInvalidField(target.name))
|
||||
}
|
||||
|
||||
function handleFormSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
if (activeTab === 'history') {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
onSubmit(event)
|
||||
}
|
||||
|
||||
const historyFeedItems: JournalItems = device
|
||||
? deviceHistory.map((entry) => {
|
||||
const journalChange = entry.changes.find((change) => change.field === 'journal')
|
||||
@ -157,6 +186,15 @@ export default function EditDeviceModal({
|
||||
: 'hat das Gerät bearbeitet.',
|
||||
date: formatHistoryDate(entry.createdAt),
|
||||
datetime: entry.createdAt,
|
||||
editedDate:
|
||||
isJournalEntry && entry.updatedAt && entry.updatedAt !== entry.createdAt
|
||||
? formatHistoryDate(entry.updatedAt)
|
||||
: undefined,
|
||||
editedDatetime:
|
||||
isJournalEntry && entry.updatedAt && entry.updatedAt !== entry.createdAt
|
||||
? entry.updatedAt
|
||||
: undefined,
|
||||
canEdit: isJournalEntry && entry.canEdit,
|
||||
icon: isJournalEntry
|
||||
? ChatBubbleLeftEllipsisIcon
|
||||
: entry.action === 'created'
|
||||
@ -180,7 +218,7 @@ export default function EditDeviceModal({
|
||||
>
|
||||
<form
|
||||
key={device?.id ?? 'edit-device'}
|
||||
onSubmit={onSubmit}
|
||||
onSubmit={handleFormSubmit}
|
||||
onInvalidCapture={handleInvalid}
|
||||
className="flex h-[calc(100dvh-2rem)] max-h-[calc(100dvh-2rem)] flex-col"
|
||||
>
|
||||
@ -200,9 +238,22 @@ export default function EditDeviceModal({
|
||||
)}
|
||||
</DialogTitle>
|
||||
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Bearbeite Gerätedaten, Standort, Verleih-Status und Zubehör.
|
||||
</p>
|
||||
<div className="mt-1 space-y-1">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Bearbeite Gerätedaten, Standort, Verleih-Status und Zubehör.
|
||||
</p>
|
||||
|
||||
{device?.milestoneHardwareId && (
|
||||
<p className="flex items-center gap-1.5 text-xs text-indigo-600 dark:text-indigo-400">
|
||||
<VideoCameraIcon aria-hidden="true" className="size-4 shrink-0" />
|
||||
<span>
|
||||
{device.milestoneSyncedAt
|
||||
? `Zuletzt mit Milestone synchronisiert: ${formatHistoryDate(device.milestoneSyncedAt)}`
|
||||
: 'Noch nicht mit Milestone synchronisiert.'}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@ -264,6 +315,7 @@ export default function EditDeviceModal({
|
||||
placeholder="Journal-Eintrag zum Gerät hinzufügen..."
|
||||
submitLabel="Eintrag hinzufügen"
|
||||
onEntrySubmit={onJournalEntrySubmit}
|
||||
onEntryUpdate={onJournalEntryUpdate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@ -288,17 +340,19 @@ export default function EditDeviceModal({
|
||||
disabled={isSaving}
|
||||
onClick={() => handleOpenChange(false)}
|
||||
>
|
||||
Abbrechen
|
||||
{activeTab === 'history' ? 'Schließen' : 'Abbrechen'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
color="indigo"
|
||||
isLoading={isSaving}
|
||||
leadingIcon={<CheckIcon aria-hidden="true" className="size-4" />}
|
||||
>
|
||||
Änderungen speichern
|
||||
</Button>
|
||||
{activeTab !== 'history' && (
|
||||
<Button
|
||||
type="submit"
|
||||
color="indigo"
|
||||
isLoading={isSaving}
|
||||
leadingIcon={<CheckIcon aria-hidden="true" className="size-4" />}
|
||||
>
|
||||
Änderungen speichern
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
@ -1,19 +1,44 @@
|
||||
// frontend\src\pages\operations\CreateOperationModal.tsx
|
||||
|
||||
import { useEffect, useState, type FormEvent, type MouseEvent } from 'react'
|
||||
import {
|
||||
useEffect,
|
||||
useState,
|
||||
type ComponentType,
|
||||
type FormEvent,
|
||||
type MouseEvent,
|
||||
type ReactNode,
|
||||
type SVGProps,
|
||||
} from 'react'
|
||||
import { DialogTitle } from '@headlessui/react'
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline'
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
CheckIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ClipboardDocumentListIcon,
|
||||
DocumentTextIcon,
|
||||
MapPinIcon,
|
||||
UserGroupIcon,
|
||||
WrenchScrewdriverIcon,
|
||||
} from '@heroicons/react/20/solid'
|
||||
import {
|
||||
CircleMarker,
|
||||
MapContainer,
|
||||
TileLayer,
|
||||
} from 'react-leaflet'
|
||||
import Modal from '../../components/Modal'
|
||||
import Button from '../../components/Button'
|
||||
import AddressCombobox from '../../components/AddressCombobox'
|
||||
import Textarea from '../../components/Textarea'
|
||||
import Combobox, { type ComboboxItem } from '../../components/Combobox'
|
||||
import MultiCombobox from '../../components/MultiCombobox'
|
||||
import Avatar from '../../components/Avatar'
|
||||
import { formatOperationNumberInput, isCompleteOperationNumber } from '../../components/formatter'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||
const OPERATION_UNIT = 'TEG'
|
||||
|
||||
type CreateOperationModalProps = {
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
@ -41,6 +66,41 @@ type OperationFormValues = {
|
||||
remark: string
|
||||
}
|
||||
|
||||
type OperationAssigneeUser = {
|
||||
id: string
|
||||
username: string
|
||||
displayName: string
|
||||
email: string
|
||||
avatar: string
|
||||
unit: string
|
||||
group: string
|
||||
}
|
||||
|
||||
type OperationAssigneesResponse = {
|
||||
leaders?: OperationAssigneeUser[]
|
||||
officers?: OperationAssigneeUser[]
|
||||
}
|
||||
|
||||
function getOperationUserName(user: OperationAssigneeUser) {
|
||||
return user.displayName || user.username || user.email
|
||||
}
|
||||
|
||||
function mapOperationUserToComboboxItem(user: OperationAssigneeUser): ComboboxItem {
|
||||
return {
|
||||
id: user.id,
|
||||
name: getOperationUserName(user),
|
||||
secondaryText: user.email,
|
||||
username: user.username,
|
||||
imageUrl: user.avatar,
|
||||
}
|
||||
}
|
||||
|
||||
async function readApiError(response: Response, fallback: string) {
|
||||
const data = await response.json().catch(() => null)
|
||||
|
||||
return data?.error ?? fallback
|
||||
}
|
||||
|
||||
type SetupStepId =
|
||||
| 'masterData'
|
||||
| 'addresses'
|
||||
@ -49,6 +109,8 @@ type SetupStepId =
|
||||
| 'equipment'
|
||||
| 'review'
|
||||
|
||||
type SetupStepIcon = ComponentType<SVGProps<SVGSVGElement>>
|
||||
|
||||
const inputClassName =
|
||||
'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
|
||||
|
||||
@ -75,42 +137,49 @@ const setupSteps: Array<{
|
||||
id: SetupStepId
|
||||
title: string
|
||||
description: string
|
||||
icon: SetupStepIcon
|
||||
optional?: boolean
|
||||
}> = [
|
||||
{
|
||||
id: 'masterData',
|
||||
title: 'Stammdaten',
|
||||
description: 'Einsatznummer, Name und Delikt.',
|
||||
},
|
||||
{
|
||||
id: 'addresses',
|
||||
title: 'Adressen',
|
||||
description: 'KW-Adresse und Zielobjekt mit Kartenvorschau.',
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
id: 'responsibilities',
|
||||
title: 'Zuständigkeiten',
|
||||
description: 'Sachbearbeitung, Einsatzleitung und Einsatzteam.',
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
id: 'notes',
|
||||
title: 'Zusatzinfos',
|
||||
description: 'Legende und Bemerkung.',
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
id: 'equipment',
|
||||
title: 'Technik',
|
||||
description: 'Kamera, Router, Switchbox und Laptop.',
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
id: 'review',
|
||||
title: 'Prüfen',
|
||||
description: 'Daten prüfen und Einsatz speichern.',
|
||||
},
|
||||
{
|
||||
id: 'masterData',
|
||||
title: 'Stammdaten',
|
||||
description: 'Einsatznummer, Name und Delikt.',
|
||||
icon: ClipboardDocumentListIcon,
|
||||
},
|
||||
{
|
||||
id: 'addresses',
|
||||
title: 'Adressen',
|
||||
description: 'KW-Adresse und Zielobjekt mit Kartenvorschau.',
|
||||
icon: MapPinIcon,
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
id: 'responsibilities',
|
||||
title: 'Zuständigkeiten',
|
||||
description: 'Sachbearbeitung, Einsatzleitung und Einsatzteam.',
|
||||
icon: UserGroupIcon,
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
id: 'notes',
|
||||
title: 'Zusatzinfos',
|
||||
description: 'Legende und Bemerkung.',
|
||||
icon: DocumentTextIcon,
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
id: 'equipment',
|
||||
title: 'Technik',
|
||||
description: 'Kamera, Router, Switchbox und Laptop.',
|
||||
icon: WrenchScrewdriverIcon,
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
id: 'review',
|
||||
title: 'Prüfen',
|
||||
description: 'Daten prüfen und Einsatz speichern.',
|
||||
icon: CheckCircleIcon,
|
||||
},
|
||||
]
|
||||
|
||||
function classNames(...classes: Array<string | false | null | undefined>) {
|
||||
@ -144,6 +213,378 @@ function ReviewItem({
|
||||
)
|
||||
}
|
||||
|
||||
function parseReviewTeamMembers(value: string) {
|
||||
return value
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function ReviewPersonItem({
|
||||
label,
|
||||
item,
|
||||
fallbackValue,
|
||||
}: {
|
||||
label: string
|
||||
item: ComboboxItem | null
|
||||
fallbackValue: string
|
||||
}) {
|
||||
const displayName = item?.name ?? fallbackValue.trim()
|
||||
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<dt className="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{label}
|
||||
</dt>
|
||||
|
||||
<dd className="mt-1">
|
||||
{displayName ? (
|
||||
<div className="flex min-w-0 items-center gap-x-2">
|
||||
<Avatar
|
||||
src={item?.imageUrl}
|
||||
name={displayName}
|
||||
size={8}
|
||||
/>
|
||||
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
title={displayName}
|
||||
className="truncate text-sm font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
{displayName}
|
||||
</p>
|
||||
|
||||
{item?.secondaryText && (
|
||||
<p
|
||||
title={item.secondaryText}
|
||||
className="truncate text-xs text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{item.secondaryText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400 dark:text-gray-500">—</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReviewTeamItem({
|
||||
label,
|
||||
items,
|
||||
fallbackValue,
|
||||
}: {
|
||||
label: string
|
||||
items: ComboboxItem[]
|
||||
fallbackValue: string
|
||||
}) {
|
||||
const fallbackNames = parseReviewTeamMembers(fallbackValue)
|
||||
const fallbackItems: ComboboxItem[] = fallbackNames.map((name) => ({
|
||||
id: `fallback:${name}`,
|
||||
name,
|
||||
}))
|
||||
|
||||
const displayItems: ComboboxItem[] =
|
||||
items.length > 0 ? items : fallbackItems
|
||||
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<dt className="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{label}
|
||||
</dt>
|
||||
|
||||
<dd className="mt-1">
|
||||
{displayItems.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{displayItems.map((item) => (
|
||||
<span
|
||||
key={item.id}
|
||||
className="inline-flex max-w-full items-center gap-x-2 rounded-md bg-indigo-50 px-2 py-1 text-xs font-medium text-indigo-700 ring-1 ring-indigo-600/20 ring-inset dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20"
|
||||
>
|
||||
<Avatar
|
||||
src={item.imageUrl}
|
||||
name={item.name}
|
||||
size={6}
|
||||
/>
|
||||
|
||||
<span className="max-w-44 truncate">{item.name}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400 dark:text-gray-500">—</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReviewSection({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
icon: SetupStepIcon
|
||||
title: string
|
||||
description?: string
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className={classNames(
|
||||
'overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xs dark:border-white/10 dark:bg-white/[0.03]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="border-b border-gray-200 bg-gray-50 px-4 py-3 dark:border-white/10 dark:bg-white/[0.03]">
|
||||
<div className="flex items-start gap-x-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600 dark:bg-indigo-500/10 dark:text-indigo-300">
|
||||
<Icon aria-hidden="true" className="size-5" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{title}
|
||||
</h4>
|
||||
|
||||
{description && (
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ReviewField({
|
||||
label,
|
||||
value,
|
||||
title,
|
||||
className,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
title?: string
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'min-w-0 rounded-lg bg-gray-50 px-3 py-2 ring-1 ring-gray-200 ring-inset dark:bg-white/5 dark:ring-white/10',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<dt className="text-[11px] font-medium tracking-wide text-gray-500 uppercase dark:text-gray-400">
|
||||
{label}
|
||||
</dt>
|
||||
|
||||
<dd
|
||||
title={title || value || undefined}
|
||||
className="mt-1 truncate text-sm font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
{getDisplayValue(value)}
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReviewLongField({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
}) {
|
||||
const displayValue = value.trim()
|
||||
|
||||
return (
|
||||
<div className="min-w-0 rounded-lg bg-gray-50 px-3 py-2 ring-1 ring-gray-200 ring-inset dark:bg-white/5 dark:ring-white/10">
|
||||
<dt className="text-[11px] font-medium tracking-wide text-gray-500 uppercase dark:text-gray-400">
|
||||
{label}
|
||||
</dt>
|
||||
|
||||
<dd className="mt-1">
|
||||
{displayValue ? (
|
||||
<p
|
||||
title={displayValue}
|
||||
className="line-clamp-4 whitespace-pre-wrap text-sm text-gray-900 dark:text-white"
|
||||
>
|
||||
{displayValue}
|
||||
</p>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400 dark:text-gray-500">—</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type ReviewMapCoordinates = {
|
||||
lat: number
|
||||
lon: number
|
||||
}
|
||||
|
||||
function toCoordinateNumber(value: unknown) {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : NaN
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : NaN
|
||||
}
|
||||
|
||||
return NaN
|
||||
}
|
||||
|
||||
function ReviewAddressMap({ address }: { address: string }) {
|
||||
const [coordinates, setCoordinates] = useState<ReviewMapCoordinates | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const trimmedAddress = address.trim()
|
||||
|
||||
if (trimmedAddress.length < 3) {
|
||||
setCoordinates(null)
|
||||
return
|
||||
}
|
||||
|
||||
const abortController = new AbortController()
|
||||
|
||||
async function loadCoordinates() {
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_URL}/address-search?q=${encodeURIComponent(trimmedAddress)}`,
|
||||
{
|
||||
credentials: 'include',
|
||||
signal: abortController.signal,
|
||||
},
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
setCoordinates(null)
|
||||
return
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const firstSuggestion = data.suggestions?.[0]
|
||||
|
||||
if (!firstSuggestion) {
|
||||
setCoordinates(null)
|
||||
return
|
||||
}
|
||||
|
||||
const lat = toCoordinateNumber(firstSuggestion.lat)
|
||||
const lon = toCoordinateNumber(firstSuggestion.lon)
|
||||
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
|
||||
setCoordinates(null)
|
||||
return
|
||||
}
|
||||
|
||||
setCoordinates({
|
||||
lat,
|
||||
lon,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
return
|
||||
}
|
||||
|
||||
setCoordinates(null)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void loadCoordinates()
|
||||
|
||||
return () => {
|
||||
abortController.abort()
|
||||
}
|
||||
}, [address])
|
||||
|
||||
if (!address.trim()) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="mt-2 flex h-28 items-center justify-center rounded-md border border-gray-200 bg-white text-xs text-gray-500 dark:border-white/10 dark:bg-white/5 dark:text-gray-400">
|
||||
Karte wird geladen...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!coordinates) {
|
||||
return (
|
||||
<div className="mt-2 flex h-28 items-center justify-center rounded-md border border-gray-200 bg-white text-xs text-gray-500 dark:border-white/10 dark:bg-white/5 dark:text-gray-400">
|
||||
Keine Kartenposition gefunden.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-2 overflow-hidden rounded-md border border-gray-200 bg-white dark:border-white/10 dark:bg-white/5">
|
||||
<MapContainer
|
||||
center={[coordinates.lat, coordinates.lon]}
|
||||
zoom={16}
|
||||
scrollWheelZoom={false}
|
||||
dragging={false}
|
||||
doubleClickZoom={false}
|
||||
zoomControl={false}
|
||||
attributionControl={false}
|
||||
className="h-28 w-full"
|
||||
>
|
||||
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
||||
|
||||
<CircleMarker
|
||||
center={[coordinates.lat, coordinates.lon]}
|
||||
radius={7}
|
||||
pathOptions={{
|
||||
fillOpacity: 0.9,
|
||||
weight: 2,
|
||||
}}
|
||||
/>
|
||||
</MapContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReviewAddressItem({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
}) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<ReviewItem
|
||||
label={label}
|
||||
value={value}
|
||||
/>
|
||||
|
||||
<ReviewAddressMap address={value} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StepHeader({
|
||||
title,
|
||||
description,
|
||||
@ -174,6 +615,12 @@ export default function CreateOperationModal({
|
||||
const [formValues, setFormValues] = useState<OperationFormValues>(initialFormValues)
|
||||
const [localError, setLocalError] = useState<string | null>(null)
|
||||
|
||||
const [operationLeaderOptions, setOperationLeaderOptions] = useState<ComboboxItem[]>([])
|
||||
const [operationTeamOptions, setOperationTeamOptions] = useState<ComboboxItem[]>([])
|
||||
const [selectedOperationLeader, setSelectedOperationLeader] = useState<ComboboxItem | null>(null)
|
||||
const [selectedOperationTeam, setSelectedOperationTeam] = useState<ComboboxItem[]>([])
|
||||
const [isLoadingOperationAssignees, setIsLoadingOperationAssignees] = useState(false)
|
||||
|
||||
const activeStepIndex = getStepIndex(activeStep)
|
||||
const isFirstStep = activeStepIndex === 0
|
||||
const isLastStep = activeStepIndex === setupSteps.length - 1
|
||||
@ -185,9 +632,80 @@ export default function CreateOperationModal({
|
||||
|
||||
setActiveStep('masterData')
|
||||
setFormValues(initialFormValues)
|
||||
setSelectedOperationLeader(null)
|
||||
setSelectedOperationTeam([])
|
||||
setLocalError(null)
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
|
||||
const abortController = new AbortController()
|
||||
|
||||
void loadOperationAssignees(abortController.signal)
|
||||
|
||||
return () => {
|
||||
abortController.abort()
|
||||
}
|
||||
}, [open])
|
||||
|
||||
async function loadOperationAssignees(signal?: AbortSignal) {
|
||||
setIsLoadingOperationAssignees(true)
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_URL}/operations/assignees?unit=${encodeURIComponent(OPERATION_UNIT)}`,
|
||||
{
|
||||
credentials: 'include',
|
||||
signal,
|
||||
},
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await readApiError(response, 'Einsatzpersonen konnten nicht geladen werden'),
|
||||
)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as OperationAssigneesResponse
|
||||
|
||||
setOperationLeaderOptions(
|
||||
(data.leaders ?? []).map(mapOperationUserToComboboxItem),
|
||||
)
|
||||
|
||||
setOperationTeamOptions(
|
||||
(data.officers ?? []).map(mapOperationUserToComboboxItem),
|
||||
)
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
return
|
||||
}
|
||||
|
||||
setLocalError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Einsatzpersonen konnten nicht geladen werden',
|
||||
)
|
||||
} finally {
|
||||
setIsLoadingOperationAssignees(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleOperationLeaderChange(item: ComboboxItem | null) {
|
||||
setSelectedOperationLeader(item)
|
||||
updateField('operationLeader', item?.name ?? '')
|
||||
}
|
||||
|
||||
function handleOperationTeamChange(items: ComboboxItem[]) {
|
||||
setSelectedOperationTeam(items)
|
||||
updateField(
|
||||
'operationTeam',
|
||||
items.map((item) => item.name).join(', '),
|
||||
)
|
||||
}
|
||||
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
if (!nextOpen && isSaving) {
|
||||
return
|
||||
@ -337,6 +855,7 @@ export default function CreateOperationModal({
|
||||
{setupSteps.map((step, index) => {
|
||||
const isActive = step.id === activeStep
|
||||
const isDone = index < activeStepIndex
|
||||
const Icon = step.icon
|
||||
|
||||
return (
|
||||
<button
|
||||
@ -352,9 +871,11 @@ export default function CreateOperationModal({
|
||||
: 'border-gray-200 bg-white text-gray-600 hover:bg-gray-50 dark:border-white/10 dark:bg-white/5 dark:text-gray-300 dark:hover:bg-white/10',
|
||||
)}
|
||||
>
|
||||
<span className="block text-[11px] font-medium">
|
||||
Schritt {index + 1}
|
||||
<span className="flex items-center gap-x-1.5 text-[11px] font-medium">
|
||||
<Icon aria-hidden="true" className="size-3.5 shrink-0" />
|
||||
<span>Schritt {index + 1}</span>
|
||||
</span>
|
||||
|
||||
<span className="mt-0.5 block truncate text-sm font-semibold">
|
||||
{step.title}
|
||||
</span>
|
||||
@ -438,7 +959,7 @@ export default function CreateOperationModal({
|
||||
description="KW und Zielobjekt werden mit AddressCombobox und Karten-Vorschau erfasst."
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
<div className="grid grid-cols-2 gap-4 xl:grid-cols-2">
|
||||
<AddressCombobox
|
||||
id="createKwAddress"
|
||||
name="createKwAddressDisplay"
|
||||
@ -461,59 +982,96 @@ export default function CreateOperationModal({
|
||||
)}
|
||||
|
||||
{activeStep === 'responsibilities' && (
|
||||
<div className="space-y-6">
|
||||
<StepHeader
|
||||
title="Zuständigkeiten"
|
||||
description="Sachbearbeitung, Einsatzleitung und eingesetztes Team."
|
||||
<div className="space-y-6">
|
||||
<StepHeader
|
||||
title="Zuständigkeiten"
|
||||
description="Lege fest, wer den Einsatz bearbeitet, leitet und im Einsatzteam mitwirkt."
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/[0.03]">
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Sachbearbeitung
|
||||
</h4>
|
||||
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Interne sachbearbeitende Person oder Dienststelle.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<input
|
||||
id="createCaseWorker"
|
||||
type="text"
|
||||
value={formValues.caseWorker}
|
||||
onChange={(event) => updateField('caseWorker', event.target.value)}
|
||||
placeholder="Sachbearbeitung eintragen"
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/[0.03]">
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Einsatzleiter
|
||||
</h4>
|
||||
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Wähle einen Einsatzleiter der {OPERATION_UNIT}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Combobox
|
||||
items={operationLeaderOptions}
|
||||
value={selectedOperationLeader}
|
||||
onChange={handleOperationLeaderChange}
|
||||
variant="image"
|
||||
placeholder={
|
||||
isLoadingOperationAssignees
|
||||
? 'Einsatzleiter werden geladen...'
|
||||
: 'Einsatzleiter auswählen'
|
||||
}
|
||||
emptyText={`Keine Einsatzleiter der ${OPERATION_UNIT} gefunden.`}
|
||||
disabled={isLoadingOperationAssignees}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-indigo-200 bg-indigo-50/60 p-4 dark:border-indigo-500/20 dark:bg-indigo-500/10">
|
||||
<div className="mb-3 flex items-start justify-between gap-x-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-indigo-900 dark:text-indigo-200">
|
||||
Einsatzteam
|
||||
</h4>
|
||||
|
||||
<p className="mt-1 text-xs text-indigo-700/80 dark:text-indigo-300/80">
|
||||
Wähle einen oder mehrere Einsatzbeamte der {OPERATION_UNIT}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{selectedOperationTeam.length > 0 && (
|
||||
<span className="shrink-0 rounded-full bg-white px-2.5 py-1 text-xs font-medium text-indigo-700 ring-1 ring-indigo-600/20 ring-inset dark:bg-indigo-500/10 dark:text-indigo-200 dark:ring-indigo-300/20">
|
||||
{selectedOperationTeam.length} ausgewählt
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MultiCombobox
|
||||
items={operationTeamOptions}
|
||||
value={selectedOperationTeam}
|
||||
onChange={handleOperationTeamChange}
|
||||
placeholder={
|
||||
isLoadingOperationAssignees
|
||||
? 'Einsatzbeamte werden geladen...'
|
||||
: 'Einsatzbeamte auswählen'
|
||||
}
|
||||
emptyText={`Keine Einsatzbeamten der ${OPERATION_UNIT} gefunden.`}
|
||||
disabled={isLoadingOperationAssignees}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-6">
|
||||
<div className="sm:col-span-3 xl:col-span-2">
|
||||
<label htmlFor="createCaseWorker" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
Sachbearbeitung
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="createCaseWorker"
|
||||
type="text"
|
||||
value={formValues.caseWorker}
|
||||
onChange={(event) => updateField('caseWorker', event.target.value)}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3 xl:col-span-2">
|
||||
<label htmlFor="createOperationLeader" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
Einsatzleiter
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="createOperationLeader"
|
||||
type="text"
|
||||
value={formValues.operationLeader}
|
||||
onChange={(event) => updateField('operationLeader', event.target.value)}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3 xl:col-span-2">
|
||||
<label htmlFor="createOperationTeam" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
Einsatzteam
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="createOperationTeam"
|
||||
type="text"
|
||||
value={formValues.operationTeam}
|
||||
onChange={(event) => updateField('operationTeam', event.target.value)}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeStep === 'notes' && (
|
||||
@ -615,29 +1173,100 @@ export default function CreateOperationModal({
|
||||
)}
|
||||
|
||||
{activeStep === 'review' && (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-6">
|
||||
<StepHeader
|
||||
title="Einsatz prüfen"
|
||||
description="Kontrolliere die Angaben. Danach kannst du den Einsatz speichern."
|
||||
title="Einsatz prüfen"
|
||||
description="Kontrolliere die Angaben. Danach kannst du den Einsatz speichern."
|
||||
/>
|
||||
|
||||
<dl className="grid grid-cols-1 gap-4 rounded-lg border border-gray-200 bg-gray-50 p-4 sm:grid-cols-2 lg:grid-cols-3 dark:border-white/10 dark:bg-white/5">
|
||||
<ReviewItem label="Einsatznummer" value={formValues.operationNumber} />
|
||||
<ReviewItem label="Einsatzname" value={formValues.operationName} />
|
||||
<ReviewItem label="Delikt" value={formValues.offense} />
|
||||
<ReviewItem label="Sachbearbeitung" value={formValues.caseWorker} />
|
||||
<ReviewItem label="KW" value={formValues.kwAddress} />
|
||||
<ReviewItem label="Zielobjekt" value={formValues.targetObject} />
|
||||
<ReviewItem label="Einsatzleiter" value={formValues.operationLeader} />
|
||||
<ReviewItem label="Einsatzteam" value={formValues.operationTeam} />
|
||||
<ReviewItem label="Kamera" value={formValues.camera} />
|
||||
<ReviewItem label="Router" value={formValues.router} />
|
||||
<ReviewItem label="Switchbox" value={formValues.switchbox} />
|
||||
<ReviewItem label="Laptop" value={formValues.laptop} />
|
||||
<ReviewItem label="Legende" value={formValues.legend} />
|
||||
<ReviewItem label="Bemerkung" value={formValues.remark} />
|
||||
<dl className="space-y-6 rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/5">
|
||||
<div>
|
||||
<div className="border-b border-gray-200 pb-2 dark:border-white/10">
|
||||
<h4 className="text-xs font-semibold tracking-wide text-gray-500 uppercase dark:text-gray-400">
|
||||
Stammdaten
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<ReviewItem label="Einsatznummer" value={formValues.operationNumber} />
|
||||
<ReviewItem label="Einsatzname" value={formValues.operationName} />
|
||||
<ReviewItem label="Delikt" value={formValues.offense} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="border-b border-gray-200 pb-2 dark:border-white/10">
|
||||
<h4 className="text-xs font-semibold tracking-wide text-gray-500 uppercase dark:text-gray-400">
|
||||
Adressen
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<ReviewAddressItem
|
||||
label="KW"
|
||||
value={formValues.kwAddress}
|
||||
/>
|
||||
|
||||
<ReviewAddressItem
|
||||
label="Zielobjekt"
|
||||
value={formValues.targetObject}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="border-b border-gray-200 pb-2 dark:border-white/10">
|
||||
<h4 className="text-xs font-semibold tracking-wide text-gray-500 uppercase dark:text-gray-400">
|
||||
Zuständigkeiten
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<ReviewItem label="Sachbearbeitung" value={formValues.caseWorker} />
|
||||
|
||||
<ReviewPersonItem
|
||||
label="Einsatzleiter"
|
||||
item={selectedOperationLeader}
|
||||
fallbackValue={formValues.operationLeader}
|
||||
/>
|
||||
|
||||
<ReviewTeamItem
|
||||
label="Einsatzteam"
|
||||
items={selectedOperationTeam}
|
||||
fallbackValue={formValues.operationTeam}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="border-b border-gray-200 pb-2 dark:border-white/10">
|
||||
<h4 className="text-xs font-semibold tracking-wide text-gray-500 uppercase dark:text-gray-400">
|
||||
Technik
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<ReviewItem label="Kamera" value={formValues.camera} />
|
||||
<ReviewItem label="Router" value={formValues.router} />
|
||||
<ReviewItem label="Switchbox" value={formValues.switchbox} />
|
||||
<ReviewItem label="Laptop" value={formValues.laptop} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="border-b border-gray-200 pb-2 dark:border-white/10">
|
||||
<h4 className="text-xs font-semibold tracking-wide text-gray-500 uppercase dark:text-gray-400">
|
||||
Zusatzinformationen
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<ReviewItem label="Legende" value={formValues.legend} />
|
||||
<ReviewItem label="Bemerkung" value={formValues.remark} />
|
||||
</div>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@ -561,7 +561,7 @@ export default function OperationMap({
|
||||
|
||||
if (nextMarkers.length === 0) {
|
||||
setGeocodingError(
|
||||
'Für KW-Adressen und Zielobjekte konnten keine Kartenpositionen gefunden werden.',
|
||||
'Für die Einsatzadressen konnten keine Kartenpositionen gefunden werden.',
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
@ -630,15 +630,18 @@ export default function OperationMap({
|
||||
|
||||
const viewCones = getOperationViewCones(markers)
|
||||
|
||||
const heatPoints: MapHeatPoint[] = markers.map((marker) => ({
|
||||
id: marker.id,
|
||||
lat: marker.lat,
|
||||
lon: marker.lon,
|
||||
intensity: marker.type === 'kwAddress' ? 0.85 : 1,
|
||||
const heatPoints: MapHeatPoint[] = markers
|
||||
.filter((marker) => marker.type === 'targetObject')
|
||||
.map((marker) => ({
|
||||
id: marker.id,
|
||||
lat: marker.lat,
|
||||
lon: marker.lon,
|
||||
intensity: 1,
|
||||
}))
|
||||
|
||||
const visibleMapMarkers = mode === 'markers' ? mapMarkers : []
|
||||
const visibleViewCones = mode === 'markers' ? viewCones : []
|
||||
const showOperationLayerControls = mode === 'markers'
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -656,6 +659,10 @@ export default function OperationMap({
|
||||
<div className="relative h-full min-h-[34rem]">
|
||||
<AppMap
|
||||
markers={visibleMapMarkers}
|
||||
markerLayerName={showOperationLayerControls ? 'Einsätze' : undefined}
|
||||
markersChecked={showOperationLayerControls}
|
||||
childrenLayerName={showOperationLayerControls ? 'Sichtwinkel' : undefined}
|
||||
childrenChecked={showOperationLayerControls}
|
||||
heatPoints={heatPoints}
|
||||
showHeatmap={heatPoints.length > 0}
|
||||
heatmapLayerName="Einsatz-Heatmap"
|
||||
|
||||
@ -1,9 +1,26 @@
|
||||
// frontend/src/pages/OperationModal.tsx
|
||||
|
||||
import { useEffect, useMemo, useState, type FormEvent } from 'react'
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ComponentType,
|
||||
type FormEvent,
|
||||
type SVGProps,
|
||||
} from 'react'
|
||||
import { DialogTitle } from '@headlessui/react'
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline'
|
||||
import { ChatBubbleLeftEllipsisIcon, CheckIcon, PencilSquareIcon, PlusCircleIcon } from '@heroicons/react/20/solid'
|
||||
import {
|
||||
ChatBubbleLeftEllipsisIcon,
|
||||
CheckIcon,
|
||||
ClipboardDocumentListIcon,
|
||||
DocumentTextIcon,
|
||||
MapPinIcon,
|
||||
PencilSquareIcon,
|
||||
PlusCircleIcon,
|
||||
UserGroupIcon,
|
||||
WrenchScrewdriverIcon,
|
||||
} from '@heroicons/react/20/solid'
|
||||
import Modal from '../../components/Modal'
|
||||
import Journal, { type JournalItem } from '../../components/Journal'
|
||||
import Button from '../../components/Button'
|
||||
@ -12,6 +29,8 @@ import AddressCombobox from '../../components/AddressCombobox'
|
||||
import type { Operation, OperationHistoryChange, OperationHistoryEntry } from '../../components/types'
|
||||
import { formatOperationNumberInput } from '../../components/formatter'
|
||||
import NotificationDot from '../../components/NotificationDot'
|
||||
import Combobox, { type ComboboxItem } from '../../components/Combobox'
|
||||
import MultiCombobox from '../../components/MultiCombobox'
|
||||
|
||||
type OperationModalProps = {
|
||||
open: boolean
|
||||
@ -38,46 +57,136 @@ type OperationTabId =
|
||||
| 'equipment'
|
||||
| 'journal'
|
||||
|
||||
type OperationTabIcon = ComponentType<SVGProps<SVGSVGElement>>
|
||||
|
||||
const inputClassName =
|
||||
'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
|
||||
|
||||
const sectionClassName =
|
||||
'rounded-lg border border-gray-200 bg-white p-4 shadow-xs dark:border-white/10 dark:bg-white/5'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||
const OPERATION_UNIT = 'TEG'
|
||||
|
||||
type OperationAssigneeUser = {
|
||||
id: string
|
||||
username: string
|
||||
displayName: string
|
||||
email: string
|
||||
avatar: string
|
||||
unit: string
|
||||
group: string
|
||||
}
|
||||
|
||||
type OperationAssigneesResponse = {
|
||||
leaders?: OperationAssigneeUser[]
|
||||
officers?: OperationAssigneeUser[]
|
||||
}
|
||||
|
||||
function getOperationAssigneeName(user: OperationAssigneeUser) {
|
||||
return user.displayName || user.username || user.email
|
||||
}
|
||||
|
||||
function mapOperationAssigneeToComboboxItem(
|
||||
user: OperationAssigneeUser,
|
||||
): ComboboxItem {
|
||||
return {
|
||||
id: user.id,
|
||||
name: getOperationAssigneeName(user),
|
||||
secondaryText: user.email,
|
||||
username: user.username,
|
||||
imageUrl: user.avatar,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePersonName(value: string) {
|
||||
return value.trim().toLowerCase()
|
||||
}
|
||||
|
||||
function parseOperationTeamMembers(value: string) {
|
||||
return value
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function findComboboxItemByName(items: ComboboxItem[], name: string) {
|
||||
const normalizedName = normalizePersonName(name)
|
||||
|
||||
if (!normalizedName) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
items.find((item) => normalizePersonName(item.name) === normalizedName) ??
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
function createFallbackComboboxItem(name: string): ComboboxItem | null {
|
||||
const trimmedName = name.trim()
|
||||
|
||||
if (!trimmedName) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: `fallback:${trimmedName}`,
|
||||
name: trimmedName,
|
||||
}
|
||||
}
|
||||
|
||||
function getSelectionItem(items: ComboboxItem[], name: string) {
|
||||
return findComboboxItemByName(items, name) ?? createFallbackComboboxItem(name)
|
||||
}
|
||||
|
||||
async function readApiError(response: Response, fallback: string) {
|
||||
const data = await response.json().catch(() => null)
|
||||
|
||||
return data?.error ?? fallback
|
||||
}
|
||||
|
||||
const operationTabs: Array<{
|
||||
id: OperationTabId
|
||||
title: string
|
||||
description: string
|
||||
icon: OperationTabIcon
|
||||
}> = [
|
||||
{
|
||||
id: 'masterData',
|
||||
title: 'Stammdaten',
|
||||
description: 'Einsatznummer, Einsatzname und Delikt.',
|
||||
icon: ClipboardDocumentListIcon,
|
||||
},
|
||||
{
|
||||
id: 'addresses',
|
||||
title: 'Adressen',
|
||||
description: 'Zielobjekt und KW-Adresse.',
|
||||
icon: MapPinIcon,
|
||||
},
|
||||
{
|
||||
id: 'responsibilities',
|
||||
title: 'Zuständigkeiten',
|
||||
description: 'Sachbearbeitung, Einsatzleitung und Team.',
|
||||
icon: UserGroupIcon,
|
||||
},
|
||||
{
|
||||
id: 'notes',
|
||||
title: 'Zusatzinfos',
|
||||
description: 'Legende und Bemerkung.',
|
||||
icon: DocumentTextIcon,
|
||||
},
|
||||
{
|
||||
id: 'equipment',
|
||||
title: 'Technik',
|
||||
description: 'Kamera, Router, Switchbox und Laptop.',
|
||||
icon: WrenchScrewdriverIcon,
|
||||
},
|
||||
{
|
||||
id: 'journal',
|
||||
title: 'Journal',
|
||||
description: 'Bearbeitungsverlauf des Einsatzes.',
|
||||
icon: ChatBubbleLeftEllipsisIcon,
|
||||
},
|
||||
]
|
||||
|
||||
@ -155,6 +264,12 @@ export default function OperationModal({
|
||||
const [targetObject, setTargetObject] = useState('')
|
||||
const [kwAddress, setKwAddress] = useState('')
|
||||
|
||||
const [operationLeaderOptions, setOperationLeaderOptions] = useState<ComboboxItem[]>([])
|
||||
const [operationTeamOptions, setOperationTeamOptions] = useState<ComboboxItem[]>([])
|
||||
const [selectedOperationLeader, setSelectedOperationLeader] = useState<ComboboxItem | null>(null)
|
||||
const [selectedOperationTeam, setSelectedOperationTeam] = useState<ComboboxItem[]>([])
|
||||
const [isLoadingOperationAssignees, setIsLoadingOperationAssignees] = useState(false)
|
||||
|
||||
const visibleTabs = useMemo(
|
||||
() => operationTabs.filter((tab) => isEditing || tab.id !== 'journal'),
|
||||
[isEditing],
|
||||
@ -175,6 +290,46 @@ export default function OperationModal({
|
||||
editingOperation?.kwAddress,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
|
||||
const abortController = new AbortController()
|
||||
|
||||
void loadOperationAssignees(abortController.signal)
|
||||
|
||||
return () => {
|
||||
abortController.abort()
|
||||
}
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
|
||||
setSelectedOperationLeader(
|
||||
getSelectionItem(
|
||||
operationLeaderOptions,
|
||||
editingOperation?.operationLeader ?? '',
|
||||
),
|
||||
)
|
||||
|
||||
setSelectedOperationTeam(
|
||||
parseOperationTeamMembers(editingOperation?.operationTeam ?? '')
|
||||
.map((name) => getSelectionItem(operationTeamOptions, name))
|
||||
.filter((item): item is ComboboxItem => item !== null),
|
||||
)
|
||||
}, [
|
||||
open,
|
||||
editingOperation?.id,
|
||||
editingOperation?.operationLeader,
|
||||
editingOperation?.operationTeam,
|
||||
operationLeaderOptions,
|
||||
operationTeamOptions,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || activeTab !== 'journal' || !hasUnreadJournal) {
|
||||
return
|
||||
@ -255,6 +410,53 @@ export default function OperationModal({
|
||||
}
|
||||
})
|
||||
|
||||
async function loadOperationAssignees(signal?: AbortSignal) {
|
||||
setIsLoadingOperationAssignees(true)
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_URL}/operations/assignees?unit=${encodeURIComponent(OPERATION_UNIT)}`,
|
||||
{
|
||||
credentials: 'include',
|
||||
signal,
|
||||
},
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await readApiError(response, 'Einsatzpersonen konnten nicht geladen werden'),
|
||||
)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as OperationAssigneesResponse
|
||||
|
||||
setOperationLeaderOptions(
|
||||
(data.leaders ?? []).map(mapOperationAssigneeToComboboxItem),
|
||||
)
|
||||
|
||||
setOperationTeamOptions(
|
||||
(data.officers ?? []).map(mapOperationAssigneeToComboboxItem),
|
||||
)
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
return
|
||||
}
|
||||
|
||||
// Modal soll trotzdem nutzbar bleiben.
|
||||
console.error(error)
|
||||
} finally {
|
||||
setIsLoadingOperationAssignees(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleOperationLeaderChange(item: ComboboxItem | null) {
|
||||
setSelectedOperationLeader(item)
|
||||
}
|
||||
|
||||
function handleOperationTeamChange(items: ComboboxItem[]) {
|
||||
setSelectedOperationTeam(items)
|
||||
}
|
||||
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
if (!nextOpen && isSaving) {
|
||||
return
|
||||
@ -286,6 +488,18 @@ export default function OperationModal({
|
||||
value={editingOperation?.hardDrive ?? ''}
|
||||
/>
|
||||
|
||||
<input
|
||||
type="hidden"
|
||||
name="operationLeader"
|
||||
value={selectedOperationLeader?.name ?? ''}
|
||||
/>
|
||||
|
||||
<input
|
||||
type="hidden"
|
||||
name="operationTeam"
|
||||
value={selectedOperationTeam.map((item) => item.name).join(', ')}
|
||||
/>
|
||||
|
||||
<div className="flex items-start justify-between border-b border-gray-200 bg-gray-100 px-4 py-4 sm:px-6 dark:border-white/10 dark:bg-gray-800">
|
||||
<div>
|
||||
<DialogTitle className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
@ -313,36 +527,42 @@ export default function OperationModal({
|
||||
|
||||
<div className="shrink-0 border-b border-gray-200 bg-white px-4 sm:px-6 dark:border-white/10 dark:bg-gray-900">
|
||||
<div className="-mb-px flex gap-x-6 overflow-x-auto">
|
||||
{visibleTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveTab(tab.id)
|
||||
{visibleTabs.map((tab) => {
|
||||
const Icon = tab.icon
|
||||
|
||||
if (tab.id === 'journal') {
|
||||
onJournalTabOpen?.()
|
||||
}
|
||||
}}
|
||||
className={classNames(
|
||||
'whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium',
|
||||
activeTab === tab.id
|
||||
? 'border-indigo-600 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400'
|
||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-white/20 dark:hover:text-gray-200',
|
||||
)}
|
||||
>
|
||||
<span className="inline-flex items-center gap-x-2">
|
||||
{tab.title}
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveTab(tab.id)
|
||||
|
||||
{tab.id === 'journal' && hasUnreadJournal && (
|
||||
<NotificationDot
|
||||
title="Neue Journal-Einträge"
|
||||
label="Neue Journal-Einträge"
|
||||
/>
|
||||
if (tab.id === 'journal') {
|
||||
onJournalTabOpen?.()
|
||||
}
|
||||
}}
|
||||
className={classNames(
|
||||
'whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium',
|
||||
activeTab === tab.id
|
||||
? 'border-indigo-600 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400'
|
||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-white/20 dark:hover:text-gray-200',
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
>
|
||||
<span className="inline-flex items-center gap-x-2">
|
||||
<Icon aria-hidden="true" className="size-4 shrink-0" />
|
||||
|
||||
<span>{tab.title}</span>
|
||||
|
||||
{tab.id === 'journal' && hasUnreadJournal && (
|
||||
<NotificationDot
|
||||
title="Neue Journal-Einträge"
|
||||
label="Neue Journal-Einträge"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -423,7 +643,7 @@ export default function OperationModal({
|
||||
description="Zielobjekt und KW-Adresse mit Adresssuche und Karten-Vorschau."
|
||||
/>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
<div className="mt-4 grid grid-cols-2 gap-4 xl:grid-cols-2">
|
||||
<AddressCombobox
|
||||
id="targetObject"
|
||||
name="targetObject"
|
||||
@ -431,6 +651,7 @@ export default function OperationModal({
|
||||
value={targetObject}
|
||||
onChange={setTargetObject}
|
||||
placeholder="Adresse suchen oder Zielobjekt eingeben"
|
||||
mapVisible={activeTab === 'addresses'}
|
||||
/>
|
||||
|
||||
<AddressCombobox
|
||||
@ -440,6 +661,7 @@ export default function OperationModal({
|
||||
value={kwAddress}
|
||||
onChange={setKwAddress}
|
||||
placeholder="KW-Adresse suchen oder eingeben"
|
||||
mapVisible={activeTab === 'addresses'}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
@ -449,53 +671,90 @@ export default function OperationModal({
|
||||
<section className={sectionClassName}>
|
||||
<SectionHeader
|
||||
title="Zuständigkeiten"
|
||||
description="Sachbearbeitung, Einsatzleitung und eingesetztes Team."
|
||||
description="Lege fest, wer den Einsatz bearbeitet, leitet und im Einsatzteam mitwirkt."
|
||||
/>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-6">
|
||||
<div className="sm:col-span-3">
|
||||
<label htmlFor="caseWorker" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
Sachbearbeitung
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/[0.03]">
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Sachbearbeitung
|
||||
</h4>
|
||||
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Interne sachbearbeitende Person oder Dienststelle.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<input
|
||||
id="caseWorker"
|
||||
name="caseWorker"
|
||||
type="text"
|
||||
defaultValue={editingOperation?.caseWorker ?? ''}
|
||||
placeholder="Sachbearbeitung eintragen"
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/[0.03]">
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Einsatzleiter
|
||||
</h4>
|
||||
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Nutzer der Einheit {OPERATION_UNIT} mit Rolle Einsatzleiter.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Combobox
|
||||
items={operationLeaderOptions}
|
||||
value={selectedOperationLeader}
|
||||
onChange={handleOperationLeaderChange}
|
||||
variant="image"
|
||||
placeholder={
|
||||
isLoadingOperationAssignees
|
||||
? 'Einsatzleiter werden geladen...'
|
||||
: 'Einsatzleiter auswählen'
|
||||
}
|
||||
emptyText={`Keine Einsatzleiter der ${OPERATION_UNIT} gefunden.`}
|
||||
disabled={isLoadingOperationAssignees}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<label htmlFor="operationLeader" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
Einsatzleiter
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="operationLeader"
|
||||
name="operationLeader"
|
||||
type="text"
|
||||
defaultValue={editingOperation?.operationLeader ?? ''}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-indigo-200 bg-indigo-50/60 p-4 dark:border-indigo-500/20 dark:bg-indigo-500/10">
|
||||
<div className="mb-3 flex items-start justify-between gap-x-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-indigo-900 dark:text-indigo-200">
|
||||
Einsatzteam
|
||||
</h4>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<label htmlFor="operationTeam" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
Einsatzteam
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="operationTeam"
|
||||
name="operationTeam"
|
||||
type="text"
|
||||
defaultValue={editingOperation?.operationTeam ?? ''}
|
||||
className={inputClassName}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-indigo-700/80 dark:text-indigo-300/80">
|
||||
Wähle einen oder mehrere Einsatzbeamte der Einheit {OPERATION_UNIT}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{selectedOperationTeam.length > 0 && (
|
||||
<span className="shrink-0 rounded-full bg-white px-2.5 py-1 text-xs font-medium text-indigo-700 ring-1 ring-indigo-600/20 ring-inset dark:bg-indigo-500/10 dark:text-indigo-200 dark:ring-indigo-300/20">
|
||||
{selectedOperationTeam.length} ausgewählt
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MultiCombobox
|
||||
items={operationTeamOptions}
|
||||
value={selectedOperationTeam}
|
||||
onChange={handleOperationTeamChange}
|
||||
placeholder={
|
||||
isLoadingOperationAssignees
|
||||
? 'Einsatzbeamte werden geladen...'
|
||||
: 'Einsatzbeamte auswählen'
|
||||
}
|
||||
emptyText={`Keine Einsatzbeamten der ${OPERATION_UNIT} gefunden.`}
|
||||
disabled={isLoadingOperationAssignees}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -85,24 +85,12 @@ export default function SettingsPage({
|
||||
icon: BellIcon,
|
||||
current: pathname === '/einstellungen/benachrichtigungen',
|
||||
},
|
||||
{
|
||||
name: 'Abrechnung',
|
||||
href: '/einstellungen/abrechnung',
|
||||
icon: CreditCardIcon,
|
||||
current: pathname === '/einstellungen/abrechnung',
|
||||
},
|
||||
{
|
||||
name: 'Teams',
|
||||
href: '/einstellungen/teams',
|
||||
icon: UsersIcon,
|
||||
current: pathname === '/einstellungen/teams',
|
||||
},
|
||||
{
|
||||
name: 'Integrationen',
|
||||
href: '/einstellungen/integrationen',
|
||||
icon: PuzzlePieceIcon,
|
||||
current: pathname === '/einstellungen/integrationen',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
@ -130,17 +118,9 @@ export default function SettingsPage({
|
||||
<Benachrichtigungen />
|
||||
)}
|
||||
|
||||
{section === 'abrechnung' && (
|
||||
<PlaceholderSection title="Abrechnung" />
|
||||
)}
|
||||
|
||||
{section === 'teams' && (
|
||||
<PlaceholderSection title="Teams" />
|
||||
)}
|
||||
|
||||
{section === 'integrationen' && (
|
||||
<PlaceholderSection title="Integrationen" />
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
|
||||
@ -9,20 +9,23 @@ import {
|
||||
import {
|
||||
BellIcon,
|
||||
EnvelopeIcon,
|
||||
PlayIcon,
|
||||
SpeakerWaveIcon,
|
||||
} from '@heroicons/react/20/solid'
|
||||
import Container from '../../../components/Container'
|
||||
import LoadingSpinner from '../../../components/LoadingSpinner'
|
||||
import Switch from '../../../components/Switch'
|
||||
import Notifications, {
|
||||
type ToastNotification,
|
||||
} from '../../../components/Notifications'
|
||||
import Button from '../../../components/Button'
|
||||
import { useNotifications } from '../../../components/Notifications'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||
|
||||
type NotificationSoundName = 'default' | 'chime' | 'soft' | 'alert' | 'success'
|
||||
|
||||
type NotificationSettings = {
|
||||
inAppEnabled: boolean
|
||||
soundEnabled: boolean
|
||||
soundName: NotificationSoundName
|
||||
emailEnabled: boolean
|
||||
operationUpdates: boolean
|
||||
operationJournals: boolean
|
||||
@ -34,6 +37,7 @@ type NotificationSettings = {
|
||||
const defaultSettings: NotificationSettings = {
|
||||
inAppEnabled: true,
|
||||
soundEnabled: false,
|
||||
soundName: 'default',
|
||||
emailEnabled: false,
|
||||
operationUpdates: true,
|
||||
operationJournals: true,
|
||||
@ -42,6 +46,17 @@ const defaultSettings: NotificationSettings = {
|
||||
systemMessages: true,
|
||||
}
|
||||
|
||||
const notificationSounds: Array<{
|
||||
value: NotificationSoundName
|
||||
label: string
|
||||
}> = [
|
||||
{ value: 'default', label: 'Standard' },
|
||||
{ value: 'chime', label: 'Klang' },
|
||||
{ value: 'soft', label: 'Dezent' },
|
||||
{ value: 'alert', label: 'Alarm' },
|
||||
{ value: 'success', label: 'Erfolg' },
|
||||
]
|
||||
|
||||
function Section({
|
||||
title,
|
||||
description,
|
||||
@ -72,24 +87,21 @@ function Section({
|
||||
)
|
||||
}
|
||||
|
||||
function Message({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function readApiError(response: Response, fallback: string) {
|
||||
const data = await response.json().catch(() => null)
|
||||
|
||||
return data?.error ?? fallback
|
||||
}
|
||||
|
||||
function playNotificationSound(soundName: NotificationSoundName) {
|
||||
const audio = new Audio(`/sounds/notifications/${soundName}.mp3`)
|
||||
audio.volume = 0.6
|
||||
|
||||
void audio.play().catch(() => {
|
||||
// Browser blockiert Audio ggf., bis der User einmal mit der Seite interagiert hat.
|
||||
})
|
||||
}
|
||||
|
||||
function NotificationCard({
|
||||
icon,
|
||||
title,
|
||||
@ -127,12 +139,12 @@ function NotificationCard({
|
||||
}
|
||||
|
||||
export default function Benachrichtigungen() {
|
||||
const { showToast, showErrorToast } = useNotifications()
|
||||
|
||||
const [settings, setSettings] =
|
||||
useState<NotificationSettings>(defaultSettings)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [toasts, setToasts] = useState<ToastNotification[]>([])
|
||||
|
||||
const settingsRef = useRef<NotificationSettings>(defaultSettings)
|
||||
const saveRequestIdRef = useRef(0)
|
||||
@ -141,25 +153,8 @@ export default function Benachrichtigungen() {
|
||||
void loadSettings()
|
||||
}, [])
|
||||
|
||||
function dismissToast(id: string) {
|
||||
setToasts((currentToasts) =>
|
||||
currentToasts.filter((toast) => toast.id !== id),
|
||||
)
|
||||
}
|
||||
|
||||
function showToast(notification: Omit<ToastNotification, 'id'>) {
|
||||
setToasts((currentToasts) => [
|
||||
...currentToasts,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
...notification,
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/settings/notifications/preferences`, {
|
||||
@ -184,11 +179,11 @@ export default function Benachrichtigungen() {
|
||||
settingsRef.current = nextSettings
|
||||
setSettings(nextSettings)
|
||||
} catch (error) {
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Benachrichtigungseinstellungen konnten nicht geladen werden',
|
||||
)
|
||||
showErrorToast({
|
||||
title: 'Benachrichtigungseinstellungen konnten nicht geladen werden',
|
||||
error,
|
||||
fallback: 'Benachrichtigungseinstellungen konnten nicht geladen werden',
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
@ -214,7 +209,6 @@ export default function Benachrichtigungen() {
|
||||
saveRequestIdRef.current = requestId
|
||||
|
||||
setIsSaving(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/settings/notifications/preferences`, {
|
||||
@ -261,20 +255,10 @@ export default function Benachrichtigungen() {
|
||||
return
|
||||
}
|
||||
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Benachrichtigungseinstellungen konnten nicht gespeichert werden',
|
||||
)
|
||||
|
||||
showToast({
|
||||
showErrorToast({
|
||||
title: 'Speichern fehlgeschlagen',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Benachrichtigungseinstellungen konnten nicht gespeichert werden.',
|
||||
variant: 'error',
|
||||
autoClose: false,
|
||||
error,
|
||||
fallback: 'Benachrichtigungseinstellungen konnten nicht gespeichert werden.',
|
||||
})
|
||||
} finally {
|
||||
if (saveRequestIdRef.current === requestId) {
|
||||
@ -303,38 +287,17 @@ export default function Benachrichtigungen() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Notifications
|
||||
notifications={toasts}
|
||||
onDismiss={dismissToast}
|
||||
position="top-right"
|
||||
/>
|
||||
|
||||
<div className="divide-y divide-gray-200 dark:divide-white/10">
|
||||
<Section
|
||||
title="Benachrichtigungen"
|
||||
description="Lege fest, welche Hinweise du in der Anwendung erhalten möchtest."
|
||||
>
|
||||
<div className="space-y-6 sm:max-w-2xl">
|
||||
{error && (
|
||||
<Message>
|
||||
{error}
|
||||
</Message>
|
||||
)}
|
||||
|
||||
<NotificationCard
|
||||
icon={<BellIcon aria-hidden="true" className="size-5" />}
|
||||
title="In-App-Benachrichtigungen"
|
||||
description="Steuere Hinweise, die direkt in der Anwendung angezeigt werden."
|
||||
>
|
||||
<Switch
|
||||
checked={settings.inAppEnabled}
|
||||
onChange={(event) =>
|
||||
updateSetting('inAppEnabled', event.target.checked)
|
||||
}
|
||||
label="Benachrichtigungen anzeigen"
|
||||
description="Aktiviert Hinweise in der Topbar und im Benachrichtigungscenter."
|
||||
/>
|
||||
|
||||
<Switch
|
||||
checked={settings.soundEnabled}
|
||||
onChange={(event) =>
|
||||
@ -344,6 +307,52 @@ export default function Benachrichtigungen() {
|
||||
label="Signalton abspielen"
|
||||
description="Spielt einen kurzen Ton bei neuen Benachrichtigungen ab."
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="notification-sound"
|
||||
className="block text-sm font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Benachrichtigungssound
|
||||
</label>
|
||||
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<select
|
||||
id="notification-sound"
|
||||
value={settings.soundName}
|
||||
disabled={!settings.inAppEnabled || !settings.soundEnabled}
|
||||
onChange={(event) =>
|
||||
updateSetting(
|
||||
'soundName',
|
||||
event.target.value as NotificationSoundName,
|
||||
)
|
||||
}
|
||||
className="block w-full rounded-md bg-white px-3 py-1.5 text-sm text-gray-900 outline-1 -outline-offset-1 outline-gray-300 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:disabled:bg-white/5 dark:disabled:text-gray-500 dark:focus:outline-indigo-500"
|
||||
>
|
||||
{notificationSounds.map((sound) => (
|
||||
<option key={sound.value} value={sound.value}>
|
||||
{sound.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
color="gray"
|
||||
size="sm"
|
||||
disabled={!settings.inAppEnabled || !settings.soundEnabled}
|
||||
leadingIcon={<PlayIcon aria-hidden="true" className="size-4" />}
|
||||
onClick={() => playNotificationSound(settings.soundName)}
|
||||
>
|
||||
Abspielen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Wird bei neuen In-App-Benachrichtigungen verwendet, wenn der Signalton aktiviert ist.
|
||||
</p>
|
||||
</div>
|
||||
</NotificationCard>
|
||||
|
||||
<NotificationCard
|
||||
|
||||
@ -2,7 +2,9 @@
|
||||
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
type FormEvent,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
@ -13,9 +15,8 @@ import {
|
||||
ComputerDesktopIcon,
|
||||
ShieldCheckIcon,
|
||||
} from '@heroicons/react/20/solid'
|
||||
import Notifications, {
|
||||
type ToastNotification,
|
||||
} from '../../../components/Notifications'
|
||||
import { useNotifications } from '../../../components/Notifications'
|
||||
import Avatar from '../../../components/Avatar'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||
|
||||
@ -79,8 +80,11 @@ export default function Konto({
|
||||
user,
|
||||
onUserUpdated,
|
||||
}: KontoProps) {
|
||||
const [toasts, setToasts] = useState<ToastNotification[]>([])
|
||||
const { showToast, showErrorToast } = useNotifications()
|
||||
|
||||
const [currentUser, setCurrentUser] = useState<User | undefined>(user)
|
||||
const [avatarPreview, setAvatarPreview] = useState(user?.avatar ?? '')
|
||||
const avatarInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const [sessions, setSessions] = useState<UserSession[]>([])
|
||||
|
||||
const [isLoadingSessions, setIsLoadingSessions] = useState(false)
|
||||
@ -92,32 +96,13 @@ export default function Konto({
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentUser(user)
|
||||
setAvatarPreview(user?.avatar ?? '')
|
||||
}, [user])
|
||||
|
||||
useEffect(() => {
|
||||
void loadSessions()
|
||||
}, [])
|
||||
|
||||
const avatarUrl =
|
||||
currentUser?.avatar ||
|
||||
'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80'
|
||||
|
||||
function dismissToast(id: string) {
|
||||
setToasts((currentToasts) =>
|
||||
currentToasts.filter((toast) => toast.id !== id),
|
||||
)
|
||||
}
|
||||
|
||||
function showToast(notification: Omit<ToastNotification, 'id'>) {
|
||||
setToasts((currentToasts) => [
|
||||
...currentToasts,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
...notification,
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
async function loadSessions() {
|
||||
setIsLoadingSessions(true)
|
||||
|
||||
@ -184,6 +169,49 @@ export default function Konto({
|
||||
}
|
||||
}
|
||||
|
||||
function handleAvatarFileChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0]
|
||||
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
showToast({
|
||||
title: 'Ungültige Datei',
|
||||
message: 'Bitte wähle eine gültige Bilddatei aus.',
|
||||
variant: 'error',
|
||||
})
|
||||
event.target.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
showToast({
|
||||
title: 'Bild zu groß',
|
||||
message: 'Das Profilbild darf maximal 2 MB groß sein.',
|
||||
variant: 'error',
|
||||
})
|
||||
event.target.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
const reader = new FileReader()
|
||||
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === 'string') {
|
||||
setAvatarPreview(reader.result)
|
||||
}
|
||||
}
|
||||
|
||||
reader.readAsDataURL(file)
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
function handleRemoveAvatar() {
|
||||
setAvatarPreview('')
|
||||
}
|
||||
|
||||
async function handleKontoSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
||||
@ -220,6 +248,7 @@ export default function Konto({
|
||||
|
||||
if (nextUser) {
|
||||
setCurrentUser(nextUser)
|
||||
setAvatarPreview(nextUser.avatar ?? '')
|
||||
onUserUpdated?.(nextUser)
|
||||
}
|
||||
|
||||
@ -229,14 +258,10 @@ export default function Konto({
|
||||
variant: 'success',
|
||||
})
|
||||
} catch (error) {
|
||||
showToast({
|
||||
showErrorToast({
|
||||
title: 'Profil konnte nicht gespeichert werden',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Profil konnte nicht gespeichert werden',
|
||||
variant: 'error',
|
||||
autoClose: false,
|
||||
error,
|
||||
fallback: 'Profil konnte nicht gespeichert werden',
|
||||
})
|
||||
} finally {
|
||||
setIsSavingKonto(false)
|
||||
@ -395,12 +420,6 @@ export default function Konto({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Notifications
|
||||
notifications={toasts}
|
||||
onDismiss={dismissToast}
|
||||
position="top-right"
|
||||
/>
|
||||
|
||||
<div className="divide-y divide-gray-200 dark:divide-white/10">
|
||||
<Section
|
||||
title="Persönliche Informationen"
|
||||
@ -409,33 +428,54 @@ export default function Konto({
|
||||
<form onSubmit={handleKontoSubmit}>
|
||||
<div className="grid grid-cols-1 gap-x-6 gap-y-8 sm:max-w-xl sm:grid-cols-6">
|
||||
<div className="col-span-full flex items-center gap-x-8">
|
||||
<img
|
||||
alt=""
|
||||
src={avatarUrl}
|
||||
className="size-24 flex-none rounded-lg bg-gray-100 object-cover outline -outline-offset-1 outline-black/5 dark:bg-gray-800 dark:outline-white/10"
|
||||
<Avatar
|
||||
src={avatarPreview}
|
||||
name={currentUser?.displayName || currentUser?.username || currentUser?.email}
|
||||
size={16}
|
||||
shape="rounded"
|
||||
/>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<label
|
||||
htmlFor="avatar"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Avatar-URL
|
||||
</label>
|
||||
<input
|
||||
type="hidden"
|
||||
name="avatar"
|
||||
value={avatarPreview}
|
||||
/>
|
||||
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="avatar"
|
||||
name="avatar"
|
||||
type="url"
|
||||
defaultValue={currentUser?.avatar ?? ''}
|
||||
placeholder="https://..."
|
||||
className={inputClassName}
|
||||
/>
|
||||
<input
|
||||
ref={avatarInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
className="sr-only"
|
||||
onChange={handleAvatarFileChange}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={() => avatarInputRef.current?.click()}
|
||||
>
|
||||
Bild hochladen
|
||||
</Button>
|
||||
|
||||
{avatarPreview && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={handleRemoveAvatar}
|
||||
>
|
||||
Entfernen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-xs/5 text-gray-500 dark:text-gray-400">
|
||||
Gib eine Bild-URL für dein Profilbild an.
|
||||
PNG, JPG, WEBP oder GIF bis maximal 2 MB.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user