diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/controller/EventController.java b/cannamanage-api/src/main/java/de/cannamanage/api/controller/EventController.java new file mode 100644 index 0000000..3c032b5 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/controller/EventController.java @@ -0,0 +1,224 @@ +package de.cannamanage.api.controller; + +import de.cannamanage.api.dto.event.*; +import de.cannamanage.api.security.StaffPermissionChecker; +import de.cannamanage.domain.entity.ClubEvent; +import de.cannamanage.domain.entity.EventRsvp; +import de.cannamanage.domain.entity.TenantContext; +import de.cannamanage.domain.enums.RsvpStatus; +import de.cannamanage.domain.enums.StaffPermission; +import de.cannamanage.service.EventService; +import de.cannamanage.service.repository.EventRsvpRepository; +import de.cannamanage.service.repository.MemberRepository; +import jakarta.validation.Valid; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.*; + +import java.time.Instant; +import java.util.*; + +/** + * REST controller for club event management. + * Admin endpoints require MANAGE_INFO_BOARD permission. + * Portal endpoints are accessible to authenticated members. + */ +@RestController +@RequestMapping("/api/v1") +public class EventController { + + private final EventService eventService; + private final EventRsvpRepository rsvpRepository; + private final MemberRepository memberRepository; + private final StaffPermissionChecker permissionChecker; + + public EventController(EventService eventService, + EventRsvpRepository rsvpRepository, + MemberRepository memberRepository, + StaffPermissionChecker permissionChecker) { + this.eventService = eventService; + this.rsvpRepository = rsvpRepository; + this.memberRepository = memberRepository; + this.permissionChecker = permissionChecker; + } + + // === Admin endpoints === + + @PostMapping("/events") + public ResponseEntity createEvent(@Valid @RequestBody CreateEventRequest request, + @AuthenticationPrincipal UserDetails principal) { + permissionChecker.requirePermission(principal, StaffPermission.MANAGE_INFO_BOARD); + UUID clubId = TenantContext.getCurrentTenant(); + UUID userId = UUID.fromString(principal.getUsername()); + + boolean postToInfoBoard = request.postToInfoBoard() == null || request.postToInfoBoard(); + + ClubEvent event = eventService.createEvent( + clubId, request.title(), request.description(), request.eventType(), + request.startAt(), request.endAt(), request.location(), request.maxAttendees(), + request.recurring(), request.recurrenceRule(), request.recurrenceEndDate(), + userId, postToInfoBoard + ); + + return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(event, null)); + } + + @GetMapping("/events") + public ResponseEntity> listEvents( + @RequestParam Instant from, + @RequestParam Instant to, + @AuthenticationPrincipal UserDetails principal) { + List events = eventService.listEvents(from, to); + UUID userId = UUID.fromString(principal.getUsername()); + UUID memberId = getMemberIdForUser(userId); + List responses = events.stream() + .map(e -> toResponse(e, memberId)) + .toList(); + return ResponseEntity.ok(responses); + } + + @GetMapping("/events/{id}") + public ResponseEntity getEvent(@PathVariable UUID id, + @AuthenticationPrincipal UserDetails principal) { + ClubEvent event = eventService.getEvent(id); + UUID userId = UUID.fromString(principal.getUsername()); + UUID memberId = getMemberIdForUser(userId); + return ResponseEntity.ok(toResponse(event, memberId)); + } + + @PutMapping("/events/{id}") + public ResponseEntity updateEvent(@PathVariable UUID id, + @Valid @RequestBody UpdateEventRequest request, + @AuthenticationPrincipal UserDetails principal) { + permissionChecker.requirePermission(principal, StaffPermission.MANAGE_INFO_BOARD); + ClubEvent event = eventService.updateEvent(id, request.title(), request.description(), + request.eventType(), request.startAt(), request.endAt(), request.location(), + request.maxAttendees(), request.recurring(), request.recurrenceRule(), + request.recurrenceEndDate()); + return ResponseEntity.ok(toResponse(event, null)); + } + + @DeleteMapping("/events/{id}") + public ResponseEntity cancelEvent(@PathVariable UUID id, + @AuthenticationPrincipal UserDetails principal) { + permissionChecker.requirePermission(principal, StaffPermission.MANAGE_INFO_BOARD); + eventService.cancelEvent(id); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/events/{id}/rsvp") + public ResponseEntity rsvp(@PathVariable UUID id, + @Valid @RequestBody RsvpRequest request, + @AuthenticationPrincipal UserDetails principal) { + UUID userId = UUID.fromString(principal.getUsername()); + UUID memberId = getMemberIdForUser(userId); + if (memberId == null) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + + try { + EventRsvp rsvp = eventService.rsvp(id, memberId, request.status()); + return ResponseEntity.ok(Map.of( + "status", rsvp.getStatus(), + "respondedAt", rsvp.getRespondedAt() + )); + } catch (IllegalStateException e) { + if ("EVENT_FULL".equals(e.getMessage())) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(Map.of("error", "EVENT_FULL", "message", "Veranstaltung ist ausgebucht")); + } + throw e; + } + } + + @GetMapping("/events/{id}/attendees") + public ResponseEntity> getAttendees(@PathVariable UUID id) { + List rsvps = eventService.getAttendees(id); + List responses = rsvps.stream() + .map(r -> { + String memberName = memberRepository.findById(r.getMemberId()) + .map(m -> m.getFirstName() + " " + m.getLastName()) + .orElse("Unknown"); + return new RsvpResponse(r.getMemberId(), memberName, r.getStatus(), r.getRespondedAt()); + }) + .toList(); + return ResponseEntity.ok(responses); + } + + @GetMapping("/events/{id}/ical") + public ResponseEntity downloadIcal(@PathVariable UUID id) { + String ical = eventService.generateIcal(id); + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"event.ics\"") + .contentType(MediaType.parseMediaType("text/calendar")) + .body(ical); + } + + // === Portal endpoints === + + @GetMapping("/portal/events") + public ResponseEntity> portalEvents(@AuthenticationPrincipal UserDetails principal) { + UUID userId = UUID.fromString(principal.getUsername()); + UUID memberId = getMemberIdForUser(userId); + List events = eventService.listUpcomingEvents(10); + List responses = events.stream() + .map(e -> toResponse(e, memberId)) + .toList(); + return ResponseEntity.ok(responses); + } + + @PostMapping("/portal/events/{id}/rsvp") + public ResponseEntity portalRsvp(@PathVariable UUID id, + @Valid @RequestBody RsvpRequest request, + @AuthenticationPrincipal UserDetails principal) { + return rsvp(id, request, principal); + } + + // === Helpers === + + private EventResponse toResponse(ClubEvent event, UUID memberId) { + Map counts = new HashMap<>(); + RsvpStatus myStatus = null; + + if (event.getId() != null) { + try { + counts = eventService.getAttendeeCounts(event.getId()); + if (memberId != null) { + myStatus = rsvpRepository.findByEventIdAndMemberId(event.getId(), memberId) + .map(EventRsvp::getStatus) + .orElse(null); + } + } catch (Exception e) { + // Virtual expanded events may not have a DB id + } + } + + return new EventResponse( + event.getId(), + event.getTitle(), + event.getDescription(), + event.getEventType(), + event.getStartAt(), + event.getEndAt(), + event.getLocation(), + event.getMaxAttendees(), + event.isRecurring(), + event.getRecurrenceRule(), + event.getRecurrenceEndDate(), + event.getCreatedBy(), + event.getCreatedAt(), + counts, + myStatus + ); + } + + private UUID getMemberIdForUser(UUID userId) { + return memberRepository.findByUserId(userId) + .map(m -> m.getId()) + .orElse(null); + } +} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/event/CreateEventRequest.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/event/CreateEventRequest.java new file mode 100644 index 0000000..da4aacb --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/event/CreateEventRequest.java @@ -0,0 +1,24 @@ +package de.cannamanage.api.dto.event; + +import de.cannamanage.domain.enums.EventType; +import de.cannamanage.domain.enums.RecurrenceRule; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.time.Instant; +import java.time.LocalDate; + +public record CreateEventRequest( + @NotBlank @Size(max = 200) String title, + String description, + @NotNull EventType eventType, + @NotNull Instant startAt, + Instant endAt, + @Size(max = 300) String location, + Integer maxAttendees, + boolean recurring, + RecurrenceRule recurrenceRule, + LocalDate recurrenceEndDate, + Boolean postToInfoBoard // defaults to true if null +) {} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/event/EventResponse.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/event/EventResponse.java new file mode 100644 index 0000000..e16df9c --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/event/EventResponse.java @@ -0,0 +1,28 @@ +package de.cannamanage.api.dto.event; + +import de.cannamanage.domain.enums.EventType; +import de.cannamanage.domain.enums.RecurrenceRule; +import de.cannamanage.domain.enums.RsvpStatus; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.Map; +import java.util.UUID; + +public record EventResponse( + UUID id, + String title, + String description, + EventType eventType, + Instant startAt, + Instant endAt, + String location, + Integer maxAttendees, + boolean recurring, + RecurrenceRule recurrenceRule, + LocalDate recurrenceEndDate, + UUID createdBy, + Instant createdAt, + Map attendeeCounts, + RsvpStatus myRsvpStatus +) {} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/event/RsvpRequest.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/event/RsvpRequest.java new file mode 100644 index 0000000..1171417 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/event/RsvpRequest.java @@ -0,0 +1,8 @@ +package de.cannamanage.api.dto.event; + +import de.cannamanage.domain.enums.RsvpStatus; +import jakarta.validation.constraints.NotNull; + +public record RsvpRequest( + @NotNull RsvpStatus status +) {} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/event/RsvpResponse.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/event/RsvpResponse.java new file mode 100644 index 0000000..84c4e96 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/event/RsvpResponse.java @@ -0,0 +1,13 @@ +package de.cannamanage.api.dto.event; + +import de.cannamanage.domain.enums.RsvpStatus; + +import java.time.Instant; +import java.util.UUID; + +public record RsvpResponse( + UUID memberId, + String memberName, + RsvpStatus status, + Instant respondedAt +) {} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/event/UpdateEventRequest.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/event/UpdateEventRequest.java new file mode 100644 index 0000000..38500c2 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/event/UpdateEventRequest.java @@ -0,0 +1,23 @@ +package de.cannamanage.api.dto.event; + +import de.cannamanage.domain.enums.EventType; +import de.cannamanage.domain.enums.RecurrenceRule; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.time.Instant; +import java.time.LocalDate; + +public record UpdateEventRequest( + @NotBlank @Size(max = 200) String title, + String description, + @NotNull EventType eventType, + @NotNull Instant startAt, + Instant endAt, + @Size(max = 300) String location, + Integer maxAttendees, + boolean recurring, + RecurrenceRule recurrenceRule, + LocalDate recurrenceEndDate +) {} diff --git a/cannamanage-api/src/main/resources/db/migration/V14__club_events.sql b/cannamanage-api/src/main/resources/db/migration/V14__club_events.sql new file mode 100644 index 0000000..b958380 --- /dev/null +++ b/cannamanage-api/src/main/resources/db/migration/V14__club_events.sql @@ -0,0 +1,40 @@ +-- Sprint 7 Phase 2.5: Club Event Calendar +-- Club events with RSVP support, recurring events, and iCal export + +CREATE TABLE club_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + club_id UUID NOT NULL REFERENCES clubs(id), + title VARCHAR(200) NOT NULL, + description TEXT, + event_type VARCHAR(50) NOT NULL, + start_at TIMESTAMP WITH TIME ZONE NOT NULL, + end_at TIMESTAMP WITH TIME ZONE, + location VARCHAR(300), + max_attendees INTEGER, + is_recurring BOOLEAN NOT NULL DEFAULT FALSE, + recurrence_rule VARCHAR(100), + recurrence_end_date DATE, + reminder_sent BOOLEAN NOT NULL DEFAULT FALSE, + created_by UUID NOT NULL REFERENCES users(id), + tenant_id UUID NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_club_events_tenant_start ON club_events(tenant_id, start_at); +CREATE INDEX idx_club_events_type ON club_events(tenant_id, event_type); +CREATE INDEX idx_club_events_club_id ON club_events(club_id); + +-- Event RSVPs +CREATE TABLE event_rsvps ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_id UUID NOT NULL REFERENCES club_events(id) ON DELETE CASCADE, + member_id UUID NOT NULL REFERENCES members(id), + status VARCHAR(20) NOT NULL, + responded_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + tenant_id UUID NOT NULL, + UNIQUE(event_id, member_id) +); + +CREATE INDEX idx_event_rsvps_event ON event_rsvps(event_id); +CREATE INDEX idx_event_rsvps_member ON event_rsvps(member_id); diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/ClubEvent.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/ClubEvent.java new file mode 100644 index 0000000..caf1a09 --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/ClubEvent.java @@ -0,0 +1,139 @@ +package de.cannamanage.domain.entity; + +import de.cannamanage.domain.enums.EventType; +import de.cannamanage.domain.enums.RecurrenceRule; +import jakarta.persistence.*; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * Club event entity — supports RSVP, recurring events, and iCal export. + */ +@Entity +@Table(name = "club_events", indexes = { + @Index(name = "idx_club_events_tenant_start", columnList = "tenant_id, start_at"), + @Index(name = "idx_club_events_type", columnList = "tenant_id, event_type"), + @Index(name = "idx_club_events_club_id", columnList = "club_id") +}) +public class ClubEvent extends AbstractTenantEntity { + + @Column(name = "club_id", nullable = false) + private UUID clubId; + + @Column(name = "title", nullable = false, length = 200) + private String title; + + @Column(name = "description", columnDefinition = "TEXT") + private String description; + + @Enumerated(EnumType.STRING) + @Column(name = "event_type", nullable = false, length = 50) + private EventType eventType; + + @Column(name = "start_at", nullable = false) + private Instant startAt; + + @Column(name = "end_at") + private Instant endAt; + + @Column(name = "location", length = 300) + private String location; + + @Column(name = "max_attendees") + private Integer maxAttendees; + + @Column(name = "is_recurring", nullable = false) + private boolean recurring = false; + + @Enumerated(EnumType.STRING) + @Column(name = "recurrence_rule", length = 100) + private RecurrenceRule recurrenceRule; + + @Column(name = "recurrence_end_date") + private LocalDate recurrenceEndDate; + + @Column(name = "reminder_sent", nullable = false) + private boolean reminderSent = false; + + @Column(name = "created_by", nullable = false) + private UUID createdBy; + + @Column(name = "updated_at") + private Instant updatedAt; + + @OneToMany(mappedBy = "event", cascade = CascadeType.ALL, orphanRemoval = true) + private List rsvps = new ArrayList<>(); + + public ClubEvent() {} + + public ClubEvent(UUID clubId, String title, String description, EventType eventType, + Instant startAt, Instant endAt, String location, Integer maxAttendees, + UUID createdBy) { + this.clubId = clubId; + this.title = title; + this.description = description; + this.eventType = eventType; + this.startAt = startAt; + this.endAt = endAt; + this.location = location; + this.maxAttendees = maxAttendees; + this.createdBy = createdBy; + this.updatedAt = Instant.now(); + } + + @PreUpdate + void onUpdate() { + this.updatedAt = Instant.now(); + } + + // Getters and Setters + + public UUID getClubId() { return clubId; } + public void setClubId(UUID clubId) { this.clubId = clubId; } + + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + + public EventType getEventType() { return eventType; } + public void setEventType(EventType eventType) { this.eventType = eventType; } + + public Instant getStartAt() { return startAt; } + public void setStartAt(Instant startAt) { this.startAt = startAt; } + + public Instant getEndAt() { return endAt; } + public void setEndAt(Instant endAt) { this.endAt = endAt; } + + public String getLocation() { return location; } + public void setLocation(String location) { this.location = location; } + + public Integer getMaxAttendees() { return maxAttendees; } + public void setMaxAttendees(Integer maxAttendees) { this.maxAttendees = maxAttendees; } + + public boolean isRecurring() { return recurring; } + public void setRecurring(boolean recurring) { this.recurring = recurring; } + + public RecurrenceRule getRecurrenceRule() { return recurrenceRule; } + public void setRecurrenceRule(RecurrenceRule recurrenceRule) { this.recurrenceRule = recurrenceRule; } + + public LocalDate getRecurrenceEndDate() { return recurrenceEndDate; } + public void setRecurrenceEndDate(LocalDate recurrenceEndDate) { this.recurrenceEndDate = recurrenceEndDate; } + + public boolean isReminderSent() { return reminderSent; } + public void setReminderSent(boolean reminderSent) { this.reminderSent = reminderSent; } + + public UUID getCreatedBy() { return createdBy; } + public void setCreatedBy(UUID createdBy) { this.createdBy = createdBy; } + + public Instant getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; } + + public List getRsvps() { return rsvps; } + public void setRsvps(List rsvps) { this.rsvps = rsvps; } +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/EventRsvp.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/EventRsvp.java new file mode 100644 index 0000000..b40b2c0 --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/EventRsvp.java @@ -0,0 +1,62 @@ +package de.cannamanage.domain.entity; + +import de.cannamanage.domain.enums.RsvpStatus; +import jakarta.persistence.*; + +import java.time.Instant; +import java.util.UUID; + +/** + * Event RSVP entity — tracks member attendance responses. + * Unique constraint on (event_id, member_id) ensures one RSVP per member per event. + */ +@Entity +@Table(name = "event_rsvps", + uniqueConstraints = @UniqueConstraint( + name = "uq_event_rsvps_event_member", + columnNames = {"event_id", "member_id"} + ), + indexes = { + @Index(name = "idx_event_rsvps_event", columnList = "event_id"), + @Index(name = "idx_event_rsvps_member", columnList = "member_id") + } +) +public class EventRsvp extends AbstractTenantEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "event_id", nullable = false) + private ClubEvent event; + + @Column(name = "member_id", nullable = false) + private UUID memberId; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private RsvpStatus status; + + @Column(name = "responded_at", nullable = false) + private Instant respondedAt; + + public EventRsvp() {} + + public EventRsvp(ClubEvent event, UUID memberId, RsvpStatus status) { + this.event = event; + this.memberId = memberId; + this.status = status; + this.respondedAt = Instant.now(); + } + + // Getters and Setters + + public ClubEvent getEvent() { return event; } + public void setEvent(ClubEvent event) { this.event = event; } + + public UUID getMemberId() { return memberId; } + public void setMemberId(UUID memberId) { this.memberId = memberId; } + + public RsvpStatus getStatus() { return status; } + public void setStatus(RsvpStatus status) { this.status = status; } + + public Instant getRespondedAt() { return respondedAt; } + public void setRespondedAt(Instant respondedAt) { this.respondedAt = respondedAt; } +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/AuditEventType.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/AuditEventType.java index bb01686..e83759e 100644 --- a/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/AuditEventType.java +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/AuditEventType.java @@ -51,6 +51,11 @@ public enum AuditEventType { INFO_BOARD_POST_PINNED, INFO_BOARD_POST_ARCHIVED, + // Sprint 7 — Event Calendar events + EVENT_CREATED, + EVENT_UPDATED, + EVENT_CANCELLED, + // Sprint 7 — Forum events FORUM_TOPIC_CREATED, FORUM_TOPIC_LOCKED, diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/EventType.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/EventType.java new file mode 100644 index 0000000..7d82931 --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/EventType.java @@ -0,0 +1,12 @@ +package de.cannamanage.domain.enums; + +/** + * Types of club events. + */ +public enum EventType { + MEETING, // Mitgliederversammlung + HARVEST_FESTIVAL, // Erntefest + BOARD_MEETING, // Vorstandssitzung + WORKSHOP, // Workshop + OTHER // Sonstiges +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/NotificationType.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/NotificationType.java index 125f710..b7e0607 100644 --- a/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/NotificationType.java +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/NotificationType.java @@ -12,5 +12,9 @@ public enum NotificationType { ADMIN_MESSAGE, INFO_BOARD_POST, FORUM_REPLY, - FORUM_MENTION + FORUM_MENTION, + // Sprint 7 Phase 2.5 — Events: + EVENT_CREATED, + EVENT_REMINDER, + EVENT_CANCELLED } diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/RecurrenceRule.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/RecurrenceRule.java new file mode 100644 index 0000000..f6aeca0 --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/RecurrenceRule.java @@ -0,0 +1,10 @@ +package de.cannamanage.domain.enums; + +/** + * Recurrence rules for recurring events. + */ +public enum RecurrenceRule { + WEEKLY, + BIWEEKLY, + MONTHLY +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/RsvpStatus.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/RsvpStatus.java new file mode 100644 index 0000000..1eaaa6b --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/RsvpStatus.java @@ -0,0 +1,10 @@ +package de.cannamanage.domain.enums; + +/** + * RSVP status for event attendance. + */ +public enum RsvpStatus { + ACCEPTED, + DECLINED, + MAYBE +} diff --git a/cannamanage-frontend/messages/de.json b/cannamanage-frontend/messages/de.json index 7b089d2..d8d231b 100644 --- a/cannamanage-frontend/messages/de.json +++ b/cannamanage-frontend/messages/de.json @@ -772,5 +772,41 @@ "noPosts": "Noch keine Beiträge vorhanden. Erstelle den ersten Beitrag!", "confirmDelete": "Möchtest du diesen Beitrag wirklich löschen?", "unreadCount": "{count} ungelesen" + }, + "events": { + "title": "Kalender", + "description": "Veranstaltungen und Termine des Vereins", + "portalTitle": "Veranstaltungen", + "portalDescription": "Kommende Termine und Events deines Vereins", + "createEvent": "Veranstaltung erstellen", + "upcomingEvents": "Nächste Termine", + "noUpcomingEvents": "Keine anstehenden Veranstaltungen", + "noEventsOnDay": "Keine Veranstaltungen an diesem Tag", + "cancel": "Absagen", + "full": "Ausgebucht", + "form": { + "title": "Titel", + "type": "Art", + "start": "Beginn", + "end": "Ende", + "location": "Ort", + "description": "Beschreibung", + "maxAttendees": "Max. Teilnehmer", + "recurring": "Wiederkehrend", + "recurrenceRule": "Wiederholung", + "recurrenceEnd": "Enddatum" + }, + "rsvp": { + "accept": "Zusage", + "decline": "Absage", + "maybe": "Vielleicht" + }, + "types": { + "MEETING": "Mitgliederversammlung", + "HARVEST_FESTIVAL": "Erntefest", + "BOARD_MEETING": "Vorstandssitzung", + "WORKSHOP": "Workshop", + "OTHER": "Sonstiges" + } } } diff --git a/cannamanage-frontend/messages/en.json b/cannamanage-frontend/messages/en.json index c2dc86c..822d179 100644 --- a/cannamanage-frontend/messages/en.json +++ b/cannamanage-frontend/messages/en.json @@ -717,5 +717,41 @@ "noPosts": "No posts yet. Create the first one!", "confirmDelete": "Are you sure you want to delete this post?", "unreadCount": "{count} unread" + }, + "events": { + "title": "Calendar", + "description": "Club events and appointments", + "portalTitle": "Events", + "portalDescription": "Upcoming events from your club", + "createEvent": "Create Event", + "upcomingEvents": "Upcoming Events", + "noUpcomingEvents": "No upcoming events", + "noEventsOnDay": "No events on this day", + "cancel": "Cancel", + "full": "Fully booked", + "form": { + "title": "Title", + "type": "Type", + "start": "Start", + "end": "End", + "location": "Location", + "description": "Description", + "maxAttendees": "Max Attendees", + "recurring": "Recurring", + "recurrenceRule": "Recurrence", + "recurrenceEnd": "End Date" + }, + "rsvp": { + "accept": "Accept", + "decline": "Decline", + "maybe": "Maybe" + }, + "types": { + "MEETING": "Member Meeting", + "HARVEST_FESTIVAL": "Harvest Festival", + "BOARD_MEETING": "Board Meeting", + "WORKSHOP": "Workshop", + "OTHER": "Other" + } } } diff --git a/cannamanage-frontend/package.json b/cannamanage-frontend/package.json index 0a38a1b..579a89c 100644 --- a/cannamanage-frontend/package.json +++ b/cannamanage-frontend/package.json @@ -41,6 +41,7 @@ "@radix-ui/react-scroll-area": "1.1.0", "@radix-ui/react-separator": "1.1.1", "@radix-ui/react-slot": "1.1.1", + "@radix-ui/react-switch": "^1.3.0", "@radix-ui/react-toast": "1.2.1", "@radix-ui/react-tooltip": "1.1.5", "@tanstack/react-query": "^5.101.0", diff --git a/cannamanage-frontend/pnpm-lock.yaml b/cannamanage-frontend/pnpm-lock.yaml index aa7605f..74560a5 100644 --- a/cannamanage-frontend/pnpm-lock.yaml +++ b/cannamanage-frontend/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: '@radix-ui/react-slot': specifier: 1.1.1 version: 1.1.1(@types/react@19.0.12)(react@19.1.3) + '@radix-ui/react-switch': + specifier: ^1.3.0 + version: 1.3.0(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.1.3(react@19.1.3))(react@19.1.3) '@radix-ui/react-toast': specifier: 1.2.1 version: 1.2.1(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.1.3(react@19.1.3))(react@19.1.3) @@ -1514,6 +1517,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-switch@1.3.0': + resolution: {integrity: sha512-GP1EZwhoZO/GGnhM1P5/2Vpm8iN8EnngyU0oezn2l78kN8tj25pyrvjIaT7azBhK615KSt+P2w39y57YV5jVkA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-toast@1.2.1': resolution: {integrity: sha512-5trl7piMXcZiCq7MW6r8YYmu0bK5qDpTWz+FdEPdKyft2UixkspheYbjbrLXVN5NGKHFbOP7lm8eD0biiSqZqg==} peerDependencies: @@ -5761,6 +5777,21 @@ snapshots: optionalDependencies: '@types/react': 19.0.12 + '@radix-ui/react-switch@1.3.0(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.1.3(react@19.1.3))(react@19.1.3)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.0.12)(react@19.1.3) + '@radix-ui/react-context': 1.1.4(@types/react@19.0.12)(react@19.1.3) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.1.3(react@19.1.3))(react@19.1.3) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.0.12)(react@19.1.3) + '@radix-ui/react-use-previous': 1.1.2(@types/react@19.0.12)(react@19.1.3) + '@radix-ui/react-use-size': 1.1.2(@types/react@19.0.12)(react@19.1.3) + react: 19.1.3 + react-dom: 19.1.3(react@19.1.3) + optionalDependencies: + '@types/react': 19.0.12 + '@types/react-dom': 19.0.4(@types/react@19.0.12) + '@radix-ui/react-toast@1.2.1(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.1.3(react@19.1.3))(react@19.1.3)': dependencies: '@radix-ui/primitive': 1.1.0 diff --git a/cannamanage-frontend/src/app/(dashboard-layout)/calendar/page.tsx b/cannamanage-frontend/src/app/(dashboard-layout)/calendar/page.tsx new file mode 100644 index 0000000..927ca5f --- /dev/null +++ b/cannamanage-frontend/src/app/(dashboard-layout)/calendar/page.tsx @@ -0,0 +1,437 @@ +"use client" + +import { useState } from "react" +import { + getEventTypeColor, + getEventTypeLabel, + getIcalUrl, + useCancelEventMutation, + useCreateEventMutation, + useEventsQuery, +} from "@/services/events" +import { + addMonths, + eachDayOfInterval, + endOfMonth, + endOfWeek, + format, + isSameDay, + isSameMonth, + startOfMonth, + startOfWeek, + subMonths, +} from "date-fns" +import { de } from "date-fns/locale" +import { useTranslations } from "next-intl" +import { + Calendar, + ChevronLeft, + ChevronRight, + Download, + MapPin, + Plus, + Trash2, + Users, +} from "lucide-react" + +import type { ClubEvent, EventType, RecurrenceRule } from "@/services/events" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Select } from "@/components/ui/select" +import { Switch } from "@/components/ui/switch" +import { Textarea } from "@/components/ui/textarea" + +export default function CalendarPage() { + const t = useTranslations("events") + const [currentMonth, setCurrentMonth] = useState(new Date()) + const [selectedDate, setSelectedDate] = useState(null) + const [showCreateDialog, setShowCreateDialog] = useState(false) + + const monthStart = startOfMonth(currentMonth) + const monthEnd = endOfMonth(currentMonth) + const calendarStart = startOfWeek(monthStart, { weekStartsOn: 1 }) + const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 1 }) + + const from = monthStart.toISOString() + const to = monthEnd.toISOString() + + const { data: events = [] as ClubEvent[], isLoading } = useEventsQuery( + from, + to + ) + const cancelEvent = useCancelEventMutation() + + const days = eachDayOfInterval({ start: calendarStart, end: calendarEnd }) + const weekDays = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"] + + const getEventsForDay = (day: Date): ClubEvent[] => + events.filter((event: ClubEvent) => isSameDay(new Date(event.startAt), day)) + + const selectedDayEvents: ClubEvent[] = selectedDate + ? events.filter((event: ClubEvent) => + isSameDay(new Date(event.startAt), selectedDate) + ) + : [] + + const upcomingEvents = events + .filter((e: ClubEvent) => new Date(e.startAt) >= new Date()) + .sort( + (a: ClubEvent, b: ClubEvent) => + new Date(a.startAt).getTime() - new Date(b.startAt).getTime() + ) + .slice(0, 5) + + return ( +
+ {/* Header */} +
+
+

{t("title")}

+

{t("description")}

+
+ + + + + + + {t("createEvent")} + + setShowCreateDialog(false)} /> + + +
+ +
+ {/* Calendar Grid */} + + + + + {format(currentMonth, "MMMM yyyy", { locale: de })} + + + + + {/* Week day headers */} +
+ {weekDays.map((day) => ( +
+ {day} +
+ ))} +
+ {/* Day cells */} +
+ {days.map((day) => { + const dayEvents = getEventsForDay(day) + const isCurrentMonth = isSameMonth(day, currentMonth) + const isSelected = selectedDate && isSameDay(day, selectedDate) + const isToday = isSameDay(day, new Date()) + + return ( + + ) + })} +
+
+
+ + {/* Sidebar: Selected day events or upcoming */} +
+ {selectedDate ? ( + + + + {format(selectedDate, "EEEE, d. MMMM", { locale: de })} + + + + {selectedDayEvents.length === 0 ? ( +

+ {t("noEventsOnDay")} +

+ ) : ( +
+ {selectedDayEvents.map((event: ClubEvent) => ( + cancelEvent.mutate(event.id)} + /> + ))} +
+ )} +
+
+ ) : ( + + + + + {t("upcomingEvents")} + + + + {isLoading ? ( +

Laden...

+ ) : upcomingEvents.length === 0 ? ( +

+ {t("noUpcomingEvents")} +

+ ) : ( +
+ {upcomingEvents.map((event: ClubEvent) => ( + cancelEvent.mutate(event.id)} + /> + ))} +
+ )} +
+
+ )} +
+
+
+ ) +} + +function EventCard({ + event, + onCancel, +}: { + event: ClubEvent + onCancel: () => void +}) { + const t = useTranslations("events") + const accepted = event.attendeeCounts?.ACCEPTED ?? 0 + const maybe = event.attendeeCounts?.MAYBE ?? 0 + + return ( +
+
+
+
+ + {getEventTypeLabel(event.eventType)} + + {event.recurring && ( + + 🔁 + + )} +
+

{event.title}

+
+
+
+

+ 📅{" "} + {format(new Date(event.startAt), "dd.MM.yyyy HH:mm", { locale: de })} +

+ {event.location && ( +

+ {event.location} +

+ )} +

+ {accepted} Zusagen, {maybe} Vielleicht + {event.maxAttendees && ` / max. ${event.maxAttendees}`} +

+
+
+ + + + +
+
+ ) +} + +function CreateEventForm({ onSuccess }: { onSuccess: () => void }) { + const t = useTranslations("events") + const createEvent = useCreateEventMutation() + const [recurring, setRecurring] = useState(false) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + const formData = new FormData(e.currentTarget) + + createEvent.mutate( + { + title: formData.get("title") as string, + description: (formData.get("description") as string) || undefined, + eventType: formData.get("eventType") as EventType, + startAt: new Date(formData.get("startAt") as string).toISOString(), + endAt: formData.get("endAt") + ? new Date(formData.get("endAt") as string).toISOString() + : undefined, + location: (formData.get("location") as string) || undefined, + maxAttendees: formData.get("maxAttendees") + ? Number(formData.get("maxAttendees")) + : undefined, + recurring, + recurrenceRule: recurring + ? (formData.get("recurrenceRule") as RecurrenceRule) + : undefined, + recurrenceEndDate: + recurring && formData.get("recurrenceEndDate") + ? (formData.get("recurrenceEndDate") as string) + : undefined, + postToInfoBoard: true, + }, + { onSuccess } + ) + } + + return ( +
+
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ +