feat(sprint8): Phase 4 — Dokumentenarchiv + Vorstandsverwaltung
Backend: - V20 migration: documents table with category, access_level, file storage - V21 migration: board_positions + board_members with term tracking - Document entity + DocumentCategory/DocumentAccessLevel enums - BoardPosition + BoardMember entities - Extended AuditEventType (DOCUMENT_UPLOADED/DELETED, BOARD_MEMBER_ELECTED/REMOVED) - Extended StaffPermission (MANAGE_DOCUMENTS) - Extended NotificationType (BOARD_TERM_EXPIRING) - DocumentService: upload, list, download, delete, storage usage - BoardService: positions CRUD, elect/remove members, current/history - DocumentController: multipart upload, filtered list, download, delete, portal - BoardController: positions, elect, remove, current board, history, portal Frontend: - documents.ts + board.ts service layers - Admin /documents page: grouped by category, upload dialog, filter, download/delete - Admin /board page: current board cards, position management, elect member dialog - Navigation: added Dokumente + Vorstand to sidebar - i18n: documents.* + board.* keys in de.json + en.json
This commit is contained in:
@@ -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<BoardPosition> createPosition(
|
||||
@RequestParam UUID clubId,
|
||||
@RequestBody Map<String, Object> 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<List<BoardPosition>> getPositions(@RequestParam UUID clubId) {
|
||||
return ResponseEntity.ok(boardService.getPositions(clubId));
|
||||
}
|
||||
|
||||
@PutMapping("/board/positions/{id}")
|
||||
public ResponseEntity<BoardPosition> updatePosition(
|
||||
@PathVariable UUID id,
|
||||
@RequestBody Map<String, Object> 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<BoardMember> electBoardMember(
|
||||
@RequestParam UUID clubId,
|
||||
@RequestBody Map<String, Object> 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<List<BoardMember>> getCurrentBoard(@RequestParam UUID clubId) {
|
||||
return ResponseEntity.ok(boardService.getCurrentBoard(clubId));
|
||||
}
|
||||
|
||||
@GetMapping("/board/history")
|
||||
public ResponseEntity<List<BoardMember>> getBoardHistory(@RequestParam UUID clubId) {
|
||||
return ResponseEntity.ok(boardService.getBoardHistory(clubId));
|
||||
}
|
||||
|
||||
@DeleteMapping("/board/members/{id}")
|
||||
public ResponseEntity<Void> 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<List<BoardMember>> getPortalBoard(@RequestParam UUID clubId) {
|
||||
return ResponseEntity.ok(boardService.getCurrentBoard(clubId));
|
||||
}
|
||||
}
|
||||
@@ -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<Document> 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<List<Document>> listDocuments(
|
||||
@RequestParam UUID clubId,
|
||||
@RequestParam(required = false) DocumentCategory category,
|
||||
@RequestParam(required = false) DocumentAccessLevel accessLevel) {
|
||||
List<Document> docs = documentService.listDocuments(clubId, category, accessLevel);
|
||||
return ResponseEntity.ok(docs);
|
||||
}
|
||||
|
||||
@GetMapping("/documents/{id}/download")
|
||||
public ResponseEntity<byte[]> 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<Void> 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<Map<String, Long>> 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<List<Document>> getPortalDocuments(@RequestParam UUID clubId) {
|
||||
List<Document> docs = documentService.listDocuments(clubId, null, DocumentAccessLevel.ALL_MEMBERS);
|
||||
return ResponseEntity.ok(docs);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -23,5 +23,7 @@ public enum NotificationType {
|
||||
PAYMENT_RECEIVED,
|
||||
// Sprint 8 — Assembly:
|
||||
ASSEMBLY_INVITATION,
|
||||
ASSEMBLY_REMINDER
|
||||
ASSEMBLY_REMINDER,
|
||||
// Sprint 8 — Board:
|
||||
BOARD_TERM_EXPIRING
|
||||
}
|
||||
|
||||
@@ -21,5 +21,6 @@ public enum StaffPermission {
|
||||
// Sprint 8:
|
||||
MANAGE_FINANCES,
|
||||
VIEW_FINANCES,
|
||||
MANAGE_ASSEMBLIES
|
||||
MANAGE_ASSEMBLIES,
|
||||
MANAGE_DOCUMENTS
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{t("title")}</h1>
|
||||
<p className="text-muted-foreground">{t("description")}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Dialog
|
||||
open={positionDialogOpen}
|
||||
onOpenChange={setPositionDialogOpen}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("addPosition")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("createPosition")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="posTitle">{t("positionTitle")}</Label>
|
||||
<Input
|
||||
id="posTitle"
|
||||
placeholder={t("positionTitlePlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="posDesc">{t("positionDescription")}</Label>
|
||||
<Input
|
||||
id="posDesc"
|
||||
placeholder={t("positionDescPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="sortOrder">{t("sortOrder")}</Label>
|
||||
<Input id="sortOrder" type="number" defaultValue={0} />
|
||||
</div>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => setPositionDialogOpen(false)}
|
||||
>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog open={electDialogOpen} onOpenChange={setElectDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
{t("electMember")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("electBoardMember")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>{t("position")}</Label>
|
||||
<Select>
|
||||
<option value="">{t("selectPosition")}</option>
|
||||
{mockPositions.map((pos) => (
|
||||
<option key={pos.id} value={pos.id}>
|
||||
{pos.title}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t("member")}</Label>
|
||||
<Select>
|
||||
<option value="">{t("selectMember")}</option>
|
||||
<option value="m1">Max Mustermann</option>
|
||||
<option value="m2">Anna Schmidt</option>
|
||||
<option value="m3">Peter Weber</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="electedAt">{t("electedAt")}</Label>
|
||||
<Input id="electedAt" type="date" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="termStart">{t("termStart")}</Label>
|
||||
<Input id="termStart" type="date" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="termEnd">{t("termEnd")}</Label>
|
||||
<Input id="termEnd" type="date" />
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => setElectDialogOpen(false)}
|
||||
>
|
||||
{t("confirmElection")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Board Members as cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{mockBoardMembers.map((bm) => (
|
||||
<Card key={bm.id}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-primary" />
|
||||
<CardTitle className="text-base">
|
||||
{bm.positionTitle}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive"
|
||||
>
|
||||
<UserMinus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">{bm.memberName}</p>
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Calendar className="h-3 w-3" />
|
||||
<span>
|
||||
{t("elected")}:{" "}
|
||||
{new Date(bm.electedAt).toLocaleDateString("de-DE")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("term")}:{" "}
|
||||
{new Date(bm.termStart).toLocaleDateString("de-DE")}
|
||||
{bm.termEnd
|
||||
? ` – ${new Date(bm.termEnd).toLocaleDateString("de-DE")}`
|
||||
: ` – ${t("unlimited")}`}
|
||||
</div>
|
||||
{bm.termEnd && (
|
||||
<Badge
|
||||
variant={
|
||||
new Date(bm.termEnd) <
|
||||
new Date(Date.now() + 90 * 24 * 60 * 60 * 1000)
|
||||
? "destructive"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
{new Date(bm.termEnd) <
|
||||
new Date(Date.now() + 90 * 24 * 60 * 60 * 1000)
|
||||
? t("termExpiringSoon")
|
||||
: t("termActive")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Positions overview */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Edit className="h-5 w-5" />
|
||||
{t("positions")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{mockPositions.map((pos) => (
|
||||
<div
|
||||
key={pos.id}
|
||||
className="flex items-center justify-between rounded-lg border p-3"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">{pos.title}</p>
|
||||
{pos.description && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{pos.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant={pos.isActive ? "default" : "secondary"}>
|
||||
{pos.isActive ? t("active") : t("inactive")}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 <FileText className="h-4 w-4" />
|
||||
if (contentType.includes("spreadsheet"))
|
||||
return <FileSpreadsheet className="h-4 w-4" />
|
||||
if (contentType.startsWith("image/")) return <Image className="h-4 w-4" />
|
||||
return <File className="h-4 w-4" />
|
||||
}
|
||||
|
||||
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<ClubDocument[]>(mockDocuments)
|
||||
const [uploadOpen, setUploadOpen] = useState(false)
|
||||
const [filterCategory, setFilterCategory] = useState<string>("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<string, ClubDocument[]>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{t("title")}</h1>
|
||||
<p className="text-muted-foreground">{t("description")}</p>
|
||||
</div>
|
||||
<Dialog open={uploadOpen} onOpenChange={setUploadOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{t("upload")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("uploadDocument")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="title">{t("documentTitle")}</Label>
|
||||
<Input id="title" placeholder={t("titlePlaceholder")} />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="category">{t("category")}</Label>
|
||||
<Select id="category">
|
||||
<option value="">{t("selectCategory")}</option>
|
||||
{Object.entries(categoryLabels).map(([key, label]) => (
|
||||
<option key={key} value={key}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="accessLevel">{t("accessLevel")}</Label>
|
||||
<Select id="accessLevel" defaultValue="ALL_MEMBERS">
|
||||
<option value="ALL_MEMBERS">{t("allMembers")}</option>
|
||||
<option value="BOARD_ONLY">{t("boardOnly")}</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="description">{t("descriptionLabel")}</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder={t("descriptionPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="file">{t("file")}</Label>
|
||||
<Input
|
||||
id="file"
|
||||
type="file"
|
||||
accept=".pdf,.docx,.xlsx,.png,.jpg,.jpeg"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t("fileHint")}
|
||||
</p>
|
||||
</div>
|
||||
<Button className="w-full" onClick={() => setUploadOpen(false)}>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{t("uploadButton")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-4 w-4 text-muted-foreground" />
|
||||
<Select
|
||||
className="w-48"
|
||||
value={filterCategory}
|
||||
onChange={(e) => setFilterCategory(e.target.value)}
|
||||
>
|
||||
<option value="ALL">{t("allCategories")}</option>
|
||||
{Object.entries(categoryLabels).map(([key, label]) => (
|
||||
<option key={key} value={key}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{filteredDocuments.length} {t("documentsCount")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Documents grouped by category */}
|
||||
{Object.entries(grouped).map(([category, docs]) => (
|
||||
<Card key={category}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Badge
|
||||
variant={getCategoryBadgeVariant(category as DocumentCategory)}
|
||||
>
|
||||
{categoryLabels[category as DocumentCategory]}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
({docs.length})
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("name")}</TableHead>
|
||||
<TableHead>{t("access")}</TableHead>
|
||||
<TableHead>{t("size")}</TableHead>
|
||||
<TableHead>{t("date")}</TableHead>
|
||||
<TableHead className="text-right">{t("actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{docs.map((doc) => (
|
||||
<TableRow key={doc.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{getFileIcon(doc.contentType)}
|
||||
<div>
|
||||
<p className="font-medium">{doc.title}</p>
|
||||
{doc.description && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{doc.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
doc.accessLevel === "BOARD_ONLY"
|
||||
? "destructive"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
{doc.accessLevel === "BOARD_ONLY"
|
||||
? t("boardOnly")
|
||||
: t("allMembers")}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{formatFileSize(doc.fileSize)}</TableCell>
|
||||
<TableCell>
|
||||
{new Date(doc.createdAt).toLocaleDateString("de-DE")}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button variant="ghost" size="icon">
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -49,6 +49,16 @@ export const navigationsData: NavigationType[] = [
|
||||
href: "/assemblies",
|
||||
iconName: "Gavel",
|
||||
},
|
||||
{
|
||||
title: "Dokumente",
|
||||
href: "/documents",
|
||||
iconName: "FileArchive",
|
||||
},
|
||||
{
|
||||
title: "Vorstand",
|
||||
href: "/board",
|
||||
iconName: "Shield",
|
||||
},
|
||||
{
|
||||
title: "Kalender",
|
||||
href: "/calendar",
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { apiClient } from "@/lib/api-client"
|
||||
|
||||
export interface BoardPosition {
|
||||
id: string
|
||||
title: string
|
||||
description: string | null
|
||||
sortOrder: number
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface BoardMember {
|
||||
id: string
|
||||
clubId: string
|
||||
positionId: string
|
||||
memberId: string
|
||||
electedAt: string
|
||||
termStart: string
|
||||
termEnd: string | null
|
||||
isCurrent: boolean
|
||||
electedInAssemblyId: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface CreatePositionRequest {
|
||||
title: string
|
||||
description?: string
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
export interface ElectBoardMemberRequest {
|
||||
positionId: string
|
||||
memberId: string
|
||||
electedAt: string
|
||||
termStart: string
|
||||
termEnd?: string
|
||||
assemblyId?: string
|
||||
}
|
||||
|
||||
export function createPosition(
|
||||
clubId: string,
|
||||
data: CreatePositionRequest
|
||||
): Promise<BoardPosition> {
|
||||
return apiClient<BoardPosition>(`/board/positions?clubId=${clubId}`, {
|
||||
method: "POST",
|
||||
body: data,
|
||||
})
|
||||
}
|
||||
|
||||
export function getPositions(clubId: string): Promise<BoardPosition[]> {
|
||||
return apiClient<BoardPosition[]>(`/board/positions?clubId=${clubId}`)
|
||||
}
|
||||
|
||||
export function updatePosition(
|
||||
id: string,
|
||||
data: Partial<CreatePositionRequest & { isActive: boolean }>
|
||||
): Promise<BoardPosition> {
|
||||
return apiClient<BoardPosition>(`/board/positions/${id}`, {
|
||||
method: "PUT",
|
||||
body: data,
|
||||
})
|
||||
}
|
||||
|
||||
export function electBoardMember(
|
||||
clubId: string,
|
||||
data: ElectBoardMemberRequest
|
||||
): Promise<BoardMember> {
|
||||
return apiClient<BoardMember>(`/board/members?clubId=${clubId}`, {
|
||||
method: "POST",
|
||||
body: data,
|
||||
})
|
||||
}
|
||||
|
||||
export function getCurrentBoard(clubId: string): Promise<BoardMember[]> {
|
||||
return apiClient<BoardMember[]>(`/board?clubId=${clubId}`)
|
||||
}
|
||||
|
||||
export function getBoardHistory(clubId: string): Promise<BoardMember[]> {
|
||||
return apiClient<BoardMember[]>(`/board/history?clubId=${clubId}`)
|
||||
}
|
||||
|
||||
export function removeBoardMember(id: string, clubId: string): Promise<void> {
|
||||
return apiClient<void>(`/board/members/${id}?clubId=${clubId}`, {
|
||||
method: "DELETE",
|
||||
})
|
||||
}
|
||||
|
||||
export function getPortalBoard(clubId: string): Promise<BoardMember[]> {
|
||||
return apiClient<BoardMember[]>(`/portal/board?clubId=${clubId}`)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { apiClient } from "@/lib/api-client"
|
||||
|
||||
export type DocumentCategory =
|
||||
| "SATZUNG"
|
||||
| "PROTOKOLL"
|
||||
| "VERTRAG"
|
||||
| "VERSICHERUNG"
|
||||
| "GENEHMIGUNG"
|
||||
| "SONSTIGES"
|
||||
|
||||
export type DocumentAccessLevel = "ALL_MEMBERS" | "BOARD_ONLY"
|
||||
|
||||
export interface ClubDocument {
|
||||
id: string
|
||||
title: string
|
||||
category: DocumentCategory
|
||||
filename: string
|
||||
contentType: string
|
||||
fileSize: number
|
||||
accessLevel: DocumentAccessLevel
|
||||
description: string | null
|
||||
uploadedBy: string
|
||||
createdAt: string
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export interface StorageUsage {
|
||||
bytesUsed: number
|
||||
}
|
||||
|
||||
export async function uploadDocument(
|
||||
clubId: string,
|
||||
title: string,
|
||||
category: DocumentCategory,
|
||||
accessLevel: DocumentAccessLevel,
|
||||
description: string | null,
|
||||
file: File
|
||||
): Promise<ClubDocument> {
|
||||
const formData = new FormData()
|
||||
formData.append("file", file)
|
||||
|
||||
const params = new URLSearchParams({
|
||||
clubId,
|
||||
title,
|
||||
category,
|
||||
accessLevel,
|
||||
})
|
||||
if (description) params.append("description", description)
|
||||
|
||||
// Multipart upload — use raw fetch since apiClient assumes JSON
|
||||
const res = await fetch(
|
||||
`/api/backend/documents/upload?${params.toString()}`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
}
|
||||
)
|
||||
if (!res.ok) throw new Error("Upload failed")
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export function listDocuments(
|
||||
clubId: string,
|
||||
category?: DocumentCategory,
|
||||
accessLevel?: DocumentAccessLevel
|
||||
): Promise<ClubDocument[]> {
|
||||
const params = new URLSearchParams({ clubId })
|
||||
if (category) params.append("category", category)
|
||||
if (accessLevel) params.append("accessLevel", accessLevel)
|
||||
return apiClient<ClubDocument[]>(`/documents?${params.toString()}`)
|
||||
}
|
||||
|
||||
export async function downloadDocument(id: string): Promise<Blob> {
|
||||
const res = await fetch(`/api/backend/documents/${id}/download`)
|
||||
if (!res.ok) throw new Error("Download failed")
|
||||
return res.blob()
|
||||
}
|
||||
|
||||
export function deleteDocument(id: string, clubId: string): Promise<void> {
|
||||
return apiClient<void>(`/documents/${id}?clubId=${clubId}`, {
|
||||
method: "DELETE",
|
||||
})
|
||||
}
|
||||
|
||||
export function getStorageUsage(clubId: string): Promise<StorageUsage> {
|
||||
return apiClient<StorageUsage>(`/documents/usage?clubId=${clubId}`)
|
||||
}
|
||||
|
||||
export function getPortalDocuments(clubId: string): Promise<ClubDocument[]> {
|
||||
return apiClient<ClubDocument[]>(`/portal/documents?clubId=${clubId}`)
|
||||
}
|
||||
|
||||
// Helper: format file size
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
// Category labels
|
||||
export const categoryLabels: Record<DocumentCategory, string> = {
|
||||
SATZUNG: "Satzung",
|
||||
PROTOKOLL: "Protokoll",
|
||||
VERTRAG: "Vertrag",
|
||||
VERSICHERUNG: "Versicherung",
|
||||
GENEHMIGUNG: "Genehmigung",
|
||||
SONSTIGES: "Sonstiges",
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package de.cannamanage.service;
|
||||
|
||||
import de.cannamanage.domain.entity.BoardMember;
|
||||
import de.cannamanage.domain.entity.BoardPosition;
|
||||
import de.cannamanage.domain.enums.AuditEventType;
|
||||
import de.cannamanage.service.repository.BoardMemberRepository;
|
||||
import de.cannamanage.service.repository.BoardPositionRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class BoardService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(BoardService.class);
|
||||
|
||||
private final BoardPositionRepository positionRepository;
|
||||
private final BoardMemberRepository memberRepository;
|
||||
private final AuditService auditService;
|
||||
|
||||
public BoardService(BoardPositionRepository positionRepository,
|
||||
BoardMemberRepository memberRepository,
|
||||
AuditService auditService) {
|
||||
this.positionRepository = positionRepository;
|
||||
this.memberRepository = memberRepository;
|
||||
this.auditService = auditService;
|
||||
}
|
||||
|
||||
// --- Positions ---
|
||||
|
||||
@Transactional
|
||||
public BoardPosition createPosition(UUID clubId, String title, String description, Integer sortOrder) {
|
||||
BoardPosition pos = new BoardPosition();
|
||||
pos.setClubId(clubId);
|
||||
pos.setTitle(title);
|
||||
pos.setDescription(description);
|
||||
pos.setSortOrder(sortOrder != null ? sortOrder : 0);
|
||||
return positionRepository.save(pos);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BoardPosition updatePosition(UUID positionId, String title, String description, Integer sortOrder, Boolean isActive) {
|
||||
BoardPosition pos = positionRepository.findById(positionId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Position not found: " + positionId));
|
||||
if (title != null) pos.setTitle(title);
|
||||
if (description != null) pos.setDescription(description);
|
||||
if (sortOrder != null) pos.setSortOrder(sortOrder);
|
||||
if (isActive != null) pos.setIsActive(isActive);
|
||||
return positionRepository.save(pos);
|
||||
}
|
||||
|
||||
public List<BoardPosition> getPositions(UUID clubId) {
|
||||
return positionRepository.findByClubIdAndIsActiveTrueOrderBySortOrderAsc(clubId);
|
||||
}
|
||||
|
||||
// --- Board Members ---
|
||||
|
||||
@Transactional
|
||||
public BoardMember electBoardMember(UUID clubId, UUID positionId, UUID memberId,
|
||||
LocalDate electedAt, LocalDate termStart, LocalDate termEnd,
|
||||
UUID assemblyId, UUID electedBy) {
|
||||
// Mark current holder as not current
|
||||
memberRepository.findByPositionIdAndIsCurrentTrue(positionId)
|
||||
.ifPresent(current -> {
|
||||
current.setIsCurrent(false);
|
||||
memberRepository.save(current);
|
||||
});
|
||||
|
||||
// Create new board member
|
||||
BoardMember bm = new BoardMember();
|
||||
bm.setClubId(clubId);
|
||||
bm.setPositionId(positionId);
|
||||
bm.setMemberId(memberId);
|
||||
bm.setElectedAt(electedAt);
|
||||
bm.setTermStart(termStart);
|
||||
bm.setTermEnd(termEnd);
|
||||
bm.setIsCurrent(true);
|
||||
bm.setElectedInAssemblyId(assemblyId);
|
||||
|
||||
BoardMember saved = memberRepository.save(bm);
|
||||
|
||||
auditService.log(AuditEventType.BOARD_MEMBER_ELECTED, electedBy, clubId,
|
||||
"Board member elected to position " + positionId);
|
||||
|
||||
log.info("Board member {} elected to position {} in club {}", memberId, positionId, clubId);
|
||||
return saved;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void removeBoardMember(UUID boardMemberId, UUID removedBy, UUID clubId) {
|
||||
BoardMember bm = memberRepository.findById(boardMemberId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Board member not found: " + boardMemberId));
|
||||
bm.setIsCurrent(false);
|
||||
memberRepository.save(bm);
|
||||
|
||||
auditService.log(AuditEventType.BOARD_MEMBER_REMOVED, removedBy, clubId,
|
||||
"Board member removed from position " + bm.getPositionId());
|
||||
|
||||
log.info("Board member {} removed from position {} in club {}", bm.getMemberId(), bm.getPositionId(), clubId);
|
||||
}
|
||||
|
||||
public List<BoardMember> getCurrentBoard(UUID clubId) {
|
||||
return memberRepository.findByClubIdAndIsCurrentTrueOrderByCreatedAtAsc(clubId);
|
||||
}
|
||||
|
||||
public List<BoardMember> getBoardHistory(UUID clubId) {
|
||||
return memberRepository.findByClubIdOrderByCreatedAtDesc(clubId);
|
||||
}
|
||||
|
||||
public List<BoardMember> getExpiringTerms(UUID clubId, int withinDays) {
|
||||
LocalDate now = LocalDate.now();
|
||||
LocalDate deadline = now.plusDays(withinDays);
|
||||
return memberRepository.findByClubIdAndIsCurrentTrueAndTermEndBetween(clubId, now, deadline);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package de.cannamanage.service;
|
||||
|
||||
import de.cannamanage.domain.entity.Document;
|
||||
import de.cannamanage.domain.enums.AuditEventType;
|
||||
import de.cannamanage.domain.enums.DocumentAccessLevel;
|
||||
import de.cannamanage.domain.enums.DocumentCategory;
|
||||
import de.cannamanage.service.repository.DocumentRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class DocumentService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(DocumentService.class);
|
||||
private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
private static final Set<String> ALLOWED_TYPES = Set.of(
|
||||
"application/pdf",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"image/png",
|
||||
"image/jpeg"
|
||||
);
|
||||
private static final String UPLOAD_BASE = "/data/uploads";
|
||||
|
||||
private final DocumentRepository documentRepository;
|
||||
private final AuditService auditService;
|
||||
|
||||
public DocumentService(DocumentRepository documentRepository, AuditService auditService) {
|
||||
this.documentRepository = documentRepository;
|
||||
this.auditService = auditService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Document uploadDocument(UUID clubId, String title, DocumentCategory category,
|
||||
DocumentAccessLevel accessLevel, String description,
|
||||
MultipartFile file, UUID uploadedBy) throws IOException {
|
||||
// Validate file
|
||||
if (file.isEmpty()) {
|
||||
throw new IllegalArgumentException("File is empty");
|
||||
}
|
||||
if (file.getSize() > MAX_FILE_SIZE) {
|
||||
throw new IllegalArgumentException("File exceeds maximum size of 10MB");
|
||||
}
|
||||
if (!ALLOWED_TYPES.contains(file.getContentType())) {
|
||||
throw new IllegalArgumentException("File type not allowed. Allowed: PDF, DOCX, XLSX, PNG, JPG");
|
||||
}
|
||||
|
||||
// Generate storage path
|
||||
UUID documentId = UUID.randomUUID();
|
||||
String filename = file.getOriginalFilename() != null ? file.getOriginalFilename() : "document";
|
||||
String storagePath = clubId + "/" + documentId + "_" + filename;
|
||||
Path fullPath = Paths.get(UPLOAD_BASE, storagePath);
|
||||
|
||||
// Ensure directory exists
|
||||
Files.createDirectories(fullPath.getParent());
|
||||
|
||||
// Write file to disk
|
||||
Files.write(fullPath, file.getBytes());
|
||||
|
||||
// Create DB record
|
||||
Document doc = new Document();
|
||||
doc.setId(documentId);
|
||||
doc.setClubId(clubId);
|
||||
doc.setTitle(title);
|
||||
doc.setCategory(category);
|
||||
doc.setFilename(filename);
|
||||
doc.setContentType(file.getContentType());
|
||||
doc.setFileSize(file.getSize());
|
||||
doc.setStoragePath(storagePath);
|
||||
doc.setAccessLevel(accessLevel);
|
||||
doc.setDescription(description);
|
||||
doc.setUploadedBy(uploadedBy);
|
||||
|
||||
Document saved = documentRepository.save(doc);
|
||||
|
||||
auditService.log(AuditEventType.DOCUMENT_UPLOADED, uploadedBy, clubId,
|
||||
"Document uploaded: " + title + " (" + category + ")");
|
||||
|
||||
log.info("Document uploaded: {} ({}) for club {}", title, category, clubId);
|
||||
return saved;
|
||||
}
|
||||
|
||||
public List<Document> listDocuments(UUID clubId, DocumentCategory category, DocumentAccessLevel accessLevel) {
|
||||
if (category != null && accessLevel != null) {
|
||||
return documentRepository.findByClubIdAndCategoryAndAccessLevelOrderByCreatedAtDesc(clubId, category, accessLevel);
|
||||
} else if (category != null) {
|
||||
return documentRepository.findByClubIdAndCategoryOrderByCreatedAtDesc(clubId, category);
|
||||
} else if (accessLevel != null) {
|
||||
return documentRepository.findByClubIdAndAccessLevelOrderByCreatedAtDesc(clubId, accessLevel);
|
||||
}
|
||||
return documentRepository.findByClubIdOrderByCreatedAtDesc(clubId);
|
||||
}
|
||||
|
||||
public Document getDocument(UUID documentId) {
|
||||
return documentRepository.findById(documentId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Document not found: " + documentId));
|
||||
}
|
||||
|
||||
public byte[] downloadDocument(UUID documentId) throws IOException {
|
||||
Document doc = getDocument(documentId);
|
||||
Path fullPath = Paths.get(UPLOAD_BASE, doc.getStoragePath());
|
||||
if (!Files.exists(fullPath)) {
|
||||
throw new IllegalStateException("File not found on disk: " + doc.getStoragePath());
|
||||
}
|
||||
return Files.readAllBytes(fullPath);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteDocument(UUID documentId, UUID deletedBy, UUID clubId) throws IOException {
|
||||
Document doc = getDocument(documentId);
|
||||
Path fullPath = Paths.get(UPLOAD_BASE, doc.getStoragePath());
|
||||
|
||||
// Delete file from disk
|
||||
if (Files.exists(fullPath)) {
|
||||
Files.delete(fullPath);
|
||||
}
|
||||
|
||||
// Delete DB record
|
||||
documentRepository.delete(doc);
|
||||
|
||||
auditService.log(AuditEventType.DOCUMENT_DELETED, deletedBy, clubId,
|
||||
"Document deleted: " + doc.getTitle());
|
||||
|
||||
log.info("Document deleted: {} for club {}", doc.getTitle(), clubId);
|
||||
}
|
||||
|
||||
public long getStorageUsage(UUID clubId) {
|
||||
return documentRepository.sumFileSizeByClubId(clubId);
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package de.cannamanage.service.repository;
|
||||
|
||||
import de.cannamanage.domain.entity.BoardMember;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface BoardMemberRepository extends JpaRepository<BoardMember, UUID> {
|
||||
|
||||
List<BoardMember> findByClubIdAndIsCurrentTrueOrderByCreatedAtAsc(UUID clubId);
|
||||
|
||||
List<BoardMember> findByClubIdOrderByCreatedAtDesc(UUID clubId);
|
||||
|
||||
Optional<BoardMember> findByPositionIdAndIsCurrentTrue(UUID positionId);
|
||||
|
||||
List<BoardMember> findByClubIdAndIsCurrentTrueAndTermEndBefore(UUID clubId, LocalDate date);
|
||||
|
||||
List<BoardMember> findByClubIdAndIsCurrentTrueAndTermEndBetween(UUID clubId, LocalDate from, LocalDate to);
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package de.cannamanage.service.repository;
|
||||
|
||||
import de.cannamanage.domain.entity.BoardPosition;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface BoardPositionRepository extends JpaRepository<BoardPosition, UUID> {
|
||||
|
||||
List<BoardPosition> findByClubIdAndIsActiveTrueOrderBySortOrderAsc(UUID clubId);
|
||||
|
||||
List<BoardPosition> findByClubIdOrderBySortOrderAsc(UUID clubId);
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package de.cannamanage.service.repository;
|
||||
|
||||
import de.cannamanage.domain.entity.Document;
|
||||
import de.cannamanage.domain.enums.DocumentAccessLevel;
|
||||
import de.cannamanage.domain.enums.DocumentCategory;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface DocumentRepository extends JpaRepository<Document, UUID> {
|
||||
|
||||
List<Document> findByClubIdOrderByCreatedAtDesc(UUID clubId);
|
||||
|
||||
List<Document> findByClubIdAndCategoryOrderByCreatedAtDesc(UUID clubId, DocumentCategory category);
|
||||
|
||||
List<Document> findByClubIdAndAccessLevelOrderByCreatedAtDesc(UUID clubId, DocumentAccessLevel accessLevel);
|
||||
|
||||
List<Document> findByClubIdAndCategoryAndAccessLevelOrderByCreatedAtDesc(
|
||||
UUID clubId, DocumentCategory category, DocumentAccessLevel accessLevel);
|
||||
|
||||
@Query("SELECT COALESCE(SUM(d.fileSize), 0) FROM Document d WHERE d.clubId = :clubId")
|
||||
Long sumFileSizeByClubId(@Param("clubId") UUID clubId);
|
||||
}
|
||||
Reference in New Issue
Block a user