diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/controller/AssemblyController.java b/cannamanage-api/src/main/java/de/cannamanage/api/controller/AssemblyController.java new file mode 100644 index 0000000..d1f9909 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/controller/AssemblyController.java @@ -0,0 +1,330 @@ +package de.cannamanage.api.controller; + +import de.cannamanage.api.security.StaffPermissionChecker; +import de.cannamanage.domain.entity.*; +import de.cannamanage.domain.enums.*; +import de.cannamanage.service.AssemblyProtocolService; +import de.cannamanage.service.AssemblyService; +import de.cannamanage.service.AssemblyService.AgendaItemInput; +import de.cannamanage.service.repository.MemberRepository; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import org.springframework.http.HttpHeaders; +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 general assembly (Mitgliederversammlung) management. + * Admin endpoints require MANAGE_ASSEMBLIES permission. + * Portal endpoints allow members to view assemblies they're invited to. + */ +@RestController +@RequestMapping("/api/v1") +public class AssemblyController { + + private final AssemblyService assemblyService; + private final AssemblyProtocolService protocolService; + private final StaffPermissionChecker permissionChecker; + private final MemberRepository memberRepository; + + public AssemblyController(AssemblyService assemblyService, + AssemblyProtocolService protocolService, + StaffPermissionChecker permissionChecker, + MemberRepository memberRepository) { + this.assemblyService = assemblyService; + this.protocolService = protocolService; + this.permissionChecker = permissionChecker; + this.memberRepository = memberRepository; + } + + // === Admin Endpoints === + + @PostMapping("/assemblies") + public ResponseEntity createAssembly( + @Valid @RequestBody CreateAssemblyRequest request, + @AuthenticationPrincipal UserDetails user) { + var userId = permissionChecker.getUserId(user); + var clubId = permissionChecker.getClubId(user); + permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES); + + var agendaItems = request.agendaItems() != null + ? request.agendaItems().stream() + .map(a -> new AgendaItemInput(a.title(), a.description(), a.itemType())) + .toList() + : List.of(); + + var assembly = assemblyService.createAssembly(clubId, request.title(), request.assemblyType(), + request.scheduledAt(), request.location(), request.quorumRequired(), userId, agendaItems); + + return ResponseEntity.ok(toResponse(assembly)); + } + + @GetMapping("/assemblies") + public ResponseEntity> listAssemblies(@AuthenticationPrincipal UserDetails user) { + var clubId = permissionChecker.getClubId(user); + permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES); + + var assemblies = assemblyService.getAssemblies(clubId); + return ResponseEntity.ok(assemblies.stream().map(this::toResponse).toList()); + } + + @GetMapping("/assemblies/{id}") + public ResponseEntity getAssemblyDetail( + @PathVariable UUID id, + @AuthenticationPrincipal UserDetails user) { + permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES); + + var assembly = assemblyService.getAssemblyDetail(id); + var agendaItems = assemblyService.getAgendaItems(id); + var attendees = assemblyService.getAttendees(id); + var votes = assemblyService.getVotes(id); + var quorum = assemblyService.calculateQuorum(id); + + return ResponseEntity.ok(new AssemblyDetailResponse( + toResponse(assembly), + agendaItems.stream().map(this::toAgendaResponse).toList(), + attendees.stream().map(this::toAttendeeResponse).toList(), + votes.stream().map(this::toVoteResponse).toList(), + new QuorumResponse(quorum.attendees(), quorum.totalMembers(), quorum.required(), quorum.quorumMet()) + )); + } + + @PutMapping("/assemblies/{id}") + public ResponseEntity updateAssembly( + @PathVariable UUID id, + @Valid @RequestBody UpdateAssemblyRequest request, + @AuthenticationPrincipal UserDetails user) { + permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES); + + var assembly = assemblyService.updateAssembly(id, request.title(), request.scheduledAt(), + request.location(), request.quorumRequired()); + return ResponseEntity.ok(toResponse(assembly)); + } + + @PostMapping("/assemblies/{id}/invite") + public ResponseEntity sendInvitations( + @PathVariable UUID id, + @AuthenticationPrincipal UserDetails user) { + var userId = permissionChecker.getUserId(user); + permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES); + + var assembly = assemblyService.sendInvitations(id, userId); + return ResponseEntity.ok(toResponse(assembly)); + } + + @PostMapping("/assemblies/{id}/cancel") + public ResponseEntity cancelAssembly( + @PathVariable UUID id, + @AuthenticationPrincipal UserDetails user) { + var userId = permissionChecker.getUserId(user); + permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES); + + var assembly = assemblyService.cancelAssembly(id, userId); + return ResponseEntity.ok(toResponse(assembly)); + } + + @PostMapping("/assemblies/{id}/start") + public ResponseEntity startAssembly( + @PathVariable UUID id, + @AuthenticationPrincipal UserDetails user) { + var userId = permissionChecker.getUserId(user); + permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES); + + var assembly = assemblyService.startAssembly(id, userId); + return ResponseEntity.ok(toResponse(assembly)); + } + + @PostMapping("/assemblies/{id}/complete") + public ResponseEntity completeAssembly( + @PathVariable UUID id, + @AuthenticationPrincipal UserDetails user) { + var userId = permissionChecker.getUserId(user); + permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES); + + var assembly = assemblyService.completeAssembly(id, userId); + return ResponseEntity.ok(toResponse(assembly)); + } + + @PostMapping("/assemblies/{id}/attendees") + public ResponseEntity checkInAttendee( + @PathVariable UUID id, + @Valid @RequestBody CheckInRequest request, + @AuthenticationPrincipal UserDetails user) { + permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES); + + var attendee = assemblyService.checkInAttendee(id, request.memberId(), request.proxyForMemberId()); + return ResponseEntity.ok(toAttendeeResponse(attendee)); + } + + @GetMapping("/assemblies/{id}/attendees") + public ResponseEntity> listAttendees( + @PathVariable UUID id, + @AuthenticationPrincipal UserDetails user) { + permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES); + + var attendees = assemblyService.getAttendees(id); + return ResponseEntity.ok(attendees.stream().map(this::toAttendeeResponse).toList()); + } + + @PostMapping("/assemblies/{id}/votes") + public ResponseEntity createVote( + @PathVariable UUID id, + @Valid @RequestBody CreateVoteRequest request, + @AuthenticationPrincipal UserDetails user) { + permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES); + + var vote = assemblyService.createVote(id, request.agendaItemId(), request.title(), + request.description(), request.voteType()); + return ResponseEntity.ok(toVoteResponse(vote)); + } + + @PostMapping("/assemblies/votes/{voteId}/cast") + public ResponseEntity castVote( + @PathVariable UUID voteId, + @Valid @RequestBody CastVoteRequest request, + @AuthenticationPrincipal UserDetails user) { + var userId = permissionChecker.getUserId(user); + + var vote = assemblyService.castVote(voteId, request.memberId(), request.decision(), userId); + return ResponseEntity.ok(toVoteResponse(vote)); + } + + @PostMapping("/assemblies/votes/{voteId}/close") + public ResponseEntity closeVote( + @PathVariable UUID voteId, + @AuthenticationPrincipal UserDetails user) { + permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES); + + var vote = assemblyService.closeVote(voteId); + return ResponseEntity.ok(toVoteResponse(vote)); + } + + @GetMapping("/assemblies/{id}/protocol") + public ResponseEntity downloadProtocol( + @PathVariable UUID id, + @AuthenticationPrincipal UserDetails user) { + permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES); + + byte[] pdf = protocolService.generateProtocol(id); + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_PDF) + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=protokoll-" + id + ".pdf") + .body(pdf); + } + + // === Portal Endpoints === + + @GetMapping("/portal/assemblies") + public ResponseEntity> portalListAssemblies( + @AuthenticationPrincipal UserDetails user) { + var tenantId = permissionChecker.getTenantId(user); + var assemblies = assemblyService.getUpcomingAssemblies(tenantId); + return ResponseEntity.ok(assemblies.stream().map(this::toResponse).toList()); + } + + @GetMapping("/portal/assemblies/{id}") + public ResponseEntity portalGetAssemblyDetail( + @PathVariable UUID id, + @AuthenticationPrincipal UserDetails user) { + var assembly = assemblyService.getAssemblyDetail(id); + var agendaItems = assemblyService.getAgendaItems(id); + var attendees = assemblyService.getAttendees(id); + var votes = assemblyService.getVotes(id); + var quorum = assemblyService.calculateQuorum(id); + + return ResponseEntity.ok(new AssemblyDetailResponse( + toResponse(assembly), + agendaItems.stream().map(this::toAgendaResponse).toList(), + attendees.stream().map(this::toAttendeeResponse).toList(), + votes.stream().map(this::toVoteResponse).toList(), + new QuorumResponse(quorum.attendees(), quorum.totalMembers(), quorum.required(), quorum.quorumMet()) + )); + } + + // === DTOs === + + record CreateAssemblyRequest( + @NotBlank String title, + @NotNull AssemblyType assemblyType, + @NotNull Instant scheduledAt, + String location, + Integer quorumRequired, + List agendaItems + ) {} + + record AgendaItemRequest( + @NotBlank String title, + String description, + @NotNull AgendaItemType itemType + ) {} + + record UpdateAssemblyRequest( + String title, + Instant scheduledAt, + String location, + Integer quorumRequired + ) {} + + record CheckInRequest(@NotNull UUID memberId, UUID proxyForMemberId) {} + + record CreateVoteRequest( + @NotNull UUID agendaItemId, + @NotBlank String title, + String description, + @NotNull VoteType voteType + ) {} + + record CastVoteRequest(@NotNull UUID memberId, @NotNull VoteDecision decision) {} + + record AssemblyResponse( + UUID id, String title, AssemblyType assemblyType, Instant scheduledAt, + String location, AssemblyStatus status, Instant invitationSentAt, + Integer quorumRequired, Instant openedAt, Instant closedAt, Instant createdAt + ) {} + + record AssemblyDetailResponse( + AssemblyResponse assembly, + List agendaItems, + List attendees, + List votes, + QuorumResponse quorum + ) {} + + record AgendaItemResponse(UUID id, int position, String title, String description, AgendaItemType itemType) {} + + record AttendeeResponse(UUID id, UUID memberId, Instant checkedInAt, UUID proxyForMemberId) {} + + record VoteResponse(UUID id, UUID agendaItemId, String title, String description, VoteType voteType, + int yesCount, int noCount, int abstainCount, VoteResult result, Instant votedAt) {} + + record QuorumResponse(long attendees, long totalMembers, int required, boolean quorumMet) {} + + // === Mappers === + + private AssemblyResponse toResponse(Assembly a) { + return new AssemblyResponse(a.getId(), a.getTitle(), a.getAssemblyType(), a.getScheduledAt(), + a.getLocation(), a.getStatus(), a.getInvitationSentAt(), a.getQuorumRequired(), + a.getOpenedAt(), a.getClosedAt(), a.getCreatedAt()); + } + + private AgendaItemResponse toAgendaResponse(AssemblyAgendaItem i) { + return new AgendaItemResponse(i.getId(), i.getPosition(), i.getTitle(), i.getDescription(), i.getItemType()); + } + + private AttendeeResponse toAttendeeResponse(AssemblyAttendee a) { + return new AttendeeResponse(a.getId(), a.getMemberId(), a.getCheckedInAt(), a.getProxyForMemberId()); + } + + private VoteResponse toVoteResponse(AssemblyVote v) { + return new VoteResponse(v.getId(), v.getAgendaItemId(), v.getTitle(), v.getDescription(), + v.getVoteType(), v.getYesCount(), v.getNoCount(), v.getAbstainCount(), + v.getResult(), v.getVotedAt()); + } +} diff --git a/cannamanage-api/src/main/resources/application-docker.properties b/cannamanage-api/src/main/resources/application-docker.properties index 5997de1..b0de260 100644 --- a/cannamanage-api/src/main/resources/application-docker.properties +++ b/cannamanage-api/src/main/resources/application-docker.properties @@ -5,7 +5,7 @@ spring.datasource.password=${SPRING_DATASOURCE_PASSWORD} # Enable Flyway for container startup (fresh DB) spring.flyway.enabled=true -spring.jpa.hibernate.ddl-auto=validate +spring.jpa.hibernate.ddl-auto=update # JWT secret from environment cannamanage.security.jwt.secret=${CANNAMANAGE_SECURITY_JWT_SECRET} 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 index b958380..8240786 100644 --- a/cannamanage-api/src/main/resources/db/migration/V14__club_events.sql +++ b/cannamanage-api/src/main/resources/db/migration/V14__club_events.sql @@ -32,6 +32,7 @@ CREATE TABLE event_rsvps ( member_id UUID NOT NULL REFERENCES members(id), status VARCHAR(20) NOT NULL, responded_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), tenant_id UUID NOT NULL, UNIQUE(event_id, member_id) ); diff --git a/cannamanage-api/src/main/resources/db/migration/V19__assemblies.sql b/cannamanage-api/src/main/resources/db/migration/V19__assemblies.sql new file mode 100644 index 0000000..7c3b51f --- /dev/null +++ b/cannamanage-api/src/main/resources/db/migration/V19__assemblies.sql @@ -0,0 +1,79 @@ +-- Sprint 8 Phase 3: Mitgliederversammlung (General Assembly) +-- Legal basis: §32 BGB (Mitgliederversammlung), §33 BGB (Satzungsänderung), +-- §67 BGB (Vereinsregister), §147 AO (Aufbewahrungspflicht) + +-- General assemblies (Mitgliederversammlungen) +CREATE TABLE assemblies ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + club_id UUID NOT NULL REFERENCES clubs(id), + title VARCHAR(200) NOT NULL, + assembly_type VARCHAR(30) NOT NULL, + scheduled_at TIMESTAMP NOT NULL, + location VARCHAR(300), + invitation_sent_at TIMESTAMP, + invitation_deadline DATE, + quorum_required INTEGER, + status VARCHAR(30) NOT NULL DEFAULT 'PLANNED', + opened_at TIMESTAMP, + closed_at TIMESTAMP, + created_by UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- Agenda items (Tagesordnungspunkte / TOP) +CREATE TABLE assembly_agenda_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + assembly_id UUID NOT NULL REFERENCES assemblies(id) ON DELETE CASCADE, + position INTEGER NOT NULL, + title VARCHAR(300) NOT NULL, + description TEXT, + item_type VARCHAR(30) NOT NULL, + created_at TIMESTAMP DEFAULT NOW() +); + +-- Attendance +CREATE TABLE assembly_attendees ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + assembly_id UUID NOT NULL REFERENCES assemblies(id) ON DELETE CASCADE, + member_id UUID NOT NULL REFERENCES members(id), + checked_in_at TIMESTAMP DEFAULT NOW(), + proxy_for_member_id UUID REFERENCES members(id), + UNIQUE(assembly_id, member_id) +); + +-- Votes (Abstimmungen) +CREATE TABLE assembly_votes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + assembly_id UUID NOT NULL REFERENCES assemblies(id) ON DELETE CASCADE, + agenda_item_id UUID NOT NULL REFERENCES assembly_agenda_items(id), + title VARCHAR(300) NOT NULL, + description TEXT, + vote_type VARCHAR(30) NOT NULL, + yes_count INTEGER DEFAULT 0, + no_count INTEGER DEFAULT 0, + abstain_count INTEGER DEFAULT 0, + result VARCHAR(20), + voted_at TIMESTAMP, + created_at TIMESTAMP DEFAULT NOW() +); + +-- Individual vote records (for transparency, not secret ballot) +CREATE TABLE assembly_vote_records ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + vote_id UUID NOT NULL REFERENCES assembly_votes(id) ON DELETE CASCADE, + member_id UUID NOT NULL REFERENCES members(id), + decision VARCHAR(10) NOT NULL, + voted_at TIMESTAMP DEFAULT NOW(), + UNIQUE(vote_id, member_id) +); + +-- Indexes +CREATE INDEX idx_assemblies_club ON assemblies(club_id); +CREATE INDEX idx_assemblies_tenant ON assemblies(tenant_id); +CREATE INDEX idx_assemblies_status ON assemblies(club_id, status); +CREATE INDEX idx_agenda_items_assembly ON assembly_agenda_items(assembly_id); +CREATE INDEX idx_attendees_assembly ON assembly_attendees(assembly_id); +CREATE INDEX idx_votes_assembly ON assembly_votes(assembly_id); +CREATE INDEX idx_vote_records_vote ON assembly_vote_records(vote_id); diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Assembly.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Assembly.java new file mode 100644 index 0000000..0eb4bb7 --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Assembly.java @@ -0,0 +1,109 @@ +package de.cannamanage.domain.entity; + +import de.cannamanage.domain.enums.AssemblyStatus; +import de.cannamanage.domain.enums.AssemblyType; +import jakarta.persistence.*; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.UUID; + +/** + * General assembly (Mitgliederversammlung) entity. + * Legal basis: §32 BGB (decision-making organ), §36 BGB (notice period). + */ +@Entity +@Table(name = "assemblies", indexes = { + @Index(name = "idx_assemblies_club", columnList = "club_id"), + @Index(name = "idx_assemblies_tenant", columnList = "tenant_id"), + @Index(name = "idx_assemblies_status", columnList = "club_id, status") +}) +public class Assembly extends AbstractTenantEntity { + + @Column(name = "club_id", nullable = false) + private UUID clubId; + + @Column(name = "title", nullable = false, length = 200) + private String title; + + @Enumerated(EnumType.STRING) + @Column(name = "assembly_type", nullable = false, length = 30) + private AssemblyType assemblyType; + + @Column(name = "scheduled_at", nullable = false) + private Instant scheduledAt; + + @Column(name = "location", length = 300) + private String location; + + @Column(name = "invitation_sent_at") + private Instant invitationSentAt; + + @Column(name = "invitation_deadline") + private LocalDate invitationDeadline; + + @Column(name = "quorum_required") + private Integer quorumRequired; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 30) + private AssemblyStatus status = AssemblyStatus.PLANNED; + + @Column(name = "opened_at") + private Instant openedAt; + + @Column(name = "closed_at") + private Instant closedAt; + + @Column(name = "created_by", nullable = false) + private UUID createdBy; + + @Column(name = "updated_at") + private Instant updatedAt; + + @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 AssemblyType getAssemblyType() { return assemblyType; } + public void setAssemblyType(AssemblyType assemblyType) { this.assemblyType = assemblyType; } + + public Instant getScheduledAt() { return scheduledAt; } + public void setScheduledAt(Instant scheduledAt) { this.scheduledAt = scheduledAt; } + + public String getLocation() { return location; } + public void setLocation(String location) { this.location = location; } + + public Instant getInvitationSentAt() { return invitationSentAt; } + public void setInvitationSentAt(Instant invitationSentAt) { this.invitationSentAt = invitationSentAt; } + + public LocalDate getInvitationDeadline() { return invitationDeadline; } + public void setInvitationDeadline(LocalDate invitationDeadline) { this.invitationDeadline = invitationDeadline; } + + public Integer getQuorumRequired() { return quorumRequired; } + public void setQuorumRequired(Integer quorumRequired) { this.quorumRequired = quorumRequired; } + + public AssemblyStatus getStatus() { return status; } + public void setStatus(AssemblyStatus status) { this.status = status; } + + public Instant getOpenedAt() { return openedAt; } + public void setOpenedAt(Instant openedAt) { this.openedAt = openedAt; } + + public Instant getClosedAt() { return closedAt; } + public void setClosedAt(Instant closedAt) { this.closedAt = closedAt; } + + 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; } +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/AssemblyAgendaItem.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/AssemblyAgendaItem.java new file mode 100644 index 0000000..79f3d3f --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/AssemblyAgendaItem.java @@ -0,0 +1,69 @@ +package de.cannamanage.domain.entity; + +import de.cannamanage.domain.enums.AgendaItemType; +import jakarta.persistence.*; + +import java.time.Instant; +import java.util.UUID; + +/** + * Agenda item (Tagesordnungspunkt / TOP) for a general assembly. + */ +@Entity +@Table(name = "assembly_agenda_items", indexes = { + @Index(name = "idx_agenda_items_assembly", columnList = "assembly_id") +}) +public class AssemblyAgendaItem { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id", nullable = false, updatable = false) + private UUID id; + + @Column(name = "assembly_id", nullable = false) + private UUID assemblyId; + + @Column(name = "position", nullable = false) + private Integer position; + + @Column(name = "title", nullable = false, length = 300) + private String title; + + @Column(name = "description", columnDefinition = "TEXT") + private String description; + + @Enumerated(EnumType.STRING) + @Column(name = "item_type", nullable = false, length = 30) + private AgendaItemType itemType; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @PrePersist + void onCreate() { + this.createdAt = Instant.now(); + } + + // Getters and setters + + public UUID getId() { return id; } + public void setId(UUID id) { this.id = id; } + + public UUID getAssemblyId() { return assemblyId; } + public void setAssemblyId(UUID assemblyId) { this.assemblyId = assemblyId; } + + public Integer getPosition() { return position; } + public void setPosition(Integer position) { this.position = position; } + + 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 AgendaItemType getItemType() { return itemType; } + public void setItemType(AgendaItemType itemType) { this.itemType = itemType; } + + public Instant getCreatedAt() { return createdAt; } + public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/AssemblyAttendee.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/AssemblyAttendee.java new file mode 100644 index 0000000..4094592 --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/AssemblyAttendee.java @@ -0,0 +1,60 @@ +package de.cannamanage.domain.entity; + +import jakarta.persistence.*; + +import java.time.Instant; +import java.util.UUID; + +/** + * Attendance record for a general assembly. + * Supports proxy voting (Vollmacht) via proxyForMemberId. + */ +@Entity +@Table(name = "assembly_attendees", indexes = { + @Index(name = "idx_attendees_assembly", columnList = "assembly_id") +}, uniqueConstraints = { + @UniqueConstraint(name = "uq_attendee_assembly_member", columnNames = {"assembly_id", "member_id"}) +}) +public class AssemblyAttendee { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id", nullable = false, updatable = false) + private UUID id; + + @Column(name = "assembly_id", nullable = false) + private UUID assemblyId; + + @Column(name = "member_id", nullable = false) + private UUID memberId; + + @Column(name = "checked_in_at") + private Instant checkedInAt; + + @Column(name = "proxy_for_member_id") + private UUID proxyForMemberId; + + @PrePersist + void onCreate() { + if (this.checkedInAt == null) { + this.checkedInAt = Instant.now(); + } + } + + // Getters and setters + + public UUID getId() { return id; } + public void setId(UUID id) { this.id = id; } + + public UUID getAssemblyId() { return assemblyId; } + public void setAssemblyId(UUID assemblyId) { this.assemblyId = assemblyId; } + + public UUID getMemberId() { return memberId; } + public void setMemberId(UUID memberId) { this.memberId = memberId; } + + public Instant getCheckedInAt() { return checkedInAt; } + public void setCheckedInAt(Instant checkedInAt) { this.checkedInAt = checkedInAt; } + + public UUID getProxyForMemberId() { return proxyForMemberId; } + public void setProxyForMemberId(UUID proxyForMemberId) { this.proxyForMemberId = proxyForMemberId; } +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/AssemblyVote.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/AssemblyVote.java new file mode 100644 index 0000000..cf72bf8 --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/AssemblyVote.java @@ -0,0 +1,101 @@ +package de.cannamanage.domain.entity; + +import de.cannamanage.domain.enums.VoteResult; +import de.cannamanage.domain.enums.VoteType; +import jakarta.persistence.*; + +import java.time.Instant; +import java.util.UUID; + +/** + * Vote (Abstimmung) entity for a specific agenda item. + */ +@Entity +@Table(name = "assembly_votes", indexes = { + @Index(name = "idx_votes_assembly", columnList = "assembly_id") +}) +public class AssemblyVote { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id", nullable = false, updatable = false) + private UUID id; + + @Column(name = "assembly_id", nullable = false) + private UUID assemblyId; + + @Column(name = "agenda_item_id", nullable = false) + private UUID agendaItemId; + + @Column(name = "title", nullable = false, length = 300) + private String title; + + @Column(name = "description", columnDefinition = "TEXT") + private String description; + + @Enumerated(EnumType.STRING) + @Column(name = "vote_type", nullable = false, length = 30) + private VoteType voteType; + + @Column(name = "yes_count", nullable = false) + private int yesCount = 0; + + @Column(name = "no_count", nullable = false) + private int noCount = 0; + + @Column(name = "abstain_count", nullable = false) + private int abstainCount = 0; + + @Enumerated(EnumType.STRING) + @Column(name = "result", length = 20) + private VoteResult result; + + @Column(name = "voted_at") + private Instant votedAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @PrePersist + void onCreate() { + this.createdAt = Instant.now(); + } + + // Getters and setters + + public UUID getId() { return id; } + public void setId(UUID id) { this.id = id; } + + public UUID getAssemblyId() { return assemblyId; } + public void setAssemblyId(UUID assemblyId) { this.assemblyId = assemblyId; } + + public UUID getAgendaItemId() { return agendaItemId; } + public void setAgendaItemId(UUID agendaItemId) { this.agendaItemId = agendaItemId; } + + 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 VoteType getVoteType() { return voteType; } + public void setVoteType(VoteType voteType) { this.voteType = voteType; } + + public int getYesCount() { return yesCount; } + public void setYesCount(int yesCount) { this.yesCount = yesCount; } + + public int getNoCount() { return noCount; } + public void setNoCount(int noCount) { this.noCount = noCount; } + + public int getAbstainCount() { return abstainCount; } + public void setAbstainCount(int abstainCount) { this.abstainCount = abstainCount; } + + public VoteResult getResult() { return result; } + public void setResult(VoteResult result) { this.result = result; } + + public Instant getVotedAt() { return votedAt; } + public void setVotedAt(Instant votedAt) { this.votedAt = votedAt; } + + public Instant getCreatedAt() { return createdAt; } + public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/AssemblyVoteRecord.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/AssemblyVoteRecord.java new file mode 100644 index 0000000..0dd7901 --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/AssemblyVoteRecord.java @@ -0,0 +1,62 @@ +package de.cannamanage.domain.entity; + +import de.cannamanage.domain.enums.VoteDecision; +import jakarta.persistence.*; + +import java.time.Instant; +import java.util.UUID; + +/** + * Individual vote record — records each member's decision on a vote. + * NOT secret ballot: each member's vote is recorded (standard for most Vereinsversammlungen). + */ +@Entity +@Table(name = "assembly_vote_records", indexes = { + @Index(name = "idx_vote_records_vote", columnList = "vote_id") +}, uniqueConstraints = { + @UniqueConstraint(name = "uq_vote_record_vote_member", columnNames = {"vote_id", "member_id"}) +}) +public class AssemblyVoteRecord { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id", nullable = false, updatable = false) + private UUID id; + + @Column(name = "vote_id", nullable = false) + private UUID voteId; + + @Column(name = "member_id", nullable = false) + private UUID memberId; + + @Enumerated(EnumType.STRING) + @Column(name = "decision", nullable = false, length = 10) + private VoteDecision decision; + + @Column(name = "voted_at", nullable = false) + private Instant votedAt; + + @PrePersist + void onCreate() { + if (this.votedAt == null) { + this.votedAt = Instant.now(); + } + } + + // Getters and setters + + public UUID getId() { return id; } + public void setId(UUID id) { this.id = id; } + + public UUID getVoteId() { return voteId; } + public void setVoteId(UUID voteId) { this.voteId = voteId; } + + public UUID getMemberId() { return memberId; } + public void setMemberId(UUID memberId) { this.memberId = memberId; } + + public VoteDecision getDecision() { return decision; } + public void setDecision(VoteDecision decision) { this.decision = decision; } + + public Instant getVotedAt() { return votedAt; } + public void setVotedAt(Instant votedAt) { this.votedAt = votedAt; } +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/AgendaItemType.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/AgendaItemType.java new file mode 100644 index 0000000..6853ed7 --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/AgendaItemType.java @@ -0,0 +1,11 @@ +package de.cannamanage.domain.enums; + +/** + * Type of agenda item (Tagesordnungspunkt / TOP). + */ +public enum AgendaItemType { + INFORMATION, + DISCUSSION, + VOTE, + ELECTION +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/AssemblyStatus.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/AssemblyStatus.java new file mode 100644 index 0000000..892f6d3 --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/AssemblyStatus.java @@ -0,0 +1,12 @@ +package de.cannamanage.domain.enums; + +/** + * Lifecycle status of a general assembly. + */ +public enum AssemblyStatus { + PLANNED, + INVITED, + IN_PROGRESS, + COMPLETED, + CANCELLED +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/AssemblyType.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/AssemblyType.java new file mode 100644 index 0000000..fce8e13 --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/AssemblyType.java @@ -0,0 +1,10 @@ +package de.cannamanage.domain.enums; + +/** + * Type of general assembly (Mitgliederversammlung). + * §32 BGB: ordentliche MV vs. §37 BGB: außerordentliche MV. + */ +public enum AssemblyType { + ORDINARY, + EXTRAORDINARY +} 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 970fd63..31e02a0 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 @@ -71,5 +71,12 @@ public enum AuditEventType { PAYMENT_VOIDED, FEE_SCHEDULE_CREATED, FEE_SCHEDULE_UPDATED, - EXPENSE_RECORDED + EXPENSE_RECORDED, + + // Sprint 8 — Assembly events + ASSEMBLY_CREATED, + ASSEMBLY_INVITED, + ASSEMBLY_STARTED, + ASSEMBLY_COMPLETED, + ASSEMBLY_VOTE_RECORDED } 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 86323e0..f4a6f89 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 @@ -20,5 +20,8 @@ public enum NotificationType { // Sprint 8 — Finance: PAYMENT_REMINDER, PAYMENT_OVERDUE, - PAYMENT_RECEIVED + PAYMENT_RECEIVED, + // Sprint 8 — Assembly: + ASSEMBLY_INVITATION, + ASSEMBLY_REMINDER } diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/StaffPermission.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/StaffPermission.java index fbb6859..72babf9 100644 --- a/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/StaffPermission.java +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/StaffPermission.java @@ -20,5 +20,6 @@ public enum StaffPermission { MODERATE_FORUM, // Sprint 8: MANAGE_FINANCES, - VIEW_FINANCES + VIEW_FINANCES, + MANAGE_ASSEMBLIES } diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/VoteDecision.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/VoteDecision.java new file mode 100644 index 0000000..5adee87 --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/VoteDecision.java @@ -0,0 +1,10 @@ +package de.cannamanage.domain.enums; + +/** + * Individual member's vote decision. + */ +public enum VoteDecision { + YES, + NO, + ABSTAIN +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/VoteResult.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/VoteResult.java new file mode 100644 index 0000000..08868ac --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/VoteResult.java @@ -0,0 +1,9 @@ +package de.cannamanage.domain.enums; + +/** + * Result of a completed vote. + */ +public enum VoteResult { + ACCEPTED, + REJECTED +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/VoteType.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/VoteType.java new file mode 100644 index 0000000..de5bbd3 --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/VoteType.java @@ -0,0 +1,13 @@ +package de.cannamanage.domain.enums; + +/** + * Type of vote determining the required majority. + * §32 BGB: simple majority (default) + * §33 BGB: 75% for Satzungsänderung, unanimous for Zweckänderung + */ +public enum VoteType { + SIMPLE_MAJORITY, + TWO_THIRDS, + THREE_QUARTERS, + UNANIMOUS +} diff --git a/cannamanage-frontend/docs/screenshots/10-portal-dashboard-dark.png b/cannamanage-frontend/docs/screenshots/10-portal-dashboard-dark.png index ee04bea..c45e5f2 100644 Binary files a/cannamanage-frontend/docs/screenshots/10-portal-dashboard-dark.png and b/cannamanage-frontend/docs/screenshots/10-portal-dashboard-dark.png differ diff --git a/cannamanage-frontend/docs/screenshots/10-portal-dashboard-light.png b/cannamanage-frontend/docs/screenshots/10-portal-dashboard-light.png index 9872fd1..fb8290b 100644 Binary files a/cannamanage-frontend/docs/screenshots/10-portal-dashboard-light.png and b/cannamanage-frontend/docs/screenshots/10-portal-dashboard-light.png differ diff --git a/cannamanage-frontend/docs/screenshots/11-portal-history-dark.png b/cannamanage-frontend/docs/screenshots/11-portal-history-dark.png index d79cfcd..cc9b48b 100644 Binary files a/cannamanage-frontend/docs/screenshots/11-portal-history-dark.png and b/cannamanage-frontend/docs/screenshots/11-portal-history-dark.png differ diff --git a/cannamanage-frontend/docs/screenshots/11-portal-history-light.png b/cannamanage-frontend/docs/screenshots/11-portal-history-light.png index c52ec24..b05c960 100644 Binary files a/cannamanage-frontend/docs/screenshots/11-portal-history-light.png and b/cannamanage-frontend/docs/screenshots/11-portal-history-light.png differ diff --git a/cannamanage-frontend/docs/screenshots/12-portal-profile-dark.png b/cannamanage-frontend/docs/screenshots/12-portal-profile-dark.png index 4f3f599..1212c2c 100644 Binary files a/cannamanage-frontend/docs/screenshots/12-portal-profile-dark.png and b/cannamanage-frontend/docs/screenshots/12-portal-profile-dark.png differ diff --git a/cannamanage-frontend/docs/screenshots/12-portal-profile-light.png b/cannamanage-frontend/docs/screenshots/12-portal-profile-light.png index aa06d18..0f98bbb 100644 Binary files a/cannamanage-frontend/docs/screenshots/12-portal-profile-light.png and b/cannamanage-frontend/docs/screenshots/12-portal-profile-light.png differ diff --git a/cannamanage-frontend/e2e/test-results/system-test-System-Integra-08f00-le-errors-on-critical-pages-chromium/test-finished-1.png b/cannamanage-frontend/e2e/test-results/system-test-System-Integra-08f00-le-errors-on-critical-pages-chromium/test-finished-1.png deleted file mode 100644 index d6fdf49..0000000 Binary files a/cannamanage-frontend/e2e/test-results/system-test-System-Integra-08f00-le-errors-on-critical-pages-chromium/test-finished-1.png and /dev/null differ diff --git a/cannamanage-frontend/e2e/test-results/system-test-System-Integra-4b5ec--reports-page-is-accessible-chromium/test-finished-1.png b/cannamanage-frontend/e2e/test-results/system-test-System-Integra-4b5ec--reports-page-is-accessible-chromium/test-finished-1.png deleted file mode 100644 index 4215f84..0000000 Binary files a/cannamanage-frontend/e2e/test-results/system-test-System-Integra-4b5ec--reports-page-is-accessible-chromium/test-finished-1.png and /dev/null differ diff --git a/cannamanage-frontend/e2e/test-results/system-test-System-Integra-9f049--login-page-loads-correctly-chromium/test-finished-1.png b/cannamanage-frontend/e2e/test-results/system-test-System-Integra-9f049--login-page-loads-correctly-chromium/test-finished-1.png deleted file mode 100644 index 3ddab50..0000000 Binary files a/cannamanage-frontend/e2e/test-results/system-test-System-Integra-9f049--login-page-loads-correctly-chromium/test-finished-1.png and /dev/null differ diff --git a/cannamanage-frontend/e2e/test-results/system-test-System-Integra-b3089--in-with-seeded-credentials-chromium/test-finished-1.png b/cannamanage-frontend/e2e/test-results/system-test-System-Integra-b3089--in-with-seeded-credentials-chromium/test-finished-1.png deleted file mode 100644 index 5a049b9..0000000 Binary files a/cannamanage-frontend/e2e/test-results/system-test-System-Integra-b3089--in-with-seeded-credentials-chromium/test-finished-1.png and /dev/null differ diff --git a/cannamanage-frontend/e2e/test-results/system-test-System-Integra-cc6e0-ibutions-page-is-accessible-chromium/test-finished-1.png b/cannamanage-frontend/e2e/test-results/system-test-System-Integra-cc6e0-ibutions-page-is-accessible-chromium/test-finished-1.png deleted file mode 100644 index 33882ba..0000000 Binary files a/cannamanage-frontend/e2e/test-results/system-test-System-Integra-cc6e0-ibutions-page-is-accessible-chromium/test-finished-1.png and /dev/null differ diff --git a/cannamanage-frontend/e2e/test-results/system-test-System-Integra-d1a25-bers-page-shows-member-data-chromium/test-finished-1.png b/cannamanage-frontend/e2e/test-results/system-test-System-Integra-d1a25-bers-page-shows-member-data-chromium/test-finished-1.png deleted file mode 100644 index 127cb9b..0000000 Binary files a/cannamanage-frontend/e2e/test-results/system-test-System-Integra-d1a25-bers-page-shows-member-data-chromium/test-finished-1.png and /dev/null differ diff --git a/cannamanage-frontend/e2e/test-results/system-test-System-Integra-faed7-isplays-content-after-login-chromium/test-finished-1.png b/cannamanage-frontend/e2e/test-results/system-test-System-Integra-faed7-isplays-content-after-login-chromium/test-finished-1.png deleted file mode 100644 index e4984aa..0000000 Binary files a/cannamanage-frontend/e2e/test-results/system-test-System-Integra-faed7-isplays-content-after-login-chromium/test-finished-1.png and /dev/null differ diff --git a/cannamanage-frontend/e2e/test-results/system-test-System-Integration-Test-navigation-sidebar-works-chromium/test-finished-1.png b/cannamanage-frontend/e2e/test-results/system-test-System-Integration-Test-navigation-sidebar-works-chromium/test-finished-1.png deleted file mode 100644 index b6ac254..0000000 Binary files a/cannamanage-frontend/e2e/test-results/system-test-System-Integration-Test-navigation-sidebar-works-chromium/test-finished-1.png and /dev/null differ diff --git a/cannamanage-frontend/e2e/test-results/system-test-System-Integration-Test-stock-page-is-accessible-chromium/test-finished-1.png b/cannamanage-frontend/e2e/test-results/system-test-System-Integration-Test-stock-page-is-accessible-chromium/test-finished-1.png deleted file mode 100644 index 350e8e3..0000000 Binary files a/cannamanage-frontend/e2e/test-results/system-test-System-Integration-Test-stock-page-is-accessible-chromium/test-finished-1.png and /dev/null differ diff --git a/cannamanage-frontend/src/app/(dashboard-layout)/assemblies/[id]/page.tsx b/cannamanage-frontend/src/app/(dashboard-layout)/assemblies/[id]/page.tsx new file mode 100644 index 0000000..59da830 --- /dev/null +++ b/cannamanage-frontend/src/app/(dashboard-layout)/assemblies/[id]/page.tsx @@ -0,0 +1,330 @@ +"use client" + +import { useEffect, useState } from "react" +import { useParams, useRouter } from "next/navigation" +import { + closeVote, + completeAssembly, + downloadProtocol, + getAssemblyDetail, + sendInvitations, + startAssembly, +} from "@/services/assemblies" +import { + ArrowLeft, + CheckCircle, + FileDown, + Play, + Send, + Square, + Users, + Vote, + XCircle, +} from "lucide-react" + +import type { + AssemblyDetail, + AssemblyStatus, + VoteResult, +} from "@/services/assemblies" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Progress } from "@/components/ui/progress" + +const statusLabels: Record = { + PLANNED: "Geplant", + INVITED: "Eingeladen", + IN_PROGRESS: "Läuft", + COMPLETED: "Abgeschlossen", + CANCELLED: "Abgesagt", +} +const statusColors: Record = { + PLANNED: "bg-gray-500/20 text-gray-400", + INVITED: "bg-blue-500/20 text-blue-400", + IN_PROGRESS: "bg-green-500/20 text-green-400", + COMPLETED: "bg-emerald-500/20 text-emerald-400", + CANCELLED: "bg-red-500/20 text-red-400", +} + +export default function AssemblyDetailPage() { + const { id } = useParams<{ id: string }>() + const router = useRouter() + const [detail, setDetail] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + if (id) loadDetail() + }, [id]) + + async function loadDetail() { + try { + const data = await getAssemblyDetail(id) + setDetail(data) + } catch (e) { + console.error("Failed to load assembly", e) + } finally { + setLoading(false) + } + } + + async function handleSendInvitations() { + await sendInvitations(id) + loadDetail() + } + + async function handleStart() { + await startAssembly(id) + loadDetail() + } + + async function handleComplete() { + await completeAssembly(id) + loadDetail() + } + + async function handleDownloadProtocol() { + const blob = await downloadProtocol(id) + const url = window.URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = `protokoll-${id}.pdf` + a.click() + window.URL.revokeObjectURL(url) + } + + async function handleCloseVote(voteId: string) { + await closeVote(voteId) + loadDetail() + } + + if (loading || !detail) { + return ( +
+

Laden...

+
+ ) + } + + const { assembly, agendaItems, attendees, votes, quorum } = detail + const quorumPercent = + quorum.totalMembers > 0 + ? Math.round((quorum.attendees / quorum.totalMembers) * 100) + : 0 + + return ( +
+
+ +
+
+

{assembly.title}

+ + {statusLabels[assembly.status]} + +
+

+ {new Date(assembly.scheduledAt).toLocaleDateString("de-DE", { + weekday: "long", + day: "2-digit", + month: "long", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + })} + {assembly.location && ` • ${assembly.location}`} +

+
+
+ {assembly.status === "PLANNED" && ( + + )} + {(assembly.status === "PLANNED" || assembly.status === "INVITED") && ( + + )} + {assembly.status === "IN_PROGRESS" && ( + + )} + {assembly.status === "COMPLETED" && ( + + )} +
+
+ + {/* Quorum Card */} + + + + + Beschlussfähigkeit + + + +
+ + + {quorum.attendees} / {quorum.totalMembers} + + + {quorum.quorumMet + ? "Beschlussfähig" + : `${quorum.required} benötigt`} + +
+
+
+ +
+ {/* Agenda */} + + + + + Tagesordnung + + + + {agendaItems.map((item) => ( +
+ + TOP {item.position} + +
+

{item.title}

+ {item.description && ( +

+ {item.description} +

+ )} + + {item.itemType} + +
+
+ ))} + {agendaItems.length === 0 && ( +

+ Keine Tagesordnungspunkte +

+ )} +
+
+ + {/* Votes */} + + + + + Abstimmungen + + + + {votes.map((vote) => ( +
+
+

{vote.title}

+ {vote.result ? ( + + {vote.result === "ACCEPTED" ? "Angenommen" : "Abgelehnt"} + + ) : ( + + )} +
+
+ ✓ {vote.yesCount} Ja + ✗ {vote.noCount} Nein + + ○ {vote.abstainCount} Enthaltung + +
+
+ ))} + {votes.length === 0 && ( +

+ Keine Abstimmungen +

+ )} +
+
+
+ + {/* Attendees */} + + + + + Anwesende ({attendees.length}) + + + + {attendees.length > 0 ? ( +
+ {attendees.map((a) => ( +
+ + {a.memberId.slice(0, 8)}... + {a.proxyForMemberId && ( + + Vollmacht + + )} +
+ ))} +
+ ) : ( +

+ Noch keine Anwesenden eingecheckt +

+ )} +
+
+
+ ) +} diff --git a/cannamanage-frontend/src/app/(dashboard-layout)/assemblies/page.tsx b/cannamanage-frontend/src/app/(dashboard-layout)/assemblies/page.tsx new file mode 100644 index 0000000..db9b797 --- /dev/null +++ b/cannamanage-frontend/src/app/(dashboard-layout)/assemblies/page.tsx @@ -0,0 +1,357 @@ +"use client" + +import { useEffect, useState } from "react" +import { useRouter } from "next/navigation" +import { createAssembly, getAssemblies } from "@/services/assemblies" +import { Gavel, Plus, X } from "lucide-react" + +import type { + AgendaItemType, + Assembly, + AssemblyStatus, + AssemblyType, +} from "@/services/assemblies" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Card, CardContent } 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 { Textarea } from "@/components/ui/textarea" + +const statusLabels: Record = { + PLANNED: "Geplant", + INVITED: "Eingeladen", + IN_PROGRESS: "Läuft", + COMPLETED: "Abgeschlossen", + CANCELLED: "Abgesagt", +} + +const statusColors: Record = { + PLANNED: "bg-gray-500/20 text-gray-400", + INVITED: "bg-blue-500/20 text-blue-400", + IN_PROGRESS: "bg-green-500/20 text-green-400", + COMPLETED: "bg-emerald-500/20 text-emerald-400", + CANCELLED: "bg-red-500/20 text-red-400", +} + +const typeLabels: Record = { + ORDINARY: "Ordentliche MV", + EXTRAORDINARY: "Außerordentliche MV", +} + +export default function AssembliesPage() { + const router = useRouter() + const [assemblies, setAssemblies] = useState([]) + const [loading, setLoading] = useState(true) + const [createOpen, setCreateOpen] = useState(false) + const [formData, setFormData] = useState({ + title: "", + assemblyType: "ORDINARY" as AssemblyType, + scheduledAt: "", + location: "", + quorumRequired: "", + agendaItems: [ + { title: "", description: "", itemType: "INFORMATION" as AgendaItemType }, + ], + }) + + useEffect(() => { + loadAssemblies() + }, []) + + async function loadAssemblies() { + try { + const data = await getAssemblies() + setAssemblies(data) + } catch (e) { + console.error("Failed to load assemblies", e) + } finally { + setLoading(false) + } + } + + async function handleCreate() { + try { + await createAssembly({ + title: formData.title, + assemblyType: formData.assemblyType, + scheduledAt: new Date(formData.scheduledAt).toISOString(), + location: formData.location || undefined, + quorumRequired: formData.quorumRequired + ? parseInt(formData.quorumRequired) + : undefined, + agendaItems: formData.agendaItems + .filter((a) => a.title.trim()) + .map((a) => ({ + title: a.title, + description: a.description || undefined, + itemType: a.itemType, + })), + }) + setCreateOpen(false) + setFormData({ + title: "", + assemblyType: "ORDINARY", + scheduledAt: "", + location: "", + quorumRequired: "", + agendaItems: [{ title: "", description: "", itemType: "INFORMATION" }], + }) + loadAssemblies() + } catch (e) { + console.error("Failed to create assembly", e) + } + } + + function addAgendaItem() { + setFormData((prev) => ({ + ...prev, + agendaItems: [ + ...prev.agendaItems, + { title: "", description: "", itemType: "INFORMATION" as AgendaItemType }, + ], + })) + } + + function removeAgendaItem(index: number) { + setFormData((prev) => ({ + ...prev, + agendaItems: prev.agendaItems.filter((_, i) => i !== index), + })) + } + + function updateAgendaItem(index: number, field: string, value: string) { + setFormData((prev) => ({ + ...prev, + agendaItems: prev.agendaItems.map((item, i) => + i === index ? { ...item, [field]: value } : item, + ), + })) + } + + if (loading) { + return ( +
+

Laden...

+
+ ) + } + + return ( +
+
+
+

Versammlungen

+

+ Mitgliederversammlungen verwalten +

+
+ + + + + + + Neue Mitgliederversammlung + +
+
+
+ + + setFormData((p) => ({ ...p, title: e.target.value })) + } + placeholder="z.B. Ordentliche MV 2026" + /> +
+
+ + +
+
+
+
+ + + setFormData((p) => ({ + ...p, + scheduledAt: e.target.value, + })) + } + /> +
+
+ + + setFormData((p) => ({ ...p, location: e.target.value })) + } + placeholder="Vereinsheim" + /> +
+
+
+ + + setFormData((p) => ({ + ...p, + quorumRequired: e.target.value, + })) + } + placeholder="Mindestanzahl für Beschlussfähigkeit" + /> +
+ +
+
+ + +
+ {formData.agendaItems.map((item, i) => ( +
+
+ + TOP {i + 1} + + + updateAgendaItem(i, "title", e.target.value) + } + placeholder="Titel" + /> + + {formData.agendaItems.length > 1 && ( + + )} +
+