updated
This commit is contained in:
parent
a95f8a15ac
commit
e5a88399e9
@ -3,6 +3,8 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
@ -57,6 +59,21 @@ type milestoneRoleUserInput struct {
|
|||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func writeMilestoneRoleActionError(w http.ResponseWriter, status int, message string, err error) {
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("%s: %v", message, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := map[string]string{
|
||||||
|
"error": message,
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
payload["details"] = err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, status, payload)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleAdminListMilestoneRoles(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAdminListMilestoneRoles(w http.ResponseWriter, r *http.Request) {
|
||||||
currentUser, _ := userFromContext(r.Context())
|
currentUser, _ := userFromContext(r.Context())
|
||||||
isAdministrator := userHasRight(currentUser, "admin")
|
isAdministrator := userHasRight(currentUser, "admin")
|
||||||
@ -241,26 +258,18 @@ func (s *Server) handleAdminUpdateMilestoneRole(w http.ResponseWriter, r *http.R
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
users, err := listOperationMilestoneRoleUsers(r.Context(), config, roleID)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusBadGateway, "Milestone-Rollenbenutzer konnten nicht geladen werden")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
role.Name = roleName
|
role.Name = roleName
|
||||||
role.DisplayName = strings.TrimSpace(input.DisplayName)
|
role.DisplayName = strings.TrimSpace(input.DisplayName)
|
||||||
if role.DisplayName == "" {
|
if role.DisplayName == "" {
|
||||||
role.DisplayName = roleName
|
role.DisplayName = roleName
|
||||||
}
|
}
|
||||||
if role.Data == nil {
|
|
||||||
role.Data = map[string]any{}
|
|
||||||
}
|
|
||||||
role.Data["name"] = roleName
|
|
||||||
role.Data["displayName"] = role.DisplayName
|
|
||||||
role.Data["description"] = strings.TrimSpace(input.Description)
|
|
||||||
|
|
||||||
if err := putOperationMilestoneRole(r.Context(), config, *role, users); err != nil {
|
if err := patchOperationMilestoneRole(r.Context(), config, role.ID, map[string]any{
|
||||||
writeError(w, http.StatusBadGateway, "Milestone-Rolle konnte nicht gespeichert werden")
|
"name": roleName,
|
||||||
|
"displayName": role.DisplayName,
|
||||||
|
"description": strings.TrimSpace(input.Description),
|
||||||
|
}); err != nil {
|
||||||
|
writeMilestoneRoleActionError(w, http.StatusBadGateway, "Milestone-Rolle konnte nicht gespeichert werden", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -269,7 +278,7 @@ func (s *Server) handleAdminUpdateMilestoneRole(w http.ResponseWriter, r *http.R
|
|||||||
writeError(w, http.StatusBadGateway, "Milestone-Rolle wurde gespeichert, konnte aber nicht geladen werden")
|
writeError(w, http.StatusBadGateway, "Milestone-Rolle wurde gespeichert, konnte aber nicht geladen werden")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
users, _ = listOperationMilestoneRoleUsers(r.Context(), config, roleID)
|
users, _ := listOperationMilestoneRoleUsers(r.Context(), config, roleID)
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"role": milestoneRoleResponseFromRole(*role, users),
|
"role": milestoneRoleResponseFromRole(*role, users),
|
||||||
@ -350,20 +359,25 @@ func (s *Server) handleAdminAddMilestoneRoleUsers(w http.ResponseWriter, r *http
|
|||||||
}
|
}
|
||||||
|
|
||||||
nextUsers := users
|
nextUsers := users
|
||||||
|
usersToAdd := []map[string]any{}
|
||||||
for _, userInput := range input.Users {
|
for _, userInput := range input.Users {
|
||||||
user := milestoneRoleUserReferenceFromInput(userInput)
|
user := milestoneRoleUserReferenceFromInput(userInput)
|
||||||
if milestoneItemID(user) == "" || milestoneRoleUserItemIsHidden(milestoneRoleUserItemFromObject(user)) {
|
if milestoneItemID(user) == "" || milestoneRoleUserItemIsHidden(milestoneRoleUserItemFromObject(user)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if operationMilestoneObjectByID(nextUsers, milestoneItemID(user)) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
nextUsers = appendOperationMilestoneObjectByID(nextUsers, user)
|
nextUsers = appendOperationMilestoneObjectByID(nextUsers, user)
|
||||||
|
usersToAdd = appendOperationMilestoneObjectByID(usersToAdd, user)
|
||||||
}
|
}
|
||||||
if len(nextUsers) == len(users) {
|
if len(usersToAdd) == 0 {
|
||||||
writeError(w, http.StatusBadRequest, "Kein gültiger AD-Benutzer ausgewählt")
|
writeError(w, http.StatusBadRequest, "Kein gültiger Benutzer ausgewählt")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := putOperationMilestoneRole(r.Context(), config, *role, nextUsers); err != nil {
|
if err := addOperationMilestoneRoleMembers(r.Context(), config, role.ID, usersToAdd); err != nil {
|
||||||
writeError(w, http.StatusBadGateway, "AD-Benutzer konnten der Milestone-Rolle nicht hinzugefügt werden")
|
writeMilestoneRoleActionError(w, http.StatusBadGateway, "Benutzer konnten der Milestone-Rolle nicht hinzugefügt werden", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -405,23 +419,25 @@ func (s *Server) handleAdminRemoveMilestoneRoleUser(w http.ResponseWriter, r *ht
|
|||||||
}
|
}
|
||||||
|
|
||||||
nextUsers := make([]map[string]any, 0, len(users))
|
nextUsers := make([]map[string]any, 0, len(users))
|
||||||
|
usersToRemove := []map[string]any{}
|
||||||
for _, user := range users {
|
for _, user := range users {
|
||||||
if strings.EqualFold(milestoneItemID(user), userID) {
|
if strings.EqualFold(milestoneItemID(user), userID) {
|
||||||
if milestoneRoleUserItemIsHidden(milestoneRoleUserItemFromObject(user)) {
|
if milestoneRoleUserItemIsHidden(milestoneRoleUserItemFromObject(user)) {
|
||||||
writeError(w, http.StatusForbidden, "Dieser Benutzer darf nicht entfernt werden")
|
writeError(w, http.StatusForbidden, "Dieser Benutzer darf nicht entfernt werden")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
usersToRemove = append(usersToRemove, user)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
nextUsers = append(nextUsers, user)
|
nextUsers = append(nextUsers, user)
|
||||||
}
|
}
|
||||||
if len(nextUsers) == len(users) {
|
if len(usersToRemove) == 0 {
|
||||||
writeError(w, http.StatusNotFound, "Benutzer wurde in der Rolle nicht gefunden")
|
writeError(w, http.StatusNotFound, "Benutzer wurde in der Rolle nicht gefunden")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := putOperationMilestoneRole(r.Context(), config, *role, nextUsers); err != nil {
|
if err := removeOperationMilestoneRoleMembers(r.Context(), config, role.ID, usersToRemove); err != nil {
|
||||||
writeError(w, http.StatusBadGateway, "Benutzer konnte nicht aus der Milestone-Rolle entfernt werden")
|
writeMilestoneRoleActionError(w, http.StatusBadGateway, "Benutzer konnte nicht aus der Milestone-Rolle entfernt werden", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -493,9 +509,7 @@ func (s *Server) handleAdminListMilestoneBasicUsers(w http.ResponseWriter, r *ht
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
user.Source = "basic"
|
user.Source = "basic"
|
||||||
if user.Domain == "" {
|
user.Domain = ""
|
||||||
user.Domain = "Basic"
|
|
||||||
}
|
|
||||||
users = append(users, user)
|
users = append(users, user)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -614,8 +628,12 @@ func milestoneRoleUserItemFromObject(user map[string]any) milestoneRoleUserItem
|
|||||||
if domain == "" && distinguishedName != "" {
|
if domain == "" && distinguishedName != "" {
|
||||||
domain = activeDirectoryDomainFromDN(distinguishedName)
|
domain = activeDirectoryDomainFromDN(distinguishedName)
|
||||||
}
|
}
|
||||||
if domain == "" && source == "basic" {
|
if milestoneRoleUserDomainIsBasic(domain) {
|
||||||
domain = "Basic"
|
source = "basic"
|
||||||
|
domain = ""
|
||||||
|
}
|
||||||
|
if source == "basic" {
|
||||||
|
domain = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
if displayName == "" {
|
if displayName == "" {
|
||||||
@ -659,6 +677,32 @@ func milestoneRoleUserReferenceFromInput(input milestoneRoleUserInput) map[strin
|
|||||||
source = "ad"
|
source = "ad"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if milestoneRoleUserDomainIsBasic(domain) {
|
||||||
|
source = "basic"
|
||||||
|
domain = ""
|
||||||
|
}
|
||||||
|
if splitDomain, splitUsername := splitMilestoneDomainUser(username); splitDomain != "" {
|
||||||
|
if domain == "" && source != "basic" {
|
||||||
|
domain = splitDomain
|
||||||
|
}
|
||||||
|
username = splitUsername
|
||||||
|
}
|
||||||
|
if splitDomain, splitName := splitMilestoneDomainUser(name); splitDomain != "" {
|
||||||
|
if domain == "" && source != "basic" {
|
||||||
|
domain = splitDomain
|
||||||
|
}
|
||||||
|
name = splitName
|
||||||
|
}
|
||||||
|
if splitDomain, splitDisplayName := splitMilestoneDomainUser(displayName); splitDomain != "" {
|
||||||
|
if domain == "" && source != "basic" {
|
||||||
|
domain = splitDomain
|
||||||
|
}
|
||||||
|
displayName = splitDisplayName
|
||||||
|
}
|
||||||
|
if source == "basic" {
|
||||||
|
domain = ""
|
||||||
|
}
|
||||||
|
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
userID = userSID
|
userID = userSID
|
||||||
}
|
}
|
||||||
@ -688,8 +732,6 @@ func milestoneRoleUserReferenceFromInput(input milestoneRoleUserInput) map[strin
|
|||||||
"userName": username,
|
"userName": username,
|
||||||
"username": username,
|
"username": username,
|
||||||
"email": email,
|
"email": email,
|
||||||
"domain": domain,
|
|
||||||
"source": source,
|
|
||||||
"relations": map[string]any{
|
"relations": map[string]any{
|
||||||
"self": map[string]any{
|
"self": map[string]any{
|
||||||
"type": milestoneRoleUserRelationType(source),
|
"type": milestoneRoleUserRelationType(source),
|
||||||
@ -697,6 +739,9 @@ func milestoneRoleUserReferenceFromInput(input milestoneRoleUserInput) map[strin
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
if domain != "" {
|
||||||
|
user["domain"] = domain
|
||||||
|
}
|
||||||
if userSID != "" {
|
if userSID != "" {
|
||||||
user["sid"] = userSID
|
user["sid"] = userSID
|
||||||
user["objectSid"] = userSID
|
user["objectSid"] = userSID
|
||||||
@ -708,7 +753,140 @@ func milestoneRoleUserReferenceFromInput(input milestoneRoleUserInput) map[strin
|
|||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func addOperationMilestoneRoleMembers(
|
||||||
|
ctx context.Context,
|
||||||
|
config milestoneRuntimeConfig,
|
||||||
|
roleID string,
|
||||||
|
users []map[string]any,
|
||||||
|
) error {
|
||||||
|
return postOperationMilestoneRoleMemberTasks(ctx, config, roleID, "AddRoleMember", users)
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeOperationMilestoneRoleMembers(
|
||||||
|
ctx context.Context,
|
||||||
|
config milestoneRuntimeConfig,
|
||||||
|
roleID string,
|
||||||
|
users []map[string]any,
|
||||||
|
) error {
|
||||||
|
return postOperationMilestoneRoleMemberTasks(ctx, config, roleID, "RemoveRoleMember", users)
|
||||||
|
}
|
||||||
|
|
||||||
|
func postOperationMilestoneRoleMemberTasks(
|
||||||
|
ctx context.Context,
|
||||||
|
config milestoneRuntimeConfig,
|
||||||
|
roleID string,
|
||||||
|
taskName string,
|
||||||
|
users []map[string]any,
|
||||||
|
) error {
|
||||||
|
roleID = strings.TrimSpace(roleID)
|
||||||
|
taskName = strings.TrimSpace(taskName)
|
||||||
|
if roleID == "" {
|
||||||
|
return errors.New("role id is missing")
|
||||||
|
}
|
||||||
|
if taskName == "" {
|
||||||
|
return errors.New("role member task name is missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, user := range users {
|
||||||
|
payload, err := milestoneRoleMemberTaskPayload(user)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(payload) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _, err := operationMilestoneRequestJSON(
|
||||||
|
ctx,
|
||||||
|
config,
|
||||||
|
http.MethodPost,
|
||||||
|
"/API/rest/v1/roles/"+url.PathEscape(roleID)+"/users?task="+url.QueryEscape(taskName),
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if result, ok := milestoneTaskResultFromBody(body); ok && milestoneTaskResultIsError(result) {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"milestone %s returned error state=%s errorCode=%s errorText=%s",
|
||||||
|
taskName,
|
||||||
|
result.State,
|
||||||
|
result.ErrorCode,
|
||||||
|
result.ErrorText,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func milestoneRoleMemberTaskItemSelection(user map[string]any) map[string]any {
|
||||||
|
userID := strings.TrimSpace(milestoneItemID(user))
|
||||||
|
if userID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
source := milestoneRoleUserSourceFromObject(user)
|
||||||
|
itemSelection := map[string]any{
|
||||||
|
"type": milestoneRoleUserRelationType(source),
|
||||||
|
"id": userID,
|
||||||
|
}
|
||||||
|
|
||||||
|
return itemSelection
|
||||||
|
}
|
||||||
|
|
||||||
|
func milestoneRoleMemberTaskPayload(user map[string]any) (map[string]any, error) {
|
||||||
|
itemSelection := milestoneRoleMemberTaskItemSelection(user)
|
||||||
|
userID := strings.TrimSpace(milestoneStringValue(itemSelection["id"]))
|
||||||
|
if userID == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := map[string]any{
|
||||||
|
"itemSelection": itemSelection,
|
||||||
|
}
|
||||||
|
|
||||||
|
if milestoneRoleUserSourceFromObject(user) != "basic" {
|
||||||
|
sid := strings.TrimSpace(milestoneStringValue(user["sid"]))
|
||||||
|
if sid == "" {
|
||||||
|
sid = strings.TrimSpace(milestoneStringValue(user["objectSid"]))
|
||||||
|
}
|
||||||
|
if sid == "" && strings.HasPrefix(strings.ToUpper(userID), "S-") {
|
||||||
|
sid = userID
|
||||||
|
}
|
||||||
|
if sid == "" {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"Windows-Benutzer %q hat keine SID. AddRoleMember erwartet den Parameter sid.",
|
||||||
|
operationMilestoneItemDisplayName(user),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload["sid"] = sid
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func operationMilestoneObjectByID(items []map[string]any, id string) map[string]any {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range items {
|
||||||
|
if strings.EqualFold(milestoneItemID(item), id) {
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func milestoneRoleUserSourceFromObject(user map[string]any) string {
|
func milestoneRoleUserSourceFromObject(user map[string]any) string {
|
||||||
|
if milestoneRoleUserDomainIsBasic(milestoneRoleUserDomainFromObject(user)) {
|
||||||
|
return "basic"
|
||||||
|
}
|
||||||
|
|
||||||
source := milestoneRoleUserSource(milestoneStringValue(user["source"]))
|
source := milestoneRoleUserSource(milestoneStringValue(user["source"]))
|
||||||
if source != "" {
|
if source != "" {
|
||||||
return source
|
return source
|
||||||
@ -733,6 +911,13 @@ func milestoneRoleUserSourceFromObject(user map[string]any) string {
|
|||||||
return "ad"
|
return "ad"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func milestoneRoleUserDomainIsBasic(domain string) bool {
|
||||||
|
domain = strings.TrimSpace(domain)
|
||||||
|
domain = strings.TrimPrefix(domain, "[")
|
||||||
|
domain = strings.TrimSuffix(domain, "]")
|
||||||
|
return strings.EqualFold(domain, "basic")
|
||||||
|
}
|
||||||
|
|
||||||
func milestoneRoleUserSource(source string) string {
|
func milestoneRoleUserSource(source string) string {
|
||||||
source = strings.TrimSpace(strings.ToLower(source))
|
source = strings.TrimSpace(strings.ToLower(source))
|
||||||
switch source {
|
switch source {
|
||||||
@ -802,6 +987,7 @@ func milestoneProtectedUserNameIsHidden(value string) bool {
|
|||||||
switch value {
|
switch value {
|
||||||
case "nt-autorität\\netzwerkdienst",
|
case "nt-autorität\\netzwerkdienst",
|
||||||
"nt authority\\network service",
|
"nt authority\\network service",
|
||||||
|
"tegadmin",
|
||||||
"tegdssd\\tegadmin",
|
"tegdssd\\tegadmin",
|
||||||
"vordefiniert\\administratoren",
|
"vordefiniert\\administratoren",
|
||||||
"builtin\\administrators",
|
"builtin\\administrators",
|
||||||
|
|||||||
775
backend/calendar_events.go
Normal file
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 {
|
if selectedUser := operationMilestoneUserFromSetupInput(input); selectedUser != nil {
|
||||||
roleUsers := appendOperationMilestoneObjectByID(operationMilestoneObjectListFromValue(role.Data["users"]), selectedUser)
|
roleUsers, listErr := listOperationMilestoneRoleUsers(ctx, config, role.ID)
|
||||||
if err := putOperationMilestoneRole(ctx, config, *role, roleUsers); err != nil {
|
if listErr != nil {
|
||||||
return nil, err
|
roleUsers = operationMilestoneObjectListFromValue(role.Data["users"])
|
||||||
|
}
|
||||||
|
|
||||||
|
if operationMilestoneObjectByID(roleUsers, milestoneItemID(selectedUser)) == nil {
|
||||||
|
if err := addOperationMilestoneRoleMembers(ctx, config, role.ID, []map[string]any{selectedUser}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
roleUsers = appendOperationMilestoneObjectByID(roleUsers, selectedUser)
|
||||||
}
|
}
|
||||||
role.Data["users"] = roleUsers
|
role.Data["users"] = roleUsers
|
||||||
}
|
}
|
||||||
@ -892,21 +899,28 @@ func (s *Server) ensureOperationMilestoneRoleUser(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
roleUsers := operationMilestoneObjectListFromValue(role.Data["users"])
|
roleUsers := sourceUsers
|
||||||
if len(roleUsers) == 0 && !hasOperationMilestoneUserSelection(input) {
|
if len(roleUsers) == 0 {
|
||||||
roleUsers = appendOperationMilestoneObjectsByID(roleUsers, sourceUsers...)
|
roleUsers = operationMilestoneObjectListFromValue(role.Data["users"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
usersToAdd := []map[string]any{}
|
||||||
for _, targetUser := range usersToAssign {
|
for _, targetUser := range usersToAssign {
|
||||||
if milestoneItemID(targetUser) == "" {
|
if milestoneItemID(targetUser) == "" {
|
||||||
return nil, fmt.Errorf("AD-Benutzer %q hat keine Milestone-ID", targetUserName)
|
return nil, fmt.Errorf("AD-Benutzer %q hat keine Milestone-ID", targetUserName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if operationMilestoneObjectByID(roleUsers, milestoneItemID(targetUser)) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
usersToAdd = appendOperationMilestoneObjectByID(usersToAdd, targetUser)
|
||||||
roleUsers = appendOperationMilestoneObjectByID(roleUsers, targetUser)
|
roleUsers = appendOperationMilestoneObjectByID(roleUsers, targetUser)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := putOperationMilestoneRole(ctx, config, *role, roleUsers); err != nil {
|
if len(usersToAdd) > 0 {
|
||||||
return nil, err
|
if err := addOperationMilestoneRoleMembers(ctx, config, role.ID, usersToAdd); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
role.Data["users"] = roleUsers
|
role.Data["users"] = roleUsers
|
||||||
|
|||||||
@ -113,6 +113,12 @@ func (s *Server) Routes() http.Handler {
|
|||||||
mux.HandleFunc("PUT /settings/presence", s.requireAuth(s.handleUpdatePresenceSettings))
|
mux.HandleFunc("PUT /settings/presence", s.requireAuth(s.handleUpdatePresenceSettings))
|
||||||
mux.HandleFunc("PUT /settings/presence/status", s.requireAuth(s.handleUpdatePresenceStatus))
|
mux.HandleFunc("PUT /settings/presence/status", s.requireAuth(s.handleUpdatePresenceStatus))
|
||||||
mux.HandleFunc("GET /calendar/settings", s.requireAuth(s.requireRight("calendar:read", s.handleGetCalendarSettings)))
|
mux.HandleFunc("GET /calendar/settings", s.requireAuth(s.requireRight("calendar:read", s.handleGetCalendarSettings)))
|
||||||
|
mux.HandleFunc("GET /calendar/events", s.requireAuth(s.requireRight("calendar:read", s.handleListCalendarEvents)))
|
||||||
|
mux.HandleFunc("POST /calendar/events", s.requireAuth(s.requireRight("calendar:read", s.handleCreateCalendarEvent)))
|
||||||
|
mux.HandleFunc("PUT /calendar/events/{id}", s.requireAuth(s.requireRight("calendar:read", s.handleUpdateCalendarEvent)))
|
||||||
|
mux.HandleFunc("POST /calendar/events/{id}/occurrences", s.requireAuth(s.requireRight("calendar:read", s.handleUpdateCalendarEventOccurrence)))
|
||||||
|
mux.HandleFunc("POST /calendar/events/{id}/occurrences/delete", s.requireAuth(s.requireRight("calendar:read", s.handleDeleteCalendarEventOccurrence)))
|
||||||
|
mux.HandleFunc("DELETE /calendar/events/{id}", s.requireAuth(s.requireRight("calendar:read", s.handleDeleteCalendarEvent)))
|
||||||
|
|
||||||
mux.HandleFunc("GET /chat/conversations", s.requireAuth(s.handleListChatConversations))
|
mux.HandleFunc("GET /chat/conversations", s.requireAuth(s.handleListChatConversations))
|
||||||
mux.HandleFunc("GET /chat/search", s.requireAuth(s.handleSearchChatMessages))
|
mux.HandleFunc("GET /chat/search", s.requireAuth(s.handleSearchChatMessages))
|
||||||
|
|||||||
@ -53,6 +53,51 @@ func ensureRuntimeDeviceSchema(ctx context.Context, db *pgxpool.Pool) error {
|
|||||||
`ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS active_directory_bind_password_encrypted BYTEA`,
|
`ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS active_directory_bind_password_encrypted BYTEA`,
|
||||||
`ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS active_directory_user_group TEXT NOT NULL DEFAULT 'SE_Sachbearbeitung'`,
|
`ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS active_directory_user_group TEXT NOT NULL DEFAULT 'SE_Sachbearbeitung'`,
|
||||||
`ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS active_directory_skip_tls_verify BOOLEAN NOT NULL DEFAULT false`,
|
`ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS active_directory_skip_tls_verify BOOLEAN NOT NULL DEFAULT false`,
|
||||||
|
`
|
||||||
|
CREATE TABLE IF NOT EXISTS calendar_events (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
start_at TIMESTAMP NOT NULL,
|
||||||
|
end_at TIMESTAMP NOT NULL,
|
||||||
|
all_day BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
location TEXT NOT NULL DEFAULT '',
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
color TEXT NOT NULL DEFAULT 'indigo'
|
||||||
|
CHECK (color IN ('indigo', 'blue', 'emerald', 'amber', 'rose', 'slate')),
|
||||||
|
recurrence TEXT NOT NULL DEFAULT 'none',
|
||||||
|
recurrence_weekday INTEGER,
|
||||||
|
recurrence_ordinal INTEGER,
|
||||||
|
recurrence_exceptions JSONB NOT NULL DEFAULT '[]'::JSONB,
|
||||||
|
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
updated_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT calendar_events_valid_range CHECK (end_at > start_at)
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
`
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
constraint_name TEXT;
|
||||||
|
BEGIN
|
||||||
|
FOR constraint_name IN
|
||||||
|
SELECT c.conname
|
||||||
|
FROM pg_constraint c
|
||||||
|
JOIN pg_class t ON t.oid = c.conrelid
|
||||||
|
WHERE t.relname = 'calendar_events'
|
||||||
|
AND c.contype = 'c'
|
||||||
|
AND pg_get_constraintdef(c.oid) ILIKE '%recurrence%'
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('ALTER TABLE calendar_events DROP CONSTRAINT %I', constraint_name);
|
||||||
|
END LOOP;
|
||||||
|
END
|
||||||
|
$$
|
||||||
|
`,
|
||||||
|
`ALTER TABLE IF EXISTS calendar_events ADD COLUMN IF NOT EXISTS recurrence_weekday INTEGER`,
|
||||||
|
`ALTER TABLE IF EXISTS calendar_events ADD COLUMN IF NOT EXISTS recurrence_ordinal INTEGER`,
|
||||||
|
`ALTER TABLE IF EXISTS calendar_events ADD COLUMN IF NOT EXISTS recurrence_exceptions JSONB NOT NULL DEFAULT '[]'::JSONB`,
|
||||||
|
`UPDATE calendar_events SET recurrence_exceptions = '[]'::JSONB WHERE recurrence_exceptions IS NULL`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_calendar_events_start_at ON calendar_events(start_at)`,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, query := range queries {
|
for _, query := range queries {
|
||||||
|
|||||||
@ -407,6 +407,52 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
|||||||
ON CONFLICT (id) DO NOTHING
|
ON CONFLICT (id) DO NOTHING
|
||||||
`,
|
`,
|
||||||
|
|
||||||
|
`
|
||||||
|
CREATE TABLE IF NOT EXISTS calendar_events (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
start_at TIMESTAMP NOT NULL,
|
||||||
|
end_at TIMESTAMP NOT NULL,
|
||||||
|
all_day BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
location TEXT NOT NULL DEFAULT '',
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
color TEXT NOT NULL DEFAULT 'indigo'
|
||||||
|
CHECK (color IN ('indigo', 'blue', 'emerald', 'amber', 'rose', 'slate')),
|
||||||
|
recurrence TEXT NOT NULL DEFAULT 'none',
|
||||||
|
recurrence_weekday INTEGER,
|
||||||
|
recurrence_ordinal INTEGER,
|
||||||
|
recurrence_exceptions JSONB NOT NULL DEFAULT '[]'::JSONB,
|
||||||
|
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
updated_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT calendar_events_valid_range CHECK (end_at > start_at)
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
`
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
constraint_name TEXT;
|
||||||
|
BEGIN
|
||||||
|
FOR constraint_name IN
|
||||||
|
SELECT c.conname
|
||||||
|
FROM pg_constraint c
|
||||||
|
JOIN pg_class t ON t.oid = c.conrelid
|
||||||
|
WHERE t.relname = 'calendar_events'
|
||||||
|
AND c.contype = 'c'
|
||||||
|
AND pg_get_constraintdef(c.oid) ILIKE '%recurrence%'
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('ALTER TABLE calendar_events DROP CONSTRAINT %I', constraint_name);
|
||||||
|
END LOOP;
|
||||||
|
END
|
||||||
|
$$
|
||||||
|
`,
|
||||||
|
`ALTER TABLE IF EXISTS calendar_events ADD COLUMN IF NOT EXISTS recurrence_weekday INTEGER`,
|
||||||
|
`ALTER TABLE IF EXISTS calendar_events ADD COLUMN IF NOT EXISTS recurrence_ordinal INTEGER`,
|
||||||
|
`ALTER TABLE IF EXISTS calendar_events ADD COLUMN IF NOT EXISTS recurrence_exceptions JSONB NOT NULL DEFAULT '[]'::JSONB`,
|
||||||
|
`UPDATE calendar_events SET recurrence_exceptions = '[]'::JSONB WHERE recurrence_exceptions IS NULL`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_calendar_events_start_at ON calendar_events(start_at)`,
|
||||||
|
|
||||||
`
|
`
|
||||||
CREATE TABLE IF NOT EXISTS devices (
|
CREATE TABLE IF NOT EXISTS devices (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|||||||
@ -22,6 +22,13 @@ export type CalendarEventColor =
|
|||||||
| 'amber'
|
| 'amber'
|
||||||
| 'rose'
|
| 'rose'
|
||||||
| 'slate'
|
| 'slate'
|
||||||
|
export type CalendarEventRecurrence =
|
||||||
|
| 'none'
|
||||||
|
| 'daily'
|
||||||
|
| 'weekly'
|
||||||
|
| 'monthly'
|
||||||
|
| 'monthlyNthWeekday'
|
||||||
|
| 'yearly'
|
||||||
|
|
||||||
export type CalendarEvent = {
|
export type CalendarEvent = {
|
||||||
id: string
|
id: string
|
||||||
@ -32,8 +39,17 @@ export type CalendarEvent = {
|
|||||||
location: string
|
location: string
|
||||||
description: string
|
description: string
|
||||||
color: CalendarEventColor
|
color: CalendarEventColor
|
||||||
|
recurrence?: CalendarEventRecurrence
|
||||||
|
recurrenceWeekday?: number | null
|
||||||
|
recurrenceOrdinal?: number | null
|
||||||
|
recurrenceExceptions?: string[]
|
||||||
|
occurrenceStart?: string
|
||||||
|
occurrenceEnd?: string
|
||||||
|
occurrenceKey?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CalendarEventOccurrence = CalendarEvent
|
||||||
|
|
||||||
type CalendarProps = {
|
type CalendarProps = {
|
||||||
events: CalendarEvent[]
|
events: CalendarEvent[]
|
||||||
coreWorkingHours: CalendarCoreWorkingHours
|
coreWorkingHours: CalendarCoreWorkingHours
|
||||||
@ -124,6 +140,101 @@ function addYears(date: Date, amount: number) {
|
|||||||
return new Date(date.getFullYear() + amount, date.getMonth(), date.getDate())
|
return new Date(date.getFullYear() + amount, date.getMonth(), date.getDate())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function daysInMonth(year: number, month: number) {
|
||||||
|
return new Date(year, month + 1, 0).getDate()
|
||||||
|
}
|
||||||
|
|
||||||
|
function weekdayOrdinalInMonth(date: Date) {
|
||||||
|
return Math.floor((date.getDate() - 1) / 7) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function nthWeekdayInMonth(
|
||||||
|
year: number,
|
||||||
|
month: number,
|
||||||
|
weekday: number,
|
||||||
|
ordinal: number,
|
||||||
|
template: Date,
|
||||||
|
) {
|
||||||
|
const firstOfMonth = new Date(
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
1,
|
||||||
|
template.getHours(),
|
||||||
|
template.getMinutes(),
|
||||||
|
template.getSeconds(),
|
||||||
|
template.getMilliseconds(),
|
||||||
|
)
|
||||||
|
const firstOffset = (weekday - firstOfMonth.getDay() + 7) % 7
|
||||||
|
const day = 1 + firstOffset + (ordinal - 1) * 7
|
||||||
|
|
||||||
|
if (day > daysInMonth(year, month)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
day,
|
||||||
|
template.getHours(),
|
||||||
|
template.getMinutes(),
|
||||||
|
template.getSeconds(),
|
||||||
|
template.getMilliseconds(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPeriodClamped(
|
||||||
|
date: Date,
|
||||||
|
recurrence: CalendarEventRecurrence,
|
||||||
|
amount: number,
|
||||||
|
event?: CalendarEvent,
|
||||||
|
) {
|
||||||
|
if (recurrence === 'daily') {
|
||||||
|
const next = new Date(date)
|
||||||
|
next.setDate(next.getDate() + amount)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recurrence === 'weekly') {
|
||||||
|
const next = new Date(date)
|
||||||
|
next.setDate(next.getDate() + amount * 7)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recurrence === 'monthly') {
|
||||||
|
const next = new Date(date)
|
||||||
|
const day = date.getDate()
|
||||||
|
next.setDate(1)
|
||||||
|
next.setMonth(next.getMonth() + amount)
|
||||||
|
next.setDate(Math.min(day, daysInMonth(next.getFullYear(), next.getMonth())))
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recurrence === 'monthlyNthWeekday') {
|
||||||
|
const next = new Date(date)
|
||||||
|
next.setDate(1)
|
||||||
|
next.setMonth(next.getMonth() + amount)
|
||||||
|
|
||||||
|
return nthWeekdayInMonth(
|
||||||
|
next.getFullYear(),
|
||||||
|
next.getMonth(),
|
||||||
|
event?.recurrenceWeekday ?? date.getDay(),
|
||||||
|
event?.recurrenceOrdinal ?? weekdayOrdinalInMonth(date),
|
||||||
|
date,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recurrence === 'yearly') {
|
||||||
|
const next = new Date(date)
|
||||||
|
const day = date.getDate()
|
||||||
|
next.setDate(1)
|
||||||
|
next.setFullYear(next.getFullYear() + amount)
|
||||||
|
next.setDate(Math.min(day, daysInMonth(next.getFullYear(), next.getMonth())))
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(date)
|
||||||
|
}
|
||||||
|
|
||||||
function startOfWeek(date: Date) {
|
function startOfWeek(date: Date) {
|
||||||
const normalized = startOfDay(date)
|
const normalized = startOfDay(date)
|
||||||
const mondayOffset = (normalized.getDay() + 6) % 7
|
const mondayOffset = (normalized.getDay() + 6) % 7
|
||||||
@ -138,20 +249,168 @@ function isToday(date: Date) {
|
|||||||
return isSameDay(date, new Date())
|
return isSameDay(date, new Date())
|
||||||
}
|
}
|
||||||
|
|
||||||
function eventsForDay(events: CalendarEvent[], date: Date) {
|
function toLocalDateTimeValue(date: Date) {
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0')
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||||
|
return `${toDateKey(date)}T${hours}:${minutes}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventStartValue(event: CalendarEventOccurrence) {
|
||||||
|
return event.occurrenceStart ?? event.start
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventEndValue(event: CalendarEventOccurrence) {
|
||||||
|
return event.occurrenceEnd ?? event.end
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventKey(event: CalendarEventOccurrence) {
|
||||||
|
return event.occurrenceKey ?? event.id
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEventRecurrence(
|
||||||
|
recurrence: CalendarEvent['recurrence'],
|
||||||
|
): CalendarEventRecurrence {
|
||||||
|
return recurrence === 'daily' ||
|
||||||
|
recurrence === 'weekly' ||
|
||||||
|
recurrence === 'monthly' ||
|
||||||
|
recurrence === 'monthlyNthWeekday' ||
|
||||||
|
recurrence === 'yearly'
|
||||||
|
? recurrence
|
||||||
|
: 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
function recurrenceBaseIndex(
|
||||||
|
eventStart: Date,
|
||||||
|
dayStart: Date,
|
||||||
|
recurrence: CalendarEventRecurrence,
|
||||||
|
) {
|
||||||
|
const eventDay = startOfDay(eventStart)
|
||||||
|
const dayDistance =
|
||||||
|
(dayStart.getTime() - eventDay.getTime()) / (24 * 60 * 60 * 1000)
|
||||||
|
|
||||||
|
if (recurrence === 'daily') {
|
||||||
|
return Math.floor(dayDistance)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recurrence === 'weekly') {
|
||||||
|
return Math.floor(dayDistance / 7)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recurrence === 'monthly' || recurrence === 'monthlyNthWeekday') {
|
||||||
|
return (
|
||||||
|
(dayStart.getFullYear() - eventStart.getFullYear()) * 12 +
|
||||||
|
dayStart.getMonth() -
|
||||||
|
eventStart.getMonth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recurrence === 'yearly') {
|
||||||
|
return dayStart.getFullYear() - eventStart.getFullYear()
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function periodApproximationDays(recurrence: CalendarEventRecurrence) {
|
||||||
|
if (recurrence === 'weekly') {
|
||||||
|
return 7
|
||||||
|
}
|
||||||
|
if (recurrence === 'monthly' || recurrence === 'monthlyNthWeekday') {
|
||||||
|
return 28
|
||||||
|
}
|
||||||
|
if (recurrence === 'yearly') {
|
||||||
|
return 365
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function occurrencesForDay(
|
||||||
|
event: CalendarEvent,
|
||||||
|
date: Date,
|
||||||
|
): CalendarEventOccurrence[] {
|
||||||
const dayStart = startOfDay(date)
|
const dayStart = startOfDay(date)
|
||||||
const dayEnd = addDays(dayStart, 1)
|
const dayEnd = addDays(dayStart, 1)
|
||||||
|
const eventStart = new Date(event.start)
|
||||||
|
const eventEnd = new Date(event.end)
|
||||||
|
|
||||||
|
if (Number.isNaN(eventStart.getTime()) || Number.isNaN(eventEnd.getTime())) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const recurrence = normalizeEventRecurrence(event.recurrence)
|
||||||
|
const durationMs = eventEnd.getTime() - eventStart.getTime()
|
||||||
|
|
||||||
|
if (durationMs <= 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recurrence === 'none') {
|
||||||
|
return eventStart < dayEnd && eventEnd > dayStart ? [event] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseIndex = recurrenceBaseIndex(eventStart, dayStart, recurrence)
|
||||||
|
const durationDays = Math.max(
|
||||||
|
1,
|
||||||
|
Math.ceil(durationMs / (24 * 60 * 60 * 1000)),
|
||||||
|
)
|
||||||
|
const radius = Math.min(
|
||||||
|
400,
|
||||||
|
Math.ceil(durationDays / periodApproximationDays(recurrence)) + 2,
|
||||||
|
)
|
||||||
|
const occurrences: CalendarEventOccurrence[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const excludedStarts = new Set(event.recurrenceExceptions ?? [])
|
||||||
|
|
||||||
|
for (let offset = -radius; offset <= 2; offset += 1) {
|
||||||
|
const index = baseIndex + offset
|
||||||
|
if (index < 0) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const occurrenceStart = addPeriodClamped(eventStart, recurrence, index, event)
|
||||||
|
if (!occurrenceStart) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const occurrenceEnd = new Date(occurrenceStart.getTime() + durationMs)
|
||||||
|
const occurrenceStartValue = toLocalDateTimeValue(occurrenceStart)
|
||||||
|
|
||||||
|
if (
|
||||||
|
occurrenceStart < eventStart &&
|
||||||
|
occurrenceEnd.getTime() <= eventStart.getTime()
|
||||||
|
) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (excludedStarts.has(occurrenceStartValue)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (occurrenceStart < dayEnd && occurrenceEnd > dayStart) {
|
||||||
|
const key = `${event.id}:${recurrence}:${index}`
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key)
|
||||||
|
occurrences.push({
|
||||||
|
...event,
|
||||||
|
occurrenceStart: occurrenceStartValue,
|
||||||
|
occurrenceEnd: toLocalDateTimeValue(occurrenceEnd),
|
||||||
|
occurrenceKey: key,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return occurrences
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventsForDay(events: CalendarEvent[], date: Date) {
|
||||||
return events
|
return events
|
||||||
.filter((event) => {
|
.flatMap((event) => occurrencesForDay(event, date))
|
||||||
const eventStart = new Date(event.start)
|
|
||||||
const eventEnd = new Date(event.end)
|
|
||||||
return eventStart < dayEnd && eventEnd > dayStart
|
|
||||||
})
|
|
||||||
.sort(
|
.sort(
|
||||||
(left, right) =>
|
(left, right) =>
|
||||||
Number(right.allDay) - Number(left.allDay) ||
|
Number(right.allDay) - Number(left.allDay) ||
|
||||||
new Date(left.start).getTime() - new Date(right.start).getTime(),
|
new Date(eventStartValue(left)).getTime() -
|
||||||
|
new Date(eventStartValue(right)).getTime(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -508,7 +767,7 @@ function MonthView({
|
|||||||
<div className="mt-1 hidden space-y-1 sm:block">
|
<div className="mt-1 hidden space-y-1 sm:block">
|
||||||
{dayEvents.slice(0, 3).map((event) => (
|
{dayEvents.slice(0, 3).map((event) => (
|
||||||
<button
|
<button
|
||||||
key={event.id}
|
key={eventKey(event)}
|
||||||
type="button"
|
type="button"
|
||||||
data-calendar-event
|
data-calendar-event
|
||||||
onPointerDown={(pointerEvent) =>
|
onPointerDown={(pointerEvent) =>
|
||||||
@ -531,7 +790,7 @@ function MonthView({
|
|||||||
<span className="truncate">{event.title}</span>
|
<span className="truncate">{event.title}</span>
|
||||||
{!event.allDay && (
|
{!event.allDay && (
|
||||||
<span className="ml-auto hidden shrink-0 xl:inline">
|
<span className="ml-auto hidden shrink-0 xl:inline">
|
||||||
{formatTime(event.start)}
|
{formatTime(eventStartValue(event))}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@ -552,7 +811,7 @@ function MonthView({
|
|||||||
<div className="mt-auto flex flex-wrap justify-end gap-1 pt-2 sm:hidden">
|
<div className="mt-auto flex flex-wrap justify-end gap-1 pt-2 sm:hidden">
|
||||||
{dayEvents.slice(0, 4).map((event) => (
|
{dayEvents.slice(0, 4).map((event) => (
|
||||||
<span
|
<span
|
||||||
key={event.id}
|
key={eventKey(event)}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'size-1.5 rounded-full',
|
'size-1.5 rounded-full',
|
||||||
eventColors[event.color].dot,
|
eventColors[event.color].dot,
|
||||||
@ -590,7 +849,7 @@ function MonthView({
|
|||||||
) : (
|
) : (
|
||||||
<ol className="mt-3 space-y-2">
|
<ol className="mt-3 space-y-2">
|
||||||
{selectedEvents.map((event) => (
|
{selectedEvents.map((event) => (
|
||||||
<li key={event.id}>
|
<li key={eventKey(event)}>
|
||||||
<EventListButton event={event} onClick={onEditEvent} />
|
<EventListButton event={event} onClick={onEditEvent} />
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@ -605,7 +864,7 @@ function EventListButton({
|
|||||||
event,
|
event,
|
||||||
onClick,
|
onClick,
|
||||||
}: {
|
}: {
|
||||||
event: CalendarEvent
|
event: CalendarEventOccurrence
|
||||||
onClick: (event: CalendarEvent) => void
|
onClick: (event: CalendarEvent) => void
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@ -632,7 +891,9 @@ function EventListButton({
|
|||||||
<ClockIcon className="size-3.5" />
|
<ClockIcon className="size-3.5" />
|
||||||
{event.allDay
|
{event.allDay
|
||||||
? 'Ganztägig'
|
? 'Ganztägig'
|
||||||
: `${formatTime(event.start)} - ${formatTime(event.end)}`}
|
: `${formatTime(eventStartValue(event))} - ${formatTime(
|
||||||
|
eventEndValue(event),
|
||||||
|
)}`}
|
||||||
</span>
|
</span>
|
||||||
{event.location && (
|
{event.location && (
|
||||||
<span className="inline-flex items-center gap-1 truncate">
|
<span className="inline-flex items-center gap-1 truncate">
|
||||||
@ -864,7 +1125,7 @@ function TimeGridView({
|
|||||||
.filter((event) => event.allDay)
|
.filter((event) => event.allDay)
|
||||||
.map((event) => (
|
.map((event) => (
|
||||||
<button
|
<button
|
||||||
key={event.id}
|
key={eventKey(event)}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onEditEvent(event)}
|
onClick={() => onEditEvent(event)}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
@ -1025,8 +1286,8 @@ function TimeGridView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{timedEvents.map((event) => {
|
{timedEvents.map((event) => {
|
||||||
const eventStart = new Date(event.start)
|
const eventStart = new Date(eventStartValue(event))
|
||||||
const eventEnd = new Date(event.end)
|
const eventEnd = new Date(eventEndValue(event))
|
||||||
const segmentStart = new Date(
|
const segmentStart = new Date(
|
||||||
Math.max(eventStart.getTime(), dayStart.getTime()),
|
Math.max(eventStart.getTime(), dayStart.getTime()),
|
||||||
)
|
)
|
||||||
@ -1042,7 +1303,7 @@ function TimeGridView({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={event.id}
|
key={eventKey(event)}
|
||||||
data-calendar-event
|
data-calendar-event
|
||||||
type="button"
|
type="button"
|
||||||
onPointerDown={(pointerEvent) =>
|
onPointerDown={(pointerEvent) =>
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import Modal, { ModalTitle } from '../../components/Modal'
|
|||||||
import Calendar, {
|
import Calendar, {
|
||||||
type CalendarEvent,
|
type CalendarEvent,
|
||||||
type CalendarEventColor,
|
type CalendarEventColor,
|
||||||
|
type CalendarEventRecurrence,
|
||||||
type CalendarView,
|
type CalendarView,
|
||||||
} from '../../components/calendar/Calendar'
|
} from '../../components/calendar/Calendar'
|
||||||
import {
|
import {
|
||||||
@ -13,7 +14,6 @@ import {
|
|||||||
} from '../../components/calendar/calendarSettings'
|
} from '../../components/calendar/calendarSettings'
|
||||||
import { toDateKey } from '../../components/calendar/dateUtils'
|
import { toDateKey } from '../../components/calendar/dateUtils'
|
||||||
|
|
||||||
const STORAGE_KEY = 'teg.calendar.events.v1'
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||||
|
|
||||||
type EventDraft = {
|
type EventDraft = {
|
||||||
@ -26,8 +26,29 @@ type EventDraft = {
|
|||||||
location: string
|
location: string
|
||||||
description: string
|
description: string
|
||||||
color: CalendarEventColor
|
color: CalendarEventColor
|
||||||
|
recurrence: CalendarEventRecurrence
|
||||||
|
recurrenceWeekday: number
|
||||||
|
recurrenceOrdinal: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type EventPayload = {
|
||||||
|
title: string
|
||||||
|
start: string
|
||||||
|
end: string
|
||||||
|
allDay: boolean
|
||||||
|
location: string
|
||||||
|
description: string
|
||||||
|
color: CalendarEventColor
|
||||||
|
recurrence: CalendarEventRecurrence
|
||||||
|
recurrenceWeekday: number | null
|
||||||
|
recurrenceOrdinal: number | null
|
||||||
|
recurrenceExceptions: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type PendingSeriesAction =
|
||||||
|
| { type: 'save'; payload: EventPayload }
|
||||||
|
| { type: 'delete' }
|
||||||
|
|
||||||
const colorOptions: Array<{
|
const colorOptions: Array<{
|
||||||
value: CalendarEventColor
|
value: CalendarEventColor
|
||||||
label: string
|
label: string
|
||||||
@ -41,87 +62,80 @@ const colorOptions: Array<{
|
|||||||
{ value: 'slate', label: 'Grau', className: 'bg-slate-500' },
|
{ value: 'slate', label: 'Grau', className: 'bg-slate-500' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const recurrenceOptions: Array<{
|
||||||
|
value: CalendarEventRecurrence
|
||||||
|
label: string
|
||||||
|
}> = [
|
||||||
|
{ value: 'none', label: 'Keine Wiederholung' },
|
||||||
|
{ value: 'daily', label: 'Täglich' },
|
||||||
|
{ value: 'weekly', label: 'Wöchentlich' },
|
||||||
|
{ value: 'monthly', label: 'Monatlich' },
|
||||||
|
{ value: 'monthlyNthWeekday', label: 'Bestimmter Wochentag im Monat' },
|
||||||
|
{ value: 'yearly', label: 'Jährlich' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const recurrenceOrdinalOptions = [
|
||||||
|
{ value: 1, label: '1.' },
|
||||||
|
{ value: 2, label: '2.' },
|
||||||
|
{ value: 3, label: '3.' },
|
||||||
|
{ value: 4, label: '4.' },
|
||||||
|
{ value: 5, label: '5.' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const recurrenceWeekdayOptions = [
|
||||||
|
{ value: 1, label: 'Montag' },
|
||||||
|
{ value: 2, label: 'Dienstag' },
|
||||||
|
{ value: 3, label: 'Mittwoch' },
|
||||||
|
{ value: 4, label: 'Donnerstag' },
|
||||||
|
{ value: 5, label: 'Freitag' },
|
||||||
|
{ value: 6, label: 'Samstag' },
|
||||||
|
{ value: 0, label: 'Sonntag' },
|
||||||
|
]
|
||||||
|
|
||||||
function classNames(...classes: Array<string | false | null | undefined>) {
|
function classNames(...classes: Array<string | false | null | undefined>) {
|
||||||
return classes.filter(Boolean).join(' ')
|
return classes.filter(Boolean).join(' ')
|
||||||
}
|
}
|
||||||
|
|
||||||
function dateAt(date: Date, hour: number, minute = 0) {
|
|
||||||
return new Date(
|
|
||||||
date.getFullYear(),
|
|
||||||
date.getMonth(),
|
|
||||||
date.getDate(),
|
|
||||||
hour,
|
|
||||||
minute,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function toLocalDateTime(date: Date) {
|
|
||||||
const hours = String(date.getHours()).padStart(2, '0')
|
|
||||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
|
||||||
return `${toDateKey(date)}T${hours}:${minutes}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function createSeedEvents() {
|
|
||||||
const today = new Date()
|
|
||||||
const tomorrow = new Date(today)
|
|
||||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
|
||||||
const later = new Date(today)
|
|
||||||
later.setDate(later.getDate() + 3)
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: 'welcome-team',
|
|
||||||
title: 'Teambesprechung',
|
|
||||||
start: toLocalDateTime(dateAt(today, 10)),
|
|
||||||
end: toLocalDateTime(dateAt(today, 11)),
|
|
||||||
allDay: false,
|
|
||||||
location: 'Besprechungsraum 1',
|
|
||||||
description: 'Kurze Abstimmung zu den aktuellen Aufgaben.',
|
|
||||||
color: 'indigo',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'welcome-planning',
|
|
||||||
title: 'Einsatzplanung',
|
|
||||||
start: toLocalDateTime(dateAt(tomorrow, 14)),
|
|
||||||
end: toLocalDateTime(dateAt(tomorrow, 15, 30)),
|
|
||||||
allDay: false,
|
|
||||||
location: 'Leitstelle',
|
|
||||||
description: '',
|
|
||||||
color: 'blue',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'welcome-maintenance',
|
|
||||||
title: 'Wartungsfenster',
|
|
||||||
start: toLocalDateTime(dateAt(later, 0)),
|
|
||||||
end: toLocalDateTime(dateAt(later, 23, 59)),
|
|
||||||
allDay: true,
|
|
||||||
location: '',
|
|
||||||
description: 'Geplante Wartung der Infrastruktur.',
|
|
||||||
color: 'amber',
|
|
||||||
},
|
|
||||||
] satisfies CalendarEvent[]
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadEvents() {
|
|
||||||
try {
|
|
||||||
const stored = window.localStorage.getItem(STORAGE_KEY)
|
|
||||||
if (!stored) {
|
|
||||||
return createSeedEvents()
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = JSON.parse(stored)
|
|
||||||
return Array.isArray(parsed) ? (parsed as CalendarEvent[]) : createSeedEvents()
|
|
||||||
} catch {
|
|
||||||
return createSeedEvents()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function timeFromMinutes(minutes: number) {
|
function timeFromMinutes(minutes: number) {
|
||||||
return `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(
|
return `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(
|
||||||
minutes % 60,
|
minutes % 60,
|
||||||
).padStart(2, '0')}`
|
).padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeRecurrence(
|
||||||
|
value: CalendarEvent['recurrence'],
|
||||||
|
): CalendarEventRecurrence {
|
||||||
|
return value === 'daily' ||
|
||||||
|
value === 'weekly' ||
|
||||||
|
value === 'monthly' ||
|
||||||
|
value === 'monthlyNthWeekday' ||
|
||||||
|
value === 'yearly'
|
||||||
|
? value
|
||||||
|
: 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
function weekdayOrdinalInMonth(date: Date) {
|
||||||
|
return Math.floor((date.getDate() - 1) / 7) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWeekday(value: unknown, fallback: number) {
|
||||||
|
const next = Number(value)
|
||||||
|
return Number.isInteger(next) && next >= 0 && next <= 6 ? next : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOrdinal(value: unknown, fallback: number) {
|
||||||
|
const next = Number(value)
|
||||||
|
return Number.isInteger(next) && next >= 1 && next <= 5 ? next : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventIsRecurring(event: CalendarEvent | null) {
|
||||||
|
return Boolean(event && normalizeRecurrence(event.recurrence) !== 'none')
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventIsSeriesOccurrence(event: CalendarEvent | null) {
|
||||||
|
return Boolean(eventIsRecurring(event) && event?.occurrenceStart)
|
||||||
|
}
|
||||||
|
|
||||||
function emptyDraft(start: Date, end?: Date, allDay = false): EventDraft {
|
function emptyDraft(start: Date, end?: Date, allDay = false): EventDraft {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const isToday = toDateKey(start) === toDateKey(now)
|
const isToday = toDateKey(start) === toDateKey(now)
|
||||||
@ -148,12 +162,16 @@ function emptyDraft(start: Date, end?: Date, allDay = false): EventDraft {
|
|||||||
location: '',
|
location: '',
|
||||||
description: '',
|
description: '',
|
||||||
color: 'indigo',
|
color: 'indigo',
|
||||||
|
recurrence: 'none',
|
||||||
|
recurrenceWeekday: start.getDay(),
|
||||||
|
recurrenceOrdinal: weekdayOrdinalInMonth(start),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function eventToDraft(event: CalendarEvent): EventDraft {
|
function eventToDraft(event: CalendarEvent): EventDraft {
|
||||||
const start = new Date(event.start)
|
const start = new Date(event.occurrenceStart ?? event.start)
|
||||||
const end = new Date(event.end)
|
const end = new Date(event.occurrenceEnd ?? event.end)
|
||||||
|
const baseStart = new Date(event.start)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: event.title,
|
title: event.title,
|
||||||
@ -169,17 +187,20 @@ function eventToDraft(event: CalendarEvent): EventDraft {
|
|||||||
location: event.location,
|
location: event.location,
|
||||||
description: event.description,
|
description: event.description,
|
||||||
color: event.color,
|
color: event.color,
|
||||||
|
recurrence: normalizeRecurrence(event.recurrence),
|
||||||
|
recurrenceWeekday: normalizeWeekday(
|
||||||
|
event.recurrenceWeekday,
|
||||||
|
baseStart.getDay(),
|
||||||
|
),
|
||||||
|
recurrenceOrdinal: normalizeOrdinal(
|
||||||
|
event.recurrenceOrdinal,
|
||||||
|
weekdayOrdinalInMonth(baseStart),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createEventId() {
|
|
||||||
return typeof crypto.randomUUID === 'function'
|
|
||||||
? crypto.randomUUID()
|
|
||||||
: `event-${Date.now()}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CalendarPage() {
|
export default function CalendarPage() {
|
||||||
const [events, setEvents] = useState<CalendarEvent[]>(loadEvents)
|
const [events, setEvents] = useState<CalendarEvent[]>([])
|
||||||
const [calendarSettings, setCalendarSettings] = useState<CalendarSettings>(
|
const [calendarSettings, setCalendarSettings] = useState<CalendarSettings>(
|
||||||
createDefaultCalendarSettings,
|
createDefaultCalendarSettings,
|
||||||
)
|
)
|
||||||
@ -189,10 +210,53 @@ export default function CalendarPage() {
|
|||||||
const [editorOpen, setEditorOpen] = useState(false)
|
const [editorOpen, setEditorOpen] = useState(false)
|
||||||
const [draft, setDraft] = useState<EventDraft>(() => emptyDraft(new Date()))
|
const [draft, setDraft] = useState<EventDraft>(() => emptyDraft(new Date()))
|
||||||
const [formError, setFormError] = useState('')
|
const [formError, setFormError] = useState('')
|
||||||
|
const [pageError, setPageError] = useState('')
|
||||||
|
const [eventsLoading, setEventsLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [pendingSeriesAction, setPendingSeriesAction] =
|
||||||
|
useState<PendingSeriesAction | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(events))
|
let ignore = false
|
||||||
}, [events])
|
|
||||||
|
async function loadEvents() {
|
||||||
|
setEventsLoading(true)
|
||||||
|
setPageError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/calendar/events`, {
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
const data = await response.json().catch(() => null)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data?.error ?? 'Kalendereinträge konnten nicht geladen werden.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ignore) {
|
||||||
|
setEvents(Array.isArray(data?.events) ? data.events : [])
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!ignore) {
|
||||||
|
setPageError(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Kalendereinträge konnten nicht geladen werden.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!ignore) {
|
||||||
|
setEventsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadEvents()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ignore = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let ignore = false
|
let ignore = false
|
||||||
@ -237,15 +301,14 @@ export default function CalendarPage() {
|
|||||||
setEditorOpen(false)
|
setEditorOpen(false)
|
||||||
setEditingEvent(null)
|
setEditingEvent(null)
|
||||||
setFormError('')
|
setFormError('')
|
||||||
|
setPendingSeriesAction(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveEvent(event: FormEvent<HTMLFormElement>) {
|
function buildEventPayload(): EventPayload | null {
|
||||||
event.preventDefault()
|
|
||||||
|
|
||||||
const title = draft.title.trim()
|
const title = draft.title.trim()
|
||||||
if (!title) {
|
if (!title) {
|
||||||
setFormError('Bitte gib einen Titel ein.')
|
setFormError('Bitte gib einen Titel ein.')
|
||||||
return
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const start = draft.allDay
|
const start = draft.allDay
|
||||||
@ -260,12 +323,11 @@ export default function CalendarPage() {
|
|||||||
end = `${draft.endDate}T23:59`
|
end = `${draft.endDate}T23:59`
|
||||||
} else {
|
} else {
|
||||||
setFormError('Das Ende muss nach dem Beginn liegen.')
|
setFormError('Das Ende muss nach dem Beginn liegen.')
|
||||||
return
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextEvent: CalendarEvent = {
|
return {
|
||||||
id: editingEvent?.id ?? createEventId(),
|
|
||||||
title,
|
title,
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
@ -273,31 +335,254 @@ export default function CalendarPage() {
|
|||||||
location: draft.location.trim(),
|
location: draft.location.trim(),
|
||||||
description: draft.description.trim(),
|
description: draft.description.trim(),
|
||||||
color: draft.color,
|
color: draft.color,
|
||||||
|
recurrence: draft.recurrence,
|
||||||
|
recurrenceWeekday:
|
||||||
|
draft.recurrence === 'monthlyNthWeekday'
|
||||||
|
? draft.recurrenceWeekday
|
||||||
|
: null,
|
||||||
|
recurrenceOrdinal:
|
||||||
|
draft.recurrence === 'monthlyNthWeekday'
|
||||||
|
? draft.recurrenceOrdinal
|
||||||
|
: null,
|
||||||
|
recurrenceExceptions: editingEvent?.recurrenceExceptions ?? [],
|
||||||
}
|
}
|
||||||
|
|
||||||
setEvents((current) =>
|
|
||||||
editingEvent
|
|
||||||
? current.map((item) =>
|
|
||||||
item.id === editingEvent.id ? nextEvent : item,
|
|
||||||
)
|
|
||||||
: [...current, nextEvent],
|
|
||||||
)
|
|
||||||
setSelectedDate(new Date(start))
|
|
||||||
closeEditor()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteEvent() {
|
async function saveEventSeries(payload: EventPayload) {
|
||||||
if (
|
setSaving(true)
|
||||||
!editingEvent ||
|
setFormError('')
|
||||||
!window.confirm(`Termin „${editingEvent.title}“ wirklich löschen?`)
|
|
||||||
) {
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
editingEvent
|
||||||
|
? `${API_URL}/calendar/events/${editingEvent.id}`
|
||||||
|
: `${API_URL}/calendar/events`,
|
||||||
|
{
|
||||||
|
method: editingEvent ? 'PUT' : 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const data = await response.json().catch(() => null)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
data?.error ?? 'Kalendereintrag konnte nicht gespeichert werden.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedEvent = data?.event as CalendarEvent | undefined
|
||||||
|
if (!savedEvent) {
|
||||||
|
throw new Error('Kalendereintrag konnte nicht gelesen werden.')
|
||||||
|
}
|
||||||
|
|
||||||
|
setEvents((current) =>
|
||||||
|
editingEvent
|
||||||
|
? current.map((item) =>
|
||||||
|
item.id === editingEvent.id ? savedEvent : item,
|
||||||
|
)
|
||||||
|
: [...current, savedEvent],
|
||||||
|
)
|
||||||
|
setSelectedDate(new Date(savedEvent.start))
|
||||||
|
closeEditor()
|
||||||
|
} catch (error) {
|
||||||
|
setFormError(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Kalendereintrag konnte nicht gespeichert werden.',
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSingleOccurrence(payload: EventPayload) {
|
||||||
|
if (!editingEvent?.occurrenceStart) {
|
||||||
|
await saveEventSeries(payload)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setEvents((current) =>
|
setSaving(true)
|
||||||
current.filter((event) => event.id !== editingEvent.id),
|
setFormError('')
|
||||||
)
|
|
||||||
closeEditor()
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_URL}/calendar/events/${editingEvent.id}/occurrences`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
occurrenceStart: editingEvent.occurrenceStart,
|
||||||
|
event: {
|
||||||
|
...payload,
|
||||||
|
recurrence: 'none',
|
||||||
|
recurrenceWeekday: null,
|
||||||
|
recurrenceOrdinal: null,
|
||||||
|
recurrenceExceptions: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const data = await response.json().catch(() => null)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
data?.error ?? 'Ausgewählter Termin konnte nicht gespeichert werden.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedSeries = data?.series as CalendarEvent | undefined
|
||||||
|
const savedEvent = data?.event as CalendarEvent | undefined
|
||||||
|
if (!updatedSeries || !savedEvent) {
|
||||||
|
throw new Error('Kalendereintrag konnte nicht gelesen werden.')
|
||||||
|
}
|
||||||
|
|
||||||
|
setEvents((current) => [
|
||||||
|
...current.map((item) =>
|
||||||
|
item.id === editingEvent.id ? updatedSeries : item,
|
||||||
|
),
|
||||||
|
savedEvent,
|
||||||
|
])
|
||||||
|
setSelectedDate(new Date(savedEvent.start))
|
||||||
|
closeEditor()
|
||||||
|
} catch (error) {
|
||||||
|
setFormError(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Ausgewählter Termin konnte nicht gespeichert werden.',
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteEventSeries() {
|
||||||
|
if (!editingEvent) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
setFormError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/calendar/events/${editingEvent.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
const data = await response.json().catch(() => null)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data?.error ?? 'Kalendereintrag konnte nicht gelöscht werden.')
|
||||||
|
}
|
||||||
|
|
||||||
|
setEvents((current) =>
|
||||||
|
current.filter((event) => event.id !== editingEvent.id),
|
||||||
|
)
|
||||||
|
closeEditor()
|
||||||
|
} catch (error) {
|
||||||
|
setFormError(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Kalendereintrag konnte nicht gelöscht werden.',
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSingleOccurrence() {
|
||||||
|
if (!editingEvent?.occurrenceStart) {
|
||||||
|
await deleteEventSeries()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
setFormError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_URL}/calendar/events/${editingEvent.id}/occurrences/delete`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
occurrenceStart: editingEvent.occurrenceStart,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const data = await response.json().catch(() => null)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
data?.error ?? 'Ausgewählter Termin konnte nicht gelöscht werden.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedSeries = data?.event as CalendarEvent | undefined
|
||||||
|
if (!updatedSeries) {
|
||||||
|
throw new Error('Kalendereintrag konnte nicht gelesen werden.')
|
||||||
|
}
|
||||||
|
|
||||||
|
setEvents((current) =>
|
||||||
|
current.map((event) =>
|
||||||
|
event.id === editingEvent.id ? updatedSeries : event,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
closeEditor()
|
||||||
|
} catch (error) {
|
||||||
|
setFormError(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Ausgewählter Termin konnte nicht gelöscht werden.',
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEvent(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (saving) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = buildEventPayload()
|
||||||
|
if (!payload) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventIsSeriesOccurrence(editingEvent)) {
|
||||||
|
setPendingSeriesAction({ type: 'save', payload })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveEventSeries(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteEvent() {
|
||||||
|
if (!editingEvent) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventIsSeriesOccurrence(editingEvent)) {
|
||||||
|
setPendingSeriesAction({ type: 'delete' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.confirm(`Termin „${editingEvent.title}“ wirklich löschen?`)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteEventSeries()
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputClassName =
|
const inputClassName =
|
||||||
@ -305,17 +590,31 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="h-full min-h-0">
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
<Calendar
|
{(pageError || eventsLoading) && (
|
||||||
events={events}
|
<div
|
||||||
coreWorkingHours={calendarSettings.coreWorkingHours}
|
className={classNames(
|
||||||
selectedDate={selectedDate}
|
'border-b px-4 py-2 text-sm sm:px-6',
|
||||||
view={view}
|
pageError
|
||||||
onSelectedDateChange={setSelectedDate}
|
? 'border-red-200 bg-red-50 text-red-700 dark:border-red-500/20 dark:bg-red-500/10 dark:text-red-300'
|
||||||
onViewChange={setView}
|
: 'border-gray-200 bg-gray-50 text-gray-500 dark:border-white/10 dark:bg-white/5 dark:text-gray-300',
|
||||||
onCreateEvent={openCreate}
|
)}
|
||||||
onEditEvent={openEdit}
|
>
|
||||||
/>
|
{pageError || 'Kalendereinträge werden geladen ...'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-h-0 flex-1">
|
||||||
|
<Calendar
|
||||||
|
events={events}
|
||||||
|
coreWorkingHours={calendarSettings.coreWorkingHours}
|
||||||
|
selectedDate={selectedDate}
|
||||||
|
view={view}
|
||||||
|
onSelectedDateChange={setSelectedDate}
|
||||||
|
onViewChange={setView}
|
||||||
|
onCreateEvent={openCreate}
|
||||||
|
onEditEvent={openEdit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
@ -362,6 +661,7 @@ export default function CalendarPage() {
|
|||||||
title: event.target.value,
|
title: event.target.value,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
disabled={saving}
|
||||||
className={classNames(inputClassName, 'mt-2')}
|
className={classNames(inputClassName, 'mt-2')}
|
||||||
placeholder="Was steht an?"
|
placeholder="Was steht an?"
|
||||||
maxLength={120}
|
maxLength={120}
|
||||||
@ -380,19 +680,30 @@ export default function CalendarPage() {
|
|||||||
id="calendar-start-date"
|
id="calendar-start-date"
|
||||||
type="date"
|
type="date"
|
||||||
value={draft.startDate}
|
value={draft.startDate}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setDraft((current) => {
|
setDraft((current) => {
|
||||||
const startDate = event.target.value
|
const startDate = event.target.value
|
||||||
return {
|
const start = new Date(`${startDate}T00:00`)
|
||||||
...current,
|
|
||||||
startDate,
|
return {
|
||||||
endDate:
|
...current,
|
||||||
current.endDate < startDate
|
startDate,
|
||||||
? startDate
|
endDate:
|
||||||
: current.endDate,
|
current.endDate < startDate
|
||||||
}
|
? startDate
|
||||||
})
|
: current.endDate,
|
||||||
}
|
recurrenceWeekday:
|
||||||
|
current.recurrence === 'monthlyNthWeekday'
|
||||||
|
? start.getDay()
|
||||||
|
: current.recurrenceWeekday,
|
||||||
|
recurrenceOrdinal:
|
||||||
|
current.recurrence === 'monthlyNthWeekday'
|
||||||
|
? weekdayOrdinalInMonth(start)
|
||||||
|
: current.recurrenceOrdinal,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={saving}
|
||||||
className={classNames(inputClassName, 'mt-2')}
|
className={classNames(inputClassName, 'mt-2')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -407,7 +718,7 @@ export default function CalendarPage() {
|
|||||||
id="calendar-start"
|
id="calendar-start"
|
||||||
type="time"
|
type="time"
|
||||||
value={draft.startTime}
|
value={draft.startTime}
|
||||||
disabled={draft.allDay}
|
disabled={draft.allDay || saving}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setDraft((current) => ({
|
setDraft((current) => ({
|
||||||
...current,
|
...current,
|
||||||
@ -432,6 +743,7 @@ export default function CalendarPage() {
|
|||||||
type="date"
|
type="date"
|
||||||
min={draft.startDate}
|
min={draft.startDate}
|
||||||
value={draft.endDate}
|
value={draft.endDate}
|
||||||
|
disabled={saving}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setDraft((current) => ({
|
setDraft((current) => ({
|
||||||
...current,
|
...current,
|
||||||
@ -452,7 +764,7 @@ export default function CalendarPage() {
|
|||||||
id="calendar-end"
|
id="calendar-end"
|
||||||
type="time"
|
type="time"
|
||||||
value={draft.endTime}
|
value={draft.endTime}
|
||||||
disabled={draft.allDay}
|
disabled={draft.allDay || saving}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setDraft((current) => ({
|
setDraft((current) => ({
|
||||||
...current,
|
...current,
|
||||||
@ -471,6 +783,7 @@ export default function CalendarPage() {
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={draft.allDay}
|
checked={draft.allDay}
|
||||||
|
disabled={saving}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setDraft((current) => ({
|
setDraft((current) => ({
|
||||||
...current,
|
...current,
|
||||||
@ -484,6 +797,104 @@ export default function CalendarPage() {
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="calendar-recurrence"
|
||||||
|
className="block text-sm font-medium text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
Wiederholung
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="calendar-recurrence"
|
||||||
|
value={draft.recurrence}
|
||||||
|
disabled={saving}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDraft((current) => {
|
||||||
|
const recurrence = event.target
|
||||||
|
.value as CalendarEventRecurrence
|
||||||
|
const start = new Date(`${current.startDate}T00:00`)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
recurrence,
|
||||||
|
recurrenceWeekday:
|
||||||
|
recurrence === 'monthlyNthWeekday'
|
||||||
|
? current.recurrenceWeekday ?? start.getDay()
|
||||||
|
: current.recurrenceWeekday,
|
||||||
|
recurrenceOrdinal:
|
||||||
|
recurrence === 'monthlyNthWeekday'
|
||||||
|
? current.recurrenceOrdinal ??
|
||||||
|
weekdayOrdinalInMonth(start)
|
||||||
|
: current.recurrenceOrdinal,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={classNames(inputClassName, 'mt-2')}
|
||||||
|
>
|
||||||
|
{recurrenceOptions.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{draft.recurrence === 'monthlyNthWeekday' && (
|
||||||
|
<div className="mt-3 grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="calendar-recurrence-ordinal"
|
||||||
|
className="block text-xs font-medium text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
Woche
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="calendar-recurrence-ordinal"
|
||||||
|
value={draft.recurrenceOrdinal}
|
||||||
|
disabled={saving}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
recurrenceOrdinal: Number(event.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className={classNames(inputClassName, 'mt-1.5')}
|
||||||
|
>
|
||||||
|
{recurrenceOrdinalOptions.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="calendar-recurrence-weekday"
|
||||||
|
className="block text-xs font-medium text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
Wochentag
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="calendar-recurrence-weekday"
|
||||||
|
value={draft.recurrenceWeekday}
|
||||||
|
disabled={saving}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
recurrenceWeekday: Number(event.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className={classNames(inputClassName, 'mt-1.5')}
|
||||||
|
>
|
||||||
|
{recurrenceWeekdayOptions.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor="calendar-location"
|
htmlFor="calendar-location"
|
||||||
@ -494,6 +905,7 @@ export default function CalendarPage() {
|
|||||||
<input
|
<input
|
||||||
id="calendar-location"
|
id="calendar-location"
|
||||||
value={draft.location}
|
value={draft.location}
|
||||||
|
disabled={saving}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setDraft((current) => ({
|
setDraft((current) => ({
|
||||||
...current,
|
...current,
|
||||||
@ -517,6 +929,7 @@ export default function CalendarPage() {
|
|||||||
id="calendar-description"
|
id="calendar-description"
|
||||||
rows={3}
|
rows={3}
|
||||||
value={draft.description}
|
value={draft.description}
|
||||||
|
disabled={saving}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setDraft((current) => ({
|
setDraft((current) => ({
|
||||||
...current,
|
...current,
|
||||||
@ -545,14 +958,15 @@ export default function CalendarPage() {
|
|||||||
: 'ring-transparent hover:ring-gray-300 dark:hover:ring-white/30',
|
: 'ring-transparent hover:ring-gray-300 dark:hover:ring-white/30',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="color"
|
name="color"
|
||||||
value={color.value}
|
value={color.value}
|
||||||
checked={draft.color === color.value}
|
checked={draft.color === color.value}
|
||||||
onChange={() =>
|
disabled={saving}
|
||||||
setDraft((current) => ({
|
onChange={() =>
|
||||||
...current,
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
color: color.value,
|
color: color.value,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@ -582,6 +996,7 @@ export default function CalendarPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={deleteEvent}
|
onClick={deleteEvent}
|
||||||
|
disabled={saving}
|
||||||
className="inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-semibold text-red-600 hover:bg-red-50 dark:text-red-300 dark:hover:bg-red-500/10"
|
className="inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-semibold text-red-600 hover:bg-red-50 dark:text-red-300 dark:hover:bg-red-500/10"
|
||||||
>
|
>
|
||||||
<TrashIcon className="size-4" />
|
<TrashIcon className="size-4" />
|
||||||
@ -593,20 +1008,105 @@ export default function CalendarPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={closeEditor}
|
onClick={closeEditor}
|
||||||
|
disabled={saving}
|
||||||
className="rounded-lg px-3 py-2 text-sm font-semibold text-gray-600 hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-white/10"
|
className="rounded-lg px-3 py-2 text-sm font-semibold text-gray-600 hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-white/10"
|
||||||
>
|
>
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
|
disabled={saving}
|
||||||
|
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-indigo-500 dark:hover:bg-indigo-400"
|
||||||
>
|
>
|
||||||
{editingEvent ? 'Speichern' : 'Termin erstellen'}
|
{saving
|
||||||
|
? 'Speichern ...'
|
||||||
|
: editingEvent
|
||||||
|
? 'Speichern'
|
||||||
|
: 'Termin erstellen'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={pendingSeriesAction !== null}
|
||||||
|
setOpen={(open) => {
|
||||||
|
if (!open && !saving) {
|
||||||
|
setPendingSeriesAction(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
zIndexClassName="z-[10010]"
|
||||||
|
backdropClassName="bg-gray-950/60 backdrop-blur-sm dark:bg-gray-950/60"
|
||||||
|
panelClassName="max-w-md rounded-2xl bg-white dark:bg-gray-900"
|
||||||
|
>
|
||||||
|
<div className="border-b border-gray-200 px-5 py-4 dark:border-white/10">
|
||||||
|
<ModalTitle>
|
||||||
|
{pendingSeriesAction?.type === 'delete'
|
||||||
|
? 'Serientermin löschen'
|
||||||
|
: 'Serientermin bearbeiten'}
|
||||||
|
</ModalTitle>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4 p-5">
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Soll die Änderung nur für diesen Termin oder für die ganze Serie
|
||||||
|
übernommen werden?
|
||||||
|
</p>
|
||||||
|
{formError && (
|
||||||
|
<p className="rounded-xl bg-red-50 px-3 py-2 text-sm text-red-700 ring-1 ring-red-200 dark:bg-red-500/10 dark:text-red-300 dark:ring-red-500/20">
|
||||||
|
{formError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap justify-end gap-2 border-t border-gray-200 bg-gray-50 px-5 py-3 dark:border-white/10 dark:bg-white/5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPendingSeriesAction(null)}
|
||||||
|
disabled={saving}
|
||||||
|
className="rounded-lg px-3 py-2 text-sm font-semibold text-gray-600 hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-60 dark:text-gray-300 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={saving || !pendingSeriesAction}
|
||||||
|
onClick={() => {
|
||||||
|
if (!pendingSeriesAction) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingSeriesAction.type === 'delete') {
|
||||||
|
void deleteSingleOccurrence()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void saveSingleOccurrence(pendingSeriesAction.payload)
|
||||||
|
}}
|
||||||
|
className="rounded-lg px-3 py-2 text-sm font-semibold text-indigo-700 hover:bg-indigo-50 disabled:cursor-not-allowed disabled:opacity-60 dark:text-indigo-300 dark:hover:bg-indigo-500/10"
|
||||||
|
>
|
||||||
|
Nur dieser Termin
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={saving || !pendingSeriesAction}
|
||||||
|
onClick={() => {
|
||||||
|
if (!pendingSeriesAction) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingSeriesAction.type === 'delete') {
|
||||||
|
void deleteEventSeries()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void saveEventSeries(pendingSeriesAction.payload)
|
||||||
|
}}
|
||||||
|
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-indigo-500 dark:hover:bg-indigo-400"
|
||||||
|
>
|
||||||
|
Ganze Serie
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -108,6 +108,9 @@ function classNames(...classes: Array<string | false | null | undefined>) {
|
|||||||
|
|
||||||
async function readApiError(response: Response, fallback: string) {
|
async function readApiError(response: Response, fallback: string) {
|
||||||
const data = await response.json().catch(() => null)
|
const data = await response.json().catch(() => null)
|
||||||
|
if (data?.error && data?.details) {
|
||||||
|
return `${data.error}: ${data.details}`
|
||||||
|
}
|
||||||
|
|
||||||
return data?.error ?? fallback
|
return data?.error ?? fallback
|
||||||
}
|
}
|
||||||
@ -122,19 +125,31 @@ function roleIsAdministrators(role?: MilestoneRole | null) {
|
|||||||
.some((value) => value?.trim().toLowerCase() === 'administrators')
|
.some((value) => value?.trim().toLowerCase() === 'administrators')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function userDomainIsBasic(domain?: string) {
|
||||||
|
const normalized = (domain ?? '').trim().replace(/^\[/, '').replace(/\]$/, '')
|
||||||
|
return normalized.toLowerCase() === 'basic'
|
||||||
|
}
|
||||||
|
|
||||||
function userSource(user: MilestoneRoleUser | AssignableUser): UserSource {
|
function userSource(user: MilestoneRoleUser | AssignableUser): UserSource {
|
||||||
return user.source === 'basic' ? 'basic' : 'ad'
|
return user.source === 'basic' || userDomainIsBasic(user.domain)
|
||||||
|
? 'basic'
|
||||||
|
: 'ad'
|
||||||
|
}
|
||||||
|
|
||||||
|
function withoutDomainPrefix(value: string) {
|
||||||
|
const [, accountName] = value.split('\\')
|
||||||
|
return accountName || value
|
||||||
}
|
}
|
||||||
|
|
||||||
function userAccountName(user: MilestoneRoleUser | AssignableUser) {
|
function userAccountName(user: MilestoneRoleUser | AssignableUser) {
|
||||||
if ('username' in user && user.username) {
|
if ('username' in user && user.username) {
|
||||||
return user.username
|
return withoutDomainPrefix(user.username)
|
||||||
}
|
}
|
||||||
if ('userName' in user && user.userName) {
|
if ('userName' in user && user.userName) {
|
||||||
return user.userName
|
return withoutDomainPrefix(user.userName)
|
||||||
}
|
}
|
||||||
if ('name' in user && user.name) {
|
if ('name' in user && user.name) {
|
||||||
return user.name
|
return withoutDomainPrefix(user.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
return user.id
|
return user.id
|
||||||
@ -142,11 +157,6 @@ function userAccountName(user: MilestoneRoleUser | AssignableUser) {
|
|||||||
|
|
||||||
function userTitle(user: MilestoneRoleUser | AssignableUser) {
|
function userTitle(user: MilestoneRoleUser | AssignableUser) {
|
||||||
const accountName = userAccountName(user)
|
const accountName = userAccountName(user)
|
||||||
const domain = user.domain?.trim()
|
|
||||||
|
|
||||||
if (domain && accountName) {
|
|
||||||
return `${domain}\\${accountName}`
|
|
||||||
}
|
|
||||||
|
|
||||||
return accountName || user.displayName || user.id
|
return accountName || user.displayName || user.id
|
||||||
}
|
}
|
||||||
@ -155,25 +165,17 @@ function adUserSubtitle(user: ActiveDirectoryUser) {
|
|||||||
return [user.username, user.email].filter(Boolean).join(' / ')
|
return [user.username, user.email].filter(Boolean).join(' / ')
|
||||||
}
|
}
|
||||||
|
|
||||||
function milestoneUserSubtitle(user: MilestoneRoleUser) {
|
|
||||||
return [user.userName || user.name, user.email].filter(Boolean).join(' / ')
|
|
||||||
}
|
|
||||||
|
|
||||||
function userSubtitle(user: MilestoneRoleUser | AssignableUser) {
|
function userSubtitle(user: MilestoneRoleUser | AssignableUser) {
|
||||||
const fallbackSubtitle =
|
if (userSource(user) === 'basic') {
|
||||||
userSource(user) === 'ad'
|
return 'Basisbenutzer'
|
||||||
? adUserSubtitle(user as ActiveDirectoryUser)
|
}
|
||||||
: milestoneUserSubtitle(user as MilestoneRoleUser)
|
|
||||||
|
|
||||||
return [
|
const domain = user.domain?.trim()
|
||||||
user.displayName && user.displayName !== userTitle(user)
|
if (domain && !userDomainIsBasic(domain)) {
|
||||||
? user.displayName
|
return domain
|
||||||
: '',
|
}
|
||||||
userSource(user) === 'basic' ? 'Basisbenutzer' : '',
|
|
||||||
user.email || fallbackSubtitle,
|
return user.email || adUserSubtitle(user as ActiveDirectoryUser)
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' / ')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function userSelectionKey(user: AssignableUser) {
|
function userSelectionKey(user: AssignableUser) {
|
||||||
@ -205,7 +207,6 @@ export default function MilestoneRolesPage({
|
|||||||
const [isLoadingBasicUsers, setIsLoadingBasicUsers] = useState(false)
|
const [isLoadingBasicUsers, setIsLoadingBasicUsers] = useState(false)
|
||||||
const [isAddingUsers, setIsAddingUsers] = useState(false)
|
const [isAddingUsers, setIsAddingUsers] = useState(false)
|
||||||
const [adSearch, setAdSearch] = useState('')
|
const [adSearch, setAdSearch] = useState('')
|
||||||
const [userPickerSource, setUserPickerSource] = useState<UserSource>('ad')
|
|
||||||
const [selectedUserKeys, setSelectedUserKeys] = useState<Set<string>>(
|
const [selectedUserKeys, setSelectedUserKeys] = useState<Set<string>>(
|
||||||
() => new Set(),
|
() => new Set(),
|
||||||
)
|
)
|
||||||
@ -426,7 +427,6 @@ export default function MilestoneRolesPage({
|
|||||||
setAdModalOpen(true)
|
setAdModalOpen(true)
|
||||||
setSelectedUserKeys(new Set())
|
setSelectedUserKeys(new Set())
|
||||||
setAdSearch('')
|
setAdSearch('')
|
||||||
setUserPickerSource('ad')
|
|
||||||
await Promise.all([loadAdUsers(), loadBasicUsers()])
|
await Promise.all([loadAdUsers(), loadBasicUsers()])
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -621,6 +621,13 @@ export default function MilestoneRolesPage({
|
|||||||
}, [isAdministrator, roleSearch, roles])
|
}, [isAdministrator, roleSearch, roles])
|
||||||
|
|
||||||
const roleUsers = selectedRole?.users ?? []
|
const roleUsers = selectedRole?.users ?? []
|
||||||
|
const roleUsersBySource = useMemo<Record<UserSource, MilestoneRoleUser[]>>(
|
||||||
|
() => ({
|
||||||
|
ad: roleUsers.filter((user) => userSource(user) === 'ad'),
|
||||||
|
basic: roleUsers.filter((user) => userSource(user) === 'basic'),
|
||||||
|
}),
|
||||||
|
[roleUsers],
|
||||||
|
)
|
||||||
const visibleAssignableUsersBySource = useMemo<Record<UserSource, AssignableUser[]>>(() => {
|
const visibleAssignableUsersBySource = useMemo<Record<UserSource, AssignableUser[]>>(() => {
|
||||||
const existingUserIds = new Set(roleUsers.map((user) => user.id))
|
const existingUserIds = new Set(roleUsers.map((user) => user.id))
|
||||||
const query = adSearch.trim().toLowerCase()
|
const query = adSearch.trim().toLowerCase()
|
||||||
@ -654,11 +661,6 @@ export default function MilestoneRolesPage({
|
|||||||
}
|
}
|
||||||
}, [adSearch, adUsers, basicUsers, roleUsers])
|
}, [adSearch, adUsers, basicUsers, roleUsers])
|
||||||
|
|
||||||
const filteredAssignableUsers = visibleAssignableUsersBySource[userPickerSource]
|
|
||||||
|
|
||||||
const isLoadingPickerUsers =
|
|
||||||
userPickerSource === 'ad' ? isLoadingAdUsers : isLoadingBasicUsers
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-y-auto bg-gray-50 px-4 py-6 sm:px-6 lg:px-8 dark:bg-gray-950">
|
<div className="h-full overflow-y-auto bg-gray-50 px-4 py-6 sm:px-6 lg:px-8 dark:bg-gray-950">
|
||||||
<div className="mx-auto flex max-w-7xl flex-col gap-6">
|
<div className="mx-auto flex max-w-7xl flex-col gap-6">
|
||||||
@ -884,39 +886,71 @@ export default function MilestoneRolesPage({
|
|||||||
Diese Rolle hat noch keine Benutzer.
|
Diese Rolle hat noch keine Benutzer.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="grid gap-3 md:grid-cols-2">
|
<div className="space-y-5">
|
||||||
{roleUsers.map((user) => (
|
{([
|
||||||
<li
|
['ad', 'Windows-Benutzer'],
|
||||||
key={user.id}
|
['basic', 'Basisbenutzer'],
|
||||||
className="flex items-start gap-3 rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/[0.03]"
|
] as Array<[UserSource, string]>).map(([source, label]) => {
|
||||||
>
|
const users = roleUsersBySource[source]
|
||||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-md bg-white text-gray-500 ring-1 ring-gray-200 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10">
|
if (users.length === 0) {
|
||||||
<UserGroupIcon aria-hidden="true" className="size-4" />
|
return null
|
||||||
</div>
|
}
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="truncate text-sm font-medium text-gray-900 dark:text-white">
|
return (
|
||||||
{userTitle(user)}
|
<section key={source}>
|
||||||
</p>
|
<div className="mb-2 flex items-center justify-between">
|
||||||
{userSubtitle(user) && (
|
<h4 className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
<p className="mt-0.5 truncate text-xs text-gray-500 dark:text-gray-400">
|
{label}
|
||||||
{userSubtitle(user)}
|
</h4>
|
||||||
</p>
|
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||||
)}
|
{users.length}
|
||||||
</div>
|
</span>
|
||||||
{canWrite && (
|
</div>
|
||||||
<button
|
<ul className="grid gap-3 md:grid-cols-2">
|
||||||
type="button"
|
{users.map((user) => (
|
||||||
onClick={() => void removeRoleUser(user.id)}
|
<li
|
||||||
disabled={removingUserId === user.id}
|
key={user.id}
|
||||||
className="rounded-md p-1 text-gray-400 hover:bg-white hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10 dark:hover:text-red-300"
|
className="flex items-start gap-3 rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/[0.03]"
|
||||||
>
|
>
|
||||||
<span className="sr-only">Benutzer entfernen</span>
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-md bg-white text-gray-500 ring-1 ring-gray-200 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10">
|
||||||
<XMarkIcon aria-hidden="true" className="size-4" />
|
<UserGroupIcon
|
||||||
</button>
|
aria-hidden="true"
|
||||||
)}
|
className="size-4"
|
||||||
</li>
|
/>
|
||||||
))}
|
</div>
|
||||||
</ul>
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{userTitle(user)}
|
||||||
|
</p>
|
||||||
|
{userSubtitle(user) && (
|
||||||
|
<p className="mt-0.5 truncate text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{userSubtitle(user)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{canWrite && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void removeRoleUser(user.id)}
|
||||||
|
disabled={removingUserId === user.id}
|
||||||
|
className="rounded-md p-1 text-gray-400 hover:bg-white hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10 dark:hover:text-red-300"
|
||||||
|
>
|
||||||
|
<span className="sr-only">
|
||||||
|
Benutzer entfernen
|
||||||
|
</span>
|
||||||
|
<XMarkIcon
|
||||||
|
aria-hidden="true"
|
||||||
|
className="size-4"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@ -1060,74 +1094,69 @@ export default function MilestoneRolesPage({
|
|||||||
<div className="border-b border-gray-200 px-6 py-4 dark:border-white/10">
|
<div className="border-b border-gray-200 px-6 py-4 dark:border-white/10">
|
||||||
<ModalTitle>Benutzer hinzufügen</ModalTitle>
|
<ModalTitle>Benutzer hinzufügen</ModalTitle>
|
||||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
{userPickerSource === 'ad'
|
{adBaseDn
|
||||||
? adBaseDn
|
? `Windows-Benutzer aus ${adBaseDn} und Basisbenutzer aus Milestone.`
|
||||||
? `Benutzer aus dem Suchbereich ${adBaseDn}.`
|
: 'Windows-Benutzer aus dem konfigurierten Suchbereich und Basisbenutzer aus Milestone.'}
|
||||||
: 'Benutzer aus dem konfigurierten Suchbereich.'
|
|
||||||
: 'Basisbenutzer aus Milestone.'}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-6 py-5">
|
<div className="px-6 py-5">
|
||||||
<div className="mb-4 grid grid-cols-2 rounded-lg bg-gray-100 p-1 text-sm font-medium dark:bg-white/5">
|
<input
|
||||||
|
type="search"
|
||||||
|
value={adSearch}
|
||||||
|
onChange={(event) => setAdSearch(event.target.value)}
|
||||||
|
placeholder="Benutzer suchen"
|
||||||
|
className="block w-full rounded-md bg-white px-3 py-2 text-sm text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-4 max-h-96 space-y-4 overflow-y-auto rounded-lg border border-gray-200 p-3 dark:border-white/10">
|
||||||
{([
|
{([
|
||||||
['ad', `AD-Benutzer (${visibleAssignableUsersBySource.ad.length})`],
|
{
|
||||||
['basic', `Basisbenutzer (${visibleAssignableUsersBySource.basic.length})`],
|
source: 'ad' as const,
|
||||||
] as Array<[UserSource, string]>).map(([source, label]) => (
|
title: 'Windows-Benutzer',
|
||||||
<button
|
users: visibleAssignableUsersBySource.ad,
|
||||||
key={source}
|
loading: isLoadingAdUsers,
|
||||||
type="button"
|
unavailable: !adConfigured,
|
||||||
onClick={() => setUserPickerSource(source)}
|
emptyText: 'Keine passenden Windows-Benutzer gefunden.',
|
||||||
className={classNames(
|
},
|
||||||
'rounded-md px-3 py-2 transition',
|
{
|
||||||
userPickerSource === source
|
source: 'basic' as const,
|
||||||
? 'bg-white text-indigo-700 shadow-xs dark:bg-white/10 dark:text-indigo-300'
|
title: 'Basisbenutzer',
|
||||||
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white',
|
users: visibleAssignableUsersBySource.basic,
|
||||||
)}
|
loading: isLoadingBasicUsers,
|
||||||
>
|
unavailable: false,
|
||||||
{label}
|
emptyText: 'Keine passenden Basisbenutzer gefunden.',
|
||||||
</button>
|
},
|
||||||
))}
|
]).map((group) => (
|
||||||
</div>
|
<section key={group.source}>
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
{group.title}
|
||||||
|
</h3>
|
||||||
|
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
{group.users.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{userPickerSource === 'ad' && !adConfigured ? (
|
{group.unavailable ? (
|
||||||
<div className="rounded-lg border border-dashed border-gray-200 px-4 py-10 text-center text-sm text-gray-500 dark:border-white/10 dark:text-gray-400">
|
<div className="rounded-lg border border-dashed border-gray-200 px-4 py-6 text-center text-sm text-gray-500 dark:border-white/10 dark:text-gray-400">
|
||||||
Active Directory ist noch nicht konfiguriert.
|
Active Directory ist noch nicht konfiguriert.
|
||||||
</div>
|
</div>
|
||||||
) : isLoadingPickerUsers ? (
|
) : group.loading ? (
|
||||||
<div className="flex min-h-64 items-center justify-center">
|
<div className="flex min-h-28 items-center justify-center rounded-lg border border-dashed border-gray-200 dark:border-white/10">
|
||||||
<LoadingSpinner
|
<LoadingSpinner
|
||||||
size="md"
|
size="sm"
|
||||||
label={
|
label={`${group.title} werden geladen...`}
|
||||||
userPickerSource === 'ad'
|
className="text-indigo-600 dark:text-indigo-400"
|
||||||
? 'AD-Benutzer werden geladen...'
|
/>
|
||||||
: 'Basisbenutzer werden geladen...'
|
</div>
|
||||||
}
|
) : group.users.length === 0 ? (
|
||||||
className="text-indigo-600 dark:text-indigo-400"
|
<div className="rounded-lg border border-dashed border-gray-200 px-4 py-6 text-center text-sm text-gray-500 dark:border-white/10 dark:text-gray-400">
|
||||||
/>
|
{group.emptyText}
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<input
|
|
||||||
type="search"
|
|
||||||
value={adSearch}
|
|
||||||
onChange={(event) => setAdSearch(event.target.value)}
|
|
||||||
placeholder={
|
|
||||||
userPickerSource === 'ad'
|
|
||||||
? 'AD-Benutzer suchen'
|
|
||||||
: 'Basisbenutzer suchen'
|
|
||||||
}
|
|
||||||
className="block w-full rounded-md bg-white px-3 py-2 text-sm text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-4 max-h-80 overflow-y-auto rounded-lg border border-gray-200 dark:border-white/10">
|
|
||||||
{filteredAssignableUsers.length === 0 ? (
|
|
||||||
<div className="px-4 py-10 text-center text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
Keine passenden Benutzer gefunden.
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-y divide-gray-200 dark:divide-white/10">
|
<ul className="divide-y divide-gray-200 overflow-hidden rounded-lg border border-gray-200 dark:divide-white/10 dark:border-white/10">
|
||||||
{filteredAssignableUsers.map((user) => (
|
{group.users.map((user) => (
|
||||||
<li key={userSelectionKey(user)}>
|
<li key={userSelectionKey(user)}>
|
||||||
<label className="flex cursor-pointer items-start gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-white/5">
|
<label className="flex cursor-pointer items-start gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-white/5">
|
||||||
<input
|
<input
|
||||||
@ -1151,9 +1180,9 @@ export default function MilestoneRolesPage({
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</section>
|
||||||
</>
|
))}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 border-t border-gray-200 px-6 py-4 dark:border-white/10">
|
<div className="flex justify-end gap-3 border-t border-gray-200 px-6 py-4 dark:border-white/10">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user