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,6 +16,7 @@ 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"`
@ -24,6 +25,7 @@ type AdminGroupChat struct {
type saveAdminGroupChatRequest struct { type saveAdminGroupChatRequest struct {
Name string `json:"name"` Name string `json:"name"`
Image string `json:"image"` Image string `json:"image"`
IsPublic bool `json:"isPublic"`
UserIDs []string `json:"userIds"` UserIDs []string `json:"userIds"`
} }
@ -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,14 +330,24 @@ 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 (
EXISTS (
SELECT 1 SELECT 1
FROM conversation_members cm FROM conversation_members cm
WHERE cm.conversation_id = c.id WHERE cm.conversation_id = c.id
AND cm.user_id = $1 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
`, `,
user.ID, user.ID,
@ -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,12 +1052,64 @@ 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 (
EXISTS (
SELECT 1 SELECT 1
FROM conversation_members cm FROM conversation_members cm
WHERE cm.conversation_id = c.id WHERE cm.conversation_id = c.id
AND cm.user_id = $2 AND cm.user_id = $2
) )
OR c.is_public = true
)
)
`,
conversationID,
userID,
).Scan(&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, conversationID,
@ -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,16 +48,26 @@ 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(
viewer_membership.last_cleared_at,
'-infinity'::TIMESTAMPTZ
)
AND (
EXISTS (
SELECT 1 SELECT 1
FROM conversation_members cm FROM conversation_members cm
WHERE cm.conversation_id = c.id WHERE cm.conversation_id = c.id
AND cm.user_id = $1 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,24 +824,14 @@ func (s *Server) ensureOperationMilestoneRoleUser(
return nil, nil return nil, nil
} }
roleName := operationMilestoneEvaluationRoleName(*laptopDevice, operation)
if roleName == "" {
return nil, fmt.Errorf("Auswertungsrolle konnte aus Laptop %q nicht ermittelt werden", laptopDevice.InventoryNumber)
}
sourceRole, err := findOperationMilestoneRoleByName(ctx, config, roleName)
if err != nil {
return nil, err
}
if sourceRole == nil {
operationRoleName := operationMilestoneRoleName(operation) operationRoleName := operationMilestoneRoleName(operation)
if operationRoleName == "" { if operationRoleName == "" {
return nil, fmt.Errorf("Milestone-Rolle %q wurde nicht gefunden und Einsatznummer fehlt", roleName) return nil, errors.New("Einsatznummer fehlt, Milestone-Rolle konnte nicht angelegt werden")
} }
role, err := ensureOperationMilestoneRole(ctx, config, operationRoleName) role, err := ensureOperationMilestoneRole(ctx, config, operationRoleName)
if err != nil { if err != nil {
return nil, fmt.Errorf("Milestone-Rolle %q wurde nicht gefunden und Rolle %q konnte nicht angelegt werden: %w", roleName, operationRoleName, err) return nil, err
} }
role, err = getOperationMilestoneRole(ctx, config, role.ID) role, err = getOperationMilestoneRole(ctx, config, role.ID)
@ -849,59 +839,26 @@ func (s *Server) ensureOperationMilestoneRoleUser(
return nil, err return nil, err
} }
if selectedUser := operationMilestoneUserFromSetupInput(input); selectedUser != nil { roleUsers, err := listOperationMilestoneRoleUsers(ctx, config, role.ID)
roleUsers, listErr := listOperationMilestoneRoleUsers(ctx, config, role.ID) if err != nil {
if listErr != nil {
roleUsers = operationMilestoneObjectListFromValue(role.Data["users"]) roleUsers = operationMilestoneObjectListFromValue(role.Data["users"])
} }
if operationMilestoneObjectByID(roleUsers, milestoneItemID(selectedUser)) == nil { usersToAssign, targetUserName, err := s.operationMilestoneRoleUsersForLaptop(
if err := addOperationMilestoneRoleMembers(ctx, config, role.ID, []map[string]any{selectedUser}); err != nil { ctx,
return nil, err config,
} *laptopDevice,
roleUsers = appendOperationMilestoneObjectByID(roleUsers, selectedUser) operation,
} input,
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)
if err != nil {
return nil, err
}
candidateUsers := sourceUsers
if hasOperationMilestoneUserSelection(input) {
allRoleUsers, err := listOperationMilestoneUsersFromRoles(ctx, config)
if err != nil {
return nil, err
}
candidateUsers = appendOperationMilestoneObjectsByID(candidateUsers, allRoleUsers...)
}
usersToAssign := selectOperationMilestoneRoleUsersForAssignment(candidateUsers, targetUserName, input)
if len(usersToAssign) == 0 { if len(usersToAssign) == 0 {
if selectedUser := operationMilestoneUserFromSetupInput(input); selectedUser != nil { if targetUserName == "" {
usersToAssign = []map[string]any{selectedUser} targetUserName = operationMilestoneEvaluationRoleName(*laptopDevice, operation)
} else {
return nil, fmt.Errorf("AD-Benutzer %q wurde unter /roles/%s/users nicht gefunden", targetUserName, sourceRole.ID)
} }
} return nil, fmt.Errorf("AD-Benutzer %q wurde nicht gefunden", targetUserName)
roleUsers := sourceUsers
if len(roleUsers) == 0 {
roleUsers = operationMilestoneObjectListFromValue(role.Data["users"])
} }
usersToAdd := []map[string]any{} usersToAdd := []map[string]any{}
@ -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
</div> API-Fehler.
</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

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"
>
{children.map((child) => {
const childActive = itemIsActive(child, location.pathname)
return (
<li key={child.name}>
<NavLink
to={child.href}
onClick={handleNavigate}
className={classNames( className={classNames(
childActive 'mt-1 space-y-1 border-l border-white/10 pl-3',
? 'bg-indigo-500/10 text-indigo-200 ring-1 ring-indigo-400/20' depth === 0 ? 'ml-[1.35rem]' : 'ml-3',
: '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 {children.map((child) => renderNavigationItem(child, depth + 1))}
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,9 +335,8 @@ 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}`}>
@ -313,11 +352,11 @@ function SidebarContent({
</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">
@ -325,16 +364,11 @@ function SidebarContent({
<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,21 +801,65 @@ export default function Tables<T>({
) )
} }
function renderTable() { function getFallbackColumnWidth() {
return ( const flexibleColumnCount = columns.filter((column) => !column.width).length
<table
className={classNames(
isStickyHeader
? 'min-w-full border-separate border-spacing-0'
: 'relative min-w-full divide-y divide-gray-300 dark:divide-white/15',
isBorder && 'divide-y divide-gray-300 dark:divide-white/15',
)}
>
{renderTableHead()}
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 (
<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 <tbody
className={classNames( className={classNames(
!isStickyHeader && 'divide-y divide-gray-200 dark:divide-white/10', '[&>tr:last-child>td]:border-b-0',
isCard isCard
? 'bg-white dark:bg-gray-800/50' ? 'bg-white dark:bg-gray-800/50'
: 'bg-white dark:bg-gray-900', : 'bg-white dark:bg-gray-900',
@ -700,12 +874,62 @@ export default function Tables<T>({
</> </>
)} )}
</tbody> </tbody>
)
}
function renderTable() {
return (
<table className={tableClassName()}>
{renderTableColGroup()}
{renderTableHead()}
{renderTableBody()}
{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>

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

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 className="truncate">
{event.allDay ? 'Ganztägig' : formatTime(eventStartValue(event))}
</span> </span>
<span className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs opacity-80">
<span className="inline-flex items-center gap-1">
<ClockIcon className="size-3.5" />
{event.allDay
? 'Ganztägig'
: `${formatTime(eventStartValue(event))} - ${formatTime(
eventEndValue(event),
)}`}
</span> </span>
{event.location && ( {event.location && (
<span className="inline-flex items-center gap-1 truncate"> <span className="mt-0.5 inline-flex max-w-full items-center gap-1 truncate text-xs opacity-80">
<MapPinIcon className="size-3.5" /> <MapPinIcon className="size-3.5 shrink-0" />
{event.location} <span className="truncate">{event.location}</span>
</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>
<div className="flex min-w-0 flex-wrap items-center gap-2 align-middle">
<h1 className="truncate text-lg font-semibold capitalize text-gray-950 dark:text-white"> <h1 className="truncate text-lg font-semibold capitalize text-gray-950 dark:text-white">
{titleForView(selectedDate, view)} {titleForView(selectedDate, view)}
</h1> </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

@ -11,3 +11,46 @@ 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"

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,12 +607,13 @@ 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 items-start gap-x-3 border-b border-gray-200 pb-5 dark:border-white/10">
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600 ring-1 ring-indigo-600/10 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20"> <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" /> <VideoCameraIcon aria-hidden="true" className="size-5" />
</div> </div>
@ -574,8 +629,8 @@ export default function Milestone() {
</div> </div>
</div> </div>
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="mt-5 grid grid-cols-1 gap-5 lg:grid-cols-6">
<div className="sm:col-span-2"> <div className="lg: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,9 +712,16 @@ export default function Milestone() {
/> />
</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="sm:col-span-2">
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/5">
<div> <div>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white"> <h3 className="text-sm font-semibold text-gray-900 dark:text-white">
TreeView-Agent TreeView-Agent
@ -672,8 +730,9 @@ export default function Milestone() {
Verbindung zum Server-Folders-Agent auf dem Milestone-/Recording-Server. Verbindung zum Server-Folders-Agent auf dem Milestone-/Recording-Server.
</p> </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,10 +868,126 @@ 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> </div>
<div className="mt-6 flex justify-end gap-x-3"> <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 <Button
type="button" type="button"
variant="secondary" variant="secondary"
@ -821,7 +996,7 @@ export default function Milestone() {
disabled={!settings?.passwordConfigured || isSaving} disabled={!settings?.passwordConfigured || isSaving}
onClick={() => void handleFetchToken()} onClick={() => void handleFetchToken()}
> >
Token & Infos erneut abrufen Milestone synchronisieren
</Button> </Button>
<Button <Button
@ -832,15 +1007,27 @@ export default function Milestone() {
Speichern Speichern
</Button> </Button>
</div> </div>
</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">
<ServerStackIcon aria-hidden="true" className="size-5" />
</div>
<div>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
Status Status
</h3> </h3>
<dl className="mt-4 grid grid-cols-1 gap-x-6 gap-y-4 text-sm sm:grid-cols-2"> <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> </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 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}
</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)} {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,21 +817,22 @@ 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 && (
<fieldset disabled={isMilestoneDevice || Boolean(lockedDeviceCategory)}>
<legend className="block text-sm/6 font-medium text-gray-900 dark:text-white"> <legend className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Geräteart Geräteart
</legend> </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 (
@ -802,7 +840,7 @@ export default function DeviceFormFields({
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',
@ -837,6 +875,7 @@ export default function DeviceFormFields({
})} })}
</div> </div>
</fieldset> </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,14 +2011,80 @@ 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">
{canManageManufacturers && (
<Button
type="button"
variant="secondary"
color="gray"
size="md"
leadingIcon={<TagIcon aria-hidden="true" className="size-4" />}
onClick={openManufacturerModal}
>
Hersteller verwalten
</Button>
)}
{showFirmwareCheckButton && (
<Button <Button
type="button" type="button"
variant="secondary" variant="secondary"
@ -1580,6 +2098,7 @@ export default function DevicesPage({ currentUser }: DevicesPageProps = {}) {
> >
Firmware prüfen Firmware prüfen
</Button> </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">
{onDelete ? (
<Button
type="button"
variant="secondary"
color="red"
disabled={isSaving || isDeleting || isMilestoneDevice}
title={
isMilestoneDevice
? 'Milestone-Geräte können nicht gelöscht werden.'
: undefined
}
leadingIcon={<TrashIcon aria-hidden="true" className="size-4" />}
onClick={() => setIsDeleteConfirmOpen(true)}
>
Löschen
</Button>
) : (
<span />
)}
<div className="flex justify-end gap-x-3">
<Button <Button
type="button" type="button"
variant="secondary" variant="secondary"
color="gray" color="gray"
disabled={isSaving} disabled={isSaving || isDeleting}
onClick={() => handleOpenChange(false)} onClick={() => handleOpenChange(false)}
> >
{activeTab === 'history' ? 'Schließen' : 'Abbrechen'} Abbrechen
</Button> </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,26 +1153,55 @@ 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
},
{ return (
source: 'basic' as const, <button
title: 'Basisbenutzer', key={source}
users: visibleAssignableUsersBySource.basic, type="button"
loading: isLoadingBasicUsers, role="tab"
unavailable: false, aria-selected={active}
emptyText: 'Keine passenden Basisbenutzer gefunden.', onClick={() => setActiveAddUsersTab(source)}
}, className={classNames(
]).map((group) => ( active
? 'bg-white text-indigo-700 shadow-xs dark:bg-white/10 dark:text-white'
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white',
'flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-semibold transition',
)}
>
<span>{group.title}</span>
<span
className={classNames(
active
? 'bg-indigo-50 text-indigo-700 dark:bg-white/10 dark:text-white'
: 'bg-white text-gray-500 dark:bg-white/10 dark:text-gray-300',
'inline-flex min-w-6 justify-center rounded-full px-1.5 py-0.5 text-xs leading-none',
)}
>
{group.users.length}
</span>
</button>
)
})}
</div>
<div
className={classNames(
'mt-3 max-h-96 overflow-y-auto rounded-lg border border-gray-200 p-3 dark:border-white/10',
addUserSearchIsActive && 'space-y-4',
)}
>
{visibleAddUserGroups.map((group) => (
<section key={group.source}> <section key={group.source}>
{addUserSearchIsActive && (
<div className="mb-2 flex items-center justify-between"> <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"> <h3 className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{group.title} {group.title}
@ -1137,6 +1210,7 @@ export default function MilestoneRolesPage({
{group.users.length} {group.users.length}
</span> </span>
</div> </div>
)}
{group.unavailable ? ( {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"> <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">
@ -1161,7 +1235,9 @@ export default function MilestoneRolesPage({
<label className="flex cursor-pointer items-start gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-white/5"> <label className="flex cursor-pointer items-start gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-white/5">
<input <input
type="checkbox" type="checkbox"
checked={selectedUserKeys.has(userSelectionKey(user))} checked={selectedUserKeys.has(
userSelectionKey(user),
)}
onChange={() => toggleUserSelection(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" className="mt-1 size-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600 dark:border-white/10 dark:bg-white/5"
/> />
@ -1184,6 +1260,7 @@ export default function MilestoneRolesPage({
))} ))}
</div> </div>
</div> </div>
</div>
<div className="flex justify-end gap-3 border-t border-gray-200 px-6 py-4 dark:border-white/10"> <div className="flex justify-end gap-3 border-t border-gray-200 px-6 py-4 dark:border-white/10">
<Button <Button

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,7 +1665,8 @@ 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">
<div className="space-y-4">
<Combobox <Combobox
label="Kamera" label="Kamera"
labelIcon={VideoCameraIcon} labelIcon={VideoCameraIcon}
@ -1667,24 +1683,6 @@ export default function CreateOperationModal({
disabled={isLoadingDevices} disabled={isLoadingDevices}
/> />
<Combobox
label="Router"
labelIcon={WifiIcon}
items={routerOptions}
value={selectedRouterDevice}
onChange={handleRouterDeviceChange}
variant="secondary"
allowCustomValue
createOptionLabel={(value) => `"${value}" als Router anlegen`}
placeholder={
isLoadingDevices
? 'Geräte werden geladen...'
: 'Router auswählen oder anlegen'
}
emptyText="Kein Router gefunden."
disabled={isLoadingDevices}
/>
<Combobox <Combobox
label="Switchbox" label="Switchbox"
labelIcon={ArrowsRightLeftIcon} labelIcon={ArrowsRightLeftIcon}
@ -1703,6 +1701,43 @@ export default function CreateOperationModal({
disabled={isLoadingDevices} disabled={isLoadingDevices}
/> />
<Combobox
label="Festplatte"
labelIcon={CircleStackIcon}
items={hardDriveOptions}
value={selectedHardDriveDevice}
onChange={handleHardDriveDeviceChange}
variant="secondary"
allowCustomValue
placeholder={
isLoadingDevices
? 'Geräte werden geladen...'
: 'Festplatte auswählen oder eingeben'
}
emptyText="Keine Festplatte gefunden."
disabled={isLoadingDevices}
/>
</div>
<div className="space-y-4">
<Combobox
label="Router"
labelIcon={WifiIcon}
items={routerOptions}
value={selectedRouterDevice}
onChange={handleRouterDeviceChange}
variant="secondary"
allowCustomValue
createOptionLabel={(value) => `"${value}" als Router anlegen`}
placeholder={
isLoadingDevices
? 'Geräte werden geladen...'
: 'Router auswählen oder anlegen'
}
emptyText="Kein Router gefunden."
disabled={isLoadingDevices}
/>
<Combobox <Combobox
label="Laptop" label="Laptop"
labelIcon={ComputerDesktopIcon} labelIcon={ComputerDesktopIcon}
@ -1721,22 +1756,85 @@ export default function CreateOperationModal({
disabled={isLoadingDevices} disabled={isLoadingDevices}
/> />
<Combobox <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">
label="Festplatte" <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
labelIcon={CircleStackIcon} <div className="min-w-0 flex flex-1 items-center gap-2">
items={hardDriveOptions} <span className="shrink-0 text-xs font-semibold uppercase tracking-wide text-indigo-700 dark:text-indigo-300">
value={selectedHardDriveDevice} Auswerter
onChange={handleHardDriveDeviceChange} </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" variant="secondary"
allowCustomValue color="gray"
placeholder={ size="sm"
isLoadingDevices onClick={() => {
? 'Geräte werden geladen...' setSelectedMilestoneUser(null)
: 'Festplatte auswählen oder eingeben' 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
} }
emptyText="Keine Festplatte gefunden."
disabled={isLoadingDevices}
/> />
{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),
})
} }
function handleRejection(event: PromiseRejectionEvent) { return input.url
const reason = event.reason
const message = reason instanceof Error ? reason.message : String(reason)
recordError(`Unbehandelte Ablehnung: ${message}`, {
stack: reason instanceof Error ? truncateStack(reason.stack) : undefined,
})
} }
function safeStringify(value: unknown) { function requestMethod(input: FetchInput, init: FetchInit) {
if (init?.method) {
return init.method.toUpperCase()
}
if (typeof input !== 'string' && !(input instanceof URL)) {
return input.method.toUpperCase()
}
return 'GET'
}
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)
}
} }