updated
This commit is contained in:
parent
e5a88399e9
commit
182f04bdad
@ -16,15 +16,17 @@ type AdminGroupChat struct {
|
||||
Image string `json:"image"`
|
||||
OwnerID string `json:"ownerId"`
|
||||
TeamID string `json:"teamId"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
UserIDs []string `json:"userIds"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type saveAdminGroupChatRequest struct {
|
||||
Name string `json:"name"`
|
||||
Image string `json:"image"`
|
||||
UserIDs []string `json:"userIds"`
|
||||
Name string `json:"name"`
|
||||
Image string `json:"image"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
UserIDs []string `json:"userIds"`
|
||||
}
|
||||
|
||||
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,
|
||||
COALESCE(c.created_by::TEXT, ''),
|
||||
COALESCE(c.team_id::TEXT, ''),
|
||||
c.is_public,
|
||||
COALESCE(
|
||||
array_agg(cm.user_id::TEXT ORDER BY cm.created_at)
|
||||
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.OwnerID,
|
||||
&group.TeamID,
|
||||
&group.IsPublic,
|
||||
&group.UserIDs,
|
||||
&group.CreatedAt,
|
||||
&group.UpdatedAt,
|
||||
@ -132,10 +136,11 @@ func (s *Server) handleAdminUpdateGroupChat(w http.ResponseWriter, r *http.Reque
|
||||
defer tx.Rollback(r.Context())
|
||||
|
||||
var currentName, currentImage, ownerID string
|
||||
var currentIsPublic bool
|
||||
if err := tx.QueryRow(
|
||||
r.Context(),
|
||||
`
|
||||
SELECT name, channel_image, COALESCE(created_by::TEXT, '')
|
||||
SELECT name, channel_image, COALESCE(created_by::TEXT, ''), is_public
|
||||
FROM conversations
|
||||
WHERE id = $1
|
||||
AND type = 'group'
|
||||
@ -143,7 +148,7 @@ func (s *Server) handleAdminUpdateGroupChat(w http.ResponseWriter, r *http.Reque
|
||||
FOR UPDATE
|
||||
`,
|
||||
conversationID,
|
||||
).Scan(¤tName, ¤tImage, &ownerID); err != nil {
|
||||
).Scan(¤tName, ¤tImage, &ownerID, ¤tIsPublic); err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
writeError(w, http.StatusNotFound, "Gruppenchat wurde nicht gefunden")
|
||||
return
|
||||
@ -173,6 +178,7 @@ func (s *Server) handleAdminUpdateGroupChat(w http.ResponseWriter, r *http.Reque
|
||||
SET
|
||||
name = $2,
|
||||
channel_image = $3,
|
||||
is_public = $4,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
AND type = 'group'
|
||||
@ -181,6 +187,7 @@ func (s *Server) handleAdminUpdateGroupChat(w http.ResponseWriter, r *http.Reque
|
||||
conversationID,
|
||||
input.Name,
|
||||
input.Image,
|
||||
input.IsPublic,
|
||||
); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Gruppenchat konnte nicht aktualisiert werden")
|
||||
return
|
||||
@ -229,6 +236,7 @@ func (s *Server) handleAdminUpdateGroupChat(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
changed := currentName != input.Name ||
|
||||
currentImage != input.Image ||
|
||||
currentIsPublic != input.IsPublic ||
|
||||
!sameStringSet(oldMemberIDs, newMemberIDs)
|
||||
if changed {
|
||||
s.emitSystemMessage(
|
||||
|
||||
@ -43,6 +43,12 @@ type milestoneSettingsResponse struct {
|
||||
ServerFoldersArchiveRootLabel string `json:"serverFoldersArchiveRootLabel"`
|
||||
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"`
|
||||
}
|
||||
|
||||
@ -58,6 +64,11 @@ type updateMilestoneSettingsRequest struct {
|
||||
ServerFoldersStorageRootPath string `json:"serverFoldersStorageRootPath"`
|
||||
ServerFoldersArchiveRootLabel string `json:"serverFoldersArchiveRootLabel"`
|
||||
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 {
|
||||
@ -90,6 +101,11 @@ func (s *Server) handleGetMilestoneSettings(w http.ResponseWriter, r *http.Reque
|
||||
ServerFoldersStorageRootPath: "D:/Milestone-Speicher",
|
||||
ServerFoldersArchiveRootLabel: "Archive E",
|
||||
ServerFoldersArchiveRootPath: "E:/",
|
||||
BackupNasHost: "",
|
||||
BackupNasShare: "",
|
||||
BackupNasUsername: "",
|
||||
BackupNasPasswordConfigured: false,
|
||||
BackupLogRootPath: "",
|
||||
},
|
||||
})
|
||||
return
|
||||
@ -125,6 +141,11 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re
|
||||
serverFoldersStorageRootPath := normalizeServerFoldersRootPath(input.ServerFoldersStorageRootPath, "D:/Milestone-Speicher")
|
||||
serverFoldersArchiveRootLabel := normalizeServerFoldersRootLabel(input.ServerFoldersArchiveRootLabel, "Archive 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 == "" {
|
||||
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_archive_root_label,
|
||||
server_folders_archive_root_path,
|
||||
backup_log_root_path,
|
||||
server_folders_agent_token_encrypted
|
||||
)
|
||||
VALUES (
|
||||
@ -191,6 +213,7 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re
|
||||
$9,
|
||||
$10,
|
||||
$11,
|
||||
$12,
|
||||
NULL
|
||||
)
|
||||
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_archive_root_label = EXCLUDED.server_folders_archive_root_label,
|
||||
server_folders_archive_root_path = EXCLUDED.server_folders_archive_root_path,
|
||||
backup_log_root_path = EXCLUDED.backup_log_root_path,
|
||||
updated_at = now()
|
||||
`,
|
||||
host,
|
||||
@ -222,6 +246,7 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re
|
||||
serverFoldersStorageRootPath,
|
||||
serverFoldersArchiveRootLabel,
|
||||
serverFoldersArchiveRootPath,
|
||||
backupLogRootPath,
|
||||
)
|
||||
} else {
|
||||
_, 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_archive_root_label = $8,
|
||||
server_folders_archive_root_path = $9,
|
||||
backup_log_root_path = $10,
|
||||
updated_at = now()
|
||||
WHERE id = 1
|
||||
`,
|
||||
@ -254,6 +280,7 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re
|
||||
serverFoldersStorageRootPath,
|
||||
serverFoldersArchiveRootLabel,
|
||||
serverFoldersArchiveRootPath,
|
||||
backupLogRootPath,
|
||||
)
|
||||
}
|
||||
|
||||
@ -262,6 +289,35 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re
|
||||
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())
|
||||
|
||||
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_archive_root_label, 'Archive 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
|
||||
FROM milestone_settings
|
||||
WHERE id = 1
|
||||
@ -499,6 +560,11 @@ func (s *Server) getMilestoneSettings(ctx context.Context) (milestoneSettingsRes
|
||||
&settings.ServerFoldersStorageRootPath,
|
||||
&settings.ServerFoldersArchiveRootLabel,
|
||||
&settings.ServerFoldersArchiveRootPath,
|
||||
&settings.BackupNasHost,
|
||||
&settings.BackupNasShare,
|
||||
&settings.BackupNasUsername,
|
||||
&settings.BackupNasPasswordConfigured,
|
||||
&settings.BackupLogRootPath,
|
||||
&updatedAt,
|
||||
)
|
||||
|
||||
@ -659,6 +725,31 @@ func normalizeServerFoldersRootPath(value string, fallback string) string {
|
||||
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 {
|
||||
value = strings.TrimSpace(value)
|
||||
value = strings.TrimRight(value, "/")
|
||||
|
||||
371
backend/chat.go
371
backend/chat.go
@ -43,10 +43,22 @@ type ChatMessage struct {
|
||||
ForwardedFrom *ChatMessageReference `json:"forwardedFrom"`
|
||||
Location *ChatLocation `json:"location"`
|
||||
LinkPreview *ChatLinkPreview `json:"linkPreview"`
|
||||
Reactions []ChatReaction `json:"reactions"`
|
||||
replyToMessageID 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 {
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
@ -83,6 +95,8 @@ type ChatConversation struct {
|
||||
Image string `json:"image"`
|
||||
TeamID string `json:"teamId"`
|
||||
OwnerID string `json:"ownerId"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
IsMember bool `json:"isMember"`
|
||||
DisappearingSeconds int `json:"disappearingSeconds"`
|
||||
Participants []ChatUser `json:"participants"`
|
||||
LastMessage *ChatMessage `json:"lastMessage"`
|
||||
@ -112,6 +126,10 @@ type createChatMessageInput struct {
|
||||
Location *ChatLocation
|
||||
}
|
||||
|
||||
type updateChatReactionRequest struct {
|
||||
Emoji string `json:"emoji"`
|
||||
}
|
||||
|
||||
var (
|
||||
errChatAccessDenied = errors.New("kein zugriff auf diesen chat")
|
||||
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.channel_image,
|
||||
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((
|
||||
SELECT cm.muted
|
||||
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.sender_id IS DISTINCT FROM $1::UUID
|
||||
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(
|
||||
current_membership.last_read_at,
|
||||
'-infinity'::TIMESTAMPTZ
|
||||
@ -294,13 +330,23 @@ func (s *Server) handleListChatConversations(w http.ResponseWriter, r *http.Requ
|
||||
), 0),
|
||||
c.created_at,
|
||||
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
|
||||
FROM conversations c
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM conversation_members cm
|
||||
WHERE cm.conversation_id = c.id
|
||||
AND cm.user_id = $1
|
||||
WHERE (
|
||||
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 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.CreatedAt,
|
||||
&conversation.OwnerID,
|
||||
&conversation.IsPublic,
|
||||
&conversation.IsMember,
|
||||
&conversation.DisappearingSeconds,
|
||||
); err != nil {
|
||||
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
|
||||
|
||||
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) {
|
||||
writeError(w, http.StatusInternalServerError, "Letzte Nachricht konnte nicht geladen werden")
|
||||
return
|
||||
@ -455,7 +503,7 @@ func (s *Server) handleListChatMessages(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
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")
|
||||
return
|
||||
}
|
||||
@ -516,13 +564,21 @@ func (s *Server) handleListChatMessages(w http.ResponseWriter, r *http.Request)
|
||||
FROM messages m
|
||||
JOIN conversations c ON c.id = m.conversation_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
|
||||
AND m.deleted_at IS NULL
|
||||
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
|
||||
LIMIT 200
|
||||
`,
|
||||
conversationID,
|
||||
user.ID,
|
||||
)
|
||||
if err != nil {
|
||||
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})
|
||||
}
|
||||
|
||||
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(
|
||||
ctx context.Context,
|
||||
user User,
|
||||
@ -614,6 +806,9 @@ func (s *Server) createChatMessage(
|
||||
if !s.isConversationWritable(ctx, conversationID) {
|
||||
return message, errChatReadOnly
|
||||
}
|
||||
if !s.isConversationMember(ctx, conversationID, user.ID) {
|
||||
return message, errChatAccessDenied
|
||||
}
|
||||
|
||||
if replyToMessageID := strings.TrimSpace(input.ReplyToMessageID); replyToMessageID != "" {
|
||||
var valid bool
|
||||
@ -857,11 +1052,14 @@ func (s *Server) canAccessConversation(ctx context.Context, conversationID strin
|
||||
SELECT 1
|
||||
FROM conversations c
|
||||
WHERE c.id = $1
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM conversation_members cm
|
||||
WHERE cm.conversation_id = c.id
|
||||
AND cm.user_id = $2
|
||||
AND (
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM conversation_members cm
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
var writable bool
|
||||
err := s.db.QueryRow(
|
||||
@ -988,7 +1235,20 @@ func (s *Server) getChatConversation(ctx context.Context, conversationID string,
|
||||
c.name,
|
||||
c.channel_image,
|
||||
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((
|
||||
SELECT cm.muted
|
||||
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.sender_id IS DISTINCT FROM $2::UUID
|
||||
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(
|
||||
current_membership.last_read_at,
|
||||
'-infinity'::TIMESTAMPTZ
|
||||
@ -1013,6 +1278,13 @@ func (s *Server) getChatConversation(ctx context.Context, conversationID string,
|
||||
), 0),
|
||||
c.created_at,
|
||||
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
|
||||
FROM conversations c
|
||||
WHERE c.id = $1
|
||||
@ -1030,6 +1302,8 @@ func (s *Server) getChatConversation(ctx context.Context, conversationID string,
|
||||
&conversation.UnreadCount,
|
||||
&conversation.CreatedAt,
|
||||
&conversation.OwnerID,
|
||||
&conversation.IsPublic,
|
||||
&conversation.IsMember,
|
||||
&conversation.DisappearingSeconds,
|
||||
)
|
||||
if err != nil {
|
||||
@ -1044,7 +1318,7 @@ func (s *Server) getChatConversation(ctx context.Context, conversationID string,
|
||||
return conversation, err
|
||||
}
|
||||
|
||||
lastMessage, err := s.getLastChatMessage(ctx, conversation.ID)
|
||||
lastMessage, err := s.getLastChatMessage(ctx, conversation.ID, userID)
|
||||
if err == nil {
|
||||
conversation.LastMessage = &lastMessage
|
||||
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||||
@ -1155,7 +1429,7 @@ func (s *Server) getChatMessage(ctx context.Context, messageID string) (ChatMess
|
||||
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(
|
||||
ctx,
|
||||
`
|
||||
@ -1212,13 +1486,21 @@ func (s *Server) getLastChatMessage(ctx context.Context, conversationID string)
|
||||
FROM messages m
|
||||
JOIN conversations c ON c.id = m.conversation_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
|
||||
AND m.deleted_at IS NULL
|
||||
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
|
||||
LIMIT 1
|
||||
`,
|
||||
conversationID,
|
||||
userID,
|
||||
)
|
||||
message, err := scanChatMessage(row)
|
||||
if err != nil {
|
||||
@ -1247,6 +1529,12 @@ func (s *Server) enrichChatMessage(ctx context.Context, message *ChatMessage) er
|
||||
}
|
||||
message.LinkPreview = preview
|
||||
|
||||
reactions, err := s.listChatReactions(ctx, message.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
message.Reactions = reactions
|
||||
|
||||
if message.replyToMessageID != "" {
|
||||
reference, err := s.getChatMessageReference(ctx, message.replyToMessageID)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
@ -1270,6 +1558,57 @@ func (s *Server) enrichChatMessage(ctx context.Context, message *ChatMessage) er
|
||||
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(
|
||||
ctx context.Context,
|
||||
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"`
|
||||
Image string `json:"image"`
|
||||
MemberIDs []string `json:"memberIds"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
}
|
||||
|
||||
type updateGroupRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Image *string `json:"image"`
|
||||
Name *string `json:"name"`
|
||||
Image *string `json:"image"`
|
||||
IsPublic *bool `json:"isPublic"`
|
||||
}
|
||||
|
||||
type addGroupMembersRequest struct {
|
||||
@ -94,21 +96,6 @@ func (s *Server) startDisappearingMessagesMonitor(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)
|
||||
}
|
||||
|
||||
@ -269,13 +256,14 @@ func (s *Server) handleCreateGroup(w http.ResponseWriter, r *http.Request) {
|
||||
err = tx.QueryRow(
|
||||
r.Context(),
|
||||
`
|
||||
INSERT INTO conversations (type, name, channel_image, created_by)
|
||||
VALUES ('group', $1, $2, $3)
|
||||
INSERT INTO conversations (type, name, channel_image, created_by, is_public)
|
||||
VALUES ('group', $1, $2, $3, $4)
|
||||
RETURNING id::TEXT
|
||||
`,
|
||||
name,
|
||||
image,
|
||||
user.ID,
|
||||
input.IsPublic,
|
||||
).Scan(&conversationID)
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Gruppe konnte nicht geladen werden")
|
||||
writeError(w, http.StatusInternalServerError, "Chat konnte nicht geladen werden")
|
||||
return
|
||||
}
|
||||
|
||||
@ -339,26 +327,38 @@ func (s *Server) handleUpdateGroup(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
var currentName, currentImage, ownerID, teamID string
|
||||
var conversationType, currentName, currentImage, ownerID, teamID string
|
||||
var currentIsPublic bool
|
||||
if err := s.db.QueryRow(
|
||||
r.Context(),
|
||||
`
|
||||
SELECT
|
||||
type,
|
||||
name,
|
||||
channel_image,
|
||||
COALESCE(created_by::TEXT, ''),
|
||||
COALESCE(team_id::TEXT, '')
|
||||
COALESCE(team_id::TEXT, ''),
|
||||
is_public
|
||||
FROM conversations
|
||||
WHERE id = $1
|
||||
AND type = 'group'
|
||||
AND type IN ('direct', 'group')
|
||||
`,
|
||||
conversationID,
|
||||
).Scan(¤tName, ¤tImage, &ownerID, &teamID); err != nil {
|
||||
writeError(w, http.StatusNotFound, "Gruppe wurde nicht gefunden")
|
||||
).Scan(&conversationType, ¤tName, ¤tImage, &ownerID, &teamID, ¤tIsPublic); err != nil {
|
||||
writeError(w, http.StatusNotFound, "Chat wurde nicht gefunden")
|
||||
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) {
|
||||
writeError(w, http.StatusForbidden, "Keine Berechtigung zum Verwalten des Team-Gruppenchats")
|
||||
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 {
|
||||
s.publishConversationUpdate(r.Context(), conversationID)
|
||||
}
|
||||
|
||||
conversation, err := s.getChatConversation(r.Context(), conversationID, user.ID, user.ShowLastSeen)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Gruppe konnte nicht geladen werden")
|
||||
writeError(w, http.StatusInternalServerError, "Chat konnte nicht geladen werden")
|
||||
return
|
||||
}
|
||||
|
||||
@ -766,48 +798,6 @@ func (s *Server) handleUpdateDisappearing(w http.ResponseWriter, r *http.Request
|
||||
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 {
|
||||
writeError(w, http.StatusInternalServerError, "Einstellung konnte nicht gespeichert werden")
|
||||
return
|
||||
|
||||
@ -21,18 +21,8 @@ func (s *Server) handleUpdateConversationMute(w http.ResponseWriter, r *http.Req
|
||||
writeError(w, http.StatusForbidden, "Kein Zugriff auf diesen Chat")
|
||||
return
|
||||
}
|
||||
|
||||
var conversationType string
|
||||
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")
|
||||
if !s.isConversationMember(r.Context(), conversationID, user.ID) {
|
||||
writeError(w, http.StatusForbidden, "Nur Mitglieder können diesen Chat stummschalten")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -48,15 +48,25 @@ func (s *Server) handleSearchChatMessages(w http.ResponseWriter, r *http.Request
|
||||
FROM messages m
|
||||
JOIN conversations c ON c.id = m.conversation_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
|
||||
AND m.message_type = 'user'
|
||||
AND (m.expires_at IS NULL OR m.expires_at > now())
|
||||
AND strpos(lower(m.body), lower($2)) > 0
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM conversation_members cm
|
||||
WHERE cm.conversation_id = c.id
|
||||
AND cm.user_id = $1
|
||||
AND m.created_at > COALESCE(
|
||||
viewer_membership.last_cleared_at,
|
||||
'-infinity'::TIMESTAMPTZ
|
||||
)
|
||||
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
|
||||
LIMIT 50
|
||||
|
||||
@ -14,14 +14,10 @@ func (s *Server) markChatConversationRead(
|
||||
_, err := s.db.Exec(
|
||||
ctx,
|
||||
`
|
||||
INSERT INTO conversation_members (
|
||||
conversation_id,
|
||||
user_id,
|
||||
last_read_at
|
||||
)
|
||||
VALUES ($1, $2, now())
|
||||
ON CONFLICT (conversation_id, user_id)
|
||||
DO UPDATE SET last_read_at = EXCLUDED.last_read_at
|
||||
UPDATE conversation_members
|
||||
SET last_read_at = now()
|
||||
WHERE conversation_id = $1
|
||||
AND user_id = $2
|
||||
`,
|
||||
conversationID,
|
||||
userID,
|
||||
|
||||
@ -183,8 +183,7 @@ func (s *Server) readChatSocket(
|
||||
|
||||
switch command.Type {
|
||||
case "typing.start", "typing.stop":
|
||||
if !s.canAccessConversation(ctx, command.ConversationID, user.ID) ||
|
||||
!s.isConversationWritable(ctx, command.ConversationID) {
|
||||
if !s.canPostToConversation(ctx, command.ConversationID, user.ID) {
|
||||
client.send(chatSocketEvent{
|
||||
Type: "error",
|
||||
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")
|
||||
}
|
||||
|
||||
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) {
|
||||
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(
|
||||
ctx context.Context,
|
||||
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 {
|
||||
var exists bool
|
||||
|
||||
|
||||
@ -7,14 +7,16 @@ go 1.26.2
|
||||
require (
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0
|
||||
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/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/hirochachacha/go-smb2 v1.1.0
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
golang.org/x/crypto v0.51.0
|
||||
)
|
||||
|
||||
require (
|
||||
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/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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
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/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
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/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
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/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-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.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
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.15.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-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
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/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-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-20210615035016-665e8c7367d1/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",
|
||||
Step: "roleUser",
|
||||
Status: "current",
|
||||
Message: "Auswertungsrolle und AD-Benutzer werden geprueft.",
|
||||
Message: "Einsatzrolle und Auswerter-Benutzer werden geprueft.",
|
||||
})
|
||||
targetRole, err = s.ensureOperationMilestoneRoleUser(ctx, config, operation, input)
|
||||
if err != nil {
|
||||
@ -824,86 +824,43 @@ func (s *Server) ensureOperationMilestoneRoleUser(
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
roleName := operationMilestoneEvaluationRoleName(*laptopDevice, operation)
|
||||
if roleName == "" {
|
||||
return nil, fmt.Errorf("Auswertungsrolle konnte aus Laptop %q nicht ermittelt werden", laptopDevice.InventoryNumber)
|
||||
operationRoleName := operationMilestoneRoleName(operation)
|
||||
if operationRoleName == "" {
|
||||
return nil, errors.New("Einsatznummer fehlt, Milestone-Rolle konnte nicht angelegt werden")
|
||||
}
|
||||
|
||||
sourceRole, err := findOperationMilestoneRoleByName(ctx, config, roleName)
|
||||
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)
|
||||
role, err := ensureOperationMilestoneRole(ctx, config, operationRoleName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sourceUsers, err := listOperationMilestoneRoleUsers(ctx, config, sourceRole.ID)
|
||||
role, err = getOperationMilestoneRole(ctx, config, role.ID)
|
||||
if err != nil {
|
||||
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)
|
||||
if len(usersToAssign) == 0 {
|
||||
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, err := listOperationMilestoneRoleUsers(ctx, config, role.ID)
|
||||
if err != nil {
|
||||
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{}
|
||||
for _, targetUser := range usersToAssign {
|
||||
if milestoneItemID(targetUser) == "" {
|
||||
@ -928,6 +885,58 @@ func (s *Server) ensureOperationMilestoneRoleUser(
|
||||
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(
|
||||
users []map[string]any,
|
||||
preferredUserName string,
|
||||
@ -941,6 +950,10 @@ func selectOperationMilestoneRoleUsersForAssignment(
|
||||
return []map[string]any{targetUser}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(preferredUserName) != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
usersWithID := make([]map[string]any, 0, len(users))
|
||||
for _, user := range users {
|
||||
if milestoneItemID(user) != "" {
|
||||
@ -1116,6 +1129,146 @@ func operationMilestoneEvaluationRoleName(
|
||||
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(
|
||||
ctx context.Context,
|
||||
config milestoneRuntimeConfig,
|
||||
|
||||
@ -36,6 +36,7 @@ func (s *Server) Routes() http.Handler {
|
||||
mux.HandleFunc("GET /devices", s.requireAuth(s.handleListDevices))
|
||||
mux.HandleFunc("POST /devices", s.requireAuth(s.handleCreateDevice))
|
||||
mux.HandleFunc("PUT /devices/{id}", s.requireAuth(s.handleUpdateDevice))
|
||||
mux.HandleFunc("DELETE /devices/{id}", s.requireAuth(s.handleDeleteDevice))
|
||||
mux.HandleFunc("GET /device-loan-users", s.requireAuth(s.handleListDeviceLoanUsers))
|
||||
|
||||
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("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("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("GET /chat/conversations/{id}/messages", s.requireAuth(s.handleListChatMessages))
|
||||
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}/clear", s.requireAuth(s.handleClearChat))
|
||||
mux.HandleFunc("PUT /chat/conversations/{id}/mute", s.requireAuth(s.handleUpdateConversationMute))
|
||||
mux.HandleFunc("PUT /chat/conversations/{id}/disappearing", s.requireAuth(s.handleUpdateDisappearing))
|
||||
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("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("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/roles", s.requireAuth(s.requireRight("administration:read", s.handleAdminListMilestoneRoles)))
|
||||
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_until DATE`,
|
||||
`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 (
|
||||
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_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 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_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 ''`,
|
||||
|
||||
@ -66,7 +66,7 @@ func (s *Server) handleGlobalSearch(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
groups := make([]globalSearchGroup, 0, 5)
|
||||
groups := make([]globalSearchGroup, 0, 6)
|
||||
|
||||
if userCanSearch(user, "operations:read") {
|
||||
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{
|
||||
Query: query,
|
||||
Groups: groups,
|
||||
@ -677,6 +691,106 @@ func (s *Server) searchUsers(ctx context.Context, options globalSearchOptions) (
|
||||
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 {
|
||||
limit, err := strconv.Atoi(value)
|
||||
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_path TEXT NOT NULL DEFAULT 'E:/',
|
||||
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_base_dn 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
|
||||
`,
|
||||
|
||||
`
|
||||
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
|
||||
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,
|
||||
|
||||
last_read_at TIMESTAMPTZ,
|
||||
last_cleared_at TIMESTAMPTZ,
|
||||
muted BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
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 last_cleared_at TIMESTAMPTZ`,
|
||||
|
||||
`
|
||||
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.
|
||||
// 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_message_attachments_message_id ON message_attachments(message_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 {
|
||||
|
||||
@ -7,6 +7,7 @@ import LoginPage from './pages/LoginPage'
|
||||
import AppLayout from './components/AppLayout'
|
||||
import DashboardPage from './pages/DashboardPage'
|
||||
import DevicesPage from './pages/devices/DevicesPage'
|
||||
import DeviceCategoryPage from './pages/devices/DeviceCategoryPage'
|
||||
import OperationsPage from './pages/operations/OperationsPage'
|
||||
import CalendarPage from './pages/calendar/CalendarPage'
|
||||
import DocumentsPage from './pages/DocumentsPage'
|
||||
@ -22,6 +23,7 @@ import MilestonePage from './pages/milestone/MilestonePage'
|
||||
import MilestoneStoragePage from './pages/milestone/speicher/MilestoneStoragePage'
|
||||
import MilestoneDevicesPage from './pages/milestone/geraete/MilestoneDevicesPage'
|
||||
import MilestoneRolesPage from './pages/milestone/rollen/MilestoneRolesPage'
|
||||
import MilestoneBackupPage from './pages/milestone/backup/MilestoneBackupPage'
|
||||
import { hasRight } from './components/permissions'
|
||||
import ChatPage from './pages/chat/ChatPage'
|
||||
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
|
||||
path="/einsaetze"
|
||||
element={
|
||||
@ -679,7 +750,7 @@ function App() {
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/milestone/geraete"
|
||||
path="/milestone/kameras"
|
||||
element={
|
||||
<RequireRight user={user} right="administration:read">
|
||||
<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
|
||||
path="/milestone/speicher"
|
||||
element={
|
||||
@ -712,6 +808,15 @@ function App() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/milestone/backup"
|
||||
element={
|
||||
<RequireRight user={user} right="administration:read">
|
||||
<MilestoneBackupPage />
|
||||
</RequireRight>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/einsaetze/milestone-speicher"
|
||||
element={<Navigate to="/milestone/speicher" replace />}
|
||||
@ -719,7 +824,7 @@ function App() {
|
||||
|
||||
<Route
|
||||
path="/einsaetze/milestone-devices"
|
||||
element={<Navigate to="/milestone/geraete" replace />}
|
||||
element={<Navigate to="/milestone/kameras" replace />}
|
||||
/>
|
||||
|
||||
<Route
|
||||
|
||||
@ -6,6 +6,7 @@ import { Transition } from '@headlessui/react'
|
||||
import SearchOverlay from './SearchOverlay'
|
||||
import Sidebar from './Sidebar'
|
||||
import Topbar from './Topbar'
|
||||
import PinnedChatDock from './PinnedChatDock'
|
||||
import type { User } from './types'
|
||||
|
||||
type AppLayoutProps = {
|
||||
@ -87,6 +88,8 @@ export default function AppLayout({
|
||||
onClose={closeSearchOverlay}
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<PinnedChatDock userId={user.id} />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -3,9 +3,11 @@
|
||||
import { useMemo, useState, type FormEvent } from 'react'
|
||||
import {
|
||||
ChatBubbleBottomCenterTextIcon,
|
||||
ChevronDownIcon,
|
||||
CheckCircleIcon,
|
||||
CursorArrowRaysIcon,
|
||||
ExclamationTriangleIcon,
|
||||
PaperAirplaneIcon,
|
||||
ShieldCheckIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import Button from './Button'
|
||||
@ -52,6 +54,19 @@ export default function Feedback() {
|
||||
[logSnapshot],
|
||||
)
|
||||
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>) {
|
||||
event.preventDefault()
|
||||
@ -74,7 +89,7 @@ export default function Feedback() {
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
message: trimmedMessage,
|
||||
clientLog: logSnapshot,
|
||||
clientLog: sortedLogSnapshot,
|
||||
client,
|
||||
}),
|
||||
})
|
||||
@ -181,21 +196,24 @@ export default function Feedback() {
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
disabled={!message.trim() || isSubmitting}
|
||||
leadingIcon={
|
||||
<PaperAirplaneIcon aria-hidden="true" className="size-4" />
|
||||
}
|
||||
>
|
||||
Feedback senden
|
||||
</Button>
|
||||
</div>
|
||||
</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">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-gray-100 px-5 py-4 dark:border-white/10">
|
||||
<div>
|
||||
<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">
|
||||
<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 className="min-w-0">
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Angehängtes Protokoll
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
Deine letzten Aktionen und Fehler in dieser Sitzung – hilft
|
||||
uns, Probleme nachzuvollziehen.
|
||||
Deine letzten Schaltflächen-Klicks, Seitenaufrufe und
|
||||
API-Fehler in dieser Sitzung.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@ -213,23 +231,30 @@ export default function Feedback() {
|
||||
<ExclamationTriangleIcon className="size-3.5" />
|
||||
{errorCount} Fehler
|
||||
</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>
|
||||
</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
|
||||
entries={logSnapshot}
|
||||
entries={sortedLogSnapshot}
|
||||
showPath={false}
|
||||
emptyLabel="Bisher wurden keine Aktionen oder Fehler aufgezeichnet."
|
||||
emptyLabel="Bisher wurden keine Schaltflächen-Klicks, Seitenaufrufe oder API-Fehler aufgezeichnet."
|
||||
/>
|
||||
</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">
|
||||
<ShieldCheckIcon className="size-4 shrink-0 text-gray-400" />
|
||||
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>
|
||||
</details>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -404,7 +404,7 @@ export default function Journal({
|
||||
<Badge
|
||||
tone="indigo"
|
||||
variant="border"
|
||||
shape="rounded"
|
||||
shape="circle"
|
||||
size="small"
|
||||
>
|
||||
{changes.length}{' '}
|
||||
|
||||
@ -2,10 +2,11 @@
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
BellIcon,
|
||||
EnvelopeOpenIcon,
|
||||
FaceSmileIcon,
|
||||
} 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 Tabs, { type TabItem } from './Tabs'
|
||||
import type { Notification } from './types'
|
||||
@ -115,6 +116,8 @@ export default function NotificationCenter({
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const BellIcon = open ? BellSolidIcon : BellOutlineIcon
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<button
|
||||
@ -227,4 +230,4 @@ export default function NotificationCenter({
|
||||
)}
|
||||
</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
|
||||
case 'operation-journals':
|
||||
return ChatBubbleLeftEllipsisIcon
|
||||
case 'public-chat-messages':
|
||||
return ChatBubbleLeftEllipsisIcon
|
||||
case 'devices':
|
||||
return CircleStackIcon
|
||||
case 'device-journals':
|
||||
@ -285,15 +287,10 @@ export default function SearchOverlay({
|
||||
search.length >= 2 && !isLoading && !error && !hasResults
|
||||
|
||||
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="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 h-full overflow-y-auto bg-white [scrollbar-gutter:stable] dark:bg-gray-950">
|
||||
<div className="relative mx-auto max-w-7xl px-4 py-6 sm:px-6 sm:py-8 lg:px-8">
|
||||
{/* 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 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">
|
||||
|
||||
@ -7,23 +7,26 @@ import {
|
||||
TransitionChild,
|
||||
} from '@headlessui/react'
|
||||
import {
|
||||
ArchiveBoxIcon,
|
||||
CalendarIcon,
|
||||
ChartPieIcon,
|
||||
ChatBubbleLeftRightIcon,
|
||||
Cog6ToothIcon,
|
||||
ComputerDesktopIcon,
|
||||
CpuChipIcon,
|
||||
DocumentDuplicateIcon,
|
||||
ServerStackIcon,
|
||||
ShieldCheckIcon,
|
||||
UserGroupIcon,
|
||||
VideoCameraIcon,
|
||||
ArrowsRightLeftIcon,
|
||||
HomeIcon,
|
||||
WifiIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
import { NavLink, useLocation } from 'react-router'
|
||||
import { hasRight } from './permissions'
|
||||
import Scrollbar from './Scrollbar'
|
||||
import type { User } from './types'
|
||||
|
||||
type NavigationItem = {
|
||||
@ -39,6 +42,26 @@ type NavigationSection = {
|
||||
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[] = [
|
||||
{
|
||||
items: [
|
||||
@ -57,6 +80,32 @@ const navigationSections: NavigationSection[] = [
|
||||
href: '/geraete',
|
||||
icon: ComputerDesktopIcon,
|
||||
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',
|
||||
@ -67,21 +116,21 @@ const navigationSections: NavigationSection[] = [
|
||||
{
|
||||
name: 'Milestone',
|
||||
href: '/milestone',
|
||||
icon: ServerStackIcon,
|
||||
icon: MilestoneDiamondIcon,
|
||||
right: 'administration:read',
|
||||
children: [
|
||||
{
|
||||
name: 'Geräte',
|
||||
href: '/milestone/geraete',
|
||||
icon: CpuChipIcon,
|
||||
right: 'administration:read',
|
||||
},
|
||||
{
|
||||
name: 'Speicher',
|
||||
href: '/milestone/speicher',
|
||||
icon: ServerStackIcon,
|
||||
right: 'administration:read',
|
||||
},
|
||||
{
|
||||
name: 'Backup',
|
||||
href: '/milestone/backup',
|
||||
icon: ArchiveBoxIcon,
|
||||
right: 'administration:read',
|
||||
},
|
||||
{
|
||||
name: 'Rollen',
|
||||
href: '/milestone/rollen',
|
||||
@ -207,30 +256,46 @@ function SidebarContent({
|
||||
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 children = item.children ?? []
|
||||
const showChildren = children.length > 0
|
||||
const isSubItem = depth > 0
|
||||
|
||||
return (
|
||||
<li key={item.name}>
|
||||
<NavLink
|
||||
to={item.href}
|
||||
onClick={handleNavigate}
|
||||
className={classNames(
|
||||
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',
|
||||
)}
|
||||
className={navigationLinkClassName(active, isSubItem)}
|
||||
>
|
||||
<item.icon
|
||||
aria-hidden="true"
|
||||
className={classNames(
|
||||
active
|
||||
? 'text-white'
|
||||
: 'text-slate-400 group-hover:text-white',
|
||||
'size-5 shrink-0 transition',
|
||||
navigationIconClassName(active, isSubItem),
|
||||
item.href === '/chat' && '-mr-0.5',
|
||||
)}
|
||||
/>
|
||||
<span className="truncate">{item.name}</span>
|
||||
@ -244,37 +309,12 @@ function SidebarContent({
|
||||
{showChildren && (
|
||||
<ul
|
||||
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) => {
|
||||
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>
|
||||
)
|
||||
})}
|
||||
{children.map((child) => renderNavigationItem(child, depth + 1))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
@ -295,46 +335,40 @@ function SidebarContent({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-1 flex-col">
|
||||
<ul role="list" className="flex flex-1 flex-col gap-y-6">
|
||||
<li>
|
||||
<div className="space-y-6">
|
||||
{visibleNavigationSections.map((section, sectionIndex) => (
|
||||
<section key={section.name ?? `section-${sectionIndex}`}>
|
||||
{section.name && (
|
||||
<h2 className="mb-2 px-3 text-[0.6875rem]/6 font-semibold uppercase tracking-wider text-slate-500">
|
||||
{section.name}
|
||||
</h2>
|
||||
)}
|
||||
<nav className="flex min-h-0 flex-1 flex-col">
|
||||
<Scrollbar className="-mx-2 flex-1 px-2 pb-4">
|
||||
<div className="space-y-6">
|
||||
{visibleNavigationSections.map((section, sectionIndex) => (
|
||||
<section key={section.name ?? `section-${sectionIndex}`}>
|
||||
{section.name && (
|
||||
<h2 className="mb-2 px-3 text-[0.6875rem]/6 font-semibold uppercase tracking-wider text-slate-500">
|
||||
{section.name}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
<ul role="list" className="space-y-1">
|
||||
{section.items.map(renderNavigationItem)}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</li>
|
||||
<ul role="list" className="space-y-1">
|
||||
{section.items.map(renderNavigationItem)}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</Scrollbar>
|
||||
|
||||
<li className="mt-auto 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">
|
||||
Hilfe & System
|
||||
</h2>
|
||||
<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">
|
||||
System
|
||||
</h2>
|
||||
|
||||
<ul role="list" className="space-y-1">
|
||||
<ul role="list" className="space-y-1">
|
||||
<li>
|
||||
<NavLink
|
||||
to="/feedback"
|
||||
onClick={handleNavigate}
|
||||
className={classNames(
|
||||
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',
|
||||
)}
|
||||
className={navigationLinkClassName(isFeedbackActive)}
|
||||
>
|
||||
<ChatBubbleLeftRightIcon
|
||||
aria-hidden="true"
|
||||
className="size-5 shrink-0"
|
||||
className={navigationIconClassName(isFeedbackActive)}
|
||||
/>
|
||||
Feedback
|
||||
</NavLink>
|
||||
@ -345,16 +379,11 @@ function SidebarContent({
|
||||
<NavLink
|
||||
to="/einstellungen/konto"
|
||||
onClick={handleNavigate}
|
||||
className={classNames(
|
||||
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',
|
||||
)}
|
||||
className={navigationLinkClassName(isSettingsActive)}
|
||||
>
|
||||
<Cog6ToothIcon
|
||||
aria-hidden="true"
|
||||
className="size-5 shrink-0"
|
||||
className={navigationIconClassName(isSettingsActive)}
|
||||
/>
|
||||
Einstellungen
|
||||
</NavLink>
|
||||
@ -366,24 +395,18 @@ function SidebarContent({
|
||||
<NavLink
|
||||
to="/administration/benutzer"
|
||||
onClick={handleNavigate}
|
||||
className={classNames(
|
||||
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',
|
||||
)}
|
||||
className={navigationLinkClassName(isAdministrationActive)}
|
||||
>
|
||||
<ShieldCheckIcon
|
||||
aria-hidden="true"
|
||||
className="size-5 shrink-0"
|
||||
className={navigationIconClassName(isAdministrationActive)}
|
||||
/>
|
||||
Administration
|
||||
</NavLink>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</>
|
||||
)
|
||||
@ -426,7 +449,7 @@ export default function Sidebar({
|
||||
</div>
|
||||
</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
|
||||
user={user}
|
||||
setSidebarOpen={setSidebarOpen}
|
||||
@ -439,7 +462,7 @@ export default function Sidebar({
|
||||
</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="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
|
||||
user={user}
|
||||
setSidebarOpen={setSidebarOpen}
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import {
|
||||
Fragment,
|
||||
type CSSProperties,
|
||||
type KeyboardEvent,
|
||||
type MouseEvent,
|
||||
type ReactNode,
|
||||
@ -11,6 +12,7 @@ import {
|
||||
} from 'react'
|
||||
import { ChevronDownIcon } from '@heroicons/react/20/solid'
|
||||
import Button from './Button'
|
||||
import Scrollbar, { scrollbarClassName } from './Scrollbar'
|
||||
|
||||
export type TableVariant =
|
||||
| 'simple'
|
||||
@ -48,6 +50,7 @@ export type TableColumn<T> = {
|
||||
sortValue?: (row: T) => TableSortValue
|
||||
hideOnMobile?: 'sm' | 'md' | 'lg' | 'xl'
|
||||
align?: 'left' | 'center' | 'right'
|
||||
width?: string
|
||||
className?: string
|
||||
headerClassName?: string
|
||||
}
|
||||
@ -103,6 +106,8 @@ type TablesProps<T> = {
|
||||
|
||||
hideHeadings?: boolean
|
||||
stickyTopClassName?: string
|
||||
scrollRows?: boolean
|
||||
rowScrollClassName?: string
|
||||
|
||||
className?: string
|
||||
}
|
||||
@ -220,11 +225,15 @@ export default function Tables<T>({
|
||||
|
||||
hideHeadings = false,
|
||||
stickyTopClassName = 'top-0',
|
||||
scrollRows = false,
|
||||
rowScrollClassName,
|
||||
|
||||
className,
|
||||
}: TablesProps<T>) {
|
||||
const headerCheckboxRef = useRef<HTMLInputElement>(null)
|
||||
const bodyScrollRef = useRef<HTMLDivElement>(null)
|
||||
const [internalSelectedRowIds, setInternalSelectedRowIds] = useState<Array<string | number>>([])
|
||||
const [hasVerticalScrollbar, setHasVerticalScrollbar] = useState(false)
|
||||
|
||||
const rowGroups =
|
||||
groups && groups.length > 0
|
||||
@ -254,16 +263,25 @@ export default function Tables<T>({
|
||||
const isUppercase = variant === 'uppercase'
|
||||
const isStackedMobile = variant === 'stacked-mobile'
|
||||
const isStickyHeader = variant === 'sticky-header'
|
||||
const hasStickyHeader = isStickyHeader || scrollRows
|
||||
const hasColumnWidths = columns.some((column) => column.width)
|
||||
const isVerticalLines = variant === 'vertical-lines'
|
||||
const isCondensed = variant === 'condensed'
|
||||
const isSortable = variant === 'sortable'
|
||||
const isGrouped = variant === 'grouped' || Boolean(groups?.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 =
|
||||
columns.length +
|
||||
(selectable ? 1 : 0) +
|
||||
(rowActions ? 1 : 0)
|
||||
(rowActions ? 1 : 0) +
|
||||
(scrollRows ? 1 : 0)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!headerCheckboxRef.current) {
|
||||
@ -273,6 +291,62 @@ export default function Tables<T>({
|
||||
headerCheckboxRef.current.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>) {
|
||||
if (!selectedRowIds) {
|
||||
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() {
|
||||
if (!title && !description && !actionLabel && !headerAction) {
|
||||
return null
|
||||
@ -393,7 +482,14 @@ export default function Tables<T>({
|
||||
>
|
||||
<tr className={classNames(isVerticalLines && 'divide-x divide-gray-200 dark:divide-white/10')}>
|
||||
{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
|
||||
inputRef={headerCheckboxRef}
|
||||
checked={checked}
|
||||
@ -412,8 +508,7 @@ export default function Tables<T>({
|
||||
key={column.key}
|
||||
scope="col"
|
||||
className={classNames(
|
||||
isStickyHeader &&
|
||||
`sticky ${stickyTopClassName} z-10 bg-white/75 backdrop-blur-sm backdrop-filter dark:bg-gray-900/75`,
|
||||
stickyHeaderClassName(),
|
||||
isUppercase
|
||||
? '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',
|
||||
@ -436,8 +531,7 @@ export default function Tables<T>({
|
||||
<th
|
||||
scope="col"
|
||||
className={classNames(
|
||||
isStickyHeader &&
|
||||
`sticky ${stickyTopClassName} z-10 bg-white/75 backdrop-blur-sm backdrop-filter dark:bg-gray-900/75`,
|
||||
stickyHeaderClassName(),
|
||||
'py-3.5 pr-4 pl-3 sm:pr-0',
|
||||
isCard && 'sm:pr-6',
|
||||
)}
|
||||
@ -445,6 +539,14 @@ export default function Tables<T>({
|
||||
<span className="sr-only">Aktionen</span>
|
||||
</th>
|
||||
)}
|
||||
|
||||
{scrollRows && (
|
||||
<th
|
||||
aria-hidden="true"
|
||||
scope="col"
|
||||
className={classNames(stickyHeaderClassName(), 'p-0')}
|
||||
/>
|
||||
)}
|
||||
</tr>
|
||||
</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 selected = selectedSet.has(rowId)
|
||||
|
||||
@ -497,6 +599,7 @@ export default function Tables<T>({
|
||||
onClick={(event) => handleRowClick(row, event)}
|
||||
onKeyDown={(event) => handleRowKeyDown(row, event)}
|
||||
className={classNames(
|
||||
'group',
|
||||
isStriped && 'even:bg-gray-50 dark:even:bg-gray-800/50',
|
||||
selected && 'bg-gray-50 dark:bg-gray-800/50',
|
||||
isVerticalLines && 'divide-x divide-gray-200 dark:divide-white/10',
|
||||
@ -504,7 +607,7 @@ export default function Tables<T>({
|
||||
)}
|
||||
>
|
||||
{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 && (
|
||||
<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
|
||||
key={column.key}
|
||||
className={classNames(
|
||||
rowDividerClassName,
|
||||
isCondensed ? 'py-2' : isCard ? 'py-3' : isAvatarVariant(variant) ? 'py-5' : 'py-4',
|
||||
isCondensed ? 'px-2' : 'px-3',
|
||||
isFirst && !selectable && 'pl-4 sm:pl-0',
|
||||
@ -555,6 +659,7 @@ export default function Tables<T>({
|
||||
{rowActions && (
|
||||
<td
|
||||
className={classNames(
|
||||
rowDividerClassName,
|
||||
isCondensed ? 'py-2' : 'py-4',
|
||||
'pr-4 pl-3 text-right text-sm font-medium whitespace-nowrap sm:pr-0',
|
||||
isCard && 'sm:pr-6',
|
||||
@ -563,6 +668,28 @@ export default function Tables<T>({
|
||||
{rowActions(row)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@ -596,7 +723,7 @@ export default function Tables<T>({
|
||||
<tr>
|
||||
<td
|
||||
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">
|
||||
{loadingContent ?? (
|
||||
@ -619,7 +746,10 @@ export default function Tables<T>({
|
||||
<tr>
|
||||
<td
|
||||
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}
|
||||
</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 (
|
||||
<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(
|
||||
isStickyHeader
|
||||
? 'min-w-full border-separate border-spacing-0'
|
||||
: 'relative min-w-full divide-y divide-gray-300 dark:divide-white/15',
|
||||
isBorder && 'divide-y divide-gray-300 dark:divide-white/15',
|
||||
'[&>tr:last-child>td]:border-b-0',
|
||||
isCard
|
||||
? 'bg-white dark:bg-gray-800/50'
|
||||
: 'bg-white dark:bg-gray-900',
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
renderLoadingRow()
|
||||
) : (
|
||||
<>
|
||||
{renderEmptyRow()}
|
||||
{renderGroupedRows()}
|
||||
</>
|
||||
)}
|
||||
</tbody>
|
||||
)
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
return (
|
||||
<table className={tableClassName()}>
|
||||
{renderTableColGroup()}
|
||||
{renderTableHead()}
|
||||
|
||||
<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>
|
||||
|
||||
{renderTableBody()}
|
||||
{renderSummaryRows()}
|
||||
</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 (
|
||||
<div className={className}>
|
||||
{renderHeader()}
|
||||
@ -740,10 +964,10 @@ export default function Tables<T>({
|
||||
'overflow-hidden ring-1 ring-gray-300 sm:rounded-lg dark:ring-white/15',
|
||||
)}
|
||||
>
|
||||
{renderTable()}
|
||||
{renderScrollableTable()}
|
||||
</div>
|
||||
) : (
|
||||
renderTable()
|
||||
renderScrollableTable()
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -754,4 +978,4 @@ export default function Tables<T>({
|
||||
|
||||
function isAvatarVariant(variant: TableVariant) {
|
||||
return variant === 'avatars'
|
||||
}
|
||||
}
|
||||
|
||||
@ -371,6 +371,7 @@ export default function Tabs({
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const active = isTabActive(tab, location.pathname)
|
||||
const Icon = tab.icon
|
||||
|
||||
return (
|
||||
<Link
|
||||
@ -382,11 +383,23 @@ export default function Tabs({
|
||||
active
|
||||
? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300'
|
||||
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200',
|
||||
'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,
|
||||
)}
|
||||
>
|
||||
{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)}
|
||||
</Link>
|
||||
)
|
||||
@ -515,4 +528,4 @@ export default function Tabs({
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,11 +2,13 @@ import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ClockIcon,
|
||||
DocumentTextIcon,
|
||||
MapPinIcon,
|
||||
PlusIcon,
|
||||
} from '@heroicons/react/20/solid'
|
||||
import { useEffect, useRef, useState, type PointerEvent } from 'react'
|
||||
import ButtonGroup from '../ButtonGroup'
|
||||
import LoadingSpinner from '../LoadingSpinner'
|
||||
import {
|
||||
timeToMinutes,
|
||||
workingHoursForDate,
|
||||
@ -53,6 +55,10 @@ type CalendarEventOccurrence = CalendarEvent
|
||||
type CalendarProps = {
|
||||
events: CalendarEvent[]
|
||||
coreWorkingHours: CalendarCoreWorkingHours
|
||||
status?: {
|
||||
tone: 'loading' | 'danger'
|
||||
text: string
|
||||
} | null
|
||||
selectedDate: Date
|
||||
view: CalendarView
|
||||
onSelectedDateChange: (date: Date) => void
|
||||
@ -883,24 +889,21 @@ function EventListButton({
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-semibold">
|
||||
{event.title}
|
||||
</span>
|
||||
<span className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs opacity-80">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<ClockIcon className="size-3.5" />
|
||||
{event.allDay
|
||||
? 'Ganztägig'
|
||||
: `${formatTime(eventStartValue(event))} - ${formatTime(
|
||||
eventEndValue(event),
|
||||
)}`}
|
||||
<span className="inline-flex max-w-full items-center gap-1 truncate text-xs font-semibold opacity-80">
|
||||
<ClockIcon className="size-3.5 shrink-0" />
|
||||
<span className="truncate">
|
||||
{event.allDay ? 'Ganztägig' : formatTime(eventStartValue(event))}
|
||||
</span>
|
||||
{event.location && (
|
||||
<span className="inline-flex items-center gap-1 truncate">
|
||||
<MapPinIcon className="size-3.5" />
|
||||
{event.location}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{event.location && (
|
||||
<span className="mt-0.5 inline-flex max-w-full items-center gap-1 truncate text-xs opacity-80">
|
||||
<MapPinIcon className="size-3.5 shrink-0" />
|
||||
<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>
|
||||
</button>
|
||||
@ -1300,6 +1303,11 @@ function TimeGridView({
|
||||
30,
|
||||
(segmentEnd.getTime() - segmentStart.getTime()) / 60_000,
|
||||
)
|
||||
const eventTopPercentage = (startMinutes / 1440) * 100
|
||||
const eventHeightPercentage = Math.max(
|
||||
2.1,
|
||||
(durationMinutes / 1440) * 100,
|
||||
)
|
||||
|
||||
return (
|
||||
<button
|
||||
@ -1321,23 +1329,27 @@ function TimeGridView({
|
||||
moveEvent.stopPropagation()
|
||||
}
|
||||
className={classNames(
|
||||
'absolute right-1 left-1 z-10 overflow-hidden rounded-lg p-2 text-left text-xs ring-1 ring-inset transition',
|
||||
'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,
|
||||
)}
|
||||
style={{
|
||||
top: `${(startMinutes / 1440) * 100}%`,
|
||||
height: `${Math.max(
|
||||
2.1,
|
||||
(durationMinutes / 1440) * 100,
|
||||
)}%`,
|
||||
top: `calc(${eventTopPercentage}% + 2px)`,
|
||||
height: `calc(${eventHeightPercentage}% - 4px)`,
|
||||
}}
|
||||
>
|
||||
<span className="block truncate font-semibold">
|
||||
{event.title}
|
||||
<span className="inline-flex max-w-full items-center gap-1 truncate font-semibold">
|
||||
<ClockIcon className="size-3.5 shrink-0" />
|
||||
<span className="truncate">{formatTime(eventStart)}</span>
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate opacity-80">
|
||||
{formatTime(segmentStart)} - {formatTime(segmentEnd)}
|
||||
{event.location ? ` · ${event.location}` : ''}
|
||||
{event.location && (
|
||||
<span className="inline-flex max-w-full items-center gap-1 truncate opacity-80">
|
||||
<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>
|
||||
</button>
|
||||
)
|
||||
@ -1437,6 +1449,7 @@ function YearView({
|
||||
export default function Calendar({
|
||||
events,
|
||||
coreWorkingHours,
|
||||
status,
|
||||
selectedDate,
|
||||
view,
|
||||
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">
|
||||
Kalender
|
||||
</p>
|
||||
<h1 className="truncate text-lg font-semibold capitalize text-gray-950 dark:text-white">
|
||||
{titleForView(selectedDate, view)}
|
||||
</h1>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2 align-middle">
|
||||
<h1 className="truncate text-lg font-semibold capitalize text-gray-950 dark:text-white">
|
||||
{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 className="flex items-center gap-2">
|
||||
|
||||
@ -28,14 +28,14 @@ export type CalendarSettings = {
|
||||
|
||||
const workingDay: CalendarWorkingHoursDay = {
|
||||
enabled: true,
|
||||
start: '08:00',
|
||||
end: '17:00',
|
||||
start: '07:00',
|
||||
end: '15:00',
|
||||
}
|
||||
|
||||
const dayOff: CalendarWorkingHoursDay = {
|
||||
enabled: false,
|
||||
start: '08:00',
|
||||
end: '17:00',
|
||||
start: '07:00',
|
||||
end: '15:00',
|
||||
}
|
||||
|
||||
export function createDefaultCalendarSettings(): CalendarSettings {
|
||||
|
||||
@ -10,4 +10,47 @@ body,
|
||||
|
||||
body {
|
||||
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 ? (
|
||||
<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
|
||||
icon={Squares2X2Icon}
|
||||
iconClassName="text-indigo-400 dark:text-indigo-500"
|
||||
className="relative"
|
||||
title="Noch keine Widgets"
|
||||
description="Füge dein erstes Widget hinzu und baue dir dein persönliches Dashboard."
|
||||
actionLabel="Widget hinzufügen"
|
||||
@ -1133,4 +1134,4 @@ export default function DashboardPage({ username }: DashboardPageProps) {
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,7 +12,7 @@ import {
|
||||
import Tabs from '../../components/Tabs'
|
||||
import UsersAdministration from './users/UsersAdministration'
|
||||
import FeedbackAdministration from './feedback/FeedbackAdministration'
|
||||
import Milestone from './milestone/Milestone'
|
||||
import MilestoneAdministration from './milestone/MilestoneAdministration'
|
||||
import ChatsAdministration from './chats/ChatsAdministration'
|
||||
import CalendarAdministration from './calendar/CalendarAdministration'
|
||||
import ActiveDirectoryAdministration from './active-directory/ActiveDirectoryAdministration'
|
||||
@ -106,7 +106,7 @@ export default function AdministrationPage() {
|
||||
|
||||
{section === 'benutzer' && <UsersAdministration />}
|
||||
{section === 'chats' && <ChatsAdministration />}
|
||||
{section === 'milestone' && <Milestone />}
|
||||
{section === 'milestone' && <MilestoneAdministration />}
|
||||
{section === 'active-directory' && <ActiveDirectoryAdministration />}
|
||||
{section === 'kalender' && <CalendarAdministration />}
|
||||
{section === 'feedback' && <FeedbackAdministration />}
|
||||
|
||||
@ -411,7 +411,7 @@ export default function ChannelsAdministration() {
|
||||
src={channel.image}
|
||||
name={channel.name}
|
||||
size={9}
|
||||
shape="rounded"
|
||||
shape="circle"
|
||||
/>
|
||||
) : (
|
||||
<HashtagIcon className="size-5 shrink-0" />
|
||||
@ -479,7 +479,7 @@ export default function ChannelsAdministration() {
|
||||
src={draft.image}
|
||||
name={draft.name || 'Channel'}
|
||||
size={14}
|
||||
shape="rounded"
|
||||
shape="circle"
|
||||
/>
|
||||
<div>
|
||||
<input
|
||||
|
||||
@ -27,6 +27,7 @@ type AdminGroupChat = {
|
||||
image: string
|
||||
ownerId: string
|
||||
teamId: string
|
||||
isPublic: boolean
|
||||
userIds: string[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
@ -46,6 +47,7 @@ type GroupDraft = {
|
||||
image: string
|
||||
ownerId: string
|
||||
teamId: string
|
||||
isPublic: boolean
|
||||
userIds: string[]
|
||||
}
|
||||
|
||||
@ -68,6 +70,7 @@ function groupToDraft(group: AdminGroupChat): GroupDraft {
|
||||
image: group.image,
|
||||
ownerId: group.ownerId,
|
||||
teamId: group.teamId,
|
||||
isPublic: group.isPublic,
|
||||
userIds: group.userIds ?? [],
|
||||
}
|
||||
}
|
||||
@ -233,6 +236,7 @@ export default function GroupChatsAdministration() {
|
||||
body: JSON.stringify({
|
||||
name: draft.name,
|
||||
image: draft.image,
|
||||
isPublic: draft.isPublic,
|
||||
userIds: draft.userIds,
|
||||
}),
|
||||
})
|
||||
@ -413,7 +417,7 @@ export default function GroupChatsAdministration() {
|
||||
src={group.image}
|
||||
name={group.name}
|
||||
size={9}
|
||||
shape="rounded"
|
||||
shape="circle"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-1.5">
|
||||
@ -423,6 +427,11 @@ export default function GroupChatsAdministration() {
|
||||
Team
|
||||
</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 className="block truncate text-xs opacity-70">
|
||||
{group.userIds.length} Mitglieder
|
||||
@ -479,7 +488,7 @@ export default function GroupChatsAdministration() {
|
||||
src={draft.image}
|
||||
name={draft.name || 'Gruppenchat'}
|
||||
size={14}
|
||||
shape="rounded"
|
||||
shape="circle"
|
||||
/>
|
||||
<div>
|
||||
<input
|
||||
@ -520,6 +529,19 @@ export default function GroupChatsAdministration() {
|
||||
</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="flex flex-wrap items-end justify-between gap-3">
|
||||
<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 {
|
||||
KeyIcon,
|
||||
ServerStackIcon,
|
||||
@ -14,6 +14,7 @@ import { DialogTitle } from '@headlessui/react'
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline'
|
||||
import Modal from '../../../components/Modal'
|
||||
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'
|
||||
|
||||
@ -37,8 +38,32 @@ type MilestoneSettings = {
|
||||
serverFoldersStorageRootPath: string
|
||||
serverFoldersArchiveRootLabel: string
|
||||
serverFoldersArchiveRootPath: string
|
||||
backupNasHost: string
|
||||
backupNasShare: string
|
||||
backupNasUsername: string
|
||||
backupNasPasswordConfigured: boolean
|
||||
backupLogRootPath: 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: 'token' | 'recordingServer' | 'hardware' | 'done' | 'error'
|
||||
message?: string
|
||||
@ -201,7 +226,7 @@ function markCurrentMilestoneSyncStepAsError(
|
||||
)
|
||||
}
|
||||
|
||||
export default function Milestone() {
|
||||
export default function MilestoneAdministration() {
|
||||
const { showToast, showErrorToast } = useNotifications()
|
||||
const [settings, setSettings] = useState<MilestoneSettings | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
@ -212,6 +237,11 @@ export default function Milestone() {
|
||||
const [syncSteps, setSyncSteps] = useState<TaskListStep[]>(() => createMilestoneSyncSteps())
|
||||
const [syncError, setSyncError] = useState<string | null>(null)
|
||||
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(() => {
|
||||
void loadSettings()
|
||||
@ -291,6 +321,11 @@ export default function Milestone() {
|
||||
if (event.settings) {
|
||||
setSettings(event.settings)
|
||||
setSkipTlsVerify(Boolean(event.settings.skipTlsVerify))
|
||||
setBackupNasHost(event.settings.backupNasHost ?? '')
|
||||
setBackupNasShare(event.settings.backupNasShare ?? '')
|
||||
setBackupNasUsername(event.settings.backupNasUsername ?? '')
|
||||
setBackupNasPassword('')
|
||||
setBackupLogRootPath(event.settings.backupLogRootPath ?? '')
|
||||
}
|
||||
|
||||
return
|
||||
@ -379,6 +414,11 @@ export default function Milestone() {
|
||||
const data = await response.json()
|
||||
setSettings(data.settings)
|
||||
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) {
|
||||
showErrorToast({
|
||||
title: 'Einstellungen konnten nicht geladen werden',
|
||||
@ -428,6 +468,11 @@ export default function Milestone() {
|
||||
serverFoldersArchiveRootPath: String(
|
||||
formData.get('serverFoldersArchiveRootPath') ?? 'E:/',
|
||||
),
|
||||
backupNasHost,
|
||||
backupNasShare,
|
||||
backupNasUsername,
|
||||
backupNasPassword,
|
||||
backupLogRootPath,
|
||||
}),
|
||||
})
|
||||
|
||||
@ -446,12 +491,21 @@ export default function Milestone() {
|
||||
|
||||
setSettings(data.settings)
|
||||
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')
|
||||
if (passwordInput instanceof HTMLInputElement) {
|
||||
passwordInput.value = ''
|
||||
}
|
||||
|
||||
await runMilestoneSyncStream()
|
||||
showToast({
|
||||
variant: 'success',
|
||||
title: 'Einstellungen gespeichert',
|
||||
message: 'Die Milestone-Einstellungen wurden gespeichert.',
|
||||
})
|
||||
} catch (error) {
|
||||
showErrorToast({
|
||||
title: 'Speichern fehlgeschlagen',
|
||||
@ -553,29 +607,30 @@ export default function Milestone() {
|
||||
</div>
|
||||
</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
|
||||
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">
|
||||
<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" />
|
||||
<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 items-start gap-x-3 border-b border-gray-200 pb-5 dark:border-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">
|
||||
<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>
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Milestone Videoserver
|
||||
</h2>
|
||||
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Hinterlege die Zugangsdaten für den Milestone Videoserver.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<div className="mt-5 grid grid-cols-1 gap-5 lg:grid-cols-6">
|
||||
<div className="lg:col-span-2">
|
||||
<label
|
||||
htmlFor="milestone-host"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
@ -596,7 +651,7 @@ export default function Milestone() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<div className="lg:col-span-2">
|
||||
<label
|
||||
htmlFor="milestone-username"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
@ -617,7 +672,7 @@ export default function Milestone() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<div className="lg:col-span-2">
|
||||
<label
|
||||
htmlFor="milestone-password"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
@ -640,13 +695,9 @@ export default function Milestone() {
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Das gespeicherte Kennwort wird nicht im Klartext angezeigt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<div className="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">
|
||||
<Switch
|
||||
checked={skipTlsVerify}
|
||||
@ -661,19 +712,27 @@ export default function Milestone() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/5">
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
<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 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>
|
||||
<label
|
||||
htmlFor="server-folders-agent-scheme"
|
||||
@ -796,7 +855,7 @@ export default function Milestone() {
|
||||
</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">
|
||||
Der Agent-Token wird beim Speichern automatisch erzeugt, an
|
||||
den Agent übertragen und verschlüsselt gespeichert. Der Agent
|
||||
@ -809,38 +868,166 @@ export default function Milestone() {
|
||||
</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 className="mt-6 flex justify-end gap-x-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
color="indigo"
|
||||
isLoading={isFetchingToken}
|
||||
disabled={!settings?.passwordConfigured || isSaving}
|
||||
onClick={() => void handleFetchToken()}
|
||||
>
|
||||
Token & Infos erneut abrufen
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
color="indigo"
|
||||
isLoading={isSaving}
|
||||
>
|
||||
Speichern
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<aside className="rounded-lg bg-white p-4 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
|
||||
<h3 className="flex items-center gap-x-2 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
<ServerStackIcon aria-hidden="true" className="size-5 text-gray-400" />
|
||||
Status
|
||||
</h3>
|
||||
<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">
|
||||
<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-gray-50 text-gray-500 ring-1 ring-gray-200 dark:bg-white/5 dark:text-gray-400 dark:ring-white/10">
|
||||
<ServerStackIcon aria-hidden="true" className="size-5" />
|
||||
</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">
|
||||
<dt className="text-gray-500 dark:text-gray-400">
|
||||
Server
|
||||
@ -943,6 +1130,29 @@ export default function Milestone() {
|
||||
</dd>
|
||||
</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 && (
|
||||
<div>
|
||||
<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 label = unit || 'Ohne Einheit'
|
||||
const key = unit ? unit.toLowerCase() : '__without_unit__'
|
||||
|
||||
const existingGroup = groups.get(key)
|
||||
|
||||
if (existingGroup) {
|
||||
@ -895,13 +894,12 @@ export default function UsersAdministration() {
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{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="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">
|
||||
{group.label}
|
||||
</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">
|
||||
{group.users.length}
|
||||
</span>
|
||||
@ -936,35 +934,19 @@ export default function UsersAdministration() {
|
||||
/>
|
||||
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-x-2">
|
||||
<span className="truncate font-medium">
|
||||
{displayName}
|
||||
</span>
|
||||
|
||||
{user.rights.includes('admin') && (
|
||||
<span className="shrink-0 rounded-md bg-indigo-50 px-1.5 py-0.5 text-[10px] font-medium text-indigo-700 ring-1 ring-indigo-600/20 ring-inset dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20">
|
||||
Admin
|
||||
</span>
|
||||
)}
|
||||
<span className="block truncate font-medium">
|
||||
{displayName}
|
||||
</span>
|
||||
|
||||
<span className="block truncate text-xs text-gray-500 dark:text-gray-400">
|
||||
{user.email}
|
||||
</span>
|
||||
|
||||
<span className="mt-1 flex flex-wrap gap-1">
|
||||
{user.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>
|
||||
)}
|
||||
{getUserRoleLabel(user.group)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@ -1073,7 +1055,7 @@ export default function UsersAdministration() {
|
||||
name={selectedUser ? getUserDisplayName(selectedUser) : 'Neuer Benutzer'}
|
||||
initials={selectedUser ? getUserInitials(selectedUser) : 'NB'}
|
||||
size={16}
|
||||
shape="rounded"
|
||||
shape="circle"
|
||||
/>
|
||||
|
||||
<div className="min-w-0">
|
||||
|
||||
@ -585,28 +585,23 @@ export default function CalendarPage() {
|
||||
await deleteEventSeries()
|
||||
}
|
||||
|
||||
const calendarStatus = pageError
|
||||
? { tone: 'danger' as const, text: pageError }
|
||||
: eventsLoading
|
||||
? { tone: 'loading' as const, text: 'Kalendereinträge werden geladen ...' }
|
||||
: null
|
||||
|
||||
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'
|
||||
|
||||
return (
|
||||
<>
|
||||
<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">
|
||||
<Calendar
|
||||
events={events}
|
||||
coreWorkingHours={calendarSettings.coreWorkingHours}
|
||||
status={calendarStatus}
|
||||
selectedDate={selectedDate}
|
||||
view={view}
|
||||
onSelectedDateChange={setSelectedDate}
|
||||
@ -1049,7 +1044,7 @@ export default function CalendarPage() {
|
||||
</div>
|
||||
<div className="space-y-4 p-5">
|
||||
<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?
|
||||
</p>
|
||||
{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"
|
||||
>
|
||||
Ganze Serie
|
||||
Alle Termine
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -12,7 +12,7 @@ import {
|
||||
import Modal from '../../components/Modal'
|
||||
import Button from '../../components/Button'
|
||||
import type { Device } from '../../components/types'
|
||||
import DeviceFormFields from './DeviceFormFields'
|
||||
import DeviceFormFields, { type DeviceCategory } from './DeviceFormFields'
|
||||
import DeviceModalTabs, { type DeviceModalTabId } from './DeviceModalTabs'
|
||||
|
||||
type CreateDeviceModalProps = {
|
||||
@ -20,6 +20,12 @@ type CreateDeviceModalProps = {
|
||||
setOpen: (open: boolean) => void
|
||||
isSaving: boolean
|
||||
error: string | null
|
||||
title?: string
|
||||
description?: string
|
||||
submitLabel?: string
|
||||
initialDeviceCategory?: DeviceCategory
|
||||
lockedDeviceCategory?: DeviceCategory
|
||||
hideDeviceCategorySelector?: boolean
|
||||
devices: Device[]
|
||||
relatedDeviceIds: string[]
|
||||
onToggleRelatedDevice: (deviceId: string) => void
|
||||
@ -53,6 +59,12 @@ export default function CreateDeviceModal({
|
||||
setOpen,
|
||||
isSaving,
|
||||
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,
|
||||
relatedDeviceIds,
|
||||
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>
|
||||
<DialogTitle className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
Gerät hinzufügen
|
||||
{title}
|
||||
</DialogTitle>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@ -157,6 +169,9 @@ export default function CreateDeviceModal({
|
||||
<DeviceFormFields
|
||||
open={open}
|
||||
activeTab={activeTab}
|
||||
initialDeviceCategory={initialDeviceCategory}
|
||||
lockedDeviceCategory={lockedDeviceCategory}
|
||||
hideDeviceCategorySelector={hideDeviceCategorySelector}
|
||||
devices={devices}
|
||||
relatedDeviceIds={relatedDeviceIds}
|
||||
onToggleRelatedDevice={onToggleRelatedDevice}
|
||||
@ -180,7 +195,7 @@ export default function CreateDeviceModal({
|
||||
isLoading={isSaving}
|
||||
leadingIcon={<CheckIcon aria-hidden="true" className="size-4" />}
|
||||
>
|
||||
Gerät speichern
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</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 {
|
||||
ArrowsRightLeftIcon,
|
||||
CircleStackIcon,
|
||||
ComputerDesktopIcon,
|
||||
MagnifyingGlassIcon,
|
||||
MicrophoneIcon,
|
||||
QuestionMarkCircleIcon,
|
||||
VideoCameraIcon,
|
||||
WifiIcon,
|
||||
} from '@heroicons/react/20/solid'
|
||||
@ -37,6 +37,9 @@ type DeviceFormFieldsProps = {
|
||||
open: boolean
|
||||
activeTab: DeviceModalTabId
|
||||
device?: Device | null
|
||||
initialDeviceCategory?: DeviceCategory
|
||||
lockedDeviceCategory?: DeviceCategory
|
||||
hideDeviceCategorySelector?: boolean
|
||||
devices: Device[]
|
||||
relatedDeviceIds: string[]
|
||||
onToggleRelatedDevice: (deviceId: string) => void
|
||||
@ -63,7 +66,7 @@ type DeviceFormFieldsProps = {
|
||||
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'
|
||||
|
||||
type DeviceCategory = 'Kameras' | 'Router' | 'Switchbox' | 'Laptop' | 'Festplatte'
|
||||
export type DeviceCategory = 'Kameras' | 'Router' | 'Switchbox' | 'Laptop' | 'Andere'
|
||||
|
||||
type DeviceLoanUser = {
|
||||
id: string
|
||||
@ -119,10 +122,10 @@ const deviceKindOptions: DeviceKindOption[] = [
|
||||
icon: ComputerDesktopIcon,
|
||||
},
|
||||
{
|
||||
category: 'Festplatte',
|
||||
label: 'Festplatte',
|
||||
description: 'Modell und Seriennummer genügen meistens.',
|
||||
icon: CircleStackIcon,
|
||||
category: 'Andere',
|
||||
label: 'Andere',
|
||||
description: 'Allgemeine Gerätedaten erfassen.',
|
||||
icon: QuestionMarkCircleIcon,
|
||||
},
|
||||
]
|
||||
|
||||
@ -130,17 +133,29 @@ function classNames(...classes: Array<string | false | null | undefined>) {
|
||||
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) {
|
||||
return 'Kameras'
|
||||
}
|
||||
|
||||
if (fallbackCategory) {
|
||||
return fallbackCategory
|
||||
}
|
||||
|
||||
const category = device?.deviceCategory?.trim()
|
||||
|
||||
if (deviceKindOptions.some((option) => option.category === category)) {
|
||||
return category as DeviceCategory
|
||||
}
|
||||
|
||||
if (category) {
|
||||
return 'Andere'
|
||||
}
|
||||
|
||||
return 'Kameras'
|
||||
}
|
||||
|
||||
@ -259,6 +274,7 @@ async function createLookupOption(
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
clearDeviceLookupCache()
|
||||
return data.option as ComboboxItem
|
||||
}
|
||||
|
||||
@ -291,6 +307,11 @@ type DeviceLookupData = {
|
||||
let deviceLookupCache: DeviceLookupData | null = null
|
||||
let deviceLookupPromise: Promise<DeviceLookupData> | null = null
|
||||
|
||||
export function clearDeviceLookupCache() {
|
||||
deviceLookupCache = null
|
||||
deviceLookupPromise = null
|
||||
}
|
||||
|
||||
async function fetchDeviceLookups() {
|
||||
if (deviceLookupCache) {
|
||||
return deviceLookupCache
|
||||
@ -366,6 +387,9 @@ export default function DeviceFormFields({
|
||||
open,
|
||||
activeTab,
|
||||
device,
|
||||
initialDeviceCategory,
|
||||
lockedDeviceCategory,
|
||||
hideDeviceCategorySelector = false,
|
||||
devices,
|
||||
relatedDeviceIds,
|
||||
onToggleRelatedDevice,
|
||||
@ -392,7 +416,11 @@ export default function DeviceFormFields({
|
||||
const [selectedManufacturer, setSelectedManufacturer] = useState<ComboboxItem | null>(null)
|
||||
const [selectedLocation, setSelectedLocation] = useState<ComboboxItem | null>(null)
|
||||
const [selectedDeviceCategory, setSelectedDeviceCategory] = useState<DeviceCategory>(() =>
|
||||
getInitialDeviceCategory(device, isMilestoneDevice),
|
||||
getInitialDeviceCategory(
|
||||
device,
|
||||
isMilestoneDevice,
|
||||
lockedDeviceCategory ?? initialDeviceCategory,
|
||||
),
|
||||
)
|
||||
const [selectedLoanStatus, setSelectedLoanStatus] = useState(device?.loanStatus || 'Verfügbar')
|
||||
const [loanUsers, setLoanUsers] = useState<ComboboxItem[]>([])
|
||||
@ -428,7 +456,13 @@ export default function DeviceFormFields({
|
||||
: null,
|
||||
)
|
||||
|
||||
setSelectedDeviceCategory(getInitialDeviceCategory(device, isMilestoneDevice))
|
||||
setSelectedDeviceCategory(
|
||||
getInitialDeviceCategory(
|
||||
device,
|
||||
isMilestoneDevice,
|
||||
lockedDeviceCategory ?? initialDeviceCategory,
|
||||
),
|
||||
)
|
||||
setSelectedLoanStatus(device?.loanStatus || 'Verfügbar')
|
||||
setLoanedUntil(device?.loanedUntil ?? '')
|
||||
setIsLoanModalOpen(false)
|
||||
@ -437,7 +471,7 @@ export default function DeviceFormFields({
|
||||
setLoanModalError(null)
|
||||
|
||||
void loadDeviceLookups()
|
||||
}, [open, device?.id, isMilestoneDevice])
|
||||
}, [open, device?.id, initialDeviceCategory, isMilestoneDevice, lockedDeviceCategory])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
@ -483,7 +517,10 @@ export default function DeviceFormFields({
|
||||
|
||||
const milestoneChildDevices = device?.milestoneChildDevices ?? []
|
||||
|
||||
const visibleCategoryFields = getCategoryFieldVisibility(selectedDeviceCategory)
|
||||
const effectiveDeviceCategory = isMilestoneDevice
|
||||
? 'Kameras'
|
||||
: lockedDeviceCategory ?? selectedDeviceCategory
|
||||
const visibleCategoryFields = getCategoryFieldVisibility(effectiveDeviceCategory)
|
||||
const loanedToLabel =
|
||||
selectedLoanStatus === 'Ausgeliehen'
|
||||
? selectedLoanUser?.name || device?.loanedToDisplayName || ''
|
||||
@ -780,63 +817,65 @@ export default function DeviceFormFields({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-6">
|
||||
<div className={hideDeviceCategorySelector ? 'hidden' : 'sm:col-span-6'}>
|
||||
<input
|
||||
type="hidden"
|
||||
name="deviceCategory"
|
||||
value={isMilestoneDevice ? 'Kameras' : selectedDeviceCategory}
|
||||
value={effectiveDeviceCategory}
|
||||
/>
|
||||
|
||||
<fieldset disabled={isMilestoneDevice}>
|
||||
<legend className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
Geräteart
|
||||
</legend>
|
||||
{!hideDeviceCategorySelector && (
|
||||
<fieldset disabled={isMilestoneDevice || Boolean(lockedDeviceCategory)}>
|
||||
<legend className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
Geräteart
|
||||
</legend>
|
||||
|
||||
<div className="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-5">
|
||||
{deviceKindOptions.map((option) => {
|
||||
const selected = selectedDeviceCategory === option.category
|
||||
const Icon = option.icon
|
||||
<div className="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-5">
|
||||
{deviceKindOptions.map((option) => {
|
||||
const selected = effectiveDeviceCategory === option.category
|
||||
const Icon = option.icon
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.category}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
disabled={isMilestoneDevice}
|
||||
onClick={() => setSelectedDeviceCategory(option.category)}
|
||||
className={classNames(
|
||||
'group flex h-full min-h-24 flex-col items-start rounded-lg border p-2.5 text-left transition focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:cursor-not-allowed dark:focus-visible:outline-indigo-500',
|
||||
selected
|
||||
? 'border-indigo-500 bg-indigo-50 text-indigo-950 ring-1 ring-indigo-500 dark:border-indigo-400 dark:bg-indigo-500/10 dark:text-indigo-100 dark:ring-indigo-400'
|
||||
: 'border-gray-200 bg-white text-gray-900 hover:border-indigo-300 hover:bg-indigo-50/60 dark:border-white/10 dark:bg-white/[0.03] dark:text-white dark:hover:border-indigo-400/50 dark:hover:bg-indigo-500/10',
|
||||
isMilestoneDevice && !selected && 'opacity-45',
|
||||
)}
|
||||
>
|
||||
<span className="flex w-full items-center gap-2">
|
||||
<span
|
||||
className={classNames(
|
||||
'flex size-8 shrink-0 items-center justify-center rounded-md',
|
||||
selected
|
||||
? 'bg-indigo-600 text-white dark:bg-indigo-400 dark:text-gray-950'
|
||||
: 'bg-gray-100 text-gray-500 group-hover:bg-indigo-100 group-hover:text-indigo-600 dark:bg-white/10 dark:text-gray-400 dark:group-hover:bg-indigo-500/15 dark:group-hover:text-indigo-300',
|
||||
)}
|
||||
>
|
||||
<Icon aria-hidden="true" className="size-4" />
|
||||
return (
|
||||
<button
|
||||
key={option.category}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
disabled={isMilestoneDevice || Boolean(lockedDeviceCategory)}
|
||||
onClick={() => setSelectedDeviceCategory(option.category)}
|
||||
className={classNames(
|
||||
'group flex h-full min-h-24 flex-col items-start rounded-lg border p-2.5 text-left transition focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:cursor-not-allowed dark:focus-visible:outline-indigo-500',
|
||||
selected
|
||||
? 'border-indigo-500 bg-indigo-50 text-indigo-950 ring-1 ring-indigo-500 dark:border-indigo-400 dark:bg-indigo-500/10 dark:text-indigo-100 dark:ring-indigo-400'
|
||||
: 'border-gray-200 bg-white text-gray-900 hover:border-indigo-300 hover:bg-indigo-50/60 dark:border-white/10 dark:bg-white/[0.03] dark:text-white dark:hover:border-indigo-400/50 dark:hover:bg-indigo-500/10',
|
||||
isMilestoneDevice && !selected && 'opacity-45',
|
||||
)}
|
||||
>
|
||||
<span className="flex w-full items-center gap-2">
|
||||
<span
|
||||
className={classNames(
|
||||
'flex size-8 shrink-0 items-center justify-center rounded-md',
|
||||
selected
|
||||
? 'bg-indigo-600 text-white dark:bg-indigo-400 dark:text-gray-950'
|
||||
: 'bg-gray-100 text-gray-500 group-hover:bg-indigo-100 group-hover:text-indigo-600 dark:bg-white/10 dark:text-gray-400 dark:group-hover:bg-indigo-500/15 dark:group-hover:text-indigo-300',
|
||||
)}
|
||||
>
|
||||
<Icon aria-hidden="true" className="size-4" />
|
||||
</span>
|
||||
|
||||
<span className="min-w-0 truncate text-sm font-semibold">
|
||||
{option.label}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="min-w-0 truncate text-sm font-semibold">
|
||||
{option.label}
|
||||
<span className="mt-2 text-xs leading-4 text-gray-500 dark:text-gray-400">
|
||||
{option.description}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="mt-2 text-xs leading-4 text-gray-500 dark:text-gray-400">
|
||||
{option.description}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
|
||||
@ -1,12 +1,23 @@
|
||||
// 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 {
|
||||
ArrowDownTrayIcon,
|
||||
ArrowPathIcon,
|
||||
CheckCircleIcon,
|
||||
ClockIcon,
|
||||
PlusIcon,
|
||||
TagIcon,
|
||||
TrashIcon,
|
||||
WrenchScrewdriverIcon,
|
||||
XCircleIcon,
|
||||
} from '@heroicons/react/20/solid'
|
||||
@ -15,8 +26,9 @@ import { Badge } from '../../components/Badge'
|
||||
import CreateDeviceModal from './CreateDeviceModal'
|
||||
import EditDeviceModal from './EditDeviceModal'
|
||||
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 Modal, { ModalTitle } from '../../components/Modal'
|
||||
import type {
|
||||
Device,
|
||||
DeviceHistoryEntry,
|
||||
@ -26,6 +38,12 @@ import type {
|
||||
User,
|
||||
} from '../../components/types'
|
||||
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'
|
||||
|
||||
@ -37,6 +55,91 @@ type FirmwareCheckState = {
|
||||
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) {
|
||||
const normalizedStatus = status.toLowerCase()
|
||||
|
||||
@ -246,15 +349,93 @@ function getAvailableResolutions(
|
||||
|
||||
type DevicesPageProps = {
|
||||
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 [devices, setDevices] = useState<Device[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [activeModal, setActiveModal] = useState<'create' | 'edit' | null>(null)
|
||||
const [editingDevice, setEditingDevice] = useState<Device | null>(null)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [createError, setCreateError] = useState<string | null>(null)
|
||||
const [relatedDeviceIds, setRelatedDeviceIds] = useState<string[]>([])
|
||||
const [isJournalSubmitting, setIsJournalSubmitting] = useState(false)
|
||||
@ -267,8 +448,35 @@ export default function DevicesPage({ currentUser }: DevicesPageProps = {}) {
|
||||
const [isCheckingFirmware, setIsCheckingFirmware] = useState(false)
|
||||
const [firmwareCheckState, setFirmwareCheckState] = useState<FirmwareCheckState | null>(null)
|
||||
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 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 [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) {
|
||||
setIsHistoryLoading(true)
|
||||
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 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 =
|
||||
Boolean(firmwareCheckNextAllowedAt) &&
|
||||
new Date(firmwareCheckNextAllowedAt as string).getTime() > firmwareCheckNow
|
||||
@ -1357,11 +1759,30 @@ export default function DevicesPage({ currentUser }: DevicesPageProps = {}) {
|
||||
}`
|
||||
: 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>[] = [
|
||||
{
|
||||
key: 'inventoryNumber',
|
||||
header: 'Inventur-Nr.',
|
||||
isPrimary: true,
|
||||
sortable: true,
|
||||
width: '16%',
|
||||
sortValue: (device) => device.inventoryNumber,
|
||||
render: (device) => (
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-gray-900 dark:text-white">
|
||||
@ -1389,6 +1810,16 @@ export default function DevicesPage({ currentUser }: DevicesPageProps = {}) {
|
||||
{
|
||||
key: 'model',
|
||||
header: 'Gerät',
|
||||
sortable: true,
|
||||
width: '30%',
|
||||
sortValue: (device) =>
|
||||
[
|
||||
device.milestoneDisplayName,
|
||||
device.manufacturer,
|
||||
device.model,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
render: (device) => {
|
||||
const title = [device.manufacturer, device.model]
|
||||
.filter(Boolean)
|
||||
@ -1473,17 +1904,26 @@ export default function DevicesPage({ currentUser }: DevicesPageProps = {}) {
|
||||
key: 'ipAddress',
|
||||
header: 'IP-Adresse',
|
||||
hideOnMobile: 'md',
|
||||
sortable: true,
|
||||
width: '14%',
|
||||
sortValue: (device) => device.ipAddress,
|
||||
render: (device) => device.ipAddress || '-',
|
||||
},
|
||||
{
|
||||
key: 'location',
|
||||
header: 'Standort',
|
||||
hideOnMobile: 'lg',
|
||||
sortable: true,
|
||||
width: '12%',
|
||||
sortValue: (device) => device.location,
|
||||
render: (device) => device.location || '-',
|
||||
},
|
||||
{
|
||||
key: 'loanStatus',
|
||||
header: 'Verleih',
|
||||
sortable: true,
|
||||
width: '13%',
|
||||
sortValue: (device) => device.loanStatus || 'Verfügbar',
|
||||
render: (device) => (
|
||||
<Badge
|
||||
tone={getLoanStatusBadgeTone(device.loanStatus)}
|
||||
@ -1498,6 +1938,14 @@ export default function DevicesPage({ currentUser }: DevicesPageProps = {}) {
|
||||
key: 'milestoneEnabled',
|
||||
header: 'Milestone',
|
||||
hideOnMobile: 'lg',
|
||||
sortable: true,
|
||||
width: '10%',
|
||||
sortValue: (device) =>
|
||||
device.milestoneHardwareId
|
||||
? device.milestoneEnabled
|
||||
? 2
|
||||
: 1
|
||||
: 0,
|
||||
render: (device) => {
|
||||
if (!device.milestoneHardwareId) {
|
||||
return '-'
|
||||
@ -1544,6 +1992,10 @@ export default function DevicesPage({ currentUser }: DevicesPageProps = {}) {
|
||||
key: 'isBaoDevice',
|
||||
header: 'BAO',
|
||||
hideOnMobile: 'lg',
|
||||
sortable: true,
|
||||
align: 'center',
|
||||
width: '5%',
|
||||
sortValue: (device) => device.isBaoDevice,
|
||||
render: (device) =>
|
||||
device.isBaoDevice ? (
|
||||
<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 (
|
||||
<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
|
||||
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."
|
||||
title={title}
|
||||
description={description}
|
||||
headerAction={
|
||||
<div className="flex flex-col items-start gap-2 sm:items-end">
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<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>
|
||||
{canManageManufacturers && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
color="gray"
|
||||
size="md"
|
||||
leadingIcon={<TagIcon aria-hidden="true" className="size-4" />}
|
||||
onClick={openManufacturerModal}
|
||||
>
|
||||
Hersteller verwalten
|
||||
</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
|
||||
type="button"
|
||||
@ -1588,22 +2107,31 @@ export default function DevicesPage({ currentUser }: DevicesPageProps = {}) {
|
||||
leadingIcon={<PlusIcon aria-hidden="true" className="size-4" />}
|
||||
onClick={openCreateModal}
|
||||
>
|
||||
Gerät hinzufügen
|
||||
{createButtonLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
onAction={openCreateModal}
|
||||
rows={devices}
|
||||
rows={sortedVisibleDevices}
|
||||
columns={columns}
|
||||
getRowId={(device) => device.id}
|
||||
variant="card"
|
||||
emptyText="Keine Geräte vorhanden."
|
||||
emptyText={emptyText}
|
||||
isLoading={isLoading}
|
||||
scrollRows
|
||||
rowScrollClassName={
|
||||
showStats
|
||||
? 'max-h-[calc(100dvh-28rem)]'
|
||||
: 'max-h-[calc(100dvh-18rem)]'
|
||||
}
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
loadingContent={
|
||||
<LoadingSpinner
|
||||
size="lg"
|
||||
label="Geräteliste wird geladen..."
|
||||
label={loadingLabel}
|
||||
className="text-indigo-600 dark:text-indigo-400"
|
||||
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
|
||||
open={activeModal === 'create'}
|
||||
setOpen={(open) => {
|
||||
@ -1626,6 +2321,12 @@ export default function DevicesPage({ currentUser }: DevicesPageProps = {}) {
|
||||
}}
|
||||
isSaving={isCreating}
|
||||
error={createError}
|
||||
title={createModalTitle}
|
||||
description={createModalDescription}
|
||||
submitLabel={createSubmitLabel}
|
||||
initialDeviceCategory={deviceCategoryFilter}
|
||||
lockedDeviceCategory={deviceCategoryFilter}
|
||||
hideDeviceCategorySelector={Boolean(deviceCategoryFilter)}
|
||||
devices={devices}
|
||||
relatedDeviceIds={relatedDeviceIds}
|
||||
onToggleRelatedDevice={toggleRelatedDevice}
|
||||
@ -1643,12 +2344,17 @@ export default function DevicesPage({ currentUser }: DevicesPageProps = {}) {
|
||||
closeModal()
|
||||
}}
|
||||
isSaving={isCreating}
|
||||
isDeleting={isDeleting}
|
||||
error={createError}
|
||||
title={editModalTitle}
|
||||
lockedDeviceCategory={deviceCategoryFilter}
|
||||
hideDeviceCategorySelector={Boolean(deviceCategoryFilter)}
|
||||
device={editingDevice}
|
||||
devices={devices}
|
||||
relatedDeviceIds={relatedDeviceIds}
|
||||
onToggleRelatedDevice={toggleRelatedDevice}
|
||||
onSubmit={handleSaveDevice}
|
||||
onDelete={handleDeleteDevice}
|
||||
deviceHistory={deviceHistory}
|
||||
isHistoryLoading={isHistoryLoading}
|
||||
historyError={historyError}
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
MapPinIcon,
|
||||
PencilSquareIcon,
|
||||
PlusCircleIcon,
|
||||
TrashIcon,
|
||||
VideoCameraIcon,
|
||||
WrenchScrewdriverIcon,
|
||||
} from '@heroicons/react/20/solid'
|
||||
@ -23,19 +24,24 @@ import type {
|
||||
DeviceHistoryChange,
|
||||
DeviceHistoryEntry,
|
||||
} from '../../components/types'
|
||||
import DeviceFormFields from './DeviceFormFields'
|
||||
import DeviceFormFields, { type DeviceCategory } from './DeviceFormFields'
|
||||
import DeviceModalTabs, { type DeviceModalTabId } from './DeviceModalTabs'
|
||||
|
||||
type EditDeviceModalProps = {
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
isSaving: boolean
|
||||
isDeleting?: boolean
|
||||
error: string | null
|
||||
title?: string
|
||||
lockedDeviceCategory?: DeviceCategory
|
||||
hideDeviceCategorySelector?: boolean
|
||||
device: Device | null
|
||||
devices: Device[]
|
||||
relatedDeviceIds: string[]
|
||||
onToggleRelatedDevice: (deviceId: string) => void
|
||||
onSubmit: (event: FormEvent<HTMLFormElement>) => void
|
||||
onDelete?: (device: Device) => void | Promise<void>
|
||||
deviceHistory: DeviceHistoryEntry[]
|
||||
isHistoryLoading: boolean
|
||||
historyError: string | null
|
||||
@ -155,12 +161,17 @@ export default function EditDeviceModal({
|
||||
open,
|
||||
setOpen,
|
||||
isSaving,
|
||||
isDeleting = false,
|
||||
error,
|
||||
title = 'Gerät bearbeiten',
|
||||
lockedDeviceCategory,
|
||||
hideDeviceCategorySelector,
|
||||
device,
|
||||
devices,
|
||||
relatedDeviceIds,
|
||||
onToggleRelatedDevice,
|
||||
onSubmit,
|
||||
onDelete,
|
||||
deviceHistory,
|
||||
isHistoryLoading,
|
||||
historyError,
|
||||
@ -178,6 +189,7 @@ export default function EditDeviceModal({
|
||||
}: EditDeviceModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<DeviceModalTabId>('masterData')
|
||||
const [journalSearchQuery, setJournalSearchQuery] = useState('')
|
||||
const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false)
|
||||
const milestoneFilteredDevice = filterMilestoneChildDevices(device)
|
||||
const isMilestoneDevice = Boolean(
|
||||
milestoneFilteredDevice?.milestoneHardwareId ||
|
||||
@ -194,7 +206,7 @@ export default function EditDeviceModal({
|
||||
}, [activeTab, isMilestoneDevice])
|
||||
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
if (!nextOpen && isSaving) {
|
||||
if (!nextOpen && (isSaving || isDeleting)) {
|
||||
return
|
||||
}
|
||||
|
||||
@ -203,6 +215,7 @@ export default function EditDeviceModal({
|
||||
if (!nextOpen) {
|
||||
setActiveTab('masterData')
|
||||
setJournalSearchQuery('')
|
||||
setIsDeleteConfirmOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
@ -213,12 +226,16 @@ export default function EditDeviceModal({
|
||||
}
|
||||
|
||||
function handleFormSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
if (activeTab === 'history') {
|
||||
event.preventDefault()
|
||||
onSubmit(event)
|
||||
}
|
||||
|
||||
async function handleDeleteConfirm() {
|
||||
if (!milestoneFilteredDevice || isMilestoneDevice || !onDelete) {
|
||||
return
|
||||
}
|
||||
|
||||
onSubmit(event)
|
||||
await onDelete(milestoneFilteredDevice)
|
||||
setIsDeleteConfirmOpen(false)
|
||||
}
|
||||
|
||||
const historyFeedItems: JournalItems = device
|
||||
@ -278,6 +295,7 @@ export default function EditDeviceModal({
|
||||
: []
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
open={open && Boolean(milestoneFilteredDevice)}
|
||||
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="min-w-0 flex-1">
|
||||
<DialogTitle className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
Gerät bearbeiten
|
||||
{title}
|
||||
{milestoneFilteredDevice && (
|
||||
<span className="ml-2 font-normal text-gray-500 dark:text-gray-400">
|
||||
{[
|
||||
@ -344,7 +362,7 @@ export default function EditDeviceModal({
|
||||
variant="secondary"
|
||||
color="gray"
|
||||
size="sm"
|
||||
disabled={isSaving}
|
||||
disabled={isSaving || isDeleting}
|
||||
onClick={() => handleOpenChange(false)}
|
||||
>
|
||||
<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">
|
||||
{activeTab === 'history' ? (
|
||||
{activeTab === 'history' && (
|
||||
<div>
|
||||
{isHistoryLoading && (
|
||||
<div className="flex min-h-24 items-center justify-center rounded-md border border-dashed border-gray-200 p-4 dark:border-white/10">
|
||||
@ -402,11 +420,15 @@ export default function EditDeviceModal({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
)}
|
||||
|
||||
<div hidden={activeTab === 'history'}>
|
||||
<DeviceFormFields
|
||||
open={open}
|
||||
activeTab={activeTab}
|
||||
device={milestoneFilteredDevice ?? undefined}
|
||||
lockedDeviceCategory={lockedDeviceCategory}
|
||||
hideDeviceCategorySelector={hideDeviceCategorySelector}
|
||||
devices={devices}
|
||||
relatedDeviceIds={relatedDeviceIds}
|
||||
onToggleRelatedDevice={onToggleRelatedDevice}
|
||||
@ -417,32 +439,67 @@ export default function EditDeviceModal({
|
||||
onToggleMilestoneChildDeviceRecording={onToggleMilestoneChildDeviceRecording}
|
||||
onOpenMilestoneCameraDetails={onOpenMilestoneCameraDetails}
|
||||
/>
|
||||
)}
|
||||
</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">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
color="gray"
|
||||
disabled={isSaving}
|
||||
onClick={() => handleOpenChange(false)}
|
||||
>
|
||||
{activeTab === 'history' ? 'Schließen' : 'Abbrechen'}
|
||||
</Button>
|
||||
<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">
|
||||
{onDelete ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
color="red"
|
||||
disabled={isSaving || isDeleting || isMilestoneDevice}
|
||||
title={
|
||||
isMilestoneDevice
|
||||
? '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
|
||||
type="submit"
|
||||
color="indigo"
|
||||
disabled={isDeleting}
|
||||
isLoading={isSaving}
|
||||
leadingIcon={<CheckIcon aria-hidden="true" className="size-4" />}
|
||||
>
|
||||
Änderungen speichern
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</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 {
|
||||
ArrowPathIcon,
|
||||
ArrowRightIcon,
|
||||
ArchiveBoxIcon,
|
||||
CheckCircleIcon,
|
||||
CpuChipIcon,
|
||||
ExclamationTriangleIcon,
|
||||
@ -65,11 +66,11 @@ type LoadState = {
|
||||
|
||||
const featureCards = [
|
||||
{
|
||||
title: 'Geräte',
|
||||
title: 'Kameras',
|
||||
description:
|
||||
'Kameras und Mikrofone nach Recording-Speicher gruppiert anzeigen und Status direkt schalten.',
|
||||
href: '/milestone/geraete',
|
||||
icon: CpuChipIcon,
|
||||
'Milestone-Kameras und Mikrofone nach Recording-Speicher gruppiert anzeigen und Status direkt schalten.',
|
||||
href: '/milestone/kameras',
|
||||
icon: VideoCameraIcon,
|
||||
},
|
||||
{
|
||||
title: 'Speicher',
|
||||
@ -85,6 +86,13 @@ const featureCards = [
|
||||
href: '/milestone/rollen',
|
||||
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>) {
|
||||
@ -136,6 +144,26 @@ function storageTitle(storage: MilestoneStorage) {
|
||||
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() {
|
||||
const [state, setState] = useState<LoadState>({ devices: [], storages: [] })
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
@ -162,7 +190,7 @@ export default function MilestonePage() {
|
||||
throw new Error(
|
||||
typeof devicesData?.error === 'string'
|
||||
? 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="flex items-center gap-3">
|
||||
<div className="flex size-12 items-center justify-center rounded-xl bg-indigo-50 text-indigo-600 dark:bg-indigo-500/10 dark:text-indigo-300">
|
||||
<ServerStackIcon aria-hidden="true" className="size-6" />
|
||||
<MilestoneDiamondIcon className="size-6" />
|
||||
</div>
|
||||
<div>
|
||||
<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({
|
||||
variant: 'error',
|
||||
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
|
||||
}
|
||||
@ -860,7 +860,7 @@ export default function MilestoneDevicesPage({ canWrite = false }: Props) {
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
Milestone-Geräte
|
||||
Milestone-Kameras
|
||||
</h1>
|
||||
{!isLoading && !error && (
|
||||
<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">
|
||||
{isLoading && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
@ -911,7 +911,7 @@ export default function MilestoneDevicesPage({ canWrite = false }: Props) {
|
||||
{!isLoading && !error && filtered.length === 0 && (
|
||||
<EmptyState
|
||||
icon={VideoCameraIcon}
|
||||
title={search ? 'Keine Treffer' : 'Keine Geräte gefunden'}
|
||||
title={search ? 'Keine Treffer' : 'Keine Kameras gefunden'}
|
||||
description={
|
||||
search
|
||||
? 'Versuche einen anderen Suchbegriff.'
|
||||
|
||||
@ -207,6 +207,7 @@ export default function MilestoneRolesPage({
|
||||
const [isLoadingBasicUsers, setIsLoadingBasicUsers] = useState(false)
|
||||
const [isAddingUsers, setIsAddingUsers] = useState(false)
|
||||
const [adSearch, setAdSearch] = useState('')
|
||||
const [activeAddUsersTab, setActiveAddUsersTab] = useState<UserSource>('ad')
|
||||
const [selectedUserKeys, setSelectedUserKeys] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
)
|
||||
@ -660,9 +661,52 @@ export default function MilestoneRolesPage({
|
||||
basic: filterVisibleUsers(basicUsers),
|
||||
}
|
||||
}, [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 (
|
||||
<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">
|
||||
<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">
|
||||
@ -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"
|
||||
/>
|
||||
|
||||
<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">
|
||||
{([
|
||||
{
|
||||
source: 'ad' as const,
|
||||
title: 'Windows-Benutzer',
|
||||
users: visibleAssignableUsersBySource.ad,
|
||||
loading: isLoadingAdUsers,
|
||||
unavailable: !adConfigured,
|
||||
emptyText: 'Keine passenden Windows-Benutzer gefunden.',
|
||||
},
|
||||
{
|
||||
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>
|
||||
<div className="mt-4">
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Benutzerart"
|
||||
className="grid grid-cols-2 rounded-lg bg-gray-100 p-1 dark:bg-white/5"
|
||||
>
|
||||
{(['ad', 'basic'] as const).map((source) => {
|
||||
const group = addUserGroups[source]
|
||||
const active = activeAddUsersTab === source
|
||||
|
||||
{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>
|
||||
{userSubtitle(user) && (
|
||||
<span className="mt-0.5 block truncate text-xs text-gray-500 dark:text-gray-400">
|
||||
{userSubtitle(user)}
|
||||
return (
|
||||
<button
|
||||
key={source}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={() => setActiveAddUsersTab(source)}
|
||||
className={classNames(
|
||||
active
|
||||
? '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',
|
||||
'flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-semibold transition',
|
||||
)}
|
||||
>
|
||||
<span>{group.title}</span>
|
||||
<span
|
||||
className={classNames(
|
||||
active
|
||||
? 'bg-indigo-50 text-indigo-700 dark:bg-white/10 dark:text-white'
|
||||
: 'bg-white text-gray-500 dark:bg-white/10 dark:text-gray-300',
|
||||
'inline-flex min-w-6 justify-center rounded-full px-1.5 py-0.5 text-xs leading-none',
|
||||
)}
|
||||
>
|
||||
{group.users.length}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
'mt-3 max-h-96 overflow-y-auto rounded-lg border border-gray-200 p-3 dark:border-white/10',
|
||||
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>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
{userSubtitle(user) && (
|
||||
<span className="mt-0.5 block truncate text-xs text-gray-500 dark:text-gray-400">
|
||||
{userSubtitle(user)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -238,7 +238,6 @@ function mapActiveDirectoryUserToComboboxItem(user: OperationActiveDirectoryUser
|
||||
return {
|
||||
id: user.id,
|
||||
name: accountName || user.displayName || user.email || user.id,
|
||||
secondaryText: [user.displayName, user.email].filter(Boolean).join(' / '),
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
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) {
|
||||
const data = await response.json().catch(() => null)
|
||||
|
||||
@ -617,6 +639,7 @@ export default function CreateOperationModal({
|
||||
const [isLoadingMilestoneUsers, setIsLoadingMilestoneUsers] = useState(false)
|
||||
const [milestoneUsersConfigured, setMilestoneUsersConfigured] = useState(false)
|
||||
const [milestoneUsersError, setMilestoneUsersError] = useState<string | null>(null)
|
||||
const [showMilestoneUserOverride, setShowMilestoneUserOverride] = useState(false)
|
||||
|
||||
const [equipmentDevices, setEquipmentDevices] = useState<Device[]>([])
|
||||
const [isLoadingDevices, setIsLoadingDevices] = useState(false)
|
||||
@ -682,6 +705,26 @@ export default function CreateOperationModal({
|
||||
[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 byId = new Map<string, string>()
|
||||
|
||||
@ -709,6 +752,7 @@ export default function CreateOperationModal({
|
||||
setIsLoadingMilestoneUsers(false)
|
||||
setMilestoneUsersConfigured(false)
|
||||
setMilestoneUsersError(null)
|
||||
setShowMilestoneUserOverride(false)
|
||||
setSelectedCameraDevice(null)
|
||||
setSelectedRouterDevice(null)
|
||||
setSelectedSwitchboxDevice(null)
|
||||
@ -1025,6 +1069,9 @@ export default function CreateOperationModal({
|
||||
setSelectedSwitchboxDevice(item)
|
||||
} else {
|
||||
setSelectedLaptopDevice(item)
|
||||
setSelectedMilestoneUser(null)
|
||||
setShowMilestoneUserOverride(false)
|
||||
setMilestoneUsersError(null)
|
||||
}
|
||||
|
||||
updateField(
|
||||
@ -1082,6 +1129,9 @@ export default function CreateOperationModal({
|
||||
}
|
||||
|
||||
function handleLaptopDeviceChange(item: ComboboxItem | null) {
|
||||
setSelectedMilestoneUser(null)
|
||||
setShowMilestoneUserOverride(false)
|
||||
setMilestoneUsersError(null)
|
||||
handleEquipmentDeviceChange('laptop', item)
|
||||
}
|
||||
|
||||
@ -1587,41 +1637,6 @@ export default function CreateOperationModal({
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
@ -1650,93 +1665,176 @@ export default function CreateOperationModal({
|
||||
|
||||
{activeStep === 'equipment' && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-x-4 gap-y-4 lg:grid-cols-2">
|
||||
<Combobox
|
||||
label="Kamera"
|
||||
labelIcon={VideoCameraIcon}
|
||||
items={hardwareCameraOptions}
|
||||
value={selectedCameraDevice}
|
||||
onChange={handleCameraDeviceChange}
|
||||
variant="secondary"
|
||||
placeholder={
|
||||
isLoadingDevices
|
||||
? 'Geräte werden geladen...'
|
||||
: 'Hardware-Kamera auswählen'
|
||||
}
|
||||
emptyText="Keine Hardware-Kamera gefunden."
|
||||
disabled={isLoadingDevices}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:items-start">
|
||||
<div className="space-y-4">
|
||||
<Combobox
|
||||
label="Kamera"
|
||||
labelIcon={VideoCameraIcon}
|
||||
items={hardwareCameraOptions}
|
||||
value={selectedCameraDevice}
|
||||
onChange={handleCameraDeviceChange}
|
||||
variant="secondary"
|
||||
placeholder={
|
||||
isLoadingDevices
|
||||
? 'Geräte werden geladen...'
|
||||
: 'Hardware-Kamera auswählen'
|
||||
}
|
||||
emptyText="Keine Hardware-Kamera gefunden."
|
||||
disabled={isLoadingDevices}
|
||||
/>
|
||||
|
||||
<Combobox
|
||||
label="Router"
|
||||
labelIcon={WifiIcon}
|
||||
items={routerOptions}
|
||||
value={selectedRouterDevice}
|
||||
onChange={handleRouterDeviceChange}
|
||||
variant="secondary"
|
||||
allowCustomValue
|
||||
createOptionLabel={(value) => `"${value}" als Router anlegen`}
|
||||
placeholder={
|
||||
isLoadingDevices
|
||||
? 'Geräte werden geladen...'
|
||||
: 'Router auswählen oder anlegen'
|
||||
}
|
||||
emptyText="Kein Router gefunden."
|
||||
disabled={isLoadingDevices}
|
||||
/>
|
||||
<Combobox
|
||||
label="Switchbox"
|
||||
labelIcon={ArrowsRightLeftIcon}
|
||||
items={switchboxOptions}
|
||||
value={selectedSwitchboxDevice}
|
||||
onChange={handleSwitchboxDeviceChange}
|
||||
variant="secondary"
|
||||
allowCustomValue
|
||||
createOptionLabel={(value) => `"${value}" als Switchbox anlegen`}
|
||||
placeholder={
|
||||
isLoadingDevices
|
||||
? 'Geräte werden geladen...'
|
||||
: 'Switchbox auswählen oder anlegen'
|
||||
}
|
||||
emptyText="Keine Switchbox gefunden."
|
||||
disabled={isLoadingDevices}
|
||||
/>
|
||||
|
||||
<Combobox
|
||||
label="Switchbox"
|
||||
labelIcon={ArrowsRightLeftIcon}
|
||||
items={switchboxOptions}
|
||||
value={selectedSwitchboxDevice}
|
||||
onChange={handleSwitchboxDeviceChange}
|
||||
variant="secondary"
|
||||
allowCustomValue
|
||||
createOptionLabel={(value) => `"${value}" als Switchbox anlegen`}
|
||||
placeholder={
|
||||
isLoadingDevices
|
||||
? 'Geräte werden geladen...'
|
||||
: 'Switchbox auswählen oder anlegen'
|
||||
}
|
||||
emptyText="Keine Switchbox gefunden."
|
||||
disabled={isLoadingDevices}
|
||||
/>
|
||||
<Combobox
|
||||
label="Festplatte"
|
||||
labelIcon={CircleStackIcon}
|
||||
items={hardDriveOptions}
|
||||
value={selectedHardDriveDevice}
|
||||
onChange={handleHardDriveDeviceChange}
|
||||
variant="secondary"
|
||||
allowCustomValue
|
||||
placeholder={
|
||||
isLoadingDevices
|
||||
? 'Geräte werden geladen...'
|
||||
: 'Festplatte auswählen oder eingeben'
|
||||
}
|
||||
emptyText="Keine Festplatte gefunden."
|
||||
disabled={isLoadingDevices}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Combobox
|
||||
label="Laptop"
|
||||
labelIcon={ComputerDesktopIcon}
|
||||
items={laptopOptions}
|
||||
value={selectedLaptopDevice}
|
||||
onChange={handleLaptopDeviceChange}
|
||||
variant="secondary"
|
||||
allowCustomValue
|
||||
createOptionLabel={(value) => `"${value}" als Laptop anlegen`}
|
||||
placeholder={
|
||||
isLoadingDevices
|
||||
? 'Geräte werden geladen...'
|
||||
: 'Laptop auswählen oder anlegen'
|
||||
}
|
||||
emptyText="Kein Laptop gefunden."
|
||||
disabled={isLoadingDevices}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<Combobox
|
||||
label="Router"
|
||||
labelIcon={WifiIcon}
|
||||
items={routerOptions}
|
||||
value={selectedRouterDevice}
|
||||
onChange={handleRouterDeviceChange}
|
||||
variant="secondary"
|
||||
allowCustomValue
|
||||
createOptionLabel={(value) => `"${value}" als Router anlegen`}
|
||||
placeholder={
|
||||
isLoadingDevices
|
||||
? 'Geräte werden geladen...'
|
||||
: 'Router auswählen oder anlegen'
|
||||
}
|
||||
emptyText="Kein Router gefunden."
|
||||
disabled={isLoadingDevices}
|
||||
/>
|
||||
|
||||
<Combobox
|
||||
label="Festplatte"
|
||||
labelIcon={CircleStackIcon}
|
||||
items={hardDriveOptions}
|
||||
value={selectedHardDriveDevice}
|
||||
onChange={handleHardDriveDeviceChange}
|
||||
variant="secondary"
|
||||
allowCustomValue
|
||||
placeholder={
|
||||
isLoadingDevices
|
||||
? 'Geräte werden geladen...'
|
||||
: 'Festplatte auswählen oder eingeben'
|
||||
}
|
||||
emptyText="Keine Festplatte gefunden."
|
||||
disabled={isLoadingDevices}
|
||||
/>
|
||||
<Combobox
|
||||
label="Laptop"
|
||||
labelIcon={ComputerDesktopIcon}
|
||||
items={laptopOptions}
|
||||
value={selectedLaptopDevice}
|
||||
onChange={handleLaptopDeviceChange}
|
||||
variant="secondary"
|
||||
allowCustomValue
|
||||
createOptionLabel={(value) => `"${value}" als Laptop anlegen`}
|
||||
placeholder={
|
||||
isLoadingDevices
|
||||
? 'Geräte werden geladen...'
|
||||
: 'Laptop auswählen oder anlegen'
|
||||
}
|
||||
emptyText="Kein Laptop gefunden."
|
||||
disabled={isLoadingDevices}
|
||||
/>
|
||||
|
||||
<div 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>
|
||||
)}
|
||||
@ -2097,7 +2195,7 @@ export default function CreateOperationModal({
|
||||
</h4>
|
||||
</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} />
|
||||
|
||||
<ReviewPersonItem
|
||||
@ -2112,11 +2210,6 @@ export default function CreateOperationModal({
|
||||
fallbackValue={formValues.operationTeam}
|
||||
/>
|
||||
|
||||
<ReviewPersonItem
|
||||
label="Milestone-Auswerter"
|
||||
item={selectedMilestoneUser}
|
||||
fallbackValue=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -2127,11 +2220,12 @@ export default function CreateOperationModal({
|
||||
</h4>
|
||||
</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="Router" value={formValues.router} />
|
||||
<ReviewItem label="Switchbox" value={formValues.switchbox} />
|
||||
<ReviewItem label="Laptop" value={formValues.laptop} />
|
||||
<ReviewItem label="Milestone-Auswerter" value={milestoneUserDisplayValue} />
|
||||
<ReviewItem label="Festplatte" value={formValues.hardDrive} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -431,7 +431,7 @@ export default function Konto({
|
||||
src={avatarPreview}
|
||||
name={currentUser?.displayName || currentUser?.username || currentUser?.email}
|
||||
size={16}
|
||||
shape="rounded"
|
||||
shape="circle"
|
||||
/>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
// frontend/src/utils/activityLog.ts
|
||||
//
|
||||
// Clientseitiges Aktivitäts- und Fehlerprotokoll. Erfasst app-weit, was der
|
||||
// Nutzer anklickt, welche Seiten er aufruft und welche Fehler im Browser
|
||||
// auftreten. Wird auf der Feedback-Seite sichtbar gemacht und ans Feedback
|
||||
// angehängt, damit Administratoren Probleme nachvollziehen können.
|
||||
// Clientseitiges Aktivitaets- und Fehlerprotokoll. Erfasst app-weit nur Klicks
|
||||
// auf Schaltflaechen, Seitenaufrufe und API-Fehler. Wird auf der Feedback-Seite
|
||||
// sichtbar gemacht und ans Feedback angehaengt, damit Administratoren Probleme
|
||||
// nachvollziehen koennen.
|
||||
|
||||
export type ActivityLogType = 'action' | 'navigation' | 'error'
|
||||
|
||||
@ -18,7 +18,10 @@ export type ActivityLogEntry = {
|
||||
|
||||
const MAX_ENTRIES = 200
|
||||
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 initialized = false
|
||||
@ -50,7 +53,7 @@ function persist() {
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(entries))
|
||||
} 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) {
|
||||
if (!stack) {
|
||||
return undefined
|
||||
}
|
||||
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]',
|
||||
function describeButton(target: Element): string | null {
|
||||
const button = target.closest(
|
||||
'button, [role="button"], input[type="button"], input[type="submit"], input[type="reset"]',
|
||||
)
|
||||
|
||||
if (!actionable) {
|
||||
if (!button) {
|
||||
return null
|
||||
}
|
||||
|
||||
const element = actionable 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 element = button as HTMLElement
|
||||
const rawLabel =
|
||||
element.getAttribute('data-log-label') ||
|
||||
element.getAttribute('aria-label') ||
|
||||
element.getAttribute('title') ||
|
||||
(element as HTMLInputElement).placeholder ||
|
||||
(element instanceof HTMLInputElement ? element.value : '') ||
|
||||
(element.textContent ?? '').replace(/\s+/g, ' ').trim()
|
||||
|
||||
if (!rawLabel) {
|
||||
return role
|
||||
return 'Schaltflaeche'
|
||||
}
|
||||
|
||||
const label = rawLabel.length > 80 ? `${rawLabel.slice(0, 80)}…` : rawLabel
|
||||
return `${role}: „${label}"`
|
||||
const label = rawLabel.length > 80 ? `${rawLabel.slice(0, 80)}...` : rawLabel
|
||||
return `Schaltflaeche: "${label}"`
|
||||
}
|
||||
|
||||
function handleClick(event: MouseEvent) {
|
||||
@ -175,45 +153,103 @@ function handleClick(event: MouseEvent) {
|
||||
return
|
||||
}
|
||||
|
||||
const description = describeElement(target)
|
||||
const description = describeButton(target)
|
||||
if (description) {
|
||||
recordAction(`Klick – ${description}`)
|
||||
recordAction(`Klick - ${description}`)
|
||||
}
|
||||
}
|
||||
|
||||
function handleError(event: ErrorEvent) {
|
||||
if (!event.message && event.target && event.target !== window) {
|
||||
const element = event.target as HTMLElement & { src?: string; href?: string }
|
||||
const source = element.src || element.href || ''
|
||||
recordError(
|
||||
`Ressource konnte nicht geladen werden${source ? `: ${source}` : ''}`,
|
||||
{ element: element.tagName?.toLowerCase() },
|
||||
)
|
||||
return
|
||||
function requestUrl(input: FetchInput) {
|
||||
if (typeof input === 'string') {
|
||||
return input
|
||||
}
|
||||
|
||||
recordError(event.message || 'Unbekannter Fehler', {
|
||||
source: event.filename,
|
||||
line: event.lineno,
|
||||
column: event.colno,
|
||||
stack: truncateStack(event.error?.stack),
|
||||
})
|
||||
if (input instanceof URL) {
|
||||
return input.href
|
||||
}
|
||||
|
||||
return input.url
|
||||
}
|
||||
|
||||
function handleRejection(event: PromiseRejectionEvent) {
|
||||
const reason = event.reason
|
||||
const message = reason instanceof Error ? reason.message : String(reason)
|
||||
function requestMethod(input: FetchInput, init: FetchInit) {
|
||||
if (init?.method) {
|
||||
return init.method.toUpperCase()
|
||||
}
|
||||
|
||||
recordError(`Unbehandelte Ablehnung: ${message}`, {
|
||||
stack: reason instanceof Error ? truncateStack(reason.stack) : undefined,
|
||||
})
|
||||
if (typeof input !== 'string' && !(input instanceof URL)) {
|
||||
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 {
|
||||
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 {
|
||||
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
|
||||
|
||||
document.addEventListener('click', handleClick, { capture: true })
|
||||
window.addEventListener('error', handleError, true)
|
||||
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)
|
||||
}
|
||||
initApiErrorLogging()
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user