- 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)
10 KiB
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):
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:
- Replace
useState(mockDocuments)withuseDocumentsQuery()+ mock fallback - Wire upload form: collect form state → call
uploadMutation.mutate() - Wire download button:
onClick={() => handleDownload(doc.id, doc.filename)} - Wire delete button: confirmation dialog →
deleteMutation.mutate(doc.id)
Upload handler:
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:
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:
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:
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:
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:
<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:
<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:
<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:
<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:
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
- Add form state for position fields
- Replace close-only handler with mutation call:
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
- Add form state for election fields (positionId, memberId, electedAt, termStart, termEnd)
- Replace close-only handler:
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:
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:
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)?