updated
This commit is contained in:
parent
e5a88399e9
commit
182f04bdad
@ -16,15 +16,17 @@ type AdminGroupChat struct {
|
|||||||
Image string `json:"image"`
|
Image string `json:"image"`
|
||||||
OwnerID string `json:"ownerId"`
|
OwnerID string `json:"ownerId"`
|
||||||
TeamID string `json:"teamId"`
|
TeamID string `json:"teamId"`
|
||||||
|
IsPublic bool `json:"isPublic"`
|
||||||
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type saveAdminGroupChatRequest struct {
|
type saveAdminGroupChatRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Image string `json:"image"`
|
Image string `json:"image"`
|
||||||
UserIDs []string `json:"userIds"`
|
IsPublic bool `json:"isPublic"`
|
||||||
|
UserIDs []string `json:"userIds"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAdminListGroupChats(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAdminListGroupChats(w http.ResponseWriter, r *http.Request) {
|
||||||
@ -37,6 +39,7 @@ func (s *Server) handleAdminListGroupChats(w http.ResponseWriter, r *http.Reques
|
|||||||
c.channel_image,
|
c.channel_image,
|
||||||
COALESCE(c.created_by::TEXT, ''),
|
COALESCE(c.created_by::TEXT, ''),
|
||||||
COALESCE(c.team_id::TEXT, ''),
|
COALESCE(c.team_id::TEXT, ''),
|
||||||
|
c.is_public,
|
||||||
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),
|
||||||
@ -66,6 +69,7 @@ func (s *Server) handleAdminListGroupChats(w http.ResponseWriter, r *http.Reques
|
|||||||
&group.Image,
|
&group.Image,
|
||||||
&group.OwnerID,
|
&group.OwnerID,
|
||||||
&group.TeamID,
|
&group.TeamID,
|
||||||
|
&group.IsPublic,
|
||||||
&group.UserIDs,
|
&group.UserIDs,
|
||||||
&group.CreatedAt,
|
&group.CreatedAt,
|
||||||
&group.UpdatedAt,
|
&group.UpdatedAt,
|
||||||
@ -132,10 +136,11 @@ func (s *Server) handleAdminUpdateGroupChat(w http.ResponseWriter, r *http.Reque
|
|||||||
defer tx.Rollback(r.Context())
|
defer tx.Rollback(r.Context())
|
||||||
|
|
||||||
var currentName, currentImage, ownerID string
|
var currentName, currentImage, ownerID string
|
||||||
|
var currentIsPublic bool
|
||||||
if err := tx.QueryRow(
|
if err := tx.QueryRow(
|
||||||
r.Context(),
|
r.Context(),
|
||||||
`
|
`
|
||||||
SELECT name, channel_image, COALESCE(created_by::TEXT, '')
|
SELECT name, channel_image, COALESCE(created_by::TEXT, ''), is_public
|
||||||
FROM conversations
|
FROM conversations
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND type = 'group'
|
AND type = 'group'
|
||||||
@ -143,7 +148,7 @@ func (s *Server) handleAdminUpdateGroupChat(w http.ResponseWriter, r *http.Reque
|
|||||||
FOR UPDATE
|
FOR UPDATE
|
||||||
`,
|
`,
|
||||||
conversationID,
|
conversationID,
|
||||||
).Scan(¤tName, ¤tImage, &ownerID); err != nil {
|
).Scan(¤tName, ¤tImage, &ownerID, ¤tIsPublic); err != nil {
|
||||||
if err == pgx.ErrNoRows {
|
if err == pgx.ErrNoRows {
|
||||||
writeError(w, http.StatusNotFound, "Gruppenchat wurde nicht gefunden")
|
writeError(w, http.StatusNotFound, "Gruppenchat wurde nicht gefunden")
|
||||||
return
|
return
|
||||||
@ -173,6 +178,7 @@ func (s *Server) handleAdminUpdateGroupChat(w http.ResponseWriter, r *http.Reque
|
|||||||
SET
|
SET
|
||||||
name = $2,
|
name = $2,
|
||||||
channel_image = $3,
|
channel_image = $3,
|
||||||
|
is_public = $4,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND type = 'group'
|
AND type = 'group'
|
||||||
@ -181,6 +187,7 @@ func (s *Server) handleAdminUpdateGroupChat(w http.ResponseWriter, r *http.Reque
|
|||||||
conversationID,
|
conversationID,
|
||||||
input.Name,
|
input.Name,
|
||||||
input.Image,
|
input.Image,
|
||||||
|
input.IsPublic,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "Gruppenchat konnte nicht aktualisiert werden")
|
writeError(w, http.StatusInternalServerError, "Gruppenchat konnte nicht aktualisiert werden")
|
||||||
return
|
return
|
||||||
@ -229,6 +236,7 @@ func (s *Server) handleAdminUpdateGroupChat(w http.ResponseWriter, r *http.Reque
|
|||||||
|
|
||||||
changed := currentName != input.Name ||
|
changed := currentName != input.Name ||
|
||||||
currentImage != input.Image ||
|
currentImage != input.Image ||
|
||||||
|
currentIsPublic != input.IsPublic ||
|
||||||
!sameStringSet(oldMemberIDs, newMemberIDs)
|
!sameStringSet(oldMemberIDs, newMemberIDs)
|
||||||
if changed {
|
if changed {
|
||||||
s.emitSystemMessage(
|
s.emitSystemMessage(
|
||||||
|
|||||||
@ -43,6 +43,12 @@ type milestoneSettingsResponse struct {
|
|||||||
ServerFoldersArchiveRootLabel string `json:"serverFoldersArchiveRootLabel"`
|
ServerFoldersArchiveRootLabel string `json:"serverFoldersArchiveRootLabel"`
|
||||||
ServerFoldersArchiveRootPath string `json:"serverFoldersArchiveRootPath"`
|
ServerFoldersArchiveRootPath string `json:"serverFoldersArchiveRootPath"`
|
||||||
|
|
||||||
|
BackupNasHost string `json:"backupNasHost"`
|
||||||
|
BackupNasShare string `json:"backupNasShare"`
|
||||||
|
BackupNasUsername string `json:"backupNasUsername"`
|
||||||
|
BackupNasPasswordConfigured bool `json:"backupNasPasswordConfigured"`
|
||||||
|
BackupLogRootPath string `json:"backupLogRootPath"`
|
||||||
|
|
||||||
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -58,6 +64,11 @@ type updateMilestoneSettingsRequest struct {
|
|||||||
ServerFoldersStorageRootPath string `json:"serverFoldersStorageRootPath"`
|
ServerFoldersStorageRootPath string `json:"serverFoldersStorageRootPath"`
|
||||||
ServerFoldersArchiveRootLabel string `json:"serverFoldersArchiveRootLabel"`
|
ServerFoldersArchiveRootLabel string `json:"serverFoldersArchiveRootLabel"`
|
||||||
ServerFoldersArchiveRootPath string `json:"serverFoldersArchiveRootPath"`
|
ServerFoldersArchiveRootPath string `json:"serverFoldersArchiveRootPath"`
|
||||||
|
BackupNasHost string `json:"backupNasHost"`
|
||||||
|
BackupNasShare string `json:"backupNasShare"`
|
||||||
|
BackupNasUsername string `json:"backupNasUsername"`
|
||||||
|
BackupNasPassword string `json:"backupNasPassword"`
|
||||||
|
BackupLogRootPath string `json:"backupLogRootPath"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type milestoneCredentials struct {
|
type milestoneCredentials struct {
|
||||||
@ -90,6 +101,11 @@ func (s *Server) handleGetMilestoneSettings(w http.ResponseWriter, r *http.Reque
|
|||||||
ServerFoldersStorageRootPath: "D:/Milestone-Speicher",
|
ServerFoldersStorageRootPath: "D:/Milestone-Speicher",
|
||||||
ServerFoldersArchiveRootLabel: "Archive E",
|
ServerFoldersArchiveRootLabel: "Archive E",
|
||||||
ServerFoldersArchiveRootPath: "E:/",
|
ServerFoldersArchiveRootPath: "E:/",
|
||||||
|
BackupNasHost: "",
|
||||||
|
BackupNasShare: "",
|
||||||
|
BackupNasUsername: "",
|
||||||
|
BackupNasPasswordConfigured: false,
|
||||||
|
BackupLogRootPath: "",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@ -125,6 +141,11 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re
|
|||||||
serverFoldersStorageRootPath := normalizeServerFoldersRootPath(input.ServerFoldersStorageRootPath, "D:/Milestone-Speicher")
|
serverFoldersStorageRootPath := normalizeServerFoldersRootPath(input.ServerFoldersStorageRootPath, "D:/Milestone-Speicher")
|
||||||
serverFoldersArchiveRootLabel := normalizeServerFoldersRootLabel(input.ServerFoldersArchiveRootLabel, "Archive E")
|
serverFoldersArchiveRootLabel := normalizeServerFoldersRootLabel(input.ServerFoldersArchiveRootLabel, "Archive E")
|
||||||
serverFoldersArchiveRootPath := normalizeServerFoldersRootPath(input.ServerFoldersArchiveRootPath, "E:/")
|
serverFoldersArchiveRootPath := normalizeServerFoldersRootPath(input.ServerFoldersArchiveRootPath, "E:/")
|
||||||
|
backupNasHost := normalizeBackupNasHost(input.BackupNasHost)
|
||||||
|
backupNasShare := normalizeBackupNasShare(input.BackupNasShare)
|
||||||
|
backupNasUsername := strings.TrimSpace(input.BackupNasUsername)
|
||||||
|
backupNasPassword := strings.TrimSpace(input.BackupNasPassword)
|
||||||
|
backupLogRootPath := strings.TrimSpace(input.BackupLogRootPath)
|
||||||
|
|
||||||
if host == "" {
|
if host == "" {
|
||||||
writeError(w, http.StatusBadRequest, "IP-Adresse oder Hostname ist erforderlich")
|
writeError(w, http.StatusBadRequest, "IP-Adresse oder Hostname ist erforderlich")
|
||||||
@ -173,6 +194,7 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re
|
|||||||
server_folders_storage_root_path,
|
server_folders_storage_root_path,
|
||||||
server_folders_archive_root_label,
|
server_folders_archive_root_label,
|
||||||
server_folders_archive_root_path,
|
server_folders_archive_root_path,
|
||||||
|
backup_log_root_path,
|
||||||
server_folders_agent_token_encrypted
|
server_folders_agent_token_encrypted
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
@ -191,6 +213,7 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re
|
|||||||
$9,
|
$9,
|
||||||
$10,
|
$10,
|
||||||
$11,
|
$11,
|
||||||
|
$12,
|
||||||
NULL
|
NULL
|
||||||
)
|
)
|
||||||
ON CONFLICT (id)
|
ON CONFLICT (id)
|
||||||
@ -209,6 +232,7 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re
|
|||||||
server_folders_storage_root_path = EXCLUDED.server_folders_storage_root_path,
|
server_folders_storage_root_path = EXCLUDED.server_folders_storage_root_path,
|
||||||
server_folders_archive_root_label = EXCLUDED.server_folders_archive_root_label,
|
server_folders_archive_root_label = EXCLUDED.server_folders_archive_root_label,
|
||||||
server_folders_archive_root_path = EXCLUDED.server_folders_archive_root_path,
|
server_folders_archive_root_path = EXCLUDED.server_folders_archive_root_path,
|
||||||
|
backup_log_root_path = EXCLUDED.backup_log_root_path,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
`,
|
`,
|
||||||
host,
|
host,
|
||||||
@ -222,6 +246,7 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re
|
|||||||
serverFoldersStorageRootPath,
|
serverFoldersStorageRootPath,
|
||||||
serverFoldersArchiveRootLabel,
|
serverFoldersArchiveRootLabel,
|
||||||
serverFoldersArchiveRootPath,
|
serverFoldersArchiveRootPath,
|
||||||
|
backupLogRootPath,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
_, err = s.db.Exec(
|
_, err = s.db.Exec(
|
||||||
@ -242,6 +267,7 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re
|
|||||||
server_folders_storage_root_path = $7,
|
server_folders_storage_root_path = $7,
|
||||||
server_folders_archive_root_label = $8,
|
server_folders_archive_root_label = $8,
|
||||||
server_folders_archive_root_path = $9,
|
server_folders_archive_root_path = $9,
|
||||||
|
backup_log_root_path = $10,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = 1
|
WHERE id = 1
|
||||||
`,
|
`,
|
||||||
@ -254,6 +280,7 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re
|
|||||||
serverFoldersStorageRootPath,
|
serverFoldersStorageRootPath,
|
||||||
serverFoldersArchiveRootLabel,
|
serverFoldersArchiveRootLabel,
|
||||||
serverFoldersArchiveRootPath,
|
serverFoldersArchiveRootPath,
|
||||||
|
backupLogRootPath,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -262,6 +289,35 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_, err = s.db.Exec(
|
||||||
|
r.Context(),
|
||||||
|
`
|
||||||
|
UPDATE milestone_settings
|
||||||
|
SET
|
||||||
|
backup_nas_host = $1,
|
||||||
|
backup_nas_share = $2,
|
||||||
|
backup_nas_username = $3,
|
||||||
|
backup_nas_password_encrypted = CASE
|
||||||
|
WHEN $1 = '' AND $2 = '' AND $3 = '' AND $4 = '' THEN NULL
|
||||||
|
WHEN $4 <> '' THEN pgp_sym_encrypt($4, $5)
|
||||||
|
ELSE backup_nas_password_encrypted
|
||||||
|
END,
|
||||||
|
backup_log_root_path = $6,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = 1
|
||||||
|
`,
|
||||||
|
backupNasHost,
|
||||||
|
backupNasShare,
|
||||||
|
backupNasUsername,
|
||||||
|
backupNasPassword,
|
||||||
|
encryptionKey,
|
||||||
|
backupLogRootPath,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Backup-Einstellungen konnten nicht gespeichert werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
rotationWarning := s.rotateServerFoldersAgentTokenAfterSave(r.Context())
|
rotationWarning := s.rotateServerFoldersAgentTokenAfterSave(r.Context())
|
||||||
|
|
||||||
if r.URL.Query().Get("sync") == "false" {
|
if r.URL.Query().Get("sync") == "false" {
|
||||||
@ -474,6 +530,11 @@ func (s *Server) getMilestoneSettings(ctx context.Context) (milestoneSettingsRes
|
|||||||
COALESCE(server_folders_storage_root_path, 'D:/Milestone-Speicher'),
|
COALESCE(server_folders_storage_root_path, 'D:/Milestone-Speicher'),
|
||||||
COALESCE(server_folders_archive_root_label, 'Archive E'),
|
COALESCE(server_folders_archive_root_label, 'Archive E'),
|
||||||
COALESCE(server_folders_archive_root_path, 'E:/'),
|
COALESCE(server_folders_archive_root_path, 'E:/'),
|
||||||
|
COALESCE(backup_nas_host, ''),
|
||||||
|
COALESCE(backup_nas_share, ''),
|
||||||
|
COALESCE(backup_nas_username, ''),
|
||||||
|
backup_nas_password_encrypted IS NOT NULL,
|
||||||
|
COALESCE(backup_log_root_path, ''),
|
||||||
updated_at
|
updated_at
|
||||||
FROM milestone_settings
|
FROM milestone_settings
|
||||||
WHERE id = 1
|
WHERE id = 1
|
||||||
@ -499,6 +560,11 @@ func (s *Server) getMilestoneSettings(ctx context.Context) (milestoneSettingsRes
|
|||||||
&settings.ServerFoldersStorageRootPath,
|
&settings.ServerFoldersStorageRootPath,
|
||||||
&settings.ServerFoldersArchiveRootLabel,
|
&settings.ServerFoldersArchiveRootLabel,
|
||||||
&settings.ServerFoldersArchiveRootPath,
|
&settings.ServerFoldersArchiveRootPath,
|
||||||
|
&settings.BackupNasHost,
|
||||||
|
&settings.BackupNasShare,
|
||||||
|
&settings.BackupNasUsername,
|
||||||
|
&settings.BackupNasPasswordConfigured,
|
||||||
|
&settings.BackupLogRootPath,
|
||||||
&updatedAt,
|
&updatedAt,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -659,6 +725,31 @@ func normalizeServerFoldersRootPath(value string, fallback string) string {
|
|||||||
return strings.ReplaceAll(value, `\`, "/")
|
return strings.ReplaceAll(value, `\`, "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeBackupNasHost(value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
value = strings.TrimPrefix(value, `\\`)
|
||||||
|
value = strings.TrimPrefix(value, "//")
|
||||||
|
value = strings.TrimPrefix(value, "smb://")
|
||||||
|
value = strings.Trim(value, `/\`)
|
||||||
|
|
||||||
|
if slashIndex := strings.IndexAny(value, `/\`); slashIndex >= 0 {
|
||||||
|
value = value[:slashIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeBackupNasShare(value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
value = strings.Trim(value, `/\`)
|
||||||
|
|
||||||
|
if slashIndex := strings.IndexAny(value, `/\`); slashIndex >= 0 {
|
||||||
|
value = value[:slashIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeMilestoneHost(value string) string {
|
func normalizeMilestoneHost(value string) string {
|
||||||
value = strings.TrimSpace(value)
|
value = strings.TrimSpace(value)
|
||||||
value = strings.TrimRight(value, "/")
|
value = strings.TrimRight(value, "/")
|
||||||
|
|||||||
371
backend/chat.go
371
backend/chat.go
@ -43,10 +43,22 @@ type ChatMessage struct {
|
|||||||
ForwardedFrom *ChatMessageReference `json:"forwardedFrom"`
|
ForwardedFrom *ChatMessageReference `json:"forwardedFrom"`
|
||||||
Location *ChatLocation `json:"location"`
|
Location *ChatLocation `json:"location"`
|
||||||
LinkPreview *ChatLinkPreview `json:"linkPreview"`
|
LinkPreview *ChatLinkPreview `json:"linkPreview"`
|
||||||
|
Reactions []ChatReaction `json:"reactions"`
|
||||||
replyToMessageID string
|
replyToMessageID string
|
||||||
forwardedFromMessageID string
|
forwardedFromMessageID string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ChatReaction struct {
|
||||||
|
Emoji string `json:"emoji"`
|
||||||
|
Users []ChatReactionUser `json:"users"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChatReactionUser struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
}
|
||||||
|
|
||||||
type ChatLocation struct {
|
type ChatLocation struct {
|
||||||
Lat float64 `json:"lat"`
|
Lat float64 `json:"lat"`
|
||||||
Lng float64 `json:"lng"`
|
Lng float64 `json:"lng"`
|
||||||
@ -83,6 +95,8 @@ type ChatConversation struct {
|
|||||||
Image string `json:"image"`
|
Image string `json:"image"`
|
||||||
TeamID string `json:"teamId"`
|
TeamID string `json:"teamId"`
|
||||||
OwnerID string `json:"ownerId"`
|
OwnerID string `json:"ownerId"`
|
||||||
|
IsPublic bool `json:"isPublic"`
|
||||||
|
IsMember bool `json:"isMember"`
|
||||||
DisappearingSeconds int `json:"disappearingSeconds"`
|
DisappearingSeconds int `json:"disappearingSeconds"`
|
||||||
Participants []ChatUser `json:"participants"`
|
Participants []ChatUser `json:"participants"`
|
||||||
LastMessage *ChatMessage `json:"lastMessage"`
|
LastMessage *ChatMessage `json:"lastMessage"`
|
||||||
@ -112,6 +126,10 @@ type createChatMessageInput struct {
|
|||||||
Location *ChatLocation
|
Location *ChatLocation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type updateChatReactionRequest struct {
|
||||||
|
Emoji string `json:"emoji"`
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
errChatAccessDenied = errors.New("kein zugriff auf diesen chat")
|
errChatAccessDenied = errors.New("kein zugriff auf diesen chat")
|
||||||
errChatMessageEmpty = errors.New("nachricht darf nicht leer sein")
|
errChatMessageEmpty = errors.New("nachricht darf nicht leer sein")
|
||||||
@ -269,7 +287,20 @@ func (s *Server) handleListChatConversations(w http.ResponseWriter, r *http.Requ
|
|||||||
c.name,
|
c.name,
|
||||||
c.channel_image,
|
c.channel_image,
|
||||||
COALESCE(c.team_id::TEXT, ''),
|
COALESCE(c.team_id::TEXT, ''),
|
||||||
c.last_message_at,
|
(
|
||||||
|
SELECT MAX(last_visible_message.created_at)
|
||||||
|
FROM messages last_visible_message
|
||||||
|
LEFT JOIN conversation_members last_visible_membership
|
||||||
|
ON last_visible_membership.conversation_id = c.id
|
||||||
|
AND last_visible_membership.user_id = $1
|
||||||
|
WHERE last_visible_message.conversation_id = c.id
|
||||||
|
AND last_visible_message.deleted_at IS NULL
|
||||||
|
AND (last_visible_message.expires_at IS NULL OR last_visible_message.expires_at > now())
|
||||||
|
AND last_visible_message.created_at > COALESCE(
|
||||||
|
last_visible_membership.last_cleared_at,
|
||||||
|
'-infinity'::TIMESTAMPTZ
|
||||||
|
)
|
||||||
|
),
|
||||||
COALESCE((
|
COALESCE((
|
||||||
SELECT cm.muted
|
SELECT cm.muted
|
||||||
FROM conversation_members cm
|
FROM conversation_members cm
|
||||||
@ -287,6 +318,11 @@ func (s *Server) handleListChatConversations(w http.ResponseWriter, r *http.Requ
|
|||||||
AND unread_message.message_type <> 'system'
|
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 current_membership.user_id IS NOT NULL
|
||||||
|
AND unread_message.created_at > COALESCE(
|
||||||
|
current_membership.last_cleared_at,
|
||||||
|
'-infinity'::TIMESTAMPTZ
|
||||||
|
)
|
||||||
AND unread_message.created_at > COALESCE(
|
AND unread_message.created_at > COALESCE(
|
||||||
current_membership.last_read_at,
|
current_membership.last_read_at,
|
||||||
'-infinity'::TIMESTAMPTZ
|
'-infinity'::TIMESTAMPTZ
|
||||||
@ -294,13 +330,23 @@ func (s *Server) handleListChatConversations(w http.ResponseWriter, r *http.Requ
|
|||||||
), 0),
|
), 0),
|
||||||
c.created_at,
|
c.created_at,
|
||||||
COALESCE(c.created_by::TEXT, ''),
|
COALESCE(c.created_by::TEXT, ''),
|
||||||
|
c.is_public,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM conversation_members current_user_membership
|
||||||
|
WHERE current_user_membership.conversation_id = c.id
|
||||||
|
AND current_user_membership.user_id = $1
|
||||||
|
),
|
||||||
c.disappearing_seconds
|
c.disappearing_seconds
|
||||||
FROM conversations c
|
FROM conversations c
|
||||||
WHERE EXISTS (
|
WHERE (
|
||||||
SELECT 1
|
EXISTS (
|
||||||
FROM conversation_members cm
|
SELECT 1
|
||||||
WHERE cm.conversation_id = c.id
|
FROM conversation_members cm
|
||||||
AND cm.user_id = $1
|
WHERE cm.conversation_id = c.id
|
||||||
|
AND cm.user_id = $1
|
||||||
|
)
|
||||||
|
OR c.is_public = true
|
||||||
)
|
)
|
||||||
ORDER BY COALESCE(c.last_message_at, c.created_at) DESC
|
ORDER BY COALESCE(c.last_message_at, c.created_at) DESC
|
||||||
`,
|
`,
|
||||||
@ -326,6 +372,8 @@ func (s *Server) handleListChatConversations(w http.ResponseWriter, r *http.Requ
|
|||||||
&conversation.UnreadCount,
|
&conversation.UnreadCount,
|
||||||
&conversation.CreatedAt,
|
&conversation.CreatedAt,
|
||||||
&conversation.OwnerID,
|
&conversation.OwnerID,
|
||||||
|
&conversation.IsPublic,
|
||||||
|
&conversation.IsMember,
|
||||||
&conversation.DisappearingSeconds,
|
&conversation.DisappearingSeconds,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "Chats konnten nicht gelesen werden")
|
writeError(w, http.StatusInternalServerError, "Chats konnten nicht gelesen werden")
|
||||||
@ -339,7 +387,7 @@ func (s *Server) handleListChatConversations(w http.ResponseWriter, r *http.Requ
|
|||||||
}
|
}
|
||||||
conversation.Participants = participants
|
conversation.Participants = participants
|
||||||
|
|
||||||
lastMessage, err := s.getLastChatMessage(r.Context(), conversation.ID)
|
lastMessage, err := s.getLastChatMessage(r.Context(), conversation.ID, user.ID)
|
||||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||||
writeError(w, http.StatusInternalServerError, "Letzte Nachricht konnte nicht geladen werden")
|
writeError(w, http.StatusInternalServerError, "Letzte Nachricht konnte nicht geladen werden")
|
||||||
return
|
return
|
||||||
@ -455,7 +503,7 @@ func (s *Server) handleListChatMessages(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
|
|
||||||
conversationID := strings.TrimSpace(r.PathValue("id"))
|
conversationID := strings.TrimSpace(r.PathValue("id"))
|
||||||
if !s.canAccessConversation(r.Context(), conversationID, user.ID) {
|
if !s.isConversationMember(r.Context(), conversationID, user.ID) {
|
||||||
writeError(w, http.StatusForbidden, "Kein Zugriff auf diesen Chat")
|
writeError(w, http.StatusForbidden, "Kein Zugriff auf diesen Chat")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -516,13 +564,21 @@ func (s *Server) handleListChatMessages(w http.ResponseWriter, r *http.Request)
|
|||||||
FROM messages m
|
FROM messages m
|
||||||
JOIN conversations c ON c.id = m.conversation_id
|
JOIN conversations c ON c.id = m.conversation_id
|
||||||
LEFT JOIN users u ON u.id = m.sender_id
|
LEFT JOIN users u ON u.id = m.sender_id
|
||||||
|
LEFT JOIN conversation_members viewer_membership
|
||||||
|
ON viewer_membership.conversation_id = m.conversation_id
|
||||||
|
AND viewer_membership.user_id = $2
|
||||||
WHERE m.conversation_id = $1
|
WHERE m.conversation_id = $1
|
||||||
AND m.deleted_at IS NULL
|
AND m.deleted_at IS NULL
|
||||||
AND (m.expires_at IS NULL OR m.expires_at > now())
|
AND (m.expires_at IS NULL OR m.expires_at > now())
|
||||||
|
AND m.created_at > COALESCE(
|
||||||
|
viewer_membership.last_cleared_at,
|
||||||
|
'-infinity'::TIMESTAMPTZ
|
||||||
|
)
|
||||||
ORDER BY m.created_at ASC, m.id ASC
|
ORDER BY m.created_at ASC, m.id ASC
|
||||||
LIMIT 200
|
LIMIT 200
|
||||||
`,
|
`,
|
||||||
conversationID,
|
conversationID,
|
||||||
|
user.ID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "Nachrichten konnten nicht geladen werden")
|
writeError(w, http.StatusInternalServerError, "Nachrichten konnten nicht geladen werden")
|
||||||
@ -599,6 +655,142 @@ func (s *Server) handleCreateChatMessage(w http.ResponseWriter, r *http.Request)
|
|||||||
writeJSON(w, http.StatusCreated, map[string]any{"message": message})
|
writeJSON(w, http.StatusCreated, map[string]any{"message": message})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeChatReactionEmoji(value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
runes := []rune(value)
|
||||||
|
if len(runes) > 8 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleToggleChatMessageReaction(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := userFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
messageID := strings.TrimSpace(r.PathValue("id"))
|
||||||
|
|
||||||
|
var input updateChatReactionRequest
|
||||||
|
if err := readJSON(r, &input); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
emoji := normalizeChatReactionEmoji(input.Emoji)
|
||||||
|
if emoji == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "Emoji ist erforderlich")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var conversationID string
|
||||||
|
err := s.db.QueryRow(
|
||||||
|
r.Context(),
|
||||||
|
`
|
||||||
|
SELECT conversation_id::TEXT
|
||||||
|
FROM messages
|
||||||
|
WHERE id = $1
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
AND message_type <> 'system'
|
||||||
|
AND (expires_at IS NULL OR expires_at > now())
|
||||||
|
`,
|
||||||
|
messageID,
|
||||||
|
).Scan(&conversationID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
writeError(w, http.StatusNotFound, "Nachricht wurde nicht gefunden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusInternalServerError, "Nachricht konnte nicht geladen werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.isConversationMember(r.Context(), conversationID, user.ID) {
|
||||||
|
writeError(w, http.StatusForbidden, "Kein Zugriff auf diesen Chat")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.db.Begin(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Reaktion konnte nicht gespeichert werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback(r.Context())
|
||||||
|
|
||||||
|
var existingEmoji string
|
||||||
|
err = tx.QueryRow(
|
||||||
|
r.Context(),
|
||||||
|
`
|
||||||
|
SELECT emoji
|
||||||
|
FROM message_reactions
|
||||||
|
WHERE message_id = $1
|
||||||
|
AND user_id = $2
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
messageID,
|
||||||
|
user.ID,
|
||||||
|
).Scan(&existingEmoji)
|
||||||
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Reaktion konnte nicht geladen werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
r.Context(),
|
||||||
|
`
|
||||||
|
DELETE FROM message_reactions
|
||||||
|
WHERE message_id = $1
|
||||||
|
AND user_id = $2
|
||||||
|
`,
|
||||||
|
messageID,
|
||||||
|
user.ID,
|
||||||
|
); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Reaktion konnte nicht entfernt werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if existingEmoji != emoji {
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
r.Context(),
|
||||||
|
`
|
||||||
|
INSERT INTO message_reactions (message_id, user_id, emoji)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (message_id, user_id) DO UPDATE
|
||||||
|
SET emoji = EXCLUDED.emoji,
|
||||||
|
created_at = now()
|
||||||
|
`,
|
||||||
|
messageID,
|
||||||
|
user.ID,
|
||||||
|
emoji,
|
||||||
|
); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Reaktion konnte nicht gespeichert werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(r.Context()); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Reaktion konnte nicht gespeichert werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
message, err := s.getChatMessage(r.Context(), messageID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Nachricht konnte nicht aktualisiert werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.publishChatMessageUpdate(r.Context(), message)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"message": message})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) createChatMessage(
|
func (s *Server) createChatMessage(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
user User,
|
user User,
|
||||||
@ -614,6 +806,9 @@ func (s *Server) createChatMessage(
|
|||||||
if !s.isConversationWritable(ctx, conversationID) {
|
if !s.isConversationWritable(ctx, conversationID) {
|
||||||
return message, errChatReadOnly
|
return message, errChatReadOnly
|
||||||
}
|
}
|
||||||
|
if !s.isConversationMember(ctx, conversationID, user.ID) {
|
||||||
|
return message, errChatAccessDenied
|
||||||
|
}
|
||||||
|
|
||||||
if replyToMessageID := strings.TrimSpace(input.ReplyToMessageID); replyToMessageID != "" {
|
if replyToMessageID := strings.TrimSpace(input.ReplyToMessageID); replyToMessageID != "" {
|
||||||
var valid bool
|
var valid bool
|
||||||
@ -857,11 +1052,14 @@ func (s *Server) canAccessConversation(ctx context.Context, conversationID strin
|
|||||||
SELECT 1
|
SELECT 1
|
||||||
FROM conversations c
|
FROM conversations c
|
||||||
WHERE c.id = $1
|
WHERE c.id = $1
|
||||||
AND EXISTS (
|
AND (
|
||||||
SELECT 1
|
EXISTS (
|
||||||
FROM conversation_members cm
|
SELECT 1
|
||||||
WHERE cm.conversation_id = c.id
|
FROM conversation_members cm
|
||||||
AND cm.user_id = $2
|
WHERE cm.conversation_id = c.id
|
||||||
|
AND cm.user_id = $2
|
||||||
|
)
|
||||||
|
OR c.is_public = true
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
`,
|
`,
|
||||||
@ -872,6 +1070,55 @@ func (s *Server) canAccessConversation(ctx context.Context, conversationID strin
|
|||||||
return err == nil && allowed
|
return err == nil && allowed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) isConversationMember(ctx context.Context, conversationID string, userID string) bool {
|
||||||
|
if conversationID == "" || userID == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var member bool
|
||||||
|
err := s.db.QueryRow(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
SELECT EXISTS(
|
||||||
|
SELECT 1
|
||||||
|
FROM conversation_members cm
|
||||||
|
WHERE cm.conversation_id = $1
|
||||||
|
AND cm.user_id = $2
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
conversationID,
|
||||||
|
userID,
|
||||||
|
).Scan(&member)
|
||||||
|
|
||||||
|
return err == nil && member
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) canPostToConversation(ctx context.Context, conversationID string, userID string) bool {
|
||||||
|
if conversationID == "" || userID == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var allowed bool
|
||||||
|
err := s.db.QueryRow(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
SELECT EXISTS(
|
||||||
|
SELECT 1
|
||||||
|
FROM conversations c
|
||||||
|
JOIN conversation_members cm
|
||||||
|
ON cm.conversation_id = c.id
|
||||||
|
AND cm.user_id = $2
|
||||||
|
WHERE c.id = $1
|
||||||
|
AND c.type <> 'channel'
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
conversationID,
|
||||||
|
userID,
|
||||||
|
).Scan(&allowed)
|
||||||
|
|
||||||
|
return err == nil && allowed
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) isConversationWritable(ctx context.Context, conversationID string) bool {
|
func (s *Server) isConversationWritable(ctx context.Context, conversationID string) bool {
|
||||||
var writable bool
|
var writable bool
|
||||||
err := s.db.QueryRow(
|
err := s.db.QueryRow(
|
||||||
@ -988,7 +1235,20 @@ func (s *Server) getChatConversation(ctx context.Context, conversationID string,
|
|||||||
c.name,
|
c.name,
|
||||||
c.channel_image,
|
c.channel_image,
|
||||||
COALESCE(c.team_id::TEXT, ''),
|
COALESCE(c.team_id::TEXT, ''),
|
||||||
c.last_message_at,
|
(
|
||||||
|
SELECT MAX(last_visible_message.created_at)
|
||||||
|
FROM messages last_visible_message
|
||||||
|
LEFT JOIN conversation_members last_visible_membership
|
||||||
|
ON last_visible_membership.conversation_id = c.id
|
||||||
|
AND last_visible_membership.user_id = $2
|
||||||
|
WHERE last_visible_message.conversation_id = c.id
|
||||||
|
AND last_visible_message.deleted_at IS NULL
|
||||||
|
AND (last_visible_message.expires_at IS NULL OR last_visible_message.expires_at > now())
|
||||||
|
AND last_visible_message.created_at > COALESCE(
|
||||||
|
last_visible_membership.last_cleared_at,
|
||||||
|
'-infinity'::TIMESTAMPTZ
|
||||||
|
)
|
||||||
|
),
|
||||||
COALESCE((
|
COALESCE((
|
||||||
SELECT cm.muted
|
SELECT cm.muted
|
||||||
FROM conversation_members cm
|
FROM conversation_members cm
|
||||||
@ -1006,6 +1266,11 @@ func (s *Server) getChatConversation(ctx context.Context, conversationID string,
|
|||||||
AND unread_message.message_type <> 'system'
|
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 current_membership.user_id IS NOT NULL
|
||||||
|
AND unread_message.created_at > COALESCE(
|
||||||
|
current_membership.last_cleared_at,
|
||||||
|
'-infinity'::TIMESTAMPTZ
|
||||||
|
)
|
||||||
AND unread_message.created_at > COALESCE(
|
AND unread_message.created_at > COALESCE(
|
||||||
current_membership.last_read_at,
|
current_membership.last_read_at,
|
||||||
'-infinity'::TIMESTAMPTZ
|
'-infinity'::TIMESTAMPTZ
|
||||||
@ -1013,6 +1278,13 @@ func (s *Server) getChatConversation(ctx context.Context, conversationID string,
|
|||||||
), 0),
|
), 0),
|
||||||
c.created_at,
|
c.created_at,
|
||||||
COALESCE(c.created_by::TEXT, ''),
|
COALESCE(c.created_by::TEXT, ''),
|
||||||
|
c.is_public,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM conversation_members current_user_membership
|
||||||
|
WHERE current_user_membership.conversation_id = c.id
|
||||||
|
AND current_user_membership.user_id = $2
|
||||||
|
),
|
||||||
c.disappearing_seconds
|
c.disappearing_seconds
|
||||||
FROM conversations c
|
FROM conversations c
|
||||||
WHERE c.id = $1
|
WHERE c.id = $1
|
||||||
@ -1030,6 +1302,8 @@ func (s *Server) getChatConversation(ctx context.Context, conversationID string,
|
|||||||
&conversation.UnreadCount,
|
&conversation.UnreadCount,
|
||||||
&conversation.CreatedAt,
|
&conversation.CreatedAt,
|
||||||
&conversation.OwnerID,
|
&conversation.OwnerID,
|
||||||
|
&conversation.IsPublic,
|
||||||
|
&conversation.IsMember,
|
||||||
&conversation.DisappearingSeconds,
|
&conversation.DisappearingSeconds,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -1044,7 +1318,7 @@ func (s *Server) getChatConversation(ctx context.Context, conversationID string,
|
|||||||
return conversation, err
|
return conversation, err
|
||||||
}
|
}
|
||||||
|
|
||||||
lastMessage, err := s.getLastChatMessage(ctx, conversation.ID)
|
lastMessage, err := s.getLastChatMessage(ctx, conversation.ID, userID)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
conversation.LastMessage = &lastMessage
|
conversation.LastMessage = &lastMessage
|
||||||
} else if !errors.Is(err, pgx.ErrNoRows) {
|
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||||||
@ -1155,7 +1429,7 @@ func (s *Server) getChatMessage(ctx context.Context, messageID string) (ChatMess
|
|||||||
return message, err
|
return message, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) getLastChatMessage(ctx context.Context, conversationID string) (ChatMessage, error) {
|
func (s *Server) getLastChatMessage(ctx context.Context, conversationID string, userID string) (ChatMessage, error) {
|
||||||
row := s.db.QueryRow(
|
row := s.db.QueryRow(
|
||||||
ctx,
|
ctx,
|
||||||
`
|
`
|
||||||
@ -1212,13 +1486,21 @@ func (s *Server) getLastChatMessage(ctx context.Context, conversationID string)
|
|||||||
FROM messages m
|
FROM messages m
|
||||||
JOIN conversations c ON c.id = m.conversation_id
|
JOIN conversations c ON c.id = m.conversation_id
|
||||||
LEFT JOIN users u ON u.id = m.sender_id
|
LEFT JOIN users u ON u.id = m.sender_id
|
||||||
|
LEFT JOIN conversation_members viewer_membership
|
||||||
|
ON viewer_membership.conversation_id = m.conversation_id
|
||||||
|
AND viewer_membership.user_id = $2
|
||||||
WHERE m.conversation_id = $1
|
WHERE m.conversation_id = $1
|
||||||
AND m.deleted_at IS NULL
|
AND m.deleted_at IS NULL
|
||||||
AND (m.expires_at IS NULL OR m.expires_at > now())
|
AND (m.expires_at IS NULL OR m.expires_at > now())
|
||||||
|
AND m.created_at > COALESCE(
|
||||||
|
viewer_membership.last_cleared_at,
|
||||||
|
'-infinity'::TIMESTAMPTZ
|
||||||
|
)
|
||||||
ORDER BY m.created_at DESC, m.id DESC
|
ORDER BY m.created_at DESC, m.id DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`,
|
`,
|
||||||
conversationID,
|
conversationID,
|
||||||
|
userID,
|
||||||
)
|
)
|
||||||
message, err := scanChatMessage(row)
|
message, err := scanChatMessage(row)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -1247,6 +1529,12 @@ func (s *Server) enrichChatMessage(ctx context.Context, message *ChatMessage) er
|
|||||||
}
|
}
|
||||||
message.LinkPreview = preview
|
message.LinkPreview = preview
|
||||||
|
|
||||||
|
reactions, err := s.listChatReactions(ctx, message.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
message.Reactions = reactions
|
||||||
|
|
||||||
if message.replyToMessageID != "" {
|
if message.replyToMessageID != "" {
|
||||||
reference, err := s.getChatMessageReference(ctx, message.replyToMessageID)
|
reference, err := s.getChatMessageReference(ctx, message.replyToMessageID)
|
||||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||||
@ -1270,6 +1558,57 @@ func (s *Server) enrichChatMessage(ctx context.Context, message *ChatMessage) er
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) listChatReactions(
|
||||||
|
ctx context.Context,
|
||||||
|
messageID string,
|
||||||
|
) ([]ChatReaction, error) {
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
mr.emoji,
|
||||||
|
mr.user_id::TEXT,
|
||||||
|
COALESCE(NULLIF(u.display_name, ''), NULLIF(u.username, ''), u.email, '')
|
||||||
|
FROM message_reactions mr
|
||||||
|
JOIN users u ON u.id = mr.user_id
|
||||||
|
WHERE mr.message_id = $1
|
||||||
|
ORDER BY mr.created_at ASC, mr.user_id ASC
|
||||||
|
`,
|
||||||
|
messageID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
reactions := []ChatReaction{}
|
||||||
|
reactionByEmoji := map[string]int{}
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var emoji string
|
||||||
|
var reactionUser ChatReactionUser
|
||||||
|
if err := rows.Scan(
|
||||||
|
&emoji,
|
||||||
|
&reactionUser.ID,
|
||||||
|
&reactionUser.DisplayName,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
index, ok := reactionByEmoji[emoji]
|
||||||
|
if !ok {
|
||||||
|
reactionByEmoji[emoji] = len(reactions)
|
||||||
|
reactions = append(reactions, ChatReaction{Emoji: emoji})
|
||||||
|
index = len(reactions) - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
reactions[index].Users = append(reactions[index].Users, reactionUser)
|
||||||
|
reactions[index].Count = len(reactions[index].Users)
|
||||||
|
}
|
||||||
|
|
||||||
|
return reactions, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) getChatMessageLocation(
|
func (s *Server) getChatMessageLocation(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
messageID string,
|
messageID string,
|
||||||
|
|||||||
169
backend/chat_clear.go
Normal file
169
backend/chat_clear.go
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type clearChatRequest struct {
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleClearChat(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := userFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
conversationID := strings.TrimSpace(r.PathValue("id"))
|
||||||
|
if !s.canAccessConversation(r.Context(), conversationID, user.ID) {
|
||||||
|
writeError(w, http.StatusForbidden, "Kein Zugriff auf diesen Chat")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.isConversationMember(r.Context(), conversationID, user.ID) {
|
||||||
|
writeError(w, http.StatusForbidden, "Nur Mitglieder können diesen Chat leeren")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var input clearChatRequest
|
||||||
|
if err := readJSON(r, &input); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scope := strings.TrimSpace(input.Scope)
|
||||||
|
if scope != "me" && scope != "all" {
|
||||||
|
writeError(w, http.StatusBadRequest, "Ungültiger Löschumfang")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var conversationType, ownerID, teamID string
|
||||||
|
err := s.db.QueryRow(
|
||||||
|
r.Context(),
|
||||||
|
`
|
||||||
|
SELECT type, COALESCE(created_by::TEXT, ''), COALESCE(team_id::TEXT, '')
|
||||||
|
FROM conversations
|
||||||
|
WHERE id = $1
|
||||||
|
`,
|
||||||
|
conversationID,
|
||||||
|
).Scan(&conversationType, &ownerID, &teamID)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
writeError(w, http.StatusNotFound, "Chat wurde nicht gefunden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Chat konnte nicht geladen werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if scope == "all" {
|
||||||
|
switch conversationType {
|
||||||
|
case "direct":
|
||||||
|
case "group":
|
||||||
|
if teamID != "" {
|
||||||
|
if !canManageTeamGroup(user) {
|
||||||
|
writeError(w, http.StatusForbidden, "Keine Berechtigung zum Leeren dieses Team-Gruppenchats")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if !canManageGroup(user, ownerID) {
|
||||||
|
writeError(w, http.StatusForbidden, "Nur der Ersteller oder ein Administrator darf die Gruppe für alle leeren")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
writeError(w, http.StatusBadRequest, "Dieser Chat kann nicht für alle geleert werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if scope == "me" {
|
||||||
|
if _, err := s.db.Exec(
|
||||||
|
r.Context(),
|
||||||
|
`
|
||||||
|
UPDATE conversation_members
|
||||||
|
SET last_cleared_at = now(), last_read_at = now()
|
||||||
|
WHERE conversation_id = $1
|
||||||
|
AND user_id = $2
|
||||||
|
`,
|
||||||
|
conversationID,
|
||||||
|
user.ID,
|
||||||
|
); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Chat konnte nicht geleert werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
conversation, err := s.getChatConversation(r.Context(), conversationID, user.ID, user.ShowLastSeen)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Chat konnte nicht geladen werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"conversation": conversation,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.db.Begin(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Chat konnte nicht geleert werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback(r.Context())
|
||||||
|
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
r.Context(),
|
||||||
|
`
|
||||||
|
UPDATE messages
|
||||||
|
SET deleted_at = now()
|
||||||
|
WHERE conversation_id = $1
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
`,
|
||||||
|
conversationID,
|
||||||
|
); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Chat konnte nicht geleert werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
r.Context(),
|
||||||
|
`
|
||||||
|
UPDATE conversations
|
||||||
|
SET
|
||||||
|
last_message_at = (
|
||||||
|
SELECT MAX(created_at)
|
||||||
|
FROM messages
|
||||||
|
WHERE conversation_id = $1
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
AND (expires_at IS NULL OR expires_at > now())
|
||||||
|
),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
`,
|
||||||
|
conversationID,
|
||||||
|
); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Chat konnte nicht geleert werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(r.Context()); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Chat konnte nicht geleert werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.publishChatMessagesReload(r.Context(), conversationID)
|
||||||
|
s.publishConversationUpdate(r.Context(), conversationID)
|
||||||
|
|
||||||
|
conversation, err := s.getChatConversation(r.Context(), conversationID, user.ID, user.ShowLastSeen)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Chat konnte nicht geladen werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"conversation": conversation,
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -35,11 +35,13 @@ type createGroupRequest struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Image string `json:"image"`
|
Image string `json:"image"`
|
||||||
MemberIDs []string `json:"memberIds"`
|
MemberIDs []string `json:"memberIds"`
|
||||||
|
IsPublic bool `json:"isPublic"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type updateGroupRequest struct {
|
type updateGroupRequest struct {
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
Image *string `json:"image"`
|
Image *string `json:"image"`
|
||||||
|
IsPublic *bool `json:"isPublic"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type addGroupMembersRequest struct {
|
type addGroupMembersRequest struct {
|
||||||
@ -94,21 +96,6 @@ func (s *Server) startDisappearingMessagesMonitor(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) reconcileDisappearingMessages(ctx context.Context) {
|
func (s *Server) reconcileDisappearingMessages(ctx context.Context) {
|
||||||
if _, err := s.db.Exec(
|
|
||||||
ctx,
|
|
||||||
`UPDATE messages AS m
|
|
||||||
SET expires_at = m.created_at + make_interval(secs => c.disappearing_seconds)
|
|
||||||
FROM conversations AS c
|
|
||||||
WHERE m.conversation_id = c.id
|
|
||||||
AND c.disappearing_seconds > 0
|
|
||||||
AND m.message_type = 'user'
|
|
||||||
AND m.deleted_at IS NULL
|
|
||||||
AND m.expires_at IS NULL`,
|
|
||||||
); err != nil {
|
|
||||||
logError("Ablaufzeiten selbstlöschender Nachrichten konnten nicht ergänzt werden", err, nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
s.deleteExpiredMessages(ctx)
|
s.deleteExpiredMessages(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -269,13 +256,14 @@ func (s *Server) handleCreateGroup(w http.ResponseWriter, r *http.Request) {
|
|||||||
err = tx.QueryRow(
|
err = tx.QueryRow(
|
||||||
r.Context(),
|
r.Context(),
|
||||||
`
|
`
|
||||||
INSERT INTO conversations (type, name, channel_image, created_by)
|
INSERT INTO conversations (type, name, channel_image, created_by, is_public)
|
||||||
VALUES ('group', $1, $2, $3)
|
VALUES ('group', $1, $2, $3, $4)
|
||||||
RETURNING id::TEXT
|
RETURNING id::TEXT
|
||||||
`,
|
`,
|
||||||
name,
|
name,
|
||||||
image,
|
image,
|
||||||
user.ID,
|
user.ID,
|
||||||
|
input.IsPublic,
|
||||||
).Scan(&conversationID)
|
).Scan(&conversationID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "Gruppe konnte nicht erstellt werden")
|
writeError(w, http.StatusInternalServerError, "Gruppe konnte nicht erstellt werden")
|
||||||
@ -317,7 +305,7 @@ func (s *Server) handleCreateGroup(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
conversation, err := s.getChatConversation(r.Context(), conversationID, user.ID, user.ShowLastSeen)
|
conversation, err := s.getChatConversation(r.Context(), conversationID, user.ID, user.ShowLastSeen)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "Gruppe konnte nicht geladen werden")
|
writeError(w, http.StatusInternalServerError, "Chat konnte nicht geladen werden")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -339,26 +327,38 @@ func (s *Server) handleUpdateGroup(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var currentName, currentImage, ownerID, teamID string
|
var conversationType, currentName, currentImage, ownerID, teamID string
|
||||||
|
var currentIsPublic bool
|
||||||
if err := s.db.QueryRow(
|
if err := s.db.QueryRow(
|
||||||
r.Context(),
|
r.Context(),
|
||||||
`
|
`
|
||||||
SELECT
|
SELECT
|
||||||
|
type,
|
||||||
name,
|
name,
|
||||||
channel_image,
|
channel_image,
|
||||||
COALESCE(created_by::TEXT, ''),
|
COALESCE(created_by::TEXT, ''),
|
||||||
COALESCE(team_id::TEXT, '')
|
COALESCE(team_id::TEXT, ''),
|
||||||
|
is_public
|
||||||
FROM conversations
|
FROM conversations
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND type = 'group'
|
AND type IN ('direct', 'group')
|
||||||
`,
|
`,
|
||||||
conversationID,
|
conversationID,
|
||||||
).Scan(¤tName, ¤tImage, &ownerID, &teamID); err != nil {
|
).Scan(&conversationType, ¤tName, ¤tImage, &ownerID, &teamID, ¤tIsPublic); err != nil {
|
||||||
writeError(w, http.StatusNotFound, "Gruppe wurde nicht gefunden")
|
writeError(w, http.StatusNotFound, "Chat wurde nicht gefunden")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if teamID != "" {
|
if conversationType == "direct" {
|
||||||
|
if !s.isConversationMember(r.Context(), conversationID, user.ID) {
|
||||||
|
writeError(w, http.StatusForbidden, "Nur Mitglieder dürfen diesen Chat verwalten")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if input.Name != nil || input.Image != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Name und Bild können bei Einzelchats nicht geändert werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if teamID != "" {
|
||||||
if !canManageTeamGroup(user) {
|
if !canManageTeamGroup(user) {
|
||||||
writeError(w, http.StatusForbidden, "Keine Berechtigung zum Verwalten des Team-Gruppenchats")
|
writeError(w, http.StatusForbidden, "Keine Berechtigung zum Verwalten des Team-Gruppenchats")
|
||||||
return
|
return
|
||||||
@ -430,13 +430,45 @@ func (s *Server) handleUpdateGroup(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if input.IsPublic != nil && *input.IsPublic != currentIsPublic {
|
||||||
|
if _, err := s.db.Exec(
|
||||||
|
r.Context(),
|
||||||
|
`UPDATE conversations SET is_public = $2, updated_at = now() WHERE id = $1`,
|
||||||
|
conversationID,
|
||||||
|
*input.IsPublic,
|
||||||
|
); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Chat konnte nicht aktualisiert werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event := "conversation_private"
|
||||||
|
target := "den Chat"
|
||||||
|
if conversationType == "group" {
|
||||||
|
target = "die Gruppe"
|
||||||
|
}
|
||||||
|
body := fmt.Sprintf("%s hat %s auf privat gestellt.", conversationActorName(user), target)
|
||||||
|
if *input.IsPublic {
|
||||||
|
event = "conversation_public"
|
||||||
|
body = fmt.Sprintf("%s hat %s öffentlich gemacht.", conversationActorName(user), target)
|
||||||
|
}
|
||||||
|
s.emitSystemMessage(
|
||||||
|
r.Context(),
|
||||||
|
conversationID,
|
||||||
|
user.ID,
|
||||||
|
event,
|
||||||
|
body,
|
||||||
|
map[string]any{"isPublic": *input.IsPublic},
|
||||||
|
)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
|
||||||
if changed {
|
if changed {
|
||||||
s.publishConversationUpdate(r.Context(), conversationID)
|
s.publishConversationUpdate(r.Context(), conversationID)
|
||||||
}
|
}
|
||||||
|
|
||||||
conversation, err := s.getChatConversation(r.Context(), conversationID, user.ID, user.ShowLastSeen)
|
conversation, err := s.getChatConversation(r.Context(), conversationID, user.ID, user.ShowLastSeen)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "Gruppe konnte nicht geladen werden")
|
writeError(w, http.StatusInternalServerError, "Chat konnte nicht geladen werden")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -766,48 +798,6 @@ func (s *Server) handleUpdateDisappearing(w http.ResponseWriter, r *http.Request
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if input.Seconds == 0 {
|
|
||||||
if _, err := tx.Exec(
|
|
||||||
r.Context(),
|
|
||||||
`UPDATE messages
|
|
||||||
SET expires_at = NULL
|
|
||||||
WHERE conversation_id = $1
|
|
||||||
AND message_type = 'user'
|
|
||||||
AND deleted_at IS NULL`,
|
|
||||||
conversationID,
|
|
||||||
); err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "Einstellung konnte nicht gespeichert werden")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if _, err := tx.Exec(
|
|
||||||
r.Context(),
|
|
||||||
`UPDATE messages
|
|
||||||
SET expires_at = created_at + make_interval(secs => $2)
|
|
||||||
WHERE conversation_id = $1
|
|
||||||
AND message_type = 'user'
|
|
||||||
AND deleted_at IS NULL`,
|
|
||||||
conversationID,
|
|
||||||
input.Seconds,
|
|
||||||
); err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "Einstellung konnte nicht gespeichert werden")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := tx.Exec(
|
|
||||||
r.Context(),
|
|
||||||
`DELETE FROM messages
|
|
||||||
WHERE conversation_id = $1
|
|
||||||
AND message_type = 'user'
|
|
||||||
AND deleted_at IS NULL
|
|
||||||
AND expires_at <= now()`,
|
|
||||||
conversationID,
|
|
||||||
); err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "Einstellung konnte nicht gespeichert werden")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit(r.Context()); err != nil {
|
if err := tx.Commit(r.Context()); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "Einstellung konnte nicht gespeichert werden")
|
writeError(w, http.StatusInternalServerError, "Einstellung konnte nicht gespeichert werden")
|
||||||
return
|
return
|
||||||
|
|||||||
@ -21,18 +21,8 @@ func (s *Server) handleUpdateConversationMute(w http.ResponseWriter, r *http.Req
|
|||||||
writeError(w, http.StatusForbidden, "Kein Zugriff auf diesen Chat")
|
writeError(w, http.StatusForbidden, "Kein Zugriff auf diesen Chat")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !s.isConversationMember(r.Context(), conversationID, user.ID) {
|
||||||
var conversationType string
|
writeError(w, http.StatusForbidden, "Nur Mitglieder können diesen Chat stummschalten")
|
||||||
if err := s.db.QueryRow(
|
|
||||||
r.Context(),
|
|
||||||
`SELECT type FROM conversations WHERE id = $1`,
|
|
||||||
conversationID,
|
|
||||||
).Scan(&conversationType); err != nil {
|
|
||||||
writeError(w, http.StatusNotFound, "Chat wurde nicht gefunden")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if conversationType == "direct" {
|
|
||||||
writeError(w, http.StatusBadRequest, "Einzelchats können nicht stummgeschaltet werden")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -48,15 +48,25 @@ func (s *Server) handleSearchChatMessages(w http.ResponseWriter, r *http.Request
|
|||||||
FROM messages m
|
FROM messages m
|
||||||
JOIN conversations c ON c.id = m.conversation_id
|
JOIN conversations c ON c.id = m.conversation_id
|
||||||
LEFT JOIN users u ON u.id = m.sender_id
|
LEFT JOIN users u ON u.id = m.sender_id
|
||||||
|
LEFT JOIN conversation_members viewer_membership
|
||||||
|
ON viewer_membership.conversation_id = c.id
|
||||||
|
AND viewer_membership.user_id = $1
|
||||||
WHERE m.deleted_at IS NULL
|
WHERE m.deleted_at IS NULL
|
||||||
AND m.message_type = 'user'
|
AND m.message_type = 'user'
|
||||||
AND (m.expires_at IS NULL OR m.expires_at > now())
|
AND (m.expires_at IS NULL OR m.expires_at > now())
|
||||||
AND strpos(lower(m.body), lower($2)) > 0
|
AND strpos(lower(m.body), lower($2)) > 0
|
||||||
AND EXISTS (
|
AND m.created_at > COALESCE(
|
||||||
SELECT 1
|
viewer_membership.last_cleared_at,
|
||||||
FROM conversation_members cm
|
'-infinity'::TIMESTAMPTZ
|
||||||
WHERE cm.conversation_id = c.id
|
)
|
||||||
AND cm.user_id = $1
|
AND (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM conversation_members cm
|
||||||
|
WHERE cm.conversation_id = c.id
|
||||||
|
AND cm.user_id = $1
|
||||||
|
)
|
||||||
|
OR c.is_public = true
|
||||||
)
|
)
|
||||||
ORDER BY m.conversation_id, m.created_at DESC, m.id DESC
|
ORDER BY m.conversation_id, m.created_at DESC, m.id DESC
|
||||||
LIMIT 50
|
LIMIT 50
|
||||||
|
|||||||
@ -14,14 +14,10 @@ func (s *Server) markChatConversationRead(
|
|||||||
_, err := s.db.Exec(
|
_, err := s.db.Exec(
|
||||||
ctx,
|
ctx,
|
||||||
`
|
`
|
||||||
INSERT INTO conversation_members (
|
UPDATE conversation_members
|
||||||
conversation_id,
|
SET last_read_at = now()
|
||||||
user_id,
|
WHERE conversation_id = $1
|
||||||
last_read_at
|
AND user_id = $2
|
||||||
)
|
|
||||||
VALUES ($1, $2, now())
|
|
||||||
ON CONFLICT (conversation_id, user_id)
|
|
||||||
DO UPDATE SET last_read_at = EXCLUDED.last_read_at
|
|
||||||
`,
|
`,
|
||||||
conversationID,
|
conversationID,
|
||||||
userID,
|
userID,
|
||||||
|
|||||||
@ -183,8 +183,7 @@ func (s *Server) readChatSocket(
|
|||||||
|
|
||||||
switch command.Type {
|
switch command.Type {
|
||||||
case "typing.start", "typing.stop":
|
case "typing.start", "typing.stop":
|
||||||
if !s.canAccessConversation(ctx, command.ConversationID, user.ID) ||
|
if !s.canPostToConversation(ctx, command.ConversationID, user.ID) {
|
||||||
!s.isConversationWritable(ctx, command.ConversationID) {
|
|
||||||
client.send(chatSocketEvent{
|
client.send(chatSocketEvent{
|
||||||
Type: "error",
|
Type: "error",
|
||||||
Error: "Dieser Chat ist schreibgeschützt",
|
Error: "Dieser Chat ist schreibgeschützt",
|
||||||
|
|||||||
@ -43,6 +43,10 @@ func (s *Server) handleCreateDeviceManufacturer(w http.ResponseWriter, r *http.R
|
|||||||
s.handleCreateDeviceLookup(w, r, "device_manufacturers", "Hersteller")
|
s.handleCreateDeviceLookup(w, r, "device_manufacturers", "Hersteller")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDeleteDeviceManufacturer(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.handleDeleteDeviceLookup(w, r, "device_manufacturers", "Hersteller")
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleCreateDeviceLocation(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleCreateDeviceLocation(w http.ResponseWriter, r *http.Request) {
|
||||||
s.handleCreateDeviceLookup(w, r, "device_locations", "Standort")
|
s.handleCreateDeviceLookup(w, r, "device_locations", "Standort")
|
||||||
}
|
}
|
||||||
@ -77,6 +81,38 @@ func (s *Server) handleCreateDeviceLookup(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDeleteDeviceLookup(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
tableName string,
|
||||||
|
label string,
|
||||||
|
) {
|
||||||
|
id := strings.TrimSpace(r.PathValue("id"))
|
||||||
|
if id == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, label+" ist erforderlich")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
commandTag, err := s.db.Exec(
|
||||||
|
r.Context(),
|
||||||
|
fmt.Sprintf(`DELETE FROM %s WHERE id = $1`, tableName),
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, label+" konnte nicht gelöscht werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if commandTag.RowsAffected() == 0 {
|
||||||
|
writeError(w, http.StatusNotFound, label+" wurde nicht gefunden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"deleted": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func listDeviceLookupOptions(
|
func listDeviceLookupOptions(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
db interface {
|
db interface {
|
||||||
|
|||||||
@ -861,6 +861,108 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDeleteDevice(w http.ResponseWriter, r *http.Request) {
|
||||||
|
deviceID := strings.TrimSpace(r.PathValue("id"))
|
||||||
|
|
||||||
|
if deviceID == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "Geräte-ID fehlt")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, ok := userFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.db.Begin(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Gerät konnte nicht gelöscht werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback(r.Context())
|
||||||
|
|
||||||
|
var device Device
|
||||||
|
|
||||||
|
err = tx.QueryRow(
|
||||||
|
r.Context(),
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
COALESCE(inventory_number, ''),
|
||||||
|
COALESCE(manufacturer, ''),
|
||||||
|
COALESCE(model, ''),
|
||||||
|
COALESCE(milestone_recording_server_id, ''),
|
||||||
|
COALESCE(milestone_hardware_id, ''),
|
||||||
|
COALESCE(milestone_display_name, '')
|
||||||
|
FROM devices
|
||||||
|
WHERE id = $1
|
||||||
|
FOR UPDATE
|
||||||
|
`,
|
||||||
|
deviceID,
|
||||||
|
).Scan(
|
||||||
|
&device.ID,
|
||||||
|
&device.InventoryNumber,
|
||||||
|
&device.Manufacturer,
|
||||||
|
&device.Model,
|
||||||
|
&device.MilestoneRecordingServerID,
|
||||||
|
&device.MilestoneHardwareID,
|
||||||
|
&device.MilestoneDisplayName,
|
||||||
|
)
|
||||||
|
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
writeError(w, http.StatusNotFound, "Gerät wurde nicht gefunden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Gerät konnte nicht gelöscht werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(device.MilestoneHardwareID) != "" ||
|
||||||
|
strings.TrimSpace(device.MilestoneRecordingServerID) != "" {
|
||||||
|
writeError(w, http.StatusConflict, "Milestone-Geräte können nicht gelöscht werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(r.Context(), `DELETE FROM devices WHERE id = $1`, device.ID); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Gerät konnte nicht gelöscht werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(r.Context()); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Gerät konnte nicht gelöscht werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.publishDeviceChangeEvent(
|
||||||
|
r.Context(),
|
||||||
|
user.ID,
|
||||||
|
device.ID,
|
||||||
|
"device.deleted",
|
||||||
|
"Gerät gelöscht",
|
||||||
|
formatText(
|
||||||
|
"Gerät %s wurde gelöscht.",
|
||||||
|
device.InventoryNumber,
|
||||||
|
),
|
||||||
|
LogFields{
|
||||||
|
"inventoryNumber": device.InventoryNumber,
|
||||||
|
"manufacturer": device.Manufacturer,
|
||||||
|
"model": device.Model,
|
||||||
|
},
|
||||||
|
); err != nil {
|
||||||
|
logError("Device-SSE-Event konnte nicht gesendet werden", err, LogFields{
|
||||||
|
"deviceId": device.ID,
|
||||||
|
"type": "device.deleted",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"deletedDeviceId": device.ID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func ensureDeviceExists(ctx context.Context, tx pgx.Tx, deviceID string) error {
|
func ensureDeviceExists(ctx context.Context, tx pgx.Tx, deviceID string) error {
|
||||||
var exists bool
|
var exists bool
|
||||||
|
|
||||||
|
|||||||
@ -7,14 +7,16 @@ go 1.26.2
|
|||||||
require (
|
require (
|
||||||
github.com/SherClockHolmes/webpush-go v1.4.0
|
github.com/SherClockHolmes/webpush-go v1.4.0
|
||||||
github.com/coder/websocket v1.8.14
|
github.com/coder/websocket v1.8.14
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
|
||||||
github.com/go-ldap/ldap/v3 v3.4.13
|
github.com/go-ldap/ldap/v3 v3.4.13
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
|
github.com/hirochachacha/go-smb2 v1.1.0
|
||||||
github.com/jackc/pgx/v5 v5.9.2
|
github.com/jackc/pgx/v5 v5.9.2
|
||||||
golang.org/x/crypto v0.51.0
|
golang.org/x/crypto v0.51.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Azure/go-ntlmssp v0.1.0 // indirect
|
github.com/Azure/go-ntlmssp v0.1.0 // indirect
|
||||||
|
github.com/geoffgarside/ber v1.1.0 // indirect
|
||||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@ -7,6 +7,8 @@ github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6p
|
|||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/geoffgarside/ber v1.1.0 h1:qTmFG4jJbwiSzSXoNJeHcOprVzZ8Ulde2Rrrifu5U9w=
|
||||||
|
github.com/geoffgarside/ber v1.1.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNecGT85ZCc=
|
||||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
|
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
|
||||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||||
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
|
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
|
||||||
@ -17,6 +19,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs
|
|||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hirochachacha/go-smb2 v1.1.0 h1:b6hs9qKIql9eVXAiN0M2wSFY5xnhbHAQoCwRKbaRTZI=
|
||||||
|
github.com/hirochachacha/go-smb2 v1.1.0/go.mod h1:8F1A4d5EZzrGu5R7PU163UcMRDJQl4FtcxjBfsY8TZE=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
@ -36,6 +40,7 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
|||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||||
@ -48,6 +53,7 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
|||||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
@ -66,6 +72,7 @@ golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
|||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
|||||||
1191
backend/milestone_backup.go
Normal file
1191
backend/milestone_backup.go
Normal file
File diff suppressed because it is too large
Load Diff
@ -181,7 +181,7 @@ func (s *Server) handleOperationMilestoneSetup(w http.ResponseWriter, r *http.Re
|
|||||||
Type: "step",
|
Type: "step",
|
||||||
Step: "roleUser",
|
Step: "roleUser",
|
||||||
Status: "current",
|
Status: "current",
|
||||||
Message: "Auswertungsrolle und AD-Benutzer werden geprueft.",
|
Message: "Einsatzrolle und Auswerter-Benutzer werden geprueft.",
|
||||||
})
|
})
|
||||||
targetRole, err = s.ensureOperationMilestoneRoleUser(ctx, config, operation, input)
|
targetRole, err = s.ensureOperationMilestoneRoleUser(ctx, config, operation, input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -824,86 +824,43 @@ func (s *Server) ensureOperationMilestoneRoleUser(
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
roleName := operationMilestoneEvaluationRoleName(*laptopDevice, operation)
|
operationRoleName := operationMilestoneRoleName(operation)
|
||||||
if roleName == "" {
|
if operationRoleName == "" {
|
||||||
return nil, fmt.Errorf("Auswertungsrolle konnte aus Laptop %q nicht ermittelt werden", laptopDevice.InventoryNumber)
|
return nil, errors.New("Einsatznummer fehlt, Milestone-Rolle konnte nicht angelegt werden")
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceRole, err := findOperationMilestoneRoleByName(ctx, config, roleName)
|
role, err := ensureOperationMilestoneRole(ctx, config, operationRoleName)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if sourceRole == nil {
|
|
||||||
operationRoleName := operationMilestoneRoleName(operation)
|
|
||||||
if operationRoleName == "" {
|
|
||||||
return nil, fmt.Errorf("Milestone-Rolle %q wurde nicht gefunden und Einsatznummer fehlt", roleName)
|
|
||||||
}
|
|
||||||
|
|
||||||
role, err := ensureOperationMilestoneRole(ctx, config, operationRoleName)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("Milestone-Rolle %q wurde nicht gefunden und Rolle %q konnte nicht angelegt werden: %w", roleName, operationRoleName, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
role, err = getOperationMilestoneRole(ctx, config, role.ID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if selectedUser := operationMilestoneUserFromSetupInput(input); selectedUser != nil {
|
|
||||||
roleUsers, listErr := listOperationMilestoneRoleUsers(ctx, config, role.ID)
|
|
||||||
if listErr != nil {
|
|
||||||
roleUsers = operationMilestoneObjectListFromValue(role.Data["users"])
|
|
||||||
}
|
|
||||||
|
|
||||||
if operationMilestoneObjectByID(roleUsers, milestoneItemID(selectedUser)) == nil {
|
|
||||||
if err := addOperationMilestoneRoleMembers(ctx, config, role.ID, []map[string]any{selectedUser}); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
roleUsers = appendOperationMilestoneObjectByID(roleUsers, selectedUser)
|
|
||||||
}
|
|
||||||
role.Data["users"] = roleUsers
|
|
||||||
}
|
|
||||||
|
|
||||||
return role, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
targetUserName := strings.TrimSpace(laptopDevice.ComputerName)
|
|
||||||
if targetUserName == "" {
|
|
||||||
targetUserName = roleName
|
|
||||||
}
|
|
||||||
|
|
||||||
role, err := getOperationMilestoneRole(ctx, config, sourceRole.ID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceUsers, err := listOperationMilestoneRoleUsers(ctx, config, sourceRole.ID)
|
role, err = getOperationMilestoneRole(ctx, config, role.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
candidateUsers := sourceUsers
|
|
||||||
if hasOperationMilestoneUserSelection(input) {
|
|
||||||
allRoleUsers, err := listOperationMilestoneUsersFromRoles(ctx, config)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
candidateUsers = appendOperationMilestoneObjectsByID(candidateUsers, allRoleUsers...)
|
|
||||||
}
|
|
||||||
|
|
||||||
usersToAssign := selectOperationMilestoneRoleUsersForAssignment(candidateUsers, targetUserName, input)
|
roleUsers, err := listOperationMilestoneRoleUsers(ctx, config, role.ID)
|
||||||
if len(usersToAssign) == 0 {
|
if err != nil {
|
||||||
if selectedUser := operationMilestoneUserFromSetupInput(input); selectedUser != nil {
|
|
||||||
usersToAssign = []map[string]any{selectedUser}
|
|
||||||
} else {
|
|
||||||
return nil, fmt.Errorf("AD-Benutzer %q wurde unter /roles/%s/users nicht gefunden", targetUserName, sourceRole.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
roleUsers := sourceUsers
|
|
||||||
if len(roleUsers) == 0 {
|
|
||||||
roleUsers = operationMilestoneObjectListFromValue(role.Data["users"])
|
roleUsers = operationMilestoneObjectListFromValue(role.Data["users"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
usersToAssign, targetUserName, err := s.operationMilestoneRoleUsersForLaptop(
|
||||||
|
ctx,
|
||||||
|
config,
|
||||||
|
*laptopDevice,
|
||||||
|
operation,
|
||||||
|
input,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(usersToAssign) == 0 {
|
||||||
|
if targetUserName == "" {
|
||||||
|
targetUserName = operationMilestoneEvaluationRoleName(*laptopDevice, operation)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("AD-Benutzer %q wurde nicht gefunden", targetUserName)
|
||||||
|
}
|
||||||
|
|
||||||
usersToAdd := []map[string]any{}
|
usersToAdd := []map[string]any{}
|
||||||
for _, targetUser := range usersToAssign {
|
for _, targetUser := range usersToAssign {
|
||||||
if milestoneItemID(targetUser) == "" {
|
if milestoneItemID(targetUser) == "" {
|
||||||
@ -928,6 +885,58 @@ func (s *Server) ensureOperationMilestoneRoleUser(
|
|||||||
return role, nil
|
return role, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) operationMilestoneRoleUsersForLaptop(
|
||||||
|
ctx context.Context,
|
||||||
|
config milestoneRuntimeConfig,
|
||||||
|
laptopDevice operationMilestoneSetupDevice,
|
||||||
|
operation Operation,
|
||||||
|
input operationMilestoneSetupRequest,
|
||||||
|
) ([]map[string]any, string, error) {
|
||||||
|
if selectedUser := operationMilestoneUserFromSetupInput(input); selectedUser != nil {
|
||||||
|
return []map[string]any{selectedUser}, strings.TrimSpace(input.MilestoneUserName), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
targetUserName := operationMilestoneEvaluationUserName(laptopDevice, operation)
|
||||||
|
if targetUserName != "" {
|
||||||
|
user, err := s.findOperationMilestoneActiveDirectoryUser(ctx, targetUserName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, targetUserName, err
|
||||||
|
}
|
||||||
|
if user != nil {
|
||||||
|
return []map[string]any{user}, targetUserName, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
evaluationRoleName := operationMilestoneEvaluationRoleName(laptopDevice, operation)
|
||||||
|
candidateUsers := []map[string]any{}
|
||||||
|
if evaluationRoleName != "" {
|
||||||
|
sourceRole, err := findOperationMilestoneRoleByName(ctx, config, evaluationRoleName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, targetUserName, err
|
||||||
|
}
|
||||||
|
if sourceRole != nil {
|
||||||
|
sourceUsers, err := listOperationMilestoneRoleUsers(ctx, config, sourceRole.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, targetUserName, err
|
||||||
|
}
|
||||||
|
candidateUsers = appendOperationMilestoneObjectsByID(candidateUsers, sourceUsers...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allRoleUsers, err := listOperationMilestoneUsersFromRoles(ctx, config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, targetUserName, err
|
||||||
|
}
|
||||||
|
candidateUsers = appendOperationMilestoneObjectsByID(candidateUsers, allRoleUsers...)
|
||||||
|
|
||||||
|
usersToAssign := selectOperationMilestoneRoleUsersForAssignment(candidateUsers, targetUserName, input)
|
||||||
|
if len(usersToAssign) > 0 {
|
||||||
|
return usersToAssign, targetUserName, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, targetUserName, nil
|
||||||
|
}
|
||||||
|
|
||||||
func selectOperationMilestoneRoleUsersForAssignment(
|
func selectOperationMilestoneRoleUsersForAssignment(
|
||||||
users []map[string]any,
|
users []map[string]any,
|
||||||
preferredUserName string,
|
preferredUserName string,
|
||||||
@ -941,6 +950,10 @@ func selectOperationMilestoneRoleUsersForAssignment(
|
|||||||
return []map[string]any{targetUser}
|
return []map[string]any{targetUser}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(preferredUserName) != "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
usersWithID := make([]map[string]any, 0, len(users))
|
usersWithID := make([]map[string]any, 0, len(users))
|
||||||
for _, user := range users {
|
for _, user := range users {
|
||||||
if milestoneItemID(user) != "" {
|
if milestoneItemID(user) != "" {
|
||||||
@ -1116,6 +1129,146 @@ func operationMilestoneEvaluationRoleName(
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func operationMilestoneEvaluationUserName(
|
||||||
|
laptopDevice operationMilestoneSetupDevice,
|
||||||
|
operation Operation,
|
||||||
|
) string {
|
||||||
|
candidates := []string{
|
||||||
|
laptopDevice.ComputerName,
|
||||||
|
laptopDevice.InventoryNumber,
|
||||||
|
laptopDevice.Model,
|
||||||
|
operation.Laptop,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
match := operationMilestoneEvaluationRolePattern.FindStringSubmatch(candidate)
|
||||||
|
if len(match) == 2 {
|
||||||
|
return "auswertung" + match[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) findOperationMilestoneActiveDirectoryUser(
|
||||||
|
ctx context.Context,
|
||||||
|
targetUserName string,
|
||||||
|
) (map[string]any, error) {
|
||||||
|
target := normalizeOperationMilestoneAccountName(targetUserName)
|
||||||
|
if target == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
credentials, err := s.getActiveDirectoryCredentials(ctx)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(credentials.ServerURL) == "" ||
|
||||||
|
strings.TrimSpace(credentials.BaseDN) == "" ||
|
||||||
|
strings.TrimSpace(credentials.BindUsername) == "" ||
|
||||||
|
strings.TrimSpace(credentials.BindPassword) == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
users, err := listActiveDirectoryUsers(ctx, credentials)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, user := range users {
|
||||||
|
if operationMilestoneActiveDirectoryUserMatches(user, target) {
|
||||||
|
return operationMilestoneUserFromActiveDirectoryUser(user), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func operationMilestoneActiveDirectoryUserMatches(user activeDirectoryUser, target string) bool {
|
||||||
|
for _, value := range []string{
|
||||||
|
user.ID,
|
||||||
|
user.SID,
|
||||||
|
user.Username,
|
||||||
|
user.DisplayName,
|
||||||
|
user.Email,
|
||||||
|
user.DistinguishedName,
|
||||||
|
milestoneProtectedUserQualifiedName(user.Domain, user.Username),
|
||||||
|
} {
|
||||||
|
if normalizeOperationMilestoneAccountName(value) == target {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func operationMilestoneUserFromActiveDirectoryUser(user activeDirectoryUser) map[string]any {
|
||||||
|
userID := strings.TrimSpace(user.ID)
|
||||||
|
if userID == "" {
|
||||||
|
userID = strings.TrimSpace(user.SID)
|
||||||
|
}
|
||||||
|
if userID == "" {
|
||||||
|
userID = strings.TrimSpace(user.DistinguishedName)
|
||||||
|
}
|
||||||
|
if userID == "" {
|
||||||
|
userID = strings.TrimSpace(user.Username)
|
||||||
|
}
|
||||||
|
|
||||||
|
displayName := strings.TrimSpace(user.DisplayName)
|
||||||
|
if displayName == "" {
|
||||||
|
displayName = strings.TrimSpace(user.Username)
|
||||||
|
}
|
||||||
|
if displayName == "" {
|
||||||
|
displayName = userID
|
||||||
|
}
|
||||||
|
|
||||||
|
milestoneUser := map[string]any{
|
||||||
|
"id": userID,
|
||||||
|
"name": displayName,
|
||||||
|
"displayName": displayName,
|
||||||
|
"userName": strings.TrimSpace(user.Username),
|
||||||
|
"username": strings.TrimSpace(user.Username),
|
||||||
|
"email": strings.TrimSpace(user.Email),
|
||||||
|
"relations": map[string]any{
|
||||||
|
"self": map[string]any{
|
||||||
|
"type": "users",
|
||||||
|
"id": userID,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if domain := strings.TrimSpace(user.Domain); domain != "" {
|
||||||
|
milestoneUser["domain"] = domain
|
||||||
|
}
|
||||||
|
if sid := strings.TrimSpace(user.SID); sid != "" {
|
||||||
|
milestoneUser["sid"] = sid
|
||||||
|
milestoneUser["objectSid"] = sid
|
||||||
|
}
|
||||||
|
if dn := strings.TrimSpace(user.DistinguishedName); dn != "" {
|
||||||
|
milestoneUser["distinguishedName"] = dn
|
||||||
|
}
|
||||||
|
|
||||||
|
return milestoneUser
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeOperationMilestoneAccountName(value string) string {
|
||||||
|
value = strings.TrimSpace(strings.ToLower(value))
|
||||||
|
if value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if _, account, ok := strings.Cut(value, "\\"); ok {
|
||||||
|
value = account
|
||||||
|
}
|
||||||
|
if account, _, ok := strings.Cut(value, "@"); ok {
|
||||||
|
value = account
|
||||||
|
}
|
||||||
|
|
||||||
|
replacer := strings.NewReplacer(" ", "", "\t", "", "\n", "", "\r", "", "-", "", "_", "")
|
||||||
|
return replacer.Replace(value)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) assignOperationMilestoneCameraToRole(
|
func (s *Server) assignOperationMilestoneCameraToRole(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
config milestoneRuntimeConfig,
|
config milestoneRuntimeConfig,
|
||||||
|
|||||||
@ -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("DELETE /devices/{id}", s.requireAuth(s.handleDeleteDevice))
|
||||||
mux.HandleFunc("GET /device-loan-users", s.requireAuth(s.handleListDeviceLoanUsers))
|
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))
|
||||||
@ -78,6 +79,10 @@ func (s *Server) Routes() http.Handler {
|
|||||||
|
|
||||||
mux.HandleFunc("GET /device-lookups", s.handleDeviceLookups)
|
mux.HandleFunc("GET /device-lookups", s.handleDeviceLookups)
|
||||||
mux.HandleFunc("POST /device-manufacturers", s.handleCreateDeviceManufacturer)
|
mux.HandleFunc("POST /device-manufacturers", s.handleCreateDeviceManufacturer)
|
||||||
|
mux.HandleFunc(
|
||||||
|
"DELETE /device-manufacturers/{id}",
|
||||||
|
s.requireAuth(s.requireRight("devices:write", s.handleDeleteDeviceManufacturer)),
|
||||||
|
)
|
||||||
mux.HandleFunc("POST /device-locations", s.handleCreateDeviceLocation)
|
mux.HandleFunc("POST /device-locations", s.handleCreateDeviceLocation)
|
||||||
|
|
||||||
mux.HandleFunc("GET /operations", s.requireAuth(s.requireRight("operations:read", s.handleListOperations)))
|
mux.HandleFunc("GET /operations", s.requireAuth(s.requireRight("operations:read", s.handleListOperations)))
|
||||||
@ -131,7 +136,9 @@ func (s *Server) Routes() http.Handler {
|
|||||||
mux.HandleFunc("POST /chat/link-preview", s.requireAuth(s.handleChatLinkPreview))
|
mux.HandleFunc("POST /chat/link-preview", s.requireAuth(s.handleChatLinkPreview))
|
||||||
mux.HandleFunc("GET /chat/conversations/{id}/messages", s.requireAuth(s.handleListChatMessages))
|
mux.HandleFunc("GET /chat/conversations/{id}/messages", s.requireAuth(s.handleListChatMessages))
|
||||||
mux.HandleFunc("POST /chat/conversations/{id}/messages", s.requireAuth(s.handleCreateChatMessage))
|
mux.HandleFunc("POST /chat/conversations/{id}/messages", s.requireAuth(s.handleCreateChatMessage))
|
||||||
|
mux.HandleFunc("POST /chat/messages/{id}/reactions", s.requireAuth(s.handleToggleChatMessageReaction))
|
||||||
mux.HandleFunc("POST /chat/conversations/{id}/read", s.requireAuth(s.handleMarkChatConversationRead))
|
mux.HandleFunc("POST /chat/conversations/{id}/read", s.requireAuth(s.handleMarkChatConversationRead))
|
||||||
|
mux.HandleFunc("POST /chat/conversations/{id}/clear", s.requireAuth(s.handleClearChat))
|
||||||
mux.HandleFunc("PUT /chat/conversations/{id}/mute", s.requireAuth(s.handleUpdateConversationMute))
|
mux.HandleFunc("PUT /chat/conversations/{id}/mute", s.requireAuth(s.handleUpdateConversationMute))
|
||||||
mux.HandleFunc("PUT /chat/conversations/{id}/disappearing", s.requireAuth(s.handleUpdateDisappearing))
|
mux.HandleFunc("PUT /chat/conversations/{id}/disappearing", s.requireAuth(s.handleUpdateDisappearing))
|
||||||
mux.HandleFunc("POST /chat/groups", s.requireAuth(s.handleCreateGroup))
|
mux.HandleFunc("POST /chat/groups", s.requireAuth(s.handleCreateGroup))
|
||||||
@ -188,6 +195,9 @@ func (s *Server) Routes() http.Handler {
|
|||||||
mux.HandleFunc("PUT /admin/milestone", s.requireAuth(s.requireRight("administration:write", s.handleUpdateMilestoneSettings)))
|
mux.HandleFunc("PUT /admin/milestone", s.requireAuth(s.requireRight("administration:write", s.handleUpdateMilestoneSettings)))
|
||||||
mux.HandleFunc("POST /admin/milestone/token", s.requireAuth(s.requireRight("administration:write", s.handleFetchMilestoneToken)))
|
mux.HandleFunc("POST /admin/milestone/token", s.requireAuth(s.requireRight("administration:write", s.handleFetchMilestoneToken)))
|
||||||
mux.HandleFunc("POST /admin/milestone/token/stream", s.requireAuth(s.requireRight("administration:write", s.handleFetchMilestoneTokenStream)))
|
mux.HandleFunc("POST /admin/milestone/token/stream", s.requireAuth(s.requireRight("administration:write", s.handleFetchMilestoneTokenStream)))
|
||||||
|
mux.HandleFunc("GET /admin/milestone/backup-logs", s.requireAuth(s.requireRight("administration:read", s.handleListMilestoneBackupLogs)))
|
||||||
|
mux.HandleFunc("GET /admin/milestone/backup-folders", s.requireAuth(s.requireRight("administration:read", s.handleListMilestoneBackupFolders)))
|
||||||
|
mux.HandleFunc("POST /admin/milestone/backup-folders/browse", s.requireAuth(s.requireRight("administration:write", s.handleBrowseMilestoneBackupFolders)))
|
||||||
mux.HandleFunc("GET /admin/milestone/basic-users", s.requireAuth(s.requireRight("administration:read", s.handleAdminListMilestoneBasicUsers)))
|
mux.HandleFunc("GET /admin/milestone/basic-users", s.requireAuth(s.requireRight("administration:read", s.handleAdminListMilestoneBasicUsers)))
|
||||||
mux.HandleFunc("GET /admin/milestone/roles", s.requireAuth(s.requireRight("administration:read", s.handleAdminListMilestoneRoles)))
|
mux.HandleFunc("GET /admin/milestone/roles", s.requireAuth(s.requireRight("administration:read", s.handleAdminListMilestoneRoles)))
|
||||||
mux.HandleFunc("POST /admin/milestone/roles", s.requireAuth(s.requireRight("administration:write", s.handleAdminCreateMilestoneRole)))
|
mux.HandleFunc("POST /admin/milestone/roles", s.requireAuth(s.requireRight("administration:write", s.handleAdminCreateMilestoneRole)))
|
||||||
|
|||||||
@ -15,6 +15,38 @@ func ensureRuntimeDeviceSchema(ctx context.Context, db *pgxpool.Pool) error {
|
|||||||
`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_to_user_id UUID REFERENCES users(id) ON DELETE SET NULL`,
|
||||||
`ALTER TABLE IF EXISTS devices ADD COLUMN IF NOT EXISTS loaned_until DATE`,
|
`ALTER TABLE IF EXISTS devices ADD COLUMN IF NOT EXISTS loaned_until DATE`,
|
||||||
`ALTER TABLE IF EXISTS operations ADD COLUMN IF NOT EXISTS completed_at TIMESTAMPTZ`,
|
`ALTER TABLE IF EXISTS operations ADD COLUMN IF NOT EXISTS completed_at TIMESTAMPTZ`,
|
||||||
|
`ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS is_public BOOLEAN NOT NULL DEFAULT false`,
|
||||||
|
`ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS disappearing_seconds INTEGER NOT NULL DEFAULT 0`,
|
||||||
|
`ALTER TABLE IF EXISTS conversation_members ADD COLUMN IF NOT EXISTS muted BOOLEAN NOT NULL DEFAULT false`,
|
||||||
|
`ALTER TABLE IF EXISTS conversation_members ADD COLUMN IF NOT EXISTS last_cleared_at TIMESTAMPTZ`,
|
||||||
|
`
|
||||||
|
CREATE TABLE IF NOT EXISTS message_reactions (
|
||||||
|
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
emoji TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (message_id, user_id)
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
`
|
||||||
|
DELETE FROM message_reactions mr
|
||||||
|
USING (
|
||||||
|
SELECT ctid
|
||||||
|
FROM (
|
||||||
|
SELECT ctid,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY message_id, user_id
|
||||||
|
ORDER BY created_at DESC, emoji ASC
|
||||||
|
) AS row_number
|
||||||
|
FROM message_reactions
|
||||||
|
) ranked_reactions
|
||||||
|
WHERE row_number > 1
|
||||||
|
) duplicate_reactions
|
||||||
|
WHERE mr.ctid = duplicate_reactions.ctid
|
||||||
|
`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_message_reactions_message_id ON message_reactions(message_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_message_reactions_user_id ON message_reactions(user_id)`,
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_message_reactions_message_user_unique ON message_reactions(message_id, user_id)`,
|
||||||
`
|
`
|
||||||
CREATE TABLE IF NOT EXISTS device_relations (
|
CREATE TABLE IF NOT EXISTS device_relations (
|
||||||
device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||||
@ -47,6 +79,11 @@ func ensureRuntimeDeviceSchema(ctx context.Context, db *pgxpool.Pool) error {
|
|||||||
`ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS server_folders_storage_root_path TEXT NOT NULL DEFAULT 'D:/Milestone-Speicher'`,
|
`ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS server_folders_storage_root_path TEXT NOT NULL DEFAULT 'D:/Milestone-Speicher'`,
|
||||||
`ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS server_folders_archive_root_label TEXT NOT NULL DEFAULT 'Archive E'`,
|
`ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS server_folders_archive_root_label TEXT NOT NULL DEFAULT 'Archive E'`,
|
||||||
`ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS server_folders_archive_root_path TEXT NOT NULL DEFAULT 'E:/'`,
|
`ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS server_folders_archive_root_path TEXT NOT NULL DEFAULT 'E:/'`,
|
||||||
|
`ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS backup_nas_host TEXT NOT NULL DEFAULT ''`,
|
||||||
|
`ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS backup_nas_share TEXT NOT NULL DEFAULT ''`,
|
||||||
|
`ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS backup_nas_username TEXT NOT NULL DEFAULT ''`,
|
||||||
|
`ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS backup_nas_password_encrypted BYTEA`,
|
||||||
|
`ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS backup_log_root_path TEXT NOT NULL DEFAULT ''`,
|
||||||
`ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS active_directory_server_url TEXT NOT NULL DEFAULT ''`,
|
`ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS active_directory_server_url TEXT NOT NULL DEFAULT ''`,
|
||||||
`ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS active_directory_base_dn TEXT NOT NULL DEFAULT ''`,
|
`ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS active_directory_base_dn TEXT NOT NULL DEFAULT ''`,
|
||||||
`ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS active_directory_bind_username TEXT NOT NULL DEFAULT ''`,
|
`ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS active_directory_bind_username TEXT NOT NULL DEFAULT ''`,
|
||||||
|
|||||||
@ -66,7 +66,7 @@ func (s *Server) handleGlobalSearch(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
groups := make([]globalSearchGroup, 0, 5)
|
groups := make([]globalSearchGroup, 0, 6)
|
||||||
|
|
||||||
if userCanSearch(user, "operations:read") {
|
if userCanSearch(user, "operations:read") {
|
||||||
items, err := s.searchOperations(r.Context(), options)
|
items, err := s.searchOperations(r.Context(), options)
|
||||||
@ -148,6 +148,20 @@ func (s *Server) handleGlobalSearch(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
items, err := s.searchPublicChatMessages(r.Context(), options)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Öffentliche Chats konnten nicht durchsucht werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(items) > 0 {
|
||||||
|
groups = append(groups, globalSearchGroup{
|
||||||
|
ID: "public-chat-messages",
|
||||||
|
Title: "Öffentliche Chats",
|
||||||
|
Items: items,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, globalSearchResponse{
|
writeJSON(w, http.StatusOK, globalSearchResponse{
|
||||||
Query: query,
|
Query: query,
|
||||||
Groups: groups,
|
Groups: groups,
|
||||||
@ -677,6 +691,106 @@ func (s *Server) searchUsers(ctx context.Context, options globalSearchOptions) (
|
|||||||
return items, rows.Err()
|
return items, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) searchPublicChatMessages(ctx context.Context, options globalSearchOptions) ([]globalSearchItem, error) {
|
||||||
|
patterns := buildSearchPatterns(options)
|
||||||
|
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
m.id::TEXT,
|
||||||
|
m.conversation_id::TEXT,
|
||||||
|
COALESCE(
|
||||||
|
NULLIF(c.name, ''),
|
||||||
|
(
|
||||||
|
SELECT string_agg(
|
||||||
|
COALESCE(NULLIF(member.display_name, ''), NULLIF(member.username, ''), member.email),
|
||||||
|
' & '
|
||||||
|
ORDER BY lower(COALESCE(NULLIF(member.display_name, ''), NULLIF(member.username, ''), member.email))
|
||||||
|
)
|
||||||
|
FROM conversation_members cm
|
||||||
|
JOIN users member ON member.id = cm.user_id
|
||||||
|
WHERE cm.conversation_id = c.id
|
||||||
|
),
|
||||||
|
'Chat'
|
||||||
|
),
|
||||||
|
COALESCE(NULLIF(u.display_name, ''), NULLIF(u.username, ''), u.email, 'Unbekannt'),
|
||||||
|
m.body,
|
||||||
|
m.created_at
|
||||||
|
FROM messages m
|
||||||
|
JOIN conversations c ON c.id = m.conversation_id
|
||||||
|
LEFT JOIN users u ON u.id = m.sender_id
|
||||||
|
WHERE c.type <> 'channel'
|
||||||
|
AND c.is_public = true
|
||||||
|
AND m.deleted_at IS NULL
|
||||||
|
AND m.message_type = 'user'
|
||||||
|
AND (m.expires_at IS NULL OR m.expires_at > now())
|
||||||
|
AND (
|
||||||
|
(
|
||||||
|
$3 = false
|
||||||
|
AND CONCAT_WS(' ', c.name, m.body) ILIKE ALL($1::TEXT[])
|
||||||
|
)
|
||||||
|
OR
|
||||||
|
(
|
||||||
|
$3 = true
|
||||||
|
AND CONCAT_WS(' ', c.name, m.body) LIKE ALL($1::TEXT[])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ORDER BY m.created_at DESC
|
||||||
|
LIMIT $2
|
||||||
|
`,
|
||||||
|
patterns,
|
||||||
|
options.Limit,
|
||||||
|
options.CaseSensitive,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
items := []globalSearchItem{}
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var messageID string
|
||||||
|
var conversationID string
|
||||||
|
var conversationName string
|
||||||
|
var senderDisplayName string
|
||||||
|
var body string
|
||||||
|
var createdAt time.Time
|
||||||
|
|
||||||
|
if err := rows.Scan(
|
||||||
|
&messageID,
|
||||||
|
&conversationID,
|
||||||
|
&conversationName,
|
||||||
|
&senderDisplayName,
|
||||||
|
&body,
|
||||||
|
&createdAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
items = append(items, globalSearchItem{
|
||||||
|
ID: "public-chat-message-" + messageID,
|
||||||
|
EntityID: conversationID,
|
||||||
|
EntityType: "publicChatMessage",
|
||||||
|
Title: conversationName,
|
||||||
|
Subtitle: joinSearchSubtitle(
|
||||||
|
formatSearchDate(createdAt),
|
||||||
|
senderDisplayName,
|
||||||
|
truncateSearchText(body, 140),
|
||||||
|
),
|
||||||
|
Href: "/chat/" + conversationID,
|
||||||
|
Data: map[string]any{
|
||||||
|
"messageId": messageID,
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"createdAt": createdAt,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return items, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
func parseSearchLimit(value string, fallback int) int {
|
func parseSearchLimit(value string, fallback int) int {
|
||||||
limit, err := strconv.Atoi(value)
|
limit, err := strconv.Atoi(value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -677,6 +677,11 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
|||||||
server_folders_archive_root_label TEXT NOT NULL DEFAULT 'Archive E',
|
server_folders_archive_root_label TEXT NOT NULL DEFAULT 'Archive E',
|
||||||
server_folders_archive_root_path TEXT NOT NULL DEFAULT 'E:/',
|
server_folders_archive_root_path TEXT NOT NULL DEFAULT 'E:/',
|
||||||
server_folders_agent_token_encrypted BYTEA,
|
server_folders_agent_token_encrypted BYTEA,
|
||||||
|
backup_nas_host TEXT NOT NULL DEFAULT '',
|
||||||
|
backup_nas_share TEXT NOT NULL DEFAULT '',
|
||||||
|
backup_nas_username TEXT NOT NULL DEFAULT '',
|
||||||
|
backup_nas_password_encrypted BYTEA,
|
||||||
|
backup_log_root_path TEXT NOT NULL DEFAULT '',
|
||||||
active_directory_server_url TEXT NOT NULL DEFAULT '',
|
active_directory_server_url TEXT NOT NULL DEFAULT '',
|
||||||
active_directory_base_dn TEXT NOT NULL DEFAULT '',
|
active_directory_base_dn TEXT NOT NULL DEFAULT '',
|
||||||
active_directory_bind_username TEXT NOT NULL DEFAULT '',
|
active_directory_bind_username TEXT NOT NULL DEFAULT '',
|
||||||
@ -724,6 +729,31 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
|||||||
ADD COLUMN IF NOT EXISTS server_folders_agent_token_encrypted BYTEA
|
ADD COLUMN IF NOT EXISTS server_folders_agent_token_encrypted BYTEA
|
||||||
`,
|
`,
|
||||||
|
|
||||||
|
`
|
||||||
|
ALTER TABLE milestone_settings
|
||||||
|
ADD COLUMN IF NOT EXISTS backup_nas_host TEXT NOT NULL DEFAULT ''
|
||||||
|
`,
|
||||||
|
|
||||||
|
`
|
||||||
|
ALTER TABLE milestone_settings
|
||||||
|
ADD COLUMN IF NOT EXISTS backup_nas_share TEXT NOT NULL DEFAULT ''
|
||||||
|
`,
|
||||||
|
|
||||||
|
`
|
||||||
|
ALTER TABLE milestone_settings
|
||||||
|
ADD COLUMN IF NOT EXISTS backup_nas_username TEXT NOT NULL DEFAULT ''
|
||||||
|
`,
|
||||||
|
|
||||||
|
`
|
||||||
|
ALTER TABLE milestone_settings
|
||||||
|
ADD COLUMN IF NOT EXISTS backup_nas_password_encrypted BYTEA
|
||||||
|
`,
|
||||||
|
|
||||||
|
`
|
||||||
|
ALTER TABLE milestone_settings
|
||||||
|
ADD COLUMN IF NOT EXISTS backup_log_root_path TEXT NOT NULL DEFAULT ''
|
||||||
|
`,
|
||||||
|
|
||||||
`
|
`
|
||||||
ALTER TABLE milestone_settings
|
ALTER TABLE milestone_settings
|
||||||
ADD COLUMN IF NOT EXISTS active_directory_server_url TEXT NOT NULL DEFAULT ''
|
ADD COLUMN IF NOT EXISTS active_directory_server_url TEXT NOT NULL DEFAULT ''
|
||||||
@ -929,6 +959,7 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
|||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
last_read_at TIMESTAMPTZ,
|
last_read_at TIMESTAMPTZ,
|
||||||
|
last_cleared_at TIMESTAMPTZ,
|
||||||
muted BOOLEAN NOT NULL DEFAULT false,
|
muted BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
@ -938,6 +969,7 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
|||||||
`,
|
`,
|
||||||
|
|
||||||
`ALTER TABLE IF EXISTS conversation_members ADD COLUMN IF NOT EXISTS muted BOOLEAN NOT NULL DEFAULT false`,
|
`ALTER TABLE IF EXISTS conversation_members ADD COLUMN IF NOT EXISTS muted BOOLEAN NOT NULL DEFAULT false`,
|
||||||
|
`ALTER TABLE IF EXISTS conversation_members ADD COLUMN IF NOT EXISTS last_cleared_at TIMESTAMPTZ`,
|
||||||
|
|
||||||
`
|
`
|
||||||
CREATE TABLE IF NOT EXISTS messages (
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
@ -987,6 +1019,17 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
|||||||
)
|
)
|
||||||
`,
|
`,
|
||||||
|
|
||||||
|
`
|
||||||
|
CREATE TABLE IF NOT EXISTS message_reactions (
|
||||||
|
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
emoji TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
PRIMARY KEY (message_id, user_id)
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
|
||||||
// Link-Vorschau (OpenGraph-Metadaten), serverseitig asynchron geladen.
|
// Link-Vorschau (OpenGraph-Metadaten), serverseitig asynchron geladen.
|
||||||
// Eine Vorschau pro Nachricht (erste erkannte URL).
|
// Eine Vorschau pro Nachricht (erste erkannte URL).
|
||||||
`
|
`
|
||||||
@ -1151,6 +1194,25 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
|||||||
`CREATE INDEX IF NOT EXISTS idx_messages_expires_at ON messages(expires_at) WHERE expires_at IS NOT NULL`,
|
`CREATE INDEX IF NOT EXISTS idx_messages_expires_at ON messages(expires_at) WHERE expires_at IS NOT NULL`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_message_attachments_message_id ON message_attachments(message_id)`,
|
`CREATE INDEX IF NOT EXISTS idx_message_attachments_message_id ON message_attachments(message_id)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_message_attachments_uploader_id ON message_attachments(uploader_id)`,
|
`CREATE INDEX IF NOT EXISTS idx_message_attachments_uploader_id ON message_attachments(uploader_id)`,
|
||||||
|
`
|
||||||
|
DELETE FROM message_reactions mr
|
||||||
|
USING (
|
||||||
|
SELECT ctid
|
||||||
|
FROM (
|
||||||
|
SELECT ctid,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY message_id, user_id
|
||||||
|
ORDER BY created_at DESC, emoji ASC
|
||||||
|
) AS row_number
|
||||||
|
FROM message_reactions
|
||||||
|
) ranked_reactions
|
||||||
|
WHERE row_number > 1
|
||||||
|
) duplicate_reactions
|
||||||
|
WHERE mr.ctid = duplicate_reactions.ctid
|
||||||
|
`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_message_reactions_message_id ON message_reactions(message_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_message_reactions_user_id ON message_reactions(user_id)`,
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_message_reactions_message_user_unique ON message_reactions(message_id, user_id)`,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, query := range queries {
|
for _, query := range queries {
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import LoginPage from './pages/LoginPage'
|
|||||||
import AppLayout from './components/AppLayout'
|
import AppLayout from './components/AppLayout'
|
||||||
import DashboardPage from './pages/DashboardPage'
|
import DashboardPage from './pages/DashboardPage'
|
||||||
import DevicesPage from './pages/devices/DevicesPage'
|
import DevicesPage from './pages/devices/DevicesPage'
|
||||||
|
import DeviceCategoryPage from './pages/devices/DeviceCategoryPage'
|
||||||
import OperationsPage from './pages/operations/OperationsPage'
|
import OperationsPage from './pages/operations/OperationsPage'
|
||||||
import CalendarPage from './pages/calendar/CalendarPage'
|
import CalendarPage from './pages/calendar/CalendarPage'
|
||||||
import DocumentsPage from './pages/DocumentsPage'
|
import DocumentsPage from './pages/DocumentsPage'
|
||||||
@ -22,6 +23,7 @@ import MilestonePage from './pages/milestone/MilestonePage'
|
|||||||
import MilestoneStoragePage from './pages/milestone/speicher/MilestoneStoragePage'
|
import MilestoneStoragePage from './pages/milestone/speicher/MilestoneStoragePage'
|
||||||
import MilestoneDevicesPage from './pages/milestone/geraete/MilestoneDevicesPage'
|
import MilestoneDevicesPage from './pages/milestone/geraete/MilestoneDevicesPage'
|
||||||
import MilestoneRolesPage from './pages/milestone/rollen/MilestoneRolesPage'
|
import MilestoneRolesPage from './pages/milestone/rollen/MilestoneRolesPage'
|
||||||
|
import MilestoneBackupPage from './pages/milestone/backup/MilestoneBackupPage'
|
||||||
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 { ChatSocketProvider } from './components/ChatSocketProvider'
|
||||||
@ -655,6 +657,75 @@ function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/geraete/alle"
|
||||||
|
element={<Navigate to="/geraete" replace />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/geraete/kameras"
|
||||||
|
element={
|
||||||
|
<RequireRight user={user} right="devices:read">
|
||||||
|
<DeviceCategoryPage
|
||||||
|
currentUser={user}
|
||||||
|
category="Kameras"
|
||||||
|
title="Kameras"
|
||||||
|
description="Kamera-Hardware verwalten und die passenden Stammdaten für Einsätze pflegen."
|
||||||
|
singularLabel="Kamera"
|
||||||
|
emptyText="Keine Kameras vorhanden."
|
||||||
|
/>
|
||||||
|
</RequireRight>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/geraete/router"
|
||||||
|
element={
|
||||||
|
<RequireRight user={user} right="devices:read">
|
||||||
|
<DeviceCategoryPage
|
||||||
|
currentUser={user}
|
||||||
|
category="Router"
|
||||||
|
title="Router"
|
||||||
|
description="Router verwalten, IP-Adressen pflegen und die Geräte für Einsätze bereitstellen."
|
||||||
|
singularLabel="Router"
|
||||||
|
emptyText="Keine Router vorhanden."
|
||||||
|
/>
|
||||||
|
</RequireRight>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/geraete/switchbox"
|
||||||
|
element={
|
||||||
|
<RequireRight user={user} right="devices:read">
|
||||||
|
<DeviceCategoryPage
|
||||||
|
currentUser={user}
|
||||||
|
category="Switchbox"
|
||||||
|
title="Switchboxen"
|
||||||
|
description="Switchboxen verwalten und Rufnummern für die Einsatzplanung pflegen."
|
||||||
|
singularLabel="Switchbox"
|
||||||
|
emptyText="Keine Switchboxen vorhanden."
|
||||||
|
/>
|
||||||
|
</RequireRight>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/geraete/laptop"
|
||||||
|
element={
|
||||||
|
<RequireRight user={user} right="devices:read">
|
||||||
|
<DeviceCategoryPage
|
||||||
|
currentUser={user}
|
||||||
|
category="Laptop"
|
||||||
|
title="Laptops"
|
||||||
|
description="Laptops verwalten und Computernamen für die Auswertung zuordnen."
|
||||||
|
singularLabel="Laptop"
|
||||||
|
emptyText="Keine Laptops vorhanden."
|
||||||
|
/>
|
||||||
|
</RequireRight>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/einsaetze"
|
path="/einsaetze"
|
||||||
element={
|
element={
|
||||||
@ -679,7 +750,7 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/milestone/geraete"
|
path="/milestone/kameras"
|
||||||
element={
|
element={
|
||||||
<RequireRight user={user} right="administration:read">
|
<RequireRight user={user} right="administration:read">
|
||||||
<MilestoneDevicesPage
|
<MilestoneDevicesPage
|
||||||
@ -689,6 +760,31 @@ function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/milestone/geraete"
|
||||||
|
element={<Navigate to="/milestone/kameras" replace />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/milestone/kamera"
|
||||||
|
element={<Navigate to="/milestone/kameras" replace />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/milestone/router"
|
||||||
|
element={<Navigate to="/geraete/router" replace />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/milestone/switchbox"
|
||||||
|
element={<Navigate to="/geraete/switchbox" replace />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/milestone/laptop"
|
||||||
|
element={<Navigate to="/geraete/laptop" replace />}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/milestone/speicher"
|
path="/milestone/speicher"
|
||||||
element={
|
element={
|
||||||
@ -712,6 +808,15 @@ function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/milestone/backup"
|
||||||
|
element={
|
||||||
|
<RequireRight user={user} right="administration:read">
|
||||||
|
<MilestoneBackupPage />
|
||||||
|
</RequireRight>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/einsaetze/milestone-speicher"
|
path="/einsaetze/milestone-speicher"
|
||||||
element={<Navigate to="/milestone/speicher" replace />}
|
element={<Navigate to="/milestone/speicher" replace />}
|
||||||
@ -719,7 +824,7 @@ function App() {
|
|||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/einsaetze/milestone-devices"
|
path="/einsaetze/milestone-devices"
|
||||||
element={<Navigate to="/milestone/geraete" replace />}
|
element={<Navigate to="/milestone/kameras" replace />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { Transition } from '@headlessui/react'
|
|||||||
import SearchOverlay from './SearchOverlay'
|
import SearchOverlay from './SearchOverlay'
|
||||||
import Sidebar from './Sidebar'
|
import Sidebar from './Sidebar'
|
||||||
import Topbar from './Topbar'
|
import Topbar from './Topbar'
|
||||||
|
import PinnedChatDock from './PinnedChatDock'
|
||||||
import type { User } from './types'
|
import type { User } from './types'
|
||||||
|
|
||||||
type AppLayoutProps = {
|
type AppLayoutProps = {
|
||||||
@ -87,6 +88,8 @@ export default function AppLayout({
|
|||||||
onClose={closeSearchOverlay}
|
onClose={closeSearchOverlay}
|
||||||
/>
|
/>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
|
<PinnedChatDock userId={user.id} />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,9 +3,11 @@
|
|||||||
import { useMemo, useState, type FormEvent } from 'react'
|
import { useMemo, useState, type FormEvent } from 'react'
|
||||||
import {
|
import {
|
||||||
ChatBubbleBottomCenterTextIcon,
|
ChatBubbleBottomCenterTextIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
CursorArrowRaysIcon,
|
CursorArrowRaysIcon,
|
||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
|
PaperAirplaneIcon,
|
||||||
ShieldCheckIcon,
|
ShieldCheckIcon,
|
||||||
} from '@heroicons/react/24/outline'
|
} from '@heroicons/react/24/outline'
|
||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
@ -52,6 +54,19 @@ export default function Feedback() {
|
|||||||
[logSnapshot],
|
[logSnapshot],
|
||||||
)
|
)
|
||||||
const actionCount = logSnapshot.length - errorCount
|
const actionCount = logSnapshot.length - errorCount
|
||||||
|
const sortedLogSnapshot = useMemo(
|
||||||
|
() =>
|
||||||
|
[...logSnapshot].sort((left, right) => {
|
||||||
|
const leftTime = Date.parse(left.timestamp)
|
||||||
|
const rightTime = Date.parse(right.timestamp)
|
||||||
|
|
||||||
|
return (
|
||||||
|
(Number.isNaN(rightTime) ? 0 : rightTime) -
|
||||||
|
(Number.isNaN(leftTime) ? 0 : leftTime)
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
[logSnapshot],
|
||||||
|
)
|
||||||
|
|
||||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@ -74,7 +89,7 @@ export default function Feedback() {
|
|||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
message: trimmedMessage,
|
message: trimmedMessage,
|
||||||
clientLog: logSnapshot,
|
clientLog: sortedLogSnapshot,
|
||||||
client,
|
client,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@ -181,21 +196,24 @@ export default function Feedback() {
|
|||||||
type="submit"
|
type="submit"
|
||||||
isLoading={isSubmitting}
|
isLoading={isSubmitting}
|
||||||
disabled={!message.trim() || isSubmitting}
|
disabled={!message.trim() || isSubmitting}
|
||||||
|
leadingIcon={
|
||||||
|
<PaperAirplaneIcon aria-hidden="true" className="size-4" />
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Feedback senden
|
Feedback senden
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="mt-6 overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900">
|
<details className="group mt-6 overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-gray-100 px-5 py-4 dark:border-white/10">
|
<summary className="flex cursor-pointer list-none flex-wrap items-center justify-between gap-3 px-5 py-4 transition hover:bg-gray-50 marker:hidden dark:hover:bg-white/5 [&::-webkit-details-marker]:hidden">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
|
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
Angehängtes Protokoll
|
Angehängtes Protokoll
|
||||||
</h2>
|
</h2>
|
||||||
<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">
|
||||||
Deine letzten Aktionen und Fehler in dieser Sitzung – hilft
|
Deine letzten Schaltflächen-Klicks, Seitenaufrufe und
|
||||||
uns, Probleme nachzuvollziehen.
|
API-Fehler in dieser Sitzung.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@ -213,23 +231,30 @@ export default function Feedback() {
|
|||||||
<ExclamationTriangleIcon className="size-3.5" />
|
<ExclamationTriangleIcon className="size-3.5" />
|
||||||
{errorCount} Fehler
|
{errorCount} Fehler
|
||||||
</span>
|
</span>
|
||||||
|
<span className="inline-flex size-8 items-center justify-center rounded-full bg-gray-100 text-gray-500 transition group-open:rotate-180 dark:bg-white/10 dark:text-gray-400">
|
||||||
|
<ChevronDownIcon aria-hidden="true" className="size-4" />
|
||||||
|
<span className="sr-only">
|
||||||
|
Protokoll anzeigen oder ausblenden
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</summary>
|
||||||
|
|
||||||
<div className="max-h-80 overflow-y-auto p-2">
|
<div className="max-h-80 overflow-y-auto border-t border-gray-100 p-2 dark:border-white/10">
|
||||||
<ActivityLogView
|
<ActivityLogView
|
||||||
entries={logSnapshot}
|
entries={sortedLogSnapshot}
|
||||||
showPath={false}
|
showPath={false}
|
||||||
emptyLabel="Bisher wurden keine Aktionen oder Fehler aufgezeichnet."
|
emptyLabel="Bisher wurden keine Schaltflächen-Klicks, Seitenaufrufe oder API-Fehler aufgezeichnet."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 border-t border-gray-100 px-5 py-3 text-xs text-gray-500 dark:border-white/10 dark:text-gray-400">
|
<div className="flex items-center gap-2 border-t border-gray-100 px-5 py-3 text-xs text-gray-500 dark:border-white/10 dark:text-gray-400">
|
||||||
<ShieldCheckIcon className="size-4 shrink-0 text-gray-400" />
|
<ShieldCheckIcon className="size-4 shrink-0 text-gray-400" />
|
||||||
Es werden keine Passwörter oder Eingabeinhalte erfasst – nur,
|
Es werden keine Passwörter oder Eingabeinhalte erfasst – nur,
|
||||||
welche Elemente du benutzt hast, und technische Fehlermeldungen.
|
welche Schaltflächen du benutzt hast, Seitenaufrufe und
|
||||||
|
API-Fehler.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</details>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -404,7 +404,7 @@ export default function Journal({
|
|||||||
<Badge
|
<Badge
|
||||||
tone="indigo"
|
tone="indigo"
|
||||||
variant="border"
|
variant="border"
|
||||||
shape="rounded"
|
shape="circle"
|
||||||
size="small"
|
size="small"
|
||||||
>
|
>
|
||||||
{changes.length}{' '}
|
{changes.length}{' '}
|
||||||
|
|||||||
@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
BellIcon,
|
|
||||||
EnvelopeOpenIcon,
|
EnvelopeOpenIcon,
|
||||||
FaceSmileIcon,
|
FaceSmileIcon,
|
||||||
} from '@heroicons/react/20/solid'
|
} from '@heroicons/react/20/solid'
|
||||||
|
import { BellIcon as BellOutlineIcon } from '@heroicons/react/24/outline'
|
||||||
|
import { BellIcon as BellSolidIcon } from '@heroicons/react/24/solid'
|
||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
import Tabs, { type TabItem } from './Tabs'
|
import Tabs, { type TabItem } from './Tabs'
|
||||||
import type { Notification } from './types'
|
import type { Notification } from './types'
|
||||||
@ -115,6 +116,8 @@ export default function NotificationCenter({
|
|||||||
setOpen(false)
|
setOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BellIcon = open ? BellSolidIcon : BellOutlineIcon
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="relative">
|
<div ref={containerRef} className="relative">
|
||||||
<button
|
<button
|
||||||
@ -227,4 +230,4 @@ export default function NotificationCenter({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
1785
frontend/src/components/PinnedChatDock.tsx
Normal file
1785
frontend/src/components/PinnedChatDock.tsx
Normal file
File diff suppressed because it is too large
Load Diff
48
frontend/src/components/Scrollbar.tsx
Normal file
48
frontend/src/components/Scrollbar.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { forwardRef, type HTMLAttributes } from 'react'
|
||||||
|
|
||||||
|
type ScrollbarOrientation = 'vertical' | 'horizontal' | 'both'
|
||||||
|
|
||||||
|
type ScrollbarProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
|
orientation?: ScrollbarOrientation
|
||||||
|
}
|
||||||
|
|
||||||
|
function classNames(...classes: Array<string | false | null | undefined>) {
|
||||||
|
return classes.filter(Boolean).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOverflowClassName(orientation: ScrollbarOrientation) {
|
||||||
|
if (orientation === 'horizontal') {
|
||||||
|
return 'overflow-x-auto overflow-y-hidden'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orientation === 'both') {
|
||||||
|
return 'overflow-auto'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'overflow-y-auto overflow-x-hidden'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const scrollbarClassName =
|
||||||
|
'[scrollbar-color:rgba(148,163,184,0.55)_transparent] [scrollbar-gutter:stable] [scrollbar-width:thin] [&::-webkit-scrollbar]:h-2 [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-slate-500/40 hover:[&::-webkit-scrollbar-thumb]:bg-slate-400/70 dark:[scrollbar-color:rgba(100,116,139,0.7)_transparent] dark:[&::-webkit-scrollbar-thumb]:bg-slate-600/70 dark:hover:[&::-webkit-scrollbar-thumb]:bg-slate-500'
|
||||||
|
|
||||||
|
const Scrollbar = forwardRef<HTMLDivElement, ScrollbarProps>(function Scrollbar(
|
||||||
|
{ className, orientation = 'vertical', children, ...props },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={classNames(
|
||||||
|
'min-h-0 min-w-0',
|
||||||
|
getOverflowClassName(orientation),
|
||||||
|
scrollbarClassName,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default Scrollbar
|
||||||
@ -54,6 +54,8 @@ function getGroupIcon(groupId: string) {
|
|||||||
return ClipboardDocumentListIcon
|
return ClipboardDocumentListIcon
|
||||||
case 'operation-journals':
|
case 'operation-journals':
|
||||||
return ChatBubbleLeftEllipsisIcon
|
return ChatBubbleLeftEllipsisIcon
|
||||||
|
case 'public-chat-messages':
|
||||||
|
return ChatBubbleLeftEllipsisIcon
|
||||||
case 'devices':
|
case 'devices':
|
||||||
return CircleStackIcon
|
return CircleStackIcon
|
||||||
case 'device-journals':
|
case 'device-journals':
|
||||||
@ -285,15 +287,10 @@ export default function SearchOverlay({
|
|||||||
search.length >= 2 && !isLoading && !error && !hasResults
|
search.length >= 2 && !isLoading && !error && !hasResults
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-full overflow-y-auto bg-gradient-to-br from-slate-50 via-white to-indigo-50/70 [scrollbar-gutter:stable] dark:from-gray-950 dark:via-gray-950 dark:to-indigo-950/30">
|
<div className="relative h-full overflow-y-auto bg-white [scrollbar-gutter:stable] dark:bg-gray-950">
|
||||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
|
||||||
<div className="absolute -top-32 right-0 size-96 rounded-full bg-indigo-200/30 blur-3xl dark:bg-indigo-500/10" />
|
|
||||||
<div className="absolute bottom-0 -left-32 size-80 rounded-full bg-sky-100/50 blur-3xl dark:bg-sky-500/5" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative mx-auto max-w-7xl px-4 py-6 sm:px-6 sm:py-8 lg:px-8">
|
<div className="relative mx-auto max-w-7xl px-4 py-6 sm:px-6 sm:py-8 lg:px-8">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-8 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">
|
<div className="mb-8 overflow-hidden rounded-3xl border border-white/80 bg-white/85 p-5 shadow-sm ring-1 ring-slate-900/5 backdrop-blur-xl sm:p-6 dark:border-white/10 dark:bg-gray-900/80 dark:ring-white/5">
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex items-center gap-x-4">
|
<div className="flex items-center gap-x-4">
|
||||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br from-indigo-500 to-violet-600 text-white shadow-lg shadow-indigo-500/20 ring-1 ring-white/20">
|
<div className="flex size-12 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br from-indigo-500 to-violet-600 text-white shadow-lg shadow-indigo-500/20 ring-1 ring-white/20">
|
||||||
|
|||||||
@ -7,23 +7,26 @@ import {
|
|||||||
TransitionChild,
|
TransitionChild,
|
||||||
} from '@headlessui/react'
|
} from '@headlessui/react'
|
||||||
import {
|
import {
|
||||||
|
ArchiveBoxIcon,
|
||||||
CalendarIcon,
|
CalendarIcon,
|
||||||
ChartPieIcon,
|
ChartPieIcon,
|
||||||
ChatBubbleLeftRightIcon,
|
ChatBubbleLeftRightIcon,
|
||||||
Cog6ToothIcon,
|
Cog6ToothIcon,
|
||||||
ComputerDesktopIcon,
|
ComputerDesktopIcon,
|
||||||
CpuChipIcon,
|
|
||||||
DocumentDuplicateIcon,
|
DocumentDuplicateIcon,
|
||||||
ServerStackIcon,
|
ServerStackIcon,
|
||||||
ShieldCheckIcon,
|
ShieldCheckIcon,
|
||||||
UserGroupIcon,
|
UserGroupIcon,
|
||||||
VideoCameraIcon,
|
VideoCameraIcon,
|
||||||
|
ArrowsRightLeftIcon,
|
||||||
HomeIcon,
|
HomeIcon,
|
||||||
|
WifiIcon,
|
||||||
XMarkIcon,
|
XMarkIcon,
|
||||||
} from '@heroicons/react/24/outline'
|
} from '@heroicons/react/24/outline'
|
||||||
import type { ComponentType, SVGProps } from 'react'
|
import type { ComponentType, SVGProps } from 'react'
|
||||||
import { NavLink, useLocation } from 'react-router'
|
import { NavLink, useLocation } from 'react-router'
|
||||||
import { hasRight } from './permissions'
|
import { hasRight } from './permissions'
|
||||||
|
import Scrollbar from './Scrollbar'
|
||||||
import type { User } from './types'
|
import type { User } from './types'
|
||||||
|
|
||||||
type NavigationItem = {
|
type NavigationItem = {
|
||||||
@ -39,6 +42,26 @@ type NavigationSection = {
|
|||||||
items: NavigationItem[]
|
items: NavigationItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MilestoneDiamondIcon(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x="6.25"
|
||||||
|
y="6.25"
|
||||||
|
width="11.5"
|
||||||
|
height="11.5"
|
||||||
|
transform="rotate(45 12 12)"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const navigationSections: NavigationSection[] = [
|
const navigationSections: NavigationSection[] = [
|
||||||
{
|
{
|
||||||
items: [
|
items: [
|
||||||
@ -57,6 +80,32 @@ const navigationSections: NavigationSection[] = [
|
|||||||
href: '/geraete',
|
href: '/geraete',
|
||||||
icon: ComputerDesktopIcon,
|
icon: ComputerDesktopIcon,
|
||||||
right: 'devices:read',
|
right: 'devices:read',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
name: 'Kamera',
|
||||||
|
href: '/geraete/kameras',
|
||||||
|
icon: VideoCameraIcon,
|
||||||
|
right: 'devices:read',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Router',
|
||||||
|
href: '/geraete/router',
|
||||||
|
icon: WifiIcon,
|
||||||
|
right: 'devices:read',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Switchbox',
|
||||||
|
href: '/geraete/switchbox',
|
||||||
|
icon: ArrowsRightLeftIcon,
|
||||||
|
right: 'devices:read',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Laptop',
|
||||||
|
href: '/geraete/laptop',
|
||||||
|
icon: ComputerDesktopIcon,
|
||||||
|
right: 'devices:read',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Einsätze',
|
name: 'Einsätze',
|
||||||
@ -67,21 +116,21 @@ const navigationSections: NavigationSection[] = [
|
|||||||
{
|
{
|
||||||
name: 'Milestone',
|
name: 'Milestone',
|
||||||
href: '/milestone',
|
href: '/milestone',
|
||||||
icon: ServerStackIcon,
|
icon: MilestoneDiamondIcon,
|
||||||
right: 'administration:read',
|
right: 'administration:read',
|
||||||
children: [
|
children: [
|
||||||
{
|
|
||||||
name: 'Geräte',
|
|
||||||
href: '/milestone/geraete',
|
|
||||||
icon: CpuChipIcon,
|
|
||||||
right: 'administration:read',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'Speicher',
|
name: 'Speicher',
|
||||||
href: '/milestone/speicher',
|
href: '/milestone/speicher',
|
||||||
icon: ServerStackIcon,
|
icon: ServerStackIcon,
|
||||||
right: 'administration:read',
|
right: 'administration:read',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Backup',
|
||||||
|
href: '/milestone/backup',
|
||||||
|
icon: ArchiveBoxIcon,
|
||||||
|
right: 'administration:read',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Rollen',
|
name: 'Rollen',
|
||||||
href: '/milestone/rollen',
|
href: '/milestone/rollen',
|
||||||
@ -207,30 +256,46 @@ function SidebarContent({
|
|||||||
setSidebarOpen(false)
|
setSidebarOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderNavigationItem(item: NavigationItem) {
|
function navigationLinkClassName(active: boolean, isSubItem = false) {
|
||||||
|
return classNames(
|
||||||
|
active
|
||||||
|
? 'bg-white/10 text-white shadow-sm ring-1 ring-white/10'
|
||||||
|
: 'text-slate-300 hover:bg-white/5 hover:text-white',
|
||||||
|
isSubItem
|
||||||
|
? 'group flex items-center gap-x-2.5 rounded-lg px-2.5 py-2 text-sm/6 font-semibold transition'
|
||||||
|
: 'group flex items-center gap-x-3 rounded-xl px-3 py-2.5 text-sm/6 font-semibold transition',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigationIconClassName(active: boolean, isSubItem = false) {
|
||||||
|
return classNames(
|
||||||
|
active
|
||||||
|
? isSubItem
|
||||||
|
? 'text-indigo-100'
|
||||||
|
: 'text-white'
|
||||||
|
: 'text-slate-300 group-hover:text-white',
|
||||||
|
'size-5 shrink-0 stroke-[1.8] transition',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNavigationItem(item: NavigationItem, depth = 0) {
|
||||||
const active = itemIsActive(item, location.pathname)
|
const active = itemIsActive(item, location.pathname)
|
||||||
const children = item.children ?? []
|
const children = item.children ?? []
|
||||||
const showChildren = children.length > 0
|
const showChildren = children.length > 0
|
||||||
|
const isSubItem = depth > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={item.name}>
|
<li key={item.name}>
|
||||||
<NavLink
|
<NavLink
|
||||||
to={item.href}
|
to={item.href}
|
||||||
onClick={handleNavigate}
|
onClick={handleNavigate}
|
||||||
className={classNames(
|
className={navigationLinkClassName(active, isSubItem)}
|
||||||
active
|
|
||||||
? 'bg-white/10 text-white shadow-sm ring-1 ring-white/10'
|
|
||||||
: 'text-slate-400 hover:bg-white/5 hover:text-white',
|
|
||||||
'group flex items-center gap-x-3 rounded-xl px-3 py-2.5 text-sm/6 font-semibold transition',
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<item.icon
|
<item.icon
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={classNames(
|
className={classNames(
|
||||||
active
|
navigationIconClassName(active, isSubItem),
|
||||||
? 'text-white'
|
item.href === '/chat' && '-mr-0.5',
|
||||||
: 'text-slate-400 group-hover:text-white',
|
|
||||||
'size-5 shrink-0 transition',
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<span className="truncate">{item.name}</span>
|
<span className="truncate">{item.name}</span>
|
||||||
@ -244,37 +309,12 @@ function SidebarContent({
|
|||||||
{showChildren && (
|
{showChildren && (
|
||||||
<ul
|
<ul
|
||||||
role="list"
|
role="list"
|
||||||
className="ml-[1.35rem] mt-1 space-y-1 border-l border-white/10 pl-3"
|
className={classNames(
|
||||||
|
'mt-1 space-y-1 border-l border-white/10 pl-3',
|
||||||
|
depth === 0 ? 'ml-[1.35rem]' : 'ml-3',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{children.map((child) => {
|
{children.map((child) => renderNavigationItem(child, depth + 1))}
|
||||||
const childActive = itemIsActive(child, location.pathname)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li key={child.name}>
|
|
||||||
<NavLink
|
|
||||||
to={child.href}
|
|
||||||
onClick={handleNavigate}
|
|
||||||
className={classNames(
|
|
||||||
childActive
|
|
||||||
? 'bg-indigo-500/10 text-indigo-200 ring-1 ring-indigo-400/20'
|
|
||||||
: 'text-slate-400 hover:bg-white/5 hover:text-white',
|
|
||||||
'group flex items-center gap-x-2 rounded-lg px-2.5 py-2 text-sm/6 font-medium transition',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<child.icon
|
|
||||||
aria-hidden="true"
|
|
||||||
className={classNames(
|
|
||||||
childActive
|
|
||||||
? 'text-indigo-200'
|
|
||||||
: 'text-slate-500 group-hover:text-white',
|
|
||||||
'size-4 shrink-0 transition',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span className="truncate">{child.name}</span>
|
|
||||||
</NavLink>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
@ -295,46 +335,40 @@ function SidebarContent({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex flex-1 flex-col">
|
<nav className="flex min-h-0 flex-1 flex-col">
|
||||||
<ul role="list" className="flex flex-1 flex-col gap-y-6">
|
<Scrollbar className="-mx-2 flex-1 px-2 pb-4">
|
||||||
<li>
|
<div className="space-y-6">
|
||||||
<div className="space-y-6">
|
{visibleNavigationSections.map((section, sectionIndex) => (
|
||||||
{visibleNavigationSections.map((section, sectionIndex) => (
|
<section key={section.name ?? `section-${sectionIndex}`}>
|
||||||
<section key={section.name ?? `section-${sectionIndex}`}>
|
{section.name && (
|
||||||
{section.name && (
|
<h2 className="mb-2 px-3 text-[0.6875rem]/6 font-semibold uppercase tracking-wider text-slate-500">
|
||||||
<h2 className="mb-2 px-3 text-[0.6875rem]/6 font-semibold uppercase tracking-wider text-slate-500">
|
{section.name}
|
||||||
{section.name}
|
</h2>
|
||||||
</h2>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
<ul role="list" className="space-y-1">
|
<ul role="list" className="space-y-1">
|
||||||
{section.items.map(renderNavigationItem)}
|
{section.items.map(renderNavigationItem)}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</Scrollbar>
|
||||||
|
|
||||||
<li className="mt-auto border-t border-white/10 pt-5">
|
<div className="shrink-0 border-t border-white/10 pt-5">
|
||||||
<h2 className="mb-2 px-3 text-[0.6875rem]/6 font-semibold uppercase tracking-wider text-slate-500">
|
<h2 className="mb-2 px-3 text-[0.6875rem]/6 font-semibold uppercase tracking-wider text-slate-500">
|
||||||
Hilfe & System
|
System
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<ul role="list" className="space-y-1">
|
<ul role="list" className="space-y-1">
|
||||||
<li>
|
<li>
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/feedback"
|
to="/feedback"
|
||||||
onClick={handleNavigate}
|
onClick={handleNavigate}
|
||||||
className={classNames(
|
className={navigationLinkClassName(isFeedbackActive)}
|
||||||
isFeedbackActive
|
|
||||||
? 'bg-white/10 text-white ring-1 ring-white/10'
|
|
||||||
: 'text-slate-400 hover:bg-white/5 hover:text-white',
|
|
||||||
'group flex items-center gap-x-3 rounded-xl px-3 py-2.5 text-sm/6 font-semibold transition',
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<ChatBubbleLeftRightIcon
|
<ChatBubbleLeftRightIcon
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="size-5 shrink-0"
|
className={navigationIconClassName(isFeedbackActive)}
|
||||||
/>
|
/>
|
||||||
Feedback
|
Feedback
|
||||||
</NavLink>
|
</NavLink>
|
||||||
@ -345,16 +379,11 @@ function SidebarContent({
|
|||||||
<NavLink
|
<NavLink
|
||||||
to="/einstellungen/konto"
|
to="/einstellungen/konto"
|
||||||
onClick={handleNavigate}
|
onClick={handleNavigate}
|
||||||
className={classNames(
|
className={navigationLinkClassName(isSettingsActive)}
|
||||||
isSettingsActive
|
|
||||||
? 'bg-white/10 text-white ring-1 ring-white/10'
|
|
||||||
: 'text-slate-400 hover:bg-white/5 hover:text-white',
|
|
||||||
'group flex items-center gap-x-3 rounded-xl px-3 py-2.5 text-sm/6 font-semibold transition',
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Cog6ToothIcon
|
<Cog6ToothIcon
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="size-5 shrink-0"
|
className={navigationIconClassName(isSettingsActive)}
|
||||||
/>
|
/>
|
||||||
Einstellungen
|
Einstellungen
|
||||||
</NavLink>
|
</NavLink>
|
||||||
@ -366,24 +395,18 @@ function SidebarContent({
|
|||||||
<NavLink
|
<NavLink
|
||||||
to="/administration/benutzer"
|
to="/administration/benutzer"
|
||||||
onClick={handleNavigate}
|
onClick={handleNavigate}
|
||||||
className={classNames(
|
className={navigationLinkClassName(isAdministrationActive)}
|
||||||
isAdministrationActive
|
|
||||||
? 'bg-white/10 text-white ring-1 ring-white/10'
|
|
||||||
: 'text-slate-400 hover:bg-white/5 hover:text-white',
|
|
||||||
'group flex items-center gap-x-3 rounded-xl px-3 py-2.5 text-sm/6 font-semibold transition',
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<ShieldCheckIcon
|
<ShieldCheckIcon
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="size-5 shrink-0"
|
className={navigationIconClassName(isAdministrationActive)}
|
||||||
/>
|
/>
|
||||||
Administration
|
Administration
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</div>
|
||||||
</ul>
|
|
||||||
</nav>
|
</nav>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@ -426,7 +449,7 @@ export default function Sidebar({
|
|||||||
</div>
|
</div>
|
||||||
</TransitionChild>
|
</TransitionChild>
|
||||||
|
|
||||||
<div className="relative flex grow flex-col gap-y-5 overflow-y-auto bg-slate-950 px-5 pb-4 ring-1 ring-white/10 before:pointer-events-none before:absolute before:inset-0 before:bg-[radial-gradient(circle_at_top_left,rgba(99,102,241,0.16),transparent_32rem)]">
|
<div className="relative flex grow flex-col gap-y-5 overflow-hidden bg-slate-950 px-5 pb-4 ring-1 ring-white/10 before:pointer-events-none before:absolute before:inset-0 before:bg-[radial-gradient(circle_at_top_left,rgba(99,102,241,0.16),transparent_32rem)]">
|
||||||
<SidebarContent
|
<SidebarContent
|
||||||
user={user}
|
user={user}
|
||||||
setSidebarOpen={setSidebarOpen}
|
setSidebarOpen={setSidebarOpen}
|
||||||
@ -439,7 +462,7 @@ export default function Sidebar({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<div className="hidden bg-slate-950 ring-1 ring-white/10 lg:fixed lg:inset-y-0 lg:z-[2000] lg:flex lg:w-72 lg:flex-col">
|
<div className="hidden bg-slate-950 ring-1 ring-white/10 lg:fixed lg:inset-y-0 lg:z-[2000] lg:flex lg:w-72 lg:flex-col">
|
||||||
<div className="relative flex grow flex-col gap-y-5 overflow-y-auto px-5 pb-4 before:pointer-events-none before:absolute before:inset-0 before:bg-[radial-gradient(circle_at_top_left,rgba(99,102,241,0.14),transparent_34rem)]">
|
<div className="relative flex grow flex-col gap-y-5 overflow-hidden px-5 pb-4 before:pointer-events-none before:absolute before:inset-0 before:bg-[radial-gradient(circle_at_top_left,rgba(99,102,241,0.14),transparent_34rem)]">
|
||||||
<SidebarContent
|
<SidebarContent
|
||||||
user={user}
|
user={user}
|
||||||
setSidebarOpen={setSidebarOpen}
|
setSidebarOpen={setSidebarOpen}
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
Fragment,
|
Fragment,
|
||||||
|
type CSSProperties,
|
||||||
type KeyboardEvent,
|
type KeyboardEvent,
|
||||||
type MouseEvent,
|
type MouseEvent,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
@ -11,6 +12,7 @@ import {
|
|||||||
} from 'react'
|
} from 'react'
|
||||||
import { ChevronDownIcon } from '@heroicons/react/20/solid'
|
import { ChevronDownIcon } from '@heroicons/react/20/solid'
|
||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
|
import Scrollbar, { scrollbarClassName } from './Scrollbar'
|
||||||
|
|
||||||
export type TableVariant =
|
export type TableVariant =
|
||||||
| 'simple'
|
| 'simple'
|
||||||
@ -48,6 +50,7 @@ export type TableColumn<T> = {
|
|||||||
sortValue?: (row: T) => TableSortValue
|
sortValue?: (row: T) => TableSortValue
|
||||||
hideOnMobile?: 'sm' | 'md' | 'lg' | 'xl'
|
hideOnMobile?: 'sm' | 'md' | 'lg' | 'xl'
|
||||||
align?: 'left' | 'center' | 'right'
|
align?: 'left' | 'center' | 'right'
|
||||||
|
width?: string
|
||||||
className?: string
|
className?: string
|
||||||
headerClassName?: string
|
headerClassName?: string
|
||||||
}
|
}
|
||||||
@ -103,6 +106,8 @@ type TablesProps<T> = {
|
|||||||
|
|
||||||
hideHeadings?: boolean
|
hideHeadings?: boolean
|
||||||
stickyTopClassName?: string
|
stickyTopClassName?: string
|
||||||
|
scrollRows?: boolean
|
||||||
|
rowScrollClassName?: string
|
||||||
|
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
@ -220,11 +225,15 @@ export default function Tables<T>({
|
|||||||
|
|
||||||
hideHeadings = false,
|
hideHeadings = false,
|
||||||
stickyTopClassName = 'top-0',
|
stickyTopClassName = 'top-0',
|
||||||
|
scrollRows = false,
|
||||||
|
rowScrollClassName,
|
||||||
|
|
||||||
className,
|
className,
|
||||||
}: TablesProps<T>) {
|
}: TablesProps<T>) {
|
||||||
const headerCheckboxRef = useRef<HTMLInputElement>(null)
|
const headerCheckboxRef = useRef<HTMLInputElement>(null)
|
||||||
|
const bodyScrollRef = useRef<HTMLDivElement>(null)
|
||||||
const [internalSelectedRowIds, setInternalSelectedRowIds] = useState<Array<string | number>>([])
|
const [internalSelectedRowIds, setInternalSelectedRowIds] = useState<Array<string | number>>([])
|
||||||
|
const [hasVerticalScrollbar, setHasVerticalScrollbar] = useState(false)
|
||||||
|
|
||||||
const rowGroups =
|
const rowGroups =
|
||||||
groups && groups.length > 0
|
groups && groups.length > 0
|
||||||
@ -254,16 +263,25 @@ export default function Tables<T>({
|
|||||||
const isUppercase = variant === 'uppercase'
|
const isUppercase = variant === 'uppercase'
|
||||||
const isStackedMobile = variant === 'stacked-mobile'
|
const isStackedMobile = variant === 'stacked-mobile'
|
||||||
const isStickyHeader = variant === 'sticky-header'
|
const isStickyHeader = variant === 'sticky-header'
|
||||||
|
const hasStickyHeader = isStickyHeader || scrollRows
|
||||||
|
const hasColumnWidths = columns.some((column) => column.width)
|
||||||
const isVerticalLines = variant === 'vertical-lines'
|
const isVerticalLines = variant === 'vertical-lines'
|
||||||
const isCondensed = variant === 'condensed'
|
const isCondensed = variant === 'condensed'
|
||||||
const isSortable = variant === 'sortable'
|
const isSortable = variant === 'sortable'
|
||||||
const isGrouped = variant === 'grouped' || Boolean(groups?.length)
|
const isGrouped = variant === 'grouped' || Boolean(groups?.length)
|
||||||
const hasSummary = variant === 'summary' || Boolean(summaryRows?.length)
|
const hasSummary = variant === 'summary' || Boolean(summaryRows?.length)
|
||||||
|
const rowDividerClassName = 'border-b border-gray-200 dark:border-white/10'
|
||||||
|
const scrollGutterDividerClassName = 'border-r border-gray-200 dark:border-white/10'
|
||||||
|
const scrollGutterColumnWidth = '0.75rem'
|
||||||
|
const scrollContainerStyle = {
|
||||||
|
scrollbarGutter: hasVerticalScrollbar ? 'stable' : 'auto',
|
||||||
|
} as CSSProperties
|
||||||
|
|
||||||
const totalColumns =
|
const totalColumns =
|
||||||
columns.length +
|
columns.length +
|
||||||
(selectable ? 1 : 0) +
|
(selectable ? 1 : 0) +
|
||||||
(rowActions ? 1 : 0)
|
(rowActions ? 1 : 0) +
|
||||||
|
(scrollRows ? 1 : 0)
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!headerCheckboxRef.current) {
|
if (!headerCheckboxRef.current) {
|
||||||
@ -273,6 +291,62 @@ export default function Tables<T>({
|
|||||||
headerCheckboxRef.current.indeterminate = indeterminate
|
headerCheckboxRef.current.indeterminate = indeterminate
|
||||||
}, [indeterminate])
|
}, [indeterminate])
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!scrollRows) {
|
||||||
|
setHasVerticalScrollbar(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollElement = bodyScrollRef.current
|
||||||
|
|
||||||
|
if (!scrollElement) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let animationFrameId: number | null = null
|
||||||
|
|
||||||
|
const updateScrollbarState = () => {
|
||||||
|
if (animationFrameId !== null) {
|
||||||
|
window.cancelAnimationFrame(animationFrameId)
|
||||||
|
}
|
||||||
|
|
||||||
|
animationFrameId = window.requestAnimationFrame(() => {
|
||||||
|
const nextHasVerticalScrollbar =
|
||||||
|
scrollElement.scrollHeight > scrollElement.clientHeight + 1
|
||||||
|
|
||||||
|
setHasVerticalScrollbar((currentHasVerticalScrollbar) =>
|
||||||
|
currentHasVerticalScrollbar === nextHasVerticalScrollbar
|
||||||
|
? currentHasVerticalScrollbar
|
||||||
|
: nextHasVerticalScrollbar,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
updateScrollbarState()
|
||||||
|
|
||||||
|
const resizeObserver =
|
||||||
|
typeof ResizeObserver === 'undefined'
|
||||||
|
? null
|
||||||
|
: new ResizeObserver(updateScrollbarState)
|
||||||
|
|
||||||
|
resizeObserver?.observe(scrollElement)
|
||||||
|
|
||||||
|
if (scrollElement.firstElementChild) {
|
||||||
|
resizeObserver?.observe(scrollElement.firstElementChild)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', updateScrollbarState)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (animationFrameId !== null) {
|
||||||
|
window.cancelAnimationFrame(animationFrameId)
|
||||||
|
}
|
||||||
|
|
||||||
|
resizeObserver?.disconnect()
|
||||||
|
window.removeEventListener('resize', updateScrollbarState)
|
||||||
|
}
|
||||||
|
}, [groups, isLoading, rowScrollClassName, rows, scrollRows, summaryRows])
|
||||||
|
|
||||||
function updateSelected(nextSelectedIds: Array<string | number>) {
|
function updateSelected(nextSelectedIds: Array<string | number>) {
|
||||||
if (!selectedRowIds) {
|
if (!selectedRowIds) {
|
||||||
setInternalSelectedRowIds(nextSelectedIds)
|
setInternalSelectedRowIds(nextSelectedIds)
|
||||||
@ -315,6 +389,21 @@ export default function Tables<T>({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stickyHeaderClassName() {
|
||||||
|
if (!hasStickyHeader) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return classNames(
|
||||||
|
`sticky ${stickyTopClassName} z-10 backdrop-blur-sm backdrop-filter`,
|
||||||
|
isCard
|
||||||
|
? 'bg-gray-50/95 dark:bg-gray-800/95'
|
||||||
|
: 'bg-white/95 dark:bg-gray-900/95',
|
||||||
|
!scrollRows &&
|
||||||
|
'shadow-[inset_0_-1px_0_rgba(209,213,219,0.9)] dark:shadow-[inset_0_-1px_0_rgba(255,255,255,0.12)]',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function renderHeader() {
|
function renderHeader() {
|
||||||
if (!title && !description && !actionLabel && !headerAction) {
|
if (!title && !description && !actionLabel && !headerAction) {
|
||||||
return null
|
return null
|
||||||
@ -393,7 +482,14 @@ export default function Tables<T>({
|
|||||||
>
|
>
|
||||||
<tr className={classNames(isVerticalLines && 'divide-x divide-gray-200 dark:divide-white/10')}>
|
<tr className={classNames(isVerticalLines && 'divide-x divide-gray-200 dark:divide-white/10')}>
|
||||||
{selectable && (
|
{selectable && (
|
||||||
<th scope="col" className="relative px-7 sm:w-12 sm:px-6">
|
<th
|
||||||
|
scope="col"
|
||||||
|
className={classNames(
|
||||||
|
stickyHeaderClassName(),
|
||||||
|
!hasStickyHeader && 'relative',
|
||||||
|
'px-7 sm:w-12 sm:px-6',
|
||||||
|
)}
|
||||||
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
inputRef={headerCheckboxRef}
|
inputRef={headerCheckboxRef}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
@ -412,8 +508,7 @@ export default function Tables<T>({
|
|||||||
key={column.key}
|
key={column.key}
|
||||||
scope="col"
|
scope="col"
|
||||||
className={classNames(
|
className={classNames(
|
||||||
isStickyHeader &&
|
stickyHeaderClassName(),
|
||||||
`sticky ${stickyTopClassName} z-10 bg-white/75 backdrop-blur-sm backdrop-filter dark:bg-gray-900/75`,
|
|
||||||
isUppercase
|
isUppercase
|
||||||
? 'py-3 text-xs font-medium tracking-wide text-gray-500 uppercase dark:text-gray-400'
|
? 'py-3 text-xs font-medium tracking-wide text-gray-500 uppercase dark:text-gray-400'
|
||||||
: 'py-3.5 text-sm font-semibold text-gray-900 dark:text-white',
|
: 'py-3.5 text-sm font-semibold text-gray-900 dark:text-white',
|
||||||
@ -436,8 +531,7 @@ export default function Tables<T>({
|
|||||||
<th
|
<th
|
||||||
scope="col"
|
scope="col"
|
||||||
className={classNames(
|
className={classNames(
|
||||||
isStickyHeader &&
|
stickyHeaderClassName(),
|
||||||
`sticky ${stickyTopClassName} z-10 bg-white/75 backdrop-blur-sm backdrop-filter dark:bg-gray-900/75`,
|
|
||||||
'py-3.5 pr-4 pl-3 sm:pr-0',
|
'py-3.5 pr-4 pl-3 sm:pr-0',
|
||||||
isCard && 'sm:pr-6',
|
isCard && 'sm:pr-6',
|
||||||
)}
|
)}
|
||||||
@ -445,6 +539,14 @@ export default function Tables<T>({
|
|||||||
<span className="sr-only">Aktionen</span>
|
<span className="sr-only">Aktionen</span>
|
||||||
</th>
|
</th>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{scrollRows && (
|
||||||
|
<th
|
||||||
|
aria-hidden="true"
|
||||||
|
scope="col"
|
||||||
|
className={classNames(stickyHeaderClassName(), 'p-0')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
)
|
)
|
||||||
@ -484,7 +586,7 @@ export default function Tables<T>({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRow(row: T, rowIndex: number) {
|
function renderRow(row: T, _rowIndex: number) {
|
||||||
const rowId = getRowId(row)
|
const rowId = getRowId(row)
|
||||||
const selected = selectedSet.has(rowId)
|
const selected = selectedSet.has(rowId)
|
||||||
|
|
||||||
@ -497,6 +599,7 @@ export default function Tables<T>({
|
|||||||
onClick={(event) => handleRowClick(row, event)}
|
onClick={(event) => handleRowClick(row, event)}
|
||||||
onKeyDown={(event) => handleRowKeyDown(row, event)}
|
onKeyDown={(event) => handleRowKeyDown(row, event)}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
|
'group',
|
||||||
isStriped && 'even:bg-gray-50 dark:even:bg-gray-800/50',
|
isStriped && 'even:bg-gray-50 dark:even:bg-gray-800/50',
|
||||||
selected && 'bg-gray-50 dark:bg-gray-800/50',
|
selected && 'bg-gray-50 dark:bg-gray-800/50',
|
||||||
isVerticalLines && 'divide-x divide-gray-200 dark:divide-white/10',
|
isVerticalLines && 'divide-x divide-gray-200 dark:divide-white/10',
|
||||||
@ -504,7 +607,7 @@ export default function Tables<T>({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{selectable && (
|
{selectable && (
|
||||||
<td className="relative px-7 sm:w-12 sm:px-6">
|
<td className={classNames(rowDividerClassName, 'relative px-7 sm:w-12 sm:px-6')}>
|
||||||
{selected && (
|
{selected && (
|
||||||
<div className="absolute inset-y-0 left-0 w-0.5 bg-indigo-600 dark:bg-indigo-500" />
|
<div className="absolute inset-y-0 left-0 w-0.5 bg-indigo-600 dark:bg-indigo-500" />
|
||||||
)}
|
)}
|
||||||
@ -526,6 +629,7 @@ export default function Tables<T>({
|
|||||||
<td
|
<td
|
||||||
key={column.key}
|
key={column.key}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
|
rowDividerClassName,
|
||||||
isCondensed ? 'py-2' : isCard ? 'py-3' : isAvatarVariant(variant) ? 'py-5' : 'py-4',
|
isCondensed ? 'py-2' : isCard ? 'py-3' : isAvatarVariant(variant) ? 'py-5' : 'py-4',
|
||||||
isCondensed ? 'px-2' : 'px-3',
|
isCondensed ? 'px-2' : 'px-3',
|
||||||
isFirst && !selectable && 'pl-4 sm:pl-0',
|
isFirst && !selectable && 'pl-4 sm:pl-0',
|
||||||
@ -555,6 +659,7 @@ export default function Tables<T>({
|
|||||||
{rowActions && (
|
{rowActions && (
|
||||||
<td
|
<td
|
||||||
className={classNames(
|
className={classNames(
|
||||||
|
rowDividerClassName,
|
||||||
isCondensed ? 'py-2' : 'py-4',
|
isCondensed ? 'py-2' : 'py-4',
|
||||||
'pr-4 pl-3 text-right text-sm font-medium whitespace-nowrap sm:pr-0',
|
'pr-4 pl-3 text-right text-sm font-medium whitespace-nowrap sm:pr-0',
|
||||||
isCard && 'sm:pr-6',
|
isCard && 'sm:pr-6',
|
||||||
@ -563,6 +668,28 @@ export default function Tables<T>({
|
|||||||
{rowActions(row)}
|
{rowActions(row)}
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{scrollRows && (
|
||||||
|
<td
|
||||||
|
aria-hidden="true"
|
||||||
|
className={classNames(
|
||||||
|
rowDividerClassName,
|
||||||
|
hasVerticalScrollbar && scrollGutterDividerClassName,
|
||||||
|
'p-0',
|
||||||
|
selected
|
||||||
|
? 'bg-gray-50 dark:bg-gray-800/50'
|
||||||
|
: classNames(
|
||||||
|
isCard
|
||||||
|
? 'bg-white dark:bg-gray-800/50'
|
||||||
|
: 'bg-white dark:bg-gray-900',
|
||||||
|
onRowClick &&
|
||||||
|
(isCard
|
||||||
|
? 'group-hover:bg-gray-50 dark:group-hover:bg-gray-800/70'
|
||||||
|
: 'group-hover:bg-gray-50 dark:group-hover:bg-gray-800/70'),
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -596,7 +723,7 @@ export default function Tables<T>({
|
|||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={Math.max(totalColumns, 1)}
|
colSpan={Math.max(totalColumns, 1)}
|
||||||
className="px-4 py-16 text-center"
|
className={classNames(rowDividerClassName, 'px-4 py-16 text-center')}
|
||||||
>
|
>
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
{loadingContent ?? (
|
{loadingContent ?? (
|
||||||
@ -619,7 +746,10 @@ export default function Tables<T>({
|
|||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={Math.max(totalColumns, 1)}
|
colSpan={Math.max(totalColumns, 1)}
|
||||||
className="px-4 py-10 text-center text-sm text-gray-500 dark:text-gray-400"
|
className={classNames(
|
||||||
|
rowDividerClassName,
|
||||||
|
'px-4 py-10 text-center text-sm text-gray-500 dark:text-gray-400',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{emptyText}
|
{emptyText}
|
||||||
</td>
|
</td>
|
||||||
@ -671,41 +801,135 @@ export default function Tables<T>({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTable() {
|
function getFallbackColumnWidth() {
|
||||||
|
const flexibleColumnCount = columns.filter((column) => !column.width).length
|
||||||
|
|
||||||
|
if (flexibleColumnCount === 0) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const reservedWidth = columns
|
||||||
|
.filter((column) => column.width?.endsWith('%'))
|
||||||
|
.reduce((sum, column) => sum + Number.parseFloat(column.width ?? '0'), 0)
|
||||||
|
const remainingWidth = Math.max(100 - reservedWidth, 0)
|
||||||
|
|
||||||
|
return `${remainingWidth / flexibleColumnCount}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTableColGroup() {
|
||||||
|
if (!hasStickyHeader && !hasColumnWidths) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackColumnWidth = getFallbackColumnWidth()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<table
|
<colgroup>
|
||||||
|
{selectable && <col style={{ width: '3rem' }} />}
|
||||||
|
|
||||||
|
{columns.map((column) => (
|
||||||
|
<col
|
||||||
|
key={column.key}
|
||||||
|
className={getHiddenClass(column.hideOnMobile)}
|
||||||
|
style={{ width: column.width ?? fallbackColumnWidth }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{rowActions && <col style={{ width: '1%' }} />}
|
||||||
|
{scrollRows && <col style={{ width: scrollGutterColumnWidth }} />}
|
||||||
|
</colgroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function tableClassName() {
|
||||||
|
if (hasStickyHeader || hasColumnWidths) {
|
||||||
|
return classNames(
|
||||||
|
'w-full min-w-full table-fixed border-separate border-spacing-0',
|
||||||
|
isBorder && 'divide-y divide-gray-300 dark:divide-white/15',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return classNames(
|
||||||
|
'relative min-w-full divide-y divide-gray-300 dark:divide-white/15',
|
||||||
|
isBorder && 'divide-y divide-gray-300 dark:divide-white/15',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTableBody() {
|
||||||
|
return (
|
||||||
|
<tbody
|
||||||
className={classNames(
|
className={classNames(
|
||||||
isStickyHeader
|
'[&>tr:last-child>td]:border-b-0',
|
||||||
? 'min-w-full border-separate border-spacing-0'
|
isCard
|
||||||
: 'relative min-w-full divide-y divide-gray-300 dark:divide-white/15',
|
? 'bg-white dark:bg-gray-800/50'
|
||||||
isBorder && 'divide-y divide-gray-300 dark:divide-white/15',
|
: 'bg-white dark:bg-gray-900',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
renderLoadingRow()
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{renderEmptyRow()}
|
||||||
|
{renderGroupedRows()}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable() {
|
||||||
|
return (
|
||||||
|
<table className={tableClassName()}>
|
||||||
|
{renderTableColGroup()}
|
||||||
{renderTableHead()}
|
{renderTableHead()}
|
||||||
|
{renderTableBody()}
|
||||||
<tbody
|
|
||||||
className={classNames(
|
|
||||||
!isStickyHeader && 'divide-y divide-gray-200 dark:divide-white/10',
|
|
||||||
isCard
|
|
||||||
? 'bg-white dark:bg-gray-800/50'
|
|
||||||
: 'bg-white dark:bg-gray-900',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
renderLoadingRow()
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{renderEmptyRow()}
|
|
||||||
{renderGroupedRows()}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
|
|
||||||
{renderSummaryRows()}
|
{renderSummaryRows()}
|
||||||
</table>
|
</table>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderScrollableTable() {
|
||||||
|
if (!scrollRows) {
|
||||||
|
return renderTable()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-w-full">
|
||||||
|
<div
|
||||||
|
style={scrollContainerStyle}
|
||||||
|
className={classNames(
|
||||||
|
'min-w-0 overflow-x-hidden overflow-y-auto border-b border-gray-200 dark:border-white/10',
|
||||||
|
isCard
|
||||||
|
? 'bg-gray-50 dark:bg-gray-800/75'
|
||||||
|
: 'bg-white dark:bg-gray-900',
|
||||||
|
scrollbarClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<table className={tableClassName()}>
|
||||||
|
{renderTableColGroup()}
|
||||||
|
{renderTableHead()}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Scrollbar
|
||||||
|
ref={bodyScrollRef}
|
||||||
|
style={scrollContainerStyle}
|
||||||
|
className={classNames(
|
||||||
|
rowScrollClassName ?? 'max-h-[calc(100dvh-18rem)]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<table className={tableClassName()}>
|
||||||
|
{renderTableColGroup()}
|
||||||
|
{renderTableBody()}
|
||||||
|
{renderSummaryRows()}
|
||||||
|
</table>
|
||||||
|
</Scrollbar>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
{renderHeader()}
|
{renderHeader()}
|
||||||
@ -740,10 +964,10 @@ export default function Tables<T>({
|
|||||||
'overflow-hidden ring-1 ring-gray-300 sm:rounded-lg dark:ring-white/15',
|
'overflow-hidden ring-1 ring-gray-300 sm:rounded-lg dark:ring-white/15',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{renderTable()}
|
{renderScrollableTable()}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
renderTable()
|
renderScrollableTable()
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -754,4 +978,4 @@ export default function Tables<T>({
|
|||||||
|
|
||||||
function isAvatarVariant(variant: TableVariant) {
|
function isAvatarVariant(variant: TableVariant) {
|
||||||
return variant === 'avatars'
|
return variant === 'avatars'
|
||||||
}
|
}
|
||||||
|
|||||||
@ -371,6 +371,7 @@ export default function Tabs({
|
|||||||
>
|
>
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
const active = isTabActive(tab, location.pathname)
|
const active = isTabActive(tab, location.pathname)
|
||||||
|
const Icon = tab.icon
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
@ -382,11 +383,23 @@ export default function Tabs({
|
|||||||
active
|
active
|
||||||
? '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',
|
'relative inline-flex items-center rounded-md px-3 py-2 text-sm font-medium',
|
||||||
fullWidthItemClassName,
|
fullWidthItemClassName,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{tab.name}
|
{Icon && (
|
||||||
|
<Icon
|
||||||
|
aria-hidden="true"
|
||||||
|
strokeWidth={2}
|
||||||
|
className={classNames(
|
||||||
|
active
|
||||||
|
? 'text-indigo-600 dark:text-indigo-300'
|
||||||
|
: 'text-gray-400 dark:text-gray-500',
|
||||||
|
'mr-1.5 size-[18px] shrink-0',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span>{tab.name}</span>
|
||||||
{renderCount(tab, active)}
|
{renderCount(tab, active)}
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
@ -515,4 +528,4 @@ export default function Tabs({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,11 +2,13 @@ import {
|
|||||||
ChevronLeftIcon,
|
ChevronLeftIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
|
DocumentTextIcon,
|
||||||
MapPinIcon,
|
MapPinIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
} from '@heroicons/react/20/solid'
|
} from '@heroicons/react/20/solid'
|
||||||
import { useEffect, useRef, useState, type PointerEvent } from 'react'
|
import { useEffect, useRef, useState, type PointerEvent } from 'react'
|
||||||
import ButtonGroup from '../ButtonGroup'
|
import ButtonGroup from '../ButtonGroup'
|
||||||
|
import LoadingSpinner from '../LoadingSpinner'
|
||||||
import {
|
import {
|
||||||
timeToMinutes,
|
timeToMinutes,
|
||||||
workingHoursForDate,
|
workingHoursForDate,
|
||||||
@ -53,6 +55,10 @@ type CalendarEventOccurrence = CalendarEvent
|
|||||||
type CalendarProps = {
|
type CalendarProps = {
|
||||||
events: CalendarEvent[]
|
events: CalendarEvent[]
|
||||||
coreWorkingHours: CalendarCoreWorkingHours
|
coreWorkingHours: CalendarCoreWorkingHours
|
||||||
|
status?: {
|
||||||
|
tone: 'loading' | 'danger'
|
||||||
|
text: string
|
||||||
|
} | null
|
||||||
selectedDate: Date
|
selectedDate: Date
|
||||||
view: CalendarView
|
view: CalendarView
|
||||||
onSelectedDateChange: (date: Date) => void
|
onSelectedDateChange: (date: Date) => void
|
||||||
@ -883,24 +889,21 @@ function EventListButton({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="block truncate text-sm font-semibold">
|
<span className="inline-flex max-w-full items-center gap-1 truncate text-xs font-semibold opacity-80">
|
||||||
{event.title}
|
<ClockIcon className="size-3.5 shrink-0" />
|
||||||
</span>
|
<span className="truncate">
|
||||||
<span className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs opacity-80">
|
{event.allDay ? 'Ganztägig' : formatTime(eventStartValue(event))}
|
||||||
<span className="inline-flex items-center gap-1">
|
|
||||||
<ClockIcon className="size-3.5" />
|
|
||||||
{event.allDay
|
|
||||||
? 'Ganztägig'
|
|
||||||
: `${formatTime(eventStartValue(event))} - ${formatTime(
|
|
||||||
eventEndValue(event),
|
|
||||||
)}`}
|
|
||||||
</span>
|
</span>
|
||||||
{event.location && (
|
</span>
|
||||||
<span className="inline-flex items-center gap-1 truncate">
|
{event.location && (
|
||||||
<MapPinIcon className="size-3.5" />
|
<span className="mt-0.5 inline-flex max-w-full items-center gap-1 truncate text-xs opacity-80">
|
||||||
{event.location}
|
<MapPinIcon className="size-3.5 shrink-0" />
|
||||||
</span>
|
<span className="truncate">{event.location}</span>
|
||||||
)}
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="mt-1 inline-flex max-w-full items-center gap-1 truncate text-sm font-semibold">
|
||||||
|
<DocumentTextIcon className="size-3.5 shrink-0" />
|
||||||
|
<span className="truncate">{event.title}</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@ -1300,6 +1303,11 @@ function TimeGridView({
|
|||||||
30,
|
30,
|
||||||
(segmentEnd.getTime() - segmentStart.getTime()) / 60_000,
|
(segmentEnd.getTime() - segmentStart.getTime()) / 60_000,
|
||||||
)
|
)
|
||||||
|
const eventTopPercentage = (startMinutes / 1440) * 100
|
||||||
|
const eventHeightPercentage = Math.max(
|
||||||
|
2.1,
|
||||||
|
(durationMinutes / 1440) * 100,
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@ -1321,23 +1329,27 @@ function TimeGridView({
|
|||||||
moveEvent.stopPropagation()
|
moveEvent.stopPropagation()
|
||||||
}
|
}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'absolute right-1 left-1 z-10 overflow-hidden rounded-lg p-2 text-left text-xs ring-1 ring-inset transition',
|
'absolute right-[2px] left-[2px] z-10 flex flex-col items-start justify-start gap-0.5 overflow-hidden rounded-lg p-2 text-left text-xs leading-tight ring-1 ring-inset transition',
|
||||||
eventColors[event.color].card,
|
eventColors[event.color].card,
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
top: `${(startMinutes / 1440) * 100}%`,
|
top: `calc(${eventTopPercentage}% + 2px)`,
|
||||||
height: `${Math.max(
|
height: `calc(${eventHeightPercentage}% - 4px)`,
|
||||||
2.1,
|
|
||||||
(durationMinutes / 1440) * 100,
|
|
||||||
)}%`,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="block truncate font-semibold">
|
<span className="inline-flex max-w-full items-center gap-1 truncate font-semibold">
|
||||||
{event.title}
|
<ClockIcon className="size-3.5 shrink-0" />
|
||||||
|
<span className="truncate">{formatTime(eventStart)}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-0.5 block truncate opacity-80">
|
{event.location && (
|
||||||
{formatTime(segmentStart)} - {formatTime(segmentEnd)}
|
<span className="inline-flex max-w-full items-center gap-1 truncate opacity-80">
|
||||||
{event.location ? ` · ${event.location}` : ''}
|
<MapPinIcon className="size-3.5 shrink-0" />
|
||||||
|
<span className="truncate">{event.location}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="inline-flex max-w-full items-center gap-1 truncate font-medium">
|
||||||
|
<DocumentTextIcon className="size-3.5 shrink-0" />
|
||||||
|
<span className="truncate">{event.title}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
@ -1437,6 +1449,7 @@ function YearView({
|
|||||||
export default function Calendar({
|
export default function Calendar({
|
||||||
events,
|
events,
|
||||||
coreWorkingHours,
|
coreWorkingHours,
|
||||||
|
status,
|
||||||
selectedDate,
|
selectedDate,
|
||||||
view,
|
view,
|
||||||
onSelectedDateChange,
|
onSelectedDateChange,
|
||||||
@ -1476,9 +1489,35 @@ export default function Calendar({
|
|||||||
<p className="text-xs font-medium uppercase tracking-wide text-indigo-600 dark:text-indigo-400">
|
<p className="text-xs font-medium uppercase tracking-wide text-indigo-600 dark:text-indigo-400">
|
||||||
Kalender
|
Kalender
|
||||||
</p>
|
</p>
|
||||||
<h1 className="truncate text-lg font-semibold capitalize text-gray-950 dark:text-white">
|
<div className="flex min-w-0 flex-wrap items-center gap-2 align-middle">
|
||||||
{titleForView(selectedDate, view)}
|
<h1 className="truncate text-lg font-semibold capitalize text-gray-950 dark:text-white">
|
||||||
</h1>
|
{titleForView(selectedDate, view)}
|
||||||
|
</h1>
|
||||||
|
{status && (
|
||||||
|
<span
|
||||||
|
className={classNames(
|
||||||
|
'inline-flex h-6 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-xs font-semibold ring-1 ring-inset',
|
||||||
|
status.tone === 'danger'
|
||||||
|
? 'bg-red-100 text-red-700 ring-red-200 dark:bg-red-500/15 dark:text-red-200 dark:ring-red-400/20'
|
||||||
|
: 'bg-indigo-50 text-indigo-700 ring-indigo-200 dark:bg-indigo-500/15 dark:text-indigo-200 dark:ring-indigo-400/20',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{status.tone === 'loading' ? (
|
||||||
|
<LoadingSpinner
|
||||||
|
size="xs"
|
||||||
|
className="text-indigo-600 dark:text-indigo-200"
|
||||||
|
srLabel="Kalendereinträge werden geladen"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="size-1.5 rounded-full bg-current"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{status.text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@ -28,14 +28,14 @@ export type CalendarSettings = {
|
|||||||
|
|
||||||
const workingDay: CalendarWorkingHoursDay = {
|
const workingDay: CalendarWorkingHoursDay = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
start: '08:00',
|
start: '07:00',
|
||||||
end: '17:00',
|
end: '15:00',
|
||||||
}
|
}
|
||||||
|
|
||||||
const dayOff: CalendarWorkingHoursDay = {
|
const dayOff: CalendarWorkingHoursDay = {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
start: '08:00',
|
start: '07:00',
|
||||||
end: '17:00',
|
end: '15:00',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createDefaultCalendarSettings(): CalendarSettings {
|
export function createDefaultCalendarSettings(): CalendarSettings {
|
||||||
|
|||||||
@ -10,4 +10,47 @@ body,
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-empty-frame {
|
||||||
|
--dashboard-empty-frame-border: rgb(209 213 219);
|
||||||
|
position: relative;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .dashboard-empty-frame {
|
||||||
|
--dashboard-empty-frame-border: rgb(255 255 255 / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-empty-frame::before {
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--dashboard-empty-frame-border) 0 4px,
|
||||||
|
transparent 4px 8px
|
||||||
|
)
|
||||||
|
top left / 8px 1px repeat-x,
|
||||||
|
repeating-linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--dashboard-empty-frame-border) 0 4px,
|
||||||
|
transparent 4px 8px
|
||||||
|
)
|
||||||
|
bottom left / 8px 1px repeat-x,
|
||||||
|
repeating-linear-gradient(
|
||||||
|
180deg,
|
||||||
|
var(--dashboard-empty-frame-border) 0 4px,
|
||||||
|
transparent 4px 8px
|
||||||
|
)
|
||||||
|
top left / 1px 8px repeat-y,
|
||||||
|
repeating-linear-gradient(
|
||||||
|
180deg,
|
||||||
|
var(--dashboard-empty-frame-border) 0 4px,
|
||||||
|
transparent 4px 8px
|
||||||
|
)
|
||||||
|
top right / 1px 8px repeat-y;
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|||||||
@ -934,10 +934,11 @@ export default function DashboardPage({ username }: DashboardPageProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{widgets.length === 0 ? (
|
{widgets.length === 0 ? (
|
||||||
<div className="rounded-lg border border-dashed border-gray-300 bg-white px-6 py-16 dark:border-white/10 dark:bg-gray-900">
|
<div className="dashboard-empty-frame bg-white px-6 py-16 dark:bg-gray-900">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Squares2X2Icon}
|
icon={Squares2X2Icon}
|
||||||
iconClassName="text-indigo-400 dark:text-indigo-500"
|
iconClassName="text-indigo-400 dark:text-indigo-500"
|
||||||
|
className="relative"
|
||||||
title="Noch keine Widgets"
|
title="Noch keine Widgets"
|
||||||
description="Füge dein erstes Widget hinzu und baue dir dein persönliches Dashboard."
|
description="Füge dein erstes Widget hinzu und baue dir dein persönliches Dashboard."
|
||||||
actionLabel="Widget hinzufügen"
|
actionLabel="Widget hinzufügen"
|
||||||
@ -1133,4 +1134,4 @@ export default function DashboardPage({ username }: DashboardPageProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import {
|
|||||||
import Tabs from '../../components/Tabs'
|
import Tabs from '../../components/Tabs'
|
||||||
import UsersAdministration from './users/UsersAdministration'
|
import UsersAdministration from './users/UsersAdministration'
|
||||||
import FeedbackAdministration from './feedback/FeedbackAdministration'
|
import FeedbackAdministration from './feedback/FeedbackAdministration'
|
||||||
import Milestone from './milestone/Milestone'
|
import MilestoneAdministration from './milestone/MilestoneAdministration'
|
||||||
import ChatsAdministration from './chats/ChatsAdministration'
|
import ChatsAdministration from './chats/ChatsAdministration'
|
||||||
import CalendarAdministration from './calendar/CalendarAdministration'
|
import CalendarAdministration from './calendar/CalendarAdministration'
|
||||||
import ActiveDirectoryAdministration from './active-directory/ActiveDirectoryAdministration'
|
import ActiveDirectoryAdministration from './active-directory/ActiveDirectoryAdministration'
|
||||||
@ -106,7 +106,7 @@ export default function AdministrationPage() {
|
|||||||
|
|
||||||
{section === 'benutzer' && <UsersAdministration />}
|
{section === 'benutzer' && <UsersAdministration />}
|
||||||
{section === 'chats' && <ChatsAdministration />}
|
{section === 'chats' && <ChatsAdministration />}
|
||||||
{section === 'milestone' && <Milestone />}
|
{section === 'milestone' && <MilestoneAdministration />}
|
||||||
{section === 'active-directory' && <ActiveDirectoryAdministration />}
|
{section === 'active-directory' && <ActiveDirectoryAdministration />}
|
||||||
{section === 'kalender' && <CalendarAdministration />}
|
{section === 'kalender' && <CalendarAdministration />}
|
||||||
{section === 'feedback' && <FeedbackAdministration />}
|
{section === 'feedback' && <FeedbackAdministration />}
|
||||||
|
|||||||
@ -411,7 +411,7 @@ export default function ChannelsAdministration() {
|
|||||||
src={channel.image}
|
src={channel.image}
|
||||||
name={channel.name}
|
name={channel.name}
|
||||||
size={9}
|
size={9}
|
||||||
shape="rounded"
|
shape="circle"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<HashtagIcon className="size-5 shrink-0" />
|
<HashtagIcon className="size-5 shrink-0" />
|
||||||
@ -479,7 +479,7 @@ export default function ChannelsAdministration() {
|
|||||||
src={draft.image}
|
src={draft.image}
|
||||||
name={draft.name || 'Channel'}
|
name={draft.name || 'Channel'}
|
||||||
size={14}
|
size={14}
|
||||||
shape="rounded"
|
shape="circle"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@ -27,6 +27,7 @@ type AdminGroupChat = {
|
|||||||
image: string
|
image: string
|
||||||
ownerId: string
|
ownerId: string
|
||||||
teamId: string
|
teamId: string
|
||||||
|
isPublic: boolean
|
||||||
userIds: string[]
|
userIds: string[]
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
@ -46,6 +47,7 @@ type GroupDraft = {
|
|||||||
image: string
|
image: string
|
||||||
ownerId: string
|
ownerId: string
|
||||||
teamId: string
|
teamId: string
|
||||||
|
isPublic: boolean
|
||||||
userIds: string[]
|
userIds: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -68,6 +70,7 @@ function groupToDraft(group: AdminGroupChat): GroupDraft {
|
|||||||
image: group.image,
|
image: group.image,
|
||||||
ownerId: group.ownerId,
|
ownerId: group.ownerId,
|
||||||
teamId: group.teamId,
|
teamId: group.teamId,
|
||||||
|
isPublic: group.isPublic,
|
||||||
userIds: group.userIds ?? [],
|
userIds: group.userIds ?? [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -233,6 +236,7 @@ export default function GroupChatsAdministration() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: draft.name,
|
name: draft.name,
|
||||||
image: draft.image,
|
image: draft.image,
|
||||||
|
isPublic: draft.isPublic,
|
||||||
userIds: draft.userIds,
|
userIds: draft.userIds,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@ -413,7 +417,7 @@ export default function GroupChatsAdministration() {
|
|||||||
src={group.image}
|
src={group.image}
|
||||||
name={group.name}
|
name={group.name}
|
||||||
size={9}
|
size={9}
|
||||||
shape="rounded"
|
shape="circle"
|
||||||
/>
|
/>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
@ -423,6 +427,11 @@ export default function GroupChatsAdministration() {
|
|||||||
Team
|
Team
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{group.isPublic && (
|
||||||
|
<span className="shrink-0 rounded bg-emerald-100 px-1.5 py-0.5 text-[10px] font-semibold tracking-wide text-emerald-700 uppercase dark:bg-emerald-500/20 dark:text-emerald-300">
|
||||||
|
Öffentlich
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</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
|
||||||
@ -479,7 +488,7 @@ export default function GroupChatsAdministration() {
|
|||||||
src={draft.image}
|
src={draft.image}
|
||||||
name={draft.name || 'Gruppenchat'}
|
name={draft.name || 'Gruppenchat'}
|
||||||
size={14}
|
size={14}
|
||||||
shape="rounded"
|
shape="circle"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<input
|
<input
|
||||||
@ -520,6 +529,19 @@ export default function GroupChatsAdministration() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!isTeamChat && (
|
||||||
|
<div className="mt-5 rounded-lg bg-gray-50 p-3 ring-1 ring-gray-200 dark:bg-white/5 dark:ring-white/10">
|
||||||
|
<Checkbox
|
||||||
|
checked={draft.isPublic}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateDraft('isPublic', event.target.checked)
|
||||||
|
}
|
||||||
|
label="Öffentlich"
|
||||||
|
description="Öffentliche Gruppenchats können von allen angemeldeten Benutzern gelesen und über die globale Suche gefunden werden."
|
||||||
|
/>
|
||||||
|
</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">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
// frontend\src\pages\administration\milestone\Milestone.tsx
|
// frontend\src\pages\administration\milestone\MilestoneAdministration.tsx
|
||||||
|
|
||||||
import { useEffect, useState, type FormEvent } from 'react'
|
import { useEffect, useRef, useState, type FormEvent } from 'react'
|
||||||
import {
|
import {
|
||||||
KeyIcon,
|
KeyIcon,
|
||||||
ServerStackIcon,
|
ServerStackIcon,
|
||||||
@ -14,6 +14,7 @@ import { DialogTitle } from '@headlessui/react'
|
|||||||
import { XMarkIcon } from '@heroicons/react/24/outline'
|
import { XMarkIcon } from '@heroicons/react/24/outline'
|
||||||
import Modal from '../../../components/Modal'
|
import Modal from '../../../components/Modal'
|
||||||
import TaskList, { type TaskListStep } from '../../../components/TaskList'
|
import TaskList, { type TaskListStep } from '../../../components/TaskList'
|
||||||
|
import TreeList, { type TreeListNode } from '../../../components/TreeList'
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||||
|
|
||||||
@ -37,8 +38,32 @@ type MilestoneSettings = {
|
|||||||
serverFoldersStorageRootPath: string
|
serverFoldersStorageRootPath: string
|
||||||
serverFoldersArchiveRootLabel: string
|
serverFoldersArchiveRootLabel: string
|
||||||
serverFoldersArchiveRootPath: string
|
serverFoldersArchiveRootPath: string
|
||||||
|
backupNasHost: string
|
||||||
|
backupNasShare: string
|
||||||
|
backupNasUsername: string
|
||||||
|
backupNasPasswordConfigured: boolean
|
||||||
|
backupLogRootPath: string
|
||||||
updatedAt?: string
|
updatedAt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BackupFolderRoot = {
|
||||||
|
label: string
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type BackupFolderItem = {
|
||||||
|
name: string
|
||||||
|
displayName?: string
|
||||||
|
path: string
|
||||||
|
hasChildren?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type BackupFoldersResponse = {
|
||||||
|
path: string
|
||||||
|
roots?: BackupFolderRoot[]
|
||||||
|
folders?: BackupFolderItem[]
|
||||||
|
warning?: string
|
||||||
|
}
|
||||||
type MilestoneSyncEvent = {
|
type MilestoneSyncEvent = {
|
||||||
type: 'token' | 'recordingServer' | 'hardware' | 'done' | 'error'
|
type: 'token' | 'recordingServer' | 'hardware' | 'done' | 'error'
|
||||||
message?: string
|
message?: string
|
||||||
@ -201,7 +226,7 @@ function markCurrentMilestoneSyncStepAsError(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Milestone() {
|
export default function MilestoneAdministration() {
|
||||||
const { showToast, showErrorToast } = useNotifications()
|
const { showToast, showErrorToast } = useNotifications()
|
||||||
const [settings, setSettings] = useState<MilestoneSettings | null>(null)
|
const [settings, setSettings] = useState<MilestoneSettings | null>(null)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
@ -212,6 +237,11 @@ export default function Milestone() {
|
|||||||
const [syncSteps, setSyncSteps] = useState<TaskListStep[]>(() => createMilestoneSyncSteps())
|
const [syncSteps, setSyncSteps] = useState<TaskListStep[]>(() => createMilestoneSyncSteps())
|
||||||
const [syncError, setSyncError] = useState<string | null>(null)
|
const [syncError, setSyncError] = useState<string | null>(null)
|
||||||
const [syncCompleted, setSyncCompleted] = useState(false)
|
const [syncCompleted, setSyncCompleted] = useState(false)
|
||||||
|
const [backupNasHost, setBackupNasHost] = useState('')
|
||||||
|
const [backupNasShare, setBackupNasShare] = useState('')
|
||||||
|
const [backupNasUsername, setBackupNasUsername] = useState('')
|
||||||
|
const [backupNasPassword, setBackupNasPassword] = useState('')
|
||||||
|
const [backupLogRootPath, setBackupLogRootPath] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadSettings()
|
void loadSettings()
|
||||||
@ -291,6 +321,11 @@ export default function Milestone() {
|
|||||||
if (event.settings) {
|
if (event.settings) {
|
||||||
setSettings(event.settings)
|
setSettings(event.settings)
|
||||||
setSkipTlsVerify(Boolean(event.settings.skipTlsVerify))
|
setSkipTlsVerify(Boolean(event.settings.skipTlsVerify))
|
||||||
|
setBackupNasHost(event.settings.backupNasHost ?? '')
|
||||||
|
setBackupNasShare(event.settings.backupNasShare ?? '')
|
||||||
|
setBackupNasUsername(event.settings.backupNasUsername ?? '')
|
||||||
|
setBackupNasPassword('')
|
||||||
|
setBackupLogRootPath(event.settings.backupLogRootPath ?? '')
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
@ -379,6 +414,11 @@ export default function Milestone() {
|
|||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
setSettings(data.settings)
|
setSettings(data.settings)
|
||||||
setSkipTlsVerify(Boolean(data.settings?.skipTlsVerify))
|
setSkipTlsVerify(Boolean(data.settings?.skipTlsVerify))
|
||||||
|
setBackupNasHost(String(data.settings?.backupNasHost ?? ''))
|
||||||
|
setBackupNasShare(String(data.settings?.backupNasShare ?? ''))
|
||||||
|
setBackupNasUsername(String(data.settings?.backupNasUsername ?? ''))
|
||||||
|
setBackupNasPassword('')
|
||||||
|
setBackupLogRootPath(String(data.settings?.backupLogRootPath ?? ''))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showErrorToast({
|
showErrorToast({
|
||||||
title: 'Einstellungen konnten nicht geladen werden',
|
title: 'Einstellungen konnten nicht geladen werden',
|
||||||
@ -428,6 +468,11 @@ export default function Milestone() {
|
|||||||
serverFoldersArchiveRootPath: String(
|
serverFoldersArchiveRootPath: String(
|
||||||
formData.get('serverFoldersArchiveRootPath') ?? 'E:/',
|
formData.get('serverFoldersArchiveRootPath') ?? 'E:/',
|
||||||
),
|
),
|
||||||
|
backupNasHost,
|
||||||
|
backupNasShare,
|
||||||
|
backupNasUsername,
|
||||||
|
backupNasPassword,
|
||||||
|
backupLogRootPath,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -446,12 +491,21 @@ export default function Milestone() {
|
|||||||
|
|
||||||
setSettings(data.settings)
|
setSettings(data.settings)
|
||||||
setSkipTlsVerify(Boolean(data.settings?.skipTlsVerify))
|
setSkipTlsVerify(Boolean(data.settings?.skipTlsVerify))
|
||||||
|
setBackupNasHost(data.settings.backupNasHost ?? '')
|
||||||
|
setBackupNasShare(data.settings.backupNasShare ?? '')
|
||||||
|
setBackupNasUsername(data.settings.backupNasUsername ?? '')
|
||||||
|
setBackupNasPassword('')
|
||||||
|
setBackupLogRootPath(data.settings.backupLogRootPath ?? '')
|
||||||
const passwordInput = form.elements.namedItem('password')
|
const passwordInput = form.elements.namedItem('password')
|
||||||
if (passwordInput instanceof HTMLInputElement) {
|
if (passwordInput instanceof HTMLInputElement) {
|
||||||
passwordInput.value = ''
|
passwordInput.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
await runMilestoneSyncStream()
|
showToast({
|
||||||
|
variant: 'success',
|
||||||
|
title: 'Einstellungen gespeichert',
|
||||||
|
message: 'Die Milestone-Einstellungen wurden gespeichert.',
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showErrorToast({
|
showErrorToast({
|
||||||
title: 'Speichern fehlgeschlagen',
|
title: 'Speichern fehlgeschlagen',
|
||||||
@ -553,29 +607,30 @@ export default function Milestone() {
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1fr)_40rem]">
|
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1fr)_28rem]">
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
className="rounded-lg bg-white p-4 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10"
|
className="space-y-6"
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-x-3">
|
<section className="rounded-lg bg-white p-5 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
|
||||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600 ring-1 ring-indigo-600/10 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20">
|
<div className="flex items-start gap-x-3 border-b border-gray-200 pb-5 dark:border-white/10">
|
||||||
<VideoCameraIcon aria-hidden="true" className="size-5" />
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600 ring-1 ring-indigo-600/10 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20">
|
||||||
|
<VideoCameraIcon aria-hidden="true" className="size-5" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
Milestone Videoserver
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Hinterlege die Zugangsdaten für den Milestone Videoserver.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="mt-5 grid grid-cols-1 gap-5 lg:grid-cols-6">
|
||||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
|
<div className="lg:col-span-2">
|
||||||
Milestone Videoserver
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
Hinterlege die Zugangsdaten für den Milestone Videoserver.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
||||||
<div className="sm:col-span-2">
|
|
||||||
<label
|
<label
|
||||||
htmlFor="milestone-host"
|
htmlFor="milestone-host"
|
||||||
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"
|
||||||
@ -596,7 +651,7 @@ export default function Milestone() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sm:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<label
|
<label
|
||||||
htmlFor="milestone-username"
|
htmlFor="milestone-username"
|
||||||
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"
|
||||||
@ -617,7 +672,7 @@ export default function Milestone() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sm:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<label
|
<label
|
||||||
htmlFor="milestone-password"
|
htmlFor="milestone-password"
|
||||||
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"
|
||||||
@ -640,13 +695,9 @@ export default function Milestone() {
|
|||||||
className={inputClassName}
|
className={inputClassName}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
Das gespeicherte Kennwort wird nicht im Klartext angezeigt.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sm:col-span-2">
|
<div className="lg:col-span-6">
|
||||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/5">
|
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/5">
|
||||||
<Switch
|
<Switch
|
||||||
checked={skipTlsVerify}
|
checked={skipTlsVerify}
|
||||||
@ -661,19 +712,27 @@ export default function Milestone() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="sm:col-span-2">
|
<section className="rounded-lg bg-white p-5 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
|
||||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/5">
|
<div>
|
||||||
<div>
|
<div className="flex items-start gap-x-3 border-b border-gray-200 pb-5 dark:border-white/10">
|
||||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600 ring-1 ring-indigo-600/10 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20">
|
||||||
TreeView-Agent
|
<ServerStackIcon aria-hidden="true" className="size-5" />
|
||||||
</h3>
|
</div>
|
||||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
Verbindung zum Server-Folders-Agent auf dem Milestone-/Recording-Server.
|
<div>
|
||||||
</p>
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
TreeView-Agent
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Verbindung zum Server-Folders-Agent auf dem Milestone-/Recording-Server.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="mt-5 grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor="server-folders-agent-scheme"
|
htmlFor="server-folders-agent-scheme"
|
||||||
@ -796,7 +855,7 @@ export default function Milestone() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-3">
|
||||||
<p className="rounded-md bg-gray-50 p-3 text-xs text-gray-500 ring-1 ring-gray-200 dark:bg-white/5 dark:text-gray-400 dark:ring-white/10">
|
<p className="rounded-md bg-gray-50 p-3 text-xs text-gray-500 ring-1 ring-gray-200 dark:bg-white/5 dark:text-gray-400 dark:ring-white/10">
|
||||||
Der Agent-Token wird beim Speichern automatisch erzeugt, an
|
Der Agent-Token wird beim Speichern automatisch erzeugt, an
|
||||||
den Agent übertragen und verschlüsselt gespeichert. Der Agent
|
den Agent übertragen und verschlüsselt gespeichert. Der Agent
|
||||||
@ -809,38 +868,166 @@ export default function Milestone() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-lg bg-white p-5 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-start gap-x-3 border-b border-gray-200 pb-5 dark:border-white/10">
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600 ring-1 ring-indigo-600/10 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20">
|
||||||
|
<ServerStackIcon aria-hidden="true" className="size-5" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
Backup-Protokolle
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
NAS-Zugang und Ordner mit den Robocopy-.log-Dateien.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="backup-nas-host"
|
||||||
|
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
NAS-IP / Hostname
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="mt-2">
|
||||||
|
<input
|
||||||
|
id="backup-nas-host"
|
||||||
|
type="text"
|
||||||
|
value={backupNasHost}
|
||||||
|
onChange={(event) => {
|
||||||
|
setBackupNasHost(event.target.value)
|
||||||
|
setBackupNasShare('')
|
||||||
|
setBackupLogRootPath('')
|
||||||
|
}}
|
||||||
|
placeholder="z. B. 192.168.178.20 oder NAS01"
|
||||||
|
className={inputClassName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="backup-nas-username"
|
||||||
|
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
Benutzername
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="mt-2">
|
||||||
|
<input
|
||||||
|
id="backup-nas-username"
|
||||||
|
type="text"
|
||||||
|
value={backupNasUsername}
|
||||||
|
onChange={(event) => setBackupNasUsername(event.target.value)}
|
||||||
|
placeholder="z. B. backup-user oder DOMÄNE\\backup-user"
|
||||||
|
autoComplete="off"
|
||||||
|
className={inputClassName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="backup-nas-password"
|
||||||
|
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
Kennwort {settings?.backupNasPasswordConfigured ? '(leer lassen = unverändert)' : ''}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="mt-2">
|
||||||
|
<input
|
||||||
|
id="backup-nas-password"
|
||||||
|
type="password"
|
||||||
|
value={backupNasPassword}
|
||||||
|
onChange={(event) => setBackupNasPassword(event.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder={
|
||||||
|
settings?.backupNasPasswordConfigured
|
||||||
|
? 'Gespeichertes Kennwort verwenden'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
className={inputClassName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 border-t border-gray-200 pt-4 dark:border-white/10">
|
||||||
|
<div>
|
||||||
|
<BackupFolderPicker
|
||||||
|
value={backupLogRootPath}
|
||||||
|
nasHost={backupNasHost}
|
||||||
|
nasShare={backupNasShare}
|
||||||
|
nasUsername={backupNasUsername}
|
||||||
|
nasPassword={backupNasPassword}
|
||||||
|
storedPasswordAvailable={Boolean(
|
||||||
|
settings?.backupNasPasswordConfigured &&
|
||||||
|
backupNasHost.trim() === (settings.backupNasHost ?? '').trim() &&
|
||||||
|
backupNasUsername.trim() === (settings.backupNasUsername ?? '').trim(),
|
||||||
|
)}
|
||||||
|
onChange={setBackupLogRootPath}
|
||||||
|
onShareChange={setBackupNasShare}
|
||||||
|
disabled={isSaving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 rounded-lg bg-white p-4 shadow-sm ring-1 ring-gray-200 sm:flex-row sm:items-center sm:justify-between dark:bg-gray-900 dark:ring-white/10">
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Speichern aktualisiert nur die Einstellungen. Der Milestone-Abruf
|
||||||
|
läuft separat.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-x-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
color="indigo"
|
||||||
|
isLoading={isFetchingToken}
|
||||||
|
disabled={!settings?.passwordConfigured || isSaving}
|
||||||
|
onClick={() => void handleFetchToken()}
|
||||||
|
>
|
||||||
|
Milestone synchronisieren
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
color="indigo"
|
||||||
|
isLoading={isSaving}
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 flex justify-end gap-x-3">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
color="indigo"
|
|
||||||
isLoading={isFetchingToken}
|
|
||||||
disabled={!settings?.passwordConfigured || isSaving}
|
|
||||||
onClick={() => void handleFetchToken()}
|
|
||||||
>
|
|
||||||
Token & Infos erneut abrufen
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
color="indigo"
|
|
||||||
isLoading={isSaving}
|
|
||||||
>
|
|
||||||
Speichern
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<aside className="rounded-lg bg-white p-4 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
|
<aside className="rounded-lg bg-white p-5 shadow-sm ring-1 ring-gray-200 xl:sticky xl:top-6 xl:self-start dark:bg-gray-900 dark:ring-white/10">
|
||||||
<h3 className="flex items-center gap-x-2 text-sm font-semibold text-gray-900 dark:text-white">
|
<div className="flex items-start gap-x-3 border-b border-gray-200 pb-5 dark:border-white/10">
|
||||||
<ServerStackIcon aria-hidden="true" className="size-5 text-gray-400" />
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-gray-50 text-gray-500 ring-1 ring-gray-200 dark:bg-white/5 dark:text-gray-400 dark:ring-white/10">
|
||||||
Status
|
<ServerStackIcon aria-hidden="true" className="size-5" />
|
||||||
</h3>
|
</div>
|
||||||
|
|
||||||
<dl className="mt-4 grid grid-cols-1 gap-x-6 gap-y-4 text-sm sm:grid-cols-2">
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
Status
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Aktuell gespeicherte Milestone-Konfiguration.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="mt-5 grid grid-cols-1 gap-x-6 gap-y-4 text-sm sm:grid-cols-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<dt className="text-gray-500 dark:text-gray-400">
|
<dt className="text-gray-500 dark:text-gray-400">
|
||||||
Server
|
Server
|
||||||
@ -943,6 +1130,29 @@ export default function Milestone() {
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0">
|
||||||
|
<dt className="text-gray-500 dark:text-gray-400">
|
||||||
|
Backup-NAS
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1 break-all font-medium text-gray-900 dark:text-white">
|
||||||
|
{settings?.backupNasHost && settings?.backupNasShare
|
||||||
|
? `\\\\${settings.backupNasHost}\\${settings.backupNasShare}`
|
||||||
|
: 'Nicht gespeichert'}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<dt className="text-gray-500 dark:text-gray-400">
|
||||||
|
Backup-Kennwort
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1 flex items-center gap-x-2 font-medium text-gray-900 dark:text-white">
|
||||||
|
<KeyIcon aria-hidden="true" className="size-4 text-gray-400" />
|
||||||
|
{settings?.backupNasPasswordConfigured
|
||||||
|
? 'Gespeichert'
|
||||||
|
: 'Nicht gespeichert'}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
{settings?.tokenExpiresAt && (
|
{settings?.tokenExpiresAt && (
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-gray-500 dark:text-gray-400">
|
<dt className="text-gray-500 dark:text-gray-400">
|
||||||
@ -992,3 +1202,390 @@ export default function Milestone() {
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeBackupPath(value: string) {
|
||||||
|
return value.trim().replace(/\//g, '\\').replace(/[\\]+$/, '').toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBackupDisplayPath(value: string, nasHost?: string) {
|
||||||
|
const cleanValue = value.trim().replace(/\//g, '\\').replace(/[\\]+$/, '')
|
||||||
|
|
||||||
|
if (!cleanValue) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cleanValue.startsWith('\\\\')) {
|
||||||
|
return `\\\\${cleanValue.replace(/^\\+/, '')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanHost = nasHost?.trim().replace(/\//g, '\\').replace(/[\\]+$/, '')
|
||||||
|
if (
|
||||||
|
cleanHost &&
|
||||||
|
cleanValue.toLowerCase().startsWith(`${cleanHost.toLowerCase()}\\`)
|
||||||
|
) {
|
||||||
|
return `\\\\${cleanValue}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleanValue
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBackupPathInsideRoot(path: string, rootPath: string) {
|
||||||
|
const normalizedPath = normalizeBackupPath(path)
|
||||||
|
const normalizedRoot = normalizeBackupPath(rootPath)
|
||||||
|
|
||||||
|
return (
|
||||||
|
normalizedPath === normalizedRoot ||
|
||||||
|
normalizedPath.startsWith(`${normalizedRoot}\\`)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function backupFolderNameFromPath(path: string) {
|
||||||
|
const cleanPath = path.trim().replace(/[\\/]+$/, '')
|
||||||
|
const parts = cleanPath.split(/[\\/]+/).filter(Boolean)
|
||||||
|
|
||||||
|
return parts[parts.length - 1] || cleanPath || path
|
||||||
|
}
|
||||||
|
|
||||||
|
function backupFolderNodeFromItem(item: BackupFolderItem): TreeListNode {
|
||||||
|
const name = item.displayName?.trim() || item.name || backupFolderNameFromPath(item.path)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item.path,
|
||||||
|
name,
|
||||||
|
path: item.path,
|
||||||
|
children: item.hasChildren ? [] : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinBackupPath(basePath: string, childName: string) {
|
||||||
|
const base = basePath.trim().replace(/[\\/]+$/, '')
|
||||||
|
const child = childName.trim().replace(/^[\\/]+/, '')
|
||||||
|
|
||||||
|
if (!base) {
|
||||||
|
return child
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!child) {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${base}\\${child}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBackupFolderBranch(
|
||||||
|
rootPath: string,
|
||||||
|
currentPath: string,
|
||||||
|
folders: BackupFolderItem[],
|
||||||
|
): TreeListNode[] {
|
||||||
|
const cleanRootPath = rootPath.trim().replace(/[\\/]+$/, '')
|
||||||
|
const cleanCurrentPath = currentPath.trim().replace(/[\\/]+$/, '')
|
||||||
|
const folderNodes = folders.map(backupFolderNodeFromItem)
|
||||||
|
|
||||||
|
if (
|
||||||
|
!cleanRootPath ||
|
||||||
|
!cleanCurrentPath ||
|
||||||
|
normalizeBackupPath(cleanRootPath) === normalizeBackupPath(cleanCurrentPath)
|
||||||
|
) {
|
||||||
|
return folderNodes
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isBackupPathInsideRoot(cleanCurrentPath, cleanRootPath)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
let relativePath = cleanCurrentPath
|
||||||
|
if (normalizeBackupPath(relativePath).startsWith(normalizeBackupPath(cleanRootPath))) {
|
||||||
|
relativePath = relativePath.slice(cleanRootPath.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments = relativePath.replace(/^[\\/]+/, '').split(/[\\/]+/).filter(Boolean)
|
||||||
|
|
||||||
|
if (segments.length === 0) {
|
||||||
|
return folderNodes
|
||||||
|
}
|
||||||
|
|
||||||
|
let runningPath = cleanRootPath
|
||||||
|
let rootBranchNode: TreeListNode | null = null
|
||||||
|
let currentNode: TreeListNode | null = null
|
||||||
|
|
||||||
|
for (const segment of segments) {
|
||||||
|
runningPath = joinBackupPath(runningPath, segment)
|
||||||
|
|
||||||
|
const nextNode: TreeListNode = {
|
||||||
|
id: runningPath,
|
||||||
|
name: segment,
|
||||||
|
path: runningPath,
|
||||||
|
children: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rootBranchNode) {
|
||||||
|
rootBranchNode = nextNode
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentNode) {
|
||||||
|
currentNode.children = [nextNode]
|
||||||
|
}
|
||||||
|
|
||||||
|
currentNode = nextNode
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentNode) {
|
||||||
|
currentNode.children = folderNodes
|
||||||
|
}
|
||||||
|
|
||||||
|
return rootBranchNode ? [rootBranchNode] : folderNodes
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBackupFolderTree(response: BackupFoldersResponse): TreeListNode[] {
|
||||||
|
const roots = Array.isArray(response.roots) ? response.roots : []
|
||||||
|
const folders = Array.isArray(response.folders) ? response.folders : []
|
||||||
|
|
||||||
|
if (roots.length === 0) {
|
||||||
|
return folders.map(backupFolderNodeFromItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
return roots.map((root) => ({
|
||||||
|
id: root.path,
|
||||||
|
name: root.label || root.path,
|
||||||
|
path: root.path,
|
||||||
|
children: isBackupPathInsideRoot(response.path, root.path)
|
||||||
|
? buildBackupFolderBranch(root.path, response.path, folders)
|
||||||
|
: [],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBackupShareFromPath(path: string) {
|
||||||
|
const cleanPath = normalizeBackupDisplayPath(path)
|
||||||
|
|
||||||
|
if (!cleanPath.startsWith('\\\\')) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = cleanPath.slice(2).split('\\').filter(Boolean)
|
||||||
|
|
||||||
|
return parts[1] ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function BackupFolderPicker({
|
||||||
|
value,
|
||||||
|
nasHost,
|
||||||
|
nasShare,
|
||||||
|
nasUsername,
|
||||||
|
nasPassword,
|
||||||
|
storedPasswordAvailable,
|
||||||
|
onChange,
|
||||||
|
onShareChange,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
value: string
|
||||||
|
nasHost: string
|
||||||
|
nasShare: string
|
||||||
|
nasUsername: string
|
||||||
|
nasPassword: string
|
||||||
|
storedPasswordAvailable: boolean
|
||||||
|
onChange: (value: string) => void
|
||||||
|
onShareChange: (value: string) => void
|
||||||
|
disabled?: boolean
|
||||||
|
}) {
|
||||||
|
const [nodes, setNodes] = useState<TreeListNode[]>([])
|
||||||
|
const [rootPath, setRootPath] = useState('')
|
||||||
|
const [currentPath, setCurrentPath] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const loadAbortControllerRef = useRef<AbortController | null>(null)
|
||||||
|
const canLoadBackupFolders =
|
||||||
|
Boolean(nasHost.trim()) &&
|
||||||
|
Boolean(nasUsername.trim()) &&
|
||||||
|
(Boolean(nasPassword) || storedPasswordAvailable)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
loadAbortControllerRef.current?.abort()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function loadFolders(path?: string, shareOverride?: string) {
|
||||||
|
const cleanHost = nasHost.trim()
|
||||||
|
const cleanUsername = nasUsername.trim()
|
||||||
|
const requestedPath = normalizeBackupDisplayPath(path ?? '', cleanHost)
|
||||||
|
const pathShare = getBackupShareFromPath(requestedPath)
|
||||||
|
const requestedShare = shareOverride ?? (pathShare || nasShare.trim())
|
||||||
|
|
||||||
|
if (!cleanHost || !cleanUsername || (!nasPassword && !storedPasswordAvailable)) {
|
||||||
|
setNodes([])
|
||||||
|
setRootPath('')
|
||||||
|
setCurrentPath('')
|
||||||
|
setError(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
loadAbortControllerRef.current?.abort()
|
||||||
|
const abortController = new AbortController()
|
||||||
|
loadAbortControllerRef.current = abortController
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
abortController.abort()
|
||||||
|
}, 20000)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_URL}/admin/milestone/backup-folders/browse`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
signal: abortController.signal,
|
||||||
|
body: JSON.stringify({
|
||||||
|
nasHost: cleanHost,
|
||||||
|
nasShare: requestedShare,
|
||||||
|
nasUsername: cleanUsername,
|
||||||
|
nasPassword,
|
||||||
|
path: requestedPath,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
setError(
|
||||||
|
await readApiError(
|
||||||
|
response,
|
||||||
|
'Backup-Ordner konnten nicht geladen werden.',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as BackupFoldersResponse
|
||||||
|
const nextRootPath = data.roots?.[0]?.path ?? ''
|
||||||
|
const nextPath = data.path || requestedPath || nextRootPath
|
||||||
|
const nextShare = getBackupShareFromPath(nextPath || nextRootPath)
|
||||||
|
|
||||||
|
setNodes(buildBackupFolderTree(data))
|
||||||
|
setRootPath(nextRootPath)
|
||||||
|
setCurrentPath(nextPath)
|
||||||
|
setError(data.warning ?? null)
|
||||||
|
|
||||||
|
if (nextShare) {
|
||||||
|
onShareChange(nextShare)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextPath && value.trim() !== nextPath) {
|
||||||
|
onChange(nextPath)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (abortController.signal.aborted) {
|
||||||
|
if (loadAbortControllerRef.current === abortController) {
|
||||||
|
setError(
|
||||||
|
'NAS hat nicht rechtzeitig geantwortet. Prüfe Hostname, Benutzername, Domäne und Netzwerkverbindung.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Backup-Ordner konnten nicht geladen werden.',
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
window.clearTimeout(timeoutId)
|
||||||
|
|
||||||
|
if (loadAbortControllerRef.current === abortController) {
|
||||||
|
loadAbortControllerRef.current = null
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canLoadBackupFolders) {
|
||||||
|
setNodes([])
|
||||||
|
setRootPath('')
|
||||||
|
setCurrentPath('')
|
||||||
|
setError(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
const selectedPath = normalizeBackupDisplayPath(value, nasHost)
|
||||||
|
const selectedShare = selectedPath ? getBackupShareFromPath(selectedPath) : ''
|
||||||
|
|
||||||
|
void loadFolders(selectedPath || undefined, selectedShare || '')
|
||||||
|
}, 500)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [nasHost, nasUsername, nasPassword, storedPasswordAvailable])
|
||||||
|
|
||||||
|
function handleSelectedPathChange(path: string) {
|
||||||
|
const nextPath = normalizeBackupDisplayPath(path, nasHost)
|
||||||
|
const nextShare = getBackupShareFromPath(nextPath)
|
||||||
|
if (nextShare) {
|
||||||
|
onShareChange(nextShare)
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange(nextPath)
|
||||||
|
void loadFolders(nextPath, nextShare)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLoadShares() {
|
||||||
|
onShareChange('')
|
||||||
|
onChange('')
|
||||||
|
setRootPath('')
|
||||||
|
setCurrentPath('')
|
||||||
|
void loadFolders('', '')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{error && (
|
||||||
|
<div className="mb-3 rounded-md bg-amber-50 p-3 text-sm text-amber-800 ring-1 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-200 dark:ring-amber-400/20">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Lade die Freigaben des NAS und wähle darunter den Ordner mit den
|
||||||
|
Robocopy-.log-Dateien aus.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
color="indigo"
|
||||||
|
size="sm"
|
||||||
|
isLoading={isLoading}
|
||||||
|
disabled={disabled || isLoading || !canLoadBackupFolders}
|
||||||
|
onClick={handleLoadShares}
|
||||||
|
>
|
||||||
|
Neu laden
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TreeList
|
||||||
|
label="Freigabe und Ordner auswählen"
|
||||||
|
description={
|
||||||
|
canLoadBackupFolders
|
||||||
|
? 'Freigabe auswählen, danach den passenden Ordner darunter anklicken.'
|
||||||
|
: 'Trage NAS-IP, Benutzername und Kennwort ein.'
|
||||||
|
}
|
||||||
|
selectedPath={value || currentPath}
|
||||||
|
nodes={nodes}
|
||||||
|
rootPath={rootPath || currentPath || value}
|
||||||
|
onSelectedPathChange={handleSelectedPathChange}
|
||||||
|
disabled={disabled || isLoading || !canLoadBackupFolders}
|
||||||
|
showFallbackNodes
|
||||||
|
emptyMessage="Noch keine Freigaben geladen."
|
||||||
|
showNewFolderInput={false}
|
||||||
|
pathPlaceholder="\\\\NAS\\Freigabe\\Ordner"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -369,7 +369,6 @@ export default function UsersAdministration() {
|
|||||||
const unit = user.unit.trim()
|
const unit = user.unit.trim()
|
||||||
const label = unit || 'Ohne Einheit'
|
const label = unit || 'Ohne Einheit'
|
||||||
const key = unit ? unit.toLowerCase() : '__without_unit__'
|
const key = unit ? unit.toLowerCase() : '__without_unit__'
|
||||||
|
|
||||||
const existingGroup = groups.get(key)
|
const existingGroup = groups.get(key)
|
||||||
|
|
||||||
if (existingGroup) {
|
if (existingGroup) {
|
||||||
@ -895,13 +894,12 @@ export default function UsersAdministration() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{groupedFilteredUsers.map((group) => (
|
{groupedFilteredUsers.map((group) => (
|
||||||
<div key={group.key}>
|
<section key={group.key}>
|
||||||
<div className="sticky top-0 z-10 -mx-2 bg-white/95 px-3 py-1.5 backdrop-blur dark:bg-gray-900/95">
|
<div className="sticky top-0 z-10 -mx-2 bg-white/95 px-3 py-1.5 backdrop-blur dark:bg-gray-900/95">
|
||||||
<div className="flex items-center justify-between gap-x-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<h3 className="truncate text-[11px] font-semibold tracking-wide text-gray-500 uppercase dark:text-gray-400">
|
<h3 className="truncate text-[11px] font-semibold tracking-wide text-gray-500 uppercase dark:text-gray-400">
|
||||||
{group.label}
|
{group.label}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<span className="shrink-0 rounded-full bg-gray-100 px-2 py-0.5 text-[10px] font-medium text-gray-500 dark:bg-white/10 dark:text-gray-400">
|
<span className="shrink-0 rounded-full bg-gray-100 px-2 py-0.5 text-[10px] font-medium text-gray-500 dark:bg-white/10 dark:text-gray-400">
|
||||||
{group.users.length}
|
{group.users.length}
|
||||||
</span>
|
</span>
|
||||||
@ -936,35 +934,19 @@ export default function UsersAdministration() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="flex items-center gap-x-2">
|
<span className="block truncate font-medium">
|
||||||
<span className="truncate font-medium">
|
{displayName}
|
||||||
{displayName}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{user.rights.includes('admin') && (
|
|
||||||
<span className="shrink-0 rounded-md bg-indigo-50 px-1.5 py-0.5 text-[10px] font-medium text-indigo-700 ring-1 ring-indigo-600/20 ring-inset dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20">
|
|
||||||
Admin
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className="block truncate text-xs text-gray-500 dark:text-gray-400">
|
<span className="block truncate text-xs text-gray-500 dark:text-gray-400">
|
||||||
{user.email}
|
{getUserRoleLabel(user.group)}
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="mt-1 flex flex-wrap gap-1">
|
|
||||||
{user.group && (
|
|
||||||
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-600 dark:bg-white/10 dark:text-gray-300">
|
|
||||||
{getUserRoleLabel(user.group)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -1073,7 +1055,7 @@ export default function UsersAdministration() {
|
|||||||
name={selectedUser ? getUserDisplayName(selectedUser) : 'Neuer Benutzer'}
|
name={selectedUser ? getUserDisplayName(selectedUser) : 'Neuer Benutzer'}
|
||||||
initials={selectedUser ? getUserInitials(selectedUser) : 'NB'}
|
initials={selectedUser ? getUserInitials(selectedUser) : 'NB'}
|
||||||
size={16}
|
size={16}
|
||||||
shape="rounded"
|
shape="circle"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
|||||||
@ -585,28 +585,23 @@ export default function CalendarPage() {
|
|||||||
await deleteEventSeries()
|
await deleteEventSeries()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const calendarStatus = pageError
|
||||||
|
? { tone: 'danger' as const, text: pageError }
|
||||||
|
: eventsLoading
|
||||||
|
? { tone: 'loading' as const, text: 'Kalendereinträge werden geladen ...' }
|
||||||
|
: null
|
||||||
|
|
||||||
const inputClassName =
|
const inputClassName =
|
||||||
'block w-full rounded-xl border-0 bg-gray-100 px-3 py-2.5 text-sm text-gray-900 outline-none ring-1 ring-transparent focus:bg-white focus:ring-indigo-500 dark:bg-white/5 dark:text-white dark:focus:bg-white/10'
|
'block w-full rounded-xl border-0 bg-gray-100 px-3 py-2.5 text-sm text-gray-900 outline-none ring-1 ring-transparent focus:bg-white focus:ring-indigo-500 dark:bg-white/5 dark:text-white dark:focus:bg-white/10'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex h-full min-h-0 flex-col">
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
{(pageError || eventsLoading) && (
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
'border-b px-4 py-2 text-sm sm:px-6',
|
|
||||||
pageError
|
|
||||||
? 'border-red-200 bg-red-50 text-red-700 dark:border-red-500/20 dark:bg-red-500/10 dark:text-red-300'
|
|
||||||
: 'border-gray-200 bg-gray-50 text-gray-500 dark:border-white/10 dark:bg-white/5 dark:text-gray-300',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{pageError || 'Kalendereinträge werden geladen ...'}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="min-h-0 flex-1">
|
<div className="min-h-0 flex-1">
|
||||||
<Calendar
|
<Calendar
|
||||||
events={events}
|
events={events}
|
||||||
coreWorkingHours={calendarSettings.coreWorkingHours}
|
coreWorkingHours={calendarSettings.coreWorkingHours}
|
||||||
|
status={calendarStatus}
|
||||||
selectedDate={selectedDate}
|
selectedDate={selectedDate}
|
||||||
view={view}
|
view={view}
|
||||||
onSelectedDateChange={setSelectedDate}
|
onSelectedDateChange={setSelectedDate}
|
||||||
@ -1049,7 +1044,7 @@ export default function CalendarPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-4 p-5">
|
<div className="space-y-4 p-5">
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||||
Soll die Änderung nur für diesen Termin oder für die ganze Serie
|
Soll die Änderung nur für diesen Termin oder für alle Termine
|
||||||
übernommen werden?
|
übernommen werden?
|
||||||
</p>
|
</p>
|
||||||
{formError && (
|
{formError && (
|
||||||
@ -1103,7 +1098,7 @@ export default function CalendarPage() {
|
|||||||
}}
|
}}
|
||||||
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-indigo-500 dark:hover:bg-indigo-400"
|
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-indigo-500 dark:hover:bg-indigo-400"
|
||||||
>
|
>
|
||||||
Ganze Serie
|
Alle Termine
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -12,7 +12,7 @@ import {
|
|||||||
import Modal from '../../components/Modal'
|
import Modal from '../../components/Modal'
|
||||||
import Button from '../../components/Button'
|
import Button from '../../components/Button'
|
||||||
import type { Device } from '../../components/types'
|
import type { Device } from '../../components/types'
|
||||||
import DeviceFormFields from './DeviceFormFields'
|
import DeviceFormFields, { type DeviceCategory } from './DeviceFormFields'
|
||||||
import DeviceModalTabs, { type DeviceModalTabId } from './DeviceModalTabs'
|
import DeviceModalTabs, { type DeviceModalTabId } from './DeviceModalTabs'
|
||||||
|
|
||||||
type CreateDeviceModalProps = {
|
type CreateDeviceModalProps = {
|
||||||
@ -20,6 +20,12 @@ type CreateDeviceModalProps = {
|
|||||||
setOpen: (open: boolean) => void
|
setOpen: (open: boolean) => void
|
||||||
isSaving: boolean
|
isSaving: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
submitLabel?: string
|
||||||
|
initialDeviceCategory?: DeviceCategory
|
||||||
|
lockedDeviceCategory?: DeviceCategory
|
||||||
|
hideDeviceCategorySelector?: boolean
|
||||||
devices: Device[]
|
devices: Device[]
|
||||||
relatedDeviceIds: string[]
|
relatedDeviceIds: string[]
|
||||||
onToggleRelatedDevice: (deviceId: string) => void
|
onToggleRelatedDevice: (deviceId: string) => void
|
||||||
@ -53,6 +59,12 @@ export default function CreateDeviceModal({
|
|||||||
setOpen,
|
setOpen,
|
||||||
isSaving,
|
isSaving,
|
||||||
error,
|
error,
|
||||||
|
title = 'Gerät hinzufügen',
|
||||||
|
description = 'Wähle die Geräteart und erfasse nur die passenden Daten.',
|
||||||
|
submitLabel = 'Gerät speichern',
|
||||||
|
initialDeviceCategory,
|
||||||
|
lockedDeviceCategory,
|
||||||
|
hideDeviceCategorySelector,
|
||||||
devices,
|
devices,
|
||||||
relatedDeviceIds,
|
relatedDeviceIds,
|
||||||
onToggleRelatedDevice,
|
onToggleRelatedDevice,
|
||||||
@ -120,11 +132,11 @@ export default function CreateDeviceModal({
|
|||||||
<div className="flex items-start justify-between border-b border-gray-200 bg-gray-100 px-4 py-4 sm:px-6 dark:border-white/10 dark:bg-gray-800">
|
<div className="flex items-start justify-between border-b border-gray-200 bg-gray-100 px-4 py-4 sm:px-6 dark:border-white/10 dark:bg-gray-800">
|
||||||
<div>
|
<div>
|
||||||
<DialogTitle className="text-base font-semibold text-gray-900 dark:text-white">
|
<DialogTitle className="text-base font-semibold text-gray-900 dark:text-white">
|
||||||
Gerät hinzufügen
|
{title}
|
||||||
</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">
|
||||||
Wähle die Geräteart und erfasse nur die passenden Daten.
|
{description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -157,6 +169,9 @@ export default function CreateDeviceModal({
|
|||||||
<DeviceFormFields
|
<DeviceFormFields
|
||||||
open={open}
|
open={open}
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
|
initialDeviceCategory={initialDeviceCategory}
|
||||||
|
lockedDeviceCategory={lockedDeviceCategory}
|
||||||
|
hideDeviceCategorySelector={hideDeviceCategorySelector}
|
||||||
devices={devices}
|
devices={devices}
|
||||||
relatedDeviceIds={relatedDeviceIds}
|
relatedDeviceIds={relatedDeviceIds}
|
||||||
onToggleRelatedDevice={onToggleRelatedDevice}
|
onToggleRelatedDevice={onToggleRelatedDevice}
|
||||||
@ -180,7 +195,7 @@ export default function CreateDeviceModal({
|
|||||||
isLoading={isSaving}
|
isLoading={isSaving}
|
||||||
leadingIcon={<CheckIcon aria-hidden="true" className="size-4" />}
|
leadingIcon={<CheckIcon aria-hidden="true" className="size-4" />}
|
||||||
>
|
>
|
||||||
Gerät speichern
|
{submitLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
41
frontend/src/pages/devices/DeviceCategoryPage.tsx
Normal file
41
frontend/src/pages/devices/DeviceCategoryPage.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import DevicesPage from './DevicesPage'
|
||||||
|
import type { DeviceCategory } from './DeviceFormFields'
|
||||||
|
import type { User } from '../../components/types'
|
||||||
|
|
||||||
|
type DeviceCategoryPageProps = {
|
||||||
|
currentUser?: User | null
|
||||||
|
category: DeviceCategory
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
singularLabel: string
|
||||||
|
emptyText: string
|
||||||
|
showFirmwareCheckButton?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeviceCategoryPage({
|
||||||
|
currentUser,
|
||||||
|
category,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
singularLabel,
|
||||||
|
emptyText,
|
||||||
|
showFirmwareCheckButton = false,
|
||||||
|
}: DeviceCategoryPageProps) {
|
||||||
|
return (
|
||||||
|
<DevicesPage
|
||||||
|
currentUser={currentUser}
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
createButtonLabel={`${singularLabel} hinzufügen`}
|
||||||
|
createModalTitle={`${singularLabel} hinzufügen`}
|
||||||
|
createModalDescription={`${singularLabel} erfassen und die passenden Gerätedaten pflegen.`}
|
||||||
|
createSubmitLabel={`${singularLabel} speichern`}
|
||||||
|
editModalTitle={`${singularLabel} bearbeiten`}
|
||||||
|
emptyText={emptyText}
|
||||||
|
loadingLabel={`${title} werden geladen...`}
|
||||||
|
deviceCategoryFilter={category}
|
||||||
|
showFirmwareCheckButton={showFirmwareCheckButton}
|
||||||
|
showStats={false}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -23,10 +23,10 @@ import MilestoneChildDeviceCards from './MilestoneChildDeviceCards'
|
|||||||
import type { Device } from '../../components/types'
|
import type { Device } from '../../components/types'
|
||||||
import {
|
import {
|
||||||
ArrowsRightLeftIcon,
|
ArrowsRightLeftIcon,
|
||||||
CircleStackIcon,
|
|
||||||
ComputerDesktopIcon,
|
ComputerDesktopIcon,
|
||||||
MagnifyingGlassIcon,
|
MagnifyingGlassIcon,
|
||||||
MicrophoneIcon,
|
MicrophoneIcon,
|
||||||
|
QuestionMarkCircleIcon,
|
||||||
VideoCameraIcon,
|
VideoCameraIcon,
|
||||||
WifiIcon,
|
WifiIcon,
|
||||||
} from '@heroicons/react/20/solid'
|
} from '@heroicons/react/20/solid'
|
||||||
@ -37,6 +37,9 @@ type DeviceFormFieldsProps = {
|
|||||||
open: boolean
|
open: boolean
|
||||||
activeTab: DeviceModalTabId
|
activeTab: DeviceModalTabId
|
||||||
device?: Device | null
|
device?: Device | null
|
||||||
|
initialDeviceCategory?: DeviceCategory
|
||||||
|
lockedDeviceCategory?: DeviceCategory
|
||||||
|
hideDeviceCategorySelector?: boolean
|
||||||
devices: Device[]
|
devices: Device[]
|
||||||
relatedDeviceIds: string[]
|
relatedDeviceIds: string[]
|
||||||
onToggleRelatedDevice: (deviceId: string) => void
|
onToggleRelatedDevice: (deviceId: string) => void
|
||||||
@ -63,7 +66,7 @@ type DeviceFormFieldsProps = {
|
|||||||
export const inputClassName =
|
export const inputClassName =
|
||||||
'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'
|
'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'
|
||||||
|
|
||||||
type DeviceCategory = 'Kameras' | 'Router' | 'Switchbox' | 'Laptop' | 'Festplatte'
|
export type DeviceCategory = 'Kameras' | 'Router' | 'Switchbox' | 'Laptop' | 'Andere'
|
||||||
|
|
||||||
type DeviceLoanUser = {
|
type DeviceLoanUser = {
|
||||||
id: string
|
id: string
|
||||||
@ -119,10 +122,10 @@ const deviceKindOptions: DeviceKindOption[] = [
|
|||||||
icon: ComputerDesktopIcon,
|
icon: ComputerDesktopIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: 'Festplatte',
|
category: 'Andere',
|
||||||
label: 'Festplatte',
|
label: 'Andere',
|
||||||
description: 'Modell und Seriennummer genügen meistens.',
|
description: 'Allgemeine Gerätedaten erfassen.',
|
||||||
icon: CircleStackIcon,
|
icon: QuestionMarkCircleIcon,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -130,17 +133,29 @@ 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 {
|
function getInitialDeviceCategory(
|
||||||
|
device: Device | null | undefined,
|
||||||
|
isMilestoneDevice: boolean,
|
||||||
|
fallbackCategory?: DeviceCategory,
|
||||||
|
): DeviceCategory {
|
||||||
if (isMilestoneDevice) {
|
if (isMilestoneDevice) {
|
||||||
return 'Kameras'
|
return 'Kameras'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (fallbackCategory) {
|
||||||
|
return fallbackCategory
|
||||||
|
}
|
||||||
|
|
||||||
const category = device?.deviceCategory?.trim()
|
const category = device?.deviceCategory?.trim()
|
||||||
|
|
||||||
if (deviceKindOptions.some((option) => option.category === category)) {
|
if (deviceKindOptions.some((option) => option.category === category)) {
|
||||||
return category as DeviceCategory
|
return category as DeviceCategory
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (category) {
|
||||||
|
return 'Andere'
|
||||||
|
}
|
||||||
|
|
||||||
return 'Kameras'
|
return 'Kameras'
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -259,6 +274,7 @@ async function createLookupOption(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
clearDeviceLookupCache()
|
||||||
return data.option as ComboboxItem
|
return data.option as ComboboxItem
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -291,6 +307,11 @@ type DeviceLookupData = {
|
|||||||
let deviceLookupCache: DeviceLookupData | null = null
|
let deviceLookupCache: DeviceLookupData | null = null
|
||||||
let deviceLookupPromise: Promise<DeviceLookupData> | null = null
|
let deviceLookupPromise: Promise<DeviceLookupData> | null = null
|
||||||
|
|
||||||
|
export function clearDeviceLookupCache() {
|
||||||
|
deviceLookupCache = null
|
||||||
|
deviceLookupPromise = null
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchDeviceLookups() {
|
async function fetchDeviceLookups() {
|
||||||
if (deviceLookupCache) {
|
if (deviceLookupCache) {
|
||||||
return deviceLookupCache
|
return deviceLookupCache
|
||||||
@ -366,6 +387,9 @@ export default function DeviceFormFields({
|
|||||||
open,
|
open,
|
||||||
activeTab,
|
activeTab,
|
||||||
device,
|
device,
|
||||||
|
initialDeviceCategory,
|
||||||
|
lockedDeviceCategory,
|
||||||
|
hideDeviceCategorySelector = false,
|
||||||
devices,
|
devices,
|
||||||
relatedDeviceIds,
|
relatedDeviceIds,
|
||||||
onToggleRelatedDevice,
|
onToggleRelatedDevice,
|
||||||
@ -392,7 +416,11 @@ export default function DeviceFormFields({
|
|||||||
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>(() =>
|
const [selectedDeviceCategory, setSelectedDeviceCategory] = useState<DeviceCategory>(() =>
|
||||||
getInitialDeviceCategory(device, isMilestoneDevice),
|
getInitialDeviceCategory(
|
||||||
|
device,
|
||||||
|
isMilestoneDevice,
|
||||||
|
lockedDeviceCategory ?? initialDeviceCategory,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
const [selectedLoanStatus, setSelectedLoanStatus] = useState(device?.loanStatus || 'Verfügbar')
|
const [selectedLoanStatus, setSelectedLoanStatus] = useState(device?.loanStatus || 'Verfügbar')
|
||||||
const [loanUsers, setLoanUsers] = useState<ComboboxItem[]>([])
|
const [loanUsers, setLoanUsers] = useState<ComboboxItem[]>([])
|
||||||
@ -428,7 +456,13 @@ export default function DeviceFormFields({
|
|||||||
: null,
|
: null,
|
||||||
)
|
)
|
||||||
|
|
||||||
setSelectedDeviceCategory(getInitialDeviceCategory(device, isMilestoneDevice))
|
setSelectedDeviceCategory(
|
||||||
|
getInitialDeviceCategory(
|
||||||
|
device,
|
||||||
|
isMilestoneDevice,
|
||||||
|
lockedDeviceCategory ?? initialDeviceCategory,
|
||||||
|
),
|
||||||
|
)
|
||||||
setSelectedLoanStatus(device?.loanStatus || 'Verfügbar')
|
setSelectedLoanStatus(device?.loanStatus || 'Verfügbar')
|
||||||
setLoanedUntil(device?.loanedUntil ?? '')
|
setLoanedUntil(device?.loanedUntil ?? '')
|
||||||
setIsLoanModalOpen(false)
|
setIsLoanModalOpen(false)
|
||||||
@ -437,7 +471,7 @@ export default function DeviceFormFields({
|
|||||||
setLoanModalError(null)
|
setLoanModalError(null)
|
||||||
|
|
||||||
void loadDeviceLookups()
|
void loadDeviceLookups()
|
||||||
}, [open, device?.id, isMilestoneDevice])
|
}, [open, device?.id, initialDeviceCategory, isMilestoneDevice, lockedDeviceCategory])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
@ -483,7 +517,10 @@ export default function DeviceFormFields({
|
|||||||
|
|
||||||
const milestoneChildDevices = device?.milestoneChildDevices ?? []
|
const milestoneChildDevices = device?.milestoneChildDevices ?? []
|
||||||
|
|
||||||
const visibleCategoryFields = getCategoryFieldVisibility(selectedDeviceCategory)
|
const effectiveDeviceCategory = isMilestoneDevice
|
||||||
|
? 'Kameras'
|
||||||
|
: lockedDeviceCategory ?? selectedDeviceCategory
|
||||||
|
const visibleCategoryFields = getCategoryFieldVisibility(effectiveDeviceCategory)
|
||||||
const loanedToLabel =
|
const loanedToLabel =
|
||||||
selectedLoanStatus === 'Ausgeliehen'
|
selectedLoanStatus === 'Ausgeliehen'
|
||||||
? selectedLoanUser?.name || device?.loanedToDisplayName || ''
|
? selectedLoanUser?.name || device?.loanedToDisplayName || ''
|
||||||
@ -780,63 +817,65 @@ export default function DeviceFormFields({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sm:col-span-6">
|
<div className={hideDeviceCategorySelector ? 'hidden' : 'sm:col-span-6'}>
|
||||||
<input
|
<input
|
||||||
type="hidden"
|
type="hidden"
|
||||||
name="deviceCategory"
|
name="deviceCategory"
|
||||||
value={isMilestoneDevice ? 'Kameras' : selectedDeviceCategory}
|
value={effectiveDeviceCategory}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<fieldset disabled={isMilestoneDevice}>
|
{!hideDeviceCategorySelector && (
|
||||||
<legend className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
<fieldset disabled={isMilestoneDevice || Boolean(lockedDeviceCategory)}>
|
||||||
Geräteart
|
<legend className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||||
</legend>
|
Geräteart
|
||||||
|
</legend>
|
||||||
|
|
||||||
<div className="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-5">
|
<div className="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-5">
|
||||||
{deviceKindOptions.map((option) => {
|
{deviceKindOptions.map((option) => {
|
||||||
const selected = selectedDeviceCategory === option.category
|
const selected = effectiveDeviceCategory === option.category
|
||||||
const Icon = option.icon
|
const Icon = option.icon
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={option.category}
|
key={option.category}
|
||||||
type="button"
|
type="button"
|
||||||
aria-pressed={selected}
|
aria-pressed={selected}
|
||||||
disabled={isMilestoneDevice}
|
disabled={isMilestoneDevice || Boolean(lockedDeviceCategory)}
|
||||||
onClick={() => setSelectedDeviceCategory(option.category)}
|
onClick={() => setSelectedDeviceCategory(option.category)}
|
||||||
className={classNames(
|
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',
|
'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
|
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-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',
|
: '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',
|
isMilestoneDevice && !selected && 'opacity-45',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="flex w-full items-center gap-2">
|
<span className="flex w-full items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'flex size-8 shrink-0 items-center justify-center rounded-md',
|
'flex size-8 shrink-0 items-center justify-center rounded-md',
|
||||||
selected
|
selected
|
||||||
? 'bg-indigo-600 text-white dark:bg-indigo-400 dark:text-gray-950'
|
? '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',
|
: '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" />
|
<Icon aria-hidden="true" className="size-4" />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="min-w-0 truncate text-sm font-semibold">
|
||||||
|
{option.label}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className="min-w-0 truncate text-sm font-semibold">
|
<span className="mt-2 text-xs leading-4 text-gray-500 dark:text-gray-400">
|
||||||
{option.label}
|
{option.description}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</button>
|
||||||
|
)
|
||||||
<span className="mt-2 text-xs leading-4 text-gray-500 dark:text-gray-400">
|
})}
|
||||||
{option.description}
|
</div>
|
||||||
</span>
|
</fieldset>
|
||||||
</button>
|
)}
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sm:col-span-3">
|
<div className="sm:col-span-3">
|
||||||
|
|||||||
@ -1,12 +1,23 @@
|
|||||||
// frontend\src\pages\devices\DevicesPage.tsx
|
// frontend\src\pages\devices\DevicesPage.tsx
|
||||||
|
|
||||||
import { useEffect, useRef, useState, type ComponentProps, type FormEvent, type MouseEvent } from 'react'
|
import {
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ComponentProps,
|
||||||
|
type FormEvent,
|
||||||
|
type MouseEvent,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react'
|
||||||
import {
|
import {
|
||||||
ArrowDownTrayIcon,
|
ArrowDownTrayIcon,
|
||||||
ArrowPathIcon,
|
ArrowPathIcon,
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
|
TagIcon,
|
||||||
|
TrashIcon,
|
||||||
WrenchScrewdriverIcon,
|
WrenchScrewdriverIcon,
|
||||||
XCircleIcon,
|
XCircleIcon,
|
||||||
} from '@heroicons/react/20/solid'
|
} from '@heroicons/react/20/solid'
|
||||||
@ -15,8 +26,9 @@ import { Badge } from '../../components/Badge'
|
|||||||
import CreateDeviceModal from './CreateDeviceModal'
|
import CreateDeviceModal from './CreateDeviceModal'
|
||||||
import EditDeviceModal from './EditDeviceModal'
|
import EditDeviceModal from './EditDeviceModal'
|
||||||
import CameraDetailsModal from './CameraDetailsModal'
|
import CameraDetailsModal from './CameraDetailsModal'
|
||||||
import Tables, { type TableColumn } from '../../components/Tables'
|
import Tables, { type TableColumn, type TableSortValue } from '../../components/Tables'
|
||||||
import LoadingSpinner from '../../components/LoadingSpinner'
|
import LoadingSpinner from '../../components/LoadingSpinner'
|
||||||
|
import Modal, { ModalTitle } from '../../components/Modal'
|
||||||
import type {
|
import type {
|
||||||
Device,
|
Device,
|
||||||
DeviceHistoryEntry,
|
DeviceHistoryEntry,
|
||||||
@ -26,6 +38,12 @@ import type {
|
|||||||
User,
|
User,
|
||||||
} from '../../components/types'
|
} from '../../components/types'
|
||||||
import { useNotifications } from '../../components/Notifications'
|
import { useNotifications } from '../../components/Notifications'
|
||||||
|
import { hasRight } from '../../components/permissions'
|
||||||
|
import {
|
||||||
|
clearDeviceLookupCache,
|
||||||
|
inputClassName,
|
||||||
|
type DeviceCategory,
|
||||||
|
} from './DeviceFormFields'
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||||
|
|
||||||
@ -37,6 +55,91 @@ type FirmwareCheckState = {
|
|||||||
remainingSeconds?: number
|
remainingSeconds?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DeviceLookupOption = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceSortDirection = 'asc' | 'desc'
|
||||||
|
|
||||||
|
const deviceSortCollator = new Intl.Collator('de-DE', {
|
||||||
|
numeric: true,
|
||||||
|
sensitivity: 'base',
|
||||||
|
})
|
||||||
|
|
||||||
|
function normalizeLookupName(value: string) {
|
||||||
|
return value.trim().toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDeviceSortValue(value: TableSortValue) {
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return value.getTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value ? 1 : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareDeviceSortValues(leftValue: TableSortValue, rightValue: TableSortValue) {
|
||||||
|
const left = normalizeDeviceSortValue(leftValue)
|
||||||
|
const right = normalizeDeviceSortValue(rightValue)
|
||||||
|
|
||||||
|
if (left === right) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (left === null || left === undefined || left === '') {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (right === null || right === undefined || right === '') {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof left === 'number' && typeof right === 'number') {
|
||||||
|
return left - right
|
||||||
|
}
|
||||||
|
|
||||||
|
return deviceSortCollator.compare(String(left), String(right))
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortLookupOptions(options: DeviceLookupOption[]) {
|
||||||
|
return [...options].sort((left, right) =>
|
||||||
|
left.name.localeCompare(right.name, 'de-DE', {
|
||||||
|
numeric: true,
|
||||||
|
sensitivity: 'base',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertLookupOption(options: DeviceLookupOption[], option: DeviceLookupOption) {
|
||||||
|
const normalizedName = normalizeLookupName(option.name)
|
||||||
|
const withoutDuplicate = options.filter(
|
||||||
|
(currentOption) => normalizeLookupName(currentOption.name) !== normalizedName,
|
||||||
|
)
|
||||||
|
|
||||||
|
return sortLookupOptions([...withoutDuplicate, option])
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDeviceUsageCount(count: number) {
|
||||||
|
if (count === 0) {
|
||||||
|
return 'Nicht verwendet'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count === 1) {
|
||||||
|
return '1 Gerät'
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${count.toLocaleString('de-DE')} Geräte`
|
||||||
|
}
|
||||||
|
|
||||||
function getLoanStatusBadgeIcon(status: string) {
|
function getLoanStatusBadgeIcon(status: string) {
|
||||||
const normalizedStatus = status.toLowerCase()
|
const normalizedStatus = status.toLowerCase()
|
||||||
|
|
||||||
@ -246,15 +349,93 @@ function getAvailableResolutions(
|
|||||||
|
|
||||||
type DevicesPageProps = {
|
type DevicesPageProps = {
|
||||||
currentUser?: User | null
|
currentUser?: User | null
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
createButtonLabel?: string
|
||||||
|
createModalTitle?: string
|
||||||
|
createModalDescription?: string
|
||||||
|
createSubmitLabel?: string
|
||||||
|
editModalTitle?: string
|
||||||
|
emptyText?: string
|
||||||
|
loadingLabel?: string
|
||||||
|
deviceCategoryFilter?: DeviceCategory
|
||||||
|
showFirmwareCheckButton?: boolean
|
||||||
|
showStats?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DevicesPage({ currentUser }: DevicesPageProps = {}) {
|
function matchesDeviceCategory(device: Device, category: DeviceCategory) {
|
||||||
|
return (device.deviceCategory ?? '').trim().toLowerCase() === category.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLoanedDevice(device: Device) {
|
||||||
|
const status = (device.loanStatus ?? '').toLowerCase()
|
||||||
|
return status.includes('ausgeliehen') || status.includes('verliehen')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUnavailableDevice(device: Device) {
|
||||||
|
const status = (device.loanStatus ?? '').toLowerCase()
|
||||||
|
return (
|
||||||
|
status.includes('defekt') ||
|
||||||
|
status.includes('verloren') ||
|
||||||
|
status.includes('wartung') ||
|
||||||
|
status.includes('gesperrt') ||
|
||||||
|
status.includes('deaktiviert')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDeviceCount(value: number) {
|
||||||
|
return value.toLocaleString('de-DE')
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeviceStatCard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
detail,
|
||||||
|
icon,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
value: string
|
||||||
|
detail: string
|
||||||
|
icon: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-4 shadow-xs dark:border-white/10 dark:bg-gray-900">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
<span className="text-indigo-500 dark:text-indigo-400">{icon}</span>
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DevicesPage({
|
||||||
|
currentUser,
|
||||||
|
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.',
|
||||||
|
createButtonLabel = 'Gerät hinzufügen',
|
||||||
|
createModalTitle = 'Gerät hinzufügen',
|
||||||
|
createModalDescription = 'Wähle die Geräteart und erfasse nur die passenden Daten.',
|
||||||
|
createSubmitLabel = 'Gerät speichern',
|
||||||
|
editModalTitle = 'Gerät bearbeiten',
|
||||||
|
emptyText = 'Keine Geräte vorhanden.',
|
||||||
|
loadingLabel = 'Geräteliste wird geladen...',
|
||||||
|
deviceCategoryFilter,
|
||||||
|
showFirmwareCheckButton = true,
|
||||||
|
showStats = true,
|
||||||
|
}: 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)
|
||||||
const [activeModal, setActiveModal] = useState<'create' | 'edit' | null>(null)
|
const [activeModal, setActiveModal] = useState<'create' | 'edit' | null>(null)
|
||||||
const [editingDevice, setEditingDevice] = useState<Device | null>(null)
|
const [editingDevice, setEditingDevice] = useState<Device | null>(null)
|
||||||
const [isCreating, setIsCreating] = useState(false)
|
const [isCreating, setIsCreating] = useState(false)
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
const [createError, setCreateError] = useState<string | null>(null)
|
const [createError, setCreateError] = useState<string | null>(null)
|
||||||
const [relatedDeviceIds, setRelatedDeviceIds] = useState<string[]>([])
|
const [relatedDeviceIds, setRelatedDeviceIds] = useState<string[]>([])
|
||||||
const [isJournalSubmitting, setIsJournalSubmitting] = useState(false)
|
const [isJournalSubmitting, setIsJournalSubmitting] = useState(false)
|
||||||
@ -267,8 +448,35 @@ export default function DevicesPage({ currentUser }: DevicesPageProps = {}) {
|
|||||||
const [isCheckingFirmware, setIsCheckingFirmware] = useState(false)
|
const [isCheckingFirmware, setIsCheckingFirmware] = useState(false)
|
||||||
const [firmwareCheckState, setFirmwareCheckState] = useState<FirmwareCheckState | null>(null)
|
const [firmwareCheckState, setFirmwareCheckState] = useState<FirmwareCheckState | null>(null)
|
||||||
const [firmwareCheckNow, setFirmwareCheckNow] = useState(() => Date.now())
|
const [firmwareCheckNow, setFirmwareCheckNow] = useState(() => Date.now())
|
||||||
|
const [sortKey, setSortKey] = useState('inventoryNumber')
|
||||||
|
const [sortDirection, setSortDirection] = useState<DeviceSortDirection>('asc')
|
||||||
|
const [isManufacturerModalOpen, setIsManufacturerModalOpen] = useState(false)
|
||||||
|
const [manufacturerOptions, setManufacturerOptions] = useState<DeviceLookupOption[]>([])
|
||||||
|
const [isManufacturerLoading, setIsManufacturerLoading] = useState(false)
|
||||||
|
const [manufacturerError, setManufacturerError] = useState<string | null>(null)
|
||||||
|
const [newManufacturerName, setNewManufacturerName] = useState('')
|
||||||
|
const [isSavingManufacturer, setIsSavingManufacturer] = useState(false)
|
||||||
|
const [deletingManufacturerId, setDeletingManufacturerId] = useState<string | null>(null)
|
||||||
|
const [manufacturerToDelete, setManufacturerToDelete] = useState<DeviceLookupOption | null>(null)
|
||||||
const closeCleanupTimeoutRef = useRef<number | null>(null)
|
const closeCleanupTimeoutRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
const canManageManufacturers = hasRight(currentUser, 'devices:write')
|
||||||
|
const manufacturerUsageCounts = useMemo(() => {
|
||||||
|
const counts = new Map<string, number>()
|
||||||
|
|
||||||
|
for (const device of devices) {
|
||||||
|
const manufacturer = normalizeLookupName(device.manufacturer ?? '')
|
||||||
|
|
||||||
|
if (!manufacturer) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
counts.set(manufacturer, (counts.get(manufacturer) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return counts
|
||||||
|
}, [devices])
|
||||||
|
|
||||||
const [togglingMilestoneCameraRecordingIds, setTogglingMilestoneCameraRecordingIds] = useState<Set<string>>(() => new Set())
|
const [togglingMilestoneCameraRecordingIds, setTogglingMilestoneCameraRecordingIds] = useState<Set<string>>(() => new Set())
|
||||||
|
|
||||||
const [isCameraDetailsOpen, setIsCameraDetailsOpen] = useState(false)
|
const [isCameraDetailsOpen, setIsCameraDetailsOpen] = useState(false)
|
||||||
@ -798,6 +1006,149 @@ export default function DevicesPage({ currentUser }: DevicesPageProps = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadManufacturerOptions() {
|
||||||
|
setIsManufacturerLoading(true)
|
||||||
|
setManufacturerError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/device-lookups`, {
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
await readApiError(response, 'Hersteller konnten nicht geladen werden'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
setManufacturerOptions(sortLookupOptions(data.manufacturers ?? []))
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Hersteller konnten nicht geladen werden'
|
||||||
|
|
||||||
|
setManufacturerError(message)
|
||||||
|
showErrorToast({
|
||||||
|
title: 'Hersteller konnten nicht geladen werden',
|
||||||
|
error,
|
||||||
|
fallback: 'Hersteller konnten nicht geladen werden',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsManufacturerLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openManufacturerModal() {
|
||||||
|
setManufacturerError(null)
|
||||||
|
setNewManufacturerName('')
|
||||||
|
setIsManufacturerModalOpen(true)
|
||||||
|
void loadManufacturerOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateManufacturer(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
const name = newManufacturerName.trim()
|
||||||
|
if (!name) {
|
||||||
|
setManufacturerError('Hersteller ist erforderlich.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSavingManufacturer(true)
|
||||||
|
setManufacturerError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/device-manufacturers`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
await readApiError(response, 'Hersteller konnte nicht gespeichert werden'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
const option = data.option as DeviceLookupOption
|
||||||
|
|
||||||
|
clearDeviceLookupCache()
|
||||||
|
setManufacturerOptions((currentOptions) =>
|
||||||
|
upsertLookupOption(currentOptions, option),
|
||||||
|
)
|
||||||
|
setNewManufacturerName('')
|
||||||
|
|
||||||
|
showToast({
|
||||||
|
variant: 'success',
|
||||||
|
title: 'Hersteller gespeichert',
|
||||||
|
message: `${option.name} wurde zur Auswahlliste hinzugefügt.`,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Hersteller konnte nicht gespeichert werden'
|
||||||
|
|
||||||
|
setManufacturerError(message)
|
||||||
|
showErrorToast({
|
||||||
|
title: 'Hersteller konnte nicht gespeichert werden',
|
||||||
|
error,
|
||||||
|
fallback: 'Hersteller konnte nicht gespeichert werden',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsSavingManufacturer(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteManufacturer(option: DeviceLookupOption) {
|
||||||
|
setDeletingManufacturerId(option.id)
|
||||||
|
setManufacturerError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/device-manufacturers/${option.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
await readApiError(response, 'Hersteller konnte nicht gelöscht werden'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
clearDeviceLookupCache()
|
||||||
|
setManufacturerOptions((currentOptions) =>
|
||||||
|
currentOptions.filter((currentOption) => currentOption.id !== option.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
showToast({
|
||||||
|
variant: 'success',
|
||||||
|
title: 'Hersteller gelöscht',
|
||||||
|
message: `${option.name} wurde aus der Auswahlliste entfernt.`,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Hersteller konnte nicht gelöscht werden'
|
||||||
|
|
||||||
|
setManufacturerError(message)
|
||||||
|
showErrorToast({
|
||||||
|
title: 'Hersteller konnte nicht gelöscht werden',
|
||||||
|
error,
|
||||||
|
fallback: 'Hersteller konnte nicht gelöscht werden',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setDeletingManufacturerId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadDeviceHistory(deviceId: string) {
|
async function loadDeviceHistory(deviceId: string) {
|
||||||
setIsHistoryLoading(true)
|
setIsHistoryLoading(true)
|
||||||
setHistoryError(null)
|
setHistoryError(null)
|
||||||
@ -1341,7 +1692,58 @@ export default function DevicesPage({ currentUser }: DevicesPageProps = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleDeleteDevice(device: Device) {
|
||||||
|
if (device.milestoneHardwareId || device.milestoneRecordingServerId) {
|
||||||
|
setCreateError('Milestone-Geräte können nicht gelöscht werden.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsDeleting(true)
|
||||||
|
setCreateError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/devices/${device.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
await readApiError(response, 'Gerät konnte nicht gelöscht werden'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast({
|
||||||
|
variant: 'success',
|
||||||
|
title: 'Gerät gelöscht',
|
||||||
|
message: `Gerät ${device.inventoryNumber || device.model || device.id} wurde gelöscht.`,
|
||||||
|
})
|
||||||
|
|
||||||
|
closeModal()
|
||||||
|
await loadDevices()
|
||||||
|
} catch (error) {
|
||||||
|
setCreateError(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Gerät konnte nicht gelöscht werden',
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const firmwareCheckNextAllowedAt = firmwareCheckState?.nextAllowedAt
|
const firmwareCheckNextAllowedAt = firmwareCheckState?.nextAllowedAt
|
||||||
|
const visibleDevices = deviceCategoryFilter
|
||||||
|
? devices.filter((device) => matchesDeviceCategory(device, deviceCategoryFilter))
|
||||||
|
: devices
|
||||||
|
const loanedDevices = visibleDevices.filter(isLoanedDevice)
|
||||||
|
const unavailableDevices = visibleDevices.filter(isUnavailableDevice)
|
||||||
|
const availableDevices = visibleDevices.filter(
|
||||||
|
(device) => !isLoanedDevice(device) && !isUnavailableDevice(device),
|
||||||
|
)
|
||||||
|
const milestoneDevices = visibleDevices.filter((device) =>
|
||||||
|
Boolean(device.milestoneHardwareId || device.milestoneRecordingServerId),
|
||||||
|
)
|
||||||
const isFirmwareCheckLocked =
|
const isFirmwareCheckLocked =
|
||||||
Boolean(firmwareCheckNextAllowedAt) &&
|
Boolean(firmwareCheckNextAllowedAt) &&
|
||||||
new Date(firmwareCheckNextAllowedAt as string).getTime() > firmwareCheckNow
|
new Date(firmwareCheckNextAllowedAt as string).getTime() > firmwareCheckNow
|
||||||
@ -1357,11 +1759,30 @@ export default function DevicesPage({ currentUser }: DevicesPageProps = {}) {
|
|||||||
}`
|
}`
|
||||||
: null
|
: null
|
||||||
|
|
||||||
|
function handleSort(column: TableColumn<Device>) {
|
||||||
|
if (!column.sortable) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortKey === column.key) {
|
||||||
|
setSortDirection((currentDirection) =>
|
||||||
|
currentDirection === 'asc' ? 'desc' : 'asc',
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSortKey(column.key)
|
||||||
|
setSortDirection('asc')
|
||||||
|
}
|
||||||
|
|
||||||
const columns: TableColumn<Device>[] = [
|
const columns: TableColumn<Device>[] = [
|
||||||
{
|
{
|
||||||
key: 'inventoryNumber',
|
key: 'inventoryNumber',
|
||||||
header: 'Inventur-Nr.',
|
header: 'Inventur-Nr.',
|
||||||
isPrimary: true,
|
isPrimary: true,
|
||||||
|
sortable: true,
|
||||||
|
width: '16%',
|
||||||
|
sortValue: (device) => device.inventoryNumber,
|
||||||
render: (device) => (
|
render: (device) => (
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="font-medium text-gray-900 dark:text-white">
|
<div className="font-medium text-gray-900 dark:text-white">
|
||||||
@ -1389,6 +1810,16 @@ export default function DevicesPage({ currentUser }: DevicesPageProps = {}) {
|
|||||||
{
|
{
|
||||||
key: 'model',
|
key: 'model',
|
||||||
header: 'Gerät',
|
header: 'Gerät',
|
||||||
|
sortable: true,
|
||||||
|
width: '30%',
|
||||||
|
sortValue: (device) =>
|
||||||
|
[
|
||||||
|
device.milestoneDisplayName,
|
||||||
|
device.manufacturer,
|
||||||
|
device.model,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' '),
|
||||||
render: (device) => {
|
render: (device) => {
|
||||||
const title = [device.manufacturer, device.model]
|
const title = [device.manufacturer, device.model]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@ -1473,17 +1904,26 @@ export default function DevicesPage({ currentUser }: DevicesPageProps = {}) {
|
|||||||
key: 'ipAddress',
|
key: 'ipAddress',
|
||||||
header: 'IP-Adresse',
|
header: 'IP-Adresse',
|
||||||
hideOnMobile: 'md',
|
hideOnMobile: 'md',
|
||||||
|
sortable: true,
|
||||||
|
width: '14%',
|
||||||
|
sortValue: (device) => device.ipAddress,
|
||||||
render: (device) => device.ipAddress || '-',
|
render: (device) => device.ipAddress || '-',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'location',
|
key: 'location',
|
||||||
header: 'Standort',
|
header: 'Standort',
|
||||||
hideOnMobile: 'lg',
|
hideOnMobile: 'lg',
|
||||||
|
sortable: true,
|
||||||
|
width: '12%',
|
||||||
|
sortValue: (device) => device.location,
|
||||||
render: (device) => device.location || '-',
|
render: (device) => device.location || '-',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'loanStatus',
|
key: 'loanStatus',
|
||||||
header: 'Verleih',
|
header: 'Verleih',
|
||||||
|
sortable: true,
|
||||||
|
width: '13%',
|
||||||
|
sortValue: (device) => device.loanStatus || 'Verfügbar',
|
||||||
render: (device) => (
|
render: (device) => (
|
||||||
<Badge
|
<Badge
|
||||||
tone={getLoanStatusBadgeTone(device.loanStatus)}
|
tone={getLoanStatusBadgeTone(device.loanStatus)}
|
||||||
@ -1498,6 +1938,14 @@ export default function DevicesPage({ currentUser }: DevicesPageProps = {}) {
|
|||||||
key: 'milestoneEnabled',
|
key: 'milestoneEnabled',
|
||||||
header: 'Milestone',
|
header: 'Milestone',
|
||||||
hideOnMobile: 'lg',
|
hideOnMobile: 'lg',
|
||||||
|
sortable: true,
|
||||||
|
width: '10%',
|
||||||
|
sortValue: (device) =>
|
||||||
|
device.milestoneHardwareId
|
||||||
|
? device.milestoneEnabled
|
||||||
|
? 2
|
||||||
|
: 1
|
||||||
|
: 0,
|
||||||
render: (device) => {
|
render: (device) => {
|
||||||
if (!device.milestoneHardwareId) {
|
if (!device.milestoneHardwareId) {
|
||||||
return '-'
|
return '-'
|
||||||
@ -1544,6 +1992,10 @@ export default function DevicesPage({ currentUser }: DevicesPageProps = {}) {
|
|||||||
key: 'isBaoDevice',
|
key: 'isBaoDevice',
|
||||||
header: 'BAO',
|
header: 'BAO',
|
||||||
hideOnMobile: 'lg',
|
hideOnMobile: 'lg',
|
||||||
|
sortable: true,
|
||||||
|
align: 'center',
|
||||||
|
width: '5%',
|
||||||
|
sortValue: (device) => device.isBaoDevice,
|
||||||
render: (device) =>
|
render: (device) =>
|
||||||
device.isBaoDevice ? (
|
device.isBaoDevice ? (
|
||||||
<CheckCircleIcon
|
<CheckCircleIcon
|
||||||
@ -1559,27 +2011,94 @@ export default function DevicesPage({ currentUser }: DevicesPageProps = {}) {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const sortedVisibleDevices = (() => {
|
||||||
|
const sortColumn = columns.find((column) => column.key === sortKey)
|
||||||
|
|
||||||
|
if (!sortColumn?.sortable || !sortColumn.sortValue) {
|
||||||
|
return visibleDevices
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...visibleDevices].sort((leftDevice, rightDevice) => {
|
||||||
|
const result = compareDeviceSortValues(
|
||||||
|
sortColumn.sortValue?.(leftDevice),
|
||||||
|
sortColumn.sortValue?.(rightDevice),
|
||||||
|
)
|
||||||
|
|
||||||
|
return sortDirection === 'asc' ? result : -result
|
||||||
|
})
|
||||||
|
})()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full min-h-0 overflow-y-auto px-4 py-10 sm:px-6 lg:px-8" >
|
<div className="h-full min-h-0 overflow-hidden px-4 py-10 sm:px-6 lg:px-8">
|
||||||
|
{showStats && (
|
||||||
|
<div className="mb-6 grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
|
||||||
|
<DeviceStatCard
|
||||||
|
title="Gesamt"
|
||||||
|
value={formatDeviceCount(visibleDevices.length)}
|
||||||
|
detail="erfasste Geräte"
|
||||||
|
icon={<WrenchScrewdriverIcon aria-hidden="true" className="size-5" />}
|
||||||
|
/>
|
||||||
|
<DeviceStatCard
|
||||||
|
title="Verfügbar"
|
||||||
|
value={formatDeviceCount(availableDevices.length)}
|
||||||
|
detail="einsatzbereit"
|
||||||
|
icon={<CheckCircleIcon aria-hidden="true" className="size-5" />}
|
||||||
|
/>
|
||||||
|
<DeviceStatCard
|
||||||
|
title="Ausgeliehen"
|
||||||
|
value={formatDeviceCount(loanedDevices.length)}
|
||||||
|
detail="aktuell im Verleih"
|
||||||
|
icon={<ClockIcon aria-hidden="true" className="size-5" />}
|
||||||
|
/>
|
||||||
|
<DeviceStatCard
|
||||||
|
title="Nicht einsatzbereit"
|
||||||
|
value={formatDeviceCount(unavailableDevices.length)}
|
||||||
|
detail="Wartung, Defekt oder gesperrt"
|
||||||
|
icon={<XCircleIcon aria-hidden="true" className="size-5" />}
|
||||||
|
/>
|
||||||
|
<DeviceStatCard
|
||||||
|
title="Milestone"
|
||||||
|
value={formatDeviceCount(milestoneDevices.length)}
|
||||||
|
detail="synchronisierte Geräte"
|
||||||
|
icon={<CheckCircleIcon aria-hidden="true" className="size-5" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Tables
|
<Tables
|
||||||
title="Geräte"
|
title={title}
|
||||||
description="Eine Liste aller Geräte inklusive Inventur-Nr., Hersteller, Modell, Seriennummer, MAC-Adresse, IP-Adresse, Firmware, Standort, Verleih-Status und Zubehör."
|
description={description}
|
||||||
headerAction={
|
headerAction={
|
||||||
<div className="flex flex-col items-start gap-2 sm:items-end">
|
<div className="flex flex-col items-start gap-2 sm:items-end">
|
||||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||||
<Button
|
{canManageManufacturers && (
|
||||||
type="button"
|
<Button
|
||||||
variant="secondary"
|
type="button"
|
||||||
color="gray"
|
variant="secondary"
|
||||||
size="md"
|
color="gray"
|
||||||
disabled={firmwareCheckButtonDisabled}
|
size="md"
|
||||||
isLoading={isCheckingFirmware}
|
leadingIcon={<TagIcon aria-hidden="true" className="size-4" />}
|
||||||
leadingIcon={<ArrowPathIcon aria-hidden="true" className="size-4" />}
|
onClick={openManufacturerModal}
|
||||||
onClick={() => void handleCheckFirmware()}
|
>
|
||||||
title={firmwareCheckNextAllowedLabel ?? undefined}
|
Hersteller verwalten
|
||||||
>
|
</Button>
|
||||||
Firmware prüfen
|
)}
|
||||||
</Button>
|
|
||||||
|
{showFirmwareCheckButton && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
color="gray"
|
||||||
|
size="md"
|
||||||
|
disabled={firmwareCheckButtonDisabled}
|
||||||
|
isLoading={isCheckingFirmware}
|
||||||
|
leadingIcon={<ArrowPathIcon aria-hidden="true" className="size-4" />}
|
||||||
|
onClick={() => void handleCheckFirmware()}
|
||||||
|
title={firmwareCheckNextAllowedLabel ?? undefined}
|
||||||
|
>
|
||||||
|
Firmware prüfen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@ -1588,22 +2107,31 @@ export default function DevicesPage({ currentUser }: DevicesPageProps = {}) {
|
|||||||
leadingIcon={<PlusIcon aria-hidden="true" className="size-4" />}
|
leadingIcon={<PlusIcon aria-hidden="true" className="size-4" />}
|
||||||
onClick={openCreateModal}
|
onClick={openCreateModal}
|
||||||
>
|
>
|
||||||
Gerät hinzufügen
|
{createButtonLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
onAction={openCreateModal}
|
onAction={openCreateModal}
|
||||||
rows={devices}
|
rows={sortedVisibleDevices}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
getRowId={(device) => device.id}
|
getRowId={(device) => device.id}
|
||||||
variant="card"
|
variant="card"
|
||||||
emptyText="Keine Geräte vorhanden."
|
emptyText={emptyText}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
scrollRows
|
||||||
|
rowScrollClassName={
|
||||||
|
showStats
|
||||||
|
? 'max-h-[calc(100dvh-28rem)]'
|
||||||
|
: 'max-h-[calc(100dvh-18rem)]'
|
||||||
|
}
|
||||||
|
sortKey={sortKey}
|
||||||
|
sortDirection={sortDirection}
|
||||||
|
onSort={handleSort}
|
||||||
loadingContent={
|
loadingContent={
|
||||||
<LoadingSpinner
|
<LoadingSpinner
|
||||||
size="lg"
|
size="lg"
|
||||||
label="Geräteliste wird geladen..."
|
label={loadingLabel}
|
||||||
className="text-indigo-600 dark:text-indigo-400"
|
className="text-indigo-600 dark:text-indigo-400"
|
||||||
center
|
center
|
||||||
/>
|
/>
|
||||||
@ -1614,6 +2142,173 @@ export default function DevicesPage({ currentUser }: DevicesPageProps = {}) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={isManufacturerModalOpen}
|
||||||
|
setOpen={(open) => {
|
||||||
|
if (!open && (isSavingManufacturer || Boolean(deletingManufacturerId))) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsManufacturerModalOpen(open)
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
setManufacturerError(null)
|
||||||
|
setNewManufacturerName('')
|
||||||
|
setManufacturerToDelete(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
panelClassName="max-w-2xl"
|
||||||
|
>
|
||||||
|
<div className="border-b border-gray-200 px-6 py-4 dark:border-white/10">
|
||||||
|
<ModalTitle>Hersteller verwalten</ModalTitle>
|
||||||
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Hersteller werden als Auswahlliste für Geräte gespeichert. Bestehende Geräte
|
||||||
|
behalten ihren eingetragenen Hersteller, wenn du einen Eintrag entfernst.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-5 px-6 py-5">
|
||||||
|
<form className="flex flex-col gap-3 sm:flex-row" onSubmit={handleCreateManufacturer}>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<label
|
||||||
|
htmlFor="new-manufacturer-name"
|
||||||
|
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
Neuer Hersteller
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="new-manufacturer-name"
|
||||||
|
type="text"
|
||||||
|
value={newManufacturerName}
|
||||||
|
onChange={(event) => setNewManufacturerName(event.target.value)}
|
||||||
|
className={inputClassName}
|
||||||
|
placeholder="z. B. Axis"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-end">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
color="indigo"
|
||||||
|
size="md"
|
||||||
|
isLoading={isSavingManufacturer}
|
||||||
|
disabled={isSavingManufacturer || !newManufacturerName.trim()}
|
||||||
|
leadingIcon={<PlusIcon aria-hidden="true" className="size-4" />}
|
||||||
|
>
|
||||||
|
Hinzufügen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{manufacturerError && (
|
||||||
|
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200">
|
||||||
|
{manufacturerError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-3">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
Gespeicherte Hersteller
|
||||||
|
</h3>
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{manufacturerOptions.length.toLocaleString('de-DE')} Einträge
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isManufacturerLoading ? (
|
||||||
|
<LoadingSpinner
|
||||||
|
size="md"
|
||||||
|
label="Hersteller werden geladen..."
|
||||||
|
className="py-8 text-indigo-600 dark:text-indigo-400"
|
||||||
|
center
|
||||||
|
/>
|
||||||
|
) : manufacturerOptions.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-dashed border-gray-300 px-4 py-8 text-center text-sm text-gray-500 dark:border-white/15 dark:text-gray-400">
|
||||||
|
Noch keine Hersteller vorhanden.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="max-h-80 divide-y divide-gray-200 overflow-y-auto rounded-lg border border-gray-200 bg-white dark:divide-white/10 dark:border-white/10 dark:bg-white/[0.03]">
|
||||||
|
{manufacturerOptions.map((option) => {
|
||||||
|
const usageCount =
|
||||||
|
manufacturerUsageCounts.get(normalizeLookupName(option.name)) ?? 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={option.id}
|
||||||
|
className="flex items-center justify-between gap-3 px-4 py-3"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
{option.name}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{formatDeviceUsageCount(usageCount)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
color="red"
|
||||||
|
size="sm"
|
||||||
|
disabled={Boolean(deletingManufacturerId)}
|
||||||
|
isLoading={deletingManufacturerId === option.id}
|
||||||
|
leadingIcon={<TrashIcon aria-hidden="true" className="size-4" />}
|
||||||
|
onClick={() => setManufacturerToDelete(option)}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end border-t border-gray-200 bg-gray-50 px-6 py-4 dark:border-white/10 dark:bg-white/[0.03]">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
color="gray"
|
||||||
|
size="md"
|
||||||
|
disabled={isSavingManufacturer || Boolean(deletingManufacturerId)}
|
||||||
|
onClick={() => setIsManufacturerModalOpen(false)}
|
||||||
|
>
|
||||||
|
Schließen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={Boolean(manufacturerToDelete)}
|
||||||
|
setOpen={(open) => {
|
||||||
|
if (!open && deletingManufacturerId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
setManufacturerToDelete(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
variant="alert"
|
||||||
|
title="Hersteller löschen?"
|
||||||
|
description={
|
||||||
|
manufacturerToDelete
|
||||||
|
? `${manufacturerToDelete.name} wird aus der Auswahlliste entfernt. Bestehende Geräte bleiben unverändert.`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
confirmText="Löschen"
|
||||||
|
cancelText="Abbrechen"
|
||||||
|
onConfirm={() => {
|
||||||
|
if (manufacturerToDelete) {
|
||||||
|
void handleDeleteManufacturer(manufacturerToDelete)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<CreateDeviceModal
|
<CreateDeviceModal
|
||||||
open={activeModal === 'create'}
|
open={activeModal === 'create'}
|
||||||
setOpen={(open) => {
|
setOpen={(open) => {
|
||||||
@ -1626,6 +2321,12 @@ export default function DevicesPage({ currentUser }: DevicesPageProps = {}) {
|
|||||||
}}
|
}}
|
||||||
isSaving={isCreating}
|
isSaving={isCreating}
|
||||||
error={createError}
|
error={createError}
|
||||||
|
title={createModalTitle}
|
||||||
|
description={createModalDescription}
|
||||||
|
submitLabel={createSubmitLabel}
|
||||||
|
initialDeviceCategory={deviceCategoryFilter}
|
||||||
|
lockedDeviceCategory={deviceCategoryFilter}
|
||||||
|
hideDeviceCategorySelector={Boolean(deviceCategoryFilter)}
|
||||||
devices={devices}
|
devices={devices}
|
||||||
relatedDeviceIds={relatedDeviceIds}
|
relatedDeviceIds={relatedDeviceIds}
|
||||||
onToggleRelatedDevice={toggleRelatedDevice}
|
onToggleRelatedDevice={toggleRelatedDevice}
|
||||||
@ -1643,12 +2344,17 @@ export default function DevicesPage({ currentUser }: DevicesPageProps = {}) {
|
|||||||
closeModal()
|
closeModal()
|
||||||
}}
|
}}
|
||||||
isSaving={isCreating}
|
isSaving={isCreating}
|
||||||
|
isDeleting={isDeleting}
|
||||||
error={createError}
|
error={createError}
|
||||||
|
title={editModalTitle}
|
||||||
|
lockedDeviceCategory={deviceCategoryFilter}
|
||||||
|
hideDeviceCategorySelector={Boolean(deviceCategoryFilter)}
|
||||||
device={editingDevice}
|
device={editingDevice}
|
||||||
devices={devices}
|
devices={devices}
|
||||||
relatedDeviceIds={relatedDeviceIds}
|
relatedDeviceIds={relatedDeviceIds}
|
||||||
onToggleRelatedDevice={toggleRelatedDevice}
|
onToggleRelatedDevice={toggleRelatedDevice}
|
||||||
onSubmit={handleSaveDevice}
|
onSubmit={handleSaveDevice}
|
||||||
|
onDelete={handleDeleteDevice}
|
||||||
deviceHistory={deviceHistory}
|
deviceHistory={deviceHistory}
|
||||||
isHistoryLoading={isHistoryLoading}
|
isHistoryLoading={isHistoryLoading}
|
||||||
historyError={historyError}
|
historyError={historyError}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
MapPinIcon,
|
MapPinIcon,
|
||||||
PencilSquareIcon,
|
PencilSquareIcon,
|
||||||
PlusCircleIcon,
|
PlusCircleIcon,
|
||||||
|
TrashIcon,
|
||||||
VideoCameraIcon,
|
VideoCameraIcon,
|
||||||
WrenchScrewdriverIcon,
|
WrenchScrewdriverIcon,
|
||||||
} from '@heroicons/react/20/solid'
|
} from '@heroicons/react/20/solid'
|
||||||
@ -23,19 +24,24 @@ import type {
|
|||||||
DeviceHistoryChange,
|
DeviceHistoryChange,
|
||||||
DeviceHistoryEntry,
|
DeviceHistoryEntry,
|
||||||
} from '../../components/types'
|
} from '../../components/types'
|
||||||
import DeviceFormFields from './DeviceFormFields'
|
import DeviceFormFields, { type DeviceCategory } from './DeviceFormFields'
|
||||||
import DeviceModalTabs, { type DeviceModalTabId } from './DeviceModalTabs'
|
import DeviceModalTabs, { type DeviceModalTabId } from './DeviceModalTabs'
|
||||||
|
|
||||||
type EditDeviceModalProps = {
|
type EditDeviceModalProps = {
|
||||||
open: boolean
|
open: boolean
|
||||||
setOpen: (open: boolean) => void
|
setOpen: (open: boolean) => void
|
||||||
isSaving: boolean
|
isSaving: boolean
|
||||||
|
isDeleting?: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
|
title?: string
|
||||||
|
lockedDeviceCategory?: DeviceCategory
|
||||||
|
hideDeviceCategorySelector?: boolean
|
||||||
device: Device | null
|
device: Device | null
|
||||||
devices: Device[]
|
devices: Device[]
|
||||||
relatedDeviceIds: string[]
|
relatedDeviceIds: string[]
|
||||||
onToggleRelatedDevice: (deviceId: string) => void
|
onToggleRelatedDevice: (deviceId: string) => void
|
||||||
onSubmit: (event: FormEvent<HTMLFormElement>) => void
|
onSubmit: (event: FormEvent<HTMLFormElement>) => void
|
||||||
|
onDelete?: (device: Device) => void | Promise<void>
|
||||||
deviceHistory: DeviceHistoryEntry[]
|
deviceHistory: DeviceHistoryEntry[]
|
||||||
isHistoryLoading: boolean
|
isHistoryLoading: boolean
|
||||||
historyError: string | null
|
historyError: string | null
|
||||||
@ -155,12 +161,17 @@ export default function EditDeviceModal({
|
|||||||
open,
|
open,
|
||||||
setOpen,
|
setOpen,
|
||||||
isSaving,
|
isSaving,
|
||||||
|
isDeleting = false,
|
||||||
error,
|
error,
|
||||||
|
title = 'Gerät bearbeiten',
|
||||||
|
lockedDeviceCategory,
|
||||||
|
hideDeviceCategorySelector,
|
||||||
device,
|
device,
|
||||||
devices,
|
devices,
|
||||||
relatedDeviceIds,
|
relatedDeviceIds,
|
||||||
onToggleRelatedDevice,
|
onToggleRelatedDevice,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
onDelete,
|
||||||
deviceHistory,
|
deviceHistory,
|
||||||
isHistoryLoading,
|
isHistoryLoading,
|
||||||
historyError,
|
historyError,
|
||||||
@ -178,6 +189,7 @@ export default function EditDeviceModal({
|
|||||||
}: EditDeviceModalProps) {
|
}: EditDeviceModalProps) {
|
||||||
const [activeTab, setActiveTab] = useState<DeviceModalTabId>('masterData')
|
const [activeTab, setActiveTab] = useState<DeviceModalTabId>('masterData')
|
||||||
const [journalSearchQuery, setJournalSearchQuery] = useState('')
|
const [journalSearchQuery, setJournalSearchQuery] = useState('')
|
||||||
|
const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false)
|
||||||
const milestoneFilteredDevice = filterMilestoneChildDevices(device)
|
const milestoneFilteredDevice = filterMilestoneChildDevices(device)
|
||||||
const isMilestoneDevice = Boolean(
|
const isMilestoneDevice = Boolean(
|
||||||
milestoneFilteredDevice?.milestoneHardwareId ||
|
milestoneFilteredDevice?.milestoneHardwareId ||
|
||||||
@ -194,7 +206,7 @@ export default function EditDeviceModal({
|
|||||||
}, [activeTab, isMilestoneDevice])
|
}, [activeTab, isMilestoneDevice])
|
||||||
|
|
||||||
function handleOpenChange(nextOpen: boolean) {
|
function handleOpenChange(nextOpen: boolean) {
|
||||||
if (!nextOpen && isSaving) {
|
if (!nextOpen && (isSaving || isDeleting)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -203,6 +215,7 @@ export default function EditDeviceModal({
|
|||||||
if (!nextOpen) {
|
if (!nextOpen) {
|
||||||
setActiveTab('masterData')
|
setActiveTab('masterData')
|
||||||
setJournalSearchQuery('')
|
setJournalSearchQuery('')
|
||||||
|
setIsDeleteConfirmOpen(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -213,12 +226,16 @@ export default function EditDeviceModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleFormSubmit(event: FormEvent<HTMLFormElement>) {
|
function handleFormSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
if (activeTab === 'history') {
|
onSubmit(event)
|
||||||
event.preventDefault()
|
}
|
||||||
|
|
||||||
|
async function handleDeleteConfirm() {
|
||||||
|
if (!milestoneFilteredDevice || isMilestoneDevice || !onDelete) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
onSubmit(event)
|
await onDelete(milestoneFilteredDevice)
|
||||||
|
setIsDeleteConfirmOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const historyFeedItems: JournalItems = device
|
const historyFeedItems: JournalItems = device
|
||||||
@ -278,6 +295,7 @@ export default function EditDeviceModal({
|
|||||||
: []
|
: []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Modal
|
<Modal
|
||||||
open={open && Boolean(milestoneFilteredDevice)}
|
open={open && Boolean(milestoneFilteredDevice)}
|
||||||
setOpen={handleOpenChange}
|
setOpen={handleOpenChange}
|
||||||
@ -293,7 +311,7 @@ export default function EditDeviceModal({
|
|||||||
<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 items-start justify-between gap-4 border-b border-gray-200 bg-gray-100 px-4 py-4 sm:px-6 dark:border-white/10 dark:bg-gray-800">
|
||||||
<div className="min-w-0 flex-1">
|
<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
|
{title}
|
||||||
{milestoneFilteredDevice && (
|
{milestoneFilteredDevice && (
|
||||||
<span className="ml-2 font-normal text-gray-500 dark:text-gray-400">
|
<span className="ml-2 font-normal text-gray-500 dark:text-gray-400">
|
||||||
{[
|
{[
|
||||||
@ -344,7 +362,7 @@ export default function EditDeviceModal({
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
color="gray"
|
color="gray"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={isSaving}
|
disabled={isSaving || isDeleting}
|
||||||
onClick={() => handleOpenChange(false)}
|
onClick={() => handleOpenChange(false)}
|
||||||
>
|
>
|
||||||
<span className="sr-only">Schließen</span>
|
<span className="sr-only">Schließen</span>
|
||||||
@ -366,7 +384,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' && (
|
||||||
<div>
|
<div>
|
||||||
{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">
|
||||||
@ -402,11 +420,15 @@ export default function EditDeviceModal({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
)}
|
||||||
|
|
||||||
|
<div hidden={activeTab === 'history'}>
|
||||||
<DeviceFormFields
|
<DeviceFormFields
|
||||||
open={open}
|
open={open}
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
device={milestoneFilteredDevice ?? undefined}
|
device={milestoneFilteredDevice ?? undefined}
|
||||||
|
lockedDeviceCategory={lockedDeviceCategory}
|
||||||
|
hideDeviceCategorySelector={hideDeviceCategorySelector}
|
||||||
devices={devices}
|
devices={devices}
|
||||||
relatedDeviceIds={relatedDeviceIds}
|
relatedDeviceIds={relatedDeviceIds}
|
||||||
onToggleRelatedDevice={onToggleRelatedDevice}
|
onToggleRelatedDevice={onToggleRelatedDevice}
|
||||||
@ -417,32 +439,67 @@ export default function EditDeviceModal({
|
|||||||
onToggleMilestoneChildDeviceRecording={onToggleMilestoneChildDeviceRecording}
|
onToggleMilestoneChildDeviceRecording={onToggleMilestoneChildDeviceRecording}
|
||||||
onOpenMilestoneCameraDetails={onOpenMilestoneCameraDetails}
|
onOpenMilestoneCameraDetails={onOpenMilestoneCameraDetails}
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</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">
|
<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">
|
||||||
<Button
|
{onDelete ? (
|
||||||
type="button"
|
<Button
|
||||||
variant="secondary"
|
type="button"
|
||||||
color="gray"
|
variant="secondary"
|
||||||
disabled={isSaving}
|
color="red"
|
||||||
onClick={() => handleOpenChange(false)}
|
disabled={isSaving || isDeleting || isMilestoneDevice}
|
||||||
>
|
title={
|
||||||
{activeTab === 'history' ? 'Schließen' : 'Abbrechen'}
|
isMilestoneDevice
|
||||||
</Button>
|
? 'Milestone-Geräte können nicht gelöscht werden.'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
leadingIcon={<TrashIcon aria-hidden="true" className="size-4" />}
|
||||||
|
onClick={() => setIsDeleteConfirmOpen(true)}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-x-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
color="gray"
|
||||||
|
disabled={isSaving || isDeleting}
|
||||||
|
onClick={() => handleOpenChange(false)}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
|
||||||
{activeTab !== 'history' && (
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
color="indigo"
|
color="indigo"
|
||||||
|
disabled={isDeleting}
|
||||||
isLoading={isSaving}
|
isLoading={isSaving}
|
||||||
leadingIcon={<CheckIcon aria-hidden="true" className="size-4" />}
|
leadingIcon={<CheckIcon aria-hidden="true" className="size-4" />}
|
||||||
>
|
>
|
||||||
Änderungen speichern
|
Änderungen speichern
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={isDeleteConfirmOpen}
|
||||||
|
setOpen={setIsDeleteConfirmOpen}
|
||||||
|
variant="alert"
|
||||||
|
title="Gerät löschen?"
|
||||||
|
description={`"${milestoneFilteredDevice?.inventoryNumber || milestoneFilteredDevice?.model || 'Dieses Gerät'}" wird dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.`}
|
||||||
|
confirmText="Gerät löschen"
|
||||||
|
cancelText="Abbrechen"
|
||||||
|
onConfirm={() => {
|
||||||
|
void handleDeleteConfirm()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'
|
|||||||
import {
|
import {
|
||||||
ArrowPathIcon,
|
ArrowPathIcon,
|
||||||
ArrowRightIcon,
|
ArrowRightIcon,
|
||||||
|
ArchiveBoxIcon,
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
CpuChipIcon,
|
CpuChipIcon,
|
||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
@ -65,11 +66,11 @@ type LoadState = {
|
|||||||
|
|
||||||
const featureCards = [
|
const featureCards = [
|
||||||
{
|
{
|
||||||
title: 'Geräte',
|
title: 'Kameras',
|
||||||
description:
|
description:
|
||||||
'Kameras und Mikrofone nach Recording-Speicher gruppiert anzeigen und Status direkt schalten.',
|
'Milestone-Kameras und Mikrofone nach Recording-Speicher gruppiert anzeigen und Status direkt schalten.',
|
||||||
href: '/milestone/geraete',
|
href: '/milestone/kameras',
|
||||||
icon: CpuChipIcon,
|
icon: VideoCameraIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Speicher',
|
title: 'Speicher',
|
||||||
@ -85,6 +86,13 @@ const featureCards = [
|
|||||||
href: '/milestone/rollen',
|
href: '/milestone/rollen',
|
||||||
icon: UserGroupIcon,
|
icon: UserGroupIcon,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Backup',
|
||||||
|
description:
|
||||||
|
'Robocopy-Protokolle vom NAS anzeigen und die Zusammenfassung am Log-Ende auswerten.',
|
||||||
|
href: '/milestone/backup',
|
||||||
|
icon: ArchiveBoxIcon,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
function classNames(...classes: Array<string | false | null | undefined>) {
|
function classNames(...classes: Array<string | false | null | undefined>) {
|
||||||
@ -136,6 +144,26 @@ function storageTitle(storage: MilestoneStorage) {
|
|||||||
return storage.displayName || storage.name || storage.id
|
return storage.displayName || storage.name || storage.id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MilestoneDiamondIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x="6.25"
|
||||||
|
y="6.25"
|
||||||
|
width="11.5"
|
||||||
|
height="11.5"
|
||||||
|
transform="rotate(45 12 12)"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function MilestonePage() {
|
export default function MilestonePage() {
|
||||||
const [state, setState] = useState<LoadState>({ devices: [], storages: [] })
|
const [state, setState] = useState<LoadState>({ devices: [], storages: [] })
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
@ -162,7 +190,7 @@ export default function MilestonePage() {
|
|||||||
throw new Error(
|
throw new Error(
|
||||||
typeof devicesData?.error === 'string'
|
typeof devicesData?.error === 'string'
|
||||||
? devicesData.error
|
? devicesData.error
|
||||||
: 'Milestone-Geräte konnten nicht geladen werden.',
|
: 'Milestone-Kameras konnten nicht geladen werden.',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -255,7 +283,7 @@ export default function MilestonePage() {
|
|||||||
<div className="max-w-3xl">
|
<div className="max-w-3xl">
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
<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" />
|
<MilestoneDiamondIcon className="size-6" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold uppercase tracking-wide text-indigo-600 dark:text-indigo-300">
|
<p className="text-sm font-semibold uppercase tracking-wide text-indigo-600 dark:text-indigo-300">
|
||||||
|
|||||||
590
frontend/src/pages/milestone/backup/MilestoneBackupPage.tsx
Normal file
590
frontend/src/pages/milestone/backup/MilestoneBackupPage.tsx
Normal file
@ -0,0 +1,590 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import {
|
||||||
|
ArrowPathIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
|
ClockIcon,
|
||||||
|
DocumentTextIcon,
|
||||||
|
ExclamationTriangleIcon,
|
||||||
|
FolderIcon,
|
||||||
|
XCircleIcon,
|
||||||
|
} from '@heroicons/react/24/outline'
|
||||||
|
import { Link } from 'react-router'
|
||||||
|
import Button from '../../../components/Button'
|
||||||
|
import LoadingSpinner from '../../../components/LoadingSpinner'
|
||||||
|
import Scrollbar from '../../../components/Scrollbar'
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||||
|
|
||||||
|
type BackupLogSummaryStatus = 'success' | 'failed' | 'unknown'
|
||||||
|
|
||||||
|
type BackupLogSummaryRow = {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
total: string
|
||||||
|
copied: string
|
||||||
|
skipped: string
|
||||||
|
mismatch: string
|
||||||
|
failed: string
|
||||||
|
extras: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type BackupLogSummary = {
|
||||||
|
status: BackupLogSummaryStatus
|
||||||
|
startedAt: string
|
||||||
|
endedAt: string
|
||||||
|
duration: string
|
||||||
|
totalFiles: string
|
||||||
|
copiedFiles: string
|
||||||
|
failedCount: number
|
||||||
|
rows: BackupLogSummaryRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type BackupLogFile = {
|
||||||
|
name: string
|
||||||
|
size: number
|
||||||
|
modifiedAt: string
|
||||||
|
summary: BackupLogSummary
|
||||||
|
}
|
||||||
|
|
||||||
|
type BackupLogDetail = BackupLogFile & {
|
||||||
|
tailLines: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type BackupLogListResponse = {
|
||||||
|
rootPath: string
|
||||||
|
logs: BackupLogFile[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type BackupLogDetailResponse = {
|
||||||
|
rootPath: string
|
||||||
|
log: BackupLogDetail
|
||||||
|
}
|
||||||
|
|
||||||
|
function classNames(...classes: Array<string | false | null | undefined>) {
|
||||||
|
return classes.filter(Boolean).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readApiError(response: Response, fallback: string) {
|
||||||
|
const data = await response.json().catch(() => null)
|
||||||
|
|
||||||
|
return data?.error ?? fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value?: string) {
|
||||||
|
if (!value) {
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat('de-DE', {
|
||||||
|
dateStyle: 'short',
|
||||||
|
timeStyle: 'short',
|
||||||
|
}).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(value?: number) {
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === 0) {
|
||||||
|
return '0 B'
|
||||||
|
}
|
||||||
|
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
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 statusLabel(status: BackupLogSummaryStatus) {
|
||||||
|
if (status === 'success') {
|
||||||
|
return 'Erfolgreich'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'failed') {
|
||||||
|
return 'Fehler'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Unbekannt'
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: BackupLogSummaryStatus }) {
|
||||||
|
const Icon = status === 'success' ? CheckCircleIcon : status === 'failed' ? XCircleIcon : ExclamationTriangleIcon
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={classNames(
|
||||||
|
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium',
|
||||||
|
status === 'success' &&
|
||||||
|
'bg-green-50 text-green-700 ring-1 ring-green-600/20 ring-inset dark:bg-green-500/10 dark:text-green-300 dark:ring-green-400/20',
|
||||||
|
status === 'failed' &&
|
||||||
|
'bg-red-50 text-red-700 ring-1 ring-red-600/20 ring-inset dark:bg-red-500/10 dark:text-red-300 dark:ring-red-400/20',
|
||||||
|
status === 'unknown' &&
|
||||||
|
'bg-gray-100 text-gray-600 ring-1 ring-gray-200 ring-inset dark:bg-white/10 dark:text-gray-300 dark:ring-white/10',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon aria-hidden="true" className="size-3.5" />
|
||||||
|
{statusLabel(status)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function findSummaryRow(summary: BackupLogSummary | undefined, key: string) {
|
||||||
|
return summary?.rows.find((row) => row.key === key)
|
||||||
|
}
|
||||||
|
|
||||||
|
function summaryValue(value?: string) {
|
||||||
|
return value && value.trim() ? value : '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileTitle(fileName: string) {
|
||||||
|
return fileName.replace(/\.log$/i, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MilestoneBackupPage() {
|
||||||
|
const [rootPath, setRootPath] = useState('')
|
||||||
|
const [logs, setLogs] = useState<BackupLogFile[]>([])
|
||||||
|
const [selectedName, setSelectedName] = useState('')
|
||||||
|
const [selectedLog, setSelectedLog] = useState<BackupLogDetail | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [isLoadingDetail, setIsLoadingDetail] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadLogs()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function loadLogs() {
|
||||||
|
setIsLoading(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/admin/milestone/backup-logs`, {
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
await readApiError(response, 'Backup-Protokolle konnten nicht geladen werden'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as BackupLogListResponse
|
||||||
|
const nextLogs = Array.isArray(data.logs) ? data.logs : []
|
||||||
|
const nextSelectedName =
|
||||||
|
selectedName && nextLogs.some((log) => log.name === selectedName)
|
||||||
|
? selectedName
|
||||||
|
: nextLogs[0]?.name ?? ''
|
||||||
|
|
||||||
|
setRootPath(data.rootPath ?? '')
|
||||||
|
setLogs(nextLogs)
|
||||||
|
setSelectedName(nextSelectedName)
|
||||||
|
|
||||||
|
if (nextSelectedName) {
|
||||||
|
await loadLogDetail(nextSelectedName)
|
||||||
|
} else {
|
||||||
|
setSelectedLog(null)
|
||||||
|
}
|
||||||
|
} catch (loadError) {
|
||||||
|
setLogs([])
|
||||||
|
setSelectedLog(null)
|
||||||
|
setError(
|
||||||
|
loadError instanceof Error
|
||||||
|
? loadError.message
|
||||||
|
: 'Backup-Protokolle konnten nicht geladen werden',
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLogDetail(name: string) {
|
||||||
|
setIsLoadingDetail(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const query = new URLSearchParams({ file: name })
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_URL}/admin/milestone/backup-logs?${query.toString()}`,
|
||||||
|
{
|
||||||
|
credentials: 'include',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
await readApiError(response, 'Backup-Protokoll konnte nicht geladen werden'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as BackupLogDetailResponse
|
||||||
|
setRootPath(data.rootPath ?? '')
|
||||||
|
setSelectedLog(data.log ?? null)
|
||||||
|
} catch (loadError) {
|
||||||
|
setSelectedLog(null)
|
||||||
|
setError(
|
||||||
|
loadError instanceof Error
|
||||||
|
? loadError.message
|
||||||
|
: 'Backup-Protokoll konnte nicht geladen werden',
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setIsLoadingDetail(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectLog(name: string) {
|
||||||
|
setSelectedName(name)
|
||||||
|
void loadLogDetail(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytesRow = findSummaryRow(selectedLog?.summary, 'bytes')
|
||||||
|
const latestLog = logs[0]
|
||||||
|
|
||||||
|
const statusCounts = useMemo(
|
||||||
|
() =>
|
||||||
|
logs.reduce(
|
||||||
|
(counts, log) => {
|
||||||
|
counts[log.summary.status] += 1
|
||||||
|
return counts
|
||||||
|
},
|
||||||
|
{ success: 0, failed: 0, unknown: 0 } as Record<BackupLogSummaryStatus, number>,
|
||||||
|
),
|
||||||
|
[logs],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-gray-50 dark:bg-gray-950">
|
||||||
|
<div className="shrink-0 border-b border-gray-200 bg-white px-4 py-5 sm:px-6 lg:px-8 dark:border-white/10 dark:bg-gray-900">
|
||||||
|
<div className="mx-auto flex max-w-7xl flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-wide text-indigo-600 dark:text-indigo-300">
|
||||||
|
Milestone
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-1 text-2xl font-semibold tracking-tight text-gray-900 dark:text-white">
|
||||||
|
Backup
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Robocopy-Protokolle vom NAS anzeigen und die Zusammenfassung am Log-Ende auswerten.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Link
|
||||||
|
to="/administration/milestone"
|
||||||
|
className="inline-flex items-center justify-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-700 shadow-xs ring-1 ring-gray-200 hover:bg-gray-50 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10 dark:hover:bg-white/15"
|
||||||
|
>
|
||||||
|
Einstellungen
|
||||||
|
</Link>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
color="gray"
|
||||||
|
isLoading={isLoading}
|
||||||
|
leadingIcon={<ArrowPathIcon aria-hidden="true" className="size-4" />}
|
||||||
|
onClick={() => void loadLogs()}
|
||||||
|
>
|
||||||
|
Aktualisieren
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Scrollbar className="flex-1 px-4 py-6 sm:px-6 lg:px-8">
|
||||||
|
<div className="mx-auto grid max-w-7xl gap-6 xl:grid-cols-[24rem_minmax(0,1fr)]">
|
||||||
|
<aside className="min-h-0 rounded-xl border border-gray-200 bg-white shadow-xs dark:border-white/10 dark:bg-white/5">
|
||||||
|
<div className="border-b border-gray-200 p-4 dark:border-white/10">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FolderIcon aria-hidden="true" className="size-5 text-indigo-500" />
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
Protokolle
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 break-all text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{rootPath || 'Kein Protokollordner konfiguriert'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-2 border-b border-gray-200 p-4 text-center dark:border-white/10">
|
||||||
|
<MiniStat label="OK" value={statusCounts.success} tone="green" />
|
||||||
|
<MiniStat label="Fehler" value={statusCounts.failed} tone="red" />
|
||||||
|
<MiniStat label="Unklar" value={statusCounts.unknown} tone="gray" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex min-h-64 items-center justify-center p-6">
|
||||||
|
<LoadingSpinner
|
||||||
|
size="md"
|
||||||
|
label="Backup-Protokolle werden geladen..."
|
||||||
|
className="text-indigo-600 dark:text-indigo-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : logs.length === 0 ? (
|
||||||
|
<div className="p-6 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Keine .log-Dateien gefunden.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Scrollbar className="max-h-[calc(100vh-24rem)] p-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{logs.map((log) => {
|
||||||
|
const current = log.name === selectedName
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={log.name}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelectLog(log.name)}
|
||||||
|
className={classNames(
|
||||||
|
'flex w-full items-start gap-3 rounded-lg p-3 text-left transition',
|
||||||
|
current
|
||||||
|
? 'bg-indigo-50 text-indigo-950 ring-1 ring-indigo-100 dark:bg-indigo-500/10 dark:text-indigo-100 dark:ring-indigo-400/20'
|
||||||
|
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-white/5',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<DocumentTextIcon
|
||||||
|
aria-hidden="true"
|
||||||
|
className={classNames(
|
||||||
|
'mt-0.5 size-5 shrink-0',
|
||||||
|
current ? 'text-indigo-600 dark:text-indigo-300' : 'text-gray-400',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-sm font-semibold">
|
||||||
|
{fileTitle(log.name)}
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 flex flex-wrap items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<span>{formatDateTime(log.modifiedAt)}</span>
|
||||||
|
<StatusBadge status={log.summary.status} />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Scrollbar>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="min-w-0 space-y-6">
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-xl 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" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">Backup-Protokolle konnten nicht geladen werden</p>
|
||||||
|
<p className="mt-1">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!selectedLog && !isLoadingDetail ? (
|
||||||
|
<section className="rounded-xl border border-dashed border-gray-300 bg-white p-10 text-center dark:border-white/15 dark:bg-white/5">
|
||||||
|
<DocumentTextIcon aria-hidden="true" className="mx-auto size-10 text-gray-300 dark:text-gray-600" />
|
||||||
|
<h2 className="mt-4 text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
Kein Protokoll ausgewählt
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Wähle links ein Backup-Protokoll aus.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
) : isLoadingDetail ? (
|
||||||
|
<section className="rounded-xl border border-gray-200 bg-white p-10 dark:border-white/10 dark:bg-white/5">
|
||||||
|
<LoadingSpinner
|
||||||
|
size="md"
|
||||||
|
label="Backup-Protokoll wird geladen..."
|
||||||
|
className="text-indigo-600 dark:text-indigo-400"
|
||||||
|
center
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
) : selectedLog ? (
|
||||||
|
<>
|
||||||
|
<section className="rounded-xl border border-gray-200 bg-white p-5 shadow-xs dark:border-white/10 dark:bg-white/5">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<StatusBadge status={selectedLog.summary.status} />
|
||||||
|
<span className="inline-flex items-center gap-1.5 rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-600 dark:bg-white/10 dark:text-gray-300">
|
||||||
|
<ClockIcon aria-hidden="true" className="size-3.5" />
|
||||||
|
{formatDateTime(selectedLog.modifiedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="mt-3 truncate text-xl font-semibold text-gray-900 dark:text-white">
|
||||||
|
{fileTitle(selectedLog.name)}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{formatFileSize(selectedLog.size)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{latestLog?.name === selectedLog.name && (
|
||||||
|
<span className="inline-flex rounded-full bg-indigo-50 px-3 py-1 text-xs font-semibold text-indigo-700 ring-1 ring-indigo-600/20 ring-inset dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20">
|
||||||
|
Neuestes Log
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<SummaryCard
|
||||||
|
label="Status"
|
||||||
|
value={statusLabel(selectedLog.summary.status)}
|
||||||
|
detail={
|
||||||
|
selectedLog.summary.failedCount > 0
|
||||||
|
? `${selectedLog.summary.failedCount} Fehler`
|
||||||
|
: 'Keine Fehler in der Endtabelle'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SummaryCard
|
||||||
|
label="Dateien"
|
||||||
|
value={`${summaryValue(selectedLog.summary.copiedFiles)} / ${summaryValue(selectedLog.summary.totalFiles)}`}
|
||||||
|
detail="kopiert / gesamt"
|
||||||
|
/>
|
||||||
|
<SummaryCard
|
||||||
|
label="Datenmenge"
|
||||||
|
value={`${summaryValue(bytesRow?.copied)} / ${summaryValue(bytesRow?.total)}`}
|
||||||
|
detail="kopiert / gesamt"
|
||||||
|
/>
|
||||||
|
<SummaryCard
|
||||||
|
label="Dauer"
|
||||||
|
value={summaryValue(selectedLog.summary.duration)}
|
||||||
|
detail={selectedLog.summary.endedAt || 'Endzeit nicht erkannt'}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xs dark:border-white/10 dark:bg-white/5">
|
||||||
|
<div className="border-b border-gray-200 px-5 py-4 dark:border-white/10">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
Robocopy-Zusammenfassung
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Aus der Endtabelle des Protokolls gelesen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedLog.summary.rows.length === 0 ? (
|
||||||
|
<div className="p-5 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Im Log-Ende wurde keine Robocopy-Zusammenfassung erkannt.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200 text-sm dark:divide-white/10">
|
||||||
|
<thead className="bg-gray-50 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:bg-white/5 dark:text-gray-400">
|
||||||
|
<tr>
|
||||||
|
<th className="px-5 py-3 text-left">Bereich</th>
|
||||||
|
<th className="px-5 py-3 text-right">Gesamt</th>
|
||||||
|
<th className="px-5 py-3 text-right">Kopiert</th>
|
||||||
|
<th className="px-5 py-3 text-right">Übersprungen</th>
|
||||||
|
<th className="px-5 py-3 text-right">Abweichend</th>
|
||||||
|
<th className="px-5 py-3 text-right">Fehler</th>
|
||||||
|
<th className="px-5 py-3 text-right">Extras</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100 dark:divide-white/10">
|
||||||
|
{selectedLog.summary.rows.map((row) => (
|
||||||
|
<tr key={row.key} className="text-gray-700 dark:text-gray-300">
|
||||||
|
<td className="px-5 py-3 font-medium text-gray-900 dark:text-white">
|
||||||
|
{row.label}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3 text-right tabular-nums">{summaryValue(row.total)}</td>
|
||||||
|
<td className="px-5 py-3 text-right tabular-nums">{summaryValue(row.copied)}</td>
|
||||||
|
<td className="px-5 py-3 text-right tabular-nums">{summaryValue(row.skipped)}</td>
|
||||||
|
<td className="px-5 py-3 text-right tabular-nums">{summaryValue(row.mismatch)}</td>
|
||||||
|
<td className="px-5 py-3 text-right tabular-nums">{summaryValue(row.failed)}</td>
|
||||||
|
<td className="px-5 py-3 text-right tabular-nums">{summaryValue(row.extras)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xs dark:border-white/10 dark:bg-white/5">
|
||||||
|
<div className="border-b border-gray-200 px-5 py-4 dark:border-white/10">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
Log-Ende
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Die letzten Zeilen der Datei zur Kontrolle.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Scrollbar orientation="both" className="max-h-96 bg-gray-950 p-4">
|
||||||
|
<pre className="whitespace-pre-wrap font-mono text-xs leading-5 text-gray-100">
|
||||||
|
{(selectedLog.tailLines ?? []).join('\n')}
|
||||||
|
</pre>
|
||||||
|
</Scrollbar>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</Scrollbar>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiniStat({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
tone,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: number
|
||||||
|
tone: 'green' | 'red' | 'gray'
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
className={classNames(
|
||||||
|
'text-lg font-semibold',
|
||||||
|
tone === 'green' && 'text-green-700 dark:text-green-300',
|
||||||
|
tone === 'red' && 'text-red-700 dark:text-red-300',
|
||||||
|
tone === 'gray' && 'text-gray-700 dark:text-gray-300',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{value.toLocaleString('de-DE')}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">{label}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
detail,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
detail: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-4 shadow-xs dark:border-white/10 dark:bg-white/5">
|
||||||
|
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 truncate text-xl font-semibold tracking-tight text-gray-900 dark:text-white">
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 truncate text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{detail}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -492,7 +492,7 @@ export default function MilestoneDevicesPage({ canWrite = false }: Props) {
|
|||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Kamera nicht lokal zugeordnet',
|
title: 'Kamera nicht lokal zugeordnet',
|
||||||
message: 'Bitte synchronisiere die Milestone-Geräte neu und versuche es danach erneut.',
|
message: 'Bitte synchronisiere die Milestone-Kameras neu und versuche es danach erneut.',
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -860,7 +860,7 @@ export default function MilestoneDevicesPage({ canWrite = false }: Props) {
|
|||||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">
|
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||||
Milestone-Geräte
|
Milestone-Kameras
|
||||||
</h1>
|
</h1>
|
||||||
{!isLoading && !error && (
|
{!isLoading && !error && (
|
||||||
<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">
|
||||||
@ -898,7 +898,7 @@ export default function MilestoneDevicesPage({ canWrite = false }: Props) {
|
|||||||
<div className="min-h-0 flex-1 space-y-8 overflow-y-auto p-4 sm:p-6 lg:p-8">
|
<div className="min-h-0 flex-1 space-y-8 overflow-y-auto p-4 sm:p-6 lg:p-8">
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="flex justify-center py-16">
|
<div className="flex justify-center py-16">
|
||||||
<LoadingSpinner size="lg" label="Geräte werden geladen…" center />
|
<LoadingSpinner size="lg" label="Kameras werden geladen…" center />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -911,7 +911,7 @@ export default function MilestoneDevicesPage({ canWrite = false }: Props) {
|
|||||||
{!isLoading && !error && filtered.length === 0 && (
|
{!isLoading && !error && filtered.length === 0 && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={VideoCameraIcon}
|
icon={VideoCameraIcon}
|
||||||
title={search ? 'Keine Treffer' : 'Keine Geräte gefunden'}
|
title={search ? 'Keine Treffer' : 'Keine Kameras gefunden'}
|
||||||
description={
|
description={
|
||||||
search
|
search
|
||||||
? 'Versuche einen anderen Suchbegriff.'
|
? 'Versuche einen anderen Suchbegriff.'
|
||||||
|
|||||||
@ -207,6 +207,7 @@ export default function MilestoneRolesPage({
|
|||||||
const [isLoadingBasicUsers, setIsLoadingBasicUsers] = useState(false)
|
const [isLoadingBasicUsers, setIsLoadingBasicUsers] = useState(false)
|
||||||
const [isAddingUsers, setIsAddingUsers] = useState(false)
|
const [isAddingUsers, setIsAddingUsers] = useState(false)
|
||||||
const [adSearch, setAdSearch] = useState('')
|
const [adSearch, setAdSearch] = useState('')
|
||||||
|
const [activeAddUsersTab, setActiveAddUsersTab] = useState<UserSource>('ad')
|
||||||
const [selectedUserKeys, setSelectedUserKeys] = useState<Set<string>>(
|
const [selectedUserKeys, setSelectedUserKeys] = useState<Set<string>>(
|
||||||
() => new Set(),
|
() => new Set(),
|
||||||
)
|
)
|
||||||
@ -660,9 +661,52 @@ export default function MilestoneRolesPage({
|
|||||||
basic: filterVisibleUsers(basicUsers),
|
basic: filterVisibleUsers(basicUsers),
|
||||||
}
|
}
|
||||||
}, [adSearch, adUsers, basicUsers, roleUsers])
|
}, [adSearch, adUsers, basicUsers, roleUsers])
|
||||||
|
const addUserGroups = useMemo<
|
||||||
|
Record<
|
||||||
|
UserSource,
|
||||||
|
{
|
||||||
|
source: UserSource
|
||||||
|
title: string
|
||||||
|
users: AssignableUser[]
|
||||||
|
loading: boolean
|
||||||
|
unavailable: boolean
|
||||||
|
emptyText: string
|
||||||
|
}
|
||||||
|
>
|
||||||
|
>(
|
||||||
|
() => ({
|
||||||
|
ad: {
|
||||||
|
source: 'ad',
|
||||||
|
title: 'Windows-Benutzer',
|
||||||
|
users: visibleAssignableUsersBySource.ad,
|
||||||
|
loading: isLoadingAdUsers,
|
||||||
|
unavailable: !adConfigured,
|
||||||
|
emptyText: 'Keine passenden Windows-Benutzer gefunden.',
|
||||||
|
},
|
||||||
|
basic: {
|
||||||
|
source: 'basic',
|
||||||
|
title: 'Basisbenutzer',
|
||||||
|
users: visibleAssignableUsersBySource.basic,
|
||||||
|
loading: isLoadingBasicUsers,
|
||||||
|
unavailable: false,
|
||||||
|
emptyText: 'Keine passenden Basisbenutzer gefunden.',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
adConfigured,
|
||||||
|
isLoadingAdUsers,
|
||||||
|
isLoadingBasicUsers,
|
||||||
|
visibleAssignableUsersBySource,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
const activeAddUserGroup = addUserGroups[activeAddUsersTab]
|
||||||
|
const addUserSearchIsActive = adSearch.trim().length > 0
|
||||||
|
const visibleAddUserGroups = addUserSearchIsActive
|
||||||
|
? [addUserGroups.ad, addUserGroups.basic]
|
||||||
|
: [activeAddUserGroup]
|
||||||
|
|
||||||
return (
|
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="h-full overflow-y-auto bg-gray-50 px-4 py-6 sm:px-6 lg:px-8 [&_svg[data-slot=icon]]:stroke-2 dark:bg-gray-950">
|
||||||
<div className="mx-auto flex max-w-7xl flex-col gap-6">
|
<div className="mx-auto flex max-w-7xl flex-col gap-6">
|
||||||
<header className="rounded-xl border border-gray-200 bg-white p-5 shadow-xs dark:border-white/10 dark:bg-white/5">
|
<header className="rounded-xl border border-gray-200 bg-white p-5 shadow-xs dark:border-white/10 dark:bg-white/5">
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
@ -1109,79 +1153,112 @@ export default function MilestoneRolesPage({
|
|||||||
className="block w-full rounded-md bg-white px-3 py-2 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"
|
className="block w-full rounded-md bg-white px-3 py-2 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"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-4 max-h-96 space-y-4 overflow-y-auto rounded-lg border border-gray-200 p-3 dark:border-white/10">
|
<div className="mt-4">
|
||||||
{([
|
<div
|
||||||
{
|
role="tablist"
|
||||||
source: 'ad' as const,
|
aria-label="Benutzerart"
|
||||||
title: 'Windows-Benutzer',
|
className="grid grid-cols-2 rounded-lg bg-gray-100 p-1 dark:bg-white/5"
|
||||||
users: visibleAssignableUsersBySource.ad,
|
>
|
||||||
loading: isLoadingAdUsers,
|
{(['ad', 'basic'] as const).map((source) => {
|
||||||
unavailable: !adConfigured,
|
const group = addUserGroups[source]
|
||||||
emptyText: 'Keine passenden Windows-Benutzer gefunden.',
|
const active = activeAddUsersTab === source
|
||||||
},
|
|
||||||
{
|
|
||||||
source: 'basic' as const,
|
|
||||||
title: 'Basisbenutzer',
|
|
||||||
users: visibleAssignableUsersBySource.basic,
|
|
||||||
loading: isLoadingBasicUsers,
|
|
||||||
unavailable: false,
|
|
||||||
emptyText: 'Keine passenden Basisbenutzer gefunden.',
|
|
||||||
},
|
|
||||||
]).map((group) => (
|
|
||||||
<section key={group.source}>
|
|
||||||
<div className="mb-2 flex items-center justify-between">
|
|
||||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
|
||||||
{group.title}
|
|
||||||
</h3>
|
|
||||||
<span className="text-xs text-gray-400 dark:text-gray-500">
|
|
||||||
{group.users.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{group.unavailable ? (
|
return (
|
||||||
<div className="rounded-lg border border-dashed border-gray-200 px-4 py-6 text-center text-sm text-gray-500 dark:border-white/10 dark:text-gray-400">
|
<button
|
||||||
Active Directory ist noch nicht konfiguriert.
|
key={source}
|
||||||
</div>
|
type="button"
|
||||||
) : group.loading ? (
|
role="tab"
|
||||||
<div className="flex min-h-28 items-center justify-center rounded-lg border border-dashed border-gray-200 dark:border-white/10">
|
aria-selected={active}
|
||||||
<LoadingSpinner
|
onClick={() => setActiveAddUsersTab(source)}
|
||||||
size="sm"
|
className={classNames(
|
||||||
label={`${group.title} werden geladen...`}
|
active
|
||||||
className="text-indigo-600 dark:text-indigo-400"
|
? 'bg-white text-indigo-700 shadow-xs dark:bg-white/10 dark:text-white'
|
||||||
/>
|
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white',
|
||||||
</div>
|
'flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-semibold transition',
|
||||||
) : group.users.length === 0 ? (
|
)}
|
||||||
<div className="rounded-lg border border-dashed border-gray-200 px-4 py-6 text-center text-sm text-gray-500 dark:border-white/10 dark:text-gray-400">
|
>
|
||||||
{group.emptyText}
|
<span>{group.title}</span>
|
||||||
</div>
|
<span
|
||||||
) : (
|
className={classNames(
|
||||||
<ul className="divide-y divide-gray-200 overflow-hidden rounded-lg border border-gray-200 dark:divide-white/10 dark:border-white/10">
|
active
|
||||||
{group.users.map((user) => (
|
? 'bg-indigo-50 text-indigo-700 dark:bg-white/10 dark:text-white'
|
||||||
<li key={userSelectionKey(user)}>
|
: 'bg-white text-gray-500 dark:bg-white/10 dark:text-gray-300',
|
||||||
<label className="flex cursor-pointer items-start gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-white/5">
|
'inline-flex min-w-6 justify-center rounded-full px-1.5 py-0.5 text-xs leading-none',
|
||||||
<input
|
)}
|
||||||
type="checkbox"
|
>
|
||||||
checked={selectedUserKeys.has(userSelectionKey(user))}
|
{group.users.length}
|
||||||
onChange={() => toggleUserSelection(user)}
|
</span>
|
||||||
className="mt-1 size-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600 dark:border-white/10 dark:bg-white/5"
|
</button>
|
||||||
/>
|
)
|
||||||
<span className="min-w-0 flex-1">
|
})}
|
||||||
<span className="block truncate text-sm font-medium text-gray-900 dark:text-white">
|
</div>
|
||||||
{userTitle(user)}
|
|
||||||
</span>
|
<div
|
||||||
{userSubtitle(user) && (
|
className={classNames(
|
||||||
<span className="mt-0.5 block truncate text-xs text-gray-500 dark:text-gray-400">
|
'mt-3 max-h-96 overflow-y-auto rounded-lg border border-gray-200 p-3 dark:border-white/10',
|
||||||
{userSubtitle(user)}
|
addUserSearchIsActive && 'space-y-4',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{visibleAddUserGroups.map((group) => (
|
||||||
|
<section key={group.source}>
|
||||||
|
{addUserSearchIsActive && (
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
{group.title}
|
||||||
|
</h3>
|
||||||
|
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
{group.users.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{group.unavailable ? (
|
||||||
|
<div className="rounded-lg border border-dashed border-gray-200 px-4 py-6 text-center text-sm text-gray-500 dark:border-white/10 dark:text-gray-400">
|
||||||
|
Active Directory ist noch nicht konfiguriert.
|
||||||
|
</div>
|
||||||
|
) : group.loading ? (
|
||||||
|
<div className="flex min-h-28 items-center justify-center rounded-lg border border-dashed border-gray-200 dark:border-white/10">
|
||||||
|
<LoadingSpinner
|
||||||
|
size="sm"
|
||||||
|
label={`${group.title} werden geladen...`}
|
||||||
|
className="text-indigo-600 dark:text-indigo-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : group.users.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-dashed border-gray-200 px-4 py-6 text-center text-sm text-gray-500 dark:border-white/10 dark:text-gray-400">
|
||||||
|
{group.emptyText}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-gray-200 overflow-hidden rounded-lg border border-gray-200 dark:divide-white/10 dark:border-white/10">
|
||||||
|
{group.users.map((user) => (
|
||||||
|
<li key={userSelectionKey(user)}>
|
||||||
|
<label className="flex cursor-pointer items-start gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-white/5">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedUserKeys.has(
|
||||||
|
userSelectionKey(user),
|
||||||
|
)}
|
||||||
|
onChange={() => toggleUserSelection(user)}
|
||||||
|
className="mt-1 size-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600 dark:border-white/10 dark:bg-white/5"
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{userTitle(user)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
{userSubtitle(user) && (
|
||||||
</span>
|
<span className="mt-0.5 block truncate text-xs text-gray-500 dark:text-gray-400">
|
||||||
</label>
|
{userSubtitle(user)}
|
||||||
</li>
|
</span>
|
||||||
))}
|
)}
|
||||||
</ul>
|
</span>
|
||||||
)}
|
</label>
|
||||||
</section>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -238,7 +238,6 @@ function mapActiveDirectoryUserToComboboxItem(user: OperationActiveDirectoryUser
|
|||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
name: accountName || user.displayName || user.email || user.id,
|
name: accountName || user.displayName || user.email || user.id,
|
||||||
secondaryText: [user.displayName, user.email].filter(Boolean).join(' / '),
|
|
||||||
username: user.username,
|
username: user.username,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
sid: user.sid,
|
sid: user.sid,
|
||||||
@ -246,6 +245,29 @@ function mapActiveDirectoryUserToComboboxItem(user: OperationActiveDirectoryUser
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const evaluationLaptopPattern = /\bauswertung\s*([1-9]|10)\b/i
|
||||||
|
|
||||||
|
function automaticMilestoneUserNameFromLaptop(
|
||||||
|
device: Device | undefined,
|
||||||
|
fallbackValue: string,
|
||||||
|
) {
|
||||||
|
const candidates = [
|
||||||
|
device?.computerName,
|
||||||
|
device?.inventoryNumber,
|
||||||
|
device?.model,
|
||||||
|
fallbackValue,
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const match = candidate?.match(evaluationLaptopPattern)
|
||||||
|
if (match?.[1]) {
|
||||||
|
return `auswertung${match[1]}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
async function readApiError(response: Response, fallback: string) {
|
async function readApiError(response: Response, fallback: string) {
|
||||||
const data = await response.json().catch(() => null)
|
const data = await response.json().catch(() => null)
|
||||||
|
|
||||||
@ -617,6 +639,7 @@ export default function CreateOperationModal({
|
|||||||
const [isLoadingMilestoneUsers, setIsLoadingMilestoneUsers] = useState(false)
|
const [isLoadingMilestoneUsers, setIsLoadingMilestoneUsers] = useState(false)
|
||||||
const [milestoneUsersConfigured, setMilestoneUsersConfigured] = useState(false)
|
const [milestoneUsersConfigured, setMilestoneUsersConfigured] = useState(false)
|
||||||
const [milestoneUsersError, setMilestoneUsersError] = useState<string | null>(null)
|
const [milestoneUsersError, setMilestoneUsersError] = useState<string | null>(null)
|
||||||
|
const [showMilestoneUserOverride, setShowMilestoneUserOverride] = useState(false)
|
||||||
|
|
||||||
const [equipmentDevices, setEquipmentDevices] = useState<Device[]>([])
|
const [equipmentDevices, setEquipmentDevices] = useState<Device[]>([])
|
||||||
const [isLoadingDevices, setIsLoadingDevices] = useState(false)
|
const [isLoadingDevices, setIsLoadingDevices] = useState(false)
|
||||||
@ -682,6 +705,26 @@ export default function CreateOperationModal({
|
|||||||
[equipmentDevices],
|
[equipmentDevices],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const selectedLaptopDetails = useMemo(
|
||||||
|
() => findEquipmentDevice(selectedLaptopDevice),
|
||||||
|
[equipmentDevices, selectedLaptopDevice],
|
||||||
|
)
|
||||||
|
|
||||||
|
const automaticMilestoneUserName = useMemo(
|
||||||
|
() =>
|
||||||
|
automaticMilestoneUserNameFromLaptop(
|
||||||
|
selectedLaptopDetails,
|
||||||
|
formValues.laptop,
|
||||||
|
),
|
||||||
|
[formValues.laptop, selectedLaptopDetails],
|
||||||
|
)
|
||||||
|
|
||||||
|
const milestoneUserDisplayValue =
|
||||||
|
selectedMilestoneUser?.name ||
|
||||||
|
(automaticMilestoneUserName
|
||||||
|
? `${automaticMilestoneUserName} (automatisch)`
|
||||||
|
: '')
|
||||||
|
|
||||||
const storageDisplayNameById = useMemo(() => {
|
const storageDisplayNameById = useMemo(() => {
|
||||||
const byId = new Map<string, string>()
|
const byId = new Map<string, string>()
|
||||||
|
|
||||||
@ -709,6 +752,7 @@ export default function CreateOperationModal({
|
|||||||
setIsLoadingMilestoneUsers(false)
|
setIsLoadingMilestoneUsers(false)
|
||||||
setMilestoneUsersConfigured(false)
|
setMilestoneUsersConfigured(false)
|
||||||
setMilestoneUsersError(null)
|
setMilestoneUsersError(null)
|
||||||
|
setShowMilestoneUserOverride(false)
|
||||||
setSelectedCameraDevice(null)
|
setSelectedCameraDevice(null)
|
||||||
setSelectedRouterDevice(null)
|
setSelectedRouterDevice(null)
|
||||||
setSelectedSwitchboxDevice(null)
|
setSelectedSwitchboxDevice(null)
|
||||||
@ -1025,6 +1069,9 @@ export default function CreateOperationModal({
|
|||||||
setSelectedSwitchboxDevice(item)
|
setSelectedSwitchboxDevice(item)
|
||||||
} else {
|
} else {
|
||||||
setSelectedLaptopDevice(item)
|
setSelectedLaptopDevice(item)
|
||||||
|
setSelectedMilestoneUser(null)
|
||||||
|
setShowMilestoneUserOverride(false)
|
||||||
|
setMilestoneUsersError(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
updateField(
|
updateField(
|
||||||
@ -1082,6 +1129,9 @@ export default function CreateOperationModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleLaptopDeviceChange(item: ComboboxItem | null) {
|
function handleLaptopDeviceChange(item: ComboboxItem | null) {
|
||||||
|
setSelectedMilestoneUser(null)
|
||||||
|
setShowMilestoneUserOverride(false)
|
||||||
|
setMilestoneUsersError(null)
|
||||||
handleEquipmentDeviceChange('laptop', item)
|
handleEquipmentDeviceChange('laptop', item)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1587,41 +1637,6 @@ export default function CreateOperationModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/[0.03]">
|
|
||||||
<div className="mb-3">
|
|
||||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
|
|
||||||
Milestone-Auswerter
|
|
||||||
</h4>
|
|
||||||
|
|
||||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
AD-Benutzer aus dem konfigurierten Suchbereich für die Milestone-Rolle.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Combobox
|
|
||||||
items={milestoneUserOptions}
|
|
||||||
value={selectedMilestoneUser}
|
|
||||||
onChange={handleMilestoneUserChange}
|
|
||||||
variant="secondary"
|
|
||||||
placeholder={
|
|
||||||
isLoadingMilestoneUsers
|
|
||||||
? 'AD-Benutzer werden geladen...'
|
|
||||||
: 'AD-Benutzer auswählen'
|
|
||||||
}
|
|
||||||
emptyText={
|
|
||||||
milestoneUsersConfigured
|
|
||||||
? 'Keine AD-Benutzer im Suchbereich gefunden.'
|
|
||||||
: 'AD ist in den Milestone-Einstellungen noch nicht konfiguriert.'
|
|
||||||
}
|
|
||||||
disabled={isLoadingMilestoneUsers || !milestoneUsersConfigured}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{milestoneUsersError && (
|
|
||||||
<p className="mt-2 text-xs text-amber-700 dark:text-amber-300">
|
|
||||||
{milestoneUsersError}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -1650,93 +1665,176 @@ export default function CreateOperationModal({
|
|||||||
|
|
||||||
{activeStep === 'equipment' && (
|
{activeStep === 'equipment' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="grid grid-cols-1 gap-x-4 gap-y-4 lg:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:items-start">
|
||||||
<Combobox
|
<div className="space-y-4">
|
||||||
label="Kamera"
|
<Combobox
|
||||||
labelIcon={VideoCameraIcon}
|
label="Kamera"
|
||||||
items={hardwareCameraOptions}
|
labelIcon={VideoCameraIcon}
|
||||||
value={selectedCameraDevice}
|
items={hardwareCameraOptions}
|
||||||
onChange={handleCameraDeviceChange}
|
value={selectedCameraDevice}
|
||||||
variant="secondary"
|
onChange={handleCameraDeviceChange}
|
||||||
placeholder={
|
variant="secondary"
|
||||||
isLoadingDevices
|
placeholder={
|
||||||
? 'Geräte werden geladen...'
|
isLoadingDevices
|
||||||
: 'Hardware-Kamera auswählen'
|
? 'Geräte werden geladen...'
|
||||||
}
|
: 'Hardware-Kamera auswählen'
|
||||||
emptyText="Keine Hardware-Kamera gefunden."
|
}
|
||||||
disabled={isLoadingDevices}
|
emptyText="Keine Hardware-Kamera gefunden."
|
||||||
/>
|
disabled={isLoadingDevices}
|
||||||
|
/>
|
||||||
|
|
||||||
<Combobox
|
<Combobox
|
||||||
label="Router"
|
label="Switchbox"
|
||||||
labelIcon={WifiIcon}
|
labelIcon={ArrowsRightLeftIcon}
|
||||||
items={routerOptions}
|
items={switchboxOptions}
|
||||||
value={selectedRouterDevice}
|
value={selectedSwitchboxDevice}
|
||||||
onChange={handleRouterDeviceChange}
|
onChange={handleSwitchboxDeviceChange}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
allowCustomValue
|
allowCustomValue
|
||||||
createOptionLabel={(value) => `"${value}" als Router anlegen`}
|
createOptionLabel={(value) => `"${value}" als Switchbox anlegen`}
|
||||||
placeholder={
|
placeholder={
|
||||||
isLoadingDevices
|
isLoadingDevices
|
||||||
? 'Geräte werden geladen...'
|
? 'Geräte werden geladen...'
|
||||||
: 'Router auswählen oder anlegen'
|
: 'Switchbox auswählen oder anlegen'
|
||||||
}
|
}
|
||||||
emptyText="Kein Router gefunden."
|
emptyText="Keine Switchbox gefunden."
|
||||||
disabled={isLoadingDevices}
|
disabled={isLoadingDevices}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Combobox
|
<Combobox
|
||||||
label="Switchbox"
|
label="Festplatte"
|
||||||
labelIcon={ArrowsRightLeftIcon}
|
labelIcon={CircleStackIcon}
|
||||||
items={switchboxOptions}
|
items={hardDriveOptions}
|
||||||
value={selectedSwitchboxDevice}
|
value={selectedHardDriveDevice}
|
||||||
onChange={handleSwitchboxDeviceChange}
|
onChange={handleHardDriveDeviceChange}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
allowCustomValue
|
allowCustomValue
|
||||||
createOptionLabel={(value) => `"${value}" als Switchbox anlegen`}
|
placeholder={
|
||||||
placeholder={
|
isLoadingDevices
|
||||||
isLoadingDevices
|
? 'Geräte werden geladen...'
|
||||||
? 'Geräte werden geladen...'
|
: 'Festplatte auswählen oder eingeben'
|
||||||
: 'Switchbox auswählen oder anlegen'
|
}
|
||||||
}
|
emptyText="Keine Festplatte gefunden."
|
||||||
emptyText="Keine Switchbox gefunden."
|
disabled={isLoadingDevices}
|
||||||
disabled={isLoadingDevices}
|
/>
|
||||||
/>
|
</div>
|
||||||
|
|
||||||
<Combobox
|
<div className="space-y-4">
|
||||||
label="Laptop"
|
<Combobox
|
||||||
labelIcon={ComputerDesktopIcon}
|
label="Router"
|
||||||
items={laptopOptions}
|
labelIcon={WifiIcon}
|
||||||
value={selectedLaptopDevice}
|
items={routerOptions}
|
||||||
onChange={handleLaptopDeviceChange}
|
value={selectedRouterDevice}
|
||||||
variant="secondary"
|
onChange={handleRouterDeviceChange}
|
||||||
allowCustomValue
|
variant="secondary"
|
||||||
createOptionLabel={(value) => `"${value}" als Laptop anlegen`}
|
allowCustomValue
|
||||||
placeholder={
|
createOptionLabel={(value) => `"${value}" als Router anlegen`}
|
||||||
isLoadingDevices
|
placeholder={
|
||||||
? 'Geräte werden geladen...'
|
isLoadingDevices
|
||||||
: 'Laptop auswählen oder anlegen'
|
? 'Geräte werden geladen...'
|
||||||
}
|
: 'Router auswählen oder anlegen'
|
||||||
emptyText="Kein Laptop gefunden."
|
}
|
||||||
disabled={isLoadingDevices}
|
emptyText="Kein Router gefunden."
|
||||||
/>
|
disabled={isLoadingDevices}
|
||||||
|
/>
|
||||||
|
|
||||||
<Combobox
|
<Combobox
|
||||||
label="Festplatte"
|
label="Laptop"
|
||||||
labelIcon={CircleStackIcon}
|
labelIcon={ComputerDesktopIcon}
|
||||||
items={hardDriveOptions}
|
items={laptopOptions}
|
||||||
value={selectedHardDriveDevice}
|
value={selectedLaptopDevice}
|
||||||
onChange={handleHardDriveDeviceChange}
|
onChange={handleLaptopDeviceChange}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
allowCustomValue
|
allowCustomValue
|
||||||
placeholder={
|
createOptionLabel={(value) => `"${value}" als Laptop anlegen`}
|
||||||
isLoadingDevices
|
placeholder={
|
||||||
? 'Geräte werden geladen...'
|
isLoadingDevices
|
||||||
: 'Festplatte auswählen oder eingeben'
|
? 'Geräte werden geladen...'
|
||||||
}
|
: 'Laptop auswählen oder anlegen'
|
||||||
emptyText="Keine Festplatte gefunden."
|
}
|
||||||
disabled={isLoadingDevices}
|
emptyText="Kein Laptop gefunden."
|
||||||
/>
|
disabled={isLoadingDevices}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-indigo-100 bg-indigo-50/60 px-3 py-2 dark:border-indigo-400/20 dark:bg-indigo-500/10">
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||||
|
<div className="min-w-0 flex flex-1 items-center gap-2">
|
||||||
|
<span className="shrink-0 text-xs font-semibold uppercase tracking-wide text-indigo-700 dark:text-indigo-300">
|
||||||
|
Auswerter
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 truncate text-sm text-gray-900 dark:text-white">
|
||||||
|
{selectedMilestoneUser?.name ||
|
||||||
|
automaticMilestoneUserName ||
|
||||||
|
'Automatisch nach Laptop'}
|
||||||
|
</span>
|
||||||
|
{!selectedMilestoneUser && automaticMilestoneUserName && (
|
||||||
|
<span className="shrink-0 rounded-full bg-white px-2 py-0.5 text-[0.6875rem] font-medium text-indigo-700 ring-1 ring-indigo-600/15 ring-inset dark:bg-white/10 dark:text-indigo-200 dark:ring-indigo-300/20">
|
||||||
|
automatisch
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 gap-2">
|
||||||
|
{selectedMilestoneUser && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
color="gray"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedMilestoneUser(null)
|
||||||
|
setShowMilestoneUserOverride(false)
|
||||||
|
setMilestoneUsersError(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Automatik
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
color="gray"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setShowMilestoneUserOverride((current) => !current)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{showMilestoneUserOverride ? 'Ausblenden' : 'Ändern'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showMilestoneUserOverride && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<Combobox
|
||||||
|
items={milestoneUserOptions}
|
||||||
|
value={selectedMilestoneUser}
|
||||||
|
onChange={handleMilestoneUserChange}
|
||||||
|
variant="secondary"
|
||||||
|
placeholder={
|
||||||
|
isLoadingMilestoneUsers
|
||||||
|
? 'AD-Benutzer werden geladen...'
|
||||||
|
: 'AD-Benutzer auswählen'
|
||||||
|
}
|
||||||
|
emptyText={
|
||||||
|
milestoneUsersConfigured
|
||||||
|
? 'Keine AD-Benutzer im Suchbereich gefunden.'
|
||||||
|
: 'AD ist in den Milestone-Einstellungen noch nicht konfiguriert.'
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
isLoadingMilestoneUsers || !milestoneUsersConfigured
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{milestoneUsersError && (
|
||||||
|
<p className="mt-2 text-xs text-amber-700 dark:text-amber-300">
|
||||||
|
{milestoneUsersError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -2097,7 +2195,7 @@ export default function CreateOperationModal({
|
|||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-4">
|
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||||
<ReviewItem label="Sachbearbeitung" value={formValues.caseWorker} />
|
<ReviewItem label="Sachbearbeitung" value={formValues.caseWorker} />
|
||||||
|
|
||||||
<ReviewPersonItem
|
<ReviewPersonItem
|
||||||
@ -2112,11 +2210,6 @@ export default function CreateOperationModal({
|
|||||||
fallbackValue={formValues.operationTeam}
|
fallbackValue={formValues.operationTeam}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ReviewPersonItem
|
|
||||||
label="Milestone-Auswerter"
|
|
||||||
item={selectedMilestoneUser}
|
|
||||||
fallbackValue=""
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -2127,11 +2220,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-5">
|
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-6">
|
||||||
<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="Milestone-Auswerter" value={milestoneUserDisplayValue} />
|
||||||
<ReviewItem label="Festplatte" value={formValues.hardDrive} />
|
<ReviewItem label="Festplatte" value={formValues.hardDrive} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -431,7 +431,7 @@ export default function Konto({
|
|||||||
src={avatarPreview}
|
src={avatarPreview}
|
||||||
name={currentUser?.displayName || currentUser?.username || currentUser?.email}
|
name={currentUser?.displayName || currentUser?.username || currentUser?.email}
|
||||||
size={16}
|
size={16}
|
||||||
shape="rounded"
|
shape="circle"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
// frontend/src/utils/activityLog.ts
|
// frontend/src/utils/activityLog.ts
|
||||||
//
|
//
|
||||||
// Clientseitiges Aktivitäts- und Fehlerprotokoll. Erfasst app-weit, was der
|
// Clientseitiges Aktivitaets- und Fehlerprotokoll. Erfasst app-weit nur Klicks
|
||||||
// Nutzer anklickt, welche Seiten er aufruft und welche Fehler im Browser
|
// auf Schaltflaechen, Seitenaufrufe und API-Fehler. Wird auf der Feedback-Seite
|
||||||
// auftreten. Wird auf der Feedback-Seite sichtbar gemacht und ans Feedback
|
// sichtbar gemacht und ans Feedback angehaengt, damit Administratoren Probleme
|
||||||
// angehängt, damit Administratoren Probleme nachvollziehen können.
|
// nachvollziehen koennen.
|
||||||
|
|
||||||
export type ActivityLogType = 'action' | 'navigation' | 'error'
|
export type ActivityLogType = 'action' | 'navigation' | 'error'
|
||||||
|
|
||||||
@ -18,7 +18,10 @@ export type ActivityLogEntry = {
|
|||||||
|
|
||||||
const MAX_ENTRIES = 200
|
const MAX_ENTRIES = 200
|
||||||
const STORAGE_KEY = 'teg.activityLog'
|
const STORAGE_KEY = 'teg.activityLog'
|
||||||
const MAX_STACK_LENGTH = 4000
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||||
|
|
||||||
|
type FetchInput = Parameters<typeof fetch>[0]
|
||||||
|
type FetchInit = Parameters<typeof fetch>[1]
|
||||||
|
|
||||||
let entries: ActivityLogEntry[] = loadEntries()
|
let entries: ActivityLogEntry[] = loadEntries()
|
||||||
let initialized = false
|
let initialized = false
|
||||||
@ -50,7 +53,7 @@ function persist() {
|
|||||||
try {
|
try {
|
||||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(entries))
|
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(entries))
|
||||||
} catch {
|
} catch {
|
||||||
// Speicher voll oder im privaten Modus – Protokoll bleibt nur im Speicher.
|
// Speicher voll oder privater Modus: Protokoll bleibt nur im Speicher.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -119,54 +122,29 @@ export function subscribeActivityLog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function truncateStack(stack?: string) {
|
function describeButton(target: Element): string | null {
|
||||||
if (!stack) {
|
const button = target.closest(
|
||||||
return undefined
|
'button, [role="button"], input[type="button"], input[type="submit"], input[type="reset"]',
|
||||||
}
|
|
||||||
return stack.length > MAX_STACK_LENGTH ? stack.slice(0, MAX_STACK_LENGTH) : stack
|
|
||||||
}
|
|
||||||
|
|
||||||
function describeElement(target: Element): string | null {
|
|
||||||
const actionable = target.closest(
|
|
||||||
'button, a, [role="button"], input, select, textarea, label, summary, [role="menuitem"], [role="tab"], [data-log-label]',
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!actionable) {
|
if (!button) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const element = actionable as HTMLElement
|
const element = button as HTMLElement
|
||||||
const tag = element.tagName.toLowerCase()
|
|
||||||
|
|
||||||
const role =
|
|
||||||
element.getAttribute('data-log-role') ||
|
|
||||||
(tag === 'a'
|
|
||||||
? 'Link'
|
|
||||||
: tag === 'button' || element.getAttribute('role') === 'button'
|
|
||||||
? 'Schaltfläche'
|
|
||||||
: tag === 'input'
|
|
||||||
? `Eingabe (${(element as HTMLInputElement).type || 'text'})`
|
|
||||||
: tag === 'select'
|
|
||||||
? 'Auswahl'
|
|
||||||
: tag === 'textarea'
|
|
||||||
? 'Textfeld'
|
|
||||||
: tag === 'summary'
|
|
||||||
? 'Aufklappen'
|
|
||||||
: 'Element')
|
|
||||||
|
|
||||||
const rawLabel =
|
const rawLabel =
|
||||||
element.getAttribute('data-log-label') ||
|
element.getAttribute('data-log-label') ||
|
||||||
element.getAttribute('aria-label') ||
|
element.getAttribute('aria-label') ||
|
||||||
element.getAttribute('title') ||
|
element.getAttribute('title') ||
|
||||||
(element as HTMLInputElement).placeholder ||
|
(element instanceof HTMLInputElement ? element.value : '') ||
|
||||||
(element.textContent ?? '').replace(/\s+/g, ' ').trim()
|
(element.textContent ?? '').replace(/\s+/g, ' ').trim()
|
||||||
|
|
||||||
if (!rawLabel) {
|
if (!rawLabel) {
|
||||||
return role
|
return 'Schaltflaeche'
|
||||||
}
|
}
|
||||||
|
|
||||||
const label = rawLabel.length > 80 ? `${rawLabel.slice(0, 80)}…` : rawLabel
|
const label = rawLabel.length > 80 ? `${rawLabel.slice(0, 80)}...` : rawLabel
|
||||||
return `${role}: „${label}"`
|
return `Schaltflaeche: "${label}"`
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClick(event: MouseEvent) {
|
function handleClick(event: MouseEvent) {
|
||||||
@ -175,45 +153,103 @@ function handleClick(event: MouseEvent) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const description = describeElement(target)
|
const description = describeButton(target)
|
||||||
if (description) {
|
if (description) {
|
||||||
recordAction(`Klick – ${description}`)
|
recordAction(`Klick - ${description}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleError(event: ErrorEvent) {
|
function requestUrl(input: FetchInput) {
|
||||||
if (!event.message && event.target && event.target !== window) {
|
if (typeof input === 'string') {
|
||||||
const element = event.target as HTMLElement & { src?: string; href?: string }
|
return input
|
||||||
const source = element.src || element.href || ''
|
|
||||||
recordError(
|
|
||||||
`Ressource konnte nicht geladen werden${source ? `: ${source}` : ''}`,
|
|
||||||
{ element: element.tagName?.toLowerCase() },
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
recordError(event.message || 'Unbekannter Fehler', {
|
if (input instanceof URL) {
|
||||||
source: event.filename,
|
return input.href
|
||||||
line: event.lineno,
|
}
|
||||||
column: event.colno,
|
|
||||||
stack: truncateStack(event.error?.stack),
|
return input.url
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRejection(event: PromiseRejectionEvent) {
|
function requestMethod(input: FetchInput, init: FetchInit) {
|
||||||
const reason = event.reason
|
if (init?.method) {
|
||||||
const message = reason instanceof Error ? reason.message : String(reason)
|
return init.method.toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
recordError(`Unbehandelte Ablehnung: ${message}`, {
|
if (typeof input !== 'string' && !(input instanceof URL)) {
|
||||||
stack: reason instanceof Error ? truncateStack(reason.stack) : undefined,
|
return input.method.toUpperCase()
|
||||||
})
|
}
|
||||||
|
|
||||||
|
return 'GET'
|
||||||
}
|
}
|
||||||
|
|
||||||
function safeStringify(value: unknown) {
|
function normalizedUrl(value: string) {
|
||||||
|
return new URL(value, window.location.origin)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isApiRequest(value: string) {
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(value)
|
const url = normalizedUrl(value)
|
||||||
|
const apiUrl = normalizedUrl(API_URL)
|
||||||
|
const apiPath = apiUrl.pathname.replace(/\/$/, '')
|
||||||
|
|
||||||
|
if (url.pathname.startsWith('/api/')) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
url.origin === apiUrl.origin &&
|
||||||
|
(apiPath === '' ||
|
||||||
|
url.pathname === apiPath ||
|
||||||
|
url.pathname.startsWith(`${apiPath}/`))
|
||||||
|
)
|
||||||
} catch {
|
} catch {
|
||||||
return String(value)
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiPath(value: string) {
|
||||||
|
const url = normalizedUrl(value)
|
||||||
|
return `${url.pathname}${url.search}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiErrorDetails(method: string, url: string) {
|
||||||
|
return {
|
||||||
|
method,
|
||||||
|
url: apiPath(url),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initApiErrorLogging() {
|
||||||
|
const originalFetch = window.fetch.bind(window)
|
||||||
|
|
||||||
|
window.fetch = async (...args: Parameters<typeof window.fetch>) => {
|
||||||
|
const [input, init] = args
|
||||||
|
const url = requestUrl(input)
|
||||||
|
const method = requestMethod(input, init)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await originalFetch(...args)
|
||||||
|
|
||||||
|
if (!response.ok && isApiRequest(url)) {
|
||||||
|
recordError(`API-Fehler: ${method} ${apiPath(url)} (${response.status})`, {
|
||||||
|
...apiErrorDetails(method, url),
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
} catch (error) {
|
||||||
|
if (isApiRequest(url)) {
|
||||||
|
recordError(`API nicht erreichbar: ${method} ${apiPath(url)}`, {
|
||||||
|
...apiErrorDetails(method, url),
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -224,33 +260,5 @@ export function initActivityLog() {
|
|||||||
initialized = true
|
initialized = true
|
||||||
|
|
||||||
document.addEventListener('click', handleClick, { capture: true })
|
document.addEventListener('click', handleClick, { capture: true })
|
||||||
window.addEventListener('error', handleError, true)
|
initApiErrorLogging()
|
||||||
window.addEventListener('unhandledrejection', handleRejection)
|
|
||||||
|
|
||||||
const originalConsoleError = console.error.bind(console)
|
|
||||||
console.error = (...args: unknown[]) => {
|
|
||||||
try {
|
|
||||||
const errorArg = args.find((arg): arg is Error => arg instanceof Error)
|
|
||||||
const message = args
|
|
||||||
.map((arg) =>
|
|
||||||
arg instanceof Error
|
|
||||||
? arg.message
|
|
||||||
: typeof arg === 'string'
|
|
||||||
? arg
|
|
||||||
: safeStringify(arg),
|
|
||||||
)
|
|
||||||
.join(' ')
|
|
||||||
|
|
||||||
// React-Entwicklungswarnungen sind kein echter Nutzerfehler.
|
|
||||||
if (!message.startsWith('Warning:')) {
|
|
||||||
recordError(`console.error: ${message}`, {
|
|
||||||
stack: truncateStack(errorArg?.stack),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Logging darf die App niemals stören.
|
|
||||||
}
|
|
||||||
|
|
||||||
originalConsoleError(...args)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user