# Solo Paneel — Implementatie-specificatie (v2) > **Doel:** een persoonlijk Kanban-bord per product dat de taken toont van stories die geclaimd zijn door de ingelogde developer. Bord werkt met drie kolommen (TO_DO / IN_PROGRESS / DONE), drag-and-drop tussen kolommen, en koppelt aan een nieuw `Story.assignee_id` veld. > > **Scope v1:** geen REVIEW-status, geen multi-product aggregatie, geen taak-level overrides. Story-level assignment volstaat. Desktop-first conform ST-606 — onder 1024px tonen we dezelfde "te smal scherm"-melding als de rest van de app. > > **Versie:** v2 — verwerkt antwoorden uit `backlog.md` over sessie-flag, bestaande Server Actions en desktop-first scope. --- ## Wat veranderde t.o.v. v1 | Onderdeel | v1 aanname | v2 (op basis van backlog) | |---|---|---| | `isDemo` toegang | DB-lookup of session, ambivalent | **Komt uit `session.isDemo` (ST-006, ST-604)** — geen DB-call | | Implementation_plan editen | Bestaande Server Action of API | **Nieuwe `updateTaskPlanAction`** (gericht, optimistisch-vriendelijk) | | Mobiel | Optionele chunk 13 (tab-strip) | **Geen mobile UI**; volg ST-606 desktop-first patroon | | Toast | Algemeen genoemd | **Sonner is geïnstalleerd (ST-603)** — gebruik consistent | | Pending states | Niet uitgewerkt | **`useFormStatus` of `useTransition`** zoals ST-601 voorschrijft | | Demo-tooltip tekst | "Read-only in demo-modus" | **"Niet beschikbaar in demo-modus"** zoals ST-604 | | Sprint Board referentie | Generieke "sprint board" | **ST-313 drie-panelen Sprint Board** — assignee-UI komt in middenpaneel | --- ## 1. Datamodel — Prisma migratie Eén veld erbij, één index erbij. Geen enum-wijzigingen. ```prisma model Story { // ... bestaande velden ongewijzigd ... assignee_id String? assignee User? @relation("StoryAssignee", fields: [assignee_id], references: [id], onDelete: SetNull) @@index([sprint_id, assignee_id]) // hot path: solo-bord query // bestaande indexen ongewijzigd } model User { // ... bestaande velden ongewijzigd ... assigned_stories Story[] @relation("StoryAssignee") } ``` **Migratie:** ```bash npx prisma migrate dev --name add_story_assignee ``` **onDelete-keuze:** `SetNull` zodat verwijderen van een user de stories behoudt (assignee valt terug naar "team"). Cascade zou stories vernietigen — niet wat we willen. **Named relation `"StoryAssignee"`:** voorkomt botsing met andere mogelijke User↔Story relations in de toekomst. --- ## 2. Auth-helper (`lib/auth.ts` uitbreiding) `isDemo` zit al in de sessiecookie sinds ST-006 — geen DB-lookup nodig. ```typescript import { getIronSession } from 'iron-session' import { cookies } from 'next/headers' import { sessionOptions, type SessionData } from '@/lib/session' import { prisma } from '@/lib/prisma' export async function getSession() { return getIronSession(await cookies(), sessionOptions) } export async function requireUser() { const session = await getSession() if (!session?.userId) throw new Error('Niet ingelogd') return session } export async function requireWriter() { const session = await requireUser() if (session.isDemo) throw new Error('Niet beschikbaar in demo-modus') return session } export async function requireProductAccess(productId: string) { const session = await requireUser() const product = await prisma.product.findFirst({ where: { id: productId, OR: [ { user_id: session.userId }, // owner { members: { some: { user_id: session.userId } } }, // member ], }, select: { id: true }, }) if (!product) throw new Error('Geen toegang tot dit product') return session } export async function requireProductWriter(productId: string) { const session = await requireProductAccess(productId) if (session.isDemo) throw new Error('Niet beschikbaar in demo-modus') return session } ``` **Patroon-uitleg:** - `requireUser` — ingelogd, anders fout - `requireWriter` — ingelogd én niet-demo - `requireProductAccess` — ingelogd én lid (read) - `requireProductWriter` — ingelogd én lid én niet-demo (write) **Afhankelijkheid:** controleer of bestaande `actions/*.ts` een eigen lokale `getSession` definiëren. Zo ja, optioneel migreren bij gelegenheid (geen blocker). --- ## 3. Server Actions ### 3a. Story-claim acties (`actions/stories.ts` uitbreiding) ```typescript 'use server' import { z } from 'zod' import { revalidatePath } from 'next/cache' import { prisma } from '@/lib/prisma' import { requireProductWriter } from '@/lib/auth' // --------------------------------------------------------------------------- const claimSchema = z.object({ storyId: z.string().cuid(), productId: z.string().cuid(), }) export async function claimStoryAction(input: z.infer) { const { storyId, productId } = claimSchema.parse(input) const session = await requireProductWriter(productId) await prisma.story.update({ where: { id: storyId, product_id: productId }, // tenant-guard data: { assignee_id: session.userId }, }) revalidatePath(`/products/${productId}/sprint`) revalidatePath(`/products/${productId}/solo`) } // --------------------------------------------------------------------------- export async function unclaimStoryAction(input: z.infer) { const { storyId, productId } = claimSchema.parse(input) await requireProductWriter(productId) await prisma.story.update({ where: { id: storyId, product_id: productId }, data: { assignee_id: null }, }) revalidatePath(`/products/${productId}/sprint`) revalidatePath(`/products/${productId}/solo`) } // --------------------------------------------------------------------------- const reassignSchema = z.object({ storyId: z.string().cuid(), productId: z.string().cuid(), targetUserId: z.string().cuid(), }) export async function reassignStoryAction(input: z.infer) { const { storyId, productId, targetUserId } = reassignSchema.parse(input) await requireProductWriter(productId) // Valideer dat target-user lid is van het product (anders cross-tenant assignment) const isMember = await prisma.product.findFirst({ where: { id: productId, OR: [ { user_id: targetUserId }, { members: { some: { user_id: targetUserId } } }, ], }, select: { id: true }, }) if (!isMember) throw new Error('Doel-gebruiker is geen lid van dit product') await prisma.story.update({ where: { id: storyId, product_id: productId }, data: { assignee_id: targetUserId }, }) revalidatePath(`/products/${productId}/sprint`) revalidatePath(`/products/${productId}/solo`) } // --------------------------------------------------------------------------- const bulkClaimSchema = z.object({ productId: z.string().cuid() }) export async function claimAllUnassignedInActiveSprintAction( input: z.infer, ) { const { productId } = bulkClaimSchema.parse(input) const session = await requireProductWriter(productId) const activeSprint = await prisma.sprint.findFirst({ where: { product_id: productId, status: 'ACTIVE' }, select: { id: true }, }) if (!activeSprint) throw new Error('Geen actieve sprint gevonden') const result = await prisma.story.updateMany({ where: { sprint_id: activeSprint.id, product_id: productId, assignee_id: null, }, data: { assignee_id: session.userId }, }) revalidatePath(`/products/${productId}/sprint`) revalidatePath(`/products/${productId}/solo`) return { claimed: result.count } } ``` ### 3b. Implementation plan editen (`actions/tasks.ts` uitbreiding) Bestaande `updateTaskStatus` (ST-310) en `updateTask` (ST-311) blijven ongewijzigd. We voegen één nieuwe gerichte action toe: ```typescript 'use server' const planSchema = z.object({ taskId: z.string().cuid(), productId: z.string().cuid(), // voor tenant-guard implementationPlan: z.string().max(20000), }) export async function updateTaskPlanAction(input: z.infer) { const { taskId, productId, implementationPlan } = planSchema.parse(input) await requireProductWriter(productId) // Tenant-guard via geneste relatie await prisma.task.update({ where: { id: taskId, story: { product_id: productId }, // verifieer dat task bij product hoort }, data: { implementation_plan: implementationPlan }, }) revalidatePath(`/products/${productId}/solo`) revalidatePath(`/products/${productId}/sprint`) } ``` **Waarom een aparte action:** korter, optimistisch-vriendelijk (kleine payload, lage latency), past bij save-on-blur in de detail-dialoog. De bestaande `updateTask` is voor volledige edits via een formulier. **Toast/UX:** geen success-toast (te frequent bij save-on-blur). Wel error-toast bij fout. Indicator in dialoog (*"Bezig met opslaan…"* / *"Opgeslagen"*). --- ## 4. Routes en pagina's ``` app/ ├── solo/ │ └── page.tsx # /solo → redirect of picker └── products/ └── [id]/ ├── sprint/page.tsx # bestaand (ST-313 drie-panelen) — krijgt UI-uitbreidingen └── solo/ └── page.tsx # /products/[id]/solo → het bord ``` ### 4a. `/solo` — Redirect-pagina Server Component. Leest cookie `lastProductId`, valideert toegang, redirect. ```typescript // app/solo/page.tsx import { cookies } from 'next/headers' import { redirect } from 'next/navigation' import { prisma } from '@/lib/prisma' import { requireUser } from '@/lib/auth' import { ProductPicker } from '@/components/solo/product-picker' export default async function SoloRedirectPage() { const session = await requireUser() const lastProductId = (await cookies()).get('lastProductId')?.value if (lastProductId) { const valid = await prisma.product.findFirst({ where: { id: lastProductId, archived: false, OR: [ { user_id: session.userId }, { members: { some: { user_id: session.userId } } }, ], }, select: { id: true }, }) if (valid) redirect(`/products/${valid.id}/solo`) } // Geen valide cookie → toon picker const products = await prisma.product.findMany({ where: { archived: false, OR: [ { user_id: session.userId }, { members: { some: { user_id: session.userId } } }, ], }, select: { id: true, name: true }, orderBy: { updated_at: 'desc' }, }) return } ``` ### 4b. `/products/[id]/solo` — Het Solo Bord Server Component. Doet alle queries en geeft data door aan een client-side ``. ```typescript // app/products/[id]/solo/page.tsx import { notFound } from 'next/navigation' import { prisma } from '@/lib/prisma' import { requireUser } from '@/lib/auth' import { setLastProductCookie } from '@/lib/cookies' import { SoloBoard } from '@/components/solo/solo-board' import { NoActiveSprint } from '@/components/solo/no-active-sprint' export default async function SoloPage({ params, }: { params: Promise<{ id: string }> }) { const { id } = await params const session = await requireUser() await setLastProductCookie(id) const product = await prisma.product.findFirst({ where: { id, OR: [ { user_id: session.userId }, { members: { some: { user_id: session.userId } } }, ], }, select: { id: true, name: true }, }) if (!product) notFound() const activeSprint = await prisma.sprint.findFirst({ where: { product_id: id, status: 'ACTIVE' }, select: { id: true, sprint_goal: true }, }) if (!activeSprint) return // Parallel: eigen taken + count ongeclaimde stories const [tasks, unassignedStoryCount] = await Promise.all([ prisma.task.findMany({ where: { sprint_id: activeSprint.id, story: { assignee_id: session.userId }, }, select: { id: true, title: true, priority: true, sort_order: true, status: true, description: true, implementation_plan: true, story: { select: { id: true, title: true } }, }, orderBy: [{ priority: 'desc' }, { sort_order: 'asc' }], }), prisma.story.count({ where: { sprint_id: activeSprint.id, assignee_id: null }, }), ]) return ( ) } ``` **Performance:** - Query gebruikt `[sprint_id, assignee_id]` index die we toevoegen → snelle filter - `Promise.all` parallelliseert de twee onafhankelijke queries - `select` projectie houdt payload klein --- ## 5. Cookie-helper (`lib/cookies.ts`) ```typescript 'use server' import { cookies } from 'next/headers' const ONE_MONTH = 60 * 60 * 24 * 30 export async function setLastProductCookie(productId: string) { const store = await cookies() store.set('lastProductId', productId, { httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV === 'production', maxAge: ONE_MONTH, path: '/', }) } ``` --- ## 6. Sprint Board (ST-313) uitbreidingen In het middenpaneel (Sprint Backlog) van het drie-panelen Sprint Board komen de assignee-UI elementen. ### 6a. Story-kaart op het Sprint Backlog paneel Nieuwe elementen op elke story-kaart: ``` ┌──────────────────────────────────────────────────┐ │ ⚡ Story title [···] │ ← actie-menu rechts │ Some PBI · 3 taken │ │ ───────────────────────────────────────────── │ │ [👤 jan.visser] of [— Niet geclaimd] │ ← assignee-chip └──────────────────────────────────────────────────┘ ``` **Assignee-chip:** klein component met `` + username, of een muted badge (`bg-muted text-muted-foreground`) als `assignee_id === null`. **Actie-menu (shadcn `DropdownMenu`):** - *Pak op* → `claimStoryAction` — zichtbaar als ongeclaimd of niet-jij - *Geef terug aan team* → `unclaimStoryAction` — zichtbaar als geclaimd - *Wijs toe aan ▶* (submenu met members) → `reassignStoryAction` **Demo-modus:** hele dropdown disabled met tooltip *"Niet beschikbaar in demo-modus"* (consistent met ST-604). ### 6b. Bovenaan het Sprint Backlog paneel ```tsx

Sprint Backlog

``` Na succes: Sonner-toast *"X stories geclaimd"* (gewone success-toast, niet drag-and-drop frequentie). Bij demo: knop disabled met tooltip *"Niet beschikbaar in demo-modus"*. --- ## 7. Solo Paneel componenten ``` components/solo/ ├── solo-board.tsx # Client root, dnd context, layout ├── solo-column.tsx # Drop target per status ├── solo-task-card.tsx # Draggable kaart (bestaande task-card hergebruiken) ├── task-detail-dialog.tsx # Shadcn Dialog ├── unassigned-stories-sheet.tsx # Shadcn Sheet ├── no-active-sprint.tsx # Empty state └── product-picker.tsx # Voor /solo zonder cookie ``` ### 7a. `` — root component ```typescript 'use client' interface Props { product: { id: string; name: string } sprint: { id: string; sprint_goal: string } tasks: TaskWithStory[] unassignedStoryCount: number isDemo: boolean } export function SoloBoard({ product, sprint, tasks, unassignedStoryCount, isDemo }: Props) { // Zustand store gehydrateerd met initiële taken // DndContext (overslaan als isDemo) met sensor + collision detection // Header: productnaam, sprint goal, knop "Toon openstaande stories (N)" // Drie kolommen in een grid (md:grid-cols-3) } ``` ### 7b. Zustand store (`stores/solo-store.ts`) Volgt het patroon van `usePlannerStore` (ST-201): `init*`, `optimistic*`, `rollback*`. ```typescript import { create } from 'zustand' import type { TaskStatus } from '@prisma/client' interface SoloState { tasks: TaskWithStory[] initTasks: (tasks: TaskWithStory[]) => void optimisticMove: (taskId: string, toStatus: TaskStatus) => TaskStatus | null // returns prev for rollback rollback: (taskId: string, prevStatus: TaskStatus) => void updatePlan: (taskId: string, plan: string) => void } export const useSoloStore = create((set, get) => ({ tasks: [], initTasks: (tasks) => set({ tasks }), optimisticMove: (taskId, toStatus) => { const task = get().tasks.find(t => t.id === taskId) if (!task) return null const prev = task.status set({ tasks: get().tasks.map(t => t.id === taskId ? { ...t, status: toStatus } : t) }) return prev }, rollback: (taskId, prevStatus) => { set({ tasks: get().tasks.map(t => t.id === taskId ? { ...t, status: prevStatus } : t) }) }, updatePlan: (taskId, plan) => { set({ tasks: get().tasks.map(t => t.id === taskId ? { ...t, implementation_plan: plan } : t) }) }, })) ``` ### 7c. Drag-and-drop (dnd-kit) ```typescript function handleDragEnd(event: DragEndEvent) { const { active, over } = event if (!over) return const taskId = String(active.id) const toStatus = String(over.id) as TaskStatus // kolom-id = status enum-value if (!['TO_DO', 'IN_PROGRESS', 'DONE'].includes(toStatus)) return const prev = useSoloStore.getState().optimisticMove(taskId, toStatus) if (prev === null || prev === toStatus) return startTransition(async () => { try { await updateTaskStatusAction(taskId, toStatus) } catch (err) { useSoloStore.getState().rollback(taskId, prev) toast.error('Status bijwerken mislukt — taak teruggeplaatst') } }) } ``` **Sensor-keuze:** `PointerSensor` met `activationConstraint: { distance: 5 }` om accidentele drags op klik te voorkomen. Klik = open dialog, drag = verplaats. **Collision detection:** `closestCorners` voor kolom-niveau drops; geen sortering binnen kolom in v1. **Toast-strategie:** consistent met ST-603 — geen success-toast bij drag (te frequent), wél error-toast bij rollback. **Demo-user:** sla de hele DndContext over en wrap kaart-componenten zonder draggable. Klik werkt nog wel (lezen mag). ### 7d. `` Status-token mapping (briefing): | Status | Header background | |---|---| | `TO_DO` | `bg-status-todo/15 text-status-todo border-status-todo/30` | | `IN_PROGRESS` | `bg-status-in-progress/15 text-status-in-progress border-status-in-progress/30` | | `DONE` | `bg-status-done/15 text-status-done border-status-done/30` | ### 7e. `` — hergebruik bestaande task-card Bestaande task-card uit het sprint board (ST-313 rechterpaneel) hergebruiken. Pas zo nodig aan: - Linker-rand of dot met `bg-priority-{level}` voor prioriteit - Taaktitel (`font-medium`, `truncate`) - Story-titel (`text-sm text-muted-foreground`, `truncate`) - Optionele `showProduct?: boolean` prop (off op product-specifieke pagina; reservering voor toekomstig multi-product bord) Klik → opent `` met deze taak. ### 7f. `` Shadcn `Dialog`. Inhoud: - Header: taaktitel + statusbadge (gekleurd via MD3 tokens) - Sectie *Beschrijving* (read-only `

` of formatted block — volg bestaand task-detailpatroon) - Sectie *Implementatieplan*: `