This commit is contained in:
Linrador 2026-06-22 13:57:42 +02:00
parent cd26d67252
commit cf97fd3a18
56 changed files with 9596 additions and 3638 deletions

View File

@ -5,7 +5,9 @@
"PowerShell(Set-Location \"c:\\\\Users\\\\Rother\\\\fork\\\\teg\\\\frontend\"; npx tsc --noEmit 2>&1 | Select-Object -First 40; \"TSC EXIT: $LASTEXITCODE\")",
"PowerShell(Set-Location \"c:\\\\Users\\\\Rother\\\\fork\\\\teg\\\\frontend\"; npx tsc --noEmit 2>&1 | Select-Object -First 30; \"TSC EXIT: $LASTEXITCODE\")",
"Bash(npx tsc *)",
"Bash(echo \"EXIT:$?\")"
"Bash(echo \"EXIT:$?\")",
"Bash(go build *)",
"Bash(xargs grep -l \"upgrade\")"
]
}
}

View File

@ -1,7 +1,7 @@
PORT=8080
DATABASE_URL=postgres://postgres:Timmy0104199%3F@localhost:5432/teg?sslmode=disable
JWT_SECRET=tegvideo7010!
FRONTEND_ORIGIN=https://localhost:5173
FRONTEND_ORIGIN=https://10.0.1.25:5173
ADDRESS_SEARCH_PROVIDER=photon
ADDRESS_SEARCH_URL=https://photon.komoot.io/api
ADDRESS_SEARCH_LANGUAGE=de

View File

@ -1,4 +1,4 @@
// backend/admin.go
// backend\admin.go
package main
@ -83,15 +83,13 @@ func ensureAdminUserLookupValues(ctx context.Context, tx pgx.Tx, unit string) er
func normalizeAdminTeamInput(input *AdminTeamRequest) {
input.Name = strings.TrimSpace(input.Name)
input.Initial = strings.TrimSpace(input.Initial)
input.Href = strings.TrimSpace(input.Href)
if input.Initial == "" && input.Name != "" {
input.Initial = strings.ToUpper(string([]rune(input.Name)[0]))
}
if input.Href == "" {
input.Href = "#"
}
// Der Link entspricht immer dem Teamnamen; ein separates Feld gibt es nicht mehr.
input.Href = input.Name
}
func cleanStringList(values []string) []string {

View File

@ -15,6 +15,7 @@ type AdminGroupChat struct {
Name string `json:"name"`
Image string `json:"image"`
OwnerID string `json:"ownerId"`
TeamID string `json:"teamId"`
UserIDs []string `json:"userIds"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
@ -35,6 +36,7 @@ func (s *Server) handleAdminListGroupChats(w http.ResponseWriter, r *http.Reques
c.name,
c.channel_image,
COALESCE(c.created_by::TEXT, ''),
COALESCE(c.team_id::TEXT, ''),
COALESCE(
array_agg(cm.user_id::TEXT ORDER BY cm.created_at)
FILTER (WHERE cm.user_id IS NOT NULL),
@ -45,9 +47,8 @@ func (s *Server) handleAdminListGroupChats(w http.ResponseWriter, r *http.Reques
FROM conversations c
LEFT JOIN conversation_members cm ON cm.conversation_id = c.id
WHERE c.type = 'group'
AND c.team_id IS NULL
GROUP BY c.id
ORDER BY lower(c.name), c.created_at
ORDER BY (c.team_id IS NOT NULL), lower(c.name), c.created_at
`,
)
if err != nil {
@ -64,6 +65,7 @@ func (s *Server) handleAdminListGroupChats(w http.ResponseWriter, r *http.Reques
&group.Name,
&group.Image,
&group.OwnerID,
&group.TeamID,
&group.UserIDs,
&group.CreatedAt,
&group.UpdatedAt,
@ -315,6 +317,75 @@ func (s *Server) handleAdminDeleteGroupChat(w http.ResponseWriter, r *http.Reque
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// handleAdminEmptyGroupChat löscht den gesamten Nachrichtenverlauf eines
// Gruppenchats (inkl. Anhänge über ON DELETE CASCADE), behält den Chat aber.
// Für Team-Gruppenchats ist dies die einzige zulässige Admin-Aktion.
func (s *Server) handleAdminEmptyGroupChat(w http.ResponseWriter, r *http.Request) {
conversationID := strings.TrimSpace(r.PathValue("id"))
if conversationID == "" {
writeError(w, http.StatusBadRequest, "Gruppenchat-ID fehlt")
return
}
tx, err := s.db.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "Gruppenchat konnte nicht geleert werden")
return
}
defer tx.Rollback(r.Context())
var exists bool
if err := tx.QueryRow(
r.Context(),
`SELECT true FROM conversations WHERE id = $1 AND type = 'group'`,
conversationID,
).Scan(&exists); err != nil {
if err == pgx.ErrNoRows {
writeError(w, http.StatusNotFound, "Gruppenchat wurde nicht gefunden")
return
}
writeError(w, http.StatusInternalServerError, "Gruppenchat konnte nicht geladen werden")
return
}
if _, err := tx.Exec(
r.Context(),
`DELETE FROM notifications WHERE entity_type = 'chat' AND entity_id = $1`,
conversationID,
); err != nil {
writeError(w, http.StatusInternalServerError, "Chat-Benachrichtigungen konnten nicht gelöscht werden")
return
}
if _, err := tx.Exec(
r.Context(),
`DELETE FROM messages WHERE conversation_id = $1`,
conversationID,
); err != nil {
writeError(w, http.StatusInternalServerError, "Nachrichten konnten nicht gelöscht werden")
return
}
if _, err := tx.Exec(
r.Context(),
`UPDATE conversations SET last_message_at = NULL, updated_at = now() WHERE id = $1`,
conversationID,
); err != nil {
writeError(w, http.StatusInternalServerError, "Gruppenchat konnte nicht aktualisiert werden")
return
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "Gruppenchat konnte nicht geleert werden")
return
}
s.publishChatMessagesReload(r.Context(), conversationID)
s.publishConversationUpdate(r.Context(), conversationID)
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func adminGroupMemberIDs(ctx context.Context, tx pgx.Tx, conversationID string) ([]string, error) {
rows, err := tx.Query(
ctx,

View File

@ -291,13 +291,6 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re
// der Schritte fehl, bleibt der bisherige Token unverändert und es wird eine
// Warnung zurückgegeben (das Speichern der übrigen Einstellungen bleibt gültig).
func (s *Server) rotateServerFoldersAgentTokenAfterSave(ctx context.Context) string {
newToken, err := generateServerFoldersAgentToken()
if err != nil {
log.Printf("server-folders agent token generation failed: %v", err)
return "Der Server-Folders-Agent-Token konnte nicht erzeugt werden."
}
config, err := s.getServerFoldersAgentConfig(ctx)
if err != nil {
log.Printf("server-folders agent config load failed: %v", err)
@ -305,29 +298,12 @@ func (s *Server) rotateServerFoldersAgentTokenAfterSave(ctx context.Context) str
return "Der Server-Folders-Agent-Token konnte nicht ausgetauscht werden."
}
if err := s.pushServerFoldersAgentToken(ctx, config, newToken); err != nil {
if _, err := s.rotateServerFoldersAgentToken(ctx, config); err != nil {
log.Printf("server-folders agent token rotation failed: %v", err)
return "Der Server-Folders-Agent ist nicht erreichbar. Der Token wurde nicht ausgetauscht."
}
if _, err := s.db.Exec(
ctx,
`
UPDATE milestone_settings
SET
server_folders_agent_token_encrypted = pgp_sym_encrypt($1, $2),
updated_at = now()
WHERE id = 1
`,
newToken,
s.milestoneEncryptionKey(),
); err != nil {
log.Printf("server-folders agent token persist failed: %v", err)
return "Der neue Server-Folders-Agent-Token konnte nicht gespeichert werden."
}
return ""
}

View File

@ -62,6 +62,7 @@ type AdminChannelUser struct {
DisplayName string `json:"displayName"`
Email string `json:"email"`
Unit string `json:"unit"`
Avatar string `json:"avatar"`
}
type saveAdminChannelRequest struct {
@ -218,7 +219,8 @@ func (s *Server) listAdminChannelUsers(ctx context.Context) ([]AdminChannelUser,
COALESCE(username, ''),
COALESCE(display_name, ''),
email,
COALESCE(unit, '')
COALESCE(unit, ''),
COALESCE(avatar, '')
FROM users
ORDER BY lower(COALESCE(NULLIF(display_name, ''), username, email))
`,
@ -237,6 +239,7 @@ func (s *Server) listAdminChannelUsers(ctx context.Context) ([]AdminChannelUser,
&user.DisplayName,
&user.Email,
&user.Unit,
&user.Avatar,
); err != nil {
return nil, err
}

View File

@ -284,6 +284,7 @@ func (s *Server) handleListChatConversations(w http.ResponseWriter, r *http.Requ
AND current_membership.user_id = $1
WHERE unread_message.conversation_id = c.id
AND unread_message.deleted_at IS NULL
AND unread_message.message_type <> 'system'
AND unread_message.sender_id IS DISTINCT FROM $1::UUID
AND (unread_message.expires_at IS NULL OR unread_message.expires_at > now())
AND unread_message.created_at > COALESCE(
@ -1002,6 +1003,7 @@ func (s *Server) getChatConversation(ctx context.Context, conversationID string,
AND current_membership.user_id = $2
WHERE unread_message.conversation_id = c.id
AND unread_message.deleted_at IS NULL
AND unread_message.message_type <> 'system'
AND unread_message.sender_id IS DISTINCT FROM $2::UUID
AND (unread_message.expires_at IS NULL OR unread_message.expires_at > now())
AND unread_message.created_at > COALESCE(

View File

@ -41,6 +41,7 @@ func (s *Server) getChatUnreadCount(ctx context.Context, userID string) (int, er
ON cm.conversation_id = c.id
AND cm.user_id = $1
WHERE m.deleted_at IS NULL
AND m.message_type <> 'system'
AND (m.expires_at IS NULL OR m.expires_at > now())
AND m.sender_id IS DISTINCT FROM $1::UUID
AND m.created_at > COALESCE(cm.last_read_at, '-infinity'::TIMESTAMPTZ)

View File

@ -1,3 +1,5 @@
// backend\chat_websocket.go
package main
import (
@ -5,6 +7,7 @@ import (
"encoding/json"
"errors"
"net/http"
"net/url"
"strings"
"sync"
"time"
@ -99,9 +102,17 @@ func (s *Server) handleChatWebSocket(w http.ResponseWriter, r *http.Request) {
return
}
// coder/websocket matcht OriginPatterns gegen den Host der Origin (z. B.
// "localhost:5173"). Daher nur den Host übergeben schema-unabhängig, damit
// http/https sowie ein Dev-Proxy mit geändertem Host (changeOrigin) den
// Upgrade nicht blockieren.
originPatterns := []string{}
if frontendOrigin := strings.TrimRight(s.frontendOrigin, "/"); frontendOrigin != "" {
originPatterns = append(originPatterns, frontendOrigin)
host := frontendOrigin
if parsed, err := url.Parse(frontendOrigin); err == nil && parsed.Host != "" {
host = parsed.Host
}
originPatterns = append(originPatterns, host)
}
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{

View File

@ -21,9 +21,15 @@ type Device struct {
SerialNumber string `json:"serialNumber"`
MacAddress string `json:"macAddress"`
IPAddress string `json:"ipAddress"`
PhoneNumber string `json:"phoneNumber"`
ComputerName string `json:"computerName"`
DeviceCategory string `json:"deviceCategory"`
MilestonePort string `json:"milestonePort"`
Location string `json:"location"`
LoanStatus string `json:"loanStatus"`
LoanedToUserID string `json:"loanedToUserId"`
LoanedToDisplayName string `json:"loanedToDisplayName"`
LoanedUntil string `json:"loanedUntil"`
IsBaoDevice bool `json:"isBaoDevice"`
Comment string `json:"comment"`
FirmwareVersion string `json:"firmwareVersion"`
@ -64,6 +70,15 @@ type DeviceShort struct {
Model string `json:"model"`
}
type DeviceLoanUser struct {
ID string `json:"id"`
Username string `json:"username"`
DisplayName string `json:"displayName"`
Email string `json:"email"`
Avatar string `json:"avatar"`
Unit string `json:"unit"`
}
type MilestoneHardwareChildDevice struct {
ID string `json:"id"`
MilestoneHardwareID string `json:"milestoneHardwareId"`
@ -85,12 +100,17 @@ type CreateDeviceRequest struct {
SerialNumber string `json:"serialNumber"`
MacAddress string `json:"macAddress"`
IPAddress string `json:"ipAddress"`
PhoneNumber string `json:"phoneNumber"`
ComputerName string `json:"computerName"`
DeviceCategory string `json:"deviceCategory"`
MilestonePort string `json:"milestonePort"`
FirmwareVersion string `json:"firmwareVersion"`
MilestoneDisplayName string `json:"milestoneDisplayName"`
MilestoneEnabled *bool `json:"milestoneEnabled,omitempty"`
Location string `json:"location"`
LoanStatus string `json:"loanStatus"`
LoanedToUserID string `json:"loanedToUserId"`
LoanedUntil string `json:"loanedUntil"`
IsBaoDevice bool `json:"isBaoDevice"`
Comment string `json:"comment"`
RelatedDeviceIDs []string `json:"relatedDeviceIds"`
@ -115,37 +135,94 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) {
}
}
func (s *Server) handleListDeviceLoanUsers(w http.ResponseWriter, r *http.Request) {
rows, err := s.db.Query(
r.Context(),
`
SELECT
id::TEXT,
COALESCE(username, ''),
COALESCE(display_name, ''),
email,
COALESCE(avatar, ''),
COALESCE(unit, '')
FROM users
ORDER BY lower(COALESCE(NULLIF(display_name, ''), username, email)), lower(email)
`,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Benutzer konnten nicht geladen werden")
return
}
defer rows.Close()
users := []DeviceLoanUser{}
for rows.Next() {
var user DeviceLoanUser
if err := rows.Scan(
&user.ID,
&user.Username,
&user.DisplayName,
&user.Email,
&user.Avatar,
&user.Unit,
); err != nil {
writeError(w, http.StatusInternalServerError, "Benutzer konnten nicht gelesen werden")
return
}
users = append(users, user)
}
if err := rows.Err(); err != nil {
writeError(w, http.StatusInternalServerError, "Benutzer konnten nicht vollständig geladen werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{"users": users})
}
func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
rows, err := s.db.Query(
r.Context(),
`
SELECT
id,
inventory_number,
manufacturer,
model,
serial_number,
mac_address,
COALESCE(ip_address, ''),
COALESCE(milestone_port, ''),
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
devices.id,
COALESCE(devices.inventory_number, ''),
COALESCE(devices.manufacturer, ''),
COALESCE(devices.model, ''),
COALESCE(devices.serial_number, ''),
COALESCE(devices.mac_address, ''),
COALESCE(devices.ip_address, ''),
COALESCE(devices.phone_number, ''),
COALESCE(devices.computer_name, ''),
COALESCE(devices.device_category, ''),
COALESCE(devices.milestone_port, ''),
COALESCE(devices.location, ''),
COALESCE(devices.loan_status, 'Verfügbar'),
COALESCE(devices.loaned_to_user_id::TEXT, ''),
COALESCE(NULLIF(loaned_user.display_name, ''), NULLIF(loaned_user.username, ''), loaned_user.email, ''),
COALESCE(devices.loaned_until::TEXT, ''),
COALESCE(devices.is_bao_device, false),
COALESCE(devices.comment, ''),
COALESCE(devices.firmware_version, ''),
COALESCE(devices.milestone_recording_server_id, ''),
COALESCE(devices.milestone_hardware_id, ''),
COALESCE(devices.milestone_display_name, ''),
COALESCE(devices.milestone_enabled, true),
devices.milestone_synced_at,
devices.created_at,
devices.updated_at
FROM devices
ORDER BY inventory_number ASC
LEFT JOIN users AS loaned_user
ON loaned_user.id = devices.loaned_to_user_id
ORDER BY devices.inventory_number ASC
`,
)
if err != nil {
logError("Geräte konnten nicht geladen werden", err, nil)
writeError(w, http.StatusInternalServerError, "Could not load devices")
return
}
@ -165,9 +242,15 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
&device.SerialNumber,
&device.MacAddress,
&device.IPAddress,
&device.PhoneNumber,
&device.ComputerName,
&device.DeviceCategory,
&device.MilestonePort,
&device.Location,
&device.LoanStatus,
&device.LoanedToUserID,
&device.LoanedToDisplayName,
&device.LoanedUntil,
&device.IsBaoDevice,
&device.Comment,
&device.FirmwareVersion,
@ -179,6 +262,7 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
&device.CreatedAt,
&device.UpdatedAt,
); err != nil {
logError("Geräte konnten nicht gelesen werden", err, nil)
writeError(w, http.StatusInternalServerError, "Could not read devices")
return
}
@ -191,6 +275,7 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
}
if err := rows.Err(); err != nil {
logError("Geräte konnten nicht vollständig gelesen werden", err, nil)
writeError(w, http.StatusInternalServerError, "Could not read devices")
return
}
@ -201,9 +286,9 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
SELECT
device_relations.device_id,
related_devices.id,
related_devices.inventory_number,
related_devices.manufacturer,
related_devices.model
COALESCE(related_devices.inventory_number, ''),
COALESCE(related_devices.manufacturer, ''),
COALESCE(related_devices.model, '')
FROM device_relations
INNER JOIN devices AS related_devices
ON related_devices.id = device_relations.related_device_id
@ -212,6 +297,7 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
)
if err != nil {
logError("Geräte-Zuordnungen konnten nicht geladen werden", err, nil)
writeError(w, http.StatusInternalServerError, "Could not load device relations")
return
}
@ -228,6 +314,7 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
&relatedDevice.Manufacturer,
&relatedDevice.Model,
); err != nil {
logError("Geräte-Zuordnungen konnten nicht gelesen werden", err, nil)
writeError(w, http.StatusInternalServerError, "Could not read device relations")
return
}
@ -242,6 +329,7 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
}
if err := relationRows.Err(); err != nil {
logError("Geräte-Zuordnungen konnten nicht vollständig gelesen werden", err, nil)
writeError(w, http.StatusInternalServerError, "Could not read device relations")
return
}
@ -252,14 +340,14 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
SELECT
devices.id::TEXT,
milestone_hardware_child_devices.id::TEXT,
milestone_hardware_child_devices.milestone_hardware_id,
milestone_hardware_child_devices.milestone_device_id,
milestone_hardware_child_devices.device_type,
milestone_hardware_child_devices.name,
milestone_hardware_child_devices.display_name,
milestone_hardware_child_devices.short_name,
milestone_hardware_child_devices.enabled,
COALESCE(milestone_hardware_child_devices.recording_enabled, false),
COALESCE(milestone_hardware_child_devices.milestone_hardware_id, ''),
COALESCE(milestone_hardware_child_devices.milestone_device_id, ''),
COALESCE(milestone_hardware_child_devices.device_type, ''),
COALESCE(milestone_hardware_child_devices.name, ''),
COALESCE(milestone_hardware_child_devices.display_name, ''),
COALESCE(milestone_hardware_child_devices.short_name, ''),
COALESCE(milestone_hardware_child_devices.enabled, true),
COALESCE(milestone_hardware_child_devices.recording_enabled, false),
milestone_hardware_child_devices.created_at,
milestone_hardware_child_devices.updated_at
FROM milestone_hardware_child_devices
@ -273,6 +361,7 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
`,
)
if err != nil {
logError("Milestone-Child-Devices konnten nicht geladen werden", err, nil)
writeError(w, http.StatusInternalServerError, "Could not load Milestone child devices")
return
}
@ -296,6 +385,7 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
&childDevice.CreatedAt,
&childDevice.UpdatedAt,
); err != nil {
logError("Milestone-Child-Devices konnten nicht gelesen werden", err, nil)
writeError(w, http.StatusInternalServerError, "Could not read Milestone child devices")
return
}
@ -310,6 +400,7 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
}
if err := childRows.Err(); err != nil {
logError("Milestone-Child-Devices konnten nicht vollständig gelesen werden", err, nil)
writeError(w, http.StatusInternalServerError, "Could not read Milestone child devices")
return
}
@ -366,14 +457,19 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) {
serial_number,
mac_address,
ip_address,
phone_number,
computer_name,
device_category,
milestone_port,
location,
loan_status,
loaned_to_user_id,
loaned_until,
is_bao_device,
comment,
firmware_version
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NULLIF($13, '')::UUID, NULLIF($14, '')::DATE, $15, $16, $17)
RETURNING
id,
inventory_number,
@ -382,9 +478,15 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) {
serial_number,
mac_address,
COALESCE(ip_address, ''),
COALESCE(phone_number, ''),
COALESCE(computer_name, ''),
COALESCE(device_category, ''),
COALESCE(milestone_port, ''),
location,
loan_status,
COALESCE(loaned_to_user_id::TEXT, ''),
'',
COALESCE(loaned_until::TEXT, ''),
is_bao_device,
comment,
COALESCE(firmware_version, ''),
@ -402,9 +504,14 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) {
input.SerialNumber,
input.MacAddress,
input.IPAddress,
input.PhoneNumber,
input.ComputerName,
input.DeviceCategory,
input.MilestonePort,
input.Location,
input.LoanStatus,
input.LoanedToUserID,
input.LoanedUntil,
input.IsBaoDevice,
input.Comment,
input.FirmwareVersion,
@ -416,9 +523,15 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) {
&device.SerialNumber,
&device.MacAddress,
&device.IPAddress,
&device.PhoneNumber,
&device.ComputerName,
&device.DeviceCategory,
&device.MilestonePort,
&device.Location,
&device.LoanStatus,
&device.LoanedToUserID,
&device.LoanedToDisplayName,
&device.LoanedUntil,
&device.IsBaoDevice,
&device.Comment,
&device.FirmwareVersion,
@ -545,6 +658,9 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
}
if err != nil {
logError("Device history state konnte nicht geladen werden", err, LogFields{
"deviceId": deviceID,
})
writeError(w, http.StatusInternalServerError, "Could not load device history state")
return
}
@ -567,21 +683,26 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
serial_number = $5,
mac_address = $6,
ip_address = $7,
milestone_port = $8,
location = $9,
loan_status = $10,
is_bao_device = $11,
comment = $12,
firmware_version = $13,
milestone_display_name = $14,
milestone_enabled = $15,
phone_number = $8,
computer_name = $9,
device_category = $10,
milestone_port = $11,
location = $12,
loan_status = $13,
loaned_to_user_id = NULLIF($14, '')::UUID,
loaned_until = NULLIF($15, '')::DATE,
is_bao_device = $16,
comment = $17,
firmware_version = $18,
milestone_display_name = $19,
milestone_enabled = $20,
milestone_synced_at = CASE
WHEN COALESCE(milestone_hardware_id, '') <> ''
AND (
COALESCE(milestone_display_name, '') <> $14
COALESCE(milestone_display_name, '') <> $19
OR COALESCE(ip_address, '') <> $7
OR COALESCE(milestone_port, '') <> $8
OR milestone_enabled <> $15
OR COALESCE(milestone_port, '') <> $11
OR milestone_enabled <> $20
)
THEN now()
ELSE milestone_synced_at
@ -596,9 +717,15 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
serial_number,
mac_address,
COALESCE(ip_address, ''),
COALESCE(phone_number, ''),
COALESCE(computer_name, ''),
COALESCE(device_category, ''),
COALESCE(milestone_port, ''),
location,
loan_status,
COALESCE(loaned_to_user_id::TEXT, ''),
'',
COALESCE(loaned_until::TEXT, ''),
is_bao_device,
comment,
COALESCE(firmware_version, ''),
@ -617,9 +744,14 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
input.SerialNumber,
input.MacAddress,
input.IPAddress,
input.PhoneNumber,
input.ComputerName,
input.DeviceCategory,
input.MilestonePort,
input.Location,
input.LoanStatus,
input.LoanedToUserID,
input.LoanedUntil,
input.IsBaoDevice,
input.Comment,
input.FirmwareVersion,
@ -633,9 +765,15 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
&device.SerialNumber,
&device.MacAddress,
&device.IPAddress,
&device.PhoneNumber,
&device.ComputerName,
&device.DeviceCategory,
&device.MilestonePort,
&device.Location,
&device.LoanStatus,
&device.LoanedToUserID,
&device.LoanedToDisplayName,
&device.LoanedUntil,
&device.IsBaoDevice,
&device.Comment,
&device.FirmwareVersion,
@ -756,16 +894,26 @@ func normalizeDeviceInput(input *CreateDeviceRequest) {
input.SerialNumber = strings.TrimSpace(input.SerialNumber)
input.MacAddress = strings.TrimSpace(input.MacAddress)
input.IPAddress = strings.TrimSpace(input.IPAddress)
input.PhoneNumber = strings.TrimSpace(input.PhoneNumber)
input.ComputerName = strings.TrimSpace(input.ComputerName)
input.DeviceCategory = strings.TrimSpace(input.DeviceCategory)
input.MilestonePort = strings.TrimSpace(input.MilestonePort)
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.LoanedToUserID = strings.TrimSpace(input.LoanedToUserID)
input.LoanedUntil = strings.TrimSpace(input.LoanedUntil)
input.Comment = strings.TrimSpace(input.Comment)
if input.LoanStatus == "" {
input.LoanStatus = "Verfügbar"
}
if !strings.EqualFold(input.LoanStatus, "Ausgeliehen") {
input.LoanedToUserID = ""
input.LoanedUntil = ""
}
}
func syncDeviceRelations(ctx context.Context, tx pgx.Tx, deviceID string, relatedDeviceIDs []string) error {

View File

@ -1,4 +1,96 @@
# Server Folders mit Parametern
# Server Folders Agent per JSON
## Agent starten
Lege `server-folders-agent.json` neben die `server-folders-agent.exe` und starte den Agent ohne weitere Parameter:
```powershell
.\server-folders-agent.exe
```
Beispiel:
```json
{
"listenAddr": ":8099",
"storageRoots": [
{
"label": "Storage D",
"path": "D:\\Milestone\\Storage"
}
],
"archiveRoots": [
{
"label": "Archive E",
"path": "E:\\Milestone\\Archive"
}
],
"token": ""
}
```
Alternativ kann die Config explizit angegeben werden:
```powershell
.\server-folders-agent.exe -config "C:\Milestone\server-folders-agent.json"
```
Oder per ENV:
```powershell
$env:SERVER_FOLDER_AGENT_CONFIG="C:\Milestone\server-folders-agent.json"
.\server-folders-agent.exe
```
## Token-Handling
Der Agent kann ohne initialen Token gestartet werden. Das Backend erzeugt beim Speichern der Milestone-Einstellungen automatisch einen zufälligen Token und überträgt ihn per `PUT /agent-token` an den Agent.
Wenn keine `tokenFile` in der JSON gesetzt ist, schreibt der Agent den aktuellen Token direkt ins Feld `token` der JSON-Datei. Damit überlebt der Token einen Neustart und alle Einstellungen liegen in einer Datei.
```http
PUT /agent-token
X-Server-Folders-Token: <aktueller-token> # nur nötig, wenn bereits ein Token gesetzt ist
Content-Type: application/json
{ "token": "<neuer-token>" }
```
Solange noch kein Token gesetzt ist, akzeptiert der Agent `PUT /agent-token` auch ohne Header. Betreibe den Agent daher in einem vertrauenswürdigen Netzwerk und speichere die Einstellungen nach dem Start zeitnah.
Falls du den Token weiterhin getrennt speichern möchtest, kannst du in der JSON `tokenFile` setzen:
```json
{
"listenAddr": ":8099",
"storageRoots": [
{
"label": "Storage D",
"path": "D:\\Milestone\\Storage"
}
],
"tokenFile": "C:\\Milestone\\server-folders-agent.token"
}
```
## Kompatibilität
Die bisherigen Parameter und ENV-Variablen funktionieren weiter und überschreiben Werte aus der JSON:
```powershell
.\server-folders-agent.exe -port 8099 -storage-root "Storage D=D:\Milestone\Storage" -archive-root "Archive E=E:\Milestone\Archive"
```
Unterstützte ENV-Variablen:
- `SERVER_FOLDER_AGENT_CONFIG`
- `LISTEN_ADDR`
- `SERVER_FOLDER_AGENT_PORT`
- `SERVER_STORAGE_ROOTS`
- `SERVER_ARCHIVE_ROOTS`
- `SERVER_FOLDER_ROOTS`
- `SERVER_FOLDER_AGENT_TOKEN`
- `SERVER_FOLDER_AGENT_TOKEN_FILE`
## Backend-Proxy
@ -9,63 +101,8 @@ GET /admin/server-folders?path=D:\Milestone
POST /admin/server-folders
```
Zusätzlich kann der Proxy diese Parameter auswerten:
```http
GET /admin/server-folders?path=D:\Milestone&schema=http&port=8099&token=CHANGE-ME
```
Aliasnamen:
- `schema`, `scheme`, `agentSchema`, `agentScheme`
- `port`, `agentPort`
- `token`, `agentToken`
Der Proxy entfernt diese Parameter, bevor er an den Agent weiterleitet.
Der Token wird als Header weitergegeben:
Der Proxy gibt den Token als Header an den Agent weiter:
```http
X-Server-Folders-Token: <token>
```
## Token-Handling (automatisch)
Der Agent wird **ohne** Token gestartet. Das Backend erzeugt beim Speichern der
Milestone-Einstellungen automatisch einen zufälligen Token und überträgt ihn per
`PUT /agent-token` an den Agent. Der Agent persistiert den Token in einer Datei
(Standard: neben der `.exe`) und nutzt ihn danach für alle weiteren Anfragen
auch nach einem Neustart.
```http
PUT /agent-token
X-Server-Folders-Token: <aktueller-token> # nur nötig, wenn bereits ein Token gesetzt ist
Content-Type: application/json
{ "token": "<neuer-token>" }
```
Solange noch kein Token gesetzt ist, akzeptiert der Agent `PUT /agent-token` auch
ohne Header (Bootstrap). Betreibe den Agent daher in einem vertrauenswürdigen
Netzwerk und speichere die Einstellungen nach dem Start zeitnah.
## Agent starten
```powershell
.\server-folders-agent.exe -port 8099 -roots "Milestone D=D:\Milestone;Milestone E=E:\Milestone"
```
Alternativ weiter per ENV:
```powershell
$env:LISTEN_ADDR=":8099"
$env:SERVER_FOLDER_ROOTS="Milestone D=D:\Milestone;Milestone E=E:\Milestone"
.\server-folders-agent.exe
```
Optionale Parameter:
- `-token` / `SERVER_FOLDER_AGENT_TOKEN`: nur als Start-Seed, falls noch keine
Token-Datei existiert (in der Regel nicht nötig).
- `-token-file` / `SERVER_FOLDER_AGENT_TOKEN_FILE`: Pfad der Token-Datei
(Standard: `server-folders-agent.token` neben der `.exe`).

View File

@ -10,7 +10,19 @@
// DELETE /server-folders
// PUT /agent-token (Token zur Laufzeit setzen / rotieren)
//
// Start per Parameter (ohne Token):
// Start per JSON (empfohlen):
// server-folders-agent.json neben die .exe legen und server-folders-agent.exe starten.
// Alternativ: server-folders-agent.exe -config C:\Pfad\server-folders-agent.json
//
// Beispiel:
// {
// "listenAddr": ":8099",
// "storageRoots": [{ "label": "Storage D", "path": "D:\\Milestone\\Storage" }],
// "archiveRoots": [{ "label": "Archive E", "path": "E:\\Milestone\\Archive" }],
// "token": ""
// }
//
// Start per Parameter bleibt als Fallback möglich:
// server-folders-agent.exe -port 8099 -storage-root "Storage D=D:\Milestone\Storage" -archive-root "Archive E=E:\Milestone\Archive"
//
// Alternativ per ENV:
@ -19,11 +31,11 @@
// SERVER_ARCHIVE_ROOTS=Archive E=E:\Milestone\Archive
//
// Token-Handling:
// Der Agent wird ohne Token gestartet. Das Backend erzeugt beim Speichern der
// Der Agent kann ohne Token gestartet werden. Das Backend erzeugt beim Speichern der
// Milestone-Einstellungen automatisch einen Token und schiebt ihn per
// PUT /agent-token an den Agent. Der Token wird in einer Datei persistiert
// (Standard: neben der .exe, siehe -token-file / SERVER_FOLDER_AGENT_TOKEN_FILE)
// und überlebt damit einen Neustart.
// PUT /agent-token an den Agent. Der Token wird in der JSON-Datei oder, wenn
// explizit konfiguriert, in einer Token-Datei persistiert und überlebt damit
// einen Neustart.
//
// Solange noch kein Token gesetzt ist, ist PUT /agent-token offen (Bootstrap).
// Sobald ein Token gesetzt ist, muss der Client ihn senden:
@ -54,8 +66,9 @@ import (
)
const (
defaultListenAddr = ":8099"
defaultTokenFileName = "server-folders-agent.token"
defaultListenAddr = ":8099"
defaultConfigFileName = "server-folders-agent.json"
defaultTokenFileName = "server-folders-agent.token"
listenAddrEnv = "LISTEN_ADDR"
serverStorageRootsEnv = "SERVER_STORAGE_ROOTS"
@ -63,6 +76,8 @@ const (
legacyFolderRootsEnv = "SERVER_FOLDER_ROOTS"
agentTokenEnv = "SERVER_FOLDER_AGENT_TOKEN"
agentTokenFileEnv = "SERVER_FOLDER_AGENT_TOKEN_FILE"
agentConfigFileEnv = "SERVER_FOLDER_AGENT_CONFIG"
agentPortEnv = "SERVER_FOLDER_AGENT_PORT"
)
type serverFolderRootType string
@ -73,11 +88,12 @@ const (
)
type appConfig struct {
ListenAddr string
StorageRoots string
ArchiveRoots string
Token string
TokenFile string
ListenAddr string
StorageRoots string
ArchiveRoots string
Token string
TokenFile string
TokenConfigFile string
}
func (config appConfig) rootsForType(rootType serverFolderRootType) string {
@ -88,6 +104,16 @@ func (config appConfig) rootsForType(rootType serverFolderRootType) string {
return strings.TrimSpace(config.StorageRoots)
}
type agentConfigFile struct {
ListenAddr string `json:"listenAddr,omitempty"`
Port string `json:"port,omitempty"`
Roots []serverFolderRoot `json:"roots,omitempty"`
StorageRoots []serverFolderRoot `json:"storageRoots,omitempty"`
ArchiveRoots []serverFolderRoot `json:"archiveRoots,omitempty"`
Token string `json:"token,omitempty"`
TokenFile string `json:"tokenFile,omitempty"`
}
type serverFolderRoot struct {
Label string `json:"label"`
Path string `json:"path"`
@ -121,7 +147,7 @@ type renameServerFolderRequest struct {
func main() {
config := readConfigFromFlagsAndEnv()
store := newTokenStore(config.TokenFile, config.Token)
store := newTokenStore(config)
mux := http.NewServeMux()
@ -151,7 +177,13 @@ func main() {
log.Printf("server-folders-agent listening on %s", config.ListenAddr)
log.Printf("storage roots: %q", config.StorageRoots)
log.Printf("archive roots: %q", config.ArchiveRoots)
log.Printf("token file: %s", config.TokenFile)
if config.TokenFile != "" {
log.Printf("token file: %s", config.TokenFile)
} else if config.TokenConfigFile != "" {
log.Printf("token config: %s", config.TokenConfigFile)
} else {
log.Printf("token persistence: disabled")
}
log.Printf("token enabled: %t", store.get() != "")
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
@ -160,21 +192,50 @@ func main() {
}
func readConfigFromFlagsAndEnv() appConfig {
configFlag := flag.String("config", "", "Path to JSON config file (default: server-folders-agent.json next to the executable)")
addrFlag := flag.String("addr", "", "Listen address, e.g. :8099 or 0.0.0.0:8099")
portFlag := flag.String("port", "", "Listen port, e.g. 8099")
legacyRootsFlag := flag.String("roots", "", "Legacy fallback roots for both storage and archive, e.g. Milestone D=D:\\Milestone")
storageRootsFlag := flag.String("storage-root", "", "Allowed storage roots, e.g. Storage D=D:\\Milestone\\Storage;Storage E=E:\\Milestone\\Storage")
archiveRootsFlag := flag.String("archive-root", "", "Allowed archive roots, e.g. Archive D=D:\\Milestone\\Archive;Archive E=E:\\Milestone\\Archive")
tokenFlag := flag.String("token", "", "Optional start seed token (used only if no token file exists yet)")
tokenFileFlag := flag.String("token-file", "", "Path to persist the runtime token (default: next to the executable)")
tokenFileFlag := flag.String("token-file", "", "Path to persist the runtime token instead of writing it to the JSON config")
flag.Parse()
listenAddr := strings.TrimSpace(*addrFlag)
if listenAddr == "" {
listenAddr = strings.TrimSpace(os.Getenv(listenAddrEnv))
config := appConfig{}
configPath := strings.TrimSpace(*configFlag)
if configPath == "" {
configPath = strings.TrimSpace(os.Getenv(agentConfigFileEnv))
}
explicitConfigPath := configPath != ""
if configPath == "" {
configPath = defaultConfigFilePath()
}
loadedConfigFile := false
if explicitConfigPath || fileExists(configPath) {
fileConfig, err := readAgentConfigFile(configPath)
if err != nil {
log.Fatalf("config file konnte nicht gelesen werden: %v", err)
}
applyAgentConfigFile(&config, fileConfig)
config.TokenConfigFile = configPath
loadedConfigFile = true
}
listenAddr := firstNonEmpty(
*addrFlag,
os.Getenv(listenAddrEnv),
config.ListenAddr,
)
port := strings.TrimSpace(*portFlag)
if listenAddr == "" && port == "" {
port = strings.TrimSpace(os.Getenv(agentPortEnv))
}
if listenAddr == "" && port != "" {
port = strings.TrimPrefix(port, ":")
listenAddr = ":" + port
@ -184,49 +245,157 @@ func readConfigFromFlagsAndEnv() appConfig {
listenAddr = defaultListenAddr
}
legacyRoots := strings.TrimSpace(*legacyRootsFlag)
if legacyRoots == "" {
legacyRoots = strings.TrimSpace(os.Getenv(legacyFolderRootsEnv))
}
legacyRoots := firstNonEmpty(*legacyRootsFlag, os.Getenv(legacyFolderRootsEnv))
storageRoots := strings.TrimSpace(*storageRootsFlag)
if storageRoots == "" {
storageRoots = strings.TrimSpace(os.Getenv(serverStorageRootsEnv))
}
storageRoots := firstNonEmpty(
*storageRootsFlag,
os.Getenv(serverStorageRootsEnv),
config.StorageRoots,
)
if storageRoots == "" {
storageRoots = legacyRoots
}
archiveRoots := strings.TrimSpace(*archiveRootsFlag)
if archiveRoots == "" {
archiveRoots = strings.TrimSpace(os.Getenv(serverArchiveRootsEnv))
}
archiveRoots := firstNonEmpty(
*archiveRootsFlag,
os.Getenv(serverArchiveRootsEnv),
config.ArchiveRoots,
)
if archiveRoots == "" {
archiveRoots = legacyRoots
}
token := strings.TrimSpace(*tokenFlag)
if token == "" {
token = strings.TrimSpace(os.Getenv(agentTokenEnv))
}
token := firstNonEmpty(*tokenFlag, os.Getenv(agentTokenEnv), config.Token)
tokenFile := strings.TrimSpace(*tokenFileFlag)
tokenFile := firstNonEmpty(*tokenFileFlag, os.Getenv(agentTokenFileEnv), config.TokenFile)
if tokenFile == "" {
tokenFile = strings.TrimSpace(os.Getenv(agentTokenFileEnv))
}
if tokenFile == "" {
tokenFile = defaultTokenFilePath()
if loadedConfigFile {
config.TokenConfigFile = configPath
} else {
tokenFile = defaultTokenFilePath()
}
} else {
config.TokenConfigFile = ""
}
return appConfig{
ListenAddr: listenAddr,
StorageRoots: storageRoots,
ArchiveRoots: archiveRoots,
Token: token,
TokenFile: tokenFile,
ListenAddr: listenAddr,
StorageRoots: storageRoots,
ArchiveRoots: archiveRoots,
Token: token,
TokenFile: tokenFile,
TokenConfigFile: config.TokenConfigFile,
}
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
return value
}
}
return ""
}
func readAgentConfigFile(pathValue string) (agentConfigFile, error) {
var config agentConfigFile
data, err := os.ReadFile(pathValue)
if err != nil {
return config, err
}
if err := json.Unmarshal(data, &config); err != nil {
return config, err
}
return config, nil
}
func updateAgentConfigFileToken(pathValue string, token string) error {
data, err := os.ReadFile(pathValue)
if err != nil {
return err
}
values := map[string]any{}
if strings.TrimSpace(string(data)) != "" {
if err := json.Unmarshal(data, &values); err != nil {
return err
}
}
values["token"] = strings.TrimSpace(token)
nextData, err := json.MarshalIndent(values, "", " ")
if err != nil {
return err
}
nextData = append(nextData, '\n')
return os.WriteFile(pathValue, nextData, 0600)
}
func applyAgentConfigFile(config *appConfig, fileConfig agentConfigFile) {
config.ListenAddr = strings.TrimSpace(fileConfig.ListenAddr)
if config.ListenAddr == "" && strings.TrimSpace(fileConfig.Port) != "" {
config.ListenAddr = ":" + strings.TrimPrefix(strings.TrimSpace(fileConfig.Port), ":")
}
legacyRoots := formatServerFolderRoots(fileConfig.Roots)
config.StorageRoots = formatServerFolderRoots(fileConfig.StorageRoots)
if config.StorageRoots == "" {
config.StorageRoots = legacyRoots
}
config.ArchiveRoots = formatServerFolderRoots(fileConfig.ArchiveRoots)
if config.ArchiveRoots == "" {
config.ArchiveRoots = legacyRoots
}
config.Token = strings.TrimSpace(fileConfig.Token)
config.TokenFile = strings.TrimSpace(fileConfig.TokenFile)
}
func formatServerFolderRoots(roots []serverFolderRoot) string {
parts := make([]string, 0, len(roots))
for _, root := range roots {
pathValue := strings.TrimSpace(root.Path)
if pathValue == "" {
continue
}
label := strings.TrimSpace(root.Label)
if label == "" {
parts = append(parts, pathValue)
continue
}
parts = append(parts, label+"="+pathValue)
}
return strings.Join(parts, ";")
}
func fileExists(pathValue string) bool {
if strings.TrimSpace(pathValue) == "" {
return false
}
info, err := os.Stat(pathValue)
return err == nil && !info.IsDir()
}
func defaultConfigFilePath() string {
if exePath, err := os.Executable(); err == nil {
return filepath.Join(filepath.Dir(exePath), defaultConfigFileName)
}
return defaultConfigFileName
}
func defaultTokenFilePath() string {
if exePath, err := os.Executable(); err == nil {
return filepath.Join(filepath.Dir(exePath), defaultTokenFileName)
@ -810,23 +979,27 @@ func folderHasVisibleSubdirectories(pathValue string, roots []serverFolderRoot)
return false
}
// tokenStore hält den zur Laufzeit gültigen Agent-Token. Der Token wird in einer
// Datei persistiert, damit er einen Neustart des Agents übersteht.
// tokenStore hält den zur Laufzeit gültigen Agent-Token. Der Token wird in der
// JSON-Config oder einer separaten Datei persistiert.
type tokenStore struct {
mu sync.RWMutex
token string
filePath string
mu sync.RWMutex
token string
filePath string
configFilePath string
}
func newTokenStore(filePath string, seed string) *tokenStore {
store := &tokenStore{filePath: strings.TrimSpace(filePath)}
func newTokenStore(config appConfig) *tokenStore {
store := &tokenStore{
filePath: strings.TrimSpace(config.TokenFile),
configFilePath: strings.TrimSpace(config.TokenConfigFile),
}
if persisted := store.loadFromFile(); persisted != "" {
store.token = persisted
return store
}
if seed = strings.TrimSpace(seed); seed != "" {
if seed := strings.TrimSpace(config.Token); seed != "" {
store.token = seed
if err := store.persist(seed); err != nil {
@ -860,24 +1033,37 @@ func (t *tokenStore) set(token string) error {
}
func (t *tokenStore) loadFromFile() string {
if t.filePath == "" {
if t.filePath != "" {
data, err := os.ReadFile(t.filePath)
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
if t.configFilePath == "" {
return ""
}
data, err := os.ReadFile(t.filePath)
config, err := readAgentConfigFile(t.configFilePath)
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
return strings.TrimSpace(config.Token)
}
func (t *tokenStore) persist(token string) error {
if t.filePath == "" {
return nil
if t.filePath != "" {
return os.WriteFile(t.filePath, []byte(token), 0600)
}
return os.WriteFile(t.filePath, []byte(token), 0600)
if t.configFilePath != "" {
return updateAgentConfigFileToken(t.configFilePath, token)
}
return nil
}
type setAgentTokenRequest struct {

View File

@ -18,6 +18,7 @@ type DeviceHistoryEntry struct {
DeviceID string `json:"deviceId"`
UserID string `json:"userId"`
UserDisplayName string `json:"userDisplayName"`
UserAvatar string `json:"userAvatar"`
Action string `json:"action"`
Changes []DeviceHistoryChange `json:"changes"`
CreatedAt time.Time `json:"createdAt"`
@ -58,6 +59,7 @@ func (s *Server) handleListDeviceHistory(w http.ResponseWriter, r *http.Request)
h.device_id,
COALESCE(h.user_id::TEXT, ''),
COALESCE(NULLIF(u.display_name, ''), NULLIF(u.username, ''), u.email, 'Unbekannt'),
COALESCE(u.avatar, ''),
h.action,
h.changes,
h.created_at,
@ -87,6 +89,7 @@ func (s *Server) handleListDeviceHistory(w http.ResponseWriter, r *http.Request)
&entry.DeviceID,
&entry.UserID,
&entry.UserDisplayName,
&entry.UserAvatar,
&entry.Action,
&changesRaw,
&entry.CreatedAt,
@ -248,27 +251,35 @@ func getDeviceForHistory(ctx context.Context, tx pgx.Tx, deviceID string) (Devic
ctx,
`
SELECT
id,
inventory_number,
manufacturer,
model,
serial_number,
mac_address,
COALESCE(ip_address, ''),
COALESCE(firmware_version, ''),
COALESCE(milestone_recording_server_id, ''),
COALESCE(milestone_hardware_id, ''),
COALESCE(milestone_display_name, ''),
milestone_enabled,
milestone_synced_at,
location,
loan_status,
is_bao_device,
comment,
created_at,
updated_at
devices.id,
COALESCE(devices.inventory_number, ''),
COALESCE(devices.manufacturer, ''),
COALESCE(devices.model, ''),
COALESCE(devices.serial_number, ''),
COALESCE(devices.mac_address, ''),
COALESCE(devices.ip_address, ''),
COALESCE(devices.phone_number, ''),
COALESCE(devices.computer_name, ''),
COALESCE(devices.device_category, ''),
COALESCE(devices.firmware_version, ''),
COALESCE(devices.milestone_recording_server_id, ''),
COALESCE(devices.milestone_hardware_id, ''),
COALESCE(devices.milestone_display_name, ''),
COALESCE(devices.milestone_enabled, true),
devices.milestone_synced_at,
COALESCE(devices.location, ''),
COALESCE(devices.loan_status, 'Verfügbar'),
COALESCE(devices.loaned_to_user_id::TEXT, ''),
COALESCE(NULLIF(loaned_user.display_name, ''), NULLIF(loaned_user.username, ''), loaned_user.email, ''),
COALESCE(devices.loaned_until::TEXT, ''),
COALESCE(devices.is_bao_device, false),
COALESCE(devices.comment, ''),
devices.created_at,
devices.updated_at
FROM devices
WHERE id = $1
LEFT JOIN users AS loaned_user
ON loaned_user.id = devices.loaned_to_user_id
WHERE devices.id = $1
LIMIT 1
`,
deviceID,
@ -280,6 +291,9 @@ func getDeviceForHistory(ctx context.Context, tx pgx.Tx, deviceID string) (Devic
&device.SerialNumber,
&device.MacAddress,
&device.IPAddress,
&device.PhoneNumber,
&device.ComputerName,
&device.DeviceCategory,
&device.FirmwareVersion,
&device.MilestoneRecordingServerID,
&device.MilestoneHardwareID,
@ -288,6 +302,9 @@ func getDeviceForHistory(ctx context.Context, tx pgx.Tx, deviceID string) (Devic
&device.MilestoneSyncedAt,
&device.Location,
&device.LoanStatus,
&device.LoanedToUserID,
&device.LoanedToDisplayName,
&device.LoanedUntil,
&device.IsBaoDevice,
&device.Comment,
&device.CreatedAt,
@ -537,8 +554,17 @@ func buildDeviceCreateHistoryChanges(
changes = appendHistoryChange(changes, "model", "Modell", "", input.Model)
changes = appendHistoryChange(changes, "serialNumber", "Seriennummer", "", input.SerialNumber)
changes = appendHistoryChange(changes, "macAddress", "MAC-Adresse", "", input.MacAddress)
changes = appendHistoryChange(changes, "phoneNumber", "Rufnummer", "", input.PhoneNumber)
changes = appendHistoryChange(changes, "computerName", "Computername", "", input.ComputerName)
changes = appendHistoryChange(changes, "deviceCategory", "Kategorie", "", input.DeviceCategory)
changes = appendHistoryChange(changes, "location", "Standort", "", input.Location)
changes = appendHistoryChange(changes, "loanStatus", "Verleih-Status", "", input.LoanStatus)
loanedToUser, err := formatLoanedUserLabel(ctx, tx, input.LoanedToUserID)
if err != nil {
return nil, err
}
changes = appendHistoryChange(changes, "loanedToUserId", "Verliehen an", "", loanedToUser)
changes = appendHistoryChange(changes, "loanedUntil", "Verliehen bis", "", input.LoanedUntil)
changes = appendHistoryChange(changes, "isBaoDevice", "BAO-Gerät", "Nein", formatHistoryBool(input.IsBaoDevice))
changes = appendHistoryChange(changes, "comment", "Kommentar", "", input.Comment)
@ -566,8 +592,17 @@ func buildDeviceUpdateHistoryChanges(
changes = appendHistoryChange(changes, "model", "Modell", oldDevice.Model, input.Model)
changes = appendHistoryChange(changes, "serialNumber", "Seriennummer", oldDevice.SerialNumber, input.SerialNumber)
changes = appendHistoryChange(changes, "macAddress", "MAC-Adresse", oldDevice.MacAddress, input.MacAddress)
changes = appendHistoryChange(changes, "phoneNumber", "Rufnummer", oldDevice.PhoneNumber, input.PhoneNumber)
changes = appendHistoryChange(changes, "computerName", "Computername", oldDevice.ComputerName, input.ComputerName)
changes = appendHistoryChange(changes, "deviceCategory", "Kategorie", oldDevice.DeviceCategory, input.DeviceCategory)
changes = appendHistoryChange(changes, "location", "Standort", oldDevice.Location, input.Location)
changes = appendHistoryChange(changes, "loanStatus", "Verleih-Status", oldDevice.LoanStatus, input.LoanStatus)
loanedToUser, err := formatLoanedUserLabel(ctx, tx, input.LoanedToUserID)
if err != nil {
return nil, err
}
changes = appendHistoryChange(changes, "loanedToUserId", "Verliehen an", oldDevice.LoanedToDisplayName, loanedToUser)
changes = appendHistoryChange(changes, "loanedUntil", "Verliehen bis", oldDevice.LoanedUntil, input.LoanedUntil)
changes = appendHistoryChange(changes, "isBaoDevice", "BAO-Gerät", formatHistoryBool(oldDevice.IsBaoDevice), formatHistoryBool(input.IsBaoDevice))
changes = appendHistoryChange(changes, "comment", "Kommentar", oldDevice.Comment, input.Comment)
changes = appendHistoryChange(changes, "milestoneDisplayName", "Milestone-Anzeigename", oldDevice.MilestoneDisplayName, input.MilestoneDisplayName)
@ -678,6 +713,35 @@ func formatRelatedDeviceLabels(ctx context.Context, tx pgx.Tx, deviceIDs []strin
return strings.Join(labels, ", "), nil
}
func formatLoanedUserLabel(ctx context.Context, tx pgx.Tx, userID string) (string, error) {
userID = strings.TrimSpace(userID)
if userID == "" {
return "", nil
}
var label string
err := tx.QueryRow(
ctx,
`
SELECT COALESCE(NULLIF(display_name, ''), NULLIF(username, ''), email, '')
FROM users
WHERE id = $1
LIMIT 1
`,
userID,
).Scan(&label)
if errors.Is(err, pgx.ErrNoRows) {
return userID, nil
}
if err != nil {
return "", err
}
return label, nil
}
func normalizeDeviceIDs(deviceIDs []string) []string {
seen := map[string]bool{}
result := []string{}

View File

@ -36,6 +36,10 @@ func main() {
log.Fatal(err)
}
if err := ensureRuntimeDeviceSchema(ctx, db); err != nil {
log.Fatal(err)
}
server := NewServer(db, jwtSecret, frontendOrigin)
server.startPresenceMonitor(ctx)
server.startDisappearingMessagesMonitor(ctx)

View File

@ -3,6 +3,7 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
@ -10,6 +11,8 @@ import (
"io"
"log"
"net/http"
"net/url"
"regexp"
"strings"
"github.com/jackc/pgx/v5"
@ -22,6 +25,8 @@ type milestoneCameraPatchDevice struct {
MilestoneHardwareID string
MilestoneDeviceID string
DeviceType string
IPAddress string
MilestonePort string
Enabled bool
RecordingEnabled bool
}
@ -35,13 +40,14 @@ type milestoneRecordingStorageRef struct {
}
type milestoneCameraStream struct {
Index int `json:"index"`
Codec string `json:"codec"`
FPS *int `json:"fps"`
JPEGQuality *int `json:"jPEGQuality"`
Quality *int `json:"quality"`
Resolution string `json:"resolution"`
EdgeStorageSupported bool `json:"edgeStorageSupported"`
Index int `json:"index"`
Codec string `json:"codec"`
FPS *int `json:"fps"`
JPEGQuality *int `json:"jPEGQuality"`
Quality *int `json:"quality"`
Resolution string `json:"resolution"`
AvailableResolutions []string `json:"availableResolutions,omitempty"`
EdgeStorageSupported bool `json:"edgeStorageSupported"`
}
type milestoneCameraDetail struct {
@ -75,6 +81,119 @@ type updateMilestoneCameraDetailsRequest struct {
Streams []updateMilestoneCameraStreamRequest `json:"streams"`
}
var milestoneResolutionPattern = regexp.MustCompile(`(?i)(\d{2,5})\s*(?:x|\x{00D7})\s*(\d{2,5})`)
var milestoneResolutionCandidateKeys = []string{
"availableResolutions",
"availableResolution",
"supportedResolutions",
"supportedResolution",
"resolutionOptions",
"resolutionValues",
"resolutionChoices",
"allowedResolutions",
"allowedResolution",
"resolutions",
"videoResolutions",
}
func normalizeMilestoneResolution(value any) string {
text := strings.TrimSpace(milestoneStringValue(value))
if text == "" {
return ""
}
match := milestoneResolutionPattern.FindStringSubmatch(text)
if len(match) >= 3 {
return match[1] + "x" + match[2]
}
return ""
}
func extractMilestoneResolutionValues(value any, depth int) []string {
if depth > 4 || value == nil {
return nil
}
switch typedValue := value.(type) {
case string:
values := []string{}
for _, part := range strings.FieldsFunc(typedValue, func(r rune) bool {
return r == ',' || r == ';' || r == '|'
}) {
if resolution := normalizeMilestoneResolution(part); resolution != "" {
values = append(values, resolution)
}
}
return values
case float64, int, int64:
if resolution := normalizeMilestoneResolution(typedValue); resolution != "" {
return []string{resolution}
}
return nil
case []any:
values := []string{}
for _, item := range typedValue {
values = append(values, extractMilestoneResolutionValues(item, depth+1)...)
}
return values
case map[string]any:
values := []string{}
for _, key := range []string{"value", "name", "displayName", "label", "text", "id", "resolution"} {
values = append(values, extractMilestoneResolutionValues(typedValue[key], depth+1)...)
}
for _, key := range []string{"values", "options", "items", "choices", "allowedValues", "allowed", "array"} {
values = append(values, extractMilestoneResolutionValues(typedValue[key], depth+1)...)
}
for key := range typedValue {
if resolution := normalizeMilestoneResolution(key); resolution != "" {
values = append(values, resolution)
}
}
return values
default:
if resolution := normalizeMilestoneResolution(typedValue); resolution != "" {
return []string{resolution}
}
return nil
}
}
func milestoneAvailableResolutions(streamMap map[string]any, currentResolution string) []string {
seen := map[string]struct{}{}
values := []string{}
add := func(candidates ...string) {
for _, candidate := range candidates {
resolution := normalizeMilestoneResolution(candidate)
if resolution == "" {
continue
}
if _, exists := seen[resolution]; exists {
continue
}
seen[resolution] = struct{}{}
values = append(values, resolution)
}
}
for _, key := range milestoneResolutionCandidateKeys {
for _, resolution := range extractMilestoneResolutionValues(streamMap[key], 0) {
add(resolution)
}
}
if _, ok := streamMap["resolution"].(map[string]any); ok {
for _, resolution := range extractMilestoneResolutionValues(streamMap["resolution"], 0) {
add(resolution)
}
}
add(currentResolution)
return values
}
func (s *Server) getMilestoneCameraPatchDevice(
ctx context.Context,
deviceID string,
@ -92,6 +211,8 @@ func (s *Server) getMilestoneCameraPatchDevice(
milestone_hardware_child_devices.milestone_hardware_id,
milestone_hardware_child_devices.milestone_device_id,
milestone_hardware_child_devices.device_type,
COALESCE(devices.ip_address, ''),
COALESCE(devices.milestone_port, ''),
milestone_hardware_child_devices.enabled,
COALESCE(milestone_hardware_child_devices.recording_enabled, false)
FROM milestone_hardware_child_devices
@ -110,6 +231,8 @@ func (s *Server) getMilestoneCameraPatchDevice(
&camera.MilestoneHardwareID,
&camera.MilestoneDeviceID,
&camera.DeviceType,
&camera.IPAddress,
&camera.MilestonePort,
&camera.Enabled,
&camera.RecordingEnabled,
)
@ -382,6 +505,10 @@ func extractMilestoneCameraStreams(cameraData map[string]any) []milestoneCameraS
Resolution: milestoneStringValue(streamMap["resolution"]),
EdgeStorageSupported: milestoneBoolValue(streamMap["edgeStorageSupported"]),
}
stream.AvailableResolutions = milestoneAvailableResolutions(
streamMap,
stream.Resolution,
)
if value, ok := milestoneIntPointerValue(streamMap["FPS"]); ok {
stream.FPS = value
@ -404,6 +531,8 @@ func extractMilestoneCameraStreams(cameraData map[string]any) []milestoneCameraS
type milestoneCameraListItem struct {
ID string `json:"id"`
LocalDeviceID string `json:"localDeviceId,omitempty"`
LocalChildID string `json:"localChildId,omitempty"`
Name string `json:"name"`
DisplayName string `json:"displayName"`
Type string `json:"type"`
@ -414,6 +543,17 @@ type milestoneCameraListItem struct {
HardwareDisplayName string `json:"hardwareDisplayName"`
}
type milestoneLocalDeviceRef struct {
DeviceID string
ChildID string
}
func milestoneLocalDeviceRefKey(hardwareID string, deviceType string, milestoneDeviceID string) string {
return strings.TrimSpace(hardwareID) + "|" +
strings.TrimSpace(strings.ToLower(deviceType)) + "|" +
strings.TrimSpace(milestoneDeviceID)
}
func (s *Server) getMilestoneHardwareDisplayNameMap(
ctx context.Context,
) (map[string]string, error) {
@ -461,11 +601,77 @@ func (s *Server) getMilestoneHardwareDisplayNameMap(
return out, nil
}
func (s *Server) getMilestoneLocalDeviceRefMap(
ctx context.Context,
) (map[string]milestoneLocalDeviceRef, error) {
rows, err := s.db.Query(
ctx,
`
SELECT
devices.id::TEXT,
milestone_hardware_child_devices.id::TEXT,
milestone_hardware_child_devices.milestone_hardware_id,
milestone_hardware_child_devices.device_type,
milestone_hardware_child_devices.milestone_device_id
FROM milestone_hardware_child_devices
INNER JOIN devices
ON devices.milestone_hardware_id = milestone_hardware_child_devices.milestone_hardware_id
WHERE COALESCE(devices.milestone_hardware_id, '') <> ''
`,
)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]milestoneLocalDeviceRef{}
for rows.Next() {
var deviceID string
var childID string
var hardwareID string
var deviceType string
var milestoneDeviceID string
if err := rows.Scan(
&deviceID,
&childID,
&hardwareID,
&deviceType,
&milestoneDeviceID,
); err != nil {
return nil, err
}
deviceID = strings.TrimSpace(deviceID)
childID = strings.TrimSpace(childID)
hardwareID = strings.TrimSpace(hardwareID)
deviceType = strings.TrimSpace(strings.ToLower(deviceType))
milestoneDeviceID = strings.TrimSpace(milestoneDeviceID)
if deviceID == "" || childID == "" || hardwareID == "" || milestoneDeviceID == "" {
continue
}
out[milestoneLocalDeviceRefKey(hardwareID, deviceType, milestoneDeviceID)] = milestoneLocalDeviceRef{
DeviceID: deviceID,
ChildID: childID,
}
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
func getMilestoneDeviceList(
ctx context.Context,
config milestoneRuntimeConfig,
collection string,
hardwareDisplayNames map[string]string,
localDeviceRefs map[string]milestoneLocalDeviceRef,
) ([]milestoneCameraListItem, error) {
requestURL := strings.TrimRight(config.Host, "/") +
"/API/rest/v1/" + collection + "?disabled"
@ -557,9 +763,12 @@ func getMilestoneDeviceList(
hardwareID := milestoneNamedDeviceParentID(item)
hardwareDisplayName := strings.TrimSpace(hardwareDisplayNames[hardwareID])
localDeviceRef := localDeviceRefs[milestoneLocalDeviceRefKey(hardwareID, deviceType, id)]
items = append(items, milestoneCameraListItem{
ID: id,
LocalDeviceID: localDeviceRef.DeviceID,
LocalChildID: localDeviceRef.ChildID,
Name: name,
DisplayName: displayName,
Type: deviceType,
@ -587,14 +796,20 @@ func (s *Server) handleListMilestoneCameras(w http.ResponseWriter, r *http.Reque
return
}
cameras, err := getMilestoneDeviceList(r.Context(), config, "cameras", hardwareDisplayNames)
localDeviceRefs, err := s.getMilestoneLocalDeviceRefMap(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "Lokale Milestone-Gerätezuordnung konnte nicht geladen werden")
return
}
cameras, err := getMilestoneDeviceList(r.Context(), config, "cameras", hardwareDisplayNames, localDeviceRefs)
if err != nil {
log.Printf("Milestone cameras list failed: %v", err)
writeError(w, http.StatusBadGateway, "Milestone-Kameras konnten nicht geladen werden")
return
}
microphones, err := getMilestoneDeviceList(r.Context(), config, "microphones", hardwareDisplayNames)
microphones, err := getMilestoneDeviceList(r.Context(), config, "microphones", hardwareDisplayNames, localDeviceRefs)
if err != nil {
log.Printf("Milestone microphones list failed: %v", err)
writeError(w, http.StatusBadGateway, "Milestone-Mikrofone konnten nicht geladen werden")
@ -718,3 +933,345 @@ func applyMilestoneCameraStreamUpdates(
}
}
}
func getMilestoneApiCameraStreams(
ctx context.Context,
config milestoneRuntimeConfig,
milestoneDeviceID string,
) ([]any, error) {
requestURL := strings.TrimRight(config.Host, "/") +
"/API/rest/v1/cameras/" + url.PathEscape(milestoneDeviceID) + "/streams"
request, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
if err != nil {
return nil, err
}
setMilestoneRequestHeaders(request, config, false)
response, err := milestoneHTTPClient(config.SkipTLSVerify).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 camera streams get failed: status=%d", response.StatusCode)
}
var decoded map[string]any
if err := json.Unmarshal(body, &decoded); err != nil {
return nil, err
}
if array, ok := decoded["array"].([]any); ok {
return array, nil
}
return []any{}, nil
}
func putMilestoneApiStream(
ctx context.Context,
config milestoneRuntimeConfig,
streamID string,
payload map[string]any,
) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
requestURL := strings.TrimRight(config.Host, "/") +
"/API/rest/v1/streams/" + url.PathEscape(streamID)
request, err := http.NewRequestWithContext(ctx, http.MethodPut, requestURL, bytes.NewReader(body))
if err != nil {
return err
}
setMilestoneRequestHeaders(request, config, true)
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 fmt.Errorf("milestone stream put failed: status=%d body=%s", response.StatusCode, string(responseBody))
}
return nil
}
func (s *Server) handleGetMilestoneCameraApiStreams(w http.ResponseWriter, r *http.Request) {
deviceID := strings.TrimSpace(r.PathValue("id"))
childID := strings.TrimSpace(r.PathValue("childId"))
if deviceID == "" || childID == "" {
writeError(w, http.StatusBadRequest, "Geräte-ID oder Child-ID fehlt")
return
}
cameraDevice, err := s.getMilestoneCameraPatchDevice(r.Context(), deviceID, childID)
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "Milestone-Kamera wurde nicht gefunden")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "Milestone-Kamera konnte nicht geladen werden")
return
}
config, err := s.getMilestoneRuntimeConfig(r.Context())
if err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden")
return
}
streams, err := getMilestoneApiCameraStreams(r.Context(), config, cameraDevice.MilestoneDeviceID)
if err != nil {
log.Printf("Milestone camera API streams failed: %v", err)
writeError(w, http.StatusBadGateway, "Milestone-Streams konnten nicht geladen werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"streams": streams,
})
}
func (s *Server) handleUpdateMilestoneApiStream(w http.ResponseWriter, r *http.Request) {
deviceID := strings.TrimSpace(r.PathValue("id"))
childID := strings.TrimSpace(r.PathValue("childId"))
streamID := strings.TrimSpace(r.PathValue("streamId"))
if deviceID == "" || childID == "" || streamID == "" {
writeError(w, http.StatusBadRequest, "Geräte-ID, Child-ID oder Stream-ID fehlt")
return
}
_, err := s.getMilestoneCameraPatchDevice(r.Context(), deviceID, childID)
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "Milestone-Kamera wurde nicht gefunden")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "Milestone-Kamera konnte nicht geladen werden")
return
}
var payload map[string]any
if err := readJSON(r, &payload); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON")
return
}
config, err := s.getMilestoneRuntimeConfig(r.Context())
if err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden")
return
}
if err := putMilestoneApiStream(r.Context(), config, streamID, payload); err != nil {
log.Printf("Milestone stream put failed: %v", err)
writeError(w, http.StatusBadGateway, "Milestone-Stream konnte nicht aktualisiert werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
})
}
func getMilestoneDeviceSettings(
ctx context.Context,
config milestoneRuntimeConfig,
milestoneDeviceID string,
) (map[string]any, error) {
requestURL := strings.TrimRight(config.Host, "/") +
"/API/rest/v1/settings/" + url.PathEscape(milestoneDeviceID)
request, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
if err != nil {
return nil, err
}
setMilestoneRequestHeaders(request, config, false)
response, err := milestoneHTTPClient(config.SkipTLSVerify).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 settings get failed: status=%d", response.StatusCode)
}
var envelope struct {
Data map[string]any `json:"data"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
return nil, err
}
if envelope.Data == nil {
return map[string]any{}, nil
}
return envelope.Data, nil
}
func patchMilestoneDeviceSettings(
ctx context.Context,
config milestoneRuntimeConfig,
settingsID string,
payload map[string]any,
) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
requestURL := strings.TrimRight(config.Host, "/") +
"/API/rest/v1/settings/" + url.PathEscape(settingsID)
request, err := http.NewRequestWithContext(ctx, http.MethodPatch, requestURL, bytes.NewReader(body))
if err != nil {
return err
}
setMilestoneRequestHeaders(request, config, true)
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 fmt.Errorf("milestone settings patch failed: status=%d body=%s", response.StatusCode, string(responseBody))
}
return nil
}
func (s *Server) handleGetMilestoneCameraSettingsDetail(w http.ResponseWriter, r *http.Request) {
deviceID := strings.TrimSpace(r.PathValue("id"))
childID := strings.TrimSpace(r.PathValue("childId"))
if deviceID == "" || childID == "" {
writeError(w, http.StatusBadRequest, "Geräte-ID oder Child-ID fehlt")
return
}
cameraDevice, err := s.getMilestoneCameraPatchDevice(r.Context(), deviceID, childID)
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "Milestone-Kamera wurde nicht gefunden")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "Milestone-Kamera konnte nicht geladen werden")
return
}
config, err := s.getMilestoneRuntimeConfig(r.Context())
if err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden")
return
}
settings, err := getMilestoneDeviceSettings(r.Context(), config, cameraDevice.MilestoneDeviceID)
if err != nil {
log.Printf("Milestone camera settings get failed: %v", err)
writeError(w, http.StatusBadGateway, "Milestone-Einstellungen konnten nicht geladen werden")
return
}
activeStreams, err := getMilestoneApiCameraStreams(r.Context(), config, cameraDevice.MilestoneDeviceID)
if err != nil {
log.Printf("Milestone camera active streams get failed: %v", err)
activeStreams = []any{}
}
onvifResolutions, err := s.getCachedOnvifCameraResolutions(
r.Context(),
cameraDevice,
config.SkipTLSVerify,
)
if err != nil {
log.Printf(
"ONVIF camera resolutions could not be loaded: hardwareId=%s deviceId=%s error=%v",
cameraDevice.MilestoneHardwareID,
cameraDevice.MilestoneDeviceID,
err,
)
}
if len(onvifResolutions) > 0 {
applyOnvifAvailableResolutionsToMilestoneSettings(settings, onvifResolutions)
applyOnvifAvailableResolutionsToMilestoneActiveStreams(activeStreams, onvifResolutions)
}
writeJSON(w, http.StatusOK, map[string]any{
"settings": settings,
"activeStreams": activeStreams,
})
}
func (s *Server) handleUpdateMilestoneCameraSettingsDetail(w http.ResponseWriter, r *http.Request) {
deviceID := strings.TrimSpace(r.PathValue("id"))
childID := strings.TrimSpace(r.PathValue("childId"))
if deviceID == "" || childID == "" {
writeError(w, http.StatusBadRequest, "Geräte-ID oder Child-ID fehlt")
return
}
cameraDevice, err := s.getMilestoneCameraPatchDevice(r.Context(), deviceID, childID)
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "Milestone-Kamera wurde nicht gefunden")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "Milestone-Kamera konnte nicht geladen werden")
return
}
var payload map[string]any
if err := readJSON(r, &payload); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON")
return
}
config, err := s.getMilestoneRuntimeConfig(r.Context())
if err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden")
return
}
if err := patchMilestoneDeviceSettings(r.Context(), config, cameraDevice.MilestoneDeviceID, payload); err != nil {
log.Printf("Milestone camera settings patch failed: %v", err)
writeError(w, http.StatusBadGateway, "Milestone-Einstellungen konnten nicht aktualisiert werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
})
}

View File

@ -1038,6 +1038,7 @@ func createOrUpdateMilestoneHardwareDevice(
serial_number,
mac_address,
ip_address,
device_category,
milestone_port,
location,
loan_status,
@ -1057,6 +1058,7 @@ func createOrUpdateMilestoneHardwareDevice(
$4,
$5,
$6,
'Kameras',
$7,
'',
'Verfügbar',
@ -1078,6 +1080,7 @@ func createOrUpdateMilestoneHardwareDevice(
serial_number = EXCLUDED.serial_number,
mac_address = EXCLUDED.mac_address,
ip_address = EXCLUDED.ip_address,
device_category = 'Kameras',
milestone_port = EXCLUDED.milestone_port,
firmware_version = EXCLUDED.firmware_version,
milestone_recording_server_id = EXCLUDED.milestone_recording_server_id,

View File

@ -0,0 +1,926 @@
// backend\onvif_resolutions.go
package main
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha1"
"crypto/tls"
"encoding/base64"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"net/url"
"os"
"sort"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
const onvifResolutionCacheMaxAge = 24 * time.Hour
const onvifResolutionFetchTimeout = 7 * time.Second
type onvifResolutionCacheEntry struct {
Resolutions []string
CheckedAt time.Time
CheckError string
}
type onvifCameraConnection struct {
Host string
Port string
Username string
Password string
SkipTLSVerify bool
}
func (s *Server) getCachedOnvifCameraResolutions(
ctx context.Context,
cameraDevice milestoneCameraPatchDevice,
skipTLSVerify bool,
) ([]string, error) {
host := strings.TrimSpace(cameraDevice.IPAddress)
port := strings.TrimSpace(cameraDevice.MilestonePort)
if host == "" {
return nil, nil
}
if port == "" {
port = "80"
}
hardwareID := strings.TrimSpace(cameraDevice.MilestoneHardwareID)
cacheReady := hardwareID != ""
if cacheReady {
if err := s.ensureOnvifResolutionCacheTable(ctx); err != nil {
cacheReady = false
log.Printf("ONVIF resolution cache table could not be ensured: %v", err)
}
}
var cachedEntry onvifResolutionCacheEntry
var cached bool
if cacheReady {
entry, ok, err := s.getOnvifResolutionCache(
ctx,
hardwareID,
host,
port,
)
if err != nil {
log.Printf("ONVIF resolution cache could not be read: %v", err)
} else {
cachedEntry = entry
cached = ok
}
}
if cached &&
len(cachedEntry.Resolutions) > 0 &&
time.Since(cachedEntry.CheckedAt) <= onvifResolutionCacheMaxAge {
return cachedEntry.Resolutions, nil
}
username, password := s.onvifCredentials(ctx)
fetchCtx, cancel := context.WithTimeout(ctx, onvifResolutionFetchTimeout)
defer cancel()
resolutions, err := fetchOnvifVideoEncoderResolutions(fetchCtx, onvifCameraConnection{
Host: host,
Port: port,
Username: username,
Password: password,
SkipTLSVerify: skipTLSVerify,
})
if err != nil {
if cached && len(cachedEntry.Resolutions) > 0 {
return cachedEntry.Resolutions, nil
}
if cacheReady {
if cacheErr := s.upsertOnvifResolutionCache(
ctx,
hardwareID,
host,
port,
nil,
err.Error(),
); cacheErr != nil {
log.Printf("ONVIF resolution cache error could not be stored: %v", cacheErr)
}
}
return nil, err
}
if cacheReady {
if err := s.upsertOnvifResolutionCache(
ctx,
hardwareID,
host,
port,
resolutions,
"",
); err != nil {
log.Printf("ONVIF resolution cache could not be stored: %v", err)
}
}
return resolutions, nil
}
func (s *Server) onvifCredentials(ctx context.Context) (string, string) {
envUsername := strings.TrimSpace(os.Getenv("ONVIF_USERNAME"))
envPassword := strings.TrimSpace(os.Getenv("ONVIF_PASSWORD"))
if envUsername != "" || envPassword != "" {
return envUsername, envPassword
}
credentials, err := s.getMilestoneCredentials(ctx)
if err != nil {
return "", ""
}
return strings.TrimSpace(credentials.Username), strings.TrimSpace(credentials.Password)
}
func (s *Server) ensureOnvifResolutionCacheTable(ctx context.Context) error {
_, err := s.db.Exec(
ctx,
`
CREATE TABLE IF NOT EXISTS onvif_resolution_cache (
milestone_hardware_id TEXT PRIMARY KEY,
host TEXT NOT NULL DEFAULT '',
port TEXT NOT NULL DEFAULT '',
resolutions JSONB NOT NULL DEFAULT '[]'::JSONB,
check_error TEXT NOT NULL DEFAULT '',
checked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`,
)
return err
}
func (s *Server) getOnvifResolutionCache(
ctx context.Context,
hardwareID string,
host string,
port string,
) (onvifResolutionCacheEntry, bool, error) {
var entry onvifResolutionCacheEntry
var rawResolutions []byte
err := s.db.QueryRow(
ctx,
`
SELECT resolutions, checked_at, check_error
FROM onvif_resolution_cache
WHERE milestone_hardware_id = $1
AND host = $2
AND port = $3
LIMIT 1
`,
strings.TrimSpace(hardwareID),
strings.TrimSpace(host),
strings.TrimSpace(port),
).Scan(
&rawResolutions,
&entry.CheckedAt,
&entry.CheckError,
)
if errors.Is(err, pgxErrNoRows()) {
return entry, false, nil
}
if err != nil {
return entry, false, err
}
if err := json.Unmarshal(rawResolutions, &entry.Resolutions); err != nil {
return entry, false, err
}
entry.Resolutions = sortMilestoneResolutionValues(entry.Resolutions)
return entry, true, nil
}
func (s *Server) upsertOnvifResolutionCache(
ctx context.Context,
hardwareID string,
host string,
port string,
resolutions []string,
checkError string,
) error {
resolutions = sortMilestoneResolutionValues(resolutions)
rawResolutions, err := json.Marshal(resolutions)
if err != nil {
return err
}
_, err = s.db.Exec(
ctx,
`
INSERT INTO onvif_resolution_cache (
milestone_hardware_id,
host,
port,
resolutions,
check_error,
checked_at,
updated_at
)
VALUES ($1, $2, $3, $4::JSONB, $5, now(), now())
ON CONFLICT (milestone_hardware_id)
DO UPDATE SET
host = EXCLUDED.host,
port = EXCLUDED.port,
resolutions = EXCLUDED.resolutions,
check_error = EXCLUDED.check_error,
checked_at = EXCLUDED.checked_at,
updated_at = now()
`,
strings.TrimSpace(hardwareID),
strings.TrimSpace(host),
strings.TrimSpace(port),
string(rawResolutions),
strings.TrimSpace(checkError),
)
return err
}
func fetchOnvifVideoEncoderResolutions(
ctx context.Context,
connection onvifCameraConnection,
) ([]string, error) {
client := onvifHTTPClient(connection.SkipTLSVerify)
var lastErr error
for _, deviceEndpoint := range onvifDeviceServiceCandidates(connection.Host, connection.Port) {
mediaXAddr, err := onvifGetMediaServiceXAddr(
ctx,
client,
deviceEndpoint,
connection,
)
if err != nil {
lastErr = err
continue
}
if mediaXAddr == "" {
mediaXAddr = deviceEndpoint
}
mediaXAddr = onvifResolveEndpoint(deviceEndpoint, mediaXAddr)
resolutions, err := onvifGetVideoEncoderConfigurationOptions(
ctx,
client,
mediaXAddr,
"",
connection,
)
if err == nil && len(resolutions) > 0 {
return resolutions, nil
}
if err != nil {
lastErr = err
}
profileTokens, profileErr := onvifGetProfileTokens(
ctx,
client,
mediaXAddr,
connection,
)
if profileErr != nil {
lastErr = profileErr
continue
}
merged := []string{}
for _, profileToken := range profileTokens {
profileResolutions, err := onvifGetVideoEncoderConfigurationOptions(
ctx,
client,
mediaXAddr,
profileToken,
connection,
)
if err != nil {
lastErr = err
continue
}
merged = append(merged, profileResolutions...)
}
merged = sortMilestoneResolutionValues(merged)
if len(merged) > 0 {
return merged, nil
}
}
if lastErr != nil {
return nil, lastErr
}
return nil, fmt.Errorf("ONVIF returned no video encoder resolution options")
}
func onvifGetMediaServiceXAddr(
ctx context.Context,
client *http.Client,
deviceEndpoint string,
connection onvifCameraConnection,
) (string, error) {
body := `<tds:GetCapabilities xmlns:tds="http://www.onvif.org/ver10/device/wsdl">` +
`<tds:Category>Media</tds:Category>` +
`</tds:GetCapabilities>`
responseBody, err := onvifSOAPRequest(
ctx,
client,
deviceEndpoint,
"http://www.onvif.org/ver10/device/wsdl/GetCapabilities",
body,
connection,
)
if err != nil {
return "", err
}
xaddrs := xmlTextValuesByLocalName(responseBody, "XAddr")
for _, xaddr := range xaddrs {
if strings.Contains(strings.ToLower(xaddr), "media") {
return strings.TrimSpace(xaddr), nil
}
}
if len(xaddrs) > 0 {
return strings.TrimSpace(xaddrs[0]), nil
}
return "", fmt.Errorf("ONVIF media service XAddr is missing")
}
func onvifGetProfileTokens(
ctx context.Context,
client *http.Client,
mediaEndpoint string,
connection onvifCameraConnection,
) ([]string, error) {
body := `<trt:GetProfiles xmlns:trt="http://www.onvif.org/ver10/media/wsdl"/>`
responseBody, err := onvifSOAPRequest(
ctx,
client,
mediaEndpoint,
"http://www.onvif.org/ver10/media/wsdl/GetProfiles",
body,
connection,
)
if err != nil {
return nil, err
}
return xmlProfileTokens(responseBody), nil
}
func onvifGetVideoEncoderConfigurationOptions(
ctx context.Context,
client *http.Client,
mediaEndpoint string,
profileToken string,
connection onvifCameraConnection,
) ([]string, error) {
body := `<trt:GetVideoEncoderConfigurationOptions xmlns:trt="http://www.onvif.org/ver10/media/wsdl">`
if strings.TrimSpace(profileToken) != "" {
body += `<trt:ProfileToken>` + xmlText(profileToken) + `</trt:ProfileToken>`
}
body += `</trt:GetVideoEncoderConfigurationOptions>`
responseBody, err := onvifSOAPRequest(
ctx,
client,
mediaEndpoint,
"http://www.onvif.org/ver10/media/wsdl/GetVideoEncoderConfigurationOptions",
body,
connection,
)
if err != nil {
return nil, err
}
return xmlResolutionValues(responseBody), nil
}
func onvifSOAPRequest(
ctx context.Context,
client *http.Client,
endpoint string,
action string,
body string,
connection onvifCameraConnection,
) ([]byte, error) {
var lastErr error
for _, soap12 := range []bool{true, false} {
responseBody, err := onvifSOAPRequestWithVersion(
ctx,
client,
endpoint,
action,
body,
connection,
soap12,
)
if err == nil {
return responseBody, nil
}
lastErr = err
}
return nil, lastErr
}
func onvifSOAPRequestWithVersion(
ctx context.Context,
client *http.Client,
endpoint string,
action string,
body string,
connection onvifCameraConnection,
soap12 bool,
) ([]byte, error) {
envelopeNS := "http://www.w3.org/2003/05/soap-envelope"
if !soap12 {
envelopeNS = "http://schemas.xmlsoap.org/soap/envelope/"
}
envelope := `<?xml version="1.0" encoding="UTF-8"?>` +
`<s:Envelope xmlns:s="` + envelopeNS + `">` +
`<s:Header>` + onvifSecurityHeader(connection.Username, connection.Password) + `</s:Header>` +
`<s:Body>` + body + `</s:Body>` +
`</s:Envelope>`
request, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
endpoint,
strings.NewReader(envelope),
)
if err != nil {
return nil, err
}
if soap12 {
request.Header.Set("Content-Type", `application/soap+xml; charset=utf-8; action="`+action+`"`)
} else {
request.Header.Set("Content-Type", "text/xml; charset=utf-8")
request.Header.Set("SOAPAction", `"`+action+`"`)
}
request.Header.Set("Accept", "application/soap+xml, text/xml, application/xml")
response, err := client.Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
responseBody, readErr := io.ReadAll(response.Body)
if readErr != nil {
return nil, readErr
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return nil, fmt.Errorf(
"ONVIF request failed: endpoint=%s status=%d body=%s",
endpoint,
response.StatusCode,
truncateMilestoneLogBody(responseBody),
)
}
if faultText := xmlSOAPFaultText(responseBody); faultText != "" {
return nil, fmt.Errorf("ONVIF SOAP fault: %s", faultText)
}
return responseBody, nil
}
func onvifSecurityHeader(username string, password string) string {
username = strings.TrimSpace(username)
password = strings.TrimSpace(password)
if username == "" && password == "" {
return ""
}
nonce := make([]byte, 20)
if _, err := rand.Read(nonce); err != nil {
return ""
}
created := time.Now().UTC().Format("2006-01-02T15:04:05Z")
hash := sha1.New()
hash.Write(nonce)
hash.Write([]byte(created))
hash.Write([]byte(password))
digest := base64.StdEncoding.EncodeToString(hash.Sum(nil))
encodedNonce := base64.StdEncoding.EncodeToString(nonce)
return `<wsse:Security s:mustUnderstand="1" ` +
`xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd" ` +
`xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">` +
`<wsse:UsernameToken>` +
`<wsse:Username>` + xmlText(username) + `</wsse:Username>` +
`<wsse:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest">` +
xmlText(digest) +
`</wsse:Password>` +
`<wsse:Nonce EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary">` +
xmlText(encodedNonce) +
`</wsse:Nonce>` +
`<wsu:Created>` + xmlText(created) + `</wsu:Created>` +
`</wsse:UsernameToken>` +
`</wsse:Security>`
}
func onvifDeviceServiceCandidates(host string, port string) []string {
host = strings.TrimSpace(host)
port = strings.TrimSpace(port)
if parsedHost, parsedPort := parseOnvifHostAndPort(host); parsedHost != "" {
host = parsedHost
if port == "" {
port = parsedPort
}
}
if port == "" {
port = "80"
}
ports := []string{port}
if port != "80" {
ports = append(ports, "80")
}
candidates := []string{}
seen := map[string]struct{}{}
for _, candidatePort := range ports {
schemes := []string{"http"}
if candidatePort == "443" {
schemes = []string{"https", "http"}
}
for _, scheme := range schemes {
candidate := scheme + "://" + net.JoinHostPort(host, candidatePort) + "/onvif/device_service"
if _, exists := seen[candidate]; exists {
continue
}
seen[candidate] = struct{}{}
candidates = append(candidates, candidate)
}
}
return candidates
}
func parseOnvifHostAndPort(value string) (string, string) {
value = strings.TrimSpace(value)
if value == "" {
return "", ""
}
if strings.Contains(value, "://") {
parsedURL, err := url.Parse(value)
if err == nil && parsedURL.Hostname() != "" {
return strings.TrimSpace(parsedURL.Hostname()), strings.TrimSpace(parsedURL.Port())
}
}
parsedURL, err := url.Parse("//" + value)
if err == nil && parsedURL.Hostname() != "" {
return strings.TrimSpace(parsedURL.Hostname()), strings.TrimSpace(parsedURL.Port())
}
return value, ""
}
func onvifResolveEndpoint(baseEndpoint string, rawEndpoint string) string {
rawEndpoint = strings.TrimSpace(rawEndpoint)
if rawEndpoint == "" {
return baseEndpoint
}
parsedEndpoint, err := url.Parse(rawEndpoint)
if err == nil && parsedEndpoint.IsAbs() {
return rawEndpoint
}
baseURL, err := url.Parse(baseEndpoint)
if err != nil {
return rawEndpoint
}
relativeURL, err := url.Parse(rawEndpoint)
if err != nil {
return rawEndpoint
}
return baseURL.ResolveReference(relativeURL).String()
}
func onvifHTTPClient(skipTLSVerify bool) *http.Client {
client := &http.Client{
Timeout: onvifResolutionFetchTimeout,
}
if skipTLSVerify {
client.Transport = &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
},
}
}
return client
}
func xmlText(value string) string {
var buffer bytes.Buffer
_ = xml.EscapeText(&buffer, []byte(value))
return buffer.String()
}
func xmlTextValuesByLocalName(body []byte, localName string) []string {
decoder := xml.NewDecoder(bytes.NewReader(body))
values := []string{}
for {
token, err := decoder.Token()
if err != nil {
break
}
startElement, ok := token.(xml.StartElement)
if !ok || startElement.Name.Local != localName {
continue
}
var text string
if err := decoder.DecodeElement(&text, &startElement); err == nil {
text = strings.TrimSpace(text)
if text != "" {
values = append(values, text)
}
}
}
return values
}
func xmlProfileTokens(body []byte) []string {
decoder := xml.NewDecoder(bytes.NewReader(body))
tokens := []string{}
seen := map[string]struct{}{}
for {
token, err := decoder.Token()
if err != nil {
break
}
startElement, ok := token.(xml.StartElement)
if !ok || startElement.Name.Local != "Profiles" {
continue
}
for _, attr := range startElement.Attr {
if attr.Name.Local != "token" {
continue
}
value := strings.TrimSpace(attr.Value)
if value == "" {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
tokens = append(tokens, value)
}
}
return tokens
}
func xmlResolutionValues(body []byte) []string {
decoder := xml.NewDecoder(bytes.NewReader(body))
resolutions := []string{}
seen := map[string]struct{}{}
inResolution := false
width := 0
height := 0
addResolution := func() {
if width <= 0 || height <= 0 {
return
}
value := fmt.Sprintf("%dx%d", width, height)
if _, exists := seen[value]; exists {
return
}
seen[value] = struct{}{}
resolutions = append(resolutions, value)
}
for {
token, err := decoder.Token()
if err != nil {
break
}
switch typedToken := token.(type) {
case xml.StartElement:
switch typedToken.Name.Local {
case "ResolutionsAvailable", "Resolution":
inResolution = true
width = 0
height = 0
case "Width":
if !inResolution {
continue
}
var text string
if err := decoder.DecodeElement(&text, &typedToken); err == nil {
width, _ = strconv.Atoi(strings.TrimSpace(text))
}
case "Height":
if !inResolution {
continue
}
var text string
if err := decoder.DecodeElement(&text, &typedToken); err == nil {
height, _ = strconv.Atoi(strings.TrimSpace(text))
}
}
case xml.EndElement:
switch typedToken.Name.Local {
case "ResolutionsAvailable", "Resolution":
addResolution()
inResolution = false
width = 0
height = 0
}
}
}
return sortMilestoneResolutionValues(resolutions)
}
func xmlSOAPFaultText(body []byte) string {
for _, name := range []string{"Text", "Reason", "faultstring"} {
values := xmlTextValuesByLocalName(body, name)
if len(values) == 0 {
continue
}
return values[0]
}
return ""
}
func applyOnvifAvailableResolutionsToMilestoneSettings(
settings map[string]any,
resolutions []string,
) {
if len(resolutions) == 0 {
return
}
rawStreams, ok := settings["stream"].([]any)
if !ok {
return
}
for _, rawStream := range rawStreams {
streamMap, ok := rawStream.(map[string]any)
if !ok {
continue
}
streamMap["availableResolutions"] = mergeMilestoneResolutionValues(
streamMap["availableResolutions"],
milestoneStringValue(streamMap["resolution"]),
resolutions,
)
}
}
func applyOnvifAvailableResolutionsToMilestoneActiveStreams(
activeStreams []any,
resolutions []string,
) {
if len(resolutions) == 0 {
return
}
for _, rawGroup := range activeStreams {
group, ok := rawGroup.(map[string]any)
if !ok {
continue
}
rawSettings, ok := group["stream"].([]any)
if !ok {
continue
}
for _, rawSetting := range rawSettings {
setting, ok := rawSetting.(map[string]any)
if !ok {
continue
}
setting["availableResolutions"] = mergeMilestoneResolutionValues(
setting["availableResolutions"],
milestoneStringValue(setting["resolution"]),
resolutions,
)
}
}
}
func mergeMilestoneResolutionValues(existing any, current string, extra []string) []string {
values := []string{}
values = append(values, extractMilestoneResolutionValues(existing, 0)...)
values = append(values, current)
values = append(values, extra...)
return sortMilestoneResolutionValues(values)
}
func sortMilestoneResolutionValues(values []string) []string {
seen := map[string]struct{}{}
resolutions := []string{}
for _, value := range values {
resolution := normalizeMilestoneResolution(value)
if resolution == "" {
continue
}
if _, exists := seen[resolution]; exists {
continue
}
seen[resolution] = struct{}{}
resolutions = append(resolutions, resolution)
}
sort.SliceStable(resolutions, func(i int, j int) bool {
iWidth, iHeight := splitMilestoneResolution(resolutions[i])
jWidth, jHeight := splitMilestoneResolution(resolutions[j])
iPixels := iWidth * iHeight
jPixels := jWidth * jHeight
if iPixels == jPixels {
if iWidth == jWidth {
return iHeight < jHeight
}
return iWidth < jWidth
}
return iPixels < jPixels
})
return resolutions
}
func splitMilestoneResolution(value string) (int, int) {
parts := strings.Split(normalizeMilestoneResolution(value), "x")
if len(parts) != 2 {
return 0, 0
}
width, _ := strconv.Atoi(parts[0])
height, _ := strconv.Atoi(parts[1])
return width, height
}
func pgxErrNoRows() error {
return pgx.ErrNoRows
}

View File

@ -86,6 +86,7 @@ type OperationHistoryEntry struct {
OperationID string `json:"operationId"`
UserID string `json:"userId"`
UserDisplayName string `json:"userDisplayName"`
UserAvatar string `json:"userAvatar"`
Action string `json:"action"`
Changes []OperationHistoryChange `json:"changes"`
CreatedAt time.Time `json:"createdAt"`
@ -648,6 +649,7 @@ func (s *Server) handleListOperationHistory(w http.ResponseWriter, r *http.Reque
h.operation_id,
COALESCE(h.user_id::TEXT, ''),
COALESCE(NULLIF(u.display_name, ''), NULLIF(u.username, ''), u.email, 'Unbekannt'),
COALESCE(u.avatar, ''),
h.action,
h.changes,
h.created_at,
@ -677,6 +679,7 @@ func (s *Server) handleListOperationHistory(w http.ResponseWriter, r *http.Reque
&entry.OperationID,
&entry.UserID,
&entry.UserDisplayName,
&entry.UserAvatar,
&entry.Action,
&changesRaw,
&entry.CreatedAt,

View File

@ -36,6 +36,7 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("GET /devices", s.requireAuth(s.handleListDevices))
mux.HandleFunc("POST /devices", s.requireAuth(s.handleCreateDevice))
mux.HandleFunc("PUT /devices/{id}", s.requireAuth(s.handleUpdateDevice))
mux.HandleFunc("GET /device-loan-users", s.requireAuth(s.handleListDeviceLoanUsers))
mux.HandleFunc("GET /devices/{id}/history", s.requireAuth(s.handleListDeviceHistory))
mux.HandleFunc("POST /devices/{id}/history", s.requireAuth(s.handleCreateDeviceJournalEntry))
@ -55,6 +56,22 @@ func (s *Server) Routes() http.Handler {
"PATCH /devices/{id}/milestone-cameras/{childId}",
s.requireAuth(s.handleUpdateMilestoneCameraDetails),
)
mux.HandleFunc(
"GET /devices/{id}/milestone-cameras/{childId}/streams",
s.requireAuth(s.handleGetMilestoneCameraApiStreams),
)
mux.HandleFunc(
"PUT /devices/{id}/milestone-cameras/{childId}/streams/{streamId}",
s.requireAuth(s.handleUpdateMilestoneApiStream),
)
mux.HandleFunc(
"GET /devices/{id}/milestone-cameras/{childId}/camera-settings",
s.requireAuth(s.handleGetMilestoneCameraSettingsDetail),
)
mux.HandleFunc(
"PATCH /devices/{id}/milestone-cameras/{childId}/camera-settings",
s.requireAuth(s.handleUpdateMilestoneCameraSettingsDetail),
)
mux.HandleFunc("GET /devices/firmware-check", s.requireAuth(s.handleGetCameraFirmwareCheckState))
mux.HandleFunc("POST /devices/firmware-check", s.requireAuth(s.handleCheckCameraFirmware))
@ -144,6 +161,7 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("GET /admin/group-chats", s.requireAuth(s.requireRight("administration:read", s.handleAdminListGroupChats)))
mux.HandleFunc("PUT /admin/group-chats/{id}", s.requireAuth(s.requireRight("administration:write", s.handleAdminUpdateGroupChat)))
mux.HandleFunc("DELETE /admin/group-chats/{id}", s.requireAuth(s.requireRight("administration:write", s.handleAdminDeleteGroupChat)))
mux.HandleFunc("POST /admin/group-chats/{id}/empty", s.requireAuth(s.requireRight("administration:write", s.handleAdminEmptyGroupChat)))
mux.HandleFunc("GET /admin/calendar/settings", s.requireAuth(s.requireRight("administration:read", s.handleGetCalendarSettings)))
mux.HandleFunc("PUT /admin/calendar/settings", s.requireAuth(s.requireRight("administration:write", s.handleUpdateCalendarSettings)))

54
backend/runtime_schema.go Normal file
View File

@ -0,0 +1,54 @@
package main
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
)
func ensureRuntimeDeviceSchema(ctx context.Context, db *pgxpool.Pool) error {
queries := []string{
`CREATE EXTENSION IF NOT EXISTS pgcrypto`,
`ALTER TABLE IF EXISTS devices ADD COLUMN IF NOT EXISTS phone_number TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE IF EXISTS devices ADD COLUMN IF NOT EXISTS computer_name TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE IF EXISTS devices ADD COLUMN IF NOT EXISTS device_category TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE IF EXISTS devices ADD COLUMN IF NOT EXISTS loaned_to_user_id UUID REFERENCES users(id) ON DELETE SET NULL`,
`ALTER TABLE IF EXISTS devices ADD COLUMN IF NOT EXISTS loaned_until DATE`,
`
CREATE TABLE IF NOT EXISTS device_relations (
device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
related_device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (device_id, related_device_id),
CONSTRAINT device_relations_no_self_reference CHECK (device_id <> related_device_id)
)
`,
`
CREATE TABLE IF NOT EXISTS milestone_hardware_child_devices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
milestone_hardware_id TEXT NOT NULL,
milestone_device_id TEXT NOT NULL,
device_type TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
display_name TEXT NOT NULL DEFAULT '',
short_name TEXT NOT NULL DEFAULT '',
enabled BOOLEAN NOT NULL DEFAULT true,
recording_enabled BOOLEAN NOT NULL DEFAULT false,
recording_storage_id TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (milestone_hardware_id, milestone_device_id, device_type)
)
`,
`ALTER TABLE IF EXISTS milestone_hardware_child_devices ADD COLUMN IF NOT EXISTS recording_enabled BOOLEAN NOT NULL DEFAULT false`,
`ALTER TABLE IF EXISTS milestone_hardware_child_devices ADD COLUMN IF NOT EXISTS recording_storage_id TEXT NOT NULL DEFAULT ''`,
}
for _, query := range queries {
if _, err := db.Exec(ctx, query); err != nil {
return err
}
}
return nil
}

View File

@ -23,6 +23,12 @@ type serverFoldersAgentConfig struct {
Token string
}
type serverFoldersAgentHTTPResponse struct {
StatusCode int
Body []byte
ContentType string
}
func (s *Server) handleProxyListServerFolders(w http.ResponseWriter, r *http.Request) {
s.proxyServerFoldersRequest(w, r, http.MethodGet)
}
@ -39,6 +45,58 @@ func (s *Server) handleProxyDeleteServerFolder(w http.ResponseWriter, r *http.Re
s.proxyServerFoldersRequest(w, r, http.MethodDelete)
}
func newServerFoldersAgentHTTPRequest(
ctx context.Context,
method string,
targetURL string,
body []byte,
config serverFoldersAgentConfig,
) (*http.Request, error) {
request, err := http.NewRequestWithContext(
ctx,
method,
targetURL,
bytes.NewReader(body),
)
if err != nil {
return nil, err
}
request.Header.Set("Accept", "application/json")
if method == http.MethodPost || method == http.MethodPatch || method == http.MethodDelete {
request.Header.Set("Content-Type", "application/json")
}
if config.Token != "" {
request.Header.Set("X-Server-Folders-Token", config.Token)
}
return request, nil
}
func sendServerFoldersAgentHTTPRequest(
client *http.Client,
request *http.Request,
) (serverFoldersAgentHTTPResponse, error) {
response, err := client.Do(request)
if err != nil {
return serverFoldersAgentHTTPResponse{}, err
}
defer response.Body.Close()
responseBody, err := io.ReadAll(io.LimitReader(response.Body, 4<<20))
if err != nil {
return serverFoldersAgentHTTPResponse{StatusCode: response.StatusCode}, err
}
return serverFoldersAgentHTTPResponse{
StatusCode: response.StatusCode,
Body: responseBody,
ContentType: response.Header.Get("Content-Type"),
}, nil
}
func (s *Server) proxyServerFoldersRequest(w http.ResponseWriter, r *http.Request, method string) {
config, err := s.getServerFoldersAgentConfig(r.Context())
if err != nil {
@ -61,52 +119,76 @@ func (s *Server) proxyServerFoldersRequest(w http.ResponseWriter, r *http.Reques
}
}
request, err := http.NewRequestWithContext(
request, err := newServerFoldersAgentHTTPRequest(
r.Context(),
method,
targetURL,
bytes.NewReader(body),
body,
config,
)
if err != nil {
writeError(w, http.StatusBadGateway, "Proxy-Request konnte nicht erstellt werden")
return
}
request.Header.Set("Accept", "application/json")
if method == http.MethodPost || method == http.MethodPatch || method == http.MethodDelete {
request.Header.Set("Content-Type", "application/json")
}
if config.Token != "" {
request.Header.Set("X-Server-Folders-Token", config.Token)
}
client := &http.Client{
Timeout: 20 * time.Second,
}
response, err := client.Do(request)
agentResponse, err := sendServerFoldersAgentHTTPRequest(client, request)
if err != nil {
writeError(w, http.StatusBadGateway, "Server-Folders-Agent ist nicht erreichbar")
return
}
defer response.Body.Close()
responseBody, err := io.ReadAll(io.LimitReader(response.Body, 4<<20))
if err != nil {
writeError(w, http.StatusBadGateway, "Antwort vom Server-Folders-Agent konnte nicht gelesen werden")
return
if agentResponse.StatusCode == http.StatusUnauthorized {
refreshedConfig, err := s.rotateServerFoldersAgentToken(r.Context(), config)
if err != nil {
writeError(
w,
http.StatusBadGateway,
"Server-Folders-Agent hat Unauthorized zurückgegeben. Token-Erneuerung ist fehlgeschlagen. Bitte Agent-Token prüfen oder Milestone-Einstellungen neu speichern.",
)
return
}
request, err = newServerFoldersAgentHTTPRequest(
r.Context(),
method,
targetURL,
body,
refreshedConfig,
)
if err != nil {
writeError(w, http.StatusBadGateway, "Proxy-Request konnte nicht erstellt werden")
return
}
agentResponse, err = sendServerFoldersAgentHTTPRequest(client, request)
if err != nil {
writeError(w, http.StatusBadGateway, "Server-Folders-Agent ist nach Token-Erneuerung nicht erreichbar")
return
}
if agentResponse.StatusCode == http.StatusUnauthorized {
writeError(
w,
http.StatusBadGateway,
"Server-Folders-Agent hat auch nach Token-Erneuerung Unauthorized zurückgegeben. Bitte Agent-Token prüfen.",
)
return
}
}
contentType := response.Header.Get("Content-Type")
contentType := agentResponse.ContentType
if strings.TrimSpace(contentType) == "" {
contentType = "application/json"
}
w.Header().Set("Content-Type", contentType)
w.WriteHeader(response.StatusCode)
_, _ = w.Write(responseBody)
w.WriteHeader(agentResponse.StatusCode)
_, _ = w.Write(agentResponse.Body)
}
func (s *Server) requestServerFoldersAgent(
@ -133,51 +215,60 @@ func (s *Server) requestServerFoldersAgent(
}
}
request, err := http.NewRequestWithContext(
request, err := newServerFoldersAgentHTTPRequest(
ctx,
method,
targetURL,
bytes.NewReader(body),
body,
config,
)
if err != nil {
return nil, 0, err
}
request.Header.Set("Accept", "application/json")
if payload != nil {
request.Header.Set("Content-Type", "application/json")
}
if config.Token != "" {
request.Header.Set("X-Server-Folders-Token", config.Token)
}
client := &http.Client{
Timeout: 30 * time.Second,
}
response, err := client.Do(request)
agentResponse, err := sendServerFoldersAgentHTTPRequest(client, request)
if err != nil {
return nil, 0, err
}
defer response.Body.Close()
responseBody, err := io.ReadAll(io.LimitReader(response.Body, 4<<20))
if err != nil {
return nil, response.StatusCode, err
if agentResponse.StatusCode == http.StatusUnauthorized {
refreshedConfig, err := s.rotateServerFoldersAgentToken(ctx, config)
if err != nil {
return agentResponse.Body, agentResponse.StatusCode, err
}
request, err = newServerFoldersAgentHTTPRequest(
ctx,
method,
targetURL,
body,
refreshedConfig,
)
if err != nil {
return nil, 0, err
}
agentResponse, err = sendServerFoldersAgentHTTPRequest(client, request)
if err != nil {
return nil, 0, err
}
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return responseBody, response.StatusCode, fmt.Errorf(
if agentResponse.StatusCode < 200 || agentResponse.StatusCode >= 300 {
return agentResponse.Body, agentResponse.StatusCode, fmt.Errorf(
"server-folders-agent request failed: method=%s url=%s status=%d body=%s",
method,
targetURL,
response.StatusCode,
string(responseBody),
agentResponse.StatusCode,
string(agentResponse.Body),
)
}
return responseBody, response.StatusCode, nil
return agentResponse.Body, agentResponse.StatusCode, nil
}
type serverFolderAgentFolderResponse struct {
@ -413,6 +504,38 @@ func (s *Server) pushServerFoldersAgentToken(
return nil
}
func (s *Server) rotateServerFoldersAgentToken(
ctx context.Context,
config serverFoldersAgentConfig,
) (serverFoldersAgentConfig, error) {
newToken, err := generateServerFoldersAgentToken()
if err != nil {
return config, err
}
if err := s.pushServerFoldersAgentToken(ctx, config, newToken); err != nil {
return config, err
}
if _, err := s.db.Exec(
ctx,
`
UPDATE milestone_settings
SET
server_folders_agent_token_encrypted = pgp_sym_encrypt($1, $2),
updated_at = now()
WHERE id = 1
`,
newToken,
s.milestoneEncryptionKey(),
); err != nil {
return config, err
}
config.Token = newToken
return config, nil
}
func forwardedServerFoldersQuery(values url.Values) string {
nextValues := url.Values{}

View File

@ -417,8 +417,13 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
serial_number TEXT NOT NULL DEFAULT '',
mac_address TEXT NOT NULL DEFAULT '',
ip_address TEXT NOT NULL DEFAULT '',
phone_number TEXT NOT NULL DEFAULT '',
computer_name TEXT NOT NULL DEFAULT '',
device_category TEXT NOT NULL DEFAULT '',
location TEXT NOT NULL DEFAULT '',
loan_status TEXT NOT NULL DEFAULT 'Verfügbar',
loaned_to_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
loaned_until DATE,
is_bao_device BOOLEAN NOT NULL DEFAULT false,
comment TEXT NOT NULL DEFAULT '',
@ -434,6 +439,17 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`,
`ALTER TABLE IF EXISTS devices ADD COLUMN IF NOT EXISTS phone_number TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE IF EXISTS devices ADD COLUMN IF NOT EXISTS computer_name TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE IF EXISTS devices ADD COLUMN IF NOT EXISTS device_category TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE IF EXISTS devices ADD COLUMN IF NOT EXISTS loaned_to_user_id UUID REFERENCES users(id) ON DELETE SET NULL`,
`ALTER TABLE IF EXISTS devices ADD COLUMN IF NOT EXISTS loaned_until DATE`,
`
UPDATE devices
SET device_category = 'Kameras'
WHERE COALESCE(milestone_hardware_id, '') <> ''
AND COALESCE(device_category, '') = ''
`,
`
CREATE TABLE IF NOT EXISTS milestone_hardware_child_devices (
@ -741,6 +757,18 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
)
`,
`
CREATE TABLE IF NOT EXISTS onvif_resolution_cache (
milestone_hardware_id TEXT PRIMARY KEY,
host TEXT NOT NULL DEFAULT '',
port TEXT NOT NULL DEFAULT '',
resolutions JSONB NOT NULL DEFAULT '[]'::JSONB,
check_error TEXT NOT NULL DEFAULT '',
checked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`,
// ── Chat ────────────────────────────────────────────────────────────
// Eine Konversation ist entweder ein Team-Gruppenchat, Einzelchat,
// freier Gruppenchat oder ein schreibgeschützter Integrations-Channel.

View File

@ -1,4 +1,4 @@
// frontend/src/App.tsx
// frontend\src\App.tsx
import { useCallback, useEffect, useState } from 'react'
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router'
@ -18,10 +18,12 @@ import NotificationCenter from './components/NotificationCenter'
import type { Notification, User } from './components/types'
import AdministrationPage from './pages/administration/AdministrationPage'
import Feedback from './components/Feedback'
import MilestoneStoragePage from './pages/operations/milestone-storage/MilestoneStoragePage'
import MilestoneDevicesPage from './pages/operations/milestone-devices/MilestoneDevicesPage'
import MilestonePage from './pages/milestone/MilestonePage'
import MilestoneStoragePage from './pages/milestone/speicher/MilestoneStoragePage'
import MilestoneDevicesPage from './pages/milestone/geraete/MilestoneDevicesPage'
import { hasRight } from './components/permissions'
import ChatPage from './pages/chat/ChatPage'
import { ChatSocketProvider } from './components/ChatSocketProvider'
import { recordNavigation } from './utils/activityLog'
import { syncExistingPushSubscription } from './utils/pushNotifications'
import { notifyNewMessage } from './utils/titleNotifier'
@ -624,139 +626,161 @@ function App() {
}
return (
<AppLayout
user={user}
onUserUpdated={setUser}
onLogout={handleLogout}
unreadChatCount={unreadChatCount}
notificationCenter={
<NotificationCenter
notifications={notifications}
unreadCount={unreadNotificationCount}
onMarkRead={markNotificationRead}
onMarkAllRead={markAllNotificationsRead}
onNotificationClick={handleNotificationClick}
/>
}
>
<Routes>
<Route path="/" element={<DashboardPage username={user.username} />} />
<ChatSocketProvider>
<AppLayout
user={user}
onUserUpdated={setUser}
onLogout={handleLogout}
unreadChatCount={unreadChatCount}
notificationCenter={
<NotificationCenter
notifications={notifications}
unreadCount={unreadNotificationCount}
onMarkRead={markNotificationRead}
onMarkAllRead={markAllNotificationsRead}
onNotificationClick={handleNotificationClick}
/>
}
>
<Routes>
<Route path="/" element={<DashboardPage username={user.username} />} />
<Route
path="/geraete"
element={
<RequireRight user={user} right="devices:read">
<DevicesPage />
</RequireRight>
}
/>
<Route
path="/geraete"
element={
<RequireRight user={user} right="devices:read">
<DevicesPage currentUser={user} />
</RequireRight>
}
/>
<Route
path="/einsaetze"
element={
<RequireRight user={user} right="operations:read">
<OperationsPage
unreadJournalOperationIds={unreadJournalOperationIds}
onJournalRead={markOperationJournalRead}
canWriteOperations={hasRight(user, 'operations:write')}
/>
</RequireRight>
}
/>
<Route
path="/einsaetze"
element={
<RequireRight user={user} right="operations:read">
<OperationsPage
unreadJournalOperationIds={unreadJournalOperationIds}
onJournalRead={markOperationJournalRead}
canWriteOperations={hasRight(user, 'operations:write')}
currentUser={user}
/>
</RequireRight>
}
/>
<Route
path="/einsaetze/milestone-speicher"
element={
<RequireRight user={user} right="administration:read">
<MilestoneStoragePage
canWrite={hasRight(user, 'administration:write')}
/>
</RequireRight>
}
/>
<Route
path="/milestone"
element={
<RequireRight user={user} right="administration:read">
<MilestonePage />
</RequireRight>
}
/>
<Route
path="/einsaetze/milestone-devices"
element={
<RequireRight user={user} right="administration:read">
<MilestoneDevicesPage
canWrite={hasRight(user, 'administration:write')}
/>
</RequireRight>
}
/>
<Route
path="/milestone/geraete"
element={
<RequireRight user={user} right="administration:read">
<MilestoneDevicesPage
canWrite={hasRight(user, 'administration:write')}
/>
</RequireRight>
}
/>
<Route
path="/kalender"
element={
<RequireRight user={user} right="calendar:read">
<CalendarPage />
</RequireRight>
}
/>
<Route
path="/milestone/speicher"
element={
<RequireRight user={user} right="administration:read">
<MilestoneStoragePage
canWrite={hasRight(user, 'administration:write')}
/>
</RequireRight>
}
/>
<Route
path="/dokumente"
element={
<RequireRight user={user} right="documents:read">
<DocumentsPage />
</RequireRight>
}
/>
<Route
path="/einsaetze/milestone-speicher"
element={<Navigate to="/milestone/speicher" replace />}
/>
<Route
path="/berichte"
element={
<RequireRight user={user} right="reports:read">
<ReportsPage />
</RequireRight>
}
/>
<Route
path="/einsaetze/milestone-devices"
element={<Navigate to="/milestone/geraete" replace />}
/>
<Route
path="/administration"
element={<Navigate to="/administration/benutzer" replace />}
/>
<Route
path="/kalender"
element={
<RequireRight user={user} right="calendar:read">
<CalendarPage />
</RequireRight>
}
/>
<Route
path="/administration/*"
element={
<RequireRight user={user} right="administration:read">
<AdministrationPage />
</RequireRight>
}
/>
<Route
path="/dokumente"
element={
<RequireRight user={user} right="documents:read">
<DocumentsPage />
</RequireRight>
}
/>
<Route
path="/einstellungen/*"
element={
<RequireRight user={user} right="settings:read">
<SettingsPage
user={user}
onUserUpdated={setUser}
/>
</RequireRight>
}
/>
<Route
path="/berichte"
element={
<RequireRight user={user} right="reports:read">
<ReportsPage />
</RequireRight>
}
/>
<Route path="/feedback" element={<Feedback />} />
<Route path="/chat" element={<ChatPage currentUser={user} />} />
<Route
path="/chat/team/:teamId"
element={<ChatPage currentUser={user} />}
/>
<Route
path="/chat/channel/:channelId"
element={<ChatPage currentUser={user} />}
/>
<Route
path="/chat/:conversationId"
element={<ChatPage currentUser={user} />}
/>
<Route
path="/administration"
element={<Navigate to="/administration/benutzer" replace />}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</AppLayout>
<Route
path="/administration/*"
element={
<RequireRight user={user} right="administration:read">
<AdministrationPage />
</RequireRight>
}
/>
<Route
path="/einstellungen/*"
element={
<RequireRight user={user} right="settings:read">
<SettingsPage
user={user}
onUserUpdated={setUser}
/>
</RequireRight>
}
/>
<Route path="/feedback" element={<Feedback />} />
<Route path="/chat" element={<ChatPage currentUser={user} />} />
<Route
path="/chat/team/:teamId"
element={<ChatPage currentUser={user} />}
/>
<Route
path="/chat/channel/:channelId"
element={<ChatPage currentUser={user} />}
/>
<Route
path="/chat/:conversationId"
element={<ChatPage currentUser={user} />}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</AppLayout>
</ChatSocketProvider>
)
}

View File

@ -0,0 +1,151 @@
// frontend/src/components/ChatSocketProvider.tsx
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
function getChatWebSocketUrl() {
const url = new URL(
`${API_URL.replace(/\/$/, '')}/chat/ws`,
window.location.origin,
)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
return url.toString()
}
type ChatSocketListener = (payload: unknown) => void
type ChatSocketContextValue = {
connected: boolean
// Sendet ein Kommando; gibt zurück, ob die Verbindung offen war.
send: (command: unknown) => boolean
// Registriert einen Listener für eingehende Ereignisse; liefert Unsubscribe.
subscribe: (listener: ChatSocketListener) => () => void
}
const ChatSocketContext = createContext<ChatSocketContextValue | null>(null)
// ChatSocketProvider hält genau EINE WebSocket-Verbindung für die gesamte App
// offen, solange er eingehängt ist (d. h. ab Login). Verbraucher wie ChatPage
// abonnieren Ereignisse über subscribe() und senden über send(), statt jeweils
// eine eigene Verbindung aufzubauen.
export function ChatSocketProvider({ children }: { children: ReactNode }) {
const socketRef = useRef<WebSocket | null>(null)
const listenersRef = useRef(new Set<ChatSocketListener>())
const [connected, setConnected] = useState(false)
useEffect(() => {
let socket: WebSocket | null = null
let reconnectTimer: number | undefined
let reconnectAttempt = 0
let stopped = false
function connect() {
socket = new WebSocket(getChatWebSocketUrl())
socketRef.current = socket
socket.onopen = () => {
reconnectAttempt = 0
setConnected(true)
}
socket.onmessage = (event) => {
let payload: unknown
try {
payload = JSON.parse(event.data)
} catch {
return
}
if (
payload &&
typeof payload === 'object' &&
(payload as { type?: unknown }).type === 'connected'
) {
setConnected(true)
}
listenersRef.current.forEach((listener) => listener(payload))
}
socket.onclose = () => {
if (socketRef.current === socket) {
socketRef.current = null
}
setConnected(false)
if (stopped) {
return
}
const delay = Math.min(1000 * 2 ** reconnectAttempt, 10_000)
reconnectAttempt += 1
reconnectTimer = window.setTimeout(connect, delay)
}
}
connect()
return () => {
stopped = true
setConnected(false)
if (reconnectTimer !== undefined) {
window.clearTimeout(reconnectTimer)
}
socketRef.current = null
socket?.close(1000, 'Chat geschlossen')
}
}, [])
const send = useCallback((command: unknown) => {
const socket = socketRef.current
if (!socket || socket.readyState !== WebSocket.OPEN) {
return false
}
try {
socket.send(JSON.stringify(command))
return true
} catch {
return false
}
}, [])
const subscribe = useCallback((listener: ChatSocketListener) => {
const listeners = listenersRef.current
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}, [])
const value = useMemo(
() => ({ connected, send, subscribe }),
[connected, send, subscribe],
)
return (
<ChatSocketContext.Provider value={value}>
{children}
</ChatSocketContext.Provider>
)
}
export function useChatSocket() {
const context = useContext(ChatSocketContext)
if (!context) {
throw new Error(
'useChatSocket muss innerhalb von ChatSocketProvider verwendet werden',
)
}
return context
}

View File

@ -2,7 +2,7 @@
'use client'
import { useState } from 'react'
import { useState, type ComponentType, type SVGProps } from 'react'
import {
Combobox as HeadlessCombobox,
ComboboxButton,
@ -27,6 +27,7 @@ export type ComboboxItem = {
type ComboboxProps = {
label?: string
labelIcon?: ComponentType<SVGProps<SVGSVGElement>>
items: ComboboxItem[]
value: ComboboxItem | null
onChange: (item: ComboboxItem | null) => void
@ -51,6 +52,7 @@ function getSecondaryText(item: ComboboxItem) {
export default function Combobox({
label,
labelIcon: LabelIcon,
items,
value,
onChange,
@ -154,8 +156,18 @@ export default function Combobox({
className={className}
>
{label && (
<Label className="block text-sm/6 font-medium text-gray-900 dark:text-white">
{label}
<Label
className={classNames(
'text-sm/6 font-medium text-gray-900 dark:text-white',
LabelIcon ? 'flex items-center gap-2' : 'block',
)}
>
{LabelIcon && (
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-indigo-50 text-indigo-600 ring-1 ring-indigo-100 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20">
<LabelIcon aria-hidden="true" className="size-4" />
</span>
)}
<span>{label}</span>
</Label>
)}
@ -233,4 +245,4 @@ export default function Combobox({
</div>
</HeadlessCombobox>
)
}
}

View File

@ -0,0 +1,358 @@
import {
useEffect,
useId,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react'
import {
CalendarDaysIcon,
ChevronLeftIcon,
ChevronRightIcon,
XMarkIcon,
} from '@heroicons/react/20/solid'
import { toDateKey } from './calendar/dateUtils'
type DatePickerProps = {
id?: string
name?: string
label?: ReactNode
description?: ReactNode
error?: ReactNode
value?: string
defaultValue?: string
onChange?: (value: string) => void
placeholder?: string
disabled?: boolean
required?: boolean
min?: string
max?: string
clearable?: boolean
wrapperClassName?: string
buttonClassName?: string
}
const weekdayLabels = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(' ')
}
function startOfDay(date: Date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate())
}
function addDays(date: Date, amount: number) {
const next = new Date(date)
next.setDate(next.getDate() + amount)
return startOfDay(next)
}
function addMonths(date: Date, amount: number) {
return startOfDay(new Date(date.getFullYear(), date.getMonth() + amount, 1))
}
function startOfWeek(date: Date) {
const normalized = startOfDay(date)
const mondayOffset = (normalized.getDay() + 6) % 7
return addDays(normalized, -mondayOffset)
}
function parseDateKey(value: string | undefined) {
if (!value) {
return null
}
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value)
if (!match) {
return null
}
const date = new Date(
Number(match[1]),
Number(match[2]) - 1,
Number(match[3]),
)
return toDateKey(date) === value ? date : null
}
function formatMonthTitle(date: Date) {
return new Intl.DateTimeFormat('de-DE', {
month: 'long',
year: 'numeric',
}).format(date)
}
function formatDisplayDate(value: string) {
const date = parseDateKey(value)
if (!date) {
return value
}
return new Intl.DateTimeFormat('de-DE', {
day: '2-digit',
month: 'long',
year: 'numeric',
}).format(date)
}
function isSameDay(left: Date, right: Date) {
return toDateKey(left) === toDateKey(right)
}
function isOutsideRange(dayKey: string, min?: string, max?: string) {
return Boolean((min && dayKey < min) || (max && dayKey > max))
}
export default function DatePicker({
id,
name,
label,
description,
error,
value,
defaultValue = '',
onChange,
placeholder = 'Datum auswählen',
disabled = false,
required = false,
min,
max,
clearable = true,
wrapperClassName,
buttonClassName,
}: DatePickerProps) {
const generatedId = useId()
const inputId = id ?? name ?? `date-picker-${generatedId}`
const descriptionId = description ? `${inputId}-description` : undefined
const errorId = error ? `${inputId}-error` : undefined
const [internalValue, setInternalValue] = useState(defaultValue)
const selectedValue = value ?? internalValue
const selectedDate = useMemo(() => parseDateKey(selectedValue), [selectedValue])
const today = useMemo(() => startOfDay(new Date()), [])
const [visibleMonth, setVisibleMonth] = useState(() =>
startOfDay(selectedDate ?? today),
)
const [open, setOpen] = useState(false)
const wrapperRef = useRef<HTMLDivElement>(null)
const days = useMemo(() => {
const monthStart = new Date(
visibleMonth.getFullYear(),
visibleMonth.getMonth(),
1,
)
const gridStart = startOfWeek(monthStart)
return Array.from({ length: 42 }, (_, index) => addDays(gridStart, index))
}, [visibleMonth])
useEffect(() => {
if (open) {
setVisibleMonth(startOfDay(selectedDate ?? today))
}
}, [open, selectedDate, today])
useEffect(() => {
if (!open) {
return
}
function handlePointerDown(event: MouseEvent) {
if (
event.target instanceof Node &&
!wrapperRef.current?.contains(event.target)
) {
setOpen(false)
}
}
document.addEventListener('mousedown', handlePointerDown)
return () => document.removeEventListener('mousedown', handlePointerDown)
}, [open])
function updateValue(nextValue: string) {
if (value === undefined) {
setInternalValue(nextValue)
}
onChange?.(nextValue)
}
function selectDay(day: Date) {
const dayKey = toDateKey(day)
if (isOutsideRange(dayKey, min, max)) {
return
}
updateValue(dayKey)
setOpen(false)
}
const describedBy = [descriptionId, errorId].filter(Boolean).join(' ')
return (
<div
ref={wrapperRef}
className={classNames('relative', wrapperClassName)}
onKeyDown={(event) => {
if (event.key === 'Escape') {
setOpen(false)
}
}}
>
{name && <input type="hidden" name={name} value={selectedValue} />}
{label && (
<label
htmlFor={inputId}
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
{label}
</label>
)}
<button
id={inputId}
type="button"
disabled={disabled}
aria-haspopup="dialog"
aria-expanded={open}
aria-invalid={error ? true : undefined}
aria-describedby={describedBy || undefined}
onClick={() => setOpen((current) => !current)}
className={classNames(
'mt-2 grid w-full grid-cols-1 rounded-md bg-white py-2 pr-9 pl-3.5 text-left text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 transition focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible: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:focus-visible:outline-indigo-500 dark:disabled:bg-white/10 dark:disabled:text-gray-400 dark:disabled:outline-white/10',
!selectedValue && 'text-gray-400 dark:text-gray-500',
buttonClassName,
)}
>
<span className="col-start-1 row-start-1 truncate pr-6">
{selectedValue ? formatDisplayDate(selectedValue) : placeholder}
</span>
<CalendarDaysIcon
aria-hidden="true"
className="col-start-1 row-start-1 size-5 self-center justify-self-end text-gray-500 sm:size-4 dark:text-gray-400"
/>
</button>
{description && (
<p
id={descriptionId}
className="mt-2 text-sm text-gray-500 dark:text-gray-400"
>
{description}
</p>
)}
{error && (
<p id={errorId} className="mt-2 text-sm text-red-600 dark:text-red-400">
{error}
</p>
)}
{open && (
<div
role="dialog"
aria-label={typeof label === 'string' ? label : 'Datum auswählen'}
className="absolute z-50 mt-2 w-80 overflow-hidden rounded-lg bg-white text-left shadow-lg outline outline-black/5 dark:bg-gray-900 dark:outline-white/10"
>
<div className="flex items-center justify-between border-b border-gray-200 px-3 py-3 dark:border-white/10">
<button
type="button"
onClick={() => setVisibleMonth((current) => addMonths(current, -1))}
className="inline-flex size-8 items-center justify-center rounded-md text-gray-500 hover:bg-gray-100 hover:text-gray-900 focus-visible:outline-2 focus-visible:outline-indigo-600 dark:text-gray-400 dark:hover:bg-white/10 dark:hover:text-white dark:focus-visible:outline-indigo-500"
>
<span className="sr-only">Vorheriger Monat</span>
<ChevronLeftIcon aria-hidden="true" className="size-5" />
</button>
<div className="text-sm font-semibold text-gray-900 dark:text-white">
{formatMonthTitle(visibleMonth)}
</div>
<button
type="button"
onClick={() => setVisibleMonth((current) => addMonths(current, 1))}
className="inline-flex size-8 items-center justify-center rounded-md text-gray-500 hover:bg-gray-100 hover:text-gray-900 focus-visible:outline-2 focus-visible:outline-indigo-600 dark:text-gray-400 dark:hover:bg-white/10 dark:hover:text-white dark:focus-visible:outline-indigo-500"
>
<span className="sr-only">Nächster Monat</span>
<ChevronRightIcon aria-hidden="true" className="size-5" />
</button>
</div>
<div className="grid grid-cols-7 border-b border-gray-200 bg-gray-50 text-center text-xs font-semibold text-gray-600 dark:border-white/10 dark:bg-white/5 dark:text-gray-300">
{weekdayLabels.map((weekday) => (
<div key={weekday} className="py-2">
{weekday}
</div>
))}
</div>
<div className="grid grid-cols-7 gap-px bg-gray-200 p-px dark:bg-white/10">
{days.map((day) => {
const dayKey = toDateKey(day)
const currentMonth = day.getMonth() === visibleMonth.getMonth()
const selected = selectedValue === dayKey
const todayDate = isSameDay(day, today)
const outsideRange = isOutsideRange(dayKey, min, max)
return (
<button
key={dayKey}
type="button"
disabled={outsideRange}
onClick={() => selectDay(day)}
className={classNames(
'flex h-10 items-center justify-center bg-white text-sm font-medium text-gray-900 outline-none transition hover:bg-indigo-50 hover:text-indigo-700 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500 dark:bg-gray-900 dark:text-white dark:hover:bg-indigo-500/10 dark:hover:text-indigo-300',
!currentMonth &&
'bg-gray-100/70 text-gray-300 opacity-55 saturate-50 hover:bg-gray-100 hover:text-gray-300 dark:bg-gray-950/70 dark:text-gray-600 dark:opacity-45 dark:hover:bg-gray-950 dark:hover:text-gray-600',
outsideRange &&
'cursor-not-allowed bg-gray-50 text-gray-300 hover:bg-gray-50 hover:text-gray-300 dark:bg-gray-950 dark:text-gray-700 dark:hover:bg-gray-950 dark:hover:text-gray-700',
)}
>
<span
className={classNames(
'flex size-7 items-center justify-center rounded-full',
todayDate && !selected && 'bg-indigo-600 text-white dark:bg-indigo-500',
selected && 'bg-gray-900 text-white dark:bg-white dark:text-gray-900',
)}
>
{day.getDate()}
</span>
</button>
)
})}
</div>
<div className="flex items-center justify-between border-t border-gray-200 px-3 py-2 dark:border-white/10">
<button
type="button"
onClick={() => selectDay(today)}
disabled={isOutsideRange(toDateKey(today), min, max)}
className="rounded-md px-2.5 py-1.5 text-sm font-medium text-indigo-600 hover:bg-indigo-50 focus-visible:outline-2 focus-visible:outline-indigo-600 disabled:cursor-not-allowed disabled:text-gray-300 disabled:hover:bg-transparent dark:text-indigo-300 dark:hover:bg-indigo-500/10 dark:focus-visible:outline-indigo-500 dark:disabled:text-gray-600"
>
Heute
</button>
{clearable && selectedValue && !required && (
<button
type="button"
onClick={() => {
updateValue('')
setOpen(false)
}}
className="inline-flex items-center gap-1 rounded-md px-2.5 py-1.5 text-sm font-medium text-gray-500 hover:bg-gray-100 hover:text-gray-900 focus-visible:outline-2 focus-visible:outline-indigo-600 dark:text-gray-400 dark:hover:bg-white/10 dark:hover:text-white dark:focus-visible:outline-indigo-500"
>
<XMarkIcon aria-hidden="true" className="size-4" />
Leeren
</button>
)}
</div>
</div>
)}
</div>
)
}

View File

@ -1,10 +1,11 @@
// frontend\src\components\Journal.tsx
import { type ComponentType, type SVGProps, useState } from 'react'
import { type ComponentType, type SVGProps, useMemo, useState } from 'react'
import {
ArrowRightIcon,
ChatBubbleLeftEllipsisIcon,
ClockIcon,
MagnifyingGlassIcon,
PaperAirplaneIcon,
PencilSquareIcon,
PlusCircleIcon,
@ -13,6 +14,7 @@ import Avatar from './Avatar'
import { Badge, type BadgeTone } from './Badge'
import Button from './Button'
import NotificationDot from './NotificationDot'
import Textarea from './Textarea'
type JournalIcon = ComponentType<SVGProps<SVGSVGElement>>
@ -56,7 +58,11 @@ export type JournalItem = {
type JournalProps = {
items?: JournalItem[]
currentUserAvatar?: string
currentUserName?: string
showEntryForm?: boolean
showSearch?: boolean
searchQuery?: string
onSearchQueryChange?: (value: string) => void
isSubmitting?: boolean
emptyText?: string
placeholder?: string
@ -98,6 +104,23 @@ function getChangeKey(change: JournalChange, index: number) {
return `${change.label}-${index}`
}
function getItemSearchText(item: JournalItem) {
const changeParts = (item.changes ?? []).flatMap((change) => [
change.label,
change.oldValue,
change.newValue,
])
return [
getItemPersonName(item),
getItemDescription(item),
...changeParts,
]
.filter(Boolean)
.join(' ')
.toLowerCase()
}
function getJournalIcon(item: JournalItem) {
if (item.icon) {
return item.icon
@ -152,7 +175,11 @@ function getJournalType(item: JournalItem): {
export default function Journal({
items = [],
currentUserAvatar,
currentUserName = 'Du',
showEntryForm = true,
showSearch = true,
searchQuery,
onSearchQueryChange,
isSubmitting = false,
emptyText = 'Noch keine Journal-Einträge vorhanden.',
placeholder = 'Journal-Eintrag hinzufügen...',
@ -164,6 +191,27 @@ export default function Journal({
const [entry, setEntry] = useState('')
const [editingItemId, setEditingItemId] = useState<string | number | null>(null)
const [editEntry, setEditEntry] = useState('')
const [internalSearchQuery, setInternalSearchQuery] = useState('')
const activeSearchQuery = searchQuery ?? internalSearchQuery
function updateSearchQuery(value: string) {
if (onSearchQueryChange) {
onSearchQueryChange(value)
return
}
setInternalSearchQuery(value)
}
const filteredItems = useMemo(() => {
const normalizedQuery = activeSearchQuery.trim().toLowerCase()
if (!normalizedQuery) {
return items
}
return items.filter((item) => getItemSearchText(item).includes(normalizedQuery))
}, [items, activeSearchQuery])
async function handleSubmit() {
const content = entry.trim()
@ -199,69 +247,67 @@ export default function Journal({
return (
<div className={className}>
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 className="text-sm font-semibold text-gray-950 dark:text-white">
Journal
</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Verlauf, Änderungen und manuelle Einträge.
</p>
</div>
</div>
{showEntryForm && (
<div className="mb-8 rounded-2xl border border-indigo-100 bg-gradient-to-br from-indigo-50/80 via-white to-violet-50/50 p-4 shadow-sm dark:border-indigo-400/15 dark:from-indigo-500/10 dark:via-gray-900/60 dark:to-violet-500/5">
<div className="mb-3 flex items-center gap-3">
<div className="flex size-9 items-center justify-center rounded-xl bg-indigo-600 text-white shadow-sm shadow-indigo-600/20 dark:bg-indigo-500">
<ChatBubbleLeftEllipsisIcon
aria-hidden="true"
className="size-5"
/>
</div>
<div className="mb-8">
<Textarea
variant="avatar-actions"
id="journalEntry"
name="journalEntry"
label="Journal-Eintrag hinzufügen"
srOnlyLabel
rows={3}
value={entry}
onChange={(event) => setEntry(event.target.value)}
placeholder={placeholder}
titlePlaceholder="Titel (optional)"
avatarProps={{ src: currentUserAvatar, name: currentUserName }}
submitButton={
<Button
type="button"
variant="primary"
color="indigo"
size="sm"
isLoading={isSubmitting}
disabled={!entry.trim() || isSubmitting}
onClick={() => void handleSubmit()}
leadingIcon={
<PaperAirplaneIcon aria-hidden="true" className="size-4" />
}
>
{submitLabel}
</Button>
}
/>
</div>
)}
<div>
<h4 className="text-sm font-semibold text-gray-950 dark:text-white">
Neuer Journal-Eintrag
</h4>
<p className="text-xs text-gray-500 dark:text-gray-400">
Notizen und wichtige Informationen nachvollziehbar festhalten.
</p>
</div>
</div>
<div className="flex items-start gap-3">
<Avatar
src={currentUserAvatar}
name="Du"
size={9}
className="mt-1"
{showSearch && items.length > 0 && (
<div className="mb-4 flex justify-end">
<div className="relative w-80 max-w-full">
<MagnifyingGlassIcon
aria-hidden="true"
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-gray-400 dark:text-gray-500"
/>
<div className="min-w-0 flex-1">
<div className="overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-gray-200 transition focus-within:ring-2 focus-within:ring-indigo-600 dark:bg-white/5 dark:ring-white/10 dark:focus-within:ring-indigo-400">
<label htmlFor="journalEntry" className="sr-only">
Journal-Eintrag hinzufügen
</label>
<textarea
id="journalEntry"
name="journalEntry"
rows={3}
value={entry}
onChange={(event) => setEntry(event.target.value)}
placeholder={placeholder}
className="block min-h-24 w-full resize-y bg-transparent px-3.5 py-3 text-base text-gray-900 placeholder:text-gray-400 focus:outline-none sm:text-sm/6 dark:text-white dark:placeholder:text-gray-500"
/>
<div className="flex items-center justify-end border-t border-gray-100 bg-gray-50/70 px-2.5 py-2 dark:border-white/10 dark:bg-white/[0.03]">
<Button
type="button"
variant="primary"
color="indigo"
size="sm"
rounded="full"
isLoading={isSubmitting}
disabled={!entry.trim() || isSubmitting}
onClick={() => void handleSubmit()}
leadingIcon={
<PaperAirplaneIcon aria-hidden="true" className="size-4" />
}
>
{submitLabel}
</Button>
</div>
</div>
</div>
<input
type="search"
value={activeSearchQuery}
onChange={(event) => updateSearchQuery(event.target.value)}
placeholder="Journal durchsuchen..."
aria-label="Journal durchsuchen"
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>
)}
@ -281,10 +327,25 @@ export default function Journal({
{emptyText}
</p>
</div>
) : filteredItems.length === 0 ? (
<div className="rounded-2xl border border-dashed border-gray-300 bg-gray-50/70 px-6 py-10 text-center dark:border-white/15 dark:bg-white/[0.03]">
<div className="mx-auto flex size-11 items-center justify-center rounded-2xl bg-white text-gray-400 shadow-sm ring-1 ring-gray-200 dark:bg-white/5 dark:text-gray-500 dark:ring-white/10">
<MagnifyingGlassIcon
aria-hidden="true"
className="size-6"
/>
</div>
<p className="mt-3 text-sm font-semibold text-gray-900 dark:text-white">
Keine Treffer
</p>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Es wurden keine Einträge zu {activeSearchQuery.trim()}" gefunden.
</p>
</div>
) : (
<div className="flow-root">
<ul role="list" className="space-y-4">
{items.map((item, itemIndex) => {
{filteredItems.map((item, itemIndex) => {
const personName = getItemPersonName(item)
const imageUrl = getItemImageUrl(item)
const dateTime = getItemDateTime(item)
@ -296,7 +357,7 @@ export default function Journal({
return (
<li key={item.id} className="relative">
{itemIndex !== items.length - 1 && (
{itemIndex !== filteredItems.length - 1 && (
<span
aria-hidden="true"
className="absolute top-10 bottom-[-1rem] left-5 w-px bg-gradient-to-b from-gray-300 to-gray-200 dark:from-white/20 dark:to-white/5"
@ -322,7 +383,7 @@ export default function Journal({
</span>
</div>
<article className="min-w-0 flex-1 rounded-2xl border border-gray-200/80 bg-white p-4 shadow-sm ring-1 ring-black/[0.02] transition hover:border-gray-300 hover:shadow-md dark:border-white/10 dark:bg-gray-900/70 dark:ring-white/5 dark:hover:border-white/20">
<article className="min-w-0 flex-1 rounded-lg bg-white p-4 shadow-xs outline-1 -outline-offset-1 outline-gray-300 dark:bg-gray-900/70 dark:outline-white/10">
<header className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
@ -339,14 +400,17 @@ export default function Journal({
</span>
)}
<Badge
tone={itemType.tone}
variant="border"
shape="pill"
size="small"
>
{itemType.label}
</Badge>
{hasChanges && (
<Badge
tone="indigo"
variant="border"
shape="rounded"
size="small"
>
{changes.length}{' '}
{changes.length === 1 ? 'Änderung' : 'Änderungen'}
</Badge>
)}
{item.hasNotification && (
<NotificationDot
@ -375,11 +439,13 @@ export default function Journal({
{isManualEntry && editingItemId === item.id ? (
<div className="mt-4 rounded-xl bg-gray-50/70 p-3 ring-1 ring-gray-200 ring-inset dark:bg-white/[0.03] dark:ring-white/10">
<textarea
<Textarea
label="Journal-Eintrag bearbeiten"
srOnlyLabel
rows={3}
value={editEntry}
onChange={(event) => setEditEntry(event.target.value)}
className="block min-h-24 w-full resize-y rounded-xl border-0 bg-white px-3.5 py-3 text-sm text-gray-900 shadow-sm ring-1 ring-gray-200 ring-inset placeholder:text-gray-400 focus:ring-2 focus:ring-indigo-600 dark:bg-white/5 dark:text-white dark:ring-white/10 dark:focus:ring-indigo-400"
className="min-h-24"
/>
<div className="mt-3 flex justify-end gap-x-2">
@ -451,9 +517,13 @@ export default function Journal({
{changes.map((change, changeIndex) => (
<section
key={getChangeKey(change, changeIndex)}
className="rounded-xl border border-gray-200 bg-gray-50/70 p-3.5 dark:border-white/10 dark:bg-white/[0.03]"
className="rounded-xl border-1 border-gray-200/70 bg-gray-50/70 p-3.5 dark:border-indigo-500/60 dark:bg-white/[0.03]"
>
<h5 className="text-xs font-semibold text-gray-900 dark:text-white">
<h5 className="flex items-center gap-1.5 text-xs font-semibold text-gray-900 dark:text-white">
<PencilSquareIcon
aria-hidden="true"
className="size-3.5 text-indigo-500 dark:text-indigo-400"
/>
{change.label}
</h5>
@ -462,22 +532,25 @@ export default function Journal({
<div className="text-[10px] font-semibold tracking-wider text-gray-400 uppercase dark:text-gray-500">
Vorher
</div>
<div className="mt-1.5 whitespace-pre-wrap break-words text-sm text-gray-600 dark:text-gray-300">
<div className="mt-1.5 whitespace-pre-wrap break-words text-sm text-gray-500 line-through dark:text-gray-400">
{getChangeValue(change.oldValue)}
</div>
</div>
<div className="hidden items-center lg:flex">
<span className="flex size-7 items-center justify-center rounded-full bg-white text-gray-400 shadow-sm ring-1 ring-gray-200 dark:bg-white/5 dark:text-gray-500 dark:ring-white/10">
<ArrowRightIcon aria-hidden="true" className="size-3.5" />
<div className="flex items-center justify-center">
<span className="flex size-7 items-center justify-center rounded-full bg-white text-indigo-500 shadow-xs ring-1 ring-indigo-200 dark:bg-white/5 dark:text-indigo-400 dark:ring-indigo-400/30">
<ArrowRightIcon
aria-hidden="true"
className="size-3.5 rotate-90 lg:rotate-0"
/>
</span>
</div>
<div className="min-w-0 rounded-lg bg-indigo-50/80 p-3 ring-1 ring-indigo-200 ring-inset dark:bg-indigo-500/10 dark:ring-indigo-400/20">
<div className="min-w-0 rounded-lg bg-indigo-50 p-3 ring-1 ring-indigo-300 dark:bg-indigo-500/15 dark:ring-indigo-400/40">
<div className="text-[10px] font-semibold tracking-wider text-indigo-600 uppercase dark:text-indigo-300">
Nachher
</div>
<div className="mt-1.5 whitespace-pre-wrap break-words text-sm font-medium text-gray-950 dark:text-white">
<div className="mt-1.5 whitespace-pre-wrap break-words text-sm font-semibold text-gray-950 dark:text-white">
{getChangeValue(change.newValue)}
</div>
</div>

View File

@ -161,7 +161,8 @@ export default function NotificationCenter({
<Tabs
tabs={notificationTabs}
variant="full-width-underline"
variant="underline"
fullWidth
align="center"
ariaLabel="Benachrichtigungsfilter"
className="px-4 [&_a]:py-3"

View File

@ -11,7 +11,7 @@ type SearchProps = {
export default function Search({
value,
onChange,
placeholder = 'Suche...',
placeholder = 'Suchen...',
}: SearchProps) {
return (
<form

View File

@ -0,0 +1,149 @@
import {
forwardRef,
useId,
type ReactNode,
type SelectHTMLAttributes,
} from 'react'
import { ChevronDownIcon } from '@heroicons/react/16/solid'
export type SelectOption = {
value: string | number
label: ReactNode
disabled?: boolean
}
type SelectProps = Omit<SelectHTMLAttributes<HTMLSelectElement>, 'children'> & {
label?: ReactNode
description?: ReactNode
error?: ReactNode
options?: SelectOption[]
placeholder?: ReactNode
placeholderDisabled?: boolean
children?: ReactNode
wrapperClassName?: string
labelClassName?: string
descriptionClassName?: string
errorClassName?: string
selectClassName?: string
}
function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(' ')
}
const baseSelectClassName =
'col-start-1 row-start-1 w-full appearance-none rounded-md bg-white py-1.5 pr-8 pl-3 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible: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:*:bg-gray-800 dark:focus-visible:outline-indigo-500 dark:disabled:bg-white/10 dark:disabled:text-gray-400 dark:disabled:outline-white/10'
const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select(
{
id,
name,
label,
description,
error,
options = [],
placeholder,
placeholderDisabled = true,
children,
wrapperClassName,
labelClassName,
descriptionClassName,
errorClassName,
selectClassName,
className,
disabled,
'aria-describedby': ariaDescribedBy,
'aria-invalid': ariaInvalid,
...props
},
ref,
) {
const generatedId = useId()
const selectId = id ?? name ?? `select-${generatedId}`
const descriptionId = description ? `${selectId}-description` : undefined
const errorId = error ? `${selectId}-error` : undefined
const describedBy = [ariaDescribedBy, descriptionId, errorId]
.filter(Boolean)
.join(' ')
return (
<div className={wrapperClassName}>
{label && (
<label
htmlFor={selectId}
className={classNames(
'block text-sm/6 font-medium text-gray-900 dark:text-white',
labelClassName,
)}
>
{label}
</label>
)}
<div className={classNames('grid grid-cols-1', Boolean(label) && 'mt-2')}>
<select
{...props}
ref={ref}
id={selectId}
name={name}
disabled={disabled}
aria-invalid={error ? true : ariaInvalid}
aria-describedby={describedBy || undefined}
className={classNames(baseSelectClassName, selectClassName, className)}
>
{placeholder && (
<option value="" disabled={placeholderDisabled}>
{placeholder}
</option>
)}
{options.map((option) => (
<option
key={String(option.value)}
value={option.value}
disabled={option.disabled}
>
{option.label}
</option>
))}
{children}
</select>
<ChevronDownIcon
aria-hidden="true"
className={classNames(
'pointer-events-none col-start-1 row-start-1 mr-2 size-5 self-center justify-self-end text-gray-500 sm:size-4 dark:text-gray-400',
disabled && 'text-gray-300 dark:text-gray-500',
)}
/>
</div>
{description && (
<p
id={descriptionId}
className={classNames(
'mt-2 text-sm text-gray-500 dark:text-gray-400',
descriptionClassName,
)}
>
{description}
</p>
)}
{error && (
<p
id={errorId}
className={classNames(
'mt-2 text-sm text-red-600 dark:text-red-400',
errorClassName,
)}
>
{error}
</p>
)}
</div>
)
})
export default Select

View File

@ -62,17 +62,23 @@ const navigationSections: NavigationSection[] = [
href: '/einsaetze',
icon: VideoCameraIcon,
right: 'operations:read',
},
{
name: 'Milestone',
href: '/milestone',
icon: ServerStackIcon,
right: 'administration:read',
children: [
{
name: 'Milestone-Speicher',
href: '/einsaetze/milestone-speicher',
icon: ServerStackIcon,
name: 'Geräte',
href: '/milestone/geraete',
icon: CpuChipIcon,
right: 'administration:read',
},
{
name: 'Milestone-Geräte',
href: '/einsaetze/milestone-devices',
icon: CpuChipIcon,
name: 'Speicher',
href: '/milestone/speicher',
icon: ServerStackIcon,
right: 'administration:read',
},
],

View File

@ -1,4 +1,4 @@
// frontend/src/components/Tabs.tsx
// frontend\src\components\Tabs.tsx
import type {
ChangeEvent,
@ -26,21 +26,25 @@ type TabsVariant =
| 'pills'
| 'pills-gray'
| 'pills-brand'
| 'full-width-underline'
| 'bar-underline'
| 'underline-badges'
| 'simple'
type TabsAlign = 'left' | 'center' | 'right'
type TabsBackground = 'none' | 'subtle'
type TabsProps = {
tabs: TabItem[]
variant?: TabsVariant
align?: TabsAlign
fullWidth?: boolean
sticky?: boolean
stickyTopClassName?: string
ariaLabel?: string
className?: string
background?: TabsBackground
desktopNavClassName?: string
}
function classNames(...classes: Array<string | false | null | undefined>) {
@ -77,10 +81,13 @@ export default function Tabs({
tabs,
variant = 'underline',
align = 'left',
fullWidth = false,
sticky = false,
stickyTopClassName = 'top-0',
ariaLabel = 'Tabs',
className,
background = 'none',
desktopNavClassName,
}: TabsProps) {
const location = useLocation()
const navigate = useNavigate()
@ -89,6 +96,23 @@ export default function Tabs({
tabs.find((tab) => isTabActive(tab, location.pathname)) ?? tabs[0]
const alignClassName = alignClassMap[align]
// Bei fullWidth teilen sich die Tabs die verfügbare Breite gleichmäßig auf;
// align ist dann wirkungslos, da jeder Tab über flex-1 den Rest ausfüllt.
const fullWidthItemClassName = fullWidth
? 'min-w-0 flex-1 justify-center text-center'
: ''
// Die Pills-Varianten haben standardmäßig horizontales Padding am Wrapper.
// Bei fullWidth entfällt es, damit die Pills bis an den Rand reichen.
const pillsWrapperClassName = classNames(
background === 'subtle'
? 'rounded-md bg-gray-100 p-1.5 dark:bg-white/5'
: 'border-b border-gray-200 py-3 dark:border-white/10',
background === 'subtle'
? ''
: fullWidth
? ''
: 'px-4 sm:px-6 lg:px-8',
)
function handleSelectChange(event: ChangeEvent<HTMLSelectElement>) {
const selectedTab = tabs.find((tab) => tab.name === event.target.value)
@ -182,7 +206,11 @@ export default function Tabs({
<div className="border-b border-gray-200 dark:border-white/10">
<nav
aria-label={ariaLabel}
className={classNames('-mb-px flex space-x-8', alignClassName)}
className={classNames(
'-mb-px flex space-x-8',
alignClassName,
desktopNavClassName,
)}
>
{tabs.map((tab) => {
const active = isTabActive(tab, location.pathname)
@ -198,6 +226,7 @@ export default function Tabs({
? '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',
'flex items-center border-b-2 px-1 py-4 text-sm font-medium whitespace-nowrap',
fullWidthItemClassName,
)}
>
{tab.name}
@ -213,7 +242,11 @@ export default function Tabs({
<div className="border-b border-gray-200 dark:border-white/10">
<nav
aria-label={ariaLabel}
className={classNames('-mb-px flex space-x-8', alignClassName)}
className={classNames(
'-mb-px flex space-x-8',
alignClassName,
desktopNavClassName,
)}
>
{tabs.map((tab) => {
const active = isTabActive(tab, location.pathname)
@ -230,6 +263,7 @@ export default function Tabs({
? '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',
'group inline-flex items-center border-b-2 px-1 py-4 text-sm font-medium',
fullWidthItemClassName,
)}
>
{Icon && (
@ -254,10 +288,14 @@ 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">
<div className={pillsWrapperClassName}>
<nav
aria-label={ariaLabel}
className={classNames('flex space-x-4', alignClassName)}
className={classNames(
'flex',
fullWidth ? 'gap-1.5' : 'space-x-4',
alignClassName,
)}
>
{tabs.map((tab) => {
const active = isTabActive(tab, location.pathname)
@ -273,6 +311,7 @@ export default function Tabs({
? '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',
'inline-flex items-center rounded-md px-3 py-2 text-sm font-medium',
fullWidthItemClassName,
)}
>
{tab.name}
@ -285,10 +324,14 @@ 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">
<div className={pillsWrapperClassName}>
<nav
aria-label={ariaLabel}
className={classNames('flex space-x-4', alignClassName)}
className={classNames(
'flex',
fullWidth ? 'gap-1.5' : 'space-x-4',
alignClassName,
)}
>
{tabs.map((tab) => {
const active = isTabActive(tab, location.pathname)
@ -304,6 +347,7 @@ export default function Tabs({
? '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',
'inline-flex items-center rounded-md px-3 py-2 text-sm font-medium',
fullWidthItemClassName,
)}
>
{tab.name}
@ -316,10 +360,14 @@ 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">
<div className={pillsWrapperClassName}>
<nav
aria-label={ariaLabel}
className={classNames('flex space-x-4', alignClassName)}
className={classNames(
'flex',
fullWidth ? 'gap-1.5' : 'space-x-4',
alignClassName,
)}
>
{tabs.map((tab) => {
const active = isTabActive(tab, location.pathname)
@ -335,34 +383,7 @@ export default function Tabs({
? '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',
'inline-flex items-center rounded-md px-3 py-2 text-sm font-medium',
)}
>
{tab.name}
{renderCount(tab, active)}
</Link>
)
})}
</nav>
</div>
)}
{variant === 'full-width-underline' && (
<div className="border-b border-gray-200 dark:border-white/10">
<nav aria-label={ariaLabel} className="-mb-px flex">
{tabs.map((tab) => {
const active = isTabActive(tab, location.pathname)
return (
<Link
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 flex-1 items-center justify-center border-b-2 px-1 py-4 text-center text-sm font-medium',
fullWidthItemClassName,
)}
>
{tab.name}
@ -421,7 +442,11 @@ export default function Tabs({
<div className="border-b border-gray-200 dark:border-white/10">
<nav
aria-label={ariaLabel}
className={classNames('-mb-px flex space-x-8', alignClassName)}
className={classNames(
'-mb-px flex space-x-8',
alignClassName,
desktopNavClassName,
)}
>
{tabs.map((tab) => {
const active = isTabActive(tab, location.pathname)
@ -437,6 +462,7 @@ export default function Tabs({
? 'border-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400'
: 'border-transparent text-gray-500 hover:border-gray-200 hover:text-gray-700 dark:text-gray-400 dark:hover:border-white/20 dark:hover:text-white',
'flex border-b-2 px-1 py-4 text-sm font-medium whitespace-nowrap',
fullWidthItemClassName,
)}
>
{tab.name}
@ -464,7 +490,7 @@ export default function Tabs({
const active = isTabActive(tab, location.pathname)
return (
<li key={tab.name}>
<li key={tab.name} className={fullWidth ? 'flex-1' : undefined}>
<Link
to={tab.href}
aria-current={active ? 'page' : undefined}
@ -474,6 +500,7 @@ export default function Tabs({
? 'text-indigo-600 dark:text-indigo-400'
: 'hover:text-gray-700 dark:hover:text-white',
'inline-flex items-center',
fullWidth && 'w-full justify-center text-center',
)}
>
{tab.name}

View File

@ -33,11 +33,7 @@ export type TextareaVariant =
type TextareaResize = 'none' | 'vertical' | 'horizontal' | 'both'
type TextareaValue =
| string
| number
| readonly string[]
| undefined
type TextareaValue = string | number | readonly string[] | undefined
type TextareaProps = Omit<
TextareaHTMLAttributes<HTMLTextAreaElement>,
@ -62,12 +58,20 @@ type TextareaProps = Omit<
submitButtonType?: 'button' | 'submit'
showSubmitButton?: boolean
onSubmitButtonClick?: () => void
/** Eigener Submit-Button (ersetzt den Standard-Button, z. B. für Lade-Status/Icon). */
submitButton?: ReactNode
showAttachButton?: boolean
attachLabel?: string
onAttachClick?: () => void
/** Zusätzliche Aktions-Slots in der Toolbar (z. B. eigene Listbox-Picker). */
actions?: ReactNode
/** Pill-Aktionen oberhalb der Fußzeile (nur `title-actions`). */
pillActions?: ReactNode
titleLabel?: ReactNode
titlePlaceholder?: string
titleInputProps?: InputHTMLAttributes<HTMLInputElement>
footerAttachLabel?: string
@ -122,17 +126,18 @@ function getResizeClassName(resize: TextareaResize) {
}
}
// "Simple" / "Preview" eigenständig umrandetes Feld.
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'
// Felder, deren Umrandung am Wrapper sitzt (transparenter Hintergrund, kein eigener Fokus-Outline).
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 titleTextareaClassName = boxedTextareaClassName
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'
@ -155,6 +160,9 @@ const previewTabClassName =
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 underlineIconButtonClassName =
'-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'
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'
@ -178,12 +186,17 @@ const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
submitButtonType = 'submit',
showSubmitButton = true,
onSubmitButtonClick,
submitButton,
attachLabel = 'Datei anhängen',
onAttachClick,
showAttachButton = Boolean(onAttachClick),
actions,
pillActions,
titleLabel = 'Title',
titlePlaceholder,
titleInputProps,
footerAttachLabel = 'Datei anhängen',
@ -229,12 +242,7 @@ const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
const effectiveResize =
resize ??
(
variant === 'simple' ||
variant === 'preview-button'
? 'vertical'
: 'none'
)
(variant === 'simple' || variant === 'preview-button' ? 'vertical' : 'none')
const [internalValue, setInternalValue] = useState(() =>
normalizeTextareaValue(defaultValue),
@ -258,6 +266,10 @@ const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
}
function renderFeedback() {
if (!description && !error) {
return null
}
return (
<>
{description && !error && (
@ -287,7 +299,7 @@ const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
)
}
function renderLabel(defaultSrOnly = false) {
function renderLabel(forceSrOnly = false) {
if (!label) {
return null
}
@ -296,7 +308,7 @@ const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
<label
htmlFor={textareaId}
className={classNames(
srOnlyLabel || defaultSrOnly
srOnlyLabel || forceSrOnly
? 'sr-only'
: 'block text-sm/6 font-medium text-gray-900 dark:text-white',
labelClassName,
@ -317,7 +329,7 @@ const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
value={textareaValue}
disabled={disabled}
onChange={handleChange}
aria-invalid={Boolean(error) ? true : ariaInvalid}
aria-invalid={error ? true : ariaInvalid}
aria-describedby={describedBy || undefined}
className={classNames(
baseClassName,
@ -335,16 +347,10 @@ const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
return avatar
}
return (
<Avatar
size={10}
shape="circle"
{...avatarProps}
/>
)
return <Avatar size={10} shape="circle" {...avatarProps} />
}
function renderAttachButton(className = iconButtonClassName) {
function renderAttachButton(buttonClassName = iconButtonClassName) {
if (!showAttachButton) {
return null
}
@ -354,7 +360,7 @@ const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
type="button"
disabled={disabled}
onClick={onAttachClick}
className={className}
className={buttonClassName}
>
<PaperClipIcon aria-hidden="true" className="size-5" />
<span className="sr-only">{attachLabel}</span>
@ -363,6 +369,10 @@ const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
}
function renderSubmitButton() {
if (submitButton !== undefined) {
return submitButton
}
if (!showSubmitButton) {
return null
}
@ -419,29 +429,21 @@ const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
)
}
// Variante: Avatar mit Box und Aktionsleiste.
if (variant === 'avatar-actions') {
return (
<div
className={classNames(
hasAvatar && 'flex items-start space-x-4',
wrapperClassName,
)}
className={classNames(hasAvatar && 'flex items-start space-x-4', wrapperClassName)}
>
<div className="shrink-0">
{renderAvatar()}
</div>
<div className="shrink-0">{renderAvatar()}</div>
<div className="min-w-0 flex-1">
<div className="relative">
<div
className={classNames(
boxedWrapperClassName,
fieldWrapperClassName,
)}
>
<div className={classNames(boxedWrapperClassName, fieldWrapperClassName)}>
{renderLabel(true)}
{renderTextarea(boxedTextareaClassName)}
{/* Platzhalter, der die Höhe der Aktionsleiste ausgleicht. */}
<div aria-hidden="true" className="py-2">
<div className="py-px">
<div className="h-9" />
@ -456,14 +458,11 @@ const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
)}
>
<div className="flex items-center space-x-5">
<div className="flex items-center">
{renderAttachButton()}
</div>
<div className="flex items-center">{renderAttachButton()}</div>
{actions && <div className="flex items-center">{actions}</div>}
</div>
<div className="shrink-0">
{renderSubmitButton()}
</div>
<div className="shrink-0">{renderSubmitButton()}</div>
</div>
</div>
@ -473,44 +472,29 @@ const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
)
}
// Variante: Unterstrich mit Aktionsleiste.
if (variant === 'underline-actions') {
return (
<div
className={classNames(
hasAvatar && 'flex items-start space-x-4',
wrapperClassName,
)}
className={classNames(hasAvatar && 'flex items-start space-x-4', wrapperClassName)}
>
<div className="shrink-0">
{renderAvatar()}
</div>
<div className="shrink-0">{renderAvatar()}</div>
<div className="min-w-0 flex-1">
<div
className={classNames(
underlineWrapperClassName,
fieldWrapperClassName,
)}
>
<div className={classNames(underlineWrapperClassName, fieldWrapperClassName)}>
{renderLabel(true)}
{renderTextarea(underlineTextareaClassName)}
</div>
<div
className={classNames(
'flex justify-between pt-2',
toolbarClassName,
)}
>
<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')}
{renderAttachButton(underlineIconButtonClassName)}
</div>
{actions && <div className="flow-root">{actions}</div>}
</div>
<div className="shrink-0">
{renderSubmitButton()}
</div>
<div className="shrink-0">{renderSubmitButton()}</div>
</div>
{renderFeedback()}
@ -519,23 +503,18 @@ const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
)
}
// Variante: Titel mit Pill-Aktionen.
if (variant === 'title-actions') {
const {
className: titleInputCustomClassName,
id: _providedTitleInputId,
name: providedTitleInputName,
placeholder: titleInputCustomPlaceholder,
...restTitleInputProps
} = titleInputProps ?? {}
return (
<div className={wrapperClassName}>
<div className="relative">
<div
className={classNames(
titleWrapperClassName,
fieldWrapperClassName,
)}
>
<div className={classNames(titleWrapperClassName, fieldWrapperClassName)}>
<label htmlFor={titleInputId} className="sr-only">
{titleLabel}
</label>
@ -543,8 +522,9 @@ const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
<input
{...restTitleInputProps}
id={titleInputId}
name={providedTitleInputName ?? 'title'}
name={titleInputProps?.name ?? 'title'}
type={titleInputProps?.type ?? 'text'}
placeholder={titleInputCustomPlaceholder ?? titlePlaceholder}
disabled={disabled || titleInputProps?.disabled}
className={classNames(
titleInputBaseClassName,
@ -556,14 +536,37 @@ const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
{renderLabel(true)}
{renderTextarea(titleTextareaClassName)}
<div aria-hidden="true" className="py-2">
<div className="py-px">
<div className="h-9" />
{/* Platzhalter, der die Höhe der absoluten Aktionsbereiche ausgleicht. */}
<div aria-hidden="true">
{pillActions && (
<>
<div className="py-2">
<div className="h-9" />
</div>
<div className="h-px" />
</>
)}
<div className="py-2">
<div className="py-px">
<div className="h-9" />
</div>
</div>
</div>
</div>
<div className="absolute inset-x-px bottom-0">
{pillActions && (
<div
className={classNames(
'flex flex-nowrap justify-end space-x-2 px-2 py-2 sm:px-3',
toolbarClassName,
)}
>
{pillActions}
</div>
)}
<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 && (
@ -584,9 +587,7 @@ const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
)}
</div>
<div className="shrink-0">
{renderSubmitButton()}
</div>
<div className="shrink-0">{renderSubmitButton()}</div>
</div>
</div>
</div>
@ -596,6 +597,7 @@ const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
)
}
// Variante: Schreiben/Vorschau-Tabs.
if (variant === 'preview-button') {
const resolvedPreviewContent =
typeof previewContent === 'function'
@ -607,13 +609,8 @@ const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
<TabGroup>
<div className="group flex items-center">
<TabList className="flex gap-2">
<Tab className={previewTabClassName}>
{writeTabLabel}
</Tab>
<Tab className={previewTabClassName}>
{previewTabLabel}
</Tab>
<Tab className={previewTabClassName}>{writeTabLabel}</Tab>
<Tab className={previewTabClassName}>{previewTabLabel}</Tab>
</TabList>
{renderPreviewToolbar()}
@ -622,14 +619,12 @@ const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
<TabPanels className="mt-2">
<TabPanel className="-m-0.5 rounded-lg p-0.5">
{renderLabel(true)}
<div>
{renderTextarea(simpleTextareaClassName)}
</div>
<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">
<div className="mx-px mt-px px-3 pt-2 pb-12 text-sm whitespace-pre-wrap text-gray-800 dark:text-gray-300">
{resolvedPreviewContent || previewPlaceholder}
</div>
</div>
@ -637,15 +632,14 @@ const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
</TabPanels>
</TabGroup>
<div className="mt-2 flex justify-end">
{renderSubmitButton()}
</div>
<div className="mt-2 flex justify-end">{renderSubmitButton()}</div>
{renderFeedback()}
</div>
)
}
// Variante: Simple.
return (
<div className={wrapperClassName}>
{renderLabel()}
@ -660,4 +654,4 @@ const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
},
)
export default Textarea
export default Textarea

View File

@ -5,7 +5,7 @@ import {
MapPinIcon,
PlusIcon,
} from '@heroicons/react/20/solid'
import { useRef, useState, type PointerEvent } from 'react'
import { useEffect, useRef, useState, type PointerEvent } from 'react'
import ButtonGroup from '../ButtonGroup'
import {
timeToMinutes,
@ -224,7 +224,10 @@ function MonthView({
onSelectedDateChange,
onCreateEvent,
onEditEvent,
}: Omit<CalendarProps, 'view' | 'onViewChange' | 'coreWorkingHours'>) {
onOpenDay,
}: Omit<CalendarProps, 'view' | 'onViewChange' | 'coreWorkingHours'> & {
onOpenDay: (date: Date) => void
}) {
const monthStart = new Date(
selectedDate.getFullYear(),
selectedDate.getMonth(),
@ -248,6 +251,46 @@ function MonthView({
} | null>(null)
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
// Spiegel der aktuellen Props für den nativen Wheel-Listener, der nur einmal
// registriert wird und sonst veraltete Werte einfangen würde.
const selectedDateRef = useRef(selectedDate)
selectedDateRef.current = selectedDate
const onSelectedDateChangeRef = useRef(onSelectedDateChange)
onSelectedDateChangeRef.current = onSelectedDateChange
// Mausrad über dem Raster wechselt den Monat. Die 250-ms-Sperre verhindert,
// dass ein einzelner Scroll-Schub (Trackpad-Momentum) mehrere Monate springt.
useEffect(() => {
const grid = gridRef.current
if (!grid) {
return
}
let lastSwitch = 0
function handleWheel(event: WheelEvent) {
if (
event.deltaY === 0 ||
Math.abs(event.deltaX) > Math.abs(event.deltaY)
) {
return
}
event.preventDefault()
const now = Date.now()
if (now - lastSwitch < 250) {
return
}
lastSwitch = now
onSelectedDateChangeRef.current(
addMonths(selectedDateRef.current, event.deltaY > 0 ? 1 : -1),
)
}
grid.addEventListener('wheel', handleWheel, { passive: false })
return () => grid.removeEventListener('wheel', handleWheel)
}, [])
function getDayIndexFromPointer(
clientX: number,
clientY: number,
@ -350,8 +393,7 @@ function MonthView({
setDaySelection(null)
if (startIndex === endIndex) {
onSelectedDateChange(days[startIndex])
onCreateEvent(days[startIndex])
onOpenDay(days[startIndex])
return
}
@ -417,8 +459,7 @@ function MonthView({
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onSelectedDateChange(day)
onCreateEvent(day)
onOpenDay(day)
}
}}
>
@ -428,31 +469,41 @@ function MonthView({
className="pointer-events-none absolute inset-0 -z-10 bg-indigo-500/20 dark:bg-indigo-400/15"
/>
)}
{daySelection === null && hoveredIndex === index && (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-1 bottom-1 z-20 hidden items-center gap-1 truncate rounded border border-dashed border-indigo-400 bg-indigo-50/95 px-1.5 py-0.5 text-[11px] font-medium text-indigo-600 shadow-sm sm:flex dark:border-indigo-400/60 dark:bg-indigo-500/25 dark:text-indigo-100"
>
<PlusIcon className="size-3 shrink-0" />
<span className="truncate">Neuer Termin</span>
</div>
)}
<button
type="button"
onClick={() => onSelectedDateChange(day)}
className={classNames(
'ml-auto flex size-7 items-center justify-center rounded-full text-sm font-medium',
isToday(day)
? 'bg-indigo-600 text-white dark:bg-indigo-500'
: selected
? 'bg-gray-900 text-white dark:bg-white dark:text-gray-900'
: currentMonth
? 'font-semibold text-gray-900 hover:bg-indigo-50 hover:text-indigo-700 dark:text-white dark:hover:bg-indigo-500/15 dark:hover:text-indigo-300'
: 'text-gray-400 hover:bg-gray-200/70 dark:text-gray-600 dark:hover:bg-white/5',
<div className="flex items-center">
{daySelection === null && hoveredIndex === index && (
<button
type="button"
data-calendar-skip-drag
onPointerDown={(pointerEvent) =>
pointerEvent.stopPropagation()
}
onClick={(clickEvent) => {
clickEvent.stopPropagation()
onCreateEvent(day)
}}
className="hidden min-w-0 items-center gap-1 truncate rounded border border-dashed border-indigo-400 bg-indigo-50/95 px-1.5 py-0.5 text-[11px] font-medium text-indigo-600 shadow-sm transition hover:bg-indigo-100 sm:flex dark:border-indigo-400/60 dark:bg-indigo-500/25 dark:text-indigo-100 dark:hover:bg-indigo-500/40"
>
<PlusIcon className="size-3 shrink-0" />
<span className="truncate">Neuer Termin</span>
</button>
)}
>
{day.getDate()}
</button>
<button
type="button"
onClick={() => onOpenDay(day)}
className={classNames(
'ml-auto flex size-7 shrink-0 items-center justify-center rounded-full text-sm font-medium',
isToday(day)
? 'bg-indigo-600 text-white dark:bg-indigo-500'
: selected
? 'bg-gray-900 text-white dark:bg-white dark:text-gray-900'
: currentMonth
? 'font-semibold text-gray-900 hover:bg-indigo-50 hover:text-indigo-700 dark:text-white dark:hover:bg-indigo-500/15 dark:hover:text-indigo-300'
: 'text-gray-400 hover:bg-gray-200/70 dark:text-gray-600 dark:hover:bg-white/5',
)}
>
{day.getDate()}
</button>
</div>
<div className="mt-1 hidden space-y-1 sm:block">
{dayEvents.slice(0, 3).map((event) => (
@ -489,7 +540,7 @@ function MonthView({
<button
type="button"
data-calendar-skip-drag
onClick={() => onSelectedDateChange(day)}
onClick={() => onOpenDay(day)}
className="px-1 text-xs font-medium text-gray-500 hover:text-indigo-600 dark:text-gray-400 dark:hover:text-indigo-300"
>
+ {dayEvents.length - 3} weitere
@ -625,6 +676,7 @@ function TimeGridView({
const selectedRange = timeSelection
? normalizeTimeRange(timeSelection.anchor, timeSelection.focus)
: null
const gridTemplateColumns = `3.5rem repeat(${days.length}, minmax(7rem, 1fr))`
function getSlotFromPointer(
clientX: number,
@ -756,281 +808,278 @@ function TimeGridView({
return (
<div className="flex min-h-0 flex-1 flex-col bg-white dark:bg-gray-900">
<div
className="grid shrink-0 border-b border-gray-200 bg-white dark:border-white/10 dark:bg-gray-900"
style={{
gridTemplateColumns: `3.5rem repeat(${days.length}, minmax(7rem, 1fr))`,
}}
>
<div />
{days.map((day) => (
<button
key={toDateKey(day)}
type="button"
onClick={() => onSelectedDateChange(day)}
className={classNames(
'border-l border-gray-200 px-2 py-3 text-center dark:border-white/10',
isSameDay(day, selectedDate) &&
'bg-indigo-50 dark:bg-indigo-500/10',
)}
>
<span className="block text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
{new Intl.DateTimeFormat('de-DE', { weekday: 'short' }).format(
day,
)}
</span>
<span
className={classNames(
'mx-auto mt-1 flex size-8 items-center justify-center rounded-full text-sm font-semibold',
isToday(day)
? 'bg-indigo-600 text-white dark:bg-indigo-500'
: 'text-gray-900 dark:text-white',
)}
>
{day.getDate()}
</span>
</button>
))}
</div>
{allDayEvents.length > 0 && (
<div
className="grid shrink-0 border-b border-gray-200 bg-gray-50 dark:border-white/10 dark:bg-white/5"
style={{
gridTemplateColumns: `3.5rem repeat(${days.length}, minmax(7rem, 1fr))`,
}}
>
<div className="px-2 py-2 text-right text-[10px] font-medium uppercase text-gray-400">
Ganz
</div>
{days.map((day) => (
<div className="min-h-0 flex-1 overflow-auto">
<div className="min-w-max">
<div className="sticky top-0 z-20">
<div
key={toDateKey(day)}
className="min-h-10 space-y-1 border-l border-gray-200 p-1 dark:border-white/10"
className="grid border-b border-gray-200 bg-white dark:border-white/10 dark:bg-gray-900"
style={{ gridTemplateColumns }}
>
{eventsForDay(events, day)
.filter((event) => event.allDay)
.map((event) => (
<button
key={event.id}
type="button"
onClick={() => onEditEvent(event)}
<div className="bg-white dark:bg-gray-900" />
{days.map((day) => (
<button
key={toDateKey(day)}
type="button"
onClick={() => onSelectedDateChange(day)}
className={classNames(
'border-l border-gray-200 px-2 py-3 text-center dark:border-white/10',
isSameDay(day, selectedDate) &&
'bg-indigo-50 dark:bg-indigo-500/10',
)}
>
<span className="block text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
{new Intl.DateTimeFormat('de-DE', {
weekday: 'short',
}).format(day)}
</span>
<span
className={classNames(
'w-full truncate rounded px-2 py-1 text-left text-xs font-semibold ring-1 ring-inset',
eventColors[event.color].card,
'mx-auto mt-1 flex size-8 items-center justify-center rounded-full text-sm font-semibold',
isToday(day)
? 'bg-indigo-600 text-white dark:bg-indigo-500'
: 'text-gray-900 dark:text-white',
)}
>
{event.title}
</button>
))}
{day.getDate()}
</span>
</button>
))}
</div>
))}
</div>
)}
<div className="min-h-0 flex-1 overflow-auto">
<div
ref={timeGridRef}
onPointerDown={handleTimeGridPointerDown}
onPointerMove={handleTimeGridPointerMove}
onPointerUp={finishTimeGridSelection}
onPointerCancel={cancelTimeGridSelection}
onPointerLeave={() => {
if (!interactionRef.current) {
setHoveredSlot(null)
}
}}
className="grid w-full min-w-max"
style={{
gridTemplateColumns: `3.5rem repeat(${days.length}, minmax(7rem, 1fr))`,
}}
>
<div className="relative h-[96rem]">
{hours.map((hour) => (
{allDayEvents.length > 0 && (
<div
key={hour}
className="absolute right-2 -translate-y-2 text-[11px] text-gray-400 dark:text-gray-500"
style={{ top: `${(hour / 24) * 100}%` }}
className="grid border-b border-gray-200 bg-gray-50 dark:border-white/10 dark:bg-white/5"
style={{ gridTemplateColumns }}
>
{String(hour).padStart(2, '0')}:00
<div className="px-2 py-2 text-right text-[10px] font-medium uppercase text-gray-400">
Ganz
</div>
{days.map((day) => (
<div
key={toDateKey(day)}
className="min-h-10 space-y-1 border-l border-gray-200 p-1 dark:border-white/10"
>
{eventsForDay(events, day)
.filter((event) => event.allDay)
.map((event) => (
<button
key={event.id}
type="button"
onClick={() => onEditEvent(event)}
className={classNames(
'w-full truncate rounded px-2 py-1 text-left text-xs font-semibold ring-1 ring-inset',
eventColors[event.color].card,
)}
>
{event.title}
</button>
))}
</div>
))}
</div>
))}
)}
</div>
{days.map((day) => {
const timedEvents = eventsForDay(events, day).filter(
(event) => !event.allDay,
)
const coreHours = workingHoursForDate(coreWorkingHours, day)
const coreStartMinutes = coreHours
? timeToMinutes(coreHours.start)
: 0
const coreEndMinutes = coreHours
? timeToMinutes(coreHours.end)
: 0
const dayStart = startOfDay(day)
const dayEnd = addDays(dayStart, 1)
const selectionStart =
selectedRange && selectedRange.end > dayStart
? new Date(
Math.max(
selectedRange.start.getTime(),
dayStart.getTime(),
),
)
: null
const selectionEnd =
selectedRange && selectedRange.start < dayEnd
? new Date(
Math.min(selectedRange.end.getTime(), dayEnd.getTime()),
)
: null
const hasSelection =
selectionStart &&
selectionEnd &&
selectionEnd > selectionStart
const selectionStartMinutes = selectionStart
? (selectionStart.getTime() - dayStart.getTime()) / 60_000
: 0
const selectionDurationMinutes =
selectionStart && selectionEnd
? (selectionEnd.getTime() - selectionStart.getTime()) / 60_000
: 0
return (
<div
key={toDateKey(day)}
data-calendar-time-column
className="relative h-[96rem] cursor-crosshair border-l border-gray-200 bg-gray-100/80 dark:border-white/10 dark:bg-gray-950/70"
>
{coreHours?.enabled &&
coreEndMinutes > coreStartMinutes && (
<div
aria-label={`Kernarbeitszeit ${coreHours.start} bis ${coreHours.end}`}
className="pointer-events-none absolute inset-x-0 z-0 border-y border-gray-200 bg-white shadow-[inset_0_1px_0_rgb(255_255_255)] dark:border-white/10 dark:bg-gray-900 dark:shadow-none"
style={{
top: `${(coreStartMinutes / 1440) * 100}%`,
height: `${
((coreEndMinutes - coreStartMinutes) / 1440) * 100
}%`,
}}
/>
)}
<div
ref={timeGridRef}
onPointerDown={handleTimeGridPointerDown}
onPointerMove={handleTimeGridPointerMove}
onPointerUp={finishTimeGridSelection}
onPointerCancel={cancelTimeGridSelection}
onPointerLeave={() => {
if (!interactionRef.current) {
setHoveredSlot(null)
}
}}
className="grid w-full"
style={{ gridTemplateColumns }}
>
<div className="relative h-[96rem]">
{hours.map((hour) => (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-[1]"
key={hour}
className="absolute right-2 -translate-y-2 text-[11px] text-gray-400 dark:text-gray-500"
style={{ top: `${(hour / 24) * 100}%` }}
>
{Array.from({ length: 49 }, (_, index) => (
<span
key={index}
className={classNames(
'absolute inset-x-0 border-t',
index % 2 === 0
? 'border-gray-200 dark:border-white/10'
: 'border-gray-100 dark:border-white/5',
)}
style={{ top: `${(index / 48) * 100}%` }}
/>
))}
{String(hour).padStart(2, '0')}:00
</div>
{hasSelection && (
))}
</div>
{days.map((day) => {
const timedEvents = eventsForDay(events, day).filter(
(event) => !event.allDay,
)
const coreHours = workingHoursForDate(coreWorkingHours, day)
const coreStartMinutes = coreHours
? timeToMinutes(coreHours.start)
: 0
const coreEndMinutes = coreHours
? timeToMinutes(coreHours.end)
: 0
const dayStart = startOfDay(day)
const dayEnd = addDays(dayStart, 1)
const selectionStart =
selectedRange && selectedRange.end > dayStart
? new Date(
Math.max(
selectedRange.start.getTime(),
dayStart.getTime(),
),
)
: null
const selectionEnd =
selectedRange && selectedRange.start < dayEnd
? new Date(
Math.min(selectedRange.end.getTime(), dayEnd.getTime()),
)
: null
const hasSelection =
selectionStart &&
selectionEnd &&
selectionEnd > selectionStart
const selectionStartMinutes = selectionStart
? (selectionStart.getTime() - dayStart.getTime()) / 60_000
: 0
const selectionDurationMinutes =
selectionStart && selectionEnd
? (selectionEnd.getTime() - selectionStart.getTime()) / 60_000
: 0
return (
<div
key={toDateKey(day)}
data-calendar-time-column
className="relative h-[96rem] cursor-crosshair border-l border-gray-200 bg-gray-100/80 dark:border-white/10 dark:bg-gray-950/70"
>
{coreHours?.enabled &&
coreEndMinutes > coreStartMinutes && (
<div
aria-label={`Kernarbeitszeit ${coreHours.start} bis ${coreHours.end}`}
className="pointer-events-none absolute inset-x-0 z-0 border-y border-gray-200 bg-white shadow-[inset_0_1px_0_rgb(255_255_255)] dark:border-white/10 dark:bg-gray-900 dark:shadow-none"
style={{
top: `${(coreStartMinutes / 1440) * 100}%`,
height: `${
((coreEndMinutes - coreStartMinutes) / 1440) * 100
}%`,
}}
/>
)}
<div
aria-hidden="true"
className="pointer-events-none absolute right-1 left-1 z-[6] flex items-start overflow-hidden rounded-lg border-2 border-indigo-500 bg-indigo-100/90 px-2 py-1 text-xs font-semibold text-indigo-800 shadow-sm dark:border-indigo-400 dark:bg-indigo-500/30 dark:text-indigo-100"
style={{
top: `${(selectionStartMinutes / 1440) * 100}%`,
height: `${Math.max(
2.1,
(selectionDurationMinutes / 1440) * 100,
)}%`,
}}
className="pointer-events-none absolute inset-0 z-[1]"
>
<span className="truncate">
{selectionStart &&
new Intl.DateTimeFormat('de-DE', {
weekday: 'short',
hour: '2-digit',
minute: '2-digit',
}).format(selectionStart)}
</span>
{Array.from({ length: 49 }, (_, index) => (
<span
key={index}
className={classNames(
'absolute inset-x-0 border-t',
index % 2 === 0
? 'border-gray-200 dark:border-white/10'
: 'border-gray-100 dark:border-white/5',
)}
style={{ top: `${(index / 48) * 100}%` }}
/>
))}
</div>
)}
{!timeSelection &&
hoveredSlot?.dayKey === toDateKey(day) && (
{hasSelection && (
<div
aria-hidden="true"
className="pointer-events-none absolute right-1 left-1 z-[5] flex items-center gap-1.5 overflow-hidden rounded-lg border border-dashed border-indigo-400 bg-indigo-50/90 px-2 text-xs font-semibold text-indigo-700 shadow-sm dark:border-indigo-400/70 dark:bg-indigo-500/15 dark:text-indigo-200"
className="pointer-events-none absolute right-1 left-1 z-[6] flex items-start overflow-hidden rounded-lg border-2 border-indigo-500 bg-indigo-100/90 px-2 py-1 text-xs font-semibold text-indigo-800 shadow-sm dark:border-indigo-400 dark:bg-indigo-500/30 dark:text-indigo-100"
style={{
top: `${(hoveredSlot.minutes / 1440) * 100}%`,
height: `${(30 / 1440) * 100}%`,
}}
>
<PlusIcon className="size-3.5 shrink-0" />
<span className="truncate">
{formatSlotTime(hoveredSlot.minutes)} Neuer Termin
</span>
</div>
)}
{timedEvents.map((event) => {
const eventStart = new Date(event.start)
const eventEnd = new Date(event.end)
const segmentStart = new Date(
Math.max(eventStart.getTime(), dayStart.getTime()),
)
const segmentEnd = new Date(
Math.min(eventEnd.getTime(), dayEnd.getTime()),
)
const startMinutes =
(segmentStart.getTime() - dayStart.getTime()) / 60_000
const durationMinutes = Math.max(
30,
(segmentEnd.getTime() - segmentStart.getTime()) / 60_000,
)
return (
<button
key={event.id}
data-calendar-event
type="button"
onPointerDown={(pointerEvent) =>
pointerEvent.stopPropagation()
}
onClick={(clickEvent) => {
clickEvent.stopPropagation()
onEditEvent(event)
}}
onDoubleClick={(doubleClickEvent) =>
doubleClickEvent.stopPropagation()
}
onMouseEnter={() => setHoveredSlot(null)}
onPointerMove={(moveEvent) =>
moveEvent.stopPropagation()
}
className={classNames(
'absolute right-1 left-1 z-10 overflow-hidden rounded-lg p-2 text-left text-xs ring-1 ring-inset transition',
eventColors[event.color].card,
)}
style={{
top: `${(startMinutes / 1440) * 100}%`,
top: `${(selectionStartMinutes / 1440) * 100}%`,
height: `${Math.max(
2.1,
(durationMinutes / 1440) * 100,
(selectionDurationMinutes / 1440) * 100,
)}%`,
}}
>
<span className="block truncate font-semibold">
{event.title}
<span className="truncate">
{selectionStart &&
selectionEnd &&
`${formatTime(selectionStart)} ${formatTime(
selectionEnd,
)}`}
</span>
<span className="mt-0.5 block truncate opacity-80">
{formatTime(segmentStart)} - {formatTime(segmentEnd)}
{event.location ? ` · ${event.location}` : ''}
</span>
</button>
)
})}
</div>
)
})}
</div>
)}
{!timeSelection &&
hoveredSlot?.dayKey === toDateKey(day) && (
<div
aria-hidden="true"
className="pointer-events-none absolute right-1 left-1 z-[5] flex items-center gap-1.5 overflow-hidden rounded-lg border border-dashed border-indigo-400 bg-indigo-50/90 px-2 text-xs font-semibold text-indigo-700 shadow-sm dark:border-indigo-400/70 dark:bg-indigo-500/15 dark:text-indigo-200"
style={{
top: `${(hoveredSlot.minutes / 1440) * 100}%`,
height: `${(30 / 1440) * 100}%`,
}}
>
<PlusIcon className="size-3.5 shrink-0" />
<span className="truncate">
{formatSlotTime(hoveredSlot.minutes)} Neuer Termin
</span>
</div>
)}
{timedEvents.map((event) => {
const eventStart = new Date(event.start)
const eventEnd = new Date(event.end)
const segmentStart = new Date(
Math.max(eventStart.getTime(), dayStart.getTime()),
)
const segmentEnd = new Date(
Math.min(eventEnd.getTime(), dayEnd.getTime()),
)
const startMinutes =
(segmentStart.getTime() - dayStart.getTime()) / 60_000
const durationMinutes = Math.max(
30,
(segmentEnd.getTime() - segmentStart.getTime()) / 60_000,
)
return (
<button
key={event.id}
data-calendar-event
type="button"
onPointerDown={(pointerEvent) =>
pointerEvent.stopPropagation()
}
onClick={(clickEvent) => {
clickEvent.stopPropagation()
onEditEvent(event)
}}
onDoubleClick={(doubleClickEvent) =>
doubleClickEvent.stopPropagation()
}
onMouseEnter={() => setHoveredSlot(null)}
onPointerMove={(moveEvent) =>
moveEvent.stopPropagation()
}
className={classNames(
'absolute right-1 left-1 z-10 overflow-hidden rounded-lg p-2 text-left text-xs ring-1 ring-inset transition',
eventColors[event.color].card,
)}
style={{
top: `${(startMinutes / 1440) * 100}%`,
height: `${Math.max(
2.1,
(durationMinutes / 1440) * 100,
)}%`,
}}
>
<span className="block truncate font-semibold">
{event.title}
</span>
<span className="mt-0.5 block truncate opacity-80">
{formatTime(segmentStart)} - {formatTime(segmentEnd)}
{event.location ? ` · ${event.location}` : ''}
</span>
</button>
)
})}
</div>
)
})}
</div>
</div>
</div>
</div>
@ -1040,11 +1089,10 @@ function TimeGridView({
function YearView({
events,
selectedDate,
onSelectedDateChange,
}: Pick<
CalendarProps,
'events' | 'selectedDate' | 'onSelectedDateChange'
>) {
onOpenDay,
}: Pick<CalendarProps, 'events' | 'selectedDate'> & {
onOpenDay: (date: Date) => void
}) {
const months = Array.from(
{ length: 12 },
(_, month) => new Date(selectedDate.getFullYear(), month, 1),
@ -1084,7 +1132,7 @@ function YearView({
<button
key={toDateKey(day)}
type="button"
onClick={() => onSelectedDateChange(day)}
onClick={() => onOpenDay(day)}
className={classNames(
'relative mx-auto flex size-8 items-center justify-center rounded-full',
!inMonth && 'text-gray-300 dark:text-gray-700',
@ -1130,6 +1178,11 @@ export default function Calendar({
onCreateEvent,
onEditEvent,
}: CalendarProps) {
function openDay(date: Date) {
onSelectedDateChange(date)
onViewChange('day')
}
function move(amount: number) {
if (view === 'day') {
onSelectedDateChange(addDays(selectedDate, amount))
@ -1215,6 +1268,7 @@ export default function Calendar({
onSelectedDateChange={onSelectedDateChange}
onCreateEvent={onCreateEvent}
onEditEvent={onEditEvent}
onOpenDay={openDay}
/>
)}
{view === 'week' && (
@ -1243,7 +1297,7 @@ export default function Calendar({
<YearView
events={events}
selectedDate={selectedDate}
onSelectedDateChange={onSelectedDateChange}
onOpenDay={openDay}
/>
)}
</div>

View File

@ -40,6 +40,7 @@ export type DeviceHistoryEntry = {
deviceId: string
userId: string
userDisplayName: string
userAvatar?: string
action: string
changes: DeviceHistoryChange[]
createdAt: string
@ -62,8 +63,14 @@ export type Device = {
serialNumber: string
macAddress: string
ipAddress: string
phoneNumber: string
computerName: string
deviceCategory: string
location: string
loanStatus: string
loanedToUserId?: string
loanedToDisplayName?: string
loanedUntil?: string
comment: string
firmwareVersion: string
firmwareUpdate?: DeviceFirmwareUpdate | null
@ -131,9 +138,103 @@ export type MilestoneCameraStream = {
jPEGQuality: number | null
quality: number | null
resolution: string
availableResolutions?: string[]
edgeStorageSupported?: boolean
}
export type MilestonePtz = {
displayName: string
ptzEnabled: boolean
ptzDeviceID: number
ptzCOMPort: number
ptzProtocol: string
ptzCenterOnPositionInView: boolean
ptzCenterAndZoomToRectangle: boolean
ptzHomeSupport: boolean
ptzDiagonalSupport: boolean
ptzIpix: boolean
}
export type MilestoneGeneralSetting = {
displayName: string
cameraName: string
brightness: number
sharpness: number
contrast: number
saturation: number
whiteBalance: string
mirrorImage: string
rotation: string
blackAndWhiteMode: string
oSDTextEnabled: string
oSDText: string
oSDPosition: string
edgeStorageStreamIndex: number
edgeStorageEnabled: string
multicastVideoPort: number
multicastAddress: string
multicastTTL: number
multicastForceSSM: string
edgeStorageRecording: string
edgeStorageSpeed: string
recorderStreamIndex: string
recorderMode: string
recorderRetentionTime: number
recorderPreTriggerTime: number
recorderPostTriggerTime: number
recorderAudioEnabled: string
}
export type MilestoneStreamDetail = {
displayName: string
codec: string
FPS: number
maxGOPSize: number
maxGOPMode: string
edgeStorageSupported: string
streamingMode: string
includeDate: string
includeTime: string
controlMode: string
controlPriority: string
targetBitrate: number
compression: number
resolution: string
availableResolutions?: string[]
zStrength: string
zGopMode: string
zFpsMode: string
zGopLength: number
streamReferenceId: string
}
export type MilestoneCameraSettings = {
id: string
displayName: string
ptz?: MilestonePtz
generalSettings: MilestoneGeneralSetting[]
stream: MilestoneStreamDetail[]
}
export type MilestoneApiStreamSetting = {
defaultPlayback: boolean
displayName: string
edgeTrackId: string
liveDefault: boolean
liveMode: string
name: string
recordTo: string
streamReferenceId: string
availableResolutions?: string[]
useEdge: boolean
}
export type MilestoneApiStream = {
id: string
displayName: string
stream: MilestoneApiStreamSetting[]
}
export type MilestoneCameraDetail = {
id: string
milestoneDeviceId: string
@ -188,6 +289,7 @@ export type OperationHistoryEntry = {
operationId: string
userId: string
userDisplayName: string
userAvatar?: string
action: string
changes: OperationHistoryChange[]
createdAt: string

View File

@ -11,6 +11,7 @@ import {
ClipboardDocumentIcon,
HashtagIcon,
KeyIcon,
MagnifyingGlassIcon,
PlusIcon,
} from '@heroicons/react/20/solid'
import Button from '../../../components/Button'
@ -42,6 +43,7 @@ type AdminChannelUser = {
displayName: string
email: string
unit: string
avatar: string
}
type ChannelDraft = {
@ -96,6 +98,7 @@ export default function ChannelsAdministration() {
const [draft, setDraft] = useState<ChannelDraft>(emptyDraft)
const [zabbixScript, setZabbixScript] = useState('')
const [newToken, setNewToken] = useState('')
const [memberSearch, setMemberSearch] = useState('')
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [isRotatingToken, setIsRotatingToken] = useState(false)
@ -110,6 +113,21 @@ export default function ChannelsAdministration() {
}
}, [draft.slug, draft.webhookPath])
const filteredUsers = useMemo(() => {
const query = memberSearch.trim().toLowerCase()
if (!query) {
return users
}
return users.filter((user) =>
[userDisplayName(user), user.username, user.email, user.unit]
.filter(Boolean)
.join(' ')
.toLowerCase()
.includes(query),
)
}, [memberSearch, users])
const loadChannels = useCallback(async (preferredId?: string) => {
setIsLoading(true)
try {
@ -154,11 +172,13 @@ export default function ChannelsAdministration() {
function selectChannel(channel: AdminChannel) {
setDraft(channelToDraft(channel))
setNewToken('')
setMemberSearch('')
}
function createChannel() {
setDraft(emptyDraft)
setNewToken('')
setMemberSearch('')
}
function updateDraft<Key extends keyof ChannelDraft>(
@ -177,6 +197,24 @@ export default function ChannelsAdministration() {
}))
}
function setAllMembers(checked: boolean) {
const filteredIds = filteredUsers.map((user) => user.id)
setDraft((current) => {
if (checked) {
return {
...current,
userIds: [...new Set([...current.userIds, ...filteredIds])],
}
}
const filteredSet = new Set(filteredIds)
return {
...current,
userIds: current.userIds.filter((id) => !filteredSet.has(id)),
}
})
}
function handleImageFileChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]
event.target.value = ''
@ -325,6 +363,14 @@ export default function ChannelsAdministration() {
)
}
const selectedMemberCount = draft.userIds.length
const filteredAllSelected =
filteredUsers.length > 0 &&
filteredUsers.every((user) => draft.userIds.includes(user.id))
const filteredSomeSelected =
!filteredAllSelected &&
filteredUsers.some((user) => draft.userIds.includes(user.id))
return (
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[19rem_minmax(0,1fr)]">
<aside className="overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
@ -492,21 +538,94 @@ export default function ChannelsAdministration() {
{!draft.allUsers && (
<div className="mt-5">
<h3 className="text-sm font-medium text-gray-900 dark:text-white">
Channelmitglieder
</h3>
<div className="mt-3 grid max-h-64 gap-2 overflow-y-auto rounded-lg border border-gray-200 p-3 sm:grid-cols-2 dark:border-white/10">
{users.map((user) => (
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-medium text-gray-900 dark:text-white">
Channelmitglieder
</h3>
<span className="text-xs text-gray-500 dark:text-gray-400">
{selectedMemberCount} von {users.length} ausgewählt
</span>
</div>
<div className="relative mt-3">
<MagnifyingGlassIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-gray-400" />
<input
type="search"
value={memberSearch}
onChange={(event) => setMemberSearch(event.target.value)}
placeholder="Mitglieder 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:focus:outline-indigo-500"
/>
</div>
<div className="mt-3 overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
<div className="flex items-center justify-between gap-3 border-b border-gray-200 bg-gray-50 px-3 py-2 dark:border-white/10 dark:bg-white/5">
<Checkbox
key={user.id}
checked={draft.userIds.includes(user.id)}
onChange={(event) =>
toggleUser(user.id, event.target.checked)
checked={filteredAllSelected}
indeterminate={filteredSomeSelected}
disabled={filteredUsers.length === 0}
onChange={(event) => setAllMembers(event.target.checked)}
label={
memberSearch.trim()
? 'Alle Treffer auswählen'
: 'Alle auswählen'
}
label={userDisplayName(user)}
description={user.unit || user.email}
labelClassName="font-medium"
/>
))}
{selectedMemberCount > 0 && (
<button
type="button"
onClick={() => updateDraft('userIds', [])}
className="shrink-0 text-xs font-medium text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300"
>
Auswahl zurücksetzen
</button>
)}
</div>
<div className="max-h-72 space-y-0.5 overflow-y-auto p-2">
{filteredUsers.length === 0 ? (
<p className="px-2 py-8 text-center text-sm text-gray-500 dark:text-gray-400">
Keine Benutzer gefunden.
</p>
) : (
filteredUsers.map((user) => {
const checked = draft.userIds.includes(user.id)
return (
<Checkbox
key={user.id}
checked={checked}
onChange={(event) =>
toggleUser(user.id, event.target.checked)
}
wrapperClassName={`items-center rounded-lg px-2 py-2 transition ${
checked
? 'bg-indigo-50 dark:bg-indigo-500/10'
: 'hover:bg-gray-50 dark:hover:bg-white/5'
}`}
label={
<span className="flex min-w-0 items-center gap-3">
<Avatar
src={user.avatar}
name={userDisplayName(user)}
size={8}
className="shrink-0"
/>
<span className="min-w-0">
<span className="block truncate font-medium text-gray-900 dark:text-white">
{userDisplayName(user)}
</span>
<span className="block truncate text-xs text-gray-500 dark:text-gray-400">
{user.unit || user.email}
</span>
</span>
</span>
}
/>
)
})
)}
</div>
</div>
</div>
)}
@ -606,27 +725,6 @@ export default function ChannelsAdministration() {
))}
</div>
</div>
<div>
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-medium text-gray-900 dark:text-white">
JavaScript
</h3>
<Button
type="button"
variant="secondary"
color="gray"
size="sm"
leadingIcon={<ClipboardDocumentIcon className="size-4" />}
onClick={() => void copyText(zabbixScript, 'JavaScript')}
>
Kopieren
</Button>
</div>
<pre className="mt-2 max-h-96 overflow-auto rounded-lg bg-gray-950 p-4 text-xs leading-5 text-gray-100">
<code>{zabbixScript}</code>
</pre>
</div>
</div>
</section>
)}

View File

@ -26,6 +26,7 @@ type AdminGroupChat = {
name: string
image: string
ownerId: string
teamId: string
userIds: string[]
createdAt: string
updatedAt: string
@ -44,6 +45,7 @@ type GroupDraft = {
name: string
image: string
ownerId: string
teamId: string
userIds: string[]
}
@ -65,6 +67,7 @@ function groupToDraft(group: AdminGroupChat): GroupDraft {
name: group.name,
image: group.image,
ownerId: group.ownerId,
teamId: group.teamId,
userIds: group.userIds ?? [],
}
}
@ -79,6 +82,7 @@ export default function GroupChatsAdministration() {
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [isEmptying, setIsEmptying] = useState(false)
const imageInputRef = useRef<HTMLInputElement | null>(null)
const loadGroups = useCallback(
@ -216,7 +220,7 @@ export default function GroupChatsAdministration() {
async function saveGroup(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!draft) {
if (!draft || draft.teamId) {
return
}
@ -258,6 +262,48 @@ export default function GroupChatsAdministration() {
}
}
async function emptyGroup() {
if (
!draft ||
!window.confirm(
`Möchtest du den Verlauf von „${draft.name}“ wirklich leeren? Alle Nachrichten und Anhänge werden dauerhaft gelöscht. Der Gruppenchat bleibt bestehen.`,
)
) {
return
}
setIsEmptying(true)
try {
const response = await fetch(
`${API_URL}/admin/group-chats/${draft.id}/empty`,
{ method: 'POST', credentials: 'include' },
)
if (!response.ok) {
throw new Error(
await readApiError(
response,
'Gruppenchat konnte nicht geleert werden',
),
)
}
window.dispatchEvent(new Event('chat-unread-changed'))
showToast({
title: 'Gruppenchat geleert',
message: 'Der Nachrichtenverlauf wurde dauerhaft gelöscht.',
variant: 'success',
})
} catch (error) {
showErrorToast({
title: 'Gruppenchat konnte nicht geleert werden',
error,
fallback: 'Gruppenchat konnte nicht geleert werden',
})
} finally {
setIsEmptying(false)
}
}
async function deleteGroup() {
if (
!draft ||
@ -323,6 +369,8 @@ export default function GroupChatsAdministration() {
)
}
const isTeamChat = Boolean(draft.teamId)
return (
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[19rem_minmax(0,1fr)]">
<aside className="overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
@ -367,8 +415,15 @@ export default function GroupChatsAdministration() {
size={9}
shape="rounded"
/>
<span className="min-w-0">
<span className="block truncate font-medium">{group.name}</span>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5">
<span className="truncate font-medium">{group.name}</span>
{group.teamId && (
<span className="shrink-0 rounded bg-indigo-100 px-1.5 py-0.5 text-[10px] font-semibold tracking-wide text-indigo-700 uppercase dark:bg-indigo-500/20 dark:text-indigo-300">
Team
</span>
)}
</span>
<span className="block truncate text-xs opacity-70">
{group.userIds.length} Mitglieder
</span>
@ -384,12 +439,20 @@ export default function GroupChatsAdministration() {
>
<div>
<h2 className="text-base font-semibold text-gray-900 dark:text-white">
Gruppenchat bearbeiten
{isTeamChat ? 'Team-Gruppenchat' : 'Gruppenchat bearbeiten'}
</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Der Ersteller bleibt Mitglied; weitere Mitglieder können zentral
verwaltet werden.
</p>
{isTeamChat ? (
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Name und Mitglieder werden über die Team-Administration verwaltet
und können hier nicht geändert werden. Du kannst nur den Verlauf
leeren.
</p>
) : (
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Der Ersteller bleibt Mitglied; weitere Mitglieder können zentral
verwaltet werden.
</p>
)}
</div>
<div className="mt-5">
@ -397,62 +460,65 @@ export default function GroupChatsAdministration() {
Gruppenname
<input
required
disabled={isTeamChat}
maxLength={80}
value={draft.name}
onChange={(event) => updateDraft('name', event.target.value)}
className={`${inputClassName} mt-2`}
className={`${inputClassName} mt-2 disabled:cursor-not-allowed disabled:opacity-60`}
/>
</label>
</div>
<div className="mt-5">
<p className="text-sm font-medium text-gray-900 dark:text-white">
Gruppenbild
</p>
<div className="mt-2 flex items-center gap-4">
<Avatar
src={draft.image}
name={draft.name || 'Gruppenchat'}
size={14}
shape="rounded"
/>
<div>
<input
ref={imageInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
className="sr-only"
onChange={handleImageFileChange}
{!isTeamChat && (
<div className="mt-5">
<p className="text-sm font-medium text-gray-900 dark:text-white">
Gruppenbild
</p>
<div className="mt-2 flex items-center gap-4">
<Avatar
src={draft.image}
name={draft.name || 'Gruppenchat'}
size={14}
shape="rounded"
/>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="secondary"
color="gray"
size="sm"
leadingIcon={<PhotoIcon className="size-4" />}
onClick={() => imageInputRef.current?.click()}
>
Bild hochladen
</Button>
{draft.image && (
<div>
<input
ref={imageInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
className="sr-only"
onChange={handleImageFileChange}
/>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="secondary"
color="red"
color="gray"
size="sm"
onClick={() => updateDraft('image', '')}
leadingIcon={<PhotoIcon className="size-4" />}
onClick={() => imageInputRef.current?.click()}
>
Entfernen
Bild hochladen
</Button>
)}
{draft.image && (
<Button
type="button"
variant="secondary"
color="red"
size="sm"
onClick={() => updateDraft('image', '')}
>
Entfernen
</Button>
)}
</div>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
Optional: PNG, JPG, WEBP oder GIF bis maximal 3 MB.
</p>
</div>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
Optional: PNG, JPG, WEBP oder GIF bis maximal 3 MB.
</p>
</div>
</div>
</div>
)}
<div className="mt-6 border-t border-gray-200 pt-5 dark:border-white/10">
<div className="flex flex-wrap items-end justify-between gap-3">
@ -461,7 +527,9 @@ export default function GroupChatsAdministration() {
Mitglieder
</h3>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Ersteller: {owner ? userDisplayName(owner) : 'Unbekannt'}
{isTeamChat
? 'Mitglieder werden über die Team-Zuordnung verwaltet.'
: `Ersteller: ${owner ? userDisplayName(owner) : 'Unbekannt'}`}
</p>
</div>
<div className="relative w-full sm:w-64">
@ -486,7 +554,7 @@ export default function GroupChatsAdministration() {
<Checkbox
key={user.id}
checked={isOwner || draft.userIds.includes(user.id)}
disabled={isOwner}
disabled={isOwner || isTeamChat}
onChange={(event) =>
toggleUser(user.id, event.target.checked)
}
@ -503,25 +571,43 @@ export default function GroupChatsAdministration() {
</div>
<div className="mt-6 flex flex-wrap justify-between gap-3">
<Button
type="button"
variant="secondary"
color="red"
disabled={isSaving}
isLoading={isDeleting}
leadingIcon={<TrashIcon className="size-4" />}
onClick={() => void deleteGroup()}
>
Gruppenchat löschen
</Button>
<Button
type="submit"
color="indigo"
disabled={isDeleting}
isLoading={isSaving}
>
Gruppenchat speichern
</Button>
{isTeamChat ? (
<>
<span />
<Button
type="button"
variant="secondary"
color="red"
isLoading={isEmptying}
leadingIcon={<TrashIcon className="size-4" />}
onClick={() => void emptyGroup()}
>
Verlauf leeren
</Button>
</>
) : (
<>
<Button
type="button"
variant="secondary"
color="red"
disabled={isSaving}
isLoading={isDeleting}
leadingIcon={<TrashIcon className="size-4" />}
onClick={() => void deleteGroup()}
>
Gruppenchat löschen
</Button>
<Button
type="submit"
color="indigo"
disabled={isDeleting}
isLoading={isSaving}
>
Gruppenchat speichern
</Button>
</>
)}
</div>
</form>
</div>

View File

@ -33,13 +33,7 @@ async function readApiError(response: Response, fallback: string) {
}
function getTeamSearchText(team: AdminTeam) {
return [
team.name,
team.initial,
team.href,
]
.join(' ')
.toLowerCase()
return team.name.toLowerCase()
}
export default function TeamsAdministration() {
@ -113,8 +107,6 @@ export default function TeamsAdministration() {
const payload = {
name: String(formData.get('teamName') ?? ''),
initial: String(formData.get('teamInitial') ?? ''),
href: String(formData.get('teamHref') ?? '#'),
}
setIsSaving(true)
@ -290,7 +282,7 @@ export default function TeamsAdministration() {
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{selectedTeam
? 'Passe Name, Initial und Link des ausgewählten Teams an.'
? 'Passe den Namen des ausgewählten Teams an.'
: 'Erstelle ein neues Team, das anschließend Benutzern zugewiesen werden kann.'}
</p>
</div>
@ -321,45 +313,6 @@ export default function TeamsAdministration() {
/>
</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}
defaultValue={selectedTeam?.initial ?? ''}
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={selectedTeam?.href ?? '#'}
className={inputClassName}
/>
</div>
</div>
</div>
<div className="mt-4 flex justify-end gap-x-3">

View File

@ -576,7 +576,7 @@ export default function CalendarPage() {
)}
</div>
<div className="flex items-center justify-between gap-3 border-t border-gray-200 bg-gray-50 px-5 py-4 dark:border-white/10 dark:bg-white/5">
<div className="flex items-center justify-between gap-3 border-t border-gray-200 bg-gray-50 px-5 py-2 dark:border-white/10 dark:bg-white/5">
<div>
{editingEvent && (
<button

View File

@ -1,3 +1,5 @@
// frontend\src\pages\chat\ChatPage.tsx
import {
ArrowLeftIcon,
ArrowRightStartOnRectangleIcon,
@ -38,6 +40,7 @@ import AddressMapPicker from '../../components/AddressMapPicker'
import LoadingSpinner from '../../components/LoadingSpinner'
import Modal, { ModalTitle } from '../../components/Modal'
import Tabs from '../../components/Tabs'
import { useChatSocket } from '../../components/ChatSocketProvider'
import { hasRight } from '../../components/permissions'
import type { User } from '../../components/types'
@ -473,7 +476,7 @@ function extractFirstUrl(text: string) {
}
function lastMessagePreviewText(message?: ChatMessage | null) {
if (!message) {
if (!message || message.type === 'system') {
return ''
}
if (message.body) {
@ -488,6 +491,27 @@ function lastMessagePreviewText(message?: ChatMessage | null) {
return ''
}
function conversationPreviewText(
conversation: ChatConversation,
currentUserId: string,
) {
const preview = lastMessagePreviewText(conversation.lastMessage)
if (!preview) {
return ''
}
if (conversation.type !== 'group' || !conversation.lastMessage) {
return preview
}
const senderName =
conversation.lastMessage.sender.id === currentUserId
? 'Du'
: displayName(conversation.lastMessage.sender)
return `${senderName}: ${preview}`
}
const chatEmojis = [
'😀',
'😂',
@ -625,22 +649,17 @@ async function readApiError(response: Response, fallback: string) {
}
}
function getChatWebSocketUrl() {
const url = new URL(
`${API_URL.replace(/\/$/, '')}/chat/ws`,
window.location.origin,
)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
return url.toString()
}
export default function ChatPage({ currentUser }: ChatPageProps) {
const navigate = useNavigate()
const { channelId, conversationId, teamId } = useParams()
const {
connected: socketConnected,
send: sendChatCommand,
subscribe: subscribeChatSocket,
} = useChatSocket()
const messagesEndRef = useRef<HTMLDivElement | null>(null)
const attachmentInputRef = useRef<HTMLInputElement | null>(null)
const lastPreviewUrlRef = useRef('')
const chatSocketRef = useRef<WebSocket | null>(null)
const selectedConversationIdRef = useRef<string | undefined>(undefined)
const typingConversationIdRef = useRef<string | undefined>(undefined)
const typingStopTimerRef = useRef<number | undefined>(undefined)
@ -684,7 +703,6 @@ export default function ChatPage({ currentUser }: ChatPageProps) {
const [attachMenuOpen, setAttachMenuOpen] = useState(false)
const [draftPreview, setDraftPreview] = useState<ChatLinkPreview | null>(null)
const [dismissedPreviewUrl, setDismissedPreviewUrl] = useState('')
const [socketConnected, setSocketConnected] = useState(false)
const [statusNow, setStatusNow] = useState(() => Date.now())
const [typingUsers, setTypingUsers] = useState<
Record<string, Record<string, { name: string; expiresAt: number }>>
@ -753,22 +771,24 @@ export default function ChatPage({ currentUser }: ChatPageProps) {
const filteredConversations = useMemo(() => {
const normalizedSearch = search.trim().toLowerCase()
const sourceConversations = normalizedSearch ? conversations : tabConversations
if (!normalizedSearch) {
return tabConversations
return sourceConversations
}
const matchingConversationIds = new Set(
searchResults.map((result) => result.conversationId),
)
return tabConversations.filter(
return sourceConversations.filter(
(conversation) =>
conversationTitle(conversation, currentUser.id)
.toLowerCase()
.includes(normalizedSearch) ||
matchingConversationIds.has(conversation.id),
)
}, [currentUser.id, search, searchResults, tabConversations])
}, [conversations, currentUser.id, search, searchResults, tabConversations])
const searchResultByConversation = useMemo(
() =>
@ -1249,19 +1269,15 @@ export default function ChatPage({ currentUser }: ChatPageProps) {
}
}
const sendTypingState = useCallback((conversationId: string, typing: boolean) => {
const socket = chatSocketRef.current
if (!socket || socket.readyState !== WebSocket.OPEN) {
return
}
socket.send(
JSON.stringify({
const sendTypingState = useCallback(
(conversationId: string, typing: boolean) => {
sendChatCommand({
type: typing ? 'typing.start' : 'typing.stop',
conversationId,
}),
)
}, [])
})
},
[sendChatCommand],
)
const stopTyping = useCallback(() => {
if (typingStopTimerRef.current !== undefined) {
@ -1330,12 +1346,6 @@ export default function ChatPage({ currentUser }: ChatPageProps) {
location?: ChatLocation
},
) {
const socket = chatSocketRef.current
if (!socket || socket.readyState !== WebSocket.OPEN) {
setError('Die Chat-Verbindung wird gerade neu aufgebaut.')
return false
}
const clientId = crypto.randomUUID()
pendingMessageRef.current = {
clientId,
@ -1347,28 +1357,27 @@ export default function ChatPage({ currentUser }: ChatPageProps) {
setSending(true)
setError('')
try {
socket.send(
JSON.stringify({
type: 'message.send',
clientId,
conversationId: conversation.id,
body,
attachmentIds: (options?.attachments ?? []).map(
(attachment) => attachment.id,
),
replyToMessageId: options?.replyToMessageId ?? '',
forwardedFromMessageId: options?.forwardedMessage?.id ?? '',
location: options?.location ?? null,
}),
)
return true
} catch {
const sent = sendChatCommand({
type: 'message.send',
clientId,
conversationId: conversation.id,
body,
attachmentIds: (options?.attachments ?? []).map(
(attachment) => attachment.id,
),
replyToMessageId: options?.replyToMessageId ?? '',
forwardedFromMessageId: options?.forwardedMessage?.id ?? '',
location: options?.location ?? null,
})
if (!sent) {
pendingMessageRef.current = null
setSending(false)
setError('Nachricht konnte nicht gesendet werden')
setError('Die Chat-Verbindung wird gerade neu aufgebaut.')
return false
}
return true
}
function sendMessage(event: FormEvent<HTMLFormElement>) {
@ -1519,236 +1528,204 @@ export default function ChatPage({ currentUser }: ChatPageProps) {
selectedConversationIdRef.current = selectedConversationId
}, [selectedConversationId])
useEffect(() => {
let socket: WebSocket | null = null
let reconnectTimer: number | undefined
let reconnectAttempt = 0
let stopped = false
const restorePendingMessage = useCallback((message: string) => {
const pendingMessage = pendingMessageRef.current
pendingMessageRef.current = null
function restorePendingMessage(message: string) {
const pendingMessage = pendingMessageRef.current
pendingMessageRef.current = null
if (pendingMessage) {
setMessageBody((current) => current || pendingMessage.body)
setPendingAttachments((current) =>
current.length > 0 ? current : pendingMessage.attachments,
)
if (pendingMessage.replyTo) {
setReplyTo(pendingMessage.replyTo)
}
if (pendingMessage.forwardedFrom) {
setForwardDraft(pendingMessage.forwardedFrom)
}
if (pendingMessage) {
setMessageBody((current) => current || pendingMessage.body)
setPendingAttachments((current) =>
current.length > 0 ? current : pendingMessage.attachments,
)
if (pendingMessage.replyTo) {
setReplyTo(pendingMessage.replyTo)
}
if (pendingMessage.forwardedFrom) {
setForwardDraft(pendingMessage.forwardedFrom)
}
setSending(false)
setError(message)
}
function connect() {
socket = new WebSocket(getChatWebSocketUrl())
chatSocketRef.current = socket
setSending(false)
setError(message)
}, [])
socket.onopen = () => {
reconnectAttempt = 0
setSocketConnected(true)
// Eingehende Ereignisse der gemeinsamen Chat-WebSocket-Verbindung behandeln.
useEffect(() => {
const unsubscribe = subscribeChatSocket((rawPayload) => {
const payload = rawPayload as ChatSocketEvent
if (payload.type === 'connected') {
return
}
socket.onmessage = (event) => {
let payload: ChatSocketEvent
try {
payload = JSON.parse(event.data) as ChatSocketEvent
} catch {
return
}
if (payload.type === 'connected') {
setSocketConnected(true)
return
}
if (payload.type === 'error') {
if (
payload.clientId &&
payload.clientId === pendingMessageRef.current?.clientId
) {
restorePendingMessage(
payload.error || 'Nachricht konnte nicht gesendet werden',
)
}
return
}
if (
payload.type === 'typing.updated' &&
payload.conversationId &&
payload.userId
) {
setTypingUsers((current) => {
const conversationUsers = {
...(current[payload.conversationId as string] ?? {}),
}
if (payload.typing) {
conversationUsers[payload.userId as string] = {
name: payload.userDisplayName || 'Jemand',
expiresAt: Date.now() + 4_000,
}
} else {
delete conversationUsers[payload.userId as string]
}
return {
...current,
[payload.conversationId as string]: conversationUsers,
}
})
return
}
if (payload.type === 'conversation.updated' && payload.conversation) {
const conversation = payload.conversation
setConversations((current) =>
current.some((item) => item.id === conversation.id)
? current.map((item) =>
item.id === conversation.id
? { ...item, ...conversation }
: item,
)
: [conversation, ...current],
)
return
}
if (payload.type === 'conversation.removed' && payload.conversationId) {
const removedId = payload.conversationId
setConversations((current) =>
current.filter((item) => item.id !== removedId),
)
if (selectedConversationIdRef.current === removedId) {
navigate('/chat')
}
return
}
if (payload.type === 'messages.reload' && payload.conversationId) {
if (selectedConversationIdRef.current === payload.conversationId) {
void loadMessages(payload.conversationId)
}
return
}
const message = payload.message
if (!message) {
return
}
if (payload.type === 'message.updated') {
setMessages((current) =>
current.map((item) => (item.id === message.id ? message : item)),
)
setConversations((current) =>
current.map((conversation) =>
conversation.lastMessage?.id === message.id
? { ...conversation, lastMessage: message }
: conversation,
),
)
return
}
if (payload.type !== 'message.created') {
return
}
setConversations((current) => {
const next = current.map((conversation) =>
conversation.id === message.conversationId
? {
...conversation,
lastMessage: message,
lastMessageAt: message.createdAt,
unreadCount:
message.sender.id === currentUser.id ||
message.conversationId === selectedConversationIdRef.current
? 0
: (conversation.unreadCount ?? 0) + 1,
}
: conversation,
)
return [...next].sort(
(a, b) =>
new Date(b.lastMessageAt || b.createdAt).getTime() -
new Date(a.lastMessageAt || a.createdAt).getTime(),
)
})
if (
message.conversationId === selectedConversationIdRef.current
) {
setMessages((current) =>
current.some((item) => item.id === message.id)
? current
: [...current, message],
)
if (message.sender.id !== currentUser.id) {
void markConversationRead(message.conversationId)
}
} else if (message.sender.id !== currentUser.id) {
window.dispatchEvent(new Event('chat-unread-changed'))
}
if (payload.type === 'error') {
if (
payload.clientId &&
payload.clientId === pendingMessageRef.current?.clientId
) {
pendingMessageRef.current = null
setSending(false)
}
}
socket.onclose = () => {
if (chatSocketRef.current === socket) {
chatSocketRef.current = null
}
setSocketConnected(false)
typingConversationIdRef.current = undefined
if (pendingMessageRef.current) {
restorePendingMessage(
'Die Verbindung wurde unterbrochen. Die Nachricht wurde nicht gesendet.',
payload.error || 'Nachricht konnte nicht gesendet werden',
)
}
return
}
if (stopped) {
return
if (
payload.type === 'typing.updated' &&
payload.conversationId &&
payload.userId
) {
setTypingUsers((current) => {
const conversationUsers = {
...(current[payload.conversationId as string] ?? {}),
}
if (payload.typing) {
conversationUsers[payload.userId as string] = {
name: payload.userDisplayName || 'Jemand',
expiresAt: Date.now() + 4_000,
}
} else {
delete conversationUsers[payload.userId as string]
}
return {
...current,
[payload.conversationId as string]: conversationUsers,
}
})
return
}
if (payload.type === 'conversation.updated' && payload.conversation) {
const conversation = payload.conversation
setConversations((current) =>
current.some((item) => item.id === conversation.id)
? current.map((item) =>
item.id === conversation.id
? { ...item, ...conversation }
: item,
)
: [conversation, ...current],
)
return
}
if (payload.type === 'conversation.removed' && payload.conversationId) {
const removedId = payload.conversationId
setConversations((current) =>
current.filter((item) => item.id !== removedId),
)
if (selectedConversationIdRef.current === removedId) {
navigate('/chat')
}
const delay = Math.min(1000 * 2 ** reconnectAttempt, 10_000)
reconnectAttempt += 1
reconnectTimer = window.setTimeout(connect, delay)
return
}
if (payload.type === 'messages.reload' && payload.conversationId) {
if (selectedConversationIdRef.current === payload.conversationId) {
void loadMessages(payload.conversationId)
}
return
}
const message = payload.message
if (!message) {
return
}
if (payload.type === 'message.updated') {
setMessages((current) =>
current.map((item) => (item.id === message.id ? message : item)),
)
setConversations((current) =>
current.map((conversation) =>
conversation.lastMessage?.id === message.id
? { ...conversation, lastMessage: message }
: conversation,
),
)
return
}
if (payload.type !== 'message.created') {
return
}
setConversations((current) => {
const next = current.map((conversation) =>
conversation.id === message.conversationId
? {
...conversation,
lastMessage: message,
lastMessageAt: message.createdAt,
unreadCount:
message.sender.id === currentUser.id ||
message.conversationId === selectedConversationIdRef.current
? 0
: message.type === 'system'
? conversation.unreadCount ?? 0
: (conversation.unreadCount ?? 0) + 1,
}
: conversation,
)
return [...next].sort(
(a, b) =>
new Date(b.lastMessageAt || b.createdAt).getTime() -
new Date(a.lastMessageAt || a.createdAt).getTime(),
)
})
if (message.conversationId === selectedConversationIdRef.current) {
setMessages((current) =>
current.some((item) => item.id === message.id)
? current
: [...current, message],
)
if (message.sender.id !== currentUser.id) {
void markConversationRead(message.conversationId)
}
} else if (
message.sender.id !== currentUser.id &&
message.type !== 'system'
) {
window.dispatchEvent(new Event('chat-unread-changed'))
}
if (
payload.clientId &&
payload.clientId === pendingMessageRef.current?.clientId
) {
pendingMessageRef.current = null
setSending(false)
}
})
return unsubscribe
}, [subscribeChatSocket, currentUser.id, navigate, restorePendingMessage])
// Bei Verbindungsverlust laufendes "tippt..." zurücksetzen und eine noch
// nicht bestätigte Nachricht zum erneuten Senden wiederherstellen.
useEffect(() => {
if (socketConnected) {
return
}
connect()
typingConversationIdRef.current = undefined
if (pendingMessageRef.current) {
restorePendingMessage(
'Die Verbindung wurde unterbrochen. Die Nachricht wurde nicht gesendet.',
)
}
}, [socketConnected, restorePendingMessage])
// Beim Verlassen der Chat-Seite ein laufendes "tippt..." beenden.
useEffect(() => {
return () => {
stopped = true
setSocketConnected(false)
if (reconnectTimer !== undefined) {
window.clearTimeout(reconnectTimer)
}
chatSocketRef.current = null
stopTyping()
socket?.close(1000, 'Chat geschlossen')
}
}, [currentUser.id, navigate, stopTyping])
}, [stopTyping])
useEffect(() => {
const initialLoadId = window.setTimeout(() => {
@ -2010,6 +1987,7 @@ export default function ChatPage({ currentUser }: ChatPageProps) {
className="mt-4"
variant="pills-brand"
fullWidth
background="subtle"
ariaLabel="Chats und Channels"
tabs={[
{
@ -2047,11 +2025,7 @@ export default function ChatPage({ currentUser }: ChatPageProps) {
<input
value={search}
onChange={(event) => handleSearchChange(event.target.value)}
placeholder={
visibleTab === 'channels'
? 'Channels und Meldungen durchsuchen'
: 'Chats und Nachrichten durchsuchen'
}
placeholder="Suchen..."
className="block w-full rounded-xl border-0 bg-gray-100 py-2 pr-3 pl-9 text-sm text-gray-900 outline-none ring-1 ring-transparent placeholder:text-gray-500 focus:ring-indigo-500 dark:bg-white/5 dark:text-white dark:placeholder:text-gray-500"
/>
</div>
@ -2076,9 +2050,7 @@ export default function ChatPage({ currentUser }: ChatPageProps) {
{search.trim()
? searchingMessages
? 'Nachrichten werden durchsucht...'
: visibleTab === 'channels'
? 'Kein Channel und keine Meldung passt zur Suche.'
: 'Kein Chat und keine Nachricht passt zur Suche.'
: 'Kein Chat, Channel und keine Nachricht passt zur Suche.'
: visibleTab === 'channels'
? 'Channels werden in der Administration eingerichtet.'
: 'Öffne ein Team oder starte eine Direktnachricht.'}
@ -2176,7 +2148,10 @@ export default function ChatPage({ currentUser }: ChatPageProps) {
: `${conversationTypingUsers.length} Personen schreiben...`
: search.trim() && searchResult
? `${searchResult.senderDisplayName}: ${searchResult.body}`
: lastMessagePreviewText(conversation.lastMessage) ||
: conversationPreviewText(
conversation,
currentUser.id,
) ||
(conversation.type === 'channel'
? 'Schreibgeschützter Channel'
: conversation.type !== 'direct'
@ -2624,6 +2599,16 @@ export default function ChatPage({ currentUser }: ChatPageProps) {
angebundenen Integration veröffentlicht.
</div>
) : (
<>
{!socketConnected && (
<div className="flex shrink-0 items-center gap-2 border-t border-amber-200 bg-amber-50 px-4 py-2 text-xs font-medium text-amber-800 dark:border-amber-500/20 dark:bg-amber-500/15 dark:text-amber-300">
<span
aria-hidden="true"
className="size-2 shrink-0 animate-pulse rounded-full bg-amber-500"
/>
Chat-Verbindung wird hergestellt...
</div>
)}
<form
onSubmit={sendMessage}
className="relative shrink-0 border-t border-gray-200 bg-white p-4 dark:border-white/10 dark:bg-gray-900"
@ -2840,11 +2825,9 @@ export default function ChatPage({ currentUser }: ChatPageProps) {
rows={1}
maxLength={4000}
placeholder={
socketConnected
? forwardDraft
? 'Kommentar hinzufügen...'
: 'Nachricht schreiben...'
: 'Chat-Verbindung wird hergestellt...'
forwardDraft
? 'Kommentar hinzufügen...'
: 'Nachricht schreiben...'
}
className="max-h-32 min-h-11 flex-1 resize-none rounded-2xl border-0 bg-gray-100 px-4 py-3 text-sm text-gray-900 outline-none ring-1 ring-transparent placeholder:text-gray-500 focus:ring-indigo-500 dark:bg-white/5 dark:text-white"
/>
@ -2868,6 +2851,7 @@ export default function ChatPage({ currentUser }: ChatPageProps) {
</button>
</div>
</form>
</>
)}
</>
) : (

File diff suppressed because it is too large Load Diff

View File

@ -83,6 +83,9 @@ export default function CreateDeviceModal({
'serialNumber',
'macAddress',
'ipAddress',
'phoneNumber',
'computerName',
'deviceCategory',
'firmwareVersion',
'loanStatus',
].includes(
@ -121,7 +124,7 @@ export default function CreateDeviceModal({
</DialogTitle>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Erfasse ein neues Gerät inklusive Inventur-Nr., Standort und Zubehör.
Wähle die Geräteart und erfasse nur die passenden Daten.
</p>
</div>
@ -183,4 +186,4 @@ export default function CreateDeviceModal({
</form>
</Modal>
)
}
}

View File

@ -1,8 +1,20 @@
// frontend\src\pages\devices\DeviceFormFields.tsx
import { useEffect, useMemo, useState, type FormEvent } from 'react'
import {
useEffect,
useMemo,
useState,
type ChangeEvent,
type ComponentType,
type FormEvent,
type SVGProps,
} from 'react'
import Combobox, { type ComboboxItem } from '../../components/Combobox'
import Checkbox from '../../components/Checkbox'
import Button from '../../components/Button'
import DatePicker from '../../components/DatePicker'
import Modal, { ModalTitle } from '../../components/Modal'
import Select, { type SelectOption } from '../../components/Select'
import Switch from '../../components/Switch'
import type { DeviceModalTabId } from './DeviceModalTabs'
import Textarea from '../../components/Textarea'
@ -10,8 +22,13 @@ import ChildDeviceCard from './ChildDeviceCard'
import MilestoneChildDeviceCards from './MilestoneChildDeviceCards'
import type { Device } from '../../components/types'
import {
ArrowsRightLeftIcon,
CircleStackIcon,
ComputerDesktopIcon,
MagnifyingGlassIcon,
MicrophoneIcon,
VideoCameraIcon,
WifiIcon,
} from '@heroicons/react/20/solid'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
@ -44,18 +61,140 @@ type DeviceFormFieldsProps = {
}
export const inputClassName =
'block w-full rounded-xl border-0 bg-gray-50 px-3.5 py-2 text-base text-gray-900 shadow-sm ring-1 ring-gray-200 ring-inset placeholder:text-gray-400 transition focus:bg-white focus:ring-2 focus:ring-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:ring-white/10 dark:placeholder:text-gray-500 dark:focus:bg-white/10 dark:focus:ring-indigo-400'
'block w-full rounded-md bg-white px-3.5 py-2 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 transition 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'
export const selectClassName =
'block w-full rounded-xl border-0 bg-gray-50 px-3.5 py-2 text-base text-gray-900 shadow-sm ring-1 ring-gray-200 ring-inset transition focus:bg-white focus:ring-2 focus:ring-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:ring-white/10 dark:*:bg-gray-800 dark:focus:bg-white/10 dark:focus:ring-indigo-400'
type DeviceCategory = 'Kameras' | 'Router' | 'Switchbox' | 'Laptop' | 'Festplatte'
export const sectionClassName =
'flex min-h-0 flex-col rounded-2xl border border-gray-200/80 bg-white p-5 shadow-sm ring-1 ring-black/[0.02] dark:border-white/10 dark:bg-gray-900/70 dark:ring-white/5'
type DeviceLoanUser = {
id: string
username: string
displayName: string
email: string
avatar: string
unit: string
}
const loanStatusOptions: SelectOption[] = [
'Verfügbar',
'Ausgeliehen',
'Reserviert',
'Wartung',
'Defekt',
'Verloren',
].map((status) => ({
value: status,
label: status,
}))
type DeviceKindOption = {
category: DeviceCategory
label: string
description: string
icon: ComponentType<SVGProps<SVGSVGElement>>
}
const deviceKindOptions: DeviceKindOption[] = [
{
category: 'Kameras',
label: 'Kamera',
description: 'IP, MAC und Firmware erfassen.',
icon: VideoCameraIcon,
},
{
category: 'Router',
label: 'Router',
description: 'Netzwerkdaten und Firmware hinterlegen.',
icon: WifiIcon,
},
{
category: 'Switchbox',
label: 'Switchbox',
description: 'Rufnummer und optionale IP erfassen.',
icon: ArrowsRightLeftIcon,
},
{
category: 'Laptop',
label: 'Computer',
description: 'Hostname, IP und MAC pflegen.',
icon: ComputerDesktopIcon,
},
{
category: 'Festplatte',
label: 'Festplatte',
description: 'Modell und Seriennummer genügen meistens.',
icon: CircleStackIcon,
},
]
function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(' ')
}
function getInitialDeviceCategory(device: Device | null | undefined, isMilestoneDevice: boolean): DeviceCategory {
if (isMilestoneDevice) {
return 'Kameras'
}
const category = device?.deviceCategory?.trim()
if (deviceKindOptions.some((option) => option.category === category)) {
return category as DeviceCategory
}
return 'Kameras'
}
function getCategoryFieldVisibility(category: DeviceCategory) {
return {
macAddress: category === 'Kameras' || category === 'Router' || category === 'Laptop',
ipAddress: category === 'Kameras' || category === 'Router' || category === 'Laptop',
phoneNumber: category === 'Switchbox',
computerName: category === 'Laptop',
firmwareVersion: category === 'Kameras' || category === 'Router',
}
}
function getLoanUserDisplayName(user: DeviceLoanUser) {
return user.displayName || user.username || user.email
}
function loanUserToComboboxItem(user: DeviceLoanUser): ComboboxItem {
return {
id: user.id,
name: getLoanUserDisplayName(user),
secondaryText: [user.unit, user.email].filter(Boolean).join(' · ') || undefined,
imageUrl: user.avatar,
username: user.username,
}
}
function formatLoanedUntilLabel(value: string) {
const trimmedValue = value.trim()
if (!trimmedValue) {
return ''
}
const isoDateMatch = /^(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})$/.exec(trimmedValue)
const date = isoDateMatch?.groups
? new Date(
Number(isoDateMatch.groups.year),
Number(isoDateMatch.groups.month) - 1,
Number(isoDateMatch.groups.day),
)
: new Date(trimmedValue)
if (Number.isNaN(date.getTime())) {
return trimmedValue
}
return new Intl.DateTimeFormat('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
}).format(date)
}
export function SectionHeader({
title,
description,
@ -131,6 +270,9 @@ function getDeviceAccessorySearchText(device: Device) {
device.serialNumber,
device.macAddress,
device.ipAddress,
device.phoneNumber,
device.computerName,
device.deviceCategory,
device.firmwareVersion,
device.location,
device.loanStatus,
@ -234,6 +376,10 @@ export default function DeviceFormFields({
onOpenMilestoneCameraDetails,
togglingMilestoneCameraRecordingIds,
}: DeviceFormFieldsProps) {
const isMilestoneDevice = Boolean(
device?.milestoneHardwareId || device?.milestoneRecordingServerId,
)
const assignableDevices = useMemo(
() => devices.filter((item) => item.id !== device?.id),
[devices, device?.id],
@ -245,6 +391,18 @@ export default function DeviceFormFields({
const [locationOptions, setLocationOptions] = useState<ComboboxItem[]>([])
const [selectedManufacturer, setSelectedManufacturer] = useState<ComboboxItem | null>(null)
const [selectedLocation, setSelectedLocation] = useState<ComboboxItem | null>(null)
const [selectedDeviceCategory, setSelectedDeviceCategory] = useState<DeviceCategory>(() =>
getInitialDeviceCategory(device, isMilestoneDevice),
)
const [selectedLoanStatus, setSelectedLoanStatus] = useState(device?.loanStatus || 'Verfügbar')
const [loanUsers, setLoanUsers] = useState<ComboboxItem[]>([])
const [selectedLoanUser, setSelectedLoanUser] = useState<ComboboxItem | null>(null)
const [loanedUntil, setLoanedUntil] = useState(device?.loanedUntil ?? '')
const [isLoanModalOpen, setIsLoanModalOpen] = useState(false)
const [loanModalPreviousStatus, setLoanModalPreviousStatus] = useState('Verfügbar')
const [isLoadingLoanUsers, setIsLoadingLoanUsers] = useState(false)
const [loanUsersError, setLoanUsersError] = useState<string | null>(null)
const [loanModalError, setLoanModalError] = useState<string | null>(null)
const [lookupError, setLookupError] = useState<string | null>(null)
useEffect(() => {
@ -270,8 +428,34 @@ export default function DeviceFormFields({
: null,
)
setSelectedDeviceCategory(getInitialDeviceCategory(device, isMilestoneDevice))
setSelectedLoanStatus(device?.loanStatus || 'Verfügbar')
setLoanedUntil(device?.loanedUntil ?? '')
setIsLoanModalOpen(false)
setLoanModalPreviousStatus(device?.loanStatus || 'Verfügbar')
setLoanUsersError(null)
setLoanModalError(null)
void loadDeviceLookups()
}, [open, device?.id])
}, [open, device?.id, isMilestoneDevice])
useEffect(() => {
if (!open) {
return
}
if (!device?.loanedToUserId) {
setSelectedLoanUser(null)
return
}
setSelectedLoanUser(
loanUsers.find((item) => String(item.id) === device.loanedToUserId) ?? {
id: device.loanedToUserId,
name: device.loanedToDisplayName || device.loanedToUserId,
},
)
}, [open, device?.id, device?.loanedToDisplayName, device?.loanedToUserId, loanUsers])
useEffect(() => {
if (!open) {
@ -297,12 +481,25 @@ export default function DeviceFormFields({
relatedDeviceIds.includes(assignableDevice.id),
).length
const isMilestoneDevice = Boolean(
device?.milestoneHardwareId || device?.milestoneRecordingServerId,
)
const milestoneChildDevices = device?.milestoneChildDevices ?? []
const visibleCategoryFields = getCategoryFieldVisibility(selectedDeviceCategory)
const loanedToLabel =
selectedLoanStatus === 'Ausgeliehen'
? selectedLoanUser?.name || device?.loanedToDisplayName || ''
: ''
const loanedUntilLabel =
selectedLoanStatus === 'Ausgeliehen' && loanedUntil
? formatLoanedUntilLabel(loanedUntil)
: ''
const loanDetailsLabel =
selectedLoanStatus === 'Ausgeliehen'
? [
loanedToLabel || 'Verliehen',
loanedUntilLabel ? `bis ${loanedUntilLabel}` : 'ohne Rückgabedatum',
].join(' · ')
: ''
const milestoneCameras = sortMilestoneChildDevices(
milestoneChildDevices.filter(
(item) => item.deviceType === 'camera' || item.deviceType === 'cameras',
@ -340,6 +537,84 @@ export default function DeviceFormFields({
}
}
async function loadLoanUsers() {
if (loanUsers.length > 0 || isLoadingLoanUsers) {
return
}
setIsLoadingLoanUsers(true)
setLoanUsersError(null)
try {
const response = await fetch(`${API_URL}/device-loan-users`, {
credentials: 'include',
})
if (!response.ok) {
const errorData = await response.json().catch(() => null)
throw new Error(errorData?.error ?? 'Benutzer konnten nicht geladen werden')
}
const data = (await response.json()) as { users?: DeviceLoanUser[] }
setLoanUsers((data.users ?? []).map(loanUserToComboboxItem))
} catch (error) {
setLoanUsersError(
error instanceof Error ? error.message : 'Benutzer konnten nicht geladen werden',
)
} finally {
setIsLoadingLoanUsers(false)
}
}
function handleLoanStatusChange(event: ChangeEvent<HTMLSelectElement>) {
const nextStatus = event.target.value
if (nextStatus === 'Ausgeliehen') {
setLoanModalPreviousStatus(selectedLoanStatus || 'Verfügbar')
setSelectedLoanStatus(nextStatus)
setLoanModalError(null)
setIsLoanModalOpen(true)
void loadLoanUsers()
return
}
setSelectedLoanStatus(nextStatus)
setSelectedLoanUser(null)
setLoanedUntil('')
setIsLoanModalOpen(false)
setLoanModalError(null)
}
function handleOpenLoanDetails() {
setLoanModalPreviousStatus(selectedLoanStatus || 'Verfügbar')
setLoanModalError(null)
setIsLoanModalOpen(true)
void loadLoanUsers()
}
function handleLoanModalCancel() {
setSelectedLoanStatus(loanModalPreviousStatus || 'Verfügbar')
if (loanModalPreviousStatus !== 'Ausgeliehen') {
setSelectedLoanUser(null)
setLoanedUntil('')
}
setIsLoanModalOpen(false)
setLoanModalError(null)
}
function handleLoanModalSave() {
if (!selectedLoanUser) {
setLoanModalError('Bitte wähle einen Benutzer aus.')
return
}
setSelectedLoanStatus('Ausgeliehen')
setIsLoanModalOpen(false)
setLoanModalError(null)
}
async function handleManufacturerChange(item: ComboboxItem | null) {
setLookupError(null)
@ -412,14 +687,9 @@ export default function DeviceFormFields({
<section
hidden={activeTab !== 'masterData'}
className={classNames(sectionClassName, 'sm:col-span-6')}
className="sm:col-span-6"
>
<SectionHeader
title="Stammdaten"
description="Inventur, Hersteller, Modell und Seriennummer."
/>
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-6">
<div className="sm:col-span-3">
<label
htmlFor="inventoryNumber"
@ -441,28 +711,54 @@ export default function DeviceFormFields({
</div>
<div className="sm:col-span-3">
<label
htmlFor="loanStatus"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Verleih-Status
</label>
<input
type="hidden"
name="loanedToUserId"
value={selectedLoanStatus === 'Ausgeliehen' ? String(selectedLoanUser?.id ?? '') : ''}
/>
<div className="mt-2">
<select
id="loanStatus"
name="loanStatus"
defaultValue={device?.loanStatus || 'Verfügbar'}
className={selectClassName}
<input
type="hidden"
name="loanedUntil"
value={selectedLoanStatus === 'Ausgeliehen' ? loanedUntil : ''}
/>
<div className="flex min-h-6 items-center justify-between gap-3">
<label
htmlFor="loanStatus"
className="block shrink-0 text-sm/6 font-medium text-gray-900 dark:text-white"
>
<option>Verfügbar</option>
<option>Ausgeliehen</option>
<option>Reserviert</option>
<option>Wartung</option>
<option>Defekt</option>
<option>Verloren</option>
</select>
Verleih-Status
</label>
{selectedLoanStatus === 'Ausgeliehen' && (
<div className="flex min-w-0 items-center gap-1.5 text-xs">
<span
title={loanDetailsLabel}
className="min-w-0 truncate rounded-full bg-amber-50 px-2 py-0.5 font-medium text-amber-800 ring-1 ring-amber-200 ring-inset dark:bg-amber-500/10 dark:text-amber-200 dark:ring-amber-400/20"
>
{loanDetailsLabel}
</span>
<button
type="button"
onClick={handleOpenLoanDetails}
className="shrink-0 rounded-md px-1.5 py-0.5 font-semibold text-amber-800 transition hover:bg-amber-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500 dark:text-amber-200 dark:hover:bg-amber-400/10"
>
Ändern
</button>
</div>
)}
</div>
<Select
id="loanStatus"
name="loanStatus"
wrapperClassName="mt-2"
value={selectedLoanStatus}
onChange={handleLoanStatusChange}
options={loanStatusOptions}
/>
</div>
<div className="sm:col-span-3">
@ -480,9 +776,67 @@ export default function DeviceFormFields({
placeholder="Hersteller auswählen oder hinzufügen"
allowCustomValue
createOptionLabel={(value) => `"${value}" hinzufügen`}
inputClassName="py-2"
/>
</div>
<div className="sm:col-span-6">
<input
type="hidden"
name="deviceCategory"
value={isMilestoneDevice ? 'Kameras' : selectedDeviceCategory}
/>
<fieldset disabled={isMilestoneDevice}>
<legend className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Geräteart
</legend>
<div className="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-5">
{deviceKindOptions.map((option) => {
const selected = selectedDeviceCategory === option.category
const Icon = option.icon
return (
<button
key={option.category}
type="button"
aria-pressed={selected}
disabled={isMilestoneDevice}
onClick={() => setSelectedDeviceCategory(option.category)}
className={classNames(
'group flex h-full min-h-24 flex-col items-start rounded-lg border p-2.5 text-left transition focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:cursor-not-allowed dark:focus-visible:outline-indigo-500',
selected
? 'border-indigo-500 bg-indigo-50 text-indigo-950 ring-1 ring-indigo-500 dark:border-indigo-400 dark:bg-indigo-500/10 dark:text-indigo-100 dark:ring-indigo-400'
: 'border-gray-200 bg-white text-gray-900 hover:border-indigo-300 hover:bg-indigo-50/60 dark:border-white/10 dark:bg-white/[0.03] dark:text-white dark:hover:border-indigo-400/50 dark:hover:bg-indigo-500/10',
isMilestoneDevice && !selected && 'opacity-45',
)}
>
<span
className={classNames(
'flex size-8 items-center justify-center rounded-md',
selected
? 'bg-indigo-600 text-white dark:bg-indigo-400 dark:text-gray-950'
: 'bg-gray-100 text-gray-500 group-hover:bg-indigo-100 group-hover:text-indigo-600 dark:bg-white/10 dark:text-gray-400 dark:group-hover:bg-indigo-500/15 dark:group-hover:text-indigo-300',
)}
>
<Icon aria-hidden="true" className="size-4" />
</span>
<span className="mt-2 text-sm font-semibold">
{option.label}
</span>
<span className="mt-0.5 text-xs leading-4 text-gray-500 dark:text-gray-400">
{option.description}
</span>
</button>
)
})}
</div>
</fieldset>
</div>
<div className="sm:col-span-3">
<label
htmlFor="model"
@ -523,69 +877,118 @@ export default function DeviceFormFields({
{!isMilestoneDevice && (
<>
<div className="sm:col-span-3">
<label
htmlFor="macAddress"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
MAC-Adresse
</label>
{visibleCategoryFields.macAddress && (
<div className="sm:col-span-3">
<label
htmlFor="macAddress"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
MAC-Adresse
</label>
<div className="relative mt-2">
<input
id="macAddress"
name="macAddress"
type="text"
placeholder="00:11:22:33:44:55"
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}
className={inputClassName}
/>
<div className="relative mt-2">
<input
id="macAddress"
name="macAddress"
type="text"
placeholder="00:11:22:33:44:55"
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}
className={inputClassName}
/>
</div>
</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="relative mt-2">
<input
id="ipAddress"
name="ipAddress"
type="text"
placeholder="192.168.1.100"
defaultValue={device?.ipAddress ?? ''}
className={inputClassName}
/>
{visibleCategoryFields.ipAddress && (
<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="relative mt-2">
<input
id="ipAddress"
name="ipAddress"
type="text"
placeholder="192.168.1.100"
defaultValue={device?.ipAddress ?? ''}
className={inputClassName}
/>
</div>
</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>
{visibleCategoryFields.phoneNumber && (
<div className="sm:col-span-3">
<label
htmlFor="phoneNumber"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Rufnummer
</label>
<div className="relative mt-2">
<input
id="firmwareVersion"
name="firmwareVersion"
type="text"
defaultValue={device?.firmwareVersion ?? ''}
className={inputClassName}
/>
<div className="relative mt-2">
<input
id="phoneNumber"
name="phoneNumber"
type="text"
inputMode="tel"
defaultValue={device?.phoneNumber ?? ''}
className={inputClassName}
/>
</div>
</div>
</div>
)}
{visibleCategoryFields.computerName && (
<div className="sm:col-span-3">
<label
htmlFor="computerName"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Computername
</label>
<div className="relative mt-2">
<input
id="computerName"
name="computerName"
type="text"
defaultValue={device?.computerName ?? ''}
className={inputClassName}
/>
</div>
</div>
)}
{visibleCategoryFields.firmwareVersion && (
<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="relative mt-2">
<input
id="firmwareVersion"
name="firmwareVersion"
type="text"
defaultValue={device?.firmwareVersion ?? ''}
className={inputClassName}
/>
</div>
</div>
)}
</>
)}
@ -673,7 +1076,7 @@ export default function DeviceFormFields({
Milestone-Status
</label>
<div className="mt-2 flex min-h-[42px] items-center gap-3 rounded-xl border border-gray-200 bg-white px-3.5 py-2 shadow-sm dark:border-white/10 dark:bg-white/5">
<div className="mt-2 flex min-h-[42px] items-center gap-3 rounded-md border border-gray-200 bg-white px-3.5 py-2 dark:border-white/10 dark:bg-white/5">
<VideoCameraIcon
aria-hidden="true"
className="size-4 shrink-0 text-indigo-500 dark:text-indigo-400"
@ -812,14 +1215,9 @@ export default function DeviceFormFields({
<section
hidden={activeTab !== 'location'}
className={classNames(sectionClassName, 'sm:col-span-6')}
className="sm:col-span-6"
>
<SectionHeader
title="Standort & Kennzeichnung"
description="Standort, BAO-Markierung und zusätzliche Bemerkungen."
/>
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-6">
<div className="sm:col-span-6">
<input
type="hidden"
@ -835,6 +1233,7 @@ export default function DeviceFormFields({
placeholder="Standort auswählen oder hinzufügen"
allowCustomValue
createOptionLabel={(value) => `"${value}" hinzufügen`}
inputClassName="py-2"
/>
</div>
@ -865,14 +1264,9 @@ export default function DeviceFormFields({
<section
hidden={activeTab !== 'accessories'}
className={classNames(sectionClassName, 'sm:col-span-6')}
className="sm:col-span-6"
>
<SectionHeader
title="Zugeordnete Geräte / Zubehör"
description="Verknüpfe Zubehör oder zugehörige Geräte."
/>
<div className="mt-4">
<div>
<label
htmlFor="accessorySearch"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
@ -880,14 +1274,19 @@ export default function DeviceFormFields({
Zubehör suchen
</label>
<div className="mt-2">
<div className="relative mt-2">
<MagnifyingGlassIcon
aria-hidden="true"
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-gray-400 dark:text-gray-500"
/>
<input
id="accessorySearch"
type="search"
value={accessorySearch}
onChange={(event) => setAccessorySearch(event.target.value)}
placeholder="Inventur-Nr., Hersteller, Modell, Standort..."
className={inputClassName}
className={classNames(inputClassName, 'pl-9')}
/>
</div>
</div>
@ -897,7 +1296,14 @@ export default function DeviceFormFields({
{filteredAssignableDevices.length} von {assignableDevices.length} Geräten angezeigt
</span>
<span>
<span
className={classNames(
'inline-flex items-center rounded-full px-2 py-0.5 font-medium',
selectedAccessoryCount > 0
? 'bg-indigo-50 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300'
: 'bg-gray-100 text-gray-500 dark:bg-white/10 dark:text-gray-400',
)}
>
{selectedAccessoryCount} ausgewählt
</span>
</div>
@ -945,19 +1351,14 @@ export default function DeviceFormFields({
<section
hidden={activeTab !== 'hardware'}
className={classNames(sectionClassName, 'sm:col-span-6')}
className="sm:col-span-6"
>
<SectionHeader
title="Milestone-Hardware"
description="Zugeordnete Kameras und Mikrofone aus Milestone."
/>
{!isMilestoneDevice ? (
<div className="mt-4 rounded-xl border border-dashed border-gray-300 bg-gray-50/60 p-5 text-sm text-gray-500 dark:border-white/15 dark:bg-white/[0.03] dark:text-gray-400">
<div className="rounded-xl border border-dashed border-gray-300 bg-gray-50/60 p-5 text-sm text-gray-500 dark:border-white/15 dark:bg-white/[0.03] dark:text-gray-400">
Dieses Gerät ist kein Milestone-Gerät.
</div>
) : (
<div className="mt-4 grid min-h-0 flex-1 grid-cols-1 gap-4 lg:grid-cols-2 lg:auto-rows-fr">
<div className="grid min-h-0 flex-1 grid-cols-1 gap-4 lg:grid-cols-2 lg:auto-rows-fr">
<MilestoneChildDeviceCards
title="Kameras"
emptyText="Keine Kameras für diese Hardware gefunden."
@ -1002,6 +1403,86 @@ export default function DeviceFormFields({
</div>
)}
</section>
<Modal
open={isLoanModalOpen}
setOpen={(nextOpen) => {
if (!nextOpen) {
handleLoanModalCancel()
}
}}
panelClassName="max-w-lg overflow-visible bg-white dark:bg-gray-900"
>
<div className="border-b border-gray-200 px-4 py-4 sm:px-6 dark:border-white/10">
<ModalTitle>Ausleihe hinterlegen</ModalTitle>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Wähle aus, an wen das Gerät verliehen wird. Das Rückgabedatum ist optional.
</p>
</div>
<div className="space-y-4 px-4 py-5 sm:px-6">
{loanUsersError && (
<div className="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
{loanUsersError}
</div>
)}
<Combobox
label="Verliehen an"
items={loanUsers}
value={selectedLoanUser}
onChange={(item) => {
setSelectedLoanUser(item)
setLoanModalError(null)
}}
variant="image"
placeholder={
isLoadingLoanUsers
? 'Benutzer werden geladen...'
: 'Benutzer auswählen'
}
emptyText="Keine Benutzer gefunden."
disabled={isLoadingLoanUsers || Boolean(loanUsersError)}
/>
<DatePicker
id="loanedUntilPicker"
label="Verliehen bis (optional)"
value={loanedUntil}
onChange={(nextValue) => {
setLoanedUntil(nextValue)
setLoanModalError(null)
}}
placeholder="Kein Rückgabedatum"
/>
{loanModalError && (
<p className="text-sm text-red-600 dark:text-red-400">
{loanModalError}
</p>
)}
</div>
<div className="flex justify-end gap-x-3 border-t border-gray-200 px-4 py-4 sm:px-6 dark:border-white/10">
<Button
type="button"
variant="secondary"
color="gray"
onClick={handleLoanModalCancel}
>
Abbrechen
</Button>
<Button
type="button"
color="indigo"
disabled={isLoadingLoanUsers || Boolean(loanUsersError)}
onClick={handleLoanModalSave}
>
Übernehmen
</Button>
</div>
</Modal>
</div>
)
}

View File

@ -1,6 +1,7 @@
// frontend\src\pages\devices\DeviceModalTabs.tsx
import { type ComponentType, type SVGProps } from 'react'
import Tabs, { type TabItem } from '../../components/Tabs'
export type DeviceModalTabId =
| 'masterData'
@ -24,51 +25,29 @@ type DeviceModalTabsProps = {
onChange: (tabId: DeviceModalTabId) => void
}
function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(' ')
}
export default function DeviceModalTabs({
tabs,
activeTab,
onChange,
}: DeviceModalTabsProps) {
const tabItems: TabItem[] = tabs.map((tab) => ({
name: tab.name,
href: '#',
current: activeTab === tab.id,
icon: tab.icon,
onClick: tab.disabled ? undefined : () => onChange(tab.id),
}))
return (
<div className="border-b border-gray-200 bg-white/90 px-4 py-3 sm:px-6 dark:border-white/10 dark:bg-gray-900/90">
<nav
aria-label="Geräteformular"
className="flex gap-1.5 overflow-x-auto rounded-2xl bg-gray-100/80 p-1.5 dark:bg-white/5"
>
{tabs.map((tab) => {
const isActive = activeTab === tab.id
const Icon = tab.icon
return (
<button
key={tab.id}
type="button"
disabled={tab.disabled}
onClick={() => onChange(tab.id)}
className={classNames(
'inline-flex items-center gap-x-2 whitespace-nowrap rounded-xl px-3 py-2 text-sm font-medium transition',
isActive
? 'bg-white text-indigo-700 shadow-sm ring-1 ring-black/5 dark:bg-white/10 dark:text-indigo-300 dark:ring-white/10'
: 'text-gray-500 hover:bg-white/70 hover:text-gray-800 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-200',
tab.disabled && 'cursor-not-allowed opacity-50',
)}
>
{Icon && (
<Icon
aria-hidden="true"
className="size-4 shrink-0"
/>
)}
<span>{tab.name}</span>
</button>
)
})}
</nav>
<div className="bg-gray-50 dark:bg-gray-800/60">
<Tabs
fullWidth
tabs={tabItems}
variant="underline-icons"
ariaLabel="Geräteformular"
className="overflow-x-auto px-4 sm:px-0"
desktopNavClassName="sm:px-6"
/>
</div>
)
}

View File

@ -21,6 +21,9 @@ import type {
Device,
DeviceHistoryEntry,
MilestoneCameraDetail,
MilestoneApiStream,
MilestoneCameraSettings,
User,
} from '../../components/types'
import { useNotifications } from '../../components/Notifications'
@ -153,7 +156,99 @@ function getSupportBadgeTone(severity?: string): BadgeTone {
return 'blue'
}
export default function DevicesPage() {
const resolutionCandidateKeys = [
'availableResolutions',
'availableResolution',
'supportedResolutions',
'supportedResolution',
'resolutionOptions',
'resolutionValues',
'resolutionChoices',
'allowedResolutions',
'allowedResolution',
'resolutions',
'videoResolutions',
]
function normalizeResolution(value: unknown) {
const text = String(value ?? '').trim()
const match = text.match(/(\d{2,5})\s*(?:x|X|×)\s*(\d{2,5})/)
return match ? `${match[1]}x${match[2]}` : ''
}
function resolutionValuesFromUnknown(value: unknown, depth = 0): string[] {
if (depth > 4 || value === null || value === undefined) {
return []
}
if (typeof value === 'string' || typeof value === 'number') {
return String(value)
.split(/[,;|]/)
.map(normalizeResolution)
.filter(Boolean)
}
if (Array.isArray(value)) {
return value.flatMap((item) => resolutionValuesFromUnknown(item, depth + 1))
}
if (typeof value !== 'object') {
return []
}
const object = value as Record<string, unknown>
const directValues = [
object.value,
object.name,
object.displayName,
object.label,
object.text,
object.id,
object.resolution,
].flatMap((item) => resolutionValuesFromUnknown(item, depth + 1))
const nestedValues = [
object.values,
object.options,
object.items,
object.choices,
object.allowedValues,
object.allowed,
object.array,
].flatMap((item) => resolutionValuesFromUnknown(item, depth + 1))
const keyValues = Object.keys(object)
.map(normalizeResolution)
.filter(Boolean)
return [...directValues, ...nestedValues, ...keyValues]
}
function getAvailableResolutions(
source: Record<string, unknown>,
currentResolution: string,
) {
const candidateValues = resolutionCandidateKeys.flatMap((key) =>
resolutionValuesFromUnknown(source[key]),
)
const resolutionValue =
typeof source.resolution === 'object'
? resolutionValuesFromUnknown(source.resolution)
: []
return Array.from(
new Set(
[...candidateValues, ...resolutionValue, normalizeResolution(currentResolution)]
.filter(Boolean),
),
)
}
type DevicesPageProps = {
currentUser?: User | null
}
export default function DevicesPage({ currentUser }: DevicesPageProps = {}) {
const { showToast, showErrorToast } = useNotifications()
const [devices, setDevices] = useState<Device[]>([])
const [isLoading, setIsLoading] = useState(true)
@ -180,6 +275,8 @@ export default function DevicesPage() {
const [editingCameraChildDevice, setEditingCameraChildDevice] =
useState<NonNullable<Device['milestoneChildDevices']>[number] | null>(null)
const [cameraDetails, setCameraDetails] = useState<MilestoneCameraDetail | null>(null)
const [cameraApiStreams, setCameraApiStreams] = useState<MilestoneApiStream[]>([])
const [cameraSettings, setCameraSettings] = useState<MilestoneCameraSettings | null>(null)
const [isCameraDetailsLoading, setIsCameraDetailsLoading] = useState(false)
const [isCameraDetailsSaving, setIsCameraDetailsSaving] = useState(false)
const [cameraDetailsError, setCameraDetailsError] = useState<string | null>(null)
@ -250,21 +347,145 @@ export default function DevicesPage() {
setCameraDetailsError(null)
try {
const response = await fetch(
`${API_URL}/devices/${device.id}/milestone-cameras/${childDevice.id}`,
{
const [cameraResponse, settingsResponse] = await Promise.all([
fetch(`${API_URL}/devices/${device.id}/milestone-cameras/${childDevice.id}`, {
credentials: 'include',
},
)
}),
fetch(`${API_URL}/devices/${device.id}/milestone-cameras/${childDevice.id}/camera-settings`, {
credentials: 'include',
}),
])
if (!response.ok) {
if (!cameraResponse.ok) {
throw new Error(
await readApiError(response, 'Kamera-Details konnten nicht geladen werden'),
await readApiError(cameraResponse, 'Kamera-Details konnten nicht geladen werden'),
)
}
const data = await response.json()
setCameraDetails(data.camera ?? null)
const cameraData = await cameraResponse.json()
setCameraDetails(cameraData.camera ?? null)
if (settingsResponse.ok) {
const settingsData = await settingsResponse.json()
const rawSettings = (settingsData.settings ?? {}) as Record<string, unknown>
const rawActiveStreams = (settingsData.activeStreams ?? []) as unknown[]
const relations = (rawSettings.relations ?? {}) as Record<string, unknown>
const self = (relations.self ?? {}) as Record<string, unknown>
const rawPtz = rawSettings.ptz as Record<string, unknown> | undefined
setCameraSettings({
id: String(self.id ?? ''),
displayName: String(rawSettings.displayName ?? ''),
ptz: rawPtz
? {
displayName: String(rawPtz.displayName ?? ''),
ptzEnabled: Boolean(rawPtz.ptzEnabled),
ptzDeviceID: Number(rawPtz.ptzDeviceID ?? 0),
ptzCOMPort: Number(rawPtz.ptzCOMPort ?? 0),
ptzProtocol: String(rawPtz.ptzProtocol ?? ''),
ptzCenterOnPositionInView: Boolean(rawPtz.ptzCenterOnPositionInView),
ptzCenterAndZoomToRectangle: Boolean(rawPtz.ptzCenterAndZoomToRectangle),
ptzHomeSupport: Boolean(rawPtz.ptzHomeSupport),
ptzDiagonalSupport: Boolean(rawPtz.ptzDiagonalSupport),
ptzIpix: Boolean(rawPtz.ptzIpix),
}
: undefined,
generalSettings: Array.isArray(rawSettings.generalSettings)
? (rawSettings.generalSettings as Record<string, unknown>[]).map((gs) => ({
displayName: String(gs.displayName ?? ''),
cameraName: String(gs.cameraName ?? ''),
brightness: Number(gs.brightness ?? 0),
sharpness: Number(gs.sharpness ?? 0),
contrast: Number(gs.contrast ?? 0),
saturation: Number(gs.saturation ?? 0),
whiteBalance: String(gs.whiteBalance ?? ''),
mirrorImage: String(gs.mirrorImage ?? 'No'),
rotation: String(gs.rotation ?? '0'),
blackAndWhiteMode: String(gs.blackAndWhiteMode ?? 'No'),
oSDTextEnabled: String(gs.oSDTextEnabled ?? ''),
oSDText: String(gs.oSDText ?? ''),
oSDPosition: String(gs.oSDPosition ?? ''),
edgeStorageStreamIndex: Number(gs.edgeStorageStreamIndex ?? 0),
edgeStorageEnabled: String(gs.edgeStorageEnabled ?? ''),
multicastVideoPort: Number(gs.multicastVideoPort ?? 0),
multicastAddress: String(gs.multicastAddress ?? ''),
multicastTTL: Number(gs.multicastTTL ?? 0),
multicastForceSSM: String(gs.multicastForceSSM ?? ''),
edgeStorageRecording: String(gs.edgeStorageRecording ?? ''),
edgeStorageSpeed: String(gs.edgeStorageSpeed ?? ''),
recorderStreamIndex: String(gs.recorderStreamIndex ?? ''),
recorderMode: String(gs.recorderMode ?? ''),
recorderRetentionTime: Number(gs.recorderRetentionTime ?? 0),
recorderPreTriggerTime: Number(gs.recorderPreTriggerTime ?? 0),
recorderPostTriggerTime: Number(gs.recorderPostTriggerTime ?? 0),
recorderAudioEnabled: String(gs.recorderAudioEnabled ?? ''),
}))
: [],
stream: Array.isArray(rawSettings.stream)
? (rawSettings.stream as Record<string, unknown>[]).map((s) => {
const resolution = normalizeResolution(s.resolution) || String(s.resolution ?? '')
return {
displayName: String(s.displayName ?? ''),
codec: String(s.codec ?? ''),
FPS: Number(s.FPS ?? 0),
maxGOPSize: Number(s.maxGOPSize ?? 0),
maxGOPMode: String(s.maxGOPMode ?? ''),
edgeStorageSupported: String(s.edgeStorageSupported ?? ''),
streamingMode: String(s.streamingMode ?? ''),
includeDate: String(s.includeDate ?? ''),
includeTime: String(s.includeTime ?? ''),
controlMode: String(s.controlMode ?? ''),
controlPriority: String(s.controlPriority ?? ''),
targetBitrate: Number(s.targetBitrate ?? 0),
compression: Number(s.compression ?? 0),
resolution,
availableResolutions: getAvailableResolutions(s, resolution),
zStrength: String(s.zStrength ?? ''),
zGopMode: String(s.zGopMode ?? ''),
zFpsMode: String(s.zFpsMode ?? ''),
zGopLength: Number(s.zGopLength ?? 0),
streamReferenceId: String(s.streamReferenceId ?? ''),
}
})
: [],
})
setCameraApiStreams(
rawActiveStreams
.map((item) => {
const s = item as Record<string, unknown>
const rel = (s.relations ?? {}) as Record<string, unknown>
const selfRel = (rel.self ?? {}) as Record<string, unknown>
return {
id: String(selfRel.id ?? ''),
displayName: String(s.displayName ?? ''),
stream: Array.isArray(s.stream)
? (s.stream as Record<string, unknown>[]).map((setting) => ({
defaultPlayback: Boolean(setting.defaultPlayback),
displayName: String(setting.displayName ?? ''),
edgeTrackId: String(setting.edgeTrackId ?? ''),
liveDefault: Boolean(setting.liveDefault),
liveMode: String(setting.liveMode ?? ''),
name: String(setting.name ?? ''),
recordTo: String(setting.recordTo ?? ''),
streamReferenceId: String(setting.streamReferenceId ?? ''),
availableResolutions: getAvailableResolutions(
setting,
String(setting.resolution ?? ''),
),
useEdge: Boolean(setting.useEdge),
}))
: [],
}
})
.filter((stream) => stream.id !== ''),
)
} else {
setCameraSettings(null)
setCameraApiStreams([])
}
} catch (error) {
setCameraDetailsError(
error instanceof Error
@ -653,6 +874,8 @@ export default function DevicesPage() {
setIsCameraDetailsOpen(false)
setEditingCameraChildDevice(null)
setCameraDetails(null)
setCameraApiStreams([])
setCameraSettings(null)
setCameraDetailsError(null)
setIsCameraDetailsLoading(false)
setIsCameraDetailsSaving(false)
@ -746,7 +969,7 @@ export default function DevicesPage() {
setIsCameraDetailsSaving(true)
setCameraDetailsError(null)
const payload = {
const cameraPayload = {
enabled: formData.get('enabled') === 'on',
recordingEnabled: formData.get('recordingEnabled') === 'on',
recordingFramerate:
@ -754,33 +977,133 @@ export default function DevicesPage() {
recordKeyframesOnly: formData.get('recordKeyframesOnly') === 'on',
recordingStorageId:
String(formData.get('recordingStorageId') ?? '').trim() || null,
streams: cameraDetails.streams.map((_stream, index) => ({
index,
codec: String(formData.get(`streamCodec-${index}`) ?? ''),
fps: Number(formData.get(`streamFps-${index}`) ?? 0) || null,
jPEGQuality:
Number(formData.get(`streamJPEGQuality-${index}`) ?? 0) || null,
resolution: String(formData.get(`streamResolution-${index}`) ?? ''),
quality: Number(formData.get(`streamQuality-${index}`) ?? 0) || null,
})),
}
try {
const response = await fetch(
const cameraResponse = await fetch(
`${API_URL}/devices/${editingDevice.id}/milestone-cameras/${editingCameraChildDevice.id}`,
{
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(payload),
body: JSON.stringify(cameraPayload),
},
)
if (!response.ok) {
if (!cameraResponse.ok) {
throw new Error(
await readApiError(response, 'Kamera konnte nicht aktualisiert werden'),
await readApiError(cameraResponse, 'Kamera konnte nicht aktualisiert werden'),
)
}
if (cameraSettings) {
const settingsPayload: Record<string, unknown> = {}
if (cameraSettings.ptz) {
settingsPayload.ptz = {
...cameraSettings.ptz,
ptzEnabled: formData.get('ptz-ptzEnabled') === 'on',
ptzCenterOnPositionInView: formData.get('ptz-ptzCenterOnPosition') === 'on',
ptzCenterAndZoomToRectangle: formData.get('ptz-ptzCenterAndZoom') === 'on',
ptzHomeSupport: formData.get('ptz-ptzHomeSupport') === 'on',
ptzProtocol: String(formData.get('ptz-ptzProtocol') ?? cameraSettings.ptz.ptzProtocol),
ptzCOMPort: Number(formData.get('ptz-ptzCOMPort') ?? cameraSettings.ptz.ptzCOMPort),
}
}
if (cameraSettings.generalSettings.length > 0) {
const existing = cameraSettings.generalSettings[0]
settingsPayload.generalSettings = [
{
...existing,
cameraName: String(formData.get('gs-cameraName') ?? existing.cameraName),
brightness: Number(formData.get('gs-brightness') ?? existing.brightness),
contrast: Number(formData.get('gs-contrast') ?? existing.contrast),
saturation: Number(formData.get('gs-saturation') ?? existing.saturation),
sharpness: Number(formData.get('gs-sharpness') ?? existing.sharpness),
whiteBalance: String(formData.get('gs-whiteBalance') ?? existing.whiteBalance),
mirrorImage: String(formData.get('gs-mirrorImage') ?? existing.mirrorImage),
rotation: String(formData.get('gs-rotation') ?? existing.rotation),
blackAndWhiteMode: formData.get('gs-blackAndWhite') === 'on' ? 'Yes' : 'No',
oSDTextEnabled: formData.get('gs-oSDTextEnabled') === 'on' ? 'Yes' : 'No',
oSDPosition: String(formData.get('gs-oSDPosition') ?? existing.oSDPosition),
oSDText: String(formData.get('gs-oSDText') ?? existing.oSDText),
multicastAddress: String(formData.get('gs-multicastAddress') ?? existing.multicastAddress),
multicastVideoPort: Number(formData.get('gs-multicastVideoPort') ?? existing.multicastVideoPort),
},
]
}
if (cameraSettings.stream.length > 0) {
settingsPayload.stream = cameraSettings.stream.map((stream, index) => {
const streamPayload = { ...stream }
delete streamPayload.availableResolutions
return {
...streamPayload,
codec: String(formData.get(`stream-${index}-codec`) ?? stream.codec),
FPS: Number(formData.get(`stream-${index}-FPS`) ?? stream.FPS),
resolution: String(formData.get(`stream-${index}-resolution`) ?? stream.resolution),
targetBitrate: Number(formData.get(`stream-${index}-targetBitrate`) ?? stream.targetBitrate),
compression: Number(formData.get(`stream-${index}-compression`) ?? stream.compression),
maxGOPSize: Number(formData.get(`stream-${index}-maxGOPSize`) ?? stream.maxGOPSize),
streamingMode: String(formData.get(`stream-${index}-streamingMode`) ?? stream.streamingMode),
controlMode: String(formData.get(`stream-${index}-controlMode`) ?? stream.controlMode),
includeDate: String(formData.get(`stream-${index}-includeDate`) ?? stream.includeDate),
includeTime: String(formData.get(`stream-${index}-includeTime`) ?? stream.includeTime),
zStrength: String(formData.get(`stream-${index}-zStrength`) ?? stream.zStrength),
zGopMode: String(formData.get(`stream-${index}-zGopMode`) ?? stream.zGopMode),
zFpsMode: String(formData.get(`stream-${index}-zFpsMode`) ?? stream.zFpsMode),
zGopLength: Number(formData.get(`stream-${index}-zGopLength`) ?? stream.zGopLength),
}
})
}
if (Object.keys(settingsPayload).length > 0) {
await fetch(
`${API_URL}/devices/${editingDevice.id}/milestone-cameras/${editingCameraChildDevice.id}/camera-settings`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(settingsPayload),
},
)
}
}
if (cameraApiStreams.length > 0) {
const liveDefaultRefId = String(formData.get('active-liveDefault-refId') ?? '')
const defaultPlaybackRefId = String(formData.get('active-defaultPlayback-refId') ?? '')
const liveModeValue = String(formData.get('active-liveMode') ?? 'WhenNeeded')
await Promise.allSettled(
cameraApiStreams.map((group) => {
const updatedStream = group.stream.map((setting) => {
const streamSetting = { ...setting }
delete streamSetting.availableResolutions
return {
...streamSetting,
liveDefault: liveDefaultRefId !== '' ? setting.streamReferenceId === liveDefaultRefId : setting.liveDefault,
defaultPlayback: defaultPlaybackRefId !== '' ? setting.streamReferenceId === defaultPlaybackRefId : setting.defaultPlayback,
liveMode: liveModeValue || setting.liveMode,
}
})
return fetch(
`${API_URL}/devices/${editingDevice.id}/milestone-cameras/${editingCameraChildDevice.id}/streams/${encodeURIComponent(group.id)}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
displayName: group.displayName,
stream: updatedStream,
relations: { self: { type: 'streams', id: group.id } },
}),
},
)
}),
)
}
@ -922,6 +1245,9 @@ export default function DevicesPage() {
serialNumber: String(formData.get('serialNumber') ?? ''),
macAddress: String(formData.get('macAddress') ?? ''),
ipAddress: String(formData.get('ipAddress') ?? ''),
phoneNumber: String(formData.get('phoneNumber') ?? ''),
computerName: String(formData.get('computerName') ?? ''),
deviceCategory: String(formData.get('deviceCategory') ?? ''),
milestonePort: String(formData.get('milestonePort') ?? ''),
firmwareVersion: String(formData.get('firmwareVersion') ?? ''),
milestoneDisplayName: String(formData.get('milestoneDisplayName') ?? ''),
@ -930,6 +1256,8 @@ export default function DevicesPage() {
: {}),
location: String(formData.get('location') ?? ''),
loanStatus: String(formData.get('loanStatus') ?? 'Verfügbar'),
loanedToUserId: String(formData.get('loanedToUserId') ?? ''),
loanedUntil: String(formData.get('loanedUntil') ?? ''),
comment: String(formData.get('comment') ?? ''),
isBaoDevice: formData.get('isBaoDevice') === 'on',
relatedDeviceIds,
@ -1232,11 +1560,7 @@ export default function DevicesPage() {
]
return (
<div className="relative h-full min-h-0 overflow-y-auto bg-gradient-to-br from-slate-50 via-white to-indigo-50/50 px-4 py-8 sm:px-6 lg:px-8 dark:from-gray-950 dark:via-gray-950 dark:to-indigo-950/20 [scrollbar-gutter:stable]">
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<div className="absolute -top-36 right-0 size-96 rounded-full bg-indigo-200/25 blur-3xl dark:bg-indigo-500/10" />
</div>
<div className="h-full min-h-0 overflow-y-auto px-4 py-10 sm:px-6 lg:px-8" >
<Tables
title="Geräte"
description="Eine Liste aller Geräte inklusive Inventur-Nr., Hersteller, Modell, Seriennummer, MAC-Adresse, IP-Adresse, Firmware, Standort, Verleih-Status und Zubehör."
@ -1267,17 +1591,9 @@ export default function DevicesPage() {
Gerät hinzufügen
</Button>
</div>
{firmwareCheckNextAllowedLabel && (
<div className="inline-flex items-center gap-1.5 rounded-full border border-gray-200 bg-white/80 px-3 py-1.5 text-xs font-medium text-gray-600 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/5 dark:text-gray-300">
<ClockIcon aria-hidden="true" className="size-3.5 shrink-0" />
<span>{firmwareCheckNextAllowedLabel}</span>
</div>
)}
</div>
}
onAction={openCreateModal}
className="relative overflow-hidden rounded-3xl border border-white/80 bg-white/85 p-5 shadow-xl shadow-slate-900/5 ring-1 ring-slate-900/5 backdrop-blur-xl sm:p-6 dark:border-white/10 dark:bg-gray-900/80 dark:shadow-black/20 dark:ring-white/5"
rows={devices}
columns={columns}
getRowId={(device) => device.id}
@ -1339,6 +1655,8 @@ export default function DevicesPage() {
isJournalSubmitting={isJournalSubmitting}
onJournalEntrySubmit={handleCreateDeviceJournalEntry}
onJournalEntryUpdate={handleUpdateDeviceJournalEntry}
currentUserAvatar={currentUser?.avatar}
currentUserName={currentUser?.displayName || currentUser?.username}
togglingMilestoneChildDeviceIds={togglingMilestoneChildDeviceIds}
onToggleMilestoneChildDevice={(childDevice, enabled) => {
if (!editingDevice) {
@ -1376,6 +1694,8 @@ export default function DevicesPage() {
device={editingDevice}
childDevice={editingCameraChildDevice}
camera={cameraDetails}
apiStreams={cameraApiStreams}
cameraSettings={cameraSettings}
isLoading={isCameraDetailsLoading}
isSaving={isCameraDetailsSaving}
error={cameraDetailsError}

View File

@ -1,12 +1,13 @@
// frontend\src\pages\devices\EditDeviceModal.tsx
import { useState, type ComponentProps, type FormEvent } from 'react'
import { useEffect, useState, type ComponentProps, type FormEvent } from 'react'
import { DialogTitle } from '@headlessui/react'
import { XMarkIcon } from '@heroicons/react/24/outline'
import {
ChatBubbleLeftEllipsisIcon,
CheckIcon,
IdentificationIcon,
MagnifyingGlassIcon,
MapPinIcon,
PencilSquareIcon,
PlusCircleIcon,
@ -22,10 +23,7 @@ import type {
DeviceHistoryChange,
DeviceHistoryEntry,
} from '../../components/types'
import DeviceFormFields, {
SectionHeader,
sectionClassName,
} from './DeviceFormFields'
import DeviceFormFields from './DeviceFormFields'
import DeviceModalTabs, { type DeviceModalTabId } from './DeviceModalTabs'
type EditDeviceModalProps = {
@ -44,6 +42,8 @@ type EditDeviceModalProps = {
isJournalSubmitting?: boolean
onJournalEntrySubmit?: (content: string) => void | Promise<void>
onJournalEntryUpdate?: (itemId: string | number, content: string) => void | Promise<void>
currentUserAvatar?: string
currentUserName?: string
togglingMilestoneChildDeviceIds?: Set<string>
onToggleMilestoneChildDevice?: (
childDevice: NonNullable<Device['milestoneChildDevices']>[number],
@ -121,6 +121,9 @@ function getTabForInvalidField(fieldName: string): DeviceModalTabId {
'serialNumber',
'macAddress',
'ipAddress',
'phoneNumber',
'computerName',
'deviceCategory',
'firmwareVersion',
'loanStatus',
].includes(fieldName)
@ -135,10 +138,6 @@ function getTabForInvalidField(fieldName: string): DeviceModalTabId {
return 'accessories'
}
function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(' ')
}
function filterMilestoneChildDevices(device: Device | null) {
if (!device) {
return device
@ -168,6 +167,8 @@ export default function EditDeviceModal({
isJournalSubmitting = false,
onJournalEntrySubmit,
onJournalEntryUpdate,
currentUserAvatar,
currentUserName,
togglingMilestoneChildDeviceIds,
onToggleMilestoneChildDevice,
onToggleMilestoneChildDeviceGroup,
@ -176,7 +177,21 @@ export default function EditDeviceModal({
onOpenMilestoneCameraDetails,
}: EditDeviceModalProps) {
const [activeTab, setActiveTab] = useState<DeviceModalTabId>('masterData')
const [journalSearchQuery, setJournalSearchQuery] = useState('')
const milestoneFilteredDevice = filterMilestoneChildDevices(device)
const isMilestoneDevice = Boolean(
milestoneFilteredDevice?.milestoneHardwareId ||
milestoneFilteredDevice?.milestoneRecordingServerId,
)
const visibleEditDeviceTabs = isMilestoneDevice
? editDeviceTabs.filter((tab) => tab.id !== 'hardware')
: editDeviceTabs
useEffect(() => {
if (isMilestoneDevice && activeTab === 'hardware') {
setActiveTab('masterData')
}
}, [activeTab, isMilestoneDevice])
function handleOpenChange(nextOpen: boolean) {
if (!nextOpen && isSaving) {
@ -187,6 +202,7 @@ export default function EditDeviceModal({
if (!nextOpen) {
setActiveTab('masterData')
setJournalSearchQuery('')
}
}
@ -219,6 +235,7 @@ export default function EditDeviceModal({
: ('edited' as const),
person: {
name: entry.userDisplayName || 'Unbekannt',
imageUrl: entry.userAvatar || undefined,
},
content: isJournalEntry
? journalChange?.newValue || 'hat einen Journal-Eintrag hinzugefügt.'
@ -227,6 +244,14 @@ export default function EditDeviceModal({
: entry.changes.length > 0
? `hat ${entry.changes.map(formatHistoryChange).join(', ')} geändert.`
: 'hat das Gerät bearbeitet.',
changes:
!isJournalEntry && entry.action !== 'created' && entry.changes.length > 0
? entry.changes.map((change) => ({
label: change.label,
oldValue: change.oldValue,
newValue: change.newValue,
}))
: undefined,
date: formatHistoryDate(entry.createdAt),
datetime: entry.createdAt,
editedDate:
@ -265,8 +290,8 @@ export default function EditDeviceModal({
onInvalidCapture={handleInvalid}
className="flex h-[calc(100dvh-2rem)] max-h-[calc(100dvh-2rem)] flex-col"
>
<div className="flex items-start justify-between border-b border-gray-200 bg-gray-100 px-4 py-4 sm:px-6 dark:border-white/10 dark:bg-gray-800">
<div>
<div className="flex items-start justify-between gap-4 border-b border-gray-200 bg-gray-100 px-4 py-4 sm:px-6 dark:border-white/10 dark:bg-gray-800">
<div className="min-w-0 flex-1">
<DialogTitle className="text-base font-semibold text-gray-900 dark:text-white">
Gerät bearbeiten
{milestoneFilteredDevice && (
@ -295,21 +320,41 @@ export default function EditDeviceModal({
</div>
</div>
<Button
type="button"
variant="secondary"
color="gray"
size="sm"
disabled={isSaving}
onClick={() => handleOpenChange(false)}
>
<span className="sr-only">Schließen</span>
<XMarkIcon aria-hidden="true" className="size-5" />
</Button>
<div className="flex shrink-0 items-start gap-2">
{activeTab === 'history' && historyFeedItems.length > 0 && (
<div className="relative hidden w-60 max-w-[40vw] sm:block">
<MagnifyingGlassIcon
aria-hidden="true"
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-gray-400 dark:text-gray-500"
/>
<input
type="search"
value={journalSearchQuery}
onChange={(event) => setJournalSearchQuery(event.target.value)}
placeholder="Journal durchsuchen..."
aria-label="Journal durchsuchen"
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>
)}
<Button
type="button"
variant="secondary"
color="gray"
size="sm"
disabled={isSaving}
onClick={() => handleOpenChange(false)}
>
<span className="sr-only">Schließen</span>
<XMarkIcon aria-hidden="true" className="size-5" />
</Button>
</div>
</div>
<DeviceModalTabs
tabs={editDeviceTabs}
tabs={visibleEditDeviceTabs}
activeTab={activeTab}
onChange={setActiveTab}
/>
@ -322,43 +367,41 @@ export default function EditDeviceModal({
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-6 sm:px-6">
{activeTab === 'history' ? (
<section className={classNames(sectionClassName, 'sm:col-span-6')}>
<SectionHeader
title="Bearbeitungsverlauf"
description="Änderungen an diesem Gerät."
/>
<div className="mt-6">
{isHistoryLoading && (
<div className="flex min-h-24 items-center justify-center rounded-md border border-dashed border-gray-200 p-4 dark:border-white/10">
<LoadingSpinner
size="md"
label="Verlauf wird geladen..."
className="text-indigo-600 dark:text-indigo-400"
/>
</div>
)}
{historyError && (
<div className="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
{historyError}
</div>
)}
{!isHistoryLoading && !historyError && (
<Journal
items={historyFeedItems}
showEntryForm
isSubmitting={isJournalSubmitting}
emptyText="Noch keine Änderungen erfasst."
placeholder="Journal-Eintrag zum Gerät hinzufügen..."
submitLabel="Eintrag hinzufügen"
onEntrySubmit={onJournalEntrySubmit}
onEntryUpdate={onJournalEntryUpdate}
<div>
{isHistoryLoading && (
<div className="flex min-h-24 items-center justify-center rounded-md border border-dashed border-gray-200 p-4 dark:border-white/10">
<LoadingSpinner
size="md"
label="Verlauf wird geladen..."
className="text-indigo-600 dark:text-indigo-400"
/>
)}
</div>
</section>
</div>
)}
{historyError && (
<div className="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
{historyError}
</div>
)}
{!isHistoryLoading && !historyError && (
<Journal
items={historyFeedItems}
showEntryForm
showSearch={false}
searchQuery={journalSearchQuery}
onSearchQueryChange={setJournalSearchQuery}
isSubmitting={isJournalSubmitting}
emptyText="Noch keine Änderungen erfasst."
placeholder="Journal-Eintrag zum Gerät hinzufügen..."
submitLabel="Eintrag hinzufügen"
onEntrySubmit={onJournalEntrySubmit}
onEntryUpdate={onJournalEntryUpdate}
currentUserAvatar={currentUserAvatar}
currentUserName={currentUserName}
/>
)}
</div>
) : (
<DeviceFormFields
open={open}
@ -402,4 +445,4 @@ export default function EditDeviceModal({
</form>
</Modal>
)
}
}

View File

@ -0,0 +1,456 @@
import { useEffect, useMemo, useState } from 'react'
import {
ArrowPathIcon,
ArrowRightIcon,
CheckCircleIcon,
CpuChipIcon,
ExclamationTriangleIcon,
MicrophoneIcon,
ServerStackIcon,
VideoCameraIcon,
} from '@heroicons/react/24/outline'
import { Link } from 'react-router'
import LoadingSpinner from '../../components/LoadingSpinner'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
type MilestoneDevice = {
id: string
name: string
displayName: string
type: 'camera' | 'microphone'
enabled: boolean
recordingEnabled: boolean
recordingStorageId: string
hardwareId: string
hardwareDisplayName?: string
}
type MilestoneArchiveStorage = {
id: string
displayName: string
name: string
diskPath: string
mounted: boolean
maxSize?: number
retainMinutes?: number
}
type MilestoneStorageInformation = {
usedSpace?: number
lockedUsedSpace?: number
isAvailable?: boolean
isMounted?: boolean
lastUpdated?: string
}
type MilestoneStorage = {
id: string
displayName: string
name: string
diskPath: string
mounted: boolean
isDefault?: boolean
maxSize?: number
retainMinutes?: number
storageInformation?: MilestoneStorageInformation | null
archiveStorages: MilestoneArchiveStorage[]
}
type LoadState = {
devices: MilestoneDevice[]
storages: MilestoneStorage[]
}
const featureCards = [
{
title: 'Geräte',
description:
'Kameras und Mikrofone nach Recording-Speicher gruppiert anzeigen und Status direkt schalten.',
href: '/milestone/geraete',
icon: CpuChipIcon,
},
{
title: 'Speicher',
description:
'Basis- und Archivspeicher verwalten, Kapazitäten prüfen und neue Speicher anlegen.',
href: '/milestone/speicher',
icon: ServerStackIcon,
},
]
function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(' ')
}
function formatBytes(value?: number | null) {
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
return '—'
}
if (value === 0) {
return '0 B'
}
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
let size = value
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex += 1
}
return `${size.toLocaleString('de-DE', {
maximumFractionDigits: unitIndex === 0 ? 0 : 1,
})} ${units[unitIndex]}`
}
function megabytesToBytes(value?: number | null) {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
return 0
}
return value * 1024 * 1024
}
function formatPercent(value: number) {
if (!Number.isFinite(value)) {
return '—'
}
return `${value.toLocaleString('de-DE', {
maximumFractionDigits: 1,
})} %`
}
function storageTitle(storage: MilestoneStorage) {
return storage.displayName || storage.name || storage.id
}
export default function MilestonePage() {
const [state, setState] = useState<LoadState>({ devices: [], storages: [] })
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
void loadData()
}, [])
async function loadData() {
setIsLoading(true)
setError('')
try {
const [devicesResponse, storagesResponse] = await Promise.all([
fetch(`${API_URL}/admin/milestone/cameras`, { credentials: 'include' }),
fetch(`${API_URL}/admin/milestone/storages`, { credentials: 'include' }),
])
const devicesData = await devicesResponse.json().catch(() => ({}))
const storagesData = await storagesResponse.json().catch(() => ({}))
if (!devicesResponse.ok) {
throw new Error(
typeof devicesData?.error === 'string'
? devicesData.error
: 'Milestone-Geräte konnten nicht geladen werden.',
)
}
if (!storagesResponse.ok) {
throw new Error(
typeof storagesData?.error === 'string'
? storagesData.error
: 'Milestone-Speicher konnten nicht geladen werden.',
)
}
setState({
devices: Array.isArray(devicesData?.devices) ? devicesData.devices : [],
storages: Array.isArray(storagesData?.storages)
? storagesData.storages.map((storage: MilestoneStorage) => ({
...storage,
archiveStorages: Array.isArray(storage.archiveStorages)
? storage.archiveStorages
: [],
storageInformation: storage.storageInformation ?? null,
}))
: [],
})
} catch (loadError) {
setError(
loadError instanceof Error
? loadError.message
: 'Milestone-Daten konnten nicht geladen werden.',
)
} finally {
setIsLoading(false)
}
}
const stats = useMemo(() => {
const cameras = state.devices.filter((device) => device.type === 'camera')
const microphones = state.devices.filter((device) => device.type === 'microphone')
const enabledDevices = state.devices.filter((device) => device.enabled)
const recordingEnabled = cameras.filter((device) => device.recordingEnabled)
const hardwareIds = new Set(
state.devices.map((device) => device.hardwareId).filter(Boolean),
)
const archiveStorages = state.storages.flatMap(
(storage) => storage.archiveStorages ?? [],
)
const mountedBasisStorages = state.storages.filter((storage) => storage.mounted)
const mountedArchives = archiveStorages.filter((storage) => storage.mounted)
const totalCapacityBytes = state.storages.reduce(
(sum, storage) => sum + megabytesToBytes(storage.maxSize),
0,
)
const usedBytes = state.storages.reduce(
(sum, storage) => sum + (storage.storageInformation?.usedSpace ?? 0),
0,
)
const lockedBytes = state.storages.reduce(
(sum, storage) => sum + (storage.storageInformation?.lockedUsedSpace ?? 0),
0,
)
const usedPercent = totalCapacityBytes > 0 ? (usedBytes / totalCapacityBytes) * 100 : NaN
const defaultStorage = state.storages.find((storage) => storage.isDefault)
return {
cameras: cameras.length,
microphones: microphones.length,
devicesTotal: state.devices.length,
enabledDevices: enabledDevices.length,
disabledDevices: state.devices.length - enabledDevices.length,
recordingEnabled: recordingEnabled.length,
hardwareCount: hardwareIds.size,
basisStorages: state.storages.length,
archiveStorages: archiveStorages.length,
mountedBasisStorages: mountedBasisStorages.length,
mountedArchives: mountedArchives.length,
totalCapacityBytes,
usedBytes,
lockedBytes,
usedPercent,
defaultStorage,
}
}, [state.devices, state.storages])
return (
<div className="h-full overflow-y-auto bg-gray-50 px-4 py-6 sm:px-6 lg:px-8 dark:bg-gray-950">
<div className="mx-auto max-w-7xl space-y-6">
<section className="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-xs dark:border-white/10 dark:bg-white/5">
<div className="relative px-6 py-7 sm:px-8">
<div className="absolute inset-x-0 top-0 h-1 bg-indigo-600" />
<div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between">
<div className="max-w-3xl">
<div className="flex items-center gap-3">
<div className="flex size-12 items-center justify-center rounded-xl bg-indigo-50 text-indigo-600 dark:bg-indigo-500/10 dark:text-indigo-300">
<ServerStackIcon aria-hidden="true" className="size-6" />
</div>
<div>
<p className="text-sm font-semibold uppercase tracking-wide text-indigo-600 dark:text-indigo-300">
Milestone
</p>
<h1 className="text-2xl font-semibold tracking-tight text-gray-900 dark:text-white">
Management für Geräte, Recording und Speicher
</h1>
</div>
</div>
<p className="mt-4 text-sm leading-6 text-gray-600 dark:text-gray-300">
Hier findest du den schnellen Zustand deiner Milestone-Umgebung:
Kameras, Mikrofone, Recording-Aktivität, Basis-Speicher,
Archivspeicher und Kapazitätswerte.
</p>
</div>
<button
type="button"
onClick={() => void loadData()}
disabled={isLoading}
className="inline-flex items-center justify-center gap-2 rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-semibold text-gray-700 shadow-sm hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/10 dark:bg-white/5 dark:text-gray-200 dark:hover:bg-white/10"
>
<ArrowPathIcon
aria-hidden="true"
className={classNames('size-4', isLoading && 'animate-spin')}
/>
Aktualisieren
</button>
</div>
</div>
</section>
{isLoading ? (
<section className="rounded-lg border border-gray-200 bg-white p-6 shadow-xs dark:border-white/10 dark:bg-white/5">
<div className="flex min-h-56 items-center justify-center">
<LoadingSpinner
size="lg"
label="Milestone-Daten werden geladen..."
className="text-indigo-600 dark:text-indigo-400"
/>
</div>
</section>
) : error ? (
<section className="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700 dark:border-red-500/20 dark:bg-red-500/10 dark:text-red-300">
<div className="flex items-start gap-2">
<ExclamationTriangleIcon aria-hidden="true" className="mt-0.5 size-5 shrink-0" />
<span>{error}</span>
</div>
</section>
) : (
<>
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard
title="Geräte gesamt"
value={stats.devicesTotal.toLocaleString('de-DE')}
detail={`${stats.cameras} Kameras · ${stats.microphones} Mikrofone`}
icon={CpuChipIcon}
/>
<StatCard
title="Aktiv"
value={stats.enabledDevices.toLocaleString('de-DE')}
detail={`${stats.disabledDevices} deaktiviert`}
icon={CheckCircleIcon}
/>
<StatCard
title="Recording aktiv"
value={stats.recordingEnabled.toLocaleString('de-DE')}
detail={`${stats.cameras} Kameras insgesamt`}
icon={VideoCameraIcon}
/>
<StatCard
title="Hardwaregruppen"
value={stats.hardwareCount.toLocaleString('de-DE')}
detail="aus Milestone geladen"
icon={ServerStackIcon}
/>
</section>
<section className="grid gap-4 lg:grid-cols-3">
<InfoPanel
title="Speicherstatus"
icon={ServerStackIcon}
rows={[
['Basis-Speicher', stats.basisStorages.toLocaleString('de-DE')],
['Archivspeicher', stats.archiveStorages.toLocaleString('de-DE')],
[
'Gemountet',
`${stats.mountedBasisStorages}/${stats.basisStorages} Basis · ${stats.mountedArchives}/${stats.archiveStorages} Archiv`,
],
['Standard', stats.defaultStorage ? storageTitle(stats.defaultStorage) : '—'],
]}
/>
<InfoPanel
title="Kapazität"
icon={ServerStackIcon}
rows={[
['Gesamtgröße', formatBytes(stats.totalCapacityBytes)],
['Belegt', formatBytes(stats.usedBytes)],
['Gesperrt', formatBytes(stats.lockedBytes)],
['Auslastung', formatPercent(stats.usedPercent)],
]}
/>
<InfoPanel
title="Signalquellen"
icon={MicrophoneIcon}
rows={[
['Kameras', stats.cameras.toLocaleString('de-DE')],
['Mikrofone', stats.microphones.toLocaleString('de-DE')],
['Recording an', stats.recordingEnabled.toLocaleString('de-DE')],
['Recording aus', Math.max(0, stats.cameras - stats.recordingEnabled).toLocaleString('de-DE')],
]}
/>
</section>
<section className="grid gap-4 lg:grid-cols-2">
{featureCards.map((card) => (
<Link
key={card.title}
to={card.href}
className="group rounded-xl border border-gray-200 bg-white p-5 shadow-xs transition hover:-translate-y-0.5 hover:border-indigo-200 hover:shadow-md dark:border-white/10 dark:bg-white/5 dark:hover:border-indigo-400/30"
>
<div className="flex items-start gap-4">
<div className="flex size-11 shrink-0 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600 dark:bg-indigo-500/10 dark:text-indigo-300">
<card.icon aria-hidden="true" className="size-5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-semibold text-gray-900 dark:text-white">
{card.title}
</h2>
<ArrowRightIcon
aria-hidden="true"
className="size-4 text-gray-400 transition group-hover:translate-x-0.5 group-hover:text-indigo-500"
/>
</div>
<p className="mt-2 text-sm leading-6 text-gray-500 dark:text-gray-400">
{card.description}
</p>
</div>
</div>
</Link>
))}
</section>
</>
)}
</div>
</div>
)
}
type StatCardProps = {
title: string
value: string
detail: string
icon: typeof ServerStackIcon
}
function StatCard({ title, value, detail, icon: Icon }: StatCardProps) {
return (
<div className="rounded-xl border border-gray-200 bg-white p-4 shadow-xs dark:border-white/10 dark:bg-white/5">
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">
{title}
</p>
<Icon aria-hidden="true" className="size-5 text-indigo-500" />
</div>
<p className="mt-3 text-2xl font-semibold tracking-tight text-gray-900 dark:text-white">
{value}
</p>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{detail}</p>
</div>
)
}
type InfoPanelProps = {
title: string
icon: typeof ServerStackIcon
rows: Array<[string, string]>
}
function InfoPanel({ title, icon: Icon, rows }: InfoPanelProps) {
return (
<div className="rounded-xl border border-gray-200 bg-white p-4 shadow-xs dark:border-white/10 dark:bg-white/5">
<div className="flex items-center gap-2">
<Icon aria-hidden="true" className="size-5 text-indigo-500" />
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
{title}
</h2>
</div>
<dl className="mt-4 space-y-3">
{rows.map(([label, value]) => (
<div key={label} className="flex items-start justify-between gap-4 text-sm">
<dt className="text-gray-500 dark:text-gray-400">{label}</dt>
<dd className="text-right font-medium text-gray-900 dark:text-white">
{value}
</dd>
</div>
))}
</dl>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -2593,7 +2593,8 @@ export default function MilestoneStoragePage({
<Tabs
tabs={createStorageTabs}
variant="underline"
variant="bar-underline"
fullWidth
ariaLabel="Speicherbereiche"
className="border-b border-gray-200 px-4 py-4 sm:px-6 dark:border-white/10"
/>

View File

@ -12,16 +12,20 @@ import {
import { DialogTitle } from '@headlessui/react'
import { XMarkIcon } from '@heroicons/react/24/outline'
import {
ArrowsRightLeftIcon,
CheckCircleIcon,
CheckIcon,
ChevronLeftIcon,
ChevronRightIcon,
CircleStackIcon,
ClipboardDocumentListIcon,
ComputerDesktopIcon,
DocumentTextIcon,
MapPinIcon,
PlusIcon,
VideoCameraIcon,
UserGroupIcon,
WifiIcon,
WrenchScrewdriverIcon,
} from '@heroicons/react/20/solid'
import Modal from '../../components/Modal'
@ -39,6 +43,7 @@ import ServerFolderPicker, {
joinServerFolderPath,
} from '../../components/ServerFolderPicker'
import { formatOperationNumberInput, isCompleteOperationNumber } from '../../components/formatter'
import type { Device } from '../../components/types'
import type { OperationCoordinates } from '../../utils/operationGeometry'
import OperationGeometryEditor from './OperationGeometryEditor'
@ -87,6 +92,115 @@ type OperationAssigneesResponse = {
officers?: OperationAssigneeUser[]
}
const equipmentCategories = {
cameras: 'Kameras',
routers: 'Router',
switchboxes: 'Switchbox',
laptops: 'Laptop',
hardDrives: 'Festplatte',
} as const
type QuickEquipmentDeviceKind = 'router' | 'switchbox' | 'laptop'
type QuickEquipmentDeviceDraft = {
kind: QuickEquipmentDeviceKind
inventoryNumber: string
ipAddress: string
phoneNumber: string
computerName: string
}
const quickEquipmentDeviceConfig: Record<
QuickEquipmentDeviceKind,
{
title: string
category: string
detailField: 'ipAddress' | 'phoneNumber' | 'computerName'
detailLabel: string
detailPlaceholder: string
formField: 'router' | 'switchbox' | 'laptop'
}
> = {
router: {
title: 'Router anlegen',
category: equipmentCategories.routers,
detailField: 'ipAddress',
detailLabel: 'IP-Adresse',
detailPlaceholder: '192.168.1.1',
formField: 'router',
},
switchbox: {
title: 'Switchbox anlegen',
category: equipmentCategories.switchboxes,
detailField: 'phoneNumber',
detailLabel: 'Rufnummer',
detailPlaceholder: '+49 ...',
formField: 'switchbox',
},
laptop: {
title: 'Laptop anlegen',
category: equipmentCategories.laptops,
detailField: 'computerName',
detailLabel: 'Computername',
detailPlaceholder: 'TEG-LAP-01',
formField: 'laptop',
},
}
function getDeviceDisplayName(device: Device) {
return (
device.inventoryNumber ||
device.milestoneDisplayName ||
[device.manufacturer, device.model].filter(Boolean).join(' ') ||
device.id
)
}
function deviceToComboboxItem(device: Device): ComboboxItem {
return {
id: device.id,
name: getDeviceDisplayName(device),
}
}
function cameraDeviceToComboboxItem(device: Device): ComboboxItem {
const number = (device.inventoryNumber || device.model || device.id).trim()
const displayName = device.milestoneDisplayName?.trim()
return {
id: device.id,
name: number,
secondaryText: displayName && displayName !== number ? displayName : undefined,
}
}
function hasDeviceCategory(device: Device, category: string) {
return (device.deviceCategory ?? '').trim().toLowerCase() === category.toLowerCase()
}
function hasAnyDeviceCategory(device: Device, categories: string[]) {
return categories.some((category) => hasDeviceCategory(device, category))
}
function formatEquipmentValue(
item: ComboboxItem | null,
detailLabel?: string,
detailValue?: string,
) {
const name = item?.name.trim() ?? ''
const detail = detailValue?.trim() ?? ''
if (!name) {
return ''
}
if (!detailLabel || !detail) {
return name
}
return `${name} (${detailLabel}: ${detail})`
}
function getOperationUserName(user: OperationAssigneeUser) {
return user.displayName || user.username || user.email
}
@ -225,7 +339,7 @@ function hasInvalidWindowsFolderNameCharacters(value: string) {
type SetupStepIcon = ComponentType<SVGProps<SVGSVGElement>>
const inputClassName =
'block w-full rounded-xl border-0 bg-gray-50 px-3.5 py-2 text-base text-gray-900 shadow-sm ring-1 ring-gray-200 ring-inset placeholder:text-gray-400 transition focus:bg-white focus:ring-2 focus:ring-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:ring-white/10 dark:placeholder:text-gray-500 dark:focus:bg-white/10 dark:focus:ring-indigo-400'
'block w-full rounded-md bg-white px-3.5 py-2 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 transition 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 readOnlyInputClassName =
'!cursor-not-allowed !bg-gray-50 !text-gray-500 !outline-gray-200 dark:!bg-white/10 dark:!text-gray-400 dark:!outline-white/10'
@ -445,26 +559,6 @@ function ReviewTeamItem({
)
}
function StepHeader({
title,
description,
}: {
title: string
description: string
}) {
return (
<div className="relative rounded-2xl border border-gray-200/80 bg-white p-5 pl-7 shadow-sm dark:border-white/10 dark:bg-gray-900/70">
<span className="absolute inset-y-5 left-4 w-1 rounded-full bg-indigo-500" />
<h3 className="text-base font-semibold text-gray-950 dark:text-white">
{title}
</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{description}
</p>
</div>
)
}
export default function CreateOperationModal({
open,
setOpen,
@ -488,6 +582,17 @@ export default function CreateOperationModal({
const [selectedOperationTeam, setSelectedOperationTeam] = useState<ComboboxItem[]>([])
const [isLoadingOperationAssignees, setIsLoadingOperationAssignees] = useState(false)
const [equipmentDevices, setEquipmentDevices] = useState<Device[]>([])
const [isLoadingDevices, setIsLoadingDevices] = useState(false)
const [selectedCameraDevice, setSelectedCameraDevice] = useState<ComboboxItem | null>(null)
const [selectedRouterDevice, setSelectedRouterDevice] = useState<ComboboxItem | null>(null)
const [selectedSwitchboxDevice, setSelectedSwitchboxDevice] = useState<ComboboxItem | null>(null)
const [selectedLaptopDevice, setSelectedLaptopDevice] = useState<ComboboxItem | null>(null)
const [selectedHardDriveDevice, setSelectedHardDriveDevice] = useState<ComboboxItem | null>(null)
const [quickEquipmentDeviceDraft, setQuickEquipmentDeviceDraft] = useState<QuickEquipmentDeviceDraft | null>(null)
const [quickEquipmentDeviceError, setQuickEquipmentDeviceError] = useState<string | null>(null)
const [isCreatingQuickEquipmentDevice, setIsCreatingQuickEquipmentDevice] = useState(false)
const [milestoneStorages, setMilestoneStorages] = useState<ModalMilestoneStorage[]>([])
const [selectedMilestoneStorage, setSelectedMilestoneStorage] = useState<ModalMilestoneStorage | null>(null)
const [isLoadingStorages, setIsLoadingStorages] = useState(false)
@ -501,6 +606,46 @@ export default function CreateOperationModal({
const isFirstStep = activeStepIndex === 0
const isLastStep = activeStepIndex === setupSteps.length - 1
const hardwareCameraOptions = useMemo<ComboboxItem[]>(
() =>
equipmentDevices
.filter((device) => hasDeviceCategory(device, equipmentCategories.cameras))
.map(cameraDeviceToComboboxItem),
[equipmentDevices],
)
const routerOptions = useMemo<ComboboxItem[]>(
() =>
equipmentDevices
.filter((device) => hasDeviceCategory(device, equipmentCategories.routers))
.map(deviceToComboboxItem),
[equipmentDevices],
)
const switchboxOptions = useMemo<ComboboxItem[]>(
() =>
equipmentDevices
.filter((device) => hasDeviceCategory(device, equipmentCategories.switchboxes))
.map(deviceToComboboxItem),
[equipmentDevices],
)
const laptopOptions = useMemo<ComboboxItem[]>(
() =>
equipmentDevices
.filter((device) => hasDeviceCategory(device, equipmentCategories.laptops))
.map(deviceToComboboxItem),
[equipmentDevices],
)
const hardDriveOptions = useMemo<ComboboxItem[]>(
() =>
equipmentDevices
.filter((device) => hasAnyDeviceCategory(device, [equipmentCategories.hardDrives, 'Festplatten']))
.map(deviceToComboboxItem),
[equipmentDevices],
)
const storageDisplayNameById = useMemo(() => {
const byId = new Map<string, string>()
@ -523,6 +668,14 @@ export default function CreateOperationModal({
setFormValues(initialFormValues)
setSelectedOperationLeader(null)
setSelectedOperationTeam([])
setSelectedCameraDevice(null)
setSelectedRouterDevice(null)
setSelectedSwitchboxDevice(null)
setSelectedLaptopDevice(null)
setSelectedHardDriveDevice(null)
setQuickEquipmentDeviceDraft(null)
setQuickEquipmentDeviceError(null)
setIsCreatingQuickEquipmentDevice(false)
setKwPosition(null)
setTargetPosition(null)
setKwArea(null)
@ -546,6 +699,7 @@ export default function CreateOperationModal({
const abortController = new AbortController()
void loadOperationAssignees(abortController.signal)
void loadEquipmentDevices(abortController.signal)
return () => {
abortController.abort()
@ -599,6 +753,38 @@ export default function CreateOperationModal({
}
}
async function loadEquipmentDevices(signal?: AbortSignal) {
setIsLoadingDevices(true)
try {
const response = await fetch(`${API_URL}/devices`, {
credentials: 'include',
signal,
})
if (!response.ok) {
throw new Error(
await readApiError(response, 'Geräte konnten nicht geladen werden'),
)
}
const data = (await response.json()) as { devices?: Device[] }
setEquipmentDevices(data.devices ?? [])
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
return
}
setLocalError(
error instanceof Error
? error.message
: 'Geräte konnten nicht geladen werden',
)
} finally {
setIsLoadingDevices(false)
}
}
async function loadMilestoneStorages(autoSelectName?: string) {
setIsLoadingStorages(true)
try {
@ -725,6 +911,200 @@ export default function CreateOperationModal({
)
}
function findEquipmentDevice(item: ComboboxItem | null) {
if (!item || item.id == null) {
return undefined
}
return equipmentDevices.find((device) => device.id === String(item.id))
}
function handleCameraDeviceChange(item: ComboboxItem | null) {
setSelectedCameraDevice(item)
updateField('camera', item?.name ?? '')
}
function selectEquipmentDevice(kind: QuickEquipmentDeviceKind, device: Device) {
const item = deviceToComboboxItem(device)
const config = quickEquipmentDeviceConfig[kind]
if (kind === 'router') {
setSelectedRouterDevice(item)
} else if (kind === 'switchbox') {
setSelectedSwitchboxDevice(item)
} else {
setSelectedLaptopDevice(item)
}
updateField(
config.formField,
formatEquipmentValue(item, config.detailLabel, device[config.detailField]),
)
}
function openQuickEquipmentDeviceModal(kind: QuickEquipmentDeviceKind, inventoryNumber: string) {
setQuickEquipmentDeviceDraft({
kind,
inventoryNumber,
ipAddress: '',
phoneNumber: '',
computerName: '',
})
setQuickEquipmentDeviceError(null)
}
function handleEquipmentDeviceChange(kind: QuickEquipmentDeviceKind, item: ComboboxItem | null) {
if (!item) {
if (kind === 'router') {
setSelectedRouterDevice(null)
} else if (kind === 'switchbox') {
setSelectedSwitchboxDevice(null)
} else {
setSelectedLaptopDevice(null)
}
updateField(quickEquipmentDeviceConfig[kind].formField, '')
return
}
if (item.id === null) {
openQuickEquipmentDeviceModal(kind, item.name)
return
}
const device = findEquipmentDevice(item)
if (device) {
selectEquipmentDevice(kind, device)
return
}
updateField(quickEquipmentDeviceConfig[kind].formField, item.name)
}
function handleRouterDeviceChange(item: ComboboxItem | null) {
handleEquipmentDeviceChange('router', item)
}
function handleSwitchboxDeviceChange(item: ComboboxItem | null) {
handleEquipmentDeviceChange('switchbox', item)
}
function handleLaptopDeviceChange(item: ComboboxItem | null) {
handleEquipmentDeviceChange('laptop', item)
}
function handleHardDriveDeviceChange(item: ComboboxItem | null) {
setSelectedHardDriveDevice(item)
updateField('hardDrive', item?.name ?? '')
}
function updateQuickEquipmentDeviceDraft(
field: 'inventoryNumber' | 'ipAddress' | 'phoneNumber' | 'computerName',
value: string,
) {
setQuickEquipmentDeviceDraft((currentDraft) =>
currentDraft
? {
...currentDraft,
[field]: value,
}
: currentDraft,
)
if (quickEquipmentDeviceError) {
setQuickEquipmentDeviceError(null)
}
}
function handleQuickEquipmentDeviceOpenChange(nextOpen: boolean) {
if (!nextOpen && isCreatingQuickEquipmentDevice) {
return
}
if (!nextOpen) {
setQuickEquipmentDeviceDraft(null)
setQuickEquipmentDeviceError(null)
}
}
async function handleCreateQuickEquipmentDevice(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!quickEquipmentDeviceDraft) {
return
}
const config = quickEquipmentDeviceConfig[quickEquipmentDeviceDraft.kind]
const inventoryNumber = quickEquipmentDeviceDraft.inventoryNumber.trim()
if (!inventoryNumber) {
setQuickEquipmentDeviceError('Bitte einen Namen oder eine Inventur-Nr. eintragen.')
return
}
setIsCreatingQuickEquipmentDevice(true)
setQuickEquipmentDeviceError(null)
try {
const response = await fetch(`${API_URL}/devices`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
inventoryNumber,
manufacturer: '',
model: '',
serialNumber: '',
macAddress: '',
ipAddress: quickEquipmentDeviceDraft.kind === 'router' ? quickEquipmentDeviceDraft.ipAddress : '',
phoneNumber: quickEquipmentDeviceDraft.kind === 'switchbox' ? quickEquipmentDeviceDraft.phoneNumber : '',
computerName: quickEquipmentDeviceDraft.kind === 'laptop' ? quickEquipmentDeviceDraft.computerName : '',
deviceCategory: config.category,
milestonePort: '',
firmwareVersion: '',
milestoneDisplayName: '',
location: '',
loanStatus: 'Verfügbar',
comment: '',
isBaoDevice: false,
relatedDeviceIds: [],
}),
})
if (!response.ok) {
throw new Error(
await readApiError(response, 'Gerät konnte nicht angelegt werden'),
)
}
const data = (await response.json()) as { device?: Device }
const createdDevice = data.device
if (!createdDevice) {
throw new Error('Gerät konnte nicht angelegt werden')
}
setEquipmentDevices((currentDevices) => [
createdDevice,
...currentDevices.filter((device) => device.id !== createdDevice.id),
])
selectEquipmentDevice(quickEquipmentDeviceDraft.kind, createdDevice)
setQuickEquipmentDeviceDraft(null)
setQuickEquipmentDeviceError(null)
} catch (error) {
setQuickEquipmentDeviceError(
error instanceof Error
? error.message
: 'Gerät konnte nicht angelegt werden',
)
} finally {
setIsCreatingQuickEquipmentDevice(false)
}
}
function handleOpenChange(nextOpen: boolean) {
if (!nextOpen && isSaving) {
return
@ -818,11 +1198,12 @@ export default function CreateOperationModal({
const combinedError = localError || error
return (
<>
<Modal
open={open}
setOpen={handleOpenChange}
zIndexClassName="z-[9999]"
panelClassName="max-h-[calc(100dvh-2rem)] max-w-4xl rounded-3xl bg-white ring-1 ring-black/5 dark:bg-gray-900 dark:ring-white/10"
panelClassName="max-h-[calc(100dvh-2rem)] max-w-4xl bg-white dark:bg-gray-900"
>
<form
onSubmit={handleSubmit}
@ -852,21 +1233,15 @@ export default function CreateOperationModal({
<input type="hidden" name="remark" value={formValues.remark} />
<input type="hidden" name="milestoneStorage" value={selectedMilestoneStorage ? (selectedMilestoneStorage.displayName || selectedMilestoneStorage.name) : ''} />
<div className="relative flex items-start justify-between overflow-hidden border-b border-indigo-100 bg-gradient-to-r from-indigo-50 via-white to-violet-50 px-4 py-5 sm:px-6 dark:border-white/10 dark:from-indigo-950/50 dark:via-gray-900 dark:to-violet-950/30">
<div className="flex min-w-0 items-start gap-3.5">
<div className="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-indigo-600 text-white shadow-lg shadow-indigo-600/20 dark:bg-indigo-500">
<ClipboardDocumentListIcon aria-hidden="true" className="size-5" />
</div>
<div className="min-w-0">
<DialogTitle className="text-base font-semibold text-gray-950 dark:text-white">
<div className="flex items-start justify-between border-b border-gray-200 bg-gray-100 px-4 py-4 sm:px-6 dark:border-white/10 dark:bg-gray-800">
<div>
<DialogTitle className="text-base font-semibold text-gray-900 dark:text-white">
Einsatz anlegen
</DialogTitle>
</DialogTitle>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Folge den Schritten oder überspringe optionale Bereiche.
</p>
</div>
</p>
</div>
<Button
@ -882,7 +1257,7 @@ export default function CreateOperationModal({
</Button>
</div>
<div className="border-b border-gray-200 bg-white/90 px-4 py-3 sm:px-6 dark:border-white/10 dark:bg-gray-900/90">
<div className="border-b border-gray-200 bg-white px-4 py-3 sm:px-6 dark:border-white/10 dark:bg-gray-900">
<nav aria-label="Einrichtungsschritte" className="overflow-x-auto pb-1">
<div className="grid min-w-[52rem] grid-cols-7 gap-2">
{setupSteps.map((step, index) => {
@ -920,19 +1295,14 @@ export default function CreateOperationModal({
</div>
{combinedError && (
<div className="mx-4 mt-4 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700 sm:mx-6 dark:border-red-500/20 dark:bg-red-950/30 dark:text-red-300">
<div className="mx-4 mt-4 rounded-md bg-red-50 p-4 text-sm text-red-700 sm:mx-6 dark:bg-red-900/30 dark:text-red-300">
{combinedError}
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto bg-gray-50/70 px-4 py-6 sm:px-6 dark:bg-gray-950/40">
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-6 sm:px-6">
{activeStep === 'masterData' && (
<div className="space-y-6">
<StepHeader
title="Stammdaten"
description="Die Einsatznummer ist erforderlich. Alles Weitere kannst du direkt oder später ergänzen."
/>
<div className="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-6">
<div className="sm:col-span-3">
<label htmlFor="createOperationNumber" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
@ -988,11 +1358,6 @@ export default function CreateOperationModal({
{activeStep === 'addresses' && (
<div className="space-y-6">
<StepHeader
title="Adressen"
description="KW und Zielobjekt werden mit AddressCombobox und Karten-Vorschau erfasst."
/>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<AddressCombobox
id="createKwAddress"
@ -1039,11 +1404,6 @@ export default function CreateOperationModal({
{activeStep === 'responsibilities' && (
<div className="space-y-6">
<StepHeader
title="Zuständigkeiten"
description="Lege fest, wer den Einsatz bearbeitet, leitet und im Einsatzteam mitwirkt."
/>
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/[0.03]">
@ -1094,14 +1454,14 @@ export default function CreateOperationModal({
</div>
</div>
<div className="rounded-lg border border-indigo-200 bg-indigo-50/60 p-4 dark:border-indigo-500/20 dark:bg-indigo-500/10">
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/[0.03]">
<div className="mb-3 flex items-start justify-between gap-x-4">
<div>
<h4 className="text-sm font-semibold text-indigo-900 dark:text-indigo-200">
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
Einsatzteam
</h4>
<p className="mt-1 text-xs text-indigo-700/80 dark:text-indigo-300/80">
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Wähle einen oder mehrere Einsatzbeamte der {OPERATION_UNIT}.
</p>
</div>
@ -1132,11 +1492,6 @@ 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."
/>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Textarea
id="createLegend"
@ -1159,82 +1514,99 @@ export default function CreateOperationModal({
{activeStep === 'equipment' && (
<div className="space-y-6">
<StepHeader
title="Technik"
description="Erfasse vorgesehene oder eingesetzte technische Ausstattung."
/>
<div className="grid grid-cols-1 gap-x-4 gap-y-4 lg:grid-cols-2">
<Combobox
label="Kamera"
labelIcon={VideoCameraIcon}
items={hardwareCameraOptions}
value={selectedCameraDevice}
onChange={handleCameraDeviceChange}
variant="secondary"
placeholder={
isLoadingDevices
? 'Geräte werden geladen...'
: 'Hardware-Kamera auswählen'
}
emptyText="Keine Hardware-Kamera gefunden."
disabled={isLoadingDevices}
/>
<div className="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-2 xl:grid-cols-3">
<div>
<label htmlFor="createCamera" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Kamera
</label>
<div className="mt-2">
<input
id="createCamera"
type="text"
value={formValues.camera}
onChange={(event) => updateField('camera', event.target.value)}
className={inputClassName}
/>
</div>
</div>
<Combobox
label="Router"
labelIcon={WifiIcon}
items={routerOptions}
value={selectedRouterDevice}
onChange={handleRouterDeviceChange}
variant="secondary"
allowCustomValue
createOptionLabel={(value) => `"${value}" als Router anlegen`}
placeholder={
isLoadingDevices
? 'Geräte werden geladen...'
: 'Router auswählen oder anlegen'
}
emptyText="Kein Router gefunden."
disabled={isLoadingDevices}
/>
<div>
<label htmlFor="createRouter" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Router
</label>
<div className="mt-2">
<input
id="createRouter"
type="text"
value={formValues.router}
onChange={(event) => updateField('router', event.target.value)}
className={inputClassName}
/>
</div>
</div>
<Combobox
label="Switchbox"
labelIcon={ArrowsRightLeftIcon}
items={switchboxOptions}
value={selectedSwitchboxDevice}
onChange={handleSwitchboxDeviceChange}
variant="secondary"
allowCustomValue
createOptionLabel={(value) => `"${value}" als Switchbox anlegen`}
placeholder={
isLoadingDevices
? 'Geräte werden geladen...'
: 'Switchbox auswählen oder anlegen'
}
emptyText="Keine Switchbox gefunden."
disabled={isLoadingDevices}
/>
<div>
<label htmlFor="createSwitchbox" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Switchbox
</label>
<div className="mt-2">
<input
id="createSwitchbox"
type="text"
value={formValues.switchbox}
onChange={(event) => updateField('switchbox', event.target.value)}
className={inputClassName}
/>
</div>
</div>
<Combobox
label="Laptop"
labelIcon={ComputerDesktopIcon}
items={laptopOptions}
value={selectedLaptopDevice}
onChange={handleLaptopDeviceChange}
variant="secondary"
allowCustomValue
createOptionLabel={(value) => `"${value}" als Laptop anlegen`}
placeholder={
isLoadingDevices
? 'Geräte werden geladen...'
: 'Laptop auswählen oder anlegen'
}
emptyText="Kein Laptop gefunden."
disabled={isLoadingDevices}
/>
<div>
<label htmlFor="createLaptop" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Laptop
</label>
<div className="mt-2">
<input
id="createLaptop"
type="text"
value={formValues.laptop}
onChange={(event) => updateField('laptop', event.target.value)}
className={inputClassName}
/>
</div>
</div>
</div>
<Combobox
label="Festplatte"
labelIcon={CircleStackIcon}
items={hardDriveOptions}
value={selectedHardDriveDevice}
onChange={handleHardDriveDeviceChange}
variant="secondary"
allowCustomValue
placeholder={
isLoadingDevices
? 'Geräte werden geladen...'
: 'Festplatte auswählen oder eingeben'
}
emptyText="Keine Festplatte gefunden."
disabled={isLoadingDevices}
/>
</div>
</div>
)}
{activeStep === 'storage' && (
<div className="space-y-4">
<StepHeader
title="Milestone-Speicher"
description="Wähle einen bestehenden Speicher oder lege einen neuen an."
/>
{isLoadingStorages && (
<div className="flex items-center justify-center py-10">
<LoadingSpinner size="md" label="Speicher werden geladen…" center />
@ -1337,6 +1709,7 @@ export default function CreateOperationModal({
{ name: 'Basis-Speicher', href: '#', current: createStorageTab === 'basis', count: 1, onClick: () => goToCreateStorageTab('basis') },
{ name: 'Archivspeicher', href: '#', current: createStorageTab === 'archive', count: 2, onClick: () => goToCreateStorageTab('archive') },
]}
fullWidth
variant="underline"
ariaLabel="Speicherbereiche"
className="border-b border-gray-200 px-4 dark:border-white/10"
@ -1531,11 +1904,6 @@ export default function CreateOperationModal({
{activeStep === 'review' && (
<div className="space-y-6">
<StepHeader
title="Einsatz prüfen"
description="Kontrolliere die Angaben. Danach kannst du den Einsatz speichern."
/>
<dl className="space-y-6 rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/5">
<div>
<div className="border-b border-gray-200 pb-2 dark:border-white/10">
@ -1575,6 +1943,9 @@ export default function CreateOperationModal({
fov={viewConeFov}
length={viewConeLength}
visible={activeStep === 'review'}
readOnly
kwLabel={formValues.kwAddress}
targetLabel={formValues.targetObject}
onKwPositionChange={setKwPosition}
onTargetPositionChange={setTargetPosition}
onFovChange={setViewConeFov}
@ -1614,11 +1985,12 @@ export default function CreateOperationModal({
</h4>
</div>
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-5">
<ReviewItem label="Kamera" value={formValues.camera} />
<ReviewItem label="Router" value={formValues.router} />
<ReviewItem label="Switchbox" value={formValues.switchbox} />
<ReviewItem label="Laptop" value={formValues.laptop} />
<ReviewItem label="Festplatte" value={formValues.hardDrive} />
</div>
</div>
@ -1651,7 +2023,7 @@ export default function CreateOperationModal({
)}
</div>
<div className="flex flex-col gap-3 border-t border-gray-200 bg-white/90 px-4 py-4 backdrop-blur sm:flex-row sm:items-center sm:justify-between sm:px-6 dark:border-white/10 dark:bg-gray-900/90">
<div className="flex flex-col gap-3 border-t border-gray-200 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-6 dark:border-white/10">
<div className="min-w-36">
<div className="flex items-center justify-between gap-3 text-xs font-medium text-gray-500 dark:text-gray-400">
<span>Schritt {activeStepIndex + 1} von {setupSteps.length}</span>
@ -1726,5 +2098,99 @@ export default function CreateOperationModal({
</div>
</form>
</Modal>
<Modal
open={quickEquipmentDeviceDraft !== null}
setOpen={handleQuickEquipmentDeviceOpenChange}
zIndexClassName="z-[10000]"
panelClassName="max-w-lg bg-white dark:bg-gray-900"
>
{quickEquipmentDeviceDraft && (
<form onSubmit={handleCreateQuickEquipmentDevice}>
<div className="border-b border-gray-200 px-4 py-4 sm:px-6 dark:border-white/10">
<DialogTitle className="text-base font-semibold text-gray-900 dark:text-white">
{quickEquipmentDeviceConfig[quickEquipmentDeviceDraft.kind].title}
</DialogTitle>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Das Gerät wird direkt in der passenden Kategorie angelegt und anschließend ausgewählt.
</p>
</div>
<div className="space-y-4 px-4 py-5 sm:px-6">
{quickEquipmentDeviceError && (
<div className="rounded-md bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
{quickEquipmentDeviceError}
</div>
)}
<div>
<label htmlFor="quickEquipmentInventoryNumber" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Name / Inventur-Nr.
</label>
<div className="mt-2">
<input
id="quickEquipmentInventoryNumber"
type="text"
value={quickEquipmentDeviceDraft.inventoryNumber}
onChange={(event) => updateQuickEquipmentDeviceDraft('inventoryNumber', event.target.value)}
className={inputClassName}
autoFocus
/>
</div>
</div>
<div>
<label htmlFor="quickEquipmentDetail" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
{quickEquipmentDeviceConfig[quickEquipmentDeviceDraft.kind].detailLabel}
</label>
<div className="mt-2">
<input
id="quickEquipmentDetail"
type="text"
inputMode={quickEquipmentDeviceDraft.kind === 'switchbox' ? 'tel' : undefined}
value={
quickEquipmentDeviceDraft[
quickEquipmentDeviceConfig[quickEquipmentDeviceDraft.kind].detailField
]
}
onChange={(event) =>
updateQuickEquipmentDeviceDraft(
quickEquipmentDeviceConfig[quickEquipmentDeviceDraft.kind].detailField,
event.target.value,
)
}
placeholder={quickEquipmentDeviceConfig[quickEquipmentDeviceDraft.kind].detailPlaceholder}
className={inputClassName}
/>
</div>
</div>
</div>
<div className="flex justify-end gap-3 border-t border-gray-200 bg-gray-50 px-4 py-4 sm:px-6 dark:border-white/10 dark:bg-white/[0.03]">
<Button
type="button"
variant="secondary"
color="gray"
disabled={isCreatingQuickEquipmentDevice}
onClick={() => handleQuickEquipmentDeviceOpenChange(false)}
>
Abbrechen
</Button>
<Button
type="submit"
color="indigo"
isLoading={isCreatingQuickEquipmentDevice}
leadingIcon={<PlusIcon aria-hidden="true" className="size-4" />}
>
Gerät anlegen
</Button>
</div>
</form>
)}
</Modal>
</>
)
}

View File

@ -42,6 +42,9 @@ type OperationGeometryEditorProps = {
fov: number
length: number
visible?: boolean
readOnly?: boolean
kwLabel?: string
targetLabel?: string
onKwPositionChange: (position: OperationCoordinates) => void
onTargetPositionChange: (position: OperationCoordinates) => void
onFovChange: (value: number) => void
@ -461,17 +464,25 @@ function DraggableOperationMarker({
type,
position,
area,
readOnly = false,
label,
onChange,
}: {
type: MarkerType
position: OperationCoordinates
area: AddressArea | null
readOnly?: boolean
label: string
onChange: (type: MarkerType, position: OperationCoordinates) => void
}) {
const markerRef = useRef<L.Marker | null>(null)
const lastValidPositionRef = useRef(position)
useEffect(() => {
if (readOnly) {
return
}
const constrainedPosition = getInitialPositionInsideAddressArea(
position,
area,
@ -489,7 +500,7 @@ function DraggableOperationMarker({
) {
onChange(type, constrainedPosition)
}
}, [area, onChange, position, type])
}, [area, onChange, position, type, readOnly])
const eventHandlers = useMemo(() => {
function handleDrag() {
@ -521,6 +532,7 @@ function DraggableOperationMarker({
}
}, [area, onChange, type])
const canDrag =
!readOnly &&
area !== null &&
(!area.hasHouseNumber || getAreaPolygons(area).length > 0)
@ -528,13 +540,13 @@ function DraggableOperationMarker({
<Marker
ref={markerRef}
draggable={canDrag}
autoPan
autoPan={canDrag}
position={[position.lat, position.lon]}
icon={type === 'kw' ? kwIcon : targetIcon}
eventHandlers={eventHandlers}
>
<Tooltip permanent direction="top">
{type === 'kw' ? 'KW' : 'Zielobjekt'}
{label}
</Tooltip>
</Marker>
)
@ -609,11 +621,16 @@ export default function OperationGeometryEditor({
fov,
length,
visible = true,
readOnly = false,
kwLabel,
targetLabel,
onKwPositionChange,
onTargetPositionChange,
onFovChange,
onLengthChange,
}: OperationGeometryEditorProps) {
const kwTooltip = kwLabel?.trim() ? kwLabel.trim() : 'KW'
const targetTooltip = targetLabel?.trim() ? targetLabel.trim() : 'Zielobjekt'
const validKwPosition = isValidOperationCoordinates(kwPosition)
? kwPosition
: null
@ -723,8 +740,9 @@ export default function OperationGeometryEditor({
Position und Sichtwinkel
</h4>
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
KW und ZO innerhalb ihrer markierten Adressfläche ziehen. Die
Eckgriffe ändern das FOV, der mittlere Griff die Länge.
{readOnly
? 'Vorschau der Positionen von KW und ZO sowie des Sichtwinkels.'
: 'KW und ZO innerhalb ihrer markierten Adressfläche ziehen. Die Eckgriffe ändern das FOV, der mittlere Griff die Länge.'}
</p>
</div>
@ -732,8 +750,14 @@ export default function OperationGeometryEditor({
<MapContainer
center={[center.lat, center.lon]}
zoom={validKwPosition || validTargetPosition ? 18 : 6}
scrollWheelZoom
className="h-full w-full"
scrollWheelZoom={!readOnly}
dragging={!readOnly}
doubleClickZoom={!readOnly}
touchZoom={!readOnly}
boxZoom={!readOnly}
keyboard={!readOnly}
zoomControl={!readOnly}
className={`h-full w-full ${readOnly ? 'cursor-default' : ''}`}
>
<TileLayer
attribution="© OpenStreetMap"
@ -790,6 +814,8 @@ export default function OperationGeometryEditor({
type="kw"
position={validKwPosition}
area={kwArea}
readOnly={readOnly}
label={kwTooltip}
onChange={handlePositionChange}
/>
)}
@ -798,11 +824,13 @@ export default function OperationGeometryEditor({
type="target"
position={validTargetPosition}
area={targetArea}
readOnly={readOnly}
label={targetTooltip}
onChange={handlePositionChange}
/>
)}
{geometry && leftCorner && (
{!readOnly && geometry && leftCorner && (
<ConeControlMarker
position={leftCorner}
icon={fovHandleIcon}
@ -812,7 +840,7 @@ export default function OperationGeometryEditor({
}
/>
)}
{geometry && rightCorner && (
{!readOnly && geometry && rightCorner && (
<ConeControlMarker
position={rightCorner}
icon={fovHandleIcon}
@ -822,7 +850,7 @@ export default function OperationGeometryEditor({
}
/>
)}
{geometry && centerEndpoint && (
{!readOnly && geometry && centerEndpoint && (
<ConeControlMarker
position={centerEndpoint}
icon={lengthHandleIcon}
@ -847,7 +875,7 @@ export default function OperationGeometryEditor({
</strong>
</span>
<span>
KWZO:{' '}
Distanz KWZO:{' '}
<strong className="font-semibold text-gray-800 dark:text-gray-200">
{geometry ? formatDistance(geometry.targetDistance) : ''}
</strong>

View File

@ -1,7 +1,7 @@
// frontend\src\pages\operations\OperationMap.tsx
import { useEffect, useMemo, useState } from 'react'
import { Polygon, Polyline } from 'react-leaflet'
import { Polygon, Polyline, useMap } from 'react-leaflet'
import * as L from 'leaflet'
import { PencilSquareIcon } from '@heroicons/react/20/solid'
import AppMap, { type MapHeatPoint, type MapMarker } from '../../components/Map'
@ -53,6 +53,7 @@ type OperationMapProps = {
canEdit?: boolean
className?: string
mode?: 'markers' | 'heatmap'
focusOperationId?: string | null
}
function classNames(...classes: Array<string | false | null | undefined>) {
@ -380,6 +381,51 @@ function getOperationViewCones(markers: OperationMarker[]) {
return cones
}
function FocusOperationBounds({
markers,
focusOperationId,
}: {
markers: OperationMarker[]
focusOperationId: string
}) {
const map = useMap()
const boundsKey = markers
.map((marker) => `${marker.id}:${marker.lat}:${marker.lon}`)
.join('|')
useEffect(() => {
if (markers.length === 0) {
return
}
const frame = window.requestAnimationFrame(() => {
map.invalidateSize({ pan: false })
if (markers.length === 1) {
const [marker] = markers
map.setView([marker.lat, marker.lon], 17, {
animate: true,
})
return
}
map.fitBounds(
markers.map((marker) => [marker.lat, marker.lon]),
{
animate: true,
maxZoom: 18,
padding: [96, 96],
},
)
})
return () => window.cancelAnimationFrame(frame)
}, [boundsKey, focusOperationId, map, markers])
return null
}
export default function OperationMap({
operations,
isLoading,
@ -387,6 +433,7 @@ export default function OperationMap({
canEdit = false,
mode = 'markers',
className,
focusOperationId,
}: OperationMapProps) {
const [markers, setMarkers] = useState<OperationMarker[]>([])
const [isGeocoding, setIsGeocoding] = useState(false)
@ -515,6 +562,15 @@ export default function OperationMap({
}
}, [isLoading, operationsWithAddresses])
const focusedOperationMarkers = useMemo(
() =>
mode === 'markers' && focusOperationId
? markers.filter((marker) => marker.operation.id === focusOperationId)
: [],
[focusOperationId, markers, mode],
)
const hasFocusedOperationMarkers = focusedOperationMarkers.length > 0
if (isLoading || isGeocoding) {
return (
<div
@ -615,9 +671,16 @@ export default function OperationMap({
showCurrentLocationMarker
showLocateControl
locateZoom={17}
fitBounds
fitBounds={!hasFocusedOperationMarkers}
className="h-full min-h-[34rem]"
>
{hasFocusedOperationMarkers && focusOperationId && (
<FocusOperationBounds
markers={focusedOperationMarkers}
focusOperationId={focusOperationId}
/>
)}
{visibleViewCones.map((cone) => (
<Polygon
key={cone.id}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,546 +0,0 @@
// frontend\src\pages\operations\milestone-devices\MilestoneDevicesPage.tsx
import { useEffect, useMemo, useState, type ComponentProps } from "react";
import {
ChevronRightIcon,
CircleStackIcon,
MicrophoneIcon,
VideoCameraIcon,
} from "@heroicons/react/24/outline";
import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
import LoadingSpinner from "../../../components/LoadingSpinner";
import EmptyState from "../../../components/EmptyState";
import ChildDeviceCard from "../../devices/ChildDeviceCard";
import { useNotifications } from "../../../components/Notifications";
const API_URL = import.meta.env.VITE_API_URL ?? "http://localhost:8080";
const NO_STORAGE_KEY = "__none__";
function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(" ");
}
type MilestoneCameraListItem = {
id: string;
name: string;
displayName: string;
type: "camera" | "microphone";
enabled: boolean;
recordingEnabled: boolean;
recordingStorageId: string;
hardwareId: string;
hardwareDisplayName?: string;
};
type StorageSimple = {
id: string;
name: string;
displayName: string;
};
type DeviceGroup = {
id: string;
name: string;
rows: MilestoneCameraListItem[];
};
type Props = {
canWrite?: boolean;
};
export default function MilestoneDevicesPage({ canWrite }: Props) {
const { showToast } = useNotifications();
const [devices, setDevices] = useState<MilestoneCameraListItem[]>([]);
const [storageMap, setStorageMap] = useState<Record<string, StorageSimple>>({});
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
useEffect(() => {
void Promise.all([loadDevices(), loadStorages()]);
}, []);
async function loadDevices() {
setIsLoading(true);
setError(null);
try {
const response = await fetch(`${API_URL}/admin/milestone/cameras`, {
credentials: "include",
});
if (!response.ok) {
const data = (await response.json().catch(() => null)) as {
error?: string;
} | null;
setError(data?.error ?? "Geräte konnten nicht geladen werden");
return;
}
const data = (await response.json()) as {
devices: MilestoneCameraListItem[];
};
setDevices(data.devices ?? []);
} catch {
setError("Verbindung zum Server fehlgeschlagen");
} finally {
setIsLoading(false);
}
}
async function loadStorages() {
try {
const response = await fetch(`${API_URL}/admin/milestone/storages`, {
credentials: "include",
});
if (!response.ok) return;
const data = (await response.json()) as {
storages?: { id: string; name: string; displayName: string }[];
};
const map: Record<string, StorageSimple> = {};
for (const s of data.storages ?? []) {
map[s.id] = { id: s.id, name: s.name, displayName: s.displayName };
}
setStorageMap(map);
} catch {
// silent storage names are cosmetic
}
}
async function handleToggle(
device: MilestoneCameraListItem,
field: "enabled" | "recordingEnabled",
nextValue?: boolean,
) {
const newValue =
typeof nextValue === "boolean" ? nextValue : !device[field];
setDevices((current) =>
current.map((d) =>
d.id === device.id ? { ...d, [field]: newValue } : d,
),
);
const collection = device.type === "camera" ? "cameras" : "microphones";
try {
const response = await fetch(
`${API_URL}/admin/milestone/devices/${collection}/${device.id}`,
{
method: "PATCH",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ [field]: newValue }),
},
);
if (!response.ok) {
const data = (await response.json().catch(() => null)) as {
error?: string;
} | null;
setDevices((current) =>
current.map((d) =>
d.id === device.id ? { ...d, [field]: !newValue } : d,
),
);
showToast({
variant: "error",
title: "Gerät konnte nicht aktualisiert werden",
message: data?.error ?? undefined,
});
}
} catch {
setDevices((current) =>
current.map((d) =>
d.id === device.id ? { ...d, [field]: !newValue } : d,
),
);
showToast({
variant: "error",
title: "Verbindung fehlgeschlagen",
});
}
}
const searchLower = search.trim().toLowerCase();
const filtered = useMemo(
() =>
searchLower
? devices.filter(
(d) =>
d.name.toLowerCase().includes(searchLower) ||
d.displayName.toLowerCase().includes(searchLower),
)
: devices,
[devices, searchLower],
);
const cameraCount = filtered.filter((d) => d.type === "camera").length;
const micCount = filtered.filter((d) => d.type === "microphone").length;
const groups = useMemo<DeviceGroup[]>(() => {
const byStorage = new Map<string, MilestoneCameraListItem[]>();
for (const device of filtered) {
const key = device.recordingStorageId || NO_STORAGE_KEY;
const list = byStorage.get(key) ?? [];
list.push(device);
byStorage.set(key, list);
}
const sortRows = (rows: MilestoneCameraListItem[]) =>
[...rows].sort((a, b) => {
if (a.type !== b.type) {
return a.type === "camera" ? -1 : 1;
}
return a.name.localeCompare(b.name, "de");
});
const named: DeviceGroup[] = [];
for (const [key, rows] of byStorage) {
if (key === NO_STORAGE_KEY) {
continue;
}
const storage = storageMap[key];
named.push({
id: key,
name: storage ? storage.displayName || storage.name : key,
rows: sortRows(rows),
});
}
named.sort((a, b) => a.name.localeCompare(b.name, "de"));
const withoutStorage = byStorage.get(NO_STORAGE_KEY);
if (withoutStorage && withoutStorage.length > 0) {
named.push({
id: NO_STORAGE_KEY,
name: "Ohne Speicher",
rows: sortRows(withoutStorage),
});
}
return named;
}, [filtered, storageMap]);
return (
<div className="flex h-full min-h-0 flex-col">
<div className="shrink-0 border-b border-gray-200 px-4 py-4 sm:px-6 lg:px-8 dark:border-white/10">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">
Milestone-Geräte
</h1>
{!isLoading && !error && (
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{cameraCount} Kamera{cameraCount !== 1 ? "s" : ""},{" "}
{micCount} Mikrofon{micCount !== 1 ? "e" : ""} in {groups.length}{" "}
Speicher{groups.length !== 1 ? "n" : ""}
</p>
)}
</div>
<div className="relative w-full max-w-sm">
<MagnifyingGlassIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-gray-400" />
<input
type="search"
placeholder="Suchen…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full rounded-lg border border-gray-300 bg-white py-2 pr-3 pl-9 text-sm text-gray-900 placeholder-gray-400 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 focus:outline-none dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500"
/>
</div>
</div>
</div>
<div className="min-h-0 flex-1 space-y-8 overflow-y-auto p-4 sm:p-6 lg:p-8">
{isLoading && (
<div className="flex justify-center py-16">
<LoadingSpinner size="lg" label="Geräte werden geladen…" center />
</div>
)}
{!isLoading && error && (
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400">
{error}
</div>
)}
{!isLoading && !error && filtered.length === 0 && (
<EmptyState
icon={VideoCameraIcon}
title={search ? "Keine Treffer" : "Keine Geräte gefunden"}
description={
search
? "Versuche einen anderen Suchbegriff."
: "Es wurden keine Kameras oder Mikrofone in Milestone gefunden."
}
className="py-16"
/>
)}
{!isLoading &&
!error &&
filtered.length > 0 &&
groups.map((group) => (
<StorageGroup
key={group.id}
group={group}
canWrite={canWrite}
onToggle={handleToggle}
/>
))}
</div>
</div>
);
}
type StorageGroupProps = {
group: DeviceGroup;
canWrite?: boolean;
onToggle: (
device: MilestoneCameraListItem,
field: "enabled" | "recordingEnabled",
nextValue?: boolean,
) => void | Promise<void>;
};
type ChildDeviceItem = ComponentProps<typeof ChildDeviceCard>["item"];
function toChildDeviceItem(device: MilestoneCameraListItem): ChildDeviceItem {
return {
id: device.id,
milestoneDeviceId: device.id,
deviceType: device.type,
enabled: device.enabled,
recordingEnabled: device.recordingEnabled,
displayName: device.displayName,
name: device.name,
shortName: device.hardwareId,
} as ChildDeviceItem;
}
type HardwareGroup = {
key: string
title: string
subtitle?: string
rows: MilestoneCameraListItem[]
}
function groupRowsByHardware(rows: MilestoneCameraListItem[]): HardwareGroup[] {
const map = new Map<string, MilestoneCameraListItem[]>()
for (const row of rows) {
const key = row.hardwareId?.trim() || "__no_hardware__"
const list = map.get(key) ?? []
list.push(row)
map.set(key, list)
}
return [...map.entries()]
.map(([key, items]) => {
const hardwareDisplayName =
items.find((item) => item.hardwareDisplayName?.trim())?.hardwareDisplayName?.trim() || ""
return {
key,
title:
key === "__no_hardware__"
? "Ohne Hardware"
: hardwareDisplayName || key,
subtitle:
key === "__no_hardware__" || hardwareDisplayName === "" || hardwareDisplayName === key
? undefined
: key,
rows: items,
}
})
.sort((a, b) => a.title.localeCompare(b.title, "de"))
}
function StorageGroup({ group, canWrite, onToggle }: StorageGroupProps) {
const cameraRows = group.rows.filter((d) => d.type === "camera")
const microphoneRows = group.rows.filter((d) => d.type === "microphone")
const cameras = cameraRows.length
const microphones = microphoneRows.length
const cameraGroups = groupRowsByHardware(cameraRows)
const microphoneGroups = groupRowsByHardware(microphoneRows)
const [collapsed, setCollapsed] = useState(false)
return (
<section className="rounded-lg border border-gray-200 bg-gray-50/60 p-4 dark:border-white/10 dark:bg-white/5">
<button
type="button"
aria-expanded={!collapsed}
onClick={() => setCollapsed((value) => !value)}
className={classNames(
"flex w-full flex-wrap items-center justify-between gap-x-3 gap-y-2 text-left",
!collapsed && "mb-4 border-b border-gray-200 pb-3 dark:border-white/10",
)}
>
<div className="flex items-center gap-2">
<ChevronRightIcon
aria-hidden="true"
className={classNames(
"size-4 shrink-0 text-gray-400 transition-transform",
!collapsed && "rotate-90",
)}
/>
<CircleStackIcon className="size-5 shrink-0 text-gray-400" />
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
{group.name}
</h2>
<span className="rounded-full bg-white px-2 py-0.5 text-xs font-medium text-gray-600 ring-1 ring-gray-200 dark:bg-white/10 dark:text-gray-300 dark:ring-white/10">
{group.rows.length}
</span>
</div>
<span className="text-xs text-gray-400 dark:text-gray-500">
{cameras} Kamera{cameras !== 1 ? "s" : ""} · {microphones} Mikrofon
{microphones !== 1 ? "e" : ""}
</span>
</button>
{collapsed ? null : group.rows.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-400">
Keine Geräte in dieser Gruppe.
</p>
) : (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="min-w-0 rounded-lg border border-gray-200 bg-white/70 p-3 dark:border-white/10 dark:bg-white/5">
<div className="mb-3 flex items-center justify-between gap-2 border-b border-gray-200 pb-2 dark:border-white/10">
<div className="flex items-center gap-2">
<VideoCameraIcon className="size-4 text-indigo-600 dark:text-indigo-400" />
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
Kameras
</h3>
</div>
<span className="rounded-full bg-gray-50 px-2 py-0.5 text-xs font-medium text-gray-600 ring-1 ring-gray-200 dark:bg-white/10 dark:text-gray-300 dark:ring-white/10">
{cameraRows.length}
</span>
</div>
{cameraGroups.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-400">
Keine Kameras in dieser Gruppe.
</p>
) : (
<div className="space-y-4">
{cameraGroups.map((hardwareGroup) => (
<section
key={hardwareGroup.key}
className="rounded-lg border border-gray-200 bg-gray-50/70 p-3 dark:border-white/10 dark:bg-white/5"
>
<div className="mb-3 flex items-center justify-between gap-2 border-b border-gray-200 pb-2 dark:border-white/10">
<h4
title={hardwareGroup.title}
className="truncate text-xs font-semibold tracking-wide text-gray-700 uppercase dark:text-gray-300"
>
{hardwareGroup.title}
</h4>
<span className="rounded-full bg-white px-2 py-0.5 text-[11px] font-medium text-gray-600 ring-1 ring-gray-200 dark:bg-white/10 dark:text-gray-300 dark:ring-white/10">
{hardwareGroup.rows.length}
</span>
</div>
<div className="grid grid-cols-1 gap-2 xl:grid-cols-2">
{hardwareGroup.rows.map((device) => {
const item = toChildDeviceItem(device)
return (
<ChildDeviceCard
key={device.id}
item={item}
isBusy={false}
isRecordingBusy={false}
onToggle={
canWrite
? (_, enabled) => void onToggle(device, "enabled", enabled)
: undefined
}
onToggleRecording={
canWrite
? (_, enabled) =>
void onToggle(device, "recordingEnabled", enabled)
: undefined
}
/>
)
})}
</div>
</section>
))}
</div>
)}
</div>
<div className="min-w-0 rounded-lg border border-gray-200 bg-white/70 p-3 dark:border-white/10 dark:bg-white/5">
<div className="mb-3 flex items-center justify-between gap-2 border-b border-gray-200 pb-2 dark:border-white/10">
<div className="flex items-center gap-2">
<MicrophoneIcon className="size-4 text-indigo-600 dark:text-indigo-400" />
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
Mikrofone
</h3>
</div>
<span className="rounded-full bg-gray-50 px-2 py-0.5 text-xs font-medium text-gray-600 ring-1 ring-gray-200 dark:bg-white/10 dark:text-gray-300 dark:ring-white/10">
{microphoneRows.length}
</span>
</div>
{microphoneGroups.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-400">
Keine Mikrofone in dieser Gruppe.
</p>
) : (
<div className="space-y-4">
{microphoneGroups.map((hardwareGroup) => (
<section
key={hardwareGroup.key}
className="rounded-lg border border-gray-200 bg-gray-50/70 p-3 dark:border-white/10 dark:bg-white/5"
>
<div className="mb-3 flex items-center justify-between gap-2 border-b border-gray-200 pb-2 dark:border-white/10">
<h4
title={hardwareGroup.title}
className="truncate text-xs font-semibold tracking-wide text-gray-700 uppercase dark:text-gray-300"
>
{hardwareGroup.title}
</h4>
<span className="rounded-full bg-white px-2 py-0.5 text-[11px] font-medium text-gray-600 ring-1 ring-gray-200 dark:bg-white/10 dark:text-gray-300 dark:ring-white/10">
{hardwareGroup.rows.length}
</span>
</div>
<div className="grid grid-cols-1 gap-2 xl:grid-cols-2">
{hardwareGroup.rows.map((device) => {
const item = toChildDeviceItem(device)
return (
<ChildDeviceCard
key={device.id}
item={item}
isBusy={false}
isRecordingBusy={false}
onToggle={
canWrite
? (_, enabled) => void onToggle(device, "enabled", enabled)
: undefined
}
/>
)
})}
</div>
</section>
))}
</div>
)}
</div>
</div>
)}
</section>
)
}

View File

@ -22,7 +22,10 @@ export default defineConfig({
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
// Kein changeOrigin: so erhält das Backend den originalen Host
// (z. B. localhost:5173), der zur Browser-Origin passt. Dadurch
// akzeptiert der WebSocket-Origin-Check (coder/websocket) den Upgrade,
// egal über welchen Host (localhost / LAN-IP) zugegriffen wird.
ws: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},