Files
cannamanage/docs/sprint-12/cannamanage-sprint12-plan.md
Patrick Plate be932c1930 docs: Sprint 12 planning, analysis, reviews, and code review
- sprint12-analysis.md (full page audit)
- sprint12-plan.md (button fix plan)
- sprint12-testplan.md (button fix test plan)
- sprint12-phase2-integration-tests.md (v3, expert-approved)
- sprint12-phase2-panel-review.md (3 review cycles, 95% confidence)
- sprint12-code-review.md (approved with comments, blockers fixed)
2026-06-18 14:43:25 +02:00

378 lines
10 KiB
Markdown

# Sprint 12 Implementation Plan: "Golden Test Standard"
**Datum:** 18.06.2026
**Autor:** Patrick Plate / Lumen (Planner)
**Status:** v1
**Basis:** cannamanage-sprint12-analysis.md
---
## Übersicht
| Phase | Beschreibung | Aufwand |
|-------|-------------|---------|
| Phase 1 | Documents Page — React Query + Wire Actions | ~2.5h |
| Phase 2 | Documents Page — UX Improvements | ~1h |
| Phase 3 | Board Page — Wire All Actions | ~1.5h |
| **Gesamt** | | **~5h** |
---
## Phase 1: Documents Page — React Query Integration + Action Wiring
### Step 1.1: Add React Query hooks to `services/documents.ts`
**File:** `cannamanage-frontend/src/services/documents.ts`
Add query hooks (pattern matches other services like `services/stock.ts`):
```typescript
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
// Club ID constant (same pattern as info-board)
const CLUB_ID = "00000000-0000-0000-0000-000000000001"
export function useDocumentsQuery(category?: DocumentCategory) {
return useQuery({
queryKey: ["documents", category],
queryFn: () => listDocuments(CLUB_ID, category),
})
}
export function useUploadDocumentMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (params: {
title: string
category: DocumentCategory
accessLevel: DocumentAccessLevel
description: string | null
file: File
}) =>
uploadDocument(
CLUB_ID,
params.title,
params.category,
params.accessLevel,
params.description,
params.file
),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["documents"] })
},
})
}
export function useDeleteDocumentMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) => deleteDocument(id, CLUB_ID),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["documents"] })
},
})
}
```
### Step 1.2: Rewrite Documents Page with React Query
**File:** `cannamanage-frontend/src/app/(dashboard-layout)/documents/page.tsx`
Key changes:
1. Replace `useState(mockDocuments)` with `useDocumentsQuery()` + mock fallback
2. Wire upload form: collect form state → call `uploadMutation.mutate()`
3. Wire download button: `onClick={() => handleDownload(doc.id, doc.filename)}`
4. Wire delete button: confirmation dialog → `deleteMutation.mutate(doc.id)`
**Upload handler:**
```typescript
const uploadMutation = useUploadDocumentMutation()
function handleUpload() {
const fileInput = document.getElementById("file") as HTMLInputElement
const file = fileInput?.files?.[0]
if (!file || !title || !category) {
toast.error("Bitte alle Pflichtfelder ausfüllen")
return
}
uploadMutation.mutate(
{ title, category, accessLevel, description: description || null, file },
{
onSuccess: () => {
toast.success("Dokument hochgeladen")
setUploadOpen(false)
resetForm()
},
onError: () => toast.error("Upload fehlgeschlagen"),
}
)
}
```
**Download handler:**
```typescript
async function handleDownload(docId: string, filename: string) {
try {
const blob = await downloadDocument(docId)
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
toast.success("Download gestartet")
} catch {
toast.error("Download fehlgeschlagen")
}
}
```
**Delete handler with confirmation:**
```typescript
const deleteMutation = useDeleteDocumentMutation()
const [deleteTarget, setDeleteTarget] = useState<ClubDocument | null>(null)
// In AlertDialog:
function handleConfirmDelete() {
if (!deleteTarget) return
deleteMutation.mutate(deleteTarget.id, {
onSuccess: () => {
toast.success("Dokument gelöscht")
setDeleteTarget(null)
},
onError: () => toast.error("Löschen fehlgeschlagen"),
})
}
```
### Step 1.3: Add form state management to Upload Dialog
Add controlled state for all form fields:
```typescript
const [title, setTitle] = useState("")
const [category, setCategory] = useState<DocumentCategory | "">("")
const [accessLevel, setAccessLevel] = useState<DocumentAccessLevel>("ALL_MEMBERS")
const [description, setDescription] = useState("")
```
Wire each `<Input>` and `<Select>` to its state variable with `value` and `onChange`.
---
## Phase 2: Documents Page — UX Improvements
### Step 2.1: Category colors and icons
**File:** `cannamanage-frontend/src/app/(dashboard-layout)/documents/page.tsx`
Replace `getCategoryBadgeVariant()` with a richer mapping:
```typescript
const categoryStyles: Record<DocumentCategory, { color: string; icon: React.ReactNode }> = {
SATZUNG: {
color: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400",
icon: <BookOpen className="h-3 w-3" />,
},
PROTOKOLL: {
color: "bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400",
icon: <FileText className="h-3 w-3" />,
},
VERTRAG: {
color: "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400",
icon: <FileSignature className="h-3 w-3" />,
},
VERSICHERUNG: {
color: "bg-cyan-100 text-cyan-800 dark:bg-cyan-900/30 dark:text-cyan-400",
icon: <Shield className="h-3 w-3" />,
},
GENEHMIGUNG: {
color: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400",
icon: <CheckCircle className="h-3 w-3" />,
},
SONSTIGES: {
color: "bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400",
icon: <File className="h-3 w-3" />,
},
}
```
Use this in the Badge rendering:
```tsx
<Badge className={`inline-flex items-center gap-1 ${categoryStyles[category].color}`}>
{categoryStyles[category].icon}
{categoryLabels[category]}
</Badge>
```
### Step 2.2: Table column width constraints
Add column widths to prevent stretching:
```tsx
<TableHead className="w-[40%] min-w-[200px]">{t("name")}</TableHead>
<TableHead className="w-[120px]">{t("access")}</TableHead>
<TableHead className="w-[80px]">{t("size")}</TableHead>
<TableHead className="w-[100px]">{t("date")}</TableHead>
<TableHead className="w-[80px] text-right">{t("actions")}</TableHead>
```
Add `truncate` to the document title:
```tsx
<p className="font-medium truncate max-w-[250px]">{doc.title}</p>
```
### Step 2.3: Add upload loading state
Show a spinner/disabled state on the upload button while uploading:
```tsx
<Button
className="w-full"
onClick={handleUpload}
disabled={uploadMutation.isPending}
>
{uploadMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
{uploadMutation.isPending ? "Wird hochgeladen..." : t("uploadButton")}
</Button>
```
---
## Phase 3: Board Page — Wire All Actions
### Step 3.1: Add React Query hooks to `services/board.ts`
**File:** `cannamanage-frontend/src/services/board.ts`
Check existing service — add mutation hooks if not present:
```typescript
export function useCreatePositionMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (params: { title: string; description: string; sortOrder: number }) =>
createPosition(params),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["board"] }),
})
}
export function useElectBoardMemberMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (params: ElectBoardMemberRequest) => electBoardMember(params),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["board"] }),
})
}
export function useRemoveBoardMemberMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) => removeBoardMember(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["board"] }),
})
}
```
### Step 3.2: Wire "Position erstellen" dialog
**File:** `cannamanage-frontend/src/app/(dashboard-layout)/board/page.tsx`
1. Add form state for position fields
2. Replace close-only handler with mutation call:
```typescript
const createPosition = useCreatePositionMutation()
function handleCreatePosition() {
if (!posTitle.trim()) return
createPosition.mutate(
{ title: posTitle, description: posDesc, sortOrder: parseInt(sortOrder) || 0 },
{
onSuccess: () => {
toast.success("Position erstellt")
setPositionDialogOpen(false)
resetPositionForm()
},
onError: () => toast.error("Erstellen fehlgeschlagen"),
}
)
}
```
### Step 3.3: Wire "Wahl bestätigen" dialog
1. Add form state for election fields (positionId, memberId, electedAt, termStart, termEnd)
2. Replace close-only handler:
```typescript
const electMember = useElectBoardMemberMutation()
function handleElect() {
if (!selectedPosition || !selectedMember || !electedAt || !termStart) return
electMember.mutate(
{
positionId: selectedPosition,
memberId: selectedMember,
electedAt,
termStart,
termEnd: termEnd || undefined,
},
{
onSuccess: () => {
toast.success("Wahl bestätigt")
setElectDialogOpen(false)
},
onError: () => toast.error("Wahl fehlgeschlagen"),
}
)
}
```
### Step 3.4: Wire "Mitglied absetzen" button
Add confirmation dialog + handler:
```typescript
const removeMember = useRemoveBoardMemberMutation()
const [removeTarget, setRemoveTarget] = useState<string | null>(null)
// On the UserMinus button:
<Button variant="ghost" size="icon" onClick={() => setRemoveTarget(bm.id)}>
<UserMinus className="h-4 w-4" />
</Button>
// Confirmation AlertDialog:
function handleConfirmRemove() {
if (!removeTarget) return
removeMember.mutate(removeTarget, {
onSuccess: () => {
toast.success("Vorstandsmitglied abgesetzt")
setRemoveTarget(null)
},
onError: () => toast.error("Absetzen fehlgeschlagen"),
})
}
```
### Step 3.5: Replace mock data with React Query
Replace `mockBoardMembers` and `mockPositions` with:
```typescript
const { data: boardData, isLoading } = useBoardQuery()
const positions = boardData?.positions ?? mockPositions
const boardMembers = boardData?.members ?? mockBoardMembers
```
---
## Offene Fragen
- [ ] Soll die Documents-Upload-Funktion eine Fortschrittsanzeige haben (xhr.upload.onprogress), oder reicht ein einfacher Spinner?
- [ ] Board: Sollen die Member-Select-Optionen aus dem echten Members-Endpoint geladen werden, oder reichen hardcoded Options bis der Backend-Endpoint steht?
- [ ] Soll ein "Coming soon" Toast für den Reports-Center Tab hinzugefügt werden (momentan scheint es zu existieren als eigene Route)?