be932c1930
- 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)
378 lines
10 KiB
Markdown
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)?
|