This commit is contained in:
Linrador 2026-05-22 14:11:18 +02:00
parent 2b10d894a7
commit 9ecb3acb14
55 changed files with 11910 additions and 3022 deletions

View File

@ -52,6 +52,28 @@ type milestoneCredentials struct {
SkipTLSVerify bool
}
type milestoneSyncResult struct {
TokenReceived bool `json:"tokenReceived"`
TokenType string `json:"tokenType"`
TokenExpiresAt time.Time `json:"tokenExpiresAt"`
RecordingServerID string `json:"recordingServerId"`
RecordingServerName string `json:"recordingServerName"`
RecordingServerVersion string `json:"recordingServerVersion"`
HardwareCount int `json:"hardwareCount"`
}
type milestoneSyncEvent struct {
Type string `json:"type"`
Message string `json:"message,omitempty"`
TokenType string `json:"tokenType,omitempty"`
TokenExpiresAt *time.Time `json:"tokenExpiresAt,omitempty"`
RecordingServerID string `json:"recordingServerId,omitempty"`
RecordingServerName string `json:"recordingServerName,omitempty"`
RecordingServerVersion string `json:"recordingServerVersion,omitempty"`
HardwareCount int `json:"hardwareCount,omitempty"`
Settings *milestoneSettingsResponse `json:"settings,omitempty"`
}
type milestoneTokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
@ -71,6 +93,68 @@ type milestoneRecordingServer struct {
RecordingServerVersion string `json:"recordingServerVersion"`
}
type milestoneHardwareResponse struct {
Array []milestoneHardware `json:"array"`
}
type milestoneHardware struct {
ID string `json:"id"`
Name string `json:"name"`
DisplayName string `json:"displayName"`
DeviceName string `json:"deviceName"`
Address string `json:"address"`
Enabled *bool `json:"enabled"`
Disabled *bool `json:"disabled"`
Relations milestoneRelations `json:"relations"`
}
type milestoneRelations struct {
Parent milestoneRelationRef `json:"parent"`
Self milestoneRelationRef `json:"self"`
}
type milestoneRelationRef struct {
Type string `json:"type"`
ID string `json:"id"`
}
type milestoneHardwareDriverSettingsResponse struct {
Array []milestoneHardwareDriverSettingsItem `json:"array"`
}
type milestoneHardwareDriverSettingsItem struct {
DisplayName string `json:"displayName"`
HardwareDriverSettings milestoneHardwareDriverSettings `json:"hardwareDriverSettings"`
Relations milestoneRelations `json:"relations"`
}
type milestoneHardwareDriverSettings struct {
DetectedModelName string `json:"detectedModelName"`
ProductID string `json:"productID"`
MacAddress string `json:"macAddress"`
IPAddress string `json:"ipAddress"`
Address string `json:"address"`
HostName string `json:"hostName"`
DeviceModelName string `json:"deviceModelName"`
DeviceManufacturer string `json:"deviceManufacturer"`
FirmwareVersion string `json:"firmwareVersion"`
SerialNumber string `json:"serialNumber"`
}
type milestoneHardwareDevice struct {
HardwareID string
RecordingServerID string
DisplayName string
DeviceName string
Manufacturer string
MacAddress string
IPAddress string
DeviceModelName string
SerialNumber string
FirmwareVersion string
Enabled bool
}
func (s *Server) handleGetMilestoneSettings(w http.ResponseWriter, r *http.Request) {
settings, err := s.getMilestoneSettings(r.Context())
if errors.Is(err, pgx.ErrNoRows) {
@ -207,14 +291,30 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re
return
}
if r.URL.Query().Get("sync") == "false" {
settings, err := s.getMilestoneSettings(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "Milestone-Einstellungen wurden gespeichert, konnten aber nicht neu geladen werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"settings": settings,
})
return
}
syncWarning := ""
var syncResult milestoneSyncResult
credentials, err := s.getMilestoneCredentials(r.Context())
if err == nil && credentials.Host != "" && credentials.Username != "" && credentials.Password != "" {
if err := s.refreshMilestoneTokenAndServerID(r.Context(), credentials); err != nil {
syncResult, err = s.refreshMilestoneTokenAndServerID(r.Context(), credentials)
if err != nil {
log.Printf("Milestone sync after settings save failed: %v", err)
syncWarning = "Milestone-Zugangsdaten wurden gespeichert, aber Token oder Server-ID konnten nicht abgerufen werden."
syncWarning = "Milestone-Zugangsdaten wurden gespeichert, aber Token, Recording-Server oder Hardware konnten nicht vollständig abgerufen werden."
}
} else {
syncWarning = "Milestone-Zugangsdaten wurden gespeichert, konnten aber nicht vollständig geprüft werden."
@ -232,6 +332,8 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re
if syncWarning != "" {
response["warning"] = syncWarning
} else if syncResult.TokenReceived {
response["sync"] = syncResult
}
writeJSON(w, http.StatusOK, response)
@ -254,10 +356,11 @@ func (s *Server) handleFetchMilestoneToken(w http.ResponseWriter, r *http.Reques
return
}
if err := s.refreshMilestoneTokenAndServerID(r.Context(), credentials); err != nil {
syncResult, err := s.refreshMilestoneTokenAndServerID(r.Context(), credentials)
if err != nil {
log.Printf("Milestone token/server sync failed: %v", err)
writeError(w, http.StatusBadGateway, "Milestone-Token oder Server-ID konnten nicht abgerufen werden")
writeError(w, http.StatusBadGateway, "Milestone-Token, Recording-Server oder Hardware konnten nicht abgerufen werden")
return
}
@ -269,9 +372,83 @@ func (s *Server) handleFetchMilestoneToken(w http.ResponseWriter, r *http.Reques
writeJSON(w, http.StatusOK, map[string]any{
"settings": settings,
"sync": syncResult,
})
}
func (s *Server) handleFetchMilestoneTokenStream(w http.ResponseWriter, r *http.Request) {
credentials, err := s.getMilestoneCredentials(r.Context())
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusBadRequest, "Milestone-Einstellungen sind noch nicht hinterlegt")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "Milestone-Zugangsdaten konnten nicht geladen werden")
return
}
if credentials.Host == "" || credentials.Username == "" || credentials.Password == "" {
writeError(w, http.StatusBadRequest, "Milestone-Zugangsdaten sind unvollständig")
return
}
w.Header().Set("Content-Type", "application/x-ndjson")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("X-Accel-Buffering", "no")
_, err = s.refreshMilestoneTokenAndServerIDWithProgress(
r.Context(),
credentials,
func(event milestoneSyncEvent) {
writeMilestoneSyncEvent(w, event)
},
)
if err != nil {
log.Printf("Milestone token/server sync stream failed: %v", err)
writeMilestoneSyncEvent(w, milestoneSyncEvent{
Type: "error",
Message: "Milestone-Token, Recording-Server oder Hardware konnten nicht abgerufen werden",
})
return
}
settings, err := s.getMilestoneSettings(r.Context())
if err != nil {
writeMilestoneSyncEvent(w, milestoneSyncEvent{
Type: "error",
Message: "Milestone-Daten wurden gespeichert, konnten aber nicht neu geladen werden",
})
return
}
writeMilestoneSyncEvent(w, milestoneSyncEvent{
Type: "done",
Settings: &settings,
})
}
func writeMilestoneSyncEvent(w http.ResponseWriter, event milestoneSyncEvent) {
if err := json.NewEncoder(w).Encode(event); err != nil {
log.Printf("Milestone sync stream event could not be written: %v", err)
return
}
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
}
func emitMilestoneSyncEvent(
progress func(milestoneSyncEvent),
event milestoneSyncEvent,
) {
if progress != nil {
progress(event)
}
}
func (s *Server) getMilestoneSettings(ctx context.Context) (milestoneSettingsResponse, error) {
var settings milestoneSettingsResponse
var updatedAt time.Time
@ -331,15 +508,25 @@ func (s *Server) getMilestoneSettings(ctx context.Context) (milestoneSettingsRes
func (s *Server) refreshMilestoneTokenAndServerID(
ctx context.Context,
credentials milestoneCredentials,
) error {
) (milestoneSyncResult, error) {
return s.refreshMilestoneTokenAndServerIDWithProgress(ctx, credentials, nil)
}
func (s *Server) refreshMilestoneTokenAndServerIDWithProgress(
ctx context.Context,
credentials milestoneCredentials,
progress func(milestoneSyncEvent),
) (milestoneSyncResult, error) {
var syncResult milestoneSyncResult
tokenResponse, err := s.requestMilestoneToken(ctx, credentials)
if err != nil {
return err
return syncResult, err
}
accessToken := strings.TrimSpace(tokenResponse.AccessToken)
if accessToken == "" {
return errors.New("milestone returned empty access token")
return syncResult, errors.New("milestone returned empty access token")
}
tokenType := strings.TrimSpace(tokenResponse.TokenType)
@ -354,6 +541,16 @@ func (s *Server) refreshMilestoneTokenAndServerID(
tokenExpiresAt := time.Now().Add(time.Duration(expiresIn) * time.Second)
syncResult.TokenReceived = true
syncResult.TokenType = tokenType
syncResult.TokenExpiresAt = tokenExpiresAt
emitMilestoneSyncEvent(progress, milestoneSyncEvent{
Type: "token",
TokenType: tokenType,
TokenExpiresAt: &tokenExpiresAt,
})
serverID, serverName, serverVersion, err := s.requestMilestoneServerID(
ctx,
credentials.Host,
@ -361,14 +558,41 @@ func (s *Server) refreshMilestoneTokenAndServerID(
accessToken,
credentials.SkipTLSVerify,
)
if err != nil {
return err
return syncResult, err
}
syncResult.RecordingServerID = serverID
syncResult.RecordingServerName = serverName
syncResult.RecordingServerVersion = serverVersion
emitMilestoneSyncEvent(progress, milestoneSyncEvent{
Type: "recordingServer",
RecordingServerID: serverID,
RecordingServerName: serverName,
RecordingServerVersion: serverVersion,
})
hardwareDevices, err := s.requestMilestoneHardwareDevices(
ctx,
credentials.Host,
serverID,
accessToken,
credentials.SkipTLSVerify,
)
if err != nil {
return syncResult, err
}
tx, err := s.db.Begin(ctx)
if err != nil {
return syncResult, err
}
defer tx.Rollback(ctx)
encryptionKey := s.milestoneEncryptionKey()
_, err = s.db.Exec(
_, err = tx.Exec(
ctx,
`
UPDATE milestone_settings
@ -392,8 +616,33 @@ func (s *Server) refreshMilestoneTokenAndServerID(
serverName,
serverVersion,
)
if err != nil {
return syncResult, err
}
return err
syncedHardwareCount, err := syncMilestoneHardwareDevices(ctx, tx, hardwareDevices)
if err != nil {
return syncResult, err
}
syncResult.HardwareCount = syncedHardwareCount
if err := tx.Commit(ctx); err != nil {
return syncResult, err
}
emitMilestoneSyncEvent(progress, milestoneSyncEvent{
Type: "hardware",
HardwareCount: syncResult.HardwareCount,
})
log.Printf(
"Milestone sync completed successfully: recordingServerId=%s hardwareDevices=%d",
serverID,
syncedHardwareCount,
)
return syncResult, nil
}
func (s *Server) requestMilestoneServerID(
@ -498,6 +747,269 @@ func (s *Server) requestMilestoneServerID(
return recordingServerID, recordingServerName, recordingServerVersion, nil
}
func (s *Server) requestMilestoneHardwareDevices(
ctx context.Context,
host string,
recordingServerID string,
accessToken string,
skipTLSVerify bool,
) ([]milestoneHardwareDevice, error) {
hardwareItems, err := s.requestMilestoneHardware(
ctx,
host,
recordingServerID,
accessToken,
skipTLSVerify,
)
if err != nil {
return nil, err
}
result := make([]milestoneHardwareDevice, 0, len(hardwareItems))
for _, hardware := range hardwareItems {
hardwareID := strings.TrimSpace(hardware.ID)
if hardwareID == "" {
hardwareID = strings.TrimSpace(hardware.Relations.Self.ID)
}
if hardwareID == "" {
log.Printf("Milestone hardware skipped: missing hardware id")
continue
}
settings, err := s.requestMilestoneHardwareDriverSettings(
ctx,
host,
hardwareID,
accessToken,
skipTLSVerify,
)
if err != nil {
return nil, err
}
displayName := strings.TrimSpace(hardware.DisplayName)
if displayName == "" {
displayName = strings.TrimSpace(hardware.Name)
}
model := strings.TrimSpace(settings.DeviceModelName)
if model == "" {
model = strings.TrimSpace(settings.DetectedModelName)
}
manufacturer := normalizeMilestoneManufacturer(
settings.DeviceManufacturer,
model,
)
if manufacturer == "" {
manufacturer = normalizeMilestoneManufacturer(
settings.ProductID,
model,
)
}
if manufacturer == "" {
manufacturer = extractMilestoneManufacturerFromName(model)
}
deviceName := strings.TrimSpace(hardware.DeviceName)
if deviceName == "" {
deviceName = displayName
}
enabled := true
if hardware.Enabled != nil {
enabled = *hardware.Enabled
}
if hardware.Disabled != nil {
enabled = !*hardware.Disabled
}
ipAddress := extractMilestoneHardwareIPAddress(hardware.Address)
result = append(result, milestoneHardwareDevice{
HardwareID: hardwareID,
RecordingServerID: recordingServerID,
DisplayName: displayName,
DeviceName: deviceName,
Manufacturer: manufacturer,
MacAddress: strings.TrimSpace(settings.MacAddress),
IPAddress: ipAddress,
DeviceModelName: model,
SerialNumber: strings.TrimSpace(settings.SerialNumber),
FirmwareVersion: strings.TrimSpace(settings.FirmwareVersion),
Enabled: enabled,
})
}
log.Printf(
"Milestone hardware loaded successfully: recordingServerId=%s count=%d",
recordingServerID,
len(result),
)
return result, nil
}
func extractMilestoneHardwareIPAddress(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
parsedURL, err := url.Parse(value)
if err == nil && parsedURL.Hostname() != "" {
return parsedURL.Hostname()
}
value = strings.TrimPrefix(value, "http://")
value = strings.TrimPrefix(value, "https://")
value = strings.TrimRight(value, "/")
if host, _, found := strings.Cut(value, ":"); found {
return strings.TrimSpace(host)
}
return strings.TrimSpace(value)
}
func (s *Server) requestMilestoneHardware(
ctx context.Context,
host string,
recordingServerID string,
accessToken string,
skipTLSVerify bool,
) ([]milestoneHardware, error) {
requestURL := strings.TrimRight(host, "/") +
"/API/rest/v1/recordingServers/" +
url.PathEscape(recordingServerID) +
"/hardware?disabled"
log.Printf(
"Requesting Milestone hardware: url=%s skipTLSVerify=%t",
requestURL,
skipTLSVerify,
)
body, err := requestMilestoneJSON(
ctx,
requestURL,
accessToken,
skipTLSVerify,
)
if err != nil {
return nil, err
}
var hardwareResponse milestoneHardwareResponse
if err := json.Unmarshal(body, &hardwareResponse); err != nil {
return nil, fmt.Errorf(
"milestone hardware response could not be parsed: body=%s error=%w",
truncateMilestoneLogBody(body),
err,
)
}
return hardwareResponse.Array, nil
}
func (s *Server) requestMilestoneHardwareDriverSettings(
ctx context.Context,
host string,
hardwareID string,
accessToken string,
skipTLSVerify bool,
) (milestoneHardwareDriverSettings, error) {
requestURL := strings.TrimRight(host, "/") +
"/API/rest/v1/hardware/" +
url.PathEscape(hardwareID) +
"/hardwareDriverSettings"
log.Printf(
"Requesting Milestone hardware driver settings: url=%s skipTLSVerify=%t",
requestURL,
skipTLSVerify,
)
body, err := requestMilestoneJSON(
ctx,
requestURL,
accessToken,
skipTLSVerify,
)
if err != nil {
return milestoneHardwareDriverSettings{}, err
}
var settingsResponse milestoneHardwareDriverSettingsResponse
if err := json.Unmarshal(body, &settingsResponse); err != nil {
return milestoneHardwareDriverSettings{}, fmt.Errorf(
"milestone hardware driver settings response could not be parsed: body=%s error=%w",
truncateMilestoneLogBody(body),
err,
)
}
if len(settingsResponse.Array) == 0 {
return milestoneHardwareDriverSettings{}, fmt.Errorf(
"milestone returned no hardware driver settings: hardwareId=%s body=%s",
hardwareID,
truncateMilestoneLogBody(body),
)
}
return settingsResponse.Array[0].HardwareDriverSettings, nil
}
func requestMilestoneJSON(
ctx context.Context,
requestURL string,
accessToken string,
skipTLSVerify bool,
) ([]byte, error) {
request, err := http.NewRequestWithContext(
ctx,
http.MethodGet,
requestURL,
nil,
)
if err != nil {
return nil, err
}
request.Header.Set("Authorization", "Bearer "+accessToken)
request.Header.Set("Accept", "application/json")
client := milestoneHTTPClient(skipTLSVerify)
response, err := client.Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil {
return nil, err
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return nil, fmt.Errorf(
"milestone request failed: url=%s status=%d body=%s",
requestURL,
response.StatusCode,
truncateMilestoneLogBody(body),
)
}
return body, nil
}
func (s *Server) getMilestoneCredentials(ctx context.Context) (milestoneCredentials, error) {
var credentials milestoneCredentials

1015
backend/camera_firmware.go Normal file

File diff suppressed because it is too large Load Diff

View File

@ -45,6 +45,134 @@ type DashboardOperationChange struct {
CreatedAt time.Time `json:"createdAt"`
}
type DashboardSettings struct {
UserID string `json:"userId"`
Settings json.RawMessage `json:"settings"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type DashboardSettingsRequest struct {
Settings json.RawMessage `json:"settings"`
}
func scanDashboardSettings(scan func(dest ...any) error) (DashboardSettings, error) {
var settings DashboardSettings
var settingsRaw []byte
err := scan(
&settings.UserID,
&settingsRaw,
&settings.CreatedAt,
&settings.UpdatedAt,
)
if len(settingsRaw) == 0 {
settingsRaw = []byte(`{}`)
}
settings.Settings = json.RawMessage(settingsRaw)
return settings, err
}
func (s *Server) handleGetDashboardSettings(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
settings, err := scanDashboardSettings(func(dest ...any) error {
return s.db.QueryRow(
r.Context(),
`
INSERT INTO dashboard_settings (
user_id,
settings
)
VALUES (
$1,
'{}'::JSONB
)
ON CONFLICT (user_id)
DO UPDATE SET
user_id = EXCLUDED.user_id
RETURNING
user_id::TEXT,
settings,
created_at,
updated_at
`,
user.ID,
).Scan(dest...)
})
if err != nil {
writeError(w, http.StatusInternalServerError, "Dashboard-Einstellungen konnten nicht geladen werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"dashboardSettings": settings,
})
}
func (s *Server) handleUpdateDashboardSettings(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
var input DashboardSettingsRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON")
return
}
if len(input.Settings) == 0 || !json.Valid(input.Settings) {
input.Settings = json.RawMessage(`{}`)
}
settings, err := scanDashboardSettings(func(dest ...any) error {
return s.db.QueryRow(
r.Context(),
`
INSERT INTO dashboard_settings (
user_id,
settings
)
VALUES (
$1,
$2::JSONB
)
ON CONFLICT (user_id)
DO UPDATE SET
settings = EXCLUDED.settings,
updated_at = now()
RETURNING
user_id::TEXT,
settings,
created_at,
updated_at
`,
user.ID,
string(input.Settings),
).Scan(dest...)
})
if err != nil {
writeError(w, http.StatusInternalServerError, "Dashboard-Einstellungen konnten nicht gespeichert werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"dashboardSettings": settings,
})
}
func normalizeDashboardWidgetInput(input *DashboardWidgetRequest) {
input.Type = strings.TrimSpace(input.Type)
input.Title = strings.TrimSpace(input.Title)

118
backend/device_events.go Normal file
View File

@ -0,0 +1,118 @@
// backend/device_events.go
package main
import (
"context"
"encoding/json"
)
func (s *Server) publishDeviceChangeEvent(
ctx context.Context,
actorUserID string,
deviceID string,
notificationType string,
title string,
message string,
data map[string]any,
) error {
if data == nil {
data = map[string]any{}
}
data["deviceId"] = deviceID
data["actorUserId"] = actorUserID
dataJSON, err := json.Marshal(data)
if err != nil {
return err
}
rows, err := s.db.Query(
ctx,
`
SELECT id::TEXT
FROM users
WHERE 'admin' = ANY(rights)
OR 'devices:read' = ANY(rights)
OR 'devices:write' = ANY(rights)
`,
)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var userID string
if err := rows.Scan(&userID); err != nil {
return err
}
var notification Notification
err = s.db.QueryRow(
ctx,
`
INSERT INTO notifications (
user_id,
type,
title,
message,
visible,
entity_type,
entity_id,
data
)
VALUES (
$1,
$2,
$3,
$4,
false,
'device',
$5,
$6
)
RETURNING
id::TEXT,
user_id::TEXT,
type,
title,
message,
entity_type,
COALESCE(entity_id::TEXT, ''),
data,
visible,
read_at,
created_at
`,
userID,
notificationType,
title,
message,
deviceID,
dataJSON,
).Scan(
&notification.ID,
&notification.UserID,
&notification.Type,
&notification.Title,
&notification.Message,
&notification.EntityType,
&notification.EntityID,
&notification.Data,
&notification.Visible,
&notification.ReadAt,
&notification.CreatedAt,
)
if err != nil {
return err
}
notificationsBroker.publish(notification)
}
return rows.Err()
}

View File

@ -14,19 +14,45 @@ import (
)
type Device struct {
ID string `json:"id"`
InventoryNumber string `json:"inventoryNumber"`
Manufacturer string `json:"manufacturer"`
Model string `json:"model"`
SerialNumber string `json:"serialNumber"`
MacAddress string `json:"macAddress"`
Location string `json:"location"`
LoanStatus string `json:"loanStatus"`
IsBaoDevice bool `json:"isBaoDevice"`
Comment string `json:"comment"`
RelatedDevices []DeviceShort `json:"relatedDevices"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ID string `json:"id"`
InventoryNumber string `json:"inventoryNumber"`
Manufacturer string `json:"manufacturer"`
Model string `json:"model"`
SerialNumber string `json:"serialNumber"`
MacAddress string `json:"macAddress"`
IPAddress string `json:"ipAddress"`
Location string `json:"location"`
LoanStatus string `json:"loanStatus"`
IsBaoDevice bool `json:"isBaoDevice"`
Comment string `json:"comment"`
FirmwareVersion string `json:"firmwareVersion"`
FirmwareUpdate *DeviceFirmwareUpdate `json:"firmwareUpdate,omitempty"`
SupportBadges []DeviceSupportBadge `json:"supportBadges,omitempty"`
MilestoneRecordingServerID string `json:"milestoneRecordingServerId"`
MilestoneHardwareID string `json:"milestoneHardwareId"`
MilestoneDisplayName string `json:"milestoneDisplayName"`
MilestoneEnabled bool `json:"milestoneEnabled"`
MilestoneSyncedAt *time.Time `json:"milestoneSyncedAt,omitempty"`
RelatedDevices []DeviceShort `json:"relatedDevices"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type DeviceFirmwareUpdate struct {
Available bool `json:"available"`
CurrentVersion string `json:"currentVersion"`
LatestVersion string `json:"latestVersion"`
SupportURL string `json:"supportUrl"`
Source string `json:"source"`
CheckedAt *time.Time `json:"checkedAt,omitempty"`
}
type DeviceSupportBadge struct {
Type string `json:"type"`
Label string `json:"label"`
Message string `json:"message"`
Severity string `json:"severity"`
SupportURL string `json:"supportUrl"`
}
type DeviceShort struct {
@ -37,16 +63,19 @@ type DeviceShort struct {
}
type CreateDeviceRequest struct {
InventoryNumber string `json:"inventoryNumber"`
Manufacturer string `json:"manufacturer"`
Model string `json:"model"`
SerialNumber string `json:"serialNumber"`
MacAddress string `json:"macAddress"`
Location string `json:"location"`
LoanStatus string `json:"loanStatus"`
IsBaoDevice bool `json:"isBaoDevice"`
Comment string `json:"comment"`
RelatedDeviceIDs []string `json:"relatedDeviceIds"`
InventoryNumber string `json:"inventoryNumber"`
Manufacturer string `json:"manufacturer"`
Model string `json:"model"`
SerialNumber string `json:"serialNumber"`
MacAddress string `json:"macAddress"`
IPAddress string `json:"ipAddress"`
FirmwareVersion string `json:"firmwareVersion"`
MilestoneDisplayName string `json:"milestoneDisplayName"`
Location string `json:"location"`
LoanStatus string `json:"loanStatus"`
IsBaoDevice bool `json:"isBaoDevice"`
Comment string `json:"comment"`
RelatedDeviceIDs []string `json:"relatedDeviceIds"`
}
type UpdateDeviceRequest = CreateDeviceRequest
@ -75,10 +104,17 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
model,
serial_number,
mac_address,
COALESCE(ip_address, ''),
location,
loan_status,
is_bao_device,
comment,
COALESCE(firmware_version, ''),
COALESCE(milestone_recording_server_id, ''),
COALESCE(milestone_hardware_id, ''),
COALESCE(milestone_display_name, ''),
milestone_enabled,
milestone_synced_at,
created_at,
updated_at
FROM devices
@ -105,10 +141,17 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
&device.Model,
&device.SerialNumber,
&device.MacAddress,
&device.IPAddress,
&device.Location,
&device.LoanStatus,
&device.IsBaoDevice,
&device.Comment,
&device.FirmwareVersion,
&device.MilestoneRecordingServerID,
&device.MilestoneHardwareID,
&device.MilestoneDisplayName,
&device.MilestoneEnabled,
&device.MilestoneSyncedAt,
&device.CreatedAt,
&device.UpdatedAt,
); err != nil {
@ -175,6 +218,8 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
return
}
s.enrichDevicesWithFirmwareUpdates(r.Context(), devices)
writeJSON(w, http.StatusOK, map[string]any{
"devices": devices,
})
@ -224,12 +269,14 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) {
model,
serial_number,
mac_address,
ip_address,
location,
loan_status,
is_bao_device,
comment
comment,
firmware_version
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING
id,
inventory_number,
@ -237,10 +284,17 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) {
model,
serial_number,
mac_address,
COALESCE(ip_address, ''),
location,
loan_status,
is_bao_device,
comment,
COALESCE(firmware_version, ''),
COALESCE(milestone_recording_server_id, ''),
COALESCE(milestone_hardware_id, ''),
COALESCE(milestone_display_name, ''),
COALESCE(milestone_enabled, true),
milestone_synced_at,
created_at,
updated_at
`,
@ -249,10 +303,12 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) {
input.Model,
input.SerialNumber,
input.MacAddress,
input.IPAddress,
input.Location,
input.LoanStatus,
input.IsBaoDevice,
input.Comment,
input.FirmwareVersion,
).Scan(
&device.ID,
&device.InventoryNumber,
@ -260,10 +316,17 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) {
&device.Model,
&device.SerialNumber,
&device.MacAddress,
&device.IPAddress,
&device.Location,
&device.LoanStatus,
&device.IsBaoDevice,
&device.Comment,
&device.FirmwareVersion,
&device.MilestoneRecordingServerID,
&device.MilestoneHardwareID,
&device.MilestoneDisplayName,
&device.MilestoneEnabled,
&device.MilestoneSyncedAt,
&device.CreatedAt,
&device.UpdatedAt,
)
@ -308,6 +371,28 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) {
device.RelatedDevices = []DeviceShort{}
if err := s.publishDeviceChangeEvent(
r.Context(),
user.ID,
device.ID,
"device.created",
"Gerät erstellt",
formatText(
"Gerät %s wurde erstellt.",
device.InventoryNumber,
),
LogFields{
"inventoryNumber": device.InventoryNumber,
"manufacturer": device.Manufacturer,
"model": device.Model,
},
); err != nil {
logError("Device-SSE-Event konnte nicht gesendet werden", err, LogFields{
"deviceId": device.ID,
"type": "device.created",
})
}
writeJSON(w, http.StatusCreated, map[string]any{
"device": device,
})
@ -376,10 +461,13 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
model = $4,
serial_number = $5,
mac_address = $6,
location = $7,
loan_status = $8,
is_bao_device = $9,
comment = $10,
ip_address = $7,
location = $8,
loan_status = $9,
is_bao_device = $10,
comment = $11,
firmware_version = $12,
milestone_display_name = $13,
updated_at = now()
WHERE id = $1
RETURNING
@ -389,10 +477,17 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
model,
serial_number,
mac_address,
COALESCE(ip_address, ''),
location,
loan_status,
is_bao_device,
comment,
COALESCE(firmware_version, ''),
COALESCE(milestone_recording_server_id, ''),
COALESCE(milestone_hardware_id, ''),
COALESCE(milestone_display_name, ''),
COALESCE(milestone_enabled, true),
milestone_synced_at,
created_at,
updated_at
`,
@ -402,10 +497,13 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
input.Model,
input.SerialNumber,
input.MacAddress,
input.IPAddress,
input.Location,
input.LoanStatus,
input.IsBaoDevice,
input.Comment,
input.FirmwareVersion,
input.MilestoneDisplayName,
).Scan(
&device.ID,
&device.InventoryNumber,
@ -413,10 +511,17 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
&device.Model,
&device.SerialNumber,
&device.MacAddress,
&device.IPAddress,
&device.Location,
&device.LoanStatus,
&device.IsBaoDevice,
&device.Comment,
&device.FirmwareVersion,
&device.MilestoneRecordingServerID,
&device.MilestoneHardwareID,
&device.MilestoneDisplayName,
&device.MilestoneEnabled,
&device.MilestoneSyncedAt,
&device.CreatedAt,
&device.UpdatedAt,
)
@ -468,6 +573,29 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
device.RelatedDevices = []DeviceShort{}
if err := s.publishDeviceChangeEvent(
r.Context(),
user.ID,
device.ID,
"device.updated",
"Gerät geändert",
formatText(
"Gerät %s wurde geändert.",
device.InventoryNumber,
),
LogFields{
"inventoryNumber": device.InventoryNumber,
"manufacturer": device.Manufacturer,
"model": device.Model,
"changes": changes,
},
); err != nil {
logError("Device-SSE-Event konnte nicht gesendet werden", err, LogFields{
"deviceId": device.ID,
"type": "device.updated",
})
}
writeJSON(w, http.StatusOK, map[string]any{
"device": device,
})
@ -505,6 +633,9 @@ func normalizeDeviceInput(input *CreateDeviceRequest) {
input.Model = strings.TrimSpace(input.Model)
input.SerialNumber = strings.TrimSpace(input.SerialNumber)
input.MacAddress = strings.TrimSpace(input.MacAddress)
input.IPAddress = strings.TrimSpace(input.IPAddress)
input.FirmwareVersion = strings.TrimSpace(input.FirmwareVersion)
input.MilestoneDisplayName = strings.TrimSpace(input.MilestoneDisplayName)
input.Location = strings.TrimSpace(input.Location)
input.LoanStatus = strings.TrimSpace(input.LoanStatus)
input.Comment = strings.TrimSpace(input.Comment)

445
backend/feedback.go Normal file
View File

@ -0,0 +1,445 @@
// backend/feedback.go
package main
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
type CreateFeedbackRequest struct {
Message string `json:"message"`
}
type FeedbackResponse struct {
ID string `json:"id"`
UserID string `json:"userId"`
UserName string `json:"userName"`
UserEmail string `json:"userEmail"`
Message string `json:"message"`
Log json.RawMessage `json:"log"`
CreatedAt time.Time `json:"createdAt"`
}
func (s *Server) handleCreateFeedback(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
var input CreateFeedbackRequest
if err := readJSON(r, &input); err != nil {
s.logUserEvent(r.Context(), UserLogEntry{
User: &user,
Request: r,
Level: UserLogLevelWarning,
Category: "feedback",
Action: "read_request",
Message: "Feedback-Request konnte nicht gelesen werden",
Error: err,
})
writeError(w, http.StatusBadRequest, "Invalid JSON")
return
}
input.Message = strings.TrimSpace(input.Message)
if input.Message == "" {
writeError(w, http.StatusBadRequest, "Feedback darf nicht leer sein")
return
}
if len([]rune(input.Message)) > 5000 {
writeError(w, http.StatusBadRequest, "Feedback darf maximal 5000 Zeichen lang sein")
return
}
feedbackLog := map[string]any{
"user": buildUserLogSnapshot(user),
"request": mergeLogFields(
LogFields(buildRequestLogSnapshot(r)),
LogFields{
"contentType": r.Header.Get("Content-Type"),
},
),
"server": map[string]any{
"createdAt": time.Now().UTC().Format(time.RFC3339Nano),
},
}
logJSON, err := json.Marshal(feedbackLog)
if err != nil {
s.logAndWriteUserError(
w,
r,
user,
http.StatusInternalServerError,
"feedback",
"build_feedback_log",
"Feedback-Log konnte nicht erstellt werden",
err,
LogFields{
"messageLength": len([]rune(input.Message)),
},
)
return
}
var feedback FeedbackResponse
err = s.db.QueryRow(
r.Context(),
`
INSERT INTO feedback (
user_id,
message,
log
)
VALUES ($1, $2, $3)
RETURNING
id::TEXT,
user_id::TEXT,
$4::TEXT,
$5::TEXT,
message,
log,
created_at
`,
user.ID,
input.Message,
logJSON,
user.DisplayName,
user.Email,
).Scan(
&feedback.ID,
&feedback.UserID,
&feedback.UserName,
&feedback.UserEmail,
&feedback.Message,
&feedback.Log,
&feedback.CreatedAt,
)
if err != nil {
s.logAndWriteUserError(
w,
r,
user,
http.StatusInternalServerError,
"feedback",
"insert_feedback",
"Feedback konnte nicht gespeichert werden",
err,
LogFields{
"messageLength": len([]rune(input.Message)),
},
)
return
}
s.logUserEvent(r.Context(), UserLogEntry{
User: &user,
Request: r,
Level: UserLogLevelInfo,
Category: "feedback",
Action: "create_feedback",
Message: "Feedback wurde gespeichert",
Details: LogFields{
"feedbackId": feedback.ID,
"messageLength": len([]rune(input.Message)),
},
})
_ = s.notifyAdministratorsAboutFeedback(
r.Context(),
"feedback.created",
"Neues Feedback",
formatText("Neues Feedback von %s.", displayFeedbackUserName(feedback)),
feedback.ID,
true,
map[string]any{
"feedbackId": feedback.ID,
"userId": feedback.UserID,
"userName": feedback.UserName,
"userEmail": feedback.UserEmail,
},
)
writeJSON(w, http.StatusCreated, map[string]any{
"feedback": feedback,
})
}
func (s *Server) handleListFeedback(w http.ResponseWriter, r *http.Request) {
rows, err := s.db.Query(
r.Context(),
`
SELECT
f.id::TEXT,
f.user_id::TEXT,
COALESCE(NULLIF(u.display_name, ''), NULLIF(u.username, ''), u.email, 'Unbekannt') AS user_name,
COALESCE(u.email, '') AS user_email,
f.message,
f.log,
f.created_at
FROM feedback f
LEFT JOIN users u ON u.id = f.user_id
ORDER BY f.created_at DESC
LIMIT 200
`,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Feedback konnte nicht geladen werden")
return
}
defer rows.Close()
feedbackItems := make([]FeedbackResponse, 0)
for rows.Next() {
var feedback FeedbackResponse
if err := rows.Scan(
&feedback.ID,
&feedback.UserID,
&feedback.UserName,
&feedback.UserEmail,
&feedback.Message,
&feedback.Log,
&feedback.CreatedAt,
); err != nil {
writeError(w, http.StatusInternalServerError, "Feedback konnte nicht gelesen werden")
return
}
feedbackItems = append(feedbackItems, feedback)
}
if err := rows.Err(); err != nil {
writeError(w, http.StatusInternalServerError, "Feedback konnte nicht gelesen werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"feedback": feedbackItems,
})
}
func (s *Server) handleDeleteFeedback(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
feedbackID := strings.TrimSpace(r.PathValue("id"))
if feedbackID == "" {
writeError(w, http.StatusBadRequest, "Feedback-ID fehlt")
return
}
var deleted FeedbackResponse
err := s.db.QueryRow(
r.Context(),
`
DELETE FROM feedback f
USING users u
WHERE f.id = $1
AND u.id = f.user_id
RETURNING
f.id::TEXT,
f.user_id::TEXT,
COALESCE(NULLIF(u.display_name, ''), NULLIF(u.username, ''), u.email, 'Unbekannt') AS user_name,
COALESCE(u.email, '') AS user_email,
f.message,
f.log,
f.created_at
`,
feedbackID,
).Scan(
&deleted.ID,
&deleted.UserID,
&deleted.UserName,
&deleted.UserEmail,
&deleted.Message,
&deleted.Log,
&deleted.CreatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "Feedback wurde nicht gefunden")
return
}
if err != nil {
s.logAndWriteUserError(
w,
r,
user,
http.StatusInternalServerError,
"feedback",
"delete_feedback",
"Feedback konnte nicht gelöscht werden",
err,
LogFields{
"feedbackId": feedbackID,
},
)
return
}
s.logUserEvent(r.Context(), UserLogEntry{
User: &user,
Request: r,
Level: UserLogLevelInfo,
Category: "feedback",
Action: "delete_feedback",
Message: "Feedback wurde gelöscht",
Details: LogFields{
"feedbackId": deleted.ID,
"userId": deleted.UserID,
},
})
_ = s.notifyAdministratorsAboutFeedback(
r.Context(),
"feedback.deleted",
"Feedback gelöscht",
formatText("Feedback von %s wurde gelöscht.", displayFeedbackUserName(deleted)),
deleted.ID,
false,
map[string]any{
"feedbackId": deleted.ID,
"userId": deleted.UserID,
"userName": deleted.UserName,
"userEmail": deleted.UserEmail,
},
)
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
})
}
func displayFeedbackUserName(feedback FeedbackResponse) string {
if strings.TrimSpace(feedback.UserName) != "" {
return feedback.UserName
}
if strings.TrimSpace(feedback.UserEmail) != "" {
return feedback.UserEmail
}
return "Unbekannt"
}
func (s *Server) notifyAdministratorsAboutFeedback(
ctx context.Context,
notificationType string,
title string,
message string,
feedbackID string,
visible bool,
data map[string]any,
) error {
adminRows, err := s.db.Query(
ctx,
`
SELECT id::TEXT
FROM users
WHERE 'admin' = ANY(rights)
OR 'administration:read' = ANY(rights)
`,
)
if err != nil {
return err
}
defer adminRows.Close()
dataJSON, err := json.Marshal(data)
if err != nil {
return err
}
for adminRows.Next() {
var adminUserID string
if err := adminRows.Scan(&adminUserID); err != nil {
return err
}
var notification Notification
err = s.db.QueryRow(
ctx,
`
INSERT INTO notifications (
user_id,
type,
title,
message,
visible,
entity_type,
entity_id,
data
)
VALUES (
$1,
$2,
$3,
$4,
true,
'feedback',
$5,
$6,
$7
)
RETURNING
id::TEXT,
user_id::TEXT,
type,
title,
message,
entity_type,
COALESCE(entity_id::TEXT, ''),
data,
visible,
read_at,
created_at
`,
adminUserID,
notificationType,
title,
message,
visible,
feedbackID,
dataJSON,
).Scan(
&notification.ID,
&notification.UserID,
&notification.Type,
&notification.Title,
&notification.Message,
&notification.EntityType,
&notification.EntityID,
&notification.Data,
&notification.Visible,
&notification.ReadAt,
&notification.CreatedAt,
)
if err != nil {
return err
}
notificationsBroker.publish(notification)
}
return adminRows.Err()
}

158
backend/log.go Normal file
View File

@ -0,0 +1,158 @@
// backend/log.go
package main
import (
"encoding/json"
"errors"
"fmt"
"log"
"os"
"time"
)
type LogLevel string
const (
LogLevelInfo LogLevel = "info"
LogLevelWarning LogLevel = "warning"
LogLevelError LogLevel = "error"
)
type LogFields map[string]any
type AppError struct {
Message string
Err error
Fields LogFields
}
var appLogger = log.New(os.Stdout, "", 0)
func (e *AppError) Error() string {
if e == nil {
return ""
}
if e.Err == nil {
return e.Message
}
if e.Message == "" {
return e.Err.Error()
}
return e.Message + ": " + e.Err.Error()
}
func (e *AppError) Unwrap() error {
if e == nil {
return nil
}
return e.Err
}
func newErrorf(format string, args ...any) error {
return fmt.Errorf(format, args...)
}
func newAppError(message string, err error, fields LogFields) error {
return &AppError{
Message: message,
Err: err,
Fields: fields,
}
}
func newAppErrorf(message string, fields LogFields, format string, args ...any) error {
return newAppError(
message,
fmt.Errorf(format, args...),
fields,
)
}
func formatText(format string, args ...any) string {
return fmt.Sprintf(format, args...)
}
func logInfo(message string, fields LogFields) {
writeAppLog(LogLevelInfo, message, nil, fields)
}
func logInfof(format string, args ...any) {
writeAppLog(LogLevelInfo, fmt.Sprintf(format, args...), nil, nil)
}
func logWarning(message string, fields LogFields) {
writeAppLog(LogLevelWarning, message, nil, fields)
}
func logWarningf(format string, args ...any) {
writeAppLog(LogLevelWarning, fmt.Sprintf(format, args...), nil, nil)
}
func logError(message string, err error, fields LogFields) {
writeAppLog(LogLevelError, message, err, fields)
}
func logErrorf(err error, format string, args ...any) {
writeAppLog(LogLevelError, fmt.Sprintf(format, args...), err, nil)
}
func appErrorFields(err error) LogFields {
var appErr *AppError
if errors.As(err, &appErr) && appErr.Fields != nil {
return appErr.Fields
}
return nil
}
func mergeLogFields(base LogFields, extra LogFields) LogFields {
if len(base) == 0 && len(extra) == 0 {
return nil
}
merged := LogFields{}
for key, value := range base {
merged[key] = value
}
for key, value := range extra {
merged[key] = value
}
return merged
}
func writeAppLog(level LogLevel, message string, err error, fields LogFields) {
payload := map[string]any{
"timestamp": time.Now().UTC().Format(time.RFC3339Nano),
"level": level,
"message": message,
}
if err != nil {
payload["error"] = err.Error()
}
if len(fields) > 0 {
payload["fields"] = fields
}
data, marshalErr := json.Marshal(payload)
if marshalErr != nil {
appLogger.Printf(
`{"timestamp":"%s","level":"error","message":"could not marshal log payload","error":%q}`,
time.Now().UTC().Format(time.RFC3339Nano),
marshalErr.Error(),
)
return
}
appLogger.Println(string(data))
}

View File

@ -0,0 +1,354 @@
// backend\milestone_devices.go
package main
import (
"context"
"fmt"
"strings"
"unicode"
"github.com/jackc/pgx/v5"
)
func syncMilestoneHardwareDevices(
ctx context.Context,
tx pgx.Tx,
hardwareDevices []milestoneHardwareDevice,
) (int, error) {
syncedCount := 0
for _, hardwareDevice := range hardwareDevices {
hardwareDevice.HardwareID = strings.TrimSpace(hardwareDevice.HardwareID)
hardwareDevice.RecordingServerID = strings.TrimSpace(hardwareDevice.RecordingServerID)
hardwareDevice.DisplayName = strings.TrimSpace(hardwareDevice.DisplayName)
hardwareDevice.DeviceName = strings.TrimSpace(hardwareDevice.DeviceName)
hardwareDevice.Manufacturer = strings.TrimSpace(hardwareDevice.Manufacturer)
hardwareDevice.DeviceModelName = strings.TrimSpace(hardwareDevice.DeviceModelName)
hardwareDevice.SerialNumber = strings.TrimSpace(hardwareDevice.SerialNumber)
hardwareDevice.MacAddress = strings.TrimSpace(hardwareDevice.MacAddress)
hardwareDevice.IPAddress = strings.TrimSpace(hardwareDevice.IPAddress)
hardwareDevice.FirmwareVersion = strings.TrimSpace(hardwareDevice.FirmwareVersion)
if hardwareDevice.HardwareID == "" {
continue
}
if hardwareDevice.Manufacturer != "" {
if err := ensureOptionalDeviceLookupValue(
ctx,
tx,
"device_manufacturers",
hardwareDevice.Manufacturer,
); err != nil {
return syncedCount, err
}
}
synced, err := createOrUpdateMilestoneHardwareDevice(ctx, tx, hardwareDevice)
if err != nil {
return syncedCount, err
}
if synced {
syncedCount++
}
}
return syncedCount, nil
}
func createOrUpdateMilestoneHardwareDevice(
ctx context.Context,
tx pgx.Tx,
hardwareDevice milestoneHardwareDevice,
) (bool, error) {
inventoryNumber, err := buildMilestoneInventoryNumber(ctx, tx, hardwareDevice)
if err != nil {
return false, err
}
commandTag, err := tx.Exec(
ctx,
`
INSERT INTO devices (
inventory_number,
manufacturer,
model,
serial_number,
mac_address,
ip_address,
location,
loan_status,
is_bao_device,
comment,
firmware_version,
milestone_recording_server_id,
milestone_hardware_id,
milestone_display_name,
milestone_enabled,
milestone_synced_at
)
VALUES (
$1,
$2,
$3,
$4,
$5,
$6,
'',
'Verfügbar',
false,
$7,
$8,
$9,
$10,
$11,
$12,
now()
)
ON CONFLICT (milestone_hardware_id)
WHERE milestone_hardware_id <> ''
DO UPDATE SET
inventory_number = EXCLUDED.inventory_number,
manufacturer = EXCLUDED.manufacturer,
model = EXCLUDED.model,
serial_number = EXCLUDED.serial_number,
mac_address = EXCLUDED.mac_address,
ip_address = EXCLUDED.ip_address,
location = EXCLUDED.location,
comment = EXCLUDED.comment,
firmware_version = EXCLUDED.firmware_version,
milestone_recording_server_id = EXCLUDED.milestone_recording_server_id,
milestone_display_name = EXCLUDED.milestone_display_name,
milestone_enabled = EXCLUDED.milestone_enabled,
milestone_synced_at = now(),
updated_at = now()
`,
inventoryNumber,
hardwareDevice.Manufacturer,
hardwareDevice.DeviceModelName,
hardwareDevice.SerialNumber,
hardwareDevice.MacAddress,
hardwareDevice.IPAddress,
buildMilestoneDeviceComment(hardwareDevice),
hardwareDevice.FirmwareVersion,
hardwareDevice.RecordingServerID,
hardwareDevice.HardwareID,
hardwareDevice.DisplayName,
hardwareDevice.Enabled,
)
if err != nil {
return false, err
}
return commandTag.RowsAffected() > 0, nil
}
func normalizeMilestoneManufacturer(value string, model string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
model = strings.TrimSpace(model)
normalizedValue := strings.ToLower(value)
if strings.EqualFold(value, model) ||
strings.HasSuffix(normalizedValue, " device") ||
strings.Contains(normalizedValue, " camera") ||
strings.Contains(normalizedValue, " encoder") ||
strings.Contains(normalizedValue, " video") {
if manufacturer := extractMilestoneManufacturerFromName(value); manufacturer != "" {
return manufacturer
}
}
return value
}
func extractMilestoneManufacturerFromName(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
parts := strings.FieldsFunc(value, func(r rune) bool {
return unicode.IsSpace(r) ||
r == '_' ||
r == '-' ||
r == '/' ||
r == '\\' ||
r == '(' ||
r == ')' ||
r == ','
})
ignoredWords := map[string]bool{
"device": true,
"camera": true,
"video": true,
"encoder": true,
"ip": true,
"network": true,
"hardware": true,
}
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if isOnlyDigits(part) {
continue
}
if ignoredWords[strings.ToLower(part)] {
continue
}
return part
}
return ""
}
func buildMilestoneInventoryNumber(
ctx context.Context,
tx pgx.Tx,
hardwareDevice milestoneHardwareDevice,
) (string, error) {
inventoryNumber := extractInventoryNumberFromMilestoneDeviceName(
hardwareDevice.DeviceName,
)
if inventoryNumber == "" {
inventoryNumber = extractInventoryNumberFromMilestoneDeviceName(
hardwareDevice.DisplayName,
)
}
if inventoryNumber == "" {
inventoryNumber = buildGeneratedMilestoneInventoryNumber(
hardwareDevice.HardwareID,
)
}
var exists bool
err := tx.QueryRow(
ctx,
`
SELECT EXISTS(
SELECT 1
FROM devices
WHERE lower(inventory_number) = lower($1)
AND milestone_hardware_id <> $2
)
`,
inventoryNumber,
hardwareDevice.HardwareID,
).Scan(&exists)
if err != nil {
return "", err
}
if !exists {
return inventoryNumber, nil
}
suffix := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(hardwareDevice.HardwareID), "-", ""))
if len(suffix) > 8 {
suffix = suffix[:8]
}
if suffix == "" {
suffix = "MS"
}
return inventoryNumber + "-" + suffix, nil
}
func extractInventoryNumberFromMilestoneDeviceName(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
parts := strings.FieldsFunc(value, func(r rune) bool {
return r == '_' || r == '-' || unicode.IsSpace(r)
})
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if isOnlyDigits(part) {
return part
}
}
return ""
}
func isOnlyDigits(value string) bool {
if value == "" {
return false
}
for _, r := range value {
if r < '0' || r > '9' {
return false
}
}
return true
}
func buildGeneratedMilestoneInventoryNumber(hardwareID string) string {
value := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(hardwareID), "-", ""))
if len(value) > 12 {
value = value[:12]
}
if value == "" {
value = "UNKNOWN"
}
return "MS-" + value
}
func buildMilestoneDeviceComment(hardwareDevice milestoneHardwareDevice) string {
parts := []string{}
if strings.TrimSpace(hardwareDevice.DisplayName) != "" {
parts = append(parts, strings.TrimSpace(hardwareDevice.DisplayName))
}
if strings.TrimSpace(hardwareDevice.DeviceName) != "" &&
strings.TrimSpace(hardwareDevice.DeviceName) != strings.TrimSpace(hardwareDevice.DisplayName) {
parts = append(parts, "DeviceName: "+strings.TrimSpace(hardwareDevice.DeviceName))
}
if hardwareDevice.Enabled {
parts = append(parts, "Status: aktiviert")
} else {
parts = append(parts, "Status: deaktiviert")
}
if len(parts) == 0 {
return "Automatisch aus Milestone synchronisiert."
}
return fmt.Sprintf(
"Automatisch aus Milestone synchronisiert: %s",
strings.Join(parts, " · "),
)
}

View File

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

View File

@ -0,0 +1,160 @@
// backend\notification_preferences.go
package main
import (
"errors"
"net/http"
"github.com/jackc/pgx/v5"
)
type notificationPreferencesResponse struct {
InAppEnabled bool `json:"inAppEnabled"`
SoundEnabled bool `json:"soundEnabled"`
EmailEnabled bool `json:"emailEnabled"`
OperationUpdates bool `json:"operationUpdates"`
OperationJournals bool `json:"operationJournals"`
DeviceUpdates bool `json:"deviceUpdates"`
DeviceJournals bool `json:"deviceJournals"`
SystemMessages bool `json:"systemMessages"`
}
func defaultNotificationPreferences() notificationPreferencesResponse {
return notificationPreferencesResponse{
InAppEnabled: true,
SoundEnabled: false,
EmailEnabled: false,
OperationUpdates: true,
OperationJournals: true,
DeviceUpdates: true,
DeviceJournals: true,
SystemMessages: true,
}
}
func (s *Server) handleGetNotificationPreferences(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
settings, err := s.getNotificationPreferences(r, user.ID)
if errors.Is(err, pgx.ErrNoRows) {
writeJSON(w, http.StatusOK, map[string]any{
"settings": defaultNotificationPreferences(),
})
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "Benachrichtigungseinstellungen konnten nicht geladen werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"settings": settings,
})
}
func (s *Server) handleUpdateNotificationPreferences(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
var input notificationPreferencesResponse
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON")
return
}
_, err := s.db.Exec(
r.Context(),
`
INSERT INTO notification_preferences (
user_id,
in_app_enabled,
sound_enabled,
email_enabled,
operation_updates,
operation_journals,
device_updates,
device_journals,
system_messages
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (user_id)
DO UPDATE SET
in_app_enabled = EXCLUDED.in_app_enabled,
sound_enabled = EXCLUDED.sound_enabled,
email_enabled = EXCLUDED.email_enabled,
operation_updates = EXCLUDED.operation_updates,
operation_journals = EXCLUDED.operation_journals,
device_updates = EXCLUDED.device_updates,
device_journals = EXCLUDED.device_journals,
system_messages = EXCLUDED.system_messages,
updated_at = now()
`,
user.ID,
input.InAppEnabled,
input.SoundEnabled,
input.EmailEnabled,
input.OperationUpdates,
input.OperationJournals,
input.DeviceUpdates,
input.DeviceJournals,
input.SystemMessages,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Benachrichtigungseinstellungen konnten nicht gespeichert werden")
return
}
settings, err := s.getNotificationPreferences(r, user.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "Benachrichtigungseinstellungen wurden gespeichert, konnten aber nicht neu geladen werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"settings": settings,
})
}
func (s *Server) getNotificationPreferences(r *http.Request, userID string) (notificationPreferencesResponse, error) {
settings := defaultNotificationPreferences()
err := s.db.QueryRow(
r.Context(),
`
SELECT
in_app_enabled,
sound_enabled,
email_enabled,
operation_updates,
operation_journals,
device_updates,
device_journals,
system_messages
FROM notification_preferences
WHERE user_id = $1
LIMIT 1
`,
userID,
).Scan(
&settings.InAppEnabled,
&settings.SoundEnabled,
&settings.EmailEnabled,
&settings.OperationUpdates,
&settings.OperationJournals,
&settings.DeviceUpdates,
&settings.DeviceJournals,
&settings.SystemMessages,
)
return settings, err
}

View File

@ -144,7 +144,7 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
r.Context(),
`
SELECT
id,
id::TEXT,
user_id::TEXT,
type,
title,
@ -152,6 +152,7 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
entity_type,
COALESCE(entity_id::TEXT, ''),
data,
visible,
read_at,
created_at
FROM notifications
@ -185,6 +186,7 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
&notification.EntityType,
&notification.EntityID,
&data,
&notification.Visible,
&readAt,
&notification.CreatedAt,
); err != nil {
@ -283,9 +285,13 @@ func (s *Server) createOperationNotification(
rows, err := s.db.Query(
ctx,
`
SELECT id
SELECT id::TEXT
FROM users
WHERE id <> $1
AND (
'admin' = ANY(rights)
OR 'operations:read' = ANY(rights)
)
`,
actor.ID,
)
@ -294,18 +300,13 @@ func (s *Server) createOperationNotification(
}
defer rows.Close()
recipientIDs := []string{}
for rows.Next() {
var userID string
if err := rows.Scan(&userID); err != nil {
var recipientID string
if err := rows.Scan(&recipientID); err != nil {
return err
}
recipientIDs = append(recipientIDs, userID)
}
for _, recipientID := range recipientIDs {
var notification Notification
var rawData []byte
@ -319,11 +320,21 @@ func (s *Server) createOperationNotification(
message,
entity_type,
entity_id,
data
data,
visible
)
VALUES (
$1,
$2,
$3,
$4,
'operation',
$5,
$6::JSONB,
true
)
VALUES ($1, $2, $3, $4, 'operation', $5, $6::JSONB)
RETURNING
id,
id::TEXT,
user_id::TEXT,
type,
title,
@ -331,6 +342,7 @@ func (s *Server) createOperationNotification(
entity_type,
COALESCE(entity_id::TEXT, ''),
data,
visible,
created_at
`,
recipientID,
@ -348,6 +360,109 @@ func (s *Server) createOperationNotification(
&notification.EntityType,
&notification.EntityID,
&rawData,
&notification.Visible,
&notification.CreatedAt,
)
if err != nil {
return err
}
notification.Data = json.RawMessage(rawData)
notificationsBroker.publish(notification)
}
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
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
}
var notification Notification
var rawData []byte
err := s.db.QueryRow(
ctx,
`
INSERT INTO notifications (
user_id,
type,
title,
message,
entity_type,
entity_id,
data,
visible
)
VALUES (
$1,
'operation.updated',
'Einsatz geändert',
'',
'operation',
$2,
$3::JSONB,
false
)
RETURNING
id::TEXT,
user_id::TEXT,
type,
title,
message,
entity_type,
COALESCE(entity_id::TEXT, ''),
data,
visible,
created_at
`,
recipientID,
operation.ID,
string(dataJSON),
).Scan(
&notification.ID,
&notification.UserID,
&notification.Type,
&notification.Title,
&notification.Message,
&notification.EntityType,
&notification.EntityID,
&rawData,
&notification.Visible,
&notification.CreatedAt,
)
@ -359,7 +474,7 @@ func (s *Server) createOperationNotification(
notificationsBroker.publish(notification)
}
return nil
return rows.Err()
}
func (s *Server) createOperationJournalEvent(

View File

@ -500,20 +500,19 @@ func (s *Server) handleUpdateOperation(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.createOperationNotification(
r.Context(),
user,
operation,
"operation.updated",
"Einsatz geändert",
fmt.Sprintf("Einsatz %s wurde geändert.", operation.OperationNumber),
map[string]any{
"operationNumber": operation.OperationNumber,
"operationName": operation.OperationName,
"changes": changes,
},
); err != nil {
// nicht abbrechen, Änderung wurde bereits gespeichert
if len(changes) > 0 {
if err := s.createOperationUpdateEvent(
r.Context(),
user,
operation,
map[string]any{
"operationNumber": operation.OperationNumber,
"operationName": operation.OperationName,
"changes": changes,
},
); err != nil {
// nicht abbrechen, Änderung wurde bereits gespeichert
}
}
writeJSON(w, http.StatusOK, map[string]any{

View File

@ -1,4 +1,4 @@
// backend\routes.go
// backend/routes.go
package main
@ -16,17 +16,26 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("GET /settings/sessions", s.requireAuth(s.handleListUserSessions))
mux.HandleFunc("DELETE /settings/sessions/{id}", s.requireAuth(s.handleRevokeUserSession))
mux.HandleFunc("PUT /settings/profile", s.requireAuth(s.handleUpdateSettingsProfile))
mux.HandleFunc("PUT /settings/account", s.requireAuth(s.handleUpdateSettingsAccount))
mux.HandleFunc("PUT /settings/password", s.requireAuth(s.handleUpdateSettingsPassword))
mux.HandleFunc("POST /settings/logout-other-sessions", s.requireAuth(s.handleLogoutOtherSessions))
mux.HandleFunc("DELETE /settings/account", s.requireAuth(s.handleDeleteOwnAccount))
mux.HandleFunc("GET /settings/notifications/preferences", s.requireAuth(s.handleGetNotificationPreferences))
mux.HandleFunc("PUT /settings/notifications/preferences", s.requireAuth(s.handleUpdateNotificationPreferences))
mux.HandleFunc("POST /feedback", s.requireAuth(s.handleCreateFeedback))
mux.HandleFunc("GET /devices", s.requireAuth(s.handleListDevices))
mux.HandleFunc("POST /devices", s.requireAuth(s.handleCreateDevice))
mux.HandleFunc("PUT /devices/{id}", s.requireAuth(s.handleUpdateDevice))
mux.HandleFunc("POST /devices/{id}/milestone-toggle", s.requireAuth(s.handleToggleMilestoneDeviceStatus))
mux.HandleFunc("GET /devices/{id}/history", s.requireAuth(s.handleListDeviceHistory))
mux.HandleFunc("POST /devices/{id}/history", s.requireAuth(s.handleCreateDeviceJournalEntry))
mux.HandleFunc("GET /devices/firmware-check", s.requireAuth(s.handleGetCameraFirmwareCheckState))
mux.HandleFunc("POST /devices/firmware-check", s.requireAuth(s.handleCheckCameraFirmware))
mux.HandleFunc("GET /device-lookups", s.handleDeviceLookups)
mux.HandleFunc("POST /device-manufacturers", s.handleCreateDeviceManufacturer)
mux.HandleFunc("POST /device-locations", s.handleCreateDeviceLocation)
@ -52,6 +61,9 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("GET /dashboard/operation-changes", s.requireAuth(s.requireRight("operations:read", s.handleDashboardOperationChanges)))
mux.HandleFunc("GET /dashboard/settings", s.requireAuth(s.handleGetDashboardSettings))
mux.HandleFunc("PUT /dashboard/settings", s.requireAuth(s.handleUpdateDashboardSettings))
mux.HandleFunc("GET /dashboard/widgets", s.requireAuth(s.handleListDashboardWidgets))
mux.HandleFunc("POST /dashboard/widgets", s.requireAuth(s.handleCreateDashboardWidget))
mux.HandleFunc("PUT /dashboard/widgets/{id}", s.requireAuth(s.handleUpdateDashboardWidget))
@ -67,11 +79,15 @@ func (s *Server) Routes() http.Handler {
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("GET /admin/feedback", s.requireAuth(s.requireRight("administration:read", s.handleListFeedback)))
mux.HandleFunc("DELETE /admin/feedback/{id}", s.requireAuth(s.requireRight("administration:write", s.handleDeleteFeedback)))
/* milestone settings */
mux.HandleFunc("GET /admin/milestone", s.requireAuth(s.requireRight("administration:read", s.handleGetMilestoneSettings)))
mux.HandleFunc("PUT /admin/milestone", s.requireAuth(s.requireRight("administration:write", s.handleUpdateMilestoneSettings)))
mux.HandleFunc("POST /admin/milestone/token", s.requireAuth(s.requireRight("administration:write", s.handleFetchMilestoneToken)))
mux.HandleFunc("POST /admin/milestone/token/stream", s.requireAuth(s.requireRight("administration:write", s.handleFetchMilestoneTokenStream)))
return s.cors(mux)
}

View File

@ -7,6 +7,7 @@ import (
"net/http"
"strconv"
"strings"
"time"
)
type globalSearchResponse struct {
@ -30,6 +31,13 @@ type globalSearchItem struct {
Data map[string]any `json:"data,omitempty"`
}
type globalSearchOptions struct {
Query string
Limit int
ExactPhrase bool
CaseSensitive bool
}
func (s *Server) handleGlobalSearch(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
@ -38,7 +46,17 @@ func (s *Server) handleGlobalSearch(w http.ResponseWriter, r *http.Request) {
}
query := strings.TrimSpace(r.URL.Query().Get("q"))
limit := parseSearchLimit(r.URL.Query().Get("limit"), 8)
options := globalSearchOptions{
Query: query,
Limit: parseSearchLimit(r.URL.Query().Get("limit"), 8),
ExactPhrase: parseSearchBool(r.URL.Query().Get("exact")),
CaseSensitive: parseSearchBool(r.URL.Query().Get("caseSensitive")),
}
if !strings.Contains(query, " ") {
options.ExactPhrase = false
}
if len([]rune(query)) < 2 {
writeJSON(w, http.StatusOK, globalSearchResponse{
@ -48,10 +66,10 @@ func (s *Server) handleGlobalSearch(w http.ResponseWriter, r *http.Request) {
return
}
groups := make([]globalSearchGroup, 0, 3)
groups := make([]globalSearchGroup, 0, 5)
if userCanSearch(user, "operations:read") {
items, err := s.searchOperations(r.Context(), query, limit)
items, err := s.searchOperations(r.Context(), options)
if err != nil {
writeError(w, http.StatusInternalServerError, "Einsätze konnten nicht durchsucht werden")
return
@ -66,8 +84,40 @@ func (s *Server) handleGlobalSearch(w http.ResponseWriter, r *http.Request) {
}
}
if userCanSearch(user, "operations:read") {
items, err := s.searchOperationJournalEntries(r.Context(), options)
if err != nil {
writeError(w, http.StatusInternalServerError, "Einsatz-Journale konnten nicht durchsucht werden")
return
}
if len(items) > 0 {
groups = append(groups, globalSearchGroup{
ID: "operation-journals",
Title: "Einsatz-Journale",
Items: items,
})
}
}
if userCanSearch(user, "devices:read") {
items, err := s.searchDevices(r.Context(), query, limit)
items, err := s.searchDeviceJournalEntries(r.Context(), options)
if err != nil {
writeError(w, http.StatusInternalServerError, "Geräte-Journale konnten nicht durchsucht werden")
return
}
if len(items) > 0 {
groups = append(groups, globalSearchGroup{
ID: "device-journals",
Title: "Geräte-Journale",
Items: items,
})
}
}
if userCanSearch(user, "devices:read") {
items, err := s.searchDevices(r.Context(), options)
if err != nil {
writeError(w, http.StatusInternalServerError, "Geräte konnten nicht durchsucht werden")
return
@ -83,7 +133,7 @@ func (s *Server) handleGlobalSearch(w http.ResponseWriter, r *http.Request) {
}
if userCanSearch(user, "users:read") {
items, err := s.searchUsers(r.Context(), query, limit)
items, err := s.searchUsers(r.Context(), options)
if err != nil {
writeError(w, http.StatusInternalServerError, "Benutzer konnten nicht durchsucht werden")
return
@ -104,8 +154,8 @@ func (s *Server) handleGlobalSearch(w http.ResponseWriter, r *http.Request) {
})
}
func (s *Server) searchOperations(ctx context.Context, query string, limit int) ([]globalSearchItem, error) {
pattern := "%" + query + "%"
func (s *Server) searchOperations(ctx context.Context, options globalSearchOptions) ([]globalSearchItem, error) {
patterns := buildSearchPatterns(options)
rows, err := s.db.Query(
ctx,
@ -119,24 +169,47 @@ func (s *Server) searchOperations(ctx context.Context, query string, limit int)
operation_leader,
operation_team
FROM operations
WHERE CONCAT_WS(
' ',
operation_number,
operation_name,
offense,
case_worker,
target_object,
kw_address,
operation_leader,
operation_team,
legend,
remark
) ILIKE $1
WHERE (
(
$3 = false
AND CONCAT_WS(
' ',
operation_number,
operation_name,
offense,
case_worker,
target_object,
kw_address,
operation_leader,
operation_team,
legend,
remark
) ILIKE ALL($1::TEXT[])
)
OR
(
$3 = true
AND CONCAT_WS(
' ',
operation_number,
operation_name,
offense,
case_worker,
target_object,
kw_address,
operation_leader,
operation_team,
legend,
remark
) LIKE ALL($1::TEXT[])
)
)
ORDER BY updated_at DESC
LIMIT $2
`,
pattern,
limit,
patterns,
options.Limit,
options.CaseSensitive,
)
if err != nil {
return nil, err
@ -195,8 +268,8 @@ func (s *Server) searchOperations(ctx context.Context, query string, limit int)
return items, rows.Err()
}
func (s *Server) searchDevices(ctx context.Context, query string, limit int) ([]globalSearchItem, error) {
pattern := "%" + query + "%"
func (s *Server) searchDevices(ctx context.Context, options globalSearchOptions) ([]globalSearchItem, error) {
patterns := buildSearchPatterns(options)
rows, err := s.db.Query(
ctx,
@ -208,25 +281,52 @@ func (s *Server) searchDevices(ctx context.Context, query string, limit int) ([]
model,
serial_number,
mac_address,
location,
loan_status
FROM devices
WHERE CONCAT_WS(
' ',
inventory_number,
manufacturer,
model,
serial_number,
mac_address,
COALESCE(ip_address, ''),
location,
loan_status,
comment
) ILIKE $1
COALESCE(firmware_version, '')
FROM devices
WHERE (
(
$3 = false
AND CONCAT_WS(
' ',
inventory_number,
manufacturer,
model,
serial_number,
mac_address,
COALESCE(ip_address, ''),
location,
loan_status,
comment,
COALESCE(firmware_version, '')
) ILIKE ALL($1::TEXT[])
)
OR
(
$3 = true
AND CONCAT_WS(
' ',
inventory_number,
manufacturer,
model,
serial_number,
mac_address,
COALESCE(ip_address, ''),
location,
loan_status,
comment,
COALESCE(firmware_version, '')
) LIKE ALL($1::TEXT[])
)
)
ORDER BY updated_at DESC
LIMIT $2
`,
pattern,
limit,
patterns,
options.Limit,
options.CaseSensitive,
)
if err != nil {
return nil, err
@ -242,8 +342,10 @@ func (s *Server) searchDevices(ctx context.Context, query string, limit int) ([]
var model string
var serialNumber string
var macAddress string
var ipAddress string
var location string
var loanStatus string
var firmwareVersion string
if err := rows.Scan(
&id,
@ -252,8 +354,10 @@ func (s *Server) searchDevices(ctx context.Context, query string, limit int) ([]
&model,
&serialNumber,
&macAddress,
&ipAddress,
&location,
&loanStatus,
&firmwareVersion,
); err != nil {
return nil, err
}
@ -268,8 +372,10 @@ func (s *Server) searchDevices(ctx context.Context, query string, limit int) ([]
model,
serialNumber,
macAddress,
ipAddress,
location,
loanStatus,
firmwareVersion,
),
Href: "/geraete",
Data: map[string]any{
@ -281,8 +387,201 @@ func (s *Server) searchDevices(ctx context.Context, query string, limit int) ([]
return items, rows.Err()
}
func (s *Server) searchUsers(ctx context.Context, query string, limit int) ([]globalSearchItem, error) {
pattern := "%" + query + "%"
func (s *Server) searchOperationJournalEntries(ctx context.Context, options globalSearchOptions) ([]globalSearchItem, error) {
patterns := buildSearchPatterns(options)
rows, err := s.db.Query(
ctx,
`
SELECT
operation_history.id::TEXT,
operation_history.operation_id::TEXT,
operations.operation_number,
operations.operation_name,
COALESCE(users.display_name, users.username, users.email, 'Unbekannt'),
COALESCE(journal_change.change->>'newValue', ''),
operation_history.created_at
FROM operation_history
INNER JOIN operations
ON operations.id = operation_history.operation_id
LEFT JOIN users
ON users.id = operation_history.user_id
CROSS JOIN LATERAL jsonb_array_elements(operation_history.changes) AS journal_change(change)
WHERE
journal_change.change->>'field' = 'journal'
AND (
(
$3 = false
AND journal_change.change->>'newValue' ILIKE ALL($1::TEXT[])
)
OR
(
$3 = true
AND journal_change.change->>'newValue' LIKE ALL($1::TEXT[])
)
)
ORDER BY operation_history.created_at DESC
LIMIT $2
`,
patterns,
options.Limit,
options.CaseSensitive,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []globalSearchItem{}
for rows.Next() {
var id string
var operationID string
var operationNumber string
var operationName string
var userDisplayName string
var content string
var createdAt time.Time
if err := rows.Scan(
&id,
&operationID,
&operationNumber,
&operationName,
&userDisplayName,
&content,
&createdAt,
); err != nil {
return nil, err
}
title := operationName
if strings.TrimSpace(title) == "" {
title = operationNumber
}
items = append(items, globalSearchItem{
ID: "operation-journal-" + id,
EntityID: operationID,
EntityType: "operationJournal",
Title: title,
Subtitle: joinSearchSubtitle(
formatSearchDate(createdAt),
userDisplayName,
truncateSearchText(content, 140),
),
Href: "/einsaetze",
Data: map[string]any{
"historyId": id,
"operationId": operationID,
"operationNumber": operationNumber,
"journalCreatedAt": createdAt,
},
})
}
return items, rows.Err()
}
func (s *Server) searchDeviceJournalEntries(ctx context.Context, options globalSearchOptions) ([]globalSearchItem, error) {
patterns := buildSearchPatterns(options)
rows, err := s.db.Query(
ctx,
`
SELECT
device_history.id::TEXT,
device_history.device_id::TEXT,
devices.inventory_number,
devices.manufacturer,
devices.model,
COALESCE(users.display_name, users.username, users.email, 'Unbekannt'),
COALESCE(journal_change.change->>'newValue', ''),
device_history.created_at
FROM device_history
INNER JOIN devices
ON devices.id = device_history.device_id
LEFT JOIN users
ON users.id = device_history.user_id
CROSS JOIN LATERAL jsonb_array_elements(device_history.changes) AS journal_change(change)
WHERE
journal_change.change->>'field' = 'journal'
AND (
(
$3 = false
AND journal_change.change->>'newValue' ILIKE ALL($1::TEXT[])
)
OR
(
$3 = true
AND journal_change.change->>'newValue' LIKE ALL($1::TEXT[])
)
)
ORDER BY device_history.created_at DESC
LIMIT $2
`,
patterns,
options.Limit,
options.CaseSensitive,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []globalSearchItem{}
for rows.Next() {
var id string
var deviceID string
var inventoryNumber string
var manufacturer string
var model string
var userDisplayName string
var content string
var createdAt time.Time
if err := rows.Scan(
&id,
&deviceID,
&inventoryNumber,
&manufacturer,
&model,
&userDisplayName,
&content,
&createdAt,
); err != nil {
return nil, err
}
deviceName := joinSearchSubtitle(manufacturer, model)
items = append(items, globalSearchItem{
ID: "device-journal-" + id,
EntityID: deviceID,
EntityType: "deviceJournal",
Title: inventoryNumber,
Subtitle: joinSearchSubtitle(
deviceName,
formatSearchDate(createdAt),
userDisplayName,
truncateSearchText(content, 140),
),
Href: "/geraete",
Data: map[string]any{
"historyId": id,
"deviceId": deviceID,
"inventoryNumber": inventoryNumber,
"journalCreatedAt": createdAt,
},
})
}
return items, rows.Err()
}
func (s *Server) searchUsers(ctx context.Context, options globalSearchOptions) ([]globalSearchItem, error) {
patterns := buildSearchPatterns(options)
rows, err := s.db.Query(
ctx,
@ -291,23 +590,41 @@ func (s *Server) searchUsers(ctx context.Context, query string, limit int) ([]gl
id::TEXT,
COALESCE(username, ''),
COALESCE(display_name, ''),
email,
COALESCE(email, ''),
COALESCE(unit, ''),
COALESCE(user_group, '')
FROM users
WHERE CONCAT_WS(
' ',
username,
display_name,
email,
unit,
user_group
) ILIKE $1
WHERE (
(
$3 = false
AND CONCAT_WS(
' ',
username,
display_name,
email,
unit,
user_group
) ILIKE ALL($1::TEXT[])
)
OR
(
$3 = true
AND CONCAT_WS(
' ',
username,
display_name,
email,
unit,
user_group
) LIKE ALL($1::TEXT[])
)
)
ORDER BY display_name ASC, username ASC, email ASC
LIMIT $2
`,
pattern,
limit,
patterns,
options.Limit,
options.CaseSensitive,
)
if err != nil {
return nil, err
@ -377,6 +694,43 @@ func parseSearchLimit(value string, fallback int) int {
return limit
}
func parseSearchBool(value string) bool {
value = strings.ToLower(strings.TrimSpace(value))
return value == "1" ||
value == "true" ||
value == "yes" ||
value == "on"
}
func buildSearchPatterns(options globalSearchOptions) []string {
query := strings.TrimSpace(options.Query)
if query == "" {
return []string{"%%"}
}
if options.ExactPhrase && strings.Contains(query, " ") {
return []string{"%" + query + "%"}
}
parts := strings.Fields(query)
patterns := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
patterns = append(patterns, "%"+part+"%")
}
}
if len(patterns) == 0 {
return []string{"%" + query + "%"}
}
return patterns
}
func userCanSearch(user User, right string) bool {
for _, userRight := range user.Rights {
if userRight == "admin" || userRight == right {
@ -399,3 +753,26 @@ func joinSearchSubtitle(values ...string) string {
return strings.Join(parts, " · ")
}
func formatSearchDate(value time.Time) string {
if value.IsZero() {
return ""
}
return value.Format("02.01.2006 15:04")
}
func truncateSearchText(value string, maxLength int) string {
value = strings.TrimSpace(value)
if maxLength <= 0 {
return value
}
runes := []rune(value)
if len(runes) <= maxLength {
return value
}
return strings.TrimSpace(string(runes[:maxLength])) + "…"
}

View File

@ -3,6 +3,7 @@
package main
import (
"bufio"
"context"
"crypto/rand"
"encoding/base64"
@ -46,6 +47,11 @@ func main() {
if *reset {
if err := dropDatabaseIfExists(ctx, parsedURL, databaseName); err != nil {
if errors.Is(err, errResetAborted) {
fmt.Println("Reset abgebrochen. Es wurden keine Daten gelöscht.")
return
}
log.Fatal(err)
}
}
@ -74,6 +80,28 @@ func main() {
}
printAdminResult(password, created)
waitForExit()
}
var errResetAborted = errors.New("reset abgebrochen")
func confirmDatabaseReset(databaseName string) (bool, error) {
reader := bufio.NewReader(os.Stdin)
fmt.Println("")
fmt.Println("WARNUNG: Der Datenbank-Reset löscht alle Daten unwiderruflich.")
fmt.Printf("Datenbank: %s\n", databaseName)
fmt.Println("")
fmt.Printf("Bitte gib den Datenbanknamen %q ein, um fortzufahren: ", databaseName)
input, err := reader.ReadString('\n')
if err != nil {
return false, err
}
input = strings.TrimSpace(input)
return input == databaseName, nil
}
func dropDatabaseIfExists(ctx context.Context, parsedURL *url.URL, databaseName string) error {
@ -107,6 +135,15 @@ func dropDatabaseIfExists(ctx context.Context, parsedURL *url.URL, databaseName
return nil
}
confirmed, err := confirmDatabaseReset(databaseName)
if err != nil {
return err
}
if !confirmed {
return errResetAborted
}
fmt.Printf("Datenbank %q wird zurückgesetzt...\n", databaseName)
_, err = db.Exec(
@ -259,6 +296,17 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
)
`,
`
CREATE TABLE IF NOT EXISTS dashboard_settings (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
settings JSONB NOT NULL DEFAULT '{}'::JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`,
`
CREATE TABLE IF NOT EXISTS devices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@ -268,11 +316,19 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
model TEXT NOT NULL DEFAULT '',
serial_number TEXT NOT NULL DEFAULT '',
mac_address TEXT NOT NULL DEFAULT '',
ip_address TEXT NOT NULL DEFAULT '',
location TEXT NOT NULL DEFAULT '',
loan_status TEXT NOT NULL DEFAULT 'Verfügbar',
is_bao_device BOOLEAN NOT NULL DEFAULT false,
comment TEXT NOT NULL DEFAULT '',
milestone_recording_server_id TEXT NOT NULL DEFAULT '',
milestone_hardware_id TEXT NOT NULL DEFAULT '',
milestone_display_name TEXT NOT NULL DEFAULT '',
firmware_version TEXT NOT NULL DEFAULT '',
milestone_enabled BOOLEAN NOT NULL DEFAULT true,
milestone_synced_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
@ -414,6 +470,89 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
)
`,
`
CREATE TABLE IF NOT EXISTS notification_preferences (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
in_app_enabled BOOLEAN NOT NULL DEFAULT true,
sound_enabled BOOLEAN NOT NULL DEFAULT false,
email_enabled BOOLEAN NOT NULL DEFAULT false,
operation_updates BOOLEAN NOT NULL DEFAULT true,
operation_journals BOOLEAN NOT NULL DEFAULT true,
device_updates BOOLEAN NOT NULL DEFAULT true,
device_journals BOOLEAN NOT NULL DEFAULT true,
system_messages BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`,
`
CREATE TABLE IF NOT EXISTS feedback (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
message TEXT NOT NULL,
log JSONB NOT NULL DEFAULT '{}'::JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`,
`
CREATE TABLE IF NOT EXISTS user_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
level TEXT NOT NULL DEFAULT 'info',
category TEXT NOT NULL DEFAULT '',
action TEXT NOT NULL DEFAULT '',
message TEXT NOT NULL DEFAULT '',
error TEXT NOT NULL DEFAULT '',
user_snapshot JSONB NOT NULL DEFAULT '{}'::JSONB,
request JSONB NOT NULL DEFAULT '{}'::JSONB,
details JSONB NOT NULL DEFAULT '{}'::JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`,
`
CREATE TABLE IF NOT EXISTS camera_firmware_cache (
manufacturer TEXT NOT NULL,
normalized_model TEXT NOT NULL,
latest_version TEXT NOT NULL DEFAULT '',
support_url TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '',
check_error TEXT NOT NULL DEFAULT '',
support_badges JSONB NOT NULL DEFAULT '[]'::JSONB,
checked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (manufacturer, normalized_model)
)
`,
`
CREATE TABLE IF NOT EXISTS camera_firmware_refresh_state (
id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1),
last_checked_at TIMESTAMPTZ,
last_checked_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`,
`CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)`,
`CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)`,
@ -428,9 +567,14 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
`CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_user_id ON dashboard_widgets(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_position ON dashboard_widgets(user_id, y, x)`,
`CREATE INDEX IF NOT EXISTS idx_dashboard_settings_user_id ON dashboard_settings(user_id)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_inventory_number_unique ON devices(lower(inventory_number))`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_serial_number_unique ON devices(lower(serial_number)) WHERE serial_number <> ''`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_mac_address_unique ON devices(lower(mac_address)) WHERE mac_address <> ''`,
`CREATE INDEX IF NOT EXISTS idx_devices_ip_address ON devices(ip_address)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_milestone_hardware_id_unique ON devices(milestone_hardware_id) WHERE milestone_hardware_id <> ''`,
`CREATE INDEX IF NOT EXISTS idx_devices_milestone_recording_server_id ON devices(milestone_recording_server_id)`,
`CREATE INDEX IF NOT EXISTS idx_devices_manufacturer ON devices(manufacturer)`,
`CREATE INDEX IF NOT EXISTS idx_devices_model ON devices(model)`,
@ -462,6 +606,19 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
`CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications(created_at)`,
`CREATE INDEX IF NOT EXISTS idx_notifications_read_at ON notifications(read_at)`,
`CREATE INDEX IF NOT EXISTS idx_notifications_entity ON notifications(entity_type, entity_id)`,
`CREATE INDEX IF NOT EXISTS idx_notification_preferences_user_id ON notification_preferences(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_feedback_user_id ON feedback(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_feedback_created_at ON feedback(created_at)`,
`CREATE INDEX IF NOT EXISTS idx_user_logs_user_id ON user_logs(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_user_logs_level ON user_logs(level)`,
`CREATE INDEX IF NOT EXISTS idx_user_logs_category ON user_logs(category)`,
`CREATE INDEX IF NOT EXISTS idx_user_logs_created_at ON user_logs(created_at)`,
`CREATE INDEX IF NOT EXISTS idx_user_logs_user_created_at ON user_logs(user_id, created_at DESC, id DESC)`,
`CREATE INDEX IF NOT EXISTS idx_camera_firmware_cache_checked_at ON camera_firmware_cache(checked_at)`,
}
for _, query := range queries {
@ -728,3 +885,11 @@ func env(key string, fallback string) string {
return value
}
func waitForExit() {
fmt.Println("")
fmt.Print("Drücke Enter, um das Setup zu beenden...")
reader := bufio.NewReader(os.Stdin)
_, _ = reader.ReadString('\n')
}

400
backend/user_log.go Normal file
View File

@ -0,0 +1,400 @@
// backend/user_log.go
package main
import (
"context"
"encoding/json"
"net"
"net/http"
"strings"
)
type UserLogLevel string
const (
UserLogLevelInfo UserLogLevel = "info"
UserLogLevelWarning UserLogLevel = "warning"
UserLogLevelError UserLogLevel = "error"
)
const (
maxUserLogEntriesPerUser = 500
maxAnonymousUserLogEntries = 1000
)
type UserLogEntry struct {
User *User
UserID string
Request *http.Request
Level UserLogLevel
Category string
Action string
Message string
Error error
Details LogFields
}
func (s *Server) logUserEvent(ctx context.Context, entry UserLogEntry) {
if entry.Level == "" {
entry.Level = UserLogLevelInfo
}
userID := any(nil)
userIDForPrune := ""
userSnapshot := map[string]any{}
if entry.User != nil {
userID = entry.User.ID
userIDForPrune = entry.User.ID
userSnapshot = buildUserLogSnapshot(*entry.User)
} else if strings.TrimSpace(entry.UserID) != "" {
userID = strings.TrimSpace(entry.UserID)
userIDForPrune = strings.TrimSpace(entry.UserID)
}
requestSnapshot := map[string]any{}
if entry.Request != nil {
requestSnapshot = buildRequestLogSnapshot(entry.Request)
}
details := mergeLogFields(appErrorFields(entry.Error), entry.Details)
errorMessage := ""
if entry.Error != nil {
errorMessage = entry.Error.Error()
}
userSnapshotJSON, err := json.Marshal(userSnapshot)
if err != nil {
logError("User-Log konnte nicht serialisiert werden", err, nil)
return
}
requestSnapshotJSON, err := json.Marshal(requestSnapshot)
if err != nil {
logError("Request-Log konnte nicht serialisiert werden", err, nil)
return
}
detailsJSON, err := json.Marshal(details)
if err != nil {
logError("User-Log-Details konnten nicht serialisiert werden", err, nil)
return
}
_, err = s.db.Exec(
ctx,
`
INSERT INTO user_logs (
user_id,
level,
category,
action,
message,
error,
user_snapshot,
request,
details
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
`,
userID,
string(entry.Level),
entry.Category,
entry.Action,
entry.Message,
errorMessage,
userSnapshotJSON,
requestSnapshotJSON,
detailsJSON,
)
if err != nil {
logError("User-Log konnte nicht gespeichert werden", err, LogFields{
"category": entry.Category,
"action": entry.Action,
})
return
}
if userIDForPrune != "" {
s.pruneUserLogsByUserID(ctx, userIDForPrune)
return
}
s.pruneAnonymousUserLogs(ctx)
}
func (s *Server) pruneUserLogs(ctx context.Context, user *User) {
if user == nil || user.ID == "" {
return
}
s.pruneUserLogsByUserID(ctx, user.ID)
}
func (s *Server) pruneUserLogsByUserID(ctx context.Context, userID string) {
userID = strings.TrimSpace(userID)
if userID == "" {
return
}
_, err := s.db.Exec(
ctx,
`
DELETE FROM user_logs
WHERE user_id = $1
AND id NOT IN (
SELECT id
FROM user_logs
WHERE user_id = $1
ORDER BY created_at DESC, id DESC
LIMIT $2
)
`,
userID,
maxUserLogEntriesPerUser,
)
if err != nil {
logError("Alte User-Logs konnten nicht gelöscht werden", err, LogFields{
"userId": userID,
"limit": maxUserLogEntriesPerUser,
})
}
}
func (s *Server) pruneAnonymousUserLogs(ctx context.Context) {
_, err := s.db.Exec(
ctx,
`
DELETE FROM user_logs
WHERE user_id IS NULL
AND id NOT IN (
SELECT id
FROM user_logs
WHERE user_id IS NULL
ORDER BY created_at DESC, id DESC
LIMIT $1
)
`,
maxAnonymousUserLogEntries,
)
if err != nil {
logError("Alte anonyme User-Logs konnten nicht gelöscht werden", err, LogFields{
"limit": maxAnonymousUserLogEntries,
})
}
}
func (s *Server) logUserError(
r *http.Request,
user User,
category string,
action string,
message string,
err error,
details LogFields,
) {
s.logUserEvent(r.Context(), UserLogEntry{
User: &user,
Request: r,
Level: UserLogLevelError,
Category: category,
Action: action,
Message: message,
Error: err,
Details: details,
})
}
func (s *Server) logAndWriteUserError(
w http.ResponseWriter,
r *http.Request,
user User,
statusCode int,
category string,
action string,
publicMessage string,
err error,
details LogFields,
) {
s.logUserError(
r,
user,
category,
action,
publicMessage,
err,
details,
)
writeError(w, statusCode, publicMessage)
}
func buildUserLogSnapshot(user User) map[string]any {
return map[string]any{
"id": user.ID,
"username": user.Username,
"displayName": user.DisplayName,
"email": user.Email,
"avatar": user.Avatar,
"unit": user.Unit,
"group": user.Group,
"rights": user.Rights,
"teams": user.Teams,
}
}
func buildRequestLogSnapshot(r *http.Request) map[string]any {
if r == nil {
return map[string]any{}
}
return map[string]any{
"method": r.Method,
"path": r.URL.Path,
"query": r.URL.RawQuery,
"ipAddress": requestIPAddress(r),
"remoteAddr": r.RemoteAddr,
"userAgent": r.UserAgent(),
"referer": r.Referer(),
}
}
func requestIPAddress(r *http.Request) string {
for _, headerName := range []string{
"X-Forwarded-For",
"X-Real-IP",
"CF-Connecting-IP",
} {
headerValue := strings.TrimSpace(r.Header.Get(headerName))
if headerValue == "" {
continue
}
ipAddress := strings.TrimSpace(strings.Split(headerValue, ",")[0])
if ipAddress != "" {
return ipAddress
}
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err == nil {
return host
}
return r.RemoteAddr
}
func (s *Server) logRequestEvent(
r *http.Request,
level UserLogLevel,
category string,
action string,
message string,
err error,
details LogFields,
) {
var user *User
userID := ""
if currentUser, ok := userFromContext(r.Context()); ok {
user = &currentUser
} else if currentUserID, ok := s.currentUserIDFromRequest(r); ok {
userID = currentUserID
}
s.logUserEvent(r.Context(), UserLogEntry{
User: user,
UserID: userID,
Request: r,
Level: level,
Category: category,
Action: action,
Message: message,
Error: err,
Details: details,
})
}
func (s *Server) logRequestInfo(
r *http.Request,
category string,
action string,
message string,
details LogFields,
) {
s.logRequestEvent(
r,
UserLogLevelInfo,
category,
action,
message,
nil,
details,
)
}
func (s *Server) logRequestWarning(
r *http.Request,
category string,
action string,
message string,
err error,
details LogFields,
) {
s.logRequestEvent(
r,
UserLogLevelWarning,
category,
action,
message,
err,
details,
)
}
func (s *Server) logRequestError(
r *http.Request,
category string,
action string,
message string,
err error,
details LogFields,
) {
s.logRequestEvent(
r,
UserLogLevelError,
category,
action,
message,
err,
details,
)
}
func (s *Server) logAndWriteSettingsError(
w http.ResponseWriter,
r *http.Request,
statusCode int,
category string,
action string,
publicMessage string,
err error,
details LogFields,
) {
s.logRequestError(
r,
category,
action,
publicMessage,
err,
details,
)
writeSettingsError(w, statusCode, publicMessage)
}

View File

@ -1,4 +1,4 @@
// backend\user_sessions.go
// backend/user_sessions.go
package main
@ -35,6 +35,7 @@ func (s *Server) createUserSession(
operatingSystem := strings.TrimSpace(input.OperatingSystem)
deviceType := strings.TrimSpace(input.DeviceType)
userAgent := r.UserAgent()
ipAddress := getRequestIP(r)
if browser == "" {
browser = detectBrowserFromUserAgent(userAgent)
@ -69,10 +70,45 @@ func (s *Server) createUserSession(
operatingSystem,
deviceType,
userAgent,
getRequestIP(r),
ipAddress,
).Scan(&sessionID)
if err != nil {
s.logUserEvent(ctx, UserLogEntry{
UserID: userID,
Request: r,
Level: UserLogLevelError,
Category: "session",
Action: "create_session",
Message: "Sitzung konnte nicht erstellt werden",
Error: err,
Details: LogFields{
"browser": browser,
"operatingSystem": operatingSystem,
"deviceType": deviceType,
"ipAddress": ipAddress,
},
})
return sessionID, err
return "", err
}
s.logUserEvent(ctx, UserLogEntry{
UserID: userID,
Request: r,
Level: UserLogLevelInfo,
Category: "session",
Action: "create_session",
Message: "Neue Sitzung wurde erstellt",
Details: LogFields{
"sessionId": sessionID,
"browser": browser,
"operatingSystem": operatingSystem,
"deviceType": deviceType,
"ipAddress": ipAddress,
},
})
return sessionID, nil
}
func (s *Server) isUserSessionActive(
@ -124,6 +160,15 @@ func (s *Server) touchUserSession(
func (s *Server) handleListUserSessions(w http.ResponseWriter, r *http.Request) {
userID, ok := s.currentUserIDFromRequest(r)
if !ok {
s.logRequestWarning(
r,
"session",
"list_sessions",
"Sitzungen konnten nicht geladen werden: nicht angemeldet",
nil,
nil,
)
writeSettingsError(w, http.StatusUnauthorized, "Nicht angemeldet")
return
}
@ -153,7 +198,19 @@ func (s *Server) handleListUserSessions(w http.ResponseWriter, r *http.Request)
currentSessionID,
)
if err != nil {
writeSettingsError(w, http.StatusInternalServerError, "Sitzungen konnten nicht geladen werden")
s.logAndWriteSettingsError(
w,
r,
http.StatusInternalServerError,
"session",
"list_sessions",
"Sitzungen konnten nicht geladen werden",
err,
LogFields{
"userId": userID,
"currentSessionId": currentSessionID,
},
)
return
}
defer rows.Close()
@ -173,7 +230,18 @@ func (s *Server) handleListUserSessions(w http.ResponseWriter, r *http.Request)
&session.CreatedAt,
&session.LastSeenAt,
); err != nil {
writeSettingsError(w, http.StatusInternalServerError, "Sitzungen konnten nicht gelesen werden")
s.logAndWriteSettingsError(
w,
r,
http.StatusInternalServerError,
"session",
"scan_sessions",
"Sitzungen konnten nicht gelesen werden",
err,
LogFields{
"userId": userID,
},
)
return
}
@ -182,10 +250,32 @@ func (s *Server) handleListUserSessions(w http.ResponseWriter, r *http.Request)
}
if err := rows.Err(); err != nil {
writeSettingsError(w, http.StatusInternalServerError, "Sitzungen konnten nicht gelesen werden")
s.logAndWriteSettingsError(
w,
r,
http.StatusInternalServerError,
"session",
"read_sessions",
"Sitzungen konnten nicht gelesen werden",
err,
LogFields{
"userId": userID,
},
)
return
}
s.logRequestInfo(
r,
"session",
"list_sessions",
"Sitzungen wurden geladen",
LogFields{
"userId": userID,
"sessionCount": len(sessions),
},
)
writeSettingsJSON(w, http.StatusOK, map[string]any{
"sessions": sessions,
})
@ -194,19 +284,52 @@ func (s *Server) handleListUserSessions(w http.ResponseWriter, r *http.Request)
func (s *Server) handleRevokeUserSession(w http.ResponseWriter, r *http.Request) {
userID, ok := s.currentUserIDFromRequest(r)
if !ok {
s.logRequestWarning(
r,
"session",
"revoke_session",
"Sitzung konnte nicht abgemeldet werden: nicht angemeldet",
nil,
nil,
)
writeSettingsError(w, http.StatusUnauthorized, "Nicht angemeldet")
return
}
sessionID := r.PathValue("id")
sessionID := strings.TrimSpace(r.PathValue("id"))
currentSessionID, _ := s.currentSessionIDFromRequest(r)
if sessionID == "" {
s.logRequestWarning(
r,
"session",
"revoke_session",
"Sitzung konnte nicht abgemeldet werden: Sitzung fehlt",
nil,
LogFields{
"userId": userID,
},
)
writeSettingsError(w, http.StatusBadRequest, "Sitzung fehlt")
return
}
if sessionID == currentSessionID {
s.logRequestWarning(
r,
"session",
"revoke_session",
"Aktuelle Sitzung kann hier nicht abgemeldet werden",
nil,
LogFields{
"userId": userID,
"sessionId": sessionID,
"currentSessionId": currentSessionID,
},
)
writeSettingsError(w, http.StatusBadRequest, "Die aktuelle Sitzung kann hier nicht abgemeldet werden")
return
}
@ -224,15 +347,50 @@ func (s *Server) handleRevokeUserSession(w http.ResponseWriter, r *http.Request)
userID,
)
if err != nil {
writeSettingsError(w, http.StatusInternalServerError, "Sitzung konnte nicht abgemeldet werden")
s.logAndWriteSettingsError(
w,
r,
http.StatusInternalServerError,
"session",
"revoke_session",
"Sitzung konnte nicht abgemeldet werden",
err,
LogFields{
"userId": userID,
"sessionId": sessionID,
},
)
return
}
if commandTag.RowsAffected() == 0 {
s.logRequestWarning(
r,
"session",
"revoke_session",
"Sitzung wurde nicht gefunden",
nil,
LogFields{
"userId": userID,
"sessionId": sessionID,
},
)
writeSettingsError(w, http.StatusNotFound, "Sitzung wurde nicht gefunden")
return
}
s.logRequestInfo(
r,
"session",
"revoke_session",
"Sitzung wurde abgemeldet",
LogFields{
"userId": userID,
"sessionId": sessionID,
},
)
writeSettingsJSON(w, http.StatusOK, map[string]any{
"message": "Sitzung wurde abgemeldet",
})
@ -316,7 +474,8 @@ func detectOperatingSystemFromUserAgent(userAgent string) string {
func detectDeviceTypeFromUserAgent(userAgent string) string {
switch {
case strings.Contains(userAgent, "iPhone") || strings.Contains(userAgent, "Android") && strings.Contains(userAgent, "Mobile"):
case strings.Contains(userAgent, "iPhone") ||
(strings.Contains(userAgent, "Android") && strings.Contains(userAgent, "Mobile")):
return "Smartphone"
case strings.Contains(userAgent, "iPad") || strings.Contains(userAgent, "Tablet"):
return "Tablet"

View File

@ -13,7 +13,7 @@ import (
"golang.org/x/crypto/bcrypt"
)
type updateSettingsProfileRequest struct {
type updateSettingsAccountRequest struct {
DisplayName string `json:"displayName"`
Username string `json:"username"`
Email string `json:"email"`
@ -32,14 +32,14 @@ type confirmPasswordRequest struct {
Password string `json:"password"`
}
func (s *Server) handleUpdateSettingsProfile(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleUpdateSettingsAccount(w http.ResponseWriter, r *http.Request) {
userID, ok := s.currentUserIDFromRequest(r)
if !ok {
writeSettingsError(w, http.StatusUnauthorized, "Nicht angemeldet")
return
}
var payload updateSettingsProfileRequest
var payload updateSettingsAccountRequest
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
writeSettingsError(w, http.StatusBadRequest, "Ungültige Anfrage")
return

View File

@ -16,6 +16,7 @@ import LoadingSpinner from './components/LoadingSpinner'
import NotificationCenter from './components/NotificationCenter'
import type { Notification, User } from './components/types'
import AdministrationPage from './pages/administration/AdministrationPage'
import Feedback from './components/Feedback'
import { hasRight } from './components/permissions'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
@ -75,20 +76,18 @@ function App() {
withCredentials: true,
})
eventSource.addEventListener('notification', (event) => {
const notification = parseNotificationEvent(event)
if (!notification) {
function addVisibleNotification(notification: Notification) {
if (notification.visible === false) {
return
}
if (notification.visible !== false) {
setNotifications((currentNotifications) => [
notification,
...currentNotifications.filter((item) => item.id !== notification.id),
])
}
setNotifications((currentNotifications) => [
notification,
...currentNotifications.filter((item) => item.id !== notification.id),
])
}
function dispatchEntityEvent(notification: Notification) {
if (notification.entityType === 'operation') {
window.dispatchEvent(
new CustomEvent('operation-notification', {
@ -104,15 +103,28 @@ function App() {
}),
)
}
})
}
eventSource.addEventListener('operation-event', (event) => {
function handleNotificationEvent(event: MessageEvent) {
const notification = parseNotificationEvent(event)
if (!notification) {
return
}
addVisibleNotification(notification)
dispatchEntityEvent(notification)
}
function handleOperationEvent(event: MessageEvent) {
const notification = parseNotificationEvent(event)
if (!notification) {
return
}
addVisibleNotification(notification)
if (
notification.type === 'operation.journal' &&
notification.entityType === 'operation' &&
@ -125,30 +137,28 @@ function App() {
})
}
if (notification.entityType === 'operation') {
window.dispatchEvent(
new CustomEvent('operation-notification', {
detail: notification,
}),
)
}
})
dispatchEntityEvent(notification)
}
eventSource.addEventListener('device-event', (event) => {
function handleDeviceEvent(event: MessageEvent) {
const notification = parseNotificationEvent(event)
if (!notification) {
return
}
window.dispatchEvent(
new CustomEvent('device-notification', {
detail: notification,
}),
)
})
addVisibleNotification(notification)
dispatchEntityEvent(notification)
}
eventSource.addEventListener('notification', handleNotificationEvent)
eventSource.addEventListener('operation-event', handleOperationEvent)
eventSource.addEventListener('device-event', handleDeviceEvent)
return () => {
eventSource.removeEventListener('notification', handleNotificationEvent)
eventSource.removeEventListener('operation-event', handleOperationEvent)
eventSource.removeEventListener('device-event', handleDeviceEvent)
eventSource.close()
}
}, [user])
@ -354,6 +364,11 @@ function App() {
}
/>
<Route
path="/administration"
element={<Navigate to="/administration/benutzer" replace />}
/>
<Route
path="/administration/*"
element={
@ -375,6 +390,8 @@ function App() {
}
/>
<Route path="/feedback" element={<Feedback />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</AppLayout>

View File

@ -1,6 +1,7 @@
// frontend/src/components/AppLayout.tsx
import { useState, type ReactNode } from 'react'
import { useEffect, useState, type ReactNode } from 'react'
import { useLocation } from 'react-router'
import { Transition } from '@headlessui/react'
import SearchOverlay from './SearchOverlay'
import Sidebar from './Sidebar'
@ -20,6 +21,8 @@ export default function AppLayout({
children,
notificationCenter,
}: AppLayoutProps) {
const location = useLocation()
const [sidebarOpen, setSidebarOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
@ -29,15 +32,25 @@ export default function AppLayout({
setSearchQuery('')
}
function handleNavigation() {
setSearchQuery('')
setSidebarOpen(false)
}
useEffect(() => {
setSearchQuery('')
}, [location.pathname, location.search, location.hash])
return (
<div>
<div className="h-dvh overflow-hidden">
<Sidebar
user={user}
sidebarOpen={sidebarOpen}
setSidebarOpen={setSidebarOpen}
onNavigate={handleNavigation}
/>
<div className="lg:pl-72">
<div className="flex h-dvh min-w-0 flex-col lg:pl-72">
<Topbar
user={user}
onLogout={onLogout}
@ -45,9 +58,10 @@ export default function AppLayout({
notificationCenter={notificationCenter}
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
onNavigate={handleNavigation}
/>
<main className="relative min-h-[calc(100dvh-4rem)] overflow-hidden">
<main className="relative min-h-0 flex-1 overflow-hidden">
{children}
<Transition

View File

@ -0,0 +1,181 @@
// frontend/src/components/Avatar.tsx
import type { ImgHTMLAttributes } from 'react'
export type AvatarSize = 6 | 8 | 9 | 10 | 12 | 14 | 16
export type AvatarShape = 'circle' | 'rounded'
export type AvatarStatus = 'gray' | 'red' | 'green'
export type AvatarStatusPosition = 'top' | 'bottom'
export type AvatarProps = Omit<ImgHTMLAttributes<HTMLImageElement>, 'size'> & {
src?: string
name?: string
initials?: string
size?: AvatarSize
shape?: AvatarShape
status?: AvatarStatus
statusPosition?: AvatarStatusPosition
showPlaceholderIcon?: boolean
wrapperClassName?: string
}
function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(' ')
}
const sizeClassNames: Record<AvatarSize, string> = {
6: 'size-6',
8: 'size-8',
9: 'size-9',
10: 'size-10',
12: 'size-12',
14: 'size-14',
16: 'size-16',
}
const initialsSizeClassNames: Record<AvatarSize, string> = {
6: 'text-xs',
8: 'text-sm',
9: 'text-sm',
10: 'text-base',
12: 'text-lg',
14: 'text-xl',
16: 'text-2xl',
}
const statusSizeClassNames: Record<AvatarSize, string> = {
6: 'size-1.5',
8: 'size-2',
9: 'size-2',
10: 'size-2.5',
12: 'size-3',
14: 'size-3.5',
16: 'size-4',
}
const statusColorClassNames: Record<AvatarStatus, string> = {
gray: 'bg-gray-300 dark:bg-gray-500',
red: 'bg-red-400 dark:bg-red-500',
green: 'bg-green-400 dark:bg-green-500',
}
function getInitials(name?: string, initials?: string) {
if (initials) {
return initials.slice(0, 2).toUpperCase()
}
if (!name) {
return ''
}
return name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part.charAt(0))
.join('')
.toUpperCase()
}
function PlaceholderIcon({ className }: { className?: string }) {
return (
<svg
fill="currentColor"
viewBox="0 0 24 24"
className={className}
aria-hidden="true"
>
<path d="M24 20.993V24H0v-2.996A14.977 14.977 0 0 1 12.004 15c4.904 0 9.26 2.354 11.996 5.993zM16.002 8.999a4 4 0 1 1-8 0 4 4 0 0 1 8 0z" />
</svg>
)
}
export default function Avatar({
src,
alt,
name,
initials,
size = 10,
shape = 'circle',
status,
statusPosition = 'bottom',
showPlaceholderIcon = true,
wrapperClassName,
className,
...props
}: AvatarProps) {
const roundedClassName = shape === 'circle' ? 'rounded-full' : 'rounded-md'
const avatarInitials = getInitials(name, initials)
const avatar = src ? (
<img
{...props}
src={src}
alt={alt ?? name ?? ''}
className={classNames(
'inline-block bg-gray-100 outline -outline-offset-1 outline-black/5 dark:bg-gray-800 dark:outline-white/10',
sizeClassNames[size],
roundedClassName,
className,
)}
/>
) : avatarInitials ? (
<span
className={classNames(
'inline-flex items-center justify-center bg-gray-500 outline -outline-offset-1 outline-black/5 dark:bg-gray-800 dark:outline-white/10',
sizeClassNames[size],
roundedClassName,
className,
)}
aria-label={name}
>
<span
className={classNames(
'font-medium text-white',
initialsSizeClassNames[size],
)}
>
{avatarInitials}
</span>
</span>
) : (
<span
className={classNames(
'inline-block overflow-hidden bg-gray-100 outline -outline-offset-1 outline-black/5 dark:bg-gray-800 dark:outline-white/10',
sizeClassNames[size],
roundedClassName,
className,
)}
aria-label={name}
>
{showPlaceholderIcon && (
<PlaceholderIcon className="size-full text-gray-300 dark:text-gray-600" />
)}
</span>
)
if (!status) {
return avatar
}
return (
<span className={classNames('relative inline-block', wrapperClassName)}>
{avatar}
<span
className={classNames(
'absolute right-0 block rounded-full ring-2 ring-white dark:ring-gray-900',
statusPosition === 'top' ? 'top-0' : 'bottom-0',
statusSizeClassNames[size],
statusColorClassNames[status],
shape === 'rounded' &&
statusPosition === 'top' &&
'translate-x-1/2 -translate-y-1/2 transform',
shape === 'rounded' &&
statusPosition === 'bottom' &&
'translate-x-1/2 translate-y-1/2 transform',
)}
/>
</span>
)
}

View File

@ -0,0 +1,198 @@
// frontend/src/components/Badge.tsx
import type { HTMLAttributes, ReactNode } from "react";
export type BadgeTone =
| "gray"
| "red"
| "yellow"
| "green"
| "blue"
| "indigo"
| "purple"
| "pink";
export type BadgeVariant = "border" | "flat";
export type BadgeShape = "rounded" | "pill";
export type BadgeSize = "default" | "small";
type BadgeProps = Omit<HTMLAttributes<HTMLSpanElement>, "color"> & {
children: ReactNode;
tone?: BadgeTone;
variant?: BadgeVariant;
shape?: BadgeShape;
size?: BadgeSize;
dot?: boolean;
leadingIcon?: ReactNode;
trailingIcon?: ReactNode;
onRemove?: () => void;
removeLabel?: string;
};
function cn(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(" ");
}
const badgeClasses: Record<BadgeVariant, Record<BadgeTone, string>> = {
border: {
gray: "bg-gray-50 text-gray-600 inset-ring inset-ring-gray-500/10 dark:bg-gray-400/10 dark:text-gray-400 dark:inset-ring-gray-400/20",
red: "bg-red-50 text-red-700 inset-ring inset-ring-red-600/10 dark:bg-red-400/10 dark:text-red-400 dark:inset-ring-red-400/20",
yellow:
"bg-yellow-50 text-yellow-800 inset-ring inset-ring-yellow-600/20 dark:bg-yellow-400/10 dark:text-yellow-500 dark:inset-ring-yellow-400/20",
green:
"bg-green-50 text-green-700 inset-ring inset-ring-green-600/20 dark:bg-green-400/10 dark:text-green-400 dark:inset-ring-green-500/20",
blue: "bg-blue-50 text-blue-700 inset-ring inset-ring-blue-700/10 dark:bg-blue-400/10 dark:text-blue-400 dark:inset-ring-blue-400/30",
indigo:
"bg-indigo-50 text-indigo-700 inset-ring inset-ring-indigo-700/10 dark:bg-indigo-400/10 dark:text-indigo-400 dark:inset-ring-indigo-400/30",
purple:
"bg-purple-50 text-purple-700 inset-ring inset-ring-purple-700/10 dark:bg-purple-400/10 dark:text-purple-400 dark:inset-ring-purple-400/30",
pink: "bg-pink-50 text-pink-700 inset-ring inset-ring-pink-700/10 dark:bg-pink-400/10 dark:text-pink-400 dark:inset-ring-pink-400/20",
},
flat: {
gray: "bg-gray-100 text-gray-600 dark:bg-gray-400/10 dark:text-gray-400",
red: "bg-red-100 text-red-700 dark:bg-red-400/10 dark:text-red-400",
yellow:
"bg-yellow-100 text-yellow-800 dark:bg-yellow-400/10 dark:text-yellow-500",
green:
"bg-green-100 text-green-700 dark:bg-green-400/10 dark:text-green-400",
blue: "bg-blue-100 text-blue-700 dark:bg-blue-400/10 dark:text-blue-400",
indigo:
"bg-indigo-100 text-indigo-700 dark:bg-indigo-400/10 dark:text-indigo-400",
purple:
"bg-purple-100 text-purple-700 dark:bg-purple-400/10 dark:text-purple-400",
pink: "bg-pink-100 text-pink-700 dark:bg-pink-400/10 dark:text-pink-400",
},
};
const dotClasses: Record<BadgeTone, string> = {
gray: "fill-gray-400",
red: "fill-red-500 dark:fill-red-400",
yellow: "fill-yellow-500 dark:fill-yellow-400",
green: "fill-green-500 dark:fill-green-400",
blue: "fill-blue-500 dark:fill-blue-400",
indigo: "fill-indigo-500 dark:fill-indigo-400",
purple: "fill-purple-500 dark:fill-purple-400",
pink: "fill-pink-500 dark:fill-pink-400",
};
const removeButtonClasses: Record<BadgeTone, string> = {
gray: "hover:bg-gray-500/20 dark:hover:bg-gray-500/30",
red: "hover:bg-red-600/20 dark:hover:bg-red-500/30",
yellow: "hover:bg-yellow-600/20 dark:hover:bg-yellow-500/30",
green: "hover:bg-green-600/20 dark:hover:bg-green-500/30",
blue: "hover:bg-blue-600/20 dark:hover:bg-blue-500/30",
indigo: "hover:bg-indigo-600/20 dark:hover:bg-indigo-500/30",
purple: "hover:bg-purple-600/20 dark:hover:bg-purple-500/30",
pink: "hover:bg-pink-600/20 dark:hover:bg-pink-500/30",
};
const removeIconClasses: Record<BadgeTone, string> = {
gray: "stroke-gray-600/50 group-hover:stroke-gray-600/75 dark:stroke-gray-400 dark:group-hover:stroke-gray-300",
red: "stroke-red-600/50 group-hover:stroke-red-600/75 dark:stroke-red-400 dark:group-hover:stroke-red-300",
yellow:
"stroke-yellow-700/50 group-hover:stroke-yellow-700/75 dark:stroke-yellow-400 dark:group-hover:stroke-yellow-300",
green:
"stroke-green-700/50 group-hover:stroke-green-700/75 dark:stroke-green-400 dark:group-hover:stroke-green-300",
blue: "stroke-blue-700/50 group-hover:stroke-blue-700/75 dark:stroke-blue-400 dark:group-hover:stroke-blue-300",
indigo:
"stroke-indigo-600/50 group-hover:stroke-indigo-600/75 dark:stroke-indigo-400 dark:group-hover:stroke-indigo-300",
purple:
"stroke-purple-700/50 group-hover:stroke-purple-700/75 dark:stroke-purple-400 dark:group-hover:stroke-purple-300",
pink: "stroke-pink-700/50 group-hover:stroke-pink-700/75 dark:stroke-pink-400 dark:group-hover:stroke-pink-300",
};
function BadgeIcon({
children,
size,
}: {
children: ReactNode;
size: BadgeSize;
}) {
return (
<span
aria-hidden="true"
className={cn(
"inline-flex shrink-0 items-center justify-center",
size === "small"
? "size-3 [&>svg]:size-3"
: "size-3.5 [&>svg]:size-3.5",
)}
>
{children}
</span>
);
}
export function Badge({
children,
tone = "gray",
variant = "border",
shape = "rounded",
size = "default",
dot = false,
leadingIcon,
trailingIcon,
onRemove,
removeLabel = "Remove",
className,
...props
}: BadgeProps) {
const hasAdornment = Boolean(dot || leadingIcon || trailingIcon);
const hasOnlyRemoveAction = Boolean(onRemove && !hasAdornment);
return (
<span
className={cn(
"inline-flex items-center text-xs font-medium",
hasAdornment && "gap-x-1.5",
hasOnlyRemoveAction && "gap-x-0.5",
onRemove && hasAdornment && "gap-x-1.5",
shape === "pill" ? "rounded-full" : "rounded-md",
size === "small" ? "px-1.5 py-0.5" : "px-2 py-1",
badgeClasses[variant][tone],
className,
)}
{...props}
>
{leadingIcon ? (
<BadgeIcon size={size}>{leadingIcon}</BadgeIcon>
) : null}
{dot ? (
<svg
viewBox="0 0 6 6"
aria-hidden="true"
className={cn("size-1.5", dotClasses[tone])}
>
<circle r={3} cx={3} cy={3} />
</svg>
) : null}
{children}
{trailingIcon ? (
<BadgeIcon size={size}>{trailingIcon}</BadgeIcon>
) : null}
{onRemove ? (
<button
type="button"
onClick={onRemove}
className={cn(
"group relative -mr-1 size-3.5 rounded-xs",
removeButtonClasses[tone],
)}
>
<span className="sr-only">{removeLabel}</span>
<svg
viewBox="0 0 14 14"
className={cn("size-3.5", removeIconClasses[tone])}
>
<path d="M4 4l6 6m0-6l-6 6" />
</svg>
<span className="absolute -inset-1" />
</button>
) : null}
</span>
);
}

View File

@ -120,10 +120,9 @@ const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(function Checkbox(
)
const text = label || description ? (
<div className="min-w-0 text-sm/6">
<span className="min-w-0 text-sm/6">
{label && (
<label
htmlFor={inputId}
<span
className={classNames(
'font-small text-gray-900 select-none dark:text-white',
disabled && 'cursor-not-allowed opacity-60',
@ -131,28 +130,31 @@ const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(function Checkbox(
)}
>
{label}
</label>
</span>
)}
{description && (
<p
<span
id={descriptionId}
className={classNames(
'text-gray-500 dark:text-gray-400',
'block text-gray-500 dark:text-gray-400',
disabled && 'opacity-60',
descriptionClassName,
)}
>
{description}
</p>
</span>
)}
</div>
</span>
) : null
return (
<div
<label
htmlFor={inputId}
aria-disabled={disabled || undefined}
className={classNames(
'flex gap-3',
disabled ? 'cursor-not-allowed' : 'cursor-pointer',
checkboxPosition === 'right' && 'justify-between',
wrapperClassName,
)}
@ -168,7 +170,7 @@ const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(function Checkbox(
{checkbox}
</>
)}
</div>
</label>
)
})

View File

@ -0,0 +1,100 @@
// frontend/src/components/EmptyState.tsx
import type { ComponentType, ReactNode } from 'react'
import { PlusIcon } from '@heroicons/react/20/solid'
import Button from './Button'
type EmptyStateProps = {
title: ReactNode
description?: ReactNode
icon?: ComponentType<{ className?: string; 'aria-hidden'?: boolean }>
actionLabel?: string
onAction?: () => void
actionIcon?: ReactNode
action?: ReactNode
className?: string
iconClassName?: string
}
function DefaultIcon({
className,
'aria-hidden': ariaHidden,
}: {
className?: string
'aria-hidden'?: boolean
}) {
return (
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden={ariaHidden}
className={className}
>
<path
d="M9 13h6m-3-3v6m-9 1V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"
strokeWidth={2}
vectorEffect="non-scaling-stroke"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(' ')
}
export default function EmptyState({
title,
description,
icon: Icon = DefaultIcon,
actionLabel,
onAction,
actionIcon,
action,
className,
iconClassName,
}: EmptyStateProps) {
return (
<div className={classNames('text-center', className)}>
<Icon
aria-hidden
className={classNames(
'mx-auto size-12 text-gray-400 dark:text-gray-500',
iconClassName,
)}
/>
<h3 className="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
{title}
</h3>
{description && (
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{description}
</p>
)}
{(action || (actionLabel && onAction)) && (
<div className="mt-6">
{action ?? (
<Button
type="button"
color="indigo"
onClick={onAction}
leadingIcon={
actionIcon ?? (
<PlusIcon aria-hidden="true" className="size-5" />
)
}
>
{actionLabel}
</Button>
)}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,111 @@
// frontend/src/pages/Feedback.tsx
import { useState, type FormEvent } from 'react'
import Textarea from '../components/Textarea'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
export default function Feedback() {
const [message, setMessage] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [successMessage, setSuccessMessage] = useState<string | null>(null)
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const trimmedMessage = message.trim()
if (!trimmedMessage) {
setError('Bitte gib dein Feedback ein.')
setSuccessMessage(null)
return
}
setIsSubmitting(true)
setError(null)
setSuccessMessage(null)
try {
const response = await fetch(`${API_URL}/feedback`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
message: trimmedMessage,
}),
})
if (!response.ok) {
const errorData = await response.json().catch(() => null)
throw new Error(errorData?.error ?? 'Feedback konnte nicht gesendet werden')
}
setMessage('')
setSuccessMessage('Danke, dein Feedback wurde gespeichert.')
} catch (error) {
setError(
error instanceof Error
? error.message
: 'Feedback konnte nicht gesendet werden',
)
} finally {
setIsSubmitting(false)
}
}
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="mx-auto max-w-3xl">
<div>
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">
Feedback
</h1>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
Teile uns mit, wenn dir etwas auffällt, etwas nicht funktioniert oder du eine Idee zur Verbesserung hast.
Beim Absenden wird automatisch ein technisches Log mit deinem Benutzerkonto angehängt.
</p>
</div>
{successMessage && (
<div className="mt-6 rounded-md bg-green-50 p-4 text-sm text-green-700 dark:bg-green-900/30 dark:text-green-300">
{successMessage}
</div>
)}
{error && (
<div className="mt-6 rounded-md bg-red-50 p-4 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
{error}
</div>
)}
<form
onSubmit={handleSubmit}
className="mt-8 rounded-lg border border-gray-200 bg-white p-6 shadow-xs dark:border-white/10 dark:bg-gray-900"
>
<Textarea
id="feedback"
name="feedback"
label="Dein Feedback"
placeholder="Beschreibe möglichst genau, was passiert ist oder was verbessert werden soll..."
variant="avatar-actions"
rows={8}
value={message}
maxLength={5000}
disabled={isSubmitting}
submitLabel="Feedback senden"
showAttachButton={false}
onChange={(event) => setMessage(event.target.value)}
/>
<div className="mt-2 text-right text-xs text-gray-500 dark:text-gray-400">
{message.length} / 5000 Zeichen
</div>
</form>
</div>
</div>
)
}

View File

@ -2,6 +2,7 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'
import {
LayerGroup,
LayersControl,
MapContainer,
Marker,
@ -10,6 +11,7 @@ import {
Tooltip,
WMSTileLayer,
useMap,
useMapEvents,
} from 'react-leaflet'
import * as L from 'leaflet'
import 'leaflet.heat'
@ -72,6 +74,8 @@ type MapProps = {
heatPoints?: MapHeatPoint[]
showHeatmap?: boolean
heatmapOptions?: MapHeatOptions
heatmapLayerName?: string
heatmapChecked?: boolean
center?: [number, number]
zoom?: number
@ -239,14 +243,16 @@ type LeafletWithHeat = typeof L & {
function HeatmapLayer({
points,
options,
enabled = true,
}: {
points: MapHeatPoint[]
options?: MapHeatOptions
enabled?: boolean
}) {
const map = useMap()
useEffect(() => {
if (points.length === 0) {
if (!enabled || points.length === 0) {
return
}
@ -264,7 +270,34 @@ function HeatmapLayer({
return () => {
heatLayer.remove()
}
}, [map, points, options])
}, [enabled, map, points, options])
return null
}
function HeatmapLayerControlEvents({
heatmapLayerName,
onVisibilityChange,
}: {
heatmapLayerName: string
onVisibilityChange: (visible: boolean) => void
}) {
useMapEvents({
overlayadd: (event) => {
const layerEvent = event as L.LeafletEvent & { name?: string }
if (layerEvent.name === heatmapLayerName) {
onVisibilityChange(true)
}
},
overlayremove: (event) => {
const layerEvent = event as L.LeafletEvent & { name?: string }
if (layerEvent.name === heatmapLayerName) {
onVisibilityChange(false)
}
},
})
return null
}
@ -482,6 +515,8 @@ export default function LeafletMap({
heatPoints = [],
showHeatmap = false,
heatmapOptions,
heatmapLayerName = 'Heatmap',
heatmapChecked = false,
center = [51.1657, 10.4515],
zoom = 6,
@ -506,6 +541,7 @@ export default function LeafletMap({
}: MapProps) {
const [currentLocation, setCurrentLocation] = useState<[number, number] | null>(null)
const [isHeatmapLayerVisible, setIsHeatmapLayerVisible] = useState(heatmapChecked)
const fallbackTileLayer =
tileLayers.find((layer) => layer.checked) ?? tileLayers[0]
@ -519,6 +555,10 @@ export default function LeafletMap({
? heatPoints
: markers
useEffect(() => {
setIsHeatmapLayerVisible(heatmapChecked)
}, [heatmapChecked])
return (
<div className={classNames('relative z-0 h-full', minHeightClassName, className)}>
<MapContainer
@ -530,7 +570,13 @@ export default function LeafletMap({
className={classNames('relative z-0 h-full w-full', mapClassName)}
>
<InvalidateSizeOnResize />
{showLayerControl ? (
<HeatmapLayerControlEvents
heatmapLayerName={heatmapLayerName}
onVisibilityChange={setIsHeatmapLayerVisible}
/>
{showLayerControl ? (
<LayersControl position="topright">
{tileLayers.map((layer) => (
<LayersControl.BaseLayer
@ -565,6 +611,15 @@ export default function LeafletMap({
{renderWmsLayer(layer, maxZoom)}
</LayersControl.Overlay>
))}
{showHeatmap && heatPoints.length > 0 && (
<LayersControl.Overlay
name={heatmapLayerName}
checked={heatmapChecked}
>
<LayerGroup />
</LayersControl.Overlay>
)}
</LayersControl>
) : fallbackTileLayer ? (
renderTileLayer(fallbackTileLayer, maxZoom)
@ -572,12 +627,11 @@ export default function LeafletMap({
renderWmsLayer(fallbackWmsLayer, maxZoom)
) : null}
{showHeatmap && (
<HeatmapLayer
points={heatPoints}
options={heatmapOptions}
/>
)}
<HeatmapLayer
points={heatPoints}
options={heatmapOptions}
enabled={showHeatmap && isHeatmapLayerVisible}
/>
{showLocateControl && (
<LocateButton

View File

@ -1,7 +1,13 @@
// frontend/src/components/NotificationCenter.tsx
import { useState } from 'react'
import { BellIcon } from '@heroicons/react/20/solid'
import { useEffect, useMemo, useRef, useState } from 'react'
import {
BellIcon,
EnvelopeOpenIcon,
FaceSmileIcon,
} from '@heroicons/react/20/solid'
import Button from './Button'
import Tabs, { type TabItem } from './Tabs'
import type { Notification } from './types'
type NotificationCenterProps = {
@ -11,6 +17,8 @@ type NotificationCenterProps = {
onMarkAllRead: () => void
}
type NotificationTab = 'unread' | 'all'
function formatNotificationDate(value: string) {
try {
return new Intl.DateTimeFormat('de-DE', {
@ -29,9 +37,78 @@ export default function NotificationCenter({
onMarkAllRead,
}: NotificationCenterProps) {
const [open, setOpen] = useState(false)
const [activeTab, setActiveTab] = useState<NotificationTab>('unread')
const containerRef = useRef<HTMLDivElement | null>(null)
const filteredNotifications = useMemo(() => {
if (activeTab === 'unread') {
return notifications.filter((notification) => !notification.readAt)
}
return notifications
}, [activeTab, notifications])
const notificationTabs: TabItem[] = [
{
name: 'Ungelesen',
href: '#',
current: activeTab === 'unread',
count: unreadCount > 0 ? unreadCount : undefined,
onClick: () => setActiveTab('unread'),
},
{
name: 'Alle',
href: '#',
current: activeTab === 'all',
count: notifications.length > 0 ? notifications.length : undefined,
onClick: () => setActiveTab('all'),
},
]
useEffect(() => {
if (!open) {
return
}
function handlePointerDown(event: PointerEvent) {
const target = event.target
if (!(target instanceof Node)) {
return
}
if (containerRef.current?.contains(target)) {
return
}
setOpen(false)
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
setOpen(false)
}
}
document.addEventListener('pointerdown', handlePointerDown)
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('pointerdown', handlePointerDown)
document.removeEventListener('keydown', handleKeyDown)
}
}, [open])
function getEmptyText() {
if (activeTab === 'unread') {
return 'Keine ungelesenen Benachrichtigungen vorhanden.'
}
return 'Keine Benachrichtigungen vorhanden.'
}
return (
<div className="relative">
<div ref={containerRef} className="relative">
<button
type="button"
onClick={() => setOpen((value) => !value)}
@ -49,29 +126,57 @@ export default function NotificationCenter({
{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="flex items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-white/10">
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 border-b border-gray-200 dark:border-white/10">
<h2 className="min-w-0 truncate px-4 py-3 text-sm font-semibold text-gray-900 dark:text-white">
Benachrichtigungen
</h2>
{unreadCount > 0 && (
<button
type="button"
onClick={onMarkAllRead}
className="text-xs font-medium text-indigo-600 hover:text-indigo-500 dark:text-indigo-400"
>
Alle gelesen
</button>
)}
<div className="flex justify-end px-4">
{unreadCount > 0 && (
<Button
type="button"
variant="secondary"
color="indigo"
size="sm"
onClick={onMarkAllRead}
aria-label="Alle Benachrichtigungen als gelesen markieren"
title="Alle gelesen"
leadingIcon={
<EnvelopeOpenIcon aria-hidden="true" className="size-4" />
}
>
Alle gelesen
</Button>
)}
</div>
</div>
<div className="max-h-96 overflow-y-auto">
{notifications.length === 0 ? (
<p className="px-4 py-6 text-sm text-gray-500 dark:text-gray-400">
Keine Benachrichtigungen vorhanden.
</p>
<Tabs
tabs={notificationTabs}
variant="full-width-underline"
align="center"
ariaLabel="Benachrichtigungsfilter"
className="px-4 [&_a]:py-3"
/>
<div className="h-96 overflow-y-auto">
{filteredNotifications.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center px-6 py-10 text-center">
<FaceSmileIcon
aria-hidden="true"
className="size-12 text-gray-300 dark:text-gray-600"
/>
<p className="mt-3 text-sm font-medium text-gray-900 dark:text-white">
Alles erledigt
</p>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{getEmptyText()}
</p>
</div>
) : (
notifications.map((notification) => {
filteredNotifications.map((notification) => {
const unread = !notification.readAt
return (

View File

@ -3,6 +3,8 @@
import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router'
import {
ArrowRightIcon,
ChatBubbleLeftEllipsisIcon,
CircleStackIcon,
ClipboardDocumentListIcon,
ExclamationTriangleIcon,
@ -10,6 +12,7 @@ import {
UserGroupIcon,
} from '@heroicons/react/24/outline'
import type { User } from './types'
import Checkbox from './Checkbox'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
@ -48,8 +51,12 @@ function getGroupIcon(groupId: string) {
switch (groupId) {
case 'operations':
return ClipboardDocumentListIcon
case 'operation-journals':
return ChatBubbleLeftEllipsisIcon
case 'devices':
return CircleStackIcon
case 'device-journals':
return ChatBubbleLeftEllipsisIcon
case 'users':
return UserGroupIcon
default:
@ -57,25 +64,91 @@ function getGroupIcon(groupId: string) {
}
}
function getEntityLabel(entityType: string) {
switch (entityType) {
case 'operation':
return 'Einsatz'
case 'device':
return 'Gerät'
case 'user':
return 'Benutzer'
default:
return entityType
}
}
async function readApiError(response: Response, fallback: string) {
const data = await response.json().catch(() => null)
return data?.error ?? fallback
}
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function getHighlightTokens(
search: string,
exactPhrase: boolean,
caseSensitive: boolean,
) {
const trimmedSearch = search.trim()
if (exactPhrase && /\s/.test(trimmedSearch)) {
return [
caseSensitive
? trimmedSearch
: trimmedSearch.toLowerCase(),
]
}
const normalizedTokens = new Set<string>()
trimmedSearch
.split(/\s+/)
.map((token) => token.trim())
.filter((token) => token.length >= 2)
.forEach((token) =>
normalizedTokens.add(caseSensitive ? token : token.toLowerCase()),
)
return Array.from(normalizedTokens)
}
function HighlightedText({
value,
search,
exactPhrase,
caseSensitive,
}: {
value: string
search: string
exactPhrase: boolean
caseSensitive: boolean
}) {
const tokens = getHighlightTokens(search, exactPhrase, caseSensitive)
if (tokens.length === 0) {
return <>{value}</>
}
const pattern = new RegExp(
`(${tokens.map(escapeRegExp).join('|')})`,
caseSensitive ? 'g' : 'ig',
)
const parts = value.split(pattern)
return (
<>
{parts.map((part, index) => {
const normalizedPart = caseSensitive ? part : part.toLowerCase()
const isMatch = tokens.includes(normalizedPart)
if (!isMatch) {
return <span key={`${part}-${index}`}>{part}</span>
}
return (
<mark
key={`${part}-${index}`}
className="rounded bg-yellow-100 px-0.5 py-px font-semibold text-yellow-900 ring-1 ring-yellow-200 ring-inset"
>
{part}
</mark>
)
})}
</>
)
}
export default function SearchOverlay({
user,
query,
@ -89,6 +162,18 @@ export default function SearchOverlay({
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [exactPhrase, setExactPhrase] = useState(false)
const [caseSensitive, setCaseSensitive] = useState(false)
const canUseExactPhrase = /\s/.test(search)
const effectiveExactPhrase = canUseExactPhrase && exactPhrase
useEffect(() => {
if (!canUseExactPhrase && exactPhrase) {
setExactPhrase(false)
}
}, [canUseExactPhrase, exactPhrase])
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
@ -118,8 +203,21 @@ export default function SearchOverlay({
setError(null)
try {
const searchParams = new URLSearchParams({
q: search,
limit: '8',
})
if (effectiveExactPhrase) {
searchParams.set('exact', '1')
}
if (caseSensitive) {
searchParams.set('caseSensitive', '1')
}
const response = await fetch(
`${API_URL}/search?q=${encodeURIComponent(search)}&limit=8`,
`${API_URL}/search?${searchParams.toString()}`,
{
credentials: 'include',
signal: abortController.signal,
@ -137,11 +235,13 @@ export default function SearchOverlay({
const data = (await response.json()) as GlobalSearchResponse
const nextGroups: SearchGroup[] = (data.groups ?? []).map((group) => ({
...group,
icon: getGroupIcon(group.id),
items: group.items ?? [],
}))
const nextGroups: SearchGroup[] = (data.groups ?? [])
.map((group) => ({
...group,
icon: getGroupIcon(group.id),
items: group.items ?? [],
}))
.filter((group) => group.items.length > 0)
setGroups(nextGroups)
} catch (error) {
@ -170,41 +270,88 @@ export default function SearchOverlay({
window.clearTimeout(timeoutId)
abortController.abort()
}
}, [search])
}, [search, effectiveExactPhrase, caseSensitive])
const resultCount = useMemo(
() => groups.reduce((count, group) => count + group.items.length, 0),
[groups],
)
const hasResults = resultCount > 0
const showFullLoading = search.length >= 2 && isLoading && !hasResults
const showError = search.length >= 2 && !isLoading && error
const showNoResults =
search.length >= 2 && !isLoading && !error && !hasResults
return (
<div className="h-full overflow-y-auto bg-white backdrop-blur-sm">
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-6 flex items-center justify-between gap-x-4">
<div className="flex items-center gap-x-3">
<div className="flex size-10 items-center justify-center rounded-xl bg-indigo-50 text-indigo-600 ring-1 ring-indigo-600/10">
<MagnifyingGlassIcon
aria-hidden="true"
className="size-5"
/>
<div className="h-full overflow-y-auto bg-white/95 backdrop-blur-sm [scrollbar-gutter:stable]">
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
{/* Header */}
<div className="mb-8 rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-x-4">
<div className="flex size-12 shrink-0 items-center justify-center rounded-2xl bg-indigo-50 text-indigo-600 ring-1 ring-indigo-600/10">
<MagnifyingGlassIcon
aria-hidden="true"
className="size-6"
/>
</div>
<div>
<p className="text-base font-semibold text-gray-900">
Suche
</p>
<p className="mt-1 text-sm text-gray-500">
{search.length >= 2
? `${resultCount} Treffer für „${query}`
: 'Gib mindestens 2 Zeichen ein.'}
</p>
</div>
</div>
<div>
<p className="text-sm font-semibold text-gray-900">
Suche
</p>
<div className="flex flex-col gap-3 sm:items-end">
{search.length >= 2 && !error && (
<div className="inline-flex items-center gap-x-2 rounded-full bg-gray-50 px-3 py-1 text-sm font-medium text-gray-600 ring-1 ring-gray-200 ring-inset">
{isLoading && (
<span className="size-3 animate-spin rounded-full border-2 border-gray-200 border-t-indigo-600" />
)}
<p className="text-sm text-gray-500">
{search.length >= 2
? `${resultCount} Treffer für „${query}`
: 'Gib mindestens 2 Zeichen ein.'}
</p>
{isLoading ? 'Suche läuft...' : `${groups.length} Kategorien`}
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<Checkbox
checked={effectiveExactPhrase}
disabled={!canUseExactPhrase}
onChange={(event) => setExactPhrase(event.target.checked)}
label="Exakter Wortlaut"
wrapperClassName={[
'items-center gap-2 rounded-full px-3 py-1.5 ring-1 ring-inset',
canUseExactPhrase
? 'cursor-pointer bg-white text-gray-700 ring-gray-200 hover:bg-gray-50'
: 'cursor-not-allowed bg-gray-50 text-gray-400 ring-gray-200',
].join(' ')}
labelClassName="text-xs/5 font-medium text-inherit"
inputClassName="size-4"
/>
<Checkbox
checked={caseSensitive}
onChange={(event) => setCaseSensitive(event.target.checked)}
label="Groß-/Kleinschreibung"
wrapperClassName="items-center gap-2 rounded-full bg-white px-3 py-1.5 text-gray-700 ring-1 ring-gray-200 ring-inset hover:bg-gray-50"
labelClassName="text-xs/5 font-medium text-inherit"
inputClassName="size-4"
/>
</div>
</div>
</div>
</div>
{search.length < 2 && (
<div className="rounded-2xl border border-dashed border-gray-300 bg-white p-10 text-center">
<div className="rounded-2xl border border-dashed border-gray-300 bg-white p-10 text-center shadow-sm">
<MagnifyingGlassIcon
aria-hidden="true"
className="mx-auto size-10 text-gray-300"
@ -220,8 +367,8 @@ export default function SearchOverlay({
</div>
)}
{search.length >= 2 && isLoading && (
<div className="rounded-2xl border border-dashed border-gray-300 bg-white p-10 text-center">
{showFullLoading && (
<div className="rounded-2xl border border-dashed border-gray-300 bg-white p-10 text-center shadow-sm">
<div className="mx-auto size-8 animate-spin rounded-full border-2 border-gray-200 border-t-indigo-600" />
<p className="mt-4 text-sm font-medium text-gray-900">
@ -234,8 +381,8 @@ export default function SearchOverlay({
</div>
)}
{search.length >= 2 && !isLoading && error && (
<div className="rounded-2xl border border-red-200 bg-red-50 p-10 text-center">
{showError && (
<div className="rounded-2xl border border-red-200 bg-red-50 p-10 text-center shadow-sm">
<ExclamationTriangleIcon
aria-hidden="true"
className="mx-auto size-10 text-red-400"
@ -251,8 +398,8 @@ export default function SearchOverlay({
</div>
)}
{search.length >= 2 && !isLoading && !error && resultCount === 0 && (
<div className="rounded-2xl border border-dashed border-gray-300 bg-white p-10 text-center">
{showNoResults && (
<div className="rounded-2xl border border-dashed border-gray-300 bg-white p-10 text-center shadow-sm">
<ExclamationTriangleIcon
aria-hidden="true"
className="mx-auto size-10 text-gray-300"
@ -268,19 +415,16 @@ export default function SearchOverlay({
</div>
)}
{!isLoading && !error && groups.length > 0 && (
<div className="space-y-6">
{!error && groups.length > 0 && (
<div className="space-y-8">
{groups.map((group) => {
const Icon = group.icon
return (
<section
key={group.id}
className="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm"
>
<div className="flex items-center justify-between border-b border-gray-100 px-5 py-4">
<section key={group.id}>
<div className="mb-4 flex items-center justify-between gap-x-4">
<div className="flex items-center gap-x-3">
<div className="flex size-9 items-center justify-center rounded-lg bg-gray-50 text-gray-500 ring-1 ring-gray-200 ring-inset">
<div className="flex size-10 items-center justify-center rounded-xl bg-gray-50 text-gray-500 ring-1 ring-gray-200 ring-inset">
<Icon
aria-hidden="true"
className="size-5"
@ -299,35 +443,43 @@ export default function SearchOverlay({
</div>
</div>
<ul className="divide-y divide-gray-100">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
{group.items.map((item) => (
<li key={`${group.id}-${item.id}`}>
<Link
to={item.href}
onClick={onClose}
className="group block px-5 py-4 transition hover:bg-gray-50"
>
<div className="flex items-start justify-between gap-x-4">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-gray-900 group-hover:text-indigo-600">
{item.title}
</p>
<Link
key={`${group.id}-${item.id}`}
to={item.href}
onClick={onClose}
className="group relative flex min-h-28 flex-col justify-between rounded-xl border border-gray-200 bg-white p-4 pr-10 shadow-sm transition hover:-translate-y-0.5 hover:border-indigo-200 hover:shadow-md"
>
<ArrowRightIcon
aria-hidden="true"
className="absolute top-4 right-4 size-4 text-gray-300 transition group-hover:translate-x-0.5 group-hover:text-indigo-500"
/>
{item.subtitle && (
<p className="mt-1 line-clamp-2 text-sm text-gray-500">
{item.subtitle}
</p>
)}
</div>
<div>
<p className="line-clamp-1 text-sm font-semibold text-gray-900 group-hover:text-indigo-600">
<HighlightedText
value={item.title}
search={search}
exactPhrase={effectiveExactPhrase}
caseSensitive={caseSensitive}
/>
</p>
<span className="shrink-0 rounded-md bg-gray-50 px-2 py-1 text-xs font-medium text-gray-500 ring-1 ring-gray-200 ring-inset">
{getEntityLabel(item.entityType)}
</span>
</div>
</Link>
</li>
{item.subtitle && (
<p className="mt-1 line-clamp-2 text-xs text-gray-500">
<HighlightedText
value={item.subtitle}
search={search}
exactPhrase={effectiveExactPhrase}
caseSensitive={caseSensitive}
/>
</p>
)}
</div>
</Link>
))}
</ul>
</div>
</section>
)
})}

View File

@ -9,6 +9,7 @@ import {
import {
CalendarIcon,
ChartPieIcon,
ChatBubbleLeftRightIcon,
Cog6ToothIcon,
ComputerDesktopIcon,
DocumentDuplicateIcon,
@ -17,7 +18,7 @@ import {
HomeIcon,
XMarkIcon,
} from '@heroicons/react/24/outline'
import { NavLink } from 'react-router'
import { NavLink, useLocation } from 'react-router'
import { hasRight } from './permissions'
import type { User, Team } from './types'
@ -63,6 +64,7 @@ type SidebarProps = {
user: User
sidebarOpen: boolean
setSidebarOpen: (open: boolean) => void
onNavigate?: () => void
}
function classNames(...classes: Array<string | false | null | undefined>) {
@ -80,16 +82,36 @@ function getTeamInitial(team: Team) {
function SidebarContent({
user,
setSidebarOpen,
onNavigate,
}: {
user: User
setSidebarOpen: (open: boolean) => void
onNavigate?: () => void
}) {
const teams = user.teams ?? []
const location = useLocation()
const isAdministrationActive =
location.pathname === '/administration' ||
location.pathname.startsWith('/administration/')
const isSettingsActive =
location.pathname === '/einstellungen' ||
location.pathname.startsWith('/einstellungen/')
const isFeedbackActive =
location.pathname === '/feedback'
const visibleNavigation = navigation.filter((item) =>
!item.right || hasRight(user, item.right),
)
function handleNavigate() {
onNavigate?.()
setSidebarOpen(false)
}
return (
<>
<div className="flex h-16 shrink-0 items-center">
@ -108,7 +130,7 @@ function SidebarContent({
<li key={item.name}>
<NavLink
to={item.href}
onClick={() => setSidebarOpen(false)}
onClick={handleNavigate}
className={({ isActive }) =>
classNames(
isActive
@ -137,6 +159,7 @@ function SidebarContent({
<li key={team.id}>
<a
href={team.href || '#'}
onClick={handleNavigate}
className={classNames(
team.current
? 'bg-white/5 text-white'
@ -160,31 +183,29 @@ function SidebarContent({
</li>
<li className="mt-auto space-y-1">
{hasRight(user, 'administration:read') && (
<NavLink
to="/administration"
onClick={() => setSidebarOpen(false)}
className={({ isActive }) =>
classNames(
isActive
? 'bg-white/5 text-white'
: 'text-gray-400 hover:bg-white/5 hover:text-white',
'group -mx-2 flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
)
}
>
<ShieldCheckIcon aria-hidden="true" className="size-6 shrink-0" />
Administration
</NavLink>
)}
<NavLink
to="/feedback"
onClick={handleNavigate}
className={() =>
classNames(
isFeedbackActive
? 'bg-white/5 text-white'
: 'text-gray-400 hover:bg-white/5 hover:text-white',
'group -mx-2 flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
)
}
>
<ChatBubbleLeftRightIcon aria-hidden="true" className="size-6 shrink-0" />
Feedback
</NavLink>
{hasRight(user, 'settings:read') && (
<NavLink
to="/einstellungen"
onClick={() => setSidebarOpen(false)}
className={({ isActive }) =>
to="/einstellungen/konto"
onClick={handleNavigate}
className={() =>
classNames(
isActive
isSettingsActive
? 'bg-white/5 text-white'
: 'text-gray-400 hover:bg-white/5 hover:text-white',
'group -mx-2 flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
@ -195,6 +216,24 @@ function SidebarContent({
Einstellungen
</NavLink>
)}
{hasRight(user, 'administration:read') && (
<NavLink
to="/administration/benutzer"
onClick={handleNavigate}
className={() =>
classNames(
isAdministrationActive
? 'bg-white/5 text-white'
: 'text-gray-400 hover:bg-white/5 hover:text-white',
'group -mx-2 flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
)
}
>
<ShieldCheckIcon aria-hidden="true" className="size-6 shrink-0" />
Administration
</NavLink>
)}
</li>
</ul>
</nav>
@ -202,7 +241,12 @@ function SidebarContent({
)
}
export default function Sidebar({ user, sidebarOpen, setSidebarOpen }: SidebarProps) {
export default function Sidebar({
user,
sidebarOpen,
setSidebarOpen,
onNavigate,
}: SidebarProps) {
return (
<>
<Dialog open={sidebarOpen} onClose={setSidebarOpen} className="relative z-50 lg:hidden">
@ -226,7 +270,11 @@ export default function Sidebar({ user, sidebarOpen, setSidebarOpen }: SidebarPr
</TransitionChild>
<div className="relative flex grow flex-col gap-y-5 overflow-y-auto bg-gray-900 px-6 pb-4 ring-1 ring-white/10 dark:before:pointer-events-none dark:before:absolute dark:before:inset-0 dark:before:bg-black/10">
<SidebarContent user={user} setSidebarOpen={setSidebarOpen} />
<SidebarContent
user={user}
setSidebarOpen={setSidebarOpen}
onNavigate={onNavigate}
/>
</div>
</DialogPanel>
</div>
@ -234,7 +282,11 @@ export default function Sidebar({ user, sidebarOpen, setSidebarOpen }: SidebarPr
<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="flex grow flex-col gap-y-5 overflow-y-auto bg-black/10 px-6 pb-4">
<SidebarContent user={user} setSidebarOpen={setSidebarOpen} />
<SidebarContent
user={user}
setSidebarOpen={setSidebarOpen}
onNavigate={onNavigate}
/>
</div>
</div>
</>

View File

@ -2,6 +2,8 @@
import {
Fragment,
type KeyboardEvent,
type MouseEvent,
type ReactNode,
useLayoutEffect,
useRef,
@ -28,6 +30,14 @@ export type TableVariant =
| 'summary'
| 'border'
export type TableSortValue =
| string
| number
| boolean
| Date
| null
| undefined
export type TableColumn<T> = {
key: string
header: string
@ -35,7 +45,8 @@ export type TableColumn<T> = {
mobileRender?: (row: T) => ReactNode
isPrimary?: boolean
sortable?: boolean
hideOnMobile?: 'sm' | 'md' | 'lg'
sortValue?: (row: T) => TableSortValue
hideOnMobile?: 'sm' | 'md' | 'lg' | 'xl'
align?: 'left' | 'center' | 'right'
className?: string
headerClassName?: string
@ -74,6 +85,9 @@ type TablesProps<T> = {
headerAction?: ReactNode
rowActions?: (row: T) => ReactNode
onRowClick?: (row: T) => void
getRowAriaLabel?: (row: T) => string
isLoading?: boolean
loadingContent?: ReactNode
@ -189,6 +203,8 @@ export default function Tables<T>({
headerAction,
rowActions,
onRowClick,
getRowAriaLabel,
isLoading = false,
loadingContent,
@ -434,6 +450,40 @@ export default function Tables<T>({
)
}
function isInteractiveTarget(
target: EventTarget | null,
currentTarget: HTMLElement,
) {
if (!(target instanceof HTMLElement)) {
return false
}
const interactiveTarget = target.closest(
'a, button, input, select, textarea, label, [role="button"], [role="link"]',
)
return Boolean(interactiveTarget && interactiveTarget !== currentTarget)
}
function handleRowClick(row: T, event: MouseEvent<HTMLTableRowElement>) {
if (!onRowClick || isInteractiveTarget(event.target, event.currentTarget)) {
return
}
onRowClick(row)
}
function handleRowKeyDown(row: T, event: KeyboardEvent<HTMLTableRowElement>) {
if (!onRowClick || isInteractiveTarget(event.target, event.currentTarget)) {
return
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onRowClick(row)
}
}
function renderRow(row: T, rowIndex: number) {
const rowId = getRowId(row)
const selected = selectedSet.has(rowId)
@ -441,10 +491,16 @@ export default function Tables<T>({
return (
<tr
key={rowId}
role={onRowClick ? 'button' : undefined}
tabIndex={onRowClick ? 0 : undefined}
aria-label={onRowClick ? getRowAriaLabel?.(row) : undefined}
onClick={(event) => handleRowClick(row, event)}
onKeyDown={(event) => handleRowKeyDown(row, event)}
className={classNames(
isStriped && 'even:bg-gray-50 dark:even:bg-gray-800/50',
selected && 'bg-gray-50 dark:bg-gray-800/50',
isVerticalLines && 'divide-x divide-gray-200 dark:divide-white/10',
onRowClick && 'cursor-pointer hover:bg-gray-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-600 focus-visible:ring-inset dark:hover:bg-gray-800/70 dark:focus-visible:ring-indigo-500',
)}
>
{selectable && (
@ -470,7 +526,7 @@ export default function Tables<T>({
<td
key={column.key}
className={classNames(
isCondensed ? 'py-2' : isAvatarVariant(variant) ? 'py-5' : 'py-4',
isCondensed ? 'py-2' : isCard ? 'py-3' : isAvatarVariant(variant) ? 'py-5' : 'py-4',
isCondensed ? 'px-2' : 'px-3',
isFirst && !selectable && 'pl-4 sm:pl-0',
isCard && isFirst && 'sm:pl-6',

View File

@ -1,6 +1,11 @@
// frontend\src\components\Tabs.tsx
// frontend/src/components/Tabs.tsx
import type { ComponentType, SVGProps } from 'react'
import type {
ChangeEvent,
ComponentType,
MouseEvent,
SVGProps,
} from 'react'
import { Link, useLocation, useNavigate } from 'react-router'
import { ChevronDownIcon } from '@heroicons/react/16/solid'
@ -12,6 +17,7 @@ export type TabItem = {
current?: boolean
icon?: TabIcon
count?: string | number
onClick?: () => void
}
type TabsVariant =
@ -47,6 +53,10 @@ const alignClassMap: Record<TabsAlign, string> = {
right: 'justify-end',
}
function hasCount(tab: TabItem) {
return tab.count !== undefined && tab.count !== null && tab.count !== ''
}
function isTabActive(tab: TabItem, pathname: string) {
if (typeof tab.current === 'boolean') {
return tab.current
@ -56,6 +66,10 @@ function isTabActive(tab: TabItem, pathname: string) {
return pathname === '/'
}
if (tab.href === '#') {
return false
}
return pathname.startsWith(tab.href)
}
@ -71,16 +85,20 @@ export default function Tabs({
const location = useLocation()
const navigate = useNavigate()
const activeTab = tabs.find((tab) => isTabActive(tab, location.pathname)) ?? tabs[0]
const activeTab =
tabs.find((tab) => isTabActive(tab, location.pathname)) ?? tabs[0]
const alignClassName = alignClassMap[align]
function handleSelectChange(event: React.ChangeEvent<HTMLSelectElement>) {
function handleSelectChange(event: ChangeEvent<HTMLSelectElement>) {
const selectedTab = tabs.find((tab) => tab.name === event.target.value)
if (!selectedTab) {
return
}
selectedTab.onClick?.()
if (selectedTab.href === '#') {
return
}
@ -88,6 +106,51 @@ export default function Tabs({
navigate(selectedTab.href)
}
function handleTabClick(
event: MouseEvent<HTMLAnchorElement>,
tab: TabItem,
) {
if (tab.href === '#') {
event.preventDefault()
}
tab.onClick?.()
}
function renderCount(tab: TabItem, active: boolean) {
if (!hasCount(tab)) {
return null
}
if (variant === 'underline-badges') {
return (
<span
className={classNames(
active
? 'bg-indigo-100 text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-400'
: 'bg-gray-100 text-gray-900 dark:bg-white/10 dark:text-gray-300',
'ml-3 hidden rounded-full px-2.5 py-0.5 text-xs font-medium md:inline-block',
)}
>
{tab.count}
</span>
)
}
return (
<span
className={classNames(
active
? 'bg-indigo-100 text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-300'
: 'bg-gray-100 text-gray-700 dark:bg-white/10 dark:text-gray-300',
'ml-2 rounded-full px-2 py-0.5 text-xs font-medium',
)}
>
{tab.count}
</span>
)
}
return (
<div
className={classNames(
@ -117,7 +180,10 @@ export default function Tabs({
<div className="hidden sm:block">
{variant === 'underline' && (
<div className="border-b border-gray-200 dark:border-white/10">
<nav aria-label={ariaLabel} className={classNames('-mb-px flex space-x-8', alignClassName)}>
<nav
aria-label={ariaLabel}
className={classNames('-mb-px flex space-x-8', alignClassName)}
>
{tabs.map((tab) => {
const active = isTabActive(tab, location.pathname)
@ -126,14 +192,16 @@ export default function Tabs({
key={tab.name}
to={tab.href}
aria-current={active ? 'page' : undefined}
onClick={(event) => handleTabClick(event, tab)}
className={classNames(
active
? 'border-indigo-500 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-b-2 px-1 py-4 text-sm font-medium whitespace-nowrap',
'flex items-center border-b-2 px-1 py-4 text-sm font-medium whitespace-nowrap',
)}
>
{tab.name}
{renderCount(tab, active)}
</Link>
)
})}
@ -143,7 +211,10 @@ export default function Tabs({
{variant === 'underline-icons' && (
<div className="border-b border-gray-200 dark:border-white/10">
<nav aria-label={ariaLabel} className={classNames('-mb-px flex space-x-8', alignClassName)}>
<nav
aria-label={ariaLabel}
className={classNames('-mb-px flex space-x-8', alignClassName)}
>
{tabs.map((tab) => {
const active = isTabActive(tab, location.pathname)
const Icon = tab.icon
@ -153,6 +224,7 @@ export default function Tabs({
key={tab.name}
to={tab.href}
aria-current={active ? 'page' : undefined}
onClick={(event) => handleTabClick(event, tab)}
className={classNames(
active
? 'border-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400'
@ -171,7 +243,9 @@ export default function Tabs({
)}
/>
)}
<span>{tab.name}</span>
{renderCount(tab, active)}
</Link>
)
})}
@ -181,7 +255,10 @@ export default function Tabs({
{variant === 'pills' && (
<div className="border-b border-gray-200 px-4 py-3 sm:px-6 lg:px-8 dark:border-white/10">
<nav aria-label={ariaLabel} className={classNames('flex space-x-4', alignClassName)}>
<nav
aria-label={ariaLabel}
className={classNames('flex space-x-4', alignClassName)}
>
{tabs.map((tab) => {
const active = isTabActive(tab, location.pathname)
@ -190,14 +267,16 @@ export default function Tabs({
key={tab.name}
to={tab.href}
aria-current={active ? 'page' : undefined}
onClick={(event) => handleTabClick(event, tab)}
className={classNames(
active
? 'bg-gray-100 text-gray-700 dark:bg-white/10 dark:text-gray-200'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200',
'rounded-md px-3 py-2 text-sm font-medium',
'inline-flex items-center rounded-md px-3 py-2 text-sm font-medium',
)}
>
{tab.name}
{renderCount(tab, active)}
</Link>
)
})}
@ -207,7 +286,10 @@ export default function Tabs({
{variant === 'pills-gray' && (
<div className="border-b border-gray-200 px-4 py-3 sm:px-6 lg:px-8 dark:border-white/10">
<nav aria-label={ariaLabel} className={classNames('flex space-x-4', alignClassName)}>
<nav
aria-label={ariaLabel}
className={classNames('flex space-x-4', alignClassName)}
>
{tabs.map((tab) => {
const active = isTabActive(tab, location.pathname)
@ -216,14 +298,16 @@ export default function Tabs({
key={tab.name}
to={tab.href}
aria-current={active ? 'page' : undefined}
onClick={(event) => handleTabClick(event, tab)}
className={classNames(
active
? 'bg-gray-200 text-gray-800 dark:bg-white/10 dark:text-white'
: 'text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-white',
'rounded-md px-3 py-2 text-sm font-medium',
'inline-flex items-center rounded-md px-3 py-2 text-sm font-medium',
)}
>
{tab.name}
{renderCount(tab, active)}
</Link>
)
})}
@ -233,7 +317,10 @@ export default function Tabs({
{variant === 'pills-brand' && (
<div className="border-b border-gray-200 px-4 py-3 sm:px-6 lg:px-8 dark:border-white/10">
<nav aria-label={ariaLabel} className={classNames('flex space-x-4', alignClassName)}>
<nav
aria-label={ariaLabel}
className={classNames('flex space-x-4', alignClassName)}
>
{tabs.map((tab) => {
const active = isTabActive(tab, location.pathname)
@ -242,14 +329,16 @@ export default function Tabs({
key={tab.name}
to={tab.href}
aria-current={active ? 'page' : undefined}
onClick={(event) => handleTabClick(event, tab)}
className={classNames(
active
? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200',
'rounded-md px-3 py-2 text-sm font-medium',
'inline-flex items-center rounded-md px-3 py-2 text-sm font-medium',
)}
>
{tab.name}
{renderCount(tab, active)}
</Link>
)
})}
@ -268,14 +357,16 @@ export default function Tabs({
key={tab.name}
to={tab.href}
aria-current={active ? 'page' : undefined}
onClick={(event) => handleTabClick(event, tab)}
className={classNames(
active
? 'border-indigo-500 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-300',
'flex-1 border-b-2 px-1 py-4 text-center text-sm font-medium',
'flex flex-1 items-center justify-center border-b-2 px-1 py-4 text-center text-sm font-medium',
)}
>
{tab.name}
{renderCount(tab, active)}
</Link>
)
})}
@ -296,6 +387,7 @@ export default function Tabs({
key={tab.name}
to={tab.href}
aria-current={active ? 'page' : undefined}
onClick={(event) => handleTabClick(event, tab)}
className={classNames(
active
? 'text-gray-900 dark:text-white'
@ -305,11 +397,17 @@ export default function Tabs({
'group relative min-w-0 flex-1 overflow-hidden px-4 py-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10 dark:hover:bg-white/5',
)}
>
<span>{tab.name}</span>
<span className="inline-flex items-center justify-center">
{tab.name}
{renderCount(tab, active)}
</span>
<span
aria-hidden="true"
className={classNames(
active ? 'bg-indigo-500 dark:bg-indigo-400' : 'bg-transparent',
active
? 'bg-indigo-500 dark:bg-indigo-400'
: 'bg-transparent',
'absolute inset-x-0 bottom-0 h-0.5',
)}
/>
@ -321,7 +419,10 @@ export default function Tabs({
{variant === 'underline-badges' && (
<div className="border-b border-gray-200 dark:border-white/10">
<nav aria-label={ariaLabel} className={classNames('-mb-px flex space-x-8', alignClassName)}>
<nav
aria-label={ariaLabel}
className={classNames('-mb-px flex space-x-8', alignClassName)}
>
{tabs.map((tab) => {
const active = isTabActive(tab, location.pathname)
@ -330,6 +431,7 @@ export default function Tabs({
key={tab.name}
to={tab.href}
aria-current={active ? 'page' : undefined}
onClick={(event) => handleTabClick(event, tab)}
className={classNames(
active
? 'border-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400'
@ -338,19 +440,7 @@ export default function Tabs({
)}
>
{tab.name}
{tab.count ? (
<span
className={classNames(
active
? 'bg-indigo-100 text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-400'
: 'bg-gray-100 text-gray-900 dark:bg-white/10 dark:text-gray-300',
'ml-3 hidden rounded-full px-2.5 py-0.5 text-xs font-medium md:inline-block',
)}
>
{tab.count}
</span>
) : null}
{renderCount(tab, active)}
</Link>
)
})}
@ -378,13 +468,16 @@ export default function Tabs({
<Link
to={tab.href}
aria-current={active ? 'page' : undefined}
className={
onClick={(event) => handleTabClick(event, tab)}
className={classNames(
active
? 'text-indigo-600 dark:text-indigo-400'
: 'hover:text-gray-700 dark:hover:text-white'
}
: 'hover:text-gray-700 dark:hover:text-white',
'inline-flex items-center',
)}
>
{tab.name}
{renderCount(tab, active)}
</Link>
</li>
)

View File

@ -0,0 +1,244 @@
// frontend\src\components\TaskList.tsx
import type { ReactNode } from 'react'
import { CheckIcon, XMarkIcon } from '@heroicons/react/20/solid'
import LoadingSpinner from './LoadingSpinner'
export type TaskListStepStatus = 'complete' | 'current' | 'upcoming' | 'error'
export type TaskListStep = {
id?: string | number
name: ReactNode
description?: ReactNode
href?: string
status: TaskListStepStatus
}
type TaskListProps = {
steps: TaskListStep[]
ariaLabel?: string
className?: string
}
function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(' ')
}
export default function TaskList({
steps,
ariaLabel = 'Progress',
className,
}: TaskListProps) {
return (
<nav aria-label={ariaLabel} className={className}>
<ol role="list" className="overflow-hidden">
{steps.map((step, stepIdx) => {
const isLastStep = stepIdx === steps.length - 1
const key = step.id ?? stepIdx
return (
<li
key={key}
className={classNames(!isLastStep && 'pb-10', 'relative')}
>
{step.status === 'complete' ? (
<TaskListItemComplete step={step} isLastStep={isLastStep} />
) : step.status === 'current' ? (
<TaskListItemCurrent step={step} isLastStep={isLastStep} />
) : step.status === 'error' ? (
<TaskListItemError step={step} isLastStep={isLastStep} />
) : (
<TaskListItemUpcoming step={step} isLastStep={isLastStep} />
)}
</li>
)
})}
</ol>
</nav>
)
}
function TaskListItemComplete({
step,
isLastStep,
}: {
step: TaskListStep
isLastStep: boolean
}) {
return (
<>
{!isLastStep && (
<div
aria-hidden="true"
className="absolute top-4 left-4 mt-0.5 -ml-px h-full w-0.5 bg-indigo-600 dark:bg-indigo-500"
/>
)}
<TaskListItemWrapper step={step}>
<span className="flex h-9 items-center">
<span className="relative z-10 flex size-8 items-center justify-center rounded-full bg-indigo-600 group-hover:bg-indigo-800 dark:bg-indigo-500 dark:group-hover:bg-indigo-600">
<CheckIcon aria-hidden="true" className="size-5 text-white" />
</span>
</span>
<TaskListItemContent
step={step}
nameClassName="text-gray-900 dark:text-white"
/>
</TaskListItemWrapper>
</>
)
}
function TaskListItemCurrent({
step,
isLastStep,
}: {
step: TaskListStep
isLastStep: boolean
}) {
return (
<>
{!isLastStep && (
<div
aria-hidden="true"
className="absolute top-4 left-4 mt-0.5 -ml-px h-full w-0.5 bg-gray-300 dark:bg-gray-700"
/>
)}
<TaskListItemWrapper step={step} ariaCurrent="step">
<span className="flex h-9 items-center">
<span className="relative z-10 flex size-8 items-center justify-center rounded-full border-2 border-indigo-600 bg-white dark:border-indigo-500 dark:bg-gray-900">
<LoadingSpinner
size="sm"
className="text-indigo-600 dark:text-indigo-500"
srLabel="Aktueller Schritt läuft"
/>
</span>
</span>
<TaskListItemContent
step={step}
nameClassName="text-indigo-600 dark:text-indigo-400"
/>
</TaskListItemWrapper>
</>
)
}
function TaskListItemError({
step,
isLastStep,
}: {
step: TaskListStep
isLastStep: boolean
}) {
return (
<>
{!isLastStep && (
<div
aria-hidden="true"
className="absolute top-4 left-4 mt-0.5 -ml-px h-full w-0.5 bg-gray-300 dark:bg-white/15"
/>
)}
<TaskListItemWrapper step={step}>
<span className="flex h-9 items-center">
<span className="relative z-10 flex size-8 items-center justify-center rounded-full bg-red-600 dark:bg-red-500">
<XMarkIcon aria-hidden="true" className="size-5 text-white" />
</span>
</span>
<TaskListItemContent
step={step}
nameClassName="text-red-600 dark:text-red-400"
/>
</TaskListItemWrapper>
</>
)
}
function TaskListItemUpcoming({
step,
isLastStep,
}: {
step: TaskListStep
isLastStep: boolean
}) {
return (
<>
{!isLastStep && (
<div
aria-hidden="true"
className="absolute top-4 left-4 mt-0.5 -ml-px h-full w-0.5 bg-gray-300 dark:bg-white/15"
/>
)}
<TaskListItemWrapper step={step}>
<span aria-hidden="true" className="flex h-9 items-center">
<span className="relative z-10 flex size-8 items-center justify-center rounded-full border-2 border-gray-300 bg-white group-hover:border-gray-400 dark:border-white/15 dark:bg-gray-900 dark:group-hover:border-white/25">
<span className="size-2.5 rounded-full bg-transparent group-hover:bg-gray-300 dark:group-hover:bg-white/15" />
</span>
</span>
<TaskListItemContent
step={step}
nameClassName="text-gray-500 dark:text-gray-400"
/>
</TaskListItemWrapper>
</>
)
}
function TaskListItemWrapper({
step,
ariaCurrent,
children,
}: {
step: TaskListStep
ariaCurrent?: 'step'
children: ReactNode
}) {
if (step.href) {
return (
<a
href={step.href}
aria-current={ariaCurrent}
className="group relative flex items-start"
>
{children}
</a>
)
}
return (
<div
aria-current={ariaCurrent}
className="group relative flex items-start"
>
{children}
</div>
)
}
function TaskListItemContent({
step,
nameClassName,
}: {
step: TaskListStep
nameClassName: string
}) {
return (
<span className="ml-4 flex min-w-0 flex-col">
<span className={classNames('text-sm font-medium', nameClassName)}>
{step.name}
</span>
{step.description && (
<span className="text-sm text-gray-500 dark:text-gray-400">
{step.description}
</span>
)}
</span>
)
}

View File

@ -0,0 +1,663 @@
// frontend/src/components/Textarea.tsx
import {
forwardRef,
useId,
useState,
type ChangeEvent,
type InputHTMLAttributes,
type ReactNode,
type TextareaHTMLAttributes,
} from 'react'
import {
Tab,
TabGroup,
TabList,
TabPanel,
TabPanels,
} from '@headlessui/react'
import {
AtSymbolIcon,
CodeBracketIcon,
LinkIcon,
PaperClipIcon,
} from '@heroicons/react/20/solid'
import Avatar, { type AvatarProps } from './Avatar'
export type TextareaVariant =
| 'simple'
| 'avatar-actions'
| 'underline-actions'
| 'title-actions'
| 'preview-button'
type TextareaResize = 'none' | 'vertical' | 'horizontal' | 'both'
type TextareaValue =
| string
| number
| readonly string[]
| undefined
type TextareaProps = Omit<
TextareaHTMLAttributes<HTMLTextAreaElement>,
'value' | 'defaultValue' | 'onChange'
> & {
value?: TextareaValue
defaultValue?: TextareaValue
onChange?: (event: ChangeEvent<HTMLTextAreaElement>) => void
label?: ReactNode
description?: ReactNode
error?: ReactNode
variant?: TextareaVariant
resize?: TextareaResize
srOnlyLabel?: boolean
avatar?: ReactNode
avatarProps?: AvatarProps
submitLabel?: ReactNode
submitButtonType?: 'button' | 'submit'
showSubmitButton?: boolean
onSubmitButtonClick?: () => void
showAttachButton?: boolean
attachLabel?: string
onAttachClick?: () => void
titleLabel?: ReactNode
titleInputProps?: InputHTMLAttributes<HTMLInputElement>
footerAttachLabel?: string
writeTabLabel?: ReactNode
previewTabLabel?: ReactNode
previewContent?: ReactNode | ((value: string) => ReactNode)
previewPlaceholder?: ReactNode
showPreviewToolbar?: boolean
onInsertLinkClick?: () => void
onInsertCodeClick?: () => void
onMentionClick?: () => void
wrapperClassName?: string
fieldWrapperClassName?: string
toolbarClassName?: string
labelClassName?: string
descriptionClassName?: string
errorClassName?: string
textareaClassName?: string
titleInputClassName?: string
}
function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(' ')
}
function normalizeTextareaValue(value: TextareaValue) {
if (Array.isArray(value)) {
return value.join('')
}
if (value === undefined || value === null) {
return ''
}
return String(value)
}
function getResizeClassName(resize: TextareaResize) {
switch (resize) {
case 'none':
return 'resize-none'
case 'horizontal':
return 'resize-x'
case 'both':
return 'resize'
case 'vertical':
default:
return 'resize-y'
}
}
const simpleTextareaClassName =
'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 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500 disabled:outline-gray-200 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-gray-500'
const boxedTextareaClassName =
'block w-full bg-transparent px-3 py-1.5 text-base text-gray-900 placeholder:text-gray-400 focus:outline-none disabled:cursor-not-allowed disabled:text-gray-500 sm:text-sm/6 dark:text-white dark:placeholder:text-gray-500'
const underlineTextareaClassName =
'block w-full bg-transparent text-base text-gray-900 placeholder:text-gray-400 focus:outline-none disabled:cursor-not-allowed disabled:text-gray-500 sm:text-sm/6 dark:text-white dark:placeholder:text-gray-500'
const titleTextareaClassName =
'block w-full bg-transparent px-3 py-1.5 text-base text-gray-900 placeholder:text-gray-400 focus:outline-none disabled:cursor-not-allowed disabled:text-gray-500 sm:text-sm/6 dark:text-white dark:placeholder:text-gray-500'
const titleInputBaseClassName =
'block w-full bg-transparent px-3 pt-2.5 text-lg font-medium text-gray-900 placeholder:text-gray-400 focus:outline-none disabled:cursor-not-allowed disabled:text-gray-500 dark:text-white dark:placeholder:text-gray-500'
const errorTextareaClassName =
'text-red-900 outline-red-300 placeholder:text-red-300 focus:outline-red-600 dark:text-red-300 dark:outline-red-500/50 dark:placeholder:text-red-400 dark:focus:outline-red-500'
const boxedWrapperClassName =
'rounded-lg bg-white outline-1 -outline-offset-1 outline-gray-300 focus-within:outline-2 focus-within:-outline-offset-2 focus-within:outline-indigo-600 dark:bg-white/5 dark:outline-white/10 dark:focus-within:outline-indigo-500'
const titleWrapperClassName =
'rounded-lg bg-white outline-1 -outline-offset-1 outline-gray-300 focus-within:outline-2 focus-within:-outline-offset-2 focus-within:outline-indigo-600 dark:bg-gray-800/50 dark:outline-white/10 dark:focus-within:outline-indigo-500'
const underlineWrapperClassName =
'border-b border-gray-200 pb-px focus-within:border-b-2 focus-within:border-indigo-600 focus-within:pb-0 dark:border-white/10 dark:focus-within:border-indigo-500'
const previewTabClassName =
'rounded-md border border-transparent bg-white px-3 py-1.5 text-sm font-medium text-gray-500 hover:bg-gray-100 hover:text-gray-900 data-selected:bg-gray-100 data-selected:text-gray-900 data-selected:hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-400 dark:hover:bg-white/10 dark:hover:text-white dark:data-selected:bg-white/10 dark:data-selected:text-white dark:data-selected:hover:bg-white/10'
const iconButtonClassName =
'-m-2.5 inline-flex size-10 items-center justify-center rounded-full text-gray-400 hover:text-gray-500 disabled:cursor-not-allowed disabled:opacity-50 dark:text-gray-500 dark:hover:text-white'
const submitButtonClassName =
'inline-flex items-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-indigo-500 dark:shadow-none dark:hover:bg-indigo-400 dark:focus-visible:outline-indigo-500'
const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
function Textarea(
{
id,
label,
description,
error,
variant = 'simple',
resize,
rows = 4,
srOnlyLabel = false,
avatar,
avatarProps,
submitLabel = variant === 'title-actions' ? 'Erstellen' : 'Senden',
submitButtonType = 'submit',
showSubmitButton = true,
onSubmitButtonClick,
attachLabel = 'Datei anhängen',
onAttachClick,
showAttachButton = Boolean(onAttachClick),
titleLabel = 'Title',
titleInputProps,
footerAttachLabel = 'Datei anhängen',
writeTabLabel = 'Write',
previewTabLabel = 'Preview',
previewContent,
previewPlaceholder = 'Preview content will render here.',
showPreviewToolbar = true,
onInsertLinkClick,
onInsertCodeClick,
onMentionClick,
wrapperClassName,
fieldWrapperClassName,
toolbarClassName,
labelClassName,
descriptionClassName,
errorClassName,
textareaClassName,
titleInputClassName,
className,
value,
defaultValue,
onChange,
disabled,
'aria-describedby': ariaDescribedBy,
'aria-invalid': ariaInvalid,
...textareaProps
},
ref,
) {
const generatedId = useId()
const textareaId = id ?? `textarea-${generatedId}`
const descriptionId = description ? `${textareaId}-description` : undefined
const errorId = error ? `${textareaId}-error` : undefined
const titleInputId = titleInputProps?.id ?? `${textareaId}-title`
const effectiveResize =
resize ??
(
variant === 'simple' ||
variant === 'preview-button'
? 'vertical'
: 'none'
)
const [internalValue, setInternalValue] = useState(() =>
normalizeTextareaValue(defaultValue),
)
const textareaValue =
value !== undefined ? normalizeTextareaValue(value) : internalValue
const describedBy = [ariaDescribedBy, descriptionId, errorId]
.filter(Boolean)
.join(' ')
const hasAvatar = variant === 'avatar-actions' || variant === 'underline-actions'
function handleChange(event: ChangeEvent<HTMLTextAreaElement>) {
if (value === undefined) {
setInternalValue(event.target.value)
}
onChange?.(event)
}
function renderFeedback() {
return (
<>
{description && !error && (
<p
id={descriptionId}
className={classNames(
'mt-1 text-sm/6 text-gray-500 dark:text-gray-400',
descriptionClassName,
)}
>
{description}
</p>
)}
{error && (
<p
id={errorId}
className={classNames(
'mt-1 text-sm/6 text-red-600 dark:text-red-400',
errorClassName,
)}
>
{error}
</p>
)}
</>
)
}
function renderLabel(defaultSrOnly = false) {
if (!label) {
return null
}
return (
<label
htmlFor={textareaId}
className={classNames(
srOnlyLabel || defaultSrOnly
? 'sr-only'
: 'block text-sm/6 font-medium text-gray-900 dark:text-white',
labelClassName,
)}
>
{label}
</label>
)
}
function renderTextarea(baseClassName: string) {
return (
<textarea
{...textareaProps}
ref={ref}
id={textareaId}
rows={rows}
value={textareaValue}
disabled={disabled}
onChange={handleChange}
aria-invalid={Boolean(error) ? true : ariaInvalid}
aria-describedby={describedBy || undefined}
className={classNames(
baseClassName,
getResizeClassName(effectiveResize),
Boolean(error) && errorTextareaClassName,
textareaClassName,
className,
)}
/>
)
}
function renderAvatar() {
if (avatar) {
return avatar
}
return (
<Avatar
size={10}
shape="circle"
{...avatarProps}
/>
)
}
function renderAttachButton(className = iconButtonClassName) {
if (!showAttachButton) {
return null
}
return (
<button
type="button"
disabled={disabled}
onClick={onAttachClick}
className={className}
>
<PaperClipIcon aria-hidden="true" className="size-5" />
<span className="sr-only">{attachLabel}</span>
</button>
)
}
function renderSubmitButton() {
if (!showSubmitButton) {
return null
}
return (
<button
type={submitButtonType}
disabled={disabled}
onClick={onSubmitButtonClick}
className={submitButtonClassName}
>
{submitLabel}
</button>
)
}
function renderPreviewToolbar() {
if (!showPreviewToolbar) {
return null
}
return (
<div className="ml-auto hidden items-center space-x-5 group-has-[*:first-child[aria-selected='true']]:flex">
<button
type="button"
disabled={disabled}
onClick={onInsertLinkClick}
className={iconButtonClassName}
>
<span className="sr-only">Insert link</span>
<LinkIcon aria-hidden="true" className="size-5" />
</button>
<button
type="button"
disabled={disabled}
onClick={onInsertCodeClick}
className={iconButtonClassName}
>
<span className="sr-only">Insert code</span>
<CodeBracketIcon aria-hidden="true" className="size-5" />
</button>
<button
type="button"
disabled={disabled}
onClick={onMentionClick}
className={iconButtonClassName}
>
<span className="sr-only">Mention someone</span>
<AtSymbolIcon aria-hidden="true" className="size-5" />
</button>
</div>
)
}
if (variant === 'avatar-actions') {
return (
<div
className={classNames(
hasAvatar && 'flex items-start space-x-4',
wrapperClassName,
)}
>
<div className="shrink-0">
{renderAvatar()}
</div>
<div className="min-w-0 flex-1">
<div className="relative">
<div
className={classNames(
boxedWrapperClassName,
fieldWrapperClassName,
)}
>
{renderLabel(true)}
{renderTextarea(boxedTextareaClassName)}
<div aria-hidden="true" className="py-2">
<div className="py-px">
<div className="h-9" />
</div>
</div>
</div>
<div
className={classNames(
'absolute inset-x-0 bottom-0 flex justify-between py-2 pr-2 pl-3',
toolbarClassName,
)}
>
<div className="flex items-center space-x-5">
<div className="flex items-center">
{renderAttachButton()}
</div>
</div>
<div className="shrink-0">
{renderSubmitButton()}
</div>
</div>
</div>
{renderFeedback()}
</div>
</div>
)
}
if (variant === 'underline-actions') {
return (
<div
className={classNames(
hasAvatar && 'flex items-start space-x-4',
wrapperClassName,
)}
>
<div className="shrink-0">
{renderAvatar()}
</div>
<div className="min-w-0 flex-1">
<div
className={classNames(
underlineWrapperClassName,
fieldWrapperClassName,
)}
>
{renderLabel(true)}
{renderTextarea(underlineTextareaClassName)}
</div>
<div
className={classNames(
'flex justify-between pt-2',
toolbarClassName,
)}
>
<div className="flex items-center space-x-5">
<div className="flow-root">
{renderAttachButton('-m-2 inline-flex size-10 items-center justify-center rounded-full text-gray-400 hover:text-gray-500 disabled:cursor-not-allowed disabled:opacity-50 dark:text-gray-500 dark:hover:text-white')}
</div>
</div>
<div className="shrink-0">
{renderSubmitButton()}
</div>
</div>
{renderFeedback()}
</div>
</div>
)
}
if (variant === 'title-actions') {
const {
className: titleInputCustomClassName,
id: _providedTitleInputId,
name: providedTitleInputName,
...restTitleInputProps
} = titleInputProps ?? {}
return (
<div className={wrapperClassName}>
<div className="relative">
<div
className={classNames(
titleWrapperClassName,
fieldWrapperClassName,
)}
>
<label htmlFor={titleInputId} className="sr-only">
{titleLabel}
</label>
<input
{...restTitleInputProps}
id={titleInputId}
name={providedTitleInputName ?? 'title'}
type={titleInputProps?.type ?? 'text'}
disabled={disabled || titleInputProps?.disabled}
className={classNames(
titleInputBaseClassName,
titleInputClassName,
titleInputCustomClassName,
)}
/>
{renderLabel(true)}
{renderTextarea(titleTextareaClassName)}
<div aria-hidden="true" className="py-2">
<div className="py-px">
<div className="h-9" />
</div>
</div>
</div>
<div className="absolute inset-x-px bottom-0">
<div className="flex items-center justify-between space-x-3 border-t border-gray-200 px-2 py-2 sm:px-3 dark:border-white/10">
<div className="flex">
{showAttachButton && (
<button
type="button"
disabled={disabled}
onClick={onAttachClick}
className="group -my-2 -ml-2 inline-flex items-center rounded-full px-3 py-2 text-left text-gray-400 disabled:cursor-not-allowed disabled:opacity-50 dark:text-gray-500"
>
<PaperClipIcon
aria-hidden="true"
className="mr-2 -ml-1 size-5 group-hover:text-gray-500 dark:group-hover:text-gray-400"
/>
<span className="text-sm text-gray-500 italic group-hover:text-gray-600 dark:text-gray-400 dark:group-hover:text-gray-300">
{footerAttachLabel}
</span>
</button>
)}
</div>
<div className="shrink-0">
{renderSubmitButton()}
</div>
</div>
</div>
</div>
{renderFeedback()}
</div>
)
}
if (variant === 'preview-button') {
const resolvedPreviewContent =
typeof previewContent === 'function'
? previewContent(textareaValue)
: previewContent
return (
<div className={wrapperClassName}>
<TabGroup>
<div className="group flex items-center">
<TabList className="flex gap-2">
<Tab className={previewTabClassName}>
{writeTabLabel}
</Tab>
<Tab className={previewTabClassName}>
{previewTabLabel}
</Tab>
</TabList>
{renderPreviewToolbar()}
</div>
<TabPanels className="mt-2">
<TabPanel className="-m-0.5 rounded-lg p-0.5">
{renderLabel(true)}
<div>
{renderTextarea(simpleTextareaClassName)}
</div>
</TabPanel>
<TabPanel className="-m-0.5 rounded-lg p-0.5">
<div className="border-b border-gray-200 dark:border-white/10">
<div className="mx-px mt-px px-3 pt-2 pb-12 text-sm text-gray-800 whitespace-pre-wrap dark:text-gray-300">
{resolvedPreviewContent || previewPlaceholder}
</div>
</div>
</TabPanel>
</TabPanels>
</TabGroup>
<div className="mt-2 flex justify-end">
{renderSubmitButton()}
</div>
{renderFeedback()}
</div>
)
}
return (
<div className={wrapperClassName}>
{renderLabel()}
<div className={label && !srOnlyLabel ? 'mt-2' : undefined}>
{renderTextarea(simpleTextareaClassName)}
</div>
{renderFeedback()}
</div>
)
},
)
export default Textarea

View File

@ -24,10 +24,11 @@ type TopbarProps = {
notificationCenter?: ReactNode
searchQuery: string
onSearchQueryChange: (query: string) => void
onNavigate?: () => void
}
const userNavigation = [
{ name: 'Mein Account', href: '/einstellungen' },
{ name: 'Mein Account', href: '/einstellungen/konto' },
]
export default function Topbar({
@ -37,6 +38,7 @@ export default function Topbar({
notificationCenter,
searchQuery,
onSearchQueryChange,
onNavigate,
}: TopbarProps) {
function handleQrScan(value: string) {
console.log('QR-Code erkannt:', value)
@ -123,6 +125,7 @@ export default function Topbar({
<MenuItem key={item.name}>
<Link
to={item.href}
onClick={onNavigate}
className="block px-3 py-1 text-sm/6 text-gray-900 data-focus:bg-gray-50 data-focus:outline-hidden dark:text-white dark:data-focus:bg-white/5"
>
{item.name}
@ -133,7 +136,10 @@ export default function Topbar({
<MenuItem>
<button
type="button"
onClick={onLogout}
onClick={() => {
onNavigate?.()
void onLogout()
}}
className="block w-full px-3 py-1 text-left text-sm/6 text-gray-900 data-focus:bg-gray-50 data-focus:outline-hidden dark:text-white dark:data-focus:bg-white/5"
>
Abmelden

View File

@ -64,6 +64,8 @@ export default function WidgetSettingsModal({
isSaving,
onSave,
}: WidgetSettingsModalProps) {
const [renderedWidget, setRenderedWidget] = useState<DashboardWidget | null>(null)
const [title, setTitle] = useState('')
const [limit, setLimit] = useState(8)
@ -74,13 +76,16 @@ export default function WidgetSettingsModal({
const [initialAddress, setInitialAddress] = useState('')
const [initialZoom, setInitialZoom] = useState(14)
const activeWidget = widget ?? renderedWidget
useEffect(() => {
if (!open || !widget) {
if (!widget) {
return
}
setTitle(widget.title)
setRenderedWidget(widget)
setTitle(widget.title)
setLimit(getConfigNumber(widget.config, 'limit', 8))
setDefaultLayer(getConfigString(widget.config, 'defaultLayer', 'nrw-dop'))
@ -95,23 +100,31 @@ export default function WidgetSettingsModal({
)
setInitialAddress(getConfigString(widget.config, 'initialAddress', ''))
setInitialZoom(getConfigNumber(widget.config, 'initialZoom', 14))
}, [open, widget])
}, [widget])
function handleClose() {
if (isSaving) {
return
}
setOpen(false)
}
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!widget) {
if (!activeWidget) {
return
}
const nextConfig =
widget.type === 'operationChanges'
activeWidget.type === 'operationChanges'
? {
...widget.config,
...activeWidget.config,
limit: Math.min(Math.max(limit, 1), 50),
}
: {
...widget.config,
...activeWidget.config,
defaultLayer,
showLabelsOverlay,
showLocateControl,
@ -120,8 +133,8 @@ export default function WidgetSettingsModal({
initialZoom: Math.min(Math.max(initialZoom, 4), 20),
}
void onSave(widget, {
title: title.trim() || widget.title,
void onSave(activeWidget, {
title: title.trim() || activeWidget.title,
config: nextConfig,
})
}
@ -129,7 +142,14 @@ export default function WidgetSettingsModal({
return (
<Modal
open={open}
setOpen={setOpen}
setOpen={(nextOpen) => {
if (!nextOpen) {
handleClose()
return
}
setOpen(nextOpen)
}}
zIndexClassName="z-[9999]"
panelClassName="max-w-xl bg-white dark:bg-gray-900"
>
@ -151,7 +171,7 @@ export default function WidgetSettingsModal({
color="gray"
size="sm"
disabled={isSaving}
onClick={() => setOpen(false)}
onClick={handleClose}
>
<span className="sr-only">Schließen</span>
<XMarkIcon aria-hidden="true" className="size-5" />
@ -173,12 +193,13 @@ export default function WidgetSettingsModal({
type="text"
value={title}
onChange={(event) => setTitle(event.target.value)}
disabled={isSaving}
className={inputClassName}
/>
</div>
</div>
{widget?.type === 'operationChanges' && (
{activeWidget?.type === 'operationChanges' && (
<div>
<label
htmlFor="operationChangesLimit"
@ -195,6 +216,7 @@ export default function WidgetSettingsModal({
max={50}
value={limit}
onChange={(event) => setLimit(Number(event.target.value))}
disabled={isSaving}
className={inputClassName}
/>
</div>
@ -205,7 +227,7 @@ export default function WidgetSettingsModal({
</div>
)}
{widget?.type === 'operationMap' && (
{activeWidget?.type === 'operationMap' && (
<div className="space-y-5">
<div>
<label
@ -220,6 +242,7 @@ export default function WidgetSettingsModal({
id="mapDefaultLayer"
value={defaultLayer}
onChange={(event) => setDefaultLayer(event.target.value)}
disabled={isSaving}
className={inputClassName}
>
<option value="osm">OpenStreetMap</option>
@ -267,6 +290,7 @@ export default function WidgetSettingsModal({
value={initialAddress}
onChange={(event) => setInitialAddress(event.target.value)}
placeholder="z. B. Düsseldorf, Marktplatz"
disabled={isSaving}
className={inputClassName}
/>
</div>
@ -292,6 +316,7 @@ export default function WidgetSettingsModal({
max={20}
value={initialZoom}
onChange={(event) => setInitialZoom(Number(event.target.value))}
disabled={isSaving}
className={inputClassName}
/>
</div>
@ -306,7 +331,7 @@ export default function WidgetSettingsModal({
variant="secondary"
color="gray"
disabled={isSaving}
onClick={() => setOpen(false)}
onClick={handleClose}
>
Abbrechen
</Button>
@ -315,6 +340,7 @@ export default function WidgetSettingsModal({
type="submit"
color="indigo"
isLoading={isSaving}
disabled={!activeWidget}
>
Einstellungen speichern
</Button>

View File

@ -362,14 +362,6 @@ export default function OperationMapWidget({
)
}
if (operations.length === 0 || markers.length === 0) {
return (
<div className="flex h-full items-center justify-center text-sm text-gray-500 dark:text-gray-400">
Keine Einsatzpositionen vorhanden.
</div>
)
}
return (
<AppMap
markers={markers}

View File

@ -54,13 +54,39 @@ export type Device = {
model: string
serialNumber: string
macAddress: string
ipAddress: string
location: string
loanStatus: string
comment: string
firmwareVersion: string
firmwareUpdate?: DeviceFirmwareUpdate | null
supportBadges?: DeviceSupportBadge[]
milestoneRecordingServerId?: string
milestoneHardwareId?: string
milestoneDisplayName: string
milestoneEnabled?: boolean
milestoneSyncedAt?: string
relatedDevices: RelatedDevice[]
isBaoDevice: boolean
}
export type DeviceFirmwareUpdate = {
available: boolean
currentVersion: string
latestVersion: string
supportUrl: string
source: string
checkedAt?: string
}
export type DeviceSupportBadge = {
type: string
label: string
message: string
severity: 'info' | 'warning' | 'danger' | string
supportUrl: string
}
export type Operation = {
id: string
operationNumber: string

View File

@ -5,9 +5,9 @@ import {
CheckIcon,
Cog6ToothIcon,
PencilSquareIcon,
PlusIcon,
TrashIcon,
} from '@heroicons/react/20/solid'
import { Squares2X2Icon } from '@heroicons/react/24/outline'
import WidgetSettingsModal from '../components/dashboard/WidgetSettingsModal'
import Button from '../components/Button'
import LoadingSpinner from '../components/LoadingSpinner'
@ -18,12 +18,14 @@ import type {
DashboardWidgetDefinition,
DashboardWidgetType,
} from '../components/dashboard/types'
import EmptyState from '../components/EmptyState'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
const GRID_COLUMNS = 12
const GRID_ROW_HEIGHT = 88
const GRID_GAP = 16
const GRID_PADDING = 16
type ResizeDirection = 'nw' | 'ne' | 'sw' | 'se'
@ -88,6 +90,10 @@ type DashboardPageProps = {
username: string
}
type DashboardSettings = {
isEditingDashboard?: boolean
}
const widgetTypes: DashboardWidgetDefinition[] = [
{
id: 'operationChanges',
@ -176,31 +182,6 @@ function getWidgetTypeDefinition(type: DashboardWidgetType) {
return widgetTypes.find((widgetType) => widgetType.id === type) ?? widgetTypes[0]
}
function getGridOverlayStyle() {
const columnWidth = `calc((100% - ${(GRID_COLUMNS - 1) * GRID_GAP}px) / ${GRID_COLUMNS})`
const lineColor = 'rgb(99 102 241 / 0.18)'
return {
backgroundImage: `
linear-gradient(
to right,
transparent calc(${columnWidth} - 1px),
${lineColor} calc(${columnWidth} - 1px),
${lineColor} ${columnWidth},
transparent ${columnWidth}
),
linear-gradient(
to bottom,
transparent ${GRID_ROW_HEIGHT - 1}px,
${lineColor} ${GRID_ROW_HEIGHT - 1}px,
${lineColor} ${GRID_ROW_HEIGHT}px,
transparent ${GRID_ROW_HEIGHT}px
)
`,
backgroundSize: `calc(${columnWidth} + ${GRID_GAP}px) ${GRID_ROW_HEIGHT + GRID_GAP}px`,
}
}
export default function DashboardPage({ username }: DashboardPageProps) {
const [widgets, setWidgets] = useState<DashboardWidget[]>([])
const [isEditingDashboard, setIsEditingDashboard] = useState(false)
@ -214,6 +195,8 @@ export default function DashboardPage({ username }: DashboardPageProps) {
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [dashboardSettings, setDashboardSettings] = useState<DashboardSettings>({})
const gridRef = useRef<HTMLDivElement | null>(null)
const interactionRef = useRef<DashboardInteraction | null>(null)
@ -229,23 +212,24 @@ export default function DashboardPage({ username }: DashboardPageProps) {
previewWidgetRef.current = previewWidget
}, [previewWidget])
const dashboardHeight = useMemo(() => {
const maxY = Math.max(
const dashboardRows = useMemo(() => {
return Math.max(
4,
...widgets.map((widget) => widget.y + widget.h),
drawPlacement ? drawPlacement.y + drawPlacement.h : 0,
previewWidget ? previewWidget.y + previewWidget.h : 0,
)
return maxY * GRID_ROW_HEIGHT + Math.max(0, maxY - 1) * GRID_GAP
}, [drawPlacement, previewWidget, widgets])
const dashboardHeight = useMemo(() => {
return dashboardRows * GRID_ROW_HEIGHT + Math.max(0, dashboardRows - 1) * GRID_GAP
}, [dashboardRows])
useEffect(() => {
void loadWidgets()
void loadDashboard()
}, [])
async function loadWidgets() {
setIsLoading(true)
setError(null)
try {
@ -266,11 +250,78 @@ export default function DashboardPage({ username }: DashboardPageProps) {
? error.message
: 'Dashboard konnte nicht geladen werden',
)
}
}
async function loadDashboard() {
setIsLoading(true)
setError(null)
try {
await Promise.all([
loadWidgets(),
loadDashboardSettings(),
])
} finally {
setIsLoading(false)
}
}
async function loadDashboardSettings() {
try {
const response = await fetch(`${API_URL}/dashboard/settings`, {
credentials: 'include',
})
if (!response.ok) {
const errorData = await response.json().catch(() => null)
throw new Error(
errorData?.error ?? 'Dashboard-Einstellungen konnten nicht geladen werden',
)
}
const data = await response.json()
const settings = data.dashboardSettings?.settings ?? {}
setDashboardSettings(settings)
if (typeof settings.isEditingDashboard === 'boolean') {
setIsEditingDashboard(settings.isEditingDashboard)
}
} catch (error) {
setError(
error instanceof Error
? error.message
: 'Dashboard-Einstellungen konnten nicht geladen werden',
)
}
}
async function saveDashboardSettings(nextSettings: DashboardSettings) {
setDashboardSettings(nextSettings)
const response = await fetch(`${API_URL}/dashboard/settings`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
settings: nextSettings,
}),
})
if (!response.ok) {
const errorData = await response.json().catch(() => null)
throw new Error(
errorData?.error ?? 'Dashboard-Einstellungen konnten nicht gespeichert werden',
)
}
const data = await response.json()
setDashboardSettings(data.dashboardSettings?.settings ?? nextSettings)
}
async function saveWidget(widget: DashboardWidget) {
const response = await fetch(`${API_URL}/dashboard/widgets/${widget.id}`, {
method: 'PUT',
@ -795,6 +846,22 @@ export default function DashboardPage({ username }: DashboardPageProps) {
void updateWidget(nextWidget)
}
function openEmptyDashboardWidgetPicker() {
setIsEditingDashboard(true)
openWidgetPickerForPlacement(null)
void saveDashboardSettings({
...dashboardSettings,
isEditingDashboard: true,
}).catch((error) => {
setError(
error instanceof Error
? error.message
: 'Dashboard-Einstellungen konnten nicht gespeichert werden',
)
})
}
if (isLoading) {
return (
<div className="px-4 py-10 sm:px-6 lg:px-8">
@ -821,28 +888,43 @@ export default function DashboardPage({ username }: DashboardPageProps) {
</p>
</div>
<div className="mt-4 flex items-center gap-x-2 sm:mt-0">
<Button
type="button"
variant={isEditingDashboard ? 'primary' : 'secondary'}
color={isEditingDashboard ? 'emerald' : 'gray'}
leadingIcon={
isEditingDashboard ? (
<CheckIcon aria-hidden="true" className="size-4" />
) : (
<PencilSquareIcon aria-hidden="true" className="size-4" />
)
}
onClick={() => {
setIsEditingDashboard((currentValue) => !currentValue)
setPreviewWidget(null)
setDrawPlacement(null)
setPendingWidgetPlacement(null)
}}
>
{isEditingDashboard ? 'Fertig' : 'Dashboard bearbeiten'}
</Button>
</div>
{widgets.length > 0 && (
<div className="mt-4 flex items-center gap-x-2 sm:mt-0">
<Button
type="button"
variant={isEditingDashboard ? 'primary' : 'secondary'}
color={isEditingDashboard ? 'emerald' : 'gray'}
leadingIcon={
isEditingDashboard ? (
<CheckIcon aria-hidden="true" className="size-4" />
) : (
<PencilSquareIcon aria-hidden="true" className="size-4" />
)
}
onClick={() => {
const nextValue = !isEditingDashboard
setIsEditingDashboard(nextValue)
setPreviewWidget(null)
setDrawPlacement(null)
setPendingWidgetPlacement(null)
void saveDashboardSettings({
...dashboardSettings,
isEditingDashboard: nextValue,
}).catch((error) => {
setError(
error instanceof Error
? error.message
: 'Dashboard-Einstellungen konnten nicht gespeichert werden',
)
})
}}
>
{isEditingDashboard ? 'Fertig' : 'Dashboard bearbeiten'}
</Button>
</div>
)}
</div>
{error && (
@ -852,28 +934,15 @@ export default function DashboardPage({ username }: DashboardPageProps) {
)}
{widgets.length === 0 ? (
<div className="rounded-lg border border-dashed border-gray-300 bg-white px-6 py-16 text-center dark:border-white/10 dark:bg-gray-900">
<h2 className="text-base font-semibold text-gray-900 dark:text-white">
Noch keine Widgets
</h2>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
Füge dein erstes Widget hinzu und baue dir dein persönliches Dashboard.
</p>
<div className="mt-6">
<Button
type="button"
color="indigo"
leadingIcon={<PlusIcon aria-hidden="true" className="size-4" />}
onClick={() => {
setIsEditingDashboard(true)
openWidgetPickerForPlacement(null)
}}
>
Widget hinzufügen
</Button>
</div>
<div className="rounded-lg border border-dashed border-gray-300 bg-white px-6 py-16 dark:border-white/10 dark:bg-gray-900">
<EmptyState
icon={Squares2X2Icon}
iconClassName="text-indigo-400 dark:text-indigo-500"
title="Noch keine Widgets"
description="Füge dein erstes Widget hinzu und baue dir dein persönliches Dashboard."
actionLabel="Widget hinzufügen"
onAction={openEmptyDashboardWidgetPicker}
/>
</div>
) : (
<div
@ -892,15 +961,26 @@ export default function DashboardPage({ username }: DashboardPageProps) {
gridTemplateColumns: `repeat(${GRID_COLUMNS}, minmax(0, 1fr))`,
gridAutoRows: `${GRID_ROW_HEIGHT}px`,
gap: `${GRID_GAP}px`,
minHeight: dashboardHeight,
minHeight: dashboardHeight + GRID_PADDING * 2,
}}
>
{isEditingDashboard && (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-4 z-0 rounded-lg border border-indigo-500/25 opacity-0 transition group-hover/dashboard:opacity-100"
style={getGridOverlayStyle()}
/>
className="pointer-events-none absolute inset-4 z-0 grid overflow-hidden rounded-lg"
style={{
gridTemplateColumns: `repeat(${GRID_COLUMNS}, minmax(0, 1fr))`,
gridAutoRows: `${GRID_ROW_HEIGHT}px`,
gap: `${GRID_GAP}px`,
}}
>
{Array.from({ length: dashboardRows * GRID_COLUMNS }).map((_, index) => (
<div
key={index}
className="rounded-md border border-indigo-500/20 bg-indigo-500/[0.015]"
/>
))}
</div>
)}
{drawPlacement && (
@ -953,46 +1033,46 @@ export default function DashboardPage({ username }: DashboardPageProps) {
</div>
<div className="flex shrink-0 items-center gap-x-1">
<button
type="button"
title="Widget konfigurieren"
aria-label="Widget konfigurieren"
onPointerDown={(event) => {
event.stopPropagation()
}}
onPointerUp={(event) => {
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
setSettingsWidget(widget)
}}
className="inline-flex size-8 items-center justify-center rounded-md text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/10 dark:hover:text-gray-200"
>
<Cog6ToothIcon aria-hidden="true" className="size-4" />
</button>
{isEditingDashboard && (
<>
<button
type="button"
title="Widget konfigurieren"
aria-label="Widget konfigurieren"
onPointerDown={(event) => {
event.stopPropagation()
}}
onPointerUp={(event) => {
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
setSettingsWidget(widget)
}}
className="inline-flex size-8 items-center justify-center rounded-md text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/10 dark:hover:text-gray-200"
>
<Cog6ToothIcon aria-hidden="true" className="size-4" />
</button>
<button
type="button"
title="Widget löschen"
aria-label="Widget löschen"
onPointerDown={(event) => {
event.stopPropagation()
}}
onPointerUp={(event) => {
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
void deleteWidget(widget.id)
}}
className="inline-flex size-8 items-center justify-center rounded-md text-red-600 hover:bg-red-50 hover:text-red-700 dark:text-red-400 dark:hover:bg-red-500/10 dark:hover:text-red-300"
>
<TrashIcon aria-hidden="true" className="size-4" />
</button>
<button
type="button"
title="Widget löschen"
aria-label="Widget löschen"
onPointerDown={(event) => {
event.stopPropagation()
}}
onPointerUp={(event) => {
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
void deleteWidget(widget.id)
}}
className="inline-flex size-8 items-center justify-center rounded-md text-red-600 hover:bg-red-50 hover:text-red-700 dark:text-red-400 dark:hover:bg-red-500/10 dark:hover:text-red-300"
>
<TrashIcon aria-hidden="true" className="size-4" />
</button>
</>
)}
</div>
</div>

View File

@ -1,928 +1,86 @@
// frontend/src/pages/settings/AdministrationPage.tsx
// frontend/src/pages/administration/AdministrationPage.tsx
import { useEffect, useMemo, useState, type FormEvent } from 'react'
import { useLocation } from 'react-router'
import Button from '../../components/Button'
import LoadingSpinner from '../../components/LoadingSpinner'
import Tabs from '../../components/Tabs'
import { useLocation, Navigate } from 'react-router'
import {
MagnifyingGlassIcon,
ChatBubbleLeftRightIcon,
UserGroupIcon,
UserPlusIcon,
UsersIcon,
VideoCameraIcon,
} from '@heroicons/react/20/solid'
import Checkbox from '../../components/Checkbox'
import Milestone from './Milestone'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
type AdminTeam = {
id: string
name: string
initial: string
href: string
}
type AdminUser = {
id: string
username: string
displayName: string
email: string
avatar: string
unit: string
group: string
rights: string[]
teams: AdminTeam[]
}
const adminRightId = 'admin'
const rightGroups = [
{
id: 'devices',
label: 'Geräte',
rights: [
{ id: 'devices:read', label: 'anzeigen' },
{ id: 'devices:write', label: 'bearbeiten' },
],
},
{
id: 'operations',
label: 'Einsätze',
rights: [
{ id: 'operations:read', label: 'anzeigen' },
{ id: 'operations:write', label: 'bearbeiten' },
],
},
{
id: 'calendar',
label: 'Kalender',
rights: [
{ id: 'calendar:read', label: 'anzeigen' },
],
},
{
id: 'documents',
label: 'Dokumente',
rights: [
{ id: 'documents:read', label: 'anzeigen' },
],
},
{
id: 'reports',
label: 'Berichte',
rights: [
{ id: 'reports:read', label: 'anzeigen' },
],
},
{
id: 'settings',
label: 'Einstellungen',
rights: [
{ id: 'settings:read', label: 'anzeigen' },
],
},
{
id: 'administration',
label: 'Administration',
rights: [
{ id: 'administration:read', label: 'anzeigen' },
{ id: 'administration:write', label: 'bearbeiten' },
],
},
{
id: 'users',
label: 'Benutzer',
rights: [
{ id: 'users:read', label: 'anzeigen' },
{ id: 'users:write', label: 'bearbeiten' },
],
},
{
id: 'teams',
label: 'Teams',
rights: [
{ id: 'teams:read', label: 'anzeigen' },
{ id: 'teams:write', label: 'bearbeiten' },
],
},
]
const availableRights = [
{ id: adminRightId, label: 'Administrator' },
...rightGroups.flatMap((group) => group.rights),
]
const adminRight = availableRights.find((right) => right.id === adminRightId)
function getAllRightIds() {
return availableRights.map((right) => right.id)
}
const inputClassName =
'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(' ')
}
function getSelectedValues(formData: FormData, name: string) {
return formData
.getAll(name)
.map((value) => String(value))
.filter(Boolean)
}
function getUserDisplayName(user: AdminUser) {
return user.displayName || user.username || user.email
}
function getUserInitials(user: AdminUser) {
const displayName = getUserDisplayName(user).trim()
if (!displayName) {
return '?'
}
const parts = displayName
.split(/\s+/)
.map((part) => part.trim())
.filter(Boolean)
if (parts.length >= 2) {
return `${parts[0][0]}${parts[1][0]}`.toUpperCase()
}
return displayName.slice(0, 2).toUpperCase()
}
function getUserSearchText(user: AdminUser) {
return [
user.username,
user.displayName,
user.email,
user.unit,
user.group,
...user.teams.map((team) => team.name),
]
.join(' ')
.toLowerCase()
}
import Tabs from '../../components/Tabs'
import UsersAdministration from './users/UsersAdministration'
import TeamsAdministration from './teams/TeamsAdministration'
import FeedbackAdministration from './feedback/FeedbackAdministration'
import Milestone from './milestone/Milestone'
export default function AdministrationPage() {
const location = useLocation()
const pathname = location.pathname.replace(/\/$/, '')
const pathParts = pathname.split('/').filter(Boolean)
const lastPathPart = pathParts.at(-1)
const activeTab =
lastPathPart === 'teams'
? 'teams'
: lastPathPart === 'milestone'
? 'milestone'
: 'benutzer'
const section = pathParts[1] ?? 'benutzer'
const administrationTabs = [
{
name: 'Benutzer',
href: '/administration',
href: '/administration/benutzer',
icon: UsersIcon,
current: activeTab === 'benutzer',
current: section === 'benutzer',
},
{
name: 'Teams',
href: '/administration/teams',
icon: UserGroupIcon,
current: activeTab === 'teams',
current: section === 'teams',
},
{
name: 'Milestone',
href: '/administration/milestone',
icon: VideoCameraIcon,
current: activeTab === 'milestone',
current: section === 'milestone',
},
{
name: 'Feedback',
href: '/administration/feedback',
icon: ChatBubbleLeftRightIcon,
current: section === 'feedback',
},
]
const [users, setUsers] = useState<AdminUser[]>([])
const [teams, setTeams] = useState<AdminTeam[]>([])
const [selectedUserId, setSelectedUserId] = useState<string>('new')
const [userSearch, setUserSearch] = useState('')
const [selectedRights, setSelectedRights] = useState<string[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const selectedUser = useMemo(
() => users.find((user) => user.id === selectedUserId) ?? null,
[selectedUserId, users],
)
const filteredUsers = useMemo(() => {
const search = userSearch.trim().toLowerCase()
if (!search) {
return users
}
return users.filter((user) => getUserSearchText(user).includes(search))
}, [userSearch, users])
const isAdministratorSelected = selectedRights.includes(adminRightId)
useEffect(() => {
setSelectedRights(selectedUser?.rights ?? [])
}, [selectedUser])
useEffect(() => {
void loadAdministration()
}, [])
async function loadAdministration() {
setIsLoading(true)
setError(null)
try {
const [usersResponse, teamsResponse] = await Promise.all([
fetch(`${API_URL}/admin/users`, { credentials: 'include' }),
fetch(`${API_URL}/admin/teams`, { credentials: 'include' }),
])
if (!usersResponse.ok || !teamsResponse.ok) {
throw new Error('Administration konnte nicht geladen werden')
}
const usersData = await usersResponse.json()
const teamsData = await teamsResponse.json()
setUsers(usersData.users ?? [])
setTeams(teamsData.teams ?? [])
} catch (error) {
setError(
error instanceof Error
? error.message
: 'Administration konnte nicht geladen werden',
)
} finally {
setIsLoading(false)
}
}
function handleRightChange(rightId: string, checked: boolean) {
if (rightId === adminRightId) {
setSelectedRights(checked ? getAllRightIds() : [])
return
}
setSelectedRights((currentRights) => {
if (checked) {
return [...new Set([...currentRights, rightId])]
}
return currentRights.filter((currentRight) => currentRight !== rightId)
})
}
function handleSelectNewUser() {
setSelectedUserId('new')
setSelectedRights([])
}
async function handleSaveUser(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const form = event.currentTarget
const formData = new FormData(form)
const isCreate = selectedUserId === 'new'
const payload = {
username: String(formData.get('username') ?? ''),
displayName: String(formData.get('displayName') ?? ''),
email: String(formData.get('email') ?? ''),
password: String(formData.get('password') ?? ''),
avatar: String(formData.get('avatar') ?? ''),
unit: String(formData.get('unit') ?? ''),
group: String(formData.get('group') ?? ''),
rights: isAdministratorSelected ? getAllRightIds() : selectedRights,
teamIds: getSelectedValues(formData, 'teamIds'),
}
setIsSaving(true)
setError(null)
try {
const response = await fetch(
isCreate
? `${API_URL}/admin/users`
: `${API_URL}/admin/users/${selectedUserId}`,
{
method: isCreate ? 'POST' : 'PUT',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(payload),
},
)
if (!response.ok) {
const errorData = await response.json().catch(() => null)
throw new Error(errorData?.error ?? 'Benutzer konnte nicht gespeichert werden')
}
await loadAdministration()
form.reset()
setSelectedUserId('new')
setSelectedRights([])
} catch (error) {
setError(
error instanceof Error
? error.message
: 'Benutzer konnte nicht gespeichert werden',
)
} finally {
setIsSaving(false)
}
}
async function handleSaveTeam(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const form = event.currentTarget
const formData = new FormData(form)
const payload = {
name: String(formData.get('teamName') ?? ''),
initial: String(formData.get('teamInitial') ?? ''),
href: String(formData.get('teamHref') ?? '#'),
}
setIsSaving(true)
setError(null)
try {
const response = await fetch(`${API_URL}/admin/teams`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(payload),
})
if (!response.ok) {
const errorData = await response.json().catch(() => null)
throw new Error(errorData?.error ?? 'Team konnte nicht erstellt werden')
}
await loadAdministration()
form.reset()
} catch (error) {
setError(
error instanceof Error
? error.message
: 'Team konnte nicht erstellt werden',
)
} finally {
setIsSaving(false)
}
}
if (isLoading) {
return (
<div className="px-4 py-10 sm:px-6 lg:px-8">
<LoadingSpinner
size="lg"
label="Administration wird geladen..."
className="text-indigo-600 dark:text-indigo-400"
center
/>
</div>
)
if (!['benutzer', 'teams', 'milestone', 'feedback'].includes(section)) {
return <Navigate to="/administration/benutzer" replace />
}
return (
<main>
<main className="flex h-full min-h-0 flex-col overflow-hidden">
<h1 className="sr-only">Administration</h1>
<Tabs
tabs={administrationTabs}
variant="underline-icons"
align="center"
sticky
stickyTopClassName="top-0"
ariaLabel="Administration"
/>
<div className="shrink-0 border-b border-gray-200 bg-white dark:border-white/10 dark:bg-gray-900">
<Tabs
tabs={administrationTabs}
variant="underline-icons"
align="center"
ariaLabel="Administration"
/>
</div>
<div className="px-4 py-10 sm:px-6 lg:px-8">
<div className="mb-8">
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">
Administration
</h1>
<div className="min-h-0 flex-1 overflow-y-auto [scrollbar-gutter:stable]">
<div className="px-4 py-10 sm:px-6 lg:px-8">
<div className="mb-8">
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">
Administration
</h1>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
Benutzer verwalten, Teams zuweisen und Seitenrechte vergeben.
</p>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
Benutzer verwalten, Teams zuweisen und Integrationen konfigurieren.
</p>
</div>
{section === 'benutzer' && <UsersAdministration />}
{section === 'teams' && <TeamsAdministration />}
{section === 'milestone' && <Milestone />}
{section === 'feedback' && <FeedbackAdministration />}
</div>
{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>
)}
{activeTab === 'benutzer' && (
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[24rem_minmax(0,1fr)]">
<div className="overflow-hidden rounded-lg bg-white shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
<div className="border-b border-gray-200 px-4 py-4 dark:border-white/10">
<div className="flex items-center justify-between gap-x-3">
<div className="min-w-0">
<h2 className="flex items-center gap-x-2 text-sm font-semibold text-gray-900 dark:text-white">
<UsersIcon aria-hidden="true" className="size-5 text-gray-400" />
Benutzer
</h2>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{filteredUsers.length} von {users.length} angezeigt
</p>
</div>
<Button
type="button"
color="indigo"
size="sm"
onClick={handleSelectNewUser}
leadingIcon={<UserPlusIcon aria-hidden="true" className="size-4" />}
>
Neuer Benutzer
</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={userSearch}
onChange={(event) => setUserSearch(event.target.value)}
placeholder="Benutzer suchen..."
className="block w-full rounded-md bg-white py-1.5 pr-3 pl-9 text-sm text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500"
/>
</div>
</div>
<div className="max-h-[calc(100dvh-20rem)] overflow-y-auto p-2">
{selectedUserId === 'new' && (
<div className="mb-2 rounded-md bg-indigo-50 px-3 py-2 text-sm font-medium text-indigo-700 ring-1 ring-indigo-200 ring-inset dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-500/20">
Neuer Benutzer wird angelegt
</div>
)}
{filteredUsers.length === 0 ? (
<div className="px-3 py-8 text-center text-sm text-gray-500 dark:text-gray-400">
Keine Benutzer gefunden.
</div>
) : (
<div className="space-y-1">
{filteredUsers.map((user) => {
const isSelected = selectedUserId === user.id
const displayName = getUserDisplayName(user)
return (
<button
key={user.id}
type="button"
onClick={() => setSelectedUserId(user.id)}
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',
)}
>
{getUserInitials(user)}
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-x-2">
<span className="truncate font-medium">
{displayName}
</span>
{user.rights.includes('admin') && (
<span className="shrink-0 rounded-md bg-indigo-50 px-1.5 py-0.5 text-[10px] 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">
Admin
</span>
)}
</span>
<span className="block truncate text-xs text-gray-500 dark:text-gray-400">
{user.email}
</span>
<span className="mt-1 flex flex-wrap gap-1">
{user.unit && (
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-600 dark:bg-white/10 dark:text-gray-300">
{user.unit}
</span>
)}
{user.group && (
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-600 dark:bg-white/10 dark:text-gray-300">
{user.group}
</span>
)}
{user.teams.slice(0, 2).map((team) => (
<span
key={team.id}
className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-600 dark:bg-white/10 dark:text-gray-300"
>
{team.name}
</span>
))}
{user.teams.length > 2 && (
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-600 dark:bg-white/10 dark:text-gray-300">
+{user.teams.length - 2}
</span>
)}
</span>
</span>
</button>
)
})}
</div>
)}
</div>
</div>
<form
key={selectedUser?.id ?? 'new'}
onSubmit={handleSaveUser}
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">
{selectedUser ? 'Benutzer bearbeiten' : 'Neuen Benutzer anlegen'}
</h2>
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Benutzername
</label>
<input
name="username"
defaultValue={selectedUser?.username ?? ''}
className={inputClassName}
/>
</div>
<div>
<label className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Anzeigename
</label>
<input
name="displayName"
defaultValue={selectedUser?.displayName ?? ''}
className={inputClassName}
/>
</div>
<div>
<label className="block text-sm/6 font-medium text-gray-900 dark:text-white">
E-Mail *
</label>
<input
name="email"
type="email"
required
defaultValue={selectedUser?.email ?? ''}
className={inputClassName}
/>
</div>
<div>
<label className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Passwort {selectedUser ? '(leer lassen = unverändert)' : '*'}
</label>
<input
name="password"
type="password"
required={!selectedUser}
className={inputClassName}
/>
</div>
<div>
<label className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Einheit
</label>
<input
name="unit"
defaultValue={selectedUser?.unit ?? ''}
className={inputClassName}
/>
</div>
<div>
<label className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Gruppe
</label>
<input
name="group"
defaultValue={selectedUser?.group ?? 'user'}
className={inputClassName}
/>
</div>
</div>
<div className="mt-6 grid grid-cols-1 gap-6 lg:grid-cols-2">
<div>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
Teams
</h3>
<div className="mt-3 space-y-2">
{teams.map((team) => {
const checked = selectedUser?.teams.some(
(userTeam) => userTeam.id === team.id,
)
return (
<Checkbox
key={team.id}
name="teamIds"
value={team.id}
defaultChecked={checked}
label={team.name}
/>
)
})}
</div>
</div>
<div>
<div className="flex items-start justify-between gap-x-4">
<div>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
Rechte
</h3>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Lege fest, welche Bereiche sichtbar sind und wo Änderungen erlaubt sind.
</p>
</div>
</div>
<div className="mt-3 overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-white/10 dark:bg-white/5">
{adminRight && (
<div className="border-b border-gray-200 bg-indigo-50 px-4 py-3 dark:border-white/10 dark:bg-indigo-500/10">
<Checkbox
name="rights"
value={adminRight.id}
checked={isAdministratorSelected}
onChange={(event) =>
handleRightChange(adminRight.id, event.target.checked)
}
label="Administrator"
description="Besitzt automatisch alle Rechte."
wrapperClassName="items-start"
labelClassName="block text-sm font-semibold text-indigo-700 dark:text-indigo-300"
descriptionClassName="mt-0.5 text-xs text-indigo-700/80 dark:text-indigo-300/80"
/>
</div>
)}
<div className="grid grid-cols-[minmax(8rem,1fr)_7rem_7rem] border-b border-gray-200 bg-gray-50 px-4 py-2 text-xs font-medium text-gray-500 dark:border-white/10 dark:bg-white/5 dark:text-gray-400">
<div>Bereich</div>
<div className="text-center">Anzeigen</div>
<div className="text-center">Bearbeiten</div>
</div>
<div className="divide-y divide-gray-200 dark:divide-white/10">
{rightGroups.map((group) => {
const readRight = group.rights.find((right) => right.id.endsWith(':read'))
const writeRight = group.rights.find((right) => right.id.endsWith(':write'))
function renderRightControl(right: typeof group.rights[number] | undefined) {
if (!right) {
return (
<span className="inline-flex h-5 items-center justify-center text-xs text-gray-300 dark:text-gray-600">
</span>
)
}
const isChecked =
isAdministratorSelected || selectedRights.includes(right.id)
const isDisabled = isAdministratorSelected
return (
<Checkbox
name="rights"
value={right.id}
checked={isChecked}
disabled={isDisabled}
onChange={(event) =>
handleRightChange(right.id, event.target.checked)
}
aria-label={`${group.label} ${right.label}`}
title={
isAdministratorSelected
? 'Durch Administrator-Recht automatisch aktiv'
: right.label
}
wrapperClassName={classNames(
'justify-center',
isDisabled && 'cursor-not-allowed',
)}
/>
)
}
const groupHasAnyRight = group.rights.some((right) =>
selectedRights.includes(right.id),
)
return (
<div
key={group.id}
className={classNames(
'grid grid-cols-[minmax(8rem,1fr)_7rem_7rem] items-center px-4 py-3',
groupHasAnyRight || isAdministratorSelected
? 'bg-white dark:bg-transparent'
: 'bg-gray-50/60 dark:bg-white/[0.02]',
)}
>
<div className="min-w-0">
<div className="flex items-center gap-x-2">
<span className="truncate text-sm font-medium text-gray-900 dark:text-white">
{group.label}
</span>
{(groupHasAnyRight || isAdministratorSelected) && (
<span className="inline-flex size-1.5 rounded-full bg-indigo-600 dark:bg-indigo-400" />
)}
</div>
</div>
<div className="flex justify-center">
{renderRightControl(readRight)}
</div>
<div className="flex justify-center">
{renderRightControl(writeRight)}
</div>
</div>
)
})}
</div>
</div>
{isAdministratorSelected && (
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
Einzelrechte sind deaktiviert, weil Administrator bereits alle Berechtigungen umfasst.
</p>
)}
</div>
</div>
<div className="mt-6 flex justify-end">
<Button
type="submit"
color="indigo"
isLoading={isSaving}
>
Benutzer speichern
</Button>
</div>
</form>
</div>
)}
{activeTab === 'teams' && (
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1fr)_28rem]">
<div className="rounded-lg bg-white p-4 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
Teams
</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Übersicht der aktuell vorhandenen Teams.
</p>
<div className="mt-4 divide-y divide-gray-200 rounded-lg border border-gray-200 dark:divide-white/10 dark:border-white/10">
{teams.length === 0 ? (
<div className="p-4 text-sm text-gray-500 dark:text-gray-400">
Keine Teams vorhanden.
</div>
) : (
teams.map((team) => (
<div
key={team.id}
className="flex items-center justify-between gap-x-4 p-4"
>
<div className="flex min-w-0 items-center gap-x-3">
<span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-indigo-600 text-sm font-semibold text-white">
{team.initial}
</span>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-gray-900 dark:text-white">
{team.name}
</p>
<p className="truncate text-xs text-gray-500 dark:text-gray-400">
{team.href}
</p>
</div>
</div>
</div>
))
)}
</div>
</div>
<form
onSubmit={handleSaveTeam}
className="rounded-lg bg-white p-4 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10"
>
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
Team anlegen
</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Erstelle ein neues Team, das anschließend Benutzern zugewiesen werden kann.
</p>
<div className="mt-4 space-y-4">
<div>
<label className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Teamname
</label>
<input
name="teamName"
placeholder="Teamname"
required
className={inputClassName}
/>
</div>
<div>
<label className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Initial
</label>
<input
name="teamInitial"
placeholder="Initial"
className={inputClassName}
/>
</div>
<div>
<label className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Link
</label>
<input
name="teamHref"
placeholder="Link, z.B. #"
defaultValue="#"
className={inputClassName}
/>
</div>
</div>
<div className="mt-4 flex justify-end">
<Button
type="submit"
variant="secondary"
color="indigo"
isLoading={isSaving}
>
Team erstellen
</Button>
</div>
</form>
</div>
)}
{activeTab === 'milestone' && (
<Milestone />
)}
</div>
</main>
)

View File

@ -1,458 +0,0 @@
// frontend/src/pages/administration/Milestone.tsx
import { useEffect, useState, type FormEvent } from 'react'
import {
KeyIcon,
ServerStackIcon,
VideoCameraIcon,
} from '@heroicons/react/20/solid'
import Button from '../../components/Button'
import LoadingSpinner from '../../components/LoadingSpinner'
import Switch from '../../components/Switch'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
type MilestoneSettings = {
host: string
username: string
passwordConfigured: boolean
skipTlsVerify: boolean
tokenConfigured: boolean
tokenType: string
tokenExpiresAt?: string
tokenUpdatedAt?: string
serverId: string
serverName: string
serverVersion: string
serverUpdatedAt?: string
updatedAt?: string
}
const inputClassName =
'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
async function readApiError(response: Response, fallback: string) {
const data = await response.json().catch(() => null)
return data?.error ?? fallback
}
function formatUpdatedAt(value?: string) {
if (!value) {
return null
}
try {
return new Intl.DateTimeFormat('de-DE', {
dateStyle: 'short',
timeStyle: 'short',
}).format(new Date(value))
} catch {
return value
}
}
export default function Milestone() {
const [settings, setSettings] = useState<MilestoneSettings | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [isFetchingToken, setIsFetchingToken] = useState(false)
const [skipTlsVerify, setSkipTlsVerify] = useState(false)
useEffect(() => {
void loadSettings()
}, [])
async function loadSettings() {
setIsLoading(true)
setError(null)
try {
const response = await fetch(`${API_URL}/admin/milestone`, {
credentials: 'include',
})
if (!response.ok) {
throw new Error(
await readApiError(
response,
'Milestone-Einstellungen konnten nicht geladen werden',
),
)
}
const data = await response.json()
setSettings(data.settings)
setSkipTlsVerify(Boolean(data.settings?.skipTlsVerify))
} catch (error) {
setError(
error instanceof Error
? error.message
: 'Milestone-Einstellungen konnten nicht geladen werden',
)
} finally {
setIsLoading(false)
}
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const form = event.currentTarget
const formData = new FormData(form)
setIsSaving(true)
setError(null)
setSuccess(null)
try {
const response = await fetch(`${API_URL}/admin/milestone`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
host: String(formData.get('host') ?? ''),
username: String(formData.get('username') ?? ''),
password: String(formData.get('password') ?? ''),
skipTlsVerify,
}),
})
if (!response.ok) {
throw new Error(
await readApiError(
response,
'Milestone-Einstellungen konnten nicht gespeichert werden',
),
)
}
const data = await response.json()
setSettings(data.settings)
form.reset()
if (data.warning) {
setSuccess(data.warning)
} else {
setSuccess('Milestone-Einstellungen wurden gespeichert, Token und Recording-Server wurden abgerufen.')
}
} catch (error) {
setError(
error instanceof Error
? error.message
: 'Milestone-Einstellungen konnten nicht gespeichert werden',
)
} finally {
setIsSaving(false)
}
}
async function handleFetchToken() {
setIsFetchingToken(true)
setError(null)
setSuccess(null)
try {
const response = await fetch(`${API_URL}/admin/milestone/token`, {
method: 'POST',
credentials: 'include',
})
if (!response.ok) {
throw new Error(
await readApiError(
response,
'Milestone-Token konnte nicht abgerufen werden',
),
)
}
const data = await response.json()
setSettings(data.settings)
setSuccess('Milestone-Token wurde abgerufen und gespeichert.')
} catch (error) {
setError(
error instanceof Error
? error.message
: 'Milestone-Token konnte nicht abgerufen werden',
)
} finally {
setIsFetchingToken(false)
}
}
if (isLoading) {
return (
<div className="rounded-lg bg-white p-8 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
<LoadingSpinner
size="md"
label="Milestone-Einstellungen werden geladen..."
className="text-indigo-600 dark:text-indigo-400"
center
/>
</div>
)
}
const updatedAt = formatUpdatedAt(settings?.updatedAt)
return (
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1fr)_40rem]">
<form
onSubmit={handleSubmit}
className="rounded-lg bg-white p-4 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10"
>
<div className="flex items-start gap-x-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600 ring-1 ring-indigo-600/10 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20">
<VideoCameraIcon aria-hidden="true" className="size-5" />
</div>
<div>
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
Milestone Videoserver
</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Hinterlege die Zugangsdaten für den Milestone Videoserver.
</p>
</div>
</div>
{error && (
<div className="mt-6 rounded-md bg-red-50 p-4 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
{error}
</div>
)}
{success && (
<div className="mt-6 rounded-md bg-green-50 p-4 text-sm text-green-700 dark:bg-green-900/30 dark:text-green-300">
{success}
</div>
)}
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<label
htmlFor="milestone-host"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
IP-Adresse / Hostname *
</label>
<div className="mt-2">
<input
id="milestone-host"
name="host"
type="text"
required
defaultValue={settings?.host ?? ''}
placeholder="z. B. 192.168.178.50"
className={inputClassName}
/>
</div>
</div>
<div className="sm:col-span-2">
<label
htmlFor="milestone-username"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Benutzername *
</label>
<div className="mt-2">
<input
id="milestone-username"
name="username"
type="text"
required
defaultValue={settings?.username ?? ''}
autoComplete="off"
className={inputClassName}
/>
</div>
</div>
<div className="sm:col-span-2">
<label
htmlFor="milestone-password"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Kennwort {settings?.passwordConfigured ? '(leer lassen = unverändert)' : '*'}
</label>
<div className="mt-2">
<input
id="milestone-password"
name="password"
type="password"
required={!settings?.passwordConfigured}
autoComplete="new-password"
placeholder={
settings?.passwordConfigured
? 'Kennwort ist bereits gespeichert'
: ''
}
className={inputClassName}
/>
</div>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
Das gespeicherte Kennwort wird nicht im Klartext angezeigt.
</p>
</div>
<div className="sm:col-span-2">
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/5">
<Switch
checked={skipTlsVerify}
onChange={(event) => setSkipTlsVerify(event.target.checked)}
label="TLS-Zertifikatsprüfung deaktivieren"
description={
<>
Entspricht <code>curl --insecure</code>. Nur für selbstsignierte
Zertifikate oder interne Testsysteme verwenden.
</>
}
variant="simple"
/>
</div>
</div>
</div>
<div className="mt-6 flex justify-end gap-x-3">
<Button
type="button"
variant="secondary"
color="indigo"
isLoading={isFetchingToken}
disabled={!settings?.passwordConfigured || isSaving}
onClick={() => void handleFetchToken()}
>
Token & Recording-Server erneut abrufen
</Button>
<Button
type="submit"
color="indigo"
isLoading={isSaving}
>
Speichern
</Button>
</div>
</form>
<aside className="rounded-lg bg-white p-4 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
<h3 className="flex items-center gap-x-2 text-sm font-semibold text-gray-900 dark:text-white">
<ServerStackIcon aria-hidden="true" className="size-5 text-gray-400" />
Status
</h3>
<dl className="mt-4 grid grid-cols-1 gap-x-6 gap-y-4 text-sm sm:grid-cols-2">
<div className="min-w-0">
<dt className="text-gray-500 dark:text-gray-400">
Server
</dt>
<dd className="mt-1 break-all font-medium text-gray-900 dark:text-white">
{settings?.host || 'Nicht gespeichert'}
</dd>
</div>
<div className="min-w-0">
<dt className="text-gray-500 dark:text-gray-400">
Recording-Server-ID
</dt>
<dd className="mt-1 break-all font-medium text-gray-900 dark:text-white">
{settings?.serverId || 'Nicht gespeichert'}
</dd>
</div>
<div className="min-w-0">
<dt className="text-gray-500 dark:text-gray-400">
Benutzername
</dt>
<dd className="mt-1 break-all font-medium text-gray-900 dark:text-white">
{settings?.username || 'Nicht gespeichert'}
</dd>
</div>
<div>
<dt className="text-gray-500 dark:text-gray-400">
Kennwort
</dt>
<dd className="mt-1 flex items-center gap-x-2 font-medium text-gray-900 dark:text-white">
<KeyIcon aria-hidden="true" className="size-4 text-gray-400" />
{settings?.passwordConfigured ? 'Gespeichert' : 'Nicht gespeichert'}
</dd>
</div>
<div>
<dt className="text-gray-500 dark:text-gray-400">
TLS-Zertifikatsprüfung
</dt>
<dd className="mt-1 font-medium text-gray-900 dark:text-white">
{settings?.skipTlsVerify ? 'Deaktiviert' : 'Aktiv'}
</dd>
</div>
<div>
<dt className="text-gray-500 dark:text-gray-400">
Auth-Token
</dt>
<dd className="mt-1 font-medium text-gray-900 dark:text-white">
{settings?.tokenConfigured ? 'Gespeichert' : 'Nicht gespeichert'}
</dd>
</div>
{settings?.tokenExpiresAt && (
<div>
<dt className="text-gray-500 dark:text-gray-400">
Token gültig bis
</dt>
<dd className="mt-1 font-medium text-gray-900 dark:text-white">
{formatUpdatedAt(settings.tokenExpiresAt)}
</dd>
</div>
)}
{settings?.serverName && (
<div className="min-w-0">
<dt className="text-gray-500 dark:text-gray-400">
Recording-Server
</dt>
<dd className="mt-1 break-all font-medium text-gray-900 dark:text-white">
{settings.serverName}
</dd>
</div>
)}
{settings?.serverVersion && (
<div>
<dt className="text-gray-500 dark:text-gray-400">
Version
</dt>
<dd className="mt-1 font-medium text-gray-900 dark:text-white">
{settings.serverVersion}
</dd>
</div>
)}
{updatedAt && (
<div>
<dt className="text-gray-500 dark:text-gray-400">
Zuletzt geändert
</dt>
<dd className="mt-1 font-medium text-gray-900 dark:text-white">
{updatedAt}
</dd>
</div>
)}
</dl>
</aside>
</div>
)
}

View File

@ -0,0 +1,334 @@
// frontend/src/pages/administration/feedback/FeedbackAdministration.tsx
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
ArrowPathIcon,
TrashIcon,
} from '@heroicons/react/20/solid'
import Button from '../../../components/Button'
import LoadingSpinner from '../../../components/LoadingSpinner'
import Avatar from '../../../components/Avatar'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
type FeedbackItem = {
id: string
userId: string
userName: string
userEmail: string
message: string
log: unknown
createdAt: string
}
type NotificationLike = {
type?: string
entityType?: string
entityId?: string
data?: {
feedbackId?: string
}
}
function formatDate(value: string) {
return new Intl.DateTimeFormat('de-DE', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value))
}
function getInitials(name: string, email: string) {
const source = name || email
if (!source) {
return '?'
}
const parts = source
.replace(/@.*/, '')
.split(/[.\s_-]+/)
.filter(Boolean)
return parts
.slice(0, 2)
.map((part) => part.charAt(0))
.join('')
.toUpperCase()
}
function extractNotification(eventData: string): NotificationLike | null {
try {
const parsed = JSON.parse(eventData)
if (parsed?.notification) {
return parsed.notification
}
return parsed
} catch {
return null
}
}
function isFeedbackEvent(notification: NotificationLike | null) {
if (!notification) {
return false
}
return (
notification.type === 'feedback.created' ||
notification.type === 'feedback.deleted' ||
notification.entityType === 'feedback' ||
Boolean(notification.data?.feedbackId)
)
}
export default function FeedbackAdministration() {
const [feedback, setFeedback] = useState<FeedbackItem[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isRefreshing, setIsRefreshing] = useState(false)
const [deletingFeedbackIds, setDeletingFeedbackIds] = useState<Set<string>>(
() => new Set(),
)
const [error, setError] = useState<string | null>(null)
const sortedFeedback = useMemo(
() =>
[...feedback].sort(
(firstItem, secondItem) =>
new Date(secondItem.createdAt).getTime() -
new Date(firstItem.createdAt).getTime(),
),
[feedback],
)
const loadFeedback = useCallback(async (options?: { silent?: boolean }) => {
if (options?.silent) {
setIsRefreshing(true)
} else {
setIsLoading(true)
}
setError(null)
try {
const response = await fetch(`${API_URL}/admin/feedback`, {
credentials: 'include',
})
if (!response.ok) {
const errorData = await response.json().catch(() => null)
throw new Error(errorData?.error ?? 'Feedback konnte nicht geladen werden')
}
const data = await response.json()
setFeedback(Array.isArray(data.feedback) ? data.feedback : [])
} catch (error) {
setError(
error instanceof Error
? error.message
: 'Feedback konnte nicht geladen werden',
)
} finally {
setIsLoading(false)
setIsRefreshing(false)
}
}, [])
useEffect(() => {
void loadFeedback()
}, [loadFeedback])
useEffect(() => {
const events = new EventSource(`${API_URL}/events`, {
withCredentials: true,
})
events.onmessage = (event) => {
const notification = extractNotification(event.data)
if (isFeedbackEvent(notification)) {
void loadFeedback({ silent: true })
}
}
events.onerror = () => {
events.close()
}
return () => {
events.close()
}
}, [loadFeedback])
async function handleDeleteFeedback(feedbackItem: FeedbackItem) {
const confirmed = window.confirm(
`Feedback von ${feedbackItem.userName || feedbackItem.userEmail || 'Unbekannt'} wirklich löschen?`,
)
if (!confirmed) {
return
}
setDeletingFeedbackIds((currentIds) => {
const nextIds = new Set(currentIds)
nextIds.add(feedbackItem.id)
return nextIds
})
setError(null)
try {
const response = await fetch(`${API_URL}/admin/feedback/${feedbackItem.id}`, {
method: 'DELETE',
credentials: 'include',
})
if (!response.ok) {
const errorData = await response.json().catch(() => null)
throw new Error(errorData?.error ?? 'Feedback konnte nicht gelöscht werden')
}
setFeedback((currentFeedback) =>
currentFeedback.filter((item) => item.id !== feedbackItem.id),
)
} catch (error) {
setError(
error instanceof Error
? error.message
: 'Feedback konnte nicht gelöscht werden',
)
} finally {
setDeletingFeedbackIds((currentIds) => {
const nextIds = new Set(currentIds)
nextIds.delete(feedbackItem.id)
return nextIds
})
}
}
if (isLoading) {
return (
<div className="flex min-h-64 items-center justify-center">
<LoadingSpinner
size="lg"
label="Feedback wird geladen..."
className="text-indigo-600 dark:text-indigo-400"
center
/>
</div>
)
}
return (
<section>
<div className="mb-6 flex items-start justify-between gap-x-4">
<div>
<h2 className="text-base font-semibold text-gray-900 dark:text-white">
Feedback
</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Eingereichtes Feedback inklusive technischem Log einsehen und verwalten.
</p>
</div>
<Button
type="button"
variant="secondary"
color="gray"
size="sm"
disabled={isRefreshing}
isLoading={isRefreshing}
leadingIcon={<ArrowPathIcon aria-hidden="true" className="size-4" />}
onClick={() => void loadFeedback({ silent: true })}
>
Aktualisieren
</Button>
</div>
{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>
)}
{sortedFeedback.length === 0 ? (
<div className="rounded-lg border border-dashed border-gray-300 bg-white p-8 text-center dark:border-white/10 dark:bg-gray-900">
<p className="text-sm text-gray-500 dark:text-gray-400">
Es wurde noch kein Feedback abgegeben.
</p>
</div>
) : (
<div className="space-y-4">
{sortedFeedback.map((feedbackItem) => {
const isDeleting = deletingFeedbackIds.has(feedbackItem.id)
return (
<article
key={feedbackItem.id}
className="rounded-lg border border-gray-200 bg-white p-5 shadow-xs dark:border-white/10 dark:bg-gray-900"
>
<div className="flex items-start justify-between gap-x-4">
<div className="flex min-w-0 items-start gap-x-3">
<Avatar
initials={getInitials(feedbackItem.userName, feedbackItem.userEmail)}
name={feedbackItem.userName || feedbackItem.userEmail}
size={10}
/>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
{feedbackItem.userName || 'Unbekannter Benutzer'}
</h3>
{feedbackItem.userEmail && (
<span className="text-xs text-gray-500 dark:text-gray-400">
{feedbackItem.userEmail}
</span>
)}
</div>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{formatDate(feedbackItem.createdAt)}
</p>
</div>
</div>
<Button
type="button"
variant="secondary"
color="red"
size="sm"
disabled={isDeleting}
isLoading={isDeleting}
leadingIcon={<TrashIcon aria-hidden="true" className="size-4" />}
onClick={() => void handleDeleteFeedback(feedbackItem)}
>
Löschen
</Button>
</div>
<p className="mt-4 whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-300">
{feedbackItem.message}
</p>
<details className="mt-4 rounded-md bg-gray-50 p-3 dark:bg-white/5">
<summary className="cursor-pointer text-sm font-medium text-gray-700 dark:text-gray-300">
Technisches Log anzeigen
</summary>
<pre className="mt-3 max-h-96 overflow-auto rounded bg-gray-950 p-3 text-xs text-gray-100">
{JSON.stringify(feedbackItem.log, null, 2)}
</pre>
</details>
</article>
)
})}
</div>
)}
</section>
)
}

View File

@ -0,0 +1,814 @@
// frontend\src\pages\administration\milestone\Milestone.tsx
import { useEffect, useState, type FormEvent } from 'react'
import {
KeyIcon,
ServerStackIcon,
VideoCameraIcon,
} from '@heroicons/react/20/solid'
import Button from '../../../components/Button'
import LoadingSpinner from '../../../components/LoadingSpinner'
import Notifications, {
type ToastNotification,
} from '../../../components/Notifications'
import Switch from '../../../components/Switch'
import { DialogTitle } from '@headlessui/react'
import { XMarkIcon } from '@heroicons/react/24/outline'
import Modal from '../../../components/Modal'
import TaskList, { type TaskListStep } from '../../../components/TaskList'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
type MilestoneSettings = {
host: string
username: string
passwordConfigured: boolean
skipTlsVerify: boolean
tokenConfigured: boolean
tokenType: string
tokenExpiresAt?: string
tokenUpdatedAt?: string
serverId: string
serverName: string
serverVersion: string
serverUpdatedAt?: string
updatedAt?: string
}
type MilestoneSyncEvent = {
type: 'token' | 'recordingServer' | 'hardware' | 'done' | 'error'
message?: string
tokenType?: string
tokenExpiresAt?: string
recordingServerId?: string
recordingServerName?: string
recordingServerVersion?: string
hardwareCount?: number
settings?: MilestoneSettings
}
const inputClassName =
'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
async function readApiError(response: Response, fallback: string) {
const data = await response.json().catch(() => null)
return data?.error ?? fallback
}
function formatUpdatedAt(value?: string) {
if (!value) {
return null
}
try {
return new Intl.DateTimeFormat('de-DE', {
dateStyle: 'short',
timeStyle: 'short',
}).format(new Date(value))
} catch {
return value
}
}
function createMilestoneSyncSteps(): TaskListStep[] {
return [
{
id: 'token',
name: 'Token abrufen',
description: 'Authentifizierung am Milestone-Server läuft.',
status: 'current',
},
{
id: 'recordingServer',
name: 'Recording-Server abfragen',
description: 'Server-ID, Name und Version werden geladen.',
status: 'upcoming',
},
{
id: 'hardware',
name: 'Hardware synchronisieren',
description: 'Hardware-Geräte und Treiberdaten werden geladen.',
status: 'upcoming',
},
]
}
function updateMilestoneSyncStepsForEvent(
steps: TaskListStep[],
event: MilestoneSyncEvent,
): TaskListStep[] {
if (event.type === 'token') {
const tokenExpiresAt = formatUpdatedAt(event.tokenExpiresAt)
return steps.map((step) => {
if (step.id === 'token') {
return {
...step,
status: 'complete',
description: tokenExpiresAt
? `Token wurde abgerufen. Gültig bis ${tokenExpiresAt}.`
: 'Token wurde erfolgreich abgerufen.',
}
}
if (step.id === 'recordingServer') {
return {
...step,
status: 'current',
}
}
return step
})
}
if (event.type === 'recordingServer') {
const recordingServerDescription = [
event.recordingServerName || 'Recording-Server wurde abgerufen.',
event.recordingServerVersion ? `Version ${event.recordingServerVersion}` : '',
event.recordingServerId ? `ID: ${event.recordingServerId}` : '',
]
.filter(Boolean)
.join(' · ')
return steps.map((step) => {
if (step.id === 'recordingServer') {
return {
...step,
status: 'complete',
description: recordingServerDescription,
}
}
if (step.id === 'hardware') {
return {
...step,
status: 'current',
}
}
return step
})
}
if (event.type === 'hardware') {
return steps.map((step) => {
if (step.id === 'hardware') {
return {
...step,
status: 'complete',
description:
event.hardwareCount === 1
? '1 Hardware-Gerät wurde synchronisiert.'
: `${event.hardwareCount ?? 0} Hardware-Geräte wurden synchronisiert.`,
}
}
return step
})
}
return steps
}
function markCurrentMilestoneSyncStepAsError(
steps: TaskListStep[],
message: string,
): TaskListStep[] {
const currentStepIndex = steps.findIndex((step) => step.status === 'current')
const upcomingStepIndex = steps.findIndex((step) => step.status === 'upcoming')
const failedStepIndex =
currentStepIndex >= 0
? currentStepIndex
: upcomingStepIndex >= 0
? upcomingStepIndex
: steps.length - 1
return steps.map((step, index) =>
index === failedStepIndex
? {
...step,
status: 'error',
description: message,
}
: step,
)
}
export default function Milestone() {
const [settings, setSettings] = useState<MilestoneSettings | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [notifications, setNotifications] = useState<ToastNotification[]>([])
const [isFetchingToken, setIsFetchingToken] = useState(false)
const [skipTlsVerify, setSkipTlsVerify] = useState(false)
const [syncModalOpen, setSyncModalOpen] = useState(false)
const [syncSteps, setSyncSteps] = useState<TaskListStep[]>(() => createMilestoneSyncSteps())
const [syncError, setSyncError] = useState<string | null>(null)
const [syncCompleted, setSyncCompleted] = useState(false)
useEffect(() => {
void loadSettings()
}, [])
function dismissNotification(id: string) {
setNotifications((currentNotifications) =>
currentNotifications.filter((notification) => notification.id !== id),
)
}
function showNotification(
notification: Omit<ToastNotification, 'id'>,
) {
setNotifications((currentNotifications) => [
{
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
...notification,
},
...currentNotifications,
])
}
function getHardwareCountMessage(count: number) {
if (count === 1) {
return '1 Hardware-Gerät wurde von Milestone geladen und gespeichert.'
}
return `${count} Hardware-Geräte wurden von Milestone geladen und gespeichert.`
}
function handleMilestoneSyncEvent(event: MilestoneSyncEvent) {
setSyncSteps((currentSteps) =>
updateMilestoneSyncStepsForEvent(currentSteps, event),
)
if (event.type === 'error') {
const message = event.message || 'Milestone-Synchronisierung fehlgeschlagen'
setSyncError(message)
setSyncSteps((currentSteps) =>
markCurrentMilestoneSyncStepAsError(currentSteps, message),
)
throw new Error(message)
}
if (event.type === 'token') {
const tokenExpiresAt = formatUpdatedAt(event.tokenExpiresAt)
showNotification({
variant: 'success',
title: 'Token abgerufen',
message: tokenExpiresAt
? `Token wurde erfolgreich abgerufen. Gültig bis ${tokenExpiresAt}.`
: 'Token wurde erfolgreich abgerufen.',
})
return
}
if (event.type === 'recordingServer') {
showNotification({
variant: 'success',
title: 'Recording-Infos abgerufen',
message: [
event.recordingServerName || 'Recording-Server',
event.recordingServerVersion
? `Version ${event.recordingServerVersion}`
: '',
event.recordingServerId
? `ID: ${event.recordingServerId}`
: '',
]
.filter(Boolean)
.join(' · '),
})
return
}
if (event.type === 'hardware') {
showNotification({
variant: 'success',
title: 'Hardware synchronisiert',
message: getHardwareCountMessage(event.hardwareCount ?? 0),
})
return
}
if (event.type === 'done') {
setSyncCompleted(true)
if (event.settings) {
setSettings(event.settings)
setSkipTlsVerify(Boolean(event.settings.skipTlsVerify))
}
return
}
}
async function runMilestoneSyncStream() {
setSyncModalOpen(true)
setSyncCompleted(false)
setSyncError(null)
setSyncSteps(createMilestoneSyncSteps())
const response = await fetch(`${API_URL}/admin/milestone/token/stream`, {
method: 'POST',
credentials: 'include',
})
if (!response.ok || !response.body) {
const message = await readApiError(
response,
'Milestone-Token konnte nicht abgerufen werden',
)
setSyncError(message)
setSyncSteps((currentSteps) =>
markCurrentMilestoneSyncStepAsError(currentSteps, message),
)
throw new Error(message)
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { value, done } = await reader.read()
if (done) {
break
}
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
const trimmedLine = line.trim()
if (!trimmedLine) {
continue
}
const event = JSON.parse(trimmedLine) as MilestoneSyncEvent
handleMilestoneSyncEvent(event)
}
}
const remainingLine = buffer.trim()
if (remainingLine) {
const event = JSON.parse(remainingLine) as MilestoneSyncEvent
handleMilestoneSyncEvent(event)
}
}
async function loadSettings() {
setIsLoading(true)
try {
const response = await fetch(`${API_URL}/admin/milestone`, {
credentials: 'include',
})
if (!response.ok) {
throw new Error(
await readApiError(
response,
'Milestone-Einstellungen konnten nicht geladen werden',
),
)
}
const data = await response.json()
setSettings(data.settings)
setSkipTlsVerify(Boolean(data.settings?.skipTlsVerify))
} catch (error) {
showNotification({
variant: 'error',
title: 'Einstellungen konnten nicht geladen werden',
message:
error instanceof Error
? error.message
: 'Milestone-Einstellungen konnten nicht geladen werden',
autoClose: false,
})
} finally {
setIsLoading(false)
}
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const form = event.currentTarget
const formData = new FormData(form)
setIsSaving(true)
try {
const response = await fetch(`${API_URL}/admin/milestone?sync=false`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
host: String(formData.get('host') ?? ''),
username: String(formData.get('username') ?? ''),
password: String(formData.get('password') ?? ''),
skipTlsVerify,
}),
})
if (!response.ok) {
throw new Error(
await readApiError(
response,
'Milestone-Einstellungen konnten nicht gespeichert werden',
),
)
}
const data = (await response.json()) as {
settings: MilestoneSettings
}
setSettings(data.settings)
setSkipTlsVerify(Boolean(data.settings?.skipTlsVerify))
form.reset()
await runMilestoneSyncStream()
} catch (error) {
showNotification({
variant: 'error',
title: 'Speichern fehlgeschlagen',
message:
error instanceof Error
? error.message
: 'Milestone-Einstellungen konnten nicht gespeichert werden',
autoClose: false,
})
} finally {
setIsSaving(false)
}
}
async function handleFetchToken() {
setIsFetchingToken(true)
try {
await runMilestoneSyncStream()
} catch (error) {
showNotification({
variant: 'error',
title: 'Abruf fehlgeschlagen',
message:
error instanceof Error
? error.message
: 'Milestone-Token konnte nicht abgerufen werden',
autoClose: false,
})
} finally {
setIsFetchingToken(false)
}
}
if (isLoading) {
return (
<div className="rounded-lg bg-white p-8 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
<LoadingSpinner
size="md"
label="Milestone-Einstellungen werden geladen..."
className="text-indigo-600 dark:text-indigo-400"
center
/>
</div>
)
}
const updatedAt = formatUpdatedAt(settings?.updatedAt)
return (
<>
<Notifications
notifications={notifications}
onDismiss={dismissNotification}
position="top-right"
/>
<Modal
open={syncModalOpen}
setOpen={(open) => {
const syncIsRunning =
!syncCompleted && !syncError && (isSaving || isFetchingToken)
if (!open && syncIsRunning) {
return
}
setSyncModalOpen(open)
}}
zIndexClassName="z-[9999]"
panelClassName="max-w-xl"
>
<div className="flex items-start justify-between border-b border-gray-200 px-6 py-4 dark:border-white/10">
<div>
<DialogTitle className="text-base font-semibold text-gray-900 dark:text-white">
Milestone-Synchronisierung
</DialogTitle>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Token, Recording-Server und Hardware werden nacheinander abgefragt.
</p>
</div>
<button
type="button"
disabled={!syncCompleted && !syncError && (isSaving || isFetchingToken)}
onClick={() => setSyncModalOpen(false)}
className="rounded-md text-gray-400 hover:text-gray-500 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:text-gray-300"
>
<span className="sr-only">Schließen</span>
<XMarkIcon aria-hidden="true" className="size-6" />
</button>
</div>
<div className="px-6 py-5">
<TaskList
steps={syncSteps}
ariaLabel="Milestone-Synchronisierung"
/>
{syncError && (
<div className="mt-5 rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
{syncError}
</div>
)}
</div>
<div className="flex justify-end border-t border-gray-200 px-6 py-4 dark:border-white/10">
<Button
type="button"
variant="secondary"
color="gray"
disabled={!syncCompleted && !syncError && (isSaving || isFetchingToken)}
onClick={() => setSyncModalOpen(false)}
>
{syncCompleted || syncError ? 'Schließen' : 'Bitte warten...'}
</Button>
</div>
</Modal>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1fr)_40rem]">
<form
onSubmit={handleSubmit}
className="rounded-lg bg-white p-4 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10"
>
<div className="flex items-start gap-x-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600 ring-1 ring-indigo-600/10 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20">
<VideoCameraIcon aria-hidden="true" className="size-5" />
</div>
<div>
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
Milestone Videoserver
</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Hinterlege die Zugangsdaten für den Milestone Videoserver.
</p>
</div>
</div>
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<label
htmlFor="milestone-host"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
IP-Adresse / Hostname *
</label>
<div className="mt-2">
<input
id="milestone-host"
name="host"
type="text"
required
defaultValue={settings?.host ?? ''}
placeholder="z. B. 192.168.178.50"
className={inputClassName}
/>
</div>
</div>
<div className="sm:col-span-2">
<label
htmlFor="milestone-username"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Benutzername *
</label>
<div className="mt-2">
<input
id="milestone-username"
name="username"
type="text"
required
defaultValue={settings?.username ?? ''}
autoComplete="off"
className={inputClassName}
/>
</div>
</div>
<div className="sm:col-span-2">
<label
htmlFor="milestone-password"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Kennwort {settings?.passwordConfigured ? '(leer lassen = unverändert)' : '*'}
</label>
<div className="mt-2">
<input
id="milestone-password"
name="password"
type="password"
required={!settings?.passwordConfigured}
autoComplete="new-password"
placeholder={
settings?.passwordConfigured
? 'Kennwort ist bereits gespeichert'
: ''
}
className={inputClassName}
/>
</div>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
Das gespeicherte Kennwort wird nicht im Klartext angezeigt.
</p>
</div>
<div className="sm:col-span-2">
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/5">
<Switch
checked={skipTlsVerify}
onChange={(event) => setSkipTlsVerify(event.target.checked)}
label="TLS-Zertifikatsprüfung deaktivieren"
description={
<>
Nur für selbstsignierte Zertifikate oder interne Testsysteme verwenden.
</>
}
variant="simple"
/>
</div>
</div>
</div>
<div className="mt-6 flex justify-end gap-x-3">
<Button
type="button"
variant="secondary"
color="indigo"
isLoading={isFetchingToken}
disabled={!settings?.passwordConfigured || isSaving}
onClick={() => void handleFetchToken()}
>
Token & Infos erneut abrufen
</Button>
<Button
type="submit"
color="indigo"
isLoading={isSaving}
>
Speichern
</Button>
</div>
</form>
<aside className="rounded-lg bg-white p-4 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
<h3 className="flex items-center gap-x-2 text-sm font-semibold text-gray-900 dark:text-white">
<ServerStackIcon aria-hidden="true" className="size-5 text-gray-400" />
Status
</h3>
<dl className="mt-4 grid grid-cols-1 gap-x-6 gap-y-4 text-sm sm:grid-cols-2">
<div className="min-w-0">
<dt className="text-gray-500 dark:text-gray-400">
Server
</dt>
<dd className="mt-1 break-all font-medium text-gray-900 dark:text-white">
{settings?.host || 'Nicht gespeichert'}
</dd>
</div>
<div className="min-w-0">
<dt className="text-gray-500 dark:text-gray-400">
Recording-Server-ID
</dt>
<dd className="mt-1 break-all font-medium text-gray-900 dark:text-white">
{settings?.serverId || 'Nicht gespeichert'}
</dd>
</div>
<div className="min-w-0">
<dt className="text-gray-500 dark:text-gray-400">
Benutzername
</dt>
<dd className="mt-1 break-all font-medium text-gray-900 dark:text-white">
{settings?.username || 'Nicht gespeichert'}
</dd>
</div>
<div>
<dt className="text-gray-500 dark:text-gray-400">
Kennwort
</dt>
<dd className="mt-1 flex items-center gap-x-2 font-medium text-gray-900 dark:text-white">
<KeyIcon aria-hidden="true" className="size-4 text-gray-400" />
{settings?.passwordConfigured ? 'Gespeichert' : 'Nicht gespeichert'}
</dd>
</div>
<div>
<dt className="text-gray-500 dark:text-gray-400">
TLS-Zertifikatsprüfung
</dt>
<dd className="mt-1 font-medium text-gray-900 dark:text-white">
{settings?.skipTlsVerify ? 'Deaktiviert' : 'Aktiv'}
</dd>
</div>
<div>
<dt className="text-gray-500 dark:text-gray-400">
Auth-Token
</dt>
<dd className="mt-1 font-medium text-gray-900 dark:text-white">
{settings?.tokenConfigured ? 'Gespeichert' : 'Nicht gespeichert'}
</dd>
</div>
{settings?.tokenExpiresAt && (
<div>
<dt className="text-gray-500 dark:text-gray-400">
Token gültig bis
</dt>
<dd className="mt-1 font-medium text-gray-900 dark:text-white">
{formatUpdatedAt(settings.tokenExpiresAt)}
</dd>
</div>
)}
{settings?.serverName && (
<div className="min-w-0">
<dt className="text-gray-500 dark:text-gray-400">
Recording-Server
</dt>
<dd className="mt-1 break-all font-medium text-gray-900 dark:text-white">
{settings.serverName}
</dd>
</div>
)}
{settings?.serverVersion && (
<div>
<dt className="text-gray-500 dark:text-gray-400">
Version
</dt>
<dd className="mt-1 font-medium text-gray-900 dark:text-white">
{settings.serverVersion}
</dd>
</div>
)}
{updatedAt && (
<div>
<dt className="text-gray-500 dark:text-gray-400">
Zuletzt geändert
</dt>
<dd className="mt-1 font-medium text-gray-900 dark:text-white">
{updatedAt}
</dd>
</div>
)}
</dl>
</aside>
</div>
</>
)
}

View File

@ -0,0 +1,259 @@
// frontend\src\pages\administration\teams\TeamsAdministration.tsx
// frontend/src/pages/administration/teams/TeamsAdministration.tsx
import { useEffect, useState, type FormEvent } from 'react'
import { UserGroupIcon } from '@heroicons/react/20/solid'
import Button from '../../../components/Button'
import LoadingSpinner from '../../../components/LoadingSpinner'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
type AdminTeam = {
id: string
name: string
initial: string
href: string
}
const inputClassName =
'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
async function readApiError(response: Response, fallback: string) {
const data = await response.json().catch(() => null)
return data?.error ?? fallback
}
export default function TeamsAdministration() {
const [teams, setTeams] = useState<AdminTeam[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
void loadTeams()
}, [])
async function loadTeams() {
setIsLoading(true)
setError(null)
try {
const response = await fetch(`${API_URL}/admin/teams`, {
credentials: 'include',
})
if (!response.ok) {
throw new Error(
await readApiError(response, 'Teams konnten nicht geladen werden'),
)
}
const data = await response.json()
setTeams(data.teams ?? [])
} catch (error) {
setError(
error instanceof Error
? error.message
: 'Teams konnten nicht geladen werden',
)
} finally {
setIsLoading(false)
}
}
async function handleSaveTeam(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const form = event.currentTarget
const formData = new FormData(form)
const payload = {
name: String(formData.get('teamName') ?? ''),
initial: String(formData.get('teamInitial') ?? ''),
href: String(formData.get('teamHref') ?? '#'),
}
setIsSaving(true)
setError(null)
try {
const response = await fetch(`${API_URL}/admin/teams`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(payload),
})
if (!response.ok) {
throw new Error(
await readApiError(response, 'Team konnte nicht erstellt werden'),
)
}
await loadTeams()
form.reset()
} catch (error) {
setError(
error instanceof Error
? error.message
: 'Team konnte nicht erstellt werden',
)
} finally {
setIsSaving(false)
}
}
if (isLoading) {
return (
<div className="rounded-lg bg-white p-8 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
<LoadingSpinner
size="md"
label="Teams werden geladen..."
className="text-indigo-600 dark:text-indigo-400"
center
/>
</div>
)
}
return (
<>
{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>
)}
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1fr)_28rem]">
<div className="rounded-lg bg-white p-4 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
<h2 className="flex items-center gap-x-2 text-sm font-semibold text-gray-900 dark:text-white">
<UserGroupIcon aria-hidden="true" className="size-5 text-gray-400" />
Teams
</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Übersicht der aktuell vorhandenen Teams.
</p>
<div className="mt-4 divide-y divide-gray-200 rounded-lg border border-gray-200 dark:divide-white/10 dark:border-white/10">
{teams.length === 0 ? (
<div className="p-4 text-sm text-gray-500 dark:text-gray-400">
Keine Teams vorhanden.
</div>
) : (
teams.map((team) => (
<div
key={team.id}
className="flex items-center justify-between gap-x-4 p-4"
>
<div className="flex min-w-0 items-center gap-x-3">
<span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-indigo-600 text-sm font-semibold text-white">
{team.initial}
</span>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-gray-900 dark:text-white">
{team.name}
</p>
<p className="truncate text-xs text-gray-500 dark:text-gray-400">
{team.href}
</p>
</div>
</div>
</div>
))
)}
</div>
</div>
<form
onSubmit={handleSaveTeam}
className="rounded-lg bg-white p-4 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10"
>
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
Team anlegen
</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Erstelle ein neues Team, das anschließend Benutzern zugewiesen werden kann.
</p>
<div className="mt-4 space-y-4">
<div>
<label
htmlFor="team-name"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Teamname
</label>
<div className="mt-2">
<input
id="team-name"
name="teamName"
placeholder="Teamname"
required
className={inputClassName}
/>
</div>
</div>
<div>
<label
htmlFor="team-initial"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Initial
</label>
<div className="mt-2">
<input
id="team-initial"
name="teamInitial"
placeholder="Initial"
maxLength={3}
className={inputClassName}
/>
</div>
</div>
<div>
<label
htmlFor="team-href"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Link
</label>
<div className="mt-2">
<input
id="team-href"
name="teamHref"
placeholder="Link, z. B. #"
defaultValue="#"
className={inputClassName}
/>
</div>
</div>
</div>
<div className="mt-4 flex justify-end">
<Button
type="submit"
variant="secondary"
color="indigo"
isLoading={isSaving}
>
Team erstellen
</Button>
</div>
</form>
</div>
</>
)
}

View File

@ -0,0 +1,795 @@
// frontend\src\pages\administration\users\UsersAdministration.tsx
import { useEffect, useMemo, useState, type FormEvent } from 'react'
import {
MagnifyingGlassIcon,
UserPlusIcon,
UsersIcon,
} from '@heroicons/react/20/solid'
import Button from '../../../components/Button'
import Checkbox from '../../../components/Checkbox'
import LoadingSpinner from '../../../components/LoadingSpinner'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
type AdminTeam = {
id: string
name: string
initial: string
href: string
}
type AdminUser = {
id: string
username: string
displayName: string
email: string
avatar: string
unit: string
group: string
rights: string[]
teams: AdminTeam[]
}
const adminRightId = 'admin'
const rightGroups = [
{
id: 'devices',
label: 'Geräte',
rights: [
{ id: 'devices:read', label: 'anzeigen' },
{ id: 'devices:write', label: 'bearbeiten' },
],
},
{
id: 'operations',
label: 'Einsätze',
rights: [
{ id: 'operations:read', label: 'anzeigen' },
{ id: 'operations:write', label: 'bearbeiten' },
],
},
{
id: 'calendar',
label: 'Kalender',
rights: [{ id: 'calendar:read', label: 'anzeigen' }],
},
{
id: 'documents',
label: 'Dokumente',
rights: [{ id: 'documents:read', label: 'anzeigen' }],
},
{
id: 'reports',
label: 'Berichte',
rights: [{ id: 'reports:read', label: 'anzeigen' }],
},
{
id: 'settings',
label: 'Einstellungen',
rights: [{ id: 'settings:read', label: 'anzeigen' }],
},
{
id: 'administration',
label: 'Administration',
rights: [
{ id: 'administration:read', label: 'anzeigen' },
{ id: 'administration:write', label: 'bearbeiten' },
],
},
{
id: 'users',
label: 'Benutzer',
rights: [
{ id: 'users:read', label: 'anzeigen' },
{ id: 'users:write', label: 'bearbeiten' },
],
},
{
id: 'teams',
label: 'Teams',
rights: [
{ id: 'teams:read', label: 'anzeigen' },
{ id: 'teams:write', label: 'bearbeiten' },
],
},
]
const availableRights = [
{ id: adminRightId, label: 'Administrator' },
...rightGroups.flatMap((group) => group.rights),
]
const adminRight = availableRights.find((right) => right.id === adminRightId)
const inputClassName =
'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(' ')
}
function getAllRightIds() {
return availableRights.map((right) => right.id)
}
function getSelectedValues(formData: FormData, name: string) {
return formData
.getAll(name)
.map((value) => String(value))
.filter(Boolean)
}
function getUserDisplayName(user: AdminUser) {
return user.displayName || user.username || user.email
}
function getUserInitials(user: AdminUser) {
const displayName = getUserDisplayName(user).trim()
if (!displayName) {
return '?'
}
const parts = displayName
.split(/\s+/)
.map((part) => part.trim())
.filter(Boolean)
if (parts.length >= 2) {
return `${parts[0][0]}${parts[1][0]}`.toUpperCase()
}
return displayName.slice(0, 2).toUpperCase()
}
function getUserSearchText(user: AdminUser) {
return [
user.username,
user.displayName,
user.email,
user.unit,
user.group,
...user.teams.map((team) => team.name),
]
.join(' ')
.toLowerCase()
}
async function readApiError(response: Response, fallback: string) {
const data = await response.json().catch(() => null)
return data?.error ?? fallback
}
export default function UsersAdministration() {
const [users, setUsers] = useState<AdminUser[]>([])
const [teams, setTeams] = useState<AdminTeam[]>([])
const [selectedUserId, setSelectedUserId] = useState<string>('new')
const [userSearch, setUserSearch] = useState('')
const [selectedRights, setSelectedRights] = useState<string[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const selectedUser = useMemo(
() => users.find((user) => user.id === selectedUserId) ?? null,
[selectedUserId, users],
)
const filteredUsers = useMemo(() => {
const search = userSearch.trim().toLowerCase()
if (!search) {
return users
}
return users.filter((user) => getUserSearchText(user).includes(search))
}, [userSearch, users])
const isAdministratorSelected = selectedRights.includes(adminRightId)
useEffect(() => {
void loadAdministration()
}, [])
useEffect(() => {
setSelectedRights(selectedUser?.rights ?? [])
}, [selectedUser])
async function loadAdministration() {
setIsLoading(true)
setError(null)
try {
const [usersResponse, teamsResponse] = await Promise.all([
fetch(`${API_URL}/admin/users`, { credentials: 'include' }),
fetch(`${API_URL}/admin/teams`, { credentials: 'include' }),
])
if (!usersResponse.ok) {
throw new Error(
await readApiError(usersResponse, 'Benutzer konnten nicht geladen werden'),
)
}
if (!teamsResponse.ok) {
throw new Error(
await readApiError(teamsResponse, 'Teams konnten nicht geladen werden'),
)
}
const usersData = await usersResponse.json()
const teamsData = await teamsResponse.json()
setUsers(usersData.users ?? [])
setTeams(teamsData.teams ?? [])
} catch (error) {
setError(
error instanceof Error
? error.message
: 'Administration konnte nicht geladen werden',
)
} finally {
setIsLoading(false)
}
}
function handleRightChange(rightId: string, checked: boolean) {
if (rightId === adminRightId) {
setSelectedRights(checked ? getAllRightIds() : [])
return
}
setSelectedRights((currentRights) => {
if (checked) {
return [...new Set([...currentRights, rightId])]
}
return currentRights.filter((currentRight) => currentRight !== rightId)
})
}
function handleSelectNewUser() {
setSelectedUserId('new')
setSelectedRights([])
}
async function handleSaveUser(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const form = event.currentTarget
const formData = new FormData(form)
const isCreate = selectedUserId === 'new'
const payload = {
username: String(formData.get('username') ?? ''),
displayName: String(formData.get('displayName') ?? ''),
email: String(formData.get('email') ?? ''),
password: String(formData.get('password') ?? ''),
avatar: String(formData.get('avatar') ?? ''),
unit: String(formData.get('unit') ?? ''),
group: String(formData.get('group') ?? ''),
rights: isAdministratorSelected ? getAllRightIds() : selectedRights,
teamIds: getSelectedValues(formData, 'teamIds'),
}
setIsSaving(true)
setError(null)
try {
const response = await fetch(
isCreate
? `${API_URL}/admin/users`
: `${API_URL}/admin/users/${selectedUserId}`,
{
method: isCreate ? 'POST' : 'PUT',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(payload),
},
)
if (!response.ok) {
throw new Error(
await readApiError(response, 'Benutzer konnte nicht gespeichert werden'),
)
}
await loadAdministration()
form.reset()
setSelectedUserId('new')
setSelectedRights([])
} catch (error) {
setError(
error instanceof Error
? error.message
: 'Benutzer konnte nicht gespeichert werden',
)
} finally {
setIsSaving(false)
}
}
if (isLoading) {
return (
<div className="rounded-lg bg-white p-8 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
<LoadingSpinner
size="md"
label="Benutzer werden geladen..."
className="text-indigo-600 dark:text-indigo-400"
center
/>
</div>
)
}
return (
<>
{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>
)}
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[24rem_minmax(0,1fr)]">
<div className="overflow-hidden rounded-lg bg-white shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
<div className="border-b border-gray-200 px-4 py-4 dark:border-white/10">
<div className="flex items-center justify-between gap-x-3">
<div className="min-w-0">
<h2 className="flex items-center gap-x-2 text-sm font-semibold text-gray-900 dark:text-white">
<UsersIcon aria-hidden="true" className="size-5 text-gray-400" />
Benutzer
</h2>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{filteredUsers.length} von {users.length} angezeigt
</p>
</div>
<Button
type="button"
color="indigo"
size="sm"
onClick={handleSelectNewUser}
leadingIcon={<UserPlusIcon aria-hidden="true" className="size-4" />}
>
Neuer Benutzer
</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={userSearch}
onChange={(event) => setUserSearch(event.target.value)}
placeholder="Benutzer suchen..."
className="block w-full rounded-md bg-white py-1.5 pr-3 pl-9 text-sm text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500"
/>
</div>
</div>
<div className="max-h-[calc(100dvh-20rem)] overflow-y-auto p-2">
{selectedUserId === 'new' && (
<div className="mb-2 rounded-md bg-indigo-50 px-3 py-2 text-sm font-medium text-indigo-700 ring-1 ring-indigo-200 ring-inset dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-500/20">
Neuer Benutzer wird angelegt
</div>
)}
{filteredUsers.length === 0 ? (
<div className="px-3 py-8 text-center text-sm text-gray-500 dark:text-gray-400">
Keine Benutzer gefunden.
</div>
) : (
<div className="space-y-1">
{filteredUsers.map((user) => {
const isSelected = selectedUserId === user.id
const displayName = getUserDisplayName(user)
return (
<button
key={user.id}
type="button"
onClick={() => setSelectedUserId(user.id)}
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',
)}
>
{getUserInitials(user)}
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-x-2">
<span className="truncate font-medium">
{displayName}
</span>
{user.rights.includes('admin') && (
<span className="shrink-0 rounded-md bg-indigo-50 px-1.5 py-0.5 text-[10px] 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">
Admin
</span>
)}
</span>
<span className="block truncate text-xs text-gray-500 dark:text-gray-400">
{user.email}
</span>
<span className="mt-1 flex flex-wrap gap-1">
{user.unit && (
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-600 dark:bg-white/10 dark:text-gray-300">
{user.unit}
</span>
)}
{user.group && (
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-600 dark:bg-white/10 dark:text-gray-300">
{user.group}
</span>
)}
{user.teams.slice(0, 2).map((team) => (
<span
key={team.id}
className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-600 dark:bg-white/10 dark:text-gray-300"
>
{team.name}
</span>
))}
{user.teams.length > 2 && (
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-600 dark:bg-white/10 dark:text-gray-300">
+{user.teams.length - 2}
</span>
)}
</span>
</span>
</button>
)
})}
</div>
)}
</div>
</div>
<form
key={selectedUser?.id ?? 'new'}
onSubmit={handleSaveUser}
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">
{selectedUser ? 'Benutzer bearbeiten' : 'Neuen Benutzer anlegen'}
</h2>
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label
htmlFor="admin-user-username"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Benutzername
</label>
<div className="mt-2">
<input
id="admin-user-username"
name="username"
defaultValue={selectedUser?.username ?? ''}
className={inputClassName}
/>
</div>
</div>
<div>
<label
htmlFor="admin-user-display-name"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Anzeigename
</label>
<div className="mt-2">
<input
id="admin-user-display-name"
name="displayName"
defaultValue={selectedUser?.displayName ?? ''}
className={inputClassName}
/>
</div>
</div>
<div>
<label
htmlFor="admin-user-email"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
E-Mail *
</label>
<div className="mt-2">
<input
id="admin-user-email"
name="email"
type="email"
required
defaultValue={selectedUser?.email ?? ''}
className={inputClassName}
/>
</div>
</div>
<div>
<label
htmlFor="admin-user-password"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Passwort {selectedUser ? '(leer lassen = unverändert)' : '*'}
</label>
<div className="mt-2">
<input
id="admin-user-password"
name="password"
type="password"
required={!selectedUser}
autoComplete="new-password"
className={inputClassName}
/>
</div>
</div>
<div>
<label
htmlFor="admin-user-avatar"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Avatar-URL
</label>
<div className="mt-2">
<input
id="admin-user-avatar"
name="avatar"
type="url"
defaultValue={selectedUser?.avatar ?? ''}
placeholder="https://..."
className={inputClassName}
/>
</div>
</div>
<div>
<label
htmlFor="admin-user-unit"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Einheit
</label>
<div className="mt-2">
<input
id="admin-user-unit"
name="unit"
defaultValue={selectedUser?.unit ?? ''}
className={inputClassName}
/>
</div>
</div>
<div>
<label
htmlFor="admin-user-group"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Gruppe
</label>
<div className="mt-2">
<input
id="admin-user-group"
name="group"
defaultValue={selectedUser?.group ?? 'user'}
className={inputClassName}
/>
</div>
</div>
</div>
<div className="mt-6 grid grid-cols-1 gap-6 lg:grid-cols-2">
<div>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
Teams
</h3>
<div className="mt-3 space-y-2">
{teams.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-400">
Keine Teams vorhanden.
</p>
) : (
teams.map((team) => {
const checked = selectedUser?.teams.some(
(userTeam) => userTeam.id === team.id,
)
return (
<Checkbox
key={team.id}
name="teamIds"
value={team.id}
defaultChecked={checked}
label={team.name}
/>
)
})
)}
</div>
</div>
<div>
<div className="flex items-start justify-between gap-x-4">
<div>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
Rechte
</h3>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Lege fest, welche Bereiche sichtbar sind und wo Änderungen erlaubt sind.
</p>
</div>
</div>
<div className="mt-3 overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-white/10 dark:bg-white/5">
{adminRight && (
<div className="border-b border-gray-200 bg-indigo-50 px-4 py-3 dark:border-white/10 dark:bg-indigo-500/10">
<Checkbox
name="rights"
value={adminRight.id}
checked={isAdministratorSelected}
onChange={(event) =>
handleRightChange(adminRight.id, event.target.checked)
}
label="Administrator"
description="Besitzt automatisch alle Rechte."
wrapperClassName="items-start"
labelClassName="block text-sm font-semibold text-indigo-700 dark:text-indigo-300"
descriptionClassName="mt-0.5 text-xs text-indigo-700/80 dark:text-indigo-300/80"
/>
</div>
)}
<div className="grid grid-cols-[minmax(8rem,1fr)_7rem_7rem] border-b border-gray-200 bg-gray-50 px-4 py-2 text-xs font-medium text-gray-500 dark:border-white/10 dark:bg-white/5 dark:text-gray-400">
<div>Bereich</div>
<div className="text-center">Anzeigen</div>
<div className="text-center">Bearbeiten</div>
</div>
<div className="divide-y divide-gray-200 dark:divide-white/10">
{rightGroups.map((group) => {
const readRight = group.rights.find((right) =>
right.id.endsWith(':read'),
)
const writeRight = group.rights.find((right) =>
right.id.endsWith(':write'),
)
function renderRightControl(
right: (typeof group.rights)[number] | undefined,
) {
if (!right) {
return (
<span className="inline-flex h-5 items-center justify-center text-xs text-gray-300 dark:text-gray-600">
</span>
)
}
const isChecked =
isAdministratorSelected ||
selectedRights.includes(right.id)
const isDisabled = isAdministratorSelected
return (
<Checkbox
name="rights"
value={right.id}
checked={isChecked}
disabled={isDisabled}
onChange={(event) =>
handleRightChange(right.id, event.target.checked)
}
aria-label={`${group.label} ${right.label}`}
title={
isAdministratorSelected
? 'Durch Administrator-Recht automatisch aktiv'
: right.label
}
wrapperClassName={classNames(
'justify-center',
isDisabled && 'cursor-not-allowed',
)}
/>
)
}
const groupHasAnyRight = group.rights.some((right) =>
selectedRights.includes(right.id),
)
return (
<div
key={group.id}
className={classNames(
'grid grid-cols-[minmax(8rem,1fr)_7rem_7rem] items-center px-4 py-3',
groupHasAnyRight || isAdministratorSelected
? 'bg-white dark:bg-transparent'
: 'bg-gray-50/60 dark:bg-white/[0.02]',
)}
>
<div className="min-w-0">
<div className="flex items-center gap-x-2">
<span className="truncate text-sm font-medium text-gray-900 dark:text-white">
{group.label}
</span>
{(groupHasAnyRight || isAdministratorSelected) && (
<span className="inline-flex size-1.5 rounded-full bg-indigo-600 dark:bg-indigo-400" />
)}
</div>
</div>
<div className="flex justify-center">
{renderRightControl(readRight)}
</div>
<div className="flex justify-center">
{renderRightControl(writeRight)}
</div>
</div>
)
})}
</div>
</div>
{isAdministratorSelected && (
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
Einzelrechte sind deaktiviert, weil Administrator bereits alle Berechtigungen umfasst.
</p>
)}
</div>
</div>
<div className="mt-6 flex justify-end">
<Button
type="submit"
color="indigo"
isLoading={isSaving}
>
Benutzer speichern
</Button>
</div>
</form>
</div>
</>
)
}

View File

@ -55,7 +55,16 @@ export default function CreateDeviceModal({
const target = event.target as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
if (
['inventoryNumber', 'manufacturer', 'model', 'serialNumber', 'macAddress', 'loanStatus'].includes(
[
'inventoryNumber',
'manufacturer',
'model',
'serialNumber',
'macAddress',
'ipAddress',
'firmwareVersion',
'loanStatus',
].includes(
target.name,
)
) {

View File

@ -5,6 +5,7 @@ import Combobox, { type ComboboxItem } from '../../components/Combobox'
import Checkbox from '../../components/Checkbox'
import Switch from '../../components/Switch'
import type { DeviceModalTabId } from './DeviceModalTabs'
import Textarea from '../../components/Textarea'
import type { Device } from '../../components/types'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
@ -104,6 +105,8 @@ function getDeviceAccessorySearchText(device: Device) {
device.model,
device.serialNumber,
device.macAddress,
device.ipAddress,
device.firmwareVersion,
device.location,
device.loanStatus,
device.comment,
@ -113,6 +116,48 @@ function getDeviceAccessorySearchText(device: Device) {
.toLowerCase()
}
type DeviceLookupData = {
manufacturers: ComboboxItem[]
locations: ComboboxItem[]
}
let deviceLookupCache: DeviceLookupData | null = null
let deviceLookupPromise: Promise<DeviceLookupData> | null = null
async function fetchDeviceLookups() {
if (deviceLookupCache) {
return deviceLookupCache
}
if (deviceLookupPromise) {
return deviceLookupPromise
}
deviceLookupPromise = fetch(`${API_URL}/device-lookups`, {
credentials: 'include',
})
.then(async (response) => {
if (!response.ok) {
const errorData = await response.json().catch(() => null)
throw new Error(errorData?.error ?? 'Auswahllisten konnten nicht geladen werden')
}
const data = await response.json()
deviceLookupCache = {
manufacturers: data.manufacturers ?? [],
locations: data.locations ?? [],
}
return deviceLookupCache
})
.finally(() => {
deviceLookupPromise = null
})
return deviceLookupPromise
}
export default function DeviceFormFields({
open,
activeTab,
@ -184,23 +229,21 @@ export default function DeviceFormFields({
relatedDeviceIds.includes(assignableDevice.id),
).length
const isMilestoneDevice = Boolean(
device?.milestoneHardwareId || device?.milestoneRecordingServerId,
)
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'
async function loadDeviceLookups() {
setLookupError(null)
try {
const response = await fetch(`${API_URL}/device-lookups`, {
credentials: 'include',
})
const data = await fetchDeviceLookups()
if (!response.ok) {
const errorData = await response.json().catch(() => null)
throw new Error(errorData?.error ?? 'Auswahllisten konnten nicht geladen werden')
}
const data = await response.json()
setManufacturerOptions(data.manufacturers ?? [])
setLocationOptions(data.locations ?? [])
setManufacturerOptions(data.manufacturers)
setLocationOptions(data.locations)
} catch (error) {
setLookupError(
error instanceof Error
@ -335,6 +378,27 @@ export default function DeviceFormFields({
</div>
</div>
{isMilestoneDevice && device?.milestoneDisplayName && (
<div className="sm:col-span-6">
<label
htmlFor="milestoneDisplayName"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Anzeigename
</label>
<div className="mt-2">
<input
id="milestoneDisplayName"
name="milestoneDisplayName"
type="text"
defaultValue={device.milestoneDisplayName}
className={inputClassName}
/>
</div>
</div>
)}
<div className="sm:col-span-3">
<input
type="hidden"
@ -408,13 +472,67 @@ export default function DeviceFormFields({
defaultValue={formatMacAddress(device?.macAddress ?? '')}
maxLength={17}
pattern="(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"
title="Bitte gib eine gültige MAC-Adresse im Format 00:11:22:33:44:55 ein."
onInput={handleMacAddressInput}
onInvalid={handleMacAddressInvalid}
title={
isMilestoneDevice
? 'MAC-Adresse wird aus Milestone synchronisiert.'
: 'Bitte gib eine gültige MAC-Adresse im Format 00:11:22:33:44:55 ein.'
}
readOnly={isMilestoneDevice}
aria-readonly={isMilestoneDevice}
onInput={isMilestoneDevice ? undefined : handleMacAddressInput}
onInvalid={isMilestoneDevice ? undefined : handleMacAddressInvalid}
className={classNames(
inputClassName,
isMilestoneDevice && milestoneReadonlyClassName,
)}
/>
</div>
</div>
<div className="sm:col-span-3">
<label
htmlFor="ipAddress"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
IP-Adresse
</label>
<div className="mt-2">
<input
id="ipAddress"
name="ipAddress"
type="text"
placeholder="192.168.1.100"
defaultValue={device?.ipAddress ?? ''}
className={inputClassName}
/>
</div>
</div>
<div className="sm:col-span-3">
<label
htmlFor="firmwareVersion"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Firmware
</label>
<div className="mt-2">
<input
id="firmwareVersion"
name="firmwareVersion"
type="text"
defaultValue={device?.firmwareVersion ?? ''}
readOnly={isMilestoneDevice}
aria-readonly={isMilestoneDevice}
title={isMilestoneDevice ? 'Firmware wird aus Milestone synchronisiert.' : undefined}
className={classNames(
inputClassName,
isMilestoneDevice && milestoneReadonlyClassName,
)}
/>
</div>
</div>
</div>
</section>
@ -459,22 +577,14 @@ export default function DeviceFormFields({
</div>
<div className="sm:col-span-6">
<label
htmlFor="comment"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Kommentar
</label>
<div className="mt-2">
<textarea
id="comment"
name="comment"
rows={3}
defaultValue={device?.comment ?? ''}
className={inputClassName}
/>
</div>
<Textarea
id="comment"
name="comment"
label="Kommentar"
variant="simple"
rows={3}
defaultValue={device?.comment ?? ''}
/>
</div>
</div>
</section>

View File

@ -1,17 +1,87 @@
// frontend/src/pages/DevicesPage.tsx
import { useEffect, useRef, useState, type FormEvent } from 'react'
import { CheckCircleIcon, PencilSquareIcon, XCircleIcon } from '@heroicons/react/20/solid'
import { useEffect, useRef, useState, type ComponentProps, type FormEvent, type MouseEvent } from 'react'
import {
ArrowDownTrayIcon,
ArrowPathIcon,
CheckCircleIcon,
ClockIcon,
ExclamationTriangleIcon,
WrenchScrewdriverIcon,
XCircleIcon,
} from '@heroicons/react/20/solid'
import Button from '../../components/Button'
import { Badge } from '../../components/Badge'
import CreateDeviceModal from './CreateDeviceModal'
import EditDeviceModal from './EditDeviceModal'
import Tables, { type TableColumn } from '../../components/Tables'
import LoadingSpinner from '../../components/LoadingSpinner'
import Button from '../../components/Button'
import type { Device, DeviceHistoryEntry } from '../../components/types'
import Notifications, { type ToastNotification } from '../../components/Notifications'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
function getLoanStatusClassName(status: string) {
type FirmwareCheckState = {
canCheck: boolean
lastCheckedAt?: string | null
lastManualCheckedAt?: string | null
nextAllowedAt?: string | null
remainingSeconds?: number
}
function getLoanStatusBadgeIcon(status: string) {
const normalizedStatus = status.toLowerCase()
if (
normalizedStatus.includes('defekt') ||
normalizedStatus.includes('verloren') ||
normalizedStatus.includes('gesperrt') ||
normalizedStatus.includes('deaktiviert')
) {
return <XCircleIcon />
}
if (
normalizedStatus.includes('ausgeliehen') ||
normalizedStatus.includes('verliehen') ||
normalizedStatus.includes('reserviert') ||
normalizedStatus.includes('wartung') ||
normalizedStatus.includes('prüfung')
) {
return <ClockIcon />
}
return <CheckCircleIcon />
}
function formatFirmwareCheckedAt(value?: string | null) {
if (!value) {
return 'Noch nicht geprüft'
}
return new Intl.DateTimeFormat('de-DE', {
dateStyle: 'short',
timeStyle: 'short',
}).format(new Date(value))
}
function getLatestFirmwareCheckedAt(devices: Device[]) {
const timestamps = devices
.map((device) => device.firmwareUpdate?.checkedAt)
.filter(Boolean)
.map((value) => new Date(value as string).getTime())
.filter((value) => Number.isFinite(value))
if (timestamps.length === 0) {
return null
}
return new Date(Math.max(...timestamps)).toISOString()
}
type BadgeTone = NonNullable<ComponentProps<typeof Badge>['tone']>
function getLoanStatusBadgeTone(status: string): BadgeTone {
const normalizedStatus = status.toLowerCase()
if (
@ -19,45 +89,65 @@ function getLoanStatusClassName(status: string) {
normalizedStatus.includes('verliehen') ||
normalizedStatus.includes('reserviert')
) {
return 'bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-900/30 dark:text-amber-300 dark:ring-amber-500/30'
return 'yellow'
}
if (
normalizedStatus.includes('defekt') ||
normalizedStatus.includes('verloren') ||
normalizedStatus.includes('gesperrt')
normalizedStatus.includes('gesperrt') ||
normalizedStatus.includes('deaktiviert')
) {
return 'bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-900/30 dark:text-red-400 dark:ring-red-500/30'
return 'red'
}
if (
normalizedStatus.includes('wartung') ||
normalizedStatus.includes('prüfung')
) {
return 'bg-blue-50 text-blue-700 ring-blue-600/20 dark:bg-blue-900/30 dark:text-blue-300 dark:ring-blue-500/30'
return 'blue'
}
return 'bg-green-50 text-green-700 ring-green-600/20 dark:bg-green-900/30 dark:text-green-400 dark:ring-green-500/30'
return 'green'
}
function getSupportBadgeTone(severity?: string): BadgeTone {
if (severity === 'danger') {
return 'red'
}
if (severity === 'warning') {
return 'yellow'
}
return 'blue'
}
export default function DevicesPage() {
const [devices, setDevices] = useState<Device[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
const [activeModal, setActiveModal] = useState<'create' | 'edit' | null>(null)
const [editingDevice, setEditingDevice] = useState<Device | null>(null)
const [isCreating, setIsCreating] = useState(false)
const [error, setError] = useState<string | null>(null)
const [createError, setCreateError] = useState<string | null>(null)
const [relatedDeviceIds, setRelatedDeviceIds] = useState<string[]>([])
const [editingDevice, setEditingDevice] = useState<Device | null>(null)
const [isJournalSubmitting, setIsJournalSubmitting] = useState(false)
const [notifications, setNotifications] = useState<ToastNotification[]>([])
const [togglingMilestoneDeviceIds, setTogglingMilestoneDeviceIds] = useState<Set<string>>(
() => new Set(),
)
const [deviceHistory, setDeviceHistory] = useState<DeviceHistoryEntry[]>([])
const [isHistoryLoading, setIsHistoryLoading] = useState(false)
const [historyError, setHistoryError] = useState<string | null>(null)
const [isCheckingFirmware, setIsCheckingFirmware] = useState(false)
const [firmwareCheckState, setFirmwareCheckState] = useState<FirmwareCheckState | null>(null)
const [firmwareCheckNow, setFirmwareCheckNow] = useState(() => Date.now())
const closeCleanupTimeoutRef = useRef<number | null>(null)
useEffect(() => {
loadDevices()
void loadFirmwareCheckState()
return () => {
if (closeCleanupTimeoutRef.current) {
@ -66,9 +156,19 @@ export default function DevicesPage() {
}
}, [])
useEffect(() => {
const intervalId = window.setInterval(() => {
setFirmwareCheckNow(Date.now())
}, 30_000)
return () => {
window.clearInterval(intervalId)
}
}, [])
useEffect(() => {
function handleDeviceNotification(event: Event) {
void loadDevices()
void loadDevices({ silent: true })
const notification = (event as CustomEvent).detail as {
entityId?: string
@ -89,6 +189,90 @@ export default function DevicesPage() {
}
}, [editingDevice])
async function loadFirmwareCheckState() {
try {
const response = await fetch(`${API_URL}/devices/firmware-check`, {
credentials: 'include',
})
if (!response.ok) {
return
}
const data = await response.json()
setFirmwareCheckState({
canCheck: Boolean(data.canCheck),
lastCheckedAt: data.lastCheckedAt ?? null,
lastManualCheckedAt: data.lastManualCheckedAt ?? null,
nextAllowedAt: data.nextAllowedAt ?? null,
remainingSeconds: data.remainingSeconds ?? 0,
})
} catch {
// Status ist optional für die UI.
}
}
async function handleCheckFirmware() {
setIsCheckingFirmware(true)
setError(null)
try {
const response = await fetch(`${API_URL}/devices/firmware-check`, {
method: 'POST',
credentials: 'include',
})
const data = await response.json().catch(() => null)
if (!response.ok) {
if (response.status === 429) {
setFirmwareCheckState((currentState) => ({
canCheck: false,
lastCheckedAt: currentState?.lastCheckedAt ?? null,
lastManualCheckedAt: currentState?.lastManualCheckedAt ?? null,
nextAllowedAt: data?.nextAllowedAt ?? null,
remainingSeconds: data?.remainingSeconds ?? 0,
}))
}
throw new Error(data?.error ?? 'Firmware konnte nicht geprüft werden')
}
setFirmwareCheckState({
canCheck: false,
lastCheckedAt: data.checkedAt ?? null,
lastManualCheckedAt: data.checkedAt ?? null,
nextAllowedAt: data.nextAllowedAt ?? null,
remainingSeconds: data.remainingSeconds ?? 3600,
})
await loadDevices({ silent: true })
await loadFirmwareCheckState()
showNotification({
variant: data.failedModels > 0 ? 'warning' : 'success',
title: 'Firmware geprüft',
message:
data.failedModels > 0
? `${data.checkedModels} Modelle geprüft, ${data.failedModels} fehlgeschlagen.`
: `${data.checkedModels} Modelle wurden geprüft.`,
})
} catch (error) {
showNotification({
variant: 'error',
title: 'Firmware-Prüfung fehlgeschlagen',
message:
error instanceof Error
? error.message
: 'Firmware konnte nicht geprüft werden',
autoClose: false,
})
} finally {
setIsCheckingFirmware(false)
}
}
async function handleCreateDeviceJournalEntry(content: string) {
if (!editingDevice) {
return
@ -124,8 +308,11 @@ export default function DevicesPage() {
}
}
async function loadDevices() {
setIsLoading(true)
async function loadDevices(options?: { silent?: boolean }) {
if (!options?.silent) {
setIsLoading(true)
}
setError(null)
try {
@ -143,7 +330,9 @@ export default function DevicesPage() {
} catch (error) {
setError(error instanceof Error ? error.message : 'Geräte konnten nicht geladen werden')
} finally {
setIsLoading(false)
if (!options?.silent) {
setIsLoading(false)
}
}
}
@ -186,15 +375,15 @@ export default function DevicesPage() {
setDeviceHistory([])
setHistoryError(null)
setIsHistoryLoading(false)
setIsCreateModalOpen(true)
setActiveModal('create')
}
function closeCreateModal() {
function closeModal() {
if (isCreating) {
return
}
setIsCreateModalOpen(false)
setActiveModal(null)
if (closeCleanupTimeoutRef.current) {
window.clearTimeout(closeCleanupTimeoutRef.current)
@ -212,12 +401,17 @@ export default function DevicesPage() {
}
function openEditModal(device: Device) {
if (closeCleanupTimeoutRef.current) {
window.clearTimeout(closeCleanupTimeoutRef.current)
closeCleanupTimeoutRef.current = null
}
setCreateError(null)
setHistoryError(null)
setDeviceHistory([])
setEditingDevice(device)
setRelatedDeviceIds(device.relatedDevices?.map((relatedDevice) => relatedDevice.id) ?? [])
setIsCreateModalOpen(true)
setActiveModal('edit')
void loadDeviceHistory(device.id)
}
@ -232,6 +426,108 @@ export default function DevicesPage() {
})
}
function dismissNotification(id: string) {
setNotifications((currentNotifications) =>
currentNotifications.filter((notification) => notification.id !== id),
)
}
function showNotification(notification: Omit<ToastNotification, 'id'>) {
setNotifications((currentNotifications) => [
{
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
...notification,
},
...currentNotifications,
])
}
async function readApiError(response: Response, fallback: string) {
const data = await response.json().catch(() => null)
return data?.error ?? fallback
}
async function handleToggleMilestoneDeviceStatus(
device: Device,
event: MouseEvent<HTMLButtonElement>,
) {
event.stopPropagation()
if (!device.milestoneHardwareId) {
return
}
setTogglingMilestoneDeviceIds((currentIds) => {
const nextIds = new Set(currentIds)
nextIds.add(device.id)
return nextIds
})
try {
const response = await fetch(`${API_URL}/devices/${device.id}/milestone-toggle`, {
method: 'POST',
credentials: 'include',
})
if (!response.ok) {
throw new Error(
await readApiError(
response,
'Milestone-Status konnte nicht geändert werden',
),
)
}
const data = await response.json()
const milestoneEnabled = Boolean(data.milestoneEnabled)
setDevices((currentDevices) =>
currentDevices.map((currentDevice) =>
currentDevice.id === device.id
? {
...currentDevice,
milestoneEnabled,
}
: currentDevice,
),
)
setEditingDevice((currentDevice) =>
currentDevice?.id === device.id
? {
...currentDevice,
milestoneEnabled,
}
: currentDevice,
)
showNotification({
variant: 'success',
title: 'Milestone-Status geändert',
message: `Gerät ${device.inventoryNumber} wurde in Milestone ${
milestoneEnabled ? 'aktiviert' : 'deaktiviert'
}.`,
})
} catch (error) {
showNotification({
variant: 'error',
title: 'Status konnte nicht geändert werden',
message:
error instanceof Error
? error.message
: 'Milestone-Status konnte nicht geändert werden',
autoClose: false,
})
} finally {
setTogglingMilestoneDeviceIds((currentIds) => {
const nextIds = new Set(currentIds)
nextIds.delete(device.id)
return nextIds
})
}
}
async function handleSaveDevice(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
@ -248,6 +544,9 @@ export default function DevicesPage() {
model: String(formData.get('model') ?? ''),
serialNumber: String(formData.get('serialNumber') ?? ''),
macAddress: String(formData.get('macAddress') ?? ''),
ipAddress: String(formData.get('ipAddress') ?? ''),
firmwareVersion: String(formData.get('firmwareVersion') ?? ''),
milestoneDisplayName: String(formData.get('milestoneDisplayName') ?? ''),
location: String(formData.get('location') ?? ''),
loanStatus: String(formData.get('loanStatus') ?? 'Verfügbar'),
comment: String(formData.get('comment') ?? ''),
@ -282,7 +581,7 @@ export default function DevicesPage() {
}
form.reset()
closeCreateModal()
closeModal()
await loadDevices()
} catch (error) {
@ -298,57 +597,186 @@ export default function DevicesPage() {
}
}
const latestFirmwareCheckedAt =
firmwareCheckState?.lastCheckedAt ?? getLatestFirmwareCheckedAt(devices)
const firmwareCheckNextAllowedAt = firmwareCheckState?.nextAllowedAt
const isFirmwareCheckLocked =
Boolean(firmwareCheckNextAllowedAt) &&
new Date(firmwareCheckNextAllowedAt as string).getTime() > firmwareCheckNow
const firmwareCheckButtonDisabled = isCheckingFirmware || isFirmwareCheckLocked
const columns: TableColumn<Device>[] = [
{
key: 'inventoryNumber',
header: 'Inventur-Nr.',
isPrimary: true,
render: (device) => device.inventoryNumber,
},
{
key: 'manufacturer',
header: 'Hersteller',
render: (device) => device.manufacturer || '-',
render: (device) => (
<div className="min-w-0">
<div className="font-medium text-gray-900 dark:text-white">
{device.inventoryNumber}
</div>
{(device.serialNumber || device.macAddress) && (
<div className="mt-1 space-y-0.5 text-xs text-gray-500 dark:text-gray-400">
{device.serialNumber && (
<div className="truncate">
SN: {device.serialNumber}
</div>
)}
{device.macAddress && (
<div className="truncate">
MAC: {device.macAddress}
</div>
)}
</div>
)}
</div>
),
},
{
key: 'model',
header: 'Modell',
render: (device) => device.model || '-',
header: 'Gerät',
render: (device) => {
const title = [device.manufacturer, device.model]
.filter(Boolean)
.join(' ')
return (
<div className="min-w-0">
{device.milestoneDisplayName && (
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
{device.milestoneDisplayName}
</div>
)}
<div
className={[
'flex flex-wrap items-center gap-1 text-sm',
device.milestoneDisplayName
? 'mt-0.5 text-gray-500 dark:text-gray-400'
: 'text-gray-900 dark:text-white',
].join(' ')}
>
<span className="truncate">
{title || '-'}
</span>
{device.supportBadges?.map((badge) => (
<a
key={badge.type}
href={badge.supportUrl}
target="_blank"
rel="noreferrer"
onClick={(event) => event.stopPropagation()}
title={badge.message}
className="inline-flex"
>
<Badge
tone={getSupportBadgeTone(badge.severity)}
variant="border"
size="small"
className="text-[11px]"
leadingIcon={<WrenchScrewdriverIcon />}
>
{badge.label}
</Badge>
</a>
))}
</div>
{device.firmwareVersion && (
<div className="mt-1 flex flex-wrap items-center gap-1 text-xs text-gray-500 dark:text-gray-400">
<span className="truncate">
Firmware: {device.firmwareVersion}
</span>
{device.firmwareUpdate?.available && (
<a
href={device.firmwareUpdate.supportUrl}
target="_blank"
rel="noreferrer"
onClick={(event) => event.stopPropagation()}
className="inline-flex"
title={`Neue Firmware verfügbar: ${device.firmwareUpdate.latestVersion}`}
>
<Badge
tone="yellow"
variant="border"
size="small"
className="text-[11px]"
leadingIcon={<ArrowDownTrayIcon />}
>
{device.firmwareUpdate.latestVersion}
</Badge>
</a>
)}
</div>
)}
</div>
)
},
},
{
key: 'serialNumber',
header: 'Seriennummer',
hideOnMobile: 'lg',
render: (device) => device.serialNumber || '-',
},
{
key: 'macAddress',
header: 'MAC-Adresse',
hideOnMobile: 'lg',
render: (device) => device.macAddress || '-',
key: 'ipAddress',
header: 'IP-Adresse',
hideOnMobile: 'md',
render: (device) => device.ipAddress || '-',
},
{
key: 'location',
header: 'Standort',
hideOnMobile: 'lg',
render: (device) => device.location || '-',
},
{
key: 'loanStatus',
header: 'Verleih-Status',
header: 'Verleih',
render: (device) => (
<span
className={[
'inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset',
getLoanStatusClassName(device.loanStatus),
].join(' ')}
<Badge
tone={getLoanStatusBadgeTone(device.loanStatus)}
variant="border"
leadingIcon={getLoanStatusBadgeIcon(device.loanStatus)}
>
{device.loanStatus || 'Verfügbar'}
</span>
</Badge>
),
},
{
key: 'milestoneEnabled',
header: 'Milestone',
hideOnMobile: 'lg',
render: (device) => {
if (!device.milestoneHardwareId) {
return '-'
}
const isToggling = togglingMilestoneDeviceIds.has(device.id)
const label = device.milestoneEnabled ? 'Aktiviert' : 'Deaktiviert'
return (
<button
type="button"
disabled={isToggling}
onClick={(event) => handleToggleMilestoneDeviceStatus(device, event)}
className="inline-flex cursor-pointer rounded-md transition disabled:cursor-wait disabled:opacity-60"
title={`Milestone-Gerät ${
device.milestoneEnabled ? 'deaktivieren' : 'aktivieren'
}`}
>
<Badge tone={getLoanStatusBadgeTone(label)} variant="border">
{isToggling ? 'Wird geändert...' : label}
</Badge>
</button>
)
},
},
{
key: 'isBaoDevice',
header: 'BAO',
hideOnMobile: 'lg',
render: (device) =>
device.isBaoDevice ? (
<CheckCircleIcon
@ -362,43 +790,16 @@ export default function DevicesPage() {
/>
),
},
{
key: 'relatedDevices',
header: 'Zubehör',
hideOnMobile: 'lg',
render: (device) => {
if (!device.relatedDevices?.length) {
return '-'
}
return (
<div className="flex max-w-xs flex-wrap gap-1">
{device.relatedDevices.map((relatedDevice) => (
<span
key={relatedDevice.id}
className="inline-flex items-center rounded-md bg-gray-50 px-2 py-1 text-xs font-medium text-gray-600 ring-1 ring-gray-500/10 ring-inset dark:bg-white/5 dark:text-gray-300 dark:ring-white/10"
>
{relatedDevice.inventoryNumber}
</span>
))}
</div>
)
},
},
{
key: 'comment',
header: 'Kommentar',
hideOnMobile: 'lg',
render: (device) => (
<span className="block max-w-xs truncate">
{device.comment || '-'}
</span>
),
},
]
return (
<div className="px-4 py-10 sm:px-6 lg:px-8">
<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}
@ -407,8 +808,32 @@ export default function DevicesPage() {
<Tables
title="Geräte"
description="Eine Liste aller Geräte inklusive Inventur-Nr., Hersteller, Modell, Seriennummer, MAC-Adresse, Standort, Verleih-Status und Zubehör."
actionLabel="Gerät hinzufügen"
description="Eine Liste aller Geräte inklusive Inventur-Nr., Hersteller, Modell, Seriennummer, MAC-Adresse, IP-Adresse, Firmware, Standort, Verleih-Status und Zubehör."
headerAction={
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="secondary"
color="gray"
size="sm"
disabled={firmwareCheckButtonDisabled}
isLoading={isCheckingFirmware}
leadingIcon={<ArrowPathIcon aria-hidden="true" className="size-4" />}
onClick={() => void handleCheckFirmware()}
>
Firmware prüfen
</Button>
<Button
type="button"
color="indigo"
size="md"
onClick={openCreateModal}
>
Gerät hinzufügen
</Button>
</div>
}
onAction={openCreateModal}
rows={devices}
columns={columns}
@ -424,31 +849,21 @@ export default function DevicesPage() {
center
/>
}
rowActions={(device) => (
<Button
type="button"
variant="secondary"
color="indigo"
size="sm"
onClick={() => openEditModal(device)}
leadingIcon={
<PencilSquareIcon aria-hidden="true" className="size-4" />
}
>
Bearbeiten<span className="sr-only">, {device.inventoryNumber}</span>
</Button>
)}
onRowClick={openEditModal}
getRowAriaLabel={(device) =>
`Gerät ${device.inventoryNumber || device.model || device.id} bearbeiten`
}
/>
<CreateDeviceModal
open={isCreateModalOpen && editingDevice === null}
open={activeModal === 'create'}
setOpen={(open) => {
if (open) {
setIsCreateModalOpen(true)
setActiveModal('create')
return
}
closeCreateModal()
closeModal()
}}
isSaving={isCreating}
error={createError}
@ -459,14 +874,14 @@ export default function DevicesPage() {
/>
<EditDeviceModal
open={isCreateModalOpen && editingDevice !== null}
open={activeModal === 'edit'}
setOpen={(open) => {
if (open) {
setIsCreateModalOpen(true)
setActiveModal('edit')
return
}
closeCreateModal()
closeModal()
}}
isSaving={isCreating}
error={createError}

View File

@ -49,7 +49,7 @@ const editDeviceTabs: Array<{ id: DeviceModalTabId; name: string }> = [
{ id: 'masterData', name: 'Stammdaten' },
{ id: 'location', name: 'Standort' },
{ id: 'accessories', name: 'Zubehör' },
{ id: 'history', name: 'Verlauf' },
{ id: 'history', name: 'Journal' },
]
function formatHistoryDate(value: string) {
@ -78,6 +78,8 @@ function getTabForInvalidField(fieldName: string): DeviceModalTabId {
'model',
'serialNumber',
'macAddress',
'ipAddress',
'firmwareVersion',
'loanStatus',
].includes(fieldName)
) {
@ -131,55 +133,53 @@ export default function EditDeviceModal({
setActiveTab(getTabForInvalidField(target.name))
}
if (!device) {
return null
}
const historyFeedItems: JournalItems = device
? deviceHistory.map((entry) => {
const journalChange = entry.changes.find((change) => change.field === 'journal')
const isJournalEntry = entry.action === 'journal' || journalChange !== undefined
const historyFeedItems: JournalItems = deviceHistory.map((entry) => {
const journalChange = entry.changes.find((change) => change.field === 'journal')
const isJournalEntry = entry.action === 'journal' || journalChange !== undefined
return {
id: entry.id,
type: isJournalEntry
? ('journal' as const)
: entry.action === 'created'
? ('created' as const)
: ('edited' as const),
person: {
name: entry.userDisplayName || 'Unbekannt',
},
content: isJournalEntry
? journalChange?.newValue || 'hat einen Journal-Eintrag hinzugefügt.'
: entry.action === 'created'
? 'hat das Gerät erstellt.'
: entry.changes.length > 0
? `hat ${entry.changes.map(formatHistoryChange).join(', ')} geändert.`
: 'hat das Gerät bearbeitet.',
date: formatHistoryDate(entry.createdAt),
datetime: entry.createdAt,
icon: isJournalEntry
? ChatBubbleLeftEllipsisIcon
: entry.action === 'created'
? PlusCircleIcon
: PencilSquareIcon,
iconBackground: isJournalEntry
? 'bg-indigo-500'
: entry.action === 'created'
? 'bg-green-500'
: 'bg-blue-500',
}
})
return {
id: entry.id,
type: isJournalEntry
? ('journal' as const)
: entry.action === 'created'
? ('created' as const)
: ('edited' as const),
person: {
name: entry.userDisplayName || 'Unbekannt',
},
content: isJournalEntry
? journalChange?.newValue || 'hat einen Journal-Eintrag hinzugefügt.'
: entry.action === 'created'
? 'hat das Gerät erstellt.'
: entry.changes.length > 0
? `hat ${entry.changes.map(formatHistoryChange).join(', ')} geändert.`
: 'hat das Gerät bearbeitet.',
date: formatHistoryDate(entry.createdAt),
datetime: entry.createdAt,
icon: isJournalEntry
? ChatBubbleLeftEllipsisIcon
: entry.action === 'created'
? PlusCircleIcon
: PencilSquareIcon,
iconBackground: isJournalEntry
? 'bg-indigo-500'
: entry.action === 'created'
? 'bg-green-500'
: 'bg-blue-500',
}
})
: []
return (
<Modal
open={open}
open={open && Boolean(device)}
setOpen={handleOpenChange}
zIndexClassName="z-[9999]"
panelClassName="max-h-[calc(100dvh-2rem)] max-w-4xl bg-white dark:bg-gray-900"
>
<form
key={device.id}
key={device?.id ?? 'edit-device'}
onSubmit={onSubmit}
onInvalidCapture={handleInvalid}
className="flex h-[calc(100dvh-2rem)] max-h-[calc(100dvh-2rem)] flex-col"
@ -188,6 +188,16 @@ export default function EditDeviceModal({
<div>
<DialogTitle className="text-base font-semibold text-gray-900 dark:text-white">
Gerät bearbeiten
{device && (
<span className="ml-2 font-normal text-gray-500 dark:text-gray-400">
{[
device.inventoryNumber,
device.milestoneDisplayName,
]
.filter(Boolean)
.join(' · ')}
</span>
)}
</DialogTitle>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
@ -262,7 +272,7 @@ export default function EditDeviceModal({
<DeviceFormFields
open={open}
activeTab={activeTab}
device={device}
device={device ?? undefined}
devices={devices}
relatedDeviceIds={relatedDeviceIds}
onToggleRelatedDevice={onToggleRelatedDevice}

View File

@ -11,6 +11,7 @@ import {
import Modal from '../../components/Modal'
import Button from '../../components/Button'
import AddressCombobox from '../../components/AddressCombobox'
import Textarea from '../../components/Textarea'
import { formatOperationNumberInput, isCompleteOperationNumber } from '../../components/formatter'
type CreateOperationModalProps = {
@ -51,9 +52,6 @@ type SetupStepId =
const inputClassName =
'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
const textareaClassName =
'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
const initialFormValues: OperationFormValues = {
operationNumber: '',
operationName: '',
@ -520,42 +518,28 @@ export default function CreateOperationModal({
{activeStep === 'notes' && (
<div className="space-y-6">
<StepHeader
title="Zusatzinformationen"
description="Legende und Bemerkung können beim Anlegen oder später ergänzt werden."
/>
<StepHeader
title="Zusatzinformationen"
description="Legende und Bemerkung können beim Anlegen oder später ergänzt werden."
/>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div>
<label htmlFor="createLegend" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Legende
</label>
<div className="mt-2">
<textarea
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Textarea
id="createLegend"
label="Legende"
rows={6}
value={formValues.legend}
onChange={(event) => updateField('legend', event.target.value)}
className={textareaClassName}
/>
</div>
</div>
/>
<div>
<label htmlFor="createRemark" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Bemerkung
</label>
<div className="mt-2">
<textarea
<Textarea
id="createRemark"
label="Bemerkung"
rows={6}
value={formValues.remark}
onChange={(event) => updateField('remark', event.target.value)}
className={textareaClassName}
/>
</div>
</div>
</div>
/>
</div>
</div>
)}

View File

@ -657,7 +657,9 @@ export default function OperationMap({
<AppMap
markers={visibleMapMarkers}
heatPoints={heatPoints}
showHeatmap={mode === 'heatmap'}
showHeatmap={heatPoints.length > 0}
heatmapLayerName="Einsatz-Heatmap"
heatmapChecked={mode === 'heatmap'}
heatmapOptions={{
radius: 34,
blur: 26,

View File

@ -1,16 +1,28 @@
// frontend/src/pages/OperationsPage.tsx
import { useEffect, useRef, useState, type FormEvent } from 'react'
import {
MapPinIcon,
useEffect,
useMemo,
useRef,
useState,
type ComponentType,
type FormEvent,
type ReactNode,
} from 'react'
import {
ChatBubbleLeftEllipsisIcon,
FireIcon,
MapPinIcon,
PencilSquareIcon,
PlusIcon,
Squares2X2Icon,
TableCellsIcon,
UserGroupIcon,
UserIcon,
WrenchScrewdriverIcon,
} from '@heroicons/react/20/solid'
import OperationMap from './OperationMap'
import ButtonGroup from '../../components/ButtonGroup'
import Card from '../../components/Card'
import OperationModal from './OperationModal'
import Tables, { type TableColumn } from '../../components/Tables'
import LoadingSpinner from '../../components/LoadingSpinner'
@ -27,24 +39,38 @@ type OperationsPageProps = {
type OperationsView = 'table' | 'cards' | 'map' | 'heatmap'
type OperationSortDirection = 'asc' | 'desc'
type OperationSortValue =
| string
| number
| boolean
| Date
| null
| undefined
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
function renderEquipment(operation: Operation) {
const equipment = [
function getEquipmentItems(operation: Operation) {
return [
{ label: 'Kamera', value: operation.camera },
{ label: 'Router', value: operation.router },
{ label: 'Technik', value: operation.technology },
{ label: 'Switchbox', value: operation.switchbox },
{ label: 'Festplatte', value: operation.hardDrive },
{ label: 'Laptop', value: operation.laptop },
].filter((item) => item.value)
].filter((item) => item.value.trim() !== '')
}
function renderEquipment(operation: Operation) {
const equipment = getEquipmentItems(operation)
if (equipment.length === 0) {
return '-'
}
return (
<div className="flex max-w-xs flex-wrap gap-1">
<div className="flex max-w-md flex-wrap gap-1.5">
{equipment.map((item) => (
<span
key={item.label}
@ -130,6 +156,8 @@ export default function OperationsPage({
const [historyError, setHistoryError] = useState<string | null>(null)
const [view, setView] = useState<OperationsView>('table')
const [sortKey, setSortKey] = useState('operation')
const [sortDirection, setSortDirection] = useState<OperationSortDirection>('asc')
const [heatmapOperations, setHeatmapOperations] = useState<Operation[]>([])
const [isHeatmapLoading, setIsHeatmapLoading] = useState(false)
const [heatmapError, setHeatmapError] = useState<string | null>(null)
@ -503,85 +531,316 @@ export default function OperationsPage({
)
}
function normalizeSortValue(value: OperationSortValue) {
if (value instanceof Date) {
return value.getTime()
}
if (typeof value === 'boolean') {
return value ? 1 : 0
}
if (typeof value === 'number') {
return value
}
return String(value ?? '').trim().toLocaleLowerCase('de-DE')
}
function compareSortValues(
leftValue: OperationSortValue,
rightValue: OperationSortValue,
) {
const left = normalizeSortValue(leftValue)
const right = normalizeSortValue(rightValue)
if (typeof left === 'number' && typeof right === 'number') {
return left - right
}
return String(left).localeCompare(String(right), 'de-DE', {
numeric: true,
sensitivity: 'base',
})
}
function handleSort(column: TableColumn<Operation>) {
if (!column.sortable) {
return
}
if (sortKey === column.key) {
setSortDirection((currentDirection) =>
currentDirection === 'asc' ? 'desc' : 'asc',
)
return
}
setSortKey(column.key)
setSortDirection('asc')
}
const columns: TableColumn<Operation>[] = [
{
key: 'operationName',
header: 'Einsatzname',
key: 'operation',
header: 'Einsatz',
isPrimary: true,
render: (operation) => renderOperationName(operation),
sortable: true,
sortValue: (operation) =>
`${operation.operationNumber} ${operation.operationName}`,
render: (operation) => {
const operationName = operation.operationName || 'Ohne Einsatzname'
return (
<div className="min-w-0 max-w-sm">
<div className="flex items-center gap-x-2">
<span className="whitespace-nowrap text-sm font-semibold text-gray-900 dark:text-white">
{operation.operationNumber || '-'}
</span>
{hasUnreadJournal(operation) && (
<NotificationDot
title="Neue Journal-Einträge"
label="Neue Journal-Einträge"
/>
)}
</div>
<p
title={operationName}
className="mt-1 line-clamp-1 text-sm text-gray-700 dark:text-gray-300"
>
{operationName}
</p>
</div>
)
},
},
{
key: 'operationNumber',
header: 'Einsatznummer',
render: (operation) => operation.operationNumber,
},
{
key: 'offense',
header: 'Delikt',
render: (operation) => operation.offense || '-',
},
{
key: 'caseWorker',
header: 'Sachbearbeitung',
hideOnMobile: 'lg',
render: (operation) => operation.caseWorker || '-',
},
{
key: 'targetObject',
header: 'Zielobjekt',
render: (operation) => renderShortAddress(operation.targetObject),
},
{
key: 'kwAddress',
header: 'KW',
render: (operation) => renderShortAddress(operation.kwAddress),
},
{
key: 'operationLeader',
header: 'Einsatzleiter',
hideOnMobile: 'lg',
render: (operation) => operation.operationLeader || '-',
},
{
key: 'operationTeam',
header: 'Einsatzteam',
hideOnMobile: 'lg',
render: (operation) => operation.operationTeam || '-',
},
{
key: 'equipment',
header: 'Technik',
hideOnMobile: 'lg',
render: renderEquipment,
},
{
key: 'remark',
header: 'Bemerkung',
hideOnMobile: 'lg',
key: 'leadership',
header: 'Einsatzleitung / Team',
sortable: true,
sortValue: (operation) =>
`${operation.operationLeader} ${operation.operationTeam}`,
render: (operation) => (
<span className="block max-w-xs truncate">
{operation.remark || '-'}
</span>
<div className="min-w-0 max-w-64 space-y-1 text-sm">
<div
title={operation.operationLeader || undefined}
className="truncate text-gray-900 dark:text-white"
>
<span className="font-medium text-gray-500 dark:text-gray-400">
EL:
</span>{' '}
{operation.operationLeader || '-'}
</div>
<div
title={operation.operationTeam || undefined}
className="truncate text-xs text-gray-500 dark:text-gray-400"
>
<span className="font-medium">Team:</span>{' '}
{operation.operationTeam || '-'}
</div>
</div>
),
},
{
key: 'addresses',
header: 'Adressen',
hideOnMobile: 'lg',
sortable: true,
sortValue: (operation) =>
`${operation.kwAddress} ${operation.targetObject}`,
render: (operation) => (
<div className="min-w-0 max-w-md space-y-1 text-sm">
<div
title={operation.kwAddress || undefined}
className="truncate text-gray-900 dark:text-white"
>
<span className="font-medium text-gray-500 dark:text-gray-400">
KW:
</span>{' '}
{formatShortAddress(operation.kwAddress)}
</div>
<div
title={operation.targetObject || undefined}
className="truncate text-xs text-gray-500 dark:text-gray-400"
>
<span className="font-medium">ZO:</span>{' '}
{formatShortAddress(operation.targetObject)}
</div>
</div>
),
},
{
key: 'equipmentAndRemark',
header: 'Technik / Bemerkung',
hideOnMobile: 'xl',
sortable: true,
sortValue: (operation) =>
[
operation.camera,
operation.router,
operation.technology,
operation.switchbox,
operation.hardDrive,
operation.laptop,
operation.remark,
].join(' '),
render: (operation) => (
<div className="min-w-0 max-w-md">
{renderEquipment(operation)}
{operation.remark ? (
<p
title={operation.remark}
className="mt-2 line-clamp-2 text-xs text-gray-500 dark:text-gray-400"
>
{operation.remark}
</p>
) : (
<p className="mt-2 text-xs text-gray-400 dark:text-gray-500">
Keine Bemerkung
</p>
)}
</div>
),
},
]
function renderCardValue(label: string, value: string, title?: string) {
const sortedOperations = useMemo(() => {
const sortColumn = columns.find((column) => column.key === sortKey)
if (!sortColumn?.sortable || !sortColumn.sortValue) {
return operations
}
return [...operations].sort((leftOperation, rightOperation) => {
const result = compareSortValues(
sortColumn.sortValue?.(leftOperation),
sortColumn.sortValue?.(rightOperation),
)
return sortDirection === 'asc' ? result : -result
})
}, [columns, operations, sortDirection, sortKey])
function renderCardField(
label: string,
value: string,
title?: string,
) {
const displayValue = value.trim() || '-'
return (
<div className="min-w-0 rounded-md bg-gray-50 px-3 py-2 ring-1 ring-gray-200 ring-inset dark:bg-white/5 dark:ring-white/10">
<p className="text-[11px] font-medium text-gray-500 dark:text-gray-400">
{label}
</p>
<p
title={title || value || undefined}
className="mt-1 line-clamp-2 text-sm font-medium text-gray-900 dark:text-white"
>
{displayValue}
</p>
</div>
)
}
type IconComponent = ComponentType<{
className?: string
'aria-hidden'?: boolean
}>
function CardSection({
icon: Icon,
title,
children,
}: {
icon: IconComponent
title: string
children: ReactNode
}) {
return (
<section>
<div className="flex items-center gap-x-2">
<div className="flex size-7 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600 dark:bg-indigo-500/10 dark:text-indigo-300">
<Icon aria-hidden className="size-4" />
</div>
<h3 className="text-xs font-semibold tracking-wide text-gray-500 uppercase dark:text-gray-400">
{title}
</h3>
</div>
<div className="mt-2">
{children}
</div>
</section>
)
}
function CardInfo({
label,
value,
title,
}: {
label: string
value: string
title?: string
}) {
const displayValue = value.trim() || '-'
return (
<div className="min-w-0">
<dt className="text-[11px] font-medium text-gray-500 dark:text-gray-400">
{label}
</dt>
<dd
title={title || value || undefined}
className="mt-0.5 truncate text-xs text-gray-900 dark:text-white"
className="mt-0.5 truncate text-sm font-medium text-gray-900 dark:text-white"
>
{value || '-'}
{displayValue}
</dd>
</div>
)
}
function renderCardEquipment(operation: Operation) {
const equipment = getEquipmentItems(operation)
if (equipment.length === 0) {
return (
<p className="text-sm text-gray-400 dark:text-gray-500">
Keine Technik hinterlegt.
</p>
)
}
return (
<div className="flex flex-wrap gap-2">
{equipment.map((item) => (
<div
key={item.label}
title={`${item.label}: ${item.value}`}
className="min-w-0 max-w-full rounded-md bg-gray-50 px-2.5 py-1.5 ring-1 ring-gray-200 ring-inset dark:bg-white/5 dark:ring-white/10"
>
<span className="block text-[10px] font-medium tracking-wide text-gray-500 uppercase dark:text-gray-400">
{item.label}
</span>
<span className="mt-0.5 block max-w-44 truncate text-xs font-medium text-gray-900 dark:text-white">
{item.value}
</span>
</div>
))}
</div>
)
}
function renderOperationCards() {
if (isLoading) {
return (
@ -605,71 +864,116 @@ export default function OperationsPage({
}
return (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
{operations.map((operation) => (
<Card
key={operation.id}
variant="with-gray-header"
headerVariant="with-description-action"
headerTitle={renderOperationName(operation)}
headerDescription={operation.operationNumber}
headerAction={
canWriteOperations ? (
<Button
type="button"
variant="primary"
color="indigo"
size="md"
onClick={() => openEditModal(operation)}
leadingIcon={<PencilSquareIcon aria-hidden="true" className="size-4" />}
>
Bearbeiten
<span className="sr-only">, {operation.operationNumber}</span>
</Button>
) : undefined
}
>
<dl className="grid grid-cols-2 gap-x-3 gap-y-3">
{renderCardValue('Delikt', operation.offense)}
{renderCardValue('Sachbearbeitung', operation.caseWorker)}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 2xl:grid-cols-3">
{operations.map((operation) => {
const operationName = operation.operationName || 'Ohne Einsatzname'
const kwAddress = formatShortAddress(operation.kwAddress)
const targetObject = formatShortAddress(operation.targetObject)
{renderCardValue(
'Zielobjekt',
formatShortAddress(operation.targetObject),
operation.targetObject,
)}
{renderCardValue(
'KW',
formatShortAddress(operation.kwAddress),
operation.kwAddress,
)}
return (
<article
key={operation.id}
className="group overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-gray-200 transition hover:-translate-y-0.5 hover:shadow-md dark:bg-gray-900 dark:ring-white/10"
>
<div className="border-b border-gray-200 bg-gradient-to-br from-indigo-50 via-white to-white p-4 dark:border-white/10 dark:from-indigo-500/10 dark:via-gray-900 dark:to-gray-900">
<div className="flex items-start justify-between gap-x-3">
<div className="min-w-0">
<div className="flex items-center gap-x-2">
<span className="inline-flex items-center rounded-md bg-indigo-600 px-2 py-1 text-xs font-semibold text-white shadow-xs dark:bg-indigo-500">
{operation.operationNumber || '-'}
</span>
{renderCardValue('Einsatzleiter', operation.operationLeader)}
{renderCardValue('Team', operation.operationTeam)}
{renderCardValue('Legende', operation.legend)}
</dl>
{hasUnreadJournal(operation) && (
<NotificationDot
title="Neue Journal-Einträge"
label="Neue Journal-Einträge"
/>
)}
</div>
<div className="mt-4">
<dt className="text-[11px] font-medium text-gray-500 dark:text-gray-400">
Technik
</dt>
<dd className="mt-1.5">
{renderEquipment(operation)}
</dd>
</div>
<h2
title={operationName}
className="mt-3 line-clamp-2 text-base font-semibold text-gray-900 dark:text-white"
>
{operationName}
</h2>
</div>
{operation.remark && (
<div className="mt-4">
<dt className="text-[11px] font-medium text-gray-500 dark:text-gray-400">
Bemerkung
</dt>
<dd className="mt-0.5 line-clamp-2 text-xs text-gray-700 dark:text-gray-300">
{operation.remark}
</dd>
{canWriteOperations && (
<Button
variant="secondary"
color="indigo"
size="md"
onClick={() => openEditModal(operation)}
leadingIcon={
<PencilSquareIcon aria-hidden="true" className="size-4" />
}
className="shrink-0 bg-white/80 dark:bg-white/10"
>
Bearbeiten
<span className="sr-only">
, {operation.operationNumber}
</span>
</Button>
)}
</div>
</div>
)}
</Card>
))}
<div className="space-y-5 p-4">
<CardSection icon={UserIcon} title="Führung">
<dl className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<CardInfo
label="Einsatzleiter"
value={operation.operationLeader}
/>
<CardInfo
label="Einsatzteam"
value={operation.operationTeam}
/>
</dl>
</CardSection>
<CardSection icon={MapPinIcon} title="Adressen">
<dl className="space-y-3">
<CardInfo
label="KW-Adresse"
value={kwAddress}
title={operation.kwAddress}
/>
<CardInfo
label="Zielobjekt-Adresse"
value={targetObject}
title={operation.targetObject}
/>
</dl>
</CardSection>
<CardSection icon={WrenchScrewdriverIcon} title="Technik">
{renderCardEquipment(operation)}
</CardSection>
<CardSection icon={ChatBubbleLeftEllipsisIcon} title="Bemerkung">
<div className="rounded-lg bg-gray-50 px-3 py-2 ring-1 ring-gray-200 ring-inset dark:bg-white/5 dark:ring-white/10">
{operation.remark ? (
<p
title={operation.remark}
className="line-clamp-3 text-sm text-gray-700 dark:text-gray-300"
>
{operation.remark}
</p>
) : (
<p className="text-sm text-gray-400 dark:text-gray-500">
Keine Bemerkung hinterlegt.
</p>
)}
</div>
</CardSection>
</div>
</article>
)
})}
</div>
)
}
@ -680,8 +984,8 @@ export default function OperationsPage({
<div
className={
isMapView
? 'flex h-[calc(100dvh-4rem)] flex-col overflow-hidden pt-10'
: 'px-4 py-10 sm:px-6 lg:px-8'
? 'flex h-full min-h-0 flex-col overflow-hidden pt-10'
: 'h-full min-h-0 overflow-y-auto px-4 py-10 sm:px-6 lg:px-8'
}
>
{(error || (view === 'heatmap' && heatmapError)) && (
@ -748,9 +1052,11 @@ export default function OperationsPage({
{canWriteOperations && (
<Button
type="button"
variant="primary"
color="indigo"
size="md"
onClick={openCreateModal}
leadingIcon={<PlusIcon aria-hidden="true" className="size-4" />}
>
Einsatz hinzufügen
</Button>
@ -760,10 +1066,13 @@ export default function OperationsPage({
{view === 'table' ? (
<Tables
rows={operations}
rows={sortedOperations}
columns={columns}
getRowId={(operation) => operation.id}
variant="card"
sortKey={sortKey}
sortDirection={sortDirection}
onSort={handleSort}
emptyText="Keine Einsätze vorhanden."
isLoading={isLoading}
loadingContent={

View File

@ -1,831 +0,0 @@
// frontend\src\pages\settings\Profile.tsx
import {
useEffect,
useState,
type FormEvent,
type ReactNode,
} from 'react'
import Button from '../../components/Button'
import Container from '../../components/Container'
import type { User } from '../../components/types'
import {
ComputerDesktopIcon,
ShieldCheckIcon,
} from '@heroicons/react/20/solid'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
type ProfileProps = {
user?: User
onUserUpdated?: (user: User) => void
}
type UserSession = {
id: string
browser: string
operatingSystem: string
deviceType: string
userAgent: string
ipAddress: string
createdAt: string
lastSeenAt: string
current: boolean
}
const inputClassName =
'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
function Section({
title,
description,
children,
}: {
title: string
description: string
children: ReactNode
}) {
return (
<Container variant="constrained" className="py-16">
<div className="grid grid-cols-1 gap-x-8 gap-y-10 md:grid-cols-3">
<div>
<h2 className="text-base/7 font-semibold text-gray-900 dark:text-white">
{title}
</h2>
<p className="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
{description}
</p>
</div>
<div className="md:col-span-2">
{children}
</div>
</div>
</Container>
)
}
function Message({
type,
children,
}: {
type: 'success' | 'error'
children: ReactNode
}) {
return (
<div
className={
type === 'success'
? 'mt-6 rounded-md bg-green-50 p-3 text-sm text-green-700 dark:bg-green-900/30 dark:text-green-300'
: 'mt-6 rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300'
}
>
{children}
</div>
)
}
async function readApiError(response: Response, fallback: string) {
const data = await response.json().catch(() => null)
return data?.error ?? fallback
}
export default function Profile({
user,
onUserUpdated,
}: ProfileProps) {
const [currentUser, setCurrentUser] = useState<User | undefined>(user)
const [sessions, setSessions] = useState<UserSession[]>([])
const [isLoadingSessions, setIsLoadingSessions] = useState(false)
const [sessionsError, setSessionsError] = useState<string | null>(null)
const [revokeSessionError, setRevokeSessionError] = useState<string | null>(null)
const [revokingSessionId, setRevokingSessionId] = useState<string | null>(null)
const [profileError, setProfileError] = useState<string | null>(null)
const [profileSuccess, setProfileSuccess] = useState<string | null>(null)
const [isSavingProfile, setIsSavingProfile] = useState(false)
const [passwordError, setPasswordError] = useState<string | null>(null)
const [passwordSuccess, setPasswordSuccess] = useState<string | null>(null)
const [isSavingPassword, setIsSavingPassword] = useState(false)
const [logoutSessionsError, setLogoutSessionsError] = useState<string | null>(null)
const [logoutSessionsSuccess, setLogoutSessionsSuccess] = useState<string | null>(null)
const [isLoggingOutSessions, setIsLoggingOutSessions] = useState(false)
const [deleteAccountError, setDeleteAccountError] = useState<string | null>(null)
const [isDeletingAccount, setIsDeletingAccount] = useState(false)
useEffect(() => {
setCurrentUser(user)
}, [user])
useEffect(() => {
void loadSessions()
}, [])
const avatarUrl =
currentUser?.avatar ||
'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80'
async function loadSessions() {
setIsLoadingSessions(true)
setSessionsError(null)
try {
const response = await fetch(`${API_URL}/settings/sessions`, {
credentials: 'include',
})
if (!response.ok) {
throw new Error(
await readApiError(response, 'Sitzungen konnten nicht geladen werden'),
)
}
const data = await response.json()
setSessions(data.sessions ?? [])
} catch (error) {
setSessionsError(
error instanceof Error
? error.message
: 'Sitzungen konnten nicht geladen werden',
)
} finally {
setIsLoadingSessions(false)
}
}
async function handleRevokeSession(sessionId: string) {
setRevokingSessionId(sessionId)
setRevokeSessionError(null)
try {
const response = await fetch(`${API_URL}/settings/sessions/${sessionId}`, {
method: 'DELETE',
credentials: 'include',
})
if (!response.ok) {
throw new Error(
await readApiError(response, 'Sitzung konnte nicht abgemeldet werden'),
)
}
await loadSessions()
} catch (error) {
setRevokeSessionError(
error instanceof Error
? error.message
: 'Sitzung konnte nicht abgemeldet werden',
)
} finally {
setRevokingSessionId(null)
}
}
async function handleProfileSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const form = event.currentTarget
const formData = new FormData(form)
setIsSavingProfile(true)
setProfileError(null)
setProfileSuccess(null)
try {
const response = await fetch(`${API_URL}/settings/profile`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
displayName: String(formData.get('displayName') ?? ''),
username: String(formData.get('username') ?? ''),
email: String(formData.get('email') ?? ''),
avatar: String(formData.get('avatar') ?? ''),
unit: String(formData.get('unit') ?? ''),
group: String(formData.get('group') ?? ''),
}),
})
if (!response.ok) {
throw new Error(
await readApiError(response, 'Profil konnte nicht gespeichert werden'),
)
}
const data = await response.json().catch(() => null)
const nextUser = data?.user as User | undefined
if (nextUser) {
setCurrentUser(nextUser)
onUserUpdated?.(nextUser)
}
setProfileSuccess('Profil wurde gespeichert.')
} catch (error) {
setProfileError(
error instanceof Error
? error.message
: 'Profil konnte nicht gespeichert werden',
)
} finally {
setIsSavingProfile(false)
}
}
async function handlePasswordSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const form = event.currentTarget
const formData = new FormData(form)
setIsSavingPassword(true)
setPasswordError(null)
setPasswordSuccess(null)
try {
const response = await fetch(`${API_URL}/settings/password`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
currentPassword: String(formData.get('currentPassword') ?? ''),
newPassword: String(formData.get('newPassword') ?? ''),
confirmPassword: String(formData.get('confirmPassword') ?? ''),
}),
})
if (!response.ok) {
throw new Error(
await readApiError(response, 'Passwort konnte nicht gespeichert werden'),
)
}
form.reset()
setPasswordSuccess('Passwort wurde gespeichert.')
} catch (error) {
setPasswordError(
error instanceof Error
? error.message
: 'Passwort konnte nicht gespeichert werden',
)
} finally {
setIsSavingPassword(false)
}
}
async function handleLogoutSessionsSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const form = event.currentTarget
const formData = new FormData(form)
setIsLoggingOutSessions(true)
setLogoutSessionsError(null)
setLogoutSessionsSuccess(null)
try {
const response = await fetch(`${API_URL}/settings/logout-other-sessions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
password: String(formData.get('password') ?? ''),
}),
})
if (!response.ok) {
throw new Error(
await readApiError(
response,
'Andere Sitzungen konnten nicht abgemeldet werden',
),
)
}
form.reset()
setLogoutSessionsSuccess('Andere Sitzungen wurden abgemeldet.')
await loadSessions()
} catch (error) {
setLogoutSessionsError(
error instanceof Error
? error.message
: 'Andere Sitzungen konnten nicht abgemeldet werden',
)
} finally {
setIsLoggingOutSessions(false)
}
}
async function handleDeleteAccountSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const form = event.currentTarget
const formData = new FormData(form)
const confirmed = window.confirm(
'Konto wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.',
)
if (!confirmed) {
return
}
setIsDeletingAccount(true)
setDeleteAccountError(null)
try {
const response = await fetch(`${API_URL}/settings/account`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
password: String(formData.get('password') ?? ''),
}),
})
if (!response.ok) {
throw new Error(
await readApiError(response, 'Konto konnte nicht gelöscht werden'),
)
}
window.location.href = '/login'
} catch (error) {
setDeleteAccountError(
error instanceof Error
? error.message
: 'Konto konnte nicht gelöscht werden',
)
} finally {
setIsDeletingAccount(false)
}
}
return (
<div className="divide-y divide-gray-200 dark:divide-white/10">
<Section
title="Persönliche Informationen"
description="Verwalte deine Profildaten und Kontoinformationen."
>
<form onSubmit={handleProfileSubmit}>
<div className="grid grid-cols-1 gap-x-6 gap-y-8 sm:max-w-xl sm:grid-cols-6">
<div className="col-span-full flex items-center gap-x-8">
<img
alt=""
src={avatarUrl}
className="size-24 flex-none rounded-lg bg-gray-100 object-cover outline -outline-offset-1 outline-black/5 dark:bg-gray-800 dark:outline-white/10"
/>
<div className="min-w-0 flex-1">
<label
htmlFor="avatar"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Avatar-URL
</label>
<div className="mt-2">
<input
id="avatar"
name="avatar"
type="url"
defaultValue={currentUser?.avatar ?? ''}
placeholder="https://..."
className={inputClassName}
/>
</div>
<p className="mt-2 text-xs/5 text-gray-500 dark:text-gray-400">
Gib eine Bild-URL für dein Profilbild an.
</p>
</div>
</div>
<div className="sm:col-span-3">
<label
htmlFor="displayName"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Anzeigename
</label>
<div className="mt-2">
<input
id="displayName"
name="displayName"
type="text"
defaultValue={currentUser?.displayName ?? ''}
className={inputClassName}
/>
</div>
</div>
<div className="sm:col-span-3">
<label
htmlFor="username"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Benutzername
</label>
<div className="mt-2">
<input
id="username"
name="username"
type="text"
required
defaultValue={currentUser?.username ?? ''}
className={inputClassName}
/>
</div>
</div>
<div className="col-span-full">
<label
htmlFor="email"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
E-Mail-Adresse
</label>
<div className="mt-2">
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
defaultValue={currentUser?.email ?? ''}
className={inputClassName}
/>
</div>
</div>
<div className="sm:col-span-3">
<label
htmlFor="unit"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Einheit
</label>
<div className="mt-2">
<input
id="unit"
name="unit"
type="text"
defaultValue={currentUser?.unit ?? ''}
className={inputClassName}
/>
</div>
</div>
<div className="sm:col-span-3">
<label
htmlFor="group"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Gruppe
</label>
<div className="mt-2">
<input
id="group"
name="group"
type="text"
defaultValue={currentUser?.group ?? ''}
className={inputClassName}
/>
</div>
</div>
</div>
{profileError && (
<Message type="error">
{profileError}
</Message>
)}
{profileSuccess && (
<Message type="success">
{profileSuccess}
</Message>
)}
<div className="mt-8 flex">
<Button
type="submit"
color="indigo"
size="md"
isLoading={isSavingProfile}
>
Speichern
</Button>
</div>
</form>
</Section>
<Section
title="Passwort ändern"
description="Aktualisiere das Passwort deines Benutzerkontos."
>
<form onSubmit={handlePasswordSubmit}>
<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">
<label
htmlFor="current-password"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Aktuelles Passwort
</label>
<div className="mt-2">
<input
id="current-password"
name="currentPassword"
type="password"
autoComplete="current-password"
required
className={inputClassName}
/>
</div>
</div>
<div className="col-span-full">
<label
htmlFor="new-password"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Neues Passwort
</label>
<div className="mt-2">
<input
id="new-password"
name="newPassword"
type="password"
autoComplete="new-password"
minLength={8}
required
className={inputClassName}
/>
</div>
</div>
<div className="col-span-full">
<label
htmlFor="confirm-password"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Neues Passwort bestätigen
</label>
<div className="mt-2">
<input
id="confirm-password"
name="confirmPassword"
type="password"
autoComplete="new-password"
minLength={8}
required
className={inputClassName}
/>
</div>
</div>
</div>
{passwordError && (
<Message type="error">
{passwordError}
</Message>
)}
{passwordSuccess && (
<Message type="success">
{passwordSuccess}
</Message>
)}
<div className="mt-8 flex">
<Button
type="submit"
color="indigo"
size="md"
isLoading={isSavingPassword}
>
Passwort speichern
</Button>
</div>
</form>
</Section>
<Section
title="Sitzungen"
description="Sieh dir deine aktiven Sitzungen an und melde andere Browser ab."
>
<div className="sm:max-w-2xl">
{isLoadingSessions && (
<div className="rounded-md border border-dashed border-gray-300 p-4 text-sm text-gray-500 dark:border-white/10 dark:text-gray-400">
Sitzungen werden geladen...
</div>
)}
{sessionsError && (
<Message type="error">
{sessionsError}
</Message>
)}
{revokeSessionError && (
<Message type="error">
{revokeSessionError}
</Message>
)}
{!isLoadingSessions && !sessionsError && sessions.length > 0 && (
<div className="space-y-3">
{sessions.map((session) => (
<div
key={session.id}
className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5"
>
<div className="flex items-start gap-x-4">
<div className="flex size-11 shrink-0 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600 ring-1 ring-indigo-600/10 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20">
<ComputerDesktopIcon
aria-hidden="true"
className="size-6"
/>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-x-4">
<div>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
{session.current ? 'Aktuelle Sitzung' : 'Weitere Sitzung'}
</h3>
<p className="mt-1 text-sm text-gray-700 dark:text-gray-300">
{session.browser} auf {session.operatingSystem}
</p>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{session.deviceType}
{session.ipAddress ? ` · ${session.ipAddress}` : ''}
</p>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Zuletzt aktiv:{' '}
{new Intl.DateTimeFormat('de-DE', {
dateStyle: 'short',
timeStyle: 'short',
}).format(new Date(session.lastSeenAt))}
</p>
</div>
{session.current ? (
<span className="inline-flex shrink-0 items-center gap-x-1.5 rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-green-600/20 ring-inset dark:bg-green-900/30 dark:text-green-300 dark:ring-green-500/30">
<ShieldCheckIcon
aria-hidden="true"
className="size-3.5"
/>
Aktiv
</span>
) : (
<Button
type="button"
variant="secondary"
color="red"
size="sm"
isLoading={revokingSessionId === session.id}
onClick={() => void handleRevokeSession(session.id)}
>
Abmelden
</Button>
)}
</div>
</div>
</div>
</div>
))}
</div>
)}
{!isLoadingSessions && !sessionsError && sessions.length === 0 && (
<div className="rounded-md border border-dashed border-gray-300 p-4 text-sm text-gray-500 dark:border-white/10 dark:text-gray-400">
Keine aktiven Sitzungen gefunden.
</div>
)}
<form
onSubmit={handleLogoutSessionsSubmit}
className="mt-8 border-t border-gray-200 pt-6 dark:border-white/10"
>
<label
htmlFor="logout-password"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Dein Passwort
</label>
<div className="mt-2">
<input
id="logout-password"
name="password"
type="password"
autoComplete="current-password"
required
className={inputClassName}
/>
</div>
<p className="mt-2 text-xs/5 text-gray-500 dark:text-gray-400">
Dadurch werden alle anderen aktiven Sitzungen abgemeldet. Diese Sitzung bleibt aktiv.
</p>
{logoutSessionsError && (
<Message type="error">
{logoutSessionsError}
</Message>
)}
{logoutSessionsSuccess && (
<Message type="success">
{logoutSessionsSuccess}
</Message>
)}
<div className="mt-6 flex">
<Button
type="submit"
color="indigo"
size="md"
isLoading={isLoggingOutSessions}
>
Andere Sitzungen abmelden
</Button>
</div>
</form>
</div>
</Section>
<Section
title="Konto löschen"
description="Das Löschen deines Kontos kann nicht rückgängig gemacht werden."
>
<form onSubmit={handleDeleteAccountSubmit}>
<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">
<label
htmlFor="delete-account-password"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Dein Passwort
</label>
<div className="mt-2">
<input
id="delete-account-password"
name="password"
type="password"
autoComplete="current-password"
required
className={inputClassName}
/>
</div>
</div>
</div>
{deleteAccountError && (
<Message type="error">
{deleteAccountError}
</Message>
)}
<div className="mt-8 flex">
<Button
type="submit"
color="red"
size="md"
isLoading={isDeletingAccount}
>
Konto löschen
</Button>
</div>
</form>
</Section>
</div>
)
}

View File

@ -12,7 +12,8 @@ import {
import Tabs from '../../components/Tabs'
import Container from '../../components/Container'
import type { User } from '../../components/types'
import Profile from './Profile'
import Konto from './konto/Konto'
import Benachrichtigungen from './benachrichtigungen/Benachrichtigungen'
type SettingsPageProps = {
user?: User
@ -74,9 +75,9 @@ export default function SettingsPage({
const settingsTabs = [
{
name: 'Konto',
href: '/einstellungen',
href: '/einstellungen/konto',
icon: UserIcon,
current: pathname === '/einstellungen',
current: pathname === '/einstellungen/konto',
},
{
name: 'Benachrichtigungen',
@ -105,40 +106,42 @@ export default function SettingsPage({
]
return (
<main>
<main className="flex h-full min-h-0 flex-col overflow-hidden">
<h1 className="sr-only">Einstellungen</h1>
<Tabs
tabs={settingsTabs}
variant="underline-icons"
align="center"
sticky
stickyTopClassName="top-0"
ariaLabel="Einstellungen"
/>
{section === 'konto' && (
<Profile
user={user}
onUserUpdated={onUserUpdated}
<div className="shrink-0 border-b border-gray-200 bg-white dark:border-white/10 dark:bg-gray-900">
<Tabs
tabs={settingsTabs}
variant="underline-icons"
align="center"
ariaLabel="Einstellungen"
/>
)}
</div>
{section === 'benachrichtigungen' && (
<PlaceholderSection title="Benachrichtigungen" />
)}
<div className="min-h-0 flex-1 overflow-y-auto [scrollbar-gutter:stable]">
{section === 'konto' && (
<Konto
user={user}
onUserUpdated={onUserUpdated}
/>
)}
{section === 'abrechnung' && (
<PlaceholderSection title="Abrechnung" />
)}
{section === 'benachrichtigungen' && (
<Benachrichtigungen />
)}
{section === 'teams' && (
<PlaceholderSection title="Teams" />
)}
{section === 'abrechnung' && (
<PlaceholderSection title="Abrechnung" />
)}
{section === 'integrationen' && (
<PlaceholderSection title="Integrationen" />
)}
{section === 'teams' && (
<PlaceholderSection title="Teams" />
)}
{section === 'integrationen' && (
<PlaceholderSection title="Integrationen" />
)}
</div>
</main>
)
}

View File

@ -0,0 +1,409 @@
// frontend/src/pages/settings/benachrichtigungen/Benachrichtigungen.tsx
import {
useEffect,
useRef,
useState,
type ReactNode,
} from 'react'
import {
BellIcon,
EnvelopeIcon,
SpeakerWaveIcon,
} from '@heroicons/react/20/solid'
import Container from '../../../components/Container'
import LoadingSpinner from '../../../components/LoadingSpinner'
import Switch from '../../../components/Switch'
import Notifications, {
type ToastNotification,
} from '../../../components/Notifications'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
type NotificationSettings = {
inAppEnabled: boolean
soundEnabled: boolean
emailEnabled: boolean
operationUpdates: boolean
operationJournals: boolean
deviceUpdates: boolean
deviceJournals: boolean
systemMessages: boolean
}
const defaultSettings: NotificationSettings = {
inAppEnabled: true,
soundEnabled: false,
emailEnabled: false,
operationUpdates: true,
operationJournals: true,
deviceUpdates: true,
deviceJournals: true,
systemMessages: true,
}
function Section({
title,
description,
children,
}: {
title: string
description: string
children: ReactNode
}) {
return (
<Container variant="constrained" className="py-16">
<div className="grid grid-cols-1 gap-x-8 gap-y-10 md:grid-cols-3">
<div>
<h2 className="text-base/7 font-semibold text-gray-900 dark:text-white">
{title}
</h2>
<p className="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
{description}
</p>
</div>
<div className="md:col-span-2">
{children}
</div>
</div>
</Container>
)
}
function Message({
children,
}: {
children: ReactNode
}) {
return (
<div className="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
{children}
</div>
)
}
async function readApiError(response: Response, fallback: string) {
const data = await response.json().catch(() => null)
return data?.error ?? fallback
}
function NotificationCard({
icon,
title,
description,
children,
}: {
icon: ReactNode
title: string
description: string
children: ReactNode
}) {
return (
<div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
<div className="flex items-start gap-x-4">
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600 ring-1 ring-indigo-600/10 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20">
{icon}
</div>
<div className="min-w-0 flex-1">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
{title}
</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{description}
</p>
<div className="mt-5 space-y-5">
{children}
</div>
</div>
</div>
</div>
)
}
export default function Benachrichtigungen() {
const [settings, setSettings] =
useState<NotificationSettings>(defaultSettings)
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [toasts, setToasts] = useState<ToastNotification[]>([])
const settingsRef = useRef<NotificationSettings>(defaultSettings)
const saveRequestIdRef = useRef(0)
useEffect(() => {
void loadSettings()
}, [])
function dismissToast(id: string) {
setToasts((currentToasts) =>
currentToasts.filter((toast) => toast.id !== id),
)
}
function showToast(notification: Omit<ToastNotification, 'id'>) {
setToasts((currentToasts) => [
...currentToasts,
{
id: crypto.randomUUID(),
...notification,
},
])
}
async function loadSettings() {
setIsLoading(true)
setError(null)
try {
const response = await fetch(`${API_URL}/settings/notifications/preferences`, {
credentials: 'include',
})
if (!response.ok) {
throw new Error(
await readApiError(
response,
'Benachrichtigungseinstellungen konnten nicht geladen werden',
),
)
}
const data = await response.json()
const nextSettings = {
...defaultSettings,
...(data.settings ?? {}),
}
settingsRef.current = nextSettings
setSettings(nextSettings)
} catch (error) {
setError(
error instanceof Error
? error.message
: 'Benachrichtigungseinstellungen konnten nicht geladen werden',
)
} finally {
setIsLoading(false)
}
}
function updateSetting<Key extends keyof NotificationSettings>(
key: Key,
value: NotificationSettings[Key],
) {
const nextSettings = {
...settingsRef.current,
[key]: value,
}
settingsRef.current = nextSettings
setSettings(nextSettings)
void saveSettings(nextSettings)
}
async function saveSettings(nextSettings: NotificationSettings) {
const requestId = saveRequestIdRef.current + 1
saveRequestIdRef.current = requestId
setIsSaving(true)
setError(null)
try {
const response = await fetch(`${API_URL}/settings/notifications/preferences`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(nextSettings),
})
if (!response.ok) {
throw new Error(
await readApiError(
response,
'Benachrichtigungseinstellungen konnten nicht gespeichert werden',
),
)
}
const data = await response.json().catch(() => null)
if (saveRequestIdRef.current !== requestId) {
return
}
if (data?.settings) {
const savedSettings = {
...defaultSettings,
...data.settings,
}
settingsRef.current = savedSettings
setSettings(savedSettings)
}
showToast({
title: 'Einstellungen gespeichert',
message: 'Deine Benachrichtigungseinstellungen wurden aktualisiert.',
variant: 'success',
})
} catch (error) {
if (saveRequestIdRef.current !== requestId) {
return
}
setError(
error instanceof Error
? error.message
: 'Benachrichtigungseinstellungen konnten nicht gespeichert werden',
)
showToast({
title: 'Speichern fehlgeschlagen',
message:
error instanceof Error
? error.message
: 'Benachrichtigungseinstellungen konnten nicht gespeichert werden.',
variant: 'error',
autoClose: false,
})
} finally {
if (saveRequestIdRef.current === requestId) {
setIsSaving(false)
}
}
}
if (isLoading) {
return (
<Section
title="Benachrichtigungen"
description="Lege fest, welche Hinweise du erhalten möchtest."
>
<div className="rounded-lg border border-gray-200 bg-white p-8 shadow-sm dark:border-white/10 dark:bg-white/5">
<LoadingSpinner
size="md"
label="Benachrichtigungseinstellungen werden geladen..."
className="text-indigo-600 dark:text-indigo-400"
center
/>
</div>
</Section>
)
}
return (
<>
<Notifications
notifications={toasts}
onDismiss={dismissToast}
position="top-right"
/>
<div className="divide-y divide-gray-200 dark:divide-white/10">
<Section
title="Benachrichtigungen"
description="Lege fest, welche Hinweise du in der Anwendung erhalten möchtest."
>
<div className="space-y-6 sm:max-w-2xl">
{error && (
<Message>
{error}
</Message>
)}
<NotificationCard
icon={<BellIcon aria-hidden="true" className="size-5" />}
title="In-App-Benachrichtigungen"
description="Steuere Hinweise, die direkt in der Anwendung angezeigt werden."
>
<Switch
checked={settings.inAppEnabled}
onChange={(event) =>
updateSetting('inAppEnabled', event.target.checked)
}
label="Benachrichtigungen anzeigen"
description="Aktiviert Hinweise in der Topbar und im Benachrichtigungscenter."
/>
<Switch
checked={settings.soundEnabled}
onChange={(event) =>
updateSetting('soundEnabled', event.target.checked)
}
disabled={!settings.inAppEnabled}
label="Signalton abspielen"
description="Spielt einen kurzen Ton bei neuen Benachrichtigungen ab."
/>
</NotificationCard>
<NotificationCard
icon={<SpeakerWaveIcon aria-hidden="true" className="size-5" />}
title="Ereignisse"
description="Wähle aus, bei welchen Ereignissen du benachrichtigt werden möchtest."
>
<Switch
checked={settings.operationUpdates}
onChange={(event) =>
updateSetting('operationUpdates', event.target.checked)
}
disabled={!settings.inAppEnabled && !settings.emailEnabled}
label="Einsatzänderungen"
description="Benachrichtigt dich, wenn Einsätze erstellt oder geändert werden."
/>
<Switch
checked={settings.operationJournals}
onChange={(event) =>
updateSetting('operationJournals', event.target.checked)
}
disabled={!settings.inAppEnabled && !settings.emailEnabled}
label="Einsatz-Journal"
description="Benachrichtigt dich bei neuen Journal-Einträgen zu Einsätzen."
/>
<Switch
checked={settings.deviceUpdates}
onChange={(event) =>
updateSetting('deviceUpdates', event.target.checked)
}
disabled={!settings.inAppEnabled && !settings.emailEnabled}
label="Geräteänderungen"
description="Benachrichtigt dich, wenn Geräte erstellt oder geändert werden."
/>
<Switch
checked={settings.deviceJournals}
onChange={(event) =>
updateSetting('deviceJournals', event.target.checked)
}
disabled={!settings.inAppEnabled && !settings.emailEnabled}
label="Geräte-Journal"
description="Benachrichtigt dich bei neuen Journal-Einträgen zu Geräten."
/>
<Switch
checked={settings.systemMessages}
onChange={(event) =>
updateSetting('systemMessages', event.target.checked)
}
disabled={!settings.inAppEnabled && !settings.emailEnabled}
label="Systemmeldungen"
description="Benachrichtigt dich bei wichtigen Systemhinweisen."
/>
</NotificationCard>
</div>
</Section>
</div>
</>
)
}

View File

@ -0,0 +1,804 @@
// frontend\src\pages\settings\konto\Konto.tsx
import {
useEffect,
useState,
type FormEvent,
type ReactNode,
} from 'react'
import Button from '../../../components/Button'
import Container from '../../../components/Container'
import type { User } from '../../../components/types'
import {
ComputerDesktopIcon,
ShieldCheckIcon,
} from '@heroicons/react/20/solid'
import Notifications, {
type ToastNotification,
} from '../../../components/Notifications'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
type KontoProps = {
user?: User
onUserUpdated?: (user: User) => void
}
type UserSession = {
id: string
browser: string
operatingSystem: string
deviceType: string
userAgent: string
ipAddress: string
createdAt: string
lastSeenAt: string
current: boolean
}
const inputClassName =
'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
function Section({
title,
description,
children,
}: {
title: string
description: string
children: ReactNode
}) {
return (
<Container variant="constrained" className="py-16">
<div className="grid grid-cols-1 gap-x-8 gap-y-10 md:grid-cols-3">
<div>
<h2 className="text-base/7 font-semibold text-gray-900 dark:text-white">
{title}
</h2>
<p className="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
{description}
</p>
</div>
<div className="md:col-span-2">
{children}
</div>
</div>
</Container>
)
}
async function readApiError(response: Response, fallback: string) {
const data = await response.json().catch(() => null)
return data?.error ?? fallback
}
export default function Konto({
user,
onUserUpdated,
}: KontoProps) {
const [toasts, setToasts] = useState<ToastNotification[]>([])
const [currentUser, setCurrentUser] = useState<User | undefined>(user)
const [sessions, setSessions] = useState<UserSession[]>([])
const [isLoadingSessions, setIsLoadingSessions] = useState(false)
const [revokingSessionId, setRevokingSessionId] = useState<string | null>(null)
const [isSavingKonto, setIsSavingKonto] = useState(false)
const [isSavingPassword, setIsSavingPassword] = useState(false)
const [isLoggingOutSessions, setIsLoggingOutSessions] = useState(false)
const [isDeletingKonto, setIsDeletingKonto] = useState(false)
useEffect(() => {
setCurrentUser(user)
}, [user])
useEffect(() => {
void loadSessions()
}, [])
const avatarUrl =
currentUser?.avatar ||
'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80'
function dismissToast(id: string) {
setToasts((currentToasts) =>
currentToasts.filter((toast) => toast.id !== id),
)
}
function showToast(notification: Omit<ToastNotification, 'id'>) {
setToasts((currentToasts) => [
...currentToasts,
{
id: crypto.randomUUID(),
...notification,
},
])
}
async function loadSessions() {
setIsLoadingSessions(true)
try {
const response = await fetch(`${API_URL}/settings/sessions`, {
credentials: 'include',
})
if (!response.ok) {
throw new Error(
await readApiError(response, 'Sitzungen konnten nicht geladen werden'),
)
}
const data = await response.json()
setSessions(data.sessions ?? [])
} catch (error) {
showToast({
title: 'Sitzungen konnten nicht geladen werden',
message:
error instanceof Error
? error.message
: 'Sitzungen konnten nicht geladen werden',
variant: 'error',
})
} finally {
setIsLoadingSessions(false)
}
}
async function handleRevokeSession(sessionId: string) {
setRevokingSessionId(sessionId)
try {
const response = await fetch(`${API_URL}/settings/sessions/${sessionId}`, {
method: 'DELETE',
credentials: 'include',
})
if (!response.ok) {
throw new Error(
await readApiError(response, 'Sitzung konnte nicht abgemeldet werden'),
)
}
await loadSessions()
showToast({
title: 'Sitzung abgemeldet',
message: 'Die ausgewählte Sitzung wurde abgemeldet.',
variant: 'success',
})
} catch (error) {
showToast({
title: 'Sitzung konnte nicht abgemeldet werden',
message:
error instanceof Error
? error.message
: 'Sitzung konnte nicht abgemeldet werden',
variant: 'error',
autoClose: false,
})
} finally {
setRevokingSessionId(null)
}
}
async function handleKontoSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const form = event.currentTarget
const formData = new FormData(form)
setIsSavingKonto(true)
try {
const response = await fetch(`${API_URL}/settings/account`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
displayName: String(formData.get('displayName') ?? ''),
username: String(formData.get('username') ?? ''),
email: String(formData.get('email') ?? ''),
avatar: String(formData.get('avatar') ?? ''),
unit: String(formData.get('unit') ?? ''),
group: String(formData.get('group') ?? ''),
}),
})
if (!response.ok) {
throw new Error(
await readApiError(response, 'Profil konnte nicht gespeichert werden'),
)
}
const data = await response.json().catch(() => null)
const nextUser = data?.user as User | undefined
if (nextUser) {
setCurrentUser(nextUser)
onUserUpdated?.(nextUser)
}
showToast({
title: 'Profil gespeichert',
message: 'Deine Profildaten wurden gespeichert.',
variant: 'success',
})
} catch (error) {
showToast({
title: 'Profil konnte nicht gespeichert werden',
message:
error instanceof Error
? error.message
: 'Profil konnte nicht gespeichert werden',
variant: 'error',
autoClose: false,
})
} finally {
setIsSavingKonto(false)
}
}
async function handlePasswordSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const form = event.currentTarget
const formData = new FormData(form)
setIsSavingPassword(true)
try {
const response = await fetch(`${API_URL}/settings/password`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
currentPassword: String(formData.get('currentPassword') ?? ''),
newPassword: String(formData.get('newPassword') ?? ''),
confirmPassword: String(formData.get('confirmPassword') ?? ''),
}),
})
if (!response.ok) {
throw new Error(
await readApiError(response, 'Passwort konnte nicht gespeichert werden'),
)
}
form.reset()
showToast({
title: 'Passwort gespeichert',
message: 'Dein Passwort wurde erfolgreich geändert.',
variant: 'success',
})
} catch (error) {
showToast({
title: 'Passwort konnte nicht gespeichert werden',
message:
error instanceof Error
? error.message
: 'Passwort konnte nicht gespeichert werden',
variant: 'error',
autoClose: false,
})
} finally {
setIsSavingPassword(false)
}
}
async function handleLogoutSessionsSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const form = event.currentTarget
const formData = new FormData(form)
setIsLoggingOutSessions(true)
try {
const response = await fetch(`${API_URL}/settings/logout-other-sessions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
password: String(formData.get('password') ?? ''),
}),
})
if (!response.ok) {
throw new Error(
await readApiError(
response,
'Andere Sitzungen konnten nicht abgemeldet werden',
),
)
}
form.reset()
showToast({
title: 'Andere Sitzungen abgemeldet',
message: 'Alle anderen aktiven Sitzungen wurden abgemeldet.',
variant: 'success',
})
await loadSessions()
} catch (error) {
showToast({
title: 'Andere Sitzungen konnten nicht abgemeldet werden',
message:
error instanceof Error
? error.message
: 'Andere Sitzungen konnten nicht abgemeldet werden',
variant: 'error',
autoClose: false,
})
} finally {
setIsLoggingOutSessions(false)
}
}
async function handleDeleteKontoSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const form = event.currentTarget
const formData = new FormData(form)
const confirmed = window.confirm(
'Konto wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.',
)
if (!confirmed) {
return
}
setIsDeletingKonto(true)
try {
const response = await fetch(`${API_URL}/settings/account`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
password: String(formData.get('password') ?? ''),
}),
})
if (!response.ok) {
throw new Error(
await readApiError(response, 'Konto konnte nicht gelöscht werden'),
)
}
window.location.href = '/login'
} catch (error) {
showToast({
title: 'Konto konnte nicht gelöscht werden',
message:
error instanceof Error
? error.message
: 'Konto konnte nicht gelöscht werden',
variant: 'error',
autoClose: false,
})
} finally {
setIsDeletingKonto(false)
}
}
return (
<>
<Notifications
notifications={toasts}
onDismiss={dismissToast}
position="top-right"
/>
<div className="divide-y divide-gray-200 dark:divide-white/10">
<Section
title="Persönliche Informationen"
description="Verwalte deine Profildaten und Kontoinformationen."
>
<form onSubmit={handleKontoSubmit}>
<div className="grid grid-cols-1 gap-x-6 gap-y-8 sm:max-w-xl sm:grid-cols-6">
<div className="col-span-full flex items-center gap-x-8">
<img
alt=""
src={avatarUrl}
className="size-24 flex-none rounded-lg bg-gray-100 object-cover outline -outline-offset-1 outline-black/5 dark:bg-gray-800 dark:outline-white/10"
/>
<div className="min-w-0 flex-1">
<label
htmlFor="avatar"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Avatar-URL
</label>
<div className="mt-2">
<input
id="avatar"
name="avatar"
type="url"
defaultValue={currentUser?.avatar ?? ''}
placeholder="https://..."
className={inputClassName}
/>
</div>
<p className="mt-2 text-xs/5 text-gray-500 dark:text-gray-400">
Gib eine Bild-URL für dein Profilbild an.
</p>
</div>
</div>
<div className="sm:col-span-3">
<label
htmlFor="displayName"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Anzeigename
</label>
<div className="mt-2">
<input
id="displayName"
name="displayName"
type="text"
defaultValue={currentUser?.displayName ?? ''}
className={inputClassName}
/>
</div>
</div>
<div className="sm:col-span-3">
<label
htmlFor="username"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Benutzername
</label>
<div className="mt-2">
<input
id="username"
name="username"
type="text"
required
defaultValue={currentUser?.username ?? ''}
className={inputClassName}
/>
</div>
</div>
<div className="col-span-full">
<label
htmlFor="email"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
E-Mail-Adresse
</label>
<div className="mt-2">
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
defaultValue={currentUser?.email ?? ''}
className={inputClassName}
/>
</div>
</div>
<div className="sm:col-span-3">
<label
htmlFor="unit"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Einheit
</label>
<div className="mt-2">
<input
id="unit"
name="unit"
type="text"
defaultValue={currentUser?.unit ?? ''}
className={inputClassName}
/>
</div>
</div>
<div className="sm:col-span-3">
<label
htmlFor="group"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Gruppe
</label>
<div className="mt-2">
<input
id="group"
name="group"
type="text"
defaultValue={currentUser?.group ?? ''}
className={inputClassName}
/>
</div>
</div>
</div>
<div className="mt-8 flex">
<Button
type="submit"
color="indigo"
size="md"
isLoading={isSavingKonto}
>
Speichern
</Button>
</div>
</form>
</Section>
<Section
title="Passwort ändern"
description="Aktualisiere das Passwort deines Benutzerkontos."
>
<form onSubmit={handlePasswordSubmit}>
<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">
<label
htmlFor="current-password"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Aktuelles Passwort
</label>
<div className="mt-2">
<input
id="current-password"
name="currentPassword"
type="password"
autoComplete="current-password"
required
className={inputClassName}
/>
</div>
</div>
<div className="col-span-full">
<label
htmlFor="new-password"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Neues Passwort
</label>
<div className="mt-2">
<input
id="new-password"
name="newPassword"
type="password"
autoComplete="new-password"
minLength={8}
required
className={inputClassName}
/>
</div>
</div>
<div className="col-span-full">
<label
htmlFor="confirm-password"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Neues Passwort bestätigen
</label>
<div className="mt-2">
<input
id="confirm-password"
name="confirmPassword"
type="password"
autoComplete="new-password"
minLength={8}
required
className={inputClassName}
/>
</div>
</div>
</div>
<div className="mt-8 flex">
<Button
type="submit"
color="indigo"
size="md"
isLoading={isSavingPassword}
>
Passwort speichern
</Button>
</div>
</form>
</Section>
<Section
title="Sitzungen"
description="Sieh dir deine aktiven Sitzungen an und melde andere Browser ab."
>
<div className="sm:max-w-2xl">
{isLoadingSessions && (
<div className="rounded-md border border-dashed border-gray-300 p-4 text-sm text-gray-500 dark:border-white/10 dark:text-gray-400">
Sitzungen werden geladen...
</div>
)}
{!isLoadingSessions && sessions.length > 0 && (
<div className="space-y-3">
{sessions.map((session) => (
<div
key={session.id}
className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5"
>
<div className="flex items-start gap-x-4">
<div className="flex size-11 shrink-0 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600 ring-1 ring-indigo-600/10 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20">
<ComputerDesktopIcon
aria-hidden="true"
className="size-6"
/>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-x-4">
<div>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
{session.current ? 'Aktuelle Sitzung' : 'Weitere Sitzung'}
</h3>
<p className="mt-1 text-sm text-gray-700 dark:text-gray-300">
{session.browser} auf {session.operatingSystem}
</p>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{session.deviceType}
{session.ipAddress ? ` · ${session.ipAddress}` : ''}
</p>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Zuletzt aktiv:{' '}
{new Intl.DateTimeFormat('de-DE', {
dateStyle: 'short',
timeStyle: 'short',
}).format(new Date(session.lastSeenAt))}
</p>
</div>
{session.current ? (
<span className="inline-flex shrink-0 items-center gap-x-1.5 rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-green-600/20 ring-inset dark:bg-green-900/30 dark:text-green-300 dark:ring-green-500/30">
<ShieldCheckIcon
aria-hidden="true"
className="size-3.5"
/>
Aktiv
</span>
) : (
<Button
type="button"
variant="secondary"
color="red"
size="sm"
isLoading={revokingSessionId === session.id}
onClick={() => void handleRevokeSession(session.id)}
>
Abmelden
</Button>
)}
</div>
</div>
</div>
</div>
))}
</div>
)}
{!isLoadingSessions && sessions.length === 0 && (
<div className="rounded-md border border-dashed border-gray-300 p-4 text-sm text-gray-500 dark:border-white/10 dark:text-gray-400">
Keine aktiven Sitzungen gefunden.
</div>
)}
<form
onSubmit={handleLogoutSessionsSubmit}
className="mt-8 border-t border-gray-200 pt-6 dark:border-white/10"
>
<label
htmlFor="logout-password"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Dein Passwort
</label>
<div className="mt-2">
<input
id="logout-password"
name="password"
type="password"
autoComplete="current-password"
required
className={inputClassName}
/>
</div>
<p className="mt-2 text-xs/5 text-gray-500 dark:text-gray-400">
Dadurch werden alle anderen aktiven Sitzungen abgemeldet. Diese Sitzung bleibt aktiv.
</p>
<div className="mt-6 flex">
<Button
type="submit"
color="indigo"
size="md"
isLoading={isLoggingOutSessions}
>
Andere Sitzungen abmelden
</Button>
</div>
</form>
</div>
</Section>
<Section
title="Konto löschen"
description="Das Löschen deines Kontos kann nicht rückgängig gemacht werden."
>
<form onSubmit={handleDeleteKontoSubmit}>
<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">
<label
htmlFor="delete-konto-password"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Dein Passwort
</label>
<div className="mt-2">
<input
id="delete-konto-password"
name="password"
type="password"
autoComplete="current-password"
required
className={inputClassName}
/>
</div>
</div>
</div>
<div className="mt-8 flex">
<Button
type="submit"
color="red"
size="md"
isLoading={isDeletingKonto}
>
Konto löschen
</Button>
</div>
</form>
</Section>
</div>
</>
)
}