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

View File

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

View File

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

View File

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

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

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")
return
}
var conversationType string
if err := s.db.QueryRow(
r.Context(),
`SELECT type FROM conversations WHERE id = $1`,
conversationID,
).Scan(&conversationType); err != nil {
writeError(w, http.StatusNotFound, "Chat wurde nicht gefunden")
return
}
if conversationType == "direct" {
writeError(w, http.StatusBadRequest, "Einzelchats können nicht stummgeschaltet werden")
if !s.isConversationMember(r.Context(), conversationID, user.ID) {
writeError(w, http.StatusForbidden, "Nur Mitglieder können diesen Chat stummschalten")
return
}

View File

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

View File

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

View File

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

View File

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

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 {
var exists bool

View File

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

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

1191
backend/milestone_backup.go Normal file

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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