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

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:

  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:

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

  1. Add form state for position fields
  2. 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

  1. Add form state for election fields (positionId, memberId, electedAt, termStart, termEnd)
  2. 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)?