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_RIGHTS=admin,users:read,users:write
|
||||||
ADMIN_TEAM_NAME=Administration
|
ADMIN_TEAM_NAME=Administration
|
||||||
ADMIN_TEAM_INITIAL=A
|
ADMIN_TEAM_INITIAL=A
|
||||||
ADMIN_TEAM_HREF=#
|
ADMIN_TEAM_HREF=#administration
|
||||||
192
backend/address_reverse.go
Normal file
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 {
|
type nominatimSearchResult struct {
|
||||||
PlaceID int64 `json:"place_id"`
|
PlaceID int64 `json:"place_id"`
|
||||||
DisplayName string `json:"display_name"`
|
DisplayName string `json:"display_name"`
|
||||||
Lat string `json:"lat"`
|
Lat string `json:"lat"`
|
||||||
Lon string `json:"lon"`
|
Lon string `json:"lon"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
|
Address nominatimReverseAddress `json:"address"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAddressSearch(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAddressSearch(w http.ResponseWriter, r *http.Request) {
|
||||||
@ -234,7 +235,11 @@ func (s *Server) handleNominatimAddressSearch(
|
|||||||
suggestions := make([]AddressSuggestion, 0, len(results))
|
suggestions := make([]AddressSuggestion, 0, len(results))
|
||||||
|
|
||||||
for _, result := range results {
|
for _, result := range results {
|
||||||
label := strings.TrimSpace(result.DisplayName)
|
label := formatReverseAddressLabel(nominatimReverseResponse{
|
||||||
|
DisplayName: result.DisplayName,
|
||||||
|
Address: result.Address,
|
||||||
|
})
|
||||||
|
|
||||||
if label == "" {
|
if label == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@ -72,6 +72,10 @@ func normalizeAdminUserInput(input *AdminUserRequest) {
|
|||||||
input.TeamIDs = cleanStringList(input.TeamIDs)
|
input.TeamIDs = cleanStringList(input.TeamIDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ensureAdminUserLookupValues(ctx context.Context, tx pgx.Tx, unit string) error {
|
||||||
|
return ensureOptionalDeviceLookupValue(ctx, tx, "user_units", unit)
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeAdminTeamInput(input *AdminTeamRequest) {
|
func normalizeAdminTeamInput(input *AdminTeamRequest) {
|
||||||
input.Name = strings.TrimSpace(input.Name)
|
input.Name = strings.TrimSpace(input.Name)
|
||||||
input.Initial = strings.TrimSpace(input.Initial)
|
input.Initial = strings.TrimSpace(input.Initial)
|
||||||
@ -130,7 +134,7 @@ func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
|
|||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
users := []AdminUser{}
|
users := []AdminUser{}
|
||||||
userByID := map[string]*AdminUser{}
|
userIndexByID := map[string]int{}
|
||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var user AdminUser
|
var user AdminUser
|
||||||
@ -152,8 +156,14 @@ func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
user.Teams = []AdminTeam{}
|
user.Teams = []AdminTeam{}
|
||||||
|
|
||||||
users = append(users, user)
|
users = append(users, user)
|
||||||
userByID[user.ID] = &users[len(users)-1]
|
userIndexByID[user.ID] = len(users) - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Benutzer konnten nicht vollständig gelesen werden")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
teamRows, err := s.db.Query(
|
teamRows, err := s.db.Query(
|
||||||
@ -196,9 +206,17 @@ func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if user := userByID[userID]; user != nil {
|
userIndex, ok := userIndexByID[userID]
|
||||||
user.Teams = append(user.Teams, team)
|
if !ok {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
users[userIndex].Teams = append(users[userIndex].Teams, team)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := teamRows.Err(); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Team-Zuordnungen konnten nicht vollständig gelesen werden")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
@ -239,6 +257,11 @@ func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
defer tx.Rollback(r.Context())
|
defer tx.Rollback(r.Context())
|
||||||
|
|
||||||
|
if err := ensureAdminUserLookupValues(r.Context(), tx, input.Unit); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Einheit konnte nicht gespeichert werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var userID string
|
var userID string
|
||||||
|
|
||||||
err = tx.QueryRow(
|
err = tx.QueryRow(
|
||||||
@ -289,6 +312,10 @@ func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if createdUser, err := s.getUserByID(r.Context(), userID); err == nil {
|
||||||
|
s.publishUserProfileEvent("user.created", createdUser)
|
||||||
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusCreated, map[string]any{
|
writeJSON(w, http.StatusCreated, map[string]any{
|
||||||
"ok": true,
|
"ok": true,
|
||||||
})
|
})
|
||||||
@ -323,6 +350,11 @@ func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
defer tx.Rollback(r.Context())
|
defer tx.Rollback(r.Context())
|
||||||
|
|
||||||
|
if err := ensureAdminUserLookupValues(r.Context(), tx, input.Unit); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Einheit konnte nicht gespeichert werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if input.Password != "" {
|
if input.Password != "" {
|
||||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
|
passwordHash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -403,6 +435,10 @@ func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if updatedUser, err := s.getUserByID(r.Context(), userID); err == nil {
|
||||||
|
s.publishUserProfileEvent("user.updated", updatedUser)
|
||||||
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"ok": true,
|
"ok": true,
|
||||||
})
|
})
|
||||||
|
|||||||
@ -149,6 +149,7 @@ type milestoneHardwareDevice struct {
|
|||||||
Manufacturer string
|
Manufacturer string
|
||||||
MacAddress string
|
MacAddress string
|
||||||
IPAddress string
|
IPAddress string
|
||||||
|
Port string
|
||||||
DeviceModelName string
|
DeviceModelName string
|
||||||
SerialNumber string
|
SerialNumber string
|
||||||
FirmwareVersion string
|
FirmwareVersion string
|
||||||
@ -830,7 +831,7 @@ func (s *Server) requestMilestoneHardwareDevices(
|
|||||||
enabled = !*hardware.Disabled
|
enabled = !*hardware.Disabled
|
||||||
}
|
}
|
||||||
|
|
||||||
ipAddress := extractMilestoneHardwareIPAddress(hardware.Address)
|
ipAddress, port := extractMilestoneHardwareAddressParts(hardware.Address)
|
||||||
|
|
||||||
result = append(result, milestoneHardwareDevice{
|
result = append(result, milestoneHardwareDevice{
|
||||||
HardwareID: hardwareID,
|
HardwareID: hardwareID,
|
||||||
@ -840,6 +841,7 @@ func (s *Server) requestMilestoneHardwareDevices(
|
|||||||
Manufacturer: manufacturer,
|
Manufacturer: manufacturer,
|
||||||
MacAddress: strings.TrimSpace(settings.MacAddress),
|
MacAddress: strings.TrimSpace(settings.MacAddress),
|
||||||
IPAddress: ipAddress,
|
IPAddress: ipAddress,
|
||||||
|
Port: port,
|
||||||
DeviceModelName: model,
|
DeviceModelName: model,
|
||||||
SerialNumber: strings.TrimSpace(settings.SerialNumber),
|
SerialNumber: strings.TrimSpace(settings.SerialNumber),
|
||||||
FirmwareVersion: strings.TrimSpace(settings.FirmwareVersion),
|
FirmwareVersion: strings.TrimSpace(settings.FirmwareVersion),
|
||||||
@ -857,25 +859,35 @@ func (s *Server) requestMilestoneHardwareDevices(
|
|||||||
}
|
}
|
||||||
|
|
||||||
func extractMilestoneHardwareIPAddress(value string) string {
|
func extractMilestoneHardwareIPAddress(value string) string {
|
||||||
|
host, _ := extractMilestoneHardwareAddressParts(value)
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractMilestoneHardwarePort(value string) string {
|
||||||
|
_, port := extractMilestoneHardwareAddressParts(value)
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractMilestoneHardwareAddressParts(value string) (string, string) {
|
||||||
value = strings.TrimSpace(value)
|
value = strings.TrimSpace(value)
|
||||||
if value == "" {
|
if value == "" {
|
||||||
return ""
|
return "", ""
|
||||||
}
|
}
|
||||||
|
|
||||||
parsedURL, err := url.Parse(value)
|
parsedURL, err := url.Parse(value)
|
||||||
if err == nil && parsedURL.Hostname() != "" {
|
if err == nil && parsedURL.Hostname() != "" {
|
||||||
return parsedURL.Hostname()
|
return strings.TrimSpace(parsedURL.Hostname()), strings.TrimSpace(parsedURL.Port())
|
||||||
}
|
}
|
||||||
|
|
||||||
value = strings.TrimPrefix(value, "http://")
|
value = strings.TrimPrefix(value, "http://")
|
||||||
value = strings.TrimPrefix(value, "https://")
|
value = strings.TrimPrefix(value, "https://")
|
||||||
value = strings.TrimRight(value, "/")
|
value = strings.TrimRight(value, "/")
|
||||||
|
|
||||||
if host, _, found := strings.Cut(value, ":"); found {
|
if host, port, found := strings.Cut(value, ":"); found {
|
||||||
return strings.TrimSpace(host)
|
return strings.TrimSpace(host), strings.TrimSpace(port)
|
||||||
}
|
}
|
||||||
|
|
||||||
return strings.TrimSpace(value)
|
return strings.TrimSpace(value), ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) requestMilestoneHardware(
|
func (s *Server) requestMilestoneHardware(
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// backend/camera_firmware.go
|
// backend\camera_firmware.go
|
||||||
|
|
||||||
package main
|
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-Origin", s.frontendOrigin)
|
||||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.Method == http.MethodOptions {
|
if r.Method == http.MethodOptions {
|
||||||
|
|||||||
@ -50,68 +50,19 @@ func (s *Server) publishDeviceChangeEvent(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var notification Notification
|
if err := s.createAndPublishNotification(
|
||||||
|
|
||||||
err = s.db.QueryRow(
|
|
||||||
ctx,
|
ctx,
|
||||||
`
|
|
||||||
INSERT INTO notifications (
|
|
||||||
user_id,
|
|
||||||
type,
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
visible,
|
|
||||||
entity_type,
|
|
||||||
entity_id,
|
|
||||||
data
|
|
||||||
)
|
|
||||||
VALUES (
|
|
||||||
$1,
|
|
||||||
$2,
|
|
||||||
$3,
|
|
||||||
$4,
|
|
||||||
false,
|
|
||||||
'device',
|
|
||||||
$5,
|
|
||||||
$6
|
|
||||||
)
|
|
||||||
RETURNING
|
|
||||||
id::TEXT,
|
|
||||||
user_id::TEXT,
|
|
||||||
type,
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
entity_type,
|
|
||||||
COALESCE(entity_id::TEXT, ''),
|
|
||||||
data,
|
|
||||||
visible,
|
|
||||||
read_at,
|
|
||||||
created_at
|
|
||||||
`,
|
|
||||||
userID,
|
userID,
|
||||||
notificationType,
|
notificationType,
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
|
"device",
|
||||||
deviceID,
|
deviceID,
|
||||||
dataJSON,
|
dataJSON,
|
||||||
).Scan(
|
true,
|
||||||
¬ification.ID,
|
); err != nil {
|
||||||
¬ification.UserID,
|
|
||||||
¬ification.Type,
|
|
||||||
¬ification.Title,
|
|
||||||
¬ification.Message,
|
|
||||||
¬ification.EntityType,
|
|
||||||
¬ification.EntityID,
|
|
||||||
¬ification.Data,
|
|
||||||
¬ification.Visible,
|
|
||||||
¬ification.ReadAt,
|
|
||||||
¬ification.CreatedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
notificationsBroker.publish(notification)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.Err()
|
return rows.Err()
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// backend/devices.go
|
// backend\devices.go
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
@ -21,6 +21,7 @@ type Device struct {
|
|||||||
SerialNumber string `json:"serialNumber"`
|
SerialNumber string `json:"serialNumber"`
|
||||||
MacAddress string `json:"macAddress"`
|
MacAddress string `json:"macAddress"`
|
||||||
IPAddress string `json:"ipAddress"`
|
IPAddress string `json:"ipAddress"`
|
||||||
|
MilestonePort string `json:"milestonePort"`
|
||||||
Location string `json:"location"`
|
Location string `json:"location"`
|
||||||
LoanStatus string `json:"loanStatus"`
|
LoanStatus string `json:"loanStatus"`
|
||||||
IsBaoDevice bool `json:"isBaoDevice"`
|
IsBaoDevice bool `json:"isBaoDevice"`
|
||||||
@ -69,8 +70,10 @@ type CreateDeviceRequest struct {
|
|||||||
SerialNumber string `json:"serialNumber"`
|
SerialNumber string `json:"serialNumber"`
|
||||||
MacAddress string `json:"macAddress"`
|
MacAddress string `json:"macAddress"`
|
||||||
IPAddress string `json:"ipAddress"`
|
IPAddress string `json:"ipAddress"`
|
||||||
|
MilestonePort string `json:"milestonePort"`
|
||||||
FirmwareVersion string `json:"firmwareVersion"`
|
FirmwareVersion string `json:"firmwareVersion"`
|
||||||
MilestoneDisplayName string `json:"milestoneDisplayName"`
|
MilestoneDisplayName string `json:"milestoneDisplayName"`
|
||||||
|
MilestoneEnabled *bool `json:"milestoneEnabled,omitempty"`
|
||||||
Location string `json:"location"`
|
Location string `json:"location"`
|
||||||
LoanStatus string `json:"loanStatus"`
|
LoanStatus string `json:"loanStatus"`
|
||||||
IsBaoDevice bool `json:"isBaoDevice"`
|
IsBaoDevice bool `json:"isBaoDevice"`
|
||||||
@ -80,6 +83,10 @@ type CreateDeviceRequest struct {
|
|||||||
|
|
||||||
type UpdateDeviceRequest = CreateDeviceRequest
|
type UpdateDeviceRequest = CreateDeviceRequest
|
||||||
|
|
||||||
|
type UpdateDeviceJournalEntryRequest struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
var errDeviceNotFound = errors.New("device does not exist")
|
var errDeviceNotFound = errors.New("device does not exist")
|
||||||
|
|
||||||
func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) {
|
||||||
@ -105,6 +112,7 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
|
|||||||
serial_number,
|
serial_number,
|
||||||
mac_address,
|
mac_address,
|
||||||
COALESCE(ip_address, ''),
|
COALESCE(ip_address, ''),
|
||||||
|
COALESCE(milestone_port, ''),
|
||||||
location,
|
location,
|
||||||
loan_status,
|
loan_status,
|
||||||
is_bao_device,
|
is_bao_device,
|
||||||
@ -142,6 +150,7 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
|
|||||||
&device.SerialNumber,
|
&device.SerialNumber,
|
||||||
&device.MacAddress,
|
&device.MacAddress,
|
||||||
&device.IPAddress,
|
&device.IPAddress,
|
||||||
|
&device.MilestonePort,
|
||||||
&device.Location,
|
&device.Location,
|
||||||
&device.LoanStatus,
|
&device.LoanStatus,
|
||||||
&device.IsBaoDevice,
|
&device.IsBaoDevice,
|
||||||
@ -270,13 +279,14 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) {
|
|||||||
serial_number,
|
serial_number,
|
||||||
mac_address,
|
mac_address,
|
||||||
ip_address,
|
ip_address,
|
||||||
|
milestone_port,
|
||||||
location,
|
location,
|
||||||
loan_status,
|
loan_status,
|
||||||
is_bao_device,
|
is_bao_device,
|
||||||
comment,
|
comment,
|
||||||
firmware_version
|
firmware_version
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
RETURNING
|
RETURNING
|
||||||
id,
|
id,
|
||||||
inventory_number,
|
inventory_number,
|
||||||
@ -285,6 +295,7 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) {
|
|||||||
serial_number,
|
serial_number,
|
||||||
mac_address,
|
mac_address,
|
||||||
COALESCE(ip_address, ''),
|
COALESCE(ip_address, ''),
|
||||||
|
COALESCE(milestone_port, ''),
|
||||||
location,
|
location,
|
||||||
loan_status,
|
loan_status,
|
||||||
is_bao_device,
|
is_bao_device,
|
||||||
@ -304,6 +315,7 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) {
|
|||||||
input.SerialNumber,
|
input.SerialNumber,
|
||||||
input.MacAddress,
|
input.MacAddress,
|
||||||
input.IPAddress,
|
input.IPAddress,
|
||||||
|
input.MilestonePort,
|
||||||
input.Location,
|
input.Location,
|
||||||
input.LoanStatus,
|
input.LoanStatus,
|
||||||
input.IsBaoDevice,
|
input.IsBaoDevice,
|
||||||
@ -317,6 +329,7 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) {
|
|||||||
&device.SerialNumber,
|
&device.SerialNumber,
|
||||||
&device.MacAddress,
|
&device.MacAddress,
|
||||||
&device.IPAddress,
|
&device.IPAddress,
|
||||||
|
&device.MilestonePort,
|
||||||
&device.Location,
|
&device.Location,
|
||||||
&device.LoanStatus,
|
&device.LoanStatus,
|
||||||
&device.IsBaoDevice,
|
&device.IsBaoDevice,
|
||||||
@ -449,6 +462,11 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nextMilestoneEnabled := oldDevice.MilestoneEnabled
|
||||||
|
if input.MilestoneEnabled != nil {
|
||||||
|
nextMilestoneEnabled = *input.MilestoneEnabled
|
||||||
|
}
|
||||||
|
|
||||||
var device Device
|
var device Device
|
||||||
|
|
||||||
err = tx.QueryRow(
|
err = tx.QueryRow(
|
||||||
@ -462,12 +480,25 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
|
|||||||
serial_number = $5,
|
serial_number = $5,
|
||||||
mac_address = $6,
|
mac_address = $6,
|
||||||
ip_address = $7,
|
ip_address = $7,
|
||||||
location = $8,
|
milestone_port = $8,
|
||||||
loan_status = $9,
|
location = $9,
|
||||||
is_bao_device = $10,
|
loan_status = $10,
|
||||||
comment = $11,
|
is_bao_device = $11,
|
||||||
firmware_version = $12,
|
comment = $12,
|
||||||
milestone_display_name = $13,
|
firmware_version = $13,
|
||||||
|
milestone_display_name = $14,
|
||||||
|
milestone_enabled = $15,
|
||||||
|
milestone_synced_at = CASE
|
||||||
|
WHEN COALESCE(milestone_hardware_id, '') <> ''
|
||||||
|
AND (
|
||||||
|
COALESCE(milestone_display_name, '') <> $14
|
||||||
|
OR COALESCE(ip_address, '') <> $7
|
||||||
|
OR COALESCE(milestone_port, '') <> $8
|
||||||
|
OR milestone_enabled <> $15
|
||||||
|
)
|
||||||
|
THEN now()
|
||||||
|
ELSE milestone_synced_at
|
||||||
|
END,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING
|
RETURNING
|
||||||
@ -478,6 +509,7 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
|
|||||||
serial_number,
|
serial_number,
|
||||||
mac_address,
|
mac_address,
|
||||||
COALESCE(ip_address, ''),
|
COALESCE(ip_address, ''),
|
||||||
|
COALESCE(milestone_port, ''),
|
||||||
location,
|
location,
|
||||||
loan_status,
|
loan_status,
|
||||||
is_bao_device,
|
is_bao_device,
|
||||||
@ -498,12 +530,14 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
|
|||||||
input.SerialNumber,
|
input.SerialNumber,
|
||||||
input.MacAddress,
|
input.MacAddress,
|
||||||
input.IPAddress,
|
input.IPAddress,
|
||||||
|
input.MilestonePort,
|
||||||
input.Location,
|
input.Location,
|
||||||
input.LoanStatus,
|
input.LoanStatus,
|
||||||
input.IsBaoDevice,
|
input.IsBaoDevice,
|
||||||
input.Comment,
|
input.Comment,
|
||||||
input.FirmwareVersion,
|
input.FirmwareVersion,
|
||||||
input.MilestoneDisplayName,
|
input.MilestoneDisplayName,
|
||||||
|
nextMilestoneEnabled,
|
||||||
).Scan(
|
).Scan(
|
||||||
&device.ID,
|
&device.ID,
|
||||||
&device.InventoryNumber,
|
&device.InventoryNumber,
|
||||||
@ -512,6 +546,7 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
|
|||||||
&device.SerialNumber,
|
&device.SerialNumber,
|
||||||
&device.MacAddress,
|
&device.MacAddress,
|
||||||
&device.IPAddress,
|
&device.IPAddress,
|
||||||
|
&device.MilestonePort,
|
||||||
&device.Location,
|
&device.Location,
|
||||||
&device.LoanStatus,
|
&device.LoanStatus,
|
||||||
&device.IsBaoDevice,
|
&device.IsBaoDevice,
|
||||||
@ -634,6 +669,7 @@ func normalizeDeviceInput(input *CreateDeviceRequest) {
|
|||||||
input.SerialNumber = strings.TrimSpace(input.SerialNumber)
|
input.SerialNumber = strings.TrimSpace(input.SerialNumber)
|
||||||
input.MacAddress = strings.TrimSpace(input.MacAddress)
|
input.MacAddress = strings.TrimSpace(input.MacAddress)
|
||||||
input.IPAddress = strings.TrimSpace(input.IPAddress)
|
input.IPAddress = strings.TrimSpace(input.IPAddress)
|
||||||
|
input.MilestonePort = strings.TrimSpace(input.MilestonePort)
|
||||||
input.FirmwareVersion = strings.TrimSpace(input.FirmwareVersion)
|
input.FirmwareVersion = strings.TrimSpace(input.FirmwareVersion)
|
||||||
input.MilestoneDisplayName = strings.TrimSpace(input.MilestoneDisplayName)
|
input.MilestoneDisplayName = strings.TrimSpace(input.MilestoneDisplayName)
|
||||||
input.Location = strings.TrimSpace(input.Location)
|
input.Location = strings.TrimSpace(input.Location)
|
||||||
|
|||||||
@ -164,7 +164,7 @@ func (s *Server) handleCreateFeedback(w http.ResponseWriter, r *http.Request) {
|
|||||||
"Neues Feedback",
|
"Neues Feedback",
|
||||||
formatText("Neues Feedback von %s.", displayFeedbackUserName(feedback)),
|
formatText("Neues Feedback von %s.", displayFeedbackUserName(feedback)),
|
||||||
feedback.ID,
|
feedback.ID,
|
||||||
true,
|
false,
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"feedbackId": feedback.ID,
|
"feedbackId": feedback.ID,
|
||||||
"userId": feedback.UserID,
|
"userId": feedback.UserID,
|
||||||
@ -314,7 +314,7 @@ func (s *Server) handleDeleteFeedback(w http.ResponseWriter, r *http.Request) {
|
|||||||
"Feedback gelöscht",
|
"Feedback gelöscht",
|
||||||
formatText("Feedback von %s wurde gelöscht.", displayFeedbackUserName(deleted)),
|
formatText("Feedback von %s wurde gelöscht.", displayFeedbackUserName(deleted)),
|
||||||
deleted.ID,
|
deleted.ID,
|
||||||
false,
|
true,
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"feedbackId": deleted.ID,
|
"feedbackId": deleted.ID,
|
||||||
"userId": deleted.UserID,
|
"userId": deleted.UserID,
|
||||||
@ -375,70 +375,19 @@ func (s *Server) notifyAdministratorsAboutFeedback(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var notification Notification
|
if err := s.createAndPublishNotification(
|
||||||
|
|
||||||
err = s.db.QueryRow(
|
|
||||||
ctx,
|
ctx,
|
||||||
`
|
|
||||||
INSERT INTO notifications (
|
|
||||||
user_id,
|
|
||||||
type,
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
visible,
|
|
||||||
entity_type,
|
|
||||||
entity_id,
|
|
||||||
data
|
|
||||||
)
|
|
||||||
VALUES (
|
|
||||||
$1,
|
|
||||||
$2,
|
|
||||||
$3,
|
|
||||||
$4,
|
|
||||||
true,
|
|
||||||
'feedback',
|
|
||||||
$5,
|
|
||||||
$6,
|
|
||||||
$7
|
|
||||||
)
|
|
||||||
RETURNING
|
|
||||||
id::TEXT,
|
|
||||||
user_id::TEXT,
|
|
||||||
type,
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
entity_type,
|
|
||||||
COALESCE(entity_id::TEXT, ''),
|
|
||||||
data,
|
|
||||||
visible,
|
|
||||||
read_at,
|
|
||||||
created_at
|
|
||||||
`,
|
|
||||||
adminUserID,
|
adminUserID,
|
||||||
notificationType,
|
notificationType,
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
visible,
|
"feedback",
|
||||||
feedbackID,
|
feedbackID,
|
||||||
dataJSON,
|
dataJSON,
|
||||||
).Scan(
|
visible,
|
||||||
¬ification.ID,
|
); err != nil {
|
||||||
¬ification.UserID,
|
|
||||||
¬ification.Type,
|
|
||||||
¬ification.Title,
|
|
||||||
¬ification.Message,
|
|
||||||
¬ification.EntityType,
|
|
||||||
¬ification.EntityID,
|
|
||||||
¬ification.Data,
|
|
||||||
¬ification.Visible,
|
|
||||||
¬ification.ReadAt,
|
|
||||||
¬ification.CreatedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
notificationsBroker.publish(notification)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return adminRows.Err()
|
return adminRows.Err()
|
||||||
|
|||||||
@ -21,6 +21,8 @@ type DeviceHistoryEntry struct {
|
|||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
Changes []DeviceHistoryChange `json:"changes"`
|
Changes []DeviceHistoryChange `json:"changes"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
CanEdit bool `json:"canEdit"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DeviceHistoryChange struct {
|
type DeviceHistoryChange struct {
|
||||||
@ -42,6 +44,12 @@ func (s *Server) handleListDeviceHistory(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
user, ok := userFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
rows, err := s.db.Query(
|
rows, err := s.db.Query(
|
||||||
r.Context(),
|
r.Context(),
|
||||||
`
|
`
|
||||||
@ -52,7 +60,8 @@ func (s *Server) handleListDeviceHistory(w http.ResponseWriter, r *http.Request)
|
|||||||
COALESCE(NULLIF(u.display_name, ''), NULLIF(u.username, ''), u.email, 'Unbekannt'),
|
COALESCE(NULLIF(u.display_name, ''), NULLIF(u.username, ''), u.email, 'Unbekannt'),
|
||||||
h.action,
|
h.action,
|
||||||
h.changes,
|
h.changes,
|
||||||
h.created_at
|
h.created_at,
|
||||||
|
COALESCE(h.updated_at, h.created_at)
|
||||||
FROM device_history h
|
FROM device_history h
|
||||||
LEFT JOIN users u ON u.id = h.user_id
|
LEFT JOIN users u ON u.id = h.user_id
|
||||||
WHERE h.device_id = $1
|
WHERE h.device_id = $1
|
||||||
@ -81,6 +90,7 @@ func (s *Server) handleListDeviceHistory(w http.ResponseWriter, r *http.Request)
|
|||||||
&entry.Action,
|
&entry.Action,
|
||||||
&changesRaw,
|
&changesRaw,
|
||||||
&entry.CreatedAt,
|
&entry.CreatedAt,
|
||||||
|
&entry.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "Could not read device history")
|
writeError(w, http.StatusInternalServerError, "Could not read device history")
|
||||||
return
|
return
|
||||||
@ -93,6 +103,11 @@ func (s *Server) handleListDeviceHistory(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
entry.CanEdit =
|
||||||
|
entry.Action == "journal" &&
|
||||||
|
entry.UserID == user.ID &&
|
||||||
|
time.Since(entry.CreatedAt) <= 10*time.Minute
|
||||||
|
|
||||||
history = append(history, entry)
|
history = append(history, entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -239,6 +254,13 @@ func getDeviceForHistory(ctx context.Context, tx pgx.Tx, deviceID string) (Devic
|
|||||||
model,
|
model,
|
||||||
serial_number,
|
serial_number,
|
||||||
mac_address,
|
mac_address,
|
||||||
|
COALESCE(ip_address, ''),
|
||||||
|
COALESCE(firmware_version, ''),
|
||||||
|
COALESCE(milestone_recording_server_id, ''),
|
||||||
|
COALESCE(milestone_hardware_id, ''),
|
||||||
|
COALESCE(milestone_display_name, ''),
|
||||||
|
milestone_enabled,
|
||||||
|
milestone_synced_at,
|
||||||
location,
|
location,
|
||||||
loan_status,
|
loan_status,
|
||||||
is_bao_device,
|
is_bao_device,
|
||||||
@ -257,6 +279,13 @@ func getDeviceForHistory(ctx context.Context, tx pgx.Tx, deviceID string) (Devic
|
|||||||
&device.Model,
|
&device.Model,
|
||||||
&device.SerialNumber,
|
&device.SerialNumber,
|
||||||
&device.MacAddress,
|
&device.MacAddress,
|
||||||
|
&device.IPAddress,
|
||||||
|
&device.FirmwareVersion,
|
||||||
|
&device.MilestoneRecordingServerID,
|
||||||
|
&device.MilestoneHardwareID,
|
||||||
|
&device.MilestoneDisplayName,
|
||||||
|
&device.MilestoneEnabled,
|
||||||
|
&device.MilestoneSyncedAt,
|
||||||
&device.Location,
|
&device.Location,
|
||||||
&device.LoanStatus,
|
&device.LoanStatus,
|
||||||
&device.IsBaoDevice,
|
&device.IsBaoDevice,
|
||||||
@ -304,6 +333,190 @@ func getDeviceForHistory(ctx context.Context, tx pgx.Tx, deviceID string) (Devic
|
|||||||
return device, relatedDeviceIDs, nil
|
return device, relatedDeviceIDs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUpdateDeviceJournalEntry(w http.ResponseWriter, r *http.Request) {
|
||||||
|
deviceID := strings.TrimSpace(r.PathValue("id"))
|
||||||
|
historyID := strings.TrimSpace(r.PathValue("historyId"))
|
||||||
|
|
||||||
|
if deviceID == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "Geräte-ID fehlt")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if historyID == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "Journal-ID fehlt")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var input UpdateDeviceJournalEntryRequest
|
||||||
|
|
||||||
|
if err := readJSON(r, &input); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Invalid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
content := strings.TrimSpace(input.Content)
|
||||||
|
|
||||||
|
if content == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "Journal-Eintrag darf nicht leer sein")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, ok := userFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.db.Begin(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht gespeichert werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback(r.Context())
|
||||||
|
|
||||||
|
if err := ensureDeviceExists(r.Context(), tx, deviceID); err != nil {
|
||||||
|
if errors.Is(err, errDeviceNotFound) {
|
||||||
|
writeError(w, http.StatusNotFound, "Gerät wurde nicht gefunden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeError(w, http.StatusInternalServerError, "Gerät konnte nicht geladen werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var changesRaw []byte
|
||||||
|
var createdAt time.Time
|
||||||
|
|
||||||
|
err = tx.QueryRow(
|
||||||
|
r.Context(),
|
||||||
|
`
|
||||||
|
SELECT changes, created_at
|
||||||
|
FROM device_history
|
||||||
|
WHERE id = $1
|
||||||
|
AND device_id = $2
|
||||||
|
AND user_id = $3
|
||||||
|
AND action = 'journal'
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
historyID,
|
||||||
|
deviceID,
|
||||||
|
user.ID,
|
||||||
|
).Scan(&changesRaw, &createdAt)
|
||||||
|
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
writeError(w, http.StatusNotFound, "Journal-Eintrag wurde nicht gefunden oder darf nicht bearbeitet werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht geladen werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Since(createdAt) > 10*time.Minute {
|
||||||
|
writeError(w, http.StatusForbidden, "Journal-Eintrag kann nur innerhalb von 10 Minuten bearbeitet werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var oldChanges []DeviceHistoryChange
|
||||||
|
|
||||||
|
if len(changesRaw) > 0 {
|
||||||
|
if err := json.Unmarshal(changesRaw, &oldChanges); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht gelesen werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
oldContent := ""
|
||||||
|
|
||||||
|
for _, change := range oldChanges {
|
||||||
|
if change.Field == "journal" {
|
||||||
|
oldContent = change.NewValue
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(oldContent) == content {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"ok": true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
changes := []DeviceHistoryChange{
|
||||||
|
{
|
||||||
|
Field: "journal",
|
||||||
|
Label: "Journal",
|
||||||
|
OldValue: oldContent,
|
||||||
|
NewValue: content,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
changesJSON, err := json.Marshal(changes)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht vorbereitet werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
commandTag, err := tx.Exec(
|
||||||
|
r.Context(),
|
||||||
|
`
|
||||||
|
UPDATE device_history
|
||||||
|
SET
|
||||||
|
changes = $4::JSONB,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
AND device_id = $2
|
||||||
|
AND user_id = $3
|
||||||
|
AND action = 'journal'
|
||||||
|
AND created_at >= now() - interval '10 minutes'
|
||||||
|
`,
|
||||||
|
historyID,
|
||||||
|
deviceID,
|
||||||
|
user.ID,
|
||||||
|
string(changesJSON),
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht gespeichert werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if commandTag.RowsAffected() == 0 {
|
||||||
|
writeError(w, http.StatusForbidden, "Journal-Eintrag kann nicht mehr bearbeitet werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(r.Context()); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht gespeichert werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.publishDeviceChangeEvent(
|
||||||
|
r.Context(),
|
||||||
|
user.ID,
|
||||||
|
deviceID,
|
||||||
|
"device.journal",
|
||||||
|
"Geräte-Journal aktualisiert",
|
||||||
|
"Ein Geräte-Journal-Eintrag wurde aktualisiert.",
|
||||||
|
LogFields{
|
||||||
|
"deviceId": deviceID,
|
||||||
|
"historyId": historyID,
|
||||||
|
"action": "updated",
|
||||||
|
},
|
||||||
|
); err != nil {
|
||||||
|
logError("Device-Journal-SSE-Event konnte nicht gesendet werden", err, LogFields{
|
||||||
|
"deviceId": deviceID,
|
||||||
|
"historyId": historyID,
|
||||||
|
"type": "device.journal",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"ok": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func formatHistoryBool(value bool) string {
|
func formatHistoryBool(value bool) string {
|
||||||
if value {
|
if value {
|
||||||
return "Ja"
|
return "Ja"
|
||||||
@ -357,6 +570,17 @@ func buildDeviceUpdateHistoryChanges(
|
|||||||
changes = appendHistoryChange(changes, "loanStatus", "Verleih-Status", oldDevice.LoanStatus, input.LoanStatus)
|
changes = appendHistoryChange(changes, "loanStatus", "Verleih-Status", oldDevice.LoanStatus, input.LoanStatus)
|
||||||
changes = appendHistoryChange(changes, "isBaoDevice", "BAO-Gerät", formatHistoryBool(oldDevice.IsBaoDevice), formatHistoryBool(input.IsBaoDevice))
|
changes = appendHistoryChange(changes, "isBaoDevice", "BAO-Gerät", formatHistoryBool(oldDevice.IsBaoDevice), formatHistoryBool(input.IsBaoDevice))
|
||||||
changes = appendHistoryChange(changes, "comment", "Kommentar", oldDevice.Comment, input.Comment)
|
changes = appendHistoryChange(changes, "comment", "Kommentar", oldDevice.Comment, input.Comment)
|
||||||
|
changes = appendHistoryChange(changes, "milestoneDisplayName", "Milestone-Anzeigename", oldDevice.MilestoneDisplayName, input.MilestoneDisplayName)
|
||||||
|
changes = appendHistoryChange(changes, "ipAddress", "IP-Adresse", oldDevice.IPAddress, input.IPAddress)
|
||||||
|
if input.MilestoneEnabled != nil {
|
||||||
|
changes = appendHistoryChange(
|
||||||
|
changes,
|
||||||
|
"milestoneEnabled",
|
||||||
|
"Milestone-Status",
|
||||||
|
formatHistoryBool(oldDevice.MilestoneEnabled),
|
||||||
|
formatHistoryBool(*input.MilestoneEnabled),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
oldRelatedDevices, err := formatRelatedDeviceLabels(ctx, tx, oldRelatedDeviceIDs)
|
oldRelatedDevices, err := formatRelatedDeviceLabels(ctx, tx, oldRelatedDeviceIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
614
backend/milestone_api.go
Normal file
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
@ -28,6 +27,7 @@ func syncMilestoneHardwareDevices(
|
|||||||
hardwareDevice.SerialNumber = strings.TrimSpace(hardwareDevice.SerialNumber)
|
hardwareDevice.SerialNumber = strings.TrimSpace(hardwareDevice.SerialNumber)
|
||||||
hardwareDevice.MacAddress = strings.TrimSpace(hardwareDevice.MacAddress)
|
hardwareDevice.MacAddress = strings.TrimSpace(hardwareDevice.MacAddress)
|
||||||
hardwareDevice.IPAddress = strings.TrimSpace(hardwareDevice.IPAddress)
|
hardwareDevice.IPAddress = strings.TrimSpace(hardwareDevice.IPAddress)
|
||||||
|
hardwareDevice.Port = strings.TrimSpace(hardwareDevice.Port)
|
||||||
hardwareDevice.FirmwareVersion = strings.TrimSpace(hardwareDevice.FirmwareVersion)
|
hardwareDevice.FirmwareVersion = strings.TrimSpace(hardwareDevice.FirmwareVersion)
|
||||||
|
|
||||||
if hardwareDevice.HardwareID == "" {
|
if hardwareDevice.HardwareID == "" {
|
||||||
@ -78,6 +78,7 @@ func createOrUpdateMilestoneHardwareDevice(
|
|||||||
serial_number,
|
serial_number,
|
||||||
mac_address,
|
mac_address,
|
||||||
ip_address,
|
ip_address,
|
||||||
|
milestone_port,
|
||||||
location,
|
location,
|
||||||
loan_status,
|
loan_status,
|
||||||
is_bao_device,
|
is_bao_device,
|
||||||
@ -96,10 +97,11 @@ func createOrUpdateMilestoneHardwareDevice(
|
|||||||
$4,
|
$4,
|
||||||
$5,
|
$5,
|
||||||
$6,
|
$6,
|
||||||
|
$7,
|
||||||
'',
|
'',
|
||||||
'Verfügbar',
|
'Verfügbar',
|
||||||
false,
|
false,
|
||||||
$7,
|
'',
|
||||||
$8,
|
$8,
|
||||||
$9,
|
$9,
|
||||||
$10,
|
$10,
|
||||||
@ -116,8 +118,7 @@ func createOrUpdateMilestoneHardwareDevice(
|
|||||||
serial_number = EXCLUDED.serial_number,
|
serial_number = EXCLUDED.serial_number,
|
||||||
mac_address = EXCLUDED.mac_address,
|
mac_address = EXCLUDED.mac_address,
|
||||||
ip_address = EXCLUDED.ip_address,
|
ip_address = EXCLUDED.ip_address,
|
||||||
location = EXCLUDED.location,
|
milestone_port = EXCLUDED.milestone_port,
|
||||||
comment = EXCLUDED.comment,
|
|
||||||
firmware_version = EXCLUDED.firmware_version,
|
firmware_version = EXCLUDED.firmware_version,
|
||||||
milestone_recording_server_id = EXCLUDED.milestone_recording_server_id,
|
milestone_recording_server_id = EXCLUDED.milestone_recording_server_id,
|
||||||
milestone_display_name = EXCLUDED.milestone_display_name,
|
milestone_display_name = EXCLUDED.milestone_display_name,
|
||||||
@ -131,7 +132,7 @@ func createOrUpdateMilestoneHardwareDevice(
|
|||||||
hardwareDevice.SerialNumber,
|
hardwareDevice.SerialNumber,
|
||||||
hardwareDevice.MacAddress,
|
hardwareDevice.MacAddress,
|
||||||
hardwareDevice.IPAddress,
|
hardwareDevice.IPAddress,
|
||||||
buildMilestoneDeviceComment(hardwareDevice),
|
hardwareDevice.Port,
|
||||||
hardwareDevice.FirmwareVersion,
|
hardwareDevice.FirmwareVersion,
|
||||||
hardwareDevice.RecordingServerID,
|
hardwareDevice.RecordingServerID,
|
||||||
hardwareDevice.HardwareID,
|
hardwareDevice.HardwareID,
|
||||||
@ -324,31 +325,3 @@ func buildGeneratedMilestoneInventoryNumber(hardwareID string) string {
|
|||||||
|
|
||||||
return "MS-" + value
|
return "MS-" + value
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildMilestoneDeviceComment(hardwareDevice milestoneHardwareDevice) string {
|
|
||||||
parts := []string{}
|
|
||||||
|
|
||||||
if strings.TrimSpace(hardwareDevice.DisplayName) != "" {
|
|
||||||
parts = append(parts, strings.TrimSpace(hardwareDevice.DisplayName))
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.TrimSpace(hardwareDevice.DeviceName) != "" &&
|
|
||||||
strings.TrimSpace(hardwareDevice.DeviceName) != strings.TrimSpace(hardwareDevice.DisplayName) {
|
|
||||||
parts = append(parts, "DeviceName: "+strings.TrimSpace(hardwareDevice.DeviceName))
|
|
||||||
}
|
|
||||||
|
|
||||||
if hardwareDevice.Enabled {
|
|
||||||
parts = append(parts, "Status: aktiviert")
|
|
||||||
} else {
|
|
||||||
parts = append(parts, "Status: deaktiviert")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(parts) == 0 {
|
|
||||||
return "Automatisch aus Milestone synchronisiert."
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf(
|
|
||||||
"Automatisch aus Milestone synchronisiert: %s",
|
|
||||||
strings.Join(parts, " · "),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
type notificationPreferencesResponse struct {
|
type notificationPreferencesResponse struct {
|
||||||
InAppEnabled bool `json:"inAppEnabled"`
|
InAppEnabled bool `json:"inAppEnabled"`
|
||||||
SoundEnabled bool `json:"soundEnabled"`
|
SoundEnabled bool `json:"soundEnabled"`
|
||||||
EmailEnabled bool `json:"emailEnabled"`
|
SoundName string `json:"soundName"`
|
||||||
OperationUpdates bool `json:"operationUpdates"`
|
EmailEnabled bool `json:"emailEnabled"`
|
||||||
OperationJournals bool `json:"operationJournals"`
|
OperationUpdates bool `json:"operationUpdates"`
|
||||||
DeviceUpdates bool `json:"deviceUpdates"`
|
OperationJournals bool `json:"operationJournals"`
|
||||||
DeviceJournals bool `json:"deviceJournals"`
|
DeviceUpdates bool `json:"deviceUpdates"`
|
||||||
SystemMessages bool `json:"systemMessages"`
|
DeviceJournals bool `json:"deviceJournals"`
|
||||||
|
SystemMessages bool `json:"systemMessages"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultNotificationPreferences() notificationPreferencesResponse {
|
func defaultNotificationPreferences() notificationPreferencesResponse {
|
||||||
return notificationPreferencesResponse{
|
return notificationPreferencesResponse{
|
||||||
InAppEnabled: true,
|
InAppEnabled: true,
|
||||||
SoundEnabled: false,
|
SoundEnabled: false,
|
||||||
|
SoundName: "default",
|
||||||
EmailEnabled: false,
|
EmailEnabled: false,
|
||||||
OperationUpdates: true,
|
OperationUpdates: true,
|
||||||
OperationJournals: true,
|
OperationJournals: true,
|
||||||
@ -33,6 +37,17 @@ func defaultNotificationPreferences() notificationPreferencesResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeNotificationSoundName(value string) string {
|
||||||
|
value = strings.TrimSpace(strings.ToLower(value))
|
||||||
|
|
||||||
|
switch value {
|
||||||
|
case "default", "chime", "soft", "alert", "success":
|
||||||
|
return value
|
||||||
|
default:
|
||||||
|
return "default"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleGetNotificationPreferences(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleGetNotificationPreferences(w http.ResponseWriter, r *http.Request) {
|
||||||
user, ok := userFromContext(r.Context())
|
user, ok := userFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
@ -40,7 +55,7 @@ func (s *Server) handleGetNotificationPreferences(w http.ResponseWriter, r *http
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
settings, err := s.getNotificationPreferences(r, user.ID)
|
settings, err := s.getNotificationPreferences(r.Context(), user.ID)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"settings": defaultNotificationPreferences(),
|
"settings": defaultNotificationPreferences(),
|
||||||
@ -72,6 +87,8 @@ func (s *Server) handleUpdateNotificationPreferences(w http.ResponseWriter, r *h
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input.SoundName = normalizeNotificationSoundName(input.SoundName)
|
||||||
|
|
||||||
_, err := s.db.Exec(
|
_, err := s.db.Exec(
|
||||||
r.Context(),
|
r.Context(),
|
||||||
`
|
`
|
||||||
@ -79,6 +96,7 @@ func (s *Server) handleUpdateNotificationPreferences(w http.ResponseWriter, r *h
|
|||||||
user_id,
|
user_id,
|
||||||
in_app_enabled,
|
in_app_enabled,
|
||||||
sound_enabled,
|
sound_enabled,
|
||||||
|
sound_name,
|
||||||
email_enabled,
|
email_enabled,
|
||||||
operation_updates,
|
operation_updates,
|
||||||
operation_journals,
|
operation_journals,
|
||||||
@ -86,11 +104,12 @@ func (s *Server) handleUpdateNotificationPreferences(w http.ResponseWriter, r *h
|
|||||||
device_journals,
|
device_journals,
|
||||||
system_messages
|
system_messages
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
ON CONFLICT (user_id)
|
ON CONFLICT (user_id)
|
||||||
DO UPDATE SET
|
DO UPDATE SET
|
||||||
in_app_enabled = EXCLUDED.in_app_enabled,
|
in_app_enabled = EXCLUDED.in_app_enabled,
|
||||||
sound_enabled = EXCLUDED.sound_enabled,
|
sound_enabled = EXCLUDED.sound_enabled,
|
||||||
|
sound_name = EXCLUDED.sound_name,
|
||||||
email_enabled = EXCLUDED.email_enabled,
|
email_enabled = EXCLUDED.email_enabled,
|
||||||
operation_updates = EXCLUDED.operation_updates,
|
operation_updates = EXCLUDED.operation_updates,
|
||||||
operation_journals = EXCLUDED.operation_journals,
|
operation_journals = EXCLUDED.operation_journals,
|
||||||
@ -102,6 +121,7 @@ func (s *Server) handleUpdateNotificationPreferences(w http.ResponseWriter, r *h
|
|||||||
user.ID,
|
user.ID,
|
||||||
input.InAppEnabled,
|
input.InAppEnabled,
|
||||||
input.SoundEnabled,
|
input.SoundEnabled,
|
||||||
|
input.SoundName,
|
||||||
input.EmailEnabled,
|
input.EmailEnabled,
|
||||||
input.OperationUpdates,
|
input.OperationUpdates,
|
||||||
input.OperationJournals,
|
input.OperationJournals,
|
||||||
@ -114,7 +134,7 @@ func (s *Server) handleUpdateNotificationPreferences(w http.ResponseWriter, r *h
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
settings, err := s.getNotificationPreferences(r, user.ID)
|
settings, err := s.getNotificationPreferences(r.Context(), user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "Benachrichtigungseinstellungen wurden gespeichert, konnten aber nicht neu geladen werden")
|
writeError(w, http.StatusInternalServerError, "Benachrichtigungseinstellungen wurden gespeichert, konnten aber nicht neu geladen werden")
|
||||||
return
|
return
|
||||||
@ -125,15 +145,16 @@ func (s *Server) handleUpdateNotificationPreferences(w http.ResponseWriter, r *h
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) getNotificationPreferences(r *http.Request, userID string) (notificationPreferencesResponse, error) {
|
func (s *Server) getNotificationPreferences(ctx context.Context, userID string) (notificationPreferencesResponse, error) {
|
||||||
settings := defaultNotificationPreferences()
|
settings := defaultNotificationPreferences()
|
||||||
|
|
||||||
err := s.db.QueryRow(
|
err := s.db.QueryRow(
|
||||||
r.Context(),
|
ctx,
|
||||||
`
|
`
|
||||||
SELECT
|
SELECT
|
||||||
in_app_enabled,
|
in_app_enabled,
|
||||||
sound_enabled,
|
sound_enabled,
|
||||||
|
COALESCE(sound_name, 'default'),
|
||||||
email_enabled,
|
email_enabled,
|
||||||
operation_updates,
|
operation_updates,
|
||||||
operation_journals,
|
operation_journals,
|
||||||
@ -148,6 +169,7 @@ func (s *Server) getNotificationPreferences(r *http.Request, userID string) (not
|
|||||||
).Scan(
|
).Scan(
|
||||||
&settings.InAppEnabled,
|
&settings.InAppEnabled,
|
||||||
&settings.SoundEnabled,
|
&settings.SoundEnabled,
|
||||||
|
&settings.SoundName,
|
||||||
&settings.EmailEnabled,
|
&settings.EmailEnabled,
|
||||||
&settings.OperationUpdates,
|
&settings.OperationUpdates,
|
||||||
&settings.OperationJournals,
|
&settings.OperationJournals,
|
||||||
@ -156,5 +178,16 @@ func (s *Server) getNotificationPreferences(r *http.Request, userID string) (not
|
|||||||
&settings.SystemMessages,
|
&settings.SystemMessages,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
settings.SoundName = normalizeNotificationSoundName(settings.SoundName)
|
||||||
|
|
||||||
|
return settings, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getNotificationPreferencesOrDefault(ctx context.Context, userID string) (notificationPreferencesResponse, error) {
|
||||||
|
settings, err := s.getNotificationPreferences(ctx, userID)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return defaultNotificationPreferences(), nil
|
||||||
|
}
|
||||||
|
|
||||||
return settings, err
|
return settings, err
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// backend\notifications.go
|
// backend/notifications.go
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
@ -22,7 +22,8 @@ type Notification struct {
|
|||||||
EntityType string `json:"entityType"`
|
EntityType string `json:"entityType"`
|
||||||
EntityID string `json:"entityId"`
|
EntityID string `json:"entityId"`
|
||||||
Data json.RawMessage `json:"data"`
|
Data json.RawMessage `json:"data"`
|
||||||
Visible bool `json:"visible"`
|
Silent bool `json:"silent"`
|
||||||
|
SoundName string `json:"soundName,omitempty"`
|
||||||
ReadAt *time.Time `json:"readAt"`
|
ReadAt *time.Time `json:"readAt"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
}
|
}
|
||||||
@ -77,6 +78,151 @@ func (b *notificationBroker) publish(notification Notification) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *notificationBroker) publishToAll(notification Notification) {
|
||||||
|
b.mu.RLock()
|
||||||
|
defer b.mu.RUnlock()
|
||||||
|
|
||||||
|
for client := range b.clients {
|
||||||
|
select {
|
||||||
|
case client.ch <- notification:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldDeliverNotification(
|
||||||
|
preferences notificationPreferencesResponse,
|
||||||
|
notificationType string,
|
||||||
|
entityType string,
|
||||||
|
) bool {
|
||||||
|
if !preferences.InAppEnabled {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case notificationType == "operation.journal":
|
||||||
|
return preferences.OperationJournals
|
||||||
|
|
||||||
|
case strings.HasPrefix(notificationType, "operation.") || entityType == "operation":
|
||||||
|
return preferences.OperationUpdates
|
||||||
|
|
||||||
|
case notificationType == "device.journal":
|
||||||
|
return preferences.DeviceJournals
|
||||||
|
|
||||||
|
case strings.HasPrefix(notificationType, "device.") || entityType == "device":
|
||||||
|
return preferences.DeviceUpdates
|
||||||
|
|
||||||
|
case strings.HasPrefix(notificationType, "system.") || entityType == "system":
|
||||||
|
return preferences.SystemMessages
|
||||||
|
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func notificationSoundName(preferences notificationPreferencesResponse) string {
|
||||||
|
if !preferences.SoundEnabled {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeNotificationSoundName(preferences.SoundName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createAndPublishNotification(
|
||||||
|
ctx context.Context,
|
||||||
|
recipientID string,
|
||||||
|
notificationType string,
|
||||||
|
title string,
|
||||||
|
message string,
|
||||||
|
entityType string,
|
||||||
|
entityID string,
|
||||||
|
dataJSON []byte,
|
||||||
|
silent bool,
|
||||||
|
) error {
|
||||||
|
preferences, err := s.getNotificationPreferencesOrDefault(ctx, recipientID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sichtbare Benachrichtigungen respektieren die User-Preferences.
|
||||||
|
// Stille Events werden immer erzeugt, weil sie das UI synchronisieren.
|
||||||
|
if !silent && !shouldDeliverNotification(preferences, notificationType, entityType) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var notification Notification
|
||||||
|
var rawData []byte
|
||||||
|
|
||||||
|
err = s.db.QueryRow(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
INSERT INTO notifications (
|
||||||
|
user_id,
|
||||||
|
type,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
entity_type,
|
||||||
|
entity_id,
|
||||||
|
data,
|
||||||
|
silent
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
$1,
|
||||||
|
$2,
|
||||||
|
$3,
|
||||||
|
$4,
|
||||||
|
$5,
|
||||||
|
NULLIF($6, '')::UUID,
|
||||||
|
$7::JSONB,
|
||||||
|
$8
|
||||||
|
)
|
||||||
|
RETURNING
|
||||||
|
id::TEXT,
|
||||||
|
user_id::TEXT,
|
||||||
|
type,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
entity_type,
|
||||||
|
COALESCE(entity_id::TEXT, ''),
|
||||||
|
data,
|
||||||
|
silent,
|
||||||
|
created_at
|
||||||
|
`,
|
||||||
|
recipientID,
|
||||||
|
notificationType,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
entityType,
|
||||||
|
entityID,
|
||||||
|
string(dataJSON),
|
||||||
|
silent,
|
||||||
|
).Scan(
|
||||||
|
¬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) {
|
func (s *Server) handleNotificationEvents(w http.ResponseWriter, r *http.Request) {
|
||||||
user, ok := userFromContext(r.Context())
|
user, ok := userFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
@ -115,12 +261,14 @@ func (s *Server) handleNotificationEvents(w http.ResponseWriter, r *http.Request
|
|||||||
|
|
||||||
eventName := "notification"
|
eventName := "notification"
|
||||||
|
|
||||||
if !notification.Visible {
|
if notification.Silent {
|
||||||
switch notification.EntityType {
|
switch notification.EntityType {
|
||||||
case "operation":
|
case "operation":
|
||||||
eventName = "operation-event"
|
eventName = "operation-event"
|
||||||
case "device":
|
case "device":
|
||||||
eventName = "device-event"
|
eventName = "device-event"
|
||||||
|
case "user":
|
||||||
|
eventName = "user-event"
|
||||||
default:
|
default:
|
||||||
eventName = "notification-event"
|
eventName = "notification-event"
|
||||||
}
|
}
|
||||||
@ -152,12 +300,12 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
|
|||||||
entity_type,
|
entity_type,
|
||||||
COALESCE(entity_id::TEXT, ''),
|
COALESCE(entity_id::TEXT, ''),
|
||||||
data,
|
data,
|
||||||
visible,
|
silent,
|
||||||
read_at,
|
read_at,
|
||||||
created_at
|
created_at
|
||||||
FROM notifications
|
FROM notifications
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
AND visible = true
|
AND silent = false
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT 50
|
LIMIT 50
|
||||||
`,
|
`,
|
||||||
@ -186,7 +334,7 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
|
|||||||
¬ification.EntityType,
|
¬ification.EntityType,
|
||||||
¬ification.EntityID,
|
¬ification.EntityID,
|
||||||
&data,
|
&data,
|
||||||
¬ification.Visible,
|
¬ification.Silent,
|
||||||
&readAt,
|
&readAt,
|
||||||
¬ification.CreatedAt,
|
¬ification.CreatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@ -307,76 +455,25 @@ func (s *Server) createOperationNotification(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var notification Notification
|
if err := s.createAndPublishNotification(
|
||||||
var rawData []byte
|
|
||||||
|
|
||||||
err := s.db.QueryRow(
|
|
||||||
ctx,
|
ctx,
|
||||||
`
|
|
||||||
INSERT INTO notifications (
|
|
||||||
user_id,
|
|
||||||
type,
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
entity_type,
|
|
||||||
entity_id,
|
|
||||||
data,
|
|
||||||
visible
|
|
||||||
)
|
|
||||||
VALUES (
|
|
||||||
$1,
|
|
||||||
$2,
|
|
||||||
$3,
|
|
||||||
$4,
|
|
||||||
'operation',
|
|
||||||
$5,
|
|
||||||
$6::JSONB,
|
|
||||||
true
|
|
||||||
)
|
|
||||||
RETURNING
|
|
||||||
id::TEXT,
|
|
||||||
user_id::TEXT,
|
|
||||||
type,
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
entity_type,
|
|
||||||
COALESCE(entity_id::TEXT, ''),
|
|
||||||
data,
|
|
||||||
visible,
|
|
||||||
created_at
|
|
||||||
`,
|
|
||||||
recipientID,
|
recipientID,
|
||||||
notificationType,
|
notificationType,
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
|
"operation",
|
||||||
operation.ID,
|
operation.ID,
|
||||||
string(dataJSON),
|
dataJSON,
|
||||||
).Scan(
|
false,
|
||||||
¬ification.ID,
|
); err != nil {
|
||||||
¬ification.UserID,
|
|
||||||
¬ification.Type,
|
|
||||||
¬ification.Title,
|
|
||||||
¬ification.Message,
|
|
||||||
¬ification.EntityType,
|
|
||||||
¬ification.EntityID,
|
|
||||||
&rawData,
|
|
||||||
¬ification.Visible,
|
|
||||||
¬ification.CreatedAt,
|
|
||||||
)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
notification.Data = json.RawMessage(rawData)
|
|
||||||
|
|
||||||
notificationsBroker.publish(notification)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.Err()
|
return rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) createOperationUpdateEvent(
|
func (s *Server) createOperationCreatedEvent(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
actor User,
|
actor User,
|
||||||
operation Operation,
|
operation Operation,
|
||||||
@ -390,7 +487,7 @@ func (s *Server) createOperationUpdateEvent(
|
|||||||
rows, err := s.db.Query(
|
rows, err := s.db.Query(
|
||||||
ctx,
|
ctx,
|
||||||
`
|
`
|
||||||
SELECT id
|
SELECT id::TEXT
|
||||||
FROM users
|
FROM users
|
||||||
WHERE id <> $1
|
WHERE id <> $1
|
||||||
AND (
|
AND (
|
||||||
@ -412,66 +509,73 @@ func (s *Server) createOperationUpdateEvent(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var notification Notification
|
if err := s.createAndPublishNotification(
|
||||||
var rawData []byte
|
|
||||||
|
|
||||||
err := s.db.QueryRow(
|
|
||||||
ctx,
|
ctx,
|
||||||
`
|
|
||||||
INSERT INTO notifications (
|
|
||||||
user_id,
|
|
||||||
type,
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
entity_type,
|
|
||||||
entity_id,
|
|
||||||
data,
|
|
||||||
visible
|
|
||||||
)
|
|
||||||
VALUES (
|
|
||||||
$1,
|
|
||||||
'operation.updated',
|
|
||||||
'Einsatz geändert',
|
|
||||||
'',
|
|
||||||
'operation',
|
|
||||||
$2,
|
|
||||||
$3::JSONB,
|
|
||||||
false
|
|
||||||
)
|
|
||||||
RETURNING
|
|
||||||
id::TEXT,
|
|
||||||
user_id::TEXT,
|
|
||||||
type,
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
entity_type,
|
|
||||||
COALESCE(entity_id::TEXT, ''),
|
|
||||||
data,
|
|
||||||
visible,
|
|
||||||
created_at
|
|
||||||
`,
|
|
||||||
recipientID,
|
recipientID,
|
||||||
|
"operation.created",
|
||||||
|
"Einsatz erstellt",
|
||||||
|
"",
|
||||||
|
"operation",
|
||||||
operation.ID,
|
operation.ID,
|
||||||
string(dataJSON),
|
dataJSON,
|
||||||
).Scan(
|
true,
|
||||||
¬ification.ID,
|
); err != nil {
|
||||||
¬ification.UserID,
|
return err
|
||||||
¬ification.Type,
|
}
|
||||||
¬ification.Title,
|
}
|
||||||
¬ification.Message,
|
|
||||||
¬ification.EntityType,
|
|
||||||
¬ification.EntityID,
|
|
||||||
&rawData,
|
|
||||||
¬ification.Visible,
|
|
||||||
¬ification.CreatedAt,
|
|
||||||
)
|
|
||||||
|
|
||||||
if err != nil {
|
return rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createOperationUpdateEvent(
|
||||||
|
ctx context.Context,
|
||||||
|
actor User,
|
||||||
|
operation Operation,
|
||||||
|
data map[string]any,
|
||||||
|
) error {
|
||||||
|
dataJSON, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
SELECT id::TEXT
|
||||||
|
FROM users
|
||||||
|
WHERE id <> $1
|
||||||
|
AND (
|
||||||
|
'admin' = ANY(rights)
|
||||||
|
OR 'operations:read' = ANY(rights)
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
actor.ID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var recipientID string
|
||||||
|
|
||||||
|
if err := rows.Scan(&recipientID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
notification.Data = json.RawMessage(rawData)
|
if err := s.createAndPublishNotification(
|
||||||
notificationsBroker.publish(notification)
|
ctx,
|
||||||
|
recipientID,
|
||||||
|
"operation.updated",
|
||||||
|
"Einsatz geändert",
|
||||||
|
"",
|
||||||
|
"operation",
|
||||||
|
operation.ID,
|
||||||
|
dataJSON,
|
||||||
|
true,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.Err()
|
return rows.Err()
|
||||||
@ -491,7 +595,7 @@ func (s *Server) createOperationJournalEvent(
|
|||||||
rows, err := s.db.Query(
|
rows, err := s.db.Query(
|
||||||
ctx,
|
ctx,
|
||||||
`
|
`
|
||||||
SELECT id
|
SELECT id::TEXT
|
||||||
FROM users
|
FROM users
|
||||||
WHERE id <> $1
|
WHERE id <> $1
|
||||||
`,
|
`,
|
||||||
@ -504,70 +608,24 @@ func (s *Server) createOperationJournalEvent(
|
|||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var recipientID string
|
var recipientID string
|
||||||
|
|
||||||
if err := rows.Scan(&recipientID); err != nil {
|
if err := rows.Scan(&recipientID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var notification Notification
|
if err := s.createAndPublishNotification(
|
||||||
var rawData []byte
|
|
||||||
|
|
||||||
err := s.db.QueryRow(
|
|
||||||
ctx,
|
ctx,
|
||||||
`
|
|
||||||
INSERT INTO notifications (
|
|
||||||
user_id,
|
|
||||||
type,
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
entity_type,
|
|
||||||
entity_id,
|
|
||||||
data,
|
|
||||||
visible
|
|
||||||
)
|
|
||||||
VALUES (
|
|
||||||
$1,
|
|
||||||
'operation.journal',
|
|
||||||
'Journal aktualisiert',
|
|
||||||
'Ein Journal-Eintrag wurde aktualisiert.',
|
|
||||||
'operation',
|
|
||||||
$2,
|
|
||||||
$3::JSONB,
|
|
||||||
false
|
|
||||||
)
|
|
||||||
RETURNING
|
|
||||||
id,
|
|
||||||
user_id::TEXT,
|
|
||||||
type,
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
entity_type,
|
|
||||||
COALESCE(entity_id::TEXT, ''),
|
|
||||||
data,
|
|
||||||
visible,
|
|
||||||
created_at
|
|
||||||
`,
|
|
||||||
recipientID,
|
recipientID,
|
||||||
|
"operation.journal",
|
||||||
|
"Journal aktualisiert",
|
||||||
|
"Ein Journal-Eintrag wurde aktualisiert.",
|
||||||
|
"operation",
|
||||||
operation.ID,
|
operation.ID,
|
||||||
string(dataJSON),
|
dataJSON,
|
||||||
).Scan(
|
true,
|
||||||
¬ification.ID,
|
); err != nil {
|
||||||
¬ification.UserID,
|
|
||||||
¬ification.Type,
|
|
||||||
¬ification.Title,
|
|
||||||
¬ification.Message,
|
|
||||||
¬ification.EntityType,
|
|
||||||
¬ification.EntityID,
|
|
||||||
&rawData,
|
|
||||||
¬ification.Visible,
|
|
||||||
¬ification.CreatedAt,
|
|
||||||
)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
notification.Data = json.RawMessage(rawData)
|
|
||||||
notificationsBroker.publish(notification)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.Err()
|
return rows.Err()
|
||||||
@ -587,7 +645,7 @@ func (s *Server) createDeviceJournalEvent(
|
|||||||
rows, err := s.db.Query(
|
rows, err := s.db.Query(
|
||||||
ctx,
|
ctx,
|
||||||
`
|
`
|
||||||
SELECT id
|
SELECT id::TEXT
|
||||||
FROM users
|
FROM users
|
||||||
WHERE id <> $1
|
WHERE id <> $1
|
||||||
AND (
|
AND (
|
||||||
@ -604,70 +662,24 @@ func (s *Server) createDeviceJournalEvent(
|
|||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var recipientID string
|
var recipientID string
|
||||||
|
|
||||||
if err := rows.Scan(&recipientID); err != nil {
|
if err := rows.Scan(&recipientID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var notification Notification
|
if err := s.createAndPublishNotification(
|
||||||
var rawData []byte
|
|
||||||
|
|
||||||
err := s.db.QueryRow(
|
|
||||||
ctx,
|
ctx,
|
||||||
`
|
|
||||||
INSERT INTO notifications (
|
|
||||||
user_id,
|
|
||||||
type,
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
entity_type,
|
|
||||||
entity_id,
|
|
||||||
data,
|
|
||||||
visible
|
|
||||||
)
|
|
||||||
VALUES (
|
|
||||||
$1,
|
|
||||||
'device.journal',
|
|
||||||
'Geräte-Journal aktualisiert',
|
|
||||||
'Ein Geräte-Journal-Eintrag wurde hinzugefügt.',
|
|
||||||
'device',
|
|
||||||
$2,
|
|
||||||
$3::JSONB,
|
|
||||||
false
|
|
||||||
)
|
|
||||||
RETURNING
|
|
||||||
id,
|
|
||||||
user_id::TEXT,
|
|
||||||
type,
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
entity_type,
|
|
||||||
COALESCE(entity_id::TEXT, ''),
|
|
||||||
data,
|
|
||||||
visible,
|
|
||||||
created_at
|
|
||||||
`,
|
|
||||||
recipientID,
|
recipientID,
|
||||||
|
"device.journal",
|
||||||
|
"Geräte-Journal aktualisiert",
|
||||||
|
"Ein Geräte-Journal-Eintrag wurde hinzugefügt.",
|
||||||
|
"device",
|
||||||
device.ID,
|
device.ID,
|
||||||
string(dataJSON),
|
dataJSON,
|
||||||
).Scan(
|
true,
|
||||||
¬ification.ID,
|
); err != nil {
|
||||||
¬ification.UserID,
|
|
||||||
¬ification.Type,
|
|
||||||
¬ification.Title,
|
|
||||||
¬ification.Message,
|
|
||||||
¬ification.EntityType,
|
|
||||||
¬ification.EntityID,
|
|
||||||
&rawData,
|
|
||||||
¬ification.Visible,
|
|
||||||
¬ification.CreatedAt,
|
|
||||||
)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
notification.Data = json.RawMessage(rawData)
|
|
||||||
notificationsBroker.publish(notification)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.Err()
|
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
|
// nicht abbrechen, Einsatz wurde bereits erstellt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := s.createOperationCreatedEvent(
|
||||||
|
r.Context(),
|
||||||
|
user,
|
||||||
|
operation,
|
||||||
|
map[string]any{
|
||||||
|
"operationNumber": operation.OperationNumber,
|
||||||
|
"operationName": operation.OperationName,
|
||||||
|
"action": "created",
|
||||||
|
},
|
||||||
|
); err != nil {
|
||||||
|
// nicht abbrechen, Einsatz wurde bereits erstellt
|
||||||
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusCreated, map[string]any{
|
writeJSON(w, http.StatusCreated, map[string]any{
|
||||||
"operation": operation,
|
"operation": operation,
|
||||||
})
|
})
|
||||||
@ -1051,7 +1064,7 @@ func (s *Server) handleListUnreadOperationJournals(w http.ResponseWriter, r *htt
|
|||||||
SELECT entity_id::TEXT
|
SELECT entity_id::TEXT
|
||||||
FROM notifications
|
FROM notifications
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
AND visible = false
|
AND silent = true
|
||||||
AND type = 'operation.journal'
|
AND type = 'operation.journal'
|
||||||
AND entity_type = 'operation'
|
AND entity_type = 'operation'
|
||||||
AND entity_id IS NOT NULL
|
AND entity_id IS NOT NULL
|
||||||
@ -1103,7 +1116,7 @@ func (s *Server) handleMarkOperationJournalRead(w http.ResponseWriter, r *http.R
|
|||||||
UPDATE notifications
|
UPDATE notifications
|
||||||
SET read_at = COALESCE(read_at, now())
|
SET read_at = COALESCE(read_at, now())
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
AND visible = false
|
AND silent = true
|
||||||
AND type = 'operation.journal'
|
AND type = 'operation.journal'
|
||||||
AND entity_type = 'operation'
|
AND entity_type = 'operation'
|
||||||
AND entity_id = $2
|
AND entity_id = $2
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// backend/routes.go
|
// backend\routes.go
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
@ -29,9 +29,12 @@ func (s *Server) Routes() http.Handler {
|
|||||||
mux.HandleFunc("GET /devices", s.requireAuth(s.handleListDevices))
|
mux.HandleFunc("GET /devices", s.requireAuth(s.handleListDevices))
|
||||||
mux.HandleFunc("POST /devices", s.requireAuth(s.handleCreateDevice))
|
mux.HandleFunc("POST /devices", s.requireAuth(s.handleCreateDevice))
|
||||||
mux.HandleFunc("PUT /devices/{id}", s.requireAuth(s.handleUpdateDevice))
|
mux.HandleFunc("PUT /devices/{id}", s.requireAuth(s.handleUpdateDevice))
|
||||||
mux.HandleFunc("POST /devices/{id}/milestone-toggle", s.requireAuth(s.handleToggleMilestoneDeviceStatus))
|
|
||||||
mux.HandleFunc("GET /devices/{id}/history", s.requireAuth(s.handleListDeviceHistory))
|
mux.HandleFunc("GET /devices/{id}/history", s.requireAuth(s.handleListDeviceHistory))
|
||||||
mux.HandleFunc("POST /devices/{id}/history", s.requireAuth(s.handleCreateDeviceJournalEntry))
|
mux.HandleFunc("POST /devices/{id}/history", s.requireAuth(s.handleCreateDeviceJournalEntry))
|
||||||
|
mux.HandleFunc("PUT /devices/{id}/history/{historyId}", s.requireAuth(s.handleUpdateDeviceJournalEntry))
|
||||||
|
|
||||||
|
mux.HandleFunc("PATCH /devices/{id}/milestone", s.requireAuth(s.handleUpdateMilestoneHardware))
|
||||||
|
|
||||||
mux.HandleFunc("GET /devices/firmware-check", s.requireAuth(s.handleGetCameraFirmwareCheckState))
|
mux.HandleFunc("GET /devices/firmware-check", s.requireAuth(s.handleGetCameraFirmwareCheckState))
|
||||||
mux.HandleFunc("POST /devices/firmware-check", s.requireAuth(s.handleCheckCameraFirmware))
|
mux.HandleFunc("POST /devices/firmware-check", s.requireAuth(s.handleCheckCameraFirmware))
|
||||||
@ -42,16 +45,21 @@ func (s *Server) Routes() http.Handler {
|
|||||||
|
|
||||||
mux.HandleFunc("GET /operations", s.requireAuth(s.requireRight("operations:read", s.handleListOperations)))
|
mux.HandleFunc("GET /operations", s.requireAuth(s.requireRight("operations:read", s.handleListOperations)))
|
||||||
mux.HandleFunc("POST /operations", s.requireAuth(s.requireRight("operations:write", s.handleCreateOperation)))
|
mux.HandleFunc("POST /operations", s.requireAuth(s.requireRight("operations:write", s.handleCreateOperation)))
|
||||||
|
|
||||||
mux.HandleFunc("PUT /operations/{id}", s.requireAuth(s.requireRight("operations:write", s.handleUpdateOperation)))
|
mux.HandleFunc("PUT /operations/{id}", s.requireAuth(s.requireRight("operations:write", s.handleUpdateOperation)))
|
||||||
mux.HandleFunc("GET /operations/{id}/history", s.requireAuth(s.handleListOperationHistory))
|
mux.HandleFunc("GET /operations/{id}/history", s.requireAuth(s.handleListOperationHistory))
|
||||||
mux.HandleFunc("POST /operations/{id}/history", s.requireAuth(s.handleCreateOperationJournalEntry))
|
mux.HandleFunc("POST /operations/{id}/history", s.requireAuth(s.handleCreateOperationJournalEntry))
|
||||||
mux.HandleFunc("PUT /operations/{id}/history/{historyId}", s.requireAuth(s.handleUpdateOperationJournalEntry))
|
mux.HandleFunc("PUT /operations/{id}/history/{historyId}", s.requireAuth(s.handleUpdateOperationJournalEntry))
|
||||||
mux.HandleFunc("GET /operations/journal-unread", s.requireAuth(s.handleListUnreadOperationJournals))
|
|
||||||
mux.HandleFunc("POST /operations/{id}/journal/read", s.requireAuth(s.handleMarkOperationJournalRead))
|
mux.HandleFunc("POST /operations/{id}/journal/read", s.requireAuth(s.handleMarkOperationJournalRead))
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /operations/journal-unread", s.requireAuth(s.handleListUnreadOperationJournals))
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /operations/assignees", s.requireAuth(s.requireRight("operations:read", s.handleListOperationAssignees)))
|
||||||
|
|
||||||
mux.HandleFunc("GET /search", s.requireAuth(s.handleGlobalSearch))
|
mux.HandleFunc("GET /search", s.requireAuth(s.handleGlobalSearch))
|
||||||
|
|
||||||
mux.HandleFunc("GET /address-search", s.requireAuth(s.handleAddressSearch))
|
mux.HandleFunc("GET /address-search", s.requireAuth(s.handleAddressSearch))
|
||||||
|
mux.HandleFunc("GET /address-reverse", s.requireAuth(s.handleAddressReverse))
|
||||||
|
|
||||||
mux.HandleFunc("GET /notifications", s.requireAuth(s.handleListNotifications))
|
mux.HandleFunc("GET /notifications", s.requireAuth(s.handleListNotifications))
|
||||||
mux.HandleFunc("POST /notifications/{id}/read", s.requireAuth(s.handleMarkNotificationRead))
|
mux.HandleFunc("POST /notifications/{id}/read", s.requireAuth(s.handleMarkNotificationRead))
|
||||||
@ -75,6 +83,9 @@ func (s *Server) Routes() http.Handler {
|
|||||||
mux.HandleFunc("POST /admin/users", s.requireAuth(s.requireRight("users:write", s.handleAdminCreateUser)))
|
mux.HandleFunc("POST /admin/users", s.requireAuth(s.requireRight("users:write", s.handleAdminCreateUser)))
|
||||||
mux.HandleFunc("PUT /admin/users/{id}", s.requireAuth(s.requireRight("users:write", s.handleAdminUpdateUser)))
|
mux.HandleFunc("PUT /admin/users/{id}", s.requireAuth(s.requireRight("users:write", s.handleAdminUpdateUser)))
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /admin/user-units", s.requireAuth(s.requireRight("users:read", s.handleListUserUnits)))
|
||||||
|
mux.HandleFunc("POST /admin/user-units", s.requireAuth(s.requireRight("users:write", s.handleCreateUserUnit)))
|
||||||
|
|
||||||
mux.HandleFunc("GET /admin/teams", s.requireAuth(s.requireRight("teams:read", s.handleAdminListTeams)))
|
mux.HandleFunc("GET /admin/teams", s.requireAuth(s.requireRight("teams:read", s.handleAdminListTeams)))
|
||||||
mux.HandleFunc("POST /admin/teams", s.requireAuth(s.requireRight("teams:write", s.handleAdminCreateTeam)))
|
mux.HandleFunc("POST /admin/teams", s.requireAuth(s.requireRight("teams:write", s.handleAdminCreateTeam)))
|
||||||
mux.HandleFunc("PUT /admin/teams/{id}", s.requireAuth(s.requireRight("teams:write", s.handleAdminUpdateTeam)))
|
mux.HandleFunc("PUT /admin/teams/{id}", s.requireAuth(s.requireRight("teams:write", s.handleAdminUpdateTeam)))
|
||||||
|
|||||||
@ -264,6 +264,18 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
|||||||
)
|
)
|
||||||
`,
|
`,
|
||||||
|
|
||||||
|
`
|
||||||
|
CREATE TABLE IF NOT EXISTS user_units (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
normalized_name TEXT NOT NULL UNIQUE,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
|
||||||
`
|
`
|
||||||
CREATE TABLE IF NOT EXISTS user_teams (
|
CREATE TABLE IF NOT EXISTS user_teams (
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
@ -326,6 +338,7 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
|||||||
milestone_hardware_id TEXT NOT NULL DEFAULT '',
|
milestone_hardware_id TEXT NOT NULL DEFAULT '',
|
||||||
milestone_display_name TEXT NOT NULL DEFAULT '',
|
milestone_display_name TEXT NOT NULL DEFAULT '',
|
||||||
firmware_version TEXT NOT NULL DEFAULT '',
|
firmware_version TEXT NOT NULL DEFAULT '',
|
||||||
|
milestone_port TEXT NOT NULL DEFAULT '',
|
||||||
milestone_enabled BOOLEAN NOT NULL DEFAULT true,
|
milestone_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||||
milestone_synced_at TIMESTAMPTZ,
|
milestone_synced_at TIMESTAMPTZ,
|
||||||
|
|
||||||
@ -380,7 +393,8 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
|||||||
action TEXT NOT NULL,
|
action TEXT NOT NULL,
|
||||||
changes JSONB NOT NULL DEFAULT '[]'::JSONB,
|
changes JSONB NOT NULL DEFAULT '[]'::JSONB,
|
||||||
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
)
|
)
|
||||||
`,
|
`,
|
||||||
|
|
||||||
@ -434,7 +448,7 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
|||||||
type TEXT NOT NULL DEFAULT 'info',
|
type TEXT NOT NULL DEFAULT 'info',
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
message TEXT NOT NULL DEFAULT '',
|
message TEXT NOT NULL DEFAULT '',
|
||||||
visible BOOLEAN NOT NULL DEFAULT true,
|
silent BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
entity_type TEXT NOT NULL DEFAULT '',
|
entity_type TEXT NOT NULL DEFAULT '',
|
||||||
entity_id UUID,
|
entity_id UUID,
|
||||||
@ -476,6 +490,7 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
|||||||
|
|
||||||
in_app_enabled BOOLEAN NOT NULL DEFAULT true,
|
in_app_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||||
sound_enabled BOOLEAN NOT NULL DEFAULT false,
|
sound_enabled BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
sound_name TEXT NOT NULL DEFAULT 'default',
|
||||||
email_enabled BOOLEAN NOT NULL DEFAULT false,
|
email_enabled BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
operation_updates BOOLEAN NOT NULL DEFAULT true,
|
operation_updates BOOLEAN NOT NULL DEFAULT true,
|
||||||
@ -564,11 +579,15 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
|||||||
`CREATE INDEX IF NOT EXISTS idx_user_teams_user_id ON user_teams(user_id)`,
|
`CREATE INDEX IF NOT EXISTS idx_user_teams_user_id ON user_teams(user_id)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_user_teams_team_id ON user_teams(team_id)`,
|
`CREATE INDEX IF NOT EXISTS idx_user_teams_team_id ON user_teams(team_id)`,
|
||||||
|
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_user_units_name ON user_units(name)`,
|
||||||
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_user_id ON dashboard_widgets(user_id)`,
|
`CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_user_id ON dashboard_widgets(user_id)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_position ON dashboard_widgets(user_id, y, x)`,
|
`CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_position ON dashboard_widgets(user_id, y, x)`,
|
||||||
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_dashboard_settings_user_id ON dashboard_settings(user_id)`,
|
`CREATE INDEX IF NOT EXISTS idx_dashboard_settings_user_id ON dashboard_settings(user_id)`,
|
||||||
|
|
||||||
|
`ALTER TABLE devices ADD COLUMN IF NOT EXISTS milestone_port TEXT NOT NULL DEFAULT ''`,
|
||||||
|
|
||||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_inventory_number_unique ON devices(lower(inventory_number))`,
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_inventory_number_unique ON devices(lower(inventory_number))`,
|
||||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_serial_number_unique ON devices(lower(serial_number)) WHERE serial_number <> ''`,
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_serial_number_unique ON devices(lower(serial_number)) WHERE serial_number <> ''`,
|
||||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_mac_address_unique ON devices(lower(mac_address)) WHERE mac_address <> ''`,
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_mac_address_unique ON devices(lower(mac_address)) WHERE mac_address <> ''`,
|
||||||
|
|||||||
33
backend/user_events.go
Normal file
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.publishUserProfileEvent("user.updated", user)
|
||||||
|
|
||||||
writeSettingsJSON(w, http.StatusOK, map[string]any{
|
writeSettingsJSON(w, http.StatusOK, map[string]any{
|
||||||
"message": "Profil gespeichert",
|
"message": "Profil gespeichert",
|
||||||
"user": user,
|
"user": user,
|
||||||
|
|||||||
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": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@types/geojson": "^7946.0.16",
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/node": "^24.12.3",
|
"@types/node": "^24.12.3",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
|
|||||||
@ -24,6 +24,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@types/geojson": "^7946.0.16",
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/node": "^24.12.3",
|
"@types/node": "^24.12.3",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
|
|||||||
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
|
// frontend/src/App.tsx
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Navigate, Route, Routes } from 'react-router'
|
import { Navigate, Route, Routes, useNavigate } from 'react-router'
|
||||||
|
import { NotificationProvider } from './components/Notifications'
|
||||||
import LoginPage from './pages/LoginPage'
|
import LoginPage from './pages/LoginPage'
|
||||||
import AppLayout from './components/AppLayout'
|
import AppLayout from './components/AppLayout'
|
||||||
import DashboardPage from './pages/DashboardPage'
|
import DashboardPage from './pages/DashboardPage'
|
||||||
@ -29,6 +30,52 @@ function parseNotificationEvent(event: MessageEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getNotificationData(notification: Notification) {
|
||||||
|
const data = (notification as { data?: unknown }).data
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
try {
|
||||||
|
return JSON.parse(data) as Record<string, unknown>
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data === 'object') {
|
||||||
|
return data as Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserFromNotification(notification: Notification) {
|
||||||
|
const data = getNotificationData(notification)
|
||||||
|
const user = data.user
|
||||||
|
|
||||||
|
if (!user || typeof user !== 'object') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return user as User
|
||||||
|
}
|
||||||
|
|
||||||
|
function playNotificationSound(soundName?: string) {
|
||||||
|
if (!soundName) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const audio = new Audio(`/sounds/notifications/${soundName}.mp3`)
|
||||||
|
audio.volume = 0.6
|
||||||
|
|
||||||
|
void audio.play().catch(() => {
|
||||||
|
// Browser blockiert Audio ggf., bis der User einmal mit der Seite interagiert hat.
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function RequireRight({
|
function RequireRight({
|
||||||
user,
|
user,
|
||||||
right,
|
right,
|
||||||
@ -46,6 +93,8 @@ function RequireRight({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const [user, setUser] = useState<User | null>(null)
|
const [user, setUser] = useState<User | null>(null)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
|
||||||
@ -77,10 +126,12 @@ function App() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function addVisibleNotification(notification: Notification) {
|
function addVisibleNotification(notification: Notification) {
|
||||||
if (notification.visible === false) {
|
if (notification.silent) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
playNotificationSound(notification.soundName)
|
||||||
|
|
||||||
setNotifications((currentNotifications) => [
|
setNotifications((currentNotifications) => [
|
||||||
notification,
|
notification,
|
||||||
...currentNotifications.filter((item) => item.id !== notification.id),
|
...currentNotifications.filter((item) => item.id !== notification.id),
|
||||||
@ -151,14 +202,48 @@ function App() {
|
|||||||
dispatchEntityEvent(notification)
|
dispatchEntityEvent(notification)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleUserEvent(event: MessageEvent) {
|
||||||
|
const notification = parseNotificationEvent(event)
|
||||||
|
|
||||||
|
if (!notification) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('user-notification', {
|
||||||
|
detail: notification,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const updatedUser = getUserFromNotification(notification)
|
||||||
|
|
||||||
|
if (!updatedUser) {
|
||||||
|
void loadUser()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setUser((currentUser) => {
|
||||||
|
if (!currentUser || currentUser.id !== updatedUser.id) {
|
||||||
|
return currentUser
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...currentUser,
|
||||||
|
...updatedUser,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
eventSource.addEventListener('notification', handleNotificationEvent)
|
eventSource.addEventListener('notification', handleNotificationEvent)
|
||||||
eventSource.addEventListener('operation-event', handleOperationEvent)
|
eventSource.addEventListener('operation-event', handleOperationEvent)
|
||||||
eventSource.addEventListener('device-event', handleDeviceEvent)
|
eventSource.addEventListener('device-event', handleDeviceEvent)
|
||||||
|
eventSource.addEventListener('user-event', handleUserEvent)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
eventSource.removeEventListener('notification', handleNotificationEvent)
|
eventSource.removeEventListener('notification', handleNotificationEvent)
|
||||||
eventSource.removeEventListener('operation-event', handleOperationEvent)
|
eventSource.removeEventListener('operation-event', handleOperationEvent)
|
||||||
eventSource.removeEventListener('device-event', handleDeviceEvent)
|
eventSource.removeEventListener('device-event', handleDeviceEvent)
|
||||||
|
eventSource.removeEventListener('user-event', handleUserEvent)
|
||||||
eventSource.close()
|
eventSource.close()
|
||||||
}
|
}
|
||||||
}, [user])
|
}, [user])
|
||||||
@ -264,6 +349,15 @@ function App() {
|
|||||||
}).catch(() => undefined)
|
}).catch(() => undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleNotificationClick(notification: Notification) {
|
||||||
|
if (
|
||||||
|
notification.type === 'operation.created' ||
|
||||||
|
notification.entityType === 'operation'
|
||||||
|
) {
|
||||||
|
navigate('/einsaetze')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
try {
|
try {
|
||||||
await fetch(`${API_URL}/auth/logout`, {
|
await fetch(`${API_URL}/auth/logout`, {
|
||||||
@ -300,101 +394,104 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppLayout
|
<NotificationProvider>
|
||||||
user={user}
|
<AppLayout
|
||||||
onLogout={handleLogout}
|
user={user}
|
||||||
notificationCenter={
|
onLogout={handleLogout}
|
||||||
<NotificationCenter
|
notificationCenter={
|
||||||
notifications={notifications}
|
<NotificationCenter
|
||||||
unreadCount={unreadNotificationCount}
|
notifications={notifications}
|
||||||
onMarkRead={markNotificationRead}
|
unreadCount={unreadNotificationCount}
|
||||||
onMarkAllRead={markAllNotificationsRead}
|
onMarkRead={markNotificationRead}
|
||||||
/>
|
onMarkAllRead={markAllNotificationsRead}
|
||||||
}
|
onNotificationClick={handleNotificationClick}
|
||||||
>
|
/>
|
||||||
<Routes>
|
}
|
||||||
<Route path="/" element={<DashboardPage username={user.username} />} />
|
>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<DashboardPage username={user.username} />} />
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/geraete"
|
path="/geraete"
|
||||||
element={
|
element={
|
||||||
<RequireRight user={user} right="devices:read">
|
<RequireRight user={user} right="devices:read">
|
||||||
<DevicesPage />
|
<DevicesPage />
|
||||||
</RequireRight>
|
</RequireRight>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/einsaetze"
|
path="/einsaetze"
|
||||||
element={
|
element={
|
||||||
<RequireRight user={user} right="operations:read">
|
<RequireRight user={user} right="operations:read">
|
||||||
<OperationsPage
|
<OperationsPage
|
||||||
unreadJournalOperationIds={unreadJournalOperationIds}
|
unreadJournalOperationIds={unreadJournalOperationIds}
|
||||||
onJournalRead={markOperationJournalRead}
|
onJournalRead={markOperationJournalRead}
|
||||||
canWriteOperations={hasRight(user, 'operations:write')}
|
canWriteOperations={hasRight(user, 'operations:write')}
|
||||||
/>
|
/>
|
||||||
</RequireRight>
|
</RequireRight>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/kalender"
|
path="/kalender"
|
||||||
element={
|
element={
|
||||||
<RequireRight user={user} right="calendar:read">
|
<RequireRight user={user} right="calendar:read">
|
||||||
<CalendarPage />
|
<CalendarPage />
|
||||||
</RequireRight>
|
</RequireRight>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/dokumente"
|
path="/dokumente"
|
||||||
element={
|
element={
|
||||||
<RequireRight user={user} right="documents:read">
|
<RequireRight user={user} right="documents:read">
|
||||||
<DocumentsPage />
|
<DocumentsPage />
|
||||||
</RequireRight>
|
</RequireRight>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/berichte"
|
path="/berichte"
|
||||||
element={
|
element={
|
||||||
<RequireRight user={user} right="reports:read">
|
<RequireRight user={user} right="reports:read">
|
||||||
<ReportsPage />
|
<ReportsPage />
|
||||||
</RequireRight>
|
</RequireRight>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/administration"
|
path="/administration"
|
||||||
element={<Navigate to="/administration/benutzer" replace />}
|
element={<Navigate to="/administration/benutzer" replace />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/administration/*"
|
path="/administration/*"
|
||||||
element={
|
element={
|
||||||
<RequireRight user={user} right="administration:read">
|
<RequireRight user={user} right="administration:read">
|
||||||
<AdministrationPage />
|
<AdministrationPage />
|
||||||
</RequireRight>
|
</RequireRight>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/einstellungen/*"
|
path="/einstellungen/*"
|
||||||
element={
|
element={
|
||||||
<RequireRight user={user} right="settings:read">
|
<RequireRight user={user} right="settings:read">
|
||||||
<SettingsPage
|
<SettingsPage
|
||||||
user={user}
|
user={user}
|
||||||
onUserUpdated={setUser}
|
onUserUpdated={setUser}
|
||||||
/>
|
/>
|
||||||
</RequireRight>
|
</RequireRight>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route path="/feedback" element={<Feedback />} />
|
<Route path="/feedback" element={<Feedback />} />
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
|
</NotificationProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,8 +8,10 @@ import {
|
|||||||
ComboboxOptions,
|
ComboboxOptions,
|
||||||
Label,
|
Label,
|
||||||
} from '@headlessui/react'
|
} from '@headlessui/react'
|
||||||
|
import AddressMapPicker from './AddressMapPicker'
|
||||||
|
import type { GeoJsonObject } from 'geojson'
|
||||||
import { ChevronDownIcon } from '@heroicons/react/20/solid'
|
import { ChevronDownIcon } from '@heroicons/react/20/solid'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||||
|
|
||||||
@ -19,6 +21,16 @@ export type AddressSuggestion = {
|
|||||||
lat: number
|
lat: number
|
||||||
lon: number
|
lon: number
|
||||||
type?: string
|
type?: string
|
||||||
|
geojson?: GeoJsonObject | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type Coordinates = {
|
||||||
|
lat: number
|
||||||
|
lon: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReverseGeocodeResponse = {
|
||||||
|
suggestion?: AddressSuggestion
|
||||||
}
|
}
|
||||||
|
|
||||||
type AddressComboboxProps = {
|
type AddressComboboxProps = {
|
||||||
@ -28,6 +40,7 @@ type AddressComboboxProps = {
|
|||||||
value: string
|
value: string
|
||||||
onChange: (value: string) => void
|
onChange: (value: string) => void
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
|
mapVisible?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputClassName =
|
const inputClassName =
|
||||||
@ -37,21 +50,34 @@ function classNames(...classes: Array<string | false | null | undefined>) {
|
|||||||
return classes.filter(Boolean).join(' ')
|
return classes.filter(Boolean).join(' ')
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildOpenStreetMapEmbedUrl(address: AddressSuggestion) {
|
function toNumber(value: unknown) {
|
||||||
const offset = 0.004
|
if (typeof value === 'number') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
if (typeof value === 'string') {
|
||||||
bbox: [
|
const parsed = Number(value)
|
||||||
address.lon - offset,
|
return Number.isFinite(parsed) ? parsed : NaN
|
||||||
address.lat - offset,
|
}
|
||||||
address.lon + offset,
|
|
||||||
address.lat + offset,
|
|
||||||
].join(','),
|
|
||||||
layer: 'mapnik',
|
|
||||||
marker: `${address.lat},${address.lon}`,
|
|
||||||
})
|
|
||||||
|
|
||||||
return `https://www.openstreetmap.org/export/embed.html?${params.toString()}`
|
return NaN
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAddressSuggestion(
|
||||||
|
suggestion: AddressSuggestion,
|
||||||
|
): AddressSuggestion | null {
|
||||||
|
const lat = toNumber(suggestion.lat)
|
||||||
|
const lon = toNumber(suggestion.lon)
|
||||||
|
|
||||||
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...suggestion,
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AddressCombobox({
|
export default function AddressCombobox({
|
||||||
@ -61,26 +87,223 @@ export default function AddressCombobox({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
placeholder = 'Adresse suchen',
|
placeholder = 'Adresse suchen',
|
||||||
|
mapVisible = true,
|
||||||
}: AddressComboboxProps) {
|
}: AddressComboboxProps) {
|
||||||
const [query, setQuery] = useState(value)
|
const [query, setQuery] = useState(value)
|
||||||
const [suggestions, setSuggestions] = useState<AddressSuggestion[]>([])
|
const [suggestions, setSuggestions] = useState<AddressSuggestion[]>([])
|
||||||
const [selectedSuggestion, setSelectedSuggestion] = useState<AddressSuggestion | null>(null)
|
const [selectedSuggestion, setSelectedSuggestion] = useState<AddressSuggestion | null>(null)
|
||||||
const [previewSuggestion, setPreviewSuggestion] = useState<AddressSuggestion | null>(null)
|
const [previewSuggestion, setPreviewSuggestion] = useState<AddressSuggestion | null>(null)
|
||||||
|
const [markerPosition, setMarkerPosition] = useState<{
|
||||||
|
lat: number
|
||||||
|
lon: number
|
||||||
|
} | null>(null)
|
||||||
|
const [mapFocusKey, setMapFocusKey] = useState(0)
|
||||||
const [isSearching, setIsSearching] = useState(false)
|
const [isSearching, setIsSearching] = useState(false)
|
||||||
const [searchError, setSearchError] = useState<string | null>(null)
|
const [searchError, setSearchError] = useState<string | null>(null)
|
||||||
|
|
||||||
const previousValueRef = useRef(value)
|
const previousValueRef = useRef(value)
|
||||||
|
const initializedAddressRef = useRef('')
|
||||||
|
const previousMapVisibleRef = useRef(mapVisible)
|
||||||
|
|
||||||
|
const reverseGeocodeAbortRef = useRef<AbortController | null>(null)
|
||||||
|
const internalValueUpdateRef = useRef(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (value !== previousValueRef.current && value !== query) {
|
if (value !== previousValueRef.current) {
|
||||||
setQuery(value)
|
setQuery(value)
|
||||||
setSelectedSuggestion(null)
|
|
||||||
setPreviewSuggestion(null)
|
if (internalValueUpdateRef.current) {
|
||||||
|
internalValueUpdateRef.current = false
|
||||||
|
previousValueRef.current = value
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value !== query) {
|
||||||
|
initializedAddressRef.current = ''
|
||||||
|
|
||||||
|
setSelectedSuggestion(null)
|
||||||
|
setPreviewSuggestion(null)
|
||||||
|
setMarkerPosition(null)
|
||||||
|
setMapFocusKey((currentKey) => currentKey + 1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
previousValueRef.current = value
|
previousValueRef.current = value
|
||||||
}, [value, query])
|
}, [value, query])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const becameVisible = mapVisible && !previousMapVisibleRef.current
|
||||||
|
|
||||||
|
previousMapVisibleRef.current = mapVisible
|
||||||
|
|
||||||
|
if (!becameVisible) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (markerPosition) {
|
||||||
|
setMapFocusKey((currentKey) => currentKey + 1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
initializedAddressRef.current = ''
|
||||||
|
|
||||||
|
void loadInitialMarkerForAddress(value)
|
||||||
|
}, [mapVisible, markerPosition, value])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const trimmedValue = value.trim()
|
||||||
|
|
||||||
|
if (trimmedValue.length < 3) {
|
||||||
|
initializedAddressRef.current = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (markerPosition) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const abortController = new AbortController()
|
||||||
|
|
||||||
|
void loadInitialMarkerForAddress(trimmedValue, abortController.signal)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
abortController.abort()
|
||||||
|
}
|
||||||
|
}, [value, markerPosition])
|
||||||
|
|
||||||
|
async function loadInitialMarkerForAddress(
|
||||||
|
address: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
) {
|
||||||
|
const trimmedAddress = address.trim()
|
||||||
|
|
||||||
|
if (trimmedAddress.length < 3) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (initializedAddressRef.current === trimmedAddress && markerPosition) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_URL}/address-search?q=${encodeURIComponent(trimmedAddress)}`,
|
||||||
|
{
|
||||||
|
credentials: 'include',
|
||||||
|
signal,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
const firstSuggestion = ((data.suggestions ?? []) as AddressSuggestion[])
|
||||||
|
.map(normalizeAddressSuggestion)
|
||||||
|
.find((suggestion): suggestion is AddressSuggestion => suggestion !== null)
|
||||||
|
|
||||||
|
if (!firstSuggestion) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const reverseSuggestion = await reverseGeocodeCoordinates({
|
||||||
|
lat: firstSuggestion.lat,
|
||||||
|
lon: firstSuggestion.lon,
|
||||||
|
})
|
||||||
|
|
||||||
|
const nextSuggestion: AddressSuggestion = {
|
||||||
|
...firstSuggestion,
|
||||||
|
geojson: reverseSuggestion?.geojson ?? firstSuggestion.geojson ?? null,
|
||||||
|
}
|
||||||
|
|
||||||
|
initializedAddressRef.current = trimmedAddress
|
||||||
|
|
||||||
|
setSelectedSuggestion(nextSuggestion)
|
||||||
|
setPreviewSuggestion(nextSuggestion)
|
||||||
|
setMarkerPosition({
|
||||||
|
lat: nextSuggestion.lat,
|
||||||
|
lon: nextSuggestion.lon,
|
||||||
|
})
|
||||||
|
setMapFocusKey((currentKey) => currentKey + 1)
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reverseGeocodeCoordinates(
|
||||||
|
coordinates: Coordinates,
|
||||||
|
): Promise<AddressSuggestion | null> {
|
||||||
|
reverseGeocodeAbortRef.current?.abort()
|
||||||
|
|
||||||
|
const abortController = new AbortController()
|
||||||
|
reverseGeocodeAbortRef.current = abortController
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_URL}/address-reverse?lat=${encodeURIComponent(
|
||||||
|
coordinates.lat,
|
||||||
|
)}&lon=${encodeURIComponent(coordinates.lon)}`,
|
||||||
|
{
|
||||||
|
credentials: 'include',
|
||||||
|
signal: abortController.signal,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as ReverseGeocodeResponse
|
||||||
|
|
||||||
|
if (!data.suggestion) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedSuggestion = normalizeAddressSuggestion(data.suggestion)
|
||||||
|
|
||||||
|
if (!normalizedSuggestion) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...normalizedSuggestion,
|
||||||
|
lat: coordinates.lat,
|
||||||
|
lon: coordinates.lon,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMapPositionChange(coordinates: Coordinates) {
|
||||||
|
setMarkerPosition(coordinates)
|
||||||
|
setSelectedSuggestion(null)
|
||||||
|
setPreviewSuggestion(null)
|
||||||
|
|
||||||
|
const suggestion = await reverseGeocodeCoordinates(coordinates)
|
||||||
|
|
||||||
|
if (!suggestion) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
initializedAddressRef.current = suggestion.label
|
||||||
|
internalValueUpdateRef.current = true
|
||||||
|
|
||||||
|
setQuery(suggestion.label)
|
||||||
|
setSelectedSuggestion(suggestion)
|
||||||
|
setPreviewSuggestion(suggestion)
|
||||||
|
|
||||||
|
onChange(suggestion.label)
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const trimmedQuery = query.trim()
|
const trimmedQuery = query.trim()
|
||||||
|
|
||||||
@ -113,10 +336,14 @@ export default function AddressCombobox({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
const nextSuggestions = (data.suggestions ?? []) as AddressSuggestion[]
|
const nextSuggestions = ((data.suggestions ?? []) as AddressSuggestion[])
|
||||||
|
.map(normalizeAddressSuggestion)
|
||||||
|
.filter((suggestion): suggestion is AddressSuggestion => suggestion !== null)
|
||||||
|
|
||||||
|
const nextPreview = nextSuggestions[0] ?? null
|
||||||
|
|
||||||
setSuggestions(nextSuggestions)
|
setSuggestions(nextSuggestions)
|
||||||
setPreviewSuggestion((currentPreview) => currentPreview ?? nextSuggestions[0] ?? null)
|
setPreviewSuggestion((currentPreview) => currentPreview ?? nextPreview)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||||
return
|
return
|
||||||
@ -140,33 +367,58 @@ export default function AddressCombobox({
|
|||||||
}
|
}
|
||||||
}, [query])
|
}, [query])
|
||||||
|
|
||||||
const mapUrl = useMemo(() => {
|
|
||||||
if (!previewSuggestion) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return buildOpenStreetMapEmbedUrl(previewSuggestion)
|
|
||||||
}, [previewSuggestion])
|
|
||||||
|
|
||||||
function handleInputChange(nextValue: string) {
|
function handleInputChange(nextValue: string) {
|
||||||
|
reverseGeocodeAbortRef.current?.abort()
|
||||||
|
internalValueUpdateRef.current = false
|
||||||
|
initializedAddressRef.current = ''
|
||||||
|
|
||||||
setQuery(nextValue)
|
setQuery(nextValue)
|
||||||
setSelectedSuggestion(null)
|
setSelectedSuggestion(null)
|
||||||
setPreviewSuggestion(null)
|
setPreviewSuggestion(null)
|
||||||
|
setMarkerPosition(null)
|
||||||
onChange(nextValue)
|
onChange(nextValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSelect(suggestion: AddressSuggestion | null) {
|
async function handleSelect(suggestion: AddressSuggestion | null) {
|
||||||
setSelectedSuggestion(suggestion)
|
const normalizedSuggestion = suggestion
|
||||||
|
? normalizeAddressSuggestion(suggestion)
|
||||||
|
: null
|
||||||
|
|
||||||
if (!suggestion) {
|
setSelectedSuggestion(normalizedSuggestion)
|
||||||
|
|
||||||
|
if (!normalizedSuggestion) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setQuery(suggestion.label)
|
setMarkerPosition({
|
||||||
setPreviewSuggestion(suggestion)
|
lat: normalizedSuggestion.lat,
|
||||||
onChange(suggestion.label)
|
lon: normalizedSuggestion.lon,
|
||||||
|
})
|
||||||
|
setMapFocusKey((currentKey) => currentKey + 1)
|
||||||
|
|
||||||
|
const reverseSuggestion = await reverseGeocodeCoordinates({
|
||||||
|
lat: normalizedSuggestion.lat,
|
||||||
|
lon: normalizedSuggestion.lon,
|
||||||
|
})
|
||||||
|
|
||||||
|
const nextSuggestion: AddressSuggestion = {
|
||||||
|
...normalizedSuggestion,
|
||||||
|
label: reverseSuggestion?.label ?? normalizedSuggestion.label,
|
||||||
|
geojson: reverseSuggestion?.geojson ?? normalizedSuggestion.geojson ?? null,
|
||||||
|
}
|
||||||
|
|
||||||
|
initializedAddressRef.current = nextSuggestion.label
|
||||||
|
internalValueUpdateRef.current = true
|
||||||
|
|
||||||
|
setQuery(nextSuggestion.label)
|
||||||
|
setSelectedSuggestion(nextSuggestion)
|
||||||
|
setPreviewSuggestion(nextSuggestion)
|
||||||
|
|
||||||
|
onChange(nextSuggestion.label)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mapKey = `${mapVisible ? 'visible' : 'hidden'}-${mapFocusKey}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<input type="hidden" name={name} value={value} />
|
<input type="hidden" name={name} value={value} />
|
||||||
@ -196,7 +448,7 @@ export default function AddressCombobox({
|
|||||||
{(suggestions.length > 0 || isSearching || searchError) && (
|
{(suggestions.length > 0 || isSearching || searchError) && (
|
||||||
<ComboboxOptions
|
<ComboboxOptions
|
||||||
transition
|
transition
|
||||||
className="absolute z-20 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg outline outline-black/5 data-leave:transition data-leave:duration-100 data-leave:ease-in data-closed:data-leave:opacity-0 sm:text-sm dark:bg-gray-800 dark:shadow-none dark:-outline-offset-1 dark:outline-white/10"
|
className="absolute z-[1000] mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg outline outline-black/5 data-leave:transition data-leave:duration-100 data-leave:ease-in data-closed:data-leave:opacity-0 sm:text-sm dark:bg-gray-800 dark:shadow-none dark:-outline-offset-1 dark:outline-white/10"
|
||||||
>
|
>
|
||||||
{isSearching && (
|
{isSearching && (
|
||||||
<div className="px-3 py-2 text-sm text-gray-500 dark:text-gray-400">
|
<div className="px-3 py-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
@ -230,22 +482,14 @@ export default function AddressCombobox({
|
|||||||
</div>
|
</div>
|
||||||
</HeadlessCombobox>
|
</HeadlessCombobox>
|
||||||
|
|
||||||
{mapUrl && previewSuggestion && (
|
<AddressMapPicker
|
||||||
<div className="mt-3 overflow-hidden rounded-lg border border-gray-200 bg-gray-50 dark:border-white/10 dark:bg-white/5">
|
key={mapKey}
|
||||||
<iframe
|
value={markerPosition}
|
||||||
title={`OpenStreetMap Vorschau: ${previewSuggestion.label}`}
|
focusKey={mapFocusKey}
|
||||||
src={mapUrl}
|
visible={mapVisible}
|
||||||
className="h-56 w-full border-0"
|
highlightGeoJson={previewSuggestion?.geojson ?? null}
|
||||||
loading="lazy"
|
onChange={handleMapPositionChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="border-t border-gray-200 px-3 py-2 text-xs text-gray-500 dark:border-white/10 dark:text-gray-400">
|
|
||||||
Vorschau: {previewSuggestion.label}
|
|
||||||
{' · '}
|
|
||||||
© OpenStreetMap-Mitwirkende
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
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}
|
onNavigate={handleNavigation}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex h-dvh min-w-0 flex-col lg:pl-72">
|
<div className="relative z-0 flex h-dvh min-w-0 flex-col lg:pl-72">
|
||||||
<Topbar
|
<Topbar
|
||||||
user={user}
|
user={user}
|
||||||
onLogout={onLogout}
|
onLogout={onLogout}
|
||||||
@ -61,7 +61,7 @@ export default function AppLayout({
|
|||||||
onNavigate={handleNavigation}
|
onNavigate={handleNavigation}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main className="relative min-h-0 flex-1 overflow-hidden">
|
<main className="relative z-0 isolate min-h-0 flex-1 overflow-hidden">
|
||||||
{children}
|
{children}
|
||||||
|
|
||||||
<Transition
|
<Transition
|
||||||
@ -73,7 +73,7 @@ export default function AppLayout({
|
|||||||
leave="transition-opacity duration-150 ease-in"
|
leave="transition-opacity duration-150 ease-in"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
className="absolute inset-0 z-30"
|
className="absolute inset-0 z-[9000]"
|
||||||
>
|
>
|
||||||
<SearchOverlay
|
<SearchOverlay
|
||||||
user={user}
|
user={user}
|
||||||
|
|||||||
@ -25,6 +25,7 @@ type BadgeProps = Omit<HTMLAttributes<HTMLSpanElement>, "color"> & {
|
|||||||
dot?: boolean;
|
dot?: boolean;
|
||||||
leadingIcon?: ReactNode;
|
leadingIcon?: ReactNode;
|
||||||
trailingIcon?: ReactNode;
|
trailingIcon?: ReactNode;
|
||||||
|
hover?: boolean;
|
||||||
onRemove?: () => void;
|
onRemove?: () => void;
|
||||||
removeLabel?: string;
|
removeLabel?: string;
|
||||||
};
|
};
|
||||||
@ -64,6 +65,37 @@ const badgeClasses: Record<BadgeVariant, Record<BadgeTone, string>> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const badgeHoverClasses: Record<BadgeVariant, Record<BadgeTone, string>> = {
|
||||||
|
border: {
|
||||||
|
gray: "hover:bg-gray-100 hover:text-gray-700 hover:inset-ring-gray-500/20 dark:hover:bg-gray-400/20 dark:hover:text-gray-300",
|
||||||
|
red: "hover:bg-red-100 hover:text-red-800 hover:inset-ring-red-600/20 dark:hover:bg-red-400/20 dark:hover:text-red-300",
|
||||||
|
yellow:
|
||||||
|
"hover:bg-yellow-100 hover:text-yellow-900 hover:inset-ring-yellow-600/30 dark:hover:bg-yellow-400/20 dark:hover:text-yellow-300",
|
||||||
|
green:
|
||||||
|
"hover:bg-green-100 hover:text-green-800 hover:inset-ring-green-600/30 dark:hover:bg-green-400/20 dark:hover:text-green-300",
|
||||||
|
blue: "hover:bg-blue-100 hover:text-blue-800 hover:inset-ring-blue-700/20 dark:hover:bg-blue-400/20 dark:hover:text-blue-300",
|
||||||
|
indigo:
|
||||||
|
"hover:bg-indigo-100 hover:text-indigo-800 hover:inset-ring-indigo-700/20 dark:hover:bg-indigo-400/20 dark:hover:text-indigo-300",
|
||||||
|
purple:
|
||||||
|
"hover:bg-purple-100 hover:text-purple-800 hover:inset-ring-purple-700/20 dark:hover:bg-purple-400/20 dark:hover:text-purple-300",
|
||||||
|
pink: "hover:bg-pink-100 hover:text-pink-800 hover:inset-ring-pink-700/20 dark:hover:bg-pink-400/20 dark:hover:text-pink-300",
|
||||||
|
},
|
||||||
|
flat: {
|
||||||
|
gray: "hover:bg-gray-200 hover:text-gray-700 dark:hover:bg-gray-400/20 dark:hover:text-gray-300",
|
||||||
|
red: "hover:bg-red-200 hover:text-red-800 dark:hover:bg-red-400/20 dark:hover:text-red-300",
|
||||||
|
yellow:
|
||||||
|
"hover:bg-yellow-200 hover:text-yellow-900 dark:hover:bg-yellow-400/20 dark:hover:text-yellow-300",
|
||||||
|
green:
|
||||||
|
"hover:bg-green-200 hover:text-green-800 dark:hover:bg-green-400/20 dark:hover:text-green-300",
|
||||||
|
blue: "hover:bg-blue-200 hover:text-blue-800 dark:hover:bg-blue-400/20 dark:hover:text-blue-300",
|
||||||
|
indigo:
|
||||||
|
"hover:bg-indigo-200 hover:text-indigo-800 dark:hover:bg-indigo-400/20 dark:hover:text-indigo-300",
|
||||||
|
purple:
|
||||||
|
"hover:bg-purple-200 hover:text-purple-800 dark:hover:bg-purple-400/20 dark:hover:text-purple-300",
|
||||||
|
pink: "hover:bg-pink-200 hover:text-pink-800 dark:hover:bg-pink-400/20 dark:hover:text-pink-300",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const dotClasses: Record<BadgeTone, string> = {
|
const dotClasses: Record<BadgeTone, string> = {
|
||||||
gray: "fill-gray-400",
|
gray: "fill-gray-400",
|
||||||
red: "fill-red-500 dark:fill-red-400",
|
red: "fill-red-500 dark:fill-red-400",
|
||||||
@ -132,6 +164,7 @@ export function Badge({
|
|||||||
dot = false,
|
dot = false,
|
||||||
leadingIcon,
|
leadingIcon,
|
||||||
trailingIcon,
|
trailingIcon,
|
||||||
|
hover = false,
|
||||||
onRemove,
|
onRemove,
|
||||||
removeLabel = "Remove",
|
removeLabel = "Remove",
|
||||||
className,
|
className,
|
||||||
@ -150,6 +183,8 @@ export function Badge({
|
|||||||
shape === "pill" ? "rounded-full" : "rounded-md",
|
shape === "pill" ? "rounded-full" : "rounded-md",
|
||||||
size === "small" ? "px-1.5 py-0.5" : "px-2 py-1",
|
size === "small" ? "px-1.5 py-0.5" : "px-2 py-1",
|
||||||
badgeClasses[variant][tone],
|
badgeClasses[variant][tone],
|
||||||
|
hover && "cursor-pointer transition-colors",
|
||||||
|
hover && badgeHoverClasses[variant][tone],
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@ -20,16 +20,40 @@ export type ButtonGroupProps = {
|
|||||||
size?: Size
|
size?: Size
|
||||||
className?: string
|
className?: string
|
||||||
ariaLabel?: string
|
ariaLabel?: string
|
||||||
|
iconOnlyOnMobile?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function cn(...parts: Array<string | false | null | undefined>) {
|
function cn(...parts: Array<string | false | null | undefined>) {
|
||||||
return parts.filter(Boolean).join(' ')
|
return parts.filter(Boolean).join(' ')
|
||||||
}
|
}
|
||||||
|
|
||||||
const sizeMap: Record<Size, { btn: string; icon: string; iconOnly: string }> = {
|
const sizeMap: Record<
|
||||||
sm: { btn: 'px-2.5 py-1.5 text-sm', icon: 'size-5', iconOnly: 'h-9 w-9' },
|
Size,
|
||||||
md: { btn: 'px-3 py-2 text-sm', icon: 'size-5', iconOnly: 'h-10 w-10' },
|
{
|
||||||
lg: { btn: 'px-3.5 py-2.5 text-sm', icon: 'size-5', iconOnly: 'h-11 w-11' },
|
btn: string
|
||||||
|
icon: string
|
||||||
|
iconOnly: string
|
||||||
|
mobileIconOnly: string
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
sm: {
|
||||||
|
btn: 'px-2.5 py-1.5 text-sm',
|
||||||
|
icon: 'size-5',
|
||||||
|
iconOnly: 'h-9 w-9',
|
||||||
|
mobileIconOnly: 'h-9 w-9 p-0 text-sm sm:h-auto sm:w-auto sm:px-2.5 sm:py-1.5',
|
||||||
|
},
|
||||||
|
md: {
|
||||||
|
btn: 'px-3 py-2 text-sm',
|
||||||
|
icon: 'size-5',
|
||||||
|
iconOnly: 'h-10 w-10',
|
||||||
|
mobileIconOnly: 'h-10 w-10 p-0 text-sm sm:h-auto sm:w-auto sm:px-3 sm:py-2',
|
||||||
|
},
|
||||||
|
lg: {
|
||||||
|
btn: 'px-3.5 py-2.5 text-sm',
|
||||||
|
icon: 'size-5',
|
||||||
|
iconOnly: 'h-11 w-11',
|
||||||
|
mobileIconOnly: 'h-11 w-11 p-0 text-sm sm:h-auto sm:w-auto sm:px-3.5 sm:py-2.5',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ButtonGroup({
|
export default function ButtonGroup({
|
||||||
@ -39,6 +63,7 @@ export default function ButtonGroup({
|
|||||||
size = 'md',
|
size = 'md',
|
||||||
className,
|
className,
|
||||||
ariaLabel = 'Optionen',
|
ariaLabel = 'Optionen',
|
||||||
|
iconOnlyOnMobile = false,
|
||||||
}: ButtonGroupProps) {
|
}: ButtonGroupProps) {
|
||||||
const s = sizeMap[size]
|
const s = sizeMap[size]
|
||||||
|
|
||||||
@ -56,6 +81,7 @@ export default function ButtonGroup({
|
|||||||
const isFirst = idx === 0
|
const isFirst = idx === 0
|
||||||
const isLast = idx === items.length - 1
|
const isLast = idx === items.length - 1
|
||||||
const iconOnly = !it.label && !!it.icon
|
const iconOnly = !it.label && !!it.icon
|
||||||
|
const mobileIconOnly = iconOnlyOnMobile && !!it.icon && !!it.label
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@ -87,7 +113,11 @@ export default function ButtonGroup({
|
|||||||
].join(' '),
|
].join(' '),
|
||||||
|
|
||||||
'disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:shadow-none',
|
'disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:shadow-none',
|
||||||
iconOnly ? `p-0 ${s.iconOnly}` : s.btn
|
iconOnly
|
||||||
|
? `p-0 ${s.iconOnly}`
|
||||||
|
: mobileIconOnly
|
||||||
|
? s.mobileIconOnly
|
||||||
|
: s.btn
|
||||||
)}
|
)}
|
||||||
title={typeof it.label === 'string' ? it.label : it.srLabel}
|
title={typeof it.label === 'string' ? it.label : it.srLabel}
|
||||||
>
|
>
|
||||||
@ -97,7 +127,7 @@ export default function ButtonGroup({
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'shrink-0 transition-colors duration-150',
|
'shrink-0 transition-colors duration-150',
|
||||||
iconOnly ? '' : '-ml-0.5',
|
iconOnly ? '' : mobileIconOnly ? 'sm:-ml-0.5' : '-ml-0.5',
|
||||||
active
|
active
|
||||||
? 'text-indigo-700 dark:text-indigo-200'
|
? 'text-indigo-700 dark:text-indigo-200'
|
||||||
: 'text-gray-500 dark:text-gray-500'
|
: 'text-gray-500 dark:text-gray-500'
|
||||||
@ -107,7 +137,21 @@ export default function ButtonGroup({
|
|||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{it.label ? <span className={it.icon ? 'ml-1.5' : ''}>{it.label}</span> : null}
|
{it.label ? (
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
it.icon
|
||||||
|
? mobileIconOnly
|
||||||
|
? 'sr-only sm:not-sr-only sm:ml-1.5'
|
||||||
|
: 'ml-1.5'
|
||||||
|
: mobileIconOnly
|
||||||
|
? 'sr-only sm:not-sr-only'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{it.label}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
// frontend\src\components\Camera.tsx
|
// frontend\src\components\Camera.tsx
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import { Dialog, DialogBackdrop, DialogPanel, DialogTitle } from '@headlessui/react'
|
import { Dialog, DialogBackdrop, DialogPanel, DialogTitle } from '@headlessui/react'
|
||||||
import { CameraIcon, XMarkIcon } from '@heroicons/react/24/outline'
|
import { CameraIcon, XMarkIcon } from '@heroicons/react/24/outline'
|
||||||
|
|
||||||
@ -153,88 +154,97 @@ export default function Camera({ onScan }: CameraProps) {
|
|||||||
<CameraIcon aria-hidden="true" className="size-6" />
|
<CameraIcon aria-hidden="true" className="size-6" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Dialog open={open} onClose={closeModal} className="relative z-50">
|
{typeof document !== 'undefined'
|
||||||
<DialogBackdrop
|
? createPortal(
|
||||||
transition
|
<Dialog
|
||||||
className="fixed inset-0 bg-gray-500/75 transition-opacity data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in dark:bg-gray-900/70"
|
open={open}
|
||||||
/>
|
onClose={closeModal}
|
||||||
|
className="fixed inset-0 z-[9999]"
|
||||||
<div className="fixed inset-0 z-50 w-screen overflow-y-auto">
|
|
||||||
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
|
||||||
<DialogPanel
|
|
||||||
transition
|
|
||||||
className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all data-closed:translate-y-4 data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in sm:my-8 sm:w-full sm:max-w-lg data-closed:sm:translate-y-0 data-closed:sm:scale-95 dark:bg-gray-800 dark:outline dark:-outline-offset-1 dark:outline-white/10"
|
|
||||||
>
|
>
|
||||||
<div className="absolute top-0 right-0 pt-4 pr-4">
|
<DialogBackdrop
|
||||||
<button
|
transition
|
||||||
type="button"
|
className="fixed inset-0 z-0 bg-gray-500/75 transition-opacity data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in dark:bg-gray-900/70"
|
||||||
onClick={closeModal}
|
/>
|
||||||
className="rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-2 focus:outline-offset-2 focus:outline-indigo-600 dark:bg-gray-800 dark:hover:text-gray-300 dark:focus:outline-white"
|
|
||||||
>
|
|
||||||
<span className="sr-only">Schließen</span>
|
|
||||||
<XMarkIcon aria-hidden="true" className="size-6" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="px-4 pt-5 pb-4 sm:p-6">
|
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
|
||||||
<DialogTitle as="h3" className="text-base font-semibold text-gray-900 dark:text-white">
|
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||||
QR-Code scannen
|
<DialogPanel
|
||||||
</DialogTitle>
|
transition
|
||||||
|
className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all data-closed:translate-y-4 data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in sm:my-8 sm:w-full sm:max-w-lg data-closed:sm:translate-y-0 data-closed:sm:scale-95 dark:bg-gray-800 dark:outline dark:-outline-offset-1 dark:outline-white/10"
|
||||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
>
|
||||||
Richte die Kamera auf einen QR-Code.
|
<div className="absolute top-0 right-0 pt-4 pr-4">
|
||||||
</p>
|
<button
|
||||||
|
type="button"
|
||||||
<div className="mt-5 overflow-hidden rounded-lg bg-gray-950">
|
onClick={closeModal}
|
||||||
{scannedValue ? (
|
className="rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-2 focus:outline-offset-2 focus:outline-indigo-600 dark:bg-gray-800 dark:hover:text-gray-300 dark:focus:outline-white"
|
||||||
<div className="p-4">
|
>
|
||||||
<p className="text-sm font-medium text-white">
|
<span className="sr-only">Schließen</span>
|
||||||
QR-Code erkannt:
|
<XMarkIcon aria-hidden="true" className="size-6" />
|
||||||
</p>
|
</button>
|
||||||
<p className="mt-2 break-all rounded-md bg-white/10 p-3 text-sm text-white">
|
|
||||||
{scannedValue}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="relative">
|
|
||||||
<video
|
|
||||||
ref={videoRef}
|
|
||||||
muted
|
|
||||||
playsInline
|
|
||||||
autoPlay
|
|
||||||
className="aspect-video w-full object-cover"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{(isStarting || error) && (
|
<div className="px-4 pt-5 pb-4 sm:p-6">
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50 p-4">
|
<DialogTitle as="h3" className="text-base font-semibold text-gray-900 dark:text-white">
|
||||||
<div
|
QR-Code scannen
|
||||||
className={[
|
</DialogTitle>
|
||||||
'max-w-sm rounded-md px-4 py-2 text-center text-sm font-medium text-white',
|
|
||||||
error ? 'bg-red-600/90' : 'bg-black/70',
|
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
].join(' ')}
|
Richte die Kamera auf einen QR-Code.
|
||||||
>
|
</p>
|
||||||
{error ?? 'Kamera wird gestartet...'}
|
|
||||||
|
<div className="mt-5 overflow-hidden rounded-lg bg-gray-950">
|
||||||
|
{scannedValue ? (
|
||||||
|
<div className="p-4">
|
||||||
|
<p className="text-sm font-medium text-white">
|
||||||
|
QR-Code erkannt:
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 break-all rounded-md bg-white/10 p-3 text-sm text-white">
|
||||||
|
{scannedValue}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
)}
|
<div className="relative">
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
autoPlay
|
||||||
|
className="aspect-video w-full object-cover"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(isStarting || error) && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/50 p-4">
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'max-w-sm rounded-md px-4 py-2 text-center text-sm font-medium text-white',
|
||||||
|
error ? 'bg-red-600/90' : 'bg-black/70',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{error ?? 'Kamera wird gestartet...'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
<div className="bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6 dark:bg-gray-700/25">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeModal}
|
||||||
|
className="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 sm:w-auto dark:bg-indigo-500 dark:shadow-none dark:hover:bg-indigo-400"
|
||||||
|
>
|
||||||
|
Schließen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</DialogPanel>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Dialog>,
|
||||||
<div className="bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6 dark:bg-gray-700/25">
|
document.body,
|
||||||
<button
|
)
|
||||||
type="button"
|
: null}
|
||||||
onClick={closeModal}
|
|
||||||
className="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 sm:w-auto dark:bg-indigo-500 dark:shadow-none dark:hover:bg-indigo-400"
|
|
||||||
>
|
|
||||||
Schließen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</DialogPanel>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -1,3 +1,5 @@
|
|||||||
|
// frontend\src\components\Combobox.tsx
|
||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
@ -9,7 +11,8 @@ import {
|
|||||||
ComboboxOptions,
|
ComboboxOptions,
|
||||||
Label,
|
Label,
|
||||||
} from '@headlessui/react'
|
} from '@headlessui/react'
|
||||||
import { ChevronDownIcon, UserIcon } from '@heroicons/react/20/solid'
|
import { ChevronDownIcon } from '@heroicons/react/20/solid'
|
||||||
|
import Avatar from './Avatar'
|
||||||
|
|
||||||
export type ComboboxVariant = 'simple' | 'status' | 'image' | 'secondary'
|
export type ComboboxVariant = 'simple' | 'status' | 'image' | 'secondary'
|
||||||
|
|
||||||
@ -109,20 +112,11 @@ export default function Combobox({
|
|||||||
if (variant === 'image') {
|
if (variant === 'image') {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{item.imageUrl ? (
|
<Avatar
|
||||||
<img
|
src={item.imageUrl}
|
||||||
src={item.imageUrl}
|
name={item.name}
|
||||||
alt=""
|
size={6}
|
||||||
className="size-6 shrink-0 rounded-full bg-gray-100 object-cover outline -outline-offset-1 outline-black/5 dark:bg-gray-700 dark:outline-white/10"
|
/>
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="grid size-6 shrink-0 place-items-center rounded-full bg-gray-300 in-data-focus:bg-white dark:bg-gray-600">
|
|
||||||
<UserIcon
|
|
||||||
aria-hidden="true"
|
|
||||||
className="size-4 fill-white in-data-focus:fill-indigo-600 dark:in-data-focus:fill-indigo-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<span className="ml-3 block truncate">{item.name}</span>
|
<span className="ml-3 block truncate">{item.name}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -166,11 +160,22 @@ export default function Combobox({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={classNames('relative', label && 'mt-2')}>
|
<div className={classNames('relative', label && 'mt-2')}>
|
||||||
|
{variant === 'image' && value && query.trim() === '' && (
|
||||||
|
<div className="pointer-events-none absolute inset-y-0 left-3 flex items-center">
|
||||||
|
<Avatar
|
||||||
|
src={value.imageUrl}
|
||||||
|
name={value.name}
|
||||||
|
size={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<ComboboxInput
|
<ComboboxInput
|
||||||
required={required}
|
required={required}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'block w-full rounded-md bg-white py-1.5 pr-12 pl-3 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:text-gray-500 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500 dark:disabled:bg-white/5 dark:disabled:text-white/40',
|
'block w-full rounded-md bg-white py-1.5 pr-12 pl-3 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:text-gray-500 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500 dark:disabled:bg-white/5 dark:disabled:text-white/40',
|
||||||
|
variant === 'image' && value && query.trim() === '' && 'pl-11',
|
||||||
inputClassName,
|
inputClassName,
|
||||||
)}
|
)}
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
|||||||
@ -77,6 +77,11 @@ type MapProps = {
|
|||||||
heatmapLayerName?: string
|
heatmapLayerName?: string
|
||||||
heatmapChecked?: boolean
|
heatmapChecked?: boolean
|
||||||
|
|
||||||
|
markerLayerName?: string
|
||||||
|
markersChecked?: boolean
|
||||||
|
childrenLayerName?: string
|
||||||
|
childrenChecked?: boolean
|
||||||
|
|
||||||
center?: [number, number]
|
center?: [number, number]
|
||||||
zoom?: number
|
zoom?: number
|
||||||
minZoom?: number
|
minZoom?: number
|
||||||
@ -142,7 +147,7 @@ const defaultWmsLayers: MapWmsLayer[] = [
|
|||||||
overlay: true,
|
overlay: true,
|
||||||
checked: true,
|
checked: true,
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
zIndex: 650,
|
zIndex: 2,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -200,6 +205,19 @@ const currentLocationIcon = L.divIcon({
|
|||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const leafletZIndexClassName = [
|
||||||
|
'[&_.leaflet-pane]:!z-0',
|
||||||
|
'[&_.leaflet-tile-pane]:!z-0',
|
||||||
|
'[&_.leaflet-overlay-pane]:!z-[1]',
|
||||||
|
'[&_.leaflet-shadow-pane]:!z-[2]',
|
||||||
|
'[&_.leaflet-marker-pane]:!z-[3]',
|
||||||
|
'[&_.leaflet-tooltip-pane]:!z-[4]',
|
||||||
|
'[&_.leaflet-popup-pane]:!z-[5]',
|
||||||
|
'[&_.leaflet-control-container]:!z-[6]',
|
||||||
|
'[&_.leaflet-top]:!z-[6]',
|
||||||
|
'[&_.leaflet-bottom]:!z-[6]',
|
||||||
|
].join(' ')
|
||||||
|
|
||||||
function classNames(...classes: Array<string | false | null | undefined>) {
|
function classNames(...classes: Array<string | false | null | undefined>) {
|
||||||
return classes.filter(Boolean).join(' ')
|
return classes.filter(Boolean).join(' ')
|
||||||
}
|
}
|
||||||
@ -275,28 +293,40 @@ function HeatmapLayer({
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function HeatmapLayerControlEvents({
|
function MapLayerControlEvents({
|
||||||
heatmapLayerName,
|
heatmapLayerName,
|
||||||
onVisibilityChange,
|
onHeatmapVisibilityChange,
|
||||||
|
markerLayerName,
|
||||||
|
onMarkerVisibilityChange,
|
||||||
|
childrenLayerName,
|
||||||
|
onChildrenVisibilityChange,
|
||||||
}: {
|
}: {
|
||||||
heatmapLayerName: string
|
heatmapLayerName: string
|
||||||
onVisibilityChange: (visible: boolean) => void
|
onHeatmapVisibilityChange: (visible: boolean) => void
|
||||||
|
markerLayerName?: string
|
||||||
|
onMarkerVisibilityChange?: (visible: boolean) => void
|
||||||
|
childrenLayerName?: string
|
||||||
|
onChildrenVisibilityChange?: (visible: boolean) => void
|
||||||
}) {
|
}) {
|
||||||
|
function handleOverlayChange(event: L.LeafletEvent, visible: boolean) {
|
||||||
|
const layerEvent = event as L.LeafletEvent & { name?: string }
|
||||||
|
|
||||||
|
if (layerEvent.name === heatmapLayerName) {
|
||||||
|
onHeatmapVisibilityChange(visible)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (markerLayerName && layerEvent.name === markerLayerName) {
|
||||||
|
onMarkerVisibilityChange?.(visible)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (childrenLayerName && layerEvent.name === childrenLayerName) {
|
||||||
|
onChildrenVisibilityChange?.(visible)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useMapEvents({
|
useMapEvents({
|
||||||
overlayadd: (event) => {
|
overlayadd: (event) => handleOverlayChange(event, true),
|
||||||
const layerEvent = event as L.LeafletEvent & { name?: string }
|
overlayremove: (event) => handleOverlayChange(event, false),
|
||||||
|
|
||||||
if (layerEvent.name === heatmapLayerName) {
|
|
||||||
onVisibilityChange(true)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
overlayremove: (event) => {
|
|
||||||
const layerEvent = event as L.LeafletEvent & { name?: string }
|
|
||||||
|
|
||||||
if (layerEvent.name === heatmapLayerName) {
|
|
||||||
onVisibilityChange(false)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return null
|
return null
|
||||||
@ -369,7 +399,7 @@ function LocateButton({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute top-20 left-3 z-[500]">
|
<div className="absolute top-20 left-3 z-[10]">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleLocate}
|
onClick={handleLocate}
|
||||||
@ -518,6 +548,11 @@ export default function LeafletMap({
|
|||||||
heatmapLayerName = 'Heatmap',
|
heatmapLayerName = 'Heatmap',
|
||||||
heatmapChecked = false,
|
heatmapChecked = false,
|
||||||
|
|
||||||
|
markerLayerName,
|
||||||
|
markersChecked = true,
|
||||||
|
childrenLayerName,
|
||||||
|
childrenChecked = true,
|
||||||
|
|
||||||
center = [51.1657, 10.4515],
|
center = [51.1657, 10.4515],
|
||||||
zoom = 6,
|
zoom = 6,
|
||||||
minZoom = 4,
|
minZoom = 4,
|
||||||
@ -542,6 +577,8 @@ export default function LeafletMap({
|
|||||||
|
|
||||||
const [currentLocation, setCurrentLocation] = useState<[number, number] | null>(null)
|
const [currentLocation, setCurrentLocation] = useState<[number, number] | null>(null)
|
||||||
const [isHeatmapLayerVisible, setIsHeatmapLayerVisible] = useState(heatmapChecked)
|
const [isHeatmapLayerVisible, setIsHeatmapLayerVisible] = useState(heatmapChecked)
|
||||||
|
const [isMarkerLayerVisible, setIsMarkerLayerVisible] = useState(markersChecked)
|
||||||
|
const [isChildrenLayerVisible, setIsChildrenLayerVisible] = useState(childrenChecked)
|
||||||
|
|
||||||
const fallbackTileLayer =
|
const fallbackTileLayer =
|
||||||
tileLayers.find((layer) => layer.checked) ?? tileLayers[0]
|
tileLayers.find((layer) => layer.checked) ?? tileLayers[0]
|
||||||
@ -559,6 +596,24 @@ export default function LeafletMap({
|
|||||||
setIsHeatmapLayerVisible(heatmapChecked)
|
setIsHeatmapLayerVisible(heatmapChecked)
|
||||||
}, [heatmapChecked])
|
}, [heatmapChecked])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsMarkerLayerVisible(markersChecked)
|
||||||
|
}, [markersChecked])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsChildrenLayerVisible(childrenChecked)
|
||||||
|
}, [childrenChecked])
|
||||||
|
|
||||||
|
const hasMarkerLayerControl = showLayerControl && Boolean(markerLayerName)
|
||||||
|
const hasChildrenLayerControl =
|
||||||
|
showLayerControl && Boolean(childrenLayerName) && children !== null && children !== undefined
|
||||||
|
|
||||||
|
const visibleMarkers =
|
||||||
|
hasMarkerLayerControl && !isMarkerLayerVisible ? [] : markers
|
||||||
|
|
||||||
|
const visibleChildren =
|
||||||
|
hasChildrenLayerControl && !isChildrenLayerVisible ? null : children
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames('relative z-0 h-full', minHeightClassName, className)}>
|
<div className={classNames('relative z-0 h-full', minHeightClassName, className)}>
|
||||||
<MapContainer
|
<MapContainer
|
||||||
@ -567,13 +622,21 @@ export default function LeafletMap({
|
|||||||
minZoom={minZoom}
|
minZoom={minZoom}
|
||||||
maxZoom={maxZoom}
|
maxZoom={maxZoom}
|
||||||
scrollWheelZoom={scrollWheelZoom}
|
scrollWheelZoom={scrollWheelZoom}
|
||||||
className={classNames('relative z-0 h-full w-full', mapClassName)}
|
className={classNames(
|
||||||
|
'relative z-0 h-full w-full',
|
||||||
|
leafletZIndexClassName,
|
||||||
|
mapClassName,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<InvalidateSizeOnResize />
|
<InvalidateSizeOnResize />
|
||||||
|
|
||||||
<HeatmapLayerControlEvents
|
<MapLayerControlEvents
|
||||||
heatmapLayerName={heatmapLayerName}
|
heatmapLayerName={heatmapLayerName}
|
||||||
onVisibilityChange={setIsHeatmapLayerVisible}
|
onHeatmapVisibilityChange={setIsHeatmapLayerVisible}
|
||||||
|
markerLayerName={markerLayerName}
|
||||||
|
onMarkerVisibilityChange={setIsMarkerLayerVisible}
|
||||||
|
childrenLayerName={childrenLayerName}
|
||||||
|
onChildrenVisibilityChange={setIsChildrenLayerVisible}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showLayerControl ? (
|
{showLayerControl ? (
|
||||||
@ -620,6 +683,24 @@ export default function LeafletMap({
|
|||||||
<LayerGroup />
|
<LayerGroup />
|
||||||
</LayersControl.Overlay>
|
</LayersControl.Overlay>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{markerLayerName && markers.length > 0 && (
|
||||||
|
<LayersControl.Overlay
|
||||||
|
name={markerLayerName}
|
||||||
|
checked={markersChecked}
|
||||||
|
>
|
||||||
|
<LayerGroup />
|
||||||
|
</LayersControl.Overlay>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{childrenLayerName && children !== null && children !== undefined && (
|
||||||
|
<LayersControl.Overlay
|
||||||
|
name={childrenLayerName}
|
||||||
|
checked={childrenChecked}
|
||||||
|
>
|
||||||
|
<LayerGroup />
|
||||||
|
</LayersControl.Overlay>
|
||||||
|
)}
|
||||||
</LayersControl>
|
</LayersControl>
|
||||||
) : fallbackTileLayer ? (
|
) : fallbackTileLayer ? (
|
||||||
renderTileLayer(fallbackTileLayer, maxZoom)
|
renderTileLayer(fallbackTileLayer, maxZoom)
|
||||||
@ -649,7 +730,7 @@ export default function LeafletMap({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{markers.map((marker) => (
|
{visibleMarkers.map((marker) => (
|
||||||
<AppMarker
|
<AppMarker
|
||||||
key={marker.id}
|
key={marker.id}
|
||||||
marker={marker}
|
marker={marker}
|
||||||
@ -668,7 +749,7 @@ export default function LeafletMap({
|
|||||||
</Marker>
|
</Marker>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{children}
|
{visibleChildren}
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -46,7 +46,7 @@ export default function Modal({
|
|||||||
onConfirm,
|
onConfirm,
|
||||||
children,
|
children,
|
||||||
panelClassName,
|
panelClassName,
|
||||||
zIndexClassName = 'z-10',
|
zIndexClassName = 'z-[9999]',
|
||||||
}: ModalProps) {
|
}: ModalProps) {
|
||||||
const isSuccess = variant === 'success-single' || variant === 'success-wide'
|
const isSuccess = variant === 'success-single' || variant === 'success-wide'
|
||||||
const isGrayFooter = variant === 'alert-gray-footer'
|
const isGrayFooter = variant === 'alert-gray-footer'
|
||||||
@ -75,10 +75,14 @@ export default function Modal({
|
|||||||
|
|
||||||
if (children) {
|
if (children) {
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={setOpen} className={classNames('relative', zIndexClassName)}>
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={setOpen}
|
||||||
|
className={classNames('fixed inset-0', zIndexClassName)}
|
||||||
|
>
|
||||||
<DialogBackdrop
|
<DialogBackdrop
|
||||||
transition
|
transition
|
||||||
className="fixed inset-0 bg-gray-500/75 transition-opacity data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in dark:bg-gray-900/50"
|
className="fixed inset-0 z-0 bg-gray-500/75 transition-opacity data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in dark:bg-gray-900/50"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="fixed inset-0 z-10 w-screen overflow-hidden">
|
<div className="fixed inset-0 z-10 w-screen overflow-hidden">
|
||||||
@ -100,10 +104,14 @@ export default function Modal({
|
|||||||
|
|
||||||
if (isGrayFooter) {
|
if (isGrayFooter) {
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={setOpen} className="relative z-10">
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={setOpen}
|
||||||
|
className={classNames('relative', zIndexClassName)}
|
||||||
|
>
|
||||||
<DialogBackdrop
|
<DialogBackdrop
|
||||||
transition
|
transition
|
||||||
className="fixed inset-0 bg-gray-500/75 transition-opacity data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in dark:bg-gray-900/50"
|
className="fixed inset-0 z-0 bg-gray-500/75 transition-opacity data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in dark:bg-gray-900/50"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
|
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
|
||||||
@ -158,13 +166,17 @@ export default function Modal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={setOpen} className="relative z-10">
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={setOpen}
|
||||||
|
className={classNames('relative', zIndexClassName)}
|
||||||
|
>
|
||||||
<DialogBackdrop
|
<DialogBackdrop
|
||||||
transition
|
transition
|
||||||
className="fixed inset-0 bg-gray-500/75 transition-opacity data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in dark:bg-gray-900/50"
|
className="fixed inset-0 z-0 bg-gray-500/75 transition-opacity data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in dark:bg-gray-900/50"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
|
<div className="fixed inset-0 z-10 w-screen overflow-hidden">
|
||||||
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||||
<DialogPanel
|
<DialogPanel
|
||||||
transition
|
transition
|
||||||
|
|||||||
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 = {
|
type NotificationCenterProps = {
|
||||||
notifications: Notification[]
|
notifications: Notification[]
|
||||||
unreadCount: number
|
unreadCount: number
|
||||||
onMarkRead: (notificationId: string) => void
|
onMarkRead: (notificationId: string) => void | Promise<void>
|
||||||
onMarkAllRead: () => void
|
onMarkAllRead: () => void | Promise<void>
|
||||||
|
onNotificationClick?: (notification: Notification) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type NotificationTab = 'unread' | 'all'
|
type NotificationTab = 'unread' | 'all'
|
||||||
@ -35,6 +36,7 @@ export default function NotificationCenter({
|
|||||||
unreadCount,
|
unreadCount,
|
||||||
onMarkRead,
|
onMarkRead,
|
||||||
onMarkAllRead,
|
onMarkAllRead,
|
||||||
|
onNotificationClick,
|
||||||
}: NotificationCenterProps) {
|
}: NotificationCenterProps) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [activeTab, setActiveTab] = useState<NotificationTab>('unread')
|
const [activeTab, setActiveTab] = useState<NotificationTab>('unread')
|
||||||
@ -107,6 +109,12 @@ export default function NotificationCenter({
|
|||||||
return 'Keine Benachrichtigungen vorhanden.'
|
return 'Keine Benachrichtigungen vorhanden.'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleNotificationClick(notification: Notification) {
|
||||||
|
void onMarkRead(notification.id)
|
||||||
|
onNotificationClick?.(notification)
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="relative">
|
<div ref={containerRef} className="relative">
|
||||||
<button
|
<button
|
||||||
@ -125,7 +133,7 @@ export default function NotificationCenter({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div className="absolute right-0 z-50 mt-2 w-96 overflow-hidden rounded-lg bg-white shadow-lg ring-1 ring-black/5 dark:bg-gray-900 dark:ring-white/10">
|
<div className="absolute right-0 z-[9600] mt-2 w-96 overflow-hidden rounded-lg bg-white shadow-lg ring-1 ring-black/5 dark:bg-gray-900 dark:ring-white/10">
|
||||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 border-b border-gray-200 dark:border-white/10">
|
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 border-b border-gray-200 dark:border-white/10">
|
||||||
<h2 className="min-w-0 truncate px-4 py-3 text-sm font-semibold text-gray-900 dark:text-white">
|
<h2 className="min-w-0 truncate px-4 py-3 text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
Benachrichtigungen
|
Benachrichtigungen
|
||||||
@ -183,7 +191,7 @@ export default function NotificationCenter({
|
|||||||
<button
|
<button
|
||||||
key={notification.id}
|
key={notification.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onMarkRead(notification.id)}
|
onClick={() => handleNotificationClick(notification)}
|
||||||
className="block w-full border-b border-gray-100 px-4 py-3 text-left hover:bg-gray-50 dark:border-white/10 dark:hover:bg-white/5"
|
className="block w-full border-b border-gray-100 px-4 py-3 text-left hover:bg-gray-50 dark:border-white/10 dark:hover:bg-white/5"
|
||||||
>
|
>
|
||||||
<div className="flex gap-x-3">
|
<div className="flex gap-x-3">
|
||||||
|
|||||||
@ -1,10 +1,18 @@
|
|||||||
// frontend/src/components/Notifications.tsx
|
// frontend/src/components/Notifications.tsx
|
||||||
|
|
||||||
import { useEffect } from 'react'
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import { Transition } from '@headlessui/react'
|
import { Transition } from '@headlessui/react'
|
||||||
import {
|
import {
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
ExclamationCircleIcon,
|
|
||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
InformationCircleIcon,
|
InformationCircleIcon,
|
||||||
XCircleIcon,
|
XCircleIcon,
|
||||||
@ -34,10 +42,38 @@ type NotificationsProps = {
|
|||||||
position?: 'top-right' | 'bottom-right'
|
position?: 'top-right' | 'bottom-right'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ShowToastInput = Omit<ToastNotification, 'id'>
|
||||||
|
|
||||||
|
type ShowErrorToastInput = {
|
||||||
|
title: string
|
||||||
|
error: unknown
|
||||||
|
fallback: string
|
||||||
|
autoClose?: boolean
|
||||||
|
duration?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type NotificationsContextValue = {
|
||||||
|
notifications: ToastNotification[]
|
||||||
|
showToast: (notification: ShowToastInput) => void
|
||||||
|
showErrorToast: (input: ShowErrorToastInput) => void
|
||||||
|
dismissToast: (id: string) => void
|
||||||
|
clearToasts: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const NotificationsContext = createContext<NotificationsContextValue | null>(null)
|
||||||
|
|
||||||
function classNames(...classes: Array<string | false | null | undefined>) {
|
function classNames(...classes: Array<string | false | null | undefined>) {
|
||||||
return classes.filter(Boolean).join(' ')
|
return classes.filter(Boolean).join(' ')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createNotificationId() {
|
||||||
|
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
||||||
|
return crypto.randomUUID()
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${Date.now()}-${Math.random()}`
|
||||||
|
}
|
||||||
|
|
||||||
function getVariantStyles(variant: NotificationVariant) {
|
function getVariantStyles(variant: NotificationVariant) {
|
||||||
switch (variant) {
|
switch (variant) {
|
||||||
case 'success':
|
case 'success':
|
||||||
@ -82,7 +118,7 @@ function NotificationToast({
|
|||||||
actionLabel,
|
actionLabel,
|
||||||
onAction,
|
onAction,
|
||||||
autoClose = true,
|
autoClose = true,
|
||||||
duration = 5000,
|
duration = 3000,
|
||||||
} = notification
|
} = notification
|
||||||
|
|
||||||
const { icon: Icon, iconClassName } = getVariantStyles(variant)
|
const { icon: Icon, iconClassName } = getVariantStyles(variant)
|
||||||
@ -154,18 +190,22 @@ function NotificationToast({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Notifications({
|
function NotificationsViewport({
|
||||||
notifications,
|
notifications,
|
||||||
onDismiss,
|
onDismiss,
|
||||||
position = 'top-right',
|
position = 'top-right',
|
||||||
}: NotificationsProps) {
|
}: NotificationsProps) {
|
||||||
return (
|
if (typeof document === 'undefined') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
aria-live="assertive"
|
aria-live="assertive"
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'pointer-events-none fixed inset-0 z-[99999] flex px-4 py-6 sm:p-6',
|
'pointer-events-none fixed inset-x-0 top-16 z-[99999] flex px-4 py-6 sm:p-6',
|
||||||
position === 'top-right'
|
position === 'top-right'
|
||||||
? 'items-end sm:items-start'
|
? 'items-start'
|
||||||
: 'items-end',
|
: 'items-end',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@ -178,6 +218,97 @@ export default function Notifications({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function NotificationProvider({
|
||||||
|
children,
|
||||||
|
position = 'top-right',
|
||||||
|
}: {
|
||||||
|
children: ReactNode
|
||||||
|
position?: 'top-right' | 'bottom-right'
|
||||||
|
}) {
|
||||||
|
const [notifications, setNotifications] = useState<ToastNotification[]>([])
|
||||||
|
|
||||||
|
const dismissToast = useCallback((id: string) => {
|
||||||
|
setNotifications((currentNotifications) =>
|
||||||
|
currentNotifications.filter((notification) => notification.id !== id),
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const clearToasts = useCallback(() => {
|
||||||
|
setNotifications([])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const showToast = useCallback((notification: ShowToastInput) => {
|
||||||
|
const id = createNotificationId()
|
||||||
|
|
||||||
|
setNotifications((currentNotifications) => [
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
autoClose: true,
|
||||||
|
duration: 3500,
|
||||||
|
...notification,
|
||||||
|
},
|
||||||
|
...currentNotifications,
|
||||||
|
])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const showErrorToast = useCallback((input: ShowErrorToastInput) => {
|
||||||
|
showToast({
|
||||||
|
variant: 'error',
|
||||||
|
title: input.title,
|
||||||
|
message:
|
||||||
|
input.error instanceof Error
|
||||||
|
? input.error.message
|
||||||
|
: input.fallback,
|
||||||
|
autoClose: input.autoClose ?? false,
|
||||||
|
duration: input.duration,
|
||||||
|
})
|
||||||
|
}, [showToast])
|
||||||
|
|
||||||
|
const value = useMemo<NotificationsContextValue>(
|
||||||
|
() => ({
|
||||||
|
notifications,
|
||||||
|
showToast,
|
||||||
|
showErrorToast,
|
||||||
|
dismissToast,
|
||||||
|
clearToasts,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
notifications,
|
||||||
|
showToast,
|
||||||
|
showErrorToast,
|
||||||
|
dismissToast,
|
||||||
|
clearToasts,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NotificationsContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
|
||||||
|
<NotificationsViewport
|
||||||
|
notifications={notifications}
|
||||||
|
onDismiss={dismissToast}
|
||||||
|
position={position}
|
||||||
|
/>
|
||||||
|
</NotificationsContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNotifications() {
|
||||||
|
const context = useContext(NotificationsContext)
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
'useNotifications muss innerhalb von NotificationProvider verwendet werden.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NotificationsViewport
|
||||||
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) {
|
}: SidebarProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog open={sidebarOpen} onClose={setSidebarOpen} className="relative z-50 lg:hidden">
|
<Dialog open={sidebarOpen} onClose={setSidebarOpen} className="relative z-[2000] lg:hidden">
|
||||||
<DialogBackdrop
|
<DialogBackdrop
|
||||||
transition
|
transition
|
||||||
className="fixed inset-0 bg-gray-900/80 transition-opacity duration-300 ease-linear data-closed:opacity-0"
|
className="fixed inset-0 bg-gray-900/80 transition-opacity duration-300 ease-linear data-closed:opacity-0"
|
||||||
@ -280,7 +280,7 @@ export default function Sidebar({
|
|||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<div className="hidden bg-gray-900 ring-1 ring-white/10 lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-72 lg:flex-col">
|
<div className="hidden bg-gray-900 ring-1 ring-white/10 lg:fixed lg:inset-y-0 lg:z-[2000] lg:flex lg:w-72 lg:flex-col">
|
||||||
<div className="flex grow flex-col gap-y-5 overflow-y-auto bg-black/10 px-6 pb-4">
|
<div className="flex grow flex-col gap-y-5 overflow-y-auto bg-black/10 px-6 pb-4">
|
||||||
<SidebarContent
|
<SidebarContent
|
||||||
user={user}
|
user={user}
|
||||||
|
|||||||
@ -76,7 +76,7 @@ function TaskListItemComplete({
|
|||||||
|
|
||||||
<TaskListItemWrapper step={step}>
|
<TaskListItemWrapper step={step}>
|
||||||
<span className="flex h-9 items-center">
|
<span className="flex h-9 items-center">
|
||||||
<span className="relative z-10 flex size-8 items-center justify-center rounded-full bg-indigo-600 group-hover:bg-indigo-800 dark:bg-indigo-500 dark:group-hover:bg-indigo-600">
|
<span className="relative z-10 flex size-8 items-center justify-center rounded-full bg-indigo-600 dark:bg-indigo-500">
|
||||||
<CheckIcon aria-hidden="true" className="size-5 text-white" />
|
<CheckIcon aria-hidden="true" className="size-5 text-white" />
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@ -108,10 +108,10 @@ function TaskListItemCurrent({
|
|||||||
|
|
||||||
<TaskListItemWrapper step={step} ariaCurrent="step">
|
<TaskListItemWrapper step={step} ariaCurrent="step">
|
||||||
<span className="flex h-9 items-center">
|
<span className="flex h-9 items-center">
|
||||||
<span className="relative z-10 flex size-8 items-center justify-center rounded-full border-2 border-indigo-600 bg-white dark:border-indigo-500 dark:bg-gray-900">
|
<span className="relative z-10 flex size-8 items-center justify-center rounded-full bg-indigo-600 dark:bg-indigo-500">
|
||||||
<LoadingSpinner
|
<LoadingSpinner
|
||||||
size="sm"
|
size={18}
|
||||||
className="text-indigo-600 dark:text-indigo-500"
|
className="text-white"
|
||||||
srLabel="Aktueller Schritt läuft"
|
srLabel="Aktueller Schritt läuft"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
@ -176,8 +176,8 @@ function TaskListItemUpcoming({
|
|||||||
|
|
||||||
<TaskListItemWrapper step={step}>
|
<TaskListItemWrapper step={step}>
|
||||||
<span aria-hidden="true" className="flex h-9 items-center">
|
<span aria-hidden="true" className="flex h-9 items-center">
|
||||||
<span className="relative z-10 flex size-8 items-center justify-center rounded-full border-2 border-gray-300 bg-white group-hover:border-gray-400 dark:border-white/15 dark:bg-gray-900 dark:group-hover:border-white/25">
|
<span className="relative z-10 flex size-8 items-center justify-center rounded-full border-2 border-gray-300 bg-white dark:border-white/15 dark:bg-gray-900">
|
||||||
<span className="size-2.5 rounded-full bg-transparent group-hover:bg-gray-300 dark:group-hover:bg-white/15" />
|
<span className="size-2.5 rounded-full bg-transparent" />
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@ -204,7 +204,7 @@ function TaskListItemWrapper({
|
|||||||
<a
|
<a
|
||||||
href={step.href}
|
href={step.href}
|
||||||
aria-current={ariaCurrent}
|
aria-current={ariaCurrent}
|
||||||
className="group relative flex items-start"
|
className="relative flex items-start"
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</a>
|
</a>
|
||||||
@ -214,7 +214,7 @@ function TaskListItemWrapper({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
aria-current={ariaCurrent}
|
aria-current={ariaCurrent}
|
||||||
className="group relative flex items-start"
|
className="relative flex items-start"
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import { ChevronDownIcon } from '@heroicons/react/20/solid'
|
|||||||
import { Link } from 'react-router'
|
import { Link } from 'react-router'
|
||||||
import Search from './Search'
|
import Search from './Search'
|
||||||
import Camera from './Camera'
|
import Camera from './Camera'
|
||||||
|
import Avatar from './Avatar'
|
||||||
import type { User } from './types'
|
import type { User } from './types'
|
||||||
|
|
||||||
type TopbarProps = {
|
type TopbarProps = {
|
||||||
@ -44,12 +45,8 @@ export default function Topbar({
|
|||||||
console.log('QR-Code erkannt:', value)
|
console.log('QR-Code erkannt:', value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const avatarUrl =
|
|
||||||
user.avatar ||
|
|
||||||
'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-gray-200 bg-white px-4 shadow-xs sm:gap-x-6 sm:px-6 lg:px-8 dark:border-white/10 dark:bg-gray-900">
|
<div className="sticky top-0 z-[9500] flex h-16 shrink-0 items-center gap-x-4 border-b border-gray-200 bg-white px-4 shadow-xs sm:gap-x-6 sm:px-6 lg:px-8 dark:border-white/10 dark:bg-gray-900">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onOpenSidebar}
|
onClick={onOpenSidebar}
|
||||||
@ -97,10 +94,10 @@ export default function Topbar({
|
|||||||
<span className="absolute -inset-1.5" />
|
<span className="absolute -inset-1.5" />
|
||||||
<span className="sr-only">Open user menu</span>
|
<span className="sr-only">Open user menu</span>
|
||||||
|
|
||||||
<img
|
<Avatar
|
||||||
alt=""
|
src={user.avatar}
|
||||||
src={avatarUrl}
|
name={user.displayName || user.username || user.email}
|
||||||
className="size-8 rounded-full bg-gray-50 object-cover outline -outline-offset-1 outline-black/5 dark:bg-gray-800 dark:outline-white/10"
|
size={8}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<span className="hidden lg:flex lg:items-center">
|
<span className="hidden lg:flex lg:items-center">
|
||||||
|
|||||||
@ -34,12 +34,13 @@ export type DeviceHistoryEntry = {
|
|||||||
deviceId: string
|
deviceId: string
|
||||||
userId: string
|
userId: string
|
||||||
userDisplayName: string
|
userDisplayName: string
|
||||||
action: DeviceHistoryAction
|
action: string
|
||||||
changes: DeviceHistoryChange[]
|
changes: DeviceHistoryChange[]
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
canEdit: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export type RelatedDevice = {
|
export type RelatedDevice = {
|
||||||
id: string
|
id: string
|
||||||
inventoryNumber: string
|
inventoryNumber: string
|
||||||
@ -136,8 +137,9 @@ export type Notification = {
|
|||||||
message: string
|
message: string
|
||||||
entityType: string
|
entityType: string
|
||||||
entityId: string
|
entityId: string
|
||||||
data: Record<string, unknown>
|
data: unknown
|
||||||
visible?: boolean
|
silent: boolean
|
||||||
|
soundName?: string
|
||||||
readAt?: string | null
|
readAt?: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
@ -8,9 +8,7 @@ import {
|
|||||||
} from '@heroicons/react/20/solid'
|
} from '@heroicons/react/20/solid'
|
||||||
import Button from '../../../components/Button'
|
import Button from '../../../components/Button'
|
||||||
import LoadingSpinner from '../../../components/LoadingSpinner'
|
import LoadingSpinner from '../../../components/LoadingSpinner'
|
||||||
import Notifications, {
|
import { useNotifications } from '../../../components/Notifications'
|
||||||
type ToastNotification,
|
|
||||||
} from '../../../components/Notifications'
|
|
||||||
import Switch from '../../../components/Switch'
|
import Switch from '../../../components/Switch'
|
||||||
import { DialogTitle } from '@headlessui/react'
|
import { DialogTitle } from '@headlessui/react'
|
||||||
import { XMarkIcon } from '@heroicons/react/24/outline'
|
import { XMarkIcon } from '@heroicons/react/24/outline'
|
||||||
@ -197,10 +195,10 @@ function markCurrentMilestoneSyncStepAsError(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Milestone() {
|
export default function Milestone() {
|
||||||
|
const { showToast, showErrorToast } = useNotifications()
|
||||||
const [settings, setSettings] = useState<MilestoneSettings | null>(null)
|
const [settings, setSettings] = useState<MilestoneSettings | null>(null)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [notifications, setNotifications] = useState<ToastNotification[]>([])
|
|
||||||
const [isFetchingToken, setIsFetchingToken] = useState(false)
|
const [isFetchingToken, setIsFetchingToken] = useState(false)
|
||||||
const [skipTlsVerify, setSkipTlsVerify] = useState(false)
|
const [skipTlsVerify, setSkipTlsVerify] = useState(false)
|
||||||
const [syncModalOpen, setSyncModalOpen] = useState(false)
|
const [syncModalOpen, setSyncModalOpen] = useState(false)
|
||||||
@ -212,24 +210,6 @@ export default function Milestone() {
|
|||||||
void loadSettings()
|
void loadSettings()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
function dismissNotification(id: string) {
|
|
||||||
setNotifications((currentNotifications) =>
|
|
||||||
currentNotifications.filter((notification) => notification.id !== id),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function showNotification(
|
|
||||||
notification: Omit<ToastNotification, 'id'>,
|
|
||||||
) {
|
|
||||||
setNotifications((currentNotifications) => [
|
|
||||||
{
|
|
||||||
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
||||||
...notification,
|
|
||||||
},
|
|
||||||
...currentNotifications,
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
function getHardwareCountMessage(count: number) {
|
function getHardwareCountMessage(count: number) {
|
||||||
if (count === 1) {
|
if (count === 1) {
|
||||||
return '1 Hardware-Gerät wurde von Milestone geladen und gespeichert.'
|
return '1 Hardware-Gerät wurde von Milestone geladen und gespeichert.'
|
||||||
@ -257,7 +237,7 @@ export default function Milestone() {
|
|||||||
if (event.type === 'token') {
|
if (event.type === 'token') {
|
||||||
const tokenExpiresAt = formatUpdatedAt(event.tokenExpiresAt)
|
const tokenExpiresAt = formatUpdatedAt(event.tokenExpiresAt)
|
||||||
|
|
||||||
showNotification({
|
showToast({
|
||||||
variant: 'success',
|
variant: 'success',
|
||||||
title: 'Token abgerufen',
|
title: 'Token abgerufen',
|
||||||
message: tokenExpiresAt
|
message: tokenExpiresAt
|
||||||
@ -269,7 +249,7 @@ export default function Milestone() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === 'recordingServer') {
|
if (event.type === 'recordingServer') {
|
||||||
showNotification({
|
showToast({
|
||||||
variant: 'success',
|
variant: 'success',
|
||||||
title: 'Recording-Infos abgerufen',
|
title: 'Recording-Infos abgerufen',
|
||||||
message: [
|
message: [
|
||||||
@ -289,7 +269,7 @@ export default function Milestone() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === 'hardware') {
|
if (event.type === 'hardware') {
|
||||||
showNotification({
|
showToast({
|
||||||
variant: 'success',
|
variant: 'success',
|
||||||
title: 'Hardware synchronisiert',
|
title: 'Hardware synchronisiert',
|
||||||
message: getHardwareCountMessage(event.hardwareCount ?? 0),
|
message: getHardwareCountMessage(event.hardwareCount ?? 0),
|
||||||
@ -393,14 +373,10 @@ export default function Milestone() {
|
|||||||
setSettings(data.settings)
|
setSettings(data.settings)
|
||||||
setSkipTlsVerify(Boolean(data.settings?.skipTlsVerify))
|
setSkipTlsVerify(Boolean(data.settings?.skipTlsVerify))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showNotification({
|
showErrorToast({
|
||||||
variant: 'error',
|
|
||||||
title: 'Einstellungen konnten nicht geladen werden',
|
title: 'Einstellungen konnten nicht geladen werden',
|
||||||
message:
|
error,
|
||||||
error instanceof Error
|
fallback: 'Milestone-Einstellungen konnten nicht geladen werden',
|
||||||
? error.message
|
|
||||||
: 'Milestone-Einstellungen konnten nicht geladen werden',
|
|
||||||
autoClose: false,
|
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
@ -449,14 +425,10 @@ export default function Milestone() {
|
|||||||
|
|
||||||
await runMilestoneSyncStream()
|
await runMilestoneSyncStream()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showNotification({
|
showErrorToast({
|
||||||
variant: 'error',
|
|
||||||
title: 'Speichern fehlgeschlagen',
|
title: 'Speichern fehlgeschlagen',
|
||||||
message:
|
error,
|
||||||
error instanceof Error
|
fallback: 'Milestone-Einstellungen konnten nicht gespeichert werden',
|
||||||
? error.message
|
|
||||||
: 'Milestone-Einstellungen konnten nicht gespeichert werden',
|
|
||||||
autoClose: false,
|
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false)
|
setIsSaving(false)
|
||||||
@ -469,14 +441,10 @@ export default function Milestone() {
|
|||||||
try {
|
try {
|
||||||
await runMilestoneSyncStream()
|
await runMilestoneSyncStream()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showNotification({
|
showErrorToast({
|
||||||
variant: 'error',
|
|
||||||
title: 'Abruf fehlgeschlagen',
|
title: 'Abruf fehlgeschlagen',
|
||||||
message:
|
error,
|
||||||
error instanceof Error
|
fallback: 'Milestone-Token konnte nicht abgerufen werden',
|
||||||
? error.message
|
|
||||||
: 'Milestone-Token konnte nicht abgerufen werden',
|
|
||||||
autoClose: false,
|
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setIsFetchingToken(false)
|
setIsFetchingToken(false)
|
||||||
@ -500,12 +468,6 @@ export default function Milestone() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Notifications
|
|
||||||
notifications={notifications}
|
|
||||||
onDismiss={dismissNotification}
|
|
||||||
position="top-right"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
open={syncModalOpen}
|
open={syncModalOpen}
|
||||||
setOpen={(open) => {
|
setOpen={(open) => {
|
||||||
@ -548,12 +510,6 @@ export default function Milestone() {
|
|||||||
steps={syncSteps}
|
steps={syncSteps}
|
||||||
ariaLabel="Milestone-Synchronisierung"
|
ariaLabel="Milestone-Synchronisierung"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{syncError && (
|
|
||||||
<div className="mt-5 rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
|
||||||
{syncError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end border-t border-gray-200 px-6 py-4 dark:border-white/10">
|
<div className="flex justify-end border-t border-gray-200 px-6 py-4 dark:border-white/10">
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
// frontend\src\pages\administration\teams\TeamsAdministration.tsx
|
|
||||||
|
|
||||||
// frontend/src/pages/administration/teams/TeamsAdministration.tsx
|
// frontend/src/pages/administration/teams/TeamsAdministration.tsx
|
||||||
|
|
||||||
import { useEffect, useState, type FormEvent } from 'react'
|
import { useEffect, useMemo, useState, type FormEvent } from 'react'
|
||||||
import { UserGroupIcon } from '@heroicons/react/20/solid'
|
import {
|
||||||
|
MagnifyingGlassIcon,
|
||||||
|
PlusIcon,
|
||||||
|
UserGroupIcon,
|
||||||
|
} from '@heroicons/react/20/solid'
|
||||||
import Button from '../../../components/Button'
|
import Button from '../../../components/Button'
|
||||||
import LoadingSpinner from '../../../components/LoadingSpinner'
|
import LoadingSpinner from '../../../components/LoadingSpinner'
|
||||||
|
|
||||||
@ -19,18 +21,44 @@ type AdminTeam = {
|
|||||||
const inputClassName =
|
const inputClassName =
|
||||||
'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
|
'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
|
||||||
|
|
||||||
|
function classNames(...classes: Array<string | false | null | undefined>) {
|
||||||
|
return classes.filter(Boolean).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
async function readApiError(response: Response, fallback: string) {
|
async function readApiError(response: Response, fallback: string) {
|
||||||
const data = await response.json().catch(() => null)
|
const data = await response.json().catch(() => null)
|
||||||
|
|
||||||
return data?.error ?? fallback
|
return data?.error ?? fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getTeamSearchText(team: AdminTeam) {
|
||||||
|
return [
|
||||||
|
team.name,
|
||||||
|
team.initial,
|
||||||
|
team.href,
|
||||||
|
]
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
export default function TeamsAdministration() {
|
export default function TeamsAdministration() {
|
||||||
const [teams, setTeams] = useState<AdminTeam[]>([])
|
const [teams, setTeams] = useState<AdminTeam[]>([])
|
||||||
|
const [selectedTeam, setSelectedTeam] = useState<AdminTeam | null>(null)
|
||||||
|
const [teamSearch, setTeamSearch] = useState('')
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const filteredTeams = useMemo(() => {
|
||||||
|
const search = teamSearch.trim().toLowerCase()
|
||||||
|
|
||||||
|
if (!search) {
|
||||||
|
return teams
|
||||||
|
}
|
||||||
|
|
||||||
|
return teams.filter((team) => getTeamSearchText(team).includes(search))
|
||||||
|
}, [teamSearch, teams])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadTeams()
|
void loadTeams()
|
||||||
}, [])
|
}, [])
|
||||||
@ -63,12 +91,25 @@ export default function TeamsAdministration() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleCreateNewTeam() {
|
||||||
|
setSelectedTeam(null)
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEditTeam(team: AdminTeam) {
|
||||||
|
setSelectedTeam(team)
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSaveTeam(event: FormEvent<HTMLFormElement>) {
|
async function handleSaveTeam(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
const form = event.currentTarget
|
const form = event.currentTarget
|
||||||
const formData = new FormData(form)
|
const formData = new FormData(form)
|
||||||
|
|
||||||
|
const teamId = selectedTeam?.id
|
||||||
|
const isEditing = Boolean(teamId)
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
name: String(formData.get('teamName') ?? ''),
|
name: String(formData.get('teamName') ?? ''),
|
||||||
initial: String(formData.get('teamInitial') ?? ''),
|
initial: String(formData.get('teamInitial') ?? ''),
|
||||||
@ -79,28 +120,41 @@ export default function TeamsAdministration() {
|
|||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_URL}/admin/teams`, {
|
const response = await fetch(
|
||||||
method: 'POST',
|
isEditing
|
||||||
headers: {
|
? `${API_URL}/admin/teams/${teamId}`
|
||||||
'Content-Type': 'application/json',
|
: `${API_URL}/admin/teams`,
|
||||||
|
{
|
||||||
|
method: isEditing ? 'PUT' : 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
},
|
},
|
||||||
credentials: 'include',
|
)
|
||||||
body: JSON.stringify(payload),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
await readApiError(response, 'Team konnte nicht erstellt werden'),
|
await readApiError(
|
||||||
|
response,
|
||||||
|
isEditing
|
||||||
|
? 'Team konnte nicht aktualisiert werden'
|
||||||
|
: 'Team konnte nicht erstellt werden',
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadTeams()
|
await loadTeams()
|
||||||
form.reset()
|
form.reset()
|
||||||
|
setSelectedTeam(null)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError(
|
setError(
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: 'Team konnte nicht erstellt werden',
|
: isEditing
|
||||||
|
? 'Team konnte nicht aktualisiert werden'
|
||||||
|
: 'Team konnte nicht erstellt werden',
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false)
|
setIsSaving(false)
|
||||||
@ -128,60 +182,116 @@ export default function TeamsAdministration() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1fr)_28rem]">
|
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[19rem_minmax(0,1fr)]">
|
||||||
<div className="rounded-lg bg-white p-4 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
|
<aside className="overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
|
||||||
<h2 className="flex items-center gap-x-2 text-sm font-semibold text-gray-900 dark:text-white">
|
<div className="border-b border-gray-200 px-4 py-4 dark:border-white/10">
|
||||||
<UserGroupIcon aria-hidden="true" className="size-5 text-gray-400" />
|
<div className="flex items-center justify-between gap-x-3">
|
||||||
Teams
|
<div className="min-w-0">
|
||||||
</h2>
|
<h2 className="flex items-center gap-x-2 text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
<UserGroupIcon aria-hidden="true" className="size-5 text-gray-400" />
|
||||||
|
Teams
|
||||||
|
</h2>
|
||||||
|
|
||||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
Übersicht der aktuell vorhandenen Teams.
|
{filteredTeams.length} von {teams.length} angezeigt
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 divide-y divide-gray-200 rounded-lg border border-gray-200 dark:divide-white/10 dark:border-white/10">
|
<Button
|
||||||
{teams.length === 0 ? (
|
type="button"
|
||||||
<div className="p-4 text-sm text-gray-500 dark:text-gray-400">
|
color="indigo"
|
||||||
Keine Teams vorhanden.
|
size="sm"
|
||||||
|
onClick={handleCreateNewTeam}
|
||||||
|
leadingIcon={<PlusIcon aria-hidden="true" className="size-4" />}
|
||||||
|
>
|
||||||
|
Neu
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative mt-4">
|
||||||
|
<MagnifyingGlassIcon
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-gray-400"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={teamSearch}
|
||||||
|
onChange={(event) => setTeamSearch(event.target.value)}
|
||||||
|
placeholder="Teams suchen..."
|
||||||
|
className="block w-full rounded-md bg-white py-1.5 pr-3 pl-9 text-sm text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-[calc(100dvh-20rem)] overflow-y-auto p-2">
|
||||||
|
{filteredTeams.length === 0 ? (
|
||||||
|
<div className="px-3 py-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Keine Teams gefunden.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
teams.map((team) => (
|
<div className="space-y-1">
|
||||||
<div
|
{filteredTeams.map((team) => {
|
||||||
key={team.id}
|
const isSelected = selectedTeam?.id === team.id
|
||||||
className="flex items-center justify-between gap-x-4 p-4"
|
|
||||||
>
|
|
||||||
<div className="flex min-w-0 items-center gap-x-3">
|
|
||||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-indigo-600 text-sm font-semibold text-white">
|
|
||||||
{team.initial}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div className="min-w-0">
|
return (
|
||||||
<p className="truncate text-sm font-medium text-gray-900 dark:text-white">
|
<button
|
||||||
{team.name}
|
key={team.id}
|
||||||
</p>
|
type="button"
|
||||||
|
onClick={() => handleEditTeam(team)}
|
||||||
|
className={classNames(
|
||||||
|
isSelected
|
||||||
|
? 'bg-indigo-50 text-indigo-700 ring-1 ring-indigo-200 ring-inset dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-500/20'
|
||||||
|
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-white/5',
|
||||||
|
'flex w-full gap-x-3 rounded-md px-3 py-2 text-left text-sm',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={classNames(
|
||||||
|
isSelected
|
||||||
|
? 'bg-indigo-600 text-white'
|
||||||
|
: 'bg-gray-100 text-gray-600 dark:bg-white/10 dark:text-gray-300',
|
||||||
|
'flex size-9 shrink-0 items-center justify-center rounded-full text-xs font-semibold',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{team.initial || team.name.slice(0, 2).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
|
||||||
<p className="truncate text-xs text-gray-500 dark:text-gray-400">
|
<span className="min-w-0 flex-1">
|
||||||
{team.href}
|
<span className="block truncate font-medium">
|
||||||
</p>
|
{team.name}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
|
||||||
</div>
|
<span className="block truncate text-xs text-gray-500 dark:text-gray-400">
|
||||||
))
|
{team.href || '#'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</aside>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
|
key={selectedTeam?.id ?? 'new'}
|
||||||
onSubmit={handleSaveTeam}
|
onSubmit={handleSaveTeam}
|
||||||
className="rounded-lg bg-white p-4 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10"
|
className="rounded-lg bg-white p-4 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10"
|
||||||
>
|
>
|
||||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
|
<div className="flex items-start justify-between gap-x-4">
|
||||||
Team anlegen
|
<div>
|
||||||
</h2>
|
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
{selectedTeam ? 'Team bearbeiten' : 'Team anlegen'}
|
||||||
|
</h2>
|
||||||
|
|
||||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
Erstelle ein neues Team, das anschließend Benutzern zugewiesen werden kann.
|
{selectedTeam
|
||||||
</p>
|
? 'Passe Name, Initial und Link des ausgewählten Teams an.'
|
||||||
|
: 'Erstelle ein neues Team, das anschließend Benutzern zugewiesen werden kann.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 space-y-4">
|
<div className="mt-4 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
@ -198,6 +308,7 @@ export default function TeamsAdministration() {
|
|||||||
name="teamName"
|
name="teamName"
|
||||||
placeholder="Teamname"
|
placeholder="Teamname"
|
||||||
required
|
required
|
||||||
|
defaultValue={selectedTeam?.name ?? ''}
|
||||||
className={inputClassName}
|
className={inputClassName}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -217,6 +328,7 @@ export default function TeamsAdministration() {
|
|||||||
name="teamInitial"
|
name="teamInitial"
|
||||||
placeholder="Initial"
|
placeholder="Initial"
|
||||||
maxLength={3}
|
maxLength={3}
|
||||||
|
defaultValue={selectedTeam?.initial ?? ''}
|
||||||
className={inputClassName}
|
className={inputClassName}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -235,21 +347,33 @@ export default function TeamsAdministration() {
|
|||||||
id="team-href"
|
id="team-href"
|
||||||
name="teamHref"
|
name="teamHref"
|
||||||
placeholder="Link, z. B. #"
|
placeholder="Link, z. B. #"
|
||||||
defaultValue="#"
|
defaultValue={selectedTeam?.href ?? '#'}
|
||||||
className={inputClassName}
|
className={inputClassName}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex justify-end">
|
<div className="mt-4 flex justify-end gap-x-3">
|
||||||
|
{selectedTeam && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
color="gray"
|
||||||
|
disabled={isSaving}
|
||||||
|
onClick={handleCreateNewTeam}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
color="indigo"
|
color="indigo"
|
||||||
isLoading={isSaving}
|
isLoading={isSaving}
|
||||||
>
|
>
|
||||||
Team erstellen
|
{selectedTeam ? 'Änderungen speichern' : 'Team erstellen'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -3,7 +3,12 @@
|
|||||||
import { useState, type FormEvent } from 'react'
|
import { useState, type FormEvent } from 'react'
|
||||||
import { DialogTitle } from '@headlessui/react'
|
import { DialogTitle } from '@headlessui/react'
|
||||||
import { XMarkIcon } from '@heroicons/react/24/outline'
|
import { XMarkIcon } from '@heroicons/react/24/outline'
|
||||||
import { CheckIcon } from '@heroicons/react/20/solid'
|
import {
|
||||||
|
CheckIcon,
|
||||||
|
IdentificationIcon,
|
||||||
|
MapPinIcon,
|
||||||
|
WrenchScrewdriverIcon,
|
||||||
|
} from '@heroicons/react/20/solid'
|
||||||
import Modal from '../../components/Modal'
|
import Modal from '../../components/Modal'
|
||||||
import Button from '../../components/Button'
|
import Button from '../../components/Button'
|
||||||
import type { Device } from '../../components/types'
|
import type { Device } from '../../components/types'
|
||||||
@ -21,10 +26,26 @@ type CreateDeviceModalProps = {
|
|||||||
onSubmit: (event: FormEvent<HTMLFormElement>) => void
|
onSubmit: (event: FormEvent<HTMLFormElement>) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const createDeviceTabs: Array<{ id: DeviceModalTabId; name: string }> = [
|
const createDeviceTabs: Array<{
|
||||||
{ id: 'masterData', name: 'Stammdaten' },
|
id: DeviceModalTabId
|
||||||
{ id: 'location', name: 'Standort' },
|
name: string
|
||||||
{ id: 'accessories', name: 'Zubehör' },
|
icon: typeof IdentificationIcon
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
id: 'masterData',
|
||||||
|
name: 'Stammdaten',
|
||||||
|
icon: IdentificationIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'location',
|
||||||
|
name: 'Standort',
|
||||||
|
icon: MapPinIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'accessories',
|
||||||
|
name: 'Zubehör',
|
||||||
|
icon: WrenchScrewdriverIcon,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function CreateDeviceModal({
|
export default function CreateDeviceModal({
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import Switch from '../../components/Switch'
|
|||||||
import type { DeviceModalTabId } from './DeviceModalTabs'
|
import type { DeviceModalTabId } from './DeviceModalTabs'
|
||||||
import Textarea from '../../components/Textarea'
|
import Textarea from '../../components/Textarea'
|
||||||
import type { Device } from '../../components/types'
|
import type { Device } from '../../components/types'
|
||||||
|
import { VideoCameraIcon } from '@heroicons/react/20/solid'
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||||
|
|
||||||
@ -236,6 +237,11 @@ export default function DeviceFormFields({
|
|||||||
const milestoneReadonlyClassName =
|
const milestoneReadonlyClassName =
|
||||||
'!cursor-not-allowed !bg-gray-50 !text-gray-500 !outline-gray-200 dark:!bg-white/10 dark:!text-gray-400 dark:!outline-white/10'
|
'!cursor-not-allowed !bg-gray-50 !text-gray-500 !outline-gray-200 dark:!bg-white/10 dark:!text-gray-400 dark:!outline-white/10'
|
||||||
|
|
||||||
|
const milestoneInputIconClassName =
|
||||||
|
'pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-indigo-500 dark:text-indigo-400'
|
||||||
|
|
||||||
|
const milestoneInputWithIconClassName = 'pl-9'
|
||||||
|
|
||||||
async function loadDeviceLookups() {
|
async function loadDeviceLookups() {
|
||||||
setLookupError(null)
|
setLookupError(null)
|
||||||
|
|
||||||
@ -378,25 +384,90 @@ export default function DeviceFormFields({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isMilestoneDevice && device?.milestoneDisplayName && (
|
{isMilestoneDevice && (
|
||||||
<div className="sm:col-span-6">
|
<>
|
||||||
<label
|
<input type="hidden" name="isMilestoneDevice" value="true" />
|
||||||
htmlFor="milestoneDisplayName"
|
|
||||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
|
||||||
>
|
|
||||||
Anzeigename
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="mt-2">
|
<div className="sm:col-span-6">
|
||||||
<input
|
<label
|
||||||
id="milestoneDisplayName"
|
htmlFor="milestoneDisplayName"
|
||||||
name="milestoneDisplayName"
|
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||||
type="text"
|
>
|
||||||
defaultValue={device.milestoneDisplayName}
|
Anzeigename
|
||||||
className={inputClassName}
|
</label>
|
||||||
/>
|
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="relative mt-2">
|
||||||
|
<VideoCameraIcon
|
||||||
|
aria-hidden="true"
|
||||||
|
className={milestoneInputIconClassName}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
id="milestoneDisplayName"
|
||||||
|
name="milestoneDisplayName"
|
||||||
|
type="text"
|
||||||
|
defaultValue={device?.milestoneDisplayName ?? ''}
|
||||||
|
className={classNames(inputClassName, milestoneInputWithIconClassName)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<div className="sm:col-span-3">
|
||||||
|
<label
|
||||||
|
htmlFor="milestoneHardwareId"
|
||||||
|
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
Milestone-Geräte-ID
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="relative mt-2">
|
||||||
|
<VideoCameraIcon
|
||||||
|
aria-hidden="true"
|
||||||
|
className={milestoneInputIconClassName}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
id="milestoneHardwareId"
|
||||||
|
type="text"
|
||||||
|
value={device?.milestoneHardwareId ?? ''}
|
||||||
|
readOnly
|
||||||
|
aria-readonly="true"
|
||||||
|
title="Die Milestone-Geräte-ID wird aus Milestone synchronisiert."
|
||||||
|
className={classNames(
|
||||||
|
inputClassName,
|
||||||
|
milestoneInputWithIconClassName,
|
||||||
|
milestoneReadonlyClassName,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:col-span-3">
|
||||||
|
<label
|
||||||
|
htmlFor="milestoneEnabled"
|
||||||
|
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
Milestone-Status
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="mt-2 flex min-h-[38px] items-center gap-3 rounded-md border border-gray-200 bg-white px-3 py-1.5 dark:border-white/10 dark:bg-white/5">
|
||||||
|
<VideoCameraIcon
|
||||||
|
aria-hidden="true"
|
||||||
|
className="size-4 shrink-0 text-indigo-500 dark:text-indigo-400"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
id="milestoneEnabled"
|
||||||
|
name="milestoneEnabled"
|
||||||
|
label="Aktiviert"
|
||||||
|
defaultChecked={device?.milestoneEnabled ?? true}
|
||||||
|
className="min-w-0 flex-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="sm:col-span-3">
|
<div className="sm:col-span-3">
|
||||||
@ -463,7 +534,14 @@ export default function DeviceFormFields({
|
|||||||
MAC-Adresse
|
MAC-Adresse
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="mt-2">
|
<div className="relative mt-2">
|
||||||
|
{isMilestoneDevice && (
|
||||||
|
<VideoCameraIcon
|
||||||
|
aria-hidden="true"
|
||||||
|
className={milestoneInputIconClassName}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<input
|
<input
|
||||||
id="macAddress"
|
id="macAddress"
|
||||||
name="macAddress"
|
name="macAddress"
|
||||||
@ -483,6 +561,7 @@ export default function DeviceFormFields({
|
|||||||
onInvalid={isMilestoneDevice ? undefined : handleMacAddressInvalid}
|
onInvalid={isMilestoneDevice ? undefined : handleMacAddressInvalid}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
inputClassName,
|
inputClassName,
|
||||||
|
isMilestoneDevice && milestoneInputWithIconClassName,
|
||||||
isMilestoneDevice && milestoneReadonlyClassName,
|
isMilestoneDevice && milestoneReadonlyClassName,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@ -497,18 +576,59 @@ export default function DeviceFormFields({
|
|||||||
IP-Adresse
|
IP-Adresse
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="mt-2">
|
<div className="relative mt-2">
|
||||||
|
{isMilestoneDevice && (
|
||||||
|
<VideoCameraIcon
|
||||||
|
aria-hidden="true"
|
||||||
|
className={milestoneInputIconClassName}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<input
|
<input
|
||||||
id="ipAddress"
|
id="ipAddress"
|
||||||
name="ipAddress"
|
name="ipAddress"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="192.168.1.100"
|
placeholder="192.168.1.100"
|
||||||
defaultValue={device?.ipAddress ?? ''}
|
defaultValue={device?.ipAddress ?? ''}
|
||||||
className={inputClassName}
|
className={classNames(
|
||||||
|
inputClassName,
|
||||||
|
isMilestoneDevice && milestoneInputWithIconClassName,
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isMilestoneDevice && (
|
||||||
|
<div className="sm:col-span-3">
|
||||||
|
<label
|
||||||
|
htmlFor="milestonePort"
|
||||||
|
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
Port
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="relative mt-2">
|
||||||
|
<VideoCameraIcon
|
||||||
|
aria-hidden="true"
|
||||||
|
className={milestoneInputIconClassName}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
id="milestonePort"
|
||||||
|
name="milestonePort"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder="4107"
|
||||||
|
defaultValue={device?.milestonePort ?? ''}
|
||||||
|
className={classNames(
|
||||||
|
inputClassName,
|
||||||
|
milestoneInputWithIconClassName,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="sm:col-span-3">
|
<div className="sm:col-span-3">
|
||||||
<label
|
<label
|
||||||
htmlFor="firmwareVersion"
|
htmlFor="firmwareVersion"
|
||||||
@ -517,7 +637,14 @@ export default function DeviceFormFields({
|
|||||||
Firmware
|
Firmware
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="mt-2">
|
<div className="relative mt-2">
|
||||||
|
{isMilestoneDevice && (
|
||||||
|
<VideoCameraIcon
|
||||||
|
aria-hidden="true"
|
||||||
|
className={milestoneInputIconClassName}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<input
|
<input
|
||||||
id="firmwareVersion"
|
id="firmwareVersion"
|
||||||
name="firmwareVersion"
|
name="firmwareVersion"
|
||||||
@ -528,6 +655,7 @@ export default function DeviceFormFields({
|
|||||||
title={isMilestoneDevice ? 'Firmware wird aus Milestone synchronisiert.' : undefined}
|
title={isMilestoneDevice ? 'Firmware wird aus Milestone synchronisiert.' : undefined}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
inputClassName,
|
inputClassName,
|
||||||
|
isMilestoneDevice && milestoneInputWithIconClassName,
|
||||||
isMilestoneDevice && milestoneReadonlyClassName,
|
isMilestoneDevice && milestoneReadonlyClassName,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@ -583,7 +711,7 @@ export default function DeviceFormFields({
|
|||||||
label="Kommentar"
|
label="Kommentar"
|
||||||
variant="simple"
|
variant="simple"
|
||||||
rows={3}
|
rows={3}
|
||||||
defaultValue={device?.comment ?? ''}
|
defaultValue={device?.comment ?? '-'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,10 +1,15 @@
|
|||||||
// frontend\src\pages\devices\DeviceModalTabs.tsx
|
// frontend\src\pages\devices\DeviceModalTabs.tsx
|
||||||
|
|
||||||
|
import { type ComponentType, type SVGProps } from 'react'
|
||||||
|
|
||||||
export type DeviceModalTabId = 'masterData' | 'location' | 'accessories' | 'history'
|
export type DeviceModalTabId = 'masterData' | 'location' | 'accessories' | 'history'
|
||||||
|
|
||||||
|
type DeviceModalTabIcon = ComponentType<SVGProps<SVGSVGElement>>
|
||||||
|
|
||||||
type DeviceModalTab = {
|
type DeviceModalTab = {
|
||||||
id: DeviceModalTabId
|
id: DeviceModalTabId
|
||||||
name: string
|
name: string
|
||||||
|
icon?: DeviceModalTabIcon
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -25,9 +30,13 @@ export default function DeviceModalTabs({
|
|||||||
}: DeviceModalTabsProps) {
|
}: DeviceModalTabsProps) {
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-gray-200 bg-white px-4 sm:px-6 dark:border-white/10 dark:bg-gray-900">
|
<div className="border-b border-gray-200 bg-white px-4 sm:px-6 dark:border-white/10 dark:bg-gray-900">
|
||||||
<nav aria-label="Geräteformular" className="-mb-px flex gap-x-6 overflow-x-auto">
|
<nav
|
||||||
|
aria-label="Geräteformular"
|
||||||
|
className="-mb-px flex gap-x-6 overflow-x-auto"
|
||||||
|
>
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
const isActive = activeTab === tab.id
|
const isActive = activeTab === tab.id
|
||||||
|
const Icon = tab.icon
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@ -36,14 +45,21 @@ export default function DeviceModalTabs({
|
|||||||
disabled={tab.disabled}
|
disabled={tab.disabled}
|
||||||
onClick={() => onChange(tab.id)}
|
onClick={() => onChange(tab.id)}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'whitespace-nowrap border-b-2 px-1 py-3 text-sm font-medium',
|
'inline-flex items-center gap-x-2 whitespace-nowrap border-b-2 px-1 py-3 text-sm font-medium',
|
||||||
isActive
|
isActive
|
||||||
? 'border-indigo-600 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400'
|
? 'border-indigo-600 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400'
|
||||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-white/20 dark:hover:text-gray-200',
|
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-white/20 dark:hover:text-gray-200',
|
||||||
tab.disabled && 'cursor-not-allowed opacity-50',
|
tab.disabled && 'cursor-not-allowed opacity-50',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{tab.name}
|
{Icon && (
|
||||||
|
<Icon
|
||||||
|
aria-hidden="true"
|
||||||
|
className="size-4 shrink-0"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span>{tab.name}</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// frontend/src/pages/DevicesPage.tsx
|
// frontend\src\pages\devices\DevicesPage.tsx
|
||||||
|
|
||||||
import { useEffect, useRef, useState, type ComponentProps, type FormEvent, type MouseEvent } from 'react'
|
import { useEffect, useRef, useState, type ComponentProps, type FormEvent, type MouseEvent } from 'react'
|
||||||
import {
|
import {
|
||||||
@ -7,6 +7,7 @@ import {
|
|||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
|
PlusIcon,
|
||||||
WrenchScrewdriverIcon,
|
WrenchScrewdriverIcon,
|
||||||
XCircleIcon,
|
XCircleIcon,
|
||||||
} from '@heroicons/react/20/solid'
|
} from '@heroicons/react/20/solid'
|
||||||
@ -17,7 +18,7 @@ import EditDeviceModal from './EditDeviceModal'
|
|||||||
import Tables, { type TableColumn } from '../../components/Tables'
|
import Tables, { type TableColumn } from '../../components/Tables'
|
||||||
import LoadingSpinner from '../../components/LoadingSpinner'
|
import LoadingSpinner from '../../components/LoadingSpinner'
|
||||||
import type { Device, DeviceHistoryEntry } from '../../components/types'
|
import type { Device, DeviceHistoryEntry } from '../../components/types'
|
||||||
import Notifications, { type ToastNotification } from '../../components/Notifications'
|
import { useNotifications } from '../../components/Notifications'
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||||
|
|
||||||
@ -59,10 +60,49 @@ function formatFirmwareCheckedAt(value?: string | null) {
|
|||||||
return 'Noch nicht geprüft'
|
return 'Noch nicht geprüft'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const date = new Date(value)
|
||||||
|
|
||||||
|
if (!Number.isFinite(date.getTime())) {
|
||||||
|
return 'Noch nicht geprüft'
|
||||||
|
}
|
||||||
|
|
||||||
return new Intl.DateTimeFormat('de-DE', {
|
return new Intl.DateTimeFormat('de-DE', {
|
||||||
dateStyle: 'short',
|
dateStyle: 'short',
|
||||||
timeStyle: 'short',
|
timeStyle: 'short',
|
||||||
}).format(new Date(value))
|
}).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFirmwareCheckRemaining(nextAllowedAt?: string | null, now = Date.now()) {
|
||||||
|
if (!nextAllowedAt) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextAllowedTimestamp = new Date(nextAllowedAt).getTime()
|
||||||
|
|
||||||
|
if (!Number.isFinite(nextAllowedTimestamp)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingSeconds = Math.ceil((nextAllowedTimestamp - now) / 1000)
|
||||||
|
|
||||||
|
if (remainingSeconds <= 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const minutes = Math.ceil(remainingSeconds / 60)
|
||||||
|
|
||||||
|
if (minutes < 60) {
|
||||||
|
return `in ${minutes} Min.`
|
||||||
|
}
|
||||||
|
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
const remainingMinutes = minutes % 60
|
||||||
|
|
||||||
|
if (remainingMinutes === 0) {
|
||||||
|
return `in ${hours} Std.`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `in ${hours} Std. ${remainingMinutes} Min.`
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLatestFirmwareCheckedAt(devices: Device[]) {
|
function getLatestFirmwareCheckedAt(devices: Device[]) {
|
||||||
@ -124,16 +164,15 @@ function getSupportBadgeTone(severity?: string): BadgeTone {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function DevicesPage() {
|
export default function DevicesPage() {
|
||||||
|
const { showToast, showErrorToast } = useNotifications()
|
||||||
const [devices, setDevices] = useState<Device[]>([])
|
const [devices, setDevices] = useState<Device[]>([])
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [activeModal, setActiveModal] = useState<'create' | 'edit' | null>(null)
|
const [activeModal, setActiveModal] = useState<'create' | 'edit' | null>(null)
|
||||||
const [editingDevice, setEditingDevice] = useState<Device | null>(null)
|
const [editingDevice, setEditingDevice] = useState<Device | null>(null)
|
||||||
const [isCreating, setIsCreating] = useState(false)
|
const [isCreating, setIsCreating] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [createError, setCreateError] = useState<string | null>(null)
|
const [createError, setCreateError] = useState<string | null>(null)
|
||||||
const [relatedDeviceIds, setRelatedDeviceIds] = useState<string[]>([])
|
const [relatedDeviceIds, setRelatedDeviceIds] = useState<string[]>([])
|
||||||
const [isJournalSubmitting, setIsJournalSubmitting] = useState(false)
|
const [isJournalSubmitting, setIsJournalSubmitting] = useState(false)
|
||||||
const [notifications, setNotifications] = useState<ToastNotification[]>([])
|
|
||||||
const [togglingMilestoneDeviceIds, setTogglingMilestoneDeviceIds] = useState<Set<string>>(
|
const [togglingMilestoneDeviceIds, setTogglingMilestoneDeviceIds] = useState<Set<string>>(
|
||||||
() => new Set(),
|
() => new Set(),
|
||||||
)
|
)
|
||||||
@ -215,7 +254,6 @@ export default function DevicesPage() {
|
|||||||
|
|
||||||
async function handleCheckFirmware() {
|
async function handleCheckFirmware() {
|
||||||
setIsCheckingFirmware(true)
|
setIsCheckingFirmware(true)
|
||||||
setError(null)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_URL}/devices/firmware-check`, {
|
const response = await fetch(`${API_URL}/devices/firmware-check`, {
|
||||||
@ -250,7 +288,7 @@ export default function DevicesPage() {
|
|||||||
await loadDevices({ silent: true })
|
await loadDevices({ silent: true })
|
||||||
await loadFirmwareCheckState()
|
await loadFirmwareCheckState()
|
||||||
|
|
||||||
showNotification({
|
showToast({
|
||||||
variant: data.failedModels > 0 ? 'warning' : 'success',
|
variant: data.failedModels > 0 ? 'warning' : 'success',
|
||||||
title: 'Firmware geprüft',
|
title: 'Firmware geprüft',
|
||||||
message:
|
message:
|
||||||
@ -259,14 +297,10 @@ export default function DevicesPage() {
|
|||||||
: `${data.checkedModels} Modelle wurden geprüft.`,
|
: `${data.checkedModels} Modelle wurden geprüft.`,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showNotification({
|
showErrorToast({
|
||||||
variant: 'error',
|
|
||||||
title: 'Firmware-Prüfung fehlgeschlagen',
|
title: 'Firmware-Prüfung fehlgeschlagen',
|
||||||
message:
|
error,
|
||||||
error instanceof Error
|
fallback: 'Firmware konnte nicht geprüft werden',
|
||||||
? error.message
|
|
||||||
: 'Firmware konnte nicht geprüft werden',
|
|
||||||
autoClose: false,
|
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setIsCheckingFirmware(false)
|
setIsCheckingFirmware(false)
|
||||||
@ -308,13 +342,52 @@ export default function DevicesPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleUpdateDeviceJournalEntry(
|
||||||
|
itemId: string | number,
|
||||||
|
content: string,
|
||||||
|
) {
|
||||||
|
if (!editingDevice) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsJournalSubmitting(true)
|
||||||
|
setHistoryError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_URL}/devices/${editingDevice.id}/history/${itemId}`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ content }),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => null)
|
||||||
|
throw new Error(errorData?.error ?? 'Journal-Eintrag konnte nicht gespeichert werden')
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadDeviceHistory(editingDevice.id)
|
||||||
|
} catch (error) {
|
||||||
|
setHistoryError(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Journal-Eintrag konnte nicht gespeichert werden',
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setIsJournalSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadDevices(options?: { silent?: boolean }) {
|
async function loadDevices(options?: { silent?: boolean }) {
|
||||||
if (!options?.silent) {
|
if (!options?.silent) {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
setError(null)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_URL}/devices`, {
|
const response = await fetch(`${API_URL}/devices`, {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
@ -328,7 +401,11 @@ export default function DevicesPage() {
|
|||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
setDevices(data.devices ?? [])
|
setDevices(data.devices ?? [])
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError(error instanceof Error ? error.message : 'Geräte konnten nicht geladen werden')
|
showErrorToast({
|
||||||
|
title: 'Geräte konnten nicht geladen werden',
|
||||||
|
error,
|
||||||
|
fallback: 'Geräte konnten nicht geladen werden',
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
if (!options?.silent) {
|
if (!options?.silent) {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
@ -426,22 +503,6 @@ export default function DevicesPage() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function dismissNotification(id: string) {
|
|
||||||
setNotifications((currentNotifications) =>
|
|
||||||
currentNotifications.filter((notification) => notification.id !== id),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function showNotification(notification: Omit<ToastNotification, 'id'>) {
|
|
||||||
setNotifications((currentNotifications) => [
|
|
||||||
{
|
|
||||||
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
||||||
...notification,
|
|
||||||
},
|
|
||||||
...currentNotifications,
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readApiError(response: Response, fallback: string) {
|
async function readApiError(response: Response, fallback: string) {
|
||||||
const data = await response.json().catch(() => null)
|
const data = await response.json().catch(() => null)
|
||||||
|
|
||||||
@ -465,9 +526,15 @@ export default function DevicesPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_URL}/devices/${device.id}/milestone-toggle`, {
|
const response = await fetch(`${API_URL}/devices/${device.id}/milestone`, {
|
||||||
method: 'POST',
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
enabled: !device.milestoneEnabled,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@ -502,7 +569,7 @@ export default function DevicesPage() {
|
|||||||
: currentDevice,
|
: currentDevice,
|
||||||
)
|
)
|
||||||
|
|
||||||
showNotification({
|
showToast({
|
||||||
variant: 'success',
|
variant: 'success',
|
||||||
title: 'Milestone-Status geändert',
|
title: 'Milestone-Status geändert',
|
||||||
message: `Gerät ${device.inventoryNumber} wurde in Milestone ${
|
message: `Gerät ${device.inventoryNumber} wurde in Milestone ${
|
||||||
@ -510,7 +577,7 @@ export default function DevicesPage() {
|
|||||||
}.`,
|
}.`,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showNotification({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Status konnte nicht geändert werden',
|
title: 'Status konnte nicht geändert werden',
|
||||||
message:
|
message:
|
||||||
@ -538,6 +605,8 @@ export default function DevicesPage() {
|
|||||||
setIsCreating(true)
|
setIsCreating(true)
|
||||||
setCreateError(null)
|
setCreateError(null)
|
||||||
|
|
||||||
|
const isMilestoneDevice = formData.get('isMilestoneDevice') === 'true'
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
inventoryNumber: String(formData.get('inventoryNumber') ?? ''),
|
inventoryNumber: String(formData.get('inventoryNumber') ?? ''),
|
||||||
manufacturer: String(formData.get('manufacturer') ?? ''),
|
manufacturer: String(formData.get('manufacturer') ?? ''),
|
||||||
@ -545,8 +614,12 @@ export default function DevicesPage() {
|
|||||||
serialNumber: String(formData.get('serialNumber') ?? ''),
|
serialNumber: String(formData.get('serialNumber') ?? ''),
|
||||||
macAddress: String(formData.get('macAddress') ?? ''),
|
macAddress: String(formData.get('macAddress') ?? ''),
|
||||||
ipAddress: String(formData.get('ipAddress') ?? ''),
|
ipAddress: String(formData.get('ipAddress') ?? ''),
|
||||||
|
milestonePort: String(formData.get('milestonePort') ?? ''),
|
||||||
firmwareVersion: String(formData.get('firmwareVersion') ?? ''),
|
firmwareVersion: String(formData.get('firmwareVersion') ?? ''),
|
||||||
milestoneDisplayName: String(formData.get('milestoneDisplayName') ?? ''),
|
milestoneDisplayName: String(formData.get('milestoneDisplayName') ?? ''),
|
||||||
|
...(isMilestoneDevice
|
||||||
|
? { milestoneEnabled: formData.get('milestoneEnabled') === 'on' }
|
||||||
|
: {}),
|
||||||
location: String(formData.get('location') ?? ''),
|
location: String(formData.get('location') ?? ''),
|
||||||
loanStatus: String(formData.get('loanStatus') ?? 'Verfügbar'),
|
loanStatus: String(formData.get('loanStatus') ?? 'Verfügbar'),
|
||||||
comment: String(formData.get('comment') ?? ''),
|
comment: String(formData.get('comment') ?? ''),
|
||||||
@ -555,6 +628,41 @@ export default function DevicesPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (deviceToEdit?.milestoneHardwareId && isMilestoneDevice) {
|
||||||
|
const milestoneResponse = await fetch(`${API_URL}/devices/${deviceToEdit.id}/milestone`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: String(formData.get('milestoneDisplayName') ?? ''),
|
||||||
|
address: String(formData.get('ipAddress') ?? ''),
|
||||||
|
port: String(formData.get('milestonePort') ?? ''),
|
||||||
|
enabled: formData.get('milestoneEnabled') === 'on',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!milestoneResponse.ok) {
|
||||||
|
const errorData = await milestoneResponse.json().catch(() => null)
|
||||||
|
const error = new Error(errorData?.error ?? 'Milestone-Gerät konnte nicht aktualisiert werden')
|
||||||
|
|
||||||
|
showErrorToast({
|
||||||
|
title: 'Milestone-Aktualisierung fehlgeschlagen',
|
||||||
|
error,
|
||||||
|
fallback: 'Milestone-Gerät konnte nicht aktualisiert werden',
|
||||||
|
})
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast({
|
||||||
|
variant: 'success',
|
||||||
|
title: 'Milestone aktualisiert',
|
||||||
|
message: `Gerät ${payload.inventoryNumber || deviceToEdit.inventoryNumber} wurde erfolgreich in Milestone aktualisiert.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
deviceToEdit
|
deviceToEdit
|
||||||
? `${API_URL}/devices/${deviceToEdit.id}`
|
? `${API_URL}/devices/${deviceToEdit.id}`
|
||||||
@ -606,6 +714,15 @@ export default function DevicesPage() {
|
|||||||
new Date(firmwareCheckNextAllowedAt as string).getTime() > firmwareCheckNow
|
new Date(firmwareCheckNextAllowedAt as string).getTime() > firmwareCheckNow
|
||||||
|
|
||||||
const firmwareCheckButtonDisabled = isCheckingFirmware || isFirmwareCheckLocked
|
const firmwareCheckButtonDisabled = isCheckingFirmware || isFirmwareCheckLocked
|
||||||
|
const firmwareCheckRemainingLabel = isFirmwareCheckLocked
|
||||||
|
? formatFirmwareCheckRemaining(firmwareCheckNextAllowedAt, firmwareCheckNow)
|
||||||
|
: null
|
||||||
|
const firmwareCheckNextAllowedLabel =
|
||||||
|
isFirmwareCheckLocked && firmwareCheckNextAllowedAt
|
||||||
|
? `Wieder möglich: ${formatFirmwareCheckedAt(firmwareCheckNextAllowedAt)}${
|
||||||
|
firmwareCheckRemainingLabel ? ` (${firmwareCheckRemainingLabel})` : ''
|
||||||
|
}`
|
||||||
|
: null
|
||||||
|
|
||||||
const columns: TableColumn<Device>[] = [
|
const columns: TableColumn<Device>[] = [
|
||||||
{
|
{
|
||||||
@ -755,19 +872,36 @@ export default function DevicesPage() {
|
|||||||
|
|
||||||
const isToggling = togglingMilestoneDeviceIds.has(device.id)
|
const isToggling = togglingMilestoneDeviceIds.has(device.id)
|
||||||
const label = device.milestoneEnabled ? 'Aktiviert' : 'Deaktiviert'
|
const label = device.milestoneEnabled ? 'Aktiviert' : 'Deaktiviert'
|
||||||
|
const milestoneIcon = isToggling ? (
|
||||||
|
<ClockIcon />
|
||||||
|
) : device.milestoneEnabled ? (
|
||||||
|
<CheckCircleIcon />
|
||||||
|
) : (
|
||||||
|
<XCircleIcon />
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isToggling}
|
disabled={isToggling}
|
||||||
onClick={(event) => handleToggleMilestoneDeviceStatus(device, event)}
|
onClick={(event) => handleToggleMilestoneDeviceStatus(device, event)}
|
||||||
className="inline-flex cursor-pointer rounded-md transition disabled:cursor-wait disabled:opacity-60"
|
className="inline-flex min-w-28 cursor-pointer justify-center rounded-md transition disabled:cursor-wait disabled:opacity-60"
|
||||||
title={`Milestone-Gerät ${
|
title={
|
||||||
device.milestoneEnabled ? 'deaktivieren' : 'aktivieren'
|
isToggling
|
||||||
}`}
|
? 'Milestone-Status wird geändert...'
|
||||||
|
: `Milestone-Gerät ${
|
||||||
|
device.milestoneEnabled ? 'deaktivieren' : 'aktivieren'
|
||||||
|
}`
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Badge tone={getLoanStatusBadgeTone(label)} variant="border">
|
<Badge
|
||||||
{isToggling ? 'Wird geändert...' : label}
|
tone={getLoanStatusBadgeTone(label)}
|
||||||
|
variant="border"
|
||||||
|
hover={!isToggling}
|
||||||
|
leadingIcon={milestoneIcon}
|
||||||
|
className="w-full justify-center whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
</Badge>
|
</Badge>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
@ -794,44 +928,43 @@ export default function DevicesPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full min-h-0 overflow-y-auto px-4 py-10 sm:px-6 lg:px-8 [scrollbar-gutter:stable]">
|
<div className="h-full min-h-0 overflow-y-auto px-4 py-10 sm:px-6 lg:px-8 [scrollbar-gutter:stable]">
|
||||||
<Notifications
|
|
||||||
notifications={notifications}
|
|
||||||
onDismiss={dismissNotification}
|
|
||||||
position="top-right"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="mb-6 rounded-md bg-red-50 p-4 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Tables
|
<Tables
|
||||||
title="Geräte"
|
title="Geräte"
|
||||||
description="Eine Liste aller Geräte inklusive Inventur-Nr., Hersteller, Modell, Seriennummer, MAC-Adresse, IP-Adresse, Firmware, Standort, Verleih-Status und Zubehör."
|
description="Eine Liste aller Geräte inklusive Inventur-Nr., Hersteller, Modell, Seriennummer, MAC-Adresse, IP-Adresse, Firmware, Standort, Verleih-Status und Zubehör."
|
||||||
headerAction={
|
headerAction={
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-col items-start gap-1.5">
|
||||||
<Button
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||||
type="button"
|
<Button
|
||||||
variant="secondary"
|
type="button"
|
||||||
color="gray"
|
variant="secondary"
|
||||||
size="sm"
|
color="gray"
|
||||||
disabled={firmwareCheckButtonDisabled}
|
size="md"
|
||||||
isLoading={isCheckingFirmware}
|
disabled={firmwareCheckButtonDisabled}
|
||||||
leadingIcon={<ArrowPathIcon aria-hidden="true" className="size-4" />}
|
isLoading={isCheckingFirmware}
|
||||||
onClick={() => void handleCheckFirmware()}
|
leadingIcon={<ArrowPathIcon aria-hidden="true" className="size-4" />}
|
||||||
>
|
onClick={() => void handleCheckFirmware()}
|
||||||
Firmware prüfen
|
title={firmwareCheckNextAllowedLabel ?? undefined}
|
||||||
</Button>
|
>
|
||||||
|
Firmware prüfen
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
color="indigo"
|
color="indigo"
|
||||||
size="md"
|
size="md"
|
||||||
onClick={openCreateModal}
|
leadingIcon={<PlusIcon aria-hidden="true" className="size-4" />}
|
||||||
>
|
onClick={openCreateModal}
|
||||||
Gerät hinzufügen
|
>
|
||||||
</Button>
|
Gerät hinzufügen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{firmwareCheckNextAllowedLabel && (
|
||||||
|
<div className="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 px-2.5 py-1 text-xs text-gray-600 shadow-sm dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||||||
|
<ClockIcon aria-hidden="true" className="size-3.5 shrink-0" />
|
||||||
|
<span>{firmwareCheckNextAllowedLabel}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
onAction={openCreateModal}
|
onAction={openCreateModal}
|
||||||
@ -895,6 +1028,7 @@ export default function DevicesPage() {
|
|||||||
historyError={historyError}
|
historyError={historyError}
|
||||||
isJournalSubmitting={isJournalSubmitting}
|
isJournalSubmitting={isJournalSubmitting}
|
||||||
onJournalEntrySubmit={handleCreateDeviceJournalEntry}
|
onJournalEntrySubmit={handleCreateDeviceJournalEntry}
|
||||||
|
onJournalEntryUpdate={handleUpdateDeviceJournalEntry}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,15 +1,17 @@
|
|||||||
// frontend\src\pages\devices\EditDeviceModal.tsx
|
// frontend\src\pages\devices\EditDeviceModal.tsx
|
||||||
|
|
||||||
// frontend/src/pages/devices/EditDeviceModal.tsx
|
|
||||||
|
|
||||||
import { useState, type ComponentProps, type FormEvent } from 'react'
|
import { useState, type ComponentProps, type FormEvent } from 'react'
|
||||||
import { DialogTitle } from '@headlessui/react'
|
import { DialogTitle } from '@headlessui/react'
|
||||||
import { XMarkIcon } from '@heroicons/react/24/outline'
|
import { XMarkIcon } from '@heroicons/react/24/outline'
|
||||||
import {
|
import {
|
||||||
ChatBubbleLeftEllipsisIcon,
|
ChatBubbleLeftEllipsisIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
|
IdentificationIcon,
|
||||||
|
MapPinIcon,
|
||||||
PencilSquareIcon,
|
PencilSquareIcon,
|
||||||
PlusCircleIcon,
|
PlusCircleIcon,
|
||||||
|
VideoCameraIcon,
|
||||||
|
WrenchScrewdriverIcon,
|
||||||
} from '@heroicons/react/20/solid'
|
} from '@heroicons/react/20/solid'
|
||||||
import Modal from '../../components/Modal'
|
import Modal from '../../components/Modal'
|
||||||
import Journal from '../../components/Journal'
|
import Journal from '../../components/Journal'
|
||||||
@ -41,15 +43,32 @@ type EditDeviceModalProps = {
|
|||||||
historyError: string | null
|
historyError: string | null
|
||||||
isJournalSubmitting?: boolean
|
isJournalSubmitting?: boolean
|
||||||
onJournalEntrySubmit?: (content: string) => void | Promise<void>
|
onJournalEntrySubmit?: (content: string) => void | Promise<void>
|
||||||
|
onJournalEntryUpdate?: (itemId: string | number, content: string) => void | Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
type JournalItems = ComponentProps<typeof Journal>['items']
|
type JournalItems = ComponentProps<typeof Journal>['items']
|
||||||
|
|
||||||
const editDeviceTabs: Array<{ id: DeviceModalTabId; name: string }> = [
|
const editDeviceTabs = [
|
||||||
{ id: 'masterData', name: 'Stammdaten' },
|
{
|
||||||
{ id: 'location', name: 'Standort' },
|
id: 'masterData' as const,
|
||||||
{ id: 'accessories', name: 'Zubehör' },
|
name: 'Stammdaten',
|
||||||
{ id: 'history', name: 'Journal' },
|
icon: IdentificationIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'location' as const,
|
||||||
|
name: 'Standort',
|
||||||
|
icon: MapPinIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'accessories' as const,
|
||||||
|
name: 'Zubehör',
|
||||||
|
icon: WrenchScrewdriverIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'history' as const,
|
||||||
|
name: 'Journal',
|
||||||
|
icon: ChatBubbleLeftEllipsisIcon,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
function formatHistoryDate(value: string) {
|
function formatHistoryDate(value: string) {
|
||||||
@ -111,7 +130,8 @@ export default function EditDeviceModal({
|
|||||||
isHistoryLoading,
|
isHistoryLoading,
|
||||||
historyError,
|
historyError,
|
||||||
isJournalSubmitting = false,
|
isJournalSubmitting = false,
|
||||||
onJournalEntrySubmit
|
onJournalEntrySubmit,
|
||||||
|
onJournalEntryUpdate,
|
||||||
}: EditDeviceModalProps) {
|
}: EditDeviceModalProps) {
|
||||||
const [activeTab, setActiveTab] = useState<DeviceModalTabId>('masterData')
|
const [activeTab, setActiveTab] = useState<DeviceModalTabId>('masterData')
|
||||||
|
|
||||||
@ -133,6 +153,15 @@ export default function EditDeviceModal({
|
|||||||
setActiveTab(getTabForInvalidField(target.name))
|
setActiveTab(getTabForInvalidField(target.name))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleFormSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
if (activeTab === 'history') {
|
||||||
|
event.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit(event)
|
||||||
|
}
|
||||||
|
|
||||||
const historyFeedItems: JournalItems = device
|
const historyFeedItems: JournalItems = device
|
||||||
? deviceHistory.map((entry) => {
|
? deviceHistory.map((entry) => {
|
||||||
const journalChange = entry.changes.find((change) => change.field === 'journal')
|
const journalChange = entry.changes.find((change) => change.field === 'journal')
|
||||||
@ -157,6 +186,15 @@ export default function EditDeviceModal({
|
|||||||
: 'hat das Gerät bearbeitet.',
|
: 'hat das Gerät bearbeitet.',
|
||||||
date: formatHistoryDate(entry.createdAt),
|
date: formatHistoryDate(entry.createdAt),
|
||||||
datetime: entry.createdAt,
|
datetime: entry.createdAt,
|
||||||
|
editedDate:
|
||||||
|
isJournalEntry && entry.updatedAt && entry.updatedAt !== entry.createdAt
|
||||||
|
? formatHistoryDate(entry.updatedAt)
|
||||||
|
: undefined,
|
||||||
|
editedDatetime:
|
||||||
|
isJournalEntry && entry.updatedAt && entry.updatedAt !== entry.createdAt
|
||||||
|
? entry.updatedAt
|
||||||
|
: undefined,
|
||||||
|
canEdit: isJournalEntry && entry.canEdit,
|
||||||
icon: isJournalEntry
|
icon: isJournalEntry
|
||||||
? ChatBubbleLeftEllipsisIcon
|
? ChatBubbleLeftEllipsisIcon
|
||||||
: entry.action === 'created'
|
: entry.action === 'created'
|
||||||
@ -180,7 +218,7 @@ export default function EditDeviceModal({
|
|||||||
>
|
>
|
||||||
<form
|
<form
|
||||||
key={device?.id ?? 'edit-device'}
|
key={device?.id ?? 'edit-device'}
|
||||||
onSubmit={onSubmit}
|
onSubmit={handleFormSubmit}
|
||||||
onInvalidCapture={handleInvalid}
|
onInvalidCapture={handleInvalid}
|
||||||
className="flex h-[calc(100dvh-2rem)] max-h-[calc(100dvh-2rem)] flex-col"
|
className="flex h-[calc(100dvh-2rem)] max-h-[calc(100dvh-2rem)] flex-col"
|
||||||
>
|
>
|
||||||
@ -200,9 +238,22 @@ export default function EditDeviceModal({
|
|||||||
)}
|
)}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|
||||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
<div className="mt-1 space-y-1">
|
||||||
Bearbeite Gerätedaten, Standort, Verleih-Status und Zubehör.
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
</p>
|
Bearbeite Gerätedaten, Standort, Verleih-Status und Zubehör.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{device?.milestoneHardwareId && (
|
||||||
|
<p className="flex items-center gap-1.5 text-xs text-indigo-600 dark:text-indigo-400">
|
||||||
|
<VideoCameraIcon aria-hidden="true" className="size-4 shrink-0" />
|
||||||
|
<span>
|
||||||
|
{device.milestoneSyncedAt
|
||||||
|
? `Zuletzt mit Milestone synchronisiert: ${formatHistoryDate(device.milestoneSyncedAt)}`
|
||||||
|
: 'Noch nicht mit Milestone synchronisiert.'}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@ -264,6 +315,7 @@ export default function EditDeviceModal({
|
|||||||
placeholder="Journal-Eintrag zum Gerät hinzufügen..."
|
placeholder="Journal-Eintrag zum Gerät hinzufügen..."
|
||||||
submitLabel="Eintrag hinzufügen"
|
submitLabel="Eintrag hinzufügen"
|
||||||
onEntrySubmit={onJournalEntrySubmit}
|
onEntrySubmit={onJournalEntrySubmit}
|
||||||
|
onEntryUpdate={onJournalEntryUpdate}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -288,17 +340,19 @@ export default function EditDeviceModal({
|
|||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
onClick={() => handleOpenChange(false)}
|
onClick={() => handleOpenChange(false)}
|
||||||
>
|
>
|
||||||
Abbrechen
|
{activeTab === 'history' ? 'Schließen' : 'Abbrechen'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
{activeTab !== 'history' && (
|
||||||
type="submit"
|
<Button
|
||||||
color="indigo"
|
type="submit"
|
||||||
isLoading={isSaving}
|
color="indigo"
|
||||||
leadingIcon={<CheckIcon aria-hidden="true" className="size-4" />}
|
isLoading={isSaving}
|
||||||
>
|
leadingIcon={<CheckIcon aria-hidden="true" className="size-4" />}
|
||||||
Änderungen speichern
|
>
|
||||||
</Button>
|
Änderungen speichern
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@ -1,19 +1,44 @@
|
|||||||
// frontend\src\pages\operations\CreateOperationModal.tsx
|
// frontend\src\pages\operations\CreateOperationModal.tsx
|
||||||
|
|
||||||
import { useEffect, useState, type FormEvent, type MouseEvent } from 'react'
|
import {
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
type ComponentType,
|
||||||
|
type FormEvent,
|
||||||
|
type MouseEvent,
|
||||||
|
type ReactNode,
|
||||||
|
type SVGProps,
|
||||||
|
} from 'react'
|
||||||
import { DialogTitle } from '@headlessui/react'
|
import { DialogTitle } from '@headlessui/react'
|
||||||
import { XMarkIcon } from '@heroicons/react/24/outline'
|
import { XMarkIcon } from '@heroicons/react/24/outline'
|
||||||
import {
|
import {
|
||||||
|
CheckCircleIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
ChevronLeftIcon,
|
ChevronLeftIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
|
ClipboardDocumentListIcon,
|
||||||
|
DocumentTextIcon,
|
||||||
|
MapPinIcon,
|
||||||
|
UserGroupIcon,
|
||||||
|
WrenchScrewdriverIcon,
|
||||||
} from '@heroicons/react/20/solid'
|
} from '@heroicons/react/20/solid'
|
||||||
|
import {
|
||||||
|
CircleMarker,
|
||||||
|
MapContainer,
|
||||||
|
TileLayer,
|
||||||
|
} from 'react-leaflet'
|
||||||
import Modal from '../../components/Modal'
|
import Modal from '../../components/Modal'
|
||||||
import Button from '../../components/Button'
|
import Button from '../../components/Button'
|
||||||
import AddressCombobox from '../../components/AddressCombobox'
|
import AddressCombobox from '../../components/AddressCombobox'
|
||||||
import Textarea from '../../components/Textarea'
|
import Textarea from '../../components/Textarea'
|
||||||
|
import Combobox, { type ComboboxItem } from '../../components/Combobox'
|
||||||
|
import MultiCombobox from '../../components/MultiCombobox'
|
||||||
|
import Avatar from '../../components/Avatar'
|
||||||
import { formatOperationNumberInput, isCompleteOperationNumber } from '../../components/formatter'
|
import { formatOperationNumberInput, isCompleteOperationNumber } from '../../components/formatter'
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||||
|
const OPERATION_UNIT = 'TEG'
|
||||||
|
|
||||||
type CreateOperationModalProps = {
|
type CreateOperationModalProps = {
|
||||||
open: boolean
|
open: boolean
|
||||||
setOpen: (open: boolean) => void
|
setOpen: (open: boolean) => void
|
||||||
@ -41,6 +66,41 @@ type OperationFormValues = {
|
|||||||
remark: string
|
remark: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OperationAssigneeUser = {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
displayName: string
|
||||||
|
email: string
|
||||||
|
avatar: string
|
||||||
|
unit: string
|
||||||
|
group: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type OperationAssigneesResponse = {
|
||||||
|
leaders?: OperationAssigneeUser[]
|
||||||
|
officers?: OperationAssigneeUser[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOperationUserName(user: OperationAssigneeUser) {
|
||||||
|
return user.displayName || user.username || user.email
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapOperationUserToComboboxItem(user: OperationAssigneeUser): ComboboxItem {
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
name: getOperationUserName(user),
|
||||||
|
secondaryText: user.email,
|
||||||
|
username: user.username,
|
||||||
|
imageUrl: user.avatar,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readApiError(response: Response, fallback: string) {
|
||||||
|
const data = await response.json().catch(() => null)
|
||||||
|
|
||||||
|
return data?.error ?? fallback
|
||||||
|
}
|
||||||
|
|
||||||
type SetupStepId =
|
type SetupStepId =
|
||||||
| 'masterData'
|
| 'masterData'
|
||||||
| 'addresses'
|
| 'addresses'
|
||||||
@ -49,6 +109,8 @@ type SetupStepId =
|
|||||||
| 'equipment'
|
| 'equipment'
|
||||||
| 'review'
|
| 'review'
|
||||||
|
|
||||||
|
type SetupStepIcon = ComponentType<SVGProps<SVGSVGElement>>
|
||||||
|
|
||||||
const inputClassName =
|
const inputClassName =
|
||||||
'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
|
'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
|
||||||
|
|
||||||
@ -75,42 +137,49 @@ const setupSteps: Array<{
|
|||||||
id: SetupStepId
|
id: SetupStepId
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
|
icon: SetupStepIcon
|
||||||
optional?: boolean
|
optional?: boolean
|
||||||
}> = [
|
}> = [
|
||||||
{
|
{
|
||||||
id: 'masterData',
|
id: 'masterData',
|
||||||
title: 'Stammdaten',
|
title: 'Stammdaten',
|
||||||
description: 'Einsatznummer, Name und Delikt.',
|
description: 'Einsatznummer, Name und Delikt.',
|
||||||
},
|
icon: ClipboardDocumentListIcon,
|
||||||
{
|
},
|
||||||
id: 'addresses',
|
{
|
||||||
title: 'Adressen',
|
id: 'addresses',
|
||||||
description: 'KW-Adresse und Zielobjekt mit Kartenvorschau.',
|
title: 'Adressen',
|
||||||
optional: true,
|
description: 'KW-Adresse und Zielobjekt mit Kartenvorschau.',
|
||||||
},
|
icon: MapPinIcon,
|
||||||
{
|
optional: true,
|
||||||
id: 'responsibilities',
|
},
|
||||||
title: 'Zuständigkeiten',
|
{
|
||||||
description: 'Sachbearbeitung, Einsatzleitung und Einsatzteam.',
|
id: 'responsibilities',
|
||||||
optional: true,
|
title: 'Zuständigkeiten',
|
||||||
},
|
description: 'Sachbearbeitung, Einsatzleitung und Einsatzteam.',
|
||||||
{
|
icon: UserGroupIcon,
|
||||||
id: 'notes',
|
optional: true,
|
||||||
title: 'Zusatzinfos',
|
},
|
||||||
description: 'Legende und Bemerkung.',
|
{
|
||||||
optional: true,
|
id: 'notes',
|
||||||
},
|
title: 'Zusatzinfos',
|
||||||
{
|
description: 'Legende und Bemerkung.',
|
||||||
id: 'equipment',
|
icon: DocumentTextIcon,
|
||||||
title: 'Technik',
|
optional: true,
|
||||||
description: 'Kamera, Router, Switchbox und Laptop.',
|
},
|
||||||
optional: true,
|
{
|
||||||
},
|
id: 'equipment',
|
||||||
{
|
title: 'Technik',
|
||||||
id: 'review',
|
description: 'Kamera, Router, Switchbox und Laptop.',
|
||||||
title: 'Prüfen',
|
icon: WrenchScrewdriverIcon,
|
||||||
description: 'Daten prüfen und Einsatz speichern.',
|
optional: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'review',
|
||||||
|
title: 'Prüfen',
|
||||||
|
description: 'Daten prüfen und Einsatz speichern.',
|
||||||
|
icon: CheckCircleIcon,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
function classNames(...classes: Array<string | false | null | undefined>) {
|
function classNames(...classes: Array<string | false | null | undefined>) {
|
||||||
@ -144,6 +213,378 @@ function ReviewItem({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseReviewTeamMembers(value: string) {
|
||||||
|
return value
|
||||||
|
.split(',')
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReviewPersonItem({
|
||||||
|
label,
|
||||||
|
item,
|
||||||
|
fallbackValue,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
item: ComboboxItem | null
|
||||||
|
fallbackValue: string
|
||||||
|
}) {
|
||||||
|
const displayName = item?.name ?? fallbackValue.trim()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<dt className="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
{label}
|
||||||
|
</dt>
|
||||||
|
|
||||||
|
<dd className="mt-1">
|
||||||
|
{displayName ? (
|
||||||
|
<div className="flex min-w-0 items-center gap-x-2">
|
||||||
|
<Avatar
|
||||||
|
src={item?.imageUrl}
|
||||||
|
name={displayName}
|
||||||
|
size={8}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p
|
||||||
|
title={displayName}
|
||||||
|
className="truncate text-sm font-medium text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
{displayName}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{item?.secondaryText && (
|
||||||
|
<p
|
||||||
|
title={item.secondaryText}
|
||||||
|
className="truncate text-xs text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{item.secondaryText}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-gray-400 dark:text-gray-500">—</span>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReviewTeamItem({
|
||||||
|
label,
|
||||||
|
items,
|
||||||
|
fallbackValue,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
items: ComboboxItem[]
|
||||||
|
fallbackValue: string
|
||||||
|
}) {
|
||||||
|
const fallbackNames = parseReviewTeamMembers(fallbackValue)
|
||||||
|
const fallbackItems: ComboboxItem[] = fallbackNames.map((name) => ({
|
||||||
|
id: `fallback:${name}`,
|
||||||
|
name,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const displayItems: ComboboxItem[] =
|
||||||
|
items.length > 0 ? items : fallbackItems
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<dt className="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
{label}
|
||||||
|
</dt>
|
||||||
|
|
||||||
|
<dd className="mt-1">
|
||||||
|
{displayItems.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{displayItems.map((item) => (
|
||||||
|
<span
|
||||||
|
key={item.id}
|
||||||
|
className="inline-flex max-w-full items-center gap-x-2 rounded-md bg-indigo-50 px-2 py-1 text-xs font-medium text-indigo-700 ring-1 ring-indigo-600/20 ring-inset dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20"
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
src={item.imageUrl}
|
||||||
|
name={item.name}
|
||||||
|
size={6}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span className="max-w-44 truncate">{item.name}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-gray-400 dark:text-gray-500">—</span>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReviewSection({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
icon: SetupStepIcon
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={classNames(
|
||||||
|
'overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xs dark:border-white/10 dark:bg-white/[0.03]',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="border-b border-gray-200 bg-gray-50 px-4 py-3 dark:border-white/10 dark:bg-white/[0.03]">
|
||||||
|
<div className="flex items-start gap-x-3">
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600 dark:bg-indigo-500/10 dark:text-indigo-300">
|
||||||
|
<Icon aria-hidden="true" className="size-5" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
{title}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{description && (
|
||||||
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReviewField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
title,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
title?: string
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'min-w-0 rounded-lg bg-gray-50 px-3 py-2 ring-1 ring-gray-200 ring-inset dark:bg-white/5 dark:ring-white/10',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<dt className="text-[11px] font-medium tracking-wide text-gray-500 uppercase dark:text-gray-400">
|
||||||
|
{label}
|
||||||
|
</dt>
|
||||||
|
|
||||||
|
<dd
|
||||||
|
title={title || value || undefined}
|
||||||
|
className="mt-1 truncate text-sm font-medium text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
{getDisplayValue(value)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReviewLongField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
}) {
|
||||||
|
const displayValue = value.trim()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-w-0 rounded-lg bg-gray-50 px-3 py-2 ring-1 ring-gray-200 ring-inset dark:bg-white/5 dark:ring-white/10">
|
||||||
|
<dt className="text-[11px] font-medium tracking-wide text-gray-500 uppercase dark:text-gray-400">
|
||||||
|
{label}
|
||||||
|
</dt>
|
||||||
|
|
||||||
|
<dd className="mt-1">
|
||||||
|
{displayValue ? (
|
||||||
|
<p
|
||||||
|
title={displayValue}
|
||||||
|
className="line-clamp-4 whitespace-pre-wrap text-sm text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
{displayValue}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-gray-400 dark:text-gray-500">—</span>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReviewMapCoordinates = {
|
||||||
|
lat: number
|
||||||
|
lon: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCoordinateNumber(value: unknown) {
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return Number.isFinite(value) ? value : NaN
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const parsed = Number(value)
|
||||||
|
return Number.isFinite(parsed) ? parsed : NaN
|
||||||
|
}
|
||||||
|
|
||||||
|
return NaN
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReviewAddressMap({ address }: { address: string }) {
|
||||||
|
const [coordinates, setCoordinates] = useState<ReviewMapCoordinates | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const trimmedAddress = address.trim()
|
||||||
|
|
||||||
|
if (trimmedAddress.length < 3) {
|
||||||
|
setCoordinates(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const abortController = new AbortController()
|
||||||
|
|
||||||
|
async function loadCoordinates() {
|
||||||
|
setIsLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_URL}/address-search?q=${encodeURIComponent(trimmedAddress)}`,
|
||||||
|
{
|
||||||
|
credentials: 'include',
|
||||||
|
signal: abortController.signal,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
setCoordinates(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
const firstSuggestion = data.suggestions?.[0]
|
||||||
|
|
||||||
|
if (!firstSuggestion) {
|
||||||
|
setCoordinates(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const lat = toCoordinateNumber(firstSuggestion.lat)
|
||||||
|
const lon = toCoordinateNumber(firstSuggestion.lon)
|
||||||
|
|
||||||
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
|
||||||
|
setCoordinates(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setCoordinates({
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setCoordinates(null)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadCoordinates()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
abortController.abort()
|
||||||
|
}
|
||||||
|
}, [address])
|
||||||
|
|
||||||
|
if (!address.trim()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="mt-2 flex h-28 items-center justify-center rounded-md border border-gray-200 bg-white text-xs text-gray-500 dark:border-white/10 dark:bg-white/5 dark:text-gray-400">
|
||||||
|
Karte wird geladen...
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!coordinates) {
|
||||||
|
return (
|
||||||
|
<div className="mt-2 flex h-28 items-center justify-center rounded-md border border-gray-200 bg-white text-xs text-gray-500 dark:border-white/10 dark:bg-white/5 dark:text-gray-400">
|
||||||
|
Keine Kartenposition gefunden.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2 overflow-hidden rounded-md border border-gray-200 bg-white dark:border-white/10 dark:bg-white/5">
|
||||||
|
<MapContainer
|
||||||
|
center={[coordinates.lat, coordinates.lon]}
|
||||||
|
zoom={16}
|
||||||
|
scrollWheelZoom={false}
|
||||||
|
dragging={false}
|
||||||
|
doubleClickZoom={false}
|
||||||
|
zoomControl={false}
|
||||||
|
attributionControl={false}
|
||||||
|
className="h-28 w-full"
|
||||||
|
>
|
||||||
|
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
||||||
|
|
||||||
|
<CircleMarker
|
||||||
|
center={[coordinates.lat, coordinates.lon]}
|
||||||
|
radius={7}
|
||||||
|
pathOptions={{
|
||||||
|
fillOpacity: 0.9,
|
||||||
|
weight: 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</MapContainer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReviewAddressItem({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<ReviewItem
|
||||||
|
label={label}
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ReviewAddressMap address={value} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function StepHeader({
|
function StepHeader({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@ -174,6 +615,12 @@ export default function CreateOperationModal({
|
|||||||
const [formValues, setFormValues] = useState<OperationFormValues>(initialFormValues)
|
const [formValues, setFormValues] = useState<OperationFormValues>(initialFormValues)
|
||||||
const [localError, setLocalError] = useState<string | null>(null)
|
const [localError, setLocalError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [operationLeaderOptions, setOperationLeaderOptions] = useState<ComboboxItem[]>([])
|
||||||
|
const [operationTeamOptions, setOperationTeamOptions] = useState<ComboboxItem[]>([])
|
||||||
|
const [selectedOperationLeader, setSelectedOperationLeader] = useState<ComboboxItem | null>(null)
|
||||||
|
const [selectedOperationTeam, setSelectedOperationTeam] = useState<ComboboxItem[]>([])
|
||||||
|
const [isLoadingOperationAssignees, setIsLoadingOperationAssignees] = useState(false)
|
||||||
|
|
||||||
const activeStepIndex = getStepIndex(activeStep)
|
const activeStepIndex = getStepIndex(activeStep)
|
||||||
const isFirstStep = activeStepIndex === 0
|
const isFirstStep = activeStepIndex === 0
|
||||||
const isLastStep = activeStepIndex === setupSteps.length - 1
|
const isLastStep = activeStepIndex === setupSteps.length - 1
|
||||||
@ -185,9 +632,80 @@ export default function CreateOperationModal({
|
|||||||
|
|
||||||
setActiveStep('masterData')
|
setActiveStep('masterData')
|
||||||
setFormValues(initialFormValues)
|
setFormValues(initialFormValues)
|
||||||
|
setSelectedOperationLeader(null)
|
||||||
|
setSelectedOperationTeam([])
|
||||||
setLocalError(null)
|
setLocalError(null)
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const abortController = new AbortController()
|
||||||
|
|
||||||
|
void loadOperationAssignees(abortController.signal)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
abortController.abort()
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
async function loadOperationAssignees(signal?: AbortSignal) {
|
||||||
|
setIsLoadingOperationAssignees(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_URL}/operations/assignees?unit=${encodeURIComponent(OPERATION_UNIT)}`,
|
||||||
|
{
|
||||||
|
credentials: 'include',
|
||||||
|
signal,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
await readApiError(response, 'Einsatzpersonen konnten nicht geladen werden'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as OperationAssigneesResponse
|
||||||
|
|
||||||
|
setOperationLeaderOptions(
|
||||||
|
(data.leaders ?? []).map(mapOperationUserToComboboxItem),
|
||||||
|
)
|
||||||
|
|
||||||
|
setOperationTeamOptions(
|
||||||
|
(data.officers ?? []).map(mapOperationUserToComboboxItem),
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLocalError(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Einsatzpersonen konnten nicht geladen werden',
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setIsLoadingOperationAssignees(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOperationLeaderChange(item: ComboboxItem | null) {
|
||||||
|
setSelectedOperationLeader(item)
|
||||||
|
updateField('operationLeader', item?.name ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOperationTeamChange(items: ComboboxItem[]) {
|
||||||
|
setSelectedOperationTeam(items)
|
||||||
|
updateField(
|
||||||
|
'operationTeam',
|
||||||
|
items.map((item) => item.name).join(', '),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function handleOpenChange(nextOpen: boolean) {
|
function handleOpenChange(nextOpen: boolean) {
|
||||||
if (!nextOpen && isSaving) {
|
if (!nextOpen && isSaving) {
|
||||||
return
|
return
|
||||||
@ -337,6 +855,7 @@ export default function CreateOperationModal({
|
|||||||
{setupSteps.map((step, index) => {
|
{setupSteps.map((step, index) => {
|
||||||
const isActive = step.id === activeStep
|
const isActive = step.id === activeStep
|
||||||
const isDone = index < activeStepIndex
|
const isDone = index < activeStepIndex
|
||||||
|
const Icon = step.icon
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@ -352,9 +871,11 @@ export default function CreateOperationModal({
|
|||||||
: 'border-gray-200 bg-white text-gray-600 hover:bg-gray-50 dark:border-white/10 dark:bg-white/5 dark:text-gray-300 dark:hover:bg-white/10',
|
: 'border-gray-200 bg-white text-gray-600 hover:bg-gray-50 dark:border-white/10 dark:bg-white/5 dark:text-gray-300 dark:hover:bg-white/10',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="block text-[11px] font-medium">
|
<span className="flex items-center gap-x-1.5 text-[11px] font-medium">
|
||||||
Schritt {index + 1}
|
<Icon aria-hidden="true" className="size-3.5 shrink-0" />
|
||||||
|
<span>Schritt {index + 1}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className="mt-0.5 block truncate text-sm font-semibold">
|
<span className="mt-0.5 block truncate text-sm font-semibold">
|
||||||
{step.title}
|
{step.title}
|
||||||
</span>
|
</span>
|
||||||
@ -438,7 +959,7 @@ export default function CreateOperationModal({
|
|||||||
description="KW und Zielobjekt werden mit AddressCombobox und Karten-Vorschau erfasst."
|
description="KW und Zielobjekt werden mit AddressCombobox und Karten-Vorschau erfasst."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
<div className="grid grid-cols-2 gap-4 xl:grid-cols-2">
|
||||||
<AddressCombobox
|
<AddressCombobox
|
||||||
id="createKwAddress"
|
id="createKwAddress"
|
||||||
name="createKwAddressDisplay"
|
name="createKwAddressDisplay"
|
||||||
@ -461,59 +982,96 @@ export default function CreateOperationModal({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeStep === 'responsibilities' && (
|
{activeStep === 'responsibilities' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<StepHeader
|
<StepHeader
|
||||||
title="Zuständigkeiten"
|
title="Zuständigkeiten"
|
||||||
description="Sachbearbeitung, Einsatzleitung und eingesetztes Team."
|
description="Lege fest, wer den Einsatz bearbeitet, leitet und im Einsatzteam mitwirkt."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/[0.03]">
|
||||||
|
<div className="mb-3">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
Sachbearbeitung
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Interne sachbearbeitende Person oder Dienststelle.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
id="createCaseWorker"
|
||||||
|
type="text"
|
||||||
|
value={formValues.caseWorker}
|
||||||
|
onChange={(event) => updateField('caseWorker', event.target.value)}
|
||||||
|
placeholder="Sachbearbeitung eintragen"
|
||||||
|
className={inputClassName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/[0.03]">
|
||||||
|
<div className="mb-3">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
Einsatzleiter
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Wähle einen Einsatzleiter der {OPERATION_UNIT}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Combobox
|
||||||
|
items={operationLeaderOptions}
|
||||||
|
value={selectedOperationLeader}
|
||||||
|
onChange={handleOperationLeaderChange}
|
||||||
|
variant="image"
|
||||||
|
placeholder={
|
||||||
|
isLoadingOperationAssignees
|
||||||
|
? 'Einsatzleiter werden geladen...'
|
||||||
|
: 'Einsatzleiter auswählen'
|
||||||
|
}
|
||||||
|
emptyText={`Keine Einsatzleiter der ${OPERATION_UNIT} gefunden.`}
|
||||||
|
disabled={isLoadingOperationAssignees}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-indigo-200 bg-indigo-50/60 p-4 dark:border-indigo-500/20 dark:bg-indigo-500/10">
|
||||||
|
<div className="mb-3 flex items-start justify-between gap-x-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold text-indigo-900 dark:text-indigo-200">
|
||||||
|
Einsatzteam
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<p className="mt-1 text-xs text-indigo-700/80 dark:text-indigo-300/80">
|
||||||
|
Wähle einen oder mehrere Einsatzbeamte der {OPERATION_UNIT}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedOperationTeam.length > 0 && (
|
||||||
|
<span className="shrink-0 rounded-full bg-white px-2.5 py-1 text-xs font-medium text-indigo-700 ring-1 ring-indigo-600/20 ring-inset dark:bg-indigo-500/10 dark:text-indigo-200 dark:ring-indigo-300/20">
|
||||||
|
{selectedOperationTeam.length} ausgewählt
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MultiCombobox
|
||||||
|
items={operationTeamOptions}
|
||||||
|
value={selectedOperationTeam}
|
||||||
|
onChange={handleOperationTeamChange}
|
||||||
|
placeholder={
|
||||||
|
isLoadingOperationAssignees
|
||||||
|
? 'Einsatzbeamte werden geladen...'
|
||||||
|
: 'Einsatzbeamte auswählen'
|
||||||
|
}
|
||||||
|
emptyText={`Keine Einsatzbeamten der ${OPERATION_UNIT} gefunden.`}
|
||||||
|
disabled={isLoadingOperationAssignees}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-6">
|
|
||||||
<div className="sm:col-span-3 xl:col-span-2">
|
|
||||||
<label htmlFor="createCaseWorker" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
|
||||||
Sachbearbeitung
|
|
||||||
</label>
|
|
||||||
<div className="mt-2">
|
|
||||||
<input
|
|
||||||
id="createCaseWorker"
|
|
||||||
type="text"
|
|
||||||
value={formValues.caseWorker}
|
|
||||||
onChange={(event) => updateField('caseWorker', event.target.value)}
|
|
||||||
className={inputClassName}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="sm:col-span-3 xl:col-span-2">
|
|
||||||
<label htmlFor="createOperationLeader" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
|
||||||
Einsatzleiter
|
|
||||||
</label>
|
|
||||||
<div className="mt-2">
|
|
||||||
<input
|
|
||||||
id="createOperationLeader"
|
|
||||||
type="text"
|
|
||||||
value={formValues.operationLeader}
|
|
||||||
onChange={(event) => updateField('operationLeader', event.target.value)}
|
|
||||||
className={inputClassName}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="sm:col-span-3 xl:col-span-2">
|
|
||||||
<label htmlFor="createOperationTeam" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
|
||||||
Einsatzteam
|
|
||||||
</label>
|
|
||||||
<div className="mt-2">
|
|
||||||
<input
|
|
||||||
id="createOperationTeam"
|
|
||||||
type="text"
|
|
||||||
value={formValues.operationTeam}
|
|
||||||
onChange={(event) => updateField('operationTeam', event.target.value)}
|
|
||||||
className={inputClassName}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeStep === 'notes' && (
|
{activeStep === 'notes' && (
|
||||||
@ -615,29 +1173,100 @@ export default function CreateOperationModal({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeStep === 'review' && (
|
{activeStep === 'review' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<StepHeader
|
<StepHeader
|
||||||
title="Einsatz prüfen"
|
title="Einsatz prüfen"
|
||||||
description="Kontrolliere die Angaben. Danach kannst du den Einsatz speichern."
|
description="Kontrolliere die Angaben. Danach kannst du den Einsatz speichern."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<dl className="grid grid-cols-1 gap-4 rounded-lg border border-gray-200 bg-gray-50 p-4 sm:grid-cols-2 lg:grid-cols-3 dark:border-white/10 dark:bg-white/5">
|
<dl className="space-y-6 rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/5">
|
||||||
<ReviewItem label="Einsatznummer" value={formValues.operationNumber} />
|
<div>
|
||||||
<ReviewItem label="Einsatzname" value={formValues.operationName} />
|
<div className="border-b border-gray-200 pb-2 dark:border-white/10">
|
||||||
<ReviewItem label="Delikt" value={formValues.offense} />
|
<h4 className="text-xs font-semibold tracking-wide text-gray-500 uppercase dark:text-gray-400">
|
||||||
<ReviewItem label="Sachbearbeitung" value={formValues.caseWorker} />
|
Stammdaten
|
||||||
<ReviewItem label="KW" value={formValues.kwAddress} />
|
</h4>
|
||||||
<ReviewItem label="Zielobjekt" value={formValues.targetObject} />
|
</div>
|
||||||
<ReviewItem label="Einsatzleiter" value={formValues.operationLeader} />
|
|
||||||
<ReviewItem label="Einsatzteam" value={formValues.operationTeam} />
|
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||||
<ReviewItem label="Kamera" value={formValues.camera} />
|
<ReviewItem label="Einsatznummer" value={formValues.operationNumber} />
|
||||||
<ReviewItem label="Router" value={formValues.router} />
|
<ReviewItem label="Einsatzname" value={formValues.operationName} />
|
||||||
<ReviewItem label="Switchbox" value={formValues.switchbox} />
|
<ReviewItem label="Delikt" value={formValues.offense} />
|
||||||
<ReviewItem label="Laptop" value={formValues.laptop} />
|
</div>
|
||||||
<ReviewItem label="Legende" value={formValues.legend} />
|
</div>
|
||||||
<ReviewItem label="Bemerkung" value={formValues.remark} />
|
|
||||||
|
<div>
|
||||||
|
<div className="border-b border-gray-200 pb-2 dark:border-white/10">
|
||||||
|
<h4 className="text-xs font-semibold tracking-wide text-gray-500 uppercase dark:text-gray-400">
|
||||||
|
Adressen
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
<ReviewAddressItem
|
||||||
|
label="KW"
|
||||||
|
value={formValues.kwAddress}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ReviewAddressItem
|
||||||
|
label="Zielobjekt"
|
||||||
|
value={formValues.targetObject}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="border-b border-gray-200 pb-2 dark:border-white/10">
|
||||||
|
<h4 className="text-xs font-semibold tracking-wide text-gray-500 uppercase dark:text-gray-400">
|
||||||
|
Zuständigkeiten
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||||
|
<ReviewItem label="Sachbearbeitung" value={formValues.caseWorker} />
|
||||||
|
|
||||||
|
<ReviewPersonItem
|
||||||
|
label="Einsatzleiter"
|
||||||
|
item={selectedOperationLeader}
|
||||||
|
fallbackValue={formValues.operationLeader}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ReviewTeamItem
|
||||||
|
label="Einsatzteam"
|
||||||
|
items={selectedOperationTeam}
|
||||||
|
fallbackValue={formValues.operationTeam}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="border-b border-gray-200 pb-2 dark:border-white/10">
|
||||||
|
<h4 className="text-xs font-semibold tracking-wide text-gray-500 uppercase dark:text-gray-400">
|
||||||
|
Technik
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<ReviewItem label="Kamera" value={formValues.camera} />
|
||||||
|
<ReviewItem label="Router" value={formValues.router} />
|
||||||
|
<ReviewItem label="Switchbox" value={formValues.switchbox} />
|
||||||
|
<ReviewItem label="Laptop" value={formValues.laptop} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="border-b border-gray-200 pb-2 dark:border-white/10">
|
||||||
|
<h4 className="text-xs font-semibold tracking-wide text-gray-500 uppercase dark:text-gray-400">
|
||||||
|
Zusatzinformationen
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
<ReviewItem label="Legende" value={formValues.legend} />
|
||||||
|
<ReviewItem label="Bemerkung" value={formValues.remark} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -561,7 +561,7 @@ export default function OperationMap({
|
|||||||
|
|
||||||
if (nextMarkers.length === 0) {
|
if (nextMarkers.length === 0) {
|
||||||
setGeocodingError(
|
setGeocodingError(
|
||||||
'Für KW-Adressen und Zielobjekte konnten keine Kartenpositionen gefunden werden.',
|
'Für die Einsatzadressen konnten keine Kartenpositionen gefunden werden.',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@ -630,15 +630,18 @@ export default function OperationMap({
|
|||||||
|
|
||||||
const viewCones = getOperationViewCones(markers)
|
const viewCones = getOperationViewCones(markers)
|
||||||
|
|
||||||
const heatPoints: MapHeatPoint[] = markers.map((marker) => ({
|
const heatPoints: MapHeatPoint[] = markers
|
||||||
id: marker.id,
|
.filter((marker) => marker.type === 'targetObject')
|
||||||
lat: marker.lat,
|
.map((marker) => ({
|
||||||
lon: marker.lon,
|
id: marker.id,
|
||||||
intensity: marker.type === 'kwAddress' ? 0.85 : 1,
|
lat: marker.lat,
|
||||||
|
lon: marker.lon,
|
||||||
|
intensity: 1,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const visibleMapMarkers = mode === 'markers' ? mapMarkers : []
|
const visibleMapMarkers = mode === 'markers' ? mapMarkers : []
|
||||||
const visibleViewCones = mode === 'markers' ? viewCones : []
|
const visibleViewCones = mode === 'markers' ? viewCones : []
|
||||||
|
const showOperationLayerControls = mode === 'markers'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -656,6 +659,10 @@ export default function OperationMap({
|
|||||||
<div className="relative h-full min-h-[34rem]">
|
<div className="relative h-full min-h-[34rem]">
|
||||||
<AppMap
|
<AppMap
|
||||||
markers={visibleMapMarkers}
|
markers={visibleMapMarkers}
|
||||||
|
markerLayerName={showOperationLayerControls ? 'Einsätze' : undefined}
|
||||||
|
markersChecked={showOperationLayerControls}
|
||||||
|
childrenLayerName={showOperationLayerControls ? 'Sichtwinkel' : undefined}
|
||||||
|
childrenChecked={showOperationLayerControls}
|
||||||
heatPoints={heatPoints}
|
heatPoints={heatPoints}
|
||||||
showHeatmap={heatPoints.length > 0}
|
showHeatmap={heatPoints.length > 0}
|
||||||
heatmapLayerName="Einsatz-Heatmap"
|
heatmapLayerName="Einsatz-Heatmap"
|
||||||
|
|||||||
@ -1,9 +1,26 @@
|
|||||||
// frontend/src/pages/OperationModal.tsx
|
// frontend/src/pages/OperationModal.tsx
|
||||||
|
|
||||||
import { useEffect, useMemo, useState, type FormEvent } from 'react'
|
import {
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ComponentType,
|
||||||
|
type FormEvent,
|
||||||
|
type SVGProps,
|
||||||
|
} from 'react'
|
||||||
import { DialogTitle } from '@headlessui/react'
|
import { DialogTitle } from '@headlessui/react'
|
||||||
import { XMarkIcon } from '@heroicons/react/24/outline'
|
import { XMarkIcon } from '@heroicons/react/24/outline'
|
||||||
import { ChatBubbleLeftEllipsisIcon, CheckIcon, PencilSquareIcon, PlusCircleIcon } from '@heroicons/react/20/solid'
|
import {
|
||||||
|
ChatBubbleLeftEllipsisIcon,
|
||||||
|
CheckIcon,
|
||||||
|
ClipboardDocumentListIcon,
|
||||||
|
DocumentTextIcon,
|
||||||
|
MapPinIcon,
|
||||||
|
PencilSquareIcon,
|
||||||
|
PlusCircleIcon,
|
||||||
|
UserGroupIcon,
|
||||||
|
WrenchScrewdriverIcon,
|
||||||
|
} from '@heroicons/react/20/solid'
|
||||||
import Modal from '../../components/Modal'
|
import Modal from '../../components/Modal'
|
||||||
import Journal, { type JournalItem } from '../../components/Journal'
|
import Journal, { type JournalItem } from '../../components/Journal'
|
||||||
import Button from '../../components/Button'
|
import Button from '../../components/Button'
|
||||||
@ -12,6 +29,8 @@ import AddressCombobox from '../../components/AddressCombobox'
|
|||||||
import type { Operation, OperationHistoryChange, OperationHistoryEntry } from '../../components/types'
|
import type { Operation, OperationHistoryChange, OperationHistoryEntry } from '../../components/types'
|
||||||
import { formatOperationNumberInput } from '../../components/formatter'
|
import { formatOperationNumberInput } from '../../components/formatter'
|
||||||
import NotificationDot from '../../components/NotificationDot'
|
import NotificationDot from '../../components/NotificationDot'
|
||||||
|
import Combobox, { type ComboboxItem } from '../../components/Combobox'
|
||||||
|
import MultiCombobox from '../../components/MultiCombobox'
|
||||||
|
|
||||||
type OperationModalProps = {
|
type OperationModalProps = {
|
||||||
open: boolean
|
open: boolean
|
||||||
@ -38,46 +57,136 @@ type OperationTabId =
|
|||||||
| 'equipment'
|
| 'equipment'
|
||||||
| 'journal'
|
| 'journal'
|
||||||
|
|
||||||
|
type OperationTabIcon = ComponentType<SVGProps<SVGSVGElement>>
|
||||||
|
|
||||||
const inputClassName =
|
const inputClassName =
|
||||||
'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
|
'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
|
||||||
|
|
||||||
const sectionClassName =
|
const sectionClassName =
|
||||||
'rounded-lg border border-gray-200 bg-white p-4 shadow-xs dark:border-white/10 dark:bg-white/5'
|
'rounded-lg border border-gray-200 bg-white p-4 shadow-xs dark:border-white/10 dark:bg-white/5'
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||||
|
const OPERATION_UNIT = 'TEG'
|
||||||
|
|
||||||
|
type OperationAssigneeUser = {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
displayName: string
|
||||||
|
email: string
|
||||||
|
avatar: string
|
||||||
|
unit: string
|
||||||
|
group: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type OperationAssigneesResponse = {
|
||||||
|
leaders?: OperationAssigneeUser[]
|
||||||
|
officers?: OperationAssigneeUser[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOperationAssigneeName(user: OperationAssigneeUser) {
|
||||||
|
return user.displayName || user.username || user.email
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapOperationAssigneeToComboboxItem(
|
||||||
|
user: OperationAssigneeUser,
|
||||||
|
): ComboboxItem {
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
name: getOperationAssigneeName(user),
|
||||||
|
secondaryText: user.email,
|
||||||
|
username: user.username,
|
||||||
|
imageUrl: user.avatar,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePersonName(value: string) {
|
||||||
|
return value.trim().toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOperationTeamMembers(value: string) {
|
||||||
|
return value
|
||||||
|
.split(',')
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function findComboboxItemByName(items: ComboboxItem[], name: string) {
|
||||||
|
const normalizedName = normalizePersonName(name)
|
||||||
|
|
||||||
|
if (!normalizedName) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
items.find((item) => normalizePersonName(item.name) === normalizedName) ??
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFallbackComboboxItem(name: string): ComboboxItem | null {
|
||||||
|
const trimmedName = name.trim()
|
||||||
|
|
||||||
|
if (!trimmedName) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `fallback:${trimmedName}`,
|
||||||
|
name: trimmedName,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectionItem(items: ComboboxItem[], name: string) {
|
||||||
|
return findComboboxItemByName(items, name) ?? createFallbackComboboxItem(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readApiError(response: Response, fallback: string) {
|
||||||
|
const data = await response.json().catch(() => null)
|
||||||
|
|
||||||
|
return data?.error ?? fallback
|
||||||
|
}
|
||||||
|
|
||||||
const operationTabs: Array<{
|
const operationTabs: Array<{
|
||||||
id: OperationTabId
|
id: OperationTabId
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
|
icon: OperationTabIcon
|
||||||
}> = [
|
}> = [
|
||||||
{
|
{
|
||||||
id: 'masterData',
|
id: 'masterData',
|
||||||
title: 'Stammdaten',
|
title: 'Stammdaten',
|
||||||
description: 'Einsatznummer, Einsatzname und Delikt.',
|
description: 'Einsatznummer, Einsatzname und Delikt.',
|
||||||
|
icon: ClipboardDocumentListIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'addresses',
|
id: 'addresses',
|
||||||
title: 'Adressen',
|
title: 'Adressen',
|
||||||
description: 'Zielobjekt und KW-Adresse.',
|
description: 'Zielobjekt und KW-Adresse.',
|
||||||
|
icon: MapPinIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'responsibilities',
|
id: 'responsibilities',
|
||||||
title: 'Zuständigkeiten',
|
title: 'Zuständigkeiten',
|
||||||
description: 'Sachbearbeitung, Einsatzleitung und Team.',
|
description: 'Sachbearbeitung, Einsatzleitung und Team.',
|
||||||
|
icon: UserGroupIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'notes',
|
id: 'notes',
|
||||||
title: 'Zusatzinfos',
|
title: 'Zusatzinfos',
|
||||||
description: 'Legende und Bemerkung.',
|
description: 'Legende und Bemerkung.',
|
||||||
|
icon: DocumentTextIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'equipment',
|
id: 'equipment',
|
||||||
title: 'Technik',
|
title: 'Technik',
|
||||||
description: 'Kamera, Router, Switchbox und Laptop.',
|
description: 'Kamera, Router, Switchbox und Laptop.',
|
||||||
|
icon: WrenchScrewdriverIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'journal',
|
id: 'journal',
|
||||||
title: 'Journal',
|
title: 'Journal',
|
||||||
description: 'Bearbeitungsverlauf des Einsatzes.',
|
description: 'Bearbeitungsverlauf des Einsatzes.',
|
||||||
|
icon: ChatBubbleLeftEllipsisIcon,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -155,6 +264,12 @@ export default function OperationModal({
|
|||||||
const [targetObject, setTargetObject] = useState('')
|
const [targetObject, setTargetObject] = useState('')
|
||||||
const [kwAddress, setKwAddress] = useState('')
|
const [kwAddress, setKwAddress] = useState('')
|
||||||
|
|
||||||
|
const [operationLeaderOptions, setOperationLeaderOptions] = useState<ComboboxItem[]>([])
|
||||||
|
const [operationTeamOptions, setOperationTeamOptions] = useState<ComboboxItem[]>([])
|
||||||
|
const [selectedOperationLeader, setSelectedOperationLeader] = useState<ComboboxItem | null>(null)
|
||||||
|
const [selectedOperationTeam, setSelectedOperationTeam] = useState<ComboboxItem[]>([])
|
||||||
|
const [isLoadingOperationAssignees, setIsLoadingOperationAssignees] = useState(false)
|
||||||
|
|
||||||
const visibleTabs = useMemo(
|
const visibleTabs = useMemo(
|
||||||
() => operationTabs.filter((tab) => isEditing || tab.id !== 'journal'),
|
() => operationTabs.filter((tab) => isEditing || tab.id !== 'journal'),
|
||||||
[isEditing],
|
[isEditing],
|
||||||
@ -175,6 +290,46 @@ export default function OperationModal({
|
|||||||
editingOperation?.kwAddress,
|
editingOperation?.kwAddress,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const abortController = new AbortController()
|
||||||
|
|
||||||
|
void loadOperationAssignees(abortController.signal)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
abortController.abort()
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedOperationLeader(
|
||||||
|
getSelectionItem(
|
||||||
|
operationLeaderOptions,
|
||||||
|
editingOperation?.operationLeader ?? '',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
setSelectedOperationTeam(
|
||||||
|
parseOperationTeamMembers(editingOperation?.operationTeam ?? '')
|
||||||
|
.map((name) => getSelectionItem(operationTeamOptions, name))
|
||||||
|
.filter((item): item is ComboboxItem => item !== null),
|
||||||
|
)
|
||||||
|
}, [
|
||||||
|
open,
|
||||||
|
editingOperation?.id,
|
||||||
|
editingOperation?.operationLeader,
|
||||||
|
editingOperation?.operationTeam,
|
||||||
|
operationLeaderOptions,
|
||||||
|
operationTeamOptions,
|
||||||
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || activeTab !== 'journal' || !hasUnreadJournal) {
|
if (!open || activeTab !== 'journal' || !hasUnreadJournal) {
|
||||||
return
|
return
|
||||||
@ -255,6 +410,53 @@ export default function OperationModal({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function loadOperationAssignees(signal?: AbortSignal) {
|
||||||
|
setIsLoadingOperationAssignees(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_URL}/operations/assignees?unit=${encodeURIComponent(OPERATION_UNIT)}`,
|
||||||
|
{
|
||||||
|
credentials: 'include',
|
||||||
|
signal,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
await readApiError(response, 'Einsatzpersonen konnten nicht geladen werden'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as OperationAssigneesResponse
|
||||||
|
|
||||||
|
setOperationLeaderOptions(
|
||||||
|
(data.leaders ?? []).map(mapOperationAssigneeToComboboxItem),
|
||||||
|
)
|
||||||
|
|
||||||
|
setOperationTeamOptions(
|
||||||
|
(data.officers ?? []).map(mapOperationAssigneeToComboboxItem),
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal soll trotzdem nutzbar bleiben.
|
||||||
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
setIsLoadingOperationAssignees(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOperationLeaderChange(item: ComboboxItem | null) {
|
||||||
|
setSelectedOperationLeader(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOperationTeamChange(items: ComboboxItem[]) {
|
||||||
|
setSelectedOperationTeam(items)
|
||||||
|
}
|
||||||
|
|
||||||
function handleOpenChange(nextOpen: boolean) {
|
function handleOpenChange(nextOpen: boolean) {
|
||||||
if (!nextOpen && isSaving) {
|
if (!nextOpen && isSaving) {
|
||||||
return
|
return
|
||||||
@ -286,6 +488,18 @@ export default function OperationModal({
|
|||||||
value={editingOperation?.hardDrive ?? ''}
|
value={editingOperation?.hardDrive ?? ''}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="operationLeader"
|
||||||
|
value={selectedOperationLeader?.name ?? ''}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="operationTeam"
|
||||||
|
value={selectedOperationTeam.map((item) => item.name).join(', ')}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex items-start justify-between border-b border-gray-200 bg-gray-100 px-4 py-4 sm:px-6 dark:border-white/10 dark:bg-gray-800">
|
<div className="flex items-start justify-between border-b border-gray-200 bg-gray-100 px-4 py-4 sm:px-6 dark:border-white/10 dark:bg-gray-800">
|
||||||
<div>
|
<div>
|
||||||
<DialogTitle className="text-base font-semibold text-gray-900 dark:text-white">
|
<DialogTitle className="text-base font-semibold text-gray-900 dark:text-white">
|
||||||
@ -313,36 +527,42 @@ export default function OperationModal({
|
|||||||
|
|
||||||
<div className="shrink-0 border-b border-gray-200 bg-white px-4 sm:px-6 dark:border-white/10 dark:bg-gray-900">
|
<div className="shrink-0 border-b border-gray-200 bg-white px-4 sm:px-6 dark:border-white/10 dark:bg-gray-900">
|
||||||
<div className="-mb-px flex gap-x-6 overflow-x-auto">
|
<div className="-mb-px flex gap-x-6 overflow-x-auto">
|
||||||
{visibleTabs.map((tab) => (
|
{visibleTabs.map((tab) => {
|
||||||
<button
|
const Icon = tab.icon
|
||||||
key={tab.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setActiveTab(tab.id)
|
|
||||||
|
|
||||||
if (tab.id === 'journal') {
|
return (
|
||||||
onJournalTabOpen?.()
|
<button
|
||||||
}
|
key={tab.id}
|
||||||
}}
|
type="button"
|
||||||
className={classNames(
|
onClick={() => {
|
||||||
'whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium',
|
setActiveTab(tab.id)
|
||||||
activeTab === tab.id
|
|
||||||
? 'border-indigo-600 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400'
|
|
||||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-white/20 dark:hover:text-gray-200',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="inline-flex items-center gap-x-2">
|
|
||||||
{tab.title}
|
|
||||||
|
|
||||||
{tab.id === 'journal' && hasUnreadJournal && (
|
if (tab.id === 'journal') {
|
||||||
<NotificationDot
|
onJournalTabOpen?.()
|
||||||
title="Neue Journal-Einträge"
|
}
|
||||||
label="Neue Journal-Einträge"
|
}}
|
||||||
/>
|
className={classNames(
|
||||||
|
'whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium',
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'border-indigo-600 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400'
|
||||||
|
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-white/20 dark:hover:text-gray-200',
|
||||||
)}
|
)}
|
||||||
</span>
|
>
|
||||||
</button>
|
<span className="inline-flex items-center gap-x-2">
|
||||||
))}
|
<Icon aria-hidden="true" className="size-4 shrink-0" />
|
||||||
|
|
||||||
|
<span>{tab.title}</span>
|
||||||
|
|
||||||
|
{tab.id === 'journal' && hasUnreadJournal && (
|
||||||
|
<NotificationDot
|
||||||
|
title="Neue Journal-Einträge"
|
||||||
|
label="Neue Journal-Einträge"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -423,7 +643,7 @@ export default function OperationModal({
|
|||||||
description="Zielobjekt und KW-Adresse mit Adresssuche und Karten-Vorschau."
|
description="Zielobjekt und KW-Adresse mit Adresssuche und Karten-Vorschau."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-2">
|
<div className="mt-4 grid grid-cols-2 gap-4 xl:grid-cols-2">
|
||||||
<AddressCombobox
|
<AddressCombobox
|
||||||
id="targetObject"
|
id="targetObject"
|
||||||
name="targetObject"
|
name="targetObject"
|
||||||
@ -431,6 +651,7 @@ export default function OperationModal({
|
|||||||
value={targetObject}
|
value={targetObject}
|
||||||
onChange={setTargetObject}
|
onChange={setTargetObject}
|
||||||
placeholder="Adresse suchen oder Zielobjekt eingeben"
|
placeholder="Adresse suchen oder Zielobjekt eingeben"
|
||||||
|
mapVisible={activeTab === 'addresses'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AddressCombobox
|
<AddressCombobox
|
||||||
@ -440,6 +661,7 @@ export default function OperationModal({
|
|||||||
value={kwAddress}
|
value={kwAddress}
|
||||||
onChange={setKwAddress}
|
onChange={setKwAddress}
|
||||||
placeholder="KW-Adresse suchen oder eingeben"
|
placeholder="KW-Adresse suchen oder eingeben"
|
||||||
|
mapVisible={activeTab === 'addresses'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -449,53 +671,90 @@ export default function OperationModal({
|
|||||||
<section className={sectionClassName}>
|
<section className={sectionClassName}>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Zuständigkeiten"
|
title="Zuständigkeiten"
|
||||||
description="Sachbearbeitung, Einsatzleitung und eingesetztes Team."
|
description="Lege fest, wer den Einsatz bearbeitet, leitet und im Einsatzteam mitwirkt."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-6">
|
<div className="mt-4 space-y-4">
|
||||||
<div className="sm:col-span-3">
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
<label htmlFor="caseWorker" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/[0.03]">
|
||||||
Sachbearbeitung
|
<div className="mb-3">
|
||||||
</label>
|
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
<div className="mt-2">
|
Sachbearbeitung
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Interne sachbearbeitende Person oder Dienststelle.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
id="caseWorker"
|
id="caseWorker"
|
||||||
name="caseWorker"
|
name="caseWorker"
|
||||||
type="text"
|
type="text"
|
||||||
defaultValue={editingOperation?.caseWorker ?? ''}
|
defaultValue={editingOperation?.caseWorker ?? ''}
|
||||||
|
placeholder="Sachbearbeitung eintragen"
|
||||||
className={inputClassName}
|
className={inputClassName}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/[0.03]">
|
||||||
|
<div className="mb-3">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
Einsatzleiter
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Nutzer der Einheit {OPERATION_UNIT} mit Rolle Einsatzleiter.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Combobox
|
||||||
|
items={operationLeaderOptions}
|
||||||
|
value={selectedOperationLeader}
|
||||||
|
onChange={handleOperationLeaderChange}
|
||||||
|
variant="image"
|
||||||
|
placeholder={
|
||||||
|
isLoadingOperationAssignees
|
||||||
|
? 'Einsatzleiter werden geladen...'
|
||||||
|
: 'Einsatzleiter auswählen'
|
||||||
|
}
|
||||||
|
emptyText={`Keine Einsatzleiter der ${OPERATION_UNIT} gefunden.`}
|
||||||
|
disabled={isLoadingOperationAssignees}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sm:col-span-3">
|
<div className="rounded-lg border border-indigo-200 bg-indigo-50/60 p-4 dark:border-indigo-500/20 dark:bg-indigo-500/10">
|
||||||
<label htmlFor="operationLeader" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
<div className="mb-3 flex items-start justify-between gap-x-4">
|
||||||
Einsatzleiter
|
<div>
|
||||||
</label>
|
<h4 className="text-sm font-semibold text-indigo-900 dark:text-indigo-200">
|
||||||
<div className="mt-2">
|
Einsatzteam
|
||||||
<input
|
</h4>
|
||||||
id="operationLeader"
|
|
||||||
name="operationLeader"
|
|
||||||
type="text"
|
|
||||||
defaultValue={editingOperation?.operationLeader ?? ''}
|
|
||||||
className={inputClassName}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="sm:col-span-3">
|
<p className="mt-1 text-xs text-indigo-700/80 dark:text-indigo-300/80">
|
||||||
<label htmlFor="operationTeam" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
Wähle einen oder mehrere Einsatzbeamte der Einheit {OPERATION_UNIT}.
|
||||||
Einsatzteam
|
</p>
|
||||||
</label>
|
</div>
|
||||||
<div className="mt-2">
|
|
||||||
<input
|
{selectedOperationTeam.length > 0 && (
|
||||||
id="operationTeam"
|
<span className="shrink-0 rounded-full bg-white px-2.5 py-1 text-xs font-medium text-indigo-700 ring-1 ring-indigo-600/20 ring-inset dark:bg-indigo-500/10 dark:text-indigo-200 dark:ring-indigo-300/20">
|
||||||
name="operationTeam"
|
{selectedOperationTeam.length} ausgewählt
|
||||||
type="text"
|
</span>
|
||||||
defaultValue={editingOperation?.operationTeam ?? ''}
|
)}
|
||||||
className={inputClassName}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<MultiCombobox
|
||||||
|
items={operationTeamOptions}
|
||||||
|
value={selectedOperationTeam}
|
||||||
|
onChange={handleOperationTeamChange}
|
||||||
|
placeholder={
|
||||||
|
isLoadingOperationAssignees
|
||||||
|
? 'Einsatzbeamte werden geladen...'
|
||||||
|
: 'Einsatzbeamte auswählen'
|
||||||
|
}
|
||||||
|
emptyText={`Keine Einsatzbeamten der ${OPERATION_UNIT} gefunden.`}
|
||||||
|
disabled={isLoadingOperationAssignees}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -85,24 +85,12 @@ export default function SettingsPage({
|
|||||||
icon: BellIcon,
|
icon: BellIcon,
|
||||||
current: pathname === '/einstellungen/benachrichtigungen',
|
current: pathname === '/einstellungen/benachrichtigungen',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'Abrechnung',
|
|
||||||
href: '/einstellungen/abrechnung',
|
|
||||||
icon: CreditCardIcon,
|
|
||||||
current: pathname === '/einstellungen/abrechnung',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'Teams',
|
name: 'Teams',
|
||||||
href: '/einstellungen/teams',
|
href: '/einstellungen/teams',
|
||||||
icon: UsersIcon,
|
icon: UsersIcon,
|
||||||
current: pathname === '/einstellungen/teams',
|
current: pathname === '/einstellungen/teams',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'Integrationen',
|
|
||||||
href: '/einstellungen/integrationen',
|
|
||||||
icon: PuzzlePieceIcon,
|
|
||||||
current: pathname === '/einstellungen/integrationen',
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -130,17 +118,9 @@ export default function SettingsPage({
|
|||||||
<Benachrichtigungen />
|
<Benachrichtigungen />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{section === 'abrechnung' && (
|
|
||||||
<PlaceholderSection title="Abrechnung" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{section === 'teams' && (
|
{section === 'teams' && (
|
||||||
<PlaceholderSection title="Teams" />
|
<PlaceholderSection title="Teams" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{section === 'integrationen' && (
|
|
||||||
<PlaceholderSection title="Integrationen" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -9,20 +9,23 @@ import {
|
|||||||
import {
|
import {
|
||||||
BellIcon,
|
BellIcon,
|
||||||
EnvelopeIcon,
|
EnvelopeIcon,
|
||||||
|
PlayIcon,
|
||||||
SpeakerWaveIcon,
|
SpeakerWaveIcon,
|
||||||
} from '@heroicons/react/20/solid'
|
} from '@heroicons/react/20/solid'
|
||||||
import Container from '../../../components/Container'
|
import Container from '../../../components/Container'
|
||||||
import LoadingSpinner from '../../../components/LoadingSpinner'
|
import LoadingSpinner from '../../../components/LoadingSpinner'
|
||||||
import Switch from '../../../components/Switch'
|
import Switch from '../../../components/Switch'
|
||||||
import Notifications, {
|
import Button from '../../../components/Button'
|
||||||
type ToastNotification,
|
import { useNotifications } from '../../../components/Notifications'
|
||||||
} from '../../../components/Notifications'
|
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||||
|
|
||||||
|
type NotificationSoundName = 'default' | 'chime' | 'soft' | 'alert' | 'success'
|
||||||
|
|
||||||
type NotificationSettings = {
|
type NotificationSettings = {
|
||||||
inAppEnabled: boolean
|
inAppEnabled: boolean
|
||||||
soundEnabled: boolean
|
soundEnabled: boolean
|
||||||
|
soundName: NotificationSoundName
|
||||||
emailEnabled: boolean
|
emailEnabled: boolean
|
||||||
operationUpdates: boolean
|
operationUpdates: boolean
|
||||||
operationJournals: boolean
|
operationJournals: boolean
|
||||||
@ -34,6 +37,7 @@ type NotificationSettings = {
|
|||||||
const defaultSettings: NotificationSettings = {
|
const defaultSettings: NotificationSettings = {
|
||||||
inAppEnabled: true,
|
inAppEnabled: true,
|
||||||
soundEnabled: false,
|
soundEnabled: false,
|
||||||
|
soundName: 'default',
|
||||||
emailEnabled: false,
|
emailEnabled: false,
|
||||||
operationUpdates: true,
|
operationUpdates: true,
|
||||||
operationJournals: true,
|
operationJournals: true,
|
||||||
@ -42,6 +46,17 @@ const defaultSettings: NotificationSettings = {
|
|||||||
systemMessages: true,
|
systemMessages: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const notificationSounds: Array<{
|
||||||
|
value: NotificationSoundName
|
||||||
|
label: string
|
||||||
|
}> = [
|
||||||
|
{ value: 'default', label: 'Standard' },
|
||||||
|
{ value: 'chime', label: 'Klang' },
|
||||||
|
{ value: 'soft', label: 'Dezent' },
|
||||||
|
{ value: 'alert', label: 'Alarm' },
|
||||||
|
{ value: 'success', label: 'Erfolg' },
|
||||||
|
]
|
||||||
|
|
||||||
function Section({
|
function Section({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@ -72,24 +87,21 @@ function Section({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Message({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readApiError(response: Response, fallback: string) {
|
async function readApiError(response: Response, fallback: string) {
|
||||||
const data = await response.json().catch(() => null)
|
const data = await response.json().catch(() => null)
|
||||||
|
|
||||||
return data?.error ?? fallback
|
return data?.error ?? fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function playNotificationSound(soundName: NotificationSoundName) {
|
||||||
|
const audio = new Audio(`/sounds/notifications/${soundName}.mp3`)
|
||||||
|
audio.volume = 0.6
|
||||||
|
|
||||||
|
void audio.play().catch(() => {
|
||||||
|
// Browser blockiert Audio ggf., bis der User einmal mit der Seite interagiert hat.
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function NotificationCard({
|
function NotificationCard({
|
||||||
icon,
|
icon,
|
||||||
title,
|
title,
|
||||||
@ -127,12 +139,12 @@ function NotificationCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Benachrichtigungen() {
|
export default function Benachrichtigungen() {
|
||||||
|
const { showToast, showErrorToast } = useNotifications()
|
||||||
|
|
||||||
const [settings, setSettings] =
|
const [settings, setSettings] =
|
||||||
useState<NotificationSettings>(defaultSettings)
|
useState<NotificationSettings>(defaultSettings)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [toasts, setToasts] = useState<ToastNotification[]>([])
|
|
||||||
|
|
||||||
const settingsRef = useRef<NotificationSettings>(defaultSettings)
|
const settingsRef = useRef<NotificationSettings>(defaultSettings)
|
||||||
const saveRequestIdRef = useRef(0)
|
const saveRequestIdRef = useRef(0)
|
||||||
@ -141,25 +153,8 @@ export default function Benachrichtigungen() {
|
|||||||
void loadSettings()
|
void loadSettings()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
function dismissToast(id: string) {
|
|
||||||
setToasts((currentToasts) =>
|
|
||||||
currentToasts.filter((toast) => toast.id !== id),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function showToast(notification: Omit<ToastNotification, 'id'>) {
|
|
||||||
setToasts((currentToasts) => [
|
|
||||||
...currentToasts,
|
|
||||||
{
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
...notification,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSettings() {
|
async function loadSettings() {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
setError(null)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_URL}/settings/notifications/preferences`, {
|
const response = await fetch(`${API_URL}/settings/notifications/preferences`, {
|
||||||
@ -184,11 +179,11 @@ export default function Benachrichtigungen() {
|
|||||||
settingsRef.current = nextSettings
|
settingsRef.current = nextSettings
|
||||||
setSettings(nextSettings)
|
setSettings(nextSettings)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError(
|
showErrorToast({
|
||||||
error instanceof Error
|
title: 'Benachrichtigungseinstellungen konnten nicht geladen werden',
|
||||||
? error.message
|
error,
|
||||||
: 'Benachrichtigungseinstellungen konnten nicht geladen werden',
|
fallback: 'Benachrichtigungseinstellungen konnten nicht geladen werden',
|
||||||
)
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
@ -214,7 +209,6 @@ export default function Benachrichtigungen() {
|
|||||||
saveRequestIdRef.current = requestId
|
saveRequestIdRef.current = requestId
|
||||||
|
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
setError(null)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_URL}/settings/notifications/preferences`, {
|
const response = await fetch(`${API_URL}/settings/notifications/preferences`, {
|
||||||
@ -261,20 +255,10 @@ export default function Benachrichtigungen() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setError(
|
showErrorToast({
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: 'Benachrichtigungseinstellungen konnten nicht gespeichert werden',
|
|
||||||
)
|
|
||||||
|
|
||||||
showToast({
|
|
||||||
title: 'Speichern fehlgeschlagen',
|
title: 'Speichern fehlgeschlagen',
|
||||||
message:
|
error,
|
||||||
error instanceof Error
|
fallback: 'Benachrichtigungseinstellungen konnten nicht gespeichert werden.',
|
||||||
? error.message
|
|
||||||
: 'Benachrichtigungseinstellungen konnten nicht gespeichert werden.',
|
|
||||||
variant: 'error',
|
|
||||||
autoClose: false,
|
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
if (saveRequestIdRef.current === requestId) {
|
if (saveRequestIdRef.current === requestId) {
|
||||||
@ -303,38 +287,17 @@ export default function Benachrichtigungen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Notifications
|
|
||||||
notifications={toasts}
|
|
||||||
onDismiss={dismissToast}
|
|
||||||
position="top-right"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="divide-y divide-gray-200 dark:divide-white/10">
|
<div className="divide-y divide-gray-200 dark:divide-white/10">
|
||||||
<Section
|
<Section
|
||||||
title="Benachrichtigungen"
|
title="Benachrichtigungen"
|
||||||
description="Lege fest, welche Hinweise du in der Anwendung erhalten möchtest."
|
description="Lege fest, welche Hinweise du in der Anwendung erhalten möchtest."
|
||||||
>
|
>
|
||||||
<div className="space-y-6 sm:max-w-2xl">
|
<div className="space-y-6 sm:max-w-2xl">
|
||||||
{error && (
|
|
||||||
<Message>
|
|
||||||
{error}
|
|
||||||
</Message>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<NotificationCard
|
<NotificationCard
|
||||||
icon={<BellIcon aria-hidden="true" className="size-5" />}
|
icon={<BellIcon aria-hidden="true" className="size-5" />}
|
||||||
title="In-App-Benachrichtigungen"
|
title="In-App-Benachrichtigungen"
|
||||||
description="Steuere Hinweise, die direkt in der Anwendung angezeigt werden."
|
description="Steuere Hinweise, die direkt in der Anwendung angezeigt werden."
|
||||||
>
|
>
|
||||||
<Switch
|
|
||||||
checked={settings.inAppEnabled}
|
|
||||||
onChange={(event) =>
|
|
||||||
updateSetting('inAppEnabled', event.target.checked)
|
|
||||||
}
|
|
||||||
label="Benachrichtigungen anzeigen"
|
|
||||||
description="Aktiviert Hinweise in der Topbar und im Benachrichtigungscenter."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Switch
|
<Switch
|
||||||
checked={settings.soundEnabled}
|
checked={settings.soundEnabled}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@ -344,6 +307,52 @@ export default function Benachrichtigungen() {
|
|||||||
label="Signalton abspielen"
|
label="Signalton abspielen"
|
||||||
description="Spielt einen kurzen Ton bei neuen Benachrichtigungen ab."
|
description="Spielt einen kurzen Ton bei neuen Benachrichtigungen ab."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="notification-sound"
|
||||||
|
className="block text-sm font-medium text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
Benachrichtigungssound
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
id="notification-sound"
|
||||||
|
value={settings.soundName}
|
||||||
|
disabled={!settings.inAppEnabled || !settings.soundEnabled}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateSetting(
|
||||||
|
'soundName',
|
||||||
|
event.target.value as NotificationSoundName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="block w-full rounded-md bg-white px-3 py-1.5 text-sm text-gray-900 outline-1 -outline-offset-1 outline-gray-300 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:disabled:bg-white/5 dark:disabled:text-gray-500 dark:focus:outline-indigo-500"
|
||||||
|
>
|
||||||
|
{notificationSounds.map((sound) => (
|
||||||
|
<option key={sound.value} value={sound.value}>
|
||||||
|
{sound.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
color="gray"
|
||||||
|
size="sm"
|
||||||
|
disabled={!settings.inAppEnabled || !settings.soundEnabled}
|
||||||
|
leadingIcon={<PlayIcon aria-hidden="true" className="size-4" />}
|
||||||
|
onClick={() => playNotificationSound(settings.soundName)}
|
||||||
|
>
|
||||||
|
Abspielen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Wird bei neuen In-App-Benachrichtigungen verwendet, wenn der Signalton aktiviert ist.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</NotificationCard>
|
</NotificationCard>
|
||||||
|
|
||||||
<NotificationCard
|
<NotificationCard
|
||||||
|
|||||||
@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
|
type ChangeEvent,
|
||||||
type FormEvent,
|
type FormEvent,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
@ -13,9 +15,8 @@ import {
|
|||||||
ComputerDesktopIcon,
|
ComputerDesktopIcon,
|
||||||
ShieldCheckIcon,
|
ShieldCheckIcon,
|
||||||
} from '@heroicons/react/20/solid'
|
} from '@heroicons/react/20/solid'
|
||||||
import Notifications, {
|
import { useNotifications } from '../../../components/Notifications'
|
||||||
type ToastNotification,
|
import Avatar from '../../../components/Avatar'
|
||||||
} from '../../../components/Notifications'
|
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||||
|
|
||||||
@ -79,8 +80,11 @@ export default function Konto({
|
|||||||
user,
|
user,
|
||||||
onUserUpdated,
|
onUserUpdated,
|
||||||
}: KontoProps) {
|
}: KontoProps) {
|
||||||
const [toasts, setToasts] = useState<ToastNotification[]>([])
|
const { showToast, showErrorToast } = useNotifications()
|
||||||
|
|
||||||
const [currentUser, setCurrentUser] = useState<User | undefined>(user)
|
const [currentUser, setCurrentUser] = useState<User | undefined>(user)
|
||||||
|
const [avatarPreview, setAvatarPreview] = useState(user?.avatar ?? '')
|
||||||
|
const avatarInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
const [sessions, setSessions] = useState<UserSession[]>([])
|
const [sessions, setSessions] = useState<UserSession[]>([])
|
||||||
|
|
||||||
const [isLoadingSessions, setIsLoadingSessions] = useState(false)
|
const [isLoadingSessions, setIsLoadingSessions] = useState(false)
|
||||||
@ -92,32 +96,13 @@ export default function Konto({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentUser(user)
|
setCurrentUser(user)
|
||||||
|
setAvatarPreview(user?.avatar ?? '')
|
||||||
}, [user])
|
}, [user])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadSessions()
|
void loadSessions()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const avatarUrl =
|
|
||||||
currentUser?.avatar ||
|
|
||||||
'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80'
|
|
||||||
|
|
||||||
function dismissToast(id: string) {
|
|
||||||
setToasts((currentToasts) =>
|
|
||||||
currentToasts.filter((toast) => toast.id !== id),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function showToast(notification: Omit<ToastNotification, 'id'>) {
|
|
||||||
setToasts((currentToasts) => [
|
|
||||||
...currentToasts,
|
|
||||||
{
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
...notification,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSessions() {
|
async function loadSessions() {
|
||||||
setIsLoadingSessions(true)
|
setIsLoadingSessions(true)
|
||||||
|
|
||||||
@ -184,6 +169,49 @@ export default function Konto({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleAvatarFileChange(event: ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = event.target.files?.[0]
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
showToast({
|
||||||
|
title: 'Ungültige Datei',
|
||||||
|
message: 'Bitte wähle eine gültige Bilddatei aus.',
|
||||||
|
variant: 'error',
|
||||||
|
})
|
||||||
|
event.target.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > 2 * 1024 * 1024) {
|
||||||
|
showToast({
|
||||||
|
title: 'Bild zu groß',
|
||||||
|
message: 'Das Profilbild darf maximal 2 MB groß sein.',
|
||||||
|
variant: 'error',
|
||||||
|
})
|
||||||
|
event.target.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader()
|
||||||
|
|
||||||
|
reader.onload = () => {
|
||||||
|
if (typeof reader.result === 'string') {
|
||||||
|
setAvatarPreview(reader.result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
event.target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemoveAvatar() {
|
||||||
|
setAvatarPreview('')
|
||||||
|
}
|
||||||
|
|
||||||
async function handleKontoSubmit(event: FormEvent<HTMLFormElement>) {
|
async function handleKontoSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
@ -220,6 +248,7 @@ export default function Konto({
|
|||||||
|
|
||||||
if (nextUser) {
|
if (nextUser) {
|
||||||
setCurrentUser(nextUser)
|
setCurrentUser(nextUser)
|
||||||
|
setAvatarPreview(nextUser.avatar ?? '')
|
||||||
onUserUpdated?.(nextUser)
|
onUserUpdated?.(nextUser)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -229,14 +258,10 @@ export default function Konto({
|
|||||||
variant: 'success',
|
variant: 'success',
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast({
|
showErrorToast({
|
||||||
title: 'Profil konnte nicht gespeichert werden',
|
title: 'Profil konnte nicht gespeichert werden',
|
||||||
message:
|
error,
|
||||||
error instanceof Error
|
fallback: 'Profil konnte nicht gespeichert werden',
|
||||||
? error.message
|
|
||||||
: 'Profil konnte nicht gespeichert werden',
|
|
||||||
variant: 'error',
|
|
||||||
autoClose: false,
|
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setIsSavingKonto(false)
|
setIsSavingKonto(false)
|
||||||
@ -395,12 +420,6 @@ export default function Konto({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Notifications
|
|
||||||
notifications={toasts}
|
|
||||||
onDismiss={dismissToast}
|
|
||||||
position="top-right"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="divide-y divide-gray-200 dark:divide-white/10">
|
<div className="divide-y divide-gray-200 dark:divide-white/10">
|
||||||
<Section
|
<Section
|
||||||
title="Persönliche Informationen"
|
title="Persönliche Informationen"
|
||||||
@ -409,33 +428,54 @@ export default function Konto({
|
|||||||
<form onSubmit={handleKontoSubmit}>
|
<form onSubmit={handleKontoSubmit}>
|
||||||
<div className="grid grid-cols-1 gap-x-6 gap-y-8 sm:max-w-xl sm:grid-cols-6">
|
<div className="grid grid-cols-1 gap-x-6 gap-y-8 sm:max-w-xl sm:grid-cols-6">
|
||||||
<div className="col-span-full flex items-center gap-x-8">
|
<div className="col-span-full flex items-center gap-x-8">
|
||||||
<img
|
<Avatar
|
||||||
alt=""
|
src={avatarPreview}
|
||||||
src={avatarUrl}
|
name={currentUser?.displayName || currentUser?.username || currentUser?.email}
|
||||||
className="size-24 flex-none rounded-lg bg-gray-100 object-cover outline -outline-offset-1 outline-black/5 dark:bg-gray-800 dark:outline-white/10"
|
size={16}
|
||||||
|
shape="rounded"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<label
|
<input
|
||||||
htmlFor="avatar"
|
type="hidden"
|
||||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
name="avatar"
|
||||||
>
|
value={avatarPreview}
|
||||||
Avatar-URL
|
/>
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="mt-2">
|
<input
|
||||||
<input
|
ref={avatarInputRef}
|
||||||
id="avatar"
|
type="file"
|
||||||
name="avatar"
|
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||||
type="url"
|
className="sr-only"
|
||||||
defaultValue={currentUser?.avatar ?? ''}
|
onChange={handleAvatarFileChange}
|
||||||
placeholder="https://..."
|
/>
|
||||||
className={inputClassName}
|
|
||||||
/>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
color="gray"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => avatarInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
Bild hochladen
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{avatarPreview && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
color="red"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRemoveAvatar}
|
||||||
|
>
|
||||||
|
Entfernen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-2 text-xs/5 text-gray-500 dark:text-gray-400">
|
<p className="mt-2 text-xs/5 text-gray-500 dark:text-gray-400">
|
||||||
Gib eine Bild-URL für dein Profilbild an.
|
PNG, JPG, WEBP oder GIF bis maximal 2 MB.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user