This commit is contained in:
Linrador 2026-07-03 16:56:20 +02:00
parent e5a88399e9
commit 182f04bdad
53 changed files with 9660 additions and 1071 deletions

View File

@ -16,15 +16,17 @@ type AdminGroupChat struct {
Image string `json:"image"` Image string `json:"image"`
OwnerID string `json:"ownerId"` OwnerID string `json:"ownerId"`
TeamID string `json:"teamId"` TeamID string `json:"teamId"`
IsPublic bool `json:"isPublic"`
UserIDs []string `json:"userIds"` UserIDs []string `json:"userIds"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
} }
type saveAdminGroupChatRequest struct { type saveAdminGroupChatRequest struct {
Name string `json:"name"` Name string `json:"name"`
Image string `json:"image"` Image string `json:"image"`
UserIDs []string `json:"userIds"` IsPublic bool `json:"isPublic"`
UserIDs []string `json:"userIds"`
} }
func (s *Server) handleAdminListGroupChats(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAdminListGroupChats(w http.ResponseWriter, r *http.Request) {
@ -37,6 +39,7 @@ func (s *Server) handleAdminListGroupChats(w http.ResponseWriter, r *http.Reques
c.channel_image, c.channel_image,
COALESCE(c.created_by::TEXT, ''), COALESCE(c.created_by::TEXT, ''),
COALESCE(c.team_id::TEXT, ''), COALESCE(c.team_id::TEXT, ''),
c.is_public,
COALESCE( COALESCE(
array_agg(cm.user_id::TEXT ORDER BY cm.created_at) array_agg(cm.user_id::TEXT ORDER BY cm.created_at)
FILTER (WHERE cm.user_id IS NOT NULL), FILTER (WHERE cm.user_id IS NOT NULL),
@ -66,6 +69,7 @@ func (s *Server) handleAdminListGroupChats(w http.ResponseWriter, r *http.Reques
&group.Image, &group.Image,
&group.OwnerID, &group.OwnerID,
&group.TeamID, &group.TeamID,
&group.IsPublic,
&group.UserIDs, &group.UserIDs,
&group.CreatedAt, &group.CreatedAt,
&group.UpdatedAt, &group.UpdatedAt,
@ -132,10 +136,11 @@ func (s *Server) handleAdminUpdateGroupChat(w http.ResponseWriter, r *http.Reque
defer tx.Rollback(r.Context()) defer tx.Rollback(r.Context())
var currentName, currentImage, ownerID string var currentName, currentImage, ownerID string
var currentIsPublic bool
if err := tx.QueryRow( if err := tx.QueryRow(
r.Context(), r.Context(),
` `
SELECT name, channel_image, COALESCE(created_by::TEXT, '') SELECT name, channel_image, COALESCE(created_by::TEXT, ''), is_public
FROM conversations FROM conversations
WHERE id = $1 WHERE id = $1
AND type = 'group' AND type = 'group'
@ -143,7 +148,7 @@ func (s *Server) handleAdminUpdateGroupChat(w http.ResponseWriter, r *http.Reque
FOR UPDATE FOR UPDATE
`, `,
conversationID, conversationID,
).Scan(&currentName, &currentImage, &ownerID); err != nil { ).Scan(&currentName, &currentImage, &ownerID, &currentIsPublic); err != nil {
if err == pgx.ErrNoRows { if err == pgx.ErrNoRows {
writeError(w, http.StatusNotFound, "Gruppenchat wurde nicht gefunden") writeError(w, http.StatusNotFound, "Gruppenchat wurde nicht gefunden")
return return
@ -173,6 +178,7 @@ func (s *Server) handleAdminUpdateGroupChat(w http.ResponseWriter, r *http.Reque
SET SET
name = $2, name = $2,
channel_image = $3, channel_image = $3,
is_public = $4,
updated_at = now() updated_at = now()
WHERE id = $1 WHERE id = $1
AND type = 'group' AND type = 'group'
@ -181,6 +187,7 @@ func (s *Server) handleAdminUpdateGroupChat(w http.ResponseWriter, r *http.Reque
conversationID, conversationID,
input.Name, input.Name,
input.Image, input.Image,
input.IsPublic,
); err != nil { ); err != nil {
writeError(w, http.StatusInternalServerError, "Gruppenchat konnte nicht aktualisiert werden") writeError(w, http.StatusInternalServerError, "Gruppenchat konnte nicht aktualisiert werden")
return return
@ -229,6 +236,7 @@ func (s *Server) handleAdminUpdateGroupChat(w http.ResponseWriter, r *http.Reque
changed := currentName != input.Name || changed := currentName != input.Name ||
currentImage != input.Image || currentImage != input.Image ||
currentIsPublic != input.IsPublic ||
!sameStringSet(oldMemberIDs, newMemberIDs) !sameStringSet(oldMemberIDs, newMemberIDs)
if changed { if changed {
s.emitSystemMessage( s.emitSystemMessage(

View File

@ -43,6 +43,12 @@ type milestoneSettingsResponse struct {
ServerFoldersArchiveRootLabel string `json:"serverFoldersArchiveRootLabel"` ServerFoldersArchiveRootLabel string `json:"serverFoldersArchiveRootLabel"`
ServerFoldersArchiveRootPath string `json:"serverFoldersArchiveRootPath"` ServerFoldersArchiveRootPath string `json:"serverFoldersArchiveRootPath"`
BackupNasHost string `json:"backupNasHost"`
BackupNasShare string `json:"backupNasShare"`
BackupNasUsername string `json:"backupNasUsername"`
BackupNasPasswordConfigured bool `json:"backupNasPasswordConfigured"`
BackupLogRootPath string `json:"backupLogRootPath"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"` UpdatedAt *time.Time `json:"updatedAt,omitempty"`
} }
@ -58,6 +64,11 @@ type updateMilestoneSettingsRequest struct {
ServerFoldersStorageRootPath string `json:"serverFoldersStorageRootPath"` ServerFoldersStorageRootPath string `json:"serverFoldersStorageRootPath"`
ServerFoldersArchiveRootLabel string `json:"serverFoldersArchiveRootLabel"` ServerFoldersArchiveRootLabel string `json:"serverFoldersArchiveRootLabel"`
ServerFoldersArchiveRootPath string `json:"serverFoldersArchiveRootPath"` ServerFoldersArchiveRootPath string `json:"serverFoldersArchiveRootPath"`
BackupNasHost string `json:"backupNasHost"`
BackupNasShare string `json:"backupNasShare"`
BackupNasUsername string `json:"backupNasUsername"`
BackupNasPassword string `json:"backupNasPassword"`
BackupLogRootPath string `json:"backupLogRootPath"`
} }
type milestoneCredentials struct { type milestoneCredentials struct {
@ -90,6 +101,11 @@ func (s *Server) handleGetMilestoneSettings(w http.ResponseWriter, r *http.Reque
ServerFoldersStorageRootPath: "D:/Milestone-Speicher", ServerFoldersStorageRootPath: "D:/Milestone-Speicher",
ServerFoldersArchiveRootLabel: "Archive E", ServerFoldersArchiveRootLabel: "Archive E",
ServerFoldersArchiveRootPath: "E:/", ServerFoldersArchiveRootPath: "E:/",
BackupNasHost: "",
BackupNasShare: "",
BackupNasUsername: "",
BackupNasPasswordConfigured: false,
BackupLogRootPath: "",
}, },
}) })
return return
@ -125,6 +141,11 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re
serverFoldersStorageRootPath := normalizeServerFoldersRootPath(input.ServerFoldersStorageRootPath, "D:/Milestone-Speicher") serverFoldersStorageRootPath := normalizeServerFoldersRootPath(input.ServerFoldersStorageRootPath, "D:/Milestone-Speicher")
serverFoldersArchiveRootLabel := normalizeServerFoldersRootLabel(input.ServerFoldersArchiveRootLabel, "Archive E") serverFoldersArchiveRootLabel := normalizeServerFoldersRootLabel(input.ServerFoldersArchiveRootLabel, "Archive E")
serverFoldersArchiveRootPath := normalizeServerFoldersRootPath(input.ServerFoldersArchiveRootPath, "E:/") serverFoldersArchiveRootPath := normalizeServerFoldersRootPath(input.ServerFoldersArchiveRootPath, "E:/")
backupNasHost := normalizeBackupNasHost(input.BackupNasHost)
backupNasShare := normalizeBackupNasShare(input.BackupNasShare)
backupNasUsername := strings.TrimSpace(input.BackupNasUsername)
backupNasPassword := strings.TrimSpace(input.BackupNasPassword)
backupLogRootPath := strings.TrimSpace(input.BackupLogRootPath)
if host == "" { if host == "" {
writeError(w, http.StatusBadRequest, "IP-Adresse oder Hostname ist erforderlich") writeError(w, http.StatusBadRequest, "IP-Adresse oder Hostname ist erforderlich")
@ -173,6 +194,7 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re
server_folders_storage_root_path, server_folders_storage_root_path,
server_folders_archive_root_label, server_folders_archive_root_label,
server_folders_archive_root_path, server_folders_archive_root_path,
backup_log_root_path,
server_folders_agent_token_encrypted server_folders_agent_token_encrypted
) )
VALUES ( VALUES (
@ -191,6 +213,7 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re
$9, $9,
$10, $10,
$11, $11,
$12,
NULL NULL
) )
ON CONFLICT (id) ON CONFLICT (id)
@ -209,6 +232,7 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re
server_folders_storage_root_path = EXCLUDED.server_folders_storage_root_path, server_folders_storage_root_path = EXCLUDED.server_folders_storage_root_path,
server_folders_archive_root_label = EXCLUDED.server_folders_archive_root_label, server_folders_archive_root_label = EXCLUDED.server_folders_archive_root_label,
server_folders_archive_root_path = EXCLUDED.server_folders_archive_root_path, server_folders_archive_root_path = EXCLUDED.server_folders_archive_root_path,
backup_log_root_path = EXCLUDED.backup_log_root_path,
updated_at = now() updated_at = now()
`, `,
host, host,
@ -222,6 +246,7 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re
serverFoldersStorageRootPath, serverFoldersStorageRootPath,
serverFoldersArchiveRootLabel, serverFoldersArchiveRootLabel,
serverFoldersArchiveRootPath, serverFoldersArchiveRootPath,
backupLogRootPath,
) )
} else { } else {
_, err = s.db.Exec( _, err = s.db.Exec(
@ -242,6 +267,7 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re
server_folders_storage_root_path = $7, server_folders_storage_root_path = $7,
server_folders_archive_root_label = $8, server_folders_archive_root_label = $8,
server_folders_archive_root_path = $9, server_folders_archive_root_path = $9,
backup_log_root_path = $10,
updated_at = now() updated_at = now()
WHERE id = 1 WHERE id = 1
`, `,
@ -254,6 +280,7 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re
serverFoldersStorageRootPath, serverFoldersStorageRootPath,
serverFoldersArchiveRootLabel, serverFoldersArchiveRootLabel,
serverFoldersArchiveRootPath, serverFoldersArchiveRootPath,
backupLogRootPath,
) )
} }
@ -262,6 +289,35 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re
return return
} }
_, err = s.db.Exec(
r.Context(),
`
UPDATE milestone_settings
SET
backup_nas_host = $1,
backup_nas_share = $2,
backup_nas_username = $3,
backup_nas_password_encrypted = CASE
WHEN $1 = '' AND $2 = '' AND $3 = '' AND $4 = '' THEN NULL
WHEN $4 <> '' THEN pgp_sym_encrypt($4, $5)
ELSE backup_nas_password_encrypted
END,
backup_log_root_path = $6,
updated_at = now()
WHERE id = 1
`,
backupNasHost,
backupNasShare,
backupNasUsername,
backupNasPassword,
encryptionKey,
backupLogRootPath,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Backup-Einstellungen konnten nicht gespeichert werden")
return
}
rotationWarning := s.rotateServerFoldersAgentTokenAfterSave(r.Context()) rotationWarning := s.rotateServerFoldersAgentTokenAfterSave(r.Context())
if r.URL.Query().Get("sync") == "false" { if r.URL.Query().Get("sync") == "false" {
@ -474,6 +530,11 @@ func (s *Server) getMilestoneSettings(ctx context.Context) (milestoneSettingsRes
COALESCE(server_folders_storage_root_path, 'D:/Milestone-Speicher'), COALESCE(server_folders_storage_root_path, 'D:/Milestone-Speicher'),
COALESCE(server_folders_archive_root_label, 'Archive E'), COALESCE(server_folders_archive_root_label, 'Archive E'),
COALESCE(server_folders_archive_root_path, 'E:/'), COALESCE(server_folders_archive_root_path, 'E:/'),
COALESCE(backup_nas_host, ''),
COALESCE(backup_nas_share, ''),
COALESCE(backup_nas_username, ''),
backup_nas_password_encrypted IS NOT NULL,
COALESCE(backup_log_root_path, ''),
updated_at updated_at
FROM milestone_settings FROM milestone_settings
WHERE id = 1 WHERE id = 1
@ -499,6 +560,11 @@ func (s *Server) getMilestoneSettings(ctx context.Context) (milestoneSettingsRes
&settings.ServerFoldersStorageRootPath, &settings.ServerFoldersStorageRootPath,
&settings.ServerFoldersArchiveRootLabel, &settings.ServerFoldersArchiveRootLabel,
&settings.ServerFoldersArchiveRootPath, &settings.ServerFoldersArchiveRootPath,
&settings.BackupNasHost,
&settings.BackupNasShare,
&settings.BackupNasUsername,
&settings.BackupNasPasswordConfigured,
&settings.BackupLogRootPath,
&updatedAt, &updatedAt,
) )
@ -659,6 +725,31 @@ func normalizeServerFoldersRootPath(value string, fallback string) string {
return strings.ReplaceAll(value, `\`, "/") return strings.ReplaceAll(value, `\`, "/")
} }
func normalizeBackupNasHost(value string) string {
value = strings.TrimSpace(value)
value = strings.TrimPrefix(value, `\\`)
value = strings.TrimPrefix(value, "//")
value = strings.TrimPrefix(value, "smb://")
value = strings.Trim(value, `/\`)
if slashIndex := strings.IndexAny(value, `/\`); slashIndex >= 0 {
value = value[:slashIndex]
}
return strings.TrimSpace(value)
}
func normalizeBackupNasShare(value string) string {
value = strings.TrimSpace(value)
value = strings.Trim(value, `/\`)
if slashIndex := strings.IndexAny(value, `/\`); slashIndex >= 0 {
value = value[:slashIndex]
}
return strings.TrimSpace(value)
}
func normalizeMilestoneHost(value string) string { func normalizeMilestoneHost(value string) string {
value = strings.TrimSpace(value) value = strings.TrimSpace(value)
value = strings.TrimRight(value, "/") value = strings.TrimRight(value, "/")

View File

@ -43,10 +43,22 @@ type ChatMessage struct {
ForwardedFrom *ChatMessageReference `json:"forwardedFrom"` ForwardedFrom *ChatMessageReference `json:"forwardedFrom"`
Location *ChatLocation `json:"location"` Location *ChatLocation `json:"location"`
LinkPreview *ChatLinkPreview `json:"linkPreview"` LinkPreview *ChatLinkPreview `json:"linkPreview"`
Reactions []ChatReaction `json:"reactions"`
replyToMessageID string replyToMessageID string
forwardedFromMessageID string forwardedFromMessageID string
} }
type ChatReaction struct {
Emoji string `json:"emoji"`
Users []ChatReactionUser `json:"users"`
Count int `json:"count"`
}
type ChatReactionUser struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
}
type ChatLocation struct { type ChatLocation struct {
Lat float64 `json:"lat"` Lat float64 `json:"lat"`
Lng float64 `json:"lng"` Lng float64 `json:"lng"`
@ -83,6 +95,8 @@ type ChatConversation struct {
Image string `json:"image"` Image string `json:"image"`
TeamID string `json:"teamId"` TeamID string `json:"teamId"`
OwnerID string `json:"ownerId"` OwnerID string `json:"ownerId"`
IsPublic bool `json:"isPublic"`
IsMember bool `json:"isMember"`
DisappearingSeconds int `json:"disappearingSeconds"` DisappearingSeconds int `json:"disappearingSeconds"`
Participants []ChatUser `json:"participants"` Participants []ChatUser `json:"participants"`
LastMessage *ChatMessage `json:"lastMessage"` LastMessage *ChatMessage `json:"lastMessage"`
@ -112,6 +126,10 @@ type createChatMessageInput struct {
Location *ChatLocation Location *ChatLocation
} }
type updateChatReactionRequest struct {
Emoji string `json:"emoji"`
}
var ( var (
errChatAccessDenied = errors.New("kein zugriff auf diesen chat") errChatAccessDenied = errors.New("kein zugriff auf diesen chat")
errChatMessageEmpty = errors.New("nachricht darf nicht leer sein") errChatMessageEmpty = errors.New("nachricht darf nicht leer sein")
@ -269,7 +287,20 @@ func (s *Server) handleListChatConversations(w http.ResponseWriter, r *http.Requ
c.name, c.name,
c.channel_image, c.channel_image,
COALESCE(c.team_id::TEXT, ''), COALESCE(c.team_id::TEXT, ''),
c.last_message_at, (
SELECT MAX(last_visible_message.created_at)
FROM messages last_visible_message
LEFT JOIN conversation_members last_visible_membership
ON last_visible_membership.conversation_id = c.id
AND last_visible_membership.user_id = $1
WHERE last_visible_message.conversation_id = c.id
AND last_visible_message.deleted_at IS NULL
AND (last_visible_message.expires_at IS NULL OR last_visible_message.expires_at > now())
AND last_visible_message.created_at > COALESCE(
last_visible_membership.last_cleared_at,
'-infinity'::TIMESTAMPTZ
)
),
COALESCE(( COALESCE((
SELECT cm.muted SELECT cm.muted
FROM conversation_members cm FROM conversation_members cm
@ -287,6 +318,11 @@ func (s *Server) handleListChatConversations(w http.ResponseWriter, r *http.Requ
AND unread_message.message_type <> 'system' AND unread_message.message_type <> 'system'
AND unread_message.sender_id IS DISTINCT FROM $1::UUID AND unread_message.sender_id IS DISTINCT FROM $1::UUID
AND (unread_message.expires_at IS NULL OR unread_message.expires_at > now()) AND (unread_message.expires_at IS NULL OR unread_message.expires_at > now())
AND current_membership.user_id IS NOT NULL
AND unread_message.created_at > COALESCE(
current_membership.last_cleared_at,
'-infinity'::TIMESTAMPTZ
)
AND unread_message.created_at > COALESCE( AND unread_message.created_at > COALESCE(
current_membership.last_read_at, current_membership.last_read_at,
'-infinity'::TIMESTAMPTZ '-infinity'::TIMESTAMPTZ
@ -294,13 +330,23 @@ func (s *Server) handleListChatConversations(w http.ResponseWriter, r *http.Requ
), 0), ), 0),
c.created_at, c.created_at,
COALESCE(c.created_by::TEXT, ''), COALESCE(c.created_by::TEXT, ''),
c.is_public,
EXISTS (
SELECT 1
FROM conversation_members current_user_membership
WHERE current_user_membership.conversation_id = c.id
AND current_user_membership.user_id = $1
),
c.disappearing_seconds c.disappearing_seconds
FROM conversations c FROM conversations c
WHERE EXISTS ( WHERE (
SELECT 1 EXISTS (
FROM conversation_members cm SELECT 1
WHERE cm.conversation_id = c.id FROM conversation_members cm
AND cm.user_id = $1 WHERE cm.conversation_id = c.id
AND cm.user_id = $1
)
OR c.is_public = true
) )
ORDER BY COALESCE(c.last_message_at, c.created_at) DESC ORDER BY COALESCE(c.last_message_at, c.created_at) DESC
`, `,
@ -326,6 +372,8 @@ func (s *Server) handleListChatConversations(w http.ResponseWriter, r *http.Requ
&conversation.UnreadCount, &conversation.UnreadCount,
&conversation.CreatedAt, &conversation.CreatedAt,
&conversation.OwnerID, &conversation.OwnerID,
&conversation.IsPublic,
&conversation.IsMember,
&conversation.DisappearingSeconds, &conversation.DisappearingSeconds,
); err != nil { ); err != nil {
writeError(w, http.StatusInternalServerError, "Chats konnten nicht gelesen werden") writeError(w, http.StatusInternalServerError, "Chats konnten nicht gelesen werden")
@ -339,7 +387,7 @@ func (s *Server) handleListChatConversations(w http.ResponseWriter, r *http.Requ
} }
conversation.Participants = participants conversation.Participants = participants
lastMessage, err := s.getLastChatMessage(r.Context(), conversation.ID) lastMessage, err := s.getLastChatMessage(r.Context(), conversation.ID, user.ID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) { if err != nil && !errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusInternalServerError, "Letzte Nachricht konnte nicht geladen werden") writeError(w, http.StatusInternalServerError, "Letzte Nachricht konnte nicht geladen werden")
return return
@ -455,7 +503,7 @@ func (s *Server) handleListChatMessages(w http.ResponseWriter, r *http.Request)
} }
conversationID := strings.TrimSpace(r.PathValue("id")) conversationID := strings.TrimSpace(r.PathValue("id"))
if !s.canAccessConversation(r.Context(), conversationID, user.ID) { if !s.isConversationMember(r.Context(), conversationID, user.ID) {
writeError(w, http.StatusForbidden, "Kein Zugriff auf diesen Chat") writeError(w, http.StatusForbidden, "Kein Zugriff auf diesen Chat")
return return
} }
@ -516,13 +564,21 @@ func (s *Server) handleListChatMessages(w http.ResponseWriter, r *http.Request)
FROM messages m FROM messages m
JOIN conversations c ON c.id = m.conversation_id JOIN conversations c ON c.id = m.conversation_id
LEFT JOIN users u ON u.id = m.sender_id LEFT JOIN users u ON u.id = m.sender_id
LEFT JOIN conversation_members viewer_membership
ON viewer_membership.conversation_id = m.conversation_id
AND viewer_membership.user_id = $2
WHERE m.conversation_id = $1 WHERE m.conversation_id = $1
AND m.deleted_at IS NULL AND m.deleted_at IS NULL
AND (m.expires_at IS NULL OR m.expires_at > now()) AND (m.expires_at IS NULL OR m.expires_at > now())
AND m.created_at > COALESCE(
viewer_membership.last_cleared_at,
'-infinity'::TIMESTAMPTZ
)
ORDER BY m.created_at ASC, m.id ASC ORDER BY m.created_at ASC, m.id ASC
LIMIT 200 LIMIT 200
`, `,
conversationID, conversationID,
user.ID,
) )
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "Nachrichten konnten nicht geladen werden") writeError(w, http.StatusInternalServerError, "Nachrichten konnten nicht geladen werden")
@ -599,6 +655,142 @@ func (s *Server) handleCreateChatMessage(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusCreated, map[string]any{"message": message}) writeJSON(w, http.StatusCreated, map[string]any{"message": message})
} }
func normalizeChatReactionEmoji(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
runes := []rune(value)
if len(runes) > 8 {
return ""
}
return value
}
func (s *Server) handleToggleChatMessageReaction(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
messageID := strings.TrimSpace(r.PathValue("id"))
var input updateChatReactionRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
emoji := normalizeChatReactionEmoji(input.Emoji)
if emoji == "" {
writeError(w, http.StatusBadRequest, "Emoji ist erforderlich")
return
}
var conversationID string
err := s.db.QueryRow(
r.Context(),
`
SELECT conversation_id::TEXT
FROM messages
WHERE id = $1
AND deleted_at IS NULL
AND message_type <> 'system'
AND (expires_at IS NULL OR expires_at > now())
`,
messageID,
).Scan(&conversationID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "Nachricht wurde nicht gefunden")
return
}
writeError(w, http.StatusInternalServerError, "Nachricht konnte nicht geladen werden")
return
}
if !s.isConversationMember(r.Context(), conversationID, user.ID) {
writeError(w, http.StatusForbidden, "Kein Zugriff auf diesen Chat")
return
}
tx, err := s.db.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "Reaktion konnte nicht gespeichert werden")
return
}
defer tx.Rollback(r.Context())
var existingEmoji string
err = tx.QueryRow(
r.Context(),
`
SELECT emoji
FROM message_reactions
WHERE message_id = $1
AND user_id = $2
ORDER BY created_at DESC
LIMIT 1
`,
messageID,
user.ID,
).Scan(&existingEmoji)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusInternalServerError, "Reaktion konnte nicht geladen werden")
return
}
if _, err := tx.Exec(
r.Context(),
`
DELETE FROM message_reactions
WHERE message_id = $1
AND user_id = $2
`,
messageID,
user.ID,
); err != nil {
writeError(w, http.StatusInternalServerError, "Reaktion konnte nicht entfernt werden")
return
}
if existingEmoji != emoji {
if _, err := tx.Exec(
r.Context(),
`
INSERT INTO message_reactions (message_id, user_id, emoji)
VALUES ($1, $2, $3)
ON CONFLICT (message_id, user_id) DO UPDATE
SET emoji = EXCLUDED.emoji,
created_at = now()
`,
messageID,
user.ID,
emoji,
); err != nil {
writeError(w, http.StatusInternalServerError, "Reaktion konnte nicht gespeichert werden")
return
}
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "Reaktion konnte nicht gespeichert werden")
return
}
message, err := s.getChatMessage(r.Context(), messageID)
if err != nil {
writeError(w, http.StatusInternalServerError, "Nachricht konnte nicht aktualisiert werden")
return
}
s.publishChatMessageUpdate(r.Context(), message)
writeJSON(w, http.StatusOK, map[string]any{"message": message})
}
func (s *Server) createChatMessage( func (s *Server) createChatMessage(
ctx context.Context, ctx context.Context,
user User, user User,
@ -614,6 +806,9 @@ func (s *Server) createChatMessage(
if !s.isConversationWritable(ctx, conversationID) { if !s.isConversationWritable(ctx, conversationID) {
return message, errChatReadOnly return message, errChatReadOnly
} }
if !s.isConversationMember(ctx, conversationID, user.ID) {
return message, errChatAccessDenied
}
if replyToMessageID := strings.TrimSpace(input.ReplyToMessageID); replyToMessageID != "" { if replyToMessageID := strings.TrimSpace(input.ReplyToMessageID); replyToMessageID != "" {
var valid bool var valid bool
@ -857,11 +1052,14 @@ func (s *Server) canAccessConversation(ctx context.Context, conversationID strin
SELECT 1 SELECT 1
FROM conversations c FROM conversations c
WHERE c.id = $1 WHERE c.id = $1
AND EXISTS ( AND (
SELECT 1 EXISTS (
FROM conversation_members cm SELECT 1
WHERE cm.conversation_id = c.id FROM conversation_members cm
AND cm.user_id = $2 WHERE cm.conversation_id = c.id
AND cm.user_id = $2
)
OR c.is_public = true
) )
) )
`, `,
@ -872,6 +1070,55 @@ func (s *Server) canAccessConversation(ctx context.Context, conversationID strin
return err == nil && allowed return err == nil && allowed
} }
func (s *Server) isConversationMember(ctx context.Context, conversationID string, userID string) bool {
if conversationID == "" || userID == "" {
return false
}
var member bool
err := s.db.QueryRow(
ctx,
`
SELECT EXISTS(
SELECT 1
FROM conversation_members cm
WHERE cm.conversation_id = $1
AND cm.user_id = $2
)
`,
conversationID,
userID,
).Scan(&member)
return err == nil && member
}
func (s *Server) canPostToConversation(ctx context.Context, conversationID string, userID string) bool {
if conversationID == "" || userID == "" {
return false
}
var allowed bool
err := s.db.QueryRow(
ctx,
`
SELECT EXISTS(
SELECT 1
FROM conversations c
JOIN conversation_members cm
ON cm.conversation_id = c.id
AND cm.user_id = $2
WHERE c.id = $1
AND c.type <> 'channel'
)
`,
conversationID,
userID,
).Scan(&allowed)
return err == nil && allowed
}
func (s *Server) isConversationWritable(ctx context.Context, conversationID string) bool { func (s *Server) isConversationWritable(ctx context.Context, conversationID string) bool {
var writable bool var writable bool
err := s.db.QueryRow( err := s.db.QueryRow(
@ -988,7 +1235,20 @@ func (s *Server) getChatConversation(ctx context.Context, conversationID string,
c.name, c.name,
c.channel_image, c.channel_image,
COALESCE(c.team_id::TEXT, ''), COALESCE(c.team_id::TEXT, ''),
c.last_message_at, (
SELECT MAX(last_visible_message.created_at)
FROM messages last_visible_message
LEFT JOIN conversation_members last_visible_membership
ON last_visible_membership.conversation_id = c.id
AND last_visible_membership.user_id = $2
WHERE last_visible_message.conversation_id = c.id
AND last_visible_message.deleted_at IS NULL
AND (last_visible_message.expires_at IS NULL OR last_visible_message.expires_at > now())
AND last_visible_message.created_at > COALESCE(
last_visible_membership.last_cleared_at,
'-infinity'::TIMESTAMPTZ
)
),
COALESCE(( COALESCE((
SELECT cm.muted SELECT cm.muted
FROM conversation_members cm FROM conversation_members cm
@ -1006,6 +1266,11 @@ func (s *Server) getChatConversation(ctx context.Context, conversationID string,
AND unread_message.message_type <> 'system' AND unread_message.message_type <> 'system'
AND unread_message.sender_id IS DISTINCT FROM $2::UUID AND unread_message.sender_id IS DISTINCT FROM $2::UUID
AND (unread_message.expires_at IS NULL OR unread_message.expires_at > now()) AND (unread_message.expires_at IS NULL OR unread_message.expires_at > now())
AND current_membership.user_id IS NOT NULL
AND unread_message.created_at > COALESCE(
current_membership.last_cleared_at,
'-infinity'::TIMESTAMPTZ
)
AND unread_message.created_at > COALESCE( AND unread_message.created_at > COALESCE(
current_membership.last_read_at, current_membership.last_read_at,
'-infinity'::TIMESTAMPTZ '-infinity'::TIMESTAMPTZ
@ -1013,6 +1278,13 @@ func (s *Server) getChatConversation(ctx context.Context, conversationID string,
), 0), ), 0),
c.created_at, c.created_at,
COALESCE(c.created_by::TEXT, ''), COALESCE(c.created_by::TEXT, ''),
c.is_public,
EXISTS (
SELECT 1
FROM conversation_members current_user_membership
WHERE current_user_membership.conversation_id = c.id
AND current_user_membership.user_id = $2
),
c.disappearing_seconds c.disappearing_seconds
FROM conversations c FROM conversations c
WHERE c.id = $1 WHERE c.id = $1
@ -1030,6 +1302,8 @@ func (s *Server) getChatConversation(ctx context.Context, conversationID string,
&conversation.UnreadCount, &conversation.UnreadCount,
&conversation.CreatedAt, &conversation.CreatedAt,
&conversation.OwnerID, &conversation.OwnerID,
&conversation.IsPublic,
&conversation.IsMember,
&conversation.DisappearingSeconds, &conversation.DisappearingSeconds,
) )
if err != nil { if err != nil {
@ -1044,7 +1318,7 @@ func (s *Server) getChatConversation(ctx context.Context, conversationID string,
return conversation, err return conversation, err
} }
lastMessage, err := s.getLastChatMessage(ctx, conversation.ID) lastMessage, err := s.getLastChatMessage(ctx, conversation.ID, userID)
if err == nil { if err == nil {
conversation.LastMessage = &lastMessage conversation.LastMessage = &lastMessage
} else if !errors.Is(err, pgx.ErrNoRows) { } else if !errors.Is(err, pgx.ErrNoRows) {
@ -1155,7 +1429,7 @@ func (s *Server) getChatMessage(ctx context.Context, messageID string) (ChatMess
return message, err return message, err
} }
func (s *Server) getLastChatMessage(ctx context.Context, conversationID string) (ChatMessage, error) { func (s *Server) getLastChatMessage(ctx context.Context, conversationID string, userID string) (ChatMessage, error) {
row := s.db.QueryRow( row := s.db.QueryRow(
ctx, ctx,
` `
@ -1212,13 +1486,21 @@ func (s *Server) getLastChatMessage(ctx context.Context, conversationID string)
FROM messages m FROM messages m
JOIN conversations c ON c.id = m.conversation_id JOIN conversations c ON c.id = m.conversation_id
LEFT JOIN users u ON u.id = m.sender_id LEFT JOIN users u ON u.id = m.sender_id
LEFT JOIN conversation_members viewer_membership
ON viewer_membership.conversation_id = m.conversation_id
AND viewer_membership.user_id = $2
WHERE m.conversation_id = $1 WHERE m.conversation_id = $1
AND m.deleted_at IS NULL AND m.deleted_at IS NULL
AND (m.expires_at IS NULL OR m.expires_at > now()) AND (m.expires_at IS NULL OR m.expires_at > now())
AND m.created_at > COALESCE(
viewer_membership.last_cleared_at,
'-infinity'::TIMESTAMPTZ
)
ORDER BY m.created_at DESC, m.id DESC ORDER BY m.created_at DESC, m.id DESC
LIMIT 1 LIMIT 1
`, `,
conversationID, conversationID,
userID,
) )
message, err := scanChatMessage(row) message, err := scanChatMessage(row)
if err != nil { if err != nil {
@ -1247,6 +1529,12 @@ func (s *Server) enrichChatMessage(ctx context.Context, message *ChatMessage) er
} }
message.LinkPreview = preview message.LinkPreview = preview
reactions, err := s.listChatReactions(ctx, message.ID)
if err != nil {
return err
}
message.Reactions = reactions
if message.replyToMessageID != "" { if message.replyToMessageID != "" {
reference, err := s.getChatMessageReference(ctx, message.replyToMessageID) reference, err := s.getChatMessageReference(ctx, message.replyToMessageID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) { if err != nil && !errors.Is(err, pgx.ErrNoRows) {
@ -1270,6 +1558,57 @@ func (s *Server) enrichChatMessage(ctx context.Context, message *ChatMessage) er
return nil return nil
} }
func (s *Server) listChatReactions(
ctx context.Context,
messageID string,
) ([]ChatReaction, error) {
rows, err := s.db.Query(
ctx,
`
SELECT
mr.emoji,
mr.user_id::TEXT,
COALESCE(NULLIF(u.display_name, ''), NULLIF(u.username, ''), u.email, '')
FROM message_reactions mr
JOIN users u ON u.id = mr.user_id
WHERE mr.message_id = $1
ORDER BY mr.created_at ASC, mr.user_id ASC
`,
messageID,
)
if err != nil {
return nil, err
}
defer rows.Close()
reactions := []ChatReaction{}
reactionByEmoji := map[string]int{}
for rows.Next() {
var emoji string
var reactionUser ChatReactionUser
if err := rows.Scan(
&emoji,
&reactionUser.ID,
&reactionUser.DisplayName,
); err != nil {
return nil, err
}
index, ok := reactionByEmoji[emoji]
if !ok {
reactionByEmoji[emoji] = len(reactions)
reactions = append(reactions, ChatReaction{Emoji: emoji})
index = len(reactions) - 1
}
reactions[index].Users = append(reactions[index].Users, reactionUser)
reactions[index].Count = len(reactions[index].Users)
}
return reactions, rows.Err()
}
func (s *Server) getChatMessageLocation( func (s *Server) getChatMessageLocation(
ctx context.Context, ctx context.Context,
messageID string, messageID string,

169
backend/chat_clear.go Normal file
View 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,
})
}

View File

@ -35,11 +35,13 @@ type createGroupRequest struct {
Name string `json:"name"` Name string `json:"name"`
Image string `json:"image"` Image string `json:"image"`
MemberIDs []string `json:"memberIds"` MemberIDs []string `json:"memberIds"`
IsPublic bool `json:"isPublic"`
} }
type updateGroupRequest struct { type updateGroupRequest struct {
Name *string `json:"name"` Name *string `json:"name"`
Image *string `json:"image"` Image *string `json:"image"`
IsPublic *bool `json:"isPublic"`
} }
type addGroupMembersRequest struct { type addGroupMembersRequest struct {
@ -94,21 +96,6 @@ func (s *Server) startDisappearingMessagesMonitor(ctx context.Context) {
} }
func (s *Server) reconcileDisappearingMessages(ctx context.Context) { func (s *Server) reconcileDisappearingMessages(ctx context.Context) {
if _, err := s.db.Exec(
ctx,
`UPDATE messages AS m
SET expires_at = m.created_at + make_interval(secs => c.disappearing_seconds)
FROM conversations AS c
WHERE m.conversation_id = c.id
AND c.disappearing_seconds > 0
AND m.message_type = 'user'
AND m.deleted_at IS NULL
AND m.expires_at IS NULL`,
); err != nil {
logError("Ablaufzeiten selbstlöschender Nachrichten konnten nicht ergänzt werden", err, nil)
return
}
s.deleteExpiredMessages(ctx) s.deleteExpiredMessages(ctx)
} }
@ -269,13 +256,14 @@ func (s *Server) handleCreateGroup(w http.ResponseWriter, r *http.Request) {
err = tx.QueryRow( err = tx.QueryRow(
r.Context(), r.Context(),
` `
INSERT INTO conversations (type, name, channel_image, created_by) INSERT INTO conversations (type, name, channel_image, created_by, is_public)
VALUES ('group', $1, $2, $3) VALUES ('group', $1, $2, $3, $4)
RETURNING id::TEXT RETURNING id::TEXT
`, `,
name, name,
image, image,
user.ID, user.ID,
input.IsPublic,
).Scan(&conversationID) ).Scan(&conversationID)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "Gruppe konnte nicht erstellt werden") writeError(w, http.StatusInternalServerError, "Gruppe konnte nicht erstellt werden")
@ -317,7 +305,7 @@ func (s *Server) handleCreateGroup(w http.ResponseWriter, r *http.Request) {
conversation, err := s.getChatConversation(r.Context(), conversationID, user.ID, user.ShowLastSeen) conversation, err := s.getChatConversation(r.Context(), conversationID, user.ID, user.ShowLastSeen)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "Gruppe konnte nicht geladen werden") writeError(w, http.StatusInternalServerError, "Chat konnte nicht geladen werden")
return return
} }
@ -339,26 +327,38 @@ func (s *Server) handleUpdateGroup(w http.ResponseWriter, r *http.Request) {
return return
} }
var currentName, currentImage, ownerID, teamID string var conversationType, currentName, currentImage, ownerID, teamID string
var currentIsPublic bool
if err := s.db.QueryRow( if err := s.db.QueryRow(
r.Context(), r.Context(),
` `
SELECT SELECT
type,
name, name,
channel_image, channel_image,
COALESCE(created_by::TEXT, ''), COALESCE(created_by::TEXT, ''),
COALESCE(team_id::TEXT, '') COALESCE(team_id::TEXT, ''),
is_public
FROM conversations FROM conversations
WHERE id = $1 WHERE id = $1
AND type = 'group' AND type IN ('direct', 'group')
`, `,
conversationID, conversationID,
).Scan(&currentName, &currentImage, &ownerID, &teamID); err != nil { ).Scan(&conversationType, &currentName, &currentImage, &ownerID, &teamID, &currentIsPublic); err != nil {
writeError(w, http.StatusNotFound, "Gruppe wurde nicht gefunden") writeError(w, http.StatusNotFound, "Chat wurde nicht gefunden")
return return
} }
if teamID != "" { if conversationType == "direct" {
if !s.isConversationMember(r.Context(), conversationID, user.ID) {
writeError(w, http.StatusForbidden, "Nur Mitglieder dürfen diesen Chat verwalten")
return
}
if input.Name != nil || input.Image != nil {
writeError(w, http.StatusBadRequest, "Name und Bild können bei Einzelchats nicht geändert werden")
return
}
} else if teamID != "" {
if !canManageTeamGroup(user) { if !canManageTeamGroup(user) {
writeError(w, http.StatusForbidden, "Keine Berechtigung zum Verwalten des Team-Gruppenchats") writeError(w, http.StatusForbidden, "Keine Berechtigung zum Verwalten des Team-Gruppenchats")
return return
@ -430,13 +430,45 @@ func (s *Server) handleUpdateGroup(w http.ResponseWriter, r *http.Request) {
} }
} }
if input.IsPublic != nil && *input.IsPublic != currentIsPublic {
if _, err := s.db.Exec(
r.Context(),
`UPDATE conversations SET is_public = $2, updated_at = now() WHERE id = $1`,
conversationID,
*input.IsPublic,
); err != nil {
writeError(w, http.StatusInternalServerError, "Chat konnte nicht aktualisiert werden")
return
}
event := "conversation_private"
target := "den Chat"
if conversationType == "group" {
target = "die Gruppe"
}
body := fmt.Sprintf("%s hat %s auf privat gestellt.", conversationActorName(user), target)
if *input.IsPublic {
event = "conversation_public"
body = fmt.Sprintf("%s hat %s öffentlich gemacht.", conversationActorName(user), target)
}
s.emitSystemMessage(
r.Context(),
conversationID,
user.ID,
event,
body,
map[string]any{"isPublic": *input.IsPublic},
)
changed = true
}
if changed { if changed {
s.publishConversationUpdate(r.Context(), conversationID) s.publishConversationUpdate(r.Context(), conversationID)
} }
conversation, err := s.getChatConversation(r.Context(), conversationID, user.ID, user.ShowLastSeen) conversation, err := s.getChatConversation(r.Context(), conversationID, user.ID, user.ShowLastSeen)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "Gruppe konnte nicht geladen werden") writeError(w, http.StatusInternalServerError, "Chat konnte nicht geladen werden")
return return
} }
@ -766,48 +798,6 @@ func (s *Server) handleUpdateDisappearing(w http.ResponseWriter, r *http.Request
return return
} }
if input.Seconds == 0 {
if _, err := tx.Exec(
r.Context(),
`UPDATE messages
SET expires_at = NULL
WHERE conversation_id = $1
AND message_type = 'user'
AND deleted_at IS NULL`,
conversationID,
); err != nil {
writeError(w, http.StatusInternalServerError, "Einstellung konnte nicht gespeichert werden")
return
}
} else {
if _, err := tx.Exec(
r.Context(),
`UPDATE messages
SET expires_at = created_at + make_interval(secs => $2)
WHERE conversation_id = $1
AND message_type = 'user'
AND deleted_at IS NULL`,
conversationID,
input.Seconds,
); err != nil {
writeError(w, http.StatusInternalServerError, "Einstellung konnte nicht gespeichert werden")
return
}
if _, err := tx.Exec(
r.Context(),
`DELETE FROM messages
WHERE conversation_id = $1
AND message_type = 'user'
AND deleted_at IS NULL
AND expires_at <= now()`,
conversationID,
); err != nil {
writeError(w, http.StatusInternalServerError, "Einstellung konnte nicht gespeichert werden")
return
}
}
if err := tx.Commit(r.Context()); err != nil { if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "Einstellung konnte nicht gespeichert werden") writeError(w, http.StatusInternalServerError, "Einstellung konnte nicht gespeichert werden")
return return

View File

@ -21,18 +21,8 @@ func (s *Server) handleUpdateConversationMute(w http.ResponseWriter, r *http.Req
writeError(w, http.StatusForbidden, "Kein Zugriff auf diesen Chat") writeError(w, http.StatusForbidden, "Kein Zugriff auf diesen Chat")
return return
} }
if !s.isConversationMember(r.Context(), conversationID, user.ID) {
var conversationType string writeError(w, http.StatusForbidden, "Nur Mitglieder können diesen Chat stummschalten")
if err := s.db.QueryRow(
r.Context(),
`SELECT type FROM conversations WHERE id = $1`,
conversationID,
).Scan(&conversationType); err != nil {
writeError(w, http.StatusNotFound, "Chat wurde nicht gefunden")
return
}
if conversationType == "direct" {
writeError(w, http.StatusBadRequest, "Einzelchats können nicht stummgeschaltet werden")
return return
} }

View File

@ -48,15 +48,25 @@ func (s *Server) handleSearchChatMessages(w http.ResponseWriter, r *http.Request
FROM messages m FROM messages m
JOIN conversations c ON c.id = m.conversation_id JOIN conversations c ON c.id = m.conversation_id
LEFT JOIN users u ON u.id = m.sender_id LEFT JOIN users u ON u.id = m.sender_id
LEFT JOIN conversation_members viewer_membership
ON viewer_membership.conversation_id = c.id
AND viewer_membership.user_id = $1
WHERE m.deleted_at IS NULL WHERE m.deleted_at IS NULL
AND m.message_type = 'user' AND m.message_type = 'user'
AND (m.expires_at IS NULL OR m.expires_at > now()) AND (m.expires_at IS NULL OR m.expires_at > now())
AND strpos(lower(m.body), lower($2)) > 0 AND strpos(lower(m.body), lower($2)) > 0
AND EXISTS ( AND m.created_at > COALESCE(
SELECT 1 viewer_membership.last_cleared_at,
FROM conversation_members cm '-infinity'::TIMESTAMPTZ
WHERE cm.conversation_id = c.id )
AND cm.user_id = $1 AND (
EXISTS (
SELECT 1
FROM conversation_members cm
WHERE cm.conversation_id = c.id
AND cm.user_id = $1
)
OR c.is_public = true
) )
ORDER BY m.conversation_id, m.created_at DESC, m.id DESC ORDER BY m.conversation_id, m.created_at DESC, m.id DESC
LIMIT 50 LIMIT 50

View File

@ -14,14 +14,10 @@ func (s *Server) markChatConversationRead(
_, err := s.db.Exec( _, err := s.db.Exec(
ctx, ctx,
` `
INSERT INTO conversation_members ( UPDATE conversation_members
conversation_id, SET last_read_at = now()
user_id, WHERE conversation_id = $1
last_read_at AND user_id = $2
)
VALUES ($1, $2, now())
ON CONFLICT (conversation_id, user_id)
DO UPDATE SET last_read_at = EXCLUDED.last_read_at
`, `,
conversationID, conversationID,
userID, userID,

View File

@ -183,8 +183,7 @@ func (s *Server) readChatSocket(
switch command.Type { switch command.Type {
case "typing.start", "typing.stop": case "typing.start", "typing.stop":
if !s.canAccessConversation(ctx, command.ConversationID, user.ID) || if !s.canPostToConversation(ctx, command.ConversationID, user.ID) {
!s.isConversationWritable(ctx, command.ConversationID) {
client.send(chatSocketEvent{ client.send(chatSocketEvent{
Type: "error", Type: "error",
Error: "Dieser Chat ist schreibgeschützt", Error: "Dieser Chat ist schreibgeschützt",

View File

@ -43,6 +43,10 @@ func (s *Server) handleCreateDeviceManufacturer(w http.ResponseWriter, r *http.R
s.handleCreateDeviceLookup(w, r, "device_manufacturers", "Hersteller") s.handleCreateDeviceLookup(w, r, "device_manufacturers", "Hersteller")
} }
func (s *Server) handleDeleteDeviceManufacturer(w http.ResponseWriter, r *http.Request) {
s.handleDeleteDeviceLookup(w, r, "device_manufacturers", "Hersteller")
}
func (s *Server) handleCreateDeviceLocation(w http.ResponseWriter, r *http.Request) { func (s *Server) handleCreateDeviceLocation(w http.ResponseWriter, r *http.Request) {
s.handleCreateDeviceLookup(w, r, "device_locations", "Standort") s.handleCreateDeviceLookup(w, r, "device_locations", "Standort")
} }
@ -77,6 +81,38 @@ func (s *Server) handleCreateDeviceLookup(
}) })
} }
func (s *Server) handleDeleteDeviceLookup(
w http.ResponseWriter,
r *http.Request,
tableName string,
label string,
) {
id := strings.TrimSpace(r.PathValue("id"))
if id == "" {
writeError(w, http.StatusBadRequest, label+" ist erforderlich")
return
}
commandTag, err := s.db.Exec(
r.Context(),
fmt.Sprintf(`DELETE FROM %s WHERE id = $1`, tableName),
id,
)
if err != nil {
writeError(w, http.StatusInternalServerError, label+" konnte nicht gelöscht werden")
return
}
if commandTag.RowsAffected() == 0 {
writeError(w, http.StatusNotFound, label+" wurde nicht gefunden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"deleted": true,
})
}
func listDeviceLookupOptions( func listDeviceLookupOptions(
ctx context.Context, ctx context.Context,
db interface { db interface {

View File

@ -861,6 +861,108 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
}) })
} }
func (s *Server) handleDeleteDevice(w http.ResponseWriter, r *http.Request) {
deviceID := strings.TrimSpace(r.PathValue("id"))
if deviceID == "" {
writeError(w, http.StatusBadRequest, "Geräte-ID fehlt")
return
}
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
tx, err := s.db.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "Gerät konnte nicht gelöscht werden")
return
}
defer tx.Rollback(r.Context())
var device Device
err = tx.QueryRow(
r.Context(),
`
SELECT
id,
COALESCE(inventory_number, ''),
COALESCE(manufacturer, ''),
COALESCE(model, ''),
COALESCE(milestone_recording_server_id, ''),
COALESCE(milestone_hardware_id, ''),
COALESCE(milestone_display_name, '')
FROM devices
WHERE id = $1
FOR UPDATE
`,
deviceID,
).Scan(
&device.ID,
&device.InventoryNumber,
&device.Manufacturer,
&device.Model,
&device.MilestoneRecordingServerID,
&device.MilestoneHardwareID,
&device.MilestoneDisplayName,
)
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "Gerät wurde nicht gefunden")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "Gerät konnte nicht gelöscht werden")
return
}
if strings.TrimSpace(device.MilestoneHardwareID) != "" ||
strings.TrimSpace(device.MilestoneRecordingServerID) != "" {
writeError(w, http.StatusConflict, "Milestone-Geräte können nicht gelöscht werden")
return
}
if _, err := tx.Exec(r.Context(), `DELETE FROM devices WHERE id = $1`, device.ID); err != nil {
writeError(w, http.StatusInternalServerError, "Gerät konnte nicht gelöscht werden")
return
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "Gerät konnte nicht gelöscht werden")
return
}
if err := s.publishDeviceChangeEvent(
r.Context(),
user.ID,
device.ID,
"device.deleted",
"Gerät gelöscht",
formatText(
"Gerät %s wurde gelöscht.",
device.InventoryNumber,
),
LogFields{
"inventoryNumber": device.InventoryNumber,
"manufacturer": device.Manufacturer,
"model": device.Model,
},
); err != nil {
logError("Device-SSE-Event konnte nicht gesendet werden", err, LogFields{
"deviceId": device.ID,
"type": "device.deleted",
})
}
writeJSON(w, http.StatusOK, map[string]any{
"deletedDeviceId": device.ID,
})
}
func ensureDeviceExists(ctx context.Context, tx pgx.Tx, deviceID string) error { func ensureDeviceExists(ctx context.Context, tx pgx.Tx, deviceID string) error {
var exists bool var exists bool

View File

@ -7,14 +7,16 @@ go 1.26.2
require ( require (
github.com/SherClockHolmes/webpush-go v1.4.0 github.com/SherClockHolmes/webpush-go v1.4.0
github.com/coder/websocket v1.8.14 github.com/coder/websocket v1.8.14
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/go-ldap/ldap/v3 v3.4.13 github.com/go-ldap/ldap/v3 v3.4.13
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/hirochachacha/go-smb2 v1.1.0
github.com/jackc/pgx/v5 v5.9.2 github.com/jackc/pgx/v5 v5.9.2
golang.org/x/crypto v0.51.0 golang.org/x/crypto v0.51.0
) )
require ( require (
github.com/Azure/go-ntlmssp v0.1.0 // indirect github.com/Azure/go-ntlmssp v0.1.0 // indirect
github.com/geoffgarside/ber v1.1.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
) )

View File

@ -7,6 +7,8 @@ github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6p
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/geoffgarside/ber v1.1.0 h1:qTmFG4jJbwiSzSXoNJeHcOprVzZ8Ulde2Rrrifu5U9w=
github.com/geoffgarside/ber v1.1.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNecGT85ZCc=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ= github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
@ -17,6 +19,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hirochachacha/go-smb2 v1.1.0 h1:b6hs9qKIql9eVXAiN0M2wSFY5xnhbHAQoCwRKbaRTZI=
github.com/hirochachacha/go-smb2 v1.1.0/go.mod h1:8F1A4d5EZzrGu5R7PU163UcMRDJQl4FtcxjBfsY8TZE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@ -36,6 +40,7 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
@ -48,6 +53,7 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
@ -66,6 +72,7 @@ golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

1191
backend/milestone_backup.go Normal file

File diff suppressed because it is too large Load Diff

View File

@ -181,7 +181,7 @@ func (s *Server) handleOperationMilestoneSetup(w http.ResponseWriter, r *http.Re
Type: "step", Type: "step",
Step: "roleUser", Step: "roleUser",
Status: "current", Status: "current",
Message: "Auswertungsrolle und AD-Benutzer werden geprueft.", Message: "Einsatzrolle und Auswerter-Benutzer werden geprueft.",
}) })
targetRole, err = s.ensureOperationMilestoneRoleUser(ctx, config, operation, input) targetRole, err = s.ensureOperationMilestoneRoleUser(ctx, config, operation, input)
if err != nil { if err != nil {
@ -824,86 +824,43 @@ func (s *Server) ensureOperationMilestoneRoleUser(
return nil, nil return nil, nil
} }
roleName := operationMilestoneEvaluationRoleName(*laptopDevice, operation) operationRoleName := operationMilestoneRoleName(operation)
if roleName == "" { if operationRoleName == "" {
return nil, fmt.Errorf("Auswertungsrolle konnte aus Laptop %q nicht ermittelt werden", laptopDevice.InventoryNumber) return nil, errors.New("Einsatznummer fehlt, Milestone-Rolle konnte nicht angelegt werden")
} }
sourceRole, err := findOperationMilestoneRoleByName(ctx, config, roleName) role, err := ensureOperationMilestoneRole(ctx, config, operationRoleName)
if err != nil {
return nil, err
}
if sourceRole == nil {
operationRoleName := operationMilestoneRoleName(operation)
if operationRoleName == "" {
return nil, fmt.Errorf("Milestone-Rolle %q wurde nicht gefunden und Einsatznummer fehlt", roleName)
}
role, err := ensureOperationMilestoneRole(ctx, config, operationRoleName)
if err != nil {
return nil, fmt.Errorf("Milestone-Rolle %q wurde nicht gefunden und Rolle %q konnte nicht angelegt werden: %w", roleName, operationRoleName, err)
}
role, err = getOperationMilestoneRole(ctx, config, role.ID)
if err != nil {
return nil, err
}
if selectedUser := operationMilestoneUserFromSetupInput(input); selectedUser != nil {
roleUsers, listErr := listOperationMilestoneRoleUsers(ctx, config, role.ID)
if listErr != nil {
roleUsers = operationMilestoneObjectListFromValue(role.Data["users"])
}
if operationMilestoneObjectByID(roleUsers, milestoneItemID(selectedUser)) == nil {
if err := addOperationMilestoneRoleMembers(ctx, config, role.ID, []map[string]any{selectedUser}); err != nil {
return nil, err
}
roleUsers = appendOperationMilestoneObjectByID(roleUsers, selectedUser)
}
role.Data["users"] = roleUsers
}
return role, nil
}
targetUserName := strings.TrimSpace(laptopDevice.ComputerName)
if targetUserName == "" {
targetUserName = roleName
}
role, err := getOperationMilestoneRole(ctx, config, sourceRole.ID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
sourceUsers, err := listOperationMilestoneRoleUsers(ctx, config, sourceRole.ID) role, err = getOperationMilestoneRole(ctx, config, role.ID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
candidateUsers := sourceUsers
if hasOperationMilestoneUserSelection(input) {
allRoleUsers, err := listOperationMilestoneUsersFromRoles(ctx, config)
if err != nil {
return nil, err
}
candidateUsers = appendOperationMilestoneObjectsByID(candidateUsers, allRoleUsers...)
}
usersToAssign := selectOperationMilestoneRoleUsersForAssignment(candidateUsers, targetUserName, input) roleUsers, err := listOperationMilestoneRoleUsers(ctx, config, role.ID)
if len(usersToAssign) == 0 { if err != nil {
if selectedUser := operationMilestoneUserFromSetupInput(input); selectedUser != nil {
usersToAssign = []map[string]any{selectedUser}
} else {
return nil, fmt.Errorf("AD-Benutzer %q wurde unter /roles/%s/users nicht gefunden", targetUserName, sourceRole.ID)
}
}
roleUsers := sourceUsers
if len(roleUsers) == 0 {
roleUsers = operationMilestoneObjectListFromValue(role.Data["users"]) roleUsers = operationMilestoneObjectListFromValue(role.Data["users"])
} }
usersToAssign, targetUserName, err := s.operationMilestoneRoleUsersForLaptop(
ctx,
config,
*laptopDevice,
operation,
input,
)
if err != nil {
return nil, err
}
if len(usersToAssign) == 0 {
if targetUserName == "" {
targetUserName = operationMilestoneEvaluationRoleName(*laptopDevice, operation)
}
return nil, fmt.Errorf("AD-Benutzer %q wurde nicht gefunden", targetUserName)
}
usersToAdd := []map[string]any{} usersToAdd := []map[string]any{}
for _, targetUser := range usersToAssign { for _, targetUser := range usersToAssign {
if milestoneItemID(targetUser) == "" { if milestoneItemID(targetUser) == "" {
@ -928,6 +885,58 @@ func (s *Server) ensureOperationMilestoneRoleUser(
return role, nil return role, nil
} }
func (s *Server) operationMilestoneRoleUsersForLaptop(
ctx context.Context,
config milestoneRuntimeConfig,
laptopDevice operationMilestoneSetupDevice,
operation Operation,
input operationMilestoneSetupRequest,
) ([]map[string]any, string, error) {
if selectedUser := operationMilestoneUserFromSetupInput(input); selectedUser != nil {
return []map[string]any{selectedUser}, strings.TrimSpace(input.MilestoneUserName), nil
}
targetUserName := operationMilestoneEvaluationUserName(laptopDevice, operation)
if targetUserName != "" {
user, err := s.findOperationMilestoneActiveDirectoryUser(ctx, targetUserName)
if err != nil {
return nil, targetUserName, err
}
if user != nil {
return []map[string]any{user}, targetUserName, nil
}
}
evaluationRoleName := operationMilestoneEvaluationRoleName(laptopDevice, operation)
candidateUsers := []map[string]any{}
if evaluationRoleName != "" {
sourceRole, err := findOperationMilestoneRoleByName(ctx, config, evaluationRoleName)
if err != nil {
return nil, targetUserName, err
}
if sourceRole != nil {
sourceUsers, err := listOperationMilestoneRoleUsers(ctx, config, sourceRole.ID)
if err != nil {
return nil, targetUserName, err
}
candidateUsers = appendOperationMilestoneObjectsByID(candidateUsers, sourceUsers...)
}
}
allRoleUsers, err := listOperationMilestoneUsersFromRoles(ctx, config)
if err != nil {
return nil, targetUserName, err
}
candidateUsers = appendOperationMilestoneObjectsByID(candidateUsers, allRoleUsers...)
usersToAssign := selectOperationMilestoneRoleUsersForAssignment(candidateUsers, targetUserName, input)
if len(usersToAssign) > 0 {
return usersToAssign, targetUserName, nil
}
return nil, targetUserName, nil
}
func selectOperationMilestoneRoleUsersForAssignment( func selectOperationMilestoneRoleUsersForAssignment(
users []map[string]any, users []map[string]any,
preferredUserName string, preferredUserName string,
@ -941,6 +950,10 @@ func selectOperationMilestoneRoleUsersForAssignment(
return []map[string]any{targetUser} return []map[string]any{targetUser}
} }
if strings.TrimSpace(preferredUserName) != "" {
return nil
}
usersWithID := make([]map[string]any, 0, len(users)) usersWithID := make([]map[string]any, 0, len(users))
for _, user := range users { for _, user := range users {
if milestoneItemID(user) != "" { if milestoneItemID(user) != "" {
@ -1116,6 +1129,146 @@ func operationMilestoneEvaluationRoleName(
return "" return ""
} }
func operationMilestoneEvaluationUserName(
laptopDevice operationMilestoneSetupDevice,
operation Operation,
) string {
candidates := []string{
laptopDevice.ComputerName,
laptopDevice.InventoryNumber,
laptopDevice.Model,
operation.Laptop,
}
for _, candidate := range candidates {
match := operationMilestoneEvaluationRolePattern.FindStringSubmatch(candidate)
if len(match) == 2 {
return "auswertung" + match[1]
}
}
return ""
}
func (s *Server) findOperationMilestoneActiveDirectoryUser(
ctx context.Context,
targetUserName string,
) (map[string]any, error) {
target := normalizeOperationMilestoneAccountName(targetUserName)
if target == "" {
return nil, nil
}
credentials, err := s.getActiveDirectoryCredentials(ctx)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
if strings.TrimSpace(credentials.ServerURL) == "" ||
strings.TrimSpace(credentials.BaseDN) == "" ||
strings.TrimSpace(credentials.BindUsername) == "" ||
strings.TrimSpace(credentials.BindPassword) == "" {
return nil, nil
}
users, err := listActiveDirectoryUsers(ctx, credentials)
if err != nil {
return nil, err
}
for _, user := range users {
if operationMilestoneActiveDirectoryUserMatches(user, target) {
return operationMilestoneUserFromActiveDirectoryUser(user), nil
}
}
return nil, nil
}
func operationMilestoneActiveDirectoryUserMatches(user activeDirectoryUser, target string) bool {
for _, value := range []string{
user.ID,
user.SID,
user.Username,
user.DisplayName,
user.Email,
user.DistinguishedName,
milestoneProtectedUserQualifiedName(user.Domain, user.Username),
} {
if normalizeOperationMilestoneAccountName(value) == target {
return true
}
}
return false
}
func operationMilestoneUserFromActiveDirectoryUser(user activeDirectoryUser) map[string]any {
userID := strings.TrimSpace(user.ID)
if userID == "" {
userID = strings.TrimSpace(user.SID)
}
if userID == "" {
userID = strings.TrimSpace(user.DistinguishedName)
}
if userID == "" {
userID = strings.TrimSpace(user.Username)
}
displayName := strings.TrimSpace(user.DisplayName)
if displayName == "" {
displayName = strings.TrimSpace(user.Username)
}
if displayName == "" {
displayName = userID
}
milestoneUser := map[string]any{
"id": userID,
"name": displayName,
"displayName": displayName,
"userName": strings.TrimSpace(user.Username),
"username": strings.TrimSpace(user.Username),
"email": strings.TrimSpace(user.Email),
"relations": map[string]any{
"self": map[string]any{
"type": "users",
"id": userID,
},
},
}
if domain := strings.TrimSpace(user.Domain); domain != "" {
milestoneUser["domain"] = domain
}
if sid := strings.TrimSpace(user.SID); sid != "" {
milestoneUser["sid"] = sid
milestoneUser["objectSid"] = sid
}
if dn := strings.TrimSpace(user.DistinguishedName); dn != "" {
milestoneUser["distinguishedName"] = dn
}
return milestoneUser
}
func normalizeOperationMilestoneAccountName(value string) string {
value = strings.TrimSpace(strings.ToLower(value))
if value == "" {
return ""
}
if _, account, ok := strings.Cut(value, "\\"); ok {
value = account
}
if account, _, ok := strings.Cut(value, "@"); ok {
value = account
}
replacer := strings.NewReplacer(" ", "", "\t", "", "\n", "", "\r", "", "-", "", "_", "")
return replacer.Replace(value)
}
func (s *Server) assignOperationMilestoneCameraToRole( func (s *Server) assignOperationMilestoneCameraToRole(
ctx context.Context, ctx context.Context,
config milestoneRuntimeConfig, config milestoneRuntimeConfig,

View File

@ -36,6 +36,7 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("GET /devices", s.requireAuth(s.handleListDevices)) mux.HandleFunc("GET /devices", s.requireAuth(s.handleListDevices))
mux.HandleFunc("POST /devices", s.requireAuth(s.handleCreateDevice)) mux.HandleFunc("POST /devices", s.requireAuth(s.handleCreateDevice))
mux.HandleFunc("PUT /devices/{id}", s.requireAuth(s.handleUpdateDevice)) mux.HandleFunc("PUT /devices/{id}", s.requireAuth(s.handleUpdateDevice))
mux.HandleFunc("DELETE /devices/{id}", s.requireAuth(s.handleDeleteDevice))
mux.HandleFunc("GET /device-loan-users", s.requireAuth(s.handleListDeviceLoanUsers)) mux.HandleFunc("GET /device-loan-users", s.requireAuth(s.handleListDeviceLoanUsers))
mux.HandleFunc("GET /devices/{id}/history", s.requireAuth(s.handleListDeviceHistory)) mux.HandleFunc("GET /devices/{id}/history", s.requireAuth(s.handleListDeviceHistory))
@ -78,6 +79,10 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("GET /device-lookups", s.handleDeviceLookups) mux.HandleFunc("GET /device-lookups", s.handleDeviceLookups)
mux.HandleFunc("POST /device-manufacturers", s.handleCreateDeviceManufacturer) mux.HandleFunc("POST /device-manufacturers", s.handleCreateDeviceManufacturer)
mux.HandleFunc(
"DELETE /device-manufacturers/{id}",
s.requireAuth(s.requireRight("devices:write", s.handleDeleteDeviceManufacturer)),
)
mux.HandleFunc("POST /device-locations", s.handleCreateDeviceLocation) mux.HandleFunc("POST /device-locations", s.handleCreateDeviceLocation)
mux.HandleFunc("GET /operations", s.requireAuth(s.requireRight("operations:read", s.handleListOperations))) mux.HandleFunc("GET /operations", s.requireAuth(s.requireRight("operations:read", s.handleListOperations)))
@ -131,7 +136,9 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("POST /chat/link-preview", s.requireAuth(s.handleChatLinkPreview)) mux.HandleFunc("POST /chat/link-preview", s.requireAuth(s.handleChatLinkPreview))
mux.HandleFunc("GET /chat/conversations/{id}/messages", s.requireAuth(s.handleListChatMessages)) mux.HandleFunc("GET /chat/conversations/{id}/messages", s.requireAuth(s.handleListChatMessages))
mux.HandleFunc("POST /chat/conversations/{id}/messages", s.requireAuth(s.handleCreateChatMessage)) mux.HandleFunc("POST /chat/conversations/{id}/messages", s.requireAuth(s.handleCreateChatMessage))
mux.HandleFunc("POST /chat/messages/{id}/reactions", s.requireAuth(s.handleToggleChatMessageReaction))
mux.HandleFunc("POST /chat/conversations/{id}/read", s.requireAuth(s.handleMarkChatConversationRead)) mux.HandleFunc("POST /chat/conversations/{id}/read", s.requireAuth(s.handleMarkChatConversationRead))
mux.HandleFunc("POST /chat/conversations/{id}/clear", s.requireAuth(s.handleClearChat))
mux.HandleFunc("PUT /chat/conversations/{id}/mute", s.requireAuth(s.handleUpdateConversationMute)) mux.HandleFunc("PUT /chat/conversations/{id}/mute", s.requireAuth(s.handleUpdateConversationMute))
mux.HandleFunc("PUT /chat/conversations/{id}/disappearing", s.requireAuth(s.handleUpdateDisappearing)) mux.HandleFunc("PUT /chat/conversations/{id}/disappearing", s.requireAuth(s.handleUpdateDisappearing))
mux.HandleFunc("POST /chat/groups", s.requireAuth(s.handleCreateGroup)) mux.HandleFunc("POST /chat/groups", s.requireAuth(s.handleCreateGroup))
@ -188,6 +195,9 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("PUT /admin/milestone", s.requireAuth(s.requireRight("administration:write", s.handleUpdateMilestoneSettings))) mux.HandleFunc("PUT /admin/milestone", s.requireAuth(s.requireRight("administration:write", s.handleUpdateMilestoneSettings)))
mux.HandleFunc("POST /admin/milestone/token", s.requireAuth(s.requireRight("administration:write", s.handleFetchMilestoneToken))) mux.HandleFunc("POST /admin/milestone/token", s.requireAuth(s.requireRight("administration:write", s.handleFetchMilestoneToken)))
mux.HandleFunc("POST /admin/milestone/token/stream", s.requireAuth(s.requireRight("administration:write", s.handleFetchMilestoneTokenStream))) mux.HandleFunc("POST /admin/milestone/token/stream", s.requireAuth(s.requireRight("administration:write", s.handleFetchMilestoneTokenStream)))
mux.HandleFunc("GET /admin/milestone/backup-logs", s.requireAuth(s.requireRight("administration:read", s.handleListMilestoneBackupLogs)))
mux.HandleFunc("GET /admin/milestone/backup-folders", s.requireAuth(s.requireRight("administration:read", s.handleListMilestoneBackupFolders)))
mux.HandleFunc("POST /admin/milestone/backup-folders/browse", s.requireAuth(s.requireRight("administration:write", s.handleBrowseMilestoneBackupFolders)))
mux.HandleFunc("GET /admin/milestone/basic-users", s.requireAuth(s.requireRight("administration:read", s.handleAdminListMilestoneBasicUsers))) mux.HandleFunc("GET /admin/milestone/basic-users", s.requireAuth(s.requireRight("administration:read", s.handleAdminListMilestoneBasicUsers)))
mux.HandleFunc("GET /admin/milestone/roles", s.requireAuth(s.requireRight("administration:read", s.handleAdminListMilestoneRoles))) mux.HandleFunc("GET /admin/milestone/roles", s.requireAuth(s.requireRight("administration:read", s.handleAdminListMilestoneRoles)))
mux.HandleFunc("POST /admin/milestone/roles", s.requireAuth(s.requireRight("administration:write", s.handleAdminCreateMilestoneRole))) mux.HandleFunc("POST /admin/milestone/roles", s.requireAuth(s.requireRight("administration:write", s.handleAdminCreateMilestoneRole)))

View File

@ -15,6 +15,38 @@ func ensureRuntimeDeviceSchema(ctx context.Context, db *pgxpool.Pool) error {
`ALTER TABLE IF EXISTS devices ADD COLUMN IF NOT EXISTS loaned_to_user_id UUID REFERENCES users(id) ON DELETE SET NULL`, `ALTER TABLE IF EXISTS devices ADD COLUMN IF NOT EXISTS loaned_to_user_id UUID REFERENCES users(id) ON DELETE SET NULL`,
`ALTER TABLE IF EXISTS devices ADD COLUMN IF NOT EXISTS loaned_until DATE`, `ALTER TABLE IF EXISTS devices ADD COLUMN IF NOT EXISTS loaned_until DATE`,
`ALTER TABLE IF EXISTS operations ADD COLUMN IF NOT EXISTS completed_at TIMESTAMPTZ`, `ALTER TABLE IF EXISTS operations ADD COLUMN IF NOT EXISTS completed_at TIMESTAMPTZ`,
`ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS is_public BOOLEAN NOT NULL DEFAULT false`,
`ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS disappearing_seconds INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE IF EXISTS conversation_members ADD COLUMN IF NOT EXISTS muted BOOLEAN NOT NULL DEFAULT false`,
`ALTER TABLE IF EXISTS conversation_members ADD COLUMN IF NOT EXISTS last_cleared_at TIMESTAMPTZ`,
`
CREATE TABLE IF NOT EXISTS message_reactions (
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
emoji TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (message_id, user_id)
)
`,
`
DELETE FROM message_reactions mr
USING (
SELECT ctid
FROM (
SELECT ctid,
ROW_NUMBER() OVER (
PARTITION BY message_id, user_id
ORDER BY created_at DESC, emoji ASC
) AS row_number
FROM message_reactions
) ranked_reactions
WHERE row_number > 1
) duplicate_reactions
WHERE mr.ctid = duplicate_reactions.ctid
`,
`CREATE INDEX IF NOT EXISTS idx_message_reactions_message_id ON message_reactions(message_id)`,
`CREATE INDEX IF NOT EXISTS idx_message_reactions_user_id ON message_reactions(user_id)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_message_reactions_message_user_unique ON message_reactions(message_id, user_id)`,
` `
CREATE TABLE IF NOT EXISTS device_relations ( CREATE TABLE IF NOT EXISTS device_relations (
device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE, device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
@ -47,6 +79,11 @@ func ensureRuntimeDeviceSchema(ctx context.Context, db *pgxpool.Pool) error {
`ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS server_folders_storage_root_path TEXT NOT NULL DEFAULT 'D:/Milestone-Speicher'`, `ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS server_folders_storage_root_path TEXT NOT NULL DEFAULT 'D:/Milestone-Speicher'`,
`ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS server_folders_archive_root_label TEXT NOT NULL DEFAULT 'Archive E'`, `ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS server_folders_archive_root_label TEXT NOT NULL DEFAULT 'Archive E'`,
`ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS server_folders_archive_root_path TEXT NOT NULL DEFAULT 'E:/'`, `ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS server_folders_archive_root_path TEXT NOT NULL DEFAULT 'E:/'`,
`ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS backup_nas_host TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS backup_nas_share TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS backup_nas_username TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS backup_nas_password_encrypted BYTEA`,
`ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS backup_log_root_path TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS active_directory_server_url TEXT NOT NULL DEFAULT ''`, `ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS active_directory_server_url TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS active_directory_base_dn TEXT NOT NULL DEFAULT ''`, `ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS active_directory_base_dn TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS active_directory_bind_username TEXT NOT NULL DEFAULT ''`, `ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS active_directory_bind_username TEXT NOT NULL DEFAULT ''`,

View File

@ -66,7 +66,7 @@ func (s *Server) handleGlobalSearch(w http.ResponseWriter, r *http.Request) {
return return
} }
groups := make([]globalSearchGroup, 0, 5) groups := make([]globalSearchGroup, 0, 6)
if userCanSearch(user, "operations:read") { if userCanSearch(user, "operations:read") {
items, err := s.searchOperations(r.Context(), options) items, err := s.searchOperations(r.Context(), options)
@ -148,6 +148,20 @@ func (s *Server) handleGlobalSearch(w http.ResponseWriter, r *http.Request) {
} }
} }
items, err := s.searchPublicChatMessages(r.Context(), options)
if err != nil {
writeError(w, http.StatusInternalServerError, "Öffentliche Chats konnten nicht durchsucht werden")
return
}
if len(items) > 0 {
groups = append(groups, globalSearchGroup{
ID: "public-chat-messages",
Title: "Öffentliche Chats",
Items: items,
})
}
writeJSON(w, http.StatusOK, globalSearchResponse{ writeJSON(w, http.StatusOK, globalSearchResponse{
Query: query, Query: query,
Groups: groups, Groups: groups,
@ -677,6 +691,106 @@ func (s *Server) searchUsers(ctx context.Context, options globalSearchOptions) (
return items, rows.Err() return items, rows.Err()
} }
func (s *Server) searchPublicChatMessages(ctx context.Context, options globalSearchOptions) ([]globalSearchItem, error) {
patterns := buildSearchPatterns(options)
rows, err := s.db.Query(
ctx,
`
SELECT
m.id::TEXT,
m.conversation_id::TEXT,
COALESCE(
NULLIF(c.name, ''),
(
SELECT string_agg(
COALESCE(NULLIF(member.display_name, ''), NULLIF(member.username, ''), member.email),
' & '
ORDER BY lower(COALESCE(NULLIF(member.display_name, ''), NULLIF(member.username, ''), member.email))
)
FROM conversation_members cm
JOIN users member ON member.id = cm.user_id
WHERE cm.conversation_id = c.id
),
'Chat'
),
COALESCE(NULLIF(u.display_name, ''), NULLIF(u.username, ''), u.email, 'Unbekannt'),
m.body,
m.created_at
FROM messages m
JOIN conversations c ON c.id = m.conversation_id
LEFT JOIN users u ON u.id = m.sender_id
WHERE c.type <> 'channel'
AND c.is_public = true
AND m.deleted_at IS NULL
AND m.message_type = 'user'
AND (m.expires_at IS NULL OR m.expires_at > now())
AND (
(
$3 = false
AND CONCAT_WS(' ', c.name, m.body) ILIKE ALL($1::TEXT[])
)
OR
(
$3 = true
AND CONCAT_WS(' ', c.name, m.body) LIKE ALL($1::TEXT[])
)
)
ORDER BY m.created_at DESC
LIMIT $2
`,
patterns,
options.Limit,
options.CaseSensitive,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []globalSearchItem{}
for rows.Next() {
var messageID string
var conversationID string
var conversationName string
var senderDisplayName string
var body string
var createdAt time.Time
if err := rows.Scan(
&messageID,
&conversationID,
&conversationName,
&senderDisplayName,
&body,
&createdAt,
); err != nil {
return nil, err
}
items = append(items, globalSearchItem{
ID: "public-chat-message-" + messageID,
EntityID: conversationID,
EntityType: "publicChatMessage",
Title: conversationName,
Subtitle: joinSearchSubtitle(
formatSearchDate(createdAt),
senderDisplayName,
truncateSearchText(body, 140),
),
Href: "/chat/" + conversationID,
Data: map[string]any{
"messageId": messageID,
"conversationId": conversationID,
"createdAt": createdAt,
},
})
}
return items, rows.Err()
}
func parseSearchLimit(value string, fallback int) int { func parseSearchLimit(value string, fallback int) int {
limit, err := strconv.Atoi(value) limit, err := strconv.Atoi(value)
if err != nil { if err != nil {

View File

@ -677,6 +677,11 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
server_folders_archive_root_label TEXT NOT NULL DEFAULT 'Archive E', server_folders_archive_root_label TEXT NOT NULL DEFAULT 'Archive E',
server_folders_archive_root_path TEXT NOT NULL DEFAULT 'E:/', server_folders_archive_root_path TEXT NOT NULL DEFAULT 'E:/',
server_folders_agent_token_encrypted BYTEA, server_folders_agent_token_encrypted BYTEA,
backup_nas_host TEXT NOT NULL DEFAULT '',
backup_nas_share TEXT NOT NULL DEFAULT '',
backup_nas_username TEXT NOT NULL DEFAULT '',
backup_nas_password_encrypted BYTEA,
backup_log_root_path TEXT NOT NULL DEFAULT '',
active_directory_server_url TEXT NOT NULL DEFAULT '', active_directory_server_url TEXT NOT NULL DEFAULT '',
active_directory_base_dn TEXT NOT NULL DEFAULT '', active_directory_base_dn TEXT NOT NULL DEFAULT '',
active_directory_bind_username TEXT NOT NULL DEFAULT '', active_directory_bind_username TEXT NOT NULL DEFAULT '',
@ -724,6 +729,31 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
ADD COLUMN IF NOT EXISTS server_folders_agent_token_encrypted BYTEA ADD COLUMN IF NOT EXISTS server_folders_agent_token_encrypted BYTEA
`, `,
`
ALTER TABLE milestone_settings
ADD COLUMN IF NOT EXISTS backup_nas_host TEXT NOT NULL DEFAULT ''
`,
`
ALTER TABLE milestone_settings
ADD COLUMN IF NOT EXISTS backup_nas_share TEXT NOT NULL DEFAULT ''
`,
`
ALTER TABLE milestone_settings
ADD COLUMN IF NOT EXISTS backup_nas_username TEXT NOT NULL DEFAULT ''
`,
`
ALTER TABLE milestone_settings
ADD COLUMN IF NOT EXISTS backup_nas_password_encrypted BYTEA
`,
`
ALTER TABLE milestone_settings
ADD COLUMN IF NOT EXISTS backup_log_root_path TEXT NOT NULL DEFAULT ''
`,
` `
ALTER TABLE milestone_settings ALTER TABLE milestone_settings
ADD COLUMN IF NOT EXISTS active_directory_server_url TEXT NOT NULL DEFAULT '' ADD COLUMN IF NOT EXISTS active_directory_server_url TEXT NOT NULL DEFAULT ''
@ -929,6 +959,7 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
last_read_at TIMESTAMPTZ, last_read_at TIMESTAMPTZ,
last_cleared_at TIMESTAMPTZ,
muted BOOLEAN NOT NULL DEFAULT false, muted BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
@ -938,6 +969,7 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
`, `,
`ALTER TABLE IF EXISTS conversation_members ADD COLUMN IF NOT EXISTS muted BOOLEAN NOT NULL DEFAULT false`, `ALTER TABLE IF EXISTS conversation_members ADD COLUMN IF NOT EXISTS muted BOOLEAN NOT NULL DEFAULT false`,
`ALTER TABLE IF EXISTS conversation_members ADD COLUMN IF NOT EXISTS last_cleared_at TIMESTAMPTZ`,
` `
CREATE TABLE IF NOT EXISTS messages ( CREATE TABLE IF NOT EXISTS messages (
@ -987,6 +1019,17 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
) )
`, `,
`
CREATE TABLE IF NOT EXISTS message_reactions (
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
emoji TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (message_id, user_id)
)
`,
// Link-Vorschau (OpenGraph-Metadaten), serverseitig asynchron geladen. // Link-Vorschau (OpenGraph-Metadaten), serverseitig asynchron geladen.
// Eine Vorschau pro Nachricht (erste erkannte URL). // Eine Vorschau pro Nachricht (erste erkannte URL).
` `
@ -1151,6 +1194,25 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
`CREATE INDEX IF NOT EXISTS idx_messages_expires_at ON messages(expires_at) WHERE expires_at IS NOT NULL`, `CREATE INDEX IF NOT EXISTS idx_messages_expires_at ON messages(expires_at) WHERE expires_at IS NOT NULL`,
`CREATE INDEX IF NOT EXISTS idx_message_attachments_message_id ON message_attachments(message_id)`, `CREATE INDEX IF NOT EXISTS idx_message_attachments_message_id ON message_attachments(message_id)`,
`CREATE INDEX IF NOT EXISTS idx_message_attachments_uploader_id ON message_attachments(uploader_id)`, `CREATE INDEX IF NOT EXISTS idx_message_attachments_uploader_id ON message_attachments(uploader_id)`,
`
DELETE FROM message_reactions mr
USING (
SELECT ctid
FROM (
SELECT ctid,
ROW_NUMBER() OVER (
PARTITION BY message_id, user_id
ORDER BY created_at DESC, emoji ASC
) AS row_number
FROM message_reactions
) ranked_reactions
WHERE row_number > 1
) duplicate_reactions
WHERE mr.ctid = duplicate_reactions.ctid
`,
`CREATE INDEX IF NOT EXISTS idx_message_reactions_message_id ON message_reactions(message_id)`,
`CREATE INDEX IF NOT EXISTS idx_message_reactions_user_id ON message_reactions(user_id)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_message_reactions_message_user_unique ON message_reactions(message_id, user_id)`,
} }
for _, query := range queries { for _, query := range queries {

View File

@ -7,6 +7,7 @@ import LoginPage from './pages/LoginPage'
import AppLayout from './components/AppLayout' import AppLayout from './components/AppLayout'
import DashboardPage from './pages/DashboardPage' import DashboardPage from './pages/DashboardPage'
import DevicesPage from './pages/devices/DevicesPage' import DevicesPage from './pages/devices/DevicesPage'
import DeviceCategoryPage from './pages/devices/DeviceCategoryPage'
import OperationsPage from './pages/operations/OperationsPage' import OperationsPage from './pages/operations/OperationsPage'
import CalendarPage from './pages/calendar/CalendarPage' import CalendarPage from './pages/calendar/CalendarPage'
import DocumentsPage from './pages/DocumentsPage' import DocumentsPage from './pages/DocumentsPage'
@ -22,6 +23,7 @@ import MilestonePage from './pages/milestone/MilestonePage'
import MilestoneStoragePage from './pages/milestone/speicher/MilestoneStoragePage' import MilestoneStoragePage from './pages/milestone/speicher/MilestoneStoragePage'
import MilestoneDevicesPage from './pages/milestone/geraete/MilestoneDevicesPage' import MilestoneDevicesPage from './pages/milestone/geraete/MilestoneDevicesPage'
import MilestoneRolesPage from './pages/milestone/rollen/MilestoneRolesPage' import MilestoneRolesPage from './pages/milestone/rollen/MilestoneRolesPage'
import MilestoneBackupPage from './pages/milestone/backup/MilestoneBackupPage'
import { hasRight } from './components/permissions' import { hasRight } from './components/permissions'
import ChatPage from './pages/chat/ChatPage' import ChatPage from './pages/chat/ChatPage'
import { ChatSocketProvider } from './components/ChatSocketProvider' import { ChatSocketProvider } from './components/ChatSocketProvider'
@ -655,6 +657,75 @@ function App() {
} }
/> />
<Route
path="/geraete/alle"
element={<Navigate to="/geraete" replace />}
/>
<Route
path="/geraete/kameras"
element={
<RequireRight user={user} right="devices:read">
<DeviceCategoryPage
currentUser={user}
category="Kameras"
title="Kameras"
description="Kamera-Hardware verwalten und die passenden Stammdaten für Einsätze pflegen."
singularLabel="Kamera"
emptyText="Keine Kameras vorhanden."
/>
</RequireRight>
}
/>
<Route
path="/geraete/router"
element={
<RequireRight user={user} right="devices:read">
<DeviceCategoryPage
currentUser={user}
category="Router"
title="Router"
description="Router verwalten, IP-Adressen pflegen und die Geräte für Einsätze bereitstellen."
singularLabel="Router"
emptyText="Keine Router vorhanden."
/>
</RequireRight>
}
/>
<Route
path="/geraete/switchbox"
element={
<RequireRight user={user} right="devices:read">
<DeviceCategoryPage
currentUser={user}
category="Switchbox"
title="Switchboxen"
description="Switchboxen verwalten und Rufnummern für die Einsatzplanung pflegen."
singularLabel="Switchbox"
emptyText="Keine Switchboxen vorhanden."
/>
</RequireRight>
}
/>
<Route
path="/geraete/laptop"
element={
<RequireRight user={user} right="devices:read">
<DeviceCategoryPage
currentUser={user}
category="Laptop"
title="Laptops"
description="Laptops verwalten und Computernamen für die Auswertung zuordnen."
singularLabel="Laptop"
emptyText="Keine Laptops vorhanden."
/>
</RequireRight>
}
/>
<Route <Route
path="/einsaetze" path="/einsaetze"
element={ element={
@ -679,7 +750,7 @@ function App() {
/> />
<Route <Route
path="/milestone/geraete" path="/milestone/kameras"
element={ element={
<RequireRight user={user} right="administration:read"> <RequireRight user={user} right="administration:read">
<MilestoneDevicesPage <MilestoneDevicesPage
@ -689,6 +760,31 @@ function App() {
} }
/> />
<Route
path="/milestone/geraete"
element={<Navigate to="/milestone/kameras" replace />}
/>
<Route
path="/milestone/kamera"
element={<Navigate to="/milestone/kameras" replace />}
/>
<Route
path="/milestone/router"
element={<Navigate to="/geraete/router" replace />}
/>
<Route
path="/milestone/switchbox"
element={<Navigate to="/geraete/switchbox" replace />}
/>
<Route
path="/milestone/laptop"
element={<Navigate to="/geraete/laptop" replace />}
/>
<Route <Route
path="/milestone/speicher" path="/milestone/speicher"
element={ element={
@ -712,6 +808,15 @@ function App() {
} }
/> />
<Route
path="/milestone/backup"
element={
<RequireRight user={user} right="administration:read">
<MilestoneBackupPage />
</RequireRight>
}
/>
<Route <Route
path="/einsaetze/milestone-speicher" path="/einsaetze/milestone-speicher"
element={<Navigate to="/milestone/speicher" replace />} element={<Navigate to="/milestone/speicher" replace />}
@ -719,7 +824,7 @@ function App() {
<Route <Route
path="/einsaetze/milestone-devices" path="/einsaetze/milestone-devices"
element={<Navigate to="/milestone/geraete" replace />} element={<Navigate to="/milestone/kameras" replace />}
/> />
<Route <Route

View File

@ -6,6 +6,7 @@ import { Transition } from '@headlessui/react'
import SearchOverlay from './SearchOverlay' import SearchOverlay from './SearchOverlay'
import Sidebar from './Sidebar' import Sidebar from './Sidebar'
import Topbar from './Topbar' import Topbar from './Topbar'
import PinnedChatDock from './PinnedChatDock'
import type { User } from './types' import type { User } from './types'
type AppLayoutProps = { type AppLayoutProps = {
@ -87,6 +88,8 @@ export default function AppLayout({
onClose={closeSearchOverlay} onClose={closeSearchOverlay}
/> />
</Transition> </Transition>
<PinnedChatDock userId={user.id} />
</main> </main>
</div> </div>
</div> </div>

View File

@ -3,9 +3,11 @@
import { useMemo, useState, type FormEvent } from 'react' import { useMemo, useState, type FormEvent } from 'react'
import { import {
ChatBubbleBottomCenterTextIcon, ChatBubbleBottomCenterTextIcon,
ChevronDownIcon,
CheckCircleIcon, CheckCircleIcon,
CursorArrowRaysIcon, CursorArrowRaysIcon,
ExclamationTriangleIcon, ExclamationTriangleIcon,
PaperAirplaneIcon,
ShieldCheckIcon, ShieldCheckIcon,
} from '@heroicons/react/24/outline' } from '@heroicons/react/24/outline'
import Button from './Button' import Button from './Button'
@ -52,6 +54,19 @@ export default function Feedback() {
[logSnapshot], [logSnapshot],
) )
const actionCount = logSnapshot.length - errorCount const actionCount = logSnapshot.length - errorCount
const sortedLogSnapshot = useMemo(
() =>
[...logSnapshot].sort((left, right) => {
const leftTime = Date.parse(left.timestamp)
const rightTime = Date.parse(right.timestamp)
return (
(Number.isNaN(rightTime) ? 0 : rightTime) -
(Number.isNaN(leftTime) ? 0 : leftTime)
)
}),
[logSnapshot],
)
async function handleSubmit(event: FormEvent<HTMLFormElement>) { async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault() event.preventDefault()
@ -74,7 +89,7 @@ export default function Feedback() {
credentials: 'include', credentials: 'include',
body: JSON.stringify({ body: JSON.stringify({
message: trimmedMessage, message: trimmedMessage,
clientLog: logSnapshot, clientLog: sortedLogSnapshot,
client, client,
}), }),
}) })
@ -181,21 +196,24 @@ export default function Feedback() {
type="submit" type="submit"
isLoading={isSubmitting} isLoading={isSubmitting}
disabled={!message.trim() || isSubmitting} disabled={!message.trim() || isSubmitting}
leadingIcon={
<PaperAirplaneIcon aria-hidden="true" className="size-4" />
}
> >
Feedback senden Feedback senden
</Button> </Button>
</div> </div>
</form> </form>
<div className="mt-6 overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900"> <details className="group mt-6 overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-gray-100 px-5 py-4 dark:border-white/10"> <summary className="flex cursor-pointer list-none flex-wrap items-center justify-between gap-3 px-5 py-4 transition hover:bg-gray-50 marker:hidden dark:hover:bg-white/5 [&::-webkit-details-marker]:hidden">
<div> <div className="min-w-0">
<h2 className="text-sm font-semibold text-gray-900 dark:text-white"> <h2 className="text-sm font-semibold text-gray-900 dark:text-white">
Angehängtes Protokoll Angehängtes Protokoll
</h2> </h2>
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400"> <p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
Deine letzten Aktionen und Fehler in dieser Sitzung hilft Deine letzten Schaltflächen-Klicks, Seitenaufrufe und
uns, Probleme nachzuvollziehen. API-Fehler in dieser Sitzung.
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -213,23 +231,30 @@ export default function Feedback() {
<ExclamationTriangleIcon className="size-3.5" /> <ExclamationTriangleIcon className="size-3.5" />
{errorCount} Fehler {errorCount} Fehler
</span> </span>
<span className="inline-flex size-8 items-center justify-center rounded-full bg-gray-100 text-gray-500 transition group-open:rotate-180 dark:bg-white/10 dark:text-gray-400">
<ChevronDownIcon aria-hidden="true" className="size-4" />
<span className="sr-only">
Protokoll anzeigen oder ausblenden
</span>
</span>
</div> </div>
</div> </summary>
<div className="max-h-80 overflow-y-auto p-2"> <div className="max-h-80 overflow-y-auto border-t border-gray-100 p-2 dark:border-white/10">
<ActivityLogView <ActivityLogView
entries={logSnapshot} entries={sortedLogSnapshot}
showPath={false} showPath={false}
emptyLabel="Bisher wurden keine Aktionen oder Fehler aufgezeichnet." emptyLabel="Bisher wurden keine Schaltflächen-Klicks, Seitenaufrufe oder API-Fehler aufgezeichnet."
/> />
</div> </div>
<div className="flex items-center gap-2 border-t border-gray-100 px-5 py-3 text-xs text-gray-500 dark:border-white/10 dark:text-gray-400"> <div className="flex items-center gap-2 border-t border-gray-100 px-5 py-3 text-xs text-gray-500 dark:border-white/10 dark:text-gray-400">
<ShieldCheckIcon className="size-4 shrink-0 text-gray-400" /> <ShieldCheckIcon className="size-4 shrink-0 text-gray-400" />
Es werden keine Passwörter oder Eingabeinhalte erfasst nur, Es werden keine Passwörter oder Eingabeinhalte erfasst nur,
welche Elemente du benutzt hast, und technische Fehlermeldungen. welche Schaltflächen du benutzt hast, Seitenaufrufe und
API-Fehler.
</div> </div>
</div> </details>
</> </>
)} )}
</div> </div>

View File

@ -404,7 +404,7 @@ export default function Journal({
<Badge <Badge
tone="indigo" tone="indigo"
variant="border" variant="border"
shape="rounded" shape="circle"
size="small" size="small"
> >
{changes.length}{' '} {changes.length}{' '}

View File

@ -2,10 +2,11 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { import {
BellIcon,
EnvelopeOpenIcon, EnvelopeOpenIcon,
FaceSmileIcon, FaceSmileIcon,
} from '@heroicons/react/20/solid' } from '@heroicons/react/20/solid'
import { BellIcon as BellOutlineIcon } from '@heroicons/react/24/outline'
import { BellIcon as BellSolidIcon } from '@heroicons/react/24/solid'
import Button from './Button' import Button from './Button'
import Tabs, { type TabItem } from './Tabs' import Tabs, { type TabItem } from './Tabs'
import type { Notification } from './types' import type { Notification } from './types'
@ -115,6 +116,8 @@ export default function NotificationCenter({
setOpen(false) setOpen(false)
} }
const BellIcon = open ? BellSolidIcon : BellOutlineIcon
return ( return (
<div ref={containerRef} className="relative"> <div ref={containerRef} className="relative">
<button <button
@ -227,4 +230,4 @@ export default function NotificationCenter({
)} )}
</div> </div>
) )
} }

File diff suppressed because it is too large Load Diff

View 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

View File

@ -54,6 +54,8 @@ function getGroupIcon(groupId: string) {
return ClipboardDocumentListIcon return ClipboardDocumentListIcon
case 'operation-journals': case 'operation-journals':
return ChatBubbleLeftEllipsisIcon return ChatBubbleLeftEllipsisIcon
case 'public-chat-messages':
return ChatBubbleLeftEllipsisIcon
case 'devices': case 'devices':
return CircleStackIcon return CircleStackIcon
case 'device-journals': case 'device-journals':
@ -285,15 +287,10 @@ export default function SearchOverlay({
search.length >= 2 && !isLoading && !error && !hasResults search.length >= 2 && !isLoading && !error && !hasResults
return ( return (
<div className="relative h-full overflow-y-auto bg-gradient-to-br from-slate-50 via-white to-indigo-50/70 [scrollbar-gutter:stable] dark:from-gray-950 dark:via-gray-950 dark:to-indigo-950/30"> <div className="relative h-full overflow-y-auto bg-white [scrollbar-gutter:stable] dark:bg-gray-950">
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<div className="absolute -top-32 right-0 size-96 rounded-full bg-indigo-200/30 blur-3xl dark:bg-indigo-500/10" />
<div className="absolute bottom-0 -left-32 size-80 rounded-full bg-sky-100/50 blur-3xl dark:bg-sky-500/5" />
</div>
<div className="relative mx-auto max-w-7xl px-4 py-6 sm:px-6 sm:py-8 lg:px-8"> <div className="relative mx-auto max-w-7xl px-4 py-6 sm:px-6 sm:py-8 lg:px-8">
{/* Header */} {/* Header */}
<div className="mb-8 overflow-hidden rounded-3xl border border-white/80 bg-white/85 p-5 shadow-xl shadow-slate-900/5 ring-1 ring-slate-900/5 backdrop-blur-xl sm:p-6 dark:border-white/10 dark:bg-gray-900/80 dark:shadow-black/20 dark:ring-white/5"> <div className="mb-8 overflow-hidden rounded-3xl border border-white/80 bg-white/85 p-5 shadow-sm ring-1 ring-slate-900/5 backdrop-blur-xl sm:p-6 dark:border-white/10 dark:bg-gray-900/80 dark:ring-white/5">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-x-4"> <div className="flex items-center gap-x-4">
<div className="flex size-12 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br from-indigo-500 to-violet-600 text-white shadow-lg shadow-indigo-500/20 ring-1 ring-white/20"> <div className="flex size-12 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br from-indigo-500 to-violet-600 text-white shadow-lg shadow-indigo-500/20 ring-1 ring-white/20">

View File

@ -7,23 +7,26 @@ import {
TransitionChild, TransitionChild,
} from '@headlessui/react' } from '@headlessui/react'
import { import {
ArchiveBoxIcon,
CalendarIcon, CalendarIcon,
ChartPieIcon, ChartPieIcon,
ChatBubbleLeftRightIcon, ChatBubbleLeftRightIcon,
Cog6ToothIcon, Cog6ToothIcon,
ComputerDesktopIcon, ComputerDesktopIcon,
CpuChipIcon,
DocumentDuplicateIcon, DocumentDuplicateIcon,
ServerStackIcon, ServerStackIcon,
ShieldCheckIcon, ShieldCheckIcon,
UserGroupIcon, UserGroupIcon,
VideoCameraIcon, VideoCameraIcon,
ArrowsRightLeftIcon,
HomeIcon, HomeIcon,
WifiIcon,
XMarkIcon, XMarkIcon,
} from '@heroicons/react/24/outline' } from '@heroicons/react/24/outline'
import type { ComponentType, SVGProps } from 'react' import type { ComponentType, SVGProps } from 'react'
import { NavLink, useLocation } from 'react-router' import { NavLink, useLocation } from 'react-router'
import { hasRight } from './permissions' import { hasRight } from './permissions'
import Scrollbar from './Scrollbar'
import type { User } from './types' import type { User } from './types'
type NavigationItem = { type NavigationItem = {
@ -39,6 +42,26 @@ type NavigationSection = {
items: NavigationItem[] items: NavigationItem[]
} }
function MilestoneDiamondIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
{...props}
>
<rect
x="6.25"
y="6.25"
width="11.5"
height="11.5"
transform="rotate(45 12 12)"
fill="currentColor"
/>
</svg>
)
}
const navigationSections: NavigationSection[] = [ const navigationSections: NavigationSection[] = [
{ {
items: [ items: [
@ -57,6 +80,32 @@ const navigationSections: NavigationSection[] = [
href: '/geraete', href: '/geraete',
icon: ComputerDesktopIcon, icon: ComputerDesktopIcon,
right: 'devices:read', right: 'devices:read',
children: [
{
name: 'Kamera',
href: '/geraete/kameras',
icon: VideoCameraIcon,
right: 'devices:read',
},
{
name: 'Router',
href: '/geraete/router',
icon: WifiIcon,
right: 'devices:read',
},
{
name: 'Switchbox',
href: '/geraete/switchbox',
icon: ArrowsRightLeftIcon,
right: 'devices:read',
},
{
name: 'Laptop',
href: '/geraete/laptop',
icon: ComputerDesktopIcon,
right: 'devices:read',
},
],
}, },
{ {
name: 'Einsätze', name: 'Einsätze',
@ -67,21 +116,21 @@ const navigationSections: NavigationSection[] = [
{ {
name: 'Milestone', name: 'Milestone',
href: '/milestone', href: '/milestone',
icon: ServerStackIcon, icon: MilestoneDiamondIcon,
right: 'administration:read', right: 'administration:read',
children: [ children: [
{
name: 'Geräte',
href: '/milestone/geraete',
icon: CpuChipIcon,
right: 'administration:read',
},
{ {
name: 'Speicher', name: 'Speicher',
href: '/milestone/speicher', href: '/milestone/speicher',
icon: ServerStackIcon, icon: ServerStackIcon,
right: 'administration:read', right: 'administration:read',
}, },
{
name: 'Backup',
href: '/milestone/backup',
icon: ArchiveBoxIcon,
right: 'administration:read',
},
{ {
name: 'Rollen', name: 'Rollen',
href: '/milestone/rollen', href: '/milestone/rollen',
@ -207,30 +256,46 @@ function SidebarContent({
setSidebarOpen(false) setSidebarOpen(false)
} }
function renderNavigationItem(item: NavigationItem) { function navigationLinkClassName(active: boolean, isSubItem = false) {
return classNames(
active
? 'bg-white/10 text-white shadow-sm ring-1 ring-white/10'
: 'text-slate-300 hover:bg-white/5 hover:text-white',
isSubItem
? 'group flex items-center gap-x-2.5 rounded-lg px-2.5 py-2 text-sm/6 font-semibold transition'
: 'group flex items-center gap-x-3 rounded-xl px-3 py-2.5 text-sm/6 font-semibold transition',
)
}
function navigationIconClassName(active: boolean, isSubItem = false) {
return classNames(
active
? isSubItem
? 'text-indigo-100'
: 'text-white'
: 'text-slate-300 group-hover:text-white',
'size-5 shrink-0 stroke-[1.8] transition',
)
}
function renderNavigationItem(item: NavigationItem, depth = 0) {
const active = itemIsActive(item, location.pathname) const active = itemIsActive(item, location.pathname)
const children = item.children ?? [] const children = item.children ?? []
const showChildren = children.length > 0 const showChildren = children.length > 0
const isSubItem = depth > 0
return ( return (
<li key={item.name}> <li key={item.name}>
<NavLink <NavLink
to={item.href} to={item.href}
onClick={handleNavigate} onClick={handleNavigate}
className={classNames( className={navigationLinkClassName(active, isSubItem)}
active
? 'bg-white/10 text-white shadow-sm ring-1 ring-white/10'
: 'text-slate-400 hover:bg-white/5 hover:text-white',
'group flex items-center gap-x-3 rounded-xl px-3 py-2.5 text-sm/6 font-semibold transition',
)}
> >
<item.icon <item.icon
aria-hidden="true" aria-hidden="true"
className={classNames( className={classNames(
active navigationIconClassName(active, isSubItem),
? 'text-white' item.href === '/chat' && '-mr-0.5',
: 'text-slate-400 group-hover:text-white',
'size-5 shrink-0 transition',
)} )}
/> />
<span className="truncate">{item.name}</span> <span className="truncate">{item.name}</span>
@ -244,37 +309,12 @@ function SidebarContent({
{showChildren && ( {showChildren && (
<ul <ul
role="list" role="list"
className="ml-[1.35rem] mt-1 space-y-1 border-l border-white/10 pl-3" className={classNames(
'mt-1 space-y-1 border-l border-white/10 pl-3',
depth === 0 ? 'ml-[1.35rem]' : 'ml-3',
)}
> >
{children.map((child) => { {children.map((child) => renderNavigationItem(child, depth + 1))}
const childActive = itemIsActive(child, location.pathname)
return (
<li key={child.name}>
<NavLink
to={child.href}
onClick={handleNavigate}
className={classNames(
childActive
? 'bg-indigo-500/10 text-indigo-200 ring-1 ring-indigo-400/20'
: 'text-slate-400 hover:bg-white/5 hover:text-white',
'group flex items-center gap-x-2 rounded-lg px-2.5 py-2 text-sm/6 font-medium transition',
)}
>
<child.icon
aria-hidden="true"
className={classNames(
childActive
? 'text-indigo-200'
: 'text-slate-500 group-hover:text-white',
'size-4 shrink-0 transition',
)}
/>
<span className="truncate">{child.name}</span>
</NavLink>
</li>
)
})}
</ul> </ul>
)} )}
</li> </li>
@ -295,46 +335,40 @@ function SidebarContent({
</div> </div>
</div> </div>
<nav className="flex flex-1 flex-col"> <nav className="flex min-h-0 flex-1 flex-col">
<ul role="list" className="flex flex-1 flex-col gap-y-6"> <Scrollbar className="-mx-2 flex-1 px-2 pb-4">
<li> <div className="space-y-6">
<div className="space-y-6"> {visibleNavigationSections.map((section, sectionIndex) => (
{visibleNavigationSections.map((section, sectionIndex) => ( <section key={section.name ?? `section-${sectionIndex}`}>
<section key={section.name ?? `section-${sectionIndex}`}> {section.name && (
{section.name && ( <h2 className="mb-2 px-3 text-[0.6875rem]/6 font-semibold uppercase tracking-wider text-slate-500">
<h2 className="mb-2 px-3 text-[0.6875rem]/6 font-semibold uppercase tracking-wider text-slate-500"> {section.name}
{section.name} </h2>
</h2> )}
)}
<ul role="list" className="space-y-1"> <ul role="list" className="space-y-1">
{section.items.map(renderNavigationItem)} {section.items.map(renderNavigationItem)}
</ul> </ul>
</section> </section>
))} ))}
</div> </div>
</li> </Scrollbar>
<li className="mt-auto border-t border-white/10 pt-5"> <div className="shrink-0 border-t border-white/10 pt-5">
<h2 className="mb-2 px-3 text-[0.6875rem]/6 font-semibold uppercase tracking-wider text-slate-500"> <h2 className="mb-2 px-3 text-[0.6875rem]/6 font-semibold uppercase tracking-wider text-slate-500">
Hilfe & System System
</h2> </h2>
<ul role="list" className="space-y-1"> <ul role="list" className="space-y-1">
<li> <li>
<NavLink <NavLink
to="/feedback" to="/feedback"
onClick={handleNavigate} onClick={handleNavigate}
className={classNames( className={navigationLinkClassName(isFeedbackActive)}
isFeedbackActive
? 'bg-white/10 text-white ring-1 ring-white/10'
: 'text-slate-400 hover:bg-white/5 hover:text-white',
'group flex items-center gap-x-3 rounded-xl px-3 py-2.5 text-sm/6 font-semibold transition',
)}
> >
<ChatBubbleLeftRightIcon <ChatBubbleLeftRightIcon
aria-hidden="true" aria-hidden="true"
className="size-5 shrink-0" className={navigationIconClassName(isFeedbackActive)}
/> />
Feedback Feedback
</NavLink> </NavLink>
@ -345,16 +379,11 @@ function SidebarContent({
<NavLink <NavLink
to="/einstellungen/konto" to="/einstellungen/konto"
onClick={handleNavigate} onClick={handleNavigate}
className={classNames( className={navigationLinkClassName(isSettingsActive)}
isSettingsActive
? 'bg-white/10 text-white ring-1 ring-white/10'
: 'text-slate-400 hover:bg-white/5 hover:text-white',
'group flex items-center gap-x-3 rounded-xl px-3 py-2.5 text-sm/6 font-semibold transition',
)}
> >
<Cog6ToothIcon <Cog6ToothIcon
aria-hidden="true" aria-hidden="true"
className="size-5 shrink-0" className={navigationIconClassName(isSettingsActive)}
/> />
Einstellungen Einstellungen
</NavLink> </NavLink>
@ -366,24 +395,18 @@ function SidebarContent({
<NavLink <NavLink
to="/administration/benutzer" to="/administration/benutzer"
onClick={handleNavigate} onClick={handleNavigate}
className={classNames( className={navigationLinkClassName(isAdministrationActive)}
isAdministrationActive
? 'bg-white/10 text-white ring-1 ring-white/10'
: 'text-slate-400 hover:bg-white/5 hover:text-white',
'group flex items-center gap-x-3 rounded-xl px-3 py-2.5 text-sm/6 font-semibold transition',
)}
> >
<ShieldCheckIcon <ShieldCheckIcon
aria-hidden="true" aria-hidden="true"
className="size-5 shrink-0" className={navigationIconClassName(isAdministrationActive)}
/> />
Administration Administration
</NavLink> </NavLink>
</li> </li>
)} )}
</ul> </ul>
</li> </div>
</ul>
</nav> </nav>
</> </>
) )
@ -426,7 +449,7 @@ export default function Sidebar({
</div> </div>
</TransitionChild> </TransitionChild>
<div className="relative flex grow flex-col gap-y-5 overflow-y-auto bg-slate-950 px-5 pb-4 ring-1 ring-white/10 before:pointer-events-none before:absolute before:inset-0 before:bg-[radial-gradient(circle_at_top_left,rgba(99,102,241,0.16),transparent_32rem)]"> <div className="relative flex grow flex-col gap-y-5 overflow-hidden bg-slate-950 px-5 pb-4 ring-1 ring-white/10 before:pointer-events-none before:absolute before:inset-0 before:bg-[radial-gradient(circle_at_top_left,rgba(99,102,241,0.16),transparent_32rem)]">
<SidebarContent <SidebarContent
user={user} user={user}
setSidebarOpen={setSidebarOpen} setSidebarOpen={setSidebarOpen}
@ -439,7 +462,7 @@ export default function Sidebar({
</Dialog> </Dialog>
<div className="hidden bg-slate-950 ring-1 ring-white/10 lg:fixed lg:inset-y-0 lg:z-[2000] lg:flex lg:w-72 lg:flex-col"> <div className="hidden bg-slate-950 ring-1 ring-white/10 lg:fixed lg:inset-y-0 lg:z-[2000] lg:flex lg:w-72 lg:flex-col">
<div className="relative flex grow flex-col gap-y-5 overflow-y-auto px-5 pb-4 before:pointer-events-none before:absolute before:inset-0 before:bg-[radial-gradient(circle_at_top_left,rgba(99,102,241,0.14),transparent_34rem)]"> <div className="relative flex grow flex-col gap-y-5 overflow-hidden px-5 pb-4 before:pointer-events-none before:absolute before:inset-0 before:bg-[radial-gradient(circle_at_top_left,rgba(99,102,241,0.14),transparent_34rem)]">
<SidebarContent <SidebarContent
user={user} user={user}
setSidebarOpen={setSidebarOpen} setSidebarOpen={setSidebarOpen}

View File

@ -2,6 +2,7 @@
import { import {
Fragment, Fragment,
type CSSProperties,
type KeyboardEvent, type KeyboardEvent,
type MouseEvent, type MouseEvent,
type ReactNode, type ReactNode,
@ -11,6 +12,7 @@ import {
} from 'react' } from 'react'
import { ChevronDownIcon } from '@heroicons/react/20/solid' import { ChevronDownIcon } from '@heroicons/react/20/solid'
import Button from './Button' import Button from './Button'
import Scrollbar, { scrollbarClassName } from './Scrollbar'
export type TableVariant = export type TableVariant =
| 'simple' | 'simple'
@ -48,6 +50,7 @@ export type TableColumn<T> = {
sortValue?: (row: T) => TableSortValue sortValue?: (row: T) => TableSortValue
hideOnMobile?: 'sm' | 'md' | 'lg' | 'xl' hideOnMobile?: 'sm' | 'md' | 'lg' | 'xl'
align?: 'left' | 'center' | 'right' align?: 'left' | 'center' | 'right'
width?: string
className?: string className?: string
headerClassName?: string headerClassName?: string
} }
@ -103,6 +106,8 @@ type TablesProps<T> = {
hideHeadings?: boolean hideHeadings?: boolean
stickyTopClassName?: string stickyTopClassName?: string
scrollRows?: boolean
rowScrollClassName?: string
className?: string className?: string
} }
@ -220,11 +225,15 @@ export default function Tables<T>({
hideHeadings = false, hideHeadings = false,
stickyTopClassName = 'top-0', stickyTopClassName = 'top-0',
scrollRows = false,
rowScrollClassName,
className, className,
}: TablesProps<T>) { }: TablesProps<T>) {
const headerCheckboxRef = useRef<HTMLInputElement>(null) const headerCheckboxRef = useRef<HTMLInputElement>(null)
const bodyScrollRef = useRef<HTMLDivElement>(null)
const [internalSelectedRowIds, setInternalSelectedRowIds] = useState<Array<string | number>>([]) const [internalSelectedRowIds, setInternalSelectedRowIds] = useState<Array<string | number>>([])
const [hasVerticalScrollbar, setHasVerticalScrollbar] = useState(false)
const rowGroups = const rowGroups =
groups && groups.length > 0 groups && groups.length > 0
@ -254,16 +263,25 @@ export default function Tables<T>({
const isUppercase = variant === 'uppercase' const isUppercase = variant === 'uppercase'
const isStackedMobile = variant === 'stacked-mobile' const isStackedMobile = variant === 'stacked-mobile'
const isStickyHeader = variant === 'sticky-header' const isStickyHeader = variant === 'sticky-header'
const hasStickyHeader = isStickyHeader || scrollRows
const hasColumnWidths = columns.some((column) => column.width)
const isVerticalLines = variant === 'vertical-lines' const isVerticalLines = variant === 'vertical-lines'
const isCondensed = variant === 'condensed' const isCondensed = variant === 'condensed'
const isSortable = variant === 'sortable' const isSortable = variant === 'sortable'
const isGrouped = variant === 'grouped' || Boolean(groups?.length) const isGrouped = variant === 'grouped' || Boolean(groups?.length)
const hasSummary = variant === 'summary' || Boolean(summaryRows?.length) const hasSummary = variant === 'summary' || Boolean(summaryRows?.length)
const rowDividerClassName = 'border-b border-gray-200 dark:border-white/10'
const scrollGutterDividerClassName = 'border-r border-gray-200 dark:border-white/10'
const scrollGutterColumnWidth = '0.75rem'
const scrollContainerStyle = {
scrollbarGutter: hasVerticalScrollbar ? 'stable' : 'auto',
} as CSSProperties
const totalColumns = const totalColumns =
columns.length + columns.length +
(selectable ? 1 : 0) + (selectable ? 1 : 0) +
(rowActions ? 1 : 0) (rowActions ? 1 : 0) +
(scrollRows ? 1 : 0)
useLayoutEffect(() => { useLayoutEffect(() => {
if (!headerCheckboxRef.current) { if (!headerCheckboxRef.current) {
@ -273,6 +291,62 @@ export default function Tables<T>({
headerCheckboxRef.current.indeterminate = indeterminate headerCheckboxRef.current.indeterminate = indeterminate
}, [indeterminate]) }, [indeterminate])
useLayoutEffect(() => {
if (!scrollRows) {
setHasVerticalScrollbar(false)
return
}
const scrollElement = bodyScrollRef.current
if (!scrollElement) {
return
}
let animationFrameId: number | null = null
const updateScrollbarState = () => {
if (animationFrameId !== null) {
window.cancelAnimationFrame(animationFrameId)
}
animationFrameId = window.requestAnimationFrame(() => {
const nextHasVerticalScrollbar =
scrollElement.scrollHeight > scrollElement.clientHeight + 1
setHasVerticalScrollbar((currentHasVerticalScrollbar) =>
currentHasVerticalScrollbar === nextHasVerticalScrollbar
? currentHasVerticalScrollbar
: nextHasVerticalScrollbar,
)
})
}
updateScrollbarState()
const resizeObserver =
typeof ResizeObserver === 'undefined'
? null
: new ResizeObserver(updateScrollbarState)
resizeObserver?.observe(scrollElement)
if (scrollElement.firstElementChild) {
resizeObserver?.observe(scrollElement.firstElementChild)
}
window.addEventListener('resize', updateScrollbarState)
return () => {
if (animationFrameId !== null) {
window.cancelAnimationFrame(animationFrameId)
}
resizeObserver?.disconnect()
window.removeEventListener('resize', updateScrollbarState)
}
}, [groups, isLoading, rowScrollClassName, rows, scrollRows, summaryRows])
function updateSelected(nextSelectedIds: Array<string | number>) { function updateSelected(nextSelectedIds: Array<string | number>) {
if (!selectedRowIds) { if (!selectedRowIds) {
setInternalSelectedRowIds(nextSelectedIds) setInternalSelectedRowIds(nextSelectedIds)
@ -315,6 +389,21 @@ export default function Tables<T>({
) )
} }
function stickyHeaderClassName() {
if (!hasStickyHeader) {
return ''
}
return classNames(
`sticky ${stickyTopClassName} z-10 backdrop-blur-sm backdrop-filter`,
isCard
? 'bg-gray-50/95 dark:bg-gray-800/95'
: 'bg-white/95 dark:bg-gray-900/95',
!scrollRows &&
'shadow-[inset_0_-1px_0_rgba(209,213,219,0.9)] dark:shadow-[inset_0_-1px_0_rgba(255,255,255,0.12)]',
)
}
function renderHeader() { function renderHeader() {
if (!title && !description && !actionLabel && !headerAction) { if (!title && !description && !actionLabel && !headerAction) {
return null return null
@ -393,7 +482,14 @@ export default function Tables<T>({
> >
<tr className={classNames(isVerticalLines && 'divide-x divide-gray-200 dark:divide-white/10')}> <tr className={classNames(isVerticalLines && 'divide-x divide-gray-200 dark:divide-white/10')}>
{selectable && ( {selectable && (
<th scope="col" className="relative px-7 sm:w-12 sm:px-6"> <th
scope="col"
className={classNames(
stickyHeaderClassName(),
!hasStickyHeader && 'relative',
'px-7 sm:w-12 sm:px-6',
)}
>
<Checkbox <Checkbox
inputRef={headerCheckboxRef} inputRef={headerCheckboxRef}
checked={checked} checked={checked}
@ -412,8 +508,7 @@ export default function Tables<T>({
key={column.key} key={column.key}
scope="col" scope="col"
className={classNames( className={classNames(
isStickyHeader && stickyHeaderClassName(),
`sticky ${stickyTopClassName} z-10 bg-white/75 backdrop-blur-sm backdrop-filter dark:bg-gray-900/75`,
isUppercase isUppercase
? 'py-3 text-xs font-medium tracking-wide text-gray-500 uppercase dark:text-gray-400' ? 'py-3 text-xs font-medium tracking-wide text-gray-500 uppercase dark:text-gray-400'
: 'py-3.5 text-sm font-semibold text-gray-900 dark:text-white', : 'py-3.5 text-sm font-semibold text-gray-900 dark:text-white',
@ -436,8 +531,7 @@ export default function Tables<T>({
<th <th
scope="col" scope="col"
className={classNames( className={classNames(
isStickyHeader && stickyHeaderClassName(),
`sticky ${stickyTopClassName} z-10 bg-white/75 backdrop-blur-sm backdrop-filter dark:bg-gray-900/75`,
'py-3.5 pr-4 pl-3 sm:pr-0', 'py-3.5 pr-4 pl-3 sm:pr-0',
isCard && 'sm:pr-6', isCard && 'sm:pr-6',
)} )}
@ -445,6 +539,14 @@ export default function Tables<T>({
<span className="sr-only">Aktionen</span> <span className="sr-only">Aktionen</span>
</th> </th>
)} )}
{scrollRows && (
<th
aria-hidden="true"
scope="col"
className={classNames(stickyHeaderClassName(), 'p-0')}
/>
)}
</tr> </tr>
</thead> </thead>
) )
@ -484,7 +586,7 @@ export default function Tables<T>({
} }
} }
function renderRow(row: T, rowIndex: number) { function renderRow(row: T, _rowIndex: number) {
const rowId = getRowId(row) const rowId = getRowId(row)
const selected = selectedSet.has(rowId) const selected = selectedSet.has(rowId)
@ -497,6 +599,7 @@ export default function Tables<T>({
onClick={(event) => handleRowClick(row, event)} onClick={(event) => handleRowClick(row, event)}
onKeyDown={(event) => handleRowKeyDown(row, event)} onKeyDown={(event) => handleRowKeyDown(row, event)}
className={classNames( className={classNames(
'group',
isStriped && 'even:bg-gray-50 dark:even:bg-gray-800/50', isStriped && 'even:bg-gray-50 dark:even:bg-gray-800/50',
selected && 'bg-gray-50 dark:bg-gray-800/50', selected && 'bg-gray-50 dark:bg-gray-800/50',
isVerticalLines && 'divide-x divide-gray-200 dark:divide-white/10', isVerticalLines && 'divide-x divide-gray-200 dark:divide-white/10',
@ -504,7 +607,7 @@ export default function Tables<T>({
)} )}
> >
{selectable && ( {selectable && (
<td className="relative px-7 sm:w-12 sm:px-6"> <td className={classNames(rowDividerClassName, 'relative px-7 sm:w-12 sm:px-6')}>
{selected && ( {selected && (
<div className="absolute inset-y-0 left-0 w-0.5 bg-indigo-600 dark:bg-indigo-500" /> <div className="absolute inset-y-0 left-0 w-0.5 bg-indigo-600 dark:bg-indigo-500" />
)} )}
@ -526,6 +629,7 @@ export default function Tables<T>({
<td <td
key={column.key} key={column.key}
className={classNames( className={classNames(
rowDividerClassName,
isCondensed ? 'py-2' : isCard ? 'py-3' : isAvatarVariant(variant) ? 'py-5' : 'py-4', isCondensed ? 'py-2' : isCard ? 'py-3' : isAvatarVariant(variant) ? 'py-5' : 'py-4',
isCondensed ? 'px-2' : 'px-3', isCondensed ? 'px-2' : 'px-3',
isFirst && !selectable && 'pl-4 sm:pl-0', isFirst && !selectable && 'pl-4 sm:pl-0',
@ -555,6 +659,7 @@ export default function Tables<T>({
{rowActions && ( {rowActions && (
<td <td
className={classNames( className={classNames(
rowDividerClassName,
isCondensed ? 'py-2' : 'py-4', isCondensed ? 'py-2' : 'py-4',
'pr-4 pl-3 text-right text-sm font-medium whitespace-nowrap sm:pr-0', 'pr-4 pl-3 text-right text-sm font-medium whitespace-nowrap sm:pr-0',
isCard && 'sm:pr-6', isCard && 'sm:pr-6',
@ -563,6 +668,28 @@ export default function Tables<T>({
{rowActions(row)} {rowActions(row)}
</td> </td>
)} )}
{scrollRows && (
<td
aria-hidden="true"
className={classNames(
rowDividerClassName,
hasVerticalScrollbar && scrollGutterDividerClassName,
'p-0',
selected
? 'bg-gray-50 dark:bg-gray-800/50'
: classNames(
isCard
? 'bg-white dark:bg-gray-800/50'
: 'bg-white dark:bg-gray-900',
onRowClick &&
(isCard
? 'group-hover:bg-gray-50 dark:group-hover:bg-gray-800/70'
: 'group-hover:bg-gray-50 dark:group-hover:bg-gray-800/70'),
),
)}
/>
)}
</tr> </tr>
) )
} }
@ -596,7 +723,7 @@ export default function Tables<T>({
<tr> <tr>
<td <td
colSpan={Math.max(totalColumns, 1)} colSpan={Math.max(totalColumns, 1)}
className="px-4 py-16 text-center" className={classNames(rowDividerClassName, 'px-4 py-16 text-center')}
> >
<div className="flex justify-center"> <div className="flex justify-center">
{loadingContent ?? ( {loadingContent ?? (
@ -619,7 +746,10 @@ export default function Tables<T>({
<tr> <tr>
<td <td
colSpan={Math.max(totalColumns, 1)} colSpan={Math.max(totalColumns, 1)}
className="px-4 py-10 text-center text-sm text-gray-500 dark:text-gray-400" className={classNames(
rowDividerClassName,
'px-4 py-10 text-center text-sm text-gray-500 dark:text-gray-400',
)}
> >
{emptyText} {emptyText}
</td> </td>
@ -671,41 +801,135 @@ export default function Tables<T>({
) )
} }
function renderTable() { function getFallbackColumnWidth() {
const flexibleColumnCount = columns.filter((column) => !column.width).length
if (flexibleColumnCount === 0) {
return undefined
}
const reservedWidth = columns
.filter((column) => column.width?.endsWith('%'))
.reduce((sum, column) => sum + Number.parseFloat(column.width ?? '0'), 0)
const remainingWidth = Math.max(100 - reservedWidth, 0)
return `${remainingWidth / flexibleColumnCount}%`
}
function renderTableColGroup() {
if (!hasStickyHeader && !hasColumnWidths) {
return null
}
const fallbackColumnWidth = getFallbackColumnWidth()
return ( return (
<table <colgroup>
{selectable && <col style={{ width: '3rem' }} />}
{columns.map((column) => (
<col
key={column.key}
className={getHiddenClass(column.hideOnMobile)}
style={{ width: column.width ?? fallbackColumnWidth }}
/>
))}
{rowActions && <col style={{ width: '1%' }} />}
{scrollRows && <col style={{ width: scrollGutterColumnWidth }} />}
</colgroup>
)
}
function tableClassName() {
if (hasStickyHeader || hasColumnWidths) {
return classNames(
'w-full min-w-full table-fixed border-separate border-spacing-0',
isBorder && 'divide-y divide-gray-300 dark:divide-white/15',
)
}
return classNames(
'relative min-w-full divide-y divide-gray-300 dark:divide-white/15',
isBorder && 'divide-y divide-gray-300 dark:divide-white/15',
)
}
function renderTableBody() {
return (
<tbody
className={classNames( className={classNames(
isStickyHeader '[&>tr:last-child>td]:border-b-0',
? 'min-w-full border-separate border-spacing-0' isCard
: 'relative min-w-full divide-y divide-gray-300 dark:divide-white/15', ? 'bg-white dark:bg-gray-800/50'
isBorder && 'divide-y divide-gray-300 dark:divide-white/15', : 'bg-white dark:bg-gray-900',
)} )}
> >
{isLoading ? (
renderLoadingRow()
) : (
<>
{renderEmptyRow()}
{renderGroupedRows()}
</>
)}
</tbody>
)
}
function renderTable() {
return (
<table className={tableClassName()}>
{renderTableColGroup()}
{renderTableHead()} {renderTableHead()}
{renderTableBody()}
<tbody
className={classNames(
!isStickyHeader && 'divide-y divide-gray-200 dark:divide-white/10',
isCard
? 'bg-white dark:bg-gray-800/50'
: 'bg-white dark:bg-gray-900',
)}
>
{isLoading ? (
renderLoadingRow()
) : (
<>
{renderEmptyRow()}
{renderGroupedRows()}
</>
)}
</tbody>
{renderSummaryRows()} {renderSummaryRows()}
</table> </table>
) )
} }
function renderScrollableTable() {
if (!scrollRows) {
return renderTable()
}
return (
<div className="min-w-full">
<div
style={scrollContainerStyle}
className={classNames(
'min-w-0 overflow-x-hidden overflow-y-auto border-b border-gray-200 dark:border-white/10',
isCard
? 'bg-gray-50 dark:bg-gray-800/75'
: 'bg-white dark:bg-gray-900',
scrollbarClassName,
)}
>
<table className={tableClassName()}>
{renderTableColGroup()}
{renderTableHead()}
</table>
</div>
<div className="relative">
<Scrollbar
ref={bodyScrollRef}
style={scrollContainerStyle}
className={classNames(
rowScrollClassName ?? 'max-h-[calc(100dvh-18rem)]',
)}
>
<table className={tableClassName()}>
{renderTableColGroup()}
{renderTableBody()}
{renderSummaryRows()}
</table>
</Scrollbar>
</div>
</div>
)
}
return ( return (
<div className={className}> <div className={className}>
{renderHeader()} {renderHeader()}
@ -740,10 +964,10 @@ export default function Tables<T>({
'overflow-hidden ring-1 ring-gray-300 sm:rounded-lg dark:ring-white/15', 'overflow-hidden ring-1 ring-gray-300 sm:rounded-lg dark:ring-white/15',
)} )}
> >
{renderTable()} {renderScrollableTable()}
</div> </div>
) : ( ) : (
renderTable() renderScrollableTable()
)} )}
</div> </div>
</div> </div>
@ -754,4 +978,4 @@ export default function Tables<T>({
function isAvatarVariant(variant: TableVariant) { function isAvatarVariant(variant: TableVariant) {
return variant === 'avatars' return variant === 'avatars'
} }

View File

@ -371,6 +371,7 @@ export default function Tabs({
> >
{tabs.map((tab) => { {tabs.map((tab) => {
const active = isTabActive(tab, location.pathname) const active = isTabActive(tab, location.pathname)
const Icon = tab.icon
return ( return (
<Link <Link
@ -382,11 +383,23 @@ export default function Tabs({
active active
? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300' ? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200', : 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200',
'inline-flex items-center rounded-md px-3 py-2 text-sm font-medium', 'relative inline-flex items-center rounded-md px-3 py-2 text-sm font-medium',
fullWidthItemClassName, fullWidthItemClassName,
)} )}
> >
{tab.name} {Icon && (
<Icon
aria-hidden="true"
strokeWidth={2}
className={classNames(
active
? 'text-indigo-600 dark:text-indigo-300'
: 'text-gray-400 dark:text-gray-500',
'mr-1.5 size-[18px] shrink-0',
)}
/>
)}
<span>{tab.name}</span>
{renderCount(tab, active)} {renderCount(tab, active)}
</Link> </Link>
) )
@ -515,4 +528,4 @@ export default function Tabs({
</div> </div>
</div> </div>
) )
} }

View File

@ -2,11 +2,13 @@ import {
ChevronLeftIcon, ChevronLeftIcon,
ChevronRightIcon, ChevronRightIcon,
ClockIcon, ClockIcon,
DocumentTextIcon,
MapPinIcon, MapPinIcon,
PlusIcon, PlusIcon,
} from '@heroicons/react/20/solid' } from '@heroicons/react/20/solid'
import { useEffect, useRef, useState, type PointerEvent } from 'react' import { useEffect, useRef, useState, type PointerEvent } from 'react'
import ButtonGroup from '../ButtonGroup' import ButtonGroup from '../ButtonGroup'
import LoadingSpinner from '../LoadingSpinner'
import { import {
timeToMinutes, timeToMinutes,
workingHoursForDate, workingHoursForDate,
@ -53,6 +55,10 @@ type CalendarEventOccurrence = CalendarEvent
type CalendarProps = { type CalendarProps = {
events: CalendarEvent[] events: CalendarEvent[]
coreWorkingHours: CalendarCoreWorkingHours coreWorkingHours: CalendarCoreWorkingHours
status?: {
tone: 'loading' | 'danger'
text: string
} | null
selectedDate: Date selectedDate: Date
view: CalendarView view: CalendarView
onSelectedDateChange: (date: Date) => void onSelectedDateChange: (date: Date) => void
@ -883,24 +889,21 @@ function EventListButton({
)} )}
/> />
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold"> <span className="inline-flex max-w-full items-center gap-1 truncate text-xs font-semibold opacity-80">
{event.title} <ClockIcon className="size-3.5 shrink-0" />
</span> <span className="truncate">
<span className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs opacity-80"> {event.allDay ? 'Ganztägig' : formatTime(eventStartValue(event))}
<span className="inline-flex items-center gap-1">
<ClockIcon className="size-3.5" />
{event.allDay
? 'Ganztägig'
: `${formatTime(eventStartValue(event))} - ${formatTime(
eventEndValue(event),
)}`}
</span> </span>
{event.location && ( </span>
<span className="inline-flex items-center gap-1 truncate"> {event.location && (
<MapPinIcon className="size-3.5" /> <span className="mt-0.5 inline-flex max-w-full items-center gap-1 truncate text-xs opacity-80">
{event.location} <MapPinIcon className="size-3.5 shrink-0" />
</span> <span className="truncate">{event.location}</span>
)} </span>
)}
<span className="mt-1 inline-flex max-w-full items-center gap-1 truncate text-sm font-semibold">
<DocumentTextIcon className="size-3.5 shrink-0" />
<span className="truncate">{event.title}</span>
</span> </span>
</span> </span>
</button> </button>
@ -1300,6 +1303,11 @@ function TimeGridView({
30, 30,
(segmentEnd.getTime() - segmentStart.getTime()) / 60_000, (segmentEnd.getTime() - segmentStart.getTime()) / 60_000,
) )
const eventTopPercentage = (startMinutes / 1440) * 100
const eventHeightPercentage = Math.max(
2.1,
(durationMinutes / 1440) * 100,
)
return ( return (
<button <button
@ -1321,23 +1329,27 @@ function TimeGridView({
moveEvent.stopPropagation() moveEvent.stopPropagation()
} }
className={classNames( className={classNames(
'absolute right-1 left-1 z-10 overflow-hidden rounded-lg p-2 text-left text-xs ring-1 ring-inset transition', 'absolute right-[2px] left-[2px] z-10 flex flex-col items-start justify-start gap-0.5 overflow-hidden rounded-lg p-2 text-left text-xs leading-tight ring-1 ring-inset transition',
eventColors[event.color].card, eventColors[event.color].card,
)} )}
style={{ style={{
top: `${(startMinutes / 1440) * 100}%`, top: `calc(${eventTopPercentage}% + 2px)`,
height: `${Math.max( height: `calc(${eventHeightPercentage}% - 4px)`,
2.1,
(durationMinutes / 1440) * 100,
)}%`,
}} }}
> >
<span className="block truncate font-semibold"> <span className="inline-flex max-w-full items-center gap-1 truncate font-semibold">
{event.title} <ClockIcon className="size-3.5 shrink-0" />
<span className="truncate">{formatTime(eventStart)}</span>
</span> </span>
<span className="mt-0.5 block truncate opacity-80"> {event.location && (
{formatTime(segmentStart)} - {formatTime(segmentEnd)} <span className="inline-flex max-w-full items-center gap-1 truncate opacity-80">
{event.location ? ` · ${event.location}` : ''} <MapPinIcon className="size-3.5 shrink-0" />
<span className="truncate">{event.location}</span>
</span>
)}
<span className="inline-flex max-w-full items-center gap-1 truncate font-medium">
<DocumentTextIcon className="size-3.5 shrink-0" />
<span className="truncate">{event.title}</span>
</span> </span>
</button> </button>
) )
@ -1437,6 +1449,7 @@ function YearView({
export default function Calendar({ export default function Calendar({
events, events,
coreWorkingHours, coreWorkingHours,
status,
selectedDate, selectedDate,
view, view,
onSelectedDateChange, onSelectedDateChange,
@ -1476,9 +1489,35 @@ export default function Calendar({
<p className="text-xs font-medium uppercase tracking-wide text-indigo-600 dark:text-indigo-400"> <p className="text-xs font-medium uppercase tracking-wide text-indigo-600 dark:text-indigo-400">
Kalender Kalender
</p> </p>
<h1 className="truncate text-lg font-semibold capitalize text-gray-950 dark:text-white"> <div className="flex min-w-0 flex-wrap items-center gap-2 align-middle">
{titleForView(selectedDate, view)} <h1 className="truncate text-lg font-semibold capitalize text-gray-950 dark:text-white">
</h1> {titleForView(selectedDate, view)}
</h1>
{status && (
<span
className={classNames(
'inline-flex h-6 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-xs font-semibold ring-1 ring-inset',
status.tone === 'danger'
? 'bg-red-100 text-red-700 ring-red-200 dark:bg-red-500/15 dark:text-red-200 dark:ring-red-400/20'
: 'bg-indigo-50 text-indigo-700 ring-indigo-200 dark:bg-indigo-500/15 dark:text-indigo-200 dark:ring-indigo-400/20',
)}
>
{status.tone === 'loading' ? (
<LoadingSpinner
size="xs"
className="text-indigo-600 dark:text-indigo-200"
srLabel="Kalendereinträge werden geladen"
/>
) : (
<span
aria-hidden="true"
className="size-1.5 rounded-full bg-current"
/>
)}
{status.text}
</span>
)}
</div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View File

@ -28,14 +28,14 @@ export type CalendarSettings = {
const workingDay: CalendarWorkingHoursDay = { const workingDay: CalendarWorkingHoursDay = {
enabled: true, enabled: true,
start: '08:00', start: '07:00',
end: '17:00', end: '15:00',
} }
const dayOff: CalendarWorkingHoursDay = { const dayOff: CalendarWorkingHoursDay = {
enabled: false, enabled: false,
start: '08:00', start: '07:00',
end: '17:00', end: '15:00',
} }
export function createDefaultCalendarSettings(): CalendarSettings { export function createDefaultCalendarSettings(): CalendarSettings {

View File

@ -10,4 +10,47 @@ body,
body { body {
margin: 0; margin: 0;
} }
.dashboard-empty-frame {
--dashboard-empty-frame-border: rgb(209 213 219);
position: relative;
border-radius: 0.5rem;
}
.dark .dashboard-empty-frame {
--dashboard-empty-frame-border: rgb(255 255 255 / 0.1);
}
.dashboard-empty-frame::before {
pointer-events: none;
position: absolute;
inset: 0;
border-radius: inherit;
background:
repeating-linear-gradient(
90deg,
var(--dashboard-empty-frame-border) 0 4px,
transparent 4px 8px
)
top left / 8px 1px repeat-x,
repeating-linear-gradient(
90deg,
var(--dashboard-empty-frame-border) 0 4px,
transparent 4px 8px
)
bottom left / 8px 1px repeat-x,
repeating-linear-gradient(
180deg,
var(--dashboard-empty-frame-border) 0 4px,
transparent 4px 8px
)
top left / 1px 8px repeat-y,
repeating-linear-gradient(
180deg,
var(--dashboard-empty-frame-border) 0 4px,
transparent 4px 8px
)
top right / 1px 8px repeat-y;
content: "";
}

View File

@ -934,10 +934,11 @@ export default function DashboardPage({ username }: DashboardPageProps) {
)} )}
{widgets.length === 0 ? ( {widgets.length === 0 ? (
<div className="rounded-lg border border-dashed border-gray-300 bg-white px-6 py-16 dark:border-white/10 dark:bg-gray-900"> <div className="dashboard-empty-frame bg-white px-6 py-16 dark:bg-gray-900">
<EmptyState <EmptyState
icon={Squares2X2Icon} icon={Squares2X2Icon}
iconClassName="text-indigo-400 dark:text-indigo-500" iconClassName="text-indigo-400 dark:text-indigo-500"
className="relative"
title="Noch keine Widgets" title="Noch keine Widgets"
description="Füge dein erstes Widget hinzu und baue dir dein persönliches Dashboard." description="Füge dein erstes Widget hinzu und baue dir dein persönliches Dashboard."
actionLabel="Widget hinzufügen" actionLabel="Widget hinzufügen"
@ -1133,4 +1134,4 @@ export default function DashboardPage({ username }: DashboardPageProps) {
/> />
</div> </div>
) )
} }

View File

@ -12,7 +12,7 @@ import {
import Tabs from '../../components/Tabs' import Tabs from '../../components/Tabs'
import UsersAdministration from './users/UsersAdministration' import UsersAdministration from './users/UsersAdministration'
import FeedbackAdministration from './feedback/FeedbackAdministration' import FeedbackAdministration from './feedback/FeedbackAdministration'
import Milestone from './milestone/Milestone' import MilestoneAdministration from './milestone/MilestoneAdministration'
import ChatsAdministration from './chats/ChatsAdministration' import ChatsAdministration from './chats/ChatsAdministration'
import CalendarAdministration from './calendar/CalendarAdministration' import CalendarAdministration from './calendar/CalendarAdministration'
import ActiveDirectoryAdministration from './active-directory/ActiveDirectoryAdministration' import ActiveDirectoryAdministration from './active-directory/ActiveDirectoryAdministration'
@ -106,7 +106,7 @@ export default function AdministrationPage() {
{section === 'benutzer' && <UsersAdministration />} {section === 'benutzer' && <UsersAdministration />}
{section === 'chats' && <ChatsAdministration />} {section === 'chats' && <ChatsAdministration />}
{section === 'milestone' && <Milestone />} {section === 'milestone' && <MilestoneAdministration />}
{section === 'active-directory' && <ActiveDirectoryAdministration />} {section === 'active-directory' && <ActiveDirectoryAdministration />}
{section === 'kalender' && <CalendarAdministration />} {section === 'kalender' && <CalendarAdministration />}
{section === 'feedback' && <FeedbackAdministration />} {section === 'feedback' && <FeedbackAdministration />}

View File

@ -411,7 +411,7 @@ export default function ChannelsAdministration() {
src={channel.image} src={channel.image}
name={channel.name} name={channel.name}
size={9} size={9}
shape="rounded" shape="circle"
/> />
) : ( ) : (
<HashtagIcon className="size-5 shrink-0" /> <HashtagIcon className="size-5 shrink-0" />
@ -479,7 +479,7 @@ export default function ChannelsAdministration() {
src={draft.image} src={draft.image}
name={draft.name || 'Channel'} name={draft.name || 'Channel'}
size={14} size={14}
shape="rounded" shape="circle"
/> />
<div> <div>
<input <input

View File

@ -27,6 +27,7 @@ type AdminGroupChat = {
image: string image: string
ownerId: string ownerId: string
teamId: string teamId: string
isPublic: boolean
userIds: string[] userIds: string[]
createdAt: string createdAt: string
updatedAt: string updatedAt: string
@ -46,6 +47,7 @@ type GroupDraft = {
image: string image: string
ownerId: string ownerId: string
teamId: string teamId: string
isPublic: boolean
userIds: string[] userIds: string[]
} }
@ -68,6 +70,7 @@ function groupToDraft(group: AdminGroupChat): GroupDraft {
image: group.image, image: group.image,
ownerId: group.ownerId, ownerId: group.ownerId,
teamId: group.teamId, teamId: group.teamId,
isPublic: group.isPublic,
userIds: group.userIds ?? [], userIds: group.userIds ?? [],
} }
} }
@ -233,6 +236,7 @@ export default function GroupChatsAdministration() {
body: JSON.stringify({ body: JSON.stringify({
name: draft.name, name: draft.name,
image: draft.image, image: draft.image,
isPublic: draft.isPublic,
userIds: draft.userIds, userIds: draft.userIds,
}), }),
}) })
@ -413,7 +417,7 @@ export default function GroupChatsAdministration() {
src={group.image} src={group.image}
name={group.name} name={group.name}
size={9} size={9}
shape="rounded" shape="circle"
/> />
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
@ -423,6 +427,11 @@ export default function GroupChatsAdministration() {
Team Team
</span> </span>
)} )}
{group.isPublic && (
<span className="shrink-0 rounded bg-emerald-100 px-1.5 py-0.5 text-[10px] font-semibold tracking-wide text-emerald-700 uppercase dark:bg-emerald-500/20 dark:text-emerald-300">
Öffentlich
</span>
)}
</span> </span>
<span className="block truncate text-xs opacity-70"> <span className="block truncate text-xs opacity-70">
{group.userIds.length} Mitglieder {group.userIds.length} Mitglieder
@ -479,7 +488,7 @@ export default function GroupChatsAdministration() {
src={draft.image} src={draft.image}
name={draft.name || 'Gruppenchat'} name={draft.name || 'Gruppenchat'}
size={14} size={14}
shape="rounded" shape="circle"
/> />
<div> <div>
<input <input
@ -520,6 +529,19 @@ export default function GroupChatsAdministration() {
</div> </div>
)} )}
{!isTeamChat && (
<div className="mt-5 rounded-lg bg-gray-50 p-3 ring-1 ring-gray-200 dark:bg-white/5 dark:ring-white/10">
<Checkbox
checked={draft.isPublic}
onChange={(event) =>
updateDraft('isPublic', event.target.checked)
}
label="Öffentlich"
description="Öffentliche Gruppenchats können von allen angemeldeten Benutzern gelesen und über die globale Suche gefunden werden."
/>
</div>
)}
<div className="mt-6 border-t border-gray-200 pt-5 dark:border-white/10"> <div className="mt-6 border-t border-gray-200 pt-5 dark:border-white/10">
<div className="flex flex-wrap items-end justify-between gap-3"> <div className="flex flex-wrap items-end justify-between gap-3">
<div> <div>

View File

@ -1,6 +1,6 @@
// frontend\src\pages\administration\milestone\Milestone.tsx // frontend\src\pages\administration\milestone\MilestoneAdministration.tsx
import { useEffect, useState, type FormEvent } from 'react' import { useEffect, useRef, useState, type FormEvent } from 'react'
import { import {
KeyIcon, KeyIcon,
ServerStackIcon, ServerStackIcon,
@ -14,6 +14,7 @@ import { DialogTitle } from '@headlessui/react'
import { XMarkIcon } from '@heroicons/react/24/outline' import { XMarkIcon } from '@heroicons/react/24/outline'
import Modal from '../../../components/Modal' import Modal from '../../../components/Modal'
import TaskList, { type TaskListStep } from '../../../components/TaskList' import TaskList, { type TaskListStep } from '../../../components/TaskList'
import TreeList, { type TreeListNode } from '../../../components/TreeList'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080' const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
@ -37,8 +38,32 @@ type MilestoneSettings = {
serverFoldersStorageRootPath: string serverFoldersStorageRootPath: string
serverFoldersArchiveRootLabel: string serverFoldersArchiveRootLabel: string
serverFoldersArchiveRootPath: string serverFoldersArchiveRootPath: string
backupNasHost: string
backupNasShare: string
backupNasUsername: string
backupNasPasswordConfigured: boolean
backupLogRootPath: string
updatedAt?: string updatedAt?: string
} }
type BackupFolderRoot = {
label: string
path: string
}
type BackupFolderItem = {
name: string
displayName?: string
path: string
hasChildren?: boolean
}
type BackupFoldersResponse = {
path: string
roots?: BackupFolderRoot[]
folders?: BackupFolderItem[]
warning?: string
}
type MilestoneSyncEvent = { type MilestoneSyncEvent = {
type: 'token' | 'recordingServer' | 'hardware' | 'done' | 'error' type: 'token' | 'recordingServer' | 'hardware' | 'done' | 'error'
message?: string message?: string
@ -201,7 +226,7 @@ function markCurrentMilestoneSyncStepAsError(
) )
} }
export default function Milestone() { export default function MilestoneAdministration() {
const { showToast, showErrorToast } = useNotifications() const { showToast, showErrorToast } = useNotifications()
const [settings, setSettings] = useState<MilestoneSettings | null>(null) const [settings, setSettings] = useState<MilestoneSettings | null>(null)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
@ -212,6 +237,11 @@ export default function Milestone() {
const [syncSteps, setSyncSteps] = useState<TaskListStep[]>(() => createMilestoneSyncSteps()) const [syncSteps, setSyncSteps] = useState<TaskListStep[]>(() => createMilestoneSyncSteps())
const [syncError, setSyncError] = useState<string | null>(null) const [syncError, setSyncError] = useState<string | null>(null)
const [syncCompleted, setSyncCompleted] = useState(false) const [syncCompleted, setSyncCompleted] = useState(false)
const [backupNasHost, setBackupNasHost] = useState('')
const [backupNasShare, setBackupNasShare] = useState('')
const [backupNasUsername, setBackupNasUsername] = useState('')
const [backupNasPassword, setBackupNasPassword] = useState('')
const [backupLogRootPath, setBackupLogRootPath] = useState('')
useEffect(() => { useEffect(() => {
void loadSettings() void loadSettings()
@ -291,6 +321,11 @@ export default function Milestone() {
if (event.settings) { if (event.settings) {
setSettings(event.settings) setSettings(event.settings)
setSkipTlsVerify(Boolean(event.settings.skipTlsVerify)) setSkipTlsVerify(Boolean(event.settings.skipTlsVerify))
setBackupNasHost(event.settings.backupNasHost ?? '')
setBackupNasShare(event.settings.backupNasShare ?? '')
setBackupNasUsername(event.settings.backupNasUsername ?? '')
setBackupNasPassword('')
setBackupLogRootPath(event.settings.backupLogRootPath ?? '')
} }
return return
@ -379,6 +414,11 @@ export default function Milestone() {
const data = await response.json() const data = await response.json()
setSettings(data.settings) setSettings(data.settings)
setSkipTlsVerify(Boolean(data.settings?.skipTlsVerify)) setSkipTlsVerify(Boolean(data.settings?.skipTlsVerify))
setBackupNasHost(String(data.settings?.backupNasHost ?? ''))
setBackupNasShare(String(data.settings?.backupNasShare ?? ''))
setBackupNasUsername(String(data.settings?.backupNasUsername ?? ''))
setBackupNasPassword('')
setBackupLogRootPath(String(data.settings?.backupLogRootPath ?? ''))
} catch (error) { } catch (error) {
showErrorToast({ showErrorToast({
title: 'Einstellungen konnten nicht geladen werden', title: 'Einstellungen konnten nicht geladen werden',
@ -428,6 +468,11 @@ export default function Milestone() {
serverFoldersArchiveRootPath: String( serverFoldersArchiveRootPath: String(
formData.get('serverFoldersArchiveRootPath') ?? 'E:/', formData.get('serverFoldersArchiveRootPath') ?? 'E:/',
), ),
backupNasHost,
backupNasShare,
backupNasUsername,
backupNasPassword,
backupLogRootPath,
}), }),
}) })
@ -446,12 +491,21 @@ export default function Milestone() {
setSettings(data.settings) setSettings(data.settings)
setSkipTlsVerify(Boolean(data.settings?.skipTlsVerify)) setSkipTlsVerify(Boolean(data.settings?.skipTlsVerify))
setBackupNasHost(data.settings.backupNasHost ?? '')
setBackupNasShare(data.settings.backupNasShare ?? '')
setBackupNasUsername(data.settings.backupNasUsername ?? '')
setBackupNasPassword('')
setBackupLogRootPath(data.settings.backupLogRootPath ?? '')
const passwordInput = form.elements.namedItem('password') const passwordInput = form.elements.namedItem('password')
if (passwordInput instanceof HTMLInputElement) { if (passwordInput instanceof HTMLInputElement) {
passwordInput.value = '' passwordInput.value = ''
} }
await runMilestoneSyncStream() showToast({
variant: 'success',
title: 'Einstellungen gespeichert',
message: 'Die Milestone-Einstellungen wurden gespeichert.',
})
} catch (error) { } catch (error) {
showErrorToast({ showErrorToast({
title: 'Speichern fehlgeschlagen', title: 'Speichern fehlgeschlagen',
@ -553,29 +607,30 @@ export default function Milestone() {
</div> </div>
</Modal> </Modal>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1fr)_40rem]"> <div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1fr)_28rem]">
<form <form
onSubmit={handleSubmit} onSubmit={handleSubmit}
className="rounded-lg bg-white p-4 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10" className="space-y-6"
> >
<div className="flex items-start gap-x-3"> <section className="rounded-lg bg-white p-5 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600 ring-1 ring-indigo-600/10 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20"> <div className="flex items-start gap-x-3 border-b border-gray-200 pb-5 dark:border-white/10">
<VideoCameraIcon aria-hidden="true" className="size-5" /> <div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600 ring-1 ring-indigo-600/10 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20">
<VideoCameraIcon aria-hidden="true" className="size-5" />
</div>
<div>
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
Milestone Videoserver
</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Hinterlege die Zugangsdaten für den Milestone Videoserver.
</p>
</div>
</div> </div>
<div> <div className="mt-5 grid grid-cols-1 gap-5 lg:grid-cols-6">
<h2 className="text-sm font-semibold text-gray-900 dark:text-white"> <div className="lg:col-span-2">
Milestone Videoserver
</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Hinterlege die Zugangsdaten für den Milestone Videoserver.
</p>
</div>
</div>
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<label <label
htmlFor="milestone-host" htmlFor="milestone-host"
className="block text-sm/6 font-medium text-gray-900 dark:text-white" className="block text-sm/6 font-medium text-gray-900 dark:text-white"
@ -596,7 +651,7 @@ export default function Milestone() {
</div> </div>
</div> </div>
<div className="sm:col-span-2"> <div className="lg:col-span-2">
<label <label
htmlFor="milestone-username" htmlFor="milestone-username"
className="block text-sm/6 font-medium text-gray-900 dark:text-white" className="block text-sm/6 font-medium text-gray-900 dark:text-white"
@ -617,7 +672,7 @@ export default function Milestone() {
</div> </div>
</div> </div>
<div className="sm:col-span-2"> <div className="lg:col-span-2">
<label <label
htmlFor="milestone-password" htmlFor="milestone-password"
className="block text-sm/6 font-medium text-gray-900 dark:text-white" className="block text-sm/6 font-medium text-gray-900 dark:text-white"
@ -640,13 +695,9 @@ export default function Milestone() {
className={inputClassName} className={inputClassName}
/> />
</div> </div>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
Das gespeicherte Kennwort wird nicht im Klartext angezeigt.
</p>
</div> </div>
<div className="sm:col-span-2"> <div className="lg:col-span-6">
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/5"> <div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/5">
<Switch <Switch
checked={skipTlsVerify} checked={skipTlsVerify}
@ -661,19 +712,27 @@ export default function Milestone() {
/> />
</div> </div>
</div> </div>
</div>
</section>
<div className="sm:col-span-2"> <section className="rounded-lg bg-white p-5 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/5"> <div>
<div> <div className="flex items-start gap-x-3 border-b border-gray-200 pb-5 dark:border-white/10">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white"> <div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600 ring-1 ring-indigo-600/10 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20">
TreeView-Agent <ServerStackIcon aria-hidden="true" className="size-5" />
</h3> </div>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Verbindung zum Server-Folders-Agent auf dem Milestone-/Recording-Server. <div>
</p> <h3 className="text-sm font-semibold text-gray-900 dark:text-white">
TreeView-Agent
</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Verbindung zum Server-Folders-Agent auf dem Milestone-/Recording-Server.
</p>
</div>
</div> </div>
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="mt-5 grid grid-cols-1 gap-4 sm:grid-cols-3">
<div> <div>
<label <label
htmlFor="server-folders-agent-scheme" htmlFor="server-folders-agent-scheme"
@ -796,7 +855,7 @@ export default function Milestone() {
</div> </div>
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-3">
<p className="rounded-md bg-gray-50 p-3 text-xs text-gray-500 ring-1 ring-gray-200 dark:bg-white/5 dark:text-gray-400 dark:ring-white/10"> <p className="rounded-md bg-gray-50 p-3 text-xs text-gray-500 ring-1 ring-gray-200 dark:bg-white/5 dark:text-gray-400 dark:ring-white/10">
Der Agent-Token wird beim Speichern automatisch erzeugt, an Der Agent-Token wird beim Speichern automatisch erzeugt, an
den Agent übertragen und verschlüsselt gespeichert. Der Agent den Agent übertragen und verschlüsselt gespeichert. Der Agent
@ -809,38 +868,166 @@ export default function Milestone() {
</div> </div>
</div> </div>
</div> </div>
</section>
<section className="rounded-lg bg-white p-5 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
<div>
<div className="flex items-start gap-x-3 border-b border-gray-200 pb-5 dark:border-white/10">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600 ring-1 ring-indigo-600/10 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20">
<ServerStackIcon aria-hidden="true" className="size-5" />
</div>
<div className="min-w-0 flex-1">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
Backup-Protokolle
</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
NAS-Zugang und Ordner mit den Robocopy-.log-Dateien.
</p>
</div>
</div>
<div className="mt-5 grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<label
htmlFor="backup-nas-host"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
NAS-IP / Hostname
</label>
<div className="mt-2">
<input
id="backup-nas-host"
type="text"
value={backupNasHost}
onChange={(event) => {
setBackupNasHost(event.target.value)
setBackupNasShare('')
setBackupLogRootPath('')
}}
placeholder="z. B. 192.168.178.20 oder NAS01"
className={inputClassName}
/>
</div>
</div>
<div>
<label
htmlFor="backup-nas-username"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Benutzername
</label>
<div className="mt-2">
<input
id="backup-nas-username"
type="text"
value={backupNasUsername}
onChange={(event) => setBackupNasUsername(event.target.value)}
placeholder="z. B. backup-user oder DOMÄNE\\backup-user"
autoComplete="off"
className={inputClassName}
/>
</div>
</div>
<div>
<label
htmlFor="backup-nas-password"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Kennwort {settings?.backupNasPasswordConfigured ? '(leer lassen = unverändert)' : ''}
</label>
<div className="mt-2">
<input
id="backup-nas-password"
type="password"
value={backupNasPassword}
onChange={(event) => setBackupNasPassword(event.target.value)}
autoComplete="new-password"
placeholder={
settings?.backupNasPasswordConfigured
? 'Gespeichertes Kennwort verwenden'
: ''
}
className={inputClassName}
/>
</div>
</div>
</div>
<div className="mt-6 border-t border-gray-200 pt-4 dark:border-white/10">
<div>
<BackupFolderPicker
value={backupLogRootPath}
nasHost={backupNasHost}
nasShare={backupNasShare}
nasUsername={backupNasUsername}
nasPassword={backupNasPassword}
storedPasswordAvailable={Boolean(
settings?.backupNasPasswordConfigured &&
backupNasHost.trim() === (settings.backupNasHost ?? '').trim() &&
backupNasUsername.trim() === (settings.backupNasUsername ?? '').trim(),
)}
onChange={setBackupLogRootPath}
onShareChange={setBackupNasShare}
disabled={isSaving}
/>
</div>
</div>
</div>
</section>
<div className="flex flex-col gap-3 rounded-lg bg-white p-4 shadow-sm ring-1 ring-gray-200 sm:flex-row sm:items-center sm:justify-between dark:bg-gray-900 dark:ring-white/10">
<p className="text-sm text-gray-500 dark:text-gray-400">
Speichern aktualisiert nur die Einstellungen. Der Milestone-Abruf
läuft separat.
</p>
<div className="flex justify-end gap-x-3">
<Button
type="button"
variant="secondary"
color="indigo"
isLoading={isFetchingToken}
disabled={!settings?.passwordConfigured || isSaving}
onClick={() => void handleFetchToken()}
>
Milestone synchronisieren
</Button>
<Button
type="submit"
color="indigo"
isLoading={isSaving}
>
Speichern
</Button>
</div> </div>
</div> </div>
<div className="mt-6 flex justify-end gap-x-3">
<Button
type="button"
variant="secondary"
color="indigo"
isLoading={isFetchingToken}
disabled={!settings?.passwordConfigured || isSaving}
onClick={() => void handleFetchToken()}
>
Token & Infos erneut abrufen
</Button>
<Button
type="submit"
color="indigo"
isLoading={isSaving}
>
Speichern
</Button>
</div>
</form> </form>
<aside className="rounded-lg bg-white p-4 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10"> <aside className="rounded-lg bg-white p-5 shadow-sm ring-1 ring-gray-200 xl:sticky xl:top-6 xl:self-start dark:bg-gray-900 dark:ring-white/10">
<h3 className="flex items-center gap-x-2 text-sm font-semibold text-gray-900 dark:text-white"> <div className="flex items-start gap-x-3 border-b border-gray-200 pb-5 dark:border-white/10">
<ServerStackIcon aria-hidden="true" className="size-5 text-gray-400" /> <div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-gray-50 text-gray-500 ring-1 ring-gray-200 dark:bg-white/5 dark:text-gray-400 dark:ring-white/10">
Status <ServerStackIcon aria-hidden="true" className="size-5" />
</h3> </div>
<dl className="mt-4 grid grid-cols-1 gap-x-6 gap-y-4 text-sm sm:grid-cols-2"> <div>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
Status
</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Aktuell gespeicherte Milestone-Konfiguration.
</p>
</div>
</div>
<dl className="mt-5 grid grid-cols-1 gap-x-6 gap-y-4 text-sm sm:grid-cols-2">
<div className="min-w-0"> <div className="min-w-0">
<dt className="text-gray-500 dark:text-gray-400"> <dt className="text-gray-500 dark:text-gray-400">
Server Server
@ -943,6 +1130,29 @@ export default function Milestone() {
</dd> </dd>
</div> </div>
<div className="min-w-0">
<dt className="text-gray-500 dark:text-gray-400">
Backup-NAS
</dt>
<dd className="mt-1 break-all font-medium text-gray-900 dark:text-white">
{settings?.backupNasHost && settings?.backupNasShare
? `\\\\${settings.backupNasHost}\\${settings.backupNasShare}`
: 'Nicht gespeichert'}
</dd>
</div>
<div>
<dt className="text-gray-500 dark:text-gray-400">
Backup-Kennwort
</dt>
<dd className="mt-1 flex items-center gap-x-2 font-medium text-gray-900 dark:text-white">
<KeyIcon aria-hidden="true" className="size-4 text-gray-400" />
{settings?.backupNasPasswordConfigured
? 'Gespeichert'
: 'Nicht gespeichert'}
</dd>
</div>
{settings?.tokenExpiresAt && ( {settings?.tokenExpiresAt && (
<div> <div>
<dt className="text-gray-500 dark:text-gray-400"> <dt className="text-gray-500 dark:text-gray-400">
@ -992,3 +1202,390 @@ export default function Milestone() {
</> </>
) )
} }
function normalizeBackupPath(value: string) {
return value.trim().replace(/\//g, '\\').replace(/[\\]+$/, '').toLowerCase()
}
function normalizeBackupDisplayPath(value: string, nasHost?: string) {
const cleanValue = value.trim().replace(/\//g, '\\').replace(/[\\]+$/, '')
if (!cleanValue) {
return ''
}
if (cleanValue.startsWith('\\\\')) {
return `\\\\${cleanValue.replace(/^\\+/, '')}`
}
const cleanHost = nasHost?.trim().replace(/\//g, '\\').replace(/[\\]+$/, '')
if (
cleanHost &&
cleanValue.toLowerCase().startsWith(`${cleanHost.toLowerCase()}\\`)
) {
return `\\\\${cleanValue}`
}
return cleanValue
}
function isBackupPathInsideRoot(path: string, rootPath: string) {
const normalizedPath = normalizeBackupPath(path)
const normalizedRoot = normalizeBackupPath(rootPath)
return (
normalizedPath === normalizedRoot ||
normalizedPath.startsWith(`${normalizedRoot}\\`)
)
}
function backupFolderNameFromPath(path: string) {
const cleanPath = path.trim().replace(/[\\/]+$/, '')
const parts = cleanPath.split(/[\\/]+/).filter(Boolean)
return parts[parts.length - 1] || cleanPath || path
}
function backupFolderNodeFromItem(item: BackupFolderItem): TreeListNode {
const name = item.displayName?.trim() || item.name || backupFolderNameFromPath(item.path)
return {
id: item.path,
name,
path: item.path,
children: item.hasChildren ? [] : undefined,
}
}
function joinBackupPath(basePath: string, childName: string) {
const base = basePath.trim().replace(/[\\/]+$/, '')
const child = childName.trim().replace(/^[\\/]+/, '')
if (!base) {
return child
}
if (!child) {
return base
}
return `${base}\\${child}`
}
function buildBackupFolderBranch(
rootPath: string,
currentPath: string,
folders: BackupFolderItem[],
): TreeListNode[] {
const cleanRootPath = rootPath.trim().replace(/[\\/]+$/, '')
const cleanCurrentPath = currentPath.trim().replace(/[\\/]+$/, '')
const folderNodes = folders.map(backupFolderNodeFromItem)
if (
!cleanRootPath ||
!cleanCurrentPath ||
normalizeBackupPath(cleanRootPath) === normalizeBackupPath(cleanCurrentPath)
) {
return folderNodes
}
if (!isBackupPathInsideRoot(cleanCurrentPath, cleanRootPath)) {
return []
}
let relativePath = cleanCurrentPath
if (normalizeBackupPath(relativePath).startsWith(normalizeBackupPath(cleanRootPath))) {
relativePath = relativePath.slice(cleanRootPath.length)
}
const segments = relativePath.replace(/^[\\/]+/, '').split(/[\\/]+/).filter(Boolean)
if (segments.length === 0) {
return folderNodes
}
let runningPath = cleanRootPath
let rootBranchNode: TreeListNode | null = null
let currentNode: TreeListNode | null = null
for (const segment of segments) {
runningPath = joinBackupPath(runningPath, segment)
const nextNode: TreeListNode = {
id: runningPath,
name: segment,
path: runningPath,
children: [],
}
if (!rootBranchNode) {
rootBranchNode = nextNode
}
if (currentNode) {
currentNode.children = [nextNode]
}
currentNode = nextNode
}
if (currentNode) {
currentNode.children = folderNodes
}
return rootBranchNode ? [rootBranchNode] : folderNodes
}
function buildBackupFolderTree(response: BackupFoldersResponse): TreeListNode[] {
const roots = Array.isArray(response.roots) ? response.roots : []
const folders = Array.isArray(response.folders) ? response.folders : []
if (roots.length === 0) {
return folders.map(backupFolderNodeFromItem)
}
return roots.map((root) => ({
id: root.path,
name: root.label || root.path,
path: root.path,
children: isBackupPathInsideRoot(response.path, root.path)
? buildBackupFolderBranch(root.path, response.path, folders)
: [],
}))
}
function getBackupShareFromPath(path: string) {
const cleanPath = normalizeBackupDisplayPath(path)
if (!cleanPath.startsWith('\\\\')) {
return ''
}
const parts = cleanPath.slice(2).split('\\').filter(Boolean)
return parts[1] ?? ''
}
function BackupFolderPicker({
value,
nasHost,
nasShare,
nasUsername,
nasPassword,
storedPasswordAvailable,
onChange,
onShareChange,
disabled,
}: {
value: string
nasHost: string
nasShare: string
nasUsername: string
nasPassword: string
storedPasswordAvailable: boolean
onChange: (value: string) => void
onShareChange: (value: string) => void
disabled?: boolean
}) {
const [nodes, setNodes] = useState<TreeListNode[]>([])
const [rootPath, setRootPath] = useState('')
const [currentPath, setCurrentPath] = useState('')
const [error, setError] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const loadAbortControllerRef = useRef<AbortController | null>(null)
const canLoadBackupFolders =
Boolean(nasHost.trim()) &&
Boolean(nasUsername.trim()) &&
(Boolean(nasPassword) || storedPasswordAvailable)
useEffect(() => {
return () => {
loadAbortControllerRef.current?.abort()
}
}, [])
async function loadFolders(path?: string, shareOverride?: string) {
const cleanHost = nasHost.trim()
const cleanUsername = nasUsername.trim()
const requestedPath = normalizeBackupDisplayPath(path ?? '', cleanHost)
const pathShare = getBackupShareFromPath(requestedPath)
const requestedShare = shareOverride ?? (pathShare || nasShare.trim())
if (!cleanHost || !cleanUsername || (!nasPassword && !storedPasswordAvailable)) {
setNodes([])
setRootPath('')
setCurrentPath('')
setError(null)
return
}
setIsLoading(true)
setError(null)
loadAbortControllerRef.current?.abort()
const abortController = new AbortController()
loadAbortControllerRef.current = abortController
const timeoutId = window.setTimeout(() => {
abortController.abort()
}, 20000)
try {
const response = await fetch(
`${API_URL}/admin/milestone/backup-folders/browse`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
signal: abortController.signal,
body: JSON.stringify({
nasHost: cleanHost,
nasShare: requestedShare,
nasUsername: cleanUsername,
nasPassword,
path: requestedPath,
}),
},
)
if (!response.ok) {
setError(
await readApiError(
response,
'Backup-Ordner konnten nicht geladen werden.',
),
)
return
}
const data = (await response.json()) as BackupFoldersResponse
const nextRootPath = data.roots?.[0]?.path ?? ''
const nextPath = data.path || requestedPath || nextRootPath
const nextShare = getBackupShareFromPath(nextPath || nextRootPath)
setNodes(buildBackupFolderTree(data))
setRootPath(nextRootPath)
setCurrentPath(nextPath)
setError(data.warning ?? null)
if (nextShare) {
onShareChange(nextShare)
}
if (nextPath && value.trim() !== nextPath) {
onChange(nextPath)
}
} catch (error) {
if (abortController.signal.aborted) {
if (loadAbortControllerRef.current === abortController) {
setError(
'NAS hat nicht rechtzeitig geantwortet. Prüfe Hostname, Benutzername, Domäne und Netzwerkverbindung.',
)
}
return
}
setError(
error instanceof Error
? error.message
: 'Backup-Ordner konnten nicht geladen werden.',
)
} finally {
window.clearTimeout(timeoutId)
if (loadAbortControllerRef.current === abortController) {
loadAbortControllerRef.current = null
setIsLoading(false)
}
}
}
useEffect(() => {
if (!canLoadBackupFolders) {
setNodes([])
setRootPath('')
setCurrentPath('')
setError(null)
return
}
const timeoutId = window.setTimeout(() => {
const selectedPath = normalizeBackupDisplayPath(value, nasHost)
const selectedShare = selectedPath ? getBackupShareFromPath(selectedPath) : ''
void loadFolders(selectedPath || undefined, selectedShare || '')
}, 500)
return () => {
window.clearTimeout(timeoutId)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nasHost, nasUsername, nasPassword, storedPasswordAvailable])
function handleSelectedPathChange(path: string) {
const nextPath = normalizeBackupDisplayPath(path, nasHost)
const nextShare = getBackupShareFromPath(nextPath)
if (nextShare) {
onShareChange(nextShare)
}
onChange(nextPath)
void loadFolders(nextPath, nextShare)
}
function handleLoadShares() {
onShareChange('')
onChange('')
setRootPath('')
setCurrentPath('')
void loadFolders('', '')
}
return (
<div>
{error && (
<div className="mb-3 rounded-md bg-amber-50 p-3 text-sm text-amber-800 ring-1 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-200 dark:ring-amber-400/20">
{error}
</div>
)}
<div className="mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-gray-500 dark:text-gray-400">
Lade die Freigaben des NAS und wähle darunter den Ordner mit den
Robocopy-.log-Dateien aus.
</p>
<Button
type="button"
variant="secondary"
color="indigo"
size="sm"
isLoading={isLoading}
disabled={disabled || isLoading || !canLoadBackupFolders}
onClick={handleLoadShares}
>
Neu laden
</Button>
</div>
<TreeList
label="Freigabe und Ordner auswählen"
description={
canLoadBackupFolders
? 'Freigabe auswählen, danach den passenden Ordner darunter anklicken.'
: 'Trage NAS-IP, Benutzername und Kennwort ein.'
}
selectedPath={value || currentPath}
nodes={nodes}
rootPath={rootPath || currentPath || value}
onSelectedPathChange={handleSelectedPathChange}
disabled={disabled || isLoading || !canLoadBackupFolders}
showFallbackNodes
emptyMessage="Noch keine Freigaben geladen."
showNewFolderInput={false}
pathPlaceholder="\\\\NAS\\Freigabe\\Ordner"
/>
</div>
)
}

View File

@ -369,7 +369,6 @@ export default function UsersAdministration() {
const unit = user.unit.trim() const unit = user.unit.trim()
const label = unit || 'Ohne Einheit' const label = unit || 'Ohne Einheit'
const key = unit ? unit.toLowerCase() : '__without_unit__' const key = unit ? unit.toLowerCase() : '__without_unit__'
const existingGroup = groups.get(key) const existingGroup = groups.get(key)
if (existingGroup) { if (existingGroup) {
@ -895,13 +894,12 @@ export default function UsersAdministration() {
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{groupedFilteredUsers.map((group) => ( {groupedFilteredUsers.map((group) => (
<div key={group.key}> <section key={group.key}>
<div className="sticky top-0 z-10 -mx-2 bg-white/95 px-3 py-1.5 backdrop-blur dark:bg-gray-900/95"> <div className="sticky top-0 z-10 -mx-2 bg-white/95 px-3 py-1.5 backdrop-blur dark:bg-gray-900/95">
<div className="flex items-center justify-between gap-x-2"> <div className="flex items-center justify-between gap-2">
<h3 className="truncate text-[11px] font-semibold tracking-wide text-gray-500 uppercase dark:text-gray-400"> <h3 className="truncate text-[11px] font-semibold tracking-wide text-gray-500 uppercase dark:text-gray-400">
{group.label} {group.label}
</h3> </h3>
<span className="shrink-0 rounded-full bg-gray-100 px-2 py-0.5 text-[10px] font-medium text-gray-500 dark:bg-white/10 dark:text-gray-400"> <span className="shrink-0 rounded-full bg-gray-100 px-2 py-0.5 text-[10px] font-medium text-gray-500 dark:bg-white/10 dark:text-gray-400">
{group.users.length} {group.users.length}
</span> </span>
@ -936,35 +934,19 @@ export default function UsersAdministration() {
/> />
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="flex items-center gap-x-2"> <span className="block truncate font-medium">
<span className="truncate font-medium"> {displayName}
{displayName}
</span>
{user.rights.includes('admin') && (
<span className="shrink-0 rounded-md bg-indigo-50 px-1.5 py-0.5 text-[10px] font-medium text-indigo-700 ring-1 ring-indigo-600/20 ring-inset dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20">
Admin
</span>
)}
</span> </span>
<span className="block truncate text-xs text-gray-500 dark:text-gray-400"> <span className="block truncate text-xs text-gray-500 dark:text-gray-400">
{user.email} {getUserRoleLabel(user.group)}
</span>
<span className="mt-1 flex flex-wrap gap-1">
{user.group && (
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-600 dark:bg-white/10 dark:text-gray-300">
{getUserRoleLabel(user.group)}
</span>
)}
</span> </span>
</span> </span>
</button> </button>
) )
})} })}
</div> </div>
</div> </section>
))} ))}
</div> </div>
)} )}
@ -1073,7 +1055,7 @@ export default function UsersAdministration() {
name={selectedUser ? getUserDisplayName(selectedUser) : 'Neuer Benutzer'} name={selectedUser ? getUserDisplayName(selectedUser) : 'Neuer Benutzer'}
initials={selectedUser ? getUserInitials(selectedUser) : 'NB'} initials={selectedUser ? getUserInitials(selectedUser) : 'NB'}
size={16} size={16}
shape="rounded" shape="circle"
/> />
<div className="min-w-0"> <div className="min-w-0">

View File

@ -585,28 +585,23 @@ export default function CalendarPage() {
await deleteEventSeries() await deleteEventSeries()
} }
const calendarStatus = pageError
? { tone: 'danger' as const, text: pageError }
: eventsLoading
? { tone: 'loading' as const, text: 'Kalendereinträge werden geladen ...' }
: null
const inputClassName = const inputClassName =
'block w-full rounded-xl border-0 bg-gray-100 px-3 py-2.5 text-sm text-gray-900 outline-none ring-1 ring-transparent focus:bg-white focus:ring-indigo-500 dark:bg-white/5 dark:text-white dark:focus:bg-white/10' 'block w-full rounded-xl border-0 bg-gray-100 px-3 py-2.5 text-sm text-gray-900 outline-none ring-1 ring-transparent focus:bg-white focus:ring-indigo-500 dark:bg-white/5 dark:text-white dark:focus:bg-white/10'
return ( return (
<> <>
<div className="flex h-full min-h-0 flex-col"> <div className="flex h-full min-h-0 flex-col">
{(pageError || eventsLoading) && (
<div
className={classNames(
'border-b px-4 py-2 text-sm sm:px-6',
pageError
? 'border-red-200 bg-red-50 text-red-700 dark:border-red-500/20 dark:bg-red-500/10 dark:text-red-300'
: 'border-gray-200 bg-gray-50 text-gray-500 dark:border-white/10 dark:bg-white/5 dark:text-gray-300',
)}
>
{pageError || 'Kalendereinträge werden geladen ...'}
</div>
)}
<div className="min-h-0 flex-1"> <div className="min-h-0 flex-1">
<Calendar <Calendar
events={events} events={events}
coreWorkingHours={calendarSettings.coreWorkingHours} coreWorkingHours={calendarSettings.coreWorkingHours}
status={calendarStatus}
selectedDate={selectedDate} selectedDate={selectedDate}
view={view} view={view}
onSelectedDateChange={setSelectedDate} onSelectedDateChange={setSelectedDate}
@ -1049,7 +1044,7 @@ export default function CalendarPage() {
</div> </div>
<div className="space-y-4 p-5"> <div className="space-y-4 p-5">
<p className="text-sm text-gray-600 dark:text-gray-300"> <p className="text-sm text-gray-600 dark:text-gray-300">
Soll die Änderung nur für diesen Termin oder für die ganze Serie Soll die Änderung nur für diesen Termin oder für alle Termine
übernommen werden? übernommen werden?
</p> </p>
{formError && ( {formError && (
@ -1103,7 +1098,7 @@ export default function CalendarPage() {
}} }}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-indigo-500 dark:hover:bg-indigo-400" className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-indigo-500 dark:hover:bg-indigo-400"
> >
Ganze Serie Alle Termine
</button> </button>
</div> </div>
</Modal> </Modal>

File diff suppressed because it is too large Load Diff

View File

@ -12,7 +12,7 @@ import {
import Modal from '../../components/Modal' import Modal from '../../components/Modal'
import Button from '../../components/Button' import Button from '../../components/Button'
import type { Device } from '../../components/types' import type { Device } from '../../components/types'
import DeviceFormFields from './DeviceFormFields' import DeviceFormFields, { type DeviceCategory } from './DeviceFormFields'
import DeviceModalTabs, { type DeviceModalTabId } from './DeviceModalTabs' import DeviceModalTabs, { type DeviceModalTabId } from './DeviceModalTabs'
type CreateDeviceModalProps = { type CreateDeviceModalProps = {
@ -20,6 +20,12 @@ type CreateDeviceModalProps = {
setOpen: (open: boolean) => void setOpen: (open: boolean) => void
isSaving: boolean isSaving: boolean
error: string | null error: string | null
title?: string
description?: string
submitLabel?: string
initialDeviceCategory?: DeviceCategory
lockedDeviceCategory?: DeviceCategory
hideDeviceCategorySelector?: boolean
devices: Device[] devices: Device[]
relatedDeviceIds: string[] relatedDeviceIds: string[]
onToggleRelatedDevice: (deviceId: string) => void onToggleRelatedDevice: (deviceId: string) => void
@ -53,6 +59,12 @@ export default function CreateDeviceModal({
setOpen, setOpen,
isSaving, isSaving,
error, error,
title = 'Gerät hinzufügen',
description = 'Wähle die Geräteart und erfasse nur die passenden Daten.',
submitLabel = 'Gerät speichern',
initialDeviceCategory,
lockedDeviceCategory,
hideDeviceCategorySelector,
devices, devices,
relatedDeviceIds, relatedDeviceIds,
onToggleRelatedDevice, onToggleRelatedDevice,
@ -120,11 +132,11 @@ export default function CreateDeviceModal({
<div className="flex items-start justify-between border-b border-gray-200 bg-gray-100 px-4 py-4 sm:px-6 dark:border-white/10 dark:bg-gray-800"> <div className="flex items-start justify-between border-b border-gray-200 bg-gray-100 px-4 py-4 sm:px-6 dark:border-white/10 dark:bg-gray-800">
<div> <div>
<DialogTitle className="text-base font-semibold text-gray-900 dark:text-white"> <DialogTitle className="text-base font-semibold text-gray-900 dark:text-white">
Gerät hinzufügen {title}
</DialogTitle> </DialogTitle>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400"> <p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Wähle die Geräteart und erfasse nur die passenden Daten. {description}
</p> </p>
</div> </div>
@ -157,6 +169,9 @@ export default function CreateDeviceModal({
<DeviceFormFields <DeviceFormFields
open={open} open={open}
activeTab={activeTab} activeTab={activeTab}
initialDeviceCategory={initialDeviceCategory}
lockedDeviceCategory={lockedDeviceCategory}
hideDeviceCategorySelector={hideDeviceCategorySelector}
devices={devices} devices={devices}
relatedDeviceIds={relatedDeviceIds} relatedDeviceIds={relatedDeviceIds}
onToggleRelatedDevice={onToggleRelatedDevice} onToggleRelatedDevice={onToggleRelatedDevice}
@ -180,7 +195,7 @@ export default function CreateDeviceModal({
isLoading={isSaving} isLoading={isSaving}
leadingIcon={<CheckIcon aria-hidden="true" className="size-4" />} leadingIcon={<CheckIcon aria-hidden="true" className="size-4" />}
> >
Gerät speichern {submitLabel}
</Button> </Button>
</div> </div>
</form> </form>

View 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}
/>
)
}

View File

@ -23,10 +23,10 @@ import MilestoneChildDeviceCards from './MilestoneChildDeviceCards'
import type { Device } from '../../components/types' import type { Device } from '../../components/types'
import { import {
ArrowsRightLeftIcon, ArrowsRightLeftIcon,
CircleStackIcon,
ComputerDesktopIcon, ComputerDesktopIcon,
MagnifyingGlassIcon, MagnifyingGlassIcon,
MicrophoneIcon, MicrophoneIcon,
QuestionMarkCircleIcon,
VideoCameraIcon, VideoCameraIcon,
WifiIcon, WifiIcon,
} from '@heroicons/react/20/solid' } from '@heroicons/react/20/solid'
@ -37,6 +37,9 @@ type DeviceFormFieldsProps = {
open: boolean open: boolean
activeTab: DeviceModalTabId activeTab: DeviceModalTabId
device?: Device | null device?: Device | null
initialDeviceCategory?: DeviceCategory
lockedDeviceCategory?: DeviceCategory
hideDeviceCategorySelector?: boolean
devices: Device[] devices: Device[]
relatedDeviceIds: string[] relatedDeviceIds: string[]
onToggleRelatedDevice: (deviceId: string) => void onToggleRelatedDevice: (deviceId: string) => void
@ -63,7 +66,7 @@ type DeviceFormFieldsProps = {
export const inputClassName = export const inputClassName =
'block w-full rounded-md bg-white px-3.5 py-2 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 transition focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500' 'block w-full rounded-md bg-white px-3.5 py-2 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 transition focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
type DeviceCategory = 'Kameras' | 'Router' | 'Switchbox' | 'Laptop' | 'Festplatte' export type DeviceCategory = 'Kameras' | 'Router' | 'Switchbox' | 'Laptop' | 'Andere'
type DeviceLoanUser = { type DeviceLoanUser = {
id: string id: string
@ -119,10 +122,10 @@ const deviceKindOptions: DeviceKindOption[] = [
icon: ComputerDesktopIcon, icon: ComputerDesktopIcon,
}, },
{ {
category: 'Festplatte', category: 'Andere',
label: 'Festplatte', label: 'Andere',
description: 'Modell und Seriennummer genügen meistens.', description: 'Allgemeine Gerätedaten erfassen.',
icon: CircleStackIcon, icon: QuestionMarkCircleIcon,
}, },
] ]
@ -130,17 +133,29 @@ function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(' ') return classes.filter(Boolean).join(' ')
} }
function getInitialDeviceCategory(device: Device | null | undefined, isMilestoneDevice: boolean): DeviceCategory { function getInitialDeviceCategory(
device: Device | null | undefined,
isMilestoneDevice: boolean,
fallbackCategory?: DeviceCategory,
): DeviceCategory {
if (isMilestoneDevice) { if (isMilestoneDevice) {
return 'Kameras' return 'Kameras'
} }
if (fallbackCategory) {
return fallbackCategory
}
const category = device?.deviceCategory?.trim() const category = device?.deviceCategory?.trim()
if (deviceKindOptions.some((option) => option.category === category)) { if (deviceKindOptions.some((option) => option.category === category)) {
return category as DeviceCategory return category as DeviceCategory
} }
if (category) {
return 'Andere'
}
return 'Kameras' return 'Kameras'
} }
@ -259,6 +274,7 @@ async function createLookupOption(
} }
const data = await response.json() const data = await response.json()
clearDeviceLookupCache()
return data.option as ComboboxItem return data.option as ComboboxItem
} }
@ -291,6 +307,11 @@ type DeviceLookupData = {
let deviceLookupCache: DeviceLookupData | null = null let deviceLookupCache: DeviceLookupData | null = null
let deviceLookupPromise: Promise<DeviceLookupData> | null = null let deviceLookupPromise: Promise<DeviceLookupData> | null = null
export function clearDeviceLookupCache() {
deviceLookupCache = null
deviceLookupPromise = null
}
async function fetchDeviceLookups() { async function fetchDeviceLookups() {
if (deviceLookupCache) { if (deviceLookupCache) {
return deviceLookupCache return deviceLookupCache
@ -366,6 +387,9 @@ export default function DeviceFormFields({
open, open,
activeTab, activeTab,
device, device,
initialDeviceCategory,
lockedDeviceCategory,
hideDeviceCategorySelector = false,
devices, devices,
relatedDeviceIds, relatedDeviceIds,
onToggleRelatedDevice, onToggleRelatedDevice,
@ -392,7 +416,11 @@ export default function DeviceFormFields({
const [selectedManufacturer, setSelectedManufacturer] = useState<ComboboxItem | null>(null) const [selectedManufacturer, setSelectedManufacturer] = useState<ComboboxItem | null>(null)
const [selectedLocation, setSelectedLocation] = useState<ComboboxItem | null>(null) const [selectedLocation, setSelectedLocation] = useState<ComboboxItem | null>(null)
const [selectedDeviceCategory, setSelectedDeviceCategory] = useState<DeviceCategory>(() => const [selectedDeviceCategory, setSelectedDeviceCategory] = useState<DeviceCategory>(() =>
getInitialDeviceCategory(device, isMilestoneDevice), getInitialDeviceCategory(
device,
isMilestoneDevice,
lockedDeviceCategory ?? initialDeviceCategory,
),
) )
const [selectedLoanStatus, setSelectedLoanStatus] = useState(device?.loanStatus || 'Verfügbar') const [selectedLoanStatus, setSelectedLoanStatus] = useState(device?.loanStatus || 'Verfügbar')
const [loanUsers, setLoanUsers] = useState<ComboboxItem[]>([]) const [loanUsers, setLoanUsers] = useState<ComboboxItem[]>([])
@ -428,7 +456,13 @@ export default function DeviceFormFields({
: null, : null,
) )
setSelectedDeviceCategory(getInitialDeviceCategory(device, isMilestoneDevice)) setSelectedDeviceCategory(
getInitialDeviceCategory(
device,
isMilestoneDevice,
lockedDeviceCategory ?? initialDeviceCategory,
),
)
setSelectedLoanStatus(device?.loanStatus || 'Verfügbar') setSelectedLoanStatus(device?.loanStatus || 'Verfügbar')
setLoanedUntil(device?.loanedUntil ?? '') setLoanedUntil(device?.loanedUntil ?? '')
setIsLoanModalOpen(false) setIsLoanModalOpen(false)
@ -437,7 +471,7 @@ export default function DeviceFormFields({
setLoanModalError(null) setLoanModalError(null)
void loadDeviceLookups() void loadDeviceLookups()
}, [open, device?.id, isMilestoneDevice]) }, [open, device?.id, initialDeviceCategory, isMilestoneDevice, lockedDeviceCategory])
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
@ -483,7 +517,10 @@ export default function DeviceFormFields({
const milestoneChildDevices = device?.milestoneChildDevices ?? [] const milestoneChildDevices = device?.milestoneChildDevices ?? []
const visibleCategoryFields = getCategoryFieldVisibility(selectedDeviceCategory) const effectiveDeviceCategory = isMilestoneDevice
? 'Kameras'
: lockedDeviceCategory ?? selectedDeviceCategory
const visibleCategoryFields = getCategoryFieldVisibility(effectiveDeviceCategory)
const loanedToLabel = const loanedToLabel =
selectedLoanStatus === 'Ausgeliehen' selectedLoanStatus === 'Ausgeliehen'
? selectedLoanUser?.name || device?.loanedToDisplayName || '' ? selectedLoanUser?.name || device?.loanedToDisplayName || ''
@ -780,63 +817,65 @@ export default function DeviceFormFields({
/> />
</div> </div>
<div className="sm:col-span-6"> <div className={hideDeviceCategorySelector ? 'hidden' : 'sm:col-span-6'}>
<input <input
type="hidden" type="hidden"
name="deviceCategory" name="deviceCategory"
value={isMilestoneDevice ? 'Kameras' : selectedDeviceCategory} value={effectiveDeviceCategory}
/> />
<fieldset disabled={isMilestoneDevice}> {!hideDeviceCategorySelector && (
<legend className="block text-sm/6 font-medium text-gray-900 dark:text-white"> <fieldset disabled={isMilestoneDevice || Boolean(lockedDeviceCategory)}>
Geräteart <legend className="block text-sm/6 font-medium text-gray-900 dark:text-white">
</legend> Geräteart
</legend>
<div className="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-5"> <div className="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-5">
{deviceKindOptions.map((option) => { {deviceKindOptions.map((option) => {
const selected = selectedDeviceCategory === option.category const selected = effectiveDeviceCategory === option.category
const Icon = option.icon const Icon = option.icon
return ( return (
<button <button
key={option.category} key={option.category}
type="button" type="button"
aria-pressed={selected} aria-pressed={selected}
disabled={isMilestoneDevice} disabled={isMilestoneDevice || Boolean(lockedDeviceCategory)}
onClick={() => setSelectedDeviceCategory(option.category)} onClick={() => setSelectedDeviceCategory(option.category)}
className={classNames( className={classNames(
'group flex h-full min-h-24 flex-col items-start rounded-lg border p-2.5 text-left transition focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:cursor-not-allowed dark:focus-visible:outline-indigo-500', 'group flex h-full min-h-24 flex-col items-start rounded-lg border p-2.5 text-left transition focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:cursor-not-allowed dark:focus-visible:outline-indigo-500',
selected selected
? 'border-indigo-500 bg-indigo-50 text-indigo-950 ring-1 ring-indigo-500 dark:border-indigo-400 dark:bg-indigo-500/10 dark:text-indigo-100 dark:ring-indigo-400' ? 'border-indigo-500 bg-indigo-50 text-indigo-950 ring-1 ring-indigo-500 dark:border-indigo-400 dark:bg-indigo-500/10 dark:text-indigo-100 dark:ring-indigo-400'
: 'border-gray-200 bg-white text-gray-900 hover:border-indigo-300 hover:bg-indigo-50/60 dark:border-white/10 dark:bg-white/[0.03] dark:text-white dark:hover:border-indigo-400/50 dark:hover:bg-indigo-500/10', : 'border-gray-200 bg-white text-gray-900 hover:border-indigo-300 hover:bg-indigo-50/60 dark:border-white/10 dark:bg-white/[0.03] dark:text-white dark:hover:border-indigo-400/50 dark:hover:bg-indigo-500/10',
isMilestoneDevice && !selected && 'opacity-45', isMilestoneDevice && !selected && 'opacity-45',
)} )}
> >
<span className="flex w-full items-center gap-2"> <span className="flex w-full items-center gap-2">
<span <span
className={classNames( className={classNames(
'flex size-8 shrink-0 items-center justify-center rounded-md', 'flex size-8 shrink-0 items-center justify-center rounded-md',
selected selected
? 'bg-indigo-600 text-white dark:bg-indigo-400 dark:text-gray-950' ? 'bg-indigo-600 text-white dark:bg-indigo-400 dark:text-gray-950'
: 'bg-gray-100 text-gray-500 group-hover:bg-indigo-100 group-hover:text-indigo-600 dark:bg-white/10 dark:text-gray-400 dark:group-hover:bg-indigo-500/15 dark:group-hover:text-indigo-300', : 'bg-gray-100 text-gray-500 group-hover:bg-indigo-100 group-hover:text-indigo-600 dark:bg-white/10 dark:text-gray-400 dark:group-hover:bg-indigo-500/15 dark:group-hover:text-indigo-300',
)} )}
> >
<Icon aria-hidden="true" className="size-4" /> <Icon aria-hidden="true" className="size-4" />
</span>
<span className="min-w-0 truncate text-sm font-semibold">
{option.label}
</span>
</span> </span>
<span className="min-w-0 truncate text-sm font-semibold"> <span className="mt-2 text-xs leading-4 text-gray-500 dark:text-gray-400">
{option.label} {option.description}
</span> </span>
</span> </button>
)
<span className="mt-2 text-xs leading-4 text-gray-500 dark:text-gray-400"> })}
{option.description} </div>
</span> </fieldset>
</button> )}
)
})}
</div>
</fieldset>
</div> </div>
<div className="sm:col-span-3"> <div className="sm:col-span-3">

View File

@ -1,12 +1,23 @@
// frontend\src\pages\devices\DevicesPage.tsx // frontend\src\pages\devices\DevicesPage.tsx
import { useEffect, useRef, useState, type ComponentProps, type FormEvent, type MouseEvent } from 'react' import {
useEffect,
useMemo,
useRef,
useState,
type ComponentProps,
type FormEvent,
type MouseEvent,
type ReactNode,
} from 'react'
import { import {
ArrowDownTrayIcon, ArrowDownTrayIcon,
ArrowPathIcon, ArrowPathIcon,
CheckCircleIcon, CheckCircleIcon,
ClockIcon, ClockIcon,
PlusIcon, PlusIcon,
TagIcon,
TrashIcon,
WrenchScrewdriverIcon, WrenchScrewdriverIcon,
XCircleIcon, XCircleIcon,
} from '@heroicons/react/20/solid' } from '@heroicons/react/20/solid'
@ -15,8 +26,9 @@ import { Badge } from '../../components/Badge'
import CreateDeviceModal from './CreateDeviceModal' import CreateDeviceModal from './CreateDeviceModal'
import EditDeviceModal from './EditDeviceModal' import EditDeviceModal from './EditDeviceModal'
import CameraDetailsModal from './CameraDetailsModal' import CameraDetailsModal from './CameraDetailsModal'
import Tables, { type TableColumn } from '../../components/Tables' import Tables, { type TableColumn, type TableSortValue } from '../../components/Tables'
import LoadingSpinner from '../../components/LoadingSpinner' import LoadingSpinner from '../../components/LoadingSpinner'
import Modal, { ModalTitle } from '../../components/Modal'
import type { import type {
Device, Device,
DeviceHistoryEntry, DeviceHistoryEntry,
@ -26,6 +38,12 @@ import type {
User, User,
} from '../../components/types' } from '../../components/types'
import { useNotifications } from '../../components/Notifications' import { useNotifications } from '../../components/Notifications'
import { hasRight } from '../../components/permissions'
import {
clearDeviceLookupCache,
inputClassName,
type DeviceCategory,
} from './DeviceFormFields'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080' const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
@ -37,6 +55,91 @@ type FirmwareCheckState = {
remainingSeconds?: number remainingSeconds?: number
} }
type DeviceLookupOption = {
id: string
name: string
}
type DeviceSortDirection = 'asc' | 'desc'
const deviceSortCollator = new Intl.Collator('de-DE', {
numeric: true,
sensitivity: 'base',
})
function normalizeLookupName(value: string) {
return value.trim().toLowerCase()
}
function normalizeDeviceSortValue(value: TableSortValue) {
if (value instanceof Date) {
return value.getTime()
}
if (typeof value === 'boolean') {
return value ? 1 : 0
}
if (typeof value === 'string') {
return value.trim()
}
return value
}
function compareDeviceSortValues(leftValue: TableSortValue, rightValue: TableSortValue) {
const left = normalizeDeviceSortValue(leftValue)
const right = normalizeDeviceSortValue(rightValue)
if (left === right) {
return 0
}
if (left === null || left === undefined || left === '') {
return 1
}
if (right === null || right === undefined || right === '') {
return -1
}
if (typeof left === 'number' && typeof right === 'number') {
return left - right
}
return deviceSortCollator.compare(String(left), String(right))
}
function sortLookupOptions(options: DeviceLookupOption[]) {
return [...options].sort((left, right) =>
left.name.localeCompare(right.name, 'de-DE', {
numeric: true,
sensitivity: 'base',
}),
)
}
function upsertLookupOption(options: DeviceLookupOption[], option: DeviceLookupOption) {
const normalizedName = normalizeLookupName(option.name)
const withoutDuplicate = options.filter(
(currentOption) => normalizeLookupName(currentOption.name) !== normalizedName,
)
return sortLookupOptions([...withoutDuplicate, option])
}
function formatDeviceUsageCount(count: number) {
if (count === 0) {
return 'Nicht verwendet'
}
if (count === 1) {
return '1 Gerät'
}
return `${count.toLocaleString('de-DE')} Geräte`
}
function getLoanStatusBadgeIcon(status: string) { function getLoanStatusBadgeIcon(status: string) {
const normalizedStatus = status.toLowerCase() const normalizedStatus = status.toLowerCase()
@ -246,15 +349,93 @@ function getAvailableResolutions(
type DevicesPageProps = { type DevicesPageProps = {
currentUser?: User | null currentUser?: User | null
title?: string
description?: string
createButtonLabel?: string
createModalTitle?: string
createModalDescription?: string
createSubmitLabel?: string
editModalTitle?: string
emptyText?: string
loadingLabel?: string
deviceCategoryFilter?: DeviceCategory
showFirmwareCheckButton?: boolean
showStats?: boolean
} }
export default function DevicesPage({ currentUser }: DevicesPageProps = {}) { function matchesDeviceCategory(device: Device, category: DeviceCategory) {
return (device.deviceCategory ?? '').trim().toLowerCase() === category.toLowerCase()
}
function isLoanedDevice(device: Device) {
const status = (device.loanStatus ?? '').toLowerCase()
return status.includes('ausgeliehen') || status.includes('verliehen')
}
function isUnavailableDevice(device: Device) {
const status = (device.loanStatus ?? '').toLowerCase()
return (
status.includes('defekt') ||
status.includes('verloren') ||
status.includes('wartung') ||
status.includes('gesperrt') ||
status.includes('deaktiviert')
)
}
function formatDeviceCount(value: number) {
return value.toLocaleString('de-DE')
}
function DeviceStatCard({
title,
value,
detail,
icon,
}: {
title: string
value: string
detail: string
icon: ReactNode
}) {
return (
<div className="rounded-xl border border-gray-200 bg-white p-4 shadow-xs dark:border-white/10 dark:bg-gray-900">
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">
{title}
</p>
<span className="text-indigo-500 dark:text-indigo-400">{icon}</span>
</div>
<p className="mt-3 text-2xl font-semibold tracking-tight text-gray-900 dark:text-white">
{value}
</p>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{detail}</p>
</div>
)
}
export default function DevicesPage({
currentUser,
title = 'Geräte',
description = 'Eine Liste aller Geräte inklusive Inventur-Nr., Hersteller, Modell, Seriennummer, MAC-Adresse, IP-Adresse, Firmware, Standort, Verleih-Status und Zubehör.',
createButtonLabel = 'Gerät hinzufügen',
createModalTitle = 'Gerät hinzufügen',
createModalDescription = 'Wähle die Geräteart und erfasse nur die passenden Daten.',
createSubmitLabel = 'Gerät speichern',
editModalTitle = 'Gerät bearbeiten',
emptyText = 'Keine Geräte vorhanden.',
loadingLabel = 'Geräteliste wird geladen...',
deviceCategoryFilter,
showFirmwareCheckButton = true,
showStats = true,
}: DevicesPageProps = {}) {
const { showToast, showErrorToast } = useNotifications() const { showToast, showErrorToast } = useNotifications()
const [devices, setDevices] = useState<Device[]>([]) const [devices, setDevices] = useState<Device[]>([])
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [activeModal, setActiveModal] = useState<'create' | 'edit' | null>(null) const [activeModal, setActiveModal] = useState<'create' | 'edit' | null>(null)
const [editingDevice, setEditingDevice] = useState<Device | null>(null) const [editingDevice, setEditingDevice] = useState<Device | null>(null)
const [isCreating, setIsCreating] = useState(false) const [isCreating, setIsCreating] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [createError, setCreateError] = useState<string | null>(null) const [createError, setCreateError] = useState<string | null>(null)
const [relatedDeviceIds, setRelatedDeviceIds] = useState<string[]>([]) const [relatedDeviceIds, setRelatedDeviceIds] = useState<string[]>([])
const [isJournalSubmitting, setIsJournalSubmitting] = useState(false) const [isJournalSubmitting, setIsJournalSubmitting] = useState(false)
@ -267,8 +448,35 @@ export default function DevicesPage({ currentUser }: DevicesPageProps = {}) {
const [isCheckingFirmware, setIsCheckingFirmware] = useState(false) const [isCheckingFirmware, setIsCheckingFirmware] = useState(false)
const [firmwareCheckState, setFirmwareCheckState] = useState<FirmwareCheckState | null>(null) const [firmwareCheckState, setFirmwareCheckState] = useState<FirmwareCheckState | null>(null)
const [firmwareCheckNow, setFirmwareCheckNow] = useState(() => Date.now()) const [firmwareCheckNow, setFirmwareCheckNow] = useState(() => Date.now())
const [sortKey, setSortKey] = useState('inventoryNumber')
const [sortDirection, setSortDirection] = useState<DeviceSortDirection>('asc')
const [isManufacturerModalOpen, setIsManufacturerModalOpen] = useState(false)
const [manufacturerOptions, setManufacturerOptions] = useState<DeviceLookupOption[]>([])
const [isManufacturerLoading, setIsManufacturerLoading] = useState(false)
const [manufacturerError, setManufacturerError] = useState<string | null>(null)
const [newManufacturerName, setNewManufacturerName] = useState('')
const [isSavingManufacturer, setIsSavingManufacturer] = useState(false)
const [deletingManufacturerId, setDeletingManufacturerId] = useState<string | null>(null)
const [manufacturerToDelete, setManufacturerToDelete] = useState<DeviceLookupOption | null>(null)
const closeCleanupTimeoutRef = useRef<number | null>(null) const closeCleanupTimeoutRef = useRef<number | null>(null)
const canManageManufacturers = hasRight(currentUser, 'devices:write')
const manufacturerUsageCounts = useMemo(() => {
const counts = new Map<string, number>()
for (const device of devices) {
const manufacturer = normalizeLookupName(device.manufacturer ?? '')
if (!manufacturer) {
continue
}
counts.set(manufacturer, (counts.get(manufacturer) ?? 0) + 1)
}
return counts
}, [devices])
const [togglingMilestoneCameraRecordingIds, setTogglingMilestoneCameraRecordingIds] = useState<Set<string>>(() => new Set()) const [togglingMilestoneCameraRecordingIds, setTogglingMilestoneCameraRecordingIds] = useState<Set<string>>(() => new Set())
const [isCameraDetailsOpen, setIsCameraDetailsOpen] = useState(false) const [isCameraDetailsOpen, setIsCameraDetailsOpen] = useState(false)
@ -798,6 +1006,149 @@ export default function DevicesPage({ currentUser }: DevicesPageProps = {}) {
} }
} }
async function loadManufacturerOptions() {
setIsManufacturerLoading(true)
setManufacturerError(null)
try {
const response = await fetch(`${API_URL}/device-lookups`, {
credentials: 'include',
})
if (!response.ok) {
throw new Error(
await readApiError(response, 'Hersteller konnten nicht geladen werden'),
)
}
const data = await response.json()
setManufacturerOptions(sortLookupOptions(data.manufacturers ?? []))
} catch (error) {
const message =
error instanceof Error
? error.message
: 'Hersteller konnten nicht geladen werden'
setManufacturerError(message)
showErrorToast({
title: 'Hersteller konnten nicht geladen werden',
error,
fallback: 'Hersteller konnten nicht geladen werden',
})
} finally {
setIsManufacturerLoading(false)
}
}
function openManufacturerModal() {
setManufacturerError(null)
setNewManufacturerName('')
setIsManufacturerModalOpen(true)
void loadManufacturerOptions()
}
async function handleCreateManufacturer(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const name = newManufacturerName.trim()
if (!name) {
setManufacturerError('Hersteller ist erforderlich.')
return
}
setIsSavingManufacturer(true)
setManufacturerError(null)
try {
const response = await fetch(`${API_URL}/device-manufacturers`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ name }),
})
if (!response.ok) {
throw new Error(
await readApiError(response, 'Hersteller konnte nicht gespeichert werden'),
)
}
const data = await response.json()
const option = data.option as DeviceLookupOption
clearDeviceLookupCache()
setManufacturerOptions((currentOptions) =>
upsertLookupOption(currentOptions, option),
)
setNewManufacturerName('')
showToast({
variant: 'success',
title: 'Hersteller gespeichert',
message: `${option.name} wurde zur Auswahlliste hinzugefügt.`,
})
} catch (error) {
const message =
error instanceof Error
? error.message
: 'Hersteller konnte nicht gespeichert werden'
setManufacturerError(message)
showErrorToast({
title: 'Hersteller konnte nicht gespeichert werden',
error,
fallback: 'Hersteller konnte nicht gespeichert werden',
})
} finally {
setIsSavingManufacturer(false)
}
}
async function handleDeleteManufacturer(option: DeviceLookupOption) {
setDeletingManufacturerId(option.id)
setManufacturerError(null)
try {
const response = await fetch(`${API_URL}/device-manufacturers/${option.id}`, {
method: 'DELETE',
credentials: 'include',
})
if (!response.ok) {
throw new Error(
await readApiError(response, 'Hersteller konnte nicht gelöscht werden'),
)
}
clearDeviceLookupCache()
setManufacturerOptions((currentOptions) =>
currentOptions.filter((currentOption) => currentOption.id !== option.id),
)
showToast({
variant: 'success',
title: 'Hersteller gelöscht',
message: `${option.name} wurde aus der Auswahlliste entfernt.`,
})
} catch (error) {
const message =
error instanceof Error
? error.message
: 'Hersteller konnte nicht gelöscht werden'
setManufacturerError(message)
showErrorToast({
title: 'Hersteller konnte nicht gelöscht werden',
error,
fallback: 'Hersteller konnte nicht gelöscht werden',
})
} finally {
setDeletingManufacturerId(null)
}
}
async function loadDeviceHistory(deviceId: string) { async function loadDeviceHistory(deviceId: string) {
setIsHistoryLoading(true) setIsHistoryLoading(true)
setHistoryError(null) setHistoryError(null)
@ -1341,7 +1692,58 @@ export default function DevicesPage({ currentUser }: DevicesPageProps = {}) {
} }
} }
async function handleDeleteDevice(device: Device) {
if (device.milestoneHardwareId || device.milestoneRecordingServerId) {
setCreateError('Milestone-Geräte können nicht gelöscht werden.')
return
}
setIsDeleting(true)
setCreateError(null)
try {
const response = await fetch(`${API_URL}/devices/${device.id}`, {
method: 'DELETE',
credentials: 'include',
})
if (!response.ok) {
throw new Error(
await readApiError(response, 'Gerät konnte nicht gelöscht werden'),
)
}
showToast({
variant: 'success',
title: 'Gerät gelöscht',
message: `Gerät ${device.inventoryNumber || device.model || device.id} wurde gelöscht.`,
})
closeModal()
await loadDevices()
} catch (error) {
setCreateError(
error instanceof Error
? error.message
: 'Gerät konnte nicht gelöscht werden',
)
} finally {
setIsDeleting(false)
}
}
const firmwareCheckNextAllowedAt = firmwareCheckState?.nextAllowedAt const firmwareCheckNextAllowedAt = firmwareCheckState?.nextAllowedAt
const visibleDevices = deviceCategoryFilter
? devices.filter((device) => matchesDeviceCategory(device, deviceCategoryFilter))
: devices
const loanedDevices = visibleDevices.filter(isLoanedDevice)
const unavailableDevices = visibleDevices.filter(isUnavailableDevice)
const availableDevices = visibleDevices.filter(
(device) => !isLoanedDevice(device) && !isUnavailableDevice(device),
)
const milestoneDevices = visibleDevices.filter((device) =>
Boolean(device.milestoneHardwareId || device.milestoneRecordingServerId),
)
const isFirmwareCheckLocked = const isFirmwareCheckLocked =
Boolean(firmwareCheckNextAllowedAt) && Boolean(firmwareCheckNextAllowedAt) &&
new Date(firmwareCheckNextAllowedAt as string).getTime() > firmwareCheckNow new Date(firmwareCheckNextAllowedAt as string).getTime() > firmwareCheckNow
@ -1357,11 +1759,30 @@ export default function DevicesPage({ currentUser }: DevicesPageProps = {}) {
}` }`
: null : null
function handleSort(column: TableColumn<Device>) {
if (!column.sortable) {
return
}
if (sortKey === column.key) {
setSortDirection((currentDirection) =>
currentDirection === 'asc' ? 'desc' : 'asc',
)
return
}
setSortKey(column.key)
setSortDirection('asc')
}
const columns: TableColumn<Device>[] = [ const columns: TableColumn<Device>[] = [
{ {
key: 'inventoryNumber', key: 'inventoryNumber',
header: 'Inventur-Nr.', header: 'Inventur-Nr.',
isPrimary: true, isPrimary: true,
sortable: true,
width: '16%',
sortValue: (device) => device.inventoryNumber,
render: (device) => ( render: (device) => (
<div className="min-w-0"> <div className="min-w-0">
<div className="font-medium text-gray-900 dark:text-white"> <div className="font-medium text-gray-900 dark:text-white">
@ -1389,6 +1810,16 @@ export default function DevicesPage({ currentUser }: DevicesPageProps = {}) {
{ {
key: 'model', key: 'model',
header: 'Gerät', header: 'Gerät',
sortable: true,
width: '30%',
sortValue: (device) =>
[
device.milestoneDisplayName,
device.manufacturer,
device.model,
]
.filter(Boolean)
.join(' '),
render: (device) => { render: (device) => {
const title = [device.manufacturer, device.model] const title = [device.manufacturer, device.model]
.filter(Boolean) .filter(Boolean)
@ -1473,17 +1904,26 @@ export default function DevicesPage({ currentUser }: DevicesPageProps = {}) {
key: 'ipAddress', key: 'ipAddress',
header: 'IP-Adresse', header: 'IP-Adresse',
hideOnMobile: 'md', hideOnMobile: 'md',
sortable: true,
width: '14%',
sortValue: (device) => device.ipAddress,
render: (device) => device.ipAddress || '-', render: (device) => device.ipAddress || '-',
}, },
{ {
key: 'location', key: 'location',
header: 'Standort', header: 'Standort',
hideOnMobile: 'lg', hideOnMobile: 'lg',
sortable: true,
width: '12%',
sortValue: (device) => device.location,
render: (device) => device.location || '-', render: (device) => device.location || '-',
}, },
{ {
key: 'loanStatus', key: 'loanStatus',
header: 'Verleih', header: 'Verleih',
sortable: true,
width: '13%',
sortValue: (device) => device.loanStatus || 'Verfügbar',
render: (device) => ( render: (device) => (
<Badge <Badge
tone={getLoanStatusBadgeTone(device.loanStatus)} tone={getLoanStatusBadgeTone(device.loanStatus)}
@ -1498,6 +1938,14 @@ export default function DevicesPage({ currentUser }: DevicesPageProps = {}) {
key: 'milestoneEnabled', key: 'milestoneEnabled',
header: 'Milestone', header: 'Milestone',
hideOnMobile: 'lg', hideOnMobile: 'lg',
sortable: true,
width: '10%',
sortValue: (device) =>
device.milestoneHardwareId
? device.milestoneEnabled
? 2
: 1
: 0,
render: (device) => { render: (device) => {
if (!device.milestoneHardwareId) { if (!device.milestoneHardwareId) {
return '-' return '-'
@ -1544,6 +1992,10 @@ export default function DevicesPage({ currentUser }: DevicesPageProps = {}) {
key: 'isBaoDevice', key: 'isBaoDevice',
header: 'BAO', header: 'BAO',
hideOnMobile: 'lg', hideOnMobile: 'lg',
sortable: true,
align: 'center',
width: '5%',
sortValue: (device) => device.isBaoDevice,
render: (device) => render: (device) =>
device.isBaoDevice ? ( device.isBaoDevice ? (
<CheckCircleIcon <CheckCircleIcon
@ -1559,27 +2011,94 @@ export default function DevicesPage({ currentUser }: DevicesPageProps = {}) {
}, },
] ]
const sortedVisibleDevices = (() => {
const sortColumn = columns.find((column) => column.key === sortKey)
if (!sortColumn?.sortable || !sortColumn.sortValue) {
return visibleDevices
}
return [...visibleDevices].sort((leftDevice, rightDevice) => {
const result = compareDeviceSortValues(
sortColumn.sortValue?.(leftDevice),
sortColumn.sortValue?.(rightDevice),
)
return sortDirection === 'asc' ? result : -result
})
})()
return ( return (
<div className="h-full min-h-0 overflow-y-auto px-4 py-10 sm:px-6 lg:px-8" > <div className="h-full min-h-0 overflow-hidden px-4 py-10 sm:px-6 lg:px-8">
{showStats && (
<div className="mb-6 grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
<DeviceStatCard
title="Gesamt"
value={formatDeviceCount(visibleDevices.length)}
detail="erfasste Geräte"
icon={<WrenchScrewdriverIcon aria-hidden="true" className="size-5" />}
/>
<DeviceStatCard
title="Verfügbar"
value={formatDeviceCount(availableDevices.length)}
detail="einsatzbereit"
icon={<CheckCircleIcon aria-hidden="true" className="size-5" />}
/>
<DeviceStatCard
title="Ausgeliehen"
value={formatDeviceCount(loanedDevices.length)}
detail="aktuell im Verleih"
icon={<ClockIcon aria-hidden="true" className="size-5" />}
/>
<DeviceStatCard
title="Nicht einsatzbereit"
value={formatDeviceCount(unavailableDevices.length)}
detail="Wartung, Defekt oder gesperrt"
icon={<XCircleIcon aria-hidden="true" className="size-5" />}
/>
<DeviceStatCard
title="Milestone"
value={formatDeviceCount(milestoneDevices.length)}
detail="synchronisierte Geräte"
icon={<CheckCircleIcon aria-hidden="true" className="size-5" />}
/>
</div>
)}
<Tables <Tables
title="Geräte" title={title}
description="Eine Liste aller Geräte inklusive Inventur-Nr., Hersteller, Modell, Seriennummer, MAC-Adresse, IP-Adresse, Firmware, Standort, Verleih-Status und Zubehör." description={description}
headerAction={ headerAction={
<div className="flex flex-col items-start gap-2 sm:items-end"> <div className="flex flex-col items-start gap-2 sm:items-end">
<div className="flex flex-wrap items-center justify-end gap-2"> <div className="flex flex-wrap items-center justify-end gap-2">
<Button {canManageManufacturers && (
type="button" <Button
variant="secondary" type="button"
color="gray" variant="secondary"
size="md" color="gray"
disabled={firmwareCheckButtonDisabled} size="md"
isLoading={isCheckingFirmware} leadingIcon={<TagIcon aria-hidden="true" className="size-4" />}
leadingIcon={<ArrowPathIcon aria-hidden="true" className="size-4" />} onClick={openManufacturerModal}
onClick={() => void handleCheckFirmware()} >
title={firmwareCheckNextAllowedLabel ?? undefined} Hersteller verwalten
> </Button>
Firmware prüfen )}
</Button>
{showFirmwareCheckButton && (
<Button
type="button"
variant="secondary"
color="gray"
size="md"
disabled={firmwareCheckButtonDisabled}
isLoading={isCheckingFirmware}
leadingIcon={<ArrowPathIcon aria-hidden="true" className="size-4" />}
onClick={() => void handleCheckFirmware()}
title={firmwareCheckNextAllowedLabel ?? undefined}
>
Firmware prüfen
</Button>
)}
<Button <Button
type="button" type="button"
@ -1588,22 +2107,31 @@ export default function DevicesPage({ currentUser }: DevicesPageProps = {}) {
leadingIcon={<PlusIcon aria-hidden="true" className="size-4" />} leadingIcon={<PlusIcon aria-hidden="true" className="size-4" />}
onClick={openCreateModal} onClick={openCreateModal}
> >
Gerät hinzufügen {createButtonLabel}
</Button> </Button>
</div> </div>
</div> </div>
} }
onAction={openCreateModal} onAction={openCreateModal}
rows={devices} rows={sortedVisibleDevices}
columns={columns} columns={columns}
getRowId={(device) => device.id} getRowId={(device) => device.id}
variant="card" variant="card"
emptyText="Keine Geräte vorhanden." emptyText={emptyText}
isLoading={isLoading} isLoading={isLoading}
scrollRows
rowScrollClassName={
showStats
? 'max-h-[calc(100dvh-28rem)]'
: 'max-h-[calc(100dvh-18rem)]'
}
sortKey={sortKey}
sortDirection={sortDirection}
onSort={handleSort}
loadingContent={ loadingContent={
<LoadingSpinner <LoadingSpinner
size="lg" size="lg"
label="Geräteliste wird geladen..." label={loadingLabel}
className="text-indigo-600 dark:text-indigo-400" className="text-indigo-600 dark:text-indigo-400"
center center
/> />
@ -1614,6 +2142,173 @@ export default function DevicesPage({ currentUser }: DevicesPageProps = {}) {
} }
/> />
<Modal
open={isManufacturerModalOpen}
setOpen={(open) => {
if (!open && (isSavingManufacturer || Boolean(deletingManufacturerId))) {
return
}
setIsManufacturerModalOpen(open)
if (!open) {
setManufacturerError(null)
setNewManufacturerName('')
setManufacturerToDelete(null)
}
}}
panelClassName="max-w-2xl"
>
<div className="border-b border-gray-200 px-6 py-4 dark:border-white/10">
<ModalTitle>Hersteller verwalten</ModalTitle>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Hersteller werden als Auswahlliste für Geräte gespeichert. Bestehende Geräte
behalten ihren eingetragenen Hersteller, wenn du einen Eintrag entfernst.
</p>
</div>
<div className="space-y-5 px-6 py-5">
<form className="flex flex-col gap-3 sm:flex-row" onSubmit={handleCreateManufacturer}>
<div className="min-w-0 flex-1">
<label
htmlFor="new-manufacturer-name"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Neuer Hersteller
</label>
<input
id="new-manufacturer-name"
type="text"
value={newManufacturerName}
onChange={(event) => setNewManufacturerName(event.target.value)}
className={inputClassName}
placeholder="z. B. Axis"
autoComplete="off"
/>
</div>
<div className="flex items-end">
<Button
type="submit"
color="indigo"
size="md"
isLoading={isSavingManufacturer}
disabled={isSavingManufacturer || !newManufacturerName.trim()}
leadingIcon={<PlusIcon aria-hidden="true" className="size-4" />}
>
Hinzufügen
</Button>
</div>
</form>
{manufacturerError && (
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200">
{manufacturerError}
</div>
)}
<div>
<div className="mb-2 flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
Gespeicherte Hersteller
</h3>
<span className="text-xs text-gray-500 dark:text-gray-400">
{manufacturerOptions.length.toLocaleString('de-DE')} Einträge
</span>
</div>
{isManufacturerLoading ? (
<LoadingSpinner
size="md"
label="Hersteller werden geladen..."
className="py-8 text-indigo-600 dark:text-indigo-400"
center
/>
) : manufacturerOptions.length === 0 ? (
<div className="rounded-lg border border-dashed border-gray-300 px-4 py-8 text-center text-sm text-gray-500 dark:border-white/15 dark:text-gray-400">
Noch keine Hersteller vorhanden.
</div>
) : (
<ul className="max-h-80 divide-y divide-gray-200 overflow-y-auto rounded-lg border border-gray-200 bg-white dark:divide-white/10 dark:border-white/10 dark:bg-white/[0.03]">
{manufacturerOptions.map((option) => {
const usageCount =
manufacturerUsageCounts.get(normalizeLookupName(option.name)) ?? 0
return (
<li
key={option.id}
className="flex items-center justify-between gap-3 px-4 py-3"
>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-gray-900 dark:text-white">
{option.name}
</p>
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{formatDeviceUsageCount(usageCount)}
</p>
</div>
<Button
type="button"
variant="secondary"
color="red"
size="sm"
disabled={Boolean(deletingManufacturerId)}
isLoading={deletingManufacturerId === option.id}
leadingIcon={<TrashIcon aria-hidden="true" className="size-4" />}
onClick={() => setManufacturerToDelete(option)}
>
Löschen
</Button>
</li>
)
})}
</ul>
)}
</div>
</div>
<div className="flex justify-end border-t border-gray-200 bg-gray-50 px-6 py-4 dark:border-white/10 dark:bg-white/[0.03]">
<Button
type="button"
variant="secondary"
color="gray"
size="md"
disabled={isSavingManufacturer || Boolean(deletingManufacturerId)}
onClick={() => setIsManufacturerModalOpen(false)}
>
Schließen
</Button>
</div>
</Modal>
<Modal
open={Boolean(manufacturerToDelete)}
setOpen={(open) => {
if (!open && deletingManufacturerId) {
return
}
if (!open) {
setManufacturerToDelete(null)
}
}}
variant="alert"
title="Hersteller löschen?"
description={
manufacturerToDelete
? `${manufacturerToDelete.name} wird aus der Auswahlliste entfernt. Bestehende Geräte bleiben unverändert.`
: undefined
}
confirmText="Löschen"
cancelText="Abbrechen"
onConfirm={() => {
if (manufacturerToDelete) {
void handleDeleteManufacturer(manufacturerToDelete)
}
}}
/>
<CreateDeviceModal <CreateDeviceModal
open={activeModal === 'create'} open={activeModal === 'create'}
setOpen={(open) => { setOpen={(open) => {
@ -1626,6 +2321,12 @@ export default function DevicesPage({ currentUser }: DevicesPageProps = {}) {
}} }}
isSaving={isCreating} isSaving={isCreating}
error={createError} error={createError}
title={createModalTitle}
description={createModalDescription}
submitLabel={createSubmitLabel}
initialDeviceCategory={deviceCategoryFilter}
lockedDeviceCategory={deviceCategoryFilter}
hideDeviceCategorySelector={Boolean(deviceCategoryFilter)}
devices={devices} devices={devices}
relatedDeviceIds={relatedDeviceIds} relatedDeviceIds={relatedDeviceIds}
onToggleRelatedDevice={toggleRelatedDevice} onToggleRelatedDevice={toggleRelatedDevice}
@ -1643,12 +2344,17 @@ export default function DevicesPage({ currentUser }: DevicesPageProps = {}) {
closeModal() closeModal()
}} }}
isSaving={isCreating} isSaving={isCreating}
isDeleting={isDeleting}
error={createError} error={createError}
title={editModalTitle}
lockedDeviceCategory={deviceCategoryFilter}
hideDeviceCategorySelector={Boolean(deviceCategoryFilter)}
device={editingDevice} device={editingDevice}
devices={devices} devices={devices}
relatedDeviceIds={relatedDeviceIds} relatedDeviceIds={relatedDeviceIds}
onToggleRelatedDevice={toggleRelatedDevice} onToggleRelatedDevice={toggleRelatedDevice}
onSubmit={handleSaveDevice} onSubmit={handleSaveDevice}
onDelete={handleDeleteDevice}
deviceHistory={deviceHistory} deviceHistory={deviceHistory}
isHistoryLoading={isHistoryLoading} isHistoryLoading={isHistoryLoading}
historyError={historyError} historyError={historyError}

View File

@ -11,6 +11,7 @@ import {
MapPinIcon, MapPinIcon,
PencilSquareIcon, PencilSquareIcon,
PlusCircleIcon, PlusCircleIcon,
TrashIcon,
VideoCameraIcon, VideoCameraIcon,
WrenchScrewdriverIcon, WrenchScrewdriverIcon,
} from '@heroicons/react/20/solid' } from '@heroicons/react/20/solid'
@ -23,19 +24,24 @@ import type {
DeviceHistoryChange, DeviceHistoryChange,
DeviceHistoryEntry, DeviceHistoryEntry,
} from '../../components/types' } from '../../components/types'
import DeviceFormFields from './DeviceFormFields' import DeviceFormFields, { type DeviceCategory } from './DeviceFormFields'
import DeviceModalTabs, { type DeviceModalTabId } from './DeviceModalTabs' import DeviceModalTabs, { type DeviceModalTabId } from './DeviceModalTabs'
type EditDeviceModalProps = { type EditDeviceModalProps = {
open: boolean open: boolean
setOpen: (open: boolean) => void setOpen: (open: boolean) => void
isSaving: boolean isSaving: boolean
isDeleting?: boolean
error: string | null error: string | null
title?: string
lockedDeviceCategory?: DeviceCategory
hideDeviceCategorySelector?: boolean
device: Device | null device: Device | null
devices: Device[] devices: Device[]
relatedDeviceIds: string[] relatedDeviceIds: string[]
onToggleRelatedDevice: (deviceId: string) => void onToggleRelatedDevice: (deviceId: string) => void
onSubmit: (event: FormEvent<HTMLFormElement>) => void onSubmit: (event: FormEvent<HTMLFormElement>) => void
onDelete?: (device: Device) => void | Promise<void>
deviceHistory: DeviceHistoryEntry[] deviceHistory: DeviceHistoryEntry[]
isHistoryLoading: boolean isHistoryLoading: boolean
historyError: string | null historyError: string | null
@ -155,12 +161,17 @@ export default function EditDeviceModal({
open, open,
setOpen, setOpen,
isSaving, isSaving,
isDeleting = false,
error, error,
title = 'Gerät bearbeiten',
lockedDeviceCategory,
hideDeviceCategorySelector,
device, device,
devices, devices,
relatedDeviceIds, relatedDeviceIds,
onToggleRelatedDevice, onToggleRelatedDevice,
onSubmit, onSubmit,
onDelete,
deviceHistory, deviceHistory,
isHistoryLoading, isHistoryLoading,
historyError, historyError,
@ -178,6 +189,7 @@ export default function EditDeviceModal({
}: EditDeviceModalProps) { }: EditDeviceModalProps) {
const [activeTab, setActiveTab] = useState<DeviceModalTabId>('masterData') const [activeTab, setActiveTab] = useState<DeviceModalTabId>('masterData')
const [journalSearchQuery, setJournalSearchQuery] = useState('') const [journalSearchQuery, setJournalSearchQuery] = useState('')
const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false)
const milestoneFilteredDevice = filterMilestoneChildDevices(device) const milestoneFilteredDevice = filterMilestoneChildDevices(device)
const isMilestoneDevice = Boolean( const isMilestoneDevice = Boolean(
milestoneFilteredDevice?.milestoneHardwareId || milestoneFilteredDevice?.milestoneHardwareId ||
@ -194,7 +206,7 @@ export default function EditDeviceModal({
}, [activeTab, isMilestoneDevice]) }, [activeTab, isMilestoneDevice])
function handleOpenChange(nextOpen: boolean) { function handleOpenChange(nextOpen: boolean) {
if (!nextOpen && isSaving) { if (!nextOpen && (isSaving || isDeleting)) {
return return
} }
@ -203,6 +215,7 @@ export default function EditDeviceModal({
if (!nextOpen) { if (!nextOpen) {
setActiveTab('masterData') setActiveTab('masterData')
setJournalSearchQuery('') setJournalSearchQuery('')
setIsDeleteConfirmOpen(false)
} }
} }
@ -213,12 +226,16 @@ export default function EditDeviceModal({
} }
function handleFormSubmit(event: FormEvent<HTMLFormElement>) { function handleFormSubmit(event: FormEvent<HTMLFormElement>) {
if (activeTab === 'history') { onSubmit(event)
event.preventDefault() }
async function handleDeleteConfirm() {
if (!milestoneFilteredDevice || isMilestoneDevice || !onDelete) {
return return
} }
onSubmit(event) await onDelete(milestoneFilteredDevice)
setIsDeleteConfirmOpen(false)
} }
const historyFeedItems: JournalItems = device const historyFeedItems: JournalItems = device
@ -278,6 +295,7 @@ export default function EditDeviceModal({
: [] : []
return ( return (
<>
<Modal <Modal
open={open && Boolean(milestoneFilteredDevice)} open={open && Boolean(milestoneFilteredDevice)}
setOpen={handleOpenChange} setOpen={handleOpenChange}
@ -293,7 +311,7 @@ export default function EditDeviceModal({
<div className="flex items-start justify-between gap-4 border-b border-gray-200 bg-gray-100 px-4 py-4 sm:px-6 dark:border-white/10 dark:bg-gray-800"> <div className="flex items-start justify-between gap-4 border-b border-gray-200 bg-gray-100 px-4 py-4 sm:px-6 dark:border-white/10 dark:bg-gray-800">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<DialogTitle className="text-base font-semibold text-gray-900 dark:text-white"> <DialogTitle className="text-base font-semibold text-gray-900 dark:text-white">
Gerät bearbeiten {title}
{milestoneFilteredDevice && ( {milestoneFilteredDevice && (
<span className="ml-2 font-normal text-gray-500 dark:text-gray-400"> <span className="ml-2 font-normal text-gray-500 dark:text-gray-400">
{[ {[
@ -344,7 +362,7 @@ export default function EditDeviceModal({
variant="secondary" variant="secondary"
color="gray" color="gray"
size="sm" size="sm"
disabled={isSaving} disabled={isSaving || isDeleting}
onClick={() => handleOpenChange(false)} onClick={() => handleOpenChange(false)}
> >
<span className="sr-only">Schließen</span> <span className="sr-only">Schließen</span>
@ -366,7 +384,7 @@ export default function EditDeviceModal({
)} )}
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-6 sm:px-6"> <div className="min-h-0 flex-1 overflow-y-auto px-4 py-6 sm:px-6">
{activeTab === 'history' ? ( {activeTab === 'history' && (
<div> <div>
{isHistoryLoading && ( {isHistoryLoading && (
<div className="flex min-h-24 items-center justify-center rounded-md border border-dashed border-gray-200 p-4 dark:border-white/10"> <div className="flex min-h-24 items-center justify-center rounded-md border border-dashed border-gray-200 p-4 dark:border-white/10">
@ -402,11 +420,15 @@ export default function EditDeviceModal({
/> />
)} )}
</div> </div>
) : ( )}
<div hidden={activeTab === 'history'}>
<DeviceFormFields <DeviceFormFields
open={open} open={open}
activeTab={activeTab} activeTab={activeTab}
device={milestoneFilteredDevice ?? undefined} device={milestoneFilteredDevice ?? undefined}
lockedDeviceCategory={lockedDeviceCategory}
hideDeviceCategorySelector={hideDeviceCategorySelector}
devices={devices} devices={devices}
relatedDeviceIds={relatedDeviceIds} relatedDeviceIds={relatedDeviceIds}
onToggleRelatedDevice={onToggleRelatedDevice} onToggleRelatedDevice={onToggleRelatedDevice}
@ -417,32 +439,67 @@ export default function EditDeviceModal({
onToggleMilestoneChildDeviceRecording={onToggleMilestoneChildDeviceRecording} onToggleMilestoneChildDeviceRecording={onToggleMilestoneChildDeviceRecording}
onOpenMilestoneCameraDetails={onOpenMilestoneCameraDetails} onOpenMilestoneCameraDetails={onOpenMilestoneCameraDetails}
/> />
)} </div>
</div> </div>
<div className="flex justify-end gap-x-3 border-t border-gray-200 px-4 py-4 sm:px-6 dark:border-white/10"> <div className="flex flex-col gap-3 border-t border-gray-200 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-6 dark:border-white/10">
<Button {onDelete ? (
type="button" <Button
variant="secondary" type="button"
color="gray" variant="secondary"
disabled={isSaving} color="red"
onClick={() => handleOpenChange(false)} disabled={isSaving || isDeleting || isMilestoneDevice}
> title={
{activeTab === 'history' ? 'Schließen' : 'Abbrechen'} isMilestoneDevice
</Button> ? 'Milestone-Geräte können nicht gelöscht werden.'
: undefined
}
leadingIcon={<TrashIcon aria-hidden="true" className="size-4" />}
onClick={() => setIsDeleteConfirmOpen(true)}
>
Löschen
</Button>
) : (
<span />
)}
<div className="flex justify-end gap-x-3">
<Button
type="button"
variant="secondary"
color="gray"
disabled={isSaving || isDeleting}
onClick={() => handleOpenChange(false)}
>
Abbrechen
</Button>
{activeTab !== 'history' && (
<Button <Button
type="submit" type="submit"
color="indigo" color="indigo"
disabled={isDeleting}
isLoading={isSaving} isLoading={isSaving}
leadingIcon={<CheckIcon aria-hidden="true" className="size-4" />} leadingIcon={<CheckIcon aria-hidden="true" className="size-4" />}
> >
Änderungen speichern Änderungen speichern
</Button> </Button>
)} </div>
</div> </div>
</form> </form>
</Modal> </Modal>
<Modal
open={isDeleteConfirmOpen}
setOpen={setIsDeleteConfirmOpen}
variant="alert"
title="Gerät löschen?"
description={`"${milestoneFilteredDevice?.inventoryNumber || milestoneFilteredDevice?.model || 'Dieses Gerät'}" wird dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.`}
confirmText="Gerät löschen"
cancelText="Abbrechen"
onConfirm={() => {
void handleDeleteConfirm()
}}
/>
</>
) )
} }

View File

@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'
import { import {
ArrowPathIcon, ArrowPathIcon,
ArrowRightIcon, ArrowRightIcon,
ArchiveBoxIcon,
CheckCircleIcon, CheckCircleIcon,
CpuChipIcon, CpuChipIcon,
ExclamationTriangleIcon, ExclamationTriangleIcon,
@ -65,11 +66,11 @@ type LoadState = {
const featureCards = [ const featureCards = [
{ {
title: 'Geräte', title: 'Kameras',
description: description:
'Kameras und Mikrofone nach Recording-Speicher gruppiert anzeigen und Status direkt schalten.', 'Milestone-Kameras und Mikrofone nach Recording-Speicher gruppiert anzeigen und Status direkt schalten.',
href: '/milestone/geraete', href: '/milestone/kameras',
icon: CpuChipIcon, icon: VideoCameraIcon,
}, },
{ {
title: 'Speicher', title: 'Speicher',
@ -85,6 +86,13 @@ const featureCards = [
href: '/milestone/rollen', href: '/milestone/rollen',
icon: UserGroupIcon, icon: UserGroupIcon,
}, },
{
title: 'Backup',
description:
'Robocopy-Protokolle vom NAS anzeigen und die Zusammenfassung am Log-Ende auswerten.',
href: '/milestone/backup',
icon: ArchiveBoxIcon,
},
] ]
function classNames(...classes: Array<string | false | null | undefined>) { function classNames(...classes: Array<string | false | null | undefined>) {
@ -136,6 +144,26 @@ function storageTitle(storage: MilestoneStorage) {
return storage.displayName || storage.name || storage.id return storage.displayName || storage.name || storage.id
} }
function MilestoneDiamondIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
className={className}
>
<rect
x="6.25"
y="6.25"
width="11.5"
height="11.5"
transform="rotate(45 12 12)"
fill="currentColor"
/>
</svg>
)
}
export default function MilestonePage() { export default function MilestonePage() {
const [state, setState] = useState<LoadState>({ devices: [], storages: [] }) const [state, setState] = useState<LoadState>({ devices: [], storages: [] })
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
@ -162,7 +190,7 @@ export default function MilestonePage() {
throw new Error( throw new Error(
typeof devicesData?.error === 'string' typeof devicesData?.error === 'string'
? devicesData.error ? devicesData.error
: 'Milestone-Geräte konnten nicht geladen werden.', : 'Milestone-Kameras konnten nicht geladen werden.',
) )
} }
@ -255,7 +283,7 @@ export default function MilestonePage() {
<div className="max-w-3xl"> <div className="max-w-3xl">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex size-12 items-center justify-center rounded-xl bg-indigo-50 text-indigo-600 dark:bg-indigo-500/10 dark:text-indigo-300"> <div className="flex size-12 items-center justify-center rounded-xl bg-indigo-50 text-indigo-600 dark:bg-indigo-500/10 dark:text-indigo-300">
<ServerStackIcon aria-hidden="true" className="size-6" /> <MilestoneDiamondIcon className="size-6" />
</div> </div>
<div> <div>
<p className="text-sm font-semibold uppercase tracking-wide text-indigo-600 dark:text-indigo-300"> <p className="text-sm font-semibold uppercase tracking-wide text-indigo-600 dark:text-indigo-300">

View 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>
)
}

View File

@ -492,7 +492,7 @@ export default function MilestoneDevicesPage({ canWrite = false }: Props) {
showToast({ showToast({
variant: 'error', variant: 'error',
title: 'Kamera nicht lokal zugeordnet', title: 'Kamera nicht lokal zugeordnet',
message: 'Bitte synchronisiere die Milestone-Geräte neu und versuche es danach erneut.', message: 'Bitte synchronisiere die Milestone-Kameras neu und versuche es danach erneut.',
}) })
return return
} }
@ -860,7 +860,7 @@ export default function MilestoneDevicesPage({ canWrite = false }: Props) {
<div className="flex flex-wrap items-end justify-between gap-4"> <div className="flex flex-wrap items-end justify-between gap-4">
<div> <div>
<h1 className="text-xl font-semibold text-gray-900 dark:text-white"> <h1 className="text-xl font-semibold text-gray-900 dark:text-white">
Milestone-Geräte Milestone-Kameras
</h1> </h1>
{!isLoading && !error && ( {!isLoading && !error && (
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400"> <p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
@ -898,7 +898,7 @@ export default function MilestoneDevicesPage({ canWrite = false }: Props) {
<div className="min-h-0 flex-1 space-y-8 overflow-y-auto p-4 sm:p-6 lg:p-8"> <div className="min-h-0 flex-1 space-y-8 overflow-y-auto p-4 sm:p-6 lg:p-8">
{isLoading && ( {isLoading && (
<div className="flex justify-center py-16"> <div className="flex justify-center py-16">
<LoadingSpinner size="lg" label="Geräte werden geladen…" center /> <LoadingSpinner size="lg" label="Kameras werden geladen…" center />
</div> </div>
)} )}
@ -911,7 +911,7 @@ export default function MilestoneDevicesPage({ canWrite = false }: Props) {
{!isLoading && !error && filtered.length === 0 && ( {!isLoading && !error && filtered.length === 0 && (
<EmptyState <EmptyState
icon={VideoCameraIcon} icon={VideoCameraIcon}
title={search ? 'Keine Treffer' : 'Keine Geräte gefunden'} title={search ? 'Keine Treffer' : 'Keine Kameras gefunden'}
description={ description={
search search
? 'Versuche einen anderen Suchbegriff.' ? 'Versuche einen anderen Suchbegriff.'

View File

@ -207,6 +207,7 @@ export default function MilestoneRolesPage({
const [isLoadingBasicUsers, setIsLoadingBasicUsers] = useState(false) const [isLoadingBasicUsers, setIsLoadingBasicUsers] = useState(false)
const [isAddingUsers, setIsAddingUsers] = useState(false) const [isAddingUsers, setIsAddingUsers] = useState(false)
const [adSearch, setAdSearch] = useState('') const [adSearch, setAdSearch] = useState('')
const [activeAddUsersTab, setActiveAddUsersTab] = useState<UserSource>('ad')
const [selectedUserKeys, setSelectedUserKeys] = useState<Set<string>>( const [selectedUserKeys, setSelectedUserKeys] = useState<Set<string>>(
() => new Set(), () => new Set(),
) )
@ -660,9 +661,52 @@ export default function MilestoneRolesPage({
basic: filterVisibleUsers(basicUsers), basic: filterVisibleUsers(basicUsers),
} }
}, [adSearch, adUsers, basicUsers, roleUsers]) }, [adSearch, adUsers, basicUsers, roleUsers])
const addUserGroups = useMemo<
Record<
UserSource,
{
source: UserSource
title: string
users: AssignableUser[]
loading: boolean
unavailable: boolean
emptyText: string
}
>
>(
() => ({
ad: {
source: 'ad',
title: 'Windows-Benutzer',
users: visibleAssignableUsersBySource.ad,
loading: isLoadingAdUsers,
unavailable: !adConfigured,
emptyText: 'Keine passenden Windows-Benutzer gefunden.',
},
basic: {
source: 'basic',
title: 'Basisbenutzer',
users: visibleAssignableUsersBySource.basic,
loading: isLoadingBasicUsers,
unavailable: false,
emptyText: 'Keine passenden Basisbenutzer gefunden.',
},
}),
[
adConfigured,
isLoadingAdUsers,
isLoadingBasicUsers,
visibleAssignableUsersBySource,
],
)
const activeAddUserGroup = addUserGroups[activeAddUsersTab]
const addUserSearchIsActive = adSearch.trim().length > 0
const visibleAddUserGroups = addUserSearchIsActive
? [addUserGroups.ad, addUserGroups.basic]
: [activeAddUserGroup]
return ( return (
<div className="h-full overflow-y-auto bg-gray-50 px-4 py-6 sm:px-6 lg:px-8 dark:bg-gray-950"> <div className="h-full overflow-y-auto bg-gray-50 px-4 py-6 sm:px-6 lg:px-8 [&_svg[data-slot=icon]]:stroke-2 dark:bg-gray-950">
<div className="mx-auto flex max-w-7xl flex-col gap-6"> <div className="mx-auto flex max-w-7xl flex-col gap-6">
<header className="rounded-xl border border-gray-200 bg-white p-5 shadow-xs dark:border-white/10 dark:bg-white/5"> <header className="rounded-xl border border-gray-200 bg-white p-5 shadow-xs dark:border-white/10 dark:bg-white/5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"> <div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
@ -1109,79 +1153,112 @@ export default function MilestoneRolesPage({
className="block w-full rounded-md bg-white px-3 py-2 text-sm text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500" className="block w-full rounded-md bg-white px-3 py-2 text-sm text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500"
/> />
<div className="mt-4 max-h-96 space-y-4 overflow-y-auto rounded-lg border border-gray-200 p-3 dark:border-white/10"> <div className="mt-4">
{([ <div
{ role="tablist"
source: 'ad' as const, aria-label="Benutzerart"
title: 'Windows-Benutzer', className="grid grid-cols-2 rounded-lg bg-gray-100 p-1 dark:bg-white/5"
users: visibleAssignableUsersBySource.ad, >
loading: isLoadingAdUsers, {(['ad', 'basic'] as const).map((source) => {
unavailable: !adConfigured, const group = addUserGroups[source]
emptyText: 'Keine passenden Windows-Benutzer gefunden.', const active = activeAddUsersTab === source
},
{
source: 'basic' as const,
title: 'Basisbenutzer',
users: visibleAssignableUsersBySource.basic,
loading: isLoadingBasicUsers,
unavailable: false,
emptyText: 'Keine passenden Basisbenutzer gefunden.',
},
]).map((group) => (
<section key={group.source}>
<div className="mb-2 flex items-center justify-between">
<h3 className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{group.title}
</h3>
<span className="text-xs text-gray-400 dark:text-gray-500">
{group.users.length}
</span>
</div>
{group.unavailable ? ( return (
<div className="rounded-lg border border-dashed border-gray-200 px-4 py-6 text-center text-sm text-gray-500 dark:border-white/10 dark:text-gray-400"> <button
Active Directory ist noch nicht konfiguriert. key={source}
</div> type="button"
) : group.loading ? ( role="tab"
<div className="flex min-h-28 items-center justify-center rounded-lg border border-dashed border-gray-200 dark:border-white/10"> aria-selected={active}
<LoadingSpinner onClick={() => setActiveAddUsersTab(source)}
size="sm" className={classNames(
label={`${group.title} werden geladen...`} active
className="text-indigo-600 dark:text-indigo-400" ? 'bg-white text-indigo-700 shadow-xs dark:bg-white/10 dark:text-white'
/> : 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white',
</div> 'flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-semibold transition',
) : group.users.length === 0 ? ( )}
<div className="rounded-lg border border-dashed border-gray-200 px-4 py-6 text-center text-sm text-gray-500 dark:border-white/10 dark:text-gray-400"> >
{group.emptyText} <span>{group.title}</span>
</div> <span
) : ( className={classNames(
<ul className="divide-y divide-gray-200 overflow-hidden rounded-lg border border-gray-200 dark:divide-white/10 dark:border-white/10"> active
{group.users.map((user) => ( ? 'bg-indigo-50 text-indigo-700 dark:bg-white/10 dark:text-white'
<li key={userSelectionKey(user)}> : 'bg-white text-gray-500 dark:bg-white/10 dark:text-gray-300',
<label className="flex cursor-pointer items-start gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-white/5"> 'inline-flex min-w-6 justify-center rounded-full px-1.5 py-0.5 text-xs leading-none',
<input )}
type="checkbox" >
checked={selectedUserKeys.has(userSelectionKey(user))} {group.users.length}
onChange={() => toggleUserSelection(user)} </span>
className="mt-1 size-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600 dark:border-white/10 dark:bg-white/5" </button>
/> )
<span className="min-w-0 flex-1"> })}
<span className="block truncate text-sm font-medium text-gray-900 dark:text-white"> </div>
{userTitle(user)}
</span> <div
{userSubtitle(user) && ( className={classNames(
<span className="mt-0.5 block truncate text-xs text-gray-500 dark:text-gray-400"> 'mt-3 max-h-96 overflow-y-auto rounded-lg border border-gray-200 p-3 dark:border-white/10',
{userSubtitle(user)} addUserSearchIsActive && 'space-y-4',
)}
>
{visibleAddUserGroups.map((group) => (
<section key={group.source}>
{addUserSearchIsActive && (
<div className="mb-2 flex items-center justify-between">
<h3 className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{group.title}
</h3>
<span className="text-xs text-gray-400 dark:text-gray-500">
{group.users.length}
</span>
</div>
)}
{group.unavailable ? (
<div className="rounded-lg border border-dashed border-gray-200 px-4 py-6 text-center text-sm text-gray-500 dark:border-white/10 dark:text-gray-400">
Active Directory ist noch nicht konfiguriert.
</div>
) : group.loading ? (
<div className="flex min-h-28 items-center justify-center rounded-lg border border-dashed border-gray-200 dark:border-white/10">
<LoadingSpinner
size="sm"
label={`${group.title} werden geladen...`}
className="text-indigo-600 dark:text-indigo-400"
/>
</div>
) : group.users.length === 0 ? (
<div className="rounded-lg border border-dashed border-gray-200 px-4 py-6 text-center text-sm text-gray-500 dark:border-white/10 dark:text-gray-400">
{group.emptyText}
</div>
) : (
<ul className="divide-y divide-gray-200 overflow-hidden rounded-lg border border-gray-200 dark:divide-white/10 dark:border-white/10">
{group.users.map((user) => (
<li key={userSelectionKey(user)}>
<label className="flex cursor-pointer items-start gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-white/5">
<input
type="checkbox"
checked={selectedUserKeys.has(
userSelectionKey(user),
)}
onChange={() => toggleUserSelection(user)}
className="mt-1 size-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600 dark:border-white/10 dark:bg-white/5"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-gray-900 dark:text-white">
{userTitle(user)}
</span> </span>
)} {userSubtitle(user) && (
</span> <span className="mt-0.5 block truncate text-xs text-gray-500 dark:text-gray-400">
</label> {userSubtitle(user)}
</li> </span>
))} )}
</ul> </span>
)} </label>
</section> </li>
))} ))}
</ul>
)}
</section>
))}
</div>
</div> </div>
</div> </div>

View File

@ -238,7 +238,6 @@ function mapActiveDirectoryUserToComboboxItem(user: OperationActiveDirectoryUser
return { return {
id: user.id, id: user.id,
name: accountName || user.displayName || user.email || user.id, name: accountName || user.displayName || user.email || user.id,
secondaryText: [user.displayName, user.email].filter(Boolean).join(' / '),
username: user.username, username: user.username,
email: user.email, email: user.email,
sid: user.sid, sid: user.sid,
@ -246,6 +245,29 @@ function mapActiveDirectoryUserToComboboxItem(user: OperationActiveDirectoryUser
} }
} }
const evaluationLaptopPattern = /\bauswertung\s*([1-9]|10)\b/i
function automaticMilestoneUserNameFromLaptop(
device: Device | undefined,
fallbackValue: string,
) {
const candidates = [
device?.computerName,
device?.inventoryNumber,
device?.model,
fallbackValue,
]
for (const candidate of candidates) {
const match = candidate?.match(evaluationLaptopPattern)
if (match?.[1]) {
return `auswertung${match[1]}`
}
}
return ''
}
async function readApiError(response: Response, fallback: string) { async function readApiError(response: Response, fallback: string) {
const data = await response.json().catch(() => null) const data = await response.json().catch(() => null)
@ -617,6 +639,7 @@ export default function CreateOperationModal({
const [isLoadingMilestoneUsers, setIsLoadingMilestoneUsers] = useState(false) const [isLoadingMilestoneUsers, setIsLoadingMilestoneUsers] = useState(false)
const [milestoneUsersConfigured, setMilestoneUsersConfigured] = useState(false) const [milestoneUsersConfigured, setMilestoneUsersConfigured] = useState(false)
const [milestoneUsersError, setMilestoneUsersError] = useState<string | null>(null) const [milestoneUsersError, setMilestoneUsersError] = useState<string | null>(null)
const [showMilestoneUserOverride, setShowMilestoneUserOverride] = useState(false)
const [equipmentDevices, setEquipmentDevices] = useState<Device[]>([]) const [equipmentDevices, setEquipmentDevices] = useState<Device[]>([])
const [isLoadingDevices, setIsLoadingDevices] = useState(false) const [isLoadingDevices, setIsLoadingDevices] = useState(false)
@ -682,6 +705,26 @@ export default function CreateOperationModal({
[equipmentDevices], [equipmentDevices],
) )
const selectedLaptopDetails = useMemo(
() => findEquipmentDevice(selectedLaptopDevice),
[equipmentDevices, selectedLaptopDevice],
)
const automaticMilestoneUserName = useMemo(
() =>
automaticMilestoneUserNameFromLaptop(
selectedLaptopDetails,
formValues.laptop,
),
[formValues.laptop, selectedLaptopDetails],
)
const milestoneUserDisplayValue =
selectedMilestoneUser?.name ||
(automaticMilestoneUserName
? `${automaticMilestoneUserName} (automatisch)`
: '')
const storageDisplayNameById = useMemo(() => { const storageDisplayNameById = useMemo(() => {
const byId = new Map<string, string>() const byId = new Map<string, string>()
@ -709,6 +752,7 @@ export default function CreateOperationModal({
setIsLoadingMilestoneUsers(false) setIsLoadingMilestoneUsers(false)
setMilestoneUsersConfigured(false) setMilestoneUsersConfigured(false)
setMilestoneUsersError(null) setMilestoneUsersError(null)
setShowMilestoneUserOverride(false)
setSelectedCameraDevice(null) setSelectedCameraDevice(null)
setSelectedRouterDevice(null) setSelectedRouterDevice(null)
setSelectedSwitchboxDevice(null) setSelectedSwitchboxDevice(null)
@ -1025,6 +1069,9 @@ export default function CreateOperationModal({
setSelectedSwitchboxDevice(item) setSelectedSwitchboxDevice(item)
} else { } else {
setSelectedLaptopDevice(item) setSelectedLaptopDevice(item)
setSelectedMilestoneUser(null)
setShowMilestoneUserOverride(false)
setMilestoneUsersError(null)
} }
updateField( updateField(
@ -1082,6 +1129,9 @@ export default function CreateOperationModal({
} }
function handleLaptopDeviceChange(item: ComboboxItem | null) { function handleLaptopDeviceChange(item: ComboboxItem | null) {
setSelectedMilestoneUser(null)
setShowMilestoneUserOverride(false)
setMilestoneUsersError(null)
handleEquipmentDeviceChange('laptop', item) handleEquipmentDeviceChange('laptop', item)
} }
@ -1587,41 +1637,6 @@ export default function CreateOperationModal({
/> />
</div> </div>
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/[0.03]">
<div className="mb-3">
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
Milestone-Auswerter
</h4>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
AD-Benutzer aus dem konfigurierten Suchbereich für die Milestone-Rolle.
</p>
</div>
<Combobox
items={milestoneUserOptions}
value={selectedMilestoneUser}
onChange={handleMilestoneUserChange}
variant="secondary"
placeholder={
isLoadingMilestoneUsers
? 'AD-Benutzer werden geladen...'
: 'AD-Benutzer auswählen'
}
emptyText={
milestoneUsersConfigured
? 'Keine AD-Benutzer im Suchbereich gefunden.'
: 'AD ist in den Milestone-Einstellungen noch nicht konfiguriert.'
}
disabled={isLoadingMilestoneUsers || !milestoneUsersConfigured}
/>
{milestoneUsersError && (
<p className="mt-2 text-xs text-amber-700 dark:text-amber-300">
{milestoneUsersError}
</p>
)}
</div>
</div> </div>
</div> </div>
)} )}
@ -1650,93 +1665,176 @@ export default function CreateOperationModal({
{activeStep === 'equipment' && ( {activeStep === 'equipment' && (
<div className="space-y-6"> <div className="space-y-6">
<div className="grid grid-cols-1 gap-x-4 gap-y-4 lg:grid-cols-2"> <div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:items-start">
<Combobox <div className="space-y-4">
label="Kamera" <Combobox
labelIcon={VideoCameraIcon} label="Kamera"
items={hardwareCameraOptions} labelIcon={VideoCameraIcon}
value={selectedCameraDevice} items={hardwareCameraOptions}
onChange={handleCameraDeviceChange} value={selectedCameraDevice}
variant="secondary" onChange={handleCameraDeviceChange}
placeholder={ variant="secondary"
isLoadingDevices placeholder={
? 'Geräte werden geladen...' isLoadingDevices
: 'Hardware-Kamera auswählen' ? 'Geräte werden geladen...'
} : 'Hardware-Kamera auswählen'
emptyText="Keine Hardware-Kamera gefunden." }
disabled={isLoadingDevices} emptyText="Keine Hardware-Kamera gefunden."
/> disabled={isLoadingDevices}
/>
<Combobox <Combobox
label="Router" label="Switchbox"
labelIcon={WifiIcon} labelIcon={ArrowsRightLeftIcon}
items={routerOptions} items={switchboxOptions}
value={selectedRouterDevice} value={selectedSwitchboxDevice}
onChange={handleRouterDeviceChange} onChange={handleSwitchboxDeviceChange}
variant="secondary" variant="secondary"
allowCustomValue allowCustomValue
createOptionLabel={(value) => `"${value}" als Router anlegen`} createOptionLabel={(value) => `"${value}" als Switchbox anlegen`}
placeholder={ placeholder={
isLoadingDevices isLoadingDevices
? 'Geräte werden geladen...' ? 'Geräte werden geladen...'
: 'Router auswählen oder anlegen' : 'Switchbox auswählen oder anlegen'
} }
emptyText="Kein Router gefunden." emptyText="Keine Switchbox gefunden."
disabled={isLoadingDevices} disabled={isLoadingDevices}
/> />
<Combobox <Combobox
label="Switchbox" label="Festplatte"
labelIcon={ArrowsRightLeftIcon} labelIcon={CircleStackIcon}
items={switchboxOptions} items={hardDriveOptions}
value={selectedSwitchboxDevice} value={selectedHardDriveDevice}
onChange={handleSwitchboxDeviceChange} onChange={handleHardDriveDeviceChange}
variant="secondary" variant="secondary"
allowCustomValue allowCustomValue
createOptionLabel={(value) => `"${value}" als Switchbox anlegen`} placeholder={
placeholder={ isLoadingDevices
isLoadingDevices ? 'Geräte werden geladen...'
? 'Geräte werden geladen...' : 'Festplatte auswählen oder eingeben'
: 'Switchbox auswählen oder anlegen' }
} emptyText="Keine Festplatte gefunden."
emptyText="Keine Switchbox gefunden." disabled={isLoadingDevices}
disabled={isLoadingDevices} />
/> </div>
<Combobox <div className="space-y-4">
label="Laptop" <Combobox
labelIcon={ComputerDesktopIcon} label="Router"
items={laptopOptions} labelIcon={WifiIcon}
value={selectedLaptopDevice} items={routerOptions}
onChange={handleLaptopDeviceChange} value={selectedRouterDevice}
variant="secondary" onChange={handleRouterDeviceChange}
allowCustomValue variant="secondary"
createOptionLabel={(value) => `"${value}" als Laptop anlegen`} allowCustomValue
placeholder={ createOptionLabel={(value) => `"${value}" als Router anlegen`}
isLoadingDevices placeholder={
? 'Geräte werden geladen...' isLoadingDevices
: 'Laptop auswählen oder anlegen' ? 'Geräte werden geladen...'
} : 'Router auswählen oder anlegen'
emptyText="Kein Laptop gefunden." }
disabled={isLoadingDevices} emptyText="Kein Router gefunden."
/> disabled={isLoadingDevices}
/>
<Combobox <Combobox
label="Festplatte" label="Laptop"
labelIcon={CircleStackIcon} labelIcon={ComputerDesktopIcon}
items={hardDriveOptions} items={laptopOptions}
value={selectedHardDriveDevice} value={selectedLaptopDevice}
onChange={handleHardDriveDeviceChange} onChange={handleLaptopDeviceChange}
variant="secondary" variant="secondary"
allowCustomValue allowCustomValue
placeholder={ createOptionLabel={(value) => `"${value}" als Laptop anlegen`}
isLoadingDevices placeholder={
? 'Geräte werden geladen...' isLoadingDevices
: 'Festplatte auswählen oder eingeben' ? 'Geräte werden geladen...'
} : 'Laptop auswählen oder anlegen'
emptyText="Keine Festplatte gefunden." }
disabled={isLoadingDevices} emptyText="Kein Laptop gefunden."
/> disabled={isLoadingDevices}
/>
<div className="rounded-md border border-indigo-100 bg-indigo-50/60 px-3 py-2 dark:border-indigo-400/20 dark:bg-indigo-500/10">
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
<div className="min-w-0 flex flex-1 items-center gap-2">
<span className="shrink-0 text-xs font-semibold uppercase tracking-wide text-indigo-700 dark:text-indigo-300">
Auswerter
</span>
<span className="min-w-0 truncate text-sm text-gray-900 dark:text-white">
{selectedMilestoneUser?.name ||
automaticMilestoneUserName ||
'Automatisch nach Laptop'}
</span>
{!selectedMilestoneUser && automaticMilestoneUserName && (
<span className="shrink-0 rounded-full bg-white px-2 py-0.5 text-[0.6875rem] font-medium text-indigo-700 ring-1 ring-indigo-600/15 ring-inset dark:bg-white/10 dark:text-indigo-200 dark:ring-indigo-300/20">
automatisch
</span>
)}
</div>
<div className="flex shrink-0 gap-2">
{selectedMilestoneUser && (
<Button
type="button"
variant="secondary"
color="gray"
size="sm"
onClick={() => {
setSelectedMilestoneUser(null)
setShowMilestoneUserOverride(false)
setMilestoneUsersError(null)
}}
>
Automatik
</Button>
)}
<Button
type="button"
variant="secondary"
color="gray"
size="sm"
onClick={() =>
setShowMilestoneUserOverride((current) => !current)
}
>
{showMilestoneUserOverride ? 'Ausblenden' : 'Ändern'}
</Button>
</div>
</div>
{showMilestoneUserOverride && (
<div className="mt-2">
<Combobox
items={milestoneUserOptions}
value={selectedMilestoneUser}
onChange={handleMilestoneUserChange}
variant="secondary"
placeholder={
isLoadingMilestoneUsers
? 'AD-Benutzer werden geladen...'
: 'AD-Benutzer auswählen'
}
emptyText={
milestoneUsersConfigured
? 'Keine AD-Benutzer im Suchbereich gefunden.'
: 'AD ist in den Milestone-Einstellungen noch nicht konfiguriert.'
}
disabled={
isLoadingMilestoneUsers || !milestoneUsersConfigured
}
/>
{milestoneUsersError && (
<p className="mt-2 text-xs text-amber-700 dark:text-amber-300">
{milestoneUsersError}
</p>
)}
</div>
)}
</div>
</div>
</div> </div>
</div> </div>
)} )}
@ -2097,7 +2195,7 @@ export default function CreateOperationModal({
</h4> </h4>
</div> </div>
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-4"> <div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-3">
<ReviewItem label="Sachbearbeitung" value={formValues.caseWorker} /> <ReviewItem label="Sachbearbeitung" value={formValues.caseWorker} />
<ReviewPersonItem <ReviewPersonItem
@ -2112,11 +2210,6 @@ export default function CreateOperationModal({
fallbackValue={formValues.operationTeam} fallbackValue={formValues.operationTeam}
/> />
<ReviewPersonItem
label="Milestone-Auswerter"
item={selectedMilestoneUser}
fallbackValue=""
/>
</div> </div>
</div> </div>
@ -2127,11 +2220,12 @@ export default function CreateOperationModal({
</h4> </h4>
</div> </div>
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-5"> <div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-6">
<ReviewItem label="Kamera" value={formValues.camera} /> <ReviewItem label="Kamera" value={formValues.camera} />
<ReviewItem label="Router" value={formValues.router} /> <ReviewItem label="Router" value={formValues.router} />
<ReviewItem label="Switchbox" value={formValues.switchbox} /> <ReviewItem label="Switchbox" value={formValues.switchbox} />
<ReviewItem label="Laptop" value={formValues.laptop} /> <ReviewItem label="Laptop" value={formValues.laptop} />
<ReviewItem label="Milestone-Auswerter" value={milestoneUserDisplayValue} />
<ReviewItem label="Festplatte" value={formValues.hardDrive} /> <ReviewItem label="Festplatte" value={formValues.hardDrive} />
</div> </div>
</div> </div>

View File

@ -431,7 +431,7 @@ export default function Konto({
src={avatarPreview} src={avatarPreview}
name={currentUser?.displayName || currentUser?.username || currentUser?.email} name={currentUser?.displayName || currentUser?.username || currentUser?.email}
size={16} size={16}
shape="rounded" shape="circle"
/> />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">

View File

@ -1,9 +1,9 @@
// frontend/src/utils/activityLog.ts // frontend/src/utils/activityLog.ts
// //
// Clientseitiges Aktivitäts- und Fehlerprotokoll. Erfasst app-weit, was der // Clientseitiges Aktivitaets- und Fehlerprotokoll. Erfasst app-weit nur Klicks
// Nutzer anklickt, welche Seiten er aufruft und welche Fehler im Browser // auf Schaltflaechen, Seitenaufrufe und API-Fehler. Wird auf der Feedback-Seite
// auftreten. Wird auf der Feedback-Seite sichtbar gemacht und ans Feedback // sichtbar gemacht und ans Feedback angehaengt, damit Administratoren Probleme
// angehängt, damit Administratoren Probleme nachvollziehen können. // nachvollziehen koennen.
export type ActivityLogType = 'action' | 'navigation' | 'error' export type ActivityLogType = 'action' | 'navigation' | 'error'
@ -18,7 +18,10 @@ export type ActivityLogEntry = {
const MAX_ENTRIES = 200 const MAX_ENTRIES = 200
const STORAGE_KEY = 'teg.activityLog' const STORAGE_KEY = 'teg.activityLog'
const MAX_STACK_LENGTH = 4000 const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
type FetchInput = Parameters<typeof fetch>[0]
type FetchInit = Parameters<typeof fetch>[1]
let entries: ActivityLogEntry[] = loadEntries() let entries: ActivityLogEntry[] = loadEntries()
let initialized = false let initialized = false
@ -50,7 +53,7 @@ function persist() {
try { try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(entries)) sessionStorage.setItem(STORAGE_KEY, JSON.stringify(entries))
} catch { } catch {
// Speicher voll oder im privaten Modus Protokoll bleibt nur im Speicher. // Speicher voll oder privater Modus: Protokoll bleibt nur im Speicher.
} }
} }
@ -119,54 +122,29 @@ export function subscribeActivityLog(
} }
} }
function truncateStack(stack?: string) { function describeButton(target: Element): string | null {
if (!stack) { const button = target.closest(
return undefined 'button, [role="button"], input[type="button"], input[type="submit"], input[type="reset"]',
}
return stack.length > MAX_STACK_LENGTH ? stack.slice(0, MAX_STACK_LENGTH) : stack
}
function describeElement(target: Element): string | null {
const actionable = target.closest(
'button, a, [role="button"], input, select, textarea, label, summary, [role="menuitem"], [role="tab"], [data-log-label]',
) )
if (!actionable) { if (!button) {
return null return null
} }
const element = actionable as HTMLElement const element = button as HTMLElement
const tag = element.tagName.toLowerCase()
const role =
element.getAttribute('data-log-role') ||
(tag === 'a'
? 'Link'
: tag === 'button' || element.getAttribute('role') === 'button'
? 'Schaltfläche'
: tag === 'input'
? `Eingabe (${(element as HTMLInputElement).type || 'text'})`
: tag === 'select'
? 'Auswahl'
: tag === 'textarea'
? 'Textfeld'
: tag === 'summary'
? 'Aufklappen'
: 'Element')
const rawLabel = const rawLabel =
element.getAttribute('data-log-label') || element.getAttribute('data-log-label') ||
element.getAttribute('aria-label') || element.getAttribute('aria-label') ||
element.getAttribute('title') || element.getAttribute('title') ||
(element as HTMLInputElement).placeholder || (element instanceof HTMLInputElement ? element.value : '') ||
(element.textContent ?? '').replace(/\s+/g, ' ').trim() (element.textContent ?? '').replace(/\s+/g, ' ').trim()
if (!rawLabel) { if (!rawLabel) {
return role return 'Schaltflaeche'
} }
const label = rawLabel.length > 80 ? `${rawLabel.slice(0, 80)}` : rawLabel const label = rawLabel.length > 80 ? `${rawLabel.slice(0, 80)}...` : rawLabel
return `${role}: „${label}"` return `Schaltflaeche: "${label}"`
} }
function handleClick(event: MouseEvent) { function handleClick(event: MouseEvent) {
@ -175,45 +153,103 @@ function handleClick(event: MouseEvent) {
return return
} }
const description = describeElement(target) const description = describeButton(target)
if (description) { if (description) {
recordAction(`Klick ${description}`) recordAction(`Klick - ${description}`)
} }
} }
function handleError(event: ErrorEvent) { function requestUrl(input: FetchInput) {
if (!event.message && event.target && event.target !== window) { if (typeof input === 'string') {
const element = event.target as HTMLElement & { src?: string; href?: string } return input
const source = element.src || element.href || ''
recordError(
`Ressource konnte nicht geladen werden${source ? `: ${source}` : ''}`,
{ element: element.tagName?.toLowerCase() },
)
return
} }
recordError(event.message || 'Unbekannter Fehler', { if (input instanceof URL) {
source: event.filename, return input.href
line: event.lineno, }
column: event.colno,
stack: truncateStack(event.error?.stack), return input.url
})
} }
function handleRejection(event: PromiseRejectionEvent) { function requestMethod(input: FetchInput, init: FetchInit) {
const reason = event.reason if (init?.method) {
const message = reason instanceof Error ? reason.message : String(reason) return init.method.toUpperCase()
}
recordError(`Unbehandelte Ablehnung: ${message}`, { if (typeof input !== 'string' && !(input instanceof URL)) {
stack: reason instanceof Error ? truncateStack(reason.stack) : undefined, return input.method.toUpperCase()
}) }
return 'GET'
} }
function safeStringify(value: unknown) { function normalizedUrl(value: string) {
return new URL(value, window.location.origin)
}
function isApiRequest(value: string) {
try { try {
return JSON.stringify(value) const url = normalizedUrl(value)
const apiUrl = normalizedUrl(API_URL)
const apiPath = apiUrl.pathname.replace(/\/$/, '')
if (url.pathname.startsWith('/api/')) {
return true
}
return (
url.origin === apiUrl.origin &&
(apiPath === '' ||
url.pathname === apiPath ||
url.pathname.startsWith(`${apiPath}/`))
)
} catch { } catch {
return String(value) return false
}
}
function apiPath(value: string) {
const url = normalizedUrl(value)
return `${url.pathname}${url.search}`
}
function apiErrorDetails(method: string, url: string) {
return {
method,
url: apiPath(url),
}
}
function initApiErrorLogging() {
const originalFetch = window.fetch.bind(window)
window.fetch = async (...args: Parameters<typeof window.fetch>) => {
const [input, init] = args
const url = requestUrl(input)
const method = requestMethod(input, init)
try {
const response = await originalFetch(...args)
if (!response.ok && isApiRequest(url)) {
recordError(`API-Fehler: ${method} ${apiPath(url)} (${response.status})`, {
...apiErrorDetails(method, url),
status: response.status,
statusText: response.statusText,
})
}
return response
} catch (error) {
if (isApiRequest(url)) {
recordError(`API nicht erreichbar: ${method} ${apiPath(url)}`, {
...apiErrorDetails(method, url),
message: error instanceof Error ? error.message : String(error),
})
}
throw error
}
} }
} }
@ -224,33 +260,5 @@ export function initActivityLog() {
initialized = true initialized = true
document.addEventListener('click', handleClick, { capture: true }) document.addEventListener('click', handleClick, { capture: true })
window.addEventListener('error', handleError, true) initApiErrorLogging()
window.addEventListener('unhandledrejection', handleRejection)
const originalConsoleError = console.error.bind(console)
console.error = (...args: unknown[]) => {
try {
const errorArg = args.find((arg): arg is Error => arg instanceof Error)
const message = args
.map((arg) =>
arg instanceof Error
? arg.message
: typeof arg === 'string'
? arg
: safeStringify(arg),
)
.join(' ')
// React-Entwicklungswarnungen sind kein echter Nutzerfehler.
if (!message.startsWith('Warning:')) {
recordError(`console.error: ${message}`, {
stack: truncateStack(errorArg?.stack),
})
}
} catch {
// Logging darf die App niemals stören.
}
originalConsoleError(...args)
}
} }