diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/controller/BoardController.java b/cannamanage-api/src/main/java/de/cannamanage/api/controller/BoardController.java new file mode 100644 index 0000000..05602f9 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/controller/BoardController.java @@ -0,0 +1,100 @@ +package de.cannamanage.api.controller; + +import de.cannamanage.domain.entity.BoardMember; +import de.cannamanage.domain.entity.BoardPosition; +import de.cannamanage.service.BoardService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.security.Principal; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@RestController +@RequestMapping("/api/v1") +public class BoardController { + + private final BoardService boardService; + + public BoardController(BoardService boardService) { + this.boardService = boardService; + } + + // --- Positions --- + + @PostMapping("/board/positions") + public ResponseEntity createPosition( + @RequestParam UUID clubId, + @RequestBody Map body) { + String title = (String) body.get("title"); + String description = (String) body.get("description"); + Integer sortOrder = body.containsKey("sortOrder") ? (Integer) body.get("sortOrder") : 0; + BoardPosition pos = boardService.createPosition(clubId, title, description, sortOrder); + return ResponseEntity.ok(pos); + } + + @GetMapping("/board/positions") + public ResponseEntity> getPositions(@RequestParam UUID clubId) { + return ResponseEntity.ok(boardService.getPositions(clubId)); + } + + @PutMapping("/board/positions/{id}") + public ResponseEntity updatePosition( + @PathVariable UUID id, + @RequestBody Map body) { + String title = (String) body.get("title"); + String description = (String) body.get("description"); + Integer sortOrder = body.containsKey("sortOrder") ? (Integer) body.get("sortOrder") : null; + Boolean isActive = body.containsKey("isActive") ? (Boolean) body.get("isActive") : null; + BoardPosition pos = boardService.updatePosition(id, title, description, sortOrder, isActive); + return ResponseEntity.ok(pos); + } + + // --- Board Members --- + + @PostMapping("/board/members") + public ResponseEntity electBoardMember( + @RequestParam UUID clubId, + @RequestBody Map body, + Principal principal) { + UUID positionId = UUID.fromString((String) body.get("positionId")); + UUID memberId = UUID.fromString((String) body.get("memberId")); + LocalDate electedAt = LocalDate.parse((String) body.get("electedAt")); + LocalDate termStart = LocalDate.parse((String) body.get("termStart")); + LocalDate termEnd = body.get("termEnd") != null ? LocalDate.parse((String) body.get("termEnd")) : null; + UUID assemblyId = body.get("assemblyId") != null ? UUID.fromString((String) body.get("assemblyId")) : null; + UUID userId = UUID.fromString(principal.getName()); + + BoardMember bm = boardService.electBoardMember(clubId, positionId, memberId, + electedAt, termStart, termEnd, assemblyId, userId); + return ResponseEntity.ok(bm); + } + + @GetMapping("/board") + public ResponseEntity> getCurrentBoard(@RequestParam UUID clubId) { + return ResponseEntity.ok(boardService.getCurrentBoard(clubId)); + } + + @GetMapping("/board/history") + public ResponseEntity> getBoardHistory(@RequestParam UUID clubId) { + return ResponseEntity.ok(boardService.getBoardHistory(clubId)); + } + + @DeleteMapping("/board/members/{id}") + public ResponseEntity removeBoardMember( + @PathVariable UUID id, + @RequestParam UUID clubId, + Principal principal) { + UUID userId = UUID.fromString(principal.getName()); + boardService.removeBoardMember(id, userId, clubId); + return ResponseEntity.noContent().build(); + } + + // Portal endpoint + @GetMapping("/portal/board") + public ResponseEntity> getPortalBoard(@RequestParam UUID clubId) { + return ResponseEntity.ok(boardService.getCurrentBoard(clubId)); + } +} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/controller/DocumentController.java b/cannamanage-api/src/main/java/de/cannamanage/api/controller/DocumentController.java new file mode 100644 index 0000000..d6748a2 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/controller/DocumentController.java @@ -0,0 +1,84 @@ +package de.cannamanage.api.controller; + +import de.cannamanage.domain.entity.Document; +import de.cannamanage.domain.enums.DocumentAccessLevel; +import de.cannamanage.domain.enums.DocumentCategory; +import de.cannamanage.service.DocumentService; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.security.Principal; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@RestController +@RequestMapping("/api/v1") +public class DocumentController { + + private final DocumentService documentService; + + public DocumentController(DocumentService documentService) { + this.documentService = documentService; + } + + @PostMapping("/documents/upload") + public ResponseEntity uploadDocument( + @RequestParam UUID clubId, + @RequestParam String title, + @RequestParam DocumentCategory category, + @RequestParam(defaultValue = "ALL_MEMBERS") DocumentAccessLevel accessLevel, + @RequestParam(required = false) String description, + @RequestParam("file") MultipartFile file, + Principal principal) throws IOException { + UUID userId = UUID.fromString(principal.getName()); + Document doc = documentService.uploadDocument(clubId, title, category, accessLevel, description, file, userId); + return ResponseEntity.ok(doc); + } + + @GetMapping("/documents") + public ResponseEntity> listDocuments( + @RequestParam UUID clubId, + @RequestParam(required = false) DocumentCategory category, + @RequestParam(required = false) DocumentAccessLevel accessLevel) { + List docs = documentService.listDocuments(clubId, category, accessLevel); + return ResponseEntity.ok(docs); + } + + @GetMapping("/documents/{id}/download") + public ResponseEntity downloadDocument(@PathVariable UUID id) throws IOException { + Document doc = documentService.getDocument(id); + byte[] content = documentService.downloadDocument(id); + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + doc.getFilename() + "\"") + .contentType(MediaType.parseMediaType(doc.getContentType())) + .body(content); + } + + @DeleteMapping("/documents/{id}") + public ResponseEntity deleteDocument( + @PathVariable UUID id, + @RequestParam UUID clubId, + Principal principal) throws IOException { + UUID userId = UUID.fromString(principal.getName()); + documentService.deleteDocument(id, userId, clubId); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/documents/usage") + public ResponseEntity> getStorageUsage(@RequestParam UUID clubId) { + long usage = documentService.getStorageUsage(clubId); + return ResponseEntity.ok(Map.of("bytesUsed", usage)); + } + + // Portal endpoint — only ALL_MEMBERS documents + @GetMapping("/portal/documents") + public ResponseEntity> getPortalDocuments(@RequestParam UUID clubId) { + List docs = documentService.listDocuments(clubId, null, DocumentAccessLevel.ALL_MEMBERS); + return ResponseEntity.ok(docs); + } +} diff --git a/cannamanage-api/src/main/resources/db/migration/V20__documents.sql b/cannamanage-api/src/main/resources/db/migration/V20__documents.sql new file mode 100644 index 0000000..08a0079 --- /dev/null +++ b/cannamanage-api/src/main/resources/db/migration/V20__documents.sql @@ -0,0 +1,23 @@ +-- V20: Document archive for club documents (Satzung, Protokolle, Verträge, etc.) +-- Legal basis: §22 KCanG (Dokumentationspflichten), §147 AO (Aufbewahrungspflichten) + +CREATE TABLE documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + club_id UUID NOT NULL REFERENCES clubs(id), + title VARCHAR(300) NOT NULL, + category VARCHAR(50) NOT NULL, + filename VARCHAR(255) NOT NULL, + content_type VARCHAR(100) NOT NULL, + file_size BIGINT NOT NULL, + storage_path VARCHAR(500) NOT NULL, + access_level VARCHAR(20) NOT NULL DEFAULT 'ALL_MEMBERS', + description TEXT, + uploaded_by UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_documents_club ON documents(club_id); +CREATE INDEX idx_documents_category ON documents(club_id, category); +CREATE INDEX idx_documents_tenant ON documents(tenant_id); diff --git a/cannamanage-api/src/main/resources/db/migration/V21__board_management.sql b/cannamanage-api/src/main/resources/db/migration/V21__board_management.sql new file mode 100644 index 0000000..8c8c81b --- /dev/null +++ b/cannamanage-api/src/main/resources/db/migration/V21__board_management.sql @@ -0,0 +1,33 @@ +-- V21: Board management (Vorstandsverwaltung) +-- Legal basis: §26 BGB (Vorstand), §27 BGB (Bestellung/Abberufung), §23 KCanG (Präventionsbeauftragter) + +CREATE TABLE board_positions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + club_id UUID NOT NULL REFERENCES clubs(id), + title VARCHAR(100) NOT NULL, + description TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE board_members ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + club_id UUID NOT NULL REFERENCES clubs(id), + position_id UUID NOT NULL REFERENCES board_positions(id), + member_id UUID NOT NULL REFERENCES members(id), + elected_at DATE NOT NULL, + term_start DATE NOT NULL, + term_end DATE, + is_current BOOLEAN DEFAULT TRUE, + elected_in_assembly_id UUID REFERENCES assemblies(id), + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_board_positions_club ON board_positions(club_id); +CREATE INDEX idx_board_positions_tenant ON board_positions(tenant_id); +CREATE INDEX idx_board_members_club ON board_members(club_id); +CREATE INDEX idx_board_members_tenant ON board_members(tenant_id); +CREATE INDEX idx_board_members_current ON board_members(club_id, is_current) WHERE is_current = TRUE; diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/BoardMember.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/BoardMember.java new file mode 100644 index 0000000..399a35f --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/BoardMember.java @@ -0,0 +1,69 @@ +package de.cannamanage.domain.entity; + +import jakarta.persistence.*; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.UUID; + +/** + * Board member assignment — links a member to a board position for a term. + * Legal basis: §27 BGB (Bestellung/Abberufung des Vorstands). + */ +@Entity +@Table(name = "board_members", indexes = { + @Index(name = "idx_board_members_club", columnList = "club_id"), + @Index(name = "idx_board_members_tenant", columnList = "tenant_id") +}) +public class BoardMember extends AbstractTenantEntity { + + @Column(name = "club_id", nullable = false) + private UUID clubId; + + @Column(name = "position_id", nullable = false) + private UUID positionId; + + @Column(name = "member_id", nullable = false) + private UUID memberId; + + @Column(name = "elected_at", nullable = false) + private LocalDate electedAt; + + @Column(name = "term_start", nullable = false) + private LocalDate termStart; + + @Column(name = "term_end") + private LocalDate termEnd; + + @Column(name = "is_current", nullable = false) + private Boolean isCurrent = true; + + @Column(name = "elected_in_assembly_id") + private UUID electedInAssemblyId; + + // Getters and setters + + public UUID getClubId() { return clubId; } + public void setClubId(UUID clubId) { this.clubId = clubId; } + + public UUID getPositionId() { return positionId; } + public void setPositionId(UUID positionId) { this.positionId = positionId; } + + public UUID getMemberId() { return memberId; } + public void setMemberId(UUID memberId) { this.memberId = memberId; } + + public LocalDate getElectedAt() { return electedAt; } + public void setElectedAt(LocalDate electedAt) { this.electedAt = electedAt; } + + public LocalDate getTermStart() { return termStart; } + public void setTermStart(LocalDate termStart) { this.termStart = termStart; } + + public LocalDate getTermEnd() { return termEnd; } + public void setTermEnd(LocalDate termEnd) { this.termEnd = termEnd; } + + public Boolean getIsCurrent() { return isCurrent; } + public void setIsCurrent(Boolean isCurrent) { this.isCurrent = isCurrent; } + + public UUID getElectedInAssemblyId() { return electedInAssemblyId; } + public void setElectedInAssemblyId(UUID electedInAssemblyId) { this.electedInAssemblyId = electedInAssemblyId; } +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/BoardPosition.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/BoardPosition.java new file mode 100644 index 0000000..1692c05 --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/BoardPosition.java @@ -0,0 +1,50 @@ +package de.cannamanage.domain.entity; + +import jakarta.persistence.*; + +import java.time.Instant; +import java.util.UUID; + +/** + * Board position definition (e.g., "1. Vorsitzender", "Kassenwart", "Präventionsbeauftragter"). + * Legal basis: §26 BGB (Vorstand), §30 BGB (Besonderer Vertreter), §23 KCanG (Präventionsbeauftragter). + */ +@Entity +@Table(name = "board_positions", indexes = { + @Index(name = "idx_board_positions_club", columnList = "club_id"), + @Index(name = "idx_board_positions_tenant", columnList = "tenant_id") +}) +public class BoardPosition extends AbstractTenantEntity { + + @Column(name = "club_id", nullable = false) + private UUID clubId; + + @Column(name = "title", nullable = false, length = 100) + private String title; + + @Column(name = "description", columnDefinition = "TEXT") + private String description; + + @Column(name = "sort_order", nullable = false) + private Integer sortOrder = 0; + + @Column(name = "is_active", nullable = false) + private Boolean isActive = true; + + // 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 Integer getSortOrder() { return sortOrder; } + public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; } + + public Boolean getIsActive() { return isActive; } + public void setIsActive(Boolean isActive) { this.isActive = isActive; } +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Document.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Document.java new file mode 100644 index 0000000..57dbec0 --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Document.java @@ -0,0 +1,96 @@ +package de.cannamanage.domain.entity; + +import de.cannamanage.domain.enums.DocumentAccessLevel; +import de.cannamanage.domain.enums.DocumentCategory; +import jakarta.persistence.*; + +import java.time.Instant; +import java.util.UUID; + +/** + * Club document entity for the document archive. + * Legal basis: §22 KCanG (documentation requirements), §147 AO (retention). + */ +@Entity +@Table(name = "documents", indexes = { + @Index(name = "idx_documents_club", columnList = "club_id"), + @Index(name = "idx_documents_tenant", columnList = "tenant_id"), + @Index(name = "idx_documents_category", columnList = "club_id, category") +}) +public class Document extends AbstractTenantEntity { + + @Column(name = "club_id", nullable = false) + private UUID clubId; + + @Column(name = "title", nullable = false, length = 300) + private String title; + + @Enumerated(EnumType.STRING) + @Column(name = "category", nullable = false, length = 50) + private DocumentCategory category; + + @Column(name = "filename", nullable = false, length = 255) + private String filename; + + @Column(name = "content_type", nullable = false, length = 100) + private String contentType; + + @Column(name = "file_size", nullable = false) + private Long fileSize; + + @Column(name = "storage_path", nullable = false, length = 500) + private String storagePath; + + @Enumerated(EnumType.STRING) + @Column(name = "access_level", nullable = false, length = 20) + private DocumentAccessLevel accessLevel = DocumentAccessLevel.ALL_MEMBERS; + + @Column(name = "description", columnDefinition = "TEXT") + private String description; + + @Column(name = "uploaded_by", nullable = false) + private UUID uploadedBy; + + @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 DocumentCategory getCategory() { return category; } + public void setCategory(DocumentCategory category) { this.category = category; } + + public String getFilename() { return filename; } + public void setFilename(String filename) { this.filename = filename; } + + public String getContentType() { return contentType; } + public void setContentType(String contentType) { this.contentType = contentType; } + + public Long getFileSize() { return fileSize; } + public void setFileSize(Long fileSize) { this.fileSize = fileSize; } + + public String getStoragePath() { return storagePath; } + public void setStoragePath(String storagePath) { this.storagePath = storagePath; } + + public DocumentAccessLevel getAccessLevel() { return accessLevel; } + public void setAccessLevel(DocumentAccessLevel accessLevel) { this.accessLevel = accessLevel; } + + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + + public UUID getUploadedBy() { return uploadedBy; } + public void setUploadedBy(UUID uploadedBy) { this.uploadedBy = uploadedBy; } + + public Instant getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; } +} 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 31e02a0..a708eff 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 @@ -78,5 +78,13 @@ public enum AuditEventType { ASSEMBLY_INVITED, ASSEMBLY_STARTED, ASSEMBLY_COMPLETED, - ASSEMBLY_VOTE_RECORDED + ASSEMBLY_VOTE_RECORDED, + + // Sprint 8 — Document events + DOCUMENT_UPLOADED, + DOCUMENT_DELETED, + + // Sprint 8 — Board events + BOARD_MEMBER_ELECTED, + BOARD_MEMBER_REMOVED } diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/DocumentAccessLevel.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/DocumentAccessLevel.java new file mode 100644 index 0000000..79f14e6 --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/DocumentAccessLevel.java @@ -0,0 +1,10 @@ +package de.cannamanage.domain.enums; + +/** + * Access levels for documents in the document archive. + * Controls visibility: ALL_MEMBERS = everyone, BOARD_ONLY = Vorstand only. + */ +public enum DocumentAccessLevel { + ALL_MEMBERS, + BOARD_ONLY +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/DocumentCategory.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/DocumentCategory.java new file mode 100644 index 0000000..f756aee --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/DocumentCategory.java @@ -0,0 +1,14 @@ +package de.cannamanage.domain.enums; + +/** + * Categories for club documents in the document archive. + * Based on typical Vereinsverwaltung document types. + */ +public enum DocumentCategory { + SATZUNG, + PROTOKOLL, + VERTRAG, + VERSICHERUNG, + GENEHMIGUNG, + 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 f4a6f89..38bb2b7 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 @@ -23,5 +23,7 @@ public enum NotificationType { PAYMENT_RECEIVED, // Sprint 8 — Assembly: ASSEMBLY_INVITATION, - ASSEMBLY_REMINDER + ASSEMBLY_REMINDER, + // Sprint 8 — Board: + BOARD_TERM_EXPIRING } 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 72babf9..dd86915 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 @@ -21,5 +21,6 @@ public enum StaffPermission { // Sprint 8: MANAGE_FINANCES, VIEW_FINANCES, - MANAGE_ASSEMBLIES + MANAGE_ASSEMBLIES, + MANAGE_DOCUMENTS } diff --git a/cannamanage-frontend/messages/de.json b/cannamanage-frontend/messages/de.json index 3eee8fd..48af0de 100644 --- a/cannamanage-frontend/messages/de.json +++ b/cannamanage-frontend/messages/de.json @@ -949,5 +949,60 @@ "paymentHistory": "Zahlungshistorie", "noPayments": "Noch keine Zahlungen vorhanden" } + }, + "documents": { + "title": "Dokumentenarchiv", + "description": "Vereinsdokumente verwalten und archivieren", + "upload": "Hochladen", + "uploadDocument": "Dokument hochladen", + "documentTitle": "Titel", + "titlePlaceholder": "z.B. Vereinssatzung 2024", + "category": "Kategorie", + "selectCategory": "Kategorie wählen", + "accessLevel": "Zugriff", + "allMembers": "Alle Mitglieder", + "boardOnly": "Nur Vorstand", + "descriptionLabel": "Beschreibung", + "descriptionPlaceholder": "Optionale Beschreibung...", + "file": "Datei", + "fileHint": "PDF, DOCX, XLSX, PNG, JPG — max. 10 MB", + "uploadButton": "Dokument hochladen", + "allCategories": "Alle Kategorien", + "documentsCount": "Dokumente", + "name": "Name", + "access": "Zugriff", + "size": "Größe", + "date": "Datum", + "actions": "Aktionen" + }, + "board": { + "title": "Vorstand", + "description": "Vorstandspositionen und -mitglieder verwalten", + "addPosition": "Position anlegen", + "electMember": "Mitglied wählen", + "createPosition": "Position erstellen", + "positionTitle": "Titel", + "positionTitlePlaceholder": "z.B. 1. Vorsitzender", + "positionDescription": "Beschreibung", + "positionDescPlaceholder": "z.B. Gesetzlicher Vertreter gem. §26 BGB", + "sortOrder": "Reihenfolge", + "save": "Speichern", + "electBoardMember": "Vorstandsmitglied wählen", + "position": "Position", + "selectPosition": "Position wählen", + "member": "Mitglied", + "selectMember": "Mitglied wählen", + "electedAt": "Gewählt am", + "termStart": "Amtszeit Beginn", + "termEnd": "Amtszeit Ende", + "confirmElection": "Wahl bestätigen", + "elected": "Gewählt", + "term": "Amtszeit", + "unlimited": "unbefristet", + "termExpiringSoon": "Läuft bald ab", + "termActive": "Aktiv", + "positions": "Positionen", + "active": "Aktiv", + "inactive": "Inaktiv" } -} +} \ No newline at end of file diff --git a/cannamanage-frontend/messages/en.json b/cannamanage-frontend/messages/en.json index d53cb28..dc893fd 100644 --- a/cannamanage-frontend/messages/en.json +++ b/cannamanage-frontend/messages/en.json @@ -949,5 +949,60 @@ "paymentHistory": "Payment History", "noPayments": "No payments yet" } + }, + "documents": { + "title": "Document Archive", + "description": "Manage and archive club documents", + "upload": "Upload", + "uploadDocument": "Upload Document", + "documentTitle": "Title", + "titlePlaceholder": "e.g. Club Bylaws 2024", + "category": "Category", + "selectCategory": "Select category", + "accessLevel": "Access", + "allMembers": "All Members", + "boardOnly": "Board Only", + "descriptionLabel": "Description", + "descriptionPlaceholder": "Optional description...", + "file": "File", + "fileHint": "PDF, DOCX, XLSX, PNG, JPG — max. 10 MB", + "uploadButton": "Upload Document", + "allCategories": "All Categories", + "documentsCount": "documents", + "name": "Name", + "access": "Access", + "size": "Size", + "date": "Date", + "actions": "Actions" + }, + "board": { + "title": "Board", + "description": "Manage board positions and members", + "addPosition": "Add Position", + "electMember": "Elect Member", + "createPosition": "Create Position", + "positionTitle": "Title", + "positionTitlePlaceholder": "e.g. President", + "positionDescription": "Description", + "positionDescPlaceholder": "e.g. Legal representative", + "sortOrder": "Sort Order", + "save": "Save", + "electBoardMember": "Elect Board Member", + "position": "Position", + "selectPosition": "Select position", + "member": "Member", + "selectMember": "Select member", + "electedAt": "Elected on", + "termStart": "Term Start", + "termEnd": "Term End", + "confirmElection": "Confirm Election", + "elected": "Elected", + "term": "Term", + "unlimited": "unlimited", + "termExpiringSoon": "Expiring soon", + "termActive": "Active", + "positions": "Positions", + "active": "Active", + "inactive": "Inactive" } -} +} \ No newline at end of file diff --git a/cannamanage-frontend/src/app/(dashboard-layout)/board/page.tsx b/cannamanage-frontend/src/app/(dashboard-layout)/board/page.tsx new file mode 100644 index 0000000..df45984 --- /dev/null +++ b/cannamanage-frontend/src/app/(dashboard-layout)/board/page.tsx @@ -0,0 +1,348 @@ +"use client" + +import { useState } from "react" +import { useTranslations } from "next-intl" +import { Calendar, Edit, Plus, Shield, UserMinus, UserPlus } from "lucide-react" + +import type { BoardMember, BoardPosition } from "@/services/board" + +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" + +// Mock data +const mockPositions: BoardPosition[] = [ + { + id: "1", + title: "1. Vorsitzender", + description: "Gesetzlicher Vertreter gem. §26 BGB", + sortOrder: 1, + isActive: true, + createdAt: "2024-01-01T00:00:00Z", + }, + { + id: "2", + title: "2. Vorsitzender", + description: "Stellvertreter", + sortOrder: 2, + isActive: true, + createdAt: "2024-01-01T00:00:00Z", + }, + { + id: "3", + title: "Kassenwart", + description: "Finanzverwaltung gem. §259 BGB", + sortOrder: 3, + isActive: true, + createdAt: "2024-01-01T00:00:00Z", + }, + { + id: "4", + title: "Schriftführer", + description: "Protokollführung", + sortOrder: 4, + isActive: true, + createdAt: "2024-01-01T00:00:00Z", + }, + { + id: "5", + title: "Präventionsbeauftragter", + description: "Gem. §23 KCanG", + sortOrder: 5, + isActive: true, + createdAt: "2024-01-01T00:00:00Z", + }, +] + +const mockBoardMembers: (BoardMember & { + memberName?: string + positionTitle?: string +})[] = [ + { + id: "1", + clubId: "c1", + positionId: "1", + memberId: "m1", + electedAt: "2024-03-15", + termStart: "2024-04-01", + termEnd: "2026-03-31", + isCurrent: true, + electedInAssemblyId: "a1", + createdAt: "2024-03-15T00:00:00Z", + memberName: "Max Mustermann", + positionTitle: "1. Vorsitzender", + }, + { + id: "2", + clubId: "c1", + positionId: "2", + memberId: "m2", + electedAt: "2024-03-15", + termStart: "2024-04-01", + termEnd: "2026-03-31", + isCurrent: true, + electedInAssemblyId: "a1", + createdAt: "2024-03-15T00:00:00Z", + memberName: "Anna Schmidt", + positionTitle: "2. Vorsitzender", + }, + { + id: "3", + clubId: "c1", + positionId: "3", + memberId: "m3", + electedAt: "2024-03-15", + termStart: "2024-04-01", + termEnd: "2026-03-31", + isCurrent: true, + electedInAssemblyId: "a1", + createdAt: "2024-03-15T00:00:00Z", + memberName: "Peter Weber", + positionTitle: "Kassenwart", + }, + { + id: "4", + clubId: "c1", + positionId: "4", + memberId: "m4", + electedAt: "2024-03-15", + termStart: "2024-04-01", + termEnd: "2026-03-31", + isCurrent: true, + electedInAssemblyId: "a1", + createdAt: "2024-03-15T00:00:00Z", + memberName: "Lisa Müller", + positionTitle: "Schriftführer", + }, + { + id: "5", + clubId: "c1", + positionId: "5", + memberId: "m5", + electedAt: "2024-03-15", + termStart: "2024-04-01", + termEnd: null, + isCurrent: true, + electedInAssemblyId: "a1", + createdAt: "2024-03-15T00:00:00Z", + memberName: "Thomas Braun", + positionTitle: "Präventionsbeauftragter", + }, +] + +export default function BoardPage() { + const t = useTranslations("board") + const [positionDialogOpen, setPositionDialogOpen] = useState(false) + const [electDialogOpen, setElectDialogOpen] = useState(false) + + return ( +
+
+
+

{t("title")}

+

{t("description")}

+
+
+ + + + + + + {t("createPosition")} + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ + + + + + + {t("electBoardMember")} + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+ + {/* Current Board Members as cards */} +
+ {mockBoardMembers.map((bm) => ( + + +
+
+ + + {bm.positionTitle} + +
+ +
+
+ +
+

{bm.memberName}

+
+ + + {t("elected")}:{" "} + {new Date(bm.electedAt).toLocaleDateString("de-DE")} + +
+
+ {t("term")}:{" "} + {new Date(bm.termStart).toLocaleDateString("de-DE")} + {bm.termEnd + ? ` – ${new Date(bm.termEnd).toLocaleDateString("de-DE")}` + : ` – ${t("unlimited")}`} +
+ {bm.termEnd && ( + + {new Date(bm.termEnd) < + new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) + ? t("termExpiringSoon") + : t("termActive")} + + )} +
+
+
+ ))} +
+ + {/* Positions overview */} + + + + + {t("positions")} + + + +
+ {mockPositions.map((pos) => ( +
+
+

{pos.title}

+ {pos.description && ( +

+ {pos.description} +

+ )} +
+ + {pos.isActive ? t("active") : t("inactive")} + +
+ ))} +
+
+
+
+ ) +} diff --git a/cannamanage-frontend/src/app/(dashboard-layout)/documents/page.tsx b/cannamanage-frontend/src/app/(dashboard-layout)/documents/page.tsx new file mode 100644 index 0000000..d51b51e --- /dev/null +++ b/cannamanage-frontend/src/app/(dashboard-layout)/documents/page.tsx @@ -0,0 +1,328 @@ +"use client" + +import { useState } from "react" +import { categoryLabels, formatFileSize } from "@/services/documents" +import { useTranslations } from "next-intl" +import { + Download, + File, + FileSpreadsheet, + FileText, + Filter, + Image, + Trash2, + Upload, +} from "lucide-react" + +import type { ClubDocument, DocumentCategory } from "@/services/documents" + +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 { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Textarea } from "@/components/ui/textarea" + +// Mock data for development +const mockDocuments: ClubDocument[] = [ + { + id: "1", + title: "Vereinssatzung 2024", + category: "SATZUNG", + filename: "satzung-2024.pdf", + contentType: "application/pdf", + fileSize: 245000, + accessLevel: "ALL_MEMBERS", + description: "Aktuelle Satzung des Vereins", + uploadedBy: "admin-1", + createdAt: "2024-01-15T10:00:00Z", + updatedAt: null, + }, + { + id: "2", + title: "Protokoll MV März 2024", + category: "PROTOKOLL", + filename: "mv-protokoll-2024-03.pdf", + contentType: "application/pdf", + fileSize: 180000, + accessLevel: "ALL_MEMBERS", + description: "Protokoll der ordentlichen Mitgliederversammlung", + uploadedBy: "admin-1", + createdAt: "2024-03-20T14:00:00Z", + updatedAt: null, + }, + { + id: "3", + title: "Mietvertrag Vereinsräume", + category: "VERTRAG", + filename: "mietvertrag-2023.pdf", + contentType: "application/pdf", + fileSize: 520000, + accessLevel: "BOARD_ONLY", + description: "Mietvertrag für die Vereinsräumlichkeiten", + uploadedBy: "admin-1", + createdAt: "2023-06-01T09:00:00Z", + updatedAt: null, + }, + { + id: "4", + title: "KCanG-Genehmigung", + category: "GENEHMIGUNG", + filename: "genehmigung-kcanG.pdf", + contentType: "application/pdf", + fileSize: 310000, + accessLevel: "BOARD_ONLY", + description: "Anbaugenehmigung nach KCanG", + uploadedBy: "admin-1", + createdAt: "2024-04-01T08:00:00Z", + updatedAt: null, + }, + { + id: "5", + title: "Haftpflichtversicherung", + category: "VERSICHERUNG", + filename: "haftpflicht-2024.pdf", + contentType: "application/pdf", + fileSize: 150000, + accessLevel: "BOARD_ONLY", + description: "Vereinshaftpflichtversicherung Police Nr. 12345", + uploadedBy: "admin-1", + createdAt: "2024-01-01T10:00:00Z", + updatedAt: null, + }, +] + +function getFileIcon(contentType: string) { + if (contentType === "application/pdf") return + if (contentType.includes("spreadsheet")) + return + if (contentType.startsWith("image/")) return + return +} + +function getCategoryBadgeVariant( + category: DocumentCategory +): "default" | "secondary" | "destructive" | "outline" { + const variants: Record< + DocumentCategory, + "default" | "secondary" | "destructive" | "outline" + > = { + SATZUNG: "default", + PROTOKOLL: "secondary", + VERTRAG: "outline", + VERSICHERUNG: "outline", + GENEHMIGUNG: "destructive", + SONSTIGES: "secondary", + } + return variants[category] +} + +export default function DocumentsPage() { + const t = useTranslations("documents") + const [documents] = useState(mockDocuments) + const [uploadOpen, setUploadOpen] = useState(false) + const [filterCategory, setFilterCategory] = useState("ALL") + + const filteredDocuments = + filterCategory === "ALL" + ? documents + : documents.filter((d) => d.category === filterCategory) + + // Group by category + const grouped = filteredDocuments.reduce( + (acc, doc) => { + const cat = doc.category + if (!acc[cat]) acc[cat] = [] + acc[cat].push(doc) + return acc + }, + {} as Record + ) + + return ( +
+
+
+

{t("title")}

+

{t("description")}

+
+ + + + + + + {t("uploadDocument")} + +
+
+ + +
+
+ + +
+
+ + +
+
+ +