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)
This commit is contained in:
@@ -0,0 +1,377 @@
|
||||
# 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)?
|
||||
Reference in New Issue
Block a user