bugfixes
This commit is contained in:
parent
cd26d67252
commit
cf97fd3a18
@ -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\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 ""
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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 {
|
||||
|
||||
151
backend/external/README.md
vendored
151
backend/external/README.md
vendored
@ -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`).
|
||||
|
||||
302
backend/external/agent_main.go
vendored
302
backend/external/agent_main.go
vendored
@ -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 {
|
||||
|
||||
@ -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{}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
926
backend/onvif_resolutions.go
Normal file
926
backend/onvif_resolutions.go
Normal 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
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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
54
backend/runtime_schema.go
Normal 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
|
||||
}
|
||||
@ -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{}
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
151
frontend/src/components/ChatSocketProvider.tsx
Normal file
151
frontend/src/components/ChatSocketProvider.tsx
Normal 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
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
358
frontend/src/components/DatePicker.tsx
Normal file
358
frontend/src/components/DatePicker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -11,7 +11,7 @@ type SearchProps = {
|
||||
export default function Search({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Suche...',
|
||||
placeholder = 'Suchen...',
|
||||
}: SearchProps) {
|
||||
return (
|
||||
<form
|
||||
|
||||
149
frontend/src/components/Select.tsx
Normal file
149
frontend/src/components/Select.tsx
Normal 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
|
||||
@ -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',
|
||||
},
|
||||
],
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
456
frontend/src/pages/milestone/MilestonePage.tsx
Normal file
456
frontend/src/pages/milestone/MilestonePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1049
frontend/src/pages/milestone/geraete/MilestoneDevicesPage.tsx
Normal file
1049
frontend/src/pages/milestone/geraete/MilestoneDevicesPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
/>
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
KW–ZO:{' '}
|
||||
Distanz KW–ZO:{' '}
|
||||
<strong className="font-semibold text-gray-800 dark:text-gray-200">
|
||||
{geometry ? formatDistance(geometry.targetDistance) : '–'}
|
||||
</strong>
|
||||
|
||||
@ -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
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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/, ''),
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user