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 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\")",
|
"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(npx tsc *)",
|
||||||
"Bash(echo \"EXIT:$?\")"
|
"Bash(echo \"EXIT:$?\")",
|
||||||
|
"Bash(go build *)",
|
||||||
|
"Bash(xargs grep -l \"upgrade\")"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
PORT=8080
|
PORT=8080
|
||||||
DATABASE_URL=postgres://postgres:Timmy0104199%3F@localhost:5432/teg?sslmode=disable
|
DATABASE_URL=postgres://postgres:Timmy0104199%3F@localhost:5432/teg?sslmode=disable
|
||||||
JWT_SECRET=tegvideo7010!
|
JWT_SECRET=tegvideo7010!
|
||||||
FRONTEND_ORIGIN=https://localhost:5173
|
FRONTEND_ORIGIN=https://10.0.1.25:5173
|
||||||
ADDRESS_SEARCH_PROVIDER=photon
|
ADDRESS_SEARCH_PROVIDER=photon
|
||||||
ADDRESS_SEARCH_URL=https://photon.komoot.io/api
|
ADDRESS_SEARCH_URL=https://photon.komoot.io/api
|
||||||
ADDRESS_SEARCH_LANGUAGE=de
|
ADDRESS_SEARCH_LANGUAGE=de
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// backend/admin.go
|
// backend\admin.go
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
@ -83,15 +83,13 @@ func ensureAdminUserLookupValues(ctx context.Context, tx pgx.Tx, unit string) er
|
|||||||
func normalizeAdminTeamInput(input *AdminTeamRequest) {
|
func normalizeAdminTeamInput(input *AdminTeamRequest) {
|
||||||
input.Name = strings.TrimSpace(input.Name)
|
input.Name = strings.TrimSpace(input.Name)
|
||||||
input.Initial = strings.TrimSpace(input.Initial)
|
input.Initial = strings.TrimSpace(input.Initial)
|
||||||
input.Href = strings.TrimSpace(input.Href)
|
|
||||||
|
|
||||||
if input.Initial == "" && input.Name != "" {
|
if input.Initial == "" && input.Name != "" {
|
||||||
input.Initial = strings.ToUpper(string([]rune(input.Name)[0]))
|
input.Initial = strings.ToUpper(string([]rune(input.Name)[0]))
|
||||||
}
|
}
|
||||||
|
|
||||||
if input.Href == "" {
|
// Der Link entspricht immer dem Teamnamen; ein separates Feld gibt es nicht mehr.
|
||||||
input.Href = "#"
|
input.Href = input.Name
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func cleanStringList(values []string) []string {
|
func cleanStringList(values []string) []string {
|
||||||
|
|||||||
@ -15,6 +15,7 @@ type AdminGroupChat struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Image string `json:"image"`
|
Image string `json:"image"`
|
||||||
OwnerID string `json:"ownerId"`
|
OwnerID string `json:"ownerId"`
|
||||||
|
TeamID string `json:"teamId"`
|
||||||
UserIDs []string `json:"userIds"`
|
UserIDs []string `json:"userIds"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
@ -35,6 +36,7 @@ func (s *Server) handleAdminListGroupChats(w http.ResponseWriter, r *http.Reques
|
|||||||
c.name,
|
c.name,
|
||||||
c.channel_image,
|
c.channel_image,
|
||||||
COALESCE(c.created_by::TEXT, ''),
|
COALESCE(c.created_by::TEXT, ''),
|
||||||
|
COALESCE(c.team_id::TEXT, ''),
|
||||||
COALESCE(
|
COALESCE(
|
||||||
array_agg(cm.user_id::TEXT ORDER BY cm.created_at)
|
array_agg(cm.user_id::TEXT ORDER BY cm.created_at)
|
||||||
FILTER (WHERE cm.user_id IS NOT NULL),
|
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
|
FROM conversations c
|
||||||
LEFT JOIN conversation_members cm ON cm.conversation_id = c.id
|
LEFT JOIN conversation_members cm ON cm.conversation_id = c.id
|
||||||
WHERE c.type = 'group'
|
WHERE c.type = 'group'
|
||||||
AND c.team_id IS NULL
|
|
||||||
GROUP BY c.id
|
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 {
|
if err != nil {
|
||||||
@ -64,6 +65,7 @@ func (s *Server) handleAdminListGroupChats(w http.ResponseWriter, r *http.Reques
|
|||||||
&group.Name,
|
&group.Name,
|
||||||
&group.Image,
|
&group.Image,
|
||||||
&group.OwnerID,
|
&group.OwnerID,
|
||||||
|
&group.TeamID,
|
||||||
&group.UserIDs,
|
&group.UserIDs,
|
||||||
&group.CreatedAt,
|
&group.CreatedAt,
|
||||||
&group.UpdatedAt,
|
&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})
|
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) {
|
func adminGroupMemberIDs(ctx context.Context, tx pgx.Tx, conversationID string) ([]string, error) {
|
||||||
rows, err := tx.Query(
|
rows, err := tx.Query(
|
||||||
ctx,
|
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
|
// der Schritte fehl, bleibt der bisherige Token unverändert und es wird eine
|
||||||
// Warnung zurückgegeben (das Speichern der übrigen Einstellungen bleibt gültig).
|
// Warnung zurückgegeben (das Speichern der übrigen Einstellungen bleibt gültig).
|
||||||
func (s *Server) rotateServerFoldersAgentTokenAfterSave(ctx context.Context) string {
|
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)
|
config, err := s.getServerFoldersAgentConfig(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("server-folders agent config load failed: %v", err)
|
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."
|
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)
|
log.Printf("server-folders agent token rotation failed: %v", err)
|
||||||
|
|
||||||
return "Der Server-Folders-Agent ist nicht erreichbar. Der Token wurde nicht ausgetauscht."
|
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 ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -62,6 +62,7 @@ type AdminChannelUser struct {
|
|||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Unit string `json:"unit"`
|
Unit string `json:"unit"`
|
||||||
|
Avatar string `json:"avatar"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type saveAdminChannelRequest struct {
|
type saveAdminChannelRequest struct {
|
||||||
@ -218,7 +219,8 @@ func (s *Server) listAdminChannelUsers(ctx context.Context) ([]AdminChannelUser,
|
|||||||
COALESCE(username, ''),
|
COALESCE(username, ''),
|
||||||
COALESCE(display_name, ''),
|
COALESCE(display_name, ''),
|
||||||
email,
|
email,
|
||||||
COALESCE(unit, '')
|
COALESCE(unit, ''),
|
||||||
|
COALESCE(avatar, '')
|
||||||
FROM users
|
FROM users
|
||||||
ORDER BY lower(COALESCE(NULLIF(display_name, ''), username, email))
|
ORDER BY lower(COALESCE(NULLIF(display_name, ''), username, email))
|
||||||
`,
|
`,
|
||||||
@ -237,6 +239,7 @@ func (s *Server) listAdminChannelUsers(ctx context.Context) ([]AdminChannelUser,
|
|||||||
&user.DisplayName,
|
&user.DisplayName,
|
||||||
&user.Email,
|
&user.Email,
|
||||||
&user.Unit,
|
&user.Unit,
|
||||||
|
&user.Avatar,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@ -284,6 +284,7 @@ func (s *Server) handleListChatConversations(w http.ResponseWriter, r *http.Requ
|
|||||||
AND current_membership.user_id = $1
|
AND current_membership.user_id = $1
|
||||||
WHERE unread_message.conversation_id = c.id
|
WHERE unread_message.conversation_id = c.id
|
||||||
AND unread_message.deleted_at IS NULL
|
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.sender_id IS DISTINCT FROM $1::UUID
|
||||||
AND (unread_message.expires_at IS NULL OR unread_message.expires_at > now())
|
AND (unread_message.expires_at IS NULL OR unread_message.expires_at > now())
|
||||||
AND unread_message.created_at > COALESCE(
|
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
|
AND current_membership.user_id = $2
|
||||||
WHERE unread_message.conversation_id = c.id
|
WHERE unread_message.conversation_id = c.id
|
||||||
AND unread_message.deleted_at IS NULL
|
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.sender_id IS DISTINCT FROM $2::UUID
|
||||||
AND (unread_message.expires_at IS NULL OR unread_message.expires_at > now())
|
AND (unread_message.expires_at IS NULL OR unread_message.expires_at > now())
|
||||||
AND unread_message.created_at > COALESCE(
|
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
|
ON cm.conversation_id = c.id
|
||||||
AND cm.user_id = $1
|
AND cm.user_id = $1
|
||||||
WHERE m.deleted_at IS NULL
|
WHERE m.deleted_at IS NULL
|
||||||
|
AND m.message_type <> 'system'
|
||||||
AND (m.expires_at IS NULL OR m.expires_at > now())
|
AND (m.expires_at IS NULL OR m.expires_at > now())
|
||||||
AND m.sender_id IS DISTINCT FROM $1::UUID
|
AND m.sender_id IS DISTINCT FROM $1::UUID
|
||||||
AND m.created_at > COALESCE(cm.last_read_at, '-infinity'::TIMESTAMPTZ)
|
AND m.created_at > COALESCE(cm.last_read_at, '-infinity'::TIMESTAMPTZ)
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
// backend\chat_websocket.go
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@ -5,6 +7,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@ -99,9 +102,17 @@ func (s *Server) handleChatWebSocket(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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{}
|
originPatterns := []string{}
|
||||||
if frontendOrigin := strings.TrimRight(s.frontendOrigin, "/"); frontendOrigin != "" {
|
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{
|
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||||
|
|||||||
@ -21,9 +21,15 @@ type Device struct {
|
|||||||
SerialNumber string `json:"serialNumber"`
|
SerialNumber string `json:"serialNumber"`
|
||||||
MacAddress string `json:"macAddress"`
|
MacAddress string `json:"macAddress"`
|
||||||
IPAddress string `json:"ipAddress"`
|
IPAddress string `json:"ipAddress"`
|
||||||
|
PhoneNumber string `json:"phoneNumber"`
|
||||||
|
ComputerName string `json:"computerName"`
|
||||||
|
DeviceCategory string `json:"deviceCategory"`
|
||||||
MilestonePort string `json:"milestonePort"`
|
MilestonePort string `json:"milestonePort"`
|
||||||
Location string `json:"location"`
|
Location string `json:"location"`
|
||||||
LoanStatus string `json:"loanStatus"`
|
LoanStatus string `json:"loanStatus"`
|
||||||
|
LoanedToUserID string `json:"loanedToUserId"`
|
||||||
|
LoanedToDisplayName string `json:"loanedToDisplayName"`
|
||||||
|
LoanedUntil string `json:"loanedUntil"`
|
||||||
IsBaoDevice bool `json:"isBaoDevice"`
|
IsBaoDevice bool `json:"isBaoDevice"`
|
||||||
Comment string `json:"comment"`
|
Comment string `json:"comment"`
|
||||||
FirmwareVersion string `json:"firmwareVersion"`
|
FirmwareVersion string `json:"firmwareVersion"`
|
||||||
@ -64,6 +70,15 @@ type DeviceShort struct {
|
|||||||
Model string `json:"model"`
|
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 {
|
type MilestoneHardwareChildDevice struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
MilestoneHardwareID string `json:"milestoneHardwareId"`
|
MilestoneHardwareID string `json:"milestoneHardwareId"`
|
||||||
@ -85,12 +100,17 @@ type CreateDeviceRequest struct {
|
|||||||
SerialNumber string `json:"serialNumber"`
|
SerialNumber string `json:"serialNumber"`
|
||||||
MacAddress string `json:"macAddress"`
|
MacAddress string `json:"macAddress"`
|
||||||
IPAddress string `json:"ipAddress"`
|
IPAddress string `json:"ipAddress"`
|
||||||
|
PhoneNumber string `json:"phoneNumber"`
|
||||||
|
ComputerName string `json:"computerName"`
|
||||||
|
DeviceCategory string `json:"deviceCategory"`
|
||||||
MilestonePort string `json:"milestonePort"`
|
MilestonePort string `json:"milestonePort"`
|
||||||
FirmwareVersion string `json:"firmwareVersion"`
|
FirmwareVersion string `json:"firmwareVersion"`
|
||||||
MilestoneDisplayName string `json:"milestoneDisplayName"`
|
MilestoneDisplayName string `json:"milestoneDisplayName"`
|
||||||
MilestoneEnabled *bool `json:"milestoneEnabled,omitempty"`
|
MilestoneEnabled *bool `json:"milestoneEnabled,omitempty"`
|
||||||
Location string `json:"location"`
|
Location string `json:"location"`
|
||||||
LoanStatus string `json:"loanStatus"`
|
LoanStatus string `json:"loanStatus"`
|
||||||
|
LoanedToUserID string `json:"loanedToUserId"`
|
||||||
|
LoanedUntil string `json:"loanedUntil"`
|
||||||
IsBaoDevice bool `json:"isBaoDevice"`
|
IsBaoDevice bool `json:"isBaoDevice"`
|
||||||
Comment string `json:"comment"`
|
Comment string `json:"comment"`
|
||||||
RelatedDeviceIDs []string `json:"relatedDeviceIds"`
|
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) {
|
func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
|
||||||
rows, err := s.db.Query(
|
rows, err := s.db.Query(
|
||||||
r.Context(),
|
r.Context(),
|
||||||
`
|
`
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
devices.id,
|
||||||
inventory_number,
|
COALESCE(devices.inventory_number, ''),
|
||||||
manufacturer,
|
COALESCE(devices.manufacturer, ''),
|
||||||
model,
|
COALESCE(devices.model, ''),
|
||||||
serial_number,
|
COALESCE(devices.serial_number, ''),
|
||||||
mac_address,
|
COALESCE(devices.mac_address, ''),
|
||||||
COALESCE(ip_address, ''),
|
COALESCE(devices.ip_address, ''),
|
||||||
COALESCE(milestone_port, ''),
|
COALESCE(devices.phone_number, ''),
|
||||||
location,
|
COALESCE(devices.computer_name, ''),
|
||||||
loan_status,
|
COALESCE(devices.device_category, ''),
|
||||||
is_bao_device,
|
COALESCE(devices.milestone_port, ''),
|
||||||
comment,
|
COALESCE(devices.location, ''),
|
||||||
COALESCE(firmware_version, ''),
|
COALESCE(devices.loan_status, 'Verfügbar'),
|
||||||
COALESCE(milestone_recording_server_id, ''),
|
COALESCE(devices.loaned_to_user_id::TEXT, ''),
|
||||||
COALESCE(milestone_hardware_id, ''),
|
COALESCE(NULLIF(loaned_user.display_name, ''), NULLIF(loaned_user.username, ''), loaned_user.email, ''),
|
||||||
COALESCE(milestone_display_name, ''),
|
COALESCE(devices.loaned_until::TEXT, ''),
|
||||||
milestone_enabled,
|
COALESCE(devices.is_bao_device, false),
|
||||||
milestone_synced_at,
|
COALESCE(devices.comment, ''),
|
||||||
created_at,
|
COALESCE(devices.firmware_version, ''),
|
||||||
updated_at
|
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
|
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 {
|
if err != nil {
|
||||||
|
logError("Geräte konnten nicht geladen werden", err, nil)
|
||||||
writeError(w, http.StatusInternalServerError, "Could not load devices")
|
writeError(w, http.StatusInternalServerError, "Could not load devices")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -165,9 +242,15 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
|
|||||||
&device.SerialNumber,
|
&device.SerialNumber,
|
||||||
&device.MacAddress,
|
&device.MacAddress,
|
||||||
&device.IPAddress,
|
&device.IPAddress,
|
||||||
|
&device.PhoneNumber,
|
||||||
|
&device.ComputerName,
|
||||||
|
&device.DeviceCategory,
|
||||||
&device.MilestonePort,
|
&device.MilestonePort,
|
||||||
&device.Location,
|
&device.Location,
|
||||||
&device.LoanStatus,
|
&device.LoanStatus,
|
||||||
|
&device.LoanedToUserID,
|
||||||
|
&device.LoanedToDisplayName,
|
||||||
|
&device.LoanedUntil,
|
||||||
&device.IsBaoDevice,
|
&device.IsBaoDevice,
|
||||||
&device.Comment,
|
&device.Comment,
|
||||||
&device.FirmwareVersion,
|
&device.FirmwareVersion,
|
||||||
@ -179,6 +262,7 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
|
|||||||
&device.CreatedAt,
|
&device.CreatedAt,
|
||||||
&device.UpdatedAt,
|
&device.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
|
logError("Geräte konnten nicht gelesen werden", err, nil)
|
||||||
writeError(w, http.StatusInternalServerError, "Could not read devices")
|
writeError(w, http.StatusInternalServerError, "Could not read devices")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -191,6 +275,7 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := rows.Err(); err != nil {
|
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")
|
writeError(w, http.StatusInternalServerError, "Could not read devices")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -201,9 +286,9 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
|
|||||||
SELECT
|
SELECT
|
||||||
device_relations.device_id,
|
device_relations.device_id,
|
||||||
related_devices.id,
|
related_devices.id,
|
||||||
related_devices.inventory_number,
|
COALESCE(related_devices.inventory_number, ''),
|
||||||
related_devices.manufacturer,
|
COALESCE(related_devices.manufacturer, ''),
|
||||||
related_devices.model
|
COALESCE(related_devices.model, '')
|
||||||
FROM device_relations
|
FROM device_relations
|
||||||
INNER JOIN devices AS related_devices
|
INNER JOIN devices AS related_devices
|
||||||
ON related_devices.id = device_relations.related_device_id
|
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 {
|
if err != nil {
|
||||||
|
logError("Geräte-Zuordnungen konnten nicht geladen werden", err, nil)
|
||||||
writeError(w, http.StatusInternalServerError, "Could not load device relations")
|
writeError(w, http.StatusInternalServerError, "Could not load device relations")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -228,6 +314,7 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
|
|||||||
&relatedDevice.Manufacturer,
|
&relatedDevice.Manufacturer,
|
||||||
&relatedDevice.Model,
|
&relatedDevice.Model,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
|
logError("Geräte-Zuordnungen konnten nicht gelesen werden", err, nil)
|
||||||
writeError(w, http.StatusInternalServerError, "Could not read device relations")
|
writeError(w, http.StatusInternalServerError, "Could not read device relations")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -242,6 +329,7 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := relationRows.Err(); err != nil {
|
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")
|
writeError(w, http.StatusInternalServerError, "Could not read device relations")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -252,13 +340,13 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
|
|||||||
SELECT
|
SELECT
|
||||||
devices.id::TEXT,
|
devices.id::TEXT,
|
||||||
milestone_hardware_child_devices.id::TEXT,
|
milestone_hardware_child_devices.id::TEXT,
|
||||||
milestone_hardware_child_devices.milestone_hardware_id,
|
COALESCE(milestone_hardware_child_devices.milestone_hardware_id, ''),
|
||||||
milestone_hardware_child_devices.milestone_device_id,
|
COALESCE(milestone_hardware_child_devices.milestone_device_id, ''),
|
||||||
milestone_hardware_child_devices.device_type,
|
COALESCE(milestone_hardware_child_devices.device_type, ''),
|
||||||
milestone_hardware_child_devices.name,
|
COALESCE(milestone_hardware_child_devices.name, ''),
|
||||||
milestone_hardware_child_devices.display_name,
|
COALESCE(milestone_hardware_child_devices.display_name, ''),
|
||||||
milestone_hardware_child_devices.short_name,
|
COALESCE(milestone_hardware_child_devices.short_name, ''),
|
||||||
milestone_hardware_child_devices.enabled,
|
COALESCE(milestone_hardware_child_devices.enabled, true),
|
||||||
COALESCE(milestone_hardware_child_devices.recording_enabled, false),
|
COALESCE(milestone_hardware_child_devices.recording_enabled, false),
|
||||||
milestone_hardware_child_devices.created_at,
|
milestone_hardware_child_devices.created_at,
|
||||||
milestone_hardware_child_devices.updated_at
|
milestone_hardware_child_devices.updated_at
|
||||||
@ -273,6 +361,7 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
|
|||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
logError("Milestone-Child-Devices konnten nicht geladen werden", err, nil)
|
||||||
writeError(w, http.StatusInternalServerError, "Could not load Milestone child devices")
|
writeError(w, http.StatusInternalServerError, "Could not load Milestone child devices")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -296,6 +385,7 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
|
|||||||
&childDevice.CreatedAt,
|
&childDevice.CreatedAt,
|
||||||
&childDevice.UpdatedAt,
|
&childDevice.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
|
logError("Milestone-Child-Devices konnten nicht gelesen werden", err, nil)
|
||||||
writeError(w, http.StatusInternalServerError, "Could not read Milestone child devices")
|
writeError(w, http.StatusInternalServerError, "Could not read Milestone child devices")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -310,6 +400,7 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := childRows.Err(); err != nil {
|
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")
|
writeError(w, http.StatusInternalServerError, "Could not read Milestone child devices")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -366,14 +457,19 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) {
|
|||||||
serial_number,
|
serial_number,
|
||||||
mac_address,
|
mac_address,
|
||||||
ip_address,
|
ip_address,
|
||||||
|
phone_number,
|
||||||
|
computer_name,
|
||||||
|
device_category,
|
||||||
milestone_port,
|
milestone_port,
|
||||||
location,
|
location,
|
||||||
loan_status,
|
loan_status,
|
||||||
|
loaned_to_user_id,
|
||||||
|
loaned_until,
|
||||||
is_bao_device,
|
is_bao_device,
|
||||||
comment,
|
comment,
|
||||||
firmware_version
|
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
|
RETURNING
|
||||||
id,
|
id,
|
||||||
inventory_number,
|
inventory_number,
|
||||||
@ -382,9 +478,15 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) {
|
|||||||
serial_number,
|
serial_number,
|
||||||
mac_address,
|
mac_address,
|
||||||
COALESCE(ip_address, ''),
|
COALESCE(ip_address, ''),
|
||||||
|
COALESCE(phone_number, ''),
|
||||||
|
COALESCE(computer_name, ''),
|
||||||
|
COALESCE(device_category, ''),
|
||||||
COALESCE(milestone_port, ''),
|
COALESCE(milestone_port, ''),
|
||||||
location,
|
location,
|
||||||
loan_status,
|
loan_status,
|
||||||
|
COALESCE(loaned_to_user_id::TEXT, ''),
|
||||||
|
'',
|
||||||
|
COALESCE(loaned_until::TEXT, ''),
|
||||||
is_bao_device,
|
is_bao_device,
|
||||||
comment,
|
comment,
|
||||||
COALESCE(firmware_version, ''),
|
COALESCE(firmware_version, ''),
|
||||||
@ -402,9 +504,14 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) {
|
|||||||
input.SerialNumber,
|
input.SerialNumber,
|
||||||
input.MacAddress,
|
input.MacAddress,
|
||||||
input.IPAddress,
|
input.IPAddress,
|
||||||
|
input.PhoneNumber,
|
||||||
|
input.ComputerName,
|
||||||
|
input.DeviceCategory,
|
||||||
input.MilestonePort,
|
input.MilestonePort,
|
||||||
input.Location,
|
input.Location,
|
||||||
input.LoanStatus,
|
input.LoanStatus,
|
||||||
|
input.LoanedToUserID,
|
||||||
|
input.LoanedUntil,
|
||||||
input.IsBaoDevice,
|
input.IsBaoDevice,
|
||||||
input.Comment,
|
input.Comment,
|
||||||
input.FirmwareVersion,
|
input.FirmwareVersion,
|
||||||
@ -416,9 +523,15 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) {
|
|||||||
&device.SerialNumber,
|
&device.SerialNumber,
|
||||||
&device.MacAddress,
|
&device.MacAddress,
|
||||||
&device.IPAddress,
|
&device.IPAddress,
|
||||||
|
&device.PhoneNumber,
|
||||||
|
&device.ComputerName,
|
||||||
|
&device.DeviceCategory,
|
||||||
&device.MilestonePort,
|
&device.MilestonePort,
|
||||||
&device.Location,
|
&device.Location,
|
||||||
&device.LoanStatus,
|
&device.LoanStatus,
|
||||||
|
&device.LoanedToUserID,
|
||||||
|
&device.LoanedToDisplayName,
|
||||||
|
&device.LoanedUntil,
|
||||||
&device.IsBaoDevice,
|
&device.IsBaoDevice,
|
||||||
&device.Comment,
|
&device.Comment,
|
||||||
&device.FirmwareVersion,
|
&device.FirmwareVersion,
|
||||||
@ -545,6 +658,9 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
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")
|
writeError(w, http.StatusInternalServerError, "Could not load device history state")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -567,21 +683,26 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
|
|||||||
serial_number = $5,
|
serial_number = $5,
|
||||||
mac_address = $6,
|
mac_address = $6,
|
||||||
ip_address = $7,
|
ip_address = $7,
|
||||||
milestone_port = $8,
|
phone_number = $8,
|
||||||
location = $9,
|
computer_name = $9,
|
||||||
loan_status = $10,
|
device_category = $10,
|
||||||
is_bao_device = $11,
|
milestone_port = $11,
|
||||||
comment = $12,
|
location = $12,
|
||||||
firmware_version = $13,
|
loan_status = $13,
|
||||||
milestone_display_name = $14,
|
loaned_to_user_id = NULLIF($14, '')::UUID,
|
||||||
milestone_enabled = $15,
|
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
|
milestone_synced_at = CASE
|
||||||
WHEN COALESCE(milestone_hardware_id, '') <> ''
|
WHEN COALESCE(milestone_hardware_id, '') <> ''
|
||||||
AND (
|
AND (
|
||||||
COALESCE(milestone_display_name, '') <> $14
|
COALESCE(milestone_display_name, '') <> $19
|
||||||
OR COALESCE(ip_address, '') <> $7
|
OR COALESCE(ip_address, '') <> $7
|
||||||
OR COALESCE(milestone_port, '') <> $8
|
OR COALESCE(milestone_port, '') <> $11
|
||||||
OR milestone_enabled <> $15
|
OR milestone_enabled <> $20
|
||||||
)
|
)
|
||||||
THEN now()
|
THEN now()
|
||||||
ELSE milestone_synced_at
|
ELSE milestone_synced_at
|
||||||
@ -596,9 +717,15 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
|
|||||||
serial_number,
|
serial_number,
|
||||||
mac_address,
|
mac_address,
|
||||||
COALESCE(ip_address, ''),
|
COALESCE(ip_address, ''),
|
||||||
|
COALESCE(phone_number, ''),
|
||||||
|
COALESCE(computer_name, ''),
|
||||||
|
COALESCE(device_category, ''),
|
||||||
COALESCE(milestone_port, ''),
|
COALESCE(milestone_port, ''),
|
||||||
location,
|
location,
|
||||||
loan_status,
|
loan_status,
|
||||||
|
COALESCE(loaned_to_user_id::TEXT, ''),
|
||||||
|
'',
|
||||||
|
COALESCE(loaned_until::TEXT, ''),
|
||||||
is_bao_device,
|
is_bao_device,
|
||||||
comment,
|
comment,
|
||||||
COALESCE(firmware_version, ''),
|
COALESCE(firmware_version, ''),
|
||||||
@ -617,9 +744,14 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
|
|||||||
input.SerialNumber,
|
input.SerialNumber,
|
||||||
input.MacAddress,
|
input.MacAddress,
|
||||||
input.IPAddress,
|
input.IPAddress,
|
||||||
|
input.PhoneNumber,
|
||||||
|
input.ComputerName,
|
||||||
|
input.DeviceCategory,
|
||||||
input.MilestonePort,
|
input.MilestonePort,
|
||||||
input.Location,
|
input.Location,
|
||||||
input.LoanStatus,
|
input.LoanStatus,
|
||||||
|
input.LoanedToUserID,
|
||||||
|
input.LoanedUntil,
|
||||||
input.IsBaoDevice,
|
input.IsBaoDevice,
|
||||||
input.Comment,
|
input.Comment,
|
||||||
input.FirmwareVersion,
|
input.FirmwareVersion,
|
||||||
@ -633,9 +765,15 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
|
|||||||
&device.SerialNumber,
|
&device.SerialNumber,
|
||||||
&device.MacAddress,
|
&device.MacAddress,
|
||||||
&device.IPAddress,
|
&device.IPAddress,
|
||||||
|
&device.PhoneNumber,
|
||||||
|
&device.ComputerName,
|
||||||
|
&device.DeviceCategory,
|
||||||
&device.MilestonePort,
|
&device.MilestonePort,
|
||||||
&device.Location,
|
&device.Location,
|
||||||
&device.LoanStatus,
|
&device.LoanStatus,
|
||||||
|
&device.LoanedToUserID,
|
||||||
|
&device.LoanedToDisplayName,
|
||||||
|
&device.LoanedUntil,
|
||||||
&device.IsBaoDevice,
|
&device.IsBaoDevice,
|
||||||
&device.Comment,
|
&device.Comment,
|
||||||
&device.FirmwareVersion,
|
&device.FirmwareVersion,
|
||||||
@ -756,16 +894,26 @@ func normalizeDeviceInput(input *CreateDeviceRequest) {
|
|||||||
input.SerialNumber = strings.TrimSpace(input.SerialNumber)
|
input.SerialNumber = strings.TrimSpace(input.SerialNumber)
|
||||||
input.MacAddress = strings.TrimSpace(input.MacAddress)
|
input.MacAddress = strings.TrimSpace(input.MacAddress)
|
||||||
input.IPAddress = strings.TrimSpace(input.IPAddress)
|
input.IPAddress = strings.TrimSpace(input.IPAddress)
|
||||||
|
input.PhoneNumber = strings.TrimSpace(input.PhoneNumber)
|
||||||
|
input.ComputerName = strings.TrimSpace(input.ComputerName)
|
||||||
|
input.DeviceCategory = strings.TrimSpace(input.DeviceCategory)
|
||||||
input.MilestonePort = strings.TrimSpace(input.MilestonePort)
|
input.MilestonePort = strings.TrimSpace(input.MilestonePort)
|
||||||
input.FirmwareVersion = strings.TrimSpace(input.FirmwareVersion)
|
input.FirmwareVersion = strings.TrimSpace(input.FirmwareVersion)
|
||||||
input.MilestoneDisplayName = strings.TrimSpace(input.MilestoneDisplayName)
|
input.MilestoneDisplayName = strings.TrimSpace(input.MilestoneDisplayName)
|
||||||
input.Location = strings.TrimSpace(input.Location)
|
input.Location = strings.TrimSpace(input.Location)
|
||||||
input.LoanStatus = strings.TrimSpace(input.LoanStatus)
|
input.LoanStatus = strings.TrimSpace(input.LoanStatus)
|
||||||
|
input.LoanedToUserID = strings.TrimSpace(input.LoanedToUserID)
|
||||||
|
input.LoanedUntil = strings.TrimSpace(input.LoanedUntil)
|
||||||
input.Comment = strings.TrimSpace(input.Comment)
|
input.Comment = strings.TrimSpace(input.Comment)
|
||||||
|
|
||||||
if input.LoanStatus == "" {
|
if input.LoanStatus == "" {
|
||||||
input.LoanStatus = "Verfügbar"
|
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 {
|
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
|
## Backend-Proxy
|
||||||
|
|
||||||
@ -9,63 +101,8 @@ GET /admin/server-folders?path=D:\Milestone
|
|||||||
POST /admin/server-folders
|
POST /admin/server-folders
|
||||||
```
|
```
|
||||||
|
|
||||||
Zusätzlich kann der Proxy diese Parameter auswerten:
|
Der Proxy gibt den Token als Header an den Agent weiter:
|
||||||
|
|
||||||
```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:
|
|
||||||
|
|
||||||
```http
|
```http
|
||||||
X-Server-Folders-Token: <token>
|
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`).
|
|
||||||
|
|||||||
270
backend/external/agent_main.go
vendored
270
backend/external/agent_main.go
vendored
@ -10,7 +10,19 @@
|
|||||||
// DELETE /server-folders
|
// DELETE /server-folders
|
||||||
// PUT /agent-token (Token zur Laufzeit setzen / rotieren)
|
// 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"
|
// server-folders-agent.exe -port 8099 -storage-root "Storage D=D:\Milestone\Storage" -archive-root "Archive E=E:\Milestone\Archive"
|
||||||
//
|
//
|
||||||
// Alternativ per ENV:
|
// Alternativ per ENV:
|
||||||
@ -19,11 +31,11 @@
|
|||||||
// SERVER_ARCHIVE_ROOTS=Archive E=E:\Milestone\Archive
|
// SERVER_ARCHIVE_ROOTS=Archive E=E:\Milestone\Archive
|
||||||
//
|
//
|
||||||
// Token-Handling:
|
// 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
|
// Milestone-Einstellungen automatisch einen Token und schiebt ihn per
|
||||||
// PUT /agent-token an den Agent. Der Token wird in einer Datei persistiert
|
// PUT /agent-token an den Agent. Der Token wird in der JSON-Datei oder, wenn
|
||||||
// (Standard: neben der .exe, siehe -token-file / SERVER_FOLDER_AGENT_TOKEN_FILE)
|
// explizit konfiguriert, in einer Token-Datei persistiert und überlebt damit
|
||||||
// und überlebt damit einen Neustart.
|
// einen Neustart.
|
||||||
//
|
//
|
||||||
// Solange noch kein Token gesetzt ist, ist PUT /agent-token offen (Bootstrap).
|
// Solange noch kein Token gesetzt ist, ist PUT /agent-token offen (Bootstrap).
|
||||||
// Sobald ein Token gesetzt ist, muss der Client ihn senden:
|
// Sobald ein Token gesetzt ist, muss der Client ihn senden:
|
||||||
@ -55,6 +67,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
defaultListenAddr = ":8099"
|
defaultListenAddr = ":8099"
|
||||||
|
defaultConfigFileName = "server-folders-agent.json"
|
||||||
defaultTokenFileName = "server-folders-agent.token"
|
defaultTokenFileName = "server-folders-agent.token"
|
||||||
|
|
||||||
listenAddrEnv = "LISTEN_ADDR"
|
listenAddrEnv = "LISTEN_ADDR"
|
||||||
@ -63,6 +76,8 @@ const (
|
|||||||
legacyFolderRootsEnv = "SERVER_FOLDER_ROOTS"
|
legacyFolderRootsEnv = "SERVER_FOLDER_ROOTS"
|
||||||
agentTokenEnv = "SERVER_FOLDER_AGENT_TOKEN"
|
agentTokenEnv = "SERVER_FOLDER_AGENT_TOKEN"
|
||||||
agentTokenFileEnv = "SERVER_FOLDER_AGENT_TOKEN_FILE"
|
agentTokenFileEnv = "SERVER_FOLDER_AGENT_TOKEN_FILE"
|
||||||
|
agentConfigFileEnv = "SERVER_FOLDER_AGENT_CONFIG"
|
||||||
|
agentPortEnv = "SERVER_FOLDER_AGENT_PORT"
|
||||||
)
|
)
|
||||||
|
|
||||||
type serverFolderRootType string
|
type serverFolderRootType string
|
||||||
@ -78,6 +93,7 @@ type appConfig struct {
|
|||||||
ArchiveRoots string
|
ArchiveRoots string
|
||||||
Token string
|
Token string
|
||||||
TokenFile string
|
TokenFile string
|
||||||
|
TokenConfigFile string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (config appConfig) rootsForType(rootType serverFolderRootType) string {
|
func (config appConfig) rootsForType(rootType serverFolderRootType) string {
|
||||||
@ -88,6 +104,16 @@ func (config appConfig) rootsForType(rootType serverFolderRootType) string {
|
|||||||
return strings.TrimSpace(config.StorageRoots)
|
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 {
|
type serverFolderRoot struct {
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
@ -121,7 +147,7 @@ type renameServerFolderRequest struct {
|
|||||||
func main() {
|
func main() {
|
||||||
config := readConfigFromFlagsAndEnv()
|
config := readConfigFromFlagsAndEnv()
|
||||||
|
|
||||||
store := newTokenStore(config.TokenFile, config.Token)
|
store := newTokenStore(config)
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
@ -151,7 +177,13 @@ func main() {
|
|||||||
log.Printf("server-folders-agent listening on %s", config.ListenAddr)
|
log.Printf("server-folders-agent listening on %s", config.ListenAddr)
|
||||||
log.Printf("storage roots: %q", config.StorageRoots)
|
log.Printf("storage roots: %q", config.StorageRoots)
|
||||||
log.Printf("archive roots: %q", config.ArchiveRoots)
|
log.Printf("archive roots: %q", config.ArchiveRoots)
|
||||||
|
if config.TokenFile != "" {
|
||||||
log.Printf("token file: %s", 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() != "")
|
log.Printf("token enabled: %t", store.get() != "")
|
||||||
|
|
||||||
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
@ -160,21 +192,50 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func readConfigFromFlagsAndEnv() appConfig {
|
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")
|
addrFlag := flag.String("addr", "", "Listen address, e.g. :8099 or 0.0.0.0:8099")
|
||||||
portFlag := flag.String("port", "", "Listen port, e.g. 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")
|
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")
|
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")
|
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)")
|
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()
|
flag.Parse()
|
||||||
|
|
||||||
listenAddr := strings.TrimSpace(*addrFlag)
|
config := appConfig{}
|
||||||
if listenAddr == "" {
|
configPath := strings.TrimSpace(*configFlag)
|
||||||
listenAddr = strings.TrimSpace(os.Getenv(listenAddrEnv))
|
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)
|
port := strings.TrimSpace(*portFlag)
|
||||||
|
if listenAddr == "" && port == "" {
|
||||||
|
port = strings.TrimSpace(os.Getenv(agentPortEnv))
|
||||||
|
}
|
||||||
|
|
||||||
if listenAddr == "" && port != "" {
|
if listenAddr == "" && port != "" {
|
||||||
port = strings.TrimPrefix(port, ":")
|
port = strings.TrimPrefix(port, ":")
|
||||||
listenAddr = ":" + port
|
listenAddr = ":" + port
|
||||||
@ -184,39 +245,38 @@ func readConfigFromFlagsAndEnv() appConfig {
|
|||||||
listenAddr = defaultListenAddr
|
listenAddr = defaultListenAddr
|
||||||
}
|
}
|
||||||
|
|
||||||
legacyRoots := strings.TrimSpace(*legacyRootsFlag)
|
legacyRoots := firstNonEmpty(*legacyRootsFlag, os.Getenv(legacyFolderRootsEnv))
|
||||||
if legacyRoots == "" {
|
|
||||||
legacyRoots = strings.TrimSpace(os.Getenv(legacyFolderRootsEnv))
|
|
||||||
}
|
|
||||||
|
|
||||||
storageRoots := strings.TrimSpace(*storageRootsFlag)
|
storageRoots := firstNonEmpty(
|
||||||
if storageRoots == "" {
|
*storageRootsFlag,
|
||||||
storageRoots = strings.TrimSpace(os.Getenv(serverStorageRootsEnv))
|
os.Getenv(serverStorageRootsEnv),
|
||||||
}
|
config.StorageRoots,
|
||||||
|
)
|
||||||
if storageRoots == "" {
|
if storageRoots == "" {
|
||||||
storageRoots = legacyRoots
|
storageRoots = legacyRoots
|
||||||
}
|
}
|
||||||
|
|
||||||
archiveRoots := strings.TrimSpace(*archiveRootsFlag)
|
archiveRoots := firstNonEmpty(
|
||||||
if archiveRoots == "" {
|
*archiveRootsFlag,
|
||||||
archiveRoots = strings.TrimSpace(os.Getenv(serverArchiveRootsEnv))
|
os.Getenv(serverArchiveRootsEnv),
|
||||||
}
|
config.ArchiveRoots,
|
||||||
|
)
|
||||||
if archiveRoots == "" {
|
if archiveRoots == "" {
|
||||||
archiveRoots = legacyRoots
|
archiveRoots = legacyRoots
|
||||||
}
|
}
|
||||||
|
|
||||||
token := strings.TrimSpace(*tokenFlag)
|
token := firstNonEmpty(*tokenFlag, os.Getenv(agentTokenEnv), config.Token)
|
||||||
if token == "" {
|
|
||||||
token = strings.TrimSpace(os.Getenv(agentTokenEnv))
|
|
||||||
}
|
|
||||||
|
|
||||||
tokenFile := strings.TrimSpace(*tokenFileFlag)
|
tokenFile := firstNonEmpty(*tokenFileFlag, os.Getenv(agentTokenFileEnv), config.TokenFile)
|
||||||
if tokenFile == "" {
|
|
||||||
tokenFile = strings.TrimSpace(os.Getenv(agentTokenFileEnv))
|
|
||||||
}
|
|
||||||
if tokenFile == "" {
|
if tokenFile == "" {
|
||||||
|
if loadedConfigFile {
|
||||||
|
config.TokenConfigFile = configPath
|
||||||
|
} else {
|
||||||
tokenFile = defaultTokenFilePath()
|
tokenFile = defaultTokenFilePath()
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
config.TokenConfigFile = ""
|
||||||
|
}
|
||||||
|
|
||||||
return appConfig{
|
return appConfig{
|
||||||
ListenAddr: listenAddr,
|
ListenAddr: listenAddr,
|
||||||
@ -224,9 +284,118 @@ func readConfigFromFlagsAndEnv() appConfig {
|
|||||||
ArchiveRoots: archiveRoots,
|
ArchiveRoots: archiveRoots,
|
||||||
Token: token,
|
Token: token,
|
||||||
TokenFile: tokenFile,
|
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 {
|
func defaultTokenFilePath() string {
|
||||||
if exePath, err := os.Executable(); err == nil {
|
if exePath, err := os.Executable(); err == nil {
|
||||||
return filepath.Join(filepath.Dir(exePath), defaultTokenFileName)
|
return filepath.Join(filepath.Dir(exePath), defaultTokenFileName)
|
||||||
@ -810,23 +979,27 @@ func folderHasVisibleSubdirectories(pathValue string, roots []serverFolderRoot)
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// tokenStore hält den zur Laufzeit gültigen Agent-Token. Der Token wird in einer
|
// tokenStore hält den zur Laufzeit gültigen Agent-Token. Der Token wird in der
|
||||||
// Datei persistiert, damit er einen Neustart des Agents übersteht.
|
// JSON-Config oder einer separaten Datei persistiert.
|
||||||
type tokenStore struct {
|
type tokenStore struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
token string
|
token string
|
||||||
filePath string
|
filePath string
|
||||||
|
configFilePath string
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTokenStore(filePath string, seed string) *tokenStore {
|
func newTokenStore(config appConfig) *tokenStore {
|
||||||
store := &tokenStore{filePath: strings.TrimSpace(filePath)}
|
store := &tokenStore{
|
||||||
|
filePath: strings.TrimSpace(config.TokenFile),
|
||||||
|
configFilePath: strings.TrimSpace(config.TokenConfigFile),
|
||||||
|
}
|
||||||
|
|
||||||
if persisted := store.loadFromFile(); persisted != "" {
|
if persisted := store.loadFromFile(); persisted != "" {
|
||||||
store.token = persisted
|
store.token = persisted
|
||||||
return store
|
return store
|
||||||
}
|
}
|
||||||
|
|
||||||
if seed = strings.TrimSpace(seed); seed != "" {
|
if seed := strings.TrimSpace(config.Token); seed != "" {
|
||||||
store.token = seed
|
store.token = seed
|
||||||
|
|
||||||
if err := store.persist(seed); err != nil {
|
if err := store.persist(seed); err != nil {
|
||||||
@ -860,10 +1033,7 @@ func (t *tokenStore) set(token string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (t *tokenStore) loadFromFile() string {
|
func (t *tokenStore) loadFromFile() string {
|
||||||
if t.filePath == "" {
|
if t.filePath != "" {
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := os.ReadFile(t.filePath)
|
data, err := os.ReadFile(t.filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
@ -872,14 +1042,30 @@ func (t *tokenStore) loadFromFile() string {
|
|||||||
return strings.TrimSpace(string(data))
|
return strings.TrimSpace(string(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *tokenStore) persist(token string) error {
|
if t.configFilePath == "" {
|
||||||
if t.filePath == "" {
|
return ""
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
config, err := readAgentConfigFile(t.configFilePath)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(config.Token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tokenStore) persist(token string) error {
|
||||||
|
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 {
|
type setAgentTokenRequest struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,6 +18,7 @@ type DeviceHistoryEntry struct {
|
|||||||
DeviceID string `json:"deviceId"`
|
DeviceID string `json:"deviceId"`
|
||||||
UserID string `json:"userId"`
|
UserID string `json:"userId"`
|
||||||
UserDisplayName string `json:"userDisplayName"`
|
UserDisplayName string `json:"userDisplayName"`
|
||||||
|
UserAvatar string `json:"userAvatar"`
|
||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
Changes []DeviceHistoryChange `json:"changes"`
|
Changes []DeviceHistoryChange `json:"changes"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
@ -58,6 +59,7 @@ func (s *Server) handleListDeviceHistory(w http.ResponseWriter, r *http.Request)
|
|||||||
h.device_id,
|
h.device_id,
|
||||||
COALESCE(h.user_id::TEXT, ''),
|
COALESCE(h.user_id::TEXT, ''),
|
||||||
COALESCE(NULLIF(u.display_name, ''), NULLIF(u.username, ''), u.email, 'Unbekannt'),
|
COALESCE(NULLIF(u.display_name, ''), NULLIF(u.username, ''), u.email, 'Unbekannt'),
|
||||||
|
COALESCE(u.avatar, ''),
|
||||||
h.action,
|
h.action,
|
||||||
h.changes,
|
h.changes,
|
||||||
h.created_at,
|
h.created_at,
|
||||||
@ -87,6 +89,7 @@ func (s *Server) handleListDeviceHistory(w http.ResponseWriter, r *http.Request)
|
|||||||
&entry.DeviceID,
|
&entry.DeviceID,
|
||||||
&entry.UserID,
|
&entry.UserID,
|
||||||
&entry.UserDisplayName,
|
&entry.UserDisplayName,
|
||||||
|
&entry.UserAvatar,
|
||||||
&entry.Action,
|
&entry.Action,
|
||||||
&changesRaw,
|
&changesRaw,
|
||||||
&entry.CreatedAt,
|
&entry.CreatedAt,
|
||||||
@ -248,27 +251,35 @@ func getDeviceForHistory(ctx context.Context, tx pgx.Tx, deviceID string) (Devic
|
|||||||
ctx,
|
ctx,
|
||||||
`
|
`
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
devices.id,
|
||||||
inventory_number,
|
COALESCE(devices.inventory_number, ''),
|
||||||
manufacturer,
|
COALESCE(devices.manufacturer, ''),
|
||||||
model,
|
COALESCE(devices.model, ''),
|
||||||
serial_number,
|
COALESCE(devices.serial_number, ''),
|
||||||
mac_address,
|
COALESCE(devices.mac_address, ''),
|
||||||
COALESCE(ip_address, ''),
|
COALESCE(devices.ip_address, ''),
|
||||||
COALESCE(firmware_version, ''),
|
COALESCE(devices.phone_number, ''),
|
||||||
COALESCE(milestone_recording_server_id, ''),
|
COALESCE(devices.computer_name, ''),
|
||||||
COALESCE(milestone_hardware_id, ''),
|
COALESCE(devices.device_category, ''),
|
||||||
COALESCE(milestone_display_name, ''),
|
COALESCE(devices.firmware_version, ''),
|
||||||
milestone_enabled,
|
COALESCE(devices.milestone_recording_server_id, ''),
|
||||||
milestone_synced_at,
|
COALESCE(devices.milestone_hardware_id, ''),
|
||||||
location,
|
COALESCE(devices.milestone_display_name, ''),
|
||||||
loan_status,
|
COALESCE(devices.milestone_enabled, true),
|
||||||
is_bao_device,
|
devices.milestone_synced_at,
|
||||||
comment,
|
COALESCE(devices.location, ''),
|
||||||
created_at,
|
COALESCE(devices.loan_status, 'Verfügbar'),
|
||||||
updated_at
|
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
|
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
|
LIMIT 1
|
||||||
`,
|
`,
|
||||||
deviceID,
|
deviceID,
|
||||||
@ -280,6 +291,9 @@ func getDeviceForHistory(ctx context.Context, tx pgx.Tx, deviceID string) (Devic
|
|||||||
&device.SerialNumber,
|
&device.SerialNumber,
|
||||||
&device.MacAddress,
|
&device.MacAddress,
|
||||||
&device.IPAddress,
|
&device.IPAddress,
|
||||||
|
&device.PhoneNumber,
|
||||||
|
&device.ComputerName,
|
||||||
|
&device.DeviceCategory,
|
||||||
&device.FirmwareVersion,
|
&device.FirmwareVersion,
|
||||||
&device.MilestoneRecordingServerID,
|
&device.MilestoneRecordingServerID,
|
||||||
&device.MilestoneHardwareID,
|
&device.MilestoneHardwareID,
|
||||||
@ -288,6 +302,9 @@ func getDeviceForHistory(ctx context.Context, tx pgx.Tx, deviceID string) (Devic
|
|||||||
&device.MilestoneSyncedAt,
|
&device.MilestoneSyncedAt,
|
||||||
&device.Location,
|
&device.Location,
|
||||||
&device.LoanStatus,
|
&device.LoanStatus,
|
||||||
|
&device.LoanedToUserID,
|
||||||
|
&device.LoanedToDisplayName,
|
||||||
|
&device.LoanedUntil,
|
||||||
&device.IsBaoDevice,
|
&device.IsBaoDevice,
|
||||||
&device.Comment,
|
&device.Comment,
|
||||||
&device.CreatedAt,
|
&device.CreatedAt,
|
||||||
@ -537,8 +554,17 @@ func buildDeviceCreateHistoryChanges(
|
|||||||
changes = appendHistoryChange(changes, "model", "Modell", "", input.Model)
|
changes = appendHistoryChange(changes, "model", "Modell", "", input.Model)
|
||||||
changes = appendHistoryChange(changes, "serialNumber", "Seriennummer", "", input.SerialNumber)
|
changes = appendHistoryChange(changes, "serialNumber", "Seriennummer", "", input.SerialNumber)
|
||||||
changes = appendHistoryChange(changes, "macAddress", "MAC-Adresse", "", input.MacAddress)
|
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, "location", "Standort", "", input.Location)
|
||||||
changes = appendHistoryChange(changes, "loanStatus", "Verleih-Status", "", input.LoanStatus)
|
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, "isBaoDevice", "BAO-Gerät", "Nein", formatHistoryBool(input.IsBaoDevice))
|
||||||
changes = appendHistoryChange(changes, "comment", "Kommentar", "", input.Comment)
|
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, "model", "Modell", oldDevice.Model, input.Model)
|
||||||
changes = appendHistoryChange(changes, "serialNumber", "Seriennummer", oldDevice.SerialNumber, input.SerialNumber)
|
changes = appendHistoryChange(changes, "serialNumber", "Seriennummer", oldDevice.SerialNumber, input.SerialNumber)
|
||||||
changes = appendHistoryChange(changes, "macAddress", "MAC-Adresse", oldDevice.MacAddress, input.MacAddress)
|
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, "location", "Standort", oldDevice.Location, input.Location)
|
||||||
changes = appendHistoryChange(changes, "loanStatus", "Verleih-Status", oldDevice.LoanStatus, input.LoanStatus)
|
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, "isBaoDevice", "BAO-Gerät", formatHistoryBool(oldDevice.IsBaoDevice), formatHistoryBool(input.IsBaoDevice))
|
||||||
changes = appendHistoryChange(changes, "comment", "Kommentar", oldDevice.Comment, input.Comment)
|
changes = appendHistoryChange(changes, "comment", "Kommentar", oldDevice.Comment, input.Comment)
|
||||||
changes = appendHistoryChange(changes, "milestoneDisplayName", "Milestone-Anzeigename", oldDevice.MilestoneDisplayName, input.MilestoneDisplayName)
|
changes = appendHistoryChange(changes, "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
|
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 {
|
func normalizeDeviceIDs(deviceIDs []string) []string {
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
result := []string{}
|
result := []string{}
|
||||||
|
|||||||
@ -36,6 +36,10 @@ func main() {
|
|||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := ensureRuntimeDeviceSchema(ctx, db); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
server := NewServer(db, jwtSecret, frontendOrigin)
|
server := NewServer(db, jwtSecret, frontendOrigin)
|
||||||
server.startPresenceMonitor(ctx)
|
server.startPresenceMonitor(ctx)
|
||||||
server.startDisappearingMessagesMonitor(ctx)
|
server.startDisappearingMessagesMonitor(ctx)
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@ -10,6 +11,8 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
@ -22,6 +25,8 @@ type milestoneCameraPatchDevice struct {
|
|||||||
MilestoneHardwareID string
|
MilestoneHardwareID string
|
||||||
MilestoneDeviceID string
|
MilestoneDeviceID string
|
||||||
DeviceType string
|
DeviceType string
|
||||||
|
IPAddress string
|
||||||
|
MilestonePort string
|
||||||
Enabled bool
|
Enabled bool
|
||||||
RecordingEnabled bool
|
RecordingEnabled bool
|
||||||
}
|
}
|
||||||
@ -41,6 +46,7 @@ type milestoneCameraStream struct {
|
|||||||
JPEGQuality *int `json:"jPEGQuality"`
|
JPEGQuality *int `json:"jPEGQuality"`
|
||||||
Quality *int `json:"quality"`
|
Quality *int `json:"quality"`
|
||||||
Resolution string `json:"resolution"`
|
Resolution string `json:"resolution"`
|
||||||
|
AvailableResolutions []string `json:"availableResolutions,omitempty"`
|
||||||
EdgeStorageSupported bool `json:"edgeStorageSupported"`
|
EdgeStorageSupported bool `json:"edgeStorageSupported"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -75,6 +81,119 @@ type updateMilestoneCameraDetailsRequest struct {
|
|||||||
Streams []updateMilestoneCameraStreamRequest `json:"streams"`
|
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(
|
func (s *Server) getMilestoneCameraPatchDevice(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
deviceID string,
|
deviceID string,
|
||||||
@ -92,6 +211,8 @@ func (s *Server) getMilestoneCameraPatchDevice(
|
|||||||
milestone_hardware_child_devices.milestone_hardware_id,
|
milestone_hardware_child_devices.milestone_hardware_id,
|
||||||
milestone_hardware_child_devices.milestone_device_id,
|
milestone_hardware_child_devices.milestone_device_id,
|
||||||
milestone_hardware_child_devices.device_type,
|
milestone_hardware_child_devices.device_type,
|
||||||
|
COALESCE(devices.ip_address, ''),
|
||||||
|
COALESCE(devices.milestone_port, ''),
|
||||||
milestone_hardware_child_devices.enabled,
|
milestone_hardware_child_devices.enabled,
|
||||||
COALESCE(milestone_hardware_child_devices.recording_enabled, false)
|
COALESCE(milestone_hardware_child_devices.recording_enabled, false)
|
||||||
FROM milestone_hardware_child_devices
|
FROM milestone_hardware_child_devices
|
||||||
@ -110,6 +231,8 @@ func (s *Server) getMilestoneCameraPatchDevice(
|
|||||||
&camera.MilestoneHardwareID,
|
&camera.MilestoneHardwareID,
|
||||||
&camera.MilestoneDeviceID,
|
&camera.MilestoneDeviceID,
|
||||||
&camera.DeviceType,
|
&camera.DeviceType,
|
||||||
|
&camera.IPAddress,
|
||||||
|
&camera.MilestonePort,
|
||||||
&camera.Enabled,
|
&camera.Enabled,
|
||||||
&camera.RecordingEnabled,
|
&camera.RecordingEnabled,
|
||||||
)
|
)
|
||||||
@ -382,6 +505,10 @@ func extractMilestoneCameraStreams(cameraData map[string]any) []milestoneCameraS
|
|||||||
Resolution: milestoneStringValue(streamMap["resolution"]),
|
Resolution: milestoneStringValue(streamMap["resolution"]),
|
||||||
EdgeStorageSupported: milestoneBoolValue(streamMap["edgeStorageSupported"]),
|
EdgeStorageSupported: milestoneBoolValue(streamMap["edgeStorageSupported"]),
|
||||||
}
|
}
|
||||||
|
stream.AvailableResolutions = milestoneAvailableResolutions(
|
||||||
|
streamMap,
|
||||||
|
stream.Resolution,
|
||||||
|
)
|
||||||
|
|
||||||
if value, ok := milestoneIntPointerValue(streamMap["FPS"]); ok {
|
if value, ok := milestoneIntPointerValue(streamMap["FPS"]); ok {
|
||||||
stream.FPS = value
|
stream.FPS = value
|
||||||
@ -404,6 +531,8 @@ func extractMilestoneCameraStreams(cameraData map[string]any) []milestoneCameraS
|
|||||||
|
|
||||||
type milestoneCameraListItem struct {
|
type milestoneCameraListItem struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
LocalDeviceID string `json:"localDeviceId,omitempty"`
|
||||||
|
LocalChildID string `json:"localChildId,omitempty"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
@ -414,6 +543,17 @@ type milestoneCameraListItem struct {
|
|||||||
HardwareDisplayName string `json:"hardwareDisplayName"`
|
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(
|
func (s *Server) getMilestoneHardwareDisplayNameMap(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
) (map[string]string, error) {
|
) (map[string]string, error) {
|
||||||
@ -461,11 +601,77 @@ func (s *Server) getMilestoneHardwareDisplayNameMap(
|
|||||||
return out, nil
|
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(
|
func getMilestoneDeviceList(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
config milestoneRuntimeConfig,
|
config milestoneRuntimeConfig,
|
||||||
collection string,
|
collection string,
|
||||||
hardwareDisplayNames map[string]string,
|
hardwareDisplayNames map[string]string,
|
||||||
|
localDeviceRefs map[string]milestoneLocalDeviceRef,
|
||||||
) ([]milestoneCameraListItem, error) {
|
) ([]milestoneCameraListItem, error) {
|
||||||
requestURL := strings.TrimRight(config.Host, "/") +
|
requestURL := strings.TrimRight(config.Host, "/") +
|
||||||
"/API/rest/v1/" + collection + "?disabled"
|
"/API/rest/v1/" + collection + "?disabled"
|
||||||
@ -557,9 +763,12 @@ func getMilestoneDeviceList(
|
|||||||
|
|
||||||
hardwareID := milestoneNamedDeviceParentID(item)
|
hardwareID := milestoneNamedDeviceParentID(item)
|
||||||
hardwareDisplayName := strings.TrimSpace(hardwareDisplayNames[hardwareID])
|
hardwareDisplayName := strings.TrimSpace(hardwareDisplayNames[hardwareID])
|
||||||
|
localDeviceRef := localDeviceRefs[milestoneLocalDeviceRefKey(hardwareID, deviceType, id)]
|
||||||
|
|
||||||
items = append(items, milestoneCameraListItem{
|
items = append(items, milestoneCameraListItem{
|
||||||
ID: id,
|
ID: id,
|
||||||
|
LocalDeviceID: localDeviceRef.DeviceID,
|
||||||
|
LocalChildID: localDeviceRef.ChildID,
|
||||||
Name: name,
|
Name: name,
|
||||||
DisplayName: displayName,
|
DisplayName: displayName,
|
||||||
Type: deviceType,
|
Type: deviceType,
|
||||||
@ -587,14 +796,20 @@ func (s *Server) handleListMilestoneCameras(w http.ResponseWriter, r *http.Reque
|
|||||||
return
|
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 {
|
if err != nil {
|
||||||
log.Printf("Milestone cameras list failed: %v", err)
|
log.Printf("Milestone cameras list failed: %v", err)
|
||||||
writeError(w, http.StatusBadGateway, "Milestone-Kameras konnten nicht geladen werden")
|
writeError(w, http.StatusBadGateway, "Milestone-Kameras konnten nicht geladen werden")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
microphones, err := getMilestoneDeviceList(r.Context(), config, "microphones", hardwareDisplayNames)
|
microphones, err := getMilestoneDeviceList(r.Context(), config, "microphones", hardwareDisplayNames, localDeviceRefs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Milestone microphones list failed: %v", err)
|
log.Printf("Milestone microphones list failed: %v", err)
|
||||||
writeError(w, http.StatusBadGateway, "Milestone-Mikrofone konnten nicht geladen werden")
|
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,
|
serial_number,
|
||||||
mac_address,
|
mac_address,
|
||||||
ip_address,
|
ip_address,
|
||||||
|
device_category,
|
||||||
milestone_port,
|
milestone_port,
|
||||||
location,
|
location,
|
||||||
loan_status,
|
loan_status,
|
||||||
@ -1057,6 +1058,7 @@ func createOrUpdateMilestoneHardwareDevice(
|
|||||||
$4,
|
$4,
|
||||||
$5,
|
$5,
|
||||||
$6,
|
$6,
|
||||||
|
'Kameras',
|
||||||
$7,
|
$7,
|
||||||
'',
|
'',
|
||||||
'Verfügbar',
|
'Verfügbar',
|
||||||
@ -1078,6 +1080,7 @@ func createOrUpdateMilestoneHardwareDevice(
|
|||||||
serial_number = EXCLUDED.serial_number,
|
serial_number = EXCLUDED.serial_number,
|
||||||
mac_address = EXCLUDED.mac_address,
|
mac_address = EXCLUDED.mac_address,
|
||||||
ip_address = EXCLUDED.ip_address,
|
ip_address = EXCLUDED.ip_address,
|
||||||
|
device_category = 'Kameras',
|
||||||
milestone_port = EXCLUDED.milestone_port,
|
milestone_port = EXCLUDED.milestone_port,
|
||||||
firmware_version = EXCLUDED.firmware_version,
|
firmware_version = EXCLUDED.firmware_version,
|
||||||
milestone_recording_server_id = EXCLUDED.milestone_recording_server_id,
|
milestone_recording_server_id = EXCLUDED.milestone_recording_server_id,
|
||||||
|
|||||||
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"`
|
OperationID string `json:"operationId"`
|
||||||
UserID string `json:"userId"`
|
UserID string `json:"userId"`
|
||||||
UserDisplayName string `json:"userDisplayName"`
|
UserDisplayName string `json:"userDisplayName"`
|
||||||
|
UserAvatar string `json:"userAvatar"`
|
||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
Changes []OperationHistoryChange `json:"changes"`
|
Changes []OperationHistoryChange `json:"changes"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
@ -648,6 +649,7 @@ func (s *Server) handleListOperationHistory(w http.ResponseWriter, r *http.Reque
|
|||||||
h.operation_id,
|
h.operation_id,
|
||||||
COALESCE(h.user_id::TEXT, ''),
|
COALESCE(h.user_id::TEXT, ''),
|
||||||
COALESCE(NULLIF(u.display_name, ''), NULLIF(u.username, ''), u.email, 'Unbekannt'),
|
COALESCE(NULLIF(u.display_name, ''), NULLIF(u.username, ''), u.email, 'Unbekannt'),
|
||||||
|
COALESCE(u.avatar, ''),
|
||||||
h.action,
|
h.action,
|
||||||
h.changes,
|
h.changes,
|
||||||
h.created_at,
|
h.created_at,
|
||||||
@ -677,6 +679,7 @@ func (s *Server) handleListOperationHistory(w http.ResponseWriter, r *http.Reque
|
|||||||
&entry.OperationID,
|
&entry.OperationID,
|
||||||
&entry.UserID,
|
&entry.UserID,
|
||||||
&entry.UserDisplayName,
|
&entry.UserDisplayName,
|
||||||
|
&entry.UserAvatar,
|
||||||
&entry.Action,
|
&entry.Action,
|
||||||
&changesRaw,
|
&changesRaw,
|
||||||
&entry.CreatedAt,
|
&entry.CreatedAt,
|
||||||
|
|||||||
@ -36,6 +36,7 @@ func (s *Server) Routes() http.Handler {
|
|||||||
mux.HandleFunc("GET /devices", s.requireAuth(s.handleListDevices))
|
mux.HandleFunc("GET /devices", s.requireAuth(s.handleListDevices))
|
||||||
mux.HandleFunc("POST /devices", s.requireAuth(s.handleCreateDevice))
|
mux.HandleFunc("POST /devices", s.requireAuth(s.handleCreateDevice))
|
||||||
mux.HandleFunc("PUT /devices/{id}", s.requireAuth(s.handleUpdateDevice))
|
mux.HandleFunc("PUT /devices/{id}", s.requireAuth(s.handleUpdateDevice))
|
||||||
|
mux.HandleFunc("GET /device-loan-users", s.requireAuth(s.handleListDeviceLoanUsers))
|
||||||
|
|
||||||
mux.HandleFunc("GET /devices/{id}/history", s.requireAuth(s.handleListDeviceHistory))
|
mux.HandleFunc("GET /devices/{id}/history", s.requireAuth(s.handleListDeviceHistory))
|
||||||
mux.HandleFunc("POST /devices/{id}/history", s.requireAuth(s.handleCreateDeviceJournalEntry))
|
mux.HandleFunc("POST /devices/{id}/history", s.requireAuth(s.handleCreateDeviceJournalEntry))
|
||||||
@ -55,6 +56,22 @@ func (s *Server) Routes() http.Handler {
|
|||||||
"PATCH /devices/{id}/milestone-cameras/{childId}",
|
"PATCH /devices/{id}/milestone-cameras/{childId}",
|
||||||
s.requireAuth(s.handleUpdateMilestoneCameraDetails),
|
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("GET /devices/firmware-check", s.requireAuth(s.handleGetCameraFirmwareCheckState))
|
||||||
mux.HandleFunc("POST /devices/firmware-check", s.requireAuth(s.handleCheckCameraFirmware))
|
mux.HandleFunc("POST /devices/firmware-check", s.requireAuth(s.handleCheckCameraFirmware))
|
||||||
@ -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("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("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("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("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)))
|
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
|
Token string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type serverFoldersAgentHTTPResponse struct {
|
||||||
|
StatusCode int
|
||||||
|
Body []byte
|
||||||
|
ContentType string
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleProxyListServerFolders(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleProxyListServerFolders(w http.ResponseWriter, r *http.Request) {
|
||||||
s.proxyServerFoldersRequest(w, r, http.MethodGet)
|
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)
|
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) {
|
func (s *Server) proxyServerFoldersRequest(w http.ResponseWriter, r *http.Request, method string) {
|
||||||
config, err := s.getServerFoldersAgentConfig(r.Context())
|
config, err := s.getServerFoldersAgentConfig(r.Context())
|
||||||
if err != nil {
|
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(),
|
r.Context(),
|
||||||
method,
|
method,
|
||||||
targetURL,
|
targetURL,
|
||||||
bytes.NewReader(body),
|
body,
|
||||||
|
config,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadGateway, "Proxy-Request konnte nicht erstellt werden")
|
writeError(w, http.StatusBadGateway, "Proxy-Request konnte nicht erstellt werden")
|
||||||
return
|
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{
|
client := &http.Client{
|
||||||
Timeout: 20 * time.Second,
|
Timeout: 20 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
response, err := client.Do(request)
|
agentResponse, err := sendServerFoldersAgentHTTPRequest(client, request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadGateway, "Server-Folders-Agent ist nicht erreichbar")
|
writeError(w, http.StatusBadGateway, "Server-Folders-Agent ist nicht erreichbar")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer response.Body.Close()
|
|
||||||
|
|
||||||
responseBody, err := io.ReadAll(io.LimitReader(response.Body, 4<<20))
|
if agentResponse.StatusCode == http.StatusUnauthorized {
|
||||||
|
refreshedConfig, err := s.rotateServerFoldersAgentToken(r.Context(), config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadGateway, "Antwort vom Server-Folders-Agent konnte nicht gelesen werden")
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
contentType := response.Header.Get("Content-Type")
|
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 := agentResponse.ContentType
|
||||||
if strings.TrimSpace(contentType) == "" {
|
if strings.TrimSpace(contentType) == "" {
|
||||||
contentType = "application/json"
|
contentType = "application/json"
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", contentType)
|
w.Header().Set("Content-Type", contentType)
|
||||||
w.WriteHeader(response.StatusCode)
|
w.WriteHeader(agentResponse.StatusCode)
|
||||||
_, _ = w.Write(responseBody)
|
_, _ = w.Write(agentResponse.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) requestServerFoldersAgent(
|
func (s *Server) requestServerFoldersAgent(
|
||||||
@ -133,51 +215,60 @@ func (s *Server) requestServerFoldersAgent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
request, err := http.NewRequestWithContext(
|
request, err := newServerFoldersAgentHTTPRequest(
|
||||||
ctx,
|
ctx,
|
||||||
method,
|
method,
|
||||||
targetURL,
|
targetURL,
|
||||||
bytes.NewReader(body),
|
body,
|
||||||
|
config,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
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{
|
client := &http.Client{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
response, err := client.Do(request)
|
agentResponse, err := sendServerFoldersAgentHTTPRequest(client, request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
defer response.Body.Close()
|
|
||||||
|
|
||||||
responseBody, err := io.ReadAll(io.LimitReader(response.Body, 4<<20))
|
if agentResponse.StatusCode == http.StatusUnauthorized {
|
||||||
|
refreshedConfig, err := s.rotateServerFoldersAgentToken(ctx, config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, response.StatusCode, err
|
return agentResponse.Body, agentResponse.StatusCode, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
request, err = newServerFoldersAgentHTTPRequest(
|
||||||
return responseBody, response.StatusCode, fmt.Errorf(
|
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 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",
|
"server-folders-agent request failed: method=%s url=%s status=%d body=%s",
|
||||||
method,
|
method,
|
||||||
targetURL,
|
targetURL,
|
||||||
response.StatusCode,
|
agentResponse.StatusCode,
|
||||||
string(responseBody),
|
string(agentResponse.Body),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return responseBody, response.StatusCode, nil
|
return agentResponse.Body, agentResponse.StatusCode, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type serverFolderAgentFolderResponse struct {
|
type serverFolderAgentFolderResponse struct {
|
||||||
@ -413,6 +504,38 @@ func (s *Server) pushServerFoldersAgentToken(
|
|||||||
return nil
|
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 {
|
func forwardedServerFoldersQuery(values url.Values) string {
|
||||||
nextValues := url.Values{}
|
nextValues := url.Values{}
|
||||||
|
|
||||||
|
|||||||
@ -417,8 +417,13 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
|||||||
serial_number TEXT NOT NULL DEFAULT '',
|
serial_number TEXT NOT NULL DEFAULT '',
|
||||||
mac_address TEXT NOT NULL DEFAULT '',
|
mac_address TEXT NOT NULL DEFAULT '',
|
||||||
ip_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 '',
|
location TEXT NOT NULL DEFAULT '',
|
||||||
loan_status TEXT NOT NULL DEFAULT 'Verfügbar',
|
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,
|
is_bao_device BOOLEAN NOT NULL DEFAULT false,
|
||||||
comment TEXT NOT NULL DEFAULT '',
|
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()
|
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 (
|
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 ────────────────────────────────────────────────────────────
|
// ── Chat ────────────────────────────────────────────────────────────
|
||||||
// Eine Konversation ist entweder ein Team-Gruppenchat, Einzelchat,
|
// Eine Konversation ist entweder ein Team-Gruppenchat, Einzelchat,
|
||||||
// freier Gruppenchat oder ein schreibgeschützter Integrations-Channel.
|
// 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 { useCallback, useEffect, useState } from 'react'
|
||||||
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router'
|
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 type { Notification, User } from './components/types'
|
||||||
import AdministrationPage from './pages/administration/AdministrationPage'
|
import AdministrationPage from './pages/administration/AdministrationPage'
|
||||||
import Feedback from './components/Feedback'
|
import Feedback from './components/Feedback'
|
||||||
import MilestoneStoragePage from './pages/operations/milestone-storage/MilestoneStoragePage'
|
import MilestonePage from './pages/milestone/MilestonePage'
|
||||||
import MilestoneDevicesPage from './pages/operations/milestone-devices/MilestoneDevicesPage'
|
import MilestoneStoragePage from './pages/milestone/speicher/MilestoneStoragePage'
|
||||||
|
import MilestoneDevicesPage from './pages/milestone/geraete/MilestoneDevicesPage'
|
||||||
import { hasRight } from './components/permissions'
|
import { hasRight } from './components/permissions'
|
||||||
import ChatPage from './pages/chat/ChatPage'
|
import ChatPage from './pages/chat/ChatPage'
|
||||||
|
import { ChatSocketProvider } from './components/ChatSocketProvider'
|
||||||
import { recordNavigation } from './utils/activityLog'
|
import { recordNavigation } from './utils/activityLog'
|
||||||
import { syncExistingPushSubscription } from './utils/pushNotifications'
|
import { syncExistingPushSubscription } from './utils/pushNotifications'
|
||||||
import { notifyNewMessage } from './utils/titleNotifier'
|
import { notifyNewMessage } from './utils/titleNotifier'
|
||||||
@ -624,6 +626,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<ChatSocketProvider>
|
||||||
<AppLayout
|
<AppLayout
|
||||||
user={user}
|
user={user}
|
||||||
onUserUpdated={setUser}
|
onUserUpdated={setUser}
|
||||||
@ -646,7 +649,7 @@ function App() {
|
|||||||
path="/geraete"
|
path="/geraete"
|
||||||
element={
|
element={
|
||||||
<RequireRight user={user} right="devices:read">
|
<RequireRight user={user} right="devices:read">
|
||||||
<DevicesPage />
|
<DevicesPage currentUser={user} />
|
||||||
</RequireRight>
|
</RequireRight>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@ -659,13 +662,34 @@ function App() {
|
|||||||
unreadJournalOperationIds={unreadJournalOperationIds}
|
unreadJournalOperationIds={unreadJournalOperationIds}
|
||||||
onJournalRead={markOperationJournalRead}
|
onJournalRead={markOperationJournalRead}
|
||||||
canWriteOperations={hasRight(user, 'operations:write')}
|
canWriteOperations={hasRight(user, 'operations:write')}
|
||||||
|
currentUser={user}
|
||||||
/>
|
/>
|
||||||
</RequireRight>
|
</RequireRight>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/einsaetze/milestone-speicher"
|
path="/milestone"
|
||||||
|
element={
|
||||||
|
<RequireRight user={user} right="administration:read">
|
||||||
|
<MilestonePage />
|
||||||
|
</RequireRight>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/milestone/geraete"
|
||||||
|
element={
|
||||||
|
<RequireRight user={user} right="administration:read">
|
||||||
|
<MilestoneDevicesPage
|
||||||
|
canWrite={hasRight(user, 'administration:write')}
|
||||||
|
/>
|
||||||
|
</RequireRight>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/milestone/speicher"
|
||||||
element={
|
element={
|
||||||
<RequireRight user={user} right="administration:read">
|
<RequireRight user={user} right="administration:read">
|
||||||
<MilestoneStoragePage
|
<MilestoneStoragePage
|
||||||
@ -676,14 +700,13 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/einsaetze/milestone-devices"
|
path="/einsaetze/milestone-speicher"
|
||||||
element={
|
element={<Navigate to="/milestone/speicher" replace />}
|
||||||
<RequireRight user={user} right="administration:read">
|
|
||||||
<MilestoneDevicesPage
|
|
||||||
canWrite={hasRight(user, 'administration:write')}
|
|
||||||
/>
|
/>
|
||||||
</RequireRight>
|
|
||||||
}
|
<Route
|
||||||
|
path="/einsaetze/milestone-devices"
|
||||||
|
element={<Navigate to="/milestone/geraete" replace />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
@ -757,6 +780,7 @@ function App() {
|
|||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</AppLayout>
|
</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'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, type ComponentType, type SVGProps } from 'react'
|
||||||
import {
|
import {
|
||||||
Combobox as HeadlessCombobox,
|
Combobox as HeadlessCombobox,
|
||||||
ComboboxButton,
|
ComboboxButton,
|
||||||
@ -27,6 +27,7 @@ export type ComboboxItem = {
|
|||||||
|
|
||||||
type ComboboxProps = {
|
type ComboboxProps = {
|
||||||
label?: string
|
label?: string
|
||||||
|
labelIcon?: ComponentType<SVGProps<SVGSVGElement>>
|
||||||
items: ComboboxItem[]
|
items: ComboboxItem[]
|
||||||
value: ComboboxItem | null
|
value: ComboboxItem | null
|
||||||
onChange: (item: ComboboxItem | null) => void
|
onChange: (item: ComboboxItem | null) => void
|
||||||
@ -51,6 +52,7 @@ function getSecondaryText(item: ComboboxItem) {
|
|||||||
|
|
||||||
export default function Combobox({
|
export default function Combobox({
|
||||||
label,
|
label,
|
||||||
|
labelIcon: LabelIcon,
|
||||||
items,
|
items,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@ -154,8 +156,18 @@ export default function Combobox({
|
|||||||
className={className}
|
className={className}
|
||||||
>
|
>
|
||||||
{label && (
|
{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>
|
</Label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
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
|
// frontend\src\components\Journal.tsx
|
||||||
|
|
||||||
import { type ComponentType, type SVGProps, useState } from 'react'
|
import { type ComponentType, type SVGProps, useMemo, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
ArrowRightIcon,
|
ArrowRightIcon,
|
||||||
ChatBubbleLeftEllipsisIcon,
|
ChatBubbleLeftEllipsisIcon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
|
MagnifyingGlassIcon,
|
||||||
PaperAirplaneIcon,
|
PaperAirplaneIcon,
|
||||||
PencilSquareIcon,
|
PencilSquareIcon,
|
||||||
PlusCircleIcon,
|
PlusCircleIcon,
|
||||||
@ -13,6 +14,7 @@ import Avatar from './Avatar'
|
|||||||
import { Badge, type BadgeTone } from './Badge'
|
import { Badge, type BadgeTone } from './Badge'
|
||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
import NotificationDot from './NotificationDot'
|
import NotificationDot from './NotificationDot'
|
||||||
|
import Textarea from './Textarea'
|
||||||
|
|
||||||
type JournalIcon = ComponentType<SVGProps<SVGSVGElement>>
|
type JournalIcon = ComponentType<SVGProps<SVGSVGElement>>
|
||||||
|
|
||||||
@ -56,7 +58,11 @@ export type JournalItem = {
|
|||||||
type JournalProps = {
|
type JournalProps = {
|
||||||
items?: JournalItem[]
|
items?: JournalItem[]
|
||||||
currentUserAvatar?: string
|
currentUserAvatar?: string
|
||||||
|
currentUserName?: string
|
||||||
showEntryForm?: boolean
|
showEntryForm?: boolean
|
||||||
|
showSearch?: boolean
|
||||||
|
searchQuery?: string
|
||||||
|
onSearchQueryChange?: (value: string) => void
|
||||||
isSubmitting?: boolean
|
isSubmitting?: boolean
|
||||||
emptyText?: string
|
emptyText?: string
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
@ -98,6 +104,23 @@ function getChangeKey(change: JournalChange, index: number) {
|
|||||||
return `${change.label}-${index}`
|
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) {
|
function getJournalIcon(item: JournalItem) {
|
||||||
if (item.icon) {
|
if (item.icon) {
|
||||||
return item.icon
|
return item.icon
|
||||||
@ -152,7 +175,11 @@ function getJournalType(item: JournalItem): {
|
|||||||
export default function Journal({
|
export default function Journal({
|
||||||
items = [],
|
items = [],
|
||||||
currentUserAvatar,
|
currentUserAvatar,
|
||||||
|
currentUserName = 'Du',
|
||||||
showEntryForm = true,
|
showEntryForm = true,
|
||||||
|
showSearch = true,
|
||||||
|
searchQuery,
|
||||||
|
onSearchQueryChange,
|
||||||
isSubmitting = false,
|
isSubmitting = false,
|
||||||
emptyText = 'Noch keine Journal-Einträge vorhanden.',
|
emptyText = 'Noch keine Journal-Einträge vorhanden.',
|
||||||
placeholder = 'Journal-Eintrag hinzufügen...',
|
placeholder = 'Journal-Eintrag hinzufügen...',
|
||||||
@ -164,6 +191,27 @@ export default function Journal({
|
|||||||
const [entry, setEntry] = useState('')
|
const [entry, setEntry] = useState('')
|
||||||
const [editingItemId, setEditingItemId] = useState<string | number | null>(null)
|
const [editingItemId, setEditingItemId] = useState<string | number | null>(null)
|
||||||
const [editEntry, setEditEntry] = useState('')
|
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() {
|
async function handleSubmit() {
|
||||||
const content = entry.trim()
|
const content = entry.trim()
|
||||||
@ -199,57 +247,37 @@ export default function Journal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
{showEntryForm && (
|
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<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>
|
<div>
|
||||||
<h4 className="text-sm font-semibold text-gray-950 dark:text-white">
|
<h3 className="text-sm font-semibold text-gray-950 dark:text-white">
|
||||||
Neuer Journal-Eintrag
|
Journal
|
||||||
</h4>
|
</h3>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
Notizen und wichtige Informationen nachvollziehbar festhalten.
|
Verlauf, Änderungen und manuelle Einträge.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start gap-3">
|
{showEntryForm && (
|
||||||
<Avatar
|
<div className="mb-8">
|
||||||
src={currentUserAvatar}
|
<Textarea
|
||||||
name="Du"
|
variant="avatar-actions"
|
||||||
size={9}
|
|
||||||
className="mt-1"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<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"
|
id="journalEntry"
|
||||||
name="journalEntry"
|
name="journalEntry"
|
||||||
|
label="Journal-Eintrag hinzufügen"
|
||||||
|
srOnlyLabel
|
||||||
rows={3}
|
rows={3}
|
||||||
value={entry}
|
value={entry}
|
||||||
onChange={(event) => setEntry(event.target.value)}
|
onChange={(event) => setEntry(event.target.value)}
|
||||||
placeholder={placeholder}
|
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"
|
titlePlaceholder="Titel (optional)"
|
||||||
/>
|
avatarProps={{ src: currentUserAvatar, name: currentUserName }}
|
||||||
|
submitButton={
|
||||||
<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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
color="indigo"
|
color="indigo"
|
||||||
size="sm"
|
size="sm"
|
||||||
rounded="full"
|
|
||||||
isLoading={isSubmitting}
|
isLoading={isSubmitting}
|
||||||
disabled={!entry.trim() || isSubmitting}
|
disabled={!entry.trim() || isSubmitting}
|
||||||
onClick={() => void handleSubmit()}
|
onClick={() => void handleSubmit()}
|
||||||
@ -259,9 +287,27 @@ export default function Journal({
|
|||||||
>
|
>
|
||||||
{submitLabel}
|
{submitLabel}
|
||||||
</Button>
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
|
||||||
|
{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"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -281,10 +327,25 @@ export default function Journal({
|
|||||||
{emptyText}
|
{emptyText}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<div className="flow-root">
|
||||||
<ul role="list" className="space-y-4">
|
<ul role="list" className="space-y-4">
|
||||||
{items.map((item, itemIndex) => {
|
{filteredItems.map((item, itemIndex) => {
|
||||||
const personName = getItemPersonName(item)
|
const personName = getItemPersonName(item)
|
||||||
const imageUrl = getItemImageUrl(item)
|
const imageUrl = getItemImageUrl(item)
|
||||||
const dateTime = getItemDateTime(item)
|
const dateTime = getItemDateTime(item)
|
||||||
@ -296,7 +357,7 @@ export default function Journal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={item.id} className="relative">
|
<li key={item.id} className="relative">
|
||||||
{itemIndex !== items.length - 1 && (
|
{itemIndex !== filteredItems.length - 1 && (
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
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"
|
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>
|
</span>
|
||||||
</div>
|
</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">
|
<header className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
@ -339,14 +400,17 @@ export default function Journal({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{hasChanges && (
|
||||||
<Badge
|
<Badge
|
||||||
tone={itemType.tone}
|
tone="indigo"
|
||||||
variant="border"
|
variant="border"
|
||||||
shape="pill"
|
shape="rounded"
|
||||||
size="small"
|
size="small"
|
||||||
>
|
>
|
||||||
{itemType.label}
|
{changes.length}{' '}
|
||||||
|
{changes.length === 1 ? 'Änderung' : 'Änderungen'}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
{item.hasNotification && (
|
{item.hasNotification && (
|
||||||
<NotificationDot
|
<NotificationDot
|
||||||
@ -375,11 +439,13 @@ export default function Journal({
|
|||||||
|
|
||||||
{isManualEntry && editingItemId === item.id ? (
|
{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">
|
<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}
|
rows={3}
|
||||||
value={editEntry}
|
value={editEntry}
|
||||||
onChange={(event) => setEditEntry(event.target.value)}
|
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">
|
<div className="mt-3 flex justify-end gap-x-2">
|
||||||
@ -451,9 +517,13 @@ export default function Journal({
|
|||||||
{changes.map((change, changeIndex) => (
|
{changes.map((change, changeIndex) => (
|
||||||
<section
|
<section
|
||||||
key={getChangeKey(change, changeIndex)}
|
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}
|
{change.label}
|
||||||
</h5>
|
</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">
|
<div className="text-[10px] font-semibold tracking-wider text-gray-400 uppercase dark:text-gray-500">
|
||||||
Vorher
|
Vorher
|
||||||
</div>
|
</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)}
|
{getChangeValue(change.oldValue)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden items-center lg:flex">
|
<div className="flex items-center justify-center">
|
||||||
<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">
|
<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" />
|
<ArrowRightIcon
|
||||||
|
aria-hidden="true"
|
||||||
|
className="size-3.5 rotate-90 lg:rotate-0"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<div className="text-[10px] font-semibold tracking-wider text-indigo-600 uppercase dark:text-indigo-300">
|
||||||
Nachher
|
Nachher
|
||||||
</div>
|
</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)}
|
{getChangeValue(change.newValue)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -161,7 +161,8 @@ export default function NotificationCenter({
|
|||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
tabs={notificationTabs}
|
tabs={notificationTabs}
|
||||||
variant="full-width-underline"
|
variant="underline"
|
||||||
|
fullWidth
|
||||||
align="center"
|
align="center"
|
||||||
ariaLabel="Benachrichtigungsfilter"
|
ariaLabel="Benachrichtigungsfilter"
|
||||||
className="px-4 [&_a]:py-3"
|
className="px-4 [&_a]:py-3"
|
||||||
|
|||||||
@ -11,7 +11,7 @@ type SearchProps = {
|
|||||||
export default function Search({
|
export default function Search({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
placeholder = 'Suche...',
|
placeholder = 'Suchen...',
|
||||||
}: SearchProps) {
|
}: SearchProps) {
|
||||||
return (
|
return (
|
||||||
<form
|
<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',
|
href: '/einsaetze',
|
||||||
icon: VideoCameraIcon,
|
icon: VideoCameraIcon,
|
||||||
right: 'operations:read',
|
right: 'operations:read',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Milestone',
|
||||||
|
href: '/milestone',
|
||||||
|
icon: ServerStackIcon,
|
||||||
|
right: 'administration:read',
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
name: 'Milestone-Speicher',
|
name: 'Geräte',
|
||||||
href: '/einsaetze/milestone-speicher',
|
href: '/milestone/geraete',
|
||||||
icon: ServerStackIcon,
|
icon: CpuChipIcon,
|
||||||
right: 'administration:read',
|
right: 'administration:read',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Milestone-Geräte',
|
name: 'Speicher',
|
||||||
href: '/einsaetze/milestone-devices',
|
href: '/milestone/speicher',
|
||||||
icon: CpuChipIcon,
|
icon: ServerStackIcon,
|
||||||
right: 'administration:read',
|
right: 'administration:read',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// frontend/src/components/Tabs.tsx
|
// frontend\src\components\Tabs.tsx
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ChangeEvent,
|
ChangeEvent,
|
||||||
@ -26,21 +26,25 @@ type TabsVariant =
|
|||||||
| 'pills'
|
| 'pills'
|
||||||
| 'pills-gray'
|
| 'pills-gray'
|
||||||
| 'pills-brand'
|
| 'pills-brand'
|
||||||
| 'full-width-underline'
|
|
||||||
| 'bar-underline'
|
| 'bar-underline'
|
||||||
| 'underline-badges'
|
| 'underline-badges'
|
||||||
| 'simple'
|
| 'simple'
|
||||||
|
|
||||||
type TabsAlign = 'left' | 'center' | 'right'
|
type TabsAlign = 'left' | 'center' | 'right'
|
||||||
|
|
||||||
|
type TabsBackground = 'none' | 'subtle'
|
||||||
|
|
||||||
type TabsProps = {
|
type TabsProps = {
|
||||||
tabs: TabItem[]
|
tabs: TabItem[]
|
||||||
variant?: TabsVariant
|
variant?: TabsVariant
|
||||||
align?: TabsAlign
|
align?: TabsAlign
|
||||||
|
fullWidth?: boolean
|
||||||
sticky?: boolean
|
sticky?: boolean
|
||||||
stickyTopClassName?: string
|
stickyTopClassName?: string
|
||||||
ariaLabel?: string
|
ariaLabel?: string
|
||||||
className?: string
|
className?: string
|
||||||
|
background?: TabsBackground
|
||||||
|
desktopNavClassName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function classNames(...classes: Array<string | false | null | undefined>) {
|
function classNames(...classes: Array<string | false | null | undefined>) {
|
||||||
@ -77,10 +81,13 @@ export default function Tabs({
|
|||||||
tabs,
|
tabs,
|
||||||
variant = 'underline',
|
variant = 'underline',
|
||||||
align = 'left',
|
align = 'left',
|
||||||
|
fullWidth = false,
|
||||||
sticky = false,
|
sticky = false,
|
||||||
stickyTopClassName = 'top-0',
|
stickyTopClassName = 'top-0',
|
||||||
ariaLabel = 'Tabs',
|
ariaLabel = 'Tabs',
|
||||||
className,
|
className,
|
||||||
|
background = 'none',
|
||||||
|
desktopNavClassName,
|
||||||
}: TabsProps) {
|
}: TabsProps) {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@ -89,6 +96,23 @@ export default function Tabs({
|
|||||||
tabs.find((tab) => isTabActive(tab, location.pathname)) ?? tabs[0]
|
tabs.find((tab) => isTabActive(tab, location.pathname)) ?? tabs[0]
|
||||||
|
|
||||||
const alignClassName = alignClassMap[align]
|
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>) {
|
function handleSelectChange(event: ChangeEvent<HTMLSelectElement>) {
|
||||||
const selectedTab = tabs.find((tab) => tab.name === event.target.value)
|
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">
|
<div className="border-b border-gray-200 dark:border-white/10">
|
||||||
<nav
|
<nav
|
||||||
aria-label={ariaLabel}
|
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) => {
|
{tabs.map((tab) => {
|
||||||
const active = isTabActive(tab, location.pathname)
|
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-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400'
|
||||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-white/20 dark:hover:text-gray-200',
|
: 'border-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',
|
'flex items-center border-b-2 px-1 py-4 text-sm font-medium whitespace-nowrap',
|
||||||
|
fullWidthItemClassName,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{tab.name}
|
{tab.name}
|
||||||
@ -213,7 +242,11 @@ export default function Tabs({
|
|||||||
<div className="border-b border-gray-200 dark:border-white/10">
|
<div className="border-b border-gray-200 dark:border-white/10">
|
||||||
<nav
|
<nav
|
||||||
aria-label={ariaLabel}
|
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) => {
|
{tabs.map((tab) => {
|
||||||
const active = isTabActive(tab, location.pathname)
|
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-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',
|
: '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',
|
'group inline-flex items-center border-b-2 px-1 py-4 text-sm font-medium',
|
||||||
|
fullWidthItemClassName,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{Icon && (
|
{Icon && (
|
||||||
@ -254,10 +288,14 @@ export default function Tabs({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{variant === 'pills' && (
|
{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
|
<nav
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
className={classNames('flex space-x-4', alignClassName)}
|
className={classNames(
|
||||||
|
'flex',
|
||||||
|
fullWidth ? 'gap-1.5' : 'space-x-4',
|
||||||
|
alignClassName,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
const active = isTabActive(tab, location.pathname)
|
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'
|
? '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',
|
: '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',
|
'inline-flex items-center rounded-md px-3 py-2 text-sm font-medium',
|
||||||
|
fullWidthItemClassName,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{tab.name}
|
{tab.name}
|
||||||
@ -285,10 +324,14 @@ export default function Tabs({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{variant === 'pills-gray' && (
|
{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
|
<nav
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
className={classNames('flex space-x-4', alignClassName)}
|
className={classNames(
|
||||||
|
'flex',
|
||||||
|
fullWidth ? 'gap-1.5' : 'space-x-4',
|
||||||
|
alignClassName,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
const active = isTabActive(tab, location.pathname)
|
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'
|
? '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',
|
: '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',
|
'inline-flex items-center rounded-md px-3 py-2 text-sm font-medium',
|
||||||
|
fullWidthItemClassName,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{tab.name}
|
{tab.name}
|
||||||
@ -316,10 +360,14 @@ export default function Tabs({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{variant === 'pills-brand' && (
|
{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
|
<nav
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
className={classNames('flex space-x-4', alignClassName)}
|
className={classNames(
|
||||||
|
'flex',
|
||||||
|
fullWidth ? 'gap-1.5' : 'space-x-4',
|
||||||
|
alignClassName,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
const active = isTabActive(tab, location.pathname)
|
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'
|
? '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',
|
: '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',
|
'inline-flex items-center rounded-md px-3 py-2 text-sm font-medium',
|
||||||
)}
|
fullWidthItemClassName,
|
||||||
>
|
|
||||||
{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',
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{tab.name}
|
{tab.name}
|
||||||
@ -421,7 +442,11 @@ export default function Tabs({
|
|||||||
<div className="border-b border-gray-200 dark:border-white/10">
|
<div className="border-b border-gray-200 dark:border-white/10">
|
||||||
<nav
|
<nav
|
||||||
aria-label={ariaLabel}
|
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) => {
|
{tabs.map((tab) => {
|
||||||
const active = isTabActive(tab, location.pathname)
|
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-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',
|
: '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',
|
'flex border-b-2 px-1 py-4 text-sm font-medium whitespace-nowrap',
|
||||||
|
fullWidthItemClassName,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{tab.name}
|
{tab.name}
|
||||||
@ -464,7 +490,7 @@ export default function Tabs({
|
|||||||
const active = isTabActive(tab, location.pathname)
|
const active = isTabActive(tab, location.pathname)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={tab.name}>
|
<li key={tab.name} className={fullWidth ? 'flex-1' : undefined}>
|
||||||
<Link
|
<Link
|
||||||
to={tab.href}
|
to={tab.href}
|
||||||
aria-current={active ? 'page' : undefined}
|
aria-current={active ? 'page' : undefined}
|
||||||
@ -474,6 +500,7 @@ export default function Tabs({
|
|||||||
? 'text-indigo-600 dark:text-indigo-400'
|
? 'text-indigo-600 dark:text-indigo-400'
|
||||||
: 'hover:text-gray-700 dark:hover:text-white',
|
: 'hover:text-gray-700 dark:hover:text-white',
|
||||||
'inline-flex items-center',
|
'inline-flex items-center',
|
||||||
|
fullWidth && 'w-full justify-center text-center',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{tab.name}
|
{tab.name}
|
||||||
|
|||||||
@ -33,11 +33,7 @@ export type TextareaVariant =
|
|||||||
|
|
||||||
type TextareaResize = 'none' | 'vertical' | 'horizontal' | 'both'
|
type TextareaResize = 'none' | 'vertical' | 'horizontal' | 'both'
|
||||||
|
|
||||||
type TextareaValue =
|
type TextareaValue = string | number | readonly string[] | undefined
|
||||||
| string
|
|
||||||
| number
|
|
||||||
| readonly string[]
|
|
||||||
| undefined
|
|
||||||
|
|
||||||
type TextareaProps = Omit<
|
type TextareaProps = Omit<
|
||||||
TextareaHTMLAttributes<HTMLTextAreaElement>,
|
TextareaHTMLAttributes<HTMLTextAreaElement>,
|
||||||
@ -62,12 +58,20 @@ type TextareaProps = Omit<
|
|||||||
submitButtonType?: 'button' | 'submit'
|
submitButtonType?: 'button' | 'submit'
|
||||||
showSubmitButton?: boolean
|
showSubmitButton?: boolean
|
||||||
onSubmitButtonClick?: () => void
|
onSubmitButtonClick?: () => void
|
||||||
|
/** Eigener Submit-Button (ersetzt den Standard-Button, z. B. für Lade-Status/Icon). */
|
||||||
|
submitButton?: ReactNode
|
||||||
|
|
||||||
showAttachButton?: boolean
|
showAttachButton?: boolean
|
||||||
attachLabel?: string
|
attachLabel?: string
|
||||||
onAttachClick?: () => void
|
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
|
titleLabel?: ReactNode
|
||||||
|
titlePlaceholder?: string
|
||||||
titleInputProps?: InputHTMLAttributes<HTMLInputElement>
|
titleInputProps?: InputHTMLAttributes<HTMLInputElement>
|
||||||
|
|
||||||
footerAttachLabel?: string
|
footerAttachLabel?: string
|
||||||
@ -122,17 +126,18 @@ function getResizeClassName(resize: TextareaResize) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "Simple" / "Preview" – eigenständig umrandetes Feld.
|
||||||
const simpleTextareaClassName =
|
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'
|
'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 =
|
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'
|
'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 =
|
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'
|
'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 =
|
const titleTextareaClassName = 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 titleInputBaseClassName =
|
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'
|
'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 =
|
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'
|
'-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 =
|
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'
|
'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',
|
submitButtonType = 'submit',
|
||||||
showSubmitButton = true,
|
showSubmitButton = true,
|
||||||
onSubmitButtonClick,
|
onSubmitButtonClick,
|
||||||
|
submitButton,
|
||||||
|
|
||||||
attachLabel = 'Datei anhängen',
|
attachLabel = 'Datei anhängen',
|
||||||
onAttachClick,
|
onAttachClick,
|
||||||
showAttachButton = Boolean(onAttachClick),
|
showAttachButton = Boolean(onAttachClick),
|
||||||
|
|
||||||
|
actions,
|
||||||
|
pillActions,
|
||||||
|
|
||||||
titleLabel = 'Title',
|
titleLabel = 'Title',
|
||||||
|
titlePlaceholder,
|
||||||
titleInputProps,
|
titleInputProps,
|
||||||
|
|
||||||
footerAttachLabel = 'Datei anhängen',
|
footerAttachLabel = 'Datei anhängen',
|
||||||
@ -229,12 +242,7 @@ const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|||||||
|
|
||||||
const effectiveResize =
|
const effectiveResize =
|
||||||
resize ??
|
resize ??
|
||||||
(
|
(variant === 'simple' || variant === 'preview-button' ? 'vertical' : 'none')
|
||||||
variant === 'simple' ||
|
|
||||||
variant === 'preview-button'
|
|
||||||
? 'vertical'
|
|
||||||
: 'none'
|
|
||||||
)
|
|
||||||
|
|
||||||
const [internalValue, setInternalValue] = useState(() =>
|
const [internalValue, setInternalValue] = useState(() =>
|
||||||
normalizeTextareaValue(defaultValue),
|
normalizeTextareaValue(defaultValue),
|
||||||
@ -258,6 +266,10 @@ const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderFeedback() {
|
function renderFeedback() {
|
||||||
|
if (!description && !error) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{description && !error && (
|
{description && !error && (
|
||||||
@ -287,7 +299,7 @@ const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLabel(defaultSrOnly = false) {
|
function renderLabel(forceSrOnly = false) {
|
||||||
if (!label) {
|
if (!label) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@ -296,7 +308,7 @@ const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|||||||
<label
|
<label
|
||||||
htmlFor={textareaId}
|
htmlFor={textareaId}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
srOnlyLabel || defaultSrOnly
|
srOnlyLabel || forceSrOnly
|
||||||
? 'sr-only'
|
? 'sr-only'
|
||||||
: 'block text-sm/6 font-medium text-gray-900 dark:text-white',
|
: 'block text-sm/6 font-medium text-gray-900 dark:text-white',
|
||||||
labelClassName,
|
labelClassName,
|
||||||
@ -317,7 +329,7 @@ const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|||||||
value={textareaValue}
|
value={textareaValue}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
aria-invalid={Boolean(error) ? true : ariaInvalid}
|
aria-invalid={error ? true : ariaInvalid}
|
||||||
aria-describedby={describedBy || undefined}
|
aria-describedby={describedBy || undefined}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
baseClassName,
|
baseClassName,
|
||||||
@ -335,16 +347,10 @@ const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|||||||
return avatar
|
return avatar
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <Avatar size={10} shape="circle" {...avatarProps} />
|
||||||
<Avatar
|
|
||||||
size={10}
|
|
||||||
shape="circle"
|
|
||||||
{...avatarProps}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAttachButton(className = iconButtonClassName) {
|
function renderAttachButton(buttonClassName = iconButtonClassName) {
|
||||||
if (!showAttachButton) {
|
if (!showAttachButton) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@ -354,7 +360,7 @@ const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={onAttachClick}
|
onClick={onAttachClick}
|
||||||
className={className}
|
className={buttonClassName}
|
||||||
>
|
>
|
||||||
<PaperClipIcon aria-hidden="true" className="size-5" />
|
<PaperClipIcon aria-hidden="true" className="size-5" />
|
||||||
<span className="sr-only">{attachLabel}</span>
|
<span className="sr-only">{attachLabel}</span>
|
||||||
@ -363,6 +369,10 @@ const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderSubmitButton() {
|
function renderSubmitButton() {
|
||||||
|
if (submitButton !== undefined) {
|
||||||
|
return submitButton
|
||||||
|
}
|
||||||
|
|
||||||
if (!showSubmitButton) {
|
if (!showSubmitButton) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@ -419,29 +429,21 @@ const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Variante: Avatar mit Box und Aktionsleiste.
|
||||||
if (variant === 'avatar-actions') {
|
if (variant === 'avatar-actions') {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(hasAvatar && 'flex items-start space-x-4', wrapperClassName)}
|
||||||
hasAvatar && 'flex items-start space-x-4',
|
|
||||||
wrapperClassName,
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">{renderAvatar()}</div>
|
||||||
{renderAvatar()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div
|
<div className={classNames(boxedWrapperClassName, fieldWrapperClassName)}>
|
||||||
className={classNames(
|
|
||||||
boxedWrapperClassName,
|
|
||||||
fieldWrapperClassName,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{renderLabel(true)}
|
{renderLabel(true)}
|
||||||
{renderTextarea(boxedTextareaClassName)}
|
{renderTextarea(boxedTextareaClassName)}
|
||||||
|
|
||||||
|
{/* Platzhalter, der die Höhe der Aktionsleiste ausgleicht. */}
|
||||||
<div aria-hidden="true" className="py-2">
|
<div aria-hidden="true" className="py-2">
|
||||||
<div className="py-px">
|
<div className="py-px">
|
||||||
<div className="h-9" />
|
<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 space-x-5">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">{renderAttachButton()}</div>
|
||||||
{renderAttachButton()}
|
{actions && <div className="flex items-center">{actions}</div>}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">{renderSubmitButton()}</div>
|
||||||
{renderSubmitButton()}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -473,44 +472,29 @@ const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Variante: Unterstrich mit Aktionsleiste.
|
||||||
if (variant === 'underline-actions') {
|
if (variant === 'underline-actions') {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(hasAvatar && 'flex items-start space-x-4', wrapperClassName)}
|
||||||
hasAvatar && 'flex items-start space-x-4',
|
|
||||||
wrapperClassName,
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">{renderAvatar()}</div>
|
||||||
{renderAvatar()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div
|
<div className={classNames(underlineWrapperClassName, fieldWrapperClassName)}>
|
||||||
className={classNames(
|
|
||||||
underlineWrapperClassName,
|
|
||||||
fieldWrapperClassName,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{renderLabel(true)}
|
{renderLabel(true)}
|
||||||
{renderTextarea(underlineTextareaClassName)}
|
{renderTextarea(underlineTextareaClassName)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div className={classNames('flex justify-between pt-2', toolbarClassName)}>
|
||||||
className={classNames(
|
|
||||||
'flex justify-between pt-2',
|
|
||||||
toolbarClassName,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center space-x-5">
|
<div className="flex items-center space-x-5">
|
||||||
<div className="flow-root">
|
<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>
|
</div>
|
||||||
|
{actions && <div className="flow-root">{actions}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">{renderSubmitButton()}</div>
|
||||||
{renderSubmitButton()}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{renderFeedback()}
|
{renderFeedback()}
|
||||||
@ -519,23 +503,18 @@ const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Variante: Titel mit Pill-Aktionen.
|
||||||
if (variant === 'title-actions') {
|
if (variant === 'title-actions') {
|
||||||
const {
|
const {
|
||||||
className: titleInputCustomClassName,
|
className: titleInputCustomClassName,
|
||||||
id: _providedTitleInputId,
|
placeholder: titleInputCustomPlaceholder,
|
||||||
name: providedTitleInputName,
|
|
||||||
...restTitleInputProps
|
...restTitleInputProps
|
||||||
} = titleInputProps ?? {}
|
} = titleInputProps ?? {}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={wrapperClassName}>
|
<div className={wrapperClassName}>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div
|
<div className={classNames(titleWrapperClassName, fieldWrapperClassName)}>
|
||||||
className={classNames(
|
|
||||||
titleWrapperClassName,
|
|
||||||
fieldWrapperClassName,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<label htmlFor={titleInputId} className="sr-only">
|
<label htmlFor={titleInputId} className="sr-only">
|
||||||
{titleLabel}
|
{titleLabel}
|
||||||
</label>
|
</label>
|
||||||
@ -543,8 +522,9 @@ const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|||||||
<input
|
<input
|
||||||
{...restTitleInputProps}
|
{...restTitleInputProps}
|
||||||
id={titleInputId}
|
id={titleInputId}
|
||||||
name={providedTitleInputName ?? 'title'}
|
name={titleInputProps?.name ?? 'title'}
|
||||||
type={titleInputProps?.type ?? 'text'}
|
type={titleInputProps?.type ?? 'text'}
|
||||||
|
placeholder={titleInputCustomPlaceholder ?? titlePlaceholder}
|
||||||
disabled={disabled || titleInputProps?.disabled}
|
disabled={disabled || titleInputProps?.disabled}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
titleInputBaseClassName,
|
titleInputBaseClassName,
|
||||||
@ -556,14 +536,37 @@ const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|||||||
{renderLabel(true)}
|
{renderLabel(true)}
|
||||||
{renderTextarea(titleTextareaClassName)}
|
{renderTextarea(titleTextareaClassName)}
|
||||||
|
|
||||||
<div aria-hidden="true" className="py-2">
|
{/* 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="py-px">
|
||||||
<div className="h-9" />
|
<div className="h-9" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="absolute inset-x-px bottom-0">
|
<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 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">
|
<div className="flex">
|
||||||
{showAttachButton && (
|
{showAttachButton && (
|
||||||
@ -584,9 +587,7 @@ const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">{renderSubmitButton()}</div>
|
||||||
{renderSubmitButton()}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -596,6 +597,7 @@ const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Variante: Schreiben/Vorschau-Tabs.
|
||||||
if (variant === 'preview-button') {
|
if (variant === 'preview-button') {
|
||||||
const resolvedPreviewContent =
|
const resolvedPreviewContent =
|
||||||
typeof previewContent === 'function'
|
typeof previewContent === 'function'
|
||||||
@ -607,13 +609,8 @@ const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|||||||
<TabGroup>
|
<TabGroup>
|
||||||
<div className="group flex items-center">
|
<div className="group flex items-center">
|
||||||
<TabList className="flex gap-2">
|
<TabList className="flex gap-2">
|
||||||
<Tab className={previewTabClassName}>
|
<Tab className={previewTabClassName}>{writeTabLabel}</Tab>
|
||||||
{writeTabLabel}
|
<Tab className={previewTabClassName}>{previewTabLabel}</Tab>
|
||||||
</Tab>
|
|
||||||
|
|
||||||
<Tab className={previewTabClassName}>
|
|
||||||
{previewTabLabel}
|
|
||||||
</Tab>
|
|
||||||
</TabList>
|
</TabList>
|
||||||
|
|
||||||
{renderPreviewToolbar()}
|
{renderPreviewToolbar()}
|
||||||
@ -622,14 +619,12 @@ const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|||||||
<TabPanels className="mt-2">
|
<TabPanels className="mt-2">
|
||||||
<TabPanel className="-m-0.5 rounded-lg p-0.5">
|
<TabPanel className="-m-0.5 rounded-lg p-0.5">
|
||||||
{renderLabel(true)}
|
{renderLabel(true)}
|
||||||
<div>
|
<div>{renderTextarea(simpleTextareaClassName)}</div>
|
||||||
{renderTextarea(simpleTextareaClassName)}
|
|
||||||
</div>
|
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
<TabPanel className="-m-0.5 rounded-lg p-0.5">
|
<TabPanel className="-m-0.5 rounded-lg p-0.5">
|
||||||
<div className="border-b border-gray-200 dark:border-white/10">
|
<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}
|
{resolvedPreviewContent || previewPlaceholder}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -637,15 +632,14 @@ const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|||||||
</TabPanels>
|
</TabPanels>
|
||||||
</TabGroup>
|
</TabGroup>
|
||||||
|
|
||||||
<div className="mt-2 flex justify-end">
|
<div className="mt-2 flex justify-end">{renderSubmitButton()}</div>
|
||||||
{renderSubmitButton()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{renderFeedback()}
|
{renderFeedback()}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Variante: Simple.
|
||||||
return (
|
return (
|
||||||
<div className={wrapperClassName}>
|
<div className={wrapperClassName}>
|
||||||
{renderLabel()}
|
{renderLabel()}
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import {
|
|||||||
MapPinIcon,
|
MapPinIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
} from '@heroicons/react/20/solid'
|
} 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 ButtonGroup from '../ButtonGroup'
|
||||||
import {
|
import {
|
||||||
timeToMinutes,
|
timeToMinutes,
|
||||||
@ -224,7 +224,10 @@ function MonthView({
|
|||||||
onSelectedDateChange,
|
onSelectedDateChange,
|
||||||
onCreateEvent,
|
onCreateEvent,
|
||||||
onEditEvent,
|
onEditEvent,
|
||||||
}: Omit<CalendarProps, 'view' | 'onViewChange' | 'coreWorkingHours'>) {
|
onOpenDay,
|
||||||
|
}: Omit<CalendarProps, 'view' | 'onViewChange' | 'coreWorkingHours'> & {
|
||||||
|
onOpenDay: (date: Date) => void
|
||||||
|
}) {
|
||||||
const monthStart = new Date(
|
const monthStart = new Date(
|
||||||
selectedDate.getFullYear(),
|
selectedDate.getFullYear(),
|
||||||
selectedDate.getMonth(),
|
selectedDate.getMonth(),
|
||||||
@ -248,6 +251,46 @@ function MonthView({
|
|||||||
} | null>(null)
|
} | null>(null)
|
||||||
const [hoveredIndex, setHoveredIndex] = useState<number | 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(
|
function getDayIndexFromPointer(
|
||||||
clientX: number,
|
clientX: number,
|
||||||
clientY: number,
|
clientY: number,
|
||||||
@ -350,8 +393,7 @@ function MonthView({
|
|||||||
setDaySelection(null)
|
setDaySelection(null)
|
||||||
|
|
||||||
if (startIndex === endIndex) {
|
if (startIndex === endIndex) {
|
||||||
onSelectedDateChange(days[startIndex])
|
onOpenDay(days[startIndex])
|
||||||
onCreateEvent(days[startIndex])
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -417,8 +459,7 @@ function MonthView({
|
|||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
onSelectedDateChange(day)
|
onOpenDay(day)
|
||||||
onCreateEvent(day)
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -428,20 +469,29 @@ function MonthView({
|
|||||||
className="pointer-events-none absolute inset-0 -z-10 bg-indigo-500/20 dark:bg-indigo-400/15"
|
className="pointer-events-none absolute inset-0 -z-10 bg-indigo-500/20 dark:bg-indigo-400/15"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<div className="flex items-center">
|
||||||
{daySelection === null && hoveredIndex === index && (
|
{daySelection === null && hoveredIndex === index && (
|
||||||
<div
|
<button
|
||||||
aria-hidden="true"
|
type="button"
|
||||||
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"
|
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" />
|
<PlusIcon className="size-3 shrink-0" />
|
||||||
<span className="truncate">Neuer Termin</span>
|
<span className="truncate">Neuer Termin</span>
|
||||||
</div>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelectedDateChange(day)}
|
onClick={() => onOpenDay(day)}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'ml-auto flex size-7 items-center justify-center rounded-full text-sm font-medium',
|
'ml-auto flex size-7 shrink-0 items-center justify-center rounded-full text-sm font-medium',
|
||||||
isToday(day)
|
isToday(day)
|
||||||
? 'bg-indigo-600 text-white dark:bg-indigo-500'
|
? 'bg-indigo-600 text-white dark:bg-indigo-500'
|
||||||
: selected
|
: selected
|
||||||
@ -453,6 +503,7 @@ function MonthView({
|
|||||||
>
|
>
|
||||||
{day.getDate()}
|
{day.getDate()}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-1 hidden space-y-1 sm:block">
|
<div className="mt-1 hidden space-y-1 sm:block">
|
||||||
{dayEvents.slice(0, 3).map((event) => (
|
{dayEvents.slice(0, 3).map((event) => (
|
||||||
@ -489,7 +540,7 @@ function MonthView({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-calendar-skip-drag
|
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"
|
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
|
+ {dayEvents.length - 3} weitere
|
||||||
@ -625,6 +676,7 @@ function TimeGridView({
|
|||||||
const selectedRange = timeSelection
|
const selectedRange = timeSelection
|
||||||
? normalizeTimeRange(timeSelection.anchor, timeSelection.focus)
|
? normalizeTimeRange(timeSelection.anchor, timeSelection.focus)
|
||||||
: null
|
: null
|
||||||
|
const gridTemplateColumns = `3.5rem repeat(${days.length}, minmax(7rem, 1fr))`
|
||||||
|
|
||||||
function getSlotFromPointer(
|
function getSlotFromPointer(
|
||||||
clientX: number,
|
clientX: number,
|
||||||
@ -756,13 +808,14 @@ function TimeGridView({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 flex-1 flex-col bg-white dark:bg-gray-900">
|
<div className="flex min-h-0 flex-1 flex-col bg-white dark:bg-gray-900">
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto">
|
||||||
|
<div className="min-w-max">
|
||||||
|
<div className="sticky top-0 z-20">
|
||||||
<div
|
<div
|
||||||
className="grid shrink-0 border-b border-gray-200 bg-white dark:border-white/10 dark:bg-gray-900"
|
className="grid border-b border-gray-200 bg-white dark:border-white/10 dark:bg-gray-900"
|
||||||
style={{
|
style={{ gridTemplateColumns }}
|
||||||
gridTemplateColumns: `3.5rem repeat(${days.length}, minmax(7rem, 1fr))`,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div />
|
<div className="bg-white dark:bg-gray-900" />
|
||||||
{days.map((day) => (
|
{days.map((day) => (
|
||||||
<button
|
<button
|
||||||
key={toDateKey(day)}
|
key={toDateKey(day)}
|
||||||
@ -775,9 +828,9 @@ function TimeGridView({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="block text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
|
<span className="block text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
|
||||||
{new Intl.DateTimeFormat('de-DE', { weekday: 'short' }).format(
|
{new Intl.DateTimeFormat('de-DE', {
|
||||||
day,
|
weekday: 'short',
|
||||||
)}
|
}).format(day)}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={classNames(
|
className={classNames(
|
||||||
@ -795,10 +848,8 @@ function TimeGridView({
|
|||||||
|
|
||||||
{allDayEvents.length > 0 && (
|
{allDayEvents.length > 0 && (
|
||||||
<div
|
<div
|
||||||
className="grid shrink-0 border-b border-gray-200 bg-gray-50 dark:border-white/10 dark:bg-white/5"
|
className="grid border-b border-gray-200 bg-gray-50 dark:border-white/10 dark:bg-white/5"
|
||||||
style={{
|
style={{ gridTemplateColumns }}
|
||||||
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">
|
<div className="px-2 py-2 text-right text-[10px] font-medium uppercase text-gray-400">
|
||||||
Ganz
|
Ganz
|
||||||
@ -827,8 +878,8 @@ function TimeGridView({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-auto">
|
|
||||||
<div
|
<div
|
||||||
ref={timeGridRef}
|
ref={timeGridRef}
|
||||||
onPointerDown={handleTimeGridPointerDown}
|
onPointerDown={handleTimeGridPointerDown}
|
||||||
@ -840,10 +891,8 @@ function TimeGridView({
|
|||||||
setHoveredSlot(null)
|
setHoveredSlot(null)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="grid w-full min-w-max"
|
className="grid w-full"
|
||||||
style={{
|
style={{ gridTemplateColumns }}
|
||||||
gridTemplateColumns: `3.5rem repeat(${days.length}, minmax(7rem, 1fr))`,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="relative h-[96rem]">
|
<div className="relative h-[96rem]">
|
||||||
{hours.map((hour) => (
|
{hours.map((hour) => (
|
||||||
@ -947,11 +996,10 @@ function TimeGridView({
|
|||||||
>
|
>
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{selectionStart &&
|
{selectionStart &&
|
||||||
new Intl.DateTimeFormat('de-DE', {
|
selectionEnd &&
|
||||||
weekday: 'short',
|
`${formatTime(selectionStart)} – ${formatTime(
|
||||||
hour: '2-digit',
|
selectionEnd,
|
||||||
minute: '2-digit',
|
)}`}
|
||||||
}).format(selectionStart)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -1034,17 +1082,17 @@ function TimeGridView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function YearView({
|
function YearView({
|
||||||
events,
|
events,
|
||||||
selectedDate,
|
selectedDate,
|
||||||
onSelectedDateChange,
|
onOpenDay,
|
||||||
}: Pick<
|
}: Pick<CalendarProps, 'events' | 'selectedDate'> & {
|
||||||
CalendarProps,
|
onOpenDay: (date: Date) => void
|
||||||
'events' | 'selectedDate' | 'onSelectedDateChange'
|
}) {
|
||||||
>) {
|
|
||||||
const months = Array.from(
|
const months = Array.from(
|
||||||
{ length: 12 },
|
{ length: 12 },
|
||||||
(_, month) => new Date(selectedDate.getFullYear(), month, 1),
|
(_, month) => new Date(selectedDate.getFullYear(), month, 1),
|
||||||
@ -1084,7 +1132,7 @@ function YearView({
|
|||||||
<button
|
<button
|
||||||
key={toDateKey(day)}
|
key={toDateKey(day)}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelectedDateChange(day)}
|
onClick={() => onOpenDay(day)}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'relative mx-auto flex size-8 items-center justify-center rounded-full',
|
'relative mx-auto flex size-8 items-center justify-center rounded-full',
|
||||||
!inMonth && 'text-gray-300 dark:text-gray-700',
|
!inMonth && 'text-gray-300 dark:text-gray-700',
|
||||||
@ -1130,6 +1178,11 @@ export default function Calendar({
|
|||||||
onCreateEvent,
|
onCreateEvent,
|
||||||
onEditEvent,
|
onEditEvent,
|
||||||
}: CalendarProps) {
|
}: CalendarProps) {
|
||||||
|
function openDay(date: Date) {
|
||||||
|
onSelectedDateChange(date)
|
||||||
|
onViewChange('day')
|
||||||
|
}
|
||||||
|
|
||||||
function move(amount: number) {
|
function move(amount: number) {
|
||||||
if (view === 'day') {
|
if (view === 'day') {
|
||||||
onSelectedDateChange(addDays(selectedDate, amount))
|
onSelectedDateChange(addDays(selectedDate, amount))
|
||||||
@ -1215,6 +1268,7 @@ export default function Calendar({
|
|||||||
onSelectedDateChange={onSelectedDateChange}
|
onSelectedDateChange={onSelectedDateChange}
|
||||||
onCreateEvent={onCreateEvent}
|
onCreateEvent={onCreateEvent}
|
||||||
onEditEvent={onEditEvent}
|
onEditEvent={onEditEvent}
|
||||||
|
onOpenDay={openDay}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{view === 'week' && (
|
{view === 'week' && (
|
||||||
@ -1243,7 +1297,7 @@ export default function Calendar({
|
|||||||
<YearView
|
<YearView
|
||||||
events={events}
|
events={events}
|
||||||
selectedDate={selectedDate}
|
selectedDate={selectedDate}
|
||||||
onSelectedDateChange={onSelectedDateChange}
|
onOpenDay={openDay}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -40,6 +40,7 @@ export type DeviceHistoryEntry = {
|
|||||||
deviceId: string
|
deviceId: string
|
||||||
userId: string
|
userId: string
|
||||||
userDisplayName: string
|
userDisplayName: string
|
||||||
|
userAvatar?: string
|
||||||
action: string
|
action: string
|
||||||
changes: DeviceHistoryChange[]
|
changes: DeviceHistoryChange[]
|
||||||
createdAt: string
|
createdAt: string
|
||||||
@ -62,8 +63,14 @@ export type Device = {
|
|||||||
serialNumber: string
|
serialNumber: string
|
||||||
macAddress: string
|
macAddress: string
|
||||||
ipAddress: string
|
ipAddress: string
|
||||||
|
phoneNumber: string
|
||||||
|
computerName: string
|
||||||
|
deviceCategory: string
|
||||||
location: string
|
location: string
|
||||||
loanStatus: string
|
loanStatus: string
|
||||||
|
loanedToUserId?: string
|
||||||
|
loanedToDisplayName?: string
|
||||||
|
loanedUntil?: string
|
||||||
comment: string
|
comment: string
|
||||||
firmwareVersion: string
|
firmwareVersion: string
|
||||||
firmwareUpdate?: DeviceFirmwareUpdate | null
|
firmwareUpdate?: DeviceFirmwareUpdate | null
|
||||||
@ -131,9 +138,103 @@ export type MilestoneCameraStream = {
|
|||||||
jPEGQuality: number | null
|
jPEGQuality: number | null
|
||||||
quality: number | null
|
quality: number | null
|
||||||
resolution: string
|
resolution: string
|
||||||
|
availableResolutions?: string[]
|
||||||
edgeStorageSupported?: boolean
|
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 = {
|
export type MilestoneCameraDetail = {
|
||||||
id: string
|
id: string
|
||||||
milestoneDeviceId: string
|
milestoneDeviceId: string
|
||||||
@ -188,6 +289,7 @@ export type OperationHistoryEntry = {
|
|||||||
operationId: string
|
operationId: string
|
||||||
userId: string
|
userId: string
|
||||||
userDisplayName: string
|
userDisplayName: string
|
||||||
|
userAvatar?: string
|
||||||
action: string
|
action: string
|
||||||
changes: OperationHistoryChange[]
|
changes: OperationHistoryChange[]
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
ClipboardDocumentIcon,
|
ClipboardDocumentIcon,
|
||||||
HashtagIcon,
|
HashtagIcon,
|
||||||
KeyIcon,
|
KeyIcon,
|
||||||
|
MagnifyingGlassIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
} from '@heroicons/react/20/solid'
|
} from '@heroicons/react/20/solid'
|
||||||
import Button from '../../../components/Button'
|
import Button from '../../../components/Button'
|
||||||
@ -42,6 +43,7 @@ type AdminChannelUser = {
|
|||||||
displayName: string
|
displayName: string
|
||||||
email: string
|
email: string
|
||||||
unit: string
|
unit: string
|
||||||
|
avatar: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChannelDraft = {
|
type ChannelDraft = {
|
||||||
@ -96,6 +98,7 @@ export default function ChannelsAdministration() {
|
|||||||
const [draft, setDraft] = useState<ChannelDraft>(emptyDraft)
|
const [draft, setDraft] = useState<ChannelDraft>(emptyDraft)
|
||||||
const [zabbixScript, setZabbixScript] = useState('')
|
const [zabbixScript, setZabbixScript] = useState('')
|
||||||
const [newToken, setNewToken] = useState('')
|
const [newToken, setNewToken] = useState('')
|
||||||
|
const [memberSearch, setMemberSearch] = useState('')
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [isRotatingToken, setIsRotatingToken] = useState(false)
|
const [isRotatingToken, setIsRotatingToken] = useState(false)
|
||||||
@ -110,6 +113,21 @@ export default function ChannelsAdministration() {
|
|||||||
}
|
}
|
||||||
}, [draft.slug, draft.webhookPath])
|
}, [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) => {
|
const loadChannels = useCallback(async (preferredId?: string) => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
try {
|
try {
|
||||||
@ -154,11 +172,13 @@ export default function ChannelsAdministration() {
|
|||||||
function selectChannel(channel: AdminChannel) {
|
function selectChannel(channel: AdminChannel) {
|
||||||
setDraft(channelToDraft(channel))
|
setDraft(channelToDraft(channel))
|
||||||
setNewToken('')
|
setNewToken('')
|
||||||
|
setMemberSearch('')
|
||||||
}
|
}
|
||||||
|
|
||||||
function createChannel() {
|
function createChannel() {
|
||||||
setDraft(emptyDraft)
|
setDraft(emptyDraft)
|
||||||
setNewToken('')
|
setNewToken('')
|
||||||
|
setMemberSearch('')
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateDraft<Key extends keyof ChannelDraft>(
|
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>) {
|
function handleImageFileChange(event: ChangeEvent<HTMLInputElement>) {
|
||||||
const file = event.target.files?.[0]
|
const file = event.target.files?.[0]
|
||||||
event.target.value = ''
|
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 (
|
return (
|
||||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[19rem_minmax(0,1fr)]">
|
<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">
|
<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 && (
|
{!draft.allUsers && (
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white">
|
<h3 className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
Channelmitglieder
|
Channelmitglieder
|
||||||
</h3>
|
</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">
|
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
{users.map((user) => (
|
{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
|
||||||
|
checked={filteredAllSelected}
|
||||||
|
indeterminate={filteredSomeSelected}
|
||||||
|
disabled={filteredUsers.length === 0}
|
||||||
|
onChange={(event) => setAllMembers(event.target.checked)}
|
||||||
|
label={
|
||||||
|
memberSearch.trim()
|
||||||
|
? 'Alle Treffer auswählen'
|
||||||
|
: 'Alle auswählen'
|
||||||
|
}
|
||||||
|
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
|
<Checkbox
|
||||||
key={user.id}
|
key={user.id}
|
||||||
checked={draft.userIds.includes(user.id)}
|
checked={checked}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
toggleUser(user.id, event.target.checked)
|
toggleUser(user.id, event.target.checked)
|
||||||
}
|
}
|
||||||
label={userDisplayName(user)}
|
wrapperClassName={`items-center rounded-lg px-2 py-2 transition ${
|
||||||
description={user.unit || user.email}
|
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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -606,27 +725,6 @@ export default function ChannelsAdministration() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -26,6 +26,7 @@ type AdminGroupChat = {
|
|||||||
name: string
|
name: string
|
||||||
image: string
|
image: string
|
||||||
ownerId: string
|
ownerId: string
|
||||||
|
teamId: string
|
||||||
userIds: string[]
|
userIds: string[]
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
@ -44,6 +45,7 @@ type GroupDraft = {
|
|||||||
name: string
|
name: string
|
||||||
image: string
|
image: string
|
||||||
ownerId: string
|
ownerId: string
|
||||||
|
teamId: string
|
||||||
userIds: string[]
|
userIds: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -65,6 +67,7 @@ function groupToDraft(group: AdminGroupChat): GroupDraft {
|
|||||||
name: group.name,
|
name: group.name,
|
||||||
image: group.image,
|
image: group.image,
|
||||||
ownerId: group.ownerId,
|
ownerId: group.ownerId,
|
||||||
|
teamId: group.teamId,
|
||||||
userIds: group.userIds ?? [],
|
userIds: group.userIds ?? [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -79,6 +82,7 @@ export default function GroupChatsAdministration() {
|
|||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [isDeleting, setIsDeleting] = useState(false)
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
|
const [isEmptying, setIsEmptying] = useState(false)
|
||||||
const imageInputRef = useRef<HTMLInputElement | null>(null)
|
const imageInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
const loadGroups = useCallback(
|
const loadGroups = useCallback(
|
||||||
@ -216,7 +220,7 @@ export default function GroupChatsAdministration() {
|
|||||||
|
|
||||||
async function saveGroup(event: FormEvent<HTMLFormElement>) {
|
async function saveGroup(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (!draft) {
|
if (!draft || draft.teamId) {
|
||||||
return
|
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() {
|
async function deleteGroup() {
|
||||||
if (
|
if (
|
||||||
!draft ||
|
!draft ||
|
||||||
@ -323,6 +369,8 @@ export default function GroupChatsAdministration() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isTeamChat = Boolean(draft.teamId)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[19rem_minmax(0,1fr)]">
|
<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">
|
<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}
|
size={9}
|
||||||
shape="rounded"
|
shape="rounded"
|
||||||
/>
|
/>
|
||||||
<span className="min-w-0">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="block truncate font-medium">{group.name}</span>
|
<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">
|
<span className="block truncate text-xs opacity-70">
|
||||||
{group.userIds.length} Mitglieder
|
{group.userIds.length} Mitglieder
|
||||||
</span>
|
</span>
|
||||||
@ -384,12 +439,20 @@ export default function GroupChatsAdministration() {
|
|||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-semibold text-gray-900 dark:text-white">
|
<h2 className="text-base font-semibold text-gray-900 dark:text-white">
|
||||||
Gruppenchat bearbeiten
|
{isTeamChat ? 'Team-Gruppenchat' : 'Gruppenchat bearbeiten'}
|
||||||
</h2>
|
</h2>
|
||||||
|
{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">
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
Der Ersteller bleibt Mitglied; weitere Mitglieder können zentral
|
Der Ersteller bleibt Mitglied; weitere Mitglieder können zentral
|
||||||
verwaltet werden.
|
verwaltet werden.
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
@ -397,14 +460,16 @@ export default function GroupChatsAdministration() {
|
|||||||
Gruppenname
|
Gruppenname
|
||||||
<input
|
<input
|
||||||
required
|
required
|
||||||
|
disabled={isTeamChat}
|
||||||
maxLength={80}
|
maxLength={80}
|
||||||
value={draft.name}
|
value={draft.name}
|
||||||
onChange={(event) => updateDraft('name', event.target.value)}
|
onChange={(event) => updateDraft('name', event.target.value)}
|
||||||
className={`${inputClassName} mt-2`}
|
className={`${inputClassName} mt-2 disabled:cursor-not-allowed disabled:opacity-60`}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!isTeamChat && (
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
Gruppenbild
|
Gruppenbild
|
||||||
@ -453,6 +518,7 @@ export default function GroupChatsAdministration() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="mt-6 border-t border-gray-200 pt-5 dark:border-white/10">
|
<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">
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||||
@ -461,7 +527,9 @@ export default function GroupChatsAdministration() {
|
|||||||
Mitglieder
|
Mitglieder
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative w-full sm:w-64">
|
<div className="relative w-full sm:w-64">
|
||||||
@ -486,7 +554,7 @@ export default function GroupChatsAdministration() {
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
key={user.id}
|
key={user.id}
|
||||||
checked={isOwner || draft.userIds.includes(user.id)}
|
checked={isOwner || draft.userIds.includes(user.id)}
|
||||||
disabled={isOwner}
|
disabled={isOwner || isTeamChat}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
toggleUser(user.id, event.target.checked)
|
toggleUser(user.id, event.target.checked)
|
||||||
}
|
}
|
||||||
@ -503,6 +571,22 @@ export default function GroupChatsAdministration() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 flex flex-wrap justify-between gap-3">
|
<div className="mt-6 flex flex-wrap justify-between gap-3">
|
||||||
|
{isTeamChat ? (
|
||||||
|
<>
|
||||||
|
<span />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
color="red"
|
||||||
|
isLoading={isEmptying}
|
||||||
|
leadingIcon={<TrashIcon className="size-4" />}
|
||||||
|
onClick={() => void emptyGroup()}
|
||||||
|
>
|
||||||
|
Verlauf leeren
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@ -522,6 +606,8 @@ export default function GroupChatsAdministration() {
|
|||||||
>
|
>
|
||||||
Gruppenchat speichern
|
Gruppenchat speichern
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -33,13 +33,7 @@ async function readApiError(response: Response, fallback: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getTeamSearchText(team: AdminTeam) {
|
function getTeamSearchText(team: AdminTeam) {
|
||||||
return [
|
return team.name.toLowerCase()
|
||||||
team.name,
|
|
||||||
team.initial,
|
|
||||||
team.href,
|
|
||||||
]
|
|
||||||
.join(' ')
|
|
||||||
.toLowerCase()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TeamsAdministration() {
|
export default function TeamsAdministration() {
|
||||||
@ -113,8 +107,6 @@ export default function TeamsAdministration() {
|
|||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
name: String(formData.get('teamName') ?? ''),
|
name: String(formData.get('teamName') ?? ''),
|
||||||
initial: String(formData.get('teamInitial') ?? ''),
|
|
||||||
href: String(formData.get('teamHref') ?? '#'),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
@ -290,7 +282,7 @@ export default function TeamsAdministration() {
|
|||||||
|
|
||||||
<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">
|
||||||
{selectedTeam
|
{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.'}
|
: 'Erstelle ein neues Team, das anschließend Benutzern zugewiesen werden kann.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -321,45 +313,6 @@ export default function TeamsAdministration() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<div className="mt-4 flex justify-end gap-x-3">
|
<div className="mt-4 flex justify-end gap-x-3">
|
||||||
|
|||||||
@ -576,7 +576,7 @@ export default function CalendarPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
<div>
|
||||||
{editingEvent && (
|
{editingEvent && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
// frontend\src\pages\chat\ChatPage.tsx
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
ArrowRightStartOnRectangleIcon,
|
ArrowRightStartOnRectangleIcon,
|
||||||
@ -38,6 +40,7 @@ import AddressMapPicker from '../../components/AddressMapPicker'
|
|||||||
import LoadingSpinner from '../../components/LoadingSpinner'
|
import LoadingSpinner from '../../components/LoadingSpinner'
|
||||||
import Modal, { ModalTitle } from '../../components/Modal'
|
import Modal, { ModalTitle } from '../../components/Modal'
|
||||||
import Tabs from '../../components/Tabs'
|
import Tabs from '../../components/Tabs'
|
||||||
|
import { useChatSocket } from '../../components/ChatSocketProvider'
|
||||||
import { hasRight } from '../../components/permissions'
|
import { hasRight } from '../../components/permissions'
|
||||||
import type { User } from '../../components/types'
|
import type { User } from '../../components/types'
|
||||||
|
|
||||||
@ -473,7 +476,7 @@ function extractFirstUrl(text: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function lastMessagePreviewText(message?: ChatMessage | null) {
|
function lastMessagePreviewText(message?: ChatMessage | null) {
|
||||||
if (!message) {
|
if (!message || message.type === 'system') {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
if (message.body) {
|
if (message.body) {
|
||||||
@ -488,6 +491,27 @@ function lastMessagePreviewText(message?: ChatMessage | null) {
|
|||||||
return ''
|
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 = [
|
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) {
|
export default function ChatPage({ currentUser }: ChatPageProps) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { channelId, conversationId, teamId } = useParams()
|
const { channelId, conversationId, teamId } = useParams()
|
||||||
|
const {
|
||||||
|
connected: socketConnected,
|
||||||
|
send: sendChatCommand,
|
||||||
|
subscribe: subscribeChatSocket,
|
||||||
|
} = useChatSocket()
|
||||||
const messagesEndRef = useRef<HTMLDivElement | null>(null)
|
const messagesEndRef = useRef<HTMLDivElement | null>(null)
|
||||||
const attachmentInputRef = useRef<HTMLInputElement | null>(null)
|
const attachmentInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
const lastPreviewUrlRef = useRef('')
|
const lastPreviewUrlRef = useRef('')
|
||||||
const chatSocketRef = useRef<WebSocket | null>(null)
|
|
||||||
const selectedConversationIdRef = useRef<string | undefined>(undefined)
|
const selectedConversationIdRef = useRef<string | undefined>(undefined)
|
||||||
const typingConversationIdRef = useRef<string | undefined>(undefined)
|
const typingConversationIdRef = useRef<string | undefined>(undefined)
|
||||||
const typingStopTimerRef = useRef<number | undefined>(undefined)
|
const typingStopTimerRef = useRef<number | undefined>(undefined)
|
||||||
@ -684,7 +703,6 @@ export default function ChatPage({ currentUser }: ChatPageProps) {
|
|||||||
const [attachMenuOpen, setAttachMenuOpen] = useState(false)
|
const [attachMenuOpen, setAttachMenuOpen] = useState(false)
|
||||||
const [draftPreview, setDraftPreview] = useState<ChatLinkPreview | null>(null)
|
const [draftPreview, setDraftPreview] = useState<ChatLinkPreview | null>(null)
|
||||||
const [dismissedPreviewUrl, setDismissedPreviewUrl] = useState('')
|
const [dismissedPreviewUrl, setDismissedPreviewUrl] = useState('')
|
||||||
const [socketConnected, setSocketConnected] = useState(false)
|
|
||||||
const [statusNow, setStatusNow] = useState(() => Date.now())
|
const [statusNow, setStatusNow] = useState(() => Date.now())
|
||||||
const [typingUsers, setTypingUsers] = useState<
|
const [typingUsers, setTypingUsers] = useState<
|
||||||
Record<string, Record<string, { name: string; expiresAt: number }>>
|
Record<string, Record<string, { name: string; expiresAt: number }>>
|
||||||
@ -753,22 +771,24 @@ export default function ChatPage({ currentUser }: ChatPageProps) {
|
|||||||
|
|
||||||
const filteredConversations = useMemo(() => {
|
const filteredConversations = useMemo(() => {
|
||||||
const normalizedSearch = search.trim().toLowerCase()
|
const normalizedSearch = search.trim().toLowerCase()
|
||||||
|
const sourceConversations = normalizedSearch ? conversations : tabConversations
|
||||||
|
|
||||||
if (!normalizedSearch) {
|
if (!normalizedSearch) {
|
||||||
return tabConversations
|
return sourceConversations
|
||||||
}
|
}
|
||||||
|
|
||||||
const matchingConversationIds = new Set(
|
const matchingConversationIds = new Set(
|
||||||
searchResults.map((result) => result.conversationId),
|
searchResults.map((result) => result.conversationId),
|
||||||
)
|
)
|
||||||
|
|
||||||
return tabConversations.filter(
|
return sourceConversations.filter(
|
||||||
(conversation) =>
|
(conversation) =>
|
||||||
conversationTitle(conversation, currentUser.id)
|
conversationTitle(conversation, currentUser.id)
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.includes(normalizedSearch) ||
|
.includes(normalizedSearch) ||
|
||||||
matchingConversationIds.has(conversation.id),
|
matchingConversationIds.has(conversation.id),
|
||||||
)
|
)
|
||||||
}, [currentUser.id, search, searchResults, tabConversations])
|
}, [conversations, currentUser.id, search, searchResults, tabConversations])
|
||||||
|
|
||||||
const searchResultByConversation = useMemo(
|
const searchResultByConversation = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@ -1249,19 +1269,15 @@ export default function ChatPage({ currentUser }: ChatPageProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sendTypingState = useCallback((conversationId: string, typing: boolean) => {
|
const sendTypingState = useCallback(
|
||||||
const socket = chatSocketRef.current
|
(conversationId: string, typing: boolean) => {
|
||||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
sendChatCommand({
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.send(
|
|
||||||
JSON.stringify({
|
|
||||||
type: typing ? 'typing.start' : 'typing.stop',
|
type: typing ? 'typing.start' : 'typing.stop',
|
||||||
conversationId,
|
conversationId,
|
||||||
}),
|
})
|
||||||
|
},
|
||||||
|
[sendChatCommand],
|
||||||
)
|
)
|
||||||
}, [])
|
|
||||||
|
|
||||||
const stopTyping = useCallback(() => {
|
const stopTyping = useCallback(() => {
|
||||||
if (typingStopTimerRef.current !== undefined) {
|
if (typingStopTimerRef.current !== undefined) {
|
||||||
@ -1330,12 +1346,6 @@ export default function ChatPage({ currentUser }: ChatPageProps) {
|
|||||||
location?: ChatLocation
|
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()
|
const clientId = crypto.randomUUID()
|
||||||
pendingMessageRef.current = {
|
pendingMessageRef.current = {
|
||||||
clientId,
|
clientId,
|
||||||
@ -1347,9 +1357,7 @@ export default function ChatPage({ currentUser }: ChatPageProps) {
|
|||||||
setSending(true)
|
setSending(true)
|
||||||
setError('')
|
setError('')
|
||||||
|
|
||||||
try {
|
const sent = sendChatCommand({
|
||||||
socket.send(
|
|
||||||
JSON.stringify({
|
|
||||||
type: 'message.send',
|
type: 'message.send',
|
||||||
clientId,
|
clientId,
|
||||||
conversationId: conversation.id,
|
conversationId: conversation.id,
|
||||||
@ -1360,15 +1368,16 @@ export default function ChatPage({ currentUser }: ChatPageProps) {
|
|||||||
replyToMessageId: options?.replyToMessageId ?? '',
|
replyToMessageId: options?.replyToMessageId ?? '',
|
||||||
forwardedFromMessageId: options?.forwardedMessage?.id ?? '',
|
forwardedFromMessageId: options?.forwardedMessage?.id ?? '',
|
||||||
location: options?.location ?? null,
|
location: options?.location ?? null,
|
||||||
}),
|
})
|
||||||
)
|
|
||||||
return true
|
if (!sent) {
|
||||||
} catch {
|
|
||||||
pendingMessageRef.current = null
|
pendingMessageRef.current = null
|
||||||
setSending(false)
|
setSending(false)
|
||||||
setError('Nachricht konnte nicht gesendet werden')
|
setError('Die Chat-Verbindung wird gerade neu aufgebaut.')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendMessage(event: FormEvent<HTMLFormElement>) {
|
function sendMessage(event: FormEvent<HTMLFormElement>) {
|
||||||
@ -1519,13 +1528,7 @@ export default function ChatPage({ currentUser }: ChatPageProps) {
|
|||||||
selectedConversationIdRef.current = selectedConversationId
|
selectedConversationIdRef.current = selectedConversationId
|
||||||
}, [selectedConversationId])
|
}, [selectedConversationId])
|
||||||
|
|
||||||
useEffect(() => {
|
const restorePendingMessage = useCallback((message: string) => {
|
||||||
let socket: WebSocket | null = null
|
|
||||||
let reconnectTimer: number | undefined
|
|
||||||
let reconnectAttempt = 0
|
|
||||||
let stopped = false
|
|
||||||
|
|
||||||
function restorePendingMessage(message: string) {
|
|
||||||
const pendingMessage = pendingMessageRef.current
|
const pendingMessage = pendingMessageRef.current
|
||||||
pendingMessageRef.current = null
|
pendingMessageRef.current = null
|
||||||
|
|
||||||
@ -1544,28 +1547,14 @@ export default function ChatPage({ currentUser }: ChatPageProps) {
|
|||||||
|
|
||||||
setSending(false)
|
setSending(false)
|
||||||
setError(message)
|
setError(message)
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
function connect() {
|
// Eingehende Ereignisse der gemeinsamen Chat-WebSocket-Verbindung behandeln.
|
||||||
socket = new WebSocket(getChatWebSocketUrl())
|
useEffect(() => {
|
||||||
chatSocketRef.current = socket
|
const unsubscribe = subscribeChatSocket((rawPayload) => {
|
||||||
|
const payload = rawPayload as ChatSocketEvent
|
||||||
socket.onopen = () => {
|
|
||||||
reconnectAttempt = 0
|
|
||||||
setSocketConnected(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.onmessage = (event) => {
|
|
||||||
let payload: ChatSocketEvent
|
|
||||||
|
|
||||||
try {
|
|
||||||
payload = JSON.parse(event.data) as ChatSocketEvent
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (payload.type === 'connected') {
|
if (payload.type === 'connected') {
|
||||||
setSocketConnected(true)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1674,6 +1663,8 @@ export default function ChatPage({ currentUser }: ChatPageProps) {
|
|||||||
message.sender.id === currentUser.id ||
|
message.sender.id === currentUser.id ||
|
||||||
message.conversationId === selectedConversationIdRef.current
|
message.conversationId === selectedConversationIdRef.current
|
||||||
? 0
|
? 0
|
||||||
|
: message.type === 'system'
|
||||||
|
? conversation.unreadCount ?? 0
|
||||||
: (conversation.unreadCount ?? 0) + 1,
|
: (conversation.unreadCount ?? 0) + 1,
|
||||||
}
|
}
|
||||||
: conversation,
|
: conversation,
|
||||||
@ -1686,9 +1677,7 @@ export default function ChatPage({ currentUser }: ChatPageProps) {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
if (
|
if (message.conversationId === selectedConversationIdRef.current) {
|
||||||
message.conversationId === selectedConversationIdRef.current
|
|
||||||
) {
|
|
||||||
setMessages((current) =>
|
setMessages((current) =>
|
||||||
current.some((item) => item.id === message.id)
|
current.some((item) => item.id === message.id)
|
||||||
? current
|
? current
|
||||||
@ -1697,7 +1686,10 @@ export default function ChatPage({ currentUser }: ChatPageProps) {
|
|||||||
if (message.sender.id !== currentUser.id) {
|
if (message.sender.id !== currentUser.id) {
|
||||||
void markConversationRead(message.conversationId)
|
void markConversationRead(message.conversationId)
|
||||||
}
|
}
|
||||||
} else if (message.sender.id !== currentUser.id) {
|
} else if (
|
||||||
|
message.sender.id !== currentUser.id &&
|
||||||
|
message.type !== 'system'
|
||||||
|
) {
|
||||||
window.dispatchEvent(new Event('chat-unread-changed'))
|
window.dispatchEvent(new Event('chat-unread-changed'))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1708,47 +1700,32 @@ export default function ChatPage({ currentUser }: ChatPageProps) {
|
|||||||
pendingMessageRef.current = null
|
pendingMessageRef.current = null
|
||||||
setSending(false)
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.onclose = () => {
|
|
||||||
if (chatSocketRef.current === socket) {
|
|
||||||
chatSocketRef.current = null
|
|
||||||
}
|
|
||||||
|
|
||||||
setSocketConnected(false)
|
|
||||||
typingConversationIdRef.current = undefined
|
typingConversationIdRef.current = undefined
|
||||||
|
|
||||||
if (pendingMessageRef.current) {
|
if (pendingMessageRef.current) {
|
||||||
restorePendingMessage(
|
restorePendingMessage(
|
||||||
'Die Verbindung wurde unterbrochen. Die Nachricht wurde nicht gesendet.',
|
'Die Verbindung wurde unterbrochen. Die Nachricht wurde nicht gesendet.',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}, [socketConnected, restorePendingMessage])
|
||||||
|
|
||||||
if (stopped) {
|
// Beim Verlassen der Chat-Seite ein laufendes "tippt..." beenden.
|
||||||
return
|
useEffect(() => {
|
||||||
}
|
|
||||||
|
|
||||||
const delay = Math.min(1000 * 2 ** reconnectAttempt, 10_000)
|
|
||||||
reconnectAttempt += 1
|
|
||||||
reconnectTimer = window.setTimeout(connect, delay)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
connect()
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
stopped = true
|
|
||||||
setSocketConnected(false)
|
|
||||||
|
|
||||||
if (reconnectTimer !== undefined) {
|
|
||||||
window.clearTimeout(reconnectTimer)
|
|
||||||
}
|
|
||||||
|
|
||||||
chatSocketRef.current = null
|
|
||||||
stopTyping()
|
stopTyping()
|
||||||
socket?.close(1000, 'Chat geschlossen')
|
|
||||||
}
|
}
|
||||||
}, [currentUser.id, navigate, stopTyping])
|
}, [stopTyping])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initialLoadId = window.setTimeout(() => {
|
const initialLoadId = window.setTimeout(() => {
|
||||||
@ -2010,6 +1987,7 @@ export default function ChatPage({ currentUser }: ChatPageProps) {
|
|||||||
className="mt-4"
|
className="mt-4"
|
||||||
variant="pills-brand"
|
variant="pills-brand"
|
||||||
fullWidth
|
fullWidth
|
||||||
|
background="subtle"
|
||||||
ariaLabel="Chats und Channels"
|
ariaLabel="Chats und Channels"
|
||||||
tabs={[
|
tabs={[
|
||||||
{
|
{
|
||||||
@ -2047,11 +2025,7 @@ export default function ChatPage({ currentUser }: ChatPageProps) {
|
|||||||
<input
|
<input
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(event) => handleSearchChange(event.target.value)}
|
onChange={(event) => handleSearchChange(event.target.value)}
|
||||||
placeholder={
|
placeholder="Suchen..."
|
||||||
visibleTab === 'channels'
|
|
||||||
? 'Channels und Meldungen durchsuchen'
|
|
||||||
: 'Chats und Nachrichten durchsuchen'
|
|
||||||
}
|
|
||||||
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"
|
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>
|
</div>
|
||||||
@ -2076,9 +2050,7 @@ export default function ChatPage({ currentUser }: ChatPageProps) {
|
|||||||
{search.trim()
|
{search.trim()
|
||||||
? searchingMessages
|
? searchingMessages
|
||||||
? 'Nachrichten werden durchsucht...'
|
? 'Nachrichten werden durchsucht...'
|
||||||
: visibleTab === 'channels'
|
: 'Kein Chat, Channel und keine Nachricht passt zur Suche.'
|
||||||
? 'Kein Channel und keine Meldung passt zur Suche.'
|
|
||||||
: 'Kein Chat und keine Nachricht passt zur Suche.'
|
|
||||||
: visibleTab === 'channels'
|
: visibleTab === 'channels'
|
||||||
? 'Channels werden in der Administration eingerichtet.'
|
? 'Channels werden in der Administration eingerichtet.'
|
||||||
: 'Öffne ein Team oder starte eine Direktnachricht.'}
|
: 'Öffne ein Team oder starte eine Direktnachricht.'}
|
||||||
@ -2176,7 +2148,10 @@ export default function ChatPage({ currentUser }: ChatPageProps) {
|
|||||||
: `${conversationTypingUsers.length} Personen schreiben...`
|
: `${conversationTypingUsers.length} Personen schreiben...`
|
||||||
: search.trim() && searchResult
|
: search.trim() && searchResult
|
||||||
? `${searchResult.senderDisplayName}: ${searchResult.body}`
|
? `${searchResult.senderDisplayName}: ${searchResult.body}`
|
||||||
: lastMessagePreviewText(conversation.lastMessage) ||
|
: conversationPreviewText(
|
||||||
|
conversation,
|
||||||
|
currentUser.id,
|
||||||
|
) ||
|
||||||
(conversation.type === 'channel'
|
(conversation.type === 'channel'
|
||||||
? 'Schreibgeschützter Channel'
|
? 'Schreibgeschützter Channel'
|
||||||
: conversation.type !== 'direct'
|
: conversation.type !== 'direct'
|
||||||
@ -2624,6 +2599,16 @@ export default function ChatPage({ currentUser }: ChatPageProps) {
|
|||||||
angebundenen Integration veröffentlicht.
|
angebundenen Integration veröffentlicht.
|
||||||
</div>
|
</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
|
<form
|
||||||
onSubmit={sendMessage}
|
onSubmit={sendMessage}
|
||||||
className="relative shrink-0 border-t border-gray-200 bg-white p-4 dark:border-white/10 dark:bg-gray-900"
|
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}
|
rows={1}
|
||||||
maxLength={4000}
|
maxLength={4000}
|
||||||
placeholder={
|
placeholder={
|
||||||
socketConnected
|
forwardDraft
|
||||||
? forwardDraft
|
|
||||||
? 'Kommentar hinzufügen...'
|
? 'Kommentar hinzufügen...'
|
||||||
: 'Nachricht schreiben...'
|
: 'Nachricht schreiben...'
|
||||||
: 'Chat-Verbindung wird hergestellt...'
|
|
||||||
}
|
}
|
||||||
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -83,6 +83,9 @@ export default function CreateDeviceModal({
|
|||||||
'serialNumber',
|
'serialNumber',
|
||||||
'macAddress',
|
'macAddress',
|
||||||
'ipAddress',
|
'ipAddress',
|
||||||
|
'phoneNumber',
|
||||||
|
'computerName',
|
||||||
|
'deviceCategory',
|
||||||
'firmwareVersion',
|
'firmwareVersion',
|
||||||
'loanStatus',
|
'loanStatus',
|
||||||
].includes(
|
].includes(
|
||||||
@ -121,7 +124,7 @@ export default function CreateDeviceModal({
|
|||||||
</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">
|
||||||
Erfasse ein neues Gerät inklusive Inventur-Nr., Standort und Zubehör.
|
Wähle die Geräteart und erfasse nur die passenden Daten.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,20 @@
|
|||||||
// frontend\src\pages\devices\DeviceFormFields.tsx
|
// 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 Combobox, { type ComboboxItem } from '../../components/Combobox'
|
||||||
import Checkbox from '../../components/Checkbox'
|
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 Switch from '../../components/Switch'
|
||||||
import type { DeviceModalTabId } from './DeviceModalTabs'
|
import type { DeviceModalTabId } from './DeviceModalTabs'
|
||||||
import Textarea from '../../components/Textarea'
|
import Textarea from '../../components/Textarea'
|
||||||
@ -10,8 +22,13 @@ import ChildDeviceCard from './ChildDeviceCard'
|
|||||||
import MilestoneChildDeviceCards from './MilestoneChildDeviceCards'
|
import MilestoneChildDeviceCards from './MilestoneChildDeviceCards'
|
||||||
import type { Device } from '../../components/types'
|
import type { Device } from '../../components/types'
|
||||||
import {
|
import {
|
||||||
|
ArrowsRightLeftIcon,
|
||||||
|
CircleStackIcon,
|
||||||
|
ComputerDesktopIcon,
|
||||||
|
MagnifyingGlassIcon,
|
||||||
MicrophoneIcon,
|
MicrophoneIcon,
|
||||||
VideoCameraIcon,
|
VideoCameraIcon,
|
||||||
|
WifiIcon,
|
||||||
} from '@heroicons/react/20/solid'
|
} from '@heroicons/react/20/solid'
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||||
@ -44,18 +61,140 @@ type DeviceFormFieldsProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const inputClassName =
|
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 =
|
type DeviceCategory = 'Kameras' | 'Router' | 'Switchbox' | 'Laptop' | 'Festplatte'
|
||||||
'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'
|
|
||||||
|
|
||||||
export const sectionClassName =
|
type DeviceLoanUser = {
|
||||||
'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'
|
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>) {
|
function classNames(...classes: Array<string | false | null | undefined>) {
|
||||||
return classes.filter(Boolean).join(' ')
|
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({
|
export function SectionHeader({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@ -131,6 +270,9 @@ function getDeviceAccessorySearchText(device: Device) {
|
|||||||
device.serialNumber,
|
device.serialNumber,
|
||||||
device.macAddress,
|
device.macAddress,
|
||||||
device.ipAddress,
|
device.ipAddress,
|
||||||
|
device.phoneNumber,
|
||||||
|
device.computerName,
|
||||||
|
device.deviceCategory,
|
||||||
device.firmwareVersion,
|
device.firmwareVersion,
|
||||||
device.location,
|
device.location,
|
||||||
device.loanStatus,
|
device.loanStatus,
|
||||||
@ -234,6 +376,10 @@ export default function DeviceFormFields({
|
|||||||
onOpenMilestoneCameraDetails,
|
onOpenMilestoneCameraDetails,
|
||||||
togglingMilestoneCameraRecordingIds,
|
togglingMilestoneCameraRecordingIds,
|
||||||
}: DeviceFormFieldsProps) {
|
}: DeviceFormFieldsProps) {
|
||||||
|
const isMilestoneDevice = Boolean(
|
||||||
|
device?.milestoneHardwareId || device?.milestoneRecordingServerId,
|
||||||
|
)
|
||||||
|
|
||||||
const assignableDevices = useMemo(
|
const assignableDevices = useMemo(
|
||||||
() => devices.filter((item) => item.id !== device?.id),
|
() => devices.filter((item) => item.id !== device?.id),
|
||||||
[devices, device?.id],
|
[devices, device?.id],
|
||||||
@ -245,6 +391,18 @@ export default function DeviceFormFields({
|
|||||||
const [locationOptions, setLocationOptions] = useState<ComboboxItem[]>([])
|
const [locationOptions, setLocationOptions] = useState<ComboboxItem[]>([])
|
||||||
const [selectedManufacturer, setSelectedManufacturer] = useState<ComboboxItem | null>(null)
|
const [selectedManufacturer, setSelectedManufacturer] = useState<ComboboxItem | null>(null)
|
||||||
const [selectedLocation, setSelectedLocation] = 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)
|
const [lookupError, setLookupError] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -270,8 +428,34 @@ export default function DeviceFormFields({
|
|||||||
: null,
|
: 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()
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
@ -297,12 +481,25 @@ export default function DeviceFormFields({
|
|||||||
relatedDeviceIds.includes(assignableDevice.id),
|
relatedDeviceIds.includes(assignableDevice.id),
|
||||||
).length
|
).length
|
||||||
|
|
||||||
const isMilestoneDevice = Boolean(
|
|
||||||
device?.milestoneHardwareId || device?.milestoneRecordingServerId,
|
|
||||||
)
|
|
||||||
|
|
||||||
const milestoneChildDevices = device?.milestoneChildDevices ?? []
|
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(
|
const milestoneCameras = sortMilestoneChildDevices(
|
||||||
milestoneChildDevices.filter(
|
milestoneChildDevices.filter(
|
||||||
(item) => item.deviceType === 'camera' || item.deviceType === 'cameras',
|
(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) {
|
async function handleManufacturerChange(item: ComboboxItem | null) {
|
||||||
setLookupError(null)
|
setLookupError(null)
|
||||||
|
|
||||||
@ -412,14 +687,9 @@ export default function DeviceFormFields({
|
|||||||
|
|
||||||
<section
|
<section
|
||||||
hidden={activeTab !== 'masterData'}
|
hidden={activeTab !== 'masterData'}
|
||||||
className={classNames(sectionClassName, 'sm:col-span-6')}
|
className="sm:col-span-6"
|
||||||
>
|
>
|
||||||
<SectionHeader
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-6">
|
||||||
title="Stammdaten"
|
|
||||||
description="Inventur, Hersteller, Modell und Seriennummer."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-6">
|
|
||||||
<div className="sm:col-span-3">
|
<div className="sm:col-span-3">
|
||||||
<label
|
<label
|
||||||
htmlFor="inventoryNumber"
|
htmlFor="inventoryNumber"
|
||||||
@ -441,28 +711,54 @@ export default function DeviceFormFields({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sm:col-span-3">
|
<div className="sm:col-span-3">
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="loanedToUserId"
|
||||||
|
value={selectedLoanStatus === 'Ausgeliehen' ? String(selectedLoanUser?.id ?? '') : ''}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="loanedUntil"
|
||||||
|
value={selectedLoanStatus === 'Ausgeliehen' ? loanedUntil : ''}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex min-h-6 items-center justify-between gap-3">
|
||||||
<label
|
<label
|
||||||
htmlFor="loanStatus"
|
htmlFor="loanStatus"
|
||||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
className="block shrink-0 text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||||
>
|
>
|
||||||
Verleih-Status
|
Verleih-Status
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="mt-2">
|
{selectedLoanStatus === 'Ausgeliehen' && (
|
||||||
<select
|
<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"
|
id="loanStatus"
|
||||||
name="loanStatus"
|
name="loanStatus"
|
||||||
defaultValue={device?.loanStatus || 'Verfügbar'}
|
wrapperClassName="mt-2"
|
||||||
className={selectClassName}
|
value={selectedLoanStatus}
|
||||||
>
|
onChange={handleLoanStatusChange}
|
||||||
<option>Verfügbar</option>
|
options={loanStatusOptions}
|
||||||
<option>Ausgeliehen</option>
|
/>
|
||||||
<option>Reserviert</option>
|
|
||||||
<option>Wartung</option>
|
|
||||||
<option>Defekt</option>
|
|
||||||
<option>Verloren</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sm:col-span-3">
|
<div className="sm:col-span-3">
|
||||||
@ -480,9 +776,67 @@ export default function DeviceFormFields({
|
|||||||
placeholder="Hersteller auswählen oder hinzufügen"
|
placeholder="Hersteller auswählen oder hinzufügen"
|
||||||
allowCustomValue
|
allowCustomValue
|
||||||
createOptionLabel={(value) => `"${value}" hinzufügen`}
|
createOptionLabel={(value) => `"${value}" hinzufügen`}
|
||||||
|
inputClassName="py-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="sm:col-span-3">
|
||||||
<label
|
<label
|
||||||
htmlFor="model"
|
htmlFor="model"
|
||||||
@ -523,6 +877,7 @@ export default function DeviceFormFields({
|
|||||||
|
|
||||||
{!isMilestoneDevice && (
|
{!isMilestoneDevice && (
|
||||||
<>
|
<>
|
||||||
|
{visibleCategoryFields.macAddress && (
|
||||||
<div className="sm:col-span-3">
|
<div className="sm:col-span-3">
|
||||||
<label
|
<label
|
||||||
htmlFor="macAddress"
|
htmlFor="macAddress"
|
||||||
@ -547,7 +902,9 @@ export default function DeviceFormFields({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{visibleCategoryFields.ipAddress && (
|
||||||
<div className="sm:col-span-3">
|
<div className="sm:col-span-3">
|
||||||
<label
|
<label
|
||||||
htmlFor="ipAddress"
|
htmlFor="ipAddress"
|
||||||
@ -567,7 +924,52 @@ export default function DeviceFormFields({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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="phoneNumber"
|
||||||
|
name="phoneNumber"
|
||||||
|
type="text"
|
||||||
|
inputMode="tel"
|
||||||
|
defaultValue={device?.phoneNumber ?? ''}
|
||||||
|
className={inputClassName}
|
||||||
|
/>
|
||||||
|
</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">
|
<div className="sm:col-span-3">
|
||||||
<label
|
<label
|
||||||
htmlFor="firmwareVersion"
|
htmlFor="firmwareVersion"
|
||||||
@ -586,6 +988,7 @@ export default function DeviceFormFields({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -673,7 +1076,7 @@ export default function DeviceFormFields({
|
|||||||
Milestone-Status
|
Milestone-Status
|
||||||
</label>
|
</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
|
<VideoCameraIcon
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="size-4 shrink-0 text-indigo-500 dark:text-indigo-400"
|
className="size-4 shrink-0 text-indigo-500 dark:text-indigo-400"
|
||||||
@ -812,14 +1215,9 @@ export default function DeviceFormFields({
|
|||||||
|
|
||||||
<section
|
<section
|
||||||
hidden={activeTab !== 'location'}
|
hidden={activeTab !== 'location'}
|
||||||
className={classNames(sectionClassName, 'sm:col-span-6')}
|
className="sm:col-span-6"
|
||||||
>
|
>
|
||||||
<SectionHeader
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-6">
|
||||||
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="sm:col-span-6">
|
<div className="sm:col-span-6">
|
||||||
<input
|
<input
|
||||||
type="hidden"
|
type="hidden"
|
||||||
@ -835,6 +1233,7 @@ export default function DeviceFormFields({
|
|||||||
placeholder="Standort auswählen oder hinzufügen"
|
placeholder="Standort auswählen oder hinzufügen"
|
||||||
allowCustomValue
|
allowCustomValue
|
||||||
createOptionLabel={(value) => `"${value}" hinzufügen`}
|
createOptionLabel={(value) => `"${value}" hinzufügen`}
|
||||||
|
inputClassName="py-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -865,14 +1264,9 @@ export default function DeviceFormFields({
|
|||||||
|
|
||||||
<section
|
<section
|
||||||
hidden={activeTab !== 'accessories'}
|
hidden={activeTab !== 'accessories'}
|
||||||
className={classNames(sectionClassName, 'sm:col-span-6')}
|
className="sm:col-span-6"
|
||||||
>
|
>
|
||||||
<SectionHeader
|
<div>
|
||||||
title="Zugeordnete Geräte / Zubehör"
|
|
||||||
description="Verknüpfe Zubehör oder zugehörige Geräte."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-4">
|
|
||||||
<label
|
<label
|
||||||
htmlFor="accessorySearch"
|
htmlFor="accessorySearch"
|
||||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||||
@ -880,14 +1274,19 @@ export default function DeviceFormFields({
|
|||||||
Zubehör suchen
|
Zubehör suchen
|
||||||
</label>
|
</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
|
<input
|
||||||
id="accessorySearch"
|
id="accessorySearch"
|
||||||
type="search"
|
type="search"
|
||||||
value={accessorySearch}
|
value={accessorySearch}
|
||||||
onChange={(event) => setAccessorySearch(event.target.value)}
|
onChange={(event) => setAccessorySearch(event.target.value)}
|
||||||
placeholder="Inventur-Nr., Hersteller, Modell, Standort..."
|
placeholder="Inventur-Nr., Hersteller, Modell, Standort..."
|
||||||
className={inputClassName}
|
className={classNames(inputClassName, 'pl-9')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -897,7 +1296,14 @@ export default function DeviceFormFields({
|
|||||||
{filteredAssignableDevices.length} von {assignableDevices.length} Geräten angezeigt
|
{filteredAssignableDevices.length} von {assignableDevices.length} Geräten angezeigt
|
||||||
</span>
|
</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
|
{selectedAccessoryCount} ausgewählt
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -945,19 +1351,14 @@ export default function DeviceFormFields({
|
|||||||
|
|
||||||
<section
|
<section
|
||||||
hidden={activeTab !== 'hardware'}
|
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 ? (
|
{!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.
|
Dieses Gerät ist kein Milestone-Gerät.
|
||||||
</div>
|
</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
|
<MilestoneChildDeviceCards
|
||||||
title="Kameras"
|
title="Kameras"
|
||||||
emptyText="Keine Kameras für diese Hardware gefunden."
|
emptyText="Keine Kameras für diese Hardware gefunden."
|
||||||
@ -1002,6 +1403,86 @@ export default function DeviceFormFields({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
// frontend\src\pages\devices\DeviceModalTabs.tsx
|
// frontend\src\pages\devices\DeviceModalTabs.tsx
|
||||||
|
|
||||||
import { type ComponentType, type SVGProps } from 'react'
|
import { type ComponentType, type SVGProps } from 'react'
|
||||||
|
import Tabs, { type TabItem } from '../../components/Tabs'
|
||||||
|
|
||||||
export type DeviceModalTabId =
|
export type DeviceModalTabId =
|
||||||
| 'masterData'
|
| 'masterData'
|
||||||
@ -24,51 +25,29 @@ type DeviceModalTabsProps = {
|
|||||||
onChange: (tabId: DeviceModalTabId) => void
|
onChange: (tabId: DeviceModalTabId) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function classNames(...classes: Array<string | false | null | undefined>) {
|
|
||||||
return classes.filter(Boolean).join(' ')
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DeviceModalTabs({
|
export default function DeviceModalTabs({
|
||||||
tabs,
|
tabs,
|
||||||
activeTab,
|
activeTab,
|
||||||
onChange,
|
onChange,
|
||||||
}: DeviceModalTabsProps) {
|
}: DeviceModalTabsProps) {
|
||||||
return (
|
const tabItems: TabItem[] = tabs.map((tab) => ({
|
||||||
<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">
|
name: tab.name,
|
||||||
<nav
|
href: '#',
|
||||||
aria-label="Geräteformular"
|
current: activeTab === tab.id,
|
||||||
className="flex gap-1.5 overflow-x-auto rounded-2xl bg-gray-100/80 p-1.5 dark:bg-white/5"
|
icon: tab.icon,
|
||||||
>
|
onClick: tab.disabled ? undefined : () => onChange(tab.id),
|
||||||
{tabs.map((tab) => {
|
}))
|
||||||
const isActive = activeTab === tab.id
|
|
||||||
const Icon = tab.icon
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div className="bg-gray-50 dark:bg-gray-800/60">
|
||||||
key={tab.id}
|
<Tabs
|
||||||
type="button"
|
fullWidth
|
||||||
disabled={tab.disabled}
|
tabs={tabItems}
|
||||||
onClick={() => onChange(tab.id)}
|
variant="underline-icons"
|
||||||
className={classNames(
|
ariaLabel="Geräteformular"
|
||||||
'inline-flex items-center gap-x-2 whitespace-nowrap rounded-xl px-3 py-2 text-sm font-medium transition',
|
className="overflow-x-auto px-4 sm:px-0"
|
||||||
isActive
|
desktopNavClassName="sm:px-6"
|
||||||
? '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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,6 +21,9 @@ import type {
|
|||||||
Device,
|
Device,
|
||||||
DeviceHistoryEntry,
|
DeviceHistoryEntry,
|
||||||
MilestoneCameraDetail,
|
MilestoneCameraDetail,
|
||||||
|
MilestoneApiStream,
|
||||||
|
MilestoneCameraSettings,
|
||||||
|
User,
|
||||||
} from '../../components/types'
|
} from '../../components/types'
|
||||||
import { useNotifications } from '../../components/Notifications'
|
import { useNotifications } from '../../components/Notifications'
|
||||||
|
|
||||||
@ -153,7 +156,99 @@ function getSupportBadgeTone(severity?: string): BadgeTone {
|
|||||||
return 'blue'
|
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 { showToast, showErrorToast } = useNotifications()
|
||||||
const [devices, setDevices] = useState<Device[]>([])
|
const [devices, setDevices] = useState<Device[]>([])
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
@ -180,6 +275,8 @@ export default function DevicesPage() {
|
|||||||
const [editingCameraChildDevice, setEditingCameraChildDevice] =
|
const [editingCameraChildDevice, setEditingCameraChildDevice] =
|
||||||
useState<NonNullable<Device['milestoneChildDevices']>[number] | null>(null)
|
useState<NonNullable<Device['milestoneChildDevices']>[number] | null>(null)
|
||||||
const [cameraDetails, setCameraDetails] = useState<MilestoneCameraDetail | 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 [isCameraDetailsLoading, setIsCameraDetailsLoading] = useState(false)
|
||||||
const [isCameraDetailsSaving, setIsCameraDetailsSaving] = useState(false)
|
const [isCameraDetailsSaving, setIsCameraDetailsSaving] = useState(false)
|
||||||
const [cameraDetailsError, setCameraDetailsError] = useState<string | null>(null)
|
const [cameraDetailsError, setCameraDetailsError] = useState<string | null>(null)
|
||||||
@ -250,21 +347,145 @@ export default function DevicesPage() {
|
|||||||
setCameraDetailsError(null)
|
setCameraDetailsError(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const [cameraResponse, settingsResponse] = await Promise.all([
|
||||||
`${API_URL}/devices/${device.id}/milestone-cameras/${childDevice.id}`,
|
fetch(`${API_URL}/devices/${device.id}/milestone-cameras/${childDevice.id}`, {
|
||||||
{
|
|
||||||
credentials: 'include',
|
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(
|
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()
|
const cameraData = await cameraResponse.json()
|
||||||
setCameraDetails(data.camera ?? null)
|
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) {
|
} catch (error) {
|
||||||
setCameraDetailsError(
|
setCameraDetailsError(
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
@ -653,6 +874,8 @@ export default function DevicesPage() {
|
|||||||
setIsCameraDetailsOpen(false)
|
setIsCameraDetailsOpen(false)
|
||||||
setEditingCameraChildDevice(null)
|
setEditingCameraChildDevice(null)
|
||||||
setCameraDetails(null)
|
setCameraDetails(null)
|
||||||
|
setCameraApiStreams([])
|
||||||
|
setCameraSettings(null)
|
||||||
setCameraDetailsError(null)
|
setCameraDetailsError(null)
|
||||||
setIsCameraDetailsLoading(false)
|
setIsCameraDetailsLoading(false)
|
||||||
setIsCameraDetailsSaving(false)
|
setIsCameraDetailsSaving(false)
|
||||||
@ -746,7 +969,7 @@ export default function DevicesPage() {
|
|||||||
setIsCameraDetailsSaving(true)
|
setIsCameraDetailsSaving(true)
|
||||||
setCameraDetailsError(null)
|
setCameraDetailsError(null)
|
||||||
|
|
||||||
const payload = {
|
const cameraPayload = {
|
||||||
enabled: formData.get('enabled') === 'on',
|
enabled: formData.get('enabled') === 'on',
|
||||||
recordingEnabled: formData.get('recordingEnabled') === 'on',
|
recordingEnabled: formData.get('recordingEnabled') === 'on',
|
||||||
recordingFramerate:
|
recordingFramerate:
|
||||||
@ -754,33 +977,133 @@ export default function DevicesPage() {
|
|||||||
recordKeyframesOnly: formData.get('recordKeyframesOnly') === 'on',
|
recordKeyframesOnly: formData.get('recordKeyframesOnly') === 'on',
|
||||||
recordingStorageId:
|
recordingStorageId:
|
||||||
String(formData.get('recordingStorageId') ?? '').trim() || null,
|
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 {
|
try {
|
||||||
const response = await fetch(
|
const cameraResponse = await fetch(
|
||||||
`${API_URL}/devices/${editingDevice.id}/milestone-cameras/${editingCameraChildDevice.id}`,
|
`${API_URL}/devices/${editingDevice.id}/milestone-cameras/${editingCameraChildDevice.id}`,
|
||||||
{
|
{
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(cameraPayload),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!cameraResponse.ok) {
|
||||||
throw new Error(
|
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') ?? ''),
|
serialNumber: String(formData.get('serialNumber') ?? ''),
|
||||||
macAddress: String(formData.get('macAddress') ?? ''),
|
macAddress: String(formData.get('macAddress') ?? ''),
|
||||||
ipAddress: String(formData.get('ipAddress') ?? ''),
|
ipAddress: String(formData.get('ipAddress') ?? ''),
|
||||||
|
phoneNumber: String(formData.get('phoneNumber') ?? ''),
|
||||||
|
computerName: String(formData.get('computerName') ?? ''),
|
||||||
|
deviceCategory: String(formData.get('deviceCategory') ?? ''),
|
||||||
milestonePort: String(formData.get('milestonePort') ?? ''),
|
milestonePort: String(formData.get('milestonePort') ?? ''),
|
||||||
firmwareVersion: String(formData.get('firmwareVersion') ?? ''),
|
firmwareVersion: String(formData.get('firmwareVersion') ?? ''),
|
||||||
milestoneDisplayName: String(formData.get('milestoneDisplayName') ?? ''),
|
milestoneDisplayName: String(formData.get('milestoneDisplayName') ?? ''),
|
||||||
@ -930,6 +1256,8 @@ export default function DevicesPage() {
|
|||||||
: {}),
|
: {}),
|
||||||
location: String(formData.get('location') ?? ''),
|
location: String(formData.get('location') ?? ''),
|
||||||
loanStatus: String(formData.get('loanStatus') ?? 'Verfügbar'),
|
loanStatus: String(formData.get('loanStatus') ?? 'Verfügbar'),
|
||||||
|
loanedToUserId: String(formData.get('loanedToUserId') ?? ''),
|
||||||
|
loanedUntil: String(formData.get('loanedUntil') ?? ''),
|
||||||
comment: String(formData.get('comment') ?? ''),
|
comment: String(formData.get('comment') ?? ''),
|
||||||
isBaoDevice: formData.get('isBaoDevice') === 'on',
|
isBaoDevice: formData.get('isBaoDevice') === 'on',
|
||||||
relatedDeviceIds,
|
relatedDeviceIds,
|
||||||
@ -1232,11 +1560,7 @@ export default function DevicesPage() {
|
|||||||
]
|
]
|
||||||
|
|
||||||
return (
|
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="h-full min-h-0 overflow-y-auto px-4 py-10 sm:px-6 lg:px-8" >
|
||||||
<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>
|
|
||||||
|
|
||||||
<Tables
|
<Tables
|
||||||
title="Geräte"
|
title="Geräte"
|
||||||
description="Eine Liste aller Geräte inklusive Inventur-Nr., Hersteller, Modell, Seriennummer, MAC-Adresse, IP-Adresse, Firmware, Standort, Verleih-Status und Zubehör."
|
description="Eine Liste aller Geräte inklusive Inventur-Nr., Hersteller, Modell, Seriennummer, MAC-Adresse, IP-Adresse, Firmware, Standort, Verleih-Status und Zubehör."
|
||||||
@ -1267,17 +1591,9 @@ export default function DevicesPage() {
|
|||||||
Gerät hinzufügen
|
Gerät hinzufügen
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
}
|
}
|
||||||
onAction={openCreateModal}
|
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}
|
rows={devices}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
getRowId={(device) => device.id}
|
getRowId={(device) => device.id}
|
||||||
@ -1339,6 +1655,8 @@ export default function DevicesPage() {
|
|||||||
isJournalSubmitting={isJournalSubmitting}
|
isJournalSubmitting={isJournalSubmitting}
|
||||||
onJournalEntrySubmit={handleCreateDeviceJournalEntry}
|
onJournalEntrySubmit={handleCreateDeviceJournalEntry}
|
||||||
onJournalEntryUpdate={handleUpdateDeviceJournalEntry}
|
onJournalEntryUpdate={handleUpdateDeviceJournalEntry}
|
||||||
|
currentUserAvatar={currentUser?.avatar}
|
||||||
|
currentUserName={currentUser?.displayName || currentUser?.username}
|
||||||
togglingMilestoneChildDeviceIds={togglingMilestoneChildDeviceIds}
|
togglingMilestoneChildDeviceIds={togglingMilestoneChildDeviceIds}
|
||||||
onToggleMilestoneChildDevice={(childDevice, enabled) => {
|
onToggleMilestoneChildDevice={(childDevice, enabled) => {
|
||||||
if (!editingDevice) {
|
if (!editingDevice) {
|
||||||
@ -1376,6 +1694,8 @@ export default function DevicesPage() {
|
|||||||
device={editingDevice}
|
device={editingDevice}
|
||||||
childDevice={editingCameraChildDevice}
|
childDevice={editingCameraChildDevice}
|
||||||
camera={cameraDetails}
|
camera={cameraDetails}
|
||||||
|
apiStreams={cameraApiStreams}
|
||||||
|
cameraSettings={cameraSettings}
|
||||||
isLoading={isCameraDetailsLoading}
|
isLoading={isCameraDetailsLoading}
|
||||||
isSaving={isCameraDetailsSaving}
|
isSaving={isCameraDetailsSaving}
|
||||||
error={cameraDetailsError}
|
error={cameraDetailsError}
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
// frontend\src\pages\devices\EditDeviceModal.tsx
|
// 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 { DialogTitle } from '@headlessui/react'
|
||||||
import { XMarkIcon } from '@heroicons/react/24/outline'
|
import { XMarkIcon } from '@heroicons/react/24/outline'
|
||||||
import {
|
import {
|
||||||
ChatBubbleLeftEllipsisIcon,
|
ChatBubbleLeftEllipsisIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
IdentificationIcon,
|
IdentificationIcon,
|
||||||
|
MagnifyingGlassIcon,
|
||||||
MapPinIcon,
|
MapPinIcon,
|
||||||
PencilSquareIcon,
|
PencilSquareIcon,
|
||||||
PlusCircleIcon,
|
PlusCircleIcon,
|
||||||
@ -22,10 +23,7 @@ import type {
|
|||||||
DeviceHistoryChange,
|
DeviceHistoryChange,
|
||||||
DeviceHistoryEntry,
|
DeviceHistoryEntry,
|
||||||
} from '../../components/types'
|
} from '../../components/types'
|
||||||
import DeviceFormFields, {
|
import DeviceFormFields from './DeviceFormFields'
|
||||||
SectionHeader,
|
|
||||||
sectionClassName,
|
|
||||||
} from './DeviceFormFields'
|
|
||||||
import DeviceModalTabs, { type DeviceModalTabId } from './DeviceModalTabs'
|
import DeviceModalTabs, { type DeviceModalTabId } from './DeviceModalTabs'
|
||||||
|
|
||||||
type EditDeviceModalProps = {
|
type EditDeviceModalProps = {
|
||||||
@ -44,6 +42,8 @@ type EditDeviceModalProps = {
|
|||||||
isJournalSubmitting?: boolean
|
isJournalSubmitting?: boolean
|
||||||
onJournalEntrySubmit?: (content: string) => void | Promise<void>
|
onJournalEntrySubmit?: (content: string) => void | Promise<void>
|
||||||
onJournalEntryUpdate?: (itemId: string | number, content: string) => void | Promise<void>
|
onJournalEntryUpdate?: (itemId: string | number, content: string) => void | Promise<void>
|
||||||
|
currentUserAvatar?: string
|
||||||
|
currentUserName?: string
|
||||||
togglingMilestoneChildDeviceIds?: Set<string>
|
togglingMilestoneChildDeviceIds?: Set<string>
|
||||||
onToggleMilestoneChildDevice?: (
|
onToggleMilestoneChildDevice?: (
|
||||||
childDevice: NonNullable<Device['milestoneChildDevices']>[number],
|
childDevice: NonNullable<Device['milestoneChildDevices']>[number],
|
||||||
@ -121,6 +121,9 @@ function getTabForInvalidField(fieldName: string): DeviceModalTabId {
|
|||||||
'serialNumber',
|
'serialNumber',
|
||||||
'macAddress',
|
'macAddress',
|
||||||
'ipAddress',
|
'ipAddress',
|
||||||
|
'phoneNumber',
|
||||||
|
'computerName',
|
||||||
|
'deviceCategory',
|
||||||
'firmwareVersion',
|
'firmwareVersion',
|
||||||
'loanStatus',
|
'loanStatus',
|
||||||
].includes(fieldName)
|
].includes(fieldName)
|
||||||
@ -135,10 +138,6 @@ function getTabForInvalidField(fieldName: string): DeviceModalTabId {
|
|||||||
return 'accessories'
|
return 'accessories'
|
||||||
}
|
}
|
||||||
|
|
||||||
function classNames(...classes: Array<string | false | null | undefined>) {
|
|
||||||
return classes.filter(Boolean).join(' ')
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterMilestoneChildDevices(device: Device | null) {
|
function filterMilestoneChildDevices(device: Device | null) {
|
||||||
if (!device) {
|
if (!device) {
|
||||||
return device
|
return device
|
||||||
@ -168,6 +167,8 @@ export default function EditDeviceModal({
|
|||||||
isJournalSubmitting = false,
|
isJournalSubmitting = false,
|
||||||
onJournalEntrySubmit,
|
onJournalEntrySubmit,
|
||||||
onJournalEntryUpdate,
|
onJournalEntryUpdate,
|
||||||
|
currentUserAvatar,
|
||||||
|
currentUserName,
|
||||||
togglingMilestoneChildDeviceIds,
|
togglingMilestoneChildDeviceIds,
|
||||||
onToggleMilestoneChildDevice,
|
onToggleMilestoneChildDevice,
|
||||||
onToggleMilestoneChildDeviceGroup,
|
onToggleMilestoneChildDeviceGroup,
|
||||||
@ -176,7 +177,21 @@ export default function EditDeviceModal({
|
|||||||
onOpenMilestoneCameraDetails,
|
onOpenMilestoneCameraDetails,
|
||||||
}: EditDeviceModalProps) {
|
}: EditDeviceModalProps) {
|
||||||
const [activeTab, setActiveTab] = useState<DeviceModalTabId>('masterData')
|
const [activeTab, setActiveTab] = useState<DeviceModalTabId>('masterData')
|
||||||
|
const [journalSearchQuery, setJournalSearchQuery] = useState('')
|
||||||
const milestoneFilteredDevice = filterMilestoneChildDevices(device)
|
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) {
|
function handleOpenChange(nextOpen: boolean) {
|
||||||
if (!nextOpen && isSaving) {
|
if (!nextOpen && isSaving) {
|
||||||
@ -187,6 +202,7 @@ export default function EditDeviceModal({
|
|||||||
|
|
||||||
if (!nextOpen) {
|
if (!nextOpen) {
|
||||||
setActiveTab('masterData')
|
setActiveTab('masterData')
|
||||||
|
setJournalSearchQuery('')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -219,6 +235,7 @@ export default function EditDeviceModal({
|
|||||||
: ('edited' as const),
|
: ('edited' as const),
|
||||||
person: {
|
person: {
|
||||||
name: entry.userDisplayName || 'Unbekannt',
|
name: entry.userDisplayName || 'Unbekannt',
|
||||||
|
imageUrl: entry.userAvatar || undefined,
|
||||||
},
|
},
|
||||||
content: isJournalEntry
|
content: isJournalEntry
|
||||||
? journalChange?.newValue || 'hat einen Journal-Eintrag hinzugefügt.'
|
? journalChange?.newValue || 'hat einen Journal-Eintrag hinzugefügt.'
|
||||||
@ -227,6 +244,14 @@ export default function EditDeviceModal({
|
|||||||
: entry.changes.length > 0
|
: entry.changes.length > 0
|
||||||
? `hat ${entry.changes.map(formatHistoryChange).join(', ')} geändert.`
|
? `hat ${entry.changes.map(formatHistoryChange).join(', ')} geändert.`
|
||||||
: 'hat das Gerät bearbeitet.',
|
: '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),
|
date: formatHistoryDate(entry.createdAt),
|
||||||
datetime: entry.createdAt,
|
datetime: entry.createdAt,
|
||||||
editedDate:
|
editedDate:
|
||||||
@ -265,8 +290,8 @@ export default function EditDeviceModal({
|
|||||||
onInvalidCapture={handleInvalid}
|
onInvalidCapture={handleInvalid}
|
||||||
className="flex h-[calc(100dvh-2rem)] max-h-[calc(100dvh-2rem)] flex-col"
|
className="flex h-[calc(100dvh-2rem)] max-h-[calc(100dvh-2rem)] flex-col"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between border-b border-gray-200 bg-gray-100 px-4 py-4 sm:px-6 dark:border-white/10 dark:bg-gray-800">
|
<div className="flex items-start justify-between 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>
|
<div className="min-w-0 flex-1">
|
||||||
<DialogTitle className="text-base font-semibold text-gray-900 dark:text-white">
|
<DialogTitle className="text-base font-semibold text-gray-900 dark:text-white">
|
||||||
Gerät bearbeiten
|
Gerät bearbeiten
|
||||||
{milestoneFilteredDevice && (
|
{milestoneFilteredDevice && (
|
||||||
@ -295,6 +320,25 @@ export default function EditDeviceModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@ -307,9 +351,10 @@ export default function EditDeviceModal({
|
|||||||
<XMarkIcon aria-hidden="true" className="size-5" />
|
<XMarkIcon aria-hidden="true" className="size-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DeviceModalTabs
|
<DeviceModalTabs
|
||||||
tabs={editDeviceTabs}
|
tabs={visibleEditDeviceTabs}
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
onChange={setActiveTab}
|
onChange={setActiveTab}
|
||||||
/>
|
/>
|
||||||
@ -322,13 +367,7 @@ export default function EditDeviceModal({
|
|||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-6 sm:px-6">
|
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-6 sm:px-6">
|
||||||
{activeTab === 'history' ? (
|
{activeTab === 'history' ? (
|
||||||
<section className={classNames(sectionClassName, 'sm:col-span-6')}>
|
<div>
|
||||||
<SectionHeader
|
|
||||||
title="Bearbeitungsverlauf"
|
|
||||||
description="Änderungen an diesem Gerät."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
{isHistoryLoading && (
|
{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">
|
<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
|
<LoadingSpinner
|
||||||
@ -349,16 +388,20 @@ export default function EditDeviceModal({
|
|||||||
<Journal
|
<Journal
|
||||||
items={historyFeedItems}
|
items={historyFeedItems}
|
||||||
showEntryForm
|
showEntryForm
|
||||||
|
showSearch={false}
|
||||||
|
searchQuery={journalSearchQuery}
|
||||||
|
onSearchQueryChange={setJournalSearchQuery}
|
||||||
isSubmitting={isJournalSubmitting}
|
isSubmitting={isJournalSubmitting}
|
||||||
emptyText="Noch keine Änderungen erfasst."
|
emptyText="Noch keine Änderungen erfasst."
|
||||||
placeholder="Journal-Eintrag zum Gerät hinzufügen..."
|
placeholder="Journal-Eintrag zum Gerät hinzufügen..."
|
||||||
submitLabel="Eintrag hinzufügen"
|
submitLabel="Eintrag hinzufügen"
|
||||||
onEntrySubmit={onJournalEntrySubmit}
|
onEntrySubmit={onJournalEntrySubmit}
|
||||||
onEntryUpdate={onJournalEntryUpdate}
|
onEntryUpdate={onJournalEntryUpdate}
|
||||||
|
currentUserAvatar={currentUserAvatar}
|
||||||
|
currentUserName={currentUserName}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
) : (
|
) : (
|
||||||
<DeviceFormFields
|
<DeviceFormFields
|
||||||
open={open}
|
open={open}
|
||||||
|
|||||||
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
|
||||||
tabs={createStorageTabs}
|
tabs={createStorageTabs}
|
||||||
variant="underline"
|
variant="bar-underline"
|
||||||
|
fullWidth
|
||||||
ariaLabel="Speicherbereiche"
|
ariaLabel="Speicherbereiche"
|
||||||
className="border-b border-gray-200 px-4 py-4 sm:px-6 dark:border-white/10"
|
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 { DialogTitle } from '@headlessui/react'
|
||||||
import { XMarkIcon } from '@heroicons/react/24/outline'
|
import { XMarkIcon } from '@heroicons/react/24/outline'
|
||||||
import {
|
import {
|
||||||
|
ArrowsRightLeftIcon,
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
ChevronLeftIcon,
|
ChevronLeftIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
CircleStackIcon,
|
CircleStackIcon,
|
||||||
ClipboardDocumentListIcon,
|
ClipboardDocumentListIcon,
|
||||||
|
ComputerDesktopIcon,
|
||||||
DocumentTextIcon,
|
DocumentTextIcon,
|
||||||
MapPinIcon,
|
MapPinIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
|
VideoCameraIcon,
|
||||||
UserGroupIcon,
|
UserGroupIcon,
|
||||||
|
WifiIcon,
|
||||||
WrenchScrewdriverIcon,
|
WrenchScrewdriverIcon,
|
||||||
} from '@heroicons/react/20/solid'
|
} from '@heroicons/react/20/solid'
|
||||||
import Modal from '../../components/Modal'
|
import Modal from '../../components/Modal'
|
||||||
@ -39,6 +43,7 @@ import ServerFolderPicker, {
|
|||||||
joinServerFolderPath,
|
joinServerFolderPath,
|
||||||
} from '../../components/ServerFolderPicker'
|
} from '../../components/ServerFolderPicker'
|
||||||
import { formatOperationNumberInput, isCompleteOperationNumber } from '../../components/formatter'
|
import { formatOperationNumberInput, isCompleteOperationNumber } from '../../components/formatter'
|
||||||
|
import type { Device } from '../../components/types'
|
||||||
import type { OperationCoordinates } from '../../utils/operationGeometry'
|
import type { OperationCoordinates } from '../../utils/operationGeometry'
|
||||||
import OperationGeometryEditor from './OperationGeometryEditor'
|
import OperationGeometryEditor from './OperationGeometryEditor'
|
||||||
|
|
||||||
@ -87,6 +92,115 @@ type OperationAssigneesResponse = {
|
|||||||
officers?: OperationAssigneeUser[]
|
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) {
|
function getOperationUserName(user: OperationAssigneeUser) {
|
||||||
return user.displayName || user.username || user.email
|
return user.displayName || user.username || user.email
|
||||||
}
|
}
|
||||||
@ -225,7 +339,7 @@ function hasInvalidWindowsFolderNameCharacters(value: string) {
|
|||||||
type SetupStepIcon = ComponentType<SVGProps<SVGSVGElement>>
|
type SetupStepIcon = ComponentType<SVGProps<SVGSVGElement>>
|
||||||
|
|
||||||
const inputClassName =
|
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 =
|
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'
|
'!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({
|
export default function CreateOperationModal({
|
||||||
open,
|
open,
|
||||||
setOpen,
|
setOpen,
|
||||||
@ -488,6 +582,17 @@ export default function CreateOperationModal({
|
|||||||
const [selectedOperationTeam, setSelectedOperationTeam] = useState<ComboboxItem[]>([])
|
const [selectedOperationTeam, setSelectedOperationTeam] = useState<ComboboxItem[]>([])
|
||||||
const [isLoadingOperationAssignees, setIsLoadingOperationAssignees] = useState(false)
|
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 [milestoneStorages, setMilestoneStorages] = useState<ModalMilestoneStorage[]>([])
|
||||||
const [selectedMilestoneStorage, setSelectedMilestoneStorage] = useState<ModalMilestoneStorage | null>(null)
|
const [selectedMilestoneStorage, setSelectedMilestoneStorage] = useState<ModalMilestoneStorage | null>(null)
|
||||||
const [isLoadingStorages, setIsLoadingStorages] = useState(false)
|
const [isLoadingStorages, setIsLoadingStorages] = useState(false)
|
||||||
@ -501,6 +606,46 @@ export default function CreateOperationModal({
|
|||||||
const isFirstStep = activeStepIndex === 0
|
const isFirstStep = activeStepIndex === 0
|
||||||
const isLastStep = activeStepIndex === setupSteps.length - 1
|
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 storageDisplayNameById = useMemo(() => {
|
||||||
const byId = new Map<string, string>()
|
const byId = new Map<string, string>()
|
||||||
|
|
||||||
@ -523,6 +668,14 @@ export default function CreateOperationModal({
|
|||||||
setFormValues(initialFormValues)
|
setFormValues(initialFormValues)
|
||||||
setSelectedOperationLeader(null)
|
setSelectedOperationLeader(null)
|
||||||
setSelectedOperationTeam([])
|
setSelectedOperationTeam([])
|
||||||
|
setSelectedCameraDevice(null)
|
||||||
|
setSelectedRouterDevice(null)
|
||||||
|
setSelectedSwitchboxDevice(null)
|
||||||
|
setSelectedLaptopDevice(null)
|
||||||
|
setSelectedHardDriveDevice(null)
|
||||||
|
setQuickEquipmentDeviceDraft(null)
|
||||||
|
setQuickEquipmentDeviceError(null)
|
||||||
|
setIsCreatingQuickEquipmentDevice(false)
|
||||||
setKwPosition(null)
|
setKwPosition(null)
|
||||||
setTargetPosition(null)
|
setTargetPosition(null)
|
||||||
setKwArea(null)
|
setKwArea(null)
|
||||||
@ -546,6 +699,7 @@ export default function CreateOperationModal({
|
|||||||
const abortController = new AbortController()
|
const abortController = new AbortController()
|
||||||
|
|
||||||
void loadOperationAssignees(abortController.signal)
|
void loadOperationAssignees(abortController.signal)
|
||||||
|
void loadEquipmentDevices(abortController.signal)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
abortController.abort()
|
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) {
|
async function loadMilestoneStorages(autoSelectName?: string) {
|
||||||
setIsLoadingStorages(true)
|
setIsLoadingStorages(true)
|
||||||
try {
|
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) {
|
function handleOpenChange(nextOpen: boolean) {
|
||||||
if (!nextOpen && isSaving) {
|
if (!nextOpen && isSaving) {
|
||||||
return
|
return
|
||||||
@ -818,11 +1198,12 @@ export default function CreateOperationModal({
|
|||||||
const combinedError = localError || error
|
const combinedError = localError || error
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Modal
|
<Modal
|
||||||
open={open}
|
open={open}
|
||||||
setOpen={handleOpenChange}
|
setOpen={handleOpenChange}
|
||||||
zIndexClassName="z-[9999]"
|
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
|
<form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
@ -852,14 +1233,9 @@ export default function CreateOperationModal({
|
|||||||
<input type="hidden" name="remark" value={formValues.remark} />
|
<input type="hidden" name="remark" value={formValues.remark} />
|
||||||
<input type="hidden" name="milestoneStorage" value={selectedMilestoneStorage ? (selectedMilestoneStorage.displayName || selectedMilestoneStorage.name) : ''} />
|
<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 items-start justify-between border-b border-gray-200 bg-gray-100 px-4 py-4 sm:px-6 dark:border-white/10 dark:bg-gray-800">
|
||||||
<div className="flex min-w-0 items-start gap-3.5">
|
<div>
|
||||||
<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">
|
<DialogTitle className="text-base font-semibold text-gray-900 dark:text-white">
|
||||||
<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">
|
|
||||||
Einsatz anlegen
|
Einsatz anlegen
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|
||||||
@ -867,7 +1243,6 @@ export default function CreateOperationModal({
|
|||||||
Folge den Schritten oder überspringe optionale Bereiche.
|
Folge den Schritten oder überspringe optionale Bereiche.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@ -882,7 +1257,7 @@ export default function CreateOperationModal({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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">
|
<nav aria-label="Einrichtungsschritte" className="overflow-x-auto pb-1">
|
||||||
<div className="grid min-w-[52rem] grid-cols-7 gap-2">
|
<div className="grid min-w-[52rem] grid-cols-7 gap-2">
|
||||||
{setupSteps.map((step, index) => {
|
{setupSteps.map((step, index) => {
|
||||||
@ -920,19 +1295,14 @@ export default function CreateOperationModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{combinedError && (
|
{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}
|
{combinedError}
|
||||||
</div>
|
</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' && (
|
{activeStep === 'masterData' && (
|
||||||
<div className="space-y-6">
|
<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="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-6">
|
||||||
<div className="sm:col-span-3">
|
<div className="sm:col-span-3">
|
||||||
<label htmlFor="createOperationNumber" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
<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' && (
|
{activeStep === 'addresses' && (
|
||||||
<div className="space-y-6">
|
<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">
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
<AddressCombobox
|
<AddressCombobox
|
||||||
id="createKwAddress"
|
id="createKwAddress"
|
||||||
@ -1039,11 +1404,6 @@ export default function CreateOperationModal({
|
|||||||
|
|
||||||
{activeStep === 'responsibilities' && (
|
{activeStep === 'responsibilities' && (
|
||||||
<div className="space-y-6">
|
<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="space-y-4">
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/[0.03]">
|
<div className="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>
|
</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 className="mb-3 flex items-start justify-between gap-x-4">
|
||||||
<div>
|
<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
|
Einsatzteam
|
||||||
</h4>
|
</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}.
|
Wähle einen oder mehrere Einsatzbeamte der {OPERATION_UNIT}.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -1132,11 +1492,6 @@ export default function CreateOperationModal({
|
|||||||
|
|
||||||
{activeStep === 'notes' && (
|
{activeStep === 'notes' && (
|
||||||
<div className="space-y-6">
|
<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">
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
<Textarea
|
<Textarea
|
||||||
id="createLegend"
|
id="createLegend"
|
||||||
@ -1159,82 +1514,99 @@ export default function CreateOperationModal({
|
|||||||
|
|
||||||
{activeStep === 'equipment' && (
|
{activeStep === 'equipment' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<StepHeader
|
<div className="grid grid-cols-1 gap-x-4 gap-y-4 lg:grid-cols-2">
|
||||||
title="Technik"
|
<Combobox
|
||||||
description="Erfasse vorgesehene oder eingesetzte technische Ausstattung."
|
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">
|
<Combobox
|
||||||
<div>
|
label="Router"
|
||||||
<label htmlFor="createCamera" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
labelIcon={WifiIcon}
|
||||||
Kamera
|
items={routerOptions}
|
||||||
</label>
|
value={selectedRouterDevice}
|
||||||
<div className="mt-2">
|
onChange={handleRouterDeviceChange}
|
||||||
<input
|
variant="secondary"
|
||||||
id="createCamera"
|
allowCustomValue
|
||||||
type="text"
|
createOptionLabel={(value) => `"${value}" als Router anlegen`}
|
||||||
value={formValues.camera}
|
placeholder={
|
||||||
onChange={(event) => updateField('camera', event.target.value)}
|
isLoadingDevices
|
||||||
className={inputClassName}
|
? 'Geräte werden geladen...'
|
||||||
|
: 'Router auswählen oder anlegen'
|
||||||
|
}
|
||||||
|
emptyText="Kein Router gefunden."
|
||||||
|
disabled={isLoadingDevices}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<Combobox
|
||||||
<label htmlFor="createRouter" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
label="Switchbox"
|
||||||
Router
|
labelIcon={ArrowsRightLeftIcon}
|
||||||
</label>
|
items={switchboxOptions}
|
||||||
<div className="mt-2">
|
value={selectedSwitchboxDevice}
|
||||||
<input
|
onChange={handleSwitchboxDeviceChange}
|
||||||
id="createRouter"
|
variant="secondary"
|
||||||
type="text"
|
allowCustomValue
|
||||||
value={formValues.router}
|
createOptionLabel={(value) => `"${value}" als Switchbox anlegen`}
|
||||||
onChange={(event) => updateField('router', event.target.value)}
|
placeholder={
|
||||||
className={inputClassName}
|
isLoadingDevices
|
||||||
|
? 'Geräte werden geladen...'
|
||||||
|
: 'Switchbox auswählen oder anlegen'
|
||||||
|
}
|
||||||
|
emptyText="Keine Switchbox gefunden."
|
||||||
|
disabled={isLoadingDevices}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<Combobox
|
||||||
<label htmlFor="createSwitchbox" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
label="Laptop"
|
||||||
Switchbox
|
labelIcon={ComputerDesktopIcon}
|
||||||
</label>
|
items={laptopOptions}
|
||||||
<div className="mt-2">
|
value={selectedLaptopDevice}
|
||||||
<input
|
onChange={handleLaptopDeviceChange}
|
||||||
id="createSwitchbox"
|
variant="secondary"
|
||||||
type="text"
|
allowCustomValue
|
||||||
value={formValues.switchbox}
|
createOptionLabel={(value) => `"${value}" als Laptop anlegen`}
|
||||||
onChange={(event) => updateField('switchbox', event.target.value)}
|
placeholder={
|
||||||
className={inputClassName}
|
isLoadingDevices
|
||||||
|
? 'Geräte werden geladen...'
|
||||||
|
: 'Laptop auswählen oder anlegen'
|
||||||
|
}
|
||||||
|
emptyText="Kein Laptop gefunden."
|
||||||
|
disabled={isLoadingDevices}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<Combobox
|
||||||
<label htmlFor="createLaptop" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
label="Festplatte"
|
||||||
Laptop
|
labelIcon={CircleStackIcon}
|
||||||
</label>
|
items={hardDriveOptions}
|
||||||
<div className="mt-2">
|
value={selectedHardDriveDevice}
|
||||||
<input
|
onChange={handleHardDriveDeviceChange}
|
||||||
id="createLaptop"
|
variant="secondary"
|
||||||
type="text"
|
allowCustomValue
|
||||||
value={formValues.laptop}
|
placeholder={
|
||||||
onChange={(event) => updateField('laptop', event.target.value)}
|
isLoadingDevices
|
||||||
className={inputClassName}
|
? 'Geräte werden geladen...'
|
||||||
|
: 'Festplatte auswählen oder eingeben'
|
||||||
|
}
|
||||||
|
emptyText="Keine Festplatte gefunden."
|
||||||
|
disabled={isLoadingDevices}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeStep === 'storage' && (
|
{activeStep === 'storage' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<StepHeader
|
|
||||||
title="Milestone-Speicher"
|
|
||||||
description="Wähle einen bestehenden Speicher oder lege einen neuen an."
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isLoadingStorages && (
|
{isLoadingStorages && (
|
||||||
<div className="flex items-center justify-center py-10">
|
<div className="flex items-center justify-center py-10">
|
||||||
<LoadingSpinner size="md" label="Speicher werden geladen…" center />
|
<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: 'Basis-Speicher', href: '#', current: createStorageTab === 'basis', count: 1, onClick: () => goToCreateStorageTab('basis') },
|
||||||
{ name: 'Archivspeicher', href: '#', current: createStorageTab === 'archive', count: 2, onClick: () => goToCreateStorageTab('archive') },
|
{ name: 'Archivspeicher', href: '#', current: createStorageTab === 'archive', count: 2, onClick: () => goToCreateStorageTab('archive') },
|
||||||
]}
|
]}
|
||||||
|
fullWidth
|
||||||
variant="underline"
|
variant="underline"
|
||||||
ariaLabel="Speicherbereiche"
|
ariaLabel="Speicherbereiche"
|
||||||
className="border-b border-gray-200 px-4 dark:border-white/10"
|
className="border-b border-gray-200 px-4 dark:border-white/10"
|
||||||
@ -1531,11 +1904,6 @@ export default function CreateOperationModal({
|
|||||||
|
|
||||||
{activeStep === 'review' && (
|
{activeStep === 'review' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<StepHeader
|
|
||||||
title="Einsatz prüfen"
|
|
||||||
description="Kontrolliere die Angaben. Danach kannst du den Einsatz speichern."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<dl className="space-y-6 rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/5">
|
<dl className="space-y-6 rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/5">
|
||||||
<div>
|
<div>
|
||||||
<div className="border-b border-gray-200 pb-2 dark:border-white/10">
|
<div className="border-b border-gray-200 pb-2 dark:border-white/10">
|
||||||
@ -1575,6 +1943,9 @@ export default function CreateOperationModal({
|
|||||||
fov={viewConeFov}
|
fov={viewConeFov}
|
||||||
length={viewConeLength}
|
length={viewConeLength}
|
||||||
visible={activeStep === 'review'}
|
visible={activeStep === 'review'}
|
||||||
|
readOnly
|
||||||
|
kwLabel={formValues.kwAddress}
|
||||||
|
targetLabel={formValues.targetObject}
|
||||||
onKwPositionChange={setKwPosition}
|
onKwPositionChange={setKwPosition}
|
||||||
onTargetPositionChange={setTargetPosition}
|
onTargetPositionChange={setTargetPosition}
|
||||||
onFovChange={setViewConeFov}
|
onFovChange={setViewConeFov}
|
||||||
@ -1614,11 +1985,12 @@ export default function CreateOperationModal({
|
|||||||
</h4>
|
</h4>
|
||||||
</div>
|
</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="Kamera" value={formValues.camera} />
|
||||||
<ReviewItem label="Router" value={formValues.router} />
|
<ReviewItem label="Router" value={formValues.router} />
|
||||||
<ReviewItem label="Switchbox" value={formValues.switchbox} />
|
<ReviewItem label="Switchbox" value={formValues.switchbox} />
|
||||||
<ReviewItem label="Laptop" value={formValues.laptop} />
|
<ReviewItem label="Laptop" value={formValues.laptop} />
|
||||||
|
<ReviewItem label="Festplatte" value={formValues.hardDrive} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -1651,7 +2023,7 @@ export default function CreateOperationModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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="min-w-36">
|
||||||
<div className="flex items-center justify-between gap-3 text-xs font-medium text-gray-500 dark:text-gray-400">
|
<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>
|
<span>Schritt {activeStepIndex + 1} von {setupSteps.length}</span>
|
||||||
@ -1726,5 +2098,99 @@ export default function CreateOperationModal({
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</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
|
fov: number
|
||||||
length: number
|
length: number
|
||||||
visible?: boolean
|
visible?: boolean
|
||||||
|
readOnly?: boolean
|
||||||
|
kwLabel?: string
|
||||||
|
targetLabel?: string
|
||||||
onKwPositionChange: (position: OperationCoordinates) => void
|
onKwPositionChange: (position: OperationCoordinates) => void
|
||||||
onTargetPositionChange: (position: OperationCoordinates) => void
|
onTargetPositionChange: (position: OperationCoordinates) => void
|
||||||
onFovChange: (value: number) => void
|
onFovChange: (value: number) => void
|
||||||
@ -461,17 +464,25 @@ function DraggableOperationMarker({
|
|||||||
type,
|
type,
|
||||||
position,
|
position,
|
||||||
area,
|
area,
|
||||||
|
readOnly = false,
|
||||||
|
label,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
type: MarkerType
|
type: MarkerType
|
||||||
position: OperationCoordinates
|
position: OperationCoordinates
|
||||||
area: AddressArea | null
|
area: AddressArea | null
|
||||||
|
readOnly?: boolean
|
||||||
|
label: string
|
||||||
onChange: (type: MarkerType, position: OperationCoordinates) => void
|
onChange: (type: MarkerType, position: OperationCoordinates) => void
|
||||||
}) {
|
}) {
|
||||||
const markerRef = useRef<L.Marker | null>(null)
|
const markerRef = useRef<L.Marker | null>(null)
|
||||||
const lastValidPositionRef = useRef(position)
|
const lastValidPositionRef = useRef(position)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (readOnly) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const constrainedPosition = getInitialPositionInsideAddressArea(
|
const constrainedPosition = getInitialPositionInsideAddressArea(
|
||||||
position,
|
position,
|
||||||
area,
|
area,
|
||||||
@ -489,7 +500,7 @@ function DraggableOperationMarker({
|
|||||||
) {
|
) {
|
||||||
onChange(type, constrainedPosition)
|
onChange(type, constrainedPosition)
|
||||||
}
|
}
|
||||||
}, [area, onChange, position, type])
|
}, [area, onChange, position, type, readOnly])
|
||||||
|
|
||||||
const eventHandlers = useMemo(() => {
|
const eventHandlers = useMemo(() => {
|
||||||
function handleDrag() {
|
function handleDrag() {
|
||||||
@ -521,6 +532,7 @@ function DraggableOperationMarker({
|
|||||||
}
|
}
|
||||||
}, [area, onChange, type])
|
}, [area, onChange, type])
|
||||||
const canDrag =
|
const canDrag =
|
||||||
|
!readOnly &&
|
||||||
area !== null &&
|
area !== null &&
|
||||||
(!area.hasHouseNumber || getAreaPolygons(area).length > 0)
|
(!area.hasHouseNumber || getAreaPolygons(area).length > 0)
|
||||||
|
|
||||||
@ -528,13 +540,13 @@ function DraggableOperationMarker({
|
|||||||
<Marker
|
<Marker
|
||||||
ref={markerRef}
|
ref={markerRef}
|
||||||
draggable={canDrag}
|
draggable={canDrag}
|
||||||
autoPan
|
autoPan={canDrag}
|
||||||
position={[position.lat, position.lon]}
|
position={[position.lat, position.lon]}
|
||||||
icon={type === 'kw' ? kwIcon : targetIcon}
|
icon={type === 'kw' ? kwIcon : targetIcon}
|
||||||
eventHandlers={eventHandlers}
|
eventHandlers={eventHandlers}
|
||||||
>
|
>
|
||||||
<Tooltip permanent direction="top">
|
<Tooltip permanent direction="top">
|
||||||
{type === 'kw' ? 'KW' : 'Zielobjekt'}
|
{label}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Marker>
|
</Marker>
|
||||||
)
|
)
|
||||||
@ -609,11 +621,16 @@ export default function OperationGeometryEditor({
|
|||||||
fov,
|
fov,
|
||||||
length,
|
length,
|
||||||
visible = true,
|
visible = true,
|
||||||
|
readOnly = false,
|
||||||
|
kwLabel,
|
||||||
|
targetLabel,
|
||||||
onKwPositionChange,
|
onKwPositionChange,
|
||||||
onTargetPositionChange,
|
onTargetPositionChange,
|
||||||
onFovChange,
|
onFovChange,
|
||||||
onLengthChange,
|
onLengthChange,
|
||||||
}: OperationGeometryEditorProps) {
|
}: OperationGeometryEditorProps) {
|
||||||
|
const kwTooltip = kwLabel?.trim() ? kwLabel.trim() : 'KW'
|
||||||
|
const targetTooltip = targetLabel?.trim() ? targetLabel.trim() : 'Zielobjekt'
|
||||||
const validKwPosition = isValidOperationCoordinates(kwPosition)
|
const validKwPosition = isValidOperationCoordinates(kwPosition)
|
||||||
? kwPosition
|
? kwPosition
|
||||||
: null
|
: null
|
||||||
@ -723,8 +740,9 @@ export default function OperationGeometryEditor({
|
|||||||
Position und Sichtwinkel
|
Position und Sichtwinkel
|
||||||
</h4>
|
</h4>
|
||||||
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||||
KW und ZO innerhalb ihrer markierten Adressfläche ziehen. Die
|
{readOnly
|
||||||
Eckgriffe ändern das FOV, der mittlere Griff die Länge.
|
? '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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -732,8 +750,14 @@ export default function OperationGeometryEditor({
|
|||||||
<MapContainer
|
<MapContainer
|
||||||
center={[center.lat, center.lon]}
|
center={[center.lat, center.lon]}
|
||||||
zoom={validKwPosition || validTargetPosition ? 18 : 6}
|
zoom={validKwPosition || validTargetPosition ? 18 : 6}
|
||||||
scrollWheelZoom
|
scrollWheelZoom={!readOnly}
|
||||||
className="h-full w-full"
|
dragging={!readOnly}
|
||||||
|
doubleClickZoom={!readOnly}
|
||||||
|
touchZoom={!readOnly}
|
||||||
|
boxZoom={!readOnly}
|
||||||
|
keyboard={!readOnly}
|
||||||
|
zoomControl={!readOnly}
|
||||||
|
className={`h-full w-full ${readOnly ? 'cursor-default' : ''}`}
|
||||||
>
|
>
|
||||||
<TileLayer
|
<TileLayer
|
||||||
attribution="© OpenStreetMap"
|
attribution="© OpenStreetMap"
|
||||||
@ -790,6 +814,8 @@ export default function OperationGeometryEditor({
|
|||||||
type="kw"
|
type="kw"
|
||||||
position={validKwPosition}
|
position={validKwPosition}
|
||||||
area={kwArea}
|
area={kwArea}
|
||||||
|
readOnly={readOnly}
|
||||||
|
label={kwTooltip}
|
||||||
onChange={handlePositionChange}
|
onChange={handlePositionChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -798,11 +824,13 @@ export default function OperationGeometryEditor({
|
|||||||
type="target"
|
type="target"
|
||||||
position={validTargetPosition}
|
position={validTargetPosition}
|
||||||
area={targetArea}
|
area={targetArea}
|
||||||
|
readOnly={readOnly}
|
||||||
|
label={targetTooltip}
|
||||||
onChange={handlePositionChange}
|
onChange={handlePositionChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{geometry && leftCorner && (
|
{!readOnly && geometry && leftCorner && (
|
||||||
<ConeControlMarker
|
<ConeControlMarker
|
||||||
position={leftCorner}
|
position={leftCorner}
|
||||||
icon={fovHandleIcon}
|
icon={fovHandleIcon}
|
||||||
@ -812,7 +840,7 @@ export default function OperationGeometryEditor({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{geometry && rightCorner && (
|
{!readOnly && geometry && rightCorner && (
|
||||||
<ConeControlMarker
|
<ConeControlMarker
|
||||||
position={rightCorner}
|
position={rightCorner}
|
||||||
icon={fovHandleIcon}
|
icon={fovHandleIcon}
|
||||||
@ -822,7 +850,7 @@ export default function OperationGeometryEditor({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{geometry && centerEndpoint && (
|
{!readOnly && geometry && centerEndpoint && (
|
||||||
<ConeControlMarker
|
<ConeControlMarker
|
||||||
position={centerEndpoint}
|
position={centerEndpoint}
|
||||||
icon={lengthHandleIcon}
|
icon={lengthHandleIcon}
|
||||||
@ -847,7 +875,7 @@ export default function OperationGeometryEditor({
|
|||||||
</strong>
|
</strong>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
KW–ZO:{' '}
|
Distanz KW–ZO:{' '}
|
||||||
<strong className="font-semibold text-gray-800 dark:text-gray-200">
|
<strong className="font-semibold text-gray-800 dark:text-gray-200">
|
||||||
{geometry ? formatDistance(geometry.targetDistance) : '–'}
|
{geometry ? formatDistance(geometry.targetDistance) : '–'}
|
||||||
</strong>
|
</strong>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
// frontend\src\pages\operations\OperationMap.tsx
|
// frontend\src\pages\operations\OperationMap.tsx
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
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 * as L from 'leaflet'
|
||||||
import { PencilSquareIcon } from '@heroicons/react/20/solid'
|
import { PencilSquareIcon } from '@heroicons/react/20/solid'
|
||||||
import AppMap, { type MapHeatPoint, type MapMarker } from '../../components/Map'
|
import AppMap, { type MapHeatPoint, type MapMarker } from '../../components/Map'
|
||||||
@ -53,6 +53,7 @@ type OperationMapProps = {
|
|||||||
canEdit?: boolean
|
canEdit?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
mode?: 'markers' | 'heatmap'
|
mode?: 'markers' | 'heatmap'
|
||||||
|
focusOperationId?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
function classNames(...classes: Array<string | false | null | undefined>) {
|
function classNames(...classes: Array<string | false | null | undefined>) {
|
||||||
@ -380,6 +381,51 @@ function getOperationViewCones(markers: OperationMarker[]) {
|
|||||||
return cones
|
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({
|
export default function OperationMap({
|
||||||
operations,
|
operations,
|
||||||
isLoading,
|
isLoading,
|
||||||
@ -387,6 +433,7 @@ export default function OperationMap({
|
|||||||
canEdit = false,
|
canEdit = false,
|
||||||
mode = 'markers',
|
mode = 'markers',
|
||||||
className,
|
className,
|
||||||
|
focusOperationId,
|
||||||
}: OperationMapProps) {
|
}: OperationMapProps) {
|
||||||
const [markers, setMarkers] = useState<OperationMarker[]>([])
|
const [markers, setMarkers] = useState<OperationMarker[]>([])
|
||||||
const [isGeocoding, setIsGeocoding] = useState(false)
|
const [isGeocoding, setIsGeocoding] = useState(false)
|
||||||
@ -515,6 +562,15 @@ export default function OperationMap({
|
|||||||
}
|
}
|
||||||
}, [isLoading, operationsWithAddresses])
|
}, [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) {
|
if (isLoading || isGeocoding) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -615,9 +671,16 @@ export default function OperationMap({
|
|||||||
showCurrentLocationMarker
|
showCurrentLocationMarker
|
||||||
showLocateControl
|
showLocateControl
|
||||||
locateZoom={17}
|
locateZoom={17}
|
||||||
fitBounds
|
fitBounds={!hasFocusedOperationMarkers}
|
||||||
className="h-full min-h-[34rem]"
|
className="h-full min-h-[34rem]"
|
||||||
>
|
>
|
||||||
|
{hasFocusedOperationMarkers && focusOperationId && (
|
||||||
|
<FocusOperationBounds
|
||||||
|
markers={focusedOperationMarkers}
|
||||||
|
focusOperationId={focusOperationId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{visibleViewCones.map((cone) => (
|
{visibleViewCones.map((cone) => (
|
||||||
<Polygon
|
<Polygon
|
||||||
key={cone.id}
|
key={cone.id}
|
||||||
|
|||||||
@ -1,12 +1,10 @@
|
|||||||
// frontend/src/pages/operations/OperationModal.tsx
|
// frontend\src\pages\operations\OperationModal.tsx
|
||||||
|
|
||||||
import {
|
import {
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
type ComponentType,
|
|
||||||
type FormEvent,
|
type FormEvent,
|
||||||
type SVGProps,
|
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { DialogTitle } from '@headlessui/react'
|
import { DialogTitle } from '@headlessui/react'
|
||||||
import { XMarkIcon } from '@heroicons/react/24/outline'
|
import { XMarkIcon } from '@heroicons/react/24/outline'
|
||||||
@ -14,15 +12,14 @@ import {
|
|||||||
ChatBubbleLeftEllipsisIcon,
|
ChatBubbleLeftEllipsisIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
CircleStackIcon,
|
CircleStackIcon,
|
||||||
ClipboardDocumentListIcon,
|
ArrowsRightLeftIcon,
|
||||||
DocumentTextIcon,
|
ComputerDesktopIcon,
|
||||||
MapPinIcon,
|
MagnifyingGlassIcon,
|
||||||
PencilSquareIcon,
|
PencilSquareIcon,
|
||||||
PlusCircleIcon,
|
PlusCircleIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
UserGroupIcon,
|
|
||||||
VideoCameraIcon,
|
VideoCameraIcon,
|
||||||
WrenchScrewdriverIcon,
|
WifiIcon,
|
||||||
} from '@heroicons/react/20/solid'
|
} from '@heroicons/react/20/solid'
|
||||||
import Modal from '../../components/Modal'
|
import Modal from '../../components/Modal'
|
||||||
import Journal, { type JournalItem } from '../../components/Journal'
|
import Journal, { type JournalItem } from '../../components/Journal'
|
||||||
@ -31,9 +28,8 @@ import LoadingSpinner from '../../components/LoadingSpinner'
|
|||||||
import AddressCombobox, {
|
import AddressCombobox, {
|
||||||
type AddressArea,
|
type AddressArea,
|
||||||
} from '../../components/AddressCombobox'
|
} from '../../components/AddressCombobox'
|
||||||
import type { Operation, OperationHistoryEntry } from '../../components/types'
|
import type { Device, Operation, OperationHistoryEntry } from '../../components/types'
|
||||||
import { formatOperationNumberInput } from '../../components/formatter'
|
import { formatOperationNumberInput } from '../../components/formatter'
|
||||||
import NotificationDot from '../../components/NotificationDot'
|
|
||||||
import Combobox, { type ComboboxItem } from '../../components/Combobox'
|
import Combobox, { type ComboboxItem } from '../../components/Combobox'
|
||||||
import MultiCombobox from '../../components/MultiCombobox'
|
import MultiCombobox from '../../components/MultiCombobox'
|
||||||
import Tabs from '../../components/Tabs'
|
import Tabs from '../../components/Tabs'
|
||||||
@ -53,6 +49,8 @@ type OperationModalProps = {
|
|||||||
isJournalSubmitting?: boolean
|
isJournalSubmitting?: boolean
|
||||||
onJournalEntrySubmit?: (content: string) => void | Promise<void>
|
onJournalEntrySubmit?: (content: string) => void | Promise<void>
|
||||||
onJournalEntryUpdate?: (entryId: string | number, content: string) => void | Promise<void>
|
onJournalEntryUpdate?: (entryId: string | number, content: string) => void | Promise<void>
|
||||||
|
currentUserAvatar?: string
|
||||||
|
currentUserName?: string
|
||||||
hasUnreadJournal?: boolean
|
hasUnreadJournal?: boolean
|
||||||
onJournalTabOpen?: () => void
|
onJournalTabOpen?: () => void
|
||||||
}
|
}
|
||||||
@ -66,13 +64,8 @@ type OperationTabId =
|
|||||||
| 'storage'
|
| 'storage'
|
||||||
| 'journal'
|
| 'journal'
|
||||||
|
|
||||||
type OperationTabIcon = ComponentType<SVGProps<SVGSVGElement>>
|
|
||||||
|
|
||||||
const inputClassName =
|
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 sectionClassName =
|
|
||||||
'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'
|
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||||
const OPERATION_UNIT = 'TEG'
|
const OPERATION_UNIT = 'TEG'
|
||||||
@ -92,19 +85,13 @@ type OperationAssigneesResponse = {
|
|||||||
officers?: OperationAssigneeUser[]
|
officers?: OperationAssigneeUser[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type EquipmentDevice = {
|
const equipmentCategories = {
|
||||||
id: string
|
cameras: 'Kameras',
|
||||||
inventoryNumber: string
|
routers: 'Router',
|
||||||
manufacturer: string
|
switchboxes: 'Switchbox',
|
||||||
model: string
|
laptops: 'Laptop',
|
||||||
milestoneChildDevices: Array<{
|
hardDrives: 'Festplatte',
|
||||||
id: string
|
} as const
|
||||||
deviceType: string
|
|
||||||
name: string
|
|
||||||
displayName: string
|
|
||||||
enabled: boolean
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
|
|
||||||
type ModalMilestoneStorage = {
|
type ModalMilestoneStorage = {
|
||||||
id: string
|
id: string
|
||||||
@ -178,78 +165,88 @@ async function readApiError(response: Response, fallback: string) {
|
|||||||
return data?.error ?? fallback
|
return data?.error ?? fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
function deviceToComboboxItem(device: EquipmentDevice): ComboboxItem {
|
function getDeviceDisplayName(device: Device) {
|
||||||
|
return (
|
||||||
|
device.inventoryNumber ||
|
||||||
|
device.milestoneDisplayName ||
|
||||||
|
[device.manufacturer, device.model].filter(Boolean).join(' ') ||
|
||||||
|
device.id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function deviceToComboboxItem(device: Device): ComboboxItem {
|
||||||
return {
|
return {
|
||||||
id: device.id,
|
id: device.id,
|
||||||
name: device.inventoryNumber || device.model || device.id,
|
name: getDeviceDisplayName(device),
|
||||||
secondaryText:
|
|
||||||
[device.manufacturer, device.model].filter(Boolean).join(' · ') ||
|
|
||||||
undefined,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCameraOptionsForDevice(
|
function cameraDeviceToComboboxItem(device: Device): ComboboxItem {
|
||||||
device: EquipmentDevice | undefined,
|
const number = (device.inventoryNumber || device.model || device.id).trim()
|
||||||
): ComboboxItem[] {
|
const displayName = device.milestoneDisplayName?.trim()
|
||||||
if (!device) return []
|
|
||||||
return (device.milestoneChildDevices ?? [])
|
return {
|
||||||
.filter(
|
id: device.id,
|
||||||
(c) => c.deviceType === 'camera' || c.deviceType === 'cameras',
|
name: number,
|
||||||
|
secondaryText: displayName && displayName !== number ? displayName : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCameraSelectionItem(items: ComboboxItem[], name: string) {
|
||||||
|
const normalizedName = normalizePersonName(name)
|
||||||
|
|
||||||
|
if (!normalizedName) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
items.find(
|
||||||
|
(item) =>
|
||||||
|
normalizePersonName(item.name) === normalizedName ||
|
||||||
|
normalizePersonName(item.secondaryText ?? '') === normalizedName,
|
||||||
|
) ?? createFallbackComboboxItem(name)
|
||||||
)
|
)
|
||||||
.map((c) => ({
|
}
|
||||||
id: c.id,
|
|
||||||
name: c.name,
|
function hasDeviceCategory(device: Device, category: string) {
|
||||||
secondaryText: c.displayName !== c.name ? c.displayName : undefined,
|
return (device.deviceCategory ?? '').trim().toLowerCase() === category.toLowerCase()
|
||||||
}))
|
}
|
||||||
|
|
||||||
|
function hasAnyDeviceCategory(device: Device, categories: string[]) {
|
||||||
|
return categories.some((category) => hasDeviceCategory(device, category))
|
||||||
}
|
}
|
||||||
|
|
||||||
const operationTabs: Array<{
|
const operationTabs: Array<{
|
||||||
id: OperationTabId
|
id: OperationTabId
|
||||||
title: string
|
title: string
|
||||||
description: string
|
|
||||||
icon: OperationTabIcon
|
|
||||||
}> = [
|
}> = [
|
||||||
{
|
{
|
||||||
id: 'masterData',
|
id: 'masterData',
|
||||||
title: 'Stammdaten',
|
title: 'Stammdaten',
|
||||||
description: 'Einsatznummer, Einsatzname und Delikt.',
|
|
||||||
icon: ClipboardDocumentListIcon,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'addresses',
|
id: 'addresses',
|
||||||
title: 'Adressen',
|
title: 'Adressen',
|
||||||
description: 'Zielobjekt und KW-Adresse.',
|
|
||||||
icon: MapPinIcon,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'responsibilities',
|
id: 'responsibilities',
|
||||||
title: 'Zuständigkeiten',
|
title: 'Zuständigkeiten',
|
||||||
description: 'Sachbearbeitung, Einsatzleitung und Team.',
|
|
||||||
icon: UserGroupIcon,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'notes',
|
id: 'notes',
|
||||||
title: 'Zusatzinfos',
|
title: 'Zusatzinfos',
|
||||||
description: 'Legende und Bemerkung.',
|
|
||||||
icon: DocumentTextIcon,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'equipment',
|
id: 'equipment',
|
||||||
title: 'Technik',
|
title: 'Technik',
|
||||||
description: 'Router, Kameras, Switchbox, Laptop und Festplatte.',
|
|
||||||
icon: WrenchScrewdriverIcon,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'storage',
|
id: 'storage',
|
||||||
title: 'Speicher',
|
title: 'Speicher',
|
||||||
description: 'Milestone-Speicher anlegen und verwalten.',
|
|
||||||
icon: CircleStackIcon,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'journal',
|
id: 'journal',
|
||||||
title: 'Journal',
|
title: 'Journal',
|
||||||
description: 'Bearbeitungsverlauf des Einsatzes.',
|
|
||||||
icon: ChatBubbleLeftEllipsisIcon,
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -350,29 +347,6 @@ function classNames(...classes: Array<string | false | null | undefined>) {
|
|||||||
return classes.filter(Boolean).join(' ')
|
return classes.filter(Boolean).join(' ')
|
||||||
}
|
}
|
||||||
|
|
||||||
function SectionHeader({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
}: {
|
|
||||||
title: string
|
|
||||||
description?: string
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="relative border-b border-gray-200 pb-4 pl-3 dark:border-white/10">
|
|
||||||
<span className="absolute inset-y-0 left-0 w-1 rounded-full bg-indigo-500" />
|
|
||||||
<h3 className="text-sm font-semibold text-gray-950 dark:text-white">
|
|
||||||
{title}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{description && (
|
|
||||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatHistoryDate(value: string) {
|
function formatHistoryDate(value: string) {
|
||||||
try {
|
try {
|
||||||
return new Intl.DateTimeFormat('de-DE', {
|
return new Intl.DateTimeFormat('de-DE', {
|
||||||
@ -413,11 +387,14 @@ export default function OperationModal({
|
|||||||
isJournalSubmitting = false,
|
isJournalSubmitting = false,
|
||||||
onJournalEntrySubmit,
|
onJournalEntrySubmit,
|
||||||
onJournalEntryUpdate,
|
onJournalEntryUpdate,
|
||||||
|
currentUserAvatar,
|
||||||
|
currentUserName,
|
||||||
hasUnreadJournal = false,
|
hasUnreadJournal = false,
|
||||||
onJournalTabOpen,
|
onJournalTabOpen,
|
||||||
}: OperationModalProps) {
|
}: OperationModalProps) {
|
||||||
const isEditing = editingOperation !== null
|
const isEditing = editingOperation !== null
|
||||||
const [activeTab, setActiveTab] = useState<OperationTabId>('masterData')
|
const [activeTab, setActiveTab] = useState<OperationTabId>('masterData')
|
||||||
|
const [journalSearchQuery, setJournalSearchQuery] = useState('')
|
||||||
const [targetObject, setTargetObject] = useState('')
|
const [targetObject, setTargetObject] = useState('')
|
||||||
const [kwAddress, setKwAddress] = useState('')
|
const [kwAddress, setKwAddress] = useState('')
|
||||||
const [kwPosition, setKwPosition] = useState<OperationCoordinates | null>(null)
|
const [kwPosition, setKwPosition] = useState<OperationCoordinates | null>(null)
|
||||||
@ -433,10 +410,13 @@ export default function OperationModal({
|
|||||||
const [selectedOperationTeam, setSelectedOperationTeam] = useState<ComboboxItem[]>([])
|
const [selectedOperationTeam, setSelectedOperationTeam] = useState<ComboboxItem[]>([])
|
||||||
const [isLoadingOperationAssignees, setIsLoadingOperationAssignees] = useState(false)
|
const [isLoadingOperationAssignees, setIsLoadingOperationAssignees] = useState(false)
|
||||||
|
|
||||||
const [equipmentDevices, setEquipmentDevices] = useState<EquipmentDevice[]>([])
|
const [equipmentDevices, setEquipmentDevices] = useState<Device[]>([])
|
||||||
const [isLoadingDevices, setIsLoadingDevices] = useState(false)
|
const [isLoadingDevices, setIsLoadingDevices] = useState(false)
|
||||||
const [selectedRouter, setSelectedRouter] = useState<ComboboxItem | null>(null)
|
const [selectedCameraDevice, setSelectedCameraDevice] = useState<ComboboxItem | null>(null)
|
||||||
const [selectedCameras, setSelectedCameras] = useState<ComboboxItem[]>([])
|
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 [milestoneStorages, setMilestoneStorages] = useState<ModalMilestoneStorage[]>([])
|
const [milestoneStorages, setMilestoneStorages] = useState<ModalMilestoneStorage[]>([])
|
||||||
const [selectedMilestoneStorage, setSelectedMilestoneStorage] = useState<ModalMilestoneStorage | null>(null)
|
const [selectedMilestoneStorage, setSelectedMilestoneStorage] = useState<ModalMilestoneStorage | null>(null)
|
||||||
@ -447,22 +427,70 @@ export default function OperationModal({
|
|||||||
const [createStorageForm, setCreateStorageForm] = useState<CreateStorageFormState>(emptyCreateStorageForm)
|
const [createStorageForm, setCreateStorageForm] = useState<CreateStorageFormState>(emptyCreateStorageForm)
|
||||||
const [createStorageError, setCreateStorageError] = useState<string | null>(null)
|
const [createStorageError, setCreateStorageError] = useState<string | null>(null)
|
||||||
|
|
||||||
const routerOptions = useMemo<ComboboxItem[]>(
|
const hardwareCameraOptions = useMemo<ComboboxItem[]>(
|
||||||
() => equipmentDevices.map(deviceToComboboxItem),
|
() =>
|
||||||
|
equipmentDevices
|
||||||
|
.filter((device) => hasDeviceCategory(device, equipmentCategories.cameras))
|
||||||
|
.map(cameraDeviceToComboboxItem),
|
||||||
[equipmentDevices],
|
[equipmentDevices],
|
||||||
)
|
)
|
||||||
|
|
||||||
const cameraOptions = useMemo<ComboboxItem[]>(() => {
|
const routerOptions = useMemo<ComboboxItem[]>(
|
||||||
if (!selectedRouter) return []
|
() =>
|
||||||
const device = equipmentDevices.find((d) => d.id === selectedRouter.id)
|
equipmentDevices
|
||||||
return buildCameraOptionsForDevice(device)
|
.filter((device) => hasDeviceCategory(device, equipmentCategories.routers))
|
||||||
}, [selectedRouter, equipmentDevices])
|
.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 visibleTabs = useMemo(
|
const visibleTabs = useMemo(
|
||||||
() => operationTabs.filter((tab) => isEditing || tab.id !== 'journal'),
|
() => operationTabs.filter((tab) => isEditing || tab.id !== 'journal'),
|
||||||
[isEditing],
|
[isEditing],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const modalTabs = useMemo(
|
||||||
|
() =>
|
||||||
|
visibleTabs.map((tab) => ({
|
||||||
|
name: tab.title,
|
||||||
|
href: '#',
|
||||||
|
current: activeTab === tab.id,
|
||||||
|
count:
|
||||||
|
tab.id === 'journal' && hasUnreadJournal ? 'Neu' : undefined,
|
||||||
|
onClick: () => {
|
||||||
|
setActiveTab(tab.id)
|
||||||
|
|
||||||
|
if (tab.id === 'journal') {
|
||||||
|
onJournalTabOpen?.()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
[activeTab, hasUnreadJournal, onJournalTabOpen, visibleTabs],
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
return
|
return
|
||||||
@ -563,48 +591,42 @@ export default function OperationModal({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setSelectedRouter(null)
|
setSelectedCameraDevice(null)
|
||||||
setSelectedCameras([])
|
setSelectedRouterDevice(null)
|
||||||
|
setSelectedSwitchboxDevice(null)
|
||||||
|
setSelectedLaptopDevice(null)
|
||||||
|
setSelectedHardDriveDevice(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const routerName = editingOperation?.router ?? ''
|
setSelectedCameraDevice(
|
||||||
let routerItem: ComboboxItem | null = null
|
getCameraSelectionItem(hardwareCameraOptions, editingOperation?.camera ?? ''),
|
||||||
|
|
||||||
if (routerName) {
|
|
||||||
const device = equipmentDevices.find(
|
|
||||||
(d) =>
|
|
||||||
normalizePersonName(d.inventoryNumber || d.model || d.id) ===
|
|
||||||
normalizePersonName(routerName),
|
|
||||||
)
|
)
|
||||||
routerItem = device
|
setSelectedRouterDevice(
|
||||||
? deviceToComboboxItem(device)
|
getSelectionItem(routerOptions, editingOperation?.router ?? ''),
|
||||||
: createFallbackComboboxItem(routerName)
|
)
|
||||||
}
|
setSelectedSwitchboxDevice(
|
||||||
|
getSelectionItem(switchboxOptions, editingOperation?.switchbox ?? ''),
|
||||||
setSelectedRouter(routerItem)
|
)
|
||||||
|
setSelectedLaptopDevice(
|
||||||
const cameraNames = parseOperationTeamMembers(editingOperation?.camera ?? '')
|
getSelectionItem(laptopOptions, editingOperation?.laptop ?? ''),
|
||||||
if (cameraNames.length > 0 && routerItem) {
|
)
|
||||||
const device = equipmentDevices.find((d) => d.id === routerItem!.id)
|
setSelectedHardDriveDevice(
|
||||||
const options = buildCameraOptionsForDevice(device)
|
getSelectionItem(hardDriveOptions, editingOperation?.hardDrive ?? ''),
|
||||||
const cameras = cameraNames
|
|
||||||
.map(
|
|
||||||
(name) =>
|
|
||||||
findComboboxItemByName(options, name) ??
|
|
||||||
createFallbackComboboxItem(name),
|
|
||||||
)
|
)
|
||||||
.filter((item): item is ComboboxItem => item !== null)
|
|
||||||
setSelectedCameras(cameras)
|
|
||||||
} else {
|
|
||||||
setSelectedCameras([])
|
|
||||||
}
|
|
||||||
}, [
|
}, [
|
||||||
open,
|
open,
|
||||||
editingOperation?.id,
|
editingOperation?.id,
|
||||||
editingOperation?.router,
|
|
||||||
editingOperation?.camera,
|
editingOperation?.camera,
|
||||||
equipmentDevices,
|
editingOperation?.router,
|
||||||
|
editingOperation?.switchbox,
|
||||||
|
editingOperation?.laptop,
|
||||||
|
editingOperation?.hardDrive,
|
||||||
|
hardwareCameraOptions,
|
||||||
|
routerOptions,
|
||||||
|
switchboxOptions,
|
||||||
|
laptopOptions,
|
||||||
|
hardDriveOptions,
|
||||||
])
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -655,6 +677,7 @@ export default function OperationModal({
|
|||||||
: 'edited',
|
: 'edited',
|
||||||
person: {
|
person: {
|
||||||
name: entry.userDisplayName || 'Unbekannt',
|
name: entry.userDisplayName || 'Unbekannt',
|
||||||
|
imageUrl: entry.userAvatar || undefined,
|
||||||
},
|
},
|
||||||
content: isJournalEntry
|
content: isJournalEntry
|
||||||
? journalChange?.newValue || 'hat einen Journal-Eintrag hinzugefügt.'
|
? journalChange?.newValue || 'hat einen Journal-Eintrag hinzugefügt.'
|
||||||
@ -736,7 +759,7 @@ export default function OperationModal({
|
|||||||
|
|
||||||
if (!response.ok) return
|
if (!response.ok) return
|
||||||
|
|
||||||
const data = (await response.json()) as { devices?: EquipmentDevice[] }
|
const data = (await response.json()) as { devices?: Device[] }
|
||||||
setEquipmentDevices(data.devices ?? [])
|
setEquipmentDevices(data.devices ?? [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||||
@ -873,21 +896,16 @@ export default function OperationModal({
|
|||||||
setSelectedOperationTeam(items)
|
setSelectedOperationTeam(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRouterChange(item: ComboboxItem | null) {
|
|
||||||
setSelectedRouter(item)
|
|
||||||
setSelectedCameras([])
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCameraChange(items: ComboboxItem[]) {
|
|
||||||
setSelectedCameras(items)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleOpenChange(nextOpen: boolean) {
|
function handleOpenChange(nextOpen: boolean) {
|
||||||
if (!nextOpen && isSaving) {
|
if (!nextOpen && isSaving) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setOpen(nextOpen)
|
setOpen(nextOpen)
|
||||||
|
|
||||||
|
if (!nextOpen) {
|
||||||
|
setJournalSearchQuery('')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -895,7 +913,7 @@ export default function OperationModal({
|
|||||||
open={open}
|
open={open}
|
||||||
setOpen={handleOpenChange}
|
setOpen={handleOpenChange}
|
||||||
zIndexClassName="z-[9999]"
|
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
|
<form
|
||||||
key={editingOperation?.id ?? 'create'}
|
key={editingOperation?.id ?? 'create'}
|
||||||
@ -923,13 +941,31 @@ export default function OperationModal({
|
|||||||
<input
|
<input
|
||||||
type="hidden"
|
type="hidden"
|
||||||
name="router"
|
name="router"
|
||||||
value={selectedRouter?.name ?? ''}
|
value={selectedRouterDevice?.name ?? ''}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="hidden"
|
type="hidden"
|
||||||
name="camera"
|
name="camera"
|
||||||
value={selectedCameras.map((c) => c.name).join(', ')}
|
value={selectedCameraDevice?.name ?? ''}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="switchbox"
|
||||||
|
value={selectedSwitchboxDevice?.name ?? ''}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="laptop"
|
||||||
|
value={selectedLaptopDevice?.name ?? ''}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="hardDrive"
|
||||||
|
value={selectedHardDriveDevice?.name ?? ''}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
@ -944,14 +980,9 @@ export default function OperationModal({
|
|||||||
<input type="hidden" name="viewConeFov" value={viewConeFov} />
|
<input type="hidden" name="viewConeFov" value={viewConeFov} />
|
||||||
<input type="hidden" name="viewConeLength" value={viewConeLength} />
|
<input type="hidden" name="viewConeLength" value={viewConeLength} />
|
||||||
|
|
||||||
<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 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="flex min-w-0 items-start gap-3.5">
|
<div className="min-w-0 flex-1">
|
||||||
<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">
|
<DialogTitle className="text-base font-semibold text-gray-900 dark:text-white">
|
||||||
<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">
|
|
||||||
{isEditing ? 'Einsatz bearbeiten' : 'Einsatz hinzufügen'}
|
{isEditing ? 'Einsatz bearbeiten' : 'Einsatz hinzufügen'}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|
||||||
@ -961,7 +992,25 @@ export default function OperationModal({
|
|||||||
: 'Erfasse einen neuen Einsatz inklusive Einsatznummer, Einsatzname und Technik.'}
|
: 'Erfasse einen neuen Einsatz inklusive Einsatznummer, Einsatzname und Technik.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-start gap-2">
|
||||||
|
{activeTab === 'journal' && journalItems.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>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@ -974,66 +1023,29 @@ export default function OperationModal({
|
|||||||
<XMarkIcon aria-hidden="true" className="size-5" />
|
<XMarkIcon aria-hidden="true" className="size-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shrink-0 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="flex gap-1.5 overflow-x-auto rounded-2xl bg-gray-100/80 p-1.5 dark:bg-white/5">
|
|
||||||
{visibleTabs.map((tab) => {
|
|
||||||
const Icon = tab.icon
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setActiveTab(tab.id)
|
|
||||||
|
|
||||||
if (tab.id === 'journal') {
|
|
||||||
onJournalTabOpen?.()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={classNames(
|
|
||||||
'whitespace-nowrap rounded-xl px-3 py-2 text-sm font-medium transition',
|
|
||||||
activeTab === tab.id
|
|
||||||
? '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',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="inline-flex items-center gap-x-2">
|
|
||||||
<Icon aria-hidden="true" className="size-4 shrink-0" />
|
|
||||||
|
|
||||||
<span>{tab.title}</span>
|
|
||||||
|
|
||||||
{tab.id === 'journal' && hasUnreadJournal && (
|
|
||||||
<NotificationDot
|
|
||||||
title="Neue Journal-Einträge"
|
|
||||||
label="Neue Journal-Einträge"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="shrink-0 bg-white dark:bg-gray-900">
|
||||||
|
<Tabs
|
||||||
|
tabs={modalTabs}
|
||||||
|
variant="underline-icons"
|
||||||
|
ariaLabel="Einsatzbereiche"
|
||||||
|
className="overflow-x-auto px-4 sm:px-6"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<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">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
<div className="min-h-full bg-gray-50/70 px-4 py-6 sm:px-6 dark:bg-gray-950/40">
|
<div className="min-h-full px-4 py-6 sm:px-6">
|
||||||
|
|
||||||
{/* Stammdaten */}
|
{/* Stammdaten */}
|
||||||
<div className={activeTab === 'masterData' ? 'block' : 'hidden'}>
|
<div className={activeTab === 'masterData' ? 'block' : 'hidden'}>
|
||||||
<section className={sectionClassName}>
|
<div className="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-6">
|
||||||
<SectionHeader
|
|
||||||
title="Stammdaten"
|
|
||||||
description="Grunddaten und sachliche Zuordnung des Einsatzes."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-6">
|
|
||||||
<div className="sm:col-span-3">
|
<div className="sm:col-span-3">
|
||||||
<label htmlFor="operationNumber" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
<label htmlFor="operationNumber" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||||
Einsatznummer *
|
Einsatznummer *
|
||||||
@ -1085,18 +1097,11 @@ export default function OperationModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Adressen */}
|
{/* Adressen */}
|
||||||
<div className={activeTab === 'addresses' ? 'block' : 'hidden'}>
|
<div className={activeTab === 'addresses' ? 'block' : 'hidden'}>
|
||||||
<section className={sectionClassName}>
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
<SectionHeader
|
|
||||||
title="Adressen"
|
|
||||||
description="Zielobjekt und KW-Adresse mit Adresssuche und Karten-Vorschau."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
|
||||||
<AddressCombobox
|
<AddressCombobox
|
||||||
key={`kw-${editingOperation?.id ?? 'new'}-${open}`}
|
key={`kw-${editingOperation?.id ?? 'new'}-${open}`}
|
||||||
id="kwAddress"
|
id="kwAddress"
|
||||||
@ -1143,18 +1148,11 @@ export default function OperationModal({
|
|||||||
onLengthChange={setViewConeLength}
|
onLengthChange={setViewConeLength}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Zuständigkeiten */}
|
{/* Zuständigkeiten */}
|
||||||
<div className={activeTab === 'responsibilities' ? 'block' : 'hidden'}>
|
<div className={activeTab === 'responsibilities' ? 'block' : 'hidden'}>
|
||||||
<section className={sectionClassName}>
|
<div className="space-y-4">
|
||||||
<SectionHeader
|
|
||||||
title="Zuständigkeiten"
|
|
||||||
description="Lege fest, wer den Einsatz bearbeitet, leitet und im Einsatzteam mitwirkt."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-4 space-y-4">
|
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/[0.03]">
|
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/[0.03]">
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
@ -1204,14 +1202,14 @@ export default function OperationModal({
|
|||||||
</div>
|
</div>
|
||||||
</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 className="mb-3 flex items-start justify-between gap-x-4">
|
||||||
<div>
|
<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
|
Einsatzteam
|
||||||
</h4>
|
</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 Einheit {OPERATION_UNIT}.
|
Wähle einen oder mehrere Einsatzbeamte der Einheit {OPERATION_UNIT}.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -1237,18 +1235,11 @@ export default function OperationModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Zusatzinfos */}
|
{/* Zusatzinfos */}
|
||||||
<div className={activeTab === 'notes' ? 'block' : 'hidden'}>
|
<div className={activeTab === 'notes' ? 'block' : 'hidden'}>
|
||||||
<section className={sectionClassName}>
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
<SectionHeader
|
|
||||||
title="Zusatzinfos"
|
|
||||||
description="Legende und Bemerkung zum Einsatz."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="legend" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
<label htmlFor="legend" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||||
Legende
|
Legende
|
||||||
@ -1279,41 +1270,34 @@ export default function OperationModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Technik */}
|
{/* Technik */}
|
||||||
<div className={activeTab === 'equipment' ? 'block' : 'hidden'}>
|
<div className={activeTab === 'equipment' ? 'block' : 'hidden'}>
|
||||||
<section className={sectionClassName}>
|
<div className="space-y-6">
|
||||||
<SectionHeader
|
<div className="grid grid-cols-1 gap-x-4 gap-y-4 lg:grid-cols-2">
|
||||||
title="Technik"
|
<Combobox
|
||||||
description="Router, zugeordnete Kameras und sonstige technische Ausstattung."
|
label="Kamera"
|
||||||
|
labelIcon={VideoCameraIcon}
|
||||||
|
items={hardwareCameraOptions}
|
||||||
|
value={selectedCameraDevice}
|
||||||
|
onChange={setSelectedCameraDevice}
|
||||||
|
variant="secondary"
|
||||||
|
placeholder={
|
||||||
|
isLoadingDevices
|
||||||
|
? 'Geräte werden geladen...'
|
||||||
|
: 'Hardware-Kamera auswählen'
|
||||||
|
}
|
||||||
|
emptyText="Keine Hardware-Kamera gefunden."
|
||||||
|
disabled={isLoadingDevices}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-4 space-y-6">
|
|
||||||
{/* Kamera-Ausstattung */}
|
|
||||||
<div className="rounded-lg border border-indigo-200 bg-indigo-50/60 p-4 dark:border-indigo-500/20 dark:bg-indigo-500/10">
|
|
||||||
<div className="mb-4 flex items-center gap-2">
|
|
||||||
<VideoCameraIcon className="size-4 shrink-0 text-indigo-600 dark:text-indigo-400" />
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-semibold text-indigo-900 dark:text-indigo-200">
|
|
||||||
Kamera-Ausstattung
|
|
||||||
</h4>
|
|
||||||
<p className="mt-0.5 text-xs text-indigo-700/80 dark:text-indigo-300/80">
|
|
||||||
Wähle den Router und die zugehörigen Kameras aus.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="mb-1.5 block text-sm font-medium text-indigo-900 dark:text-indigo-200">
|
|
||||||
Router
|
|
||||||
</label>
|
|
||||||
<Combobox
|
<Combobox
|
||||||
|
label="Router"
|
||||||
|
labelIcon={WifiIcon}
|
||||||
items={routerOptions}
|
items={routerOptions}
|
||||||
value={selectedRouter}
|
value={selectedRouterDevice}
|
||||||
onChange={handleRouterChange}
|
onChange={setSelectedRouterDevice}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
allowCustomValue
|
allowCustomValue
|
||||||
placeholder={
|
placeholder={
|
||||||
@ -1321,98 +1305,62 @@ export default function OperationModal({
|
|||||||
? 'Geräte werden geladen...'
|
? 'Geräte werden geladen...'
|
||||||
: 'Router auswählen oder eingeben'
|
: 'Router auswählen oder eingeben'
|
||||||
}
|
}
|
||||||
emptyText="Kein passendes Gerät gefunden."
|
emptyText="Kein Router gefunden."
|
||||||
|
disabled={isLoadingDevices}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Combobox
|
||||||
|
label="Switchbox"
|
||||||
|
labelIcon={ArrowsRightLeftIcon}
|
||||||
|
items={switchboxOptions}
|
||||||
|
value={selectedSwitchboxDevice}
|
||||||
|
onChange={setSelectedSwitchboxDevice}
|
||||||
|
variant="secondary"
|
||||||
|
allowCustomValue
|
||||||
|
placeholder={
|
||||||
|
isLoadingDevices
|
||||||
|
? 'Geräte werden geladen...'
|
||||||
|
: 'Switchbox auswählen oder eingeben'
|
||||||
|
}
|
||||||
|
emptyText="Keine Switchbox gefunden."
|
||||||
|
disabled={isLoadingDevices}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Combobox
|
||||||
|
label="Laptop"
|
||||||
|
labelIcon={ComputerDesktopIcon}
|
||||||
|
items={laptopOptions}
|
||||||
|
value={selectedLaptopDevice}
|
||||||
|
onChange={setSelectedLaptopDevice}
|
||||||
|
variant="secondary"
|
||||||
|
allowCustomValue
|
||||||
|
placeholder={
|
||||||
|
isLoadingDevices
|
||||||
|
? 'Geräte werden geladen...'
|
||||||
|
: 'Laptop auswählen oder eingeben'
|
||||||
|
}
|
||||||
|
emptyText="Kein Laptop gefunden."
|
||||||
|
disabled={isLoadingDevices}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Combobox
|
||||||
|
label="Festplatte"
|
||||||
|
labelIcon={CircleStackIcon}
|
||||||
|
items={hardDriveOptions}
|
||||||
|
value={selectedHardDriveDevice}
|
||||||
|
onChange={setSelectedHardDriveDevice}
|
||||||
|
variant="secondary"
|
||||||
|
allowCustomValue
|
||||||
|
placeholder={
|
||||||
|
isLoadingDevices
|
||||||
|
? 'Geräte werden geladen...'
|
||||||
|
: 'Festplatte auswählen oder eingeben'
|
||||||
|
}
|
||||||
|
emptyText="Keine Festplatte gefunden."
|
||||||
disabled={isLoadingDevices}
|
disabled={isLoadingDevices}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="mb-1.5 flex items-center justify-between">
|
|
||||||
<label className="block text-sm font-medium text-indigo-900 dark:text-indigo-200">
|
|
||||||
Kameras
|
|
||||||
</label>
|
|
||||||
{selectedCameras.length > 0 && (
|
|
||||||
<span className="rounded-full bg-white px-2.5 py-1 text-xs font-medium text-indigo-700 ring-1 ring-indigo-600/20 ring-inset dark:bg-indigo-500/10 dark:text-indigo-200 dark:ring-indigo-300/20">
|
|
||||||
{selectedCameras.length} ausgewählt
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<MultiCombobox
|
|
||||||
items={cameraOptions}
|
|
||||||
value={selectedCameras}
|
|
||||||
onChange={handleCameraChange}
|
|
||||||
placeholder={
|
|
||||||
!selectedRouter
|
|
||||||
? 'Erst einen Router auswählen'
|
|
||||||
: cameraOptions.length === 0
|
|
||||||
? 'Keine Kameras an diesem Gerät verfügbar'
|
|
||||||
: 'Kameras auswählen'
|
|
||||||
}
|
|
||||||
emptyText="Keine Kameras gefunden."
|
|
||||||
disabled={!selectedRouter || cameraOptions.length === 0}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sonstige Ausstattung */}
|
|
||||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/[0.03]">
|
|
||||||
<div className="mb-3">
|
|
||||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
|
|
||||||
Sonstige Ausstattung
|
|
||||||
</h4>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
|
||||||
<div>
|
|
||||||
<label htmlFor="switchbox" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
|
||||||
Switchbox
|
|
||||||
</label>
|
|
||||||
<div className="mt-2">
|
|
||||||
<input
|
|
||||||
id="switchbox"
|
|
||||||
name="switchbox"
|
|
||||||
type="text"
|
|
||||||
defaultValue={editingOperation?.switchbox ?? ''}
|
|
||||||
className={inputClassName}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="laptop" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
|
||||||
Laptop
|
|
||||||
</label>
|
|
||||||
<div className="mt-2">
|
|
||||||
<input
|
|
||||||
id="laptop"
|
|
||||||
name="laptop"
|
|
||||||
type="text"
|
|
||||||
defaultValue={editingOperation?.laptop ?? ''}
|
|
||||||
className={inputClassName}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="hardDrive" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
|
||||||
Festplatte
|
|
||||||
</label>
|
|
||||||
<div className="mt-2">
|
|
||||||
<input
|
|
||||||
id="hardDrive"
|
|
||||||
name="hardDrive"
|
|
||||||
type="text"
|
|
||||||
defaultValue={editingOperation?.hardDrive ?? ''}
|
|
||||||
placeholder="z. B. Samsung T7 500 GB"
|
|
||||||
className={inputClassName}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Speicher (Milestone) */}
|
{/* Speicher (Milestone) */}
|
||||||
@ -1831,13 +1779,7 @@ export default function OperationModal({
|
|||||||
{/* Journal */}
|
{/* Journal */}
|
||||||
{isEditing && (
|
{isEditing && (
|
||||||
<div className={activeTab === 'journal' ? 'block' : 'hidden'}>
|
<div className={activeTab === 'journal' ? 'block' : 'hidden'}>
|
||||||
<section className={sectionClassName}>
|
<div>
|
||||||
<SectionHeader
|
|
||||||
title="Journal"
|
|
||||||
description="Bearbeitungsverlauf und Änderungen an diesem Einsatz."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
{isHistoryLoading && (
|
{isHistoryLoading && (
|
||||||
<div className="flex min-h-32 items-center justify-center rounded-md border border-dashed border-gray-200 p-4 dark:border-white/10">
|
<div className="flex min-h-32 items-center justify-center rounded-md border border-dashed border-gray-200 p-4 dark:border-white/10">
|
||||||
<LoadingSpinner
|
<LoadingSpinner
|
||||||
@ -1858,22 +1800,26 @@ export default function OperationModal({
|
|||||||
<Journal
|
<Journal
|
||||||
items={journalItems}
|
items={journalItems}
|
||||||
showEntryForm
|
showEntryForm
|
||||||
|
showSearch={false}
|
||||||
|
searchQuery={journalSearchQuery}
|
||||||
|
onSearchQueryChange={setJournalSearchQuery}
|
||||||
isSubmitting={isJournalSubmitting}
|
isSubmitting={isJournalSubmitting}
|
||||||
onEntrySubmit={onJournalEntrySubmit}
|
onEntrySubmit={onJournalEntrySubmit}
|
||||||
onEntryUpdate={onJournalEntryUpdate}
|
onEntryUpdate={onJournalEntryUpdate}
|
||||||
|
currentUserAvatar={currentUserAvatar}
|
||||||
|
currentUserName={currentUserName}
|
||||||
emptyText="Noch keine Journal-Einträge vorhanden."
|
emptyText="Noch keine Journal-Einträge vorhanden."
|
||||||
placeholder="Journal-Eintrag zum Einsatz hinzufügen..."
|
placeholder="Journal-Eintrag zum Einsatz hinzufügen..."
|
||||||
submitLabel="Eintrag speichern"
|
submitLabel="Eintrag speichern"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-x-3 border-t border-gray-200 bg-white/90 px-4 py-4 backdrop-blur sm:px-6 dark:border-white/10 dark:bg-gray-900/90">
|
<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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
|||||||
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: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:8080',
|
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,
|
ws: true,
|
||||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user