This commit is contained in:
Linrador 2026-06-26 16:31:26 +02:00
parent a95f8a15ac
commit e5a88399e9
9 changed files with 2190 additions and 328 deletions

View File

@ -3,6 +3,8 @@ package main
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"log"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
@ -57,6 +59,21 @@ type milestoneRoleUserInput struct {
Source string `json:"source"` Source string `json:"source"`
} }
func writeMilestoneRoleActionError(w http.ResponseWriter, status int, message string, err error) {
if err != nil {
log.Printf("%s: %v", message, err)
}
payload := map[string]string{
"error": message,
}
if err != nil {
payload["details"] = err.Error()
}
writeJSON(w, status, payload)
}
func (s *Server) handleAdminListMilestoneRoles(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAdminListMilestoneRoles(w http.ResponseWriter, r *http.Request) {
currentUser, _ := userFromContext(r.Context()) currentUser, _ := userFromContext(r.Context())
isAdministrator := userHasRight(currentUser, "admin") isAdministrator := userHasRight(currentUser, "admin")
@ -241,26 +258,18 @@ func (s *Server) handleAdminUpdateMilestoneRole(w http.ResponseWriter, r *http.R
return return
} }
users, err := listOperationMilestoneRoleUsers(r.Context(), config, roleID)
if err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Rollenbenutzer konnten nicht geladen werden")
return
}
role.Name = roleName role.Name = roleName
role.DisplayName = strings.TrimSpace(input.DisplayName) role.DisplayName = strings.TrimSpace(input.DisplayName)
if role.DisplayName == "" { if role.DisplayName == "" {
role.DisplayName = roleName role.DisplayName = roleName
} }
if role.Data == nil {
role.Data = map[string]any{}
}
role.Data["name"] = roleName
role.Data["displayName"] = role.DisplayName
role.Data["description"] = strings.TrimSpace(input.Description)
if err := putOperationMilestoneRole(r.Context(), config, *role, users); err != nil { if err := patchOperationMilestoneRole(r.Context(), config, role.ID, map[string]any{
writeError(w, http.StatusBadGateway, "Milestone-Rolle konnte nicht gespeichert werden") "name": roleName,
"displayName": role.DisplayName,
"description": strings.TrimSpace(input.Description),
}); err != nil {
writeMilestoneRoleActionError(w, http.StatusBadGateway, "Milestone-Rolle konnte nicht gespeichert werden", err)
return return
} }
@ -269,7 +278,7 @@ func (s *Server) handleAdminUpdateMilestoneRole(w http.ResponseWriter, r *http.R
writeError(w, http.StatusBadGateway, "Milestone-Rolle wurde gespeichert, konnte aber nicht geladen werden") writeError(w, http.StatusBadGateway, "Milestone-Rolle wurde gespeichert, konnte aber nicht geladen werden")
return return
} }
users, _ = listOperationMilestoneRoleUsers(r.Context(), config, roleID) users, _ := listOperationMilestoneRoleUsers(r.Context(), config, roleID)
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"role": milestoneRoleResponseFromRole(*role, users), "role": milestoneRoleResponseFromRole(*role, users),
@ -350,20 +359,25 @@ func (s *Server) handleAdminAddMilestoneRoleUsers(w http.ResponseWriter, r *http
} }
nextUsers := users nextUsers := users
usersToAdd := []map[string]any{}
for _, userInput := range input.Users { for _, userInput := range input.Users {
user := milestoneRoleUserReferenceFromInput(userInput) user := milestoneRoleUserReferenceFromInput(userInput)
if milestoneItemID(user) == "" || milestoneRoleUserItemIsHidden(milestoneRoleUserItemFromObject(user)) { if milestoneItemID(user) == "" || milestoneRoleUserItemIsHidden(milestoneRoleUserItemFromObject(user)) {
continue continue
} }
if operationMilestoneObjectByID(nextUsers, milestoneItemID(user)) != nil {
continue
}
nextUsers = appendOperationMilestoneObjectByID(nextUsers, user) nextUsers = appendOperationMilestoneObjectByID(nextUsers, user)
usersToAdd = appendOperationMilestoneObjectByID(usersToAdd, user)
} }
if len(nextUsers) == len(users) { if len(usersToAdd) == 0 {
writeError(w, http.StatusBadRequest, "Kein gültiger AD-Benutzer ausgewählt") writeError(w, http.StatusBadRequest, "Kein gültiger Benutzer ausgewählt")
return return
} }
if err := putOperationMilestoneRole(r.Context(), config, *role, nextUsers); err != nil { if err := addOperationMilestoneRoleMembers(r.Context(), config, role.ID, usersToAdd); err != nil {
writeError(w, http.StatusBadGateway, "AD-Benutzer konnten der Milestone-Rolle nicht hinzugefügt werden") writeMilestoneRoleActionError(w, http.StatusBadGateway, "Benutzer konnten der Milestone-Rolle nicht hinzugefügt werden", err)
return return
} }
@ -405,23 +419,25 @@ func (s *Server) handleAdminRemoveMilestoneRoleUser(w http.ResponseWriter, r *ht
} }
nextUsers := make([]map[string]any, 0, len(users)) nextUsers := make([]map[string]any, 0, len(users))
usersToRemove := []map[string]any{}
for _, user := range users { for _, user := range users {
if strings.EqualFold(milestoneItemID(user), userID) { if strings.EqualFold(milestoneItemID(user), userID) {
if milestoneRoleUserItemIsHidden(milestoneRoleUserItemFromObject(user)) { if milestoneRoleUserItemIsHidden(milestoneRoleUserItemFromObject(user)) {
writeError(w, http.StatusForbidden, "Dieser Benutzer darf nicht entfernt werden") writeError(w, http.StatusForbidden, "Dieser Benutzer darf nicht entfernt werden")
return return
} }
usersToRemove = append(usersToRemove, user)
continue continue
} }
nextUsers = append(nextUsers, user) nextUsers = append(nextUsers, user)
} }
if len(nextUsers) == len(users) { if len(usersToRemove) == 0 {
writeError(w, http.StatusNotFound, "Benutzer wurde in der Rolle nicht gefunden") writeError(w, http.StatusNotFound, "Benutzer wurde in der Rolle nicht gefunden")
return return
} }
if err := putOperationMilestoneRole(r.Context(), config, *role, nextUsers); err != nil { if err := removeOperationMilestoneRoleMembers(r.Context(), config, role.ID, usersToRemove); err != nil {
writeError(w, http.StatusBadGateway, "Benutzer konnte nicht aus der Milestone-Rolle entfernt werden") writeMilestoneRoleActionError(w, http.StatusBadGateway, "Benutzer konnte nicht aus der Milestone-Rolle entfernt werden", err)
return return
} }
@ -493,9 +509,7 @@ func (s *Server) handleAdminListMilestoneBasicUsers(w http.ResponseWriter, r *ht
continue continue
} }
user.Source = "basic" user.Source = "basic"
if user.Domain == "" { user.Domain = ""
user.Domain = "Basic"
}
users = append(users, user) users = append(users, user)
} }
@ -614,8 +628,12 @@ func milestoneRoleUserItemFromObject(user map[string]any) milestoneRoleUserItem
if domain == "" && distinguishedName != "" { if domain == "" && distinguishedName != "" {
domain = activeDirectoryDomainFromDN(distinguishedName) domain = activeDirectoryDomainFromDN(distinguishedName)
} }
if domain == "" && source == "basic" { if milestoneRoleUserDomainIsBasic(domain) {
domain = "Basic" source = "basic"
domain = ""
}
if source == "basic" {
domain = ""
} }
if displayName == "" { if displayName == "" {
@ -659,6 +677,32 @@ func milestoneRoleUserReferenceFromInput(input milestoneRoleUserInput) map[strin
source = "ad" source = "ad"
} }
if milestoneRoleUserDomainIsBasic(domain) {
source = "basic"
domain = ""
}
if splitDomain, splitUsername := splitMilestoneDomainUser(username); splitDomain != "" {
if domain == "" && source != "basic" {
domain = splitDomain
}
username = splitUsername
}
if splitDomain, splitName := splitMilestoneDomainUser(name); splitDomain != "" {
if domain == "" && source != "basic" {
domain = splitDomain
}
name = splitName
}
if splitDomain, splitDisplayName := splitMilestoneDomainUser(displayName); splitDomain != "" {
if domain == "" && source != "basic" {
domain = splitDomain
}
displayName = splitDisplayName
}
if source == "basic" {
domain = ""
}
if userID == "" { if userID == "" {
userID = userSID userID = userSID
} }
@ -688,8 +732,6 @@ func milestoneRoleUserReferenceFromInput(input milestoneRoleUserInput) map[strin
"userName": username, "userName": username,
"username": username, "username": username,
"email": email, "email": email,
"domain": domain,
"source": source,
"relations": map[string]any{ "relations": map[string]any{
"self": map[string]any{ "self": map[string]any{
"type": milestoneRoleUserRelationType(source), "type": milestoneRoleUserRelationType(source),
@ -697,6 +739,9 @@ func milestoneRoleUserReferenceFromInput(input milestoneRoleUserInput) map[strin
}, },
}, },
} }
if domain != "" {
user["domain"] = domain
}
if userSID != "" { if userSID != "" {
user["sid"] = userSID user["sid"] = userSID
user["objectSid"] = userSID user["objectSid"] = userSID
@ -708,7 +753,140 @@ func milestoneRoleUserReferenceFromInput(input milestoneRoleUserInput) map[strin
return user return user
} }
func addOperationMilestoneRoleMembers(
ctx context.Context,
config milestoneRuntimeConfig,
roleID string,
users []map[string]any,
) error {
return postOperationMilestoneRoleMemberTasks(ctx, config, roleID, "AddRoleMember", users)
}
func removeOperationMilestoneRoleMembers(
ctx context.Context,
config milestoneRuntimeConfig,
roleID string,
users []map[string]any,
) error {
return postOperationMilestoneRoleMemberTasks(ctx, config, roleID, "RemoveRoleMember", users)
}
func postOperationMilestoneRoleMemberTasks(
ctx context.Context,
config milestoneRuntimeConfig,
roleID string,
taskName string,
users []map[string]any,
) error {
roleID = strings.TrimSpace(roleID)
taskName = strings.TrimSpace(taskName)
if roleID == "" {
return errors.New("role id is missing")
}
if taskName == "" {
return errors.New("role member task name is missing")
}
for _, user := range users {
payload, err := milestoneRoleMemberTaskPayload(user)
if err != nil {
return err
}
if len(payload) == 0 {
continue
}
body, _, err := operationMilestoneRequestJSON(
ctx,
config,
http.MethodPost,
"/API/rest/v1/roles/"+url.PathEscape(roleID)+"/users?task="+url.QueryEscape(taskName),
payload,
)
if err != nil {
return err
}
if result, ok := milestoneTaskResultFromBody(body); ok && milestoneTaskResultIsError(result) {
return fmt.Errorf(
"milestone %s returned error state=%s errorCode=%s errorText=%s",
taskName,
result.State,
result.ErrorCode,
result.ErrorText,
)
}
}
return nil
}
func milestoneRoleMemberTaskItemSelection(user map[string]any) map[string]any {
userID := strings.TrimSpace(milestoneItemID(user))
if userID == "" {
return nil
}
source := milestoneRoleUserSourceFromObject(user)
itemSelection := map[string]any{
"type": milestoneRoleUserRelationType(source),
"id": userID,
}
return itemSelection
}
func milestoneRoleMemberTaskPayload(user map[string]any) (map[string]any, error) {
itemSelection := milestoneRoleMemberTaskItemSelection(user)
userID := strings.TrimSpace(milestoneStringValue(itemSelection["id"]))
if userID == "" {
return nil, nil
}
payload := map[string]any{
"itemSelection": itemSelection,
}
if milestoneRoleUserSourceFromObject(user) != "basic" {
sid := strings.TrimSpace(milestoneStringValue(user["sid"]))
if sid == "" {
sid = strings.TrimSpace(milestoneStringValue(user["objectSid"]))
}
if sid == "" && strings.HasPrefix(strings.ToUpper(userID), "S-") {
sid = userID
}
if sid == "" {
return nil, fmt.Errorf(
"Windows-Benutzer %q hat keine SID. AddRoleMember erwartet den Parameter sid.",
operationMilestoneItemDisplayName(user),
)
}
payload["sid"] = sid
}
return payload, nil
}
func operationMilestoneObjectByID(items []map[string]any, id string) map[string]any {
id = strings.TrimSpace(id)
if id == "" {
return nil
}
for _, item := range items {
if strings.EqualFold(milestoneItemID(item), id) {
return item
}
}
return nil
}
func milestoneRoleUserSourceFromObject(user map[string]any) string { func milestoneRoleUserSourceFromObject(user map[string]any) string {
if milestoneRoleUserDomainIsBasic(milestoneRoleUserDomainFromObject(user)) {
return "basic"
}
source := milestoneRoleUserSource(milestoneStringValue(user["source"])) source := milestoneRoleUserSource(milestoneStringValue(user["source"]))
if source != "" { if source != "" {
return source return source
@ -733,6 +911,13 @@ func milestoneRoleUserSourceFromObject(user map[string]any) string {
return "ad" return "ad"
} }
func milestoneRoleUserDomainIsBasic(domain string) bool {
domain = strings.TrimSpace(domain)
domain = strings.TrimPrefix(domain, "[")
domain = strings.TrimSuffix(domain, "]")
return strings.EqualFold(domain, "basic")
}
func milestoneRoleUserSource(source string) string { func milestoneRoleUserSource(source string) string {
source = strings.TrimSpace(strings.ToLower(source)) source = strings.TrimSpace(strings.ToLower(source))
switch source { switch source {
@ -802,6 +987,7 @@ func milestoneProtectedUserNameIsHidden(value string) bool {
switch value { switch value {
case "nt-autorität\\netzwerkdienst", case "nt-autorität\\netzwerkdienst",
"nt authority\\network service", "nt authority\\network service",
"tegadmin",
"tegdssd\\tegadmin", "tegdssd\\tegadmin",
"vordefiniert\\administratoren", "vordefiniert\\administratoren",
"builtin\\administrators", "builtin\\administrators",

775
backend/calendar_events.go Normal file
View File

@ -0,0 +1,775 @@
package main
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
type CalendarEvent struct {
ID string `json:"id"`
Title string `json:"title"`
Start string `json:"start"`
End string `json:"end"`
AllDay bool `json:"allDay"`
Location string `json:"location"`
Description string `json:"description"`
Color string `json:"color"`
Recurrence string `json:"recurrence"`
// recurrenceWeekday verwendet time.Weekday/JavaScript-Date.getDay(): 0=Sonntag.
RecurrenceWeekday *int `json:"recurrenceWeekday,omitempty"`
RecurrenceOrdinal *int `json:"recurrenceOrdinal,omitempty"`
RecurrenceExceptions []string `json:"recurrenceExceptions"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type CalendarEventRequest struct {
Title string `json:"title"`
Start string `json:"start"`
End string `json:"end"`
AllDay bool `json:"allDay"`
Location string `json:"location"`
Description string `json:"description"`
Color string `json:"color"`
Recurrence string `json:"recurrence"`
RecurrenceWeekday *int `json:"recurrenceWeekday"`
RecurrenceOrdinal *int `json:"recurrenceOrdinal"`
RecurrenceExceptions []string `json:"recurrenceExceptions"`
}
type CalendarEventOccurrenceRequest struct {
OccurrenceStart string `json:"occurrenceStart"`
Event CalendarEventRequest `json:"event"`
}
const calendarEventDateTimeLayout = "2006-01-02T15:04"
var allowedCalendarEventColors = map[string]bool{
"indigo": true,
"blue": true,
"emerald": true,
"amber": true,
"rose": true,
"slate": true,
}
var allowedCalendarEventRecurrences = map[string]bool{
"none": true,
"daily": true,
"weekly": true,
"monthly": true,
"monthlyNthWeekday": true,
"yearly": true,
}
func (s *Server) ensureCalendarEventsTable(ctx context.Context) error {
queries := []string{
`CREATE EXTENSION IF NOT EXISTS pgcrypto`,
`
CREATE TABLE IF NOT EXISTS calendar_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
start_at TIMESTAMP NOT NULL,
end_at TIMESTAMP NOT NULL,
all_day BOOLEAN NOT NULL DEFAULT false,
location TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT 'indigo'
CHECK (color IN ('indigo', 'blue', 'emerald', 'amber', 'rose', 'slate')),
recurrence TEXT NOT NULL DEFAULT 'none',
recurrence_weekday INTEGER,
recurrence_ordinal INTEGER,
recurrence_exceptions JSONB NOT NULL DEFAULT '[]'::JSONB,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
updated_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT calendar_events_valid_range CHECK (end_at > start_at)
)
`,
`
DO $$
DECLARE
constraint_name TEXT;
BEGIN
FOR constraint_name IN
SELECT c.conname
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
WHERE t.relname = 'calendar_events'
AND c.contype = 'c'
AND pg_get_constraintdef(c.oid) ILIKE '%recurrence%'
LOOP
EXECUTE format('ALTER TABLE calendar_events DROP CONSTRAINT %I', constraint_name);
END LOOP;
END
$$
`,
`ALTER TABLE IF EXISTS calendar_events ADD COLUMN IF NOT EXISTS recurrence_weekday INTEGER`,
`ALTER TABLE IF EXISTS calendar_events ADD COLUMN IF NOT EXISTS recurrence_ordinal INTEGER`,
`ALTER TABLE IF EXISTS calendar_events ADD COLUMN IF NOT EXISTS recurrence_exceptions JSONB NOT NULL DEFAULT '[]'::JSONB`,
`UPDATE calendar_events SET recurrence_exceptions = '[]'::JSONB WHERE recurrence_exceptions IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_calendar_events_start_at ON calendar_events(start_at)`,
}
for _, query := range queries {
if _, err := s.db.Exec(ctx, query); err != nil {
return err
}
}
return nil
}
func parseCalendarEventDateTime(value string) (time.Time, error) {
value = strings.TrimSpace(value)
layouts := []string{
calendarEventDateTimeLayout,
"2006-01-02T15:04:05",
time.RFC3339,
}
for _, layout := range layouts {
parsed, err := time.ParseInLocation(layout, value, time.Local)
if err == nil {
return parsed, nil
}
}
return time.Time{}, errors.New("invalid datetime")
}
func cleanCalendarEventExceptions(values []string) ([]string, error) {
result := []string{}
seen := map[string]bool{}
for _, value := range values {
parsed, err := parseCalendarEventDateTime(value)
if err != nil {
return nil, errors.New("Eine Wiederholungs-Ausnahme ist ungültig")
}
normalized := formatCalendarEventDateTime(parsed)
if seen[normalized] {
continue
}
seen[normalized] = true
result = append(result, normalized)
}
return result, nil
}
func appendCalendarEventException(values []string, occurrenceStart string) ([]string, error) {
next := append([]string{}, values...)
next = append(next, occurrenceStart)
return cleanCalendarEventExceptions(next)
}
func normalizeCalendarEventRequest(input CalendarEventRequest) (CalendarEventRequest, time.Time, time.Time, error) {
input.Title = strings.TrimSpace(input.Title)
input.Location = strings.TrimSpace(input.Location)
input.Description = strings.TrimSpace(input.Description)
input.Color = strings.TrimSpace(input.Color)
input.Recurrence = strings.TrimSpace(input.Recurrence)
if input.Title == "" {
return input, time.Time{}, time.Time{}, errors.New("Bitte gib einen Titel ein")
}
if len([]rune(input.Title)) > 120 {
return input, time.Time{}, time.Time{}, errors.New("Der Titel darf maximal 120 Zeichen lang sein")
}
if len([]rune(input.Location)) > 160 {
return input, time.Time{}, time.Time{}, errors.New("Der Ort darf maximal 160 Zeichen lang sein")
}
if len([]rune(input.Description)) > 1000 {
return input, time.Time{}, time.Time{}, errors.New("Die Beschreibung darf maximal 1000 Zeichen lang sein")
}
if input.Color == "" {
input.Color = "indigo"
}
if !allowedCalendarEventColors[input.Color] {
return input, time.Time{}, time.Time{}, errors.New("Ungültige Terminfarbe")
}
if input.Recurrence == "" {
input.Recurrence = "none"
}
if !allowedCalendarEventRecurrences[input.Recurrence] {
return input, time.Time{}, time.Time{}, errors.New("Ungültige Wiederholung")
}
if input.Recurrence == "monthlyNthWeekday" {
if input.RecurrenceWeekday == nil ||
*input.RecurrenceWeekday < 0 ||
*input.RecurrenceWeekday > 6 {
return input, time.Time{}, time.Time{}, errors.New("Bitte wähle einen gültigen Wochentag für die Wiederholung")
}
if input.RecurrenceOrdinal == nil ||
*input.RecurrenceOrdinal < 1 ||
*input.RecurrenceOrdinal > 5 {
return input, time.Time{}, time.Time{}, errors.New("Bitte wähle eine gültige Woche im Monat")
}
} else {
input.RecurrenceWeekday = nil
input.RecurrenceOrdinal = nil
}
exceptions, err := cleanCalendarEventExceptions(input.RecurrenceExceptions)
if err != nil {
return input, time.Time{}, time.Time{}, err
}
input.RecurrenceExceptions = exceptions
start, err := parseCalendarEventDateTime(input.Start)
if err != nil {
return input, time.Time{}, time.Time{}, errors.New("Der Startzeitpunkt ist ungültig")
}
end, err := parseCalendarEventDateTime(input.End)
if err != nil {
return input, time.Time{}, time.Time{}, errors.New("Der Endzeitpunkt ist ungültig")
}
if !end.After(start) {
return input, time.Time{}, time.Time{}, errors.New("Das Ende muss nach dem Beginn liegen")
}
return input, start, end, nil
}
func formatCalendarEventDateTime(value time.Time) string {
return value.Format(calendarEventDateTimeLayout)
}
func scanCalendarEvent(row pgx.Row) (CalendarEvent, error) {
var event CalendarEvent
var start time.Time
var end time.Time
var rawExceptions []byte
err := row.Scan(
&event.ID,
&event.Title,
&start,
&end,
&event.AllDay,
&event.Location,
&event.Description,
&event.Color,
&event.Recurrence,
&event.RecurrenceWeekday,
&event.RecurrenceOrdinal,
&rawExceptions,
&event.CreatedAt,
&event.UpdatedAt,
)
if err != nil {
return CalendarEvent{}, err
}
event.Start = formatCalendarEventDateTime(start)
event.End = formatCalendarEventDateTime(end)
event.RecurrenceExceptions = []string{}
if len(rawExceptions) > 0 {
_ = json.Unmarshal(rawExceptions, &event.RecurrenceExceptions)
}
return event, nil
}
const calendarEventReturningColumns = `
id::TEXT,
title,
start_at,
end_at,
all_day,
COALESCE(location, ''),
COALESCE(description, ''),
color,
recurrence,
recurrence_weekday,
recurrence_ordinal,
recurrence_exceptions,
created_at,
updated_at
`
type calendarEventRowQueryer interface {
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
}
func loadCalendarEventByID(ctx context.Context, queryer calendarEventRowQueryer, eventID string) (CalendarEvent, error) {
return scanCalendarEvent(queryer.QueryRow(
ctx,
`
SELECT
`+calendarEventReturningColumns+`
FROM calendar_events
WHERE id = $1
`,
eventID,
))
}
func (s *Server) handleListCalendarEvents(w http.ResponseWriter, r *http.Request) {
if err := s.ensureCalendarEventsTable(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "Kalendereinträge konnten nicht vorbereitet werden")
return
}
rows, err := s.db.Query(
r.Context(),
`
SELECT
`+calendarEventReturningColumns+`
FROM calendar_events
ORDER BY start_at ASC, created_at ASC
`,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Kalendereinträge konnten nicht geladen werden")
return
}
defer rows.Close()
events := []CalendarEvent{}
for rows.Next() {
event, err := scanCalendarEvent(rows)
if err != nil {
writeError(w, http.StatusInternalServerError, "Kalendereinträge konnten nicht gelesen werden")
return
}
events = append(events, event)
}
if err := rows.Err(); err != nil {
writeError(w, http.StatusInternalServerError, "Kalendereinträge konnten nicht vollständig gelesen werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"events": events,
})
}
func (s *Server) handleCreateCalendarEvent(w http.ResponseWriter, r *http.Request) {
if err := s.ensureCalendarEventsTable(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "Kalendereinträge konnten nicht vorbereitet werden")
return
}
var input CalendarEventRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
input, start, end, err := normalizeCalendarEventRequest(input)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
userID, _ := s.currentUserIDFromRequest(r)
exceptionsRaw, err := json.Marshal(input.RecurrenceExceptions)
if err != nil {
writeError(w, http.StatusBadRequest, "Wiederholungs-Ausnahmen sind ungültig")
return
}
event, err := scanCalendarEvent(s.db.QueryRow(
r.Context(),
`
INSERT INTO calendar_events (
title,
start_at,
end_at,
all_day,
location,
description,
color,
recurrence,
recurrence_weekday,
recurrence_ordinal,
recurrence_exceptions,
created_by,
updated_by
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::JSONB, NULLIF($12, '')::UUID, NULLIF($12, '')::UUID)
RETURNING
`+calendarEventReturningColumns,
input.Title,
start,
end,
input.AllDay,
input.Location,
input.Description,
input.Color,
input.Recurrence,
input.RecurrenceWeekday,
input.RecurrenceOrdinal,
string(exceptionsRaw),
userID,
))
if err != nil {
writeError(w, http.StatusInternalServerError, "Kalendereintrag konnte nicht erstellt werden")
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"event": event,
})
}
func (s *Server) handleUpdateCalendarEvent(w http.ResponseWriter, r *http.Request) {
eventID := strings.TrimSpace(r.PathValue("id"))
if eventID == "" {
writeError(w, http.StatusBadRequest, "Kalendereintrag fehlt")
return
}
if err := s.ensureCalendarEventsTable(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "Kalendereinträge konnten nicht vorbereitet werden")
return
}
var input CalendarEventRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
input, start, end, err := normalizeCalendarEventRequest(input)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
userID, _ := s.currentUserIDFromRequest(r)
exceptionsRaw, err := json.Marshal(input.RecurrenceExceptions)
if err != nil {
writeError(w, http.StatusBadRequest, "Wiederholungs-Ausnahmen sind ungültig")
return
}
event, err := scanCalendarEvent(s.db.QueryRow(
r.Context(),
`
UPDATE calendar_events
SET
title = $2,
start_at = $3,
end_at = $4,
all_day = $5,
location = $6,
description = $7,
color = $8,
recurrence = $9,
recurrence_weekday = $10,
recurrence_ordinal = $11,
recurrence_exceptions = $12::JSONB,
updated_by = NULLIF($13, '')::UUID,
updated_at = now()
WHERE id = $1
RETURNING
`+calendarEventReturningColumns,
eventID,
input.Title,
start,
end,
input.AllDay,
input.Location,
input.Description,
input.Color,
input.Recurrence,
input.RecurrenceWeekday,
input.RecurrenceOrdinal,
string(exceptionsRaw),
userID,
))
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "Kalendereintrag wurde nicht gefunden")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "Kalendereintrag konnte nicht gespeichert werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"event": event,
})
}
func (s *Server) handleUpdateCalendarEventOccurrence(w http.ResponseWriter, r *http.Request) {
eventID := strings.TrimSpace(r.PathValue("id"))
if eventID == "" {
writeError(w, http.StatusBadRequest, "Kalendereintrag fehlt")
return
}
if err := s.ensureCalendarEventsTable(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "Kalendereinträge konnten nicht vorbereitet werden")
return
}
var payload CalendarEventOccurrenceRequest
if err := readJSON(r, &payload); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
occurrenceStart, err := parseCalendarEventDateTime(payload.OccurrenceStart)
if err != nil {
writeError(w, http.StatusBadRequest, "Der gewählte Serientermin ist ungültig")
return
}
occurrenceStartValue := formatCalendarEventDateTime(occurrenceStart)
payload.Event.Recurrence = "none"
payload.Event.RecurrenceWeekday = nil
payload.Event.RecurrenceOrdinal = nil
payload.Event.RecurrenceExceptions = nil
input, start, end, err := normalizeCalendarEventRequest(payload.Event)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
tx, err := s.db.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "Kalendereintrag konnte nicht vorbereitet werden")
return
}
defer tx.Rollback(r.Context())
series, err := loadCalendarEventByID(r.Context(), tx, eventID)
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "Kalendereintrag wurde nicht gefunden")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "Kalendereintrag konnte nicht geladen werden")
return
}
if series.Recurrence == "none" {
writeError(w, http.StatusBadRequest, "Der Kalendereintrag ist keine Serie")
return
}
nextExceptions, err := appendCalendarEventException(series.RecurrenceExceptions, occurrenceStartValue)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
exceptionsRaw, err := json.Marshal(nextExceptions)
if err != nil {
writeError(w, http.StatusBadRequest, "Wiederholungs-Ausnahmen sind ungültig")
return
}
userID, _ := s.currentUserIDFromRequest(r)
updatedSeries, err := scanCalendarEvent(tx.QueryRow(
r.Context(),
`
UPDATE calendar_events
SET
recurrence_exceptions = $2::JSONB,
updated_by = NULLIF($3, '')::UUID,
updated_at = now()
WHERE id = $1
RETURNING
`+calendarEventReturningColumns,
eventID,
string(exceptionsRaw),
userID,
))
if err != nil {
writeError(w, http.StatusInternalServerError, "Serientermin konnte nicht aktualisiert werden")
return
}
event, err := scanCalendarEvent(tx.QueryRow(
r.Context(),
`
INSERT INTO calendar_events (
title,
start_at,
end_at,
all_day,
location,
description,
color,
recurrence,
recurrence_weekday,
recurrence_ordinal,
recurrence_exceptions,
created_by,
updated_by
)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'none', NULL, NULL, '[]'::JSONB, NULLIF($8, '')::UUID, NULLIF($8, '')::UUID)
RETURNING
`+calendarEventReturningColumns,
input.Title,
start,
end,
input.AllDay,
input.Location,
input.Description,
input.Color,
userID,
))
if err != nil {
writeError(w, http.StatusInternalServerError, "Ausgewählter Termin konnte nicht gespeichert werden")
return
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "Kalendereintrag konnte nicht gespeichert werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"series": updatedSeries,
"event": event,
})
}
func (s *Server) handleDeleteCalendarEventOccurrence(w http.ResponseWriter, r *http.Request) {
eventID := strings.TrimSpace(r.PathValue("id"))
if eventID == "" {
writeError(w, http.StatusBadRequest, "Kalendereintrag fehlt")
return
}
if err := s.ensureCalendarEventsTable(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "Kalendereinträge konnten nicht vorbereitet werden")
return
}
var payload struct {
OccurrenceStart string `json:"occurrenceStart"`
}
if err := readJSON(r, &payload); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
occurrenceStart, err := parseCalendarEventDateTime(payload.OccurrenceStart)
if err != nil {
writeError(w, http.StatusBadRequest, "Der gewählte Serientermin ist ungültig")
return
}
occurrenceStartValue := formatCalendarEventDateTime(occurrenceStart)
tx, err := s.db.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "Kalendereintrag konnte nicht vorbereitet werden")
return
}
defer tx.Rollback(r.Context())
series, err := loadCalendarEventByID(r.Context(), tx, eventID)
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "Kalendereintrag wurde nicht gefunden")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "Kalendereintrag konnte nicht geladen werden")
return
}
if series.Recurrence == "none" {
writeError(w, http.StatusBadRequest, "Der Kalendereintrag ist keine Serie")
return
}
nextExceptions, err := appendCalendarEventException(series.RecurrenceExceptions, occurrenceStartValue)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
exceptionsRaw, err := json.Marshal(nextExceptions)
if err != nil {
writeError(w, http.StatusBadRequest, "Wiederholungs-Ausnahmen sind ungültig")
return
}
userID, _ := s.currentUserIDFromRequest(r)
updatedSeries, err := scanCalendarEvent(tx.QueryRow(
r.Context(),
`
UPDATE calendar_events
SET
recurrence_exceptions = $2::JSONB,
updated_by = NULLIF($3, '')::UUID,
updated_at = now()
WHERE id = $1
RETURNING
`+calendarEventReturningColumns,
eventID,
string(exceptionsRaw),
userID,
))
if err != nil {
writeError(w, http.StatusInternalServerError, "Serientermin konnte nicht aktualisiert werden")
return
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "Kalendereintrag konnte nicht gelöscht werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"event": updatedSeries,
})
}
func (s *Server) handleDeleteCalendarEvent(w http.ResponseWriter, r *http.Request) {
eventID := strings.TrimSpace(r.PathValue("id"))
if eventID == "" {
writeError(w, http.StatusBadRequest, "Kalendereintrag fehlt")
return
}
if err := s.ensureCalendarEventsTable(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "Kalendereinträge konnten nicht vorbereitet werden")
return
}
result, err := s.db.Exec(
r.Context(),
`DELETE FROM calendar_events WHERE id = $1`,
eventID,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Kalendereintrag konnte nicht gelöscht werden")
return
}
if result.RowsAffected() == 0 {
writeError(w, http.StatusNotFound, "Kalendereintrag wurde nicht gefunden")
return
}
writeJSON(w, http.StatusOK, map[string]string{
"message": "Kalendereintrag gelöscht",
})
}

View File

@ -850,9 +850,16 @@ func (s *Server) ensureOperationMilestoneRoleUser(
} }
if selectedUser := operationMilestoneUserFromSetupInput(input); selectedUser != nil { if selectedUser := operationMilestoneUserFromSetupInput(input); selectedUser != nil {
roleUsers := appendOperationMilestoneObjectByID(operationMilestoneObjectListFromValue(role.Data["users"]), selectedUser) roleUsers, listErr := listOperationMilestoneRoleUsers(ctx, config, role.ID)
if err := putOperationMilestoneRole(ctx, config, *role, roleUsers); err != nil { if listErr != nil {
return nil, err 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 role.Data["users"] = roleUsers
} }
@ -892,21 +899,28 @@ func (s *Server) ensureOperationMilestoneRoleUser(
} }
} }
roleUsers := operationMilestoneObjectListFromValue(role.Data["users"]) roleUsers := sourceUsers
if len(roleUsers) == 0 && !hasOperationMilestoneUserSelection(input) { if len(roleUsers) == 0 {
roleUsers = appendOperationMilestoneObjectsByID(roleUsers, sourceUsers...) roleUsers = operationMilestoneObjectListFromValue(role.Data["users"])
} }
usersToAdd := []map[string]any{}
for _, targetUser := range usersToAssign { for _, targetUser := range usersToAssign {
if milestoneItemID(targetUser) == "" { if milestoneItemID(targetUser) == "" {
return nil, fmt.Errorf("AD-Benutzer %q hat keine Milestone-ID", targetUserName) return nil, fmt.Errorf("AD-Benutzer %q hat keine Milestone-ID", targetUserName)
} }
if operationMilestoneObjectByID(roleUsers, milestoneItemID(targetUser)) != nil {
continue
}
usersToAdd = appendOperationMilestoneObjectByID(usersToAdd, targetUser)
roleUsers = appendOperationMilestoneObjectByID(roleUsers, targetUser) roleUsers = appendOperationMilestoneObjectByID(roleUsers, targetUser)
} }
if err := putOperationMilestoneRole(ctx, config, *role, roleUsers); err != nil { if len(usersToAdd) > 0 {
return nil, err if err := addOperationMilestoneRoleMembers(ctx, config, role.ID, usersToAdd); err != nil {
return nil, err
}
} }
role.Data["users"] = roleUsers role.Data["users"] = roleUsers

View File

@ -113,6 +113,12 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("PUT /settings/presence", s.requireAuth(s.handleUpdatePresenceSettings)) mux.HandleFunc("PUT /settings/presence", s.requireAuth(s.handleUpdatePresenceSettings))
mux.HandleFunc("PUT /settings/presence/status", s.requireAuth(s.handleUpdatePresenceStatus)) mux.HandleFunc("PUT /settings/presence/status", s.requireAuth(s.handleUpdatePresenceStatus))
mux.HandleFunc("GET /calendar/settings", s.requireAuth(s.requireRight("calendar:read", s.handleGetCalendarSettings))) mux.HandleFunc("GET /calendar/settings", s.requireAuth(s.requireRight("calendar:read", s.handleGetCalendarSettings)))
mux.HandleFunc("GET /calendar/events", s.requireAuth(s.requireRight("calendar:read", s.handleListCalendarEvents)))
mux.HandleFunc("POST /calendar/events", s.requireAuth(s.requireRight("calendar:read", s.handleCreateCalendarEvent)))
mux.HandleFunc("PUT /calendar/events/{id}", s.requireAuth(s.requireRight("calendar:read", s.handleUpdateCalendarEvent)))
mux.HandleFunc("POST /calendar/events/{id}/occurrences", s.requireAuth(s.requireRight("calendar:read", s.handleUpdateCalendarEventOccurrence)))
mux.HandleFunc("POST /calendar/events/{id}/occurrences/delete", s.requireAuth(s.requireRight("calendar:read", s.handleDeleteCalendarEventOccurrence)))
mux.HandleFunc("DELETE /calendar/events/{id}", s.requireAuth(s.requireRight("calendar:read", s.handleDeleteCalendarEvent)))
mux.HandleFunc("GET /chat/conversations", s.requireAuth(s.handleListChatConversations)) mux.HandleFunc("GET /chat/conversations", s.requireAuth(s.handleListChatConversations))
mux.HandleFunc("GET /chat/search", s.requireAuth(s.handleSearchChatMessages)) mux.HandleFunc("GET /chat/search", s.requireAuth(s.handleSearchChatMessages))

View File

@ -53,6 +53,51 @@ func ensureRuntimeDeviceSchema(ctx context.Context, db *pgxpool.Pool) error {
`ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS active_directory_bind_password_encrypted BYTEA`, `ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS active_directory_bind_password_encrypted BYTEA`,
`ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS active_directory_user_group TEXT NOT NULL DEFAULT 'SE_Sachbearbeitung'`, `ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS active_directory_user_group TEXT NOT NULL DEFAULT 'SE_Sachbearbeitung'`,
`ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS active_directory_skip_tls_verify BOOLEAN NOT NULL DEFAULT false`, `ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS active_directory_skip_tls_verify BOOLEAN NOT NULL DEFAULT false`,
`
CREATE TABLE IF NOT EXISTS calendar_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
start_at TIMESTAMP NOT NULL,
end_at TIMESTAMP NOT NULL,
all_day BOOLEAN NOT NULL DEFAULT false,
location TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT 'indigo'
CHECK (color IN ('indigo', 'blue', 'emerald', 'amber', 'rose', 'slate')),
recurrence TEXT NOT NULL DEFAULT 'none',
recurrence_weekday INTEGER,
recurrence_ordinal INTEGER,
recurrence_exceptions JSONB NOT NULL DEFAULT '[]'::JSONB,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
updated_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT calendar_events_valid_range CHECK (end_at > start_at)
)
`,
`
DO $$
DECLARE
constraint_name TEXT;
BEGIN
FOR constraint_name IN
SELECT c.conname
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
WHERE t.relname = 'calendar_events'
AND c.contype = 'c'
AND pg_get_constraintdef(c.oid) ILIKE '%recurrence%'
LOOP
EXECUTE format('ALTER TABLE calendar_events DROP CONSTRAINT %I', constraint_name);
END LOOP;
END
$$
`,
`ALTER TABLE IF EXISTS calendar_events ADD COLUMN IF NOT EXISTS recurrence_weekday INTEGER`,
`ALTER TABLE IF EXISTS calendar_events ADD COLUMN IF NOT EXISTS recurrence_ordinal INTEGER`,
`ALTER TABLE IF EXISTS calendar_events ADD COLUMN IF NOT EXISTS recurrence_exceptions JSONB NOT NULL DEFAULT '[]'::JSONB`,
`UPDATE calendar_events SET recurrence_exceptions = '[]'::JSONB WHERE recurrence_exceptions IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_calendar_events_start_at ON calendar_events(start_at)`,
} }
for _, query := range queries { for _, query := range queries {

View File

@ -407,6 +407,52 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
ON CONFLICT (id) DO NOTHING ON CONFLICT (id) DO NOTHING
`, `,
`
CREATE TABLE IF NOT EXISTS calendar_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
start_at TIMESTAMP NOT NULL,
end_at TIMESTAMP NOT NULL,
all_day BOOLEAN NOT NULL DEFAULT false,
location TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT 'indigo'
CHECK (color IN ('indigo', 'blue', 'emerald', 'amber', 'rose', 'slate')),
recurrence TEXT NOT NULL DEFAULT 'none',
recurrence_weekday INTEGER,
recurrence_ordinal INTEGER,
recurrence_exceptions JSONB NOT NULL DEFAULT '[]'::JSONB,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
updated_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT calendar_events_valid_range CHECK (end_at > start_at)
)
`,
`
DO $$
DECLARE
constraint_name TEXT;
BEGIN
FOR constraint_name IN
SELECT c.conname
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
WHERE t.relname = 'calendar_events'
AND c.contype = 'c'
AND pg_get_constraintdef(c.oid) ILIKE '%recurrence%'
LOOP
EXECUTE format('ALTER TABLE calendar_events DROP CONSTRAINT %I', constraint_name);
END LOOP;
END
$$
`,
`ALTER TABLE IF EXISTS calendar_events ADD COLUMN IF NOT EXISTS recurrence_weekday INTEGER`,
`ALTER TABLE IF EXISTS calendar_events ADD COLUMN IF NOT EXISTS recurrence_ordinal INTEGER`,
`ALTER TABLE IF EXISTS calendar_events ADD COLUMN IF NOT EXISTS recurrence_exceptions JSONB NOT NULL DEFAULT '[]'::JSONB`,
`UPDATE calendar_events SET recurrence_exceptions = '[]'::JSONB WHERE recurrence_exceptions IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_calendar_events_start_at ON calendar_events(start_at)`,
` `
CREATE TABLE IF NOT EXISTS devices ( CREATE TABLE IF NOT EXISTS devices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

View File

@ -22,6 +22,13 @@ export type CalendarEventColor =
| 'amber' | 'amber'
| 'rose' | 'rose'
| 'slate' | 'slate'
export type CalendarEventRecurrence =
| 'none'
| 'daily'
| 'weekly'
| 'monthly'
| 'monthlyNthWeekday'
| 'yearly'
export type CalendarEvent = { export type CalendarEvent = {
id: string id: string
@ -32,8 +39,17 @@ export type CalendarEvent = {
location: string location: string
description: string description: string
color: CalendarEventColor color: CalendarEventColor
recurrence?: CalendarEventRecurrence
recurrenceWeekday?: number | null
recurrenceOrdinal?: number | null
recurrenceExceptions?: string[]
occurrenceStart?: string
occurrenceEnd?: string
occurrenceKey?: string
} }
type CalendarEventOccurrence = CalendarEvent
type CalendarProps = { type CalendarProps = {
events: CalendarEvent[] events: CalendarEvent[]
coreWorkingHours: CalendarCoreWorkingHours coreWorkingHours: CalendarCoreWorkingHours
@ -124,6 +140,101 @@ function addYears(date: Date, amount: number) {
return new Date(date.getFullYear() + amount, date.getMonth(), date.getDate()) return new Date(date.getFullYear() + amount, date.getMonth(), date.getDate())
} }
function daysInMonth(year: number, month: number) {
return new Date(year, month + 1, 0).getDate()
}
function weekdayOrdinalInMonth(date: Date) {
return Math.floor((date.getDate() - 1) / 7) + 1
}
function nthWeekdayInMonth(
year: number,
month: number,
weekday: number,
ordinal: number,
template: Date,
) {
const firstOfMonth = new Date(
year,
month,
1,
template.getHours(),
template.getMinutes(),
template.getSeconds(),
template.getMilliseconds(),
)
const firstOffset = (weekday - firstOfMonth.getDay() + 7) % 7
const day = 1 + firstOffset + (ordinal - 1) * 7
if (day > daysInMonth(year, month)) {
return null
}
return new Date(
year,
month,
day,
template.getHours(),
template.getMinutes(),
template.getSeconds(),
template.getMilliseconds(),
)
}
function addPeriodClamped(
date: Date,
recurrence: CalendarEventRecurrence,
amount: number,
event?: CalendarEvent,
) {
if (recurrence === 'daily') {
const next = new Date(date)
next.setDate(next.getDate() + amount)
return next
}
if (recurrence === 'weekly') {
const next = new Date(date)
next.setDate(next.getDate() + amount * 7)
return next
}
if (recurrence === 'monthly') {
const next = new Date(date)
const day = date.getDate()
next.setDate(1)
next.setMonth(next.getMonth() + amount)
next.setDate(Math.min(day, daysInMonth(next.getFullYear(), next.getMonth())))
return next
}
if (recurrence === 'monthlyNthWeekday') {
const next = new Date(date)
next.setDate(1)
next.setMonth(next.getMonth() + amount)
return nthWeekdayInMonth(
next.getFullYear(),
next.getMonth(),
event?.recurrenceWeekday ?? date.getDay(),
event?.recurrenceOrdinal ?? weekdayOrdinalInMonth(date),
date,
)
}
if (recurrence === 'yearly') {
const next = new Date(date)
const day = date.getDate()
next.setDate(1)
next.setFullYear(next.getFullYear() + amount)
next.setDate(Math.min(day, daysInMonth(next.getFullYear(), next.getMonth())))
return next
}
return new Date(date)
}
function startOfWeek(date: Date) { function startOfWeek(date: Date) {
const normalized = startOfDay(date) const normalized = startOfDay(date)
const mondayOffset = (normalized.getDay() + 6) % 7 const mondayOffset = (normalized.getDay() + 6) % 7
@ -138,20 +249,168 @@ function isToday(date: Date) {
return isSameDay(date, new Date()) return isSameDay(date, new Date())
} }
function eventsForDay(events: CalendarEvent[], date: Date) { function toLocalDateTimeValue(date: Date) {
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${toDateKey(date)}T${hours}:${minutes}`
}
function eventStartValue(event: CalendarEventOccurrence) {
return event.occurrenceStart ?? event.start
}
function eventEndValue(event: CalendarEventOccurrence) {
return event.occurrenceEnd ?? event.end
}
function eventKey(event: CalendarEventOccurrence) {
return event.occurrenceKey ?? event.id
}
function normalizeEventRecurrence(
recurrence: CalendarEvent['recurrence'],
): CalendarEventRecurrence {
return recurrence === 'daily' ||
recurrence === 'weekly' ||
recurrence === 'monthly' ||
recurrence === 'monthlyNthWeekday' ||
recurrence === 'yearly'
? recurrence
: 'none'
}
function recurrenceBaseIndex(
eventStart: Date,
dayStart: Date,
recurrence: CalendarEventRecurrence,
) {
const eventDay = startOfDay(eventStart)
const dayDistance =
(dayStart.getTime() - eventDay.getTime()) / (24 * 60 * 60 * 1000)
if (recurrence === 'daily') {
return Math.floor(dayDistance)
}
if (recurrence === 'weekly') {
return Math.floor(dayDistance / 7)
}
if (recurrence === 'monthly' || recurrence === 'monthlyNthWeekday') {
return (
(dayStart.getFullYear() - eventStart.getFullYear()) * 12 +
dayStart.getMonth() -
eventStart.getMonth()
)
}
if (recurrence === 'yearly') {
return dayStart.getFullYear() - eventStart.getFullYear()
}
return 0
}
function periodApproximationDays(recurrence: CalendarEventRecurrence) {
if (recurrence === 'weekly') {
return 7
}
if (recurrence === 'monthly' || recurrence === 'monthlyNthWeekday') {
return 28
}
if (recurrence === 'yearly') {
return 365
}
return 1
}
function occurrencesForDay(
event: CalendarEvent,
date: Date,
): CalendarEventOccurrence[] {
const dayStart = startOfDay(date) const dayStart = startOfDay(date)
const dayEnd = addDays(dayStart, 1) const dayEnd = addDays(dayStart, 1)
const eventStart = new Date(event.start)
const eventEnd = new Date(event.end)
if (Number.isNaN(eventStart.getTime()) || Number.isNaN(eventEnd.getTime())) {
return []
}
const recurrence = normalizeEventRecurrence(event.recurrence)
const durationMs = eventEnd.getTime() - eventStart.getTime()
if (durationMs <= 0) {
return []
}
if (recurrence === 'none') {
return eventStart < dayEnd && eventEnd > dayStart ? [event] : []
}
const baseIndex = recurrenceBaseIndex(eventStart, dayStart, recurrence)
const durationDays = Math.max(
1,
Math.ceil(durationMs / (24 * 60 * 60 * 1000)),
)
const radius = Math.min(
400,
Math.ceil(durationDays / periodApproximationDays(recurrence)) + 2,
)
const occurrences: CalendarEventOccurrence[] = []
const seen = new Set<string>()
const excludedStarts = new Set(event.recurrenceExceptions ?? [])
for (let offset = -radius; offset <= 2; offset += 1) {
const index = baseIndex + offset
if (index < 0) {
continue
}
const occurrenceStart = addPeriodClamped(eventStart, recurrence, index, event)
if (!occurrenceStart) {
continue
}
const occurrenceEnd = new Date(occurrenceStart.getTime() + durationMs)
const occurrenceStartValue = toLocalDateTimeValue(occurrenceStart)
if (
occurrenceStart < eventStart &&
occurrenceEnd.getTime() <= eventStart.getTime()
) {
continue
}
if (excludedStarts.has(occurrenceStartValue)) {
continue
}
if (occurrenceStart < dayEnd && occurrenceEnd > dayStart) {
const key = `${event.id}:${recurrence}:${index}`
if (!seen.has(key)) {
seen.add(key)
occurrences.push({
...event,
occurrenceStart: occurrenceStartValue,
occurrenceEnd: toLocalDateTimeValue(occurrenceEnd),
occurrenceKey: key,
})
}
}
}
return occurrences
}
function eventsForDay(events: CalendarEvent[], date: Date) {
return events return events
.filter((event) => { .flatMap((event) => occurrencesForDay(event, date))
const eventStart = new Date(event.start)
const eventEnd = new Date(event.end)
return eventStart < dayEnd && eventEnd > dayStart
})
.sort( .sort(
(left, right) => (left, right) =>
Number(right.allDay) - Number(left.allDay) || Number(right.allDay) - Number(left.allDay) ||
new Date(left.start).getTime() - new Date(right.start).getTime(), new Date(eventStartValue(left)).getTime() -
new Date(eventStartValue(right)).getTime(),
) )
} }
@ -508,7 +767,7 @@ function MonthView({
<div className="mt-1 hidden space-y-1 sm:block"> <div className="mt-1 hidden space-y-1 sm:block">
{dayEvents.slice(0, 3).map((event) => ( {dayEvents.slice(0, 3).map((event) => (
<button <button
key={event.id} key={eventKey(event)}
type="button" type="button"
data-calendar-event data-calendar-event
onPointerDown={(pointerEvent) => onPointerDown={(pointerEvent) =>
@ -531,7 +790,7 @@ function MonthView({
<span className="truncate">{event.title}</span> <span className="truncate">{event.title}</span>
{!event.allDay && ( {!event.allDay && (
<span className="ml-auto hidden shrink-0 xl:inline"> <span className="ml-auto hidden shrink-0 xl:inline">
{formatTime(event.start)} {formatTime(eventStartValue(event))}
</span> </span>
)} )}
</button> </button>
@ -552,7 +811,7 @@ function MonthView({
<div className="mt-auto flex flex-wrap justify-end gap-1 pt-2 sm:hidden"> <div className="mt-auto flex flex-wrap justify-end gap-1 pt-2 sm:hidden">
{dayEvents.slice(0, 4).map((event) => ( {dayEvents.slice(0, 4).map((event) => (
<span <span
key={event.id} key={eventKey(event)}
className={classNames( className={classNames(
'size-1.5 rounded-full', 'size-1.5 rounded-full',
eventColors[event.color].dot, eventColors[event.color].dot,
@ -590,7 +849,7 @@ function MonthView({
) : ( ) : (
<ol className="mt-3 space-y-2"> <ol className="mt-3 space-y-2">
{selectedEvents.map((event) => ( {selectedEvents.map((event) => (
<li key={event.id}> <li key={eventKey(event)}>
<EventListButton event={event} onClick={onEditEvent} /> <EventListButton event={event} onClick={onEditEvent} />
</li> </li>
))} ))}
@ -605,7 +864,7 @@ function EventListButton({
event, event,
onClick, onClick,
}: { }: {
event: CalendarEvent event: CalendarEventOccurrence
onClick: (event: CalendarEvent) => void onClick: (event: CalendarEvent) => void
}) { }) {
return ( return (
@ -632,7 +891,9 @@ function EventListButton({
<ClockIcon className="size-3.5" /> <ClockIcon className="size-3.5" />
{event.allDay {event.allDay
? 'Ganztägig' ? 'Ganztägig'
: `${formatTime(event.start)} - ${formatTime(event.end)}`} : `${formatTime(eventStartValue(event))} - ${formatTime(
eventEndValue(event),
)}`}
</span> </span>
{event.location && ( {event.location && (
<span className="inline-flex items-center gap-1 truncate"> <span className="inline-flex items-center gap-1 truncate">
@ -864,7 +1125,7 @@ function TimeGridView({
.filter((event) => event.allDay) .filter((event) => event.allDay)
.map((event) => ( .map((event) => (
<button <button
key={event.id} key={eventKey(event)}
type="button" type="button"
onClick={() => onEditEvent(event)} onClick={() => onEditEvent(event)}
className={classNames( className={classNames(
@ -1025,8 +1286,8 @@ function TimeGridView({
</div> </div>
)} )}
{timedEvents.map((event) => { {timedEvents.map((event) => {
const eventStart = new Date(event.start) const eventStart = new Date(eventStartValue(event))
const eventEnd = new Date(event.end) const eventEnd = new Date(eventEndValue(event))
const segmentStart = new Date( const segmentStart = new Date(
Math.max(eventStart.getTime(), dayStart.getTime()), Math.max(eventStart.getTime(), dayStart.getTime()),
) )
@ -1042,7 +1303,7 @@ function TimeGridView({
return ( return (
<button <button
key={event.id} key={eventKey(event)}
data-calendar-event data-calendar-event
type="button" type="button"
onPointerDown={(pointerEvent) => onPointerDown={(pointerEvent) =>

View File

@ -4,6 +4,7 @@ import Modal, { ModalTitle } from '../../components/Modal'
import Calendar, { import Calendar, {
type CalendarEvent, type CalendarEvent,
type CalendarEventColor, type CalendarEventColor,
type CalendarEventRecurrence,
type CalendarView, type CalendarView,
} from '../../components/calendar/Calendar' } from '../../components/calendar/Calendar'
import { import {
@ -13,7 +14,6 @@ import {
} from '../../components/calendar/calendarSettings' } from '../../components/calendar/calendarSettings'
import { toDateKey } from '../../components/calendar/dateUtils' import { toDateKey } from '../../components/calendar/dateUtils'
const STORAGE_KEY = 'teg.calendar.events.v1'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080' const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
type EventDraft = { type EventDraft = {
@ -26,8 +26,29 @@ type EventDraft = {
location: string location: string
description: string description: string
color: CalendarEventColor color: CalendarEventColor
recurrence: CalendarEventRecurrence
recurrenceWeekday: number
recurrenceOrdinal: number
} }
type EventPayload = {
title: string
start: string
end: string
allDay: boolean
location: string
description: string
color: CalendarEventColor
recurrence: CalendarEventRecurrence
recurrenceWeekday: number | null
recurrenceOrdinal: number | null
recurrenceExceptions: string[]
}
type PendingSeriesAction =
| { type: 'save'; payload: EventPayload }
| { type: 'delete' }
const colorOptions: Array<{ const colorOptions: Array<{
value: CalendarEventColor value: CalendarEventColor
label: string label: string
@ -41,87 +62,80 @@ const colorOptions: Array<{
{ value: 'slate', label: 'Grau', className: 'bg-slate-500' }, { value: 'slate', label: 'Grau', className: 'bg-slate-500' },
] ]
const recurrenceOptions: Array<{
value: CalendarEventRecurrence
label: string
}> = [
{ value: 'none', label: 'Keine Wiederholung' },
{ value: 'daily', label: 'Täglich' },
{ value: 'weekly', label: 'Wöchentlich' },
{ value: 'monthly', label: 'Monatlich' },
{ value: 'monthlyNthWeekday', label: 'Bestimmter Wochentag im Monat' },
{ value: 'yearly', label: 'Jährlich' },
]
const recurrenceOrdinalOptions = [
{ value: 1, label: '1.' },
{ value: 2, label: '2.' },
{ value: 3, label: '3.' },
{ value: 4, label: '4.' },
{ value: 5, label: '5.' },
]
const recurrenceWeekdayOptions = [
{ value: 1, label: 'Montag' },
{ value: 2, label: 'Dienstag' },
{ value: 3, label: 'Mittwoch' },
{ value: 4, label: 'Donnerstag' },
{ value: 5, label: 'Freitag' },
{ value: 6, label: 'Samstag' },
{ value: 0, label: 'Sonntag' },
]
function classNames(...classes: Array<string | false | null | undefined>) { function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(' ') return classes.filter(Boolean).join(' ')
} }
function dateAt(date: Date, hour: number, minute = 0) {
return new Date(
date.getFullYear(),
date.getMonth(),
date.getDate(),
hour,
minute,
)
}
function toLocalDateTime(date: Date) {
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${toDateKey(date)}T${hours}:${minutes}`
}
function createSeedEvents() {
const today = new Date()
const tomorrow = new Date(today)
tomorrow.setDate(tomorrow.getDate() + 1)
const later = new Date(today)
later.setDate(later.getDate() + 3)
return [
{
id: 'welcome-team',
title: 'Teambesprechung',
start: toLocalDateTime(dateAt(today, 10)),
end: toLocalDateTime(dateAt(today, 11)),
allDay: false,
location: 'Besprechungsraum 1',
description: 'Kurze Abstimmung zu den aktuellen Aufgaben.',
color: 'indigo',
},
{
id: 'welcome-planning',
title: 'Einsatzplanung',
start: toLocalDateTime(dateAt(tomorrow, 14)),
end: toLocalDateTime(dateAt(tomorrow, 15, 30)),
allDay: false,
location: 'Leitstelle',
description: '',
color: 'blue',
},
{
id: 'welcome-maintenance',
title: 'Wartungsfenster',
start: toLocalDateTime(dateAt(later, 0)),
end: toLocalDateTime(dateAt(later, 23, 59)),
allDay: true,
location: '',
description: 'Geplante Wartung der Infrastruktur.',
color: 'amber',
},
] satisfies CalendarEvent[]
}
function loadEvents() {
try {
const stored = window.localStorage.getItem(STORAGE_KEY)
if (!stored) {
return createSeedEvents()
}
const parsed = JSON.parse(stored)
return Array.isArray(parsed) ? (parsed as CalendarEvent[]) : createSeedEvents()
} catch {
return createSeedEvents()
}
}
function timeFromMinutes(minutes: number) { function timeFromMinutes(minutes: number) {
return `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String( return `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(
minutes % 60, minutes % 60,
).padStart(2, '0')}` ).padStart(2, '0')}`
} }
function normalizeRecurrence(
value: CalendarEvent['recurrence'],
): CalendarEventRecurrence {
return value === 'daily' ||
value === 'weekly' ||
value === 'monthly' ||
value === 'monthlyNthWeekday' ||
value === 'yearly'
? value
: 'none'
}
function weekdayOrdinalInMonth(date: Date) {
return Math.floor((date.getDate() - 1) / 7) + 1
}
function normalizeWeekday(value: unknown, fallback: number) {
const next = Number(value)
return Number.isInteger(next) && next >= 0 && next <= 6 ? next : fallback
}
function normalizeOrdinal(value: unknown, fallback: number) {
const next = Number(value)
return Number.isInteger(next) && next >= 1 && next <= 5 ? next : fallback
}
function eventIsRecurring(event: CalendarEvent | null) {
return Boolean(event && normalizeRecurrence(event.recurrence) !== 'none')
}
function eventIsSeriesOccurrence(event: CalendarEvent | null) {
return Boolean(eventIsRecurring(event) && event?.occurrenceStart)
}
function emptyDraft(start: Date, end?: Date, allDay = false): EventDraft { function emptyDraft(start: Date, end?: Date, allDay = false): EventDraft {
const now = new Date() const now = new Date()
const isToday = toDateKey(start) === toDateKey(now) const isToday = toDateKey(start) === toDateKey(now)
@ -148,12 +162,16 @@ function emptyDraft(start: Date, end?: Date, allDay = false): EventDraft {
location: '', location: '',
description: '', description: '',
color: 'indigo', color: 'indigo',
recurrence: 'none',
recurrenceWeekday: start.getDay(),
recurrenceOrdinal: weekdayOrdinalInMonth(start),
} }
} }
function eventToDraft(event: CalendarEvent): EventDraft { function eventToDraft(event: CalendarEvent): EventDraft {
const start = new Date(event.start) const start = new Date(event.occurrenceStart ?? event.start)
const end = new Date(event.end) const end = new Date(event.occurrenceEnd ?? event.end)
const baseStart = new Date(event.start)
return { return {
title: event.title, title: event.title,
@ -169,17 +187,20 @@ function eventToDraft(event: CalendarEvent): EventDraft {
location: event.location, location: event.location,
description: event.description, description: event.description,
color: event.color, color: event.color,
recurrence: normalizeRecurrence(event.recurrence),
recurrenceWeekday: normalizeWeekday(
event.recurrenceWeekday,
baseStart.getDay(),
),
recurrenceOrdinal: normalizeOrdinal(
event.recurrenceOrdinal,
weekdayOrdinalInMonth(baseStart),
),
} }
} }
function createEventId() {
return typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `event-${Date.now()}`
}
export default function CalendarPage() { export default function CalendarPage() {
const [events, setEvents] = useState<CalendarEvent[]>(loadEvents) const [events, setEvents] = useState<CalendarEvent[]>([])
const [calendarSettings, setCalendarSettings] = useState<CalendarSettings>( const [calendarSettings, setCalendarSettings] = useState<CalendarSettings>(
createDefaultCalendarSettings, createDefaultCalendarSettings,
) )
@ -189,10 +210,53 @@ export default function CalendarPage() {
const [editorOpen, setEditorOpen] = useState(false) const [editorOpen, setEditorOpen] = useState(false)
const [draft, setDraft] = useState<EventDraft>(() => emptyDraft(new Date())) const [draft, setDraft] = useState<EventDraft>(() => emptyDraft(new Date()))
const [formError, setFormError] = useState('') const [formError, setFormError] = useState('')
const [pageError, setPageError] = useState('')
const [eventsLoading, setEventsLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [pendingSeriesAction, setPendingSeriesAction] =
useState<PendingSeriesAction | null>(null)
useEffect(() => { useEffect(() => {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(events)) let ignore = false
}, [events])
async function loadEvents() {
setEventsLoading(true)
setPageError('')
try {
const response = await fetch(`${API_URL}/calendar/events`, {
credentials: 'include',
})
const data = await response.json().catch(() => null)
if (!response.ok) {
throw new Error(data?.error ?? 'Kalendereinträge konnten nicht geladen werden.')
}
if (!ignore) {
setEvents(Array.isArray(data?.events) ? data.events : [])
}
} catch (error) {
if (!ignore) {
setPageError(
error instanceof Error
? error.message
: 'Kalendereinträge konnten nicht geladen werden.',
)
}
} finally {
if (!ignore) {
setEventsLoading(false)
}
}
}
void loadEvents()
return () => {
ignore = true
}
}, [])
useEffect(() => { useEffect(() => {
let ignore = false let ignore = false
@ -237,15 +301,14 @@ export default function CalendarPage() {
setEditorOpen(false) setEditorOpen(false)
setEditingEvent(null) setEditingEvent(null)
setFormError('') setFormError('')
setPendingSeriesAction(null)
} }
function saveEvent(event: FormEvent<HTMLFormElement>) { function buildEventPayload(): EventPayload | null {
event.preventDefault()
const title = draft.title.trim() const title = draft.title.trim()
if (!title) { if (!title) {
setFormError('Bitte gib einen Titel ein.') setFormError('Bitte gib einen Titel ein.')
return return null
} }
const start = draft.allDay const start = draft.allDay
@ -260,12 +323,11 @@ export default function CalendarPage() {
end = `${draft.endDate}T23:59` end = `${draft.endDate}T23:59`
} else { } else {
setFormError('Das Ende muss nach dem Beginn liegen.') setFormError('Das Ende muss nach dem Beginn liegen.')
return return null
} }
} }
const nextEvent: CalendarEvent = { return {
id: editingEvent?.id ?? createEventId(),
title, title,
start, start,
end, end,
@ -273,31 +335,254 @@ export default function CalendarPage() {
location: draft.location.trim(), location: draft.location.trim(),
description: draft.description.trim(), description: draft.description.trim(),
color: draft.color, color: draft.color,
recurrence: draft.recurrence,
recurrenceWeekday:
draft.recurrence === 'monthlyNthWeekday'
? draft.recurrenceWeekday
: null,
recurrenceOrdinal:
draft.recurrence === 'monthlyNthWeekday'
? draft.recurrenceOrdinal
: null,
recurrenceExceptions: editingEvent?.recurrenceExceptions ?? [],
} }
setEvents((current) =>
editingEvent
? current.map((item) =>
item.id === editingEvent.id ? nextEvent : item,
)
: [...current, nextEvent],
)
setSelectedDate(new Date(start))
closeEditor()
} }
function deleteEvent() { async function saveEventSeries(payload: EventPayload) {
if ( setSaving(true)
!editingEvent || setFormError('')
!window.confirm(`Termin „${editingEvent.title}“ wirklich löschen?`)
) { try {
const response = await fetch(
editingEvent
? `${API_URL}/calendar/events/${editingEvent.id}`
: `${API_URL}/calendar/events`,
{
method: editingEvent ? 'PUT' : 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
},
)
const data = await response.json().catch(() => null)
if (!response.ok) {
throw new Error(
data?.error ?? 'Kalendereintrag konnte nicht gespeichert werden.',
)
}
const savedEvent = data?.event as CalendarEvent | undefined
if (!savedEvent) {
throw new Error('Kalendereintrag konnte nicht gelesen werden.')
}
setEvents((current) =>
editingEvent
? current.map((item) =>
item.id === editingEvent.id ? savedEvent : item,
)
: [...current, savedEvent],
)
setSelectedDate(new Date(savedEvent.start))
closeEditor()
} catch (error) {
setFormError(
error instanceof Error
? error.message
: 'Kalendereintrag konnte nicht gespeichert werden.',
)
} finally {
setSaving(false)
}
}
async function saveSingleOccurrence(payload: EventPayload) {
if (!editingEvent?.occurrenceStart) {
await saveEventSeries(payload)
return return
} }
setEvents((current) => setSaving(true)
current.filter((event) => event.id !== editingEvent.id), setFormError('')
)
closeEditor() try {
const response = await fetch(
`${API_URL}/calendar/events/${editingEvent.id}/occurrences`,
{
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
occurrenceStart: editingEvent.occurrenceStart,
event: {
...payload,
recurrence: 'none',
recurrenceWeekday: null,
recurrenceOrdinal: null,
recurrenceExceptions: [],
},
}),
},
)
const data = await response.json().catch(() => null)
if (!response.ok) {
throw new Error(
data?.error ?? 'Ausgewählter Termin konnte nicht gespeichert werden.',
)
}
const updatedSeries = data?.series as CalendarEvent | undefined
const savedEvent = data?.event as CalendarEvent | undefined
if (!updatedSeries || !savedEvent) {
throw new Error('Kalendereintrag konnte nicht gelesen werden.')
}
setEvents((current) => [
...current.map((item) =>
item.id === editingEvent.id ? updatedSeries : item,
),
savedEvent,
])
setSelectedDate(new Date(savedEvent.start))
closeEditor()
} catch (error) {
setFormError(
error instanceof Error
? error.message
: 'Ausgewählter Termin konnte nicht gespeichert werden.',
)
} finally {
setSaving(false)
}
}
async function deleteEventSeries() {
if (!editingEvent) {
return
}
setSaving(true)
setFormError('')
try {
const response = await fetch(`${API_URL}/calendar/events/${editingEvent.id}`, {
method: 'DELETE',
credentials: 'include',
})
const data = await response.json().catch(() => null)
if (!response.ok) {
throw new Error(data?.error ?? 'Kalendereintrag konnte nicht gelöscht werden.')
}
setEvents((current) =>
current.filter((event) => event.id !== editingEvent.id),
)
closeEditor()
} catch (error) {
setFormError(
error instanceof Error
? error.message
: 'Kalendereintrag konnte nicht gelöscht werden.',
)
} finally {
setSaving(false)
}
}
async function deleteSingleOccurrence() {
if (!editingEvent?.occurrenceStart) {
await deleteEventSeries()
return
}
setSaving(true)
setFormError('')
try {
const response = await fetch(
`${API_URL}/calendar/events/${editingEvent.id}/occurrences/delete`,
{
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
occurrenceStart: editingEvent.occurrenceStart,
}),
},
)
const data = await response.json().catch(() => null)
if (!response.ok) {
throw new Error(
data?.error ?? 'Ausgewählter Termin konnte nicht gelöscht werden.',
)
}
const updatedSeries = data?.event as CalendarEvent | undefined
if (!updatedSeries) {
throw new Error('Kalendereintrag konnte nicht gelesen werden.')
}
setEvents((current) =>
current.map((event) =>
event.id === editingEvent.id ? updatedSeries : event,
),
)
closeEditor()
} catch (error) {
setFormError(
error instanceof Error
? error.message
: 'Ausgewählter Termin konnte nicht gelöscht werden.',
)
} finally {
setSaving(false)
}
}
async function saveEvent(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (saving) {
return
}
const payload = buildEventPayload()
if (!payload) {
return
}
if (eventIsSeriesOccurrence(editingEvent)) {
setPendingSeriesAction({ type: 'save', payload })
return
}
await saveEventSeries(payload)
}
async function deleteEvent() {
if (!editingEvent) {
return
}
if (eventIsSeriesOccurrence(editingEvent)) {
setPendingSeriesAction({ type: 'delete' })
return
}
if (!window.confirm(`Termin „${editingEvent.title}“ wirklich löschen?`)) {
return
}
await deleteEventSeries()
} }
const inputClassName = const inputClassName =
@ -305,17 +590,31 @@ export default function CalendarPage() {
return ( return (
<> <>
<div className="h-full min-h-0"> <div className="flex h-full min-h-0 flex-col">
<Calendar {(pageError || eventsLoading) && (
events={events} <div
coreWorkingHours={calendarSettings.coreWorkingHours} className={classNames(
selectedDate={selectedDate} 'border-b px-4 py-2 text-sm sm:px-6',
view={view} pageError
onSelectedDateChange={setSelectedDate} ? 'border-red-200 bg-red-50 text-red-700 dark:border-red-500/20 dark:bg-red-500/10 dark:text-red-300'
onViewChange={setView} : 'border-gray-200 bg-gray-50 text-gray-500 dark:border-white/10 dark:bg-white/5 dark:text-gray-300',
onCreateEvent={openCreate} )}
onEditEvent={openEdit} >
/> {pageError || 'Kalendereinträge werden geladen ...'}
</div>
)}
<div className="min-h-0 flex-1">
<Calendar
events={events}
coreWorkingHours={calendarSettings.coreWorkingHours}
selectedDate={selectedDate}
view={view}
onSelectedDateChange={setSelectedDate}
onViewChange={setView}
onCreateEvent={openCreate}
onEditEvent={openEdit}
/>
</div>
</div> </div>
<Modal <Modal
@ -362,6 +661,7 @@ export default function CalendarPage() {
title: event.target.value, title: event.target.value,
})) }))
} }
disabled={saving}
className={classNames(inputClassName, 'mt-2')} className={classNames(inputClassName, 'mt-2')}
placeholder="Was steht an?" placeholder="Was steht an?"
maxLength={120} maxLength={120}
@ -380,19 +680,30 @@ export default function CalendarPage() {
id="calendar-start-date" id="calendar-start-date"
type="date" type="date"
value={draft.startDate} value={draft.startDate}
onChange={(event) => onChange={(event) =>
setDraft((current) => { setDraft((current) => {
const startDate = event.target.value const startDate = event.target.value
return { const start = new Date(`${startDate}T00:00`)
...current,
startDate, return {
endDate: ...current,
current.endDate < startDate startDate,
? startDate endDate:
: current.endDate, current.endDate < startDate
} ? startDate
}) : current.endDate,
} recurrenceWeekday:
current.recurrence === 'monthlyNthWeekday'
? start.getDay()
: current.recurrenceWeekday,
recurrenceOrdinal:
current.recurrence === 'monthlyNthWeekday'
? weekdayOrdinalInMonth(start)
: current.recurrenceOrdinal,
}
})
}
disabled={saving}
className={classNames(inputClassName, 'mt-2')} className={classNames(inputClassName, 'mt-2')}
/> />
</div> </div>
@ -407,7 +718,7 @@ export default function CalendarPage() {
id="calendar-start" id="calendar-start"
type="time" type="time"
value={draft.startTime} value={draft.startTime}
disabled={draft.allDay} disabled={draft.allDay || saving}
onChange={(event) => onChange={(event) =>
setDraft((current) => ({ setDraft((current) => ({
...current, ...current,
@ -432,6 +743,7 @@ export default function CalendarPage() {
type="date" type="date"
min={draft.startDate} min={draft.startDate}
value={draft.endDate} value={draft.endDate}
disabled={saving}
onChange={(event) => onChange={(event) =>
setDraft((current) => ({ setDraft((current) => ({
...current, ...current,
@ -452,7 +764,7 @@ export default function CalendarPage() {
id="calendar-end" id="calendar-end"
type="time" type="time"
value={draft.endTime} value={draft.endTime}
disabled={draft.allDay} disabled={draft.allDay || saving}
onChange={(event) => onChange={(event) =>
setDraft((current) => ({ setDraft((current) => ({
...current, ...current,
@ -471,6 +783,7 @@ export default function CalendarPage() {
<input <input
type="checkbox" type="checkbox"
checked={draft.allDay} checked={draft.allDay}
disabled={saving}
onChange={(event) => onChange={(event) =>
setDraft((current) => ({ setDraft((current) => ({
...current, ...current,
@ -484,6 +797,104 @@ export default function CalendarPage() {
</span> </span>
</label> </label>
<div>
<label
htmlFor="calendar-recurrence"
className="block text-sm font-medium text-gray-900 dark:text-white"
>
Wiederholung
</label>
<select
id="calendar-recurrence"
value={draft.recurrence}
disabled={saving}
onChange={(event) =>
setDraft((current) => {
const recurrence = event.target
.value as CalendarEventRecurrence
const start = new Date(`${current.startDate}T00:00`)
return {
...current,
recurrence,
recurrenceWeekday:
recurrence === 'monthlyNthWeekday'
? current.recurrenceWeekday ?? start.getDay()
: current.recurrenceWeekday,
recurrenceOrdinal:
recurrence === 'monthlyNthWeekday'
? current.recurrenceOrdinal ??
weekdayOrdinalInMonth(start)
: current.recurrenceOrdinal,
}
})
}
className={classNames(inputClassName, 'mt-2')}
>
{recurrenceOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{draft.recurrence === 'monthlyNthWeekday' && (
<div className="mt-3 grid grid-cols-2 gap-3">
<div>
<label
htmlFor="calendar-recurrence-ordinal"
className="block text-xs font-medium text-gray-500 dark:text-gray-400"
>
Woche
</label>
<select
id="calendar-recurrence-ordinal"
value={draft.recurrenceOrdinal}
disabled={saving}
onChange={(event) =>
setDraft((current) => ({
...current,
recurrenceOrdinal: Number(event.target.value),
}))
}
className={classNames(inputClassName, 'mt-1.5')}
>
{recurrenceOrdinalOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div>
<label
htmlFor="calendar-recurrence-weekday"
className="block text-xs font-medium text-gray-500 dark:text-gray-400"
>
Wochentag
</label>
<select
id="calendar-recurrence-weekday"
value={draft.recurrenceWeekday}
disabled={saving}
onChange={(event) =>
setDraft((current) => ({
...current,
recurrenceWeekday: Number(event.target.value),
}))
}
className={classNames(inputClassName, 'mt-1.5')}
>
{recurrenceWeekdayOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
</div>
)}
</div>
<div> <div>
<label <label
htmlFor="calendar-location" htmlFor="calendar-location"
@ -494,6 +905,7 @@ export default function CalendarPage() {
<input <input
id="calendar-location" id="calendar-location"
value={draft.location} value={draft.location}
disabled={saving}
onChange={(event) => onChange={(event) =>
setDraft((current) => ({ setDraft((current) => ({
...current, ...current,
@ -517,6 +929,7 @@ export default function CalendarPage() {
id="calendar-description" id="calendar-description"
rows={3} rows={3}
value={draft.description} value={draft.description}
disabled={saving}
onChange={(event) => onChange={(event) =>
setDraft((current) => ({ setDraft((current) => ({
...current, ...current,
@ -545,14 +958,15 @@ export default function CalendarPage() {
: 'ring-transparent hover:ring-gray-300 dark:hover:ring-white/30', : 'ring-transparent hover:ring-gray-300 dark:hover:ring-white/30',
)} )}
> >
<input <input
type="radio" type="radio"
name="color" name="color"
value={color.value} value={color.value}
checked={draft.color === color.value} checked={draft.color === color.value}
onChange={() => disabled={saving}
setDraft((current) => ({ onChange={() =>
...current, setDraft((current) => ({
...current,
color: color.value, color: color.value,
})) }))
} }
@ -582,6 +996,7 @@ export default function CalendarPage() {
<button <button
type="button" type="button"
onClick={deleteEvent} onClick={deleteEvent}
disabled={saving}
className="inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-semibold text-red-600 hover:bg-red-50 dark:text-red-300 dark:hover:bg-red-500/10" className="inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-semibold text-red-600 hover:bg-red-50 dark:text-red-300 dark:hover:bg-red-500/10"
> >
<TrashIcon className="size-4" /> <TrashIcon className="size-4" />
@ -593,20 +1008,105 @@ export default function CalendarPage() {
<button <button
type="button" type="button"
onClick={closeEditor} onClick={closeEditor}
disabled={saving}
className="rounded-lg px-3 py-2 text-sm font-semibold text-gray-600 hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-white/10" className="rounded-lg px-3 py-2 text-sm font-semibold text-gray-600 hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-white/10"
> >
Abbrechen Abbrechen
</button> </button>
<button <button
type="submit" type="submit"
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400" disabled={saving}
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"
> >
{editingEvent ? 'Speichern' : 'Termin erstellen'} {saving
? 'Speichern ...'
: editingEvent
? 'Speichern'
: 'Termin erstellen'}
</button> </button>
</div> </div>
</div> </div>
</form> </form>
</Modal> </Modal>
<Modal
open={pendingSeriesAction !== null}
setOpen={(open) => {
if (!open && !saving) {
setPendingSeriesAction(null)
}
}}
zIndexClassName="z-[10010]"
backdropClassName="bg-gray-950/60 backdrop-blur-sm dark:bg-gray-950/60"
panelClassName="max-w-md rounded-2xl bg-white dark:bg-gray-900"
>
<div className="border-b border-gray-200 px-5 py-4 dark:border-white/10">
<ModalTitle>
{pendingSeriesAction?.type === 'delete'
? 'Serientermin löschen'
: 'Serientermin bearbeiten'}
</ModalTitle>
</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
übernommen werden?
</p>
{formError && (
<p className="rounded-xl bg-red-50 px-3 py-2 text-sm text-red-700 ring-1 ring-red-200 dark:bg-red-500/10 dark:text-red-300 dark:ring-red-500/20">
{formError}
</p>
)}
</div>
<div className="flex flex-wrap justify-end gap-2 border-t border-gray-200 bg-gray-50 px-5 py-3 dark:border-white/10 dark:bg-white/5">
<button
type="button"
onClick={() => setPendingSeriesAction(null)}
disabled={saving}
className="rounded-lg px-3 py-2 text-sm font-semibold text-gray-600 hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-60 dark:text-gray-300 dark:hover:bg-white/10"
>
Abbrechen
</button>
<button
type="button"
disabled={saving || !pendingSeriesAction}
onClick={() => {
if (!pendingSeriesAction) {
return
}
if (pendingSeriesAction.type === 'delete') {
void deleteSingleOccurrence()
return
}
void saveSingleOccurrence(pendingSeriesAction.payload)
}}
className="rounded-lg px-3 py-2 text-sm font-semibold text-indigo-700 hover:bg-indigo-50 disabled:cursor-not-allowed disabled:opacity-60 dark:text-indigo-300 dark:hover:bg-indigo-500/10"
>
Nur dieser Termin
</button>
<button
type="button"
disabled={saving || !pendingSeriesAction}
onClick={() => {
if (!pendingSeriesAction) {
return
}
if (pendingSeriesAction.type === 'delete') {
void deleteEventSeries()
return
}
void saveEventSeries(pendingSeriesAction.payload)
}}
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
</button>
</div>
</Modal>
</> </>
) )
} }

View File

@ -108,6 +108,9 @@ function classNames(...classes: Array<string | false | null | undefined>) {
async function readApiError(response: Response, fallback: string) { async function readApiError(response: Response, fallback: string) {
const data = await response.json().catch(() => null) const data = await response.json().catch(() => null)
if (data?.error && data?.details) {
return `${data.error}: ${data.details}`
}
return data?.error ?? fallback return data?.error ?? fallback
} }
@ -122,19 +125,31 @@ function roleIsAdministrators(role?: MilestoneRole | null) {
.some((value) => value?.trim().toLowerCase() === 'administrators') .some((value) => value?.trim().toLowerCase() === 'administrators')
} }
function userDomainIsBasic(domain?: string) {
const normalized = (domain ?? '').trim().replace(/^\[/, '').replace(/\]$/, '')
return normalized.toLowerCase() === 'basic'
}
function userSource(user: MilestoneRoleUser | AssignableUser): UserSource { function userSource(user: MilestoneRoleUser | AssignableUser): UserSource {
return user.source === 'basic' ? 'basic' : 'ad' return user.source === 'basic' || userDomainIsBasic(user.domain)
? 'basic'
: 'ad'
}
function withoutDomainPrefix(value: string) {
const [, accountName] = value.split('\\')
return accountName || value
} }
function userAccountName(user: MilestoneRoleUser | AssignableUser) { function userAccountName(user: MilestoneRoleUser | AssignableUser) {
if ('username' in user && user.username) { if ('username' in user && user.username) {
return user.username return withoutDomainPrefix(user.username)
} }
if ('userName' in user && user.userName) { if ('userName' in user && user.userName) {
return user.userName return withoutDomainPrefix(user.userName)
} }
if ('name' in user && user.name) { if ('name' in user && user.name) {
return user.name return withoutDomainPrefix(user.name)
} }
return user.id return user.id
@ -142,11 +157,6 @@ function userAccountName(user: MilestoneRoleUser | AssignableUser) {
function userTitle(user: MilestoneRoleUser | AssignableUser) { function userTitle(user: MilestoneRoleUser | AssignableUser) {
const accountName = userAccountName(user) const accountName = userAccountName(user)
const domain = user.domain?.trim()
if (domain && accountName) {
return `${domain}\\${accountName}`
}
return accountName || user.displayName || user.id return accountName || user.displayName || user.id
} }
@ -155,25 +165,17 @@ function adUserSubtitle(user: ActiveDirectoryUser) {
return [user.username, user.email].filter(Boolean).join(' / ') return [user.username, user.email].filter(Boolean).join(' / ')
} }
function milestoneUserSubtitle(user: MilestoneRoleUser) {
return [user.userName || user.name, user.email].filter(Boolean).join(' / ')
}
function userSubtitle(user: MilestoneRoleUser | AssignableUser) { function userSubtitle(user: MilestoneRoleUser | AssignableUser) {
const fallbackSubtitle = if (userSource(user) === 'basic') {
userSource(user) === 'ad' return 'Basisbenutzer'
? adUserSubtitle(user as ActiveDirectoryUser) }
: milestoneUserSubtitle(user as MilestoneRoleUser)
return [ const domain = user.domain?.trim()
user.displayName && user.displayName !== userTitle(user) if (domain && !userDomainIsBasic(domain)) {
? user.displayName return domain
: '', }
userSource(user) === 'basic' ? 'Basisbenutzer' : '',
user.email || fallbackSubtitle, return user.email || adUserSubtitle(user as ActiveDirectoryUser)
]
.filter(Boolean)
.join(' / ')
} }
function userSelectionKey(user: AssignableUser) { function userSelectionKey(user: AssignableUser) {
@ -205,7 +207,6 @@ export default function MilestoneRolesPage({
const [isLoadingBasicUsers, setIsLoadingBasicUsers] = useState(false) const [isLoadingBasicUsers, setIsLoadingBasicUsers] = useState(false)
const [isAddingUsers, setIsAddingUsers] = useState(false) const [isAddingUsers, setIsAddingUsers] = useState(false)
const [adSearch, setAdSearch] = useState('') const [adSearch, setAdSearch] = useState('')
const [userPickerSource, setUserPickerSource] = useState<UserSource>('ad')
const [selectedUserKeys, setSelectedUserKeys] = useState<Set<string>>( const [selectedUserKeys, setSelectedUserKeys] = useState<Set<string>>(
() => new Set(), () => new Set(),
) )
@ -426,7 +427,6 @@ export default function MilestoneRolesPage({
setAdModalOpen(true) setAdModalOpen(true)
setSelectedUserKeys(new Set()) setSelectedUserKeys(new Set())
setAdSearch('') setAdSearch('')
setUserPickerSource('ad')
await Promise.all([loadAdUsers(), loadBasicUsers()]) await Promise.all([loadAdUsers(), loadBasicUsers()])
} }
@ -621,6 +621,13 @@ export default function MilestoneRolesPage({
}, [isAdministrator, roleSearch, roles]) }, [isAdministrator, roleSearch, roles])
const roleUsers = selectedRole?.users ?? [] const roleUsers = selectedRole?.users ?? []
const roleUsersBySource = useMemo<Record<UserSource, MilestoneRoleUser[]>>(
() => ({
ad: roleUsers.filter((user) => userSource(user) === 'ad'),
basic: roleUsers.filter((user) => userSource(user) === 'basic'),
}),
[roleUsers],
)
const visibleAssignableUsersBySource = useMemo<Record<UserSource, AssignableUser[]>>(() => { const visibleAssignableUsersBySource = useMemo<Record<UserSource, AssignableUser[]>>(() => {
const existingUserIds = new Set(roleUsers.map((user) => user.id)) const existingUserIds = new Set(roleUsers.map((user) => user.id))
const query = adSearch.trim().toLowerCase() const query = adSearch.trim().toLowerCase()
@ -654,11 +661,6 @@ export default function MilestoneRolesPage({
} }
}, [adSearch, adUsers, basicUsers, roleUsers]) }, [adSearch, adUsers, basicUsers, roleUsers])
const filteredAssignableUsers = visibleAssignableUsersBySource[userPickerSource]
const isLoadingPickerUsers =
userPickerSource === 'ad' ? isLoadingAdUsers : isLoadingBasicUsers
return ( return (
<div className="h-full overflow-y-auto bg-gray-50 px-4 py-6 sm:px-6 lg:px-8 dark:bg-gray-950"> <div className="h-full overflow-y-auto bg-gray-50 px-4 py-6 sm:px-6 lg:px-8 dark:bg-gray-950">
<div className="mx-auto flex max-w-7xl flex-col gap-6"> <div className="mx-auto flex max-w-7xl flex-col gap-6">
@ -884,39 +886,71 @@ export default function MilestoneRolesPage({
Diese Rolle hat noch keine Benutzer. Diese Rolle hat noch keine Benutzer.
</div> </div>
) : ( ) : (
<ul className="grid gap-3 md:grid-cols-2"> <div className="space-y-5">
{roleUsers.map((user) => ( {([
<li ['ad', 'Windows-Benutzer'],
key={user.id} ['basic', 'Basisbenutzer'],
className="flex items-start gap-3 rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/[0.03]" ] as Array<[UserSource, string]>).map(([source, label]) => {
> const users = roleUsersBySource[source]
<div className="flex size-9 shrink-0 items-center justify-center rounded-md bg-white text-gray-500 ring-1 ring-gray-200 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10"> if (users.length === 0) {
<UserGroupIcon aria-hidden="true" className="size-4" /> return null
</div> }
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-gray-900 dark:text-white"> return (
{userTitle(user)} <section key={source}>
</p> <div className="mb-2 flex items-center justify-between">
{userSubtitle(user) && ( <h4 className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
<p className="mt-0.5 truncate text-xs text-gray-500 dark:text-gray-400"> {label}
{userSubtitle(user)} </h4>
</p> <span className="text-xs text-gray-400 dark:text-gray-500">
)} {users.length}
</div> </span>
{canWrite && ( </div>
<button <ul className="grid gap-3 md:grid-cols-2">
type="button" {users.map((user) => (
onClick={() => void removeRoleUser(user.id)} <li
disabled={removingUserId === user.id} key={user.id}
className="rounded-md p-1 text-gray-400 hover:bg-white hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10 dark:hover:text-red-300" className="flex items-start gap-3 rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/[0.03]"
> >
<span className="sr-only">Benutzer entfernen</span> <div className="flex size-9 shrink-0 items-center justify-center rounded-md bg-white text-gray-500 ring-1 ring-gray-200 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10">
<XMarkIcon aria-hidden="true" className="size-4" /> <UserGroupIcon
</button> aria-hidden="true"
)} className="size-4"
</li> />
))} </div>
</ul> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-gray-900 dark:text-white">
{userTitle(user)}
</p>
{userSubtitle(user) && (
<p className="mt-0.5 truncate text-xs text-gray-500 dark:text-gray-400">
{userSubtitle(user)}
</p>
)}
</div>
{canWrite && (
<button
type="button"
onClick={() => void removeRoleUser(user.id)}
disabled={removingUserId === user.id}
className="rounded-md p-1 text-gray-400 hover:bg-white hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10 dark:hover:text-red-300"
>
<span className="sr-only">
Benutzer entfernen
</span>
<XMarkIcon
aria-hidden="true"
className="size-4"
/>
</button>
)}
</li>
))}
</ul>
</section>
)
})}
</div>
)} )}
</div> </div>
</> </>
@ -1060,74 +1094,69 @@ export default function MilestoneRolesPage({
<div className="border-b border-gray-200 px-6 py-4 dark:border-white/10"> <div className="border-b border-gray-200 px-6 py-4 dark:border-white/10">
<ModalTitle>Benutzer hinzufügen</ModalTitle> <ModalTitle>Benutzer hinzufügen</ModalTitle>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400"> <p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{userPickerSource === 'ad' {adBaseDn
? adBaseDn ? `Windows-Benutzer aus ${adBaseDn} und Basisbenutzer aus Milestone.`
? `Benutzer aus dem Suchbereich ${adBaseDn}.` : 'Windows-Benutzer aus dem konfigurierten Suchbereich und Basisbenutzer aus Milestone.'}
: 'Benutzer aus dem konfigurierten Suchbereich.'
: 'Basisbenutzer aus Milestone.'}
</p> </p>
</div> </div>
<div className="px-6 py-5"> <div className="px-6 py-5">
<div className="mb-4 grid grid-cols-2 rounded-lg bg-gray-100 p-1 text-sm font-medium dark:bg-white/5"> <input
type="search"
value={adSearch}
onChange={(event) => setAdSearch(event.target.value)}
placeholder="Benutzer suchen"
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">
{([ {([
['ad', `AD-Benutzer (${visibleAssignableUsersBySource.ad.length})`], {
['basic', `Basisbenutzer (${visibleAssignableUsersBySource.basic.length})`], source: 'ad' as const,
] as Array<[UserSource, string]>).map(([source, label]) => ( title: 'Windows-Benutzer',
<button users: visibleAssignableUsersBySource.ad,
key={source} loading: isLoadingAdUsers,
type="button" unavailable: !adConfigured,
onClick={() => setUserPickerSource(source)} emptyText: 'Keine passenden Windows-Benutzer gefunden.',
className={classNames( },
'rounded-md px-3 py-2 transition', {
userPickerSource === source source: 'basic' as const,
? 'bg-white text-indigo-700 shadow-xs dark:bg-white/10 dark:text-indigo-300' title: 'Basisbenutzer',
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white', users: visibleAssignableUsersBySource.basic,
)} loading: isLoadingBasicUsers,
> unavailable: false,
{label} emptyText: 'Keine passenden Basisbenutzer gefunden.',
</button> },
))} ]).map((group) => (
</div> <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>
{userPickerSource === 'ad' && !adConfigured ? ( {group.unavailable ? (
<div className="rounded-lg border border-dashed border-gray-200 px-4 py-10 text-center text-sm text-gray-500 dark:border-white/10 dark:text-gray-400"> <div className="rounded-lg border border-dashed border-gray-200 px-4 py-6 text-center text-sm text-gray-500 dark:border-white/10 dark:text-gray-400">
Active Directory ist noch nicht konfiguriert. Active Directory ist noch nicht konfiguriert.
</div> </div>
) : isLoadingPickerUsers ? ( ) : group.loading ? (
<div className="flex min-h-64 items-center justify-center"> <div className="flex min-h-28 items-center justify-center rounded-lg border border-dashed border-gray-200 dark:border-white/10">
<LoadingSpinner <LoadingSpinner
size="md" size="sm"
label={ label={`${group.title} werden geladen...`}
userPickerSource === 'ad' className="text-indigo-600 dark:text-indigo-400"
? 'AD-Benutzer werden geladen...' />
: 'Basisbenutzer werden geladen...' </div>
} ) : group.users.length === 0 ? (
className="text-indigo-600 dark:text-indigo-400" <div className="rounded-lg border border-dashed border-gray-200 px-4 py-6 text-center text-sm text-gray-500 dark:border-white/10 dark:text-gray-400">
/> {group.emptyText}
</div>
) : (
<>
<input
type="search"
value={adSearch}
onChange={(event) => setAdSearch(event.target.value)}
placeholder={
userPickerSource === 'ad'
? 'AD-Benutzer suchen'
: 'Basisbenutzer suchen'
}
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-80 overflow-y-auto rounded-lg border border-gray-200 dark:border-white/10">
{filteredAssignableUsers.length === 0 ? (
<div className="px-4 py-10 text-center text-sm text-gray-500 dark:text-gray-400">
Keine passenden Benutzer gefunden.
</div> </div>
) : ( ) : (
<ul className="divide-y divide-gray-200 dark:divide-white/10"> <ul className="divide-y divide-gray-200 overflow-hidden rounded-lg border border-gray-200 dark:divide-white/10 dark:border-white/10">
{filteredAssignableUsers.map((user) => ( {group.users.map((user) => (
<li key={userSelectionKey(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"> <label className="flex cursor-pointer items-start gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-white/5">
<input <input
@ -1151,9 +1180,9 @@ export default function MilestoneRolesPage({
))} ))}
</ul> </ul>
)} )}
</div> </section>
</> ))}
)} </div>
</div> </div>
<div className="flex justify-end gap-3 border-t border-gray-200 px-6 py-4 dark:border-white/10"> <div className="flex justify-end gap-3 border-t border-gray-200 px-6 py-4 dark:border-white/10">