updated
This commit is contained in:
parent
a95f8a15ac
commit
e5a88399e9
@ -3,6 +3,8 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@ -57,6 +59,21 @@ type milestoneRoleUserInput struct {
|
||||
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) {
|
||||
currentUser, _ := userFromContext(r.Context())
|
||||
isAdministrator := userHasRight(currentUser, "admin")
|
||||
@ -241,26 +258,18 @@ func (s *Server) handleAdminUpdateMilestoneRole(w http.ResponseWriter, r *http.R
|
||||
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.DisplayName = strings.TrimSpace(input.DisplayName)
|
||||
if role.DisplayName == "" {
|
||||
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 {
|
||||
writeError(w, http.StatusBadGateway, "Milestone-Rolle konnte nicht gespeichert werden")
|
||||
if err := patchOperationMilestoneRole(r.Context(), config, role.ID, map[string]any{
|
||||
"name": roleName,
|
||||
"displayName": role.DisplayName,
|
||||
"description": strings.TrimSpace(input.Description),
|
||||
}); err != nil {
|
||||
writeMilestoneRoleActionError(w, http.StatusBadGateway, "Milestone-Rolle konnte nicht gespeichert werden", err)
|
||||
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")
|
||||
return
|
||||
}
|
||||
users, _ = listOperationMilestoneRoleUsers(r.Context(), config, roleID)
|
||||
users, _ := listOperationMilestoneRoleUsers(r.Context(), config, roleID)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"role": milestoneRoleResponseFromRole(*role, users),
|
||||
@ -350,20 +359,25 @@ func (s *Server) handleAdminAddMilestoneRoleUsers(w http.ResponseWriter, r *http
|
||||
}
|
||||
|
||||
nextUsers := users
|
||||
usersToAdd := []map[string]any{}
|
||||
for _, userInput := range input.Users {
|
||||
user := milestoneRoleUserReferenceFromInput(userInput)
|
||||
if milestoneItemID(user) == "" || milestoneRoleUserItemIsHidden(milestoneRoleUserItemFromObject(user)) {
|
||||
continue
|
||||
}
|
||||
if operationMilestoneObjectByID(nextUsers, milestoneItemID(user)) != nil {
|
||||
continue
|
||||
}
|
||||
nextUsers = appendOperationMilestoneObjectByID(nextUsers, user)
|
||||
usersToAdd = appendOperationMilestoneObjectByID(usersToAdd, user)
|
||||
}
|
||||
if len(nextUsers) == len(users) {
|
||||
writeError(w, http.StatusBadRequest, "Kein gültiger AD-Benutzer ausgewählt")
|
||||
if len(usersToAdd) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "Kein gültiger Benutzer ausgewählt")
|
||||
return
|
||||
}
|
||||
|
||||
if err := putOperationMilestoneRole(r.Context(), config, *role, nextUsers); err != nil {
|
||||
writeError(w, http.StatusBadGateway, "AD-Benutzer konnten der Milestone-Rolle nicht hinzugefügt werden")
|
||||
if err := addOperationMilestoneRoleMembers(r.Context(), config, role.ID, usersToAdd); err != nil {
|
||||
writeMilestoneRoleActionError(w, http.StatusBadGateway, "Benutzer konnten der Milestone-Rolle nicht hinzugefügt werden", err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -405,23 +419,25 @@ func (s *Server) handleAdminRemoveMilestoneRoleUser(w http.ResponseWriter, r *ht
|
||||
}
|
||||
|
||||
nextUsers := make([]map[string]any, 0, len(users))
|
||||
usersToRemove := []map[string]any{}
|
||||
for _, user := range users {
|
||||
if strings.EqualFold(milestoneItemID(user), userID) {
|
||||
if milestoneRoleUserItemIsHidden(milestoneRoleUserItemFromObject(user)) {
|
||||
writeError(w, http.StatusForbidden, "Dieser Benutzer darf nicht entfernt werden")
|
||||
return
|
||||
}
|
||||
usersToRemove = append(usersToRemove, user)
|
||||
continue
|
||||
}
|
||||
nextUsers = append(nextUsers, user)
|
||||
}
|
||||
if len(nextUsers) == len(users) {
|
||||
if len(usersToRemove) == 0 {
|
||||
writeError(w, http.StatusNotFound, "Benutzer wurde in der Rolle nicht gefunden")
|
||||
return
|
||||
}
|
||||
|
||||
if err := putOperationMilestoneRole(r.Context(), config, *role, nextUsers); err != nil {
|
||||
writeError(w, http.StatusBadGateway, "Benutzer konnte nicht aus der Milestone-Rolle entfernt werden")
|
||||
if err := removeOperationMilestoneRoleMembers(r.Context(), config, role.ID, usersToRemove); err != nil {
|
||||
writeMilestoneRoleActionError(w, http.StatusBadGateway, "Benutzer konnte nicht aus der Milestone-Rolle entfernt werden", err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -493,9 +509,7 @@ func (s *Server) handleAdminListMilestoneBasicUsers(w http.ResponseWriter, r *ht
|
||||
continue
|
||||
}
|
||||
user.Source = "basic"
|
||||
if user.Domain == "" {
|
||||
user.Domain = "Basic"
|
||||
}
|
||||
user.Domain = ""
|
||||
users = append(users, user)
|
||||
}
|
||||
|
||||
@ -614,8 +628,12 @@ func milestoneRoleUserItemFromObject(user map[string]any) milestoneRoleUserItem
|
||||
if domain == "" && distinguishedName != "" {
|
||||
domain = activeDirectoryDomainFromDN(distinguishedName)
|
||||
}
|
||||
if domain == "" && source == "basic" {
|
||||
domain = "Basic"
|
||||
if milestoneRoleUserDomainIsBasic(domain) {
|
||||
source = "basic"
|
||||
domain = ""
|
||||
}
|
||||
if source == "basic" {
|
||||
domain = ""
|
||||
}
|
||||
|
||||
if displayName == "" {
|
||||
@ -659,6 +677,32 @@ func milestoneRoleUserReferenceFromInput(input milestoneRoleUserInput) map[strin
|
||||
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 == "" {
|
||||
userID = userSID
|
||||
}
|
||||
@ -688,8 +732,6 @@ func milestoneRoleUserReferenceFromInput(input milestoneRoleUserInput) map[strin
|
||||
"userName": username,
|
||||
"username": username,
|
||||
"email": email,
|
||||
"domain": domain,
|
||||
"source": source,
|
||||
"relations": map[string]any{
|
||||
"self": map[string]any{
|
||||
"type": milestoneRoleUserRelationType(source),
|
||||
@ -697,6 +739,9 @@ func milestoneRoleUserReferenceFromInput(input milestoneRoleUserInput) map[strin
|
||||
},
|
||||
},
|
||||
}
|
||||
if domain != "" {
|
||||
user["domain"] = domain
|
||||
}
|
||||
if userSID != "" {
|
||||
user["sid"] = userSID
|
||||
user["objectSid"] = userSID
|
||||
@ -708,7 +753,140 @@ func milestoneRoleUserReferenceFromInput(input milestoneRoleUserInput) map[strin
|
||||
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 {
|
||||
if milestoneRoleUserDomainIsBasic(milestoneRoleUserDomainFromObject(user)) {
|
||||
return "basic"
|
||||
}
|
||||
|
||||
source := milestoneRoleUserSource(milestoneStringValue(user["source"]))
|
||||
if source != "" {
|
||||
return source
|
||||
@ -733,6 +911,13 @@ func milestoneRoleUserSourceFromObject(user map[string]any) string {
|
||||
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 {
|
||||
source = strings.TrimSpace(strings.ToLower(source))
|
||||
switch source {
|
||||
@ -802,6 +987,7 @@ func milestoneProtectedUserNameIsHidden(value string) bool {
|
||||
switch value {
|
||||
case "nt-autorität\\netzwerkdienst",
|
||||
"nt authority\\network service",
|
||||
"tegadmin",
|
||||
"tegdssd\\tegadmin",
|
||||
"vordefiniert\\administratoren",
|
||||
"builtin\\administrators",
|
||||
|
||||
775
backend/calendar_events.go
Normal file
775
backend/calendar_events.go
Normal 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",
|
||||
})
|
||||
}
|
||||
@ -850,9 +850,16 @@ func (s *Server) ensureOperationMilestoneRoleUser(
|
||||
}
|
||||
|
||||
if selectedUser := operationMilestoneUserFromSetupInput(input); selectedUser != nil {
|
||||
roleUsers := appendOperationMilestoneObjectByID(operationMilestoneObjectListFromValue(role.Data["users"]), selectedUser)
|
||||
if err := putOperationMilestoneRole(ctx, config, *role, roleUsers); err != nil {
|
||||
return nil, err
|
||||
roleUsers, listErr := listOperationMilestoneRoleUsers(ctx, config, role.ID)
|
||||
if listErr != nil {
|
||||
roleUsers = operationMilestoneObjectListFromValue(role.Data["users"])
|
||||
}
|
||||
|
||||
if operationMilestoneObjectByID(roleUsers, milestoneItemID(selectedUser)) == nil {
|
||||
if err := addOperationMilestoneRoleMembers(ctx, config, role.ID, []map[string]any{selectedUser}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
roleUsers = appendOperationMilestoneObjectByID(roleUsers, selectedUser)
|
||||
}
|
||||
role.Data["users"] = roleUsers
|
||||
}
|
||||
@ -892,21 +899,28 @@ func (s *Server) ensureOperationMilestoneRoleUser(
|
||||
}
|
||||
}
|
||||
|
||||
roleUsers := operationMilestoneObjectListFromValue(role.Data["users"])
|
||||
if len(roleUsers) == 0 && !hasOperationMilestoneUserSelection(input) {
|
||||
roleUsers = appendOperationMilestoneObjectsByID(roleUsers, sourceUsers...)
|
||||
roleUsers := sourceUsers
|
||||
if len(roleUsers) == 0 {
|
||||
roleUsers = operationMilestoneObjectListFromValue(role.Data["users"])
|
||||
}
|
||||
|
||||
usersToAdd := []map[string]any{}
|
||||
for _, targetUser := range usersToAssign {
|
||||
if milestoneItemID(targetUser) == "" {
|
||||
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)
|
||||
}
|
||||
|
||||
if err := putOperationMilestoneRole(ctx, config, *role, roleUsers); err != nil {
|
||||
return nil, err
|
||||
if len(usersToAdd) > 0 {
|
||||
if err := addOperationMilestoneRoleMembers(ctx, config, role.ID, usersToAdd); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
role.Data["users"] = roleUsers
|
||||
|
||||
@ -113,6 +113,12 @@ func (s *Server) Routes() http.Handler {
|
||||
mux.HandleFunc("PUT /settings/presence", s.requireAuth(s.handleUpdatePresenceSettings))
|
||||
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/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/search", s.requireAuth(s.handleSearchChatMessages))
|
||||
|
||||
@ -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_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`,
|
||||
`
|
||||
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 {
|
||||
|
||||
@ -407,6 +407,52 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
||||
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 (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
@ -22,6 +22,13 @@ export type CalendarEventColor =
|
||||
| 'amber'
|
||||
| 'rose'
|
||||
| 'slate'
|
||||
export type CalendarEventRecurrence =
|
||||
| 'none'
|
||||
| 'daily'
|
||||
| 'weekly'
|
||||
| 'monthly'
|
||||
| 'monthlyNthWeekday'
|
||||
| 'yearly'
|
||||
|
||||
export type CalendarEvent = {
|
||||
id: string
|
||||
@ -32,8 +39,17 @@ export type CalendarEvent = {
|
||||
location: string
|
||||
description: string
|
||||
color: CalendarEventColor
|
||||
recurrence?: CalendarEventRecurrence
|
||||
recurrenceWeekday?: number | null
|
||||
recurrenceOrdinal?: number | null
|
||||
recurrenceExceptions?: string[]
|
||||
occurrenceStart?: string
|
||||
occurrenceEnd?: string
|
||||
occurrenceKey?: string
|
||||
}
|
||||
|
||||
type CalendarEventOccurrence = CalendarEvent
|
||||
|
||||
type CalendarProps = {
|
||||
events: CalendarEvent[]
|
||||
coreWorkingHours: CalendarCoreWorkingHours
|
||||
@ -124,6 +140,101 @@ function addYears(date: Date, amount: number) {
|
||||
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) {
|
||||
const normalized = startOfDay(date)
|
||||
const mondayOffset = (normalized.getDay() + 6) % 7
|
||||
@ -138,20 +249,168 @@ function isToday(date: 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 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
|
||||
.filter((event) => {
|
||||
const eventStart = new Date(event.start)
|
||||
const eventEnd = new Date(event.end)
|
||||
return eventStart < dayEnd && eventEnd > dayStart
|
||||
})
|
||||
.flatMap((event) => occurrencesForDay(event, date))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
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">
|
||||
{dayEvents.slice(0, 3).map((event) => (
|
||||
<button
|
||||
key={event.id}
|
||||
key={eventKey(event)}
|
||||
type="button"
|
||||
data-calendar-event
|
||||
onPointerDown={(pointerEvent) =>
|
||||
@ -531,7 +790,7 @@ function MonthView({
|
||||
<span className="truncate">{event.title}</span>
|
||||
{!event.allDay && (
|
||||
<span className="ml-auto hidden shrink-0 xl:inline">
|
||||
{formatTime(event.start)}
|
||||
{formatTime(eventStartValue(event))}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
@ -552,7 +811,7 @@ function MonthView({
|
||||
<div className="mt-auto flex flex-wrap justify-end gap-1 pt-2 sm:hidden">
|
||||
{dayEvents.slice(0, 4).map((event) => (
|
||||
<span
|
||||
key={event.id}
|
||||
key={eventKey(event)}
|
||||
className={classNames(
|
||||
'size-1.5 rounded-full',
|
||||
eventColors[event.color].dot,
|
||||
@ -590,7 +849,7 @@ function MonthView({
|
||||
) : (
|
||||
<ol className="mt-3 space-y-2">
|
||||
{selectedEvents.map((event) => (
|
||||
<li key={event.id}>
|
||||
<li key={eventKey(event)}>
|
||||
<EventListButton event={event} onClick={onEditEvent} />
|
||||
</li>
|
||||
))}
|
||||
@ -605,7 +864,7 @@ function EventListButton({
|
||||
event,
|
||||
onClick,
|
||||
}: {
|
||||
event: CalendarEvent
|
||||
event: CalendarEventOccurrence
|
||||
onClick: (event: CalendarEvent) => void
|
||||
}) {
|
||||
return (
|
||||
@ -632,7 +891,9 @@ function EventListButton({
|
||||
<ClockIcon className="size-3.5" />
|
||||
{event.allDay
|
||||
? 'Ganztägig'
|
||||
: `${formatTime(event.start)} - ${formatTime(event.end)}`}
|
||||
: `${formatTime(eventStartValue(event))} - ${formatTime(
|
||||
eventEndValue(event),
|
||||
)}`}
|
||||
</span>
|
||||
{event.location && (
|
||||
<span className="inline-flex items-center gap-1 truncate">
|
||||
@ -864,7 +1125,7 @@ function TimeGridView({
|
||||
.filter((event) => event.allDay)
|
||||
.map((event) => (
|
||||
<button
|
||||
key={event.id}
|
||||
key={eventKey(event)}
|
||||
type="button"
|
||||
onClick={() => onEditEvent(event)}
|
||||
className={classNames(
|
||||
@ -1025,8 +1286,8 @@ function TimeGridView({
|
||||
</div>
|
||||
)}
|
||||
{timedEvents.map((event) => {
|
||||
const eventStart = new Date(event.start)
|
||||
const eventEnd = new Date(event.end)
|
||||
const eventStart = new Date(eventStartValue(event))
|
||||
const eventEnd = new Date(eventEndValue(event))
|
||||
const segmentStart = new Date(
|
||||
Math.max(eventStart.getTime(), dayStart.getTime()),
|
||||
)
|
||||
@ -1042,7 +1303,7 @@ function TimeGridView({
|
||||
|
||||
return (
|
||||
<button
|
||||
key={event.id}
|
||||
key={eventKey(event)}
|
||||
data-calendar-event
|
||||
type="button"
|
||||
onPointerDown={(pointerEvent) =>
|
||||
|
||||
@ -4,6 +4,7 @@ import Modal, { ModalTitle } from '../../components/Modal'
|
||||
import Calendar, {
|
||||
type CalendarEvent,
|
||||
type CalendarEventColor,
|
||||
type CalendarEventRecurrence,
|
||||
type CalendarView,
|
||||
} from '../../components/calendar/Calendar'
|
||||
import {
|
||||
@ -13,7 +14,6 @@ import {
|
||||
} from '../../components/calendar/calendarSettings'
|
||||
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'
|
||||
|
||||
type EventDraft = {
|
||||
@ -26,8 +26,29 @@ type EventDraft = {
|
||||
location: string
|
||||
description: string
|
||||
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<{
|
||||
value: CalendarEventColor
|
||||
label: string
|
||||
@ -41,87 +62,80 @@ const colorOptions: Array<{
|
||||
{ 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>) {
|
||||
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) {
|
||||
return `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(
|
||||
minutes % 60,
|
||||
).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 {
|
||||
const now = new Date()
|
||||
const isToday = toDateKey(start) === toDateKey(now)
|
||||
@ -148,12 +162,16 @@ function emptyDraft(start: Date, end?: Date, allDay = false): EventDraft {
|
||||
location: '',
|
||||
description: '',
|
||||
color: 'indigo',
|
||||
recurrence: 'none',
|
||||
recurrenceWeekday: start.getDay(),
|
||||
recurrenceOrdinal: weekdayOrdinalInMonth(start),
|
||||
}
|
||||
}
|
||||
|
||||
function eventToDraft(event: CalendarEvent): EventDraft {
|
||||
const start = new Date(event.start)
|
||||
const end = new Date(event.end)
|
||||
const start = new Date(event.occurrenceStart ?? event.start)
|
||||
const end = new Date(event.occurrenceEnd ?? event.end)
|
||||
const baseStart = new Date(event.start)
|
||||
|
||||
return {
|
||||
title: event.title,
|
||||
@ -169,17 +187,20 @@ function eventToDraft(event: CalendarEvent): EventDraft {
|
||||
location: event.location,
|
||||
description: event.description,
|
||||
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() {
|
||||
const [events, setEvents] = useState<CalendarEvent[]>(loadEvents)
|
||||
const [events, setEvents] = useState<CalendarEvent[]>([])
|
||||
const [calendarSettings, setCalendarSettings] = useState<CalendarSettings>(
|
||||
createDefaultCalendarSettings,
|
||||
)
|
||||
@ -189,10 +210,53 @@ export default function CalendarPage() {
|
||||
const [editorOpen, setEditorOpen] = useState(false)
|
||||
const [draft, setDraft] = useState<EventDraft>(() => emptyDraft(new Date()))
|
||||
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(() => {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(events))
|
||||
}, [events])
|
||||
let ignore = false
|
||||
|
||||
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(() => {
|
||||
let ignore = false
|
||||
@ -237,15 +301,14 @@ export default function CalendarPage() {
|
||||
setEditorOpen(false)
|
||||
setEditingEvent(null)
|
||||
setFormError('')
|
||||
setPendingSeriesAction(null)
|
||||
}
|
||||
|
||||
function saveEvent(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
||||
function buildEventPayload(): EventPayload | null {
|
||||
const title = draft.title.trim()
|
||||
if (!title) {
|
||||
setFormError('Bitte gib einen Titel ein.')
|
||||
return
|
||||
return null
|
||||
}
|
||||
|
||||
const start = draft.allDay
|
||||
@ -260,12 +323,11 @@ export default function CalendarPage() {
|
||||
end = `${draft.endDate}T23:59`
|
||||
} else {
|
||||
setFormError('Das Ende muss nach dem Beginn liegen.')
|
||||
return
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const nextEvent: CalendarEvent = {
|
||||
id: editingEvent?.id ?? createEventId(),
|
||||
return {
|
||||
title,
|
||||
start,
|
||||
end,
|
||||
@ -273,31 +335,254 @@ export default function CalendarPage() {
|
||||
location: draft.location.trim(),
|
||||
description: draft.description.trim(),
|
||||
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() {
|
||||
if (
|
||||
!editingEvent ||
|
||||
!window.confirm(`Termin „${editingEvent.title}“ wirklich löschen?`)
|
||||
) {
|
||||
async function saveEventSeries(payload: EventPayload) {
|
||||
setSaving(true)
|
||||
setFormError('')
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
setEvents((current) =>
|
||||
current.filter((event) => event.id !== editingEvent.id),
|
||||
)
|
||||
closeEditor()
|
||||
setSaving(true)
|
||||
setFormError('')
|
||||
|
||||
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 =
|
||||
@ -305,17 +590,31 @@ export default function CalendarPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full min-h-0">
|
||||
<Calendar
|
||||
events={events}
|
||||
coreWorkingHours={calendarSettings.coreWorkingHours}
|
||||
selectedDate={selectedDate}
|
||||
view={view}
|
||||
onSelectedDateChange={setSelectedDate}
|
||||
onViewChange={setView}
|
||||
onCreateEvent={openCreate}
|
||||
onEditEvent={openEdit}
|
||||
/>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{(pageError || eventsLoading) && (
|
||||
<div
|
||||
className={classNames(
|
||||
'border-b px-4 py-2 text-sm sm:px-6',
|
||||
pageError
|
||||
? 'border-red-200 bg-red-50 text-red-700 dark:border-red-500/20 dark:bg-red-500/10 dark:text-red-300'
|
||||
: 'border-gray-200 bg-gray-50 text-gray-500 dark:border-white/10 dark:bg-white/5 dark:text-gray-300',
|
||||
)}
|
||||
>
|
||||
{pageError || 'Kalendereinträge werden geladen ...'}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-0 flex-1">
|
||||
<Calendar
|
||||
events={events}
|
||||
coreWorkingHours={calendarSettings.coreWorkingHours}
|
||||
selectedDate={selectedDate}
|
||||
view={view}
|
||||
onSelectedDateChange={setSelectedDate}
|
||||
onViewChange={setView}
|
||||
onCreateEvent={openCreate}
|
||||
onEditEvent={openEdit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
@ -362,6 +661,7 @@ export default function CalendarPage() {
|
||||
title: event.target.value,
|
||||
}))
|
||||
}
|
||||
disabled={saving}
|
||||
className={classNames(inputClassName, 'mt-2')}
|
||||
placeholder="Was steht an?"
|
||||
maxLength={120}
|
||||
@ -380,19 +680,30 @@ export default function CalendarPage() {
|
||||
id="calendar-start-date"
|
||||
type="date"
|
||||
value={draft.startDate}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => {
|
||||
const startDate = event.target.value
|
||||
return {
|
||||
...current,
|
||||
startDate,
|
||||
endDate:
|
||||
current.endDate < startDate
|
||||
? startDate
|
||||
: current.endDate,
|
||||
}
|
||||
})
|
||||
}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => {
|
||||
const startDate = event.target.value
|
||||
const start = new Date(`${startDate}T00:00`)
|
||||
|
||||
return {
|
||||
...current,
|
||||
startDate,
|
||||
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')}
|
||||
/>
|
||||
</div>
|
||||
@ -407,7 +718,7 @@ export default function CalendarPage() {
|
||||
id="calendar-start"
|
||||
type="time"
|
||||
value={draft.startTime}
|
||||
disabled={draft.allDay}
|
||||
disabled={draft.allDay || saving}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
@ -432,6 +743,7 @@ export default function CalendarPage() {
|
||||
type="date"
|
||||
min={draft.startDate}
|
||||
value={draft.endDate}
|
||||
disabled={saving}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
@ -452,7 +764,7 @@ export default function CalendarPage() {
|
||||
id="calendar-end"
|
||||
type="time"
|
||||
value={draft.endTime}
|
||||
disabled={draft.allDay}
|
||||
disabled={draft.allDay || saving}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
@ -471,6 +783,7 @@ export default function CalendarPage() {
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.allDay}
|
||||
disabled={saving}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
@ -484,6 +797,104 @@ export default function CalendarPage() {
|
||||
</span>
|
||||
</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>
|
||||
<label
|
||||
htmlFor="calendar-location"
|
||||
@ -494,6 +905,7 @@ export default function CalendarPage() {
|
||||
<input
|
||||
id="calendar-location"
|
||||
value={draft.location}
|
||||
disabled={saving}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
@ -517,6 +929,7 @@ export default function CalendarPage() {
|
||||
id="calendar-description"
|
||||
rows={3}
|
||||
value={draft.description}
|
||||
disabled={saving}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
@ -545,14 +958,15 @@ export default function CalendarPage() {
|
||||
: 'ring-transparent hover:ring-gray-300 dark:hover:ring-white/30',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="color"
|
||||
value={color.value}
|
||||
checked={draft.color === color.value}
|
||||
onChange={() =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
<input
|
||||
type="radio"
|
||||
name="color"
|
||||
value={color.value}
|
||||
checked={draft.color === color.value}
|
||||
disabled={saving}
|
||||
onChange={() =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
color: color.value,
|
||||
}))
|
||||
}
|
||||
@ -582,6 +996,7 @@ export default function CalendarPage() {
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<TrashIcon className="size-4" />
|
||||
@ -593,20 +1008,105 @@ export default function CalendarPage() {
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -108,6 +108,9 @@ function classNames(...classes: Array<string | false | null | undefined>) {
|
||||
|
||||
async function readApiError(response: Response, fallback: string) {
|
||||
const data = await response.json().catch(() => null)
|
||||
if (data?.error && data?.details) {
|
||||
return `${data.error}: ${data.details}`
|
||||
}
|
||||
|
||||
return data?.error ?? fallback
|
||||
}
|
||||
@ -122,19 +125,31 @@ function roleIsAdministrators(role?: MilestoneRole | null) {
|
||||
.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 {
|
||||
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) {
|
||||
if ('username' in user && user.username) {
|
||||
return user.username
|
||||
return withoutDomainPrefix(user.username)
|
||||
}
|
||||
if ('userName' in user && user.userName) {
|
||||
return user.userName
|
||||
return withoutDomainPrefix(user.userName)
|
||||
}
|
||||
if ('name' in user && user.name) {
|
||||
return user.name
|
||||
return withoutDomainPrefix(user.name)
|
||||
}
|
||||
|
||||
return user.id
|
||||
@ -142,11 +157,6 @@ function userAccountName(user: MilestoneRoleUser | AssignableUser) {
|
||||
|
||||
function userTitle(user: MilestoneRoleUser | AssignableUser) {
|
||||
const accountName = userAccountName(user)
|
||||
const domain = user.domain?.trim()
|
||||
|
||||
if (domain && accountName) {
|
||||
return `${domain}\\${accountName}`
|
||||
}
|
||||
|
||||
return accountName || user.displayName || user.id
|
||||
}
|
||||
@ -155,25 +165,17 @@ function adUserSubtitle(user: ActiveDirectoryUser) {
|
||||
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) {
|
||||
const fallbackSubtitle =
|
||||
userSource(user) === 'ad'
|
||||
? adUserSubtitle(user as ActiveDirectoryUser)
|
||||
: milestoneUserSubtitle(user as MilestoneRoleUser)
|
||||
if (userSource(user) === 'basic') {
|
||||
return 'Basisbenutzer'
|
||||
}
|
||||
|
||||
return [
|
||||
user.displayName && user.displayName !== userTitle(user)
|
||||
? user.displayName
|
||||
: '',
|
||||
userSource(user) === 'basic' ? 'Basisbenutzer' : '',
|
||||
user.email || fallbackSubtitle,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' / ')
|
||||
const domain = user.domain?.trim()
|
||||
if (domain && !userDomainIsBasic(domain)) {
|
||||
return domain
|
||||
}
|
||||
|
||||
return user.email || adUserSubtitle(user as ActiveDirectoryUser)
|
||||
}
|
||||
|
||||
function userSelectionKey(user: AssignableUser) {
|
||||
@ -205,7 +207,6 @@ export default function MilestoneRolesPage({
|
||||
const [isLoadingBasicUsers, setIsLoadingBasicUsers] = useState(false)
|
||||
const [isAddingUsers, setIsAddingUsers] = useState(false)
|
||||
const [adSearch, setAdSearch] = useState('')
|
||||
const [userPickerSource, setUserPickerSource] = useState<UserSource>('ad')
|
||||
const [selectedUserKeys, setSelectedUserKeys] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
)
|
||||
@ -426,7 +427,6 @@ export default function MilestoneRolesPage({
|
||||
setAdModalOpen(true)
|
||||
setSelectedUserKeys(new Set())
|
||||
setAdSearch('')
|
||||
setUserPickerSource('ad')
|
||||
await Promise.all([loadAdUsers(), loadBasicUsers()])
|
||||
}
|
||||
|
||||
@ -621,6 +621,13 @@ export default function MilestoneRolesPage({
|
||||
}, [isAdministrator, roleSearch, roles])
|
||||
|
||||
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 existingUserIds = new Set(roleUsers.map((user) => user.id))
|
||||
const query = adSearch.trim().toLowerCase()
|
||||
@ -654,11 +661,6 @@ export default function MilestoneRolesPage({
|
||||
}
|
||||
}, [adSearch, adUsers, basicUsers, roleUsers])
|
||||
|
||||
const filteredAssignableUsers = visibleAssignableUsersBySource[userPickerSource]
|
||||
|
||||
const isLoadingPickerUsers =
|
||||
userPickerSource === 'ad' ? isLoadingAdUsers : isLoadingBasicUsers
|
||||
|
||||
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="mx-auto flex max-w-7xl flex-col gap-6">
|
||||
@ -884,39 +886,71 @@ export default function MilestoneRolesPage({
|
||||
Diese Rolle hat noch keine Benutzer.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="grid gap-3 md:grid-cols-2">
|
||||
{roleUsers.map((user) => (
|
||||
<li
|
||||
key={user.id}
|
||||
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]"
|
||||
>
|
||||
<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">
|
||||
<UserGroupIcon aria-hidden="true" className="size-4" />
|
||||
</div>
|
||||
<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>
|
||||
<div className="space-y-5">
|
||||
{([
|
||||
['ad', 'Windows-Benutzer'],
|
||||
['basic', 'Basisbenutzer'],
|
||||
] as Array<[UserSource, string]>).map(([source, label]) => {
|
||||
const users = roleUsersBySource[source]
|
||||
if (users.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section key={source}>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{label}
|
||||
</h4>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||
{users.length}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="grid gap-3 md:grid-cols-2">
|
||||
{users.map((user) => (
|
||||
<li
|
||||
key={user.id}
|
||||
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]"
|
||||
>
|
||||
<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">
|
||||
<UserGroupIcon
|
||||
aria-hidden="true"
|
||||
className="size-4"
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
</>
|
||||
@ -1060,74 +1094,69 @@ export default function MilestoneRolesPage({
|
||||
<div className="border-b border-gray-200 px-6 py-4 dark:border-white/10">
|
||||
<ModalTitle>Benutzer hinzufügen</ModalTitle>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{userPickerSource === 'ad'
|
||||
? adBaseDn
|
||||
? `Benutzer aus dem Suchbereich ${adBaseDn}.`
|
||||
: 'Benutzer aus dem konfigurierten Suchbereich.'
|
||||
: 'Basisbenutzer aus Milestone.'}
|
||||
{adBaseDn
|
||||
? `Windows-Benutzer aus ${adBaseDn} und Basisbenutzer aus Milestone.`
|
||||
: 'Windows-Benutzer aus dem konfigurierten Suchbereich und Basisbenutzer aus Milestone.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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})`],
|
||||
] as Array<[UserSource, string]>).map(([source, label]) => (
|
||||
<button
|
||||
key={source}
|
||||
type="button"
|
||||
onClick={() => setUserPickerSource(source)}
|
||||
className={classNames(
|
||||
'rounded-md px-3 py-2 transition',
|
||||
userPickerSource === source
|
||||
? 'bg-white text-indigo-700 shadow-xs dark:bg-white/10 dark:text-indigo-300'
|
||||
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{
|
||||
source: 'ad' as const,
|
||||
title: 'Windows-Benutzer',
|
||||
users: visibleAssignableUsersBySource.ad,
|
||||
loading: isLoadingAdUsers,
|
||||
unavailable: !adConfigured,
|
||||
emptyText: 'Keine passenden Windows-Benutzer gefunden.',
|
||||
},
|
||||
{
|
||||
source: 'basic' as const,
|
||||
title: 'Basisbenutzer',
|
||||
users: visibleAssignableUsersBySource.basic,
|
||||
loading: isLoadingBasicUsers,
|
||||
unavailable: false,
|
||||
emptyText: 'Keine passenden Basisbenutzer gefunden.',
|
||||
},
|
||||
]).map((group) => (
|
||||
<section key={group.source}>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{group.title}
|
||||
</h3>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||
{group.users.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{userPickerSource === 'ad' && !adConfigured ? (
|
||||
<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">
|
||||
Active Directory ist noch nicht konfiguriert.
|
||||
</div>
|
||||
) : isLoadingPickerUsers ? (
|
||||
<div className="flex min-h-64 items-center justify-center">
|
||||
<LoadingSpinner
|
||||
size="md"
|
||||
label={
|
||||
userPickerSource === 'ad'
|
||||
? 'AD-Benutzer werden geladen...'
|
||||
: 'Basisbenutzer werden geladen...'
|
||||
}
|
||||
className="text-indigo-600 dark:text-indigo-400"
|
||||
/>
|
||||
</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.
|
||||
{group.unavailable ? (
|
||||
<div className="rounded-lg border border-dashed border-gray-200 px-4 py-6 text-center text-sm text-gray-500 dark:border-white/10 dark:text-gray-400">
|
||||
Active Directory ist noch nicht konfiguriert.
|
||||
</div>
|
||||
) : group.loading ? (
|
||||
<div className="flex min-h-28 items-center justify-center rounded-lg border border-dashed border-gray-200 dark:border-white/10">
|
||||
<LoadingSpinner
|
||||
size="sm"
|
||||
label={`${group.title} werden geladen...`}
|
||||
className="text-indigo-600 dark:text-indigo-400"
|
||||
/>
|
||||
</div>
|
||||
) : group.users.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-gray-200 px-4 py-6 text-center text-sm text-gray-500 dark:border-white/10 dark:text-gray-400">
|
||||
{group.emptyText}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-200 dark:divide-white/10">
|
||||
{filteredAssignableUsers.map((user) => (
|
||||
<ul className="divide-y divide-gray-200 overflow-hidden rounded-lg border border-gray-200 dark:divide-white/10 dark:border-white/10">
|
||||
{group.users.map((user) => (
|
||||
<li key={userSelectionKey(user)}>
|
||||
<label className="flex cursor-pointer items-start gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-white/5">
|
||||
<input
|
||||
@ -1151,9 +1180,9 @@ export default function MilestoneRolesPage({
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 border-t border-gray-200 px-6 py-4 dark:border-white/10">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user