feat: ST-201-ST-210 M2 stories, drag-and-drop en Zustand stores

- usePlannerStore met pbiOrder/storyOrder init/reorder/rollback (ST-201)
- useSelectionStore uitgebreid met selectedStoryId en clearSelection (ST-202)
- PBI drag-and-drop binnen prioriteitsgroep via dnd-kit (ST-203)
- PBI slepen over prioriteitsgrens wijzigt priority (ST-204)
- Stories als blokken met prioriteit- en statusbadge (ST-205/ST-206)
- Story drag-and-drop horizontaal binnen en tussen groepen (ST-207)
- Story detail slide-over met bewerkformulier (ST-208)
- Story verwijderen met bevestigingsstap (ST-209)
- Filter op status en prioriteit in rechterpaneel (ST-210)
- Fix: infinite loop in useEffect door stabiele string dependency

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-04-24 11:46:18 +02:00
parent ffda65490f
commit 4dd62c199c
25 changed files with 1794 additions and 100 deletions

BIN
.icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

92
.icons/icon-master.svg Normal file
View file

@ -0,0 +1,92 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Background gradient -->
<linearGradient id="bg" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#1a1028"/>
<stop offset="100%" stop-color="#0d0a14"/>
</linearGradient>
<!-- Rocket nose gradient -->
<linearGradient id="nose" x1="256" y1="52" x2="256" y2="210" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#c4b5fd"/>
<stop offset="100%" stop-color="#7c3aed"/>
</linearGradient>
<!-- Glow filter -->
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="8" result="blur"/>
<feMerge>
<feMergeNode in="blur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<!-- Flame glow -->
<filter id="flame-glow" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="6" result="blur"/>
<feMerge>
<feMergeNode in="blur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- Background rounded rect -->
<rect width="512" height="512" rx="114" fill="url(#bg)"/>
<!-- Subtle radial glow behind rocket -->
<ellipse cx="256" cy="270" rx="140" ry="160" fill="#7c3aed" opacity="0.07"/>
<!-- ── BLOCK 1 (PBI) — bottom ── -->
<rect x="178" y="374" width="156" height="64" rx="14" fill="#3b55c4" opacity="0.55"/>
<rect x="178" y="374" width="156" height="64" rx="14" stroke="#4f6ef7" stroke-width="2" fill="none"/>
<!-- PBI label dots -->
<circle cx="210" cy="406" r="5" fill="#7c9fff" opacity="0.7"/>
<circle cx="228" cy="406" r="5" fill="#7c9fff" opacity="0.5"/>
<circle cx="246" cy="406" r="5" fill="#7c9fff" opacity="0.3"/>
<!-- ── BLOCK 2 (Story) — middle ── -->
<rect x="148" y="294" width="216" height="76" rx="14" fill="#4c52b8" opacity="0.7"/>
<rect x="148" y="294" width="216" height="76" rx="14" stroke="#818cf8" stroke-width="2" fill="none"/>
<!-- Story label line -->
<rect x="174" y="322" width="100" height="6" rx="3" fill="#a5b4fc" opacity="0.5"/>
<rect x="174" y="338" width="68" height="6" rx="3" fill="#a5b4fc" opacity="0.3"/>
<!-- ── BLOCK 3 (Task) — upper body ── -->
<rect x="164" y="210" width="184" height="80" rx="14" fill="#6d28d9" opacity="0.85"/>
<rect x="164" y="210" width="184" height="80" rx="14" stroke="#a78bfa" stroke-width="2" fill="none"/>
<!-- Task checkmark -->
<path d="M196 252 L212 268 L240 238" stroke="#c4b5fd" stroke-width="7" stroke-linecap="round" stroke-linejoin="round" fill="none" opacity="0.8"/>
<!-- ── ROCKET NOSE ── -->
<path d="M256 52 C224 52 164 112 164 210 H348 C348 112 288 52 256 52Z"
fill="url(#nose)" opacity="0.95" filter="url(#glow)"/>
<!-- Nose highlight -->
<path d="M256 70 C238 70 200 118 196 190"
stroke="white" stroke-width="3" stroke-linecap="round" opacity="0.15" fill="none"/>
<!-- ── WINDOW ── -->
<circle cx="256" cy="148" r="34" fill="#0d0a14" opacity="0.65"/>
<circle cx="256" cy="148" r="26" fill="#ddd6fe" opacity="0.12"/>
<circle cx="256" cy="148" r="18" fill="#ede9fe" opacity="0.9"/>
<!-- Window shine -->
<circle cx="248" cy="140" r="5" fill="white" opacity="0.6"/>
<!-- ── FINS ── -->
<path d="M164 330 L110 400 L164 400 Z" fill="#3b55c4" opacity="0.55"/>
<path d="M164 330 L110 400 L164 400 Z" stroke="#4f6ef7" stroke-width="1.5" fill="none" opacity="0.6"/>
<path d="M348 330 L402 400 L348 400 Z" fill="#3b55c4" opacity="0.55"/>
<path d="M348 330 L402 400 L348 400 Z" stroke="#4f6ef7" stroke-width="1.5" fill="none" opacity="0.6"/>
<!-- ── EXHAUST FLAME ── -->
<path d="M214 438 Q235 480 256 462 Q277 480 298 438"
stroke="#f59e0b" stroke-width="10" stroke-linecap="round" fill="none"
opacity="0.9" filter="url(#flame-glow)"/>
<path d="M228 438 Q242 468 256 455 Q270 468 284 438"
stroke="#fcd34d" stroke-width="7" stroke-linecap="round" fill="none"
opacity="0.75"/>
<path d="M238 438 Q248 458 256 450 Q264 458 274 438"
stroke="#fef3c7" stroke-width="4" stroke-linecap="round" fill="none"
opacity="0.6"/>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

125
.icons/icons/INSTALL.md Normal file
View file

@ -0,0 +1,125 @@
# Scrum4Me — Icon installatie voor Next.js
## Bestandslocaties
Kopieer de bestanden naar de juiste plek in je Next.js App Router project:
```
app/
favicon.ico ← favicon.ico
icon-192.png ← icon-192.png (rename: icon.png)
apple-icon.png ← icon-180.png (rename: apple-icon.png)
public/
icon-512.png ← voor PWA manifest
icon-192.png ← voor PWA manifest
components/
shared/
app-icon.tsx ← herbruikbare React component
```
## Stap 1 — Favicon en app icons
```bash
# Kopieer naar app/ map (Next.js pikt deze automatisch op)
cp favicon.ico ../app/favicon.ico
cp icon-192.png ../app/icon.png
cp icon-180.png ../app/apple-icon.png
# Kopieer naar public/ voor manifest
cp icon-512.png ../public/icon-512.png
cp icon-192.png ../public/icon-192.png
```
## Stap 2 — Metadata in app/layout.tsx
```tsx
// app/layout.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: {
default: 'Scrum4Me',
template: '%s — Scrum4Me',
},
description: 'Lichtgewicht Scrum-planner voor solo developers en kleine teams',
icons: {
icon: [
{ url: '/favicon.ico', sizes: '48x48' },
{ url: '/icon.png', sizes: '192x192', type: 'image/png' },
],
apple: [
{ url: '/apple-icon.png', sizes: '180x180', type: 'image/png' },
],
},
manifest: '/manifest.json',
}
```
## Stap 3 — PWA manifest (optioneel)
Maak `public/manifest.json` aan:
```json
{
"name": "Scrum4Me",
"short_name": "Scrum4Me",
"description": "Lichtgewicht Scrum-planner voor solo developers en kleine teams",
"start_url": "/dashboard",
"display": "standalone",
"background_color": "#0d0a14",
"theme_color": "#7c3aed",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
```
## Stap 4 — AppIcon component gebruiken
```tsx
// In de navigatiebalk:
import { AppIcon } from '@/components/shared/app-icon'
export function Navbar() {
return (
<nav>
<div className="flex items-center gap-2">
<AppIcon size={28} />
<span className="font-semibold text-foreground">Scrum4Me</span>
</div>
</nav>
)
}
```
## Gegenereerde bestanden — overzicht
| Bestand | Formaat | Gebruik |
|---|---|---|
| `favicon.ico` | ICO (16+32+48) | Browser tabblad |
| `icon-16.png` | PNG 16×16 | Browser fallback |
| `icon-32.png` | PNG 32×32 | Browser retina tabblad |
| `icon-48.png` | PNG 48×48 | Windows taskbar |
| `icon-76.png` | PNG 76×76 | iPad non-retina |
| `icon-120.png` | PNG 120×120 | iPhone retina |
| `icon-144.png` | PNG 144×144 | Windows tile |
| `icon-152.png` | PNG 152×152 | iPad retina |
| `icon-180.png` | PNG 180×180 | Apple touch icon |
| `icon-192.png` | PNG 192×192 | Android / PWA |
| `icon-512.png` | PNG 512×512 | PWA splash / stores |
| `icon-master.svg` | SVG | Bronbestand (master) |
| `icon-simple.svg` | SVG | Vereenvoudigd voor kleine formaten |
| `app-icon.tsx` | React | Inline SVG component |

70
.icons/icons/app-icon.tsx Normal file
View file

@ -0,0 +1,70 @@
// components/shared/app-icon.tsx
// Scrum4Me app icon — concept 5 (Rocket)
// Gebruik: <AppIcon size={32} /> of <AppIcon size={64} className="..." />
interface AppIconProps {
size?: number
className?: string
}
export function AppIcon({ size = 32, className }: AppIconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 512 512"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
aria-label="Scrum4Me"
>
<defs>
<linearGradient id="s4m-bg" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse">
<stop offset="0%" stopColor="#1a1028"/>
<stop offset="100%" stopColor="#0d0a14"/>
</linearGradient>
<linearGradient id="s4m-nose" x1="256" y1="60" x2="256" y2="212" gradientUnits="userSpaceOnUse">
<stop offset="0%" stopColor="#c4b5fd"/>
<stop offset="100%" stopColor="#7c3aed"/>
</linearGradient>
</defs>
{/* Background */}
<rect width="512" height="512" rx="114" fill="url(#s4m-bg)"/>
{/* Block 1 — PBI */}
<rect x="174" y="372" width="164" height="60" rx="12" fill="#4f6ef7" opacity="0.6"/>
{/* Block 2 — Story */}
<rect x="144" y="292" width="224" height="76" rx="12" fill="#6366f1" opacity="0.75"/>
{/* Block 3 — Task */}
<rect x="160" y="212" width="192" height="76" rx="12" fill="#7c3aed" opacity="0.9"/>
{/* Rocket nose */}
<path
d="M256 60 C222 60 160 122 160 212 H352 C352 122 290 60 256 60Z"
fill="url(#s4m-nose)"
/>
{/* Window */}
<circle cx="256" cy="152" r="30" fill="#0d0a14" opacity="0.5"/>
<circle cx="256" cy="152" r="20" fill="#ede9fe" opacity="0.95"/>
<circle cx="248" cy="144" r="6" fill="white" opacity="0.5"/>
{/* Fins */}
<path d="M160 332 L108 400 L160 400 Z" fill="#4f6ef7" opacity="0.55"/>
<path d="M352 332 L404 400 L352 400 Z" fill="#4f6ef7" opacity="0.55"/>
{/* Flame */}
<path
d="M212 432 Q244 472 256 456 Q268 472 300 432"
stroke="#f59e0b" strokeWidth="12" strokeLinecap="round" fill="none" opacity="0.95"
/>
<path
d="M232 432 Q248 460 256 448 Q264 460 280 432"
stroke="#fef3c7" strokeWidth="7" strokeLinecap="round" fill="none" opacity="0.7"
/>
</svg>
)
}

BIN
.icons/icons/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 569 B

BIN
.icons/icons/icon-120.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

BIN
.icons/icons/icon-144.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

BIN
.icons/icons/icon-152.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

BIN
.icons/icons/icon-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 573 B

BIN
.icons/icons/icon-180.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
.icons/icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
.icons/icons/icon-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
.icons/icons/icon-48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
.icons/icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

BIN
.icons/icons/icon-76.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

View file

@ -0,0 +1,92 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Background gradient -->
<linearGradient id="bg" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#1a1028"/>
<stop offset="100%" stop-color="#0d0a14"/>
</linearGradient>
<!-- Rocket nose gradient -->
<linearGradient id="nose" x1="256" y1="52" x2="256" y2="210" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#c4b5fd"/>
<stop offset="100%" stop-color="#7c3aed"/>
</linearGradient>
<!-- Glow filter -->
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="8" result="blur"/>
<feMerge>
<feMergeNode in="blur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<!-- Flame glow -->
<filter id="flame-glow" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="6" result="blur"/>
<feMerge>
<feMergeNode in="blur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- Background rounded rect -->
<rect width="512" height="512" rx="114" fill="url(#bg)"/>
<!-- Subtle radial glow behind rocket -->
<ellipse cx="256" cy="270" rx="140" ry="160" fill="#7c3aed" opacity="0.07"/>
<!-- ── BLOCK 1 (PBI) — bottom ── -->
<rect x="178" y="374" width="156" height="64" rx="14" fill="#3b55c4" opacity="0.55"/>
<rect x="178" y="374" width="156" height="64" rx="14" stroke="#4f6ef7" stroke-width="2" fill="none"/>
<!-- PBI label dots -->
<circle cx="210" cy="406" r="5" fill="#7c9fff" opacity="0.7"/>
<circle cx="228" cy="406" r="5" fill="#7c9fff" opacity="0.5"/>
<circle cx="246" cy="406" r="5" fill="#7c9fff" opacity="0.3"/>
<!-- ── BLOCK 2 (Story) — middle ── -->
<rect x="148" y="294" width="216" height="76" rx="14" fill="#4c52b8" opacity="0.7"/>
<rect x="148" y="294" width="216" height="76" rx="14" stroke="#818cf8" stroke-width="2" fill="none"/>
<!-- Story label line -->
<rect x="174" y="322" width="100" height="6" rx="3" fill="#a5b4fc" opacity="0.5"/>
<rect x="174" y="338" width="68" height="6" rx="3" fill="#a5b4fc" opacity="0.3"/>
<!-- ── BLOCK 3 (Task) — upper body ── -->
<rect x="164" y="210" width="184" height="80" rx="14" fill="#6d28d9" opacity="0.85"/>
<rect x="164" y="210" width="184" height="80" rx="14" stroke="#a78bfa" stroke-width="2" fill="none"/>
<!-- Task checkmark -->
<path d="M196 252 L212 268 L240 238" stroke="#c4b5fd" stroke-width="7" stroke-linecap="round" stroke-linejoin="round" fill="none" opacity="0.8"/>
<!-- ── ROCKET NOSE ── -->
<path d="M256 52 C224 52 164 112 164 210 H348 C348 112 288 52 256 52Z"
fill="url(#nose)" opacity="0.95" filter="url(#glow)"/>
<!-- Nose highlight -->
<path d="M256 70 C238 70 200 118 196 190"
stroke="white" stroke-width="3" stroke-linecap="round" opacity="0.15" fill="none"/>
<!-- ── WINDOW ── -->
<circle cx="256" cy="148" r="34" fill="#0d0a14" opacity="0.65"/>
<circle cx="256" cy="148" r="26" fill="#ddd6fe" opacity="0.12"/>
<circle cx="256" cy="148" r="18" fill="#ede9fe" opacity="0.9"/>
<!-- Window shine -->
<circle cx="248" cy="140" r="5" fill="white" opacity="0.6"/>
<!-- ── FINS ── -->
<path d="M164 330 L110 400 L164 400 Z" fill="#3b55c4" opacity="0.55"/>
<path d="M164 330 L110 400 L164 400 Z" stroke="#4f6ef7" stroke-width="1.5" fill="none" opacity="0.6"/>
<path d="M348 330 L402 400 L348 400 Z" fill="#3b55c4" opacity="0.55"/>
<path d="M348 330 L402 400 L348 400 Z" stroke="#4f6ef7" stroke-width="1.5" fill="none" opacity="0.6"/>
<!-- ── EXHAUST FLAME ── -->
<path d="M214 438 Q235 480 256 462 Q277 480 298 438"
stroke="#f59e0b" stroke-width="10" stroke-linecap="round" fill="none"
opacity="0.9" filter="url(#flame-glow)"/>
<path d="M228 438 Q242 468 256 455 Q270 468 284 438"
stroke="#fcd34d" stroke-width="7" stroke-linecap="round" fill="none"
opacity="0.75"/>
<path d="M238 438 Q248 458 256 450 Q264 458 274 438"
stroke="#fef3c7" stroke-width="4" stroke-linecap="round" fill="none"
opacity="0.6"/>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -0,0 +1,40 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg-s" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#1a1028"/>
<stop offset="100%" stop-color="#0d0a14"/>
</linearGradient>
<linearGradient id="nose-s" x1="256" y1="60" x2="256" y2="230" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#c4b5fd"/>
<stop offset="100%" stop-color="#7c3aed"/>
</linearGradient>
</defs>
<rect width="512" height="512" rx="114" fill="url(#bg-s)"/>
<!-- Block 1 (PBI) -->
<rect x="174" y="372" width="164" height="60" rx="12" fill="#4f6ef7" opacity="0.6"/>
<!-- Block 2 (Story) -->
<rect x="144" y="292" width="224" height="76" rx="12" fill="#6366f1" opacity="0.75"/>
<!-- Block 3 (Task) -->
<rect x="160" y="212" width="192" height="76" rx="12" fill="#7c3aed" opacity="0.9"/>
<!-- Rocket nose -->
<path d="M256 60 C222 60 160 122 160 212 H352 C352 122 290 60 256 60Z" fill="url(#nose-s)"/>
<!-- Window -->
<circle cx="256" cy="152" r="30" fill="#0d0a14" opacity="0.5"/>
<circle cx="256" cy="152" r="20" fill="#ede9fe" opacity="0.95"/>
<!-- Fins -->
<path d="M160 332 L108 400 L160 400 Z" fill="#4f6ef7" opacity="0.55"/>
<path d="M352 332 L404 400 L352 400 Z" fill="#4f6ef7" opacity="0.55"/>
<!-- Flame -->
<path d="M212 432 Q244 472 256 456 Q268 472 300 432"
stroke="#f59e0b" stroke-width="12" stroke-linecap="round" fill="none" opacity="0.95"/>
<path d="M232 432 Q248 460 256 448 Q264 460 280 432"
stroke="#fef3c7" stroke-width="7" stroke-linecap="round" fill="none" opacity="0.7"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -21,6 +21,9 @@ Lees het relevante document voordat je aan een feature begint. Nooit gokken over
| `scrum4me-backlog.md` | Welke task bouwen, in welke volgorde, "done when"-criteria | | `scrum4me-backlog.md` | Welke task bouwen, in welke volgorde, "done when"-criteria |
| `scrum4me-personas.md` | Lars (primaire gebruiker), Dina, Remi — gebruik bij UI-beslissingen | | `scrum4me-personas.md` | Lars (primaire gebruiker), Dina, Remi — gebruik bij UI-beslissingen |
| `scrum4me-product-backlog.md` | Testdata voor de seed — PBI's en stories van Scrum4Me zelf | | `scrum4me-product-backlog.md` | Testdata voor de seed — PBI's en stories van Scrum4Me zelf |
| `scrum4me-styling.md` | **Lees dit voor elk component** — MD3-kleuren, shadcn gebruik, component-patronen |
| `theme.css` | Bronbestand — kopieer naar `styles/theme.css`, importeer in `app/globals.css` |
| `MD3_Color_Scheme_Documentation.md` | Volledige MD3-kleurendocumentatie als referentie |
--- ---
@ -47,7 +50,8 @@ Per task:
``` ```
Next.js 15 (App Router) + React 19 Next.js 15 (App Router) + React 19
TypeScript strict TypeScript strict
Tailwind CSS + shadcn/ui Tailwind CSS + shadcn/ui ← UI-primitieven (Button, Dialog, Sheet, Badge, etc.)
MD3 kleurensysteem via theme.css ← semantische tokens, nooit willekeurige Tailwind-kleuren
Zustand (client state) Zustand (client state)
dnd-kit (drag-and-drop) dnd-kit (drag-and-drop)
Prisma v7 (ORM) Prisma v7 (ORM)
@ -58,6 +62,10 @@ Zod (validatie)
Sonner (toasts) Sonner (toasts)
``` ```
> **Stylingregel:** Gebruik **nooit** `bg-blue-500`, `bg-green-600` of andere willekeurige Tailwind-kleuren.
> Gebruik altijd semantische MD3-tokens: `bg-primary`, `bg-status-done`, `bg-priority-critical`, etc.
> Zie `scrum4me-styling.md` voor alle patronen en regels.
--- ---
## Exacte dependencies (package.json) ## Exacte dependencies (package.json)
@ -97,6 +105,18 @@ Sonner (toasts)
--- ---
## theme.css installeren
```bash
# Kopieer theme.css naar de project root of styles map
cp theme.css app/styles/theme.css
# Importeer bovenaan app/globals.css:
# @import './styles/theme.css';
```
Dark mode werkt via `.dark` class op `<html>`. Zie `scrum4me-styling.md` voor het ThemeToggle component.
## shadcn/ui componenten om te installeren ## shadcn/ui componenten om te installeren
Voer deze uit na `npx shadcn@latest init`: Voer deze uit na `npx shadcn@latest init`:

384
Srum4MeIcons.html Normal file
View file

@ -0,0 +1,384 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Scrum4Me — Icoon Concepten</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Syne:wght@700;800&display=swap');
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0f1117;
--surface: #181c27;
--border: #252a38;
--accent: #4f6ef7;
--accent2: #7c3aed;
--accent3: #06b6d4;
--text: #e8eaf0;
--muted: #6b7280;
}
body {
background: var(--bg);
color: var(--text);
font-family: 'DM Mono', monospace;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 24px;
gap: 64px;
}
header {
text-align: center;
}
header h1 {
font-family: 'Syne', sans-serif;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--muted);
}
header p {
font-size: 11px;
color: var(--muted);
margin-top: 8px;
letter-spacing: 0.05em;
}
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 40px;
max-width: 900px;
width: 100%;
}
.concept {
display: flex;
flex-direction: column;
align-items: center;
gap: 32px;
}
.label {
font-size: 11px;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--muted);
text-align: center;
}
.label strong {
display: block;
font-family: 'Syne', sans-serif;
font-size: 13px;
color: var(--text);
letter-spacing: 0.05em;
margin-bottom: 4px;
}
/* Icon containers — three sizes */
.sizes {
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
}
.icon-wrap {
display: flex;
align-items: center;
justify-content: center;
border-radius: 22%;
background: var(--surface);
border: 1px solid var(--border);
flex-shrink: 0;
transition: transform 0.2s ease, box-shadow 0.2s ease;
cursor: default;
}
.icon-wrap:hover {
transform: translateY(-3px);
box-shadow: 0 12px 40px rgba(79, 110, 247, 0.2);
}
.icon-wrap.xl { width: 120px; height: 120px; border-radius: 26px; }
.icon-wrap.md { width: 64px; height: 64px; border-radius: 14px; }
.icon-wrap.sm { width: 32px; height: 32px; border-radius: 7px; }
.size-row {
display: flex;
align-items: center;
gap: 16px;
}
/* =====================
CONCEPT 2 — S4M Lettermerk
===================== */
.s4m-xl { background: linear-gradient(135deg, #1a1f35 0%, #0f1117 100%); }
/* =====================
CONCEPT 3 — Iteratielus
===================== */
.loop-xl { background: linear-gradient(135deg, #0d1f2d 0%, #0f1117 100%); }
/* =====================
CONCEPT 5 — Raket
===================== */
.rocket-xl { background: linear-gradient(135deg, #1a1028 0%, #0f1117 100%); }
/* Divider */
.divider {
width: 1px;
height: 40px;
background: var(--border);
}
footer {
font-size: 11px;
color: var(--muted);
letter-spacing: 0.1em;
}
</style>
</head>
<body>
<header>
<h1>Scrum4Me — Icoon Concepten</h1>
<p>2 · S4M Lettermerk &nbsp;·&nbsp; 3 · Iteratielus &nbsp;·&nbsp; 5 · Raket</p>
</header>
<div class="grid">
<!-- ======================== CONCEPT 2: S4M LETTERMERK ======================== -->
<div class="concept">
<div class="sizes">
<!-- XL -->
<div class="icon-wrap xl s4m-xl">
<svg width="80" height="80" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="g2a" x1="0" y1="0" x2="80" y2="80" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#7c9fff"/>
<stop offset="100%" stop-color="#4f6ef7"/>
</linearGradient>
</defs>
<!-- S -->
<path d="M14 26C14 22 17 19 22 19H32C36 19 39 21.5 39 25.5C39 29 36.5 31 33 32L22 35C18 36.5 15 39 15 43.5C15 48 18.5 51 23 51H34C38.5 51 42 48 42 44"
stroke="url(#g2a)" stroke-width="4.5" stroke-linecap="round" fill="none"/>
<!-- 4 -->
<path d="M52 19L44 37H62" stroke="url(#g2a)" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<line x1="55" y1="30" x2="55" y2="51" stroke="url(#g2a)" stroke-width="4.5" stroke-linecap="round"/>
<!-- M -->
<path d="M14 59L14 73M14 59L21 68L28 59M28 59L28 73" stroke="url(#g2a)" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<!-- dot accent -->
<circle cx="65" cy="61" r="4" fill="#4f6ef7" opacity="0.6"/>
<circle cx="65" cy="61" r="2" fill="#7c9fff"/>
</svg>
</div>
<!-- MD + SM -->
<div class="size-row">
<div class="icon-wrap md s4m-xl">
<svg width="42" height="42" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="g2b" x1="0" y1="0" x2="80" y2="80" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#7c9fff"/>
<stop offset="100%" stop-color="#4f6ef7"/>
</linearGradient>
</defs>
<path d="M14 26C14 22 17 19 22 19H32C36 19 39 21.5 39 25.5C39 29 36.5 31 33 32L22 35C18 36.5 15 39 15 43.5C15 48 18.5 51 23 51H34C38.5 51 42 48 42 44"
stroke="url(#g2b)" stroke-width="4.5" stroke-linecap="round" fill="none"/>
<path d="M52 19L44 37H62" stroke="url(#g2b)" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<line x1="55" y1="30" x2="55" y2="51" stroke="url(#g2b)" stroke-width="4.5" stroke-linecap="round"/>
<path d="M14 59L14 73M14 59L21 68L28 59M28 59L28 73" stroke="url(#g2b)" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<circle cx="65" cy="61" r="4" fill="#4f6ef7" opacity="0.6"/>
<circle cx="65" cy="61" r="2" fill="#7c9fff"/>
</svg>
</div>
<div class="icon-wrap sm s4m-xl">
<svg width="20" height="20" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 26C14 22 17 19 22 19H32C36 19 39 21.5 39 25.5C39 29 36.5 31 33 32L22 35C18 36.5 15 39 15 43.5C15 48 18.5 51 23 51H34C38.5 51 42 48 42 44"
stroke="#4f6ef7" stroke-width="5" stroke-linecap="round" fill="none"/>
<path d="M52 19L44 37H62" stroke="#4f6ef7" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<line x1="55" y1="30" x2="55" y2="51" stroke="#4f6ef7" stroke-width="5" stroke-linecap="round"/>
</svg>
</div>
</div>
</div>
<div class="label">
<strong>02 — S4M Lettermerk</strong>
S · 4 · M als geïntegreerd monogram<br>accent-dot als leesteken
</div>
</div>
<!-- ======================== CONCEPT 3: ITERATIELUS ======================== -->
<div class="concept">
<div class="sizes">
<!-- XL -->
<div class="icon-wrap xl loop-xl">
<svg width="80" height="80" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="g3a" x1="0" y1="40" x2="80" y2="40" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#06b6d4"/>
<stop offset="60%" stop-color="#4f6ef7"/>
<stop offset="100%" stop-color="#7c3aed"/>
</linearGradient>
</defs>
<!-- Open arc — 300 degrees, gap at top-right -->
<path d="M40 12
A28 28 0 1 1 62.2 26"
stroke="url(#g3a)"
stroke-width="6"
stroke-linecap="round"
fill="none"/>
<!-- Arrowhead at the open end -->
<path d="M62.2 26 L74 20 M62.2 26 L68 38"
stroke="#7c3aed"
stroke-width="5.5"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"/>
<!-- Centre dot -->
<circle cx="40" cy="40" r="5" fill="url(#g3a)" opacity="0.5"/>
<circle cx="40" cy="40" r="2.5" fill="#06b6d4"/>
</svg>
</div>
<!-- MD + SM -->
<div class="size-row">
<div class="icon-wrap md loop-xl">
<svg width="42" height="42" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="g3b" x1="0" y1="40" x2="80" y2="40" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#06b6d4"/>
<stop offset="60%" stop-color="#4f6ef7"/>
<stop offset="100%" stop-color="#7c3aed"/>
</linearGradient>
</defs>
<path d="M40 12 A28 28 0 1 1 62.2 26" stroke="url(#g3b)" stroke-width="6" stroke-linecap="round" fill="none"/>
<path d="M62.2 26 L74 20 M62.2 26 L68 38" stroke="#7c3aed" stroke-width="5.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<circle cx="40" cy="40" r="5" fill="url(#g3b)" opacity="0.5"/>
<circle cx="40" cy="40" r="2.5" fill="#06b6d4"/>
</svg>
</div>
<div class="icon-wrap sm loop-xl">
<svg width="20" height="20" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M40 12 A28 28 0 1 1 62.2 26" stroke="#06b6d4" stroke-width="7" stroke-linecap="round" fill="none"/>
<path d="M62.2 26 L74 20 M62.2 26 L68 38" stroke="#7c3aed" stroke-width="6.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>
</div>
</div>
</div>
<div class="label">
<strong>03 — Iteratielus</strong>
Open boog · gradient cyan→indigo→violet<br>pijlpunt markeert de opening
</div>
</div>
<!-- ======================== CONCEPT 5: RAKET ======================== -->
<div class="concept">
<div class="sizes">
<!-- XL -->
<div class="icon-wrap xl rocket-xl">
<svg width="80" height="80" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="g5a" x1="40" y1="8" x2="40" y2="72" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#a78bfa"/>
<stop offset="100%" stop-color="#7c3aed"/>
</linearGradient>
<linearGradient id="g5b" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#4f6ef7" stop-opacity="0.6"/>
<stop offset="100%" stop-color="#7c3aed" stop-opacity="0.6"/>
</linearGradient>
</defs>
<!-- Block 1 (PBI) — bottom -->
<rect x="28" y="58" width="24" height="10" rx="3" fill="#4f6ef7" opacity="0.5"/>
<rect x="28" y="58" width="24" height="10" rx="3" stroke="#4f6ef7" stroke-width="1" fill="none"/>
<!-- Block 2 (Story) — middle -->
<rect x="22" y="44" width="36" height="12" rx="3" fill="#6366f1" opacity="0.65"/>
<rect x="22" y="44" width="36" height="12" rx="3" stroke="#818cf8" stroke-width="1" fill="none"/>
<!-- Block 3 (Task) — upper body -->
<rect x="26" y="30" width="28" height="12" rx="3" fill="#7c3aed" opacity="0.8"/>
<rect x="26" y="30" width="28" height="12" rx="3" stroke="#a78bfa" stroke-width="1" fill="none"/>
<!-- Rocket nose -->
<path d="M40 8 C34 8 26 18 26 30 H54 C54 18 46 8 40 8Z"
fill="url(#g5a)" opacity="0.9"/>
<!-- Window -->
<circle cx="40" cy="22" r="5" fill="#0f1117" opacity="0.6"/>
<circle cx="40" cy="22" r="3" fill="#e0e7ff" opacity="0.9"/>
<!-- Fins -->
<path d="M26 52 L18 62 L26 62 Z" fill="#4f6ef7" opacity="0.5"/>
<path d="M54 52 L62 62 L54 62 Z" fill="#4f6ef7" opacity="0.5"/>
<!-- Exhaust flame -->
<path d="M33 68 Q37 76 40 72 Q43 76 47 68" stroke="#f59e0b" stroke-width="2.5" stroke-linecap="round" fill="none" opacity="0.8"/>
<path d="M36 68 Q40 73 44 68" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.6"/>
</svg>
</div>
<!-- MD + SM -->
<div class="size-row">
<div class="icon-wrap md rocket-xl">
<svg width="42" height="42" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="g5c" x1="40" y1="8" x2="40" y2="60" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#a78bfa"/>
<stop offset="100%" stop-color="#7c3aed"/>
</linearGradient>
</defs>
<rect x="28" y="58" width="24" height="10" rx="3" fill="#4f6ef7" opacity="0.5"/>
<rect x="22" y="44" width="36" height="12" rx="3" fill="#6366f1" opacity="0.65"/>
<rect x="26" y="30" width="28" height="12" rx="3" fill="#7c3aed" opacity="0.8"/>
<path d="M40 8 C34 8 26 18 26 30 H54 C54 18 46 8 40 8Z" fill="url(#g5c)" opacity="0.9"/>
<circle cx="40" cy="22" r="5" fill="#0f1117" opacity="0.6"/>
<circle cx="40" cy="22" r="3" fill="#e0e7ff" opacity="0.9"/>
<path d="M26 52 L18 62 L26 62 Z" fill="#4f6ef7" opacity="0.5"/>
<path d="M54 52 L62 62 L54 62 Z" fill="#4f6ef7" opacity="0.5"/>
<path d="M33 68 Q37 76 40 72 Q43 76 47 68" stroke="#f59e0b" stroke-width="2.5" stroke-linecap="round" fill="none" opacity="0.8"/>
</svg>
</div>
<div class="icon-wrap sm rocket-xl">
<svg width="20" height="20" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="28" y="56" width="24" height="12" rx="3" fill="#4f6ef7" opacity="0.6"/>
<rect x="22" y="40" width="36" height="14" rx="3" fill="#6366f1" opacity="0.75"/>
<rect x="26" y="24" width="28" height="14" rx="3" fill="#7c3aed" opacity="0.9"/>
<path d="M40 6 C34 6 26 16 26 24 H54 C54 16 46 6 40 6Z" fill="#a78bfa"/>
</svg>
</div>
</div>
</div>
<div class="label">
<strong>05 — De Raket</strong>
3 blokken (PBI · story · taak) die opstijgen<br>neus · vinnen · uitlaatpit
</div>
</div>
</div>
<footer>Scrum4Me · Icoon Concepten v0.1 · april 2026</footer>
</body>
</html>

195
actions/stories.ts Normal file
View file

@ -0,0 +1,195 @@
'use server'
import { revalidatePath } from 'next/cache'
import { cookies } from 'next/headers'
import { getIronSession } from 'iron-session'
import { z } from 'zod'
import { prisma } from '@/lib/prisma'
import { SessionData, sessionOptions } from '@/lib/session'
async function getSession() {
return getIronSession<SessionData>(await cookies(), sessionOptions)
}
async function verifyStoryOwnership(storyId: string, userId: string) {
return prisma.story.findFirst({
where: { id: storyId, product: { user_id: userId } },
include: { product: true },
})
}
const createStorySchema = z.object({
pbiId: z.string(),
productId: z.string(),
title: z.string().min(1, 'Titel is verplicht').max(200),
priority: z.coerce.number().int().min(1).max(4),
})
const updateStorySchema = z.object({
id: z.string(),
title: z.string().min(1, 'Titel is verplicht').max(200),
description: z.string().max(2000).optional(),
acceptance_criteria: z.string().max(2000).optional(),
priority: z.coerce.number().int().min(1).max(4),
})
export async function createStoryAction(_prevState: unknown, formData: FormData) {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
const parsed = createStorySchema.safeParse({
pbiId: formData.get('pbiId'),
productId: formData.get('productId'),
title: formData.get('title'),
priority: formData.get('priority') ?? 2,
})
if (!parsed.success) return { error: parsed.error.flatten().fieldErrors }
// Verify ownership via product
const pbi = await prisma.pbi.findFirst({
where: { id: parsed.data.pbiId, product: { user_id: session.userId } },
})
if (!pbi) return { error: 'PBI niet gevonden' }
const last = await prisma.story.findFirst({
where: { pbi_id: parsed.data.pbiId, priority: parsed.data.priority },
orderBy: { sort_order: 'desc' },
})
const sort_order = (last?.sort_order ?? 0) + 1.0
const story = await prisma.story.create({
data: {
pbi_id: parsed.data.pbiId,
product_id: parsed.data.productId,
title: parsed.data.title,
priority: parsed.data.priority,
sort_order,
status: 'OPEN',
},
})
revalidatePath(`/products/${parsed.data.productId}`)
return { success: true, story }
}
export async function updateStoryAction(_prevState: unknown, formData: FormData) {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
const parsed = updateStorySchema.safeParse({
id: formData.get('id'),
title: formData.get('title'),
description: formData.get('description') || undefined,
acceptance_criteria: formData.get('acceptance_criteria') || undefined,
priority: formData.get('priority'),
})
if (!parsed.success) return { error: parsed.error.flatten().fieldErrors }
const story = await verifyStoryOwnership(parsed.data.id, session.userId)
if (!story) return { error: 'Story niet gevonden' }
await prisma.story.update({
where: { id: parsed.data.id },
data: {
title: parsed.data.title,
description: parsed.data.description ?? null,
acceptance_criteria: parsed.data.acceptance_criteria ?? null,
priority: parsed.data.priority,
},
})
revalidatePath(`/products/${story.product_id}`)
return { success: true }
}
export async function deleteStoryAction(id: string) {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
const story = await verifyStoryOwnership(id, session.userId)
if (!story) return { error: 'Story niet gevonden' }
await prisma.story.delete({ where: { id } })
revalidatePath(`/products/${story.product_id}`)
return { success: true }
}
export async function reorderPbisAction(productId: string, orderedIds: string[]) {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
const product = await prisma.product.findFirst({
where: { id: productId, user_id: session.userId },
})
if (!product) return { error: 'Product niet gevonden' }
await prisma.$transaction(
orderedIds.map((id, i) =>
prisma.pbi.update({ where: { id }, data: { sort_order: i + 1.0 } })
)
)
revalidatePath(`/products/${productId}`)
return { success: true }
}
export async function updatePbiPriorityAction(pbiId: string, priority: number, productId: string) {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
const pbi = await prisma.pbi.findFirst({
where: { id: pbiId, product: { user_id: session.userId } },
})
if (!pbi) return { error: 'PBI niet gevonden' }
// Place at the end of the target priority group
const last = await prisma.pbi.findFirst({
where: { product_id: productId, priority },
orderBy: { sort_order: 'desc' },
})
await prisma.pbi.update({
where: { id: pbiId },
data: { priority, sort_order: (last?.sort_order ?? 0) + 1.0 },
})
revalidatePath(`/products/${productId}`)
return { success: true }
}
export async function reorderStoriesAction(
pbiId: string,
productId: string,
orderedIds: string[],
newPriority?: number
) {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
const pbi = await prisma.pbi.findFirst({
where: { id: pbiId, product: { user_id: session.userId } },
})
if (!pbi) return { error: 'PBI niet gevonden' }
await prisma.$transaction(
orderedIds.map((id, i) =>
prisma.story.update({
where: { id },
data: {
sort_order: i + 1.0,
...(newPriority !== undefined ? { priority: newPriority } : {}),
},
})
)
)
revalidatePath(`/products/${productId}`)
return { success: true }
}

View file

@ -6,6 +6,8 @@ import { prisma } from '@/lib/prisma'
import { SplitPane } from '@/components/split-pane/split-pane' import { SplitPane } from '@/components/split-pane/split-pane'
import { PbiList } from '@/components/backlog/pbi-list' import { PbiList } from '@/components/backlog/pbi-list'
import { StoryPanel } from '@/components/backlog/story-panel' import { StoryPanel } from '@/components/backlog/story-panel'
import type { Story } from '@/components/backlog/story-panel'
import Link from 'next/link'
interface Props { interface Props {
params: Promise<{ id: string }> params: Promise<{ id: string }>
@ -28,16 +30,26 @@ export default async function ProductBacklogPage({ params }: Props) {
const stories = await prisma.story.findMany({ const stories = await prisma.story.findMany({
where: { product_id: id }, where: { product_id: id },
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
select: { id: true, title: true, status: true, pbi_id: true }, select: {
id: true,
title: true,
description: true,
acceptance_criteria: true,
priority: true,
status: true,
pbi_id: true,
},
}) })
// Group stories by PBI id // Group stories by PBI id
const storiesByPbi: Record<string, typeof stories> = {} const storiesByPbi: Record<string, Story[]> = {}
for (const story of stories) { for (const story of stories) {
if (!storiesByPbi[story.pbi_id]) storiesByPbi[story.pbi_id] = [] if (!storiesByPbi[story.pbi_id]) storiesByPbi[story.pbi_id] = []
storiesByPbi[story.pbi_id].push(story) storiesByPbi[story.pbi_id].push(story)
} }
const isDemo = session.isDemo ?? false
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Product header */} {/* Product header */}
@ -48,12 +60,12 @@ export default async function ProductBacklogPage({ params }: Props) {
<p className="text-xs text-muted-foreground mt-0.5">{product.description}</p> <p className="text-xs text-muted-foreground mt-0.5">{product.description}</p>
)} )}
</div> </div>
<a <Link
href={`/products/${id}/settings`} href={`/products/${id}/settings`}
className="text-xs text-muted-foreground hover:text-foreground" className="text-xs text-muted-foreground hover:text-foreground"
> >
Instellingen Instellingen
</a> </Link>
</div> </div>
{/* Split pane */} {/* Split pane */}
@ -64,13 +76,14 @@ export default async function ProductBacklogPage({ params }: Props) {
<PbiList <PbiList
productId={id} productId={id}
pbis={pbis.map(p => ({ id: p.id, title: p.title, priority: p.priority }))} pbis={pbis.map(p => ({ id: p.id, title: p.title, priority: p.priority }))}
isDemo={session.isDemo ?? false} isDemo={isDemo}
/> />
} }
right={ right={
<StoryPanel <StoryPanel
productId={id}
storiesByPbi={storiesByPbi} storiesByPbi={storiesByPbi}
isDemo={session.isDemo ?? false} isDemo={isDemo}
/> />
} }
/> />

View file

@ -1,15 +1,35 @@
'use client' 'use client'
import { useState, useTransition } from 'react' import { useState, useTransition, useEffect } from 'react'
import { useActionState } from 'react' import { useActionState } from 'react'
import { useFormStatus } from 'react-dom' import { useFormStatus } from 'react-dom'
import {
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
PointerSensor,
useSensor,
useSensors,
closestCenter,
} from '@dnd-kit/core'
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
arrayMove,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { PanelNavBar } from '@/components/shared/panel-nav-bar' import { PanelNavBar } from '@/components/shared/panel-nav-bar'
import { useSelectionStore } from '@/stores/selection-store' import { useSelectionStore } from '@/stores/selection-store'
import { usePlannerStore } from '@/stores/planner-store'
import { createPbiAction, deletePbiAction } from '@/actions/pbis' import { createPbiAction, deletePbiAction } from '@/actions/pbis'
import { reorderPbisAction, updatePbiPriorityAction } from '@/actions/stories'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
const PRIORITY_LABELS: Record<number, string> = { const PRIORITY_LABELS: Record<number, string> = {
@ -38,7 +58,74 @@ interface PbiListProps {
isDemo: boolean isDemo: boolean
} }
function CreatePbiForm({ productId, priority, onDone }: { productId: string; priority: number; onDone: () => void }) { // --- Sortable PBI row ---
function SortablePbiRow({
pbi,
isSelected,
isDemo,
onSelect,
onDelete,
}: {
pbi: Pbi
isSelected: boolean
isDemo: boolean
onSelect: () => void
onDelete: () => void
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: pbi.id,
})
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
}
return (
<div
ref={setNodeRef}
style={style}
onClick={onSelect}
className={cn(
'group flex items-center justify-between px-4 py-2 cursor-pointer transition-colors hover:bg-surface-container',
isSelected && 'bg-primary-container text-primary-container-foreground'
)}
>
{!isDemo && (
<span
{...attributes}
{...listeners}
className="mr-2 text-muted-foreground cursor-grab active:cursor-grabbing shrink-0 select-none"
onClick={(e) => e.stopPropagation()}
>
</span>
)}
<span className="text-sm truncate flex-1">{pbi.title}</span>
{!isDemo && (
<button
onClick={(e) => { e.stopPropagation(); onDelete() }}
className="opacity-0 group-hover:opacity-100 ml-2 text-muted-foreground hover:text-error text-xs shrink-0"
aria-label="Verwijder PBI"
>
×
</button>
)}
</div>
)
}
// --- Inline create form ---
function CreatePbiForm({
productId,
priority,
onDone,
}: {
productId: string
priority: number
onDone: () => void
}) {
const [state, formAction] = useActionState( const [state, formAction] = useActionState(
async (_prev: unknown, fd: FormData) => { async (_prev: unknown, fd: FormData) => {
const result = await createPbiAction(_prev, fd) const result = await createPbiAction(_prev, fd)
@ -53,16 +140,10 @@ function CreatePbiForm({ productId, priority, onDone }: { productId: string; pri
<form action={formAction} className="flex gap-2 p-2"> <form action={formAction} className="flex gap-2 p-2">
<input type="hidden" name="productId" value={productId} /> <input type="hidden" name="productId" value={productId} />
<input type="hidden" name="priority" value={priority} /> <input type="hidden" name="priority" value={priority} />
<Input <Input name="title" autoFocus placeholder="PBI-titel…" className="h-7 text-sm" required />
name="title"
autoFocus
placeholder="PBI-titel…"
className="h-7 text-sm"
required
/>
<CreateSubmitButton /> <CreateSubmitButton />
<Button type="button" variant="ghost" size="sm" className="h-7" onClick={onDone}> <Button type="button" variant="ghost" size="sm" className="h-7" onClick={onDone}>
Annuleren ×
</Button> </Button>
{typeof error === 'string' && ( {typeof error === 'string' && (
<p className="text-xs text-error self-center">{error}</p> <p className="text-xs text-error self-center">{error}</p>
@ -80,13 +161,33 @@ function CreateSubmitButton() {
) )
} }
// --- Main component ---
export function PbiList({ productId, pbis, isDemo }: PbiListProps) { export function PbiList({ productId, pbis, isDemo }: PbiListProps) {
const { selectedPbiId, selectPbi } = useSelectionStore() const { selectedPbiId, selectPbi } = useSelectionStore()
const { pbiOrder, pbiPriority, initPbis, reorderPbis, rollbackPbis, updatePbiPriority } = usePlannerStore()
const [filterPriority, setFilterPriority] = useState<number | null>(null) const [filterPriority, setFilterPriority] = useState<number | null>(null)
const [creatingForPriority, setCreatingForPriority] = useState<number | null>(null) const [creatingForPriority, setCreatingForPriority] = useState<number | null>(null)
const [activeDragId, setActiveDragId] = useState<string | null>(null)
const [, startTransition] = useTransition() const [, startTransition] = useTransition()
const filtered = filterPriority ? pbis.filter(p => p.priority === filterPriority) : pbis // Sync server data into store — use stable string dep to avoid infinite loop
const pbiIdKey = pbis.map(p => p.id).join(',')
useEffect(() => {
initPbis(productId, pbiIdKey ? pbiIdKey.split(',') : [])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [productId, pbiIdKey])
// Build ordered PBI list from store (or fall back to server order)
const order = pbiOrder[productId] ?? pbis.map(p => p.id)
const pbiMap = Object.fromEntries(pbis.map(p => [p.id, p]))
// Apply priority overrides from store
const orderedPbis = order
.map(id => pbiMap[id])
.filter(Boolean)
.map(p => ({ ...p, priority: pbiPriority[p.id] ?? p.priority }))
const filtered = filterPriority ? orderedPbis.filter(p => p.priority === filterPriority) : orderedPbis
const grouped = [1, 2, 3, 4].reduce<Record<number, Pbi[]>>((acc, p) => { const grouped = [1, 2, 3, 4].reduce<Record<number, Pbi[]>>((acc, p) => {
acc[p] = filtered.filter(pbi => pbi.priority === p) acc[p] = filtered.filter(pbi => pbi.priority === p)
@ -97,6 +198,49 @@ export function PbiList({ productId, pbis, isDemo }: PbiListProps) {
p => grouped[p].length > 0 || creatingForPriority === p p => grouped[p].length > 0 || creatingForPriority === p
) )
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }))
function handleDragStart(event: DragStartEvent) {
setActiveDragId(event.active.id as string)
}
function handleDragEnd(event: DragEndEvent) {
setActiveDragId(null)
const { active, over } = event
if (!over || active.id === over.id) return
const activePbi = pbiMap[active.id as string]
const overPbi = pbiMap[over.id as string]
if (!activePbi || !overPbi) return
const prevOrder = [...order]
const oldIndex = order.indexOf(active.id as string)
const newIndex = order.indexOf(over.id as string)
const newOrder = arrayMove([...order], oldIndex, newIndex)
// Optimistic update
reorderPbis(productId, newOrder)
const priorityChanged = activePbi.priority !== overPbi.priority
startTransition(async () => {
if (priorityChanged) {
updatePbiPriority(active.id as string, overPbi.priority)
const result = await updatePbiPriorityAction(active.id as string, overPbi.priority, productId)
if (!result.success) {
rollbackPbis(productId, prevOrder)
toast.error('Prioriteit opslaan mislukt')
}
} else {
const result = await reorderPbisAction(productId, newOrder)
if (!result.success) {
rollbackPbis(productId, prevOrder)
toast.error('Volgorde opslaan mislukt')
}
}
})
}
function handleDelete(id: string) { function handleDelete(id: string) {
startTransition(async () => { startTransition(async () => {
await deletePbiAction(id) await deletePbiAction(id)
@ -104,6 +248,8 @@ export function PbiList({ productId, pbis, isDemo }: PbiListProps) {
}) })
} }
const activePbi = activeDragId ? pbiMap[activeDragId] : null
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<PanelNavBar <PanelNavBar
@ -160,76 +306,81 @@ export function PbiList({ productId, pbis, isDemo }: PbiListProps) {
)} )}
</div> </div>
) : ( ) : (
<div className="py-2"> <DndContext
{visiblePriorities.map(priority => ( sensors={sensors}
<div key={priority}> collisionDetection={closestCenter}
{/* Priority group header */} onDragStart={handleDragStart}
<div className="flex items-center gap-2 px-4 py-1.5"> onDragEnd={handleDragEnd}
<span className={cn('text-xs font-semibold px-2 py-0.5 rounded-full border', PRIORITY_COLORS[priority])}> >
{PRIORITY_LABELS[priority]} <div className="py-2">
</span> {visiblePriorities.map(priority => (
<div className="flex-1 h-px bg-border" /> <div key={priority}>
{!isDemo && ( <div className="flex items-center gap-2 px-4 py-1.5">
<button <span className={cn('text-xs font-semibold px-2 py-0.5 rounded-full border', PRIORITY_COLORS[priority])}>
onClick={() => setCreatingForPriority(priority)} {PRIORITY_LABELS[priority]}
className="text-xs text-muted-foreground hover:text-foreground" </span>
> <div className="flex-1 h-px bg-border" />
+
</button>
)}
</div>
{/* PBI items */}
{grouped[priority].map(pbi => (
<div
key={pbi.id}
onClick={() => selectPbi(pbi.id)}
className={cn(
'group flex items-center justify-between px-4 py-2 cursor-pointer transition-colors hover:bg-surface-container',
selectedPbiId === pbi.id && 'bg-primary-container text-primary-container-foreground'
)}
>
<span className="text-sm truncate flex-1">{pbi.title}</span>
{!isDemo && ( {!isDemo && (
<button <button
onClick={(e) => { e.stopPropagation(); handleDelete(pbi.id) }} onClick={() => setCreatingForPriority(priority)}
className="opacity-0 group-hover:opacity-100 ml-2 text-muted-foreground hover:text-error text-xs shrink-0" className="text-xs text-muted-foreground hover:text-foreground"
aria-label="Verwijder PBI"
> >
× +
</button> </button>
)} )}
</div> </div>
))}
{/* Inline create form for this priority */} <SortableContext
{creatingForPriority === priority && ( items={grouped[priority].map(p => p.id)}
strategy={verticalListSortingStrategy}
>
{grouped[priority].map(pbi => (
<SortablePbiRow
key={pbi.id}
pbi={pbi}
isSelected={selectedPbiId === pbi.id}
isDemo={isDemo}
onSelect={() => selectPbi(pbi.id)}
onDelete={() => handleDelete(pbi.id)}
/>
))}
</SortableContext>
{creatingForPriority === priority && (
<CreatePbiForm
productId={productId}
priority={priority}
onDone={() => setCreatingForPriority(null)}
/>
)}
</div>
))}
{creatingForPriority !== null && !visiblePriorities.includes(creatingForPriority) && (
<div>
<div className="flex items-center gap-2 px-4 py-1.5">
<span className={cn('text-xs font-semibold px-2 py-0.5 rounded-full border', PRIORITY_COLORS[creatingForPriority])}>
{PRIORITY_LABELS[creatingForPriority]}
</span>
<div className="flex-1 h-px bg-border" />
</div>
<CreatePbiForm <CreatePbiForm
productId={productId} productId={productId}
priority={priority} priority={creatingForPriority}
onDone={() => setCreatingForPriority(null)} onDone={() => setCreatingForPriority(null)}
/> />
)}
</div>
))}
{/* If creating for a priority that has no items yet and isn't in visiblePriorities */}
{creatingForPriority !== null && !visiblePriorities.includes(creatingForPriority) && (
<div>
<div className="flex items-center gap-2 px-4 py-1.5">
<span className={cn('text-xs font-semibold px-2 py-0.5 rounded-full border', PRIORITY_COLORS[creatingForPriority])}>
{PRIORITY_LABELS[creatingForPriority]}
</span>
<div className="flex-1 h-px bg-border" />
</div> </div>
<CreatePbiForm )}
productId={productId} </div>
priority={creatingForPriority}
onDone={() => setCreatingForPriority(null)} <DragOverlay>
/> {activePbi && (
</div> <div className="bg-surface-container-low border border-primary rounded px-4 py-2 text-sm shadow-lg opacity-90">
)} {activePbi.title}
</div> </div>
)}
</DragOverlay>
</DndContext>
)} )}
</div> </div>
</div> </div>

View file

@ -1,56 +1,516 @@
'use client' 'use client'
import { useSelectionStore } from '@/stores/selection-store' import { useState, useTransition, useEffect, useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import {
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
PointerSensor,
useSensor,
useSensors,
closestCenter,
} from '@dnd-kit/core'
import {
SortableContext,
useSortable,
horizontalListSortingStrategy,
arrayMove,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import { PanelNavBar } from '@/components/shared/panel-nav-bar' import { PanelNavBar } from '@/components/shared/panel-nav-bar'
import { useSelectionStore } from '@/stores/selection-store'
import { usePlannerStore } from '@/stores/planner-store'
import { createStoryAction, updateStoryAction, deleteStoryAction, reorderStoriesAction } from '@/actions/stories'
import { cn } from '@/lib/utils'
interface Story { const PRIORITY_LABELS: Record<number, string> = { 1: 'Kritiek', 2: 'Hoog', 3: 'Gemiddeld', 4: 'Laag' }
const PRIORITY_COLORS: Record<number, string> = {
1: 'bg-priority-critical/15 text-priority-critical border-priority-critical/30',
2: 'bg-priority-high/15 text-priority-high border-priority-high/30',
3: 'bg-priority-medium/15 text-priority-medium border-priority-medium/30',
4: 'bg-priority-low/15 text-priority-low border-priority-low/30',
}
const STATUS_COLORS: Record<string, string> = {
OPEN: 'bg-status-todo/15 text-status-todo border-status-todo/30',
IN_SPRINT: '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',
}
const STATUS_LABELS: Record<string, string> = {
OPEN: 'Open',
IN_SPRINT: 'In Sprint',
DONE: 'Klaar',
}
export interface Story {
id: string id: string
title: string title: string
description: string | null
acceptance_criteria: string | null
priority: number
status: string status: string
pbi_id: string
} }
interface StoryPanelProps { interface StoryPanelProps {
productId: string
storiesByPbi: Record<string, Story[]> storiesByPbi: Record<string, Story[]>
isDemo: boolean isDemo: boolean
} }
export function StoryPanel({ storiesByPbi, isDemo }: StoryPanelProps) { // --- Sortable story block ---
const { selectedPbiId } = useSelectionStore() function SortableStoryBlock({
story,
onClick,
}: {
story: Story
onClick: () => void
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: story.id,
})
const stories = selectedPbiId ? (storiesByPbi[selectedPbiId] ?? []) : null const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
}
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
onClick={onClick}
title={story.title}
className="w-28 shrink-0 bg-surface-container-low border border-border rounded-lg p-2 cursor-pointer hover:border-primary transition-colors space-y-1.5 select-none"
>
<p className="text-xs font-medium text-foreground line-clamp-3 min-h-[3rem]">
{story.title}
</p>
<div className="flex flex-col gap-1">
<Badge className={cn('text-[10px] px-1.5 py-0 border', PRIORITY_COLORS[story.priority])}>
{PRIORITY_LABELS[story.priority]}
</Badge>
<Badge className={cn('text-[10px] px-1.5 py-0 border', STATUS_COLORS[story.status])}>
{STATUS_LABELS[story.status] ?? story.status}
</Badge>
</div>
</div>
)
}
// --- Story detail slide-over ---
function StoryDetailSheet({
story,
productId,
pbiId,
onClose,
isDemo,
}: {
story: Story
productId: string
pbiId: string
onClose: () => void
isDemo: boolean
}) {
const [confirmDelete, setConfirmDelete] = useState(false)
const [isDeleting, startDeleteTransition] = useTransition()
const [state, formAction] = useActionState(
async (_prev: unknown, fd: FormData) => {
const result = await updateStoryAction(_prev, fd)
if (result?.success) onClose()
return result
},
undefined
)
function handleDelete() {
startDeleteTransition(async () => {
await deleteStoryAction(story.id)
onClose()
})
}
const fieldError = (field: string) => {
const err = state?.error
if (!err || typeof err === 'string') return undefined
return (err as Record<string, string[]>)[field]?.[0]
}
return (
<Sheet open onOpenChange={(open) => { if (!open) onClose() }}>
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
<SheetHeader className="px-5 pt-5 pb-4 border-b border-border">
<SheetTitle>{story.title}</SheetTitle>
<div className="flex gap-2 mt-1">
<Badge className={cn('text-xs border', PRIORITY_COLORS[story.priority])}>
{PRIORITY_LABELS[story.priority]}
</Badge>
<Badge className={cn('text-xs border', STATUS_COLORS[story.status])}>
{STATUS_LABELS[story.status]}
</Badge>
</div>
</SheetHeader>
<div className="flex-1 overflow-y-auto">
{!isDemo ? (
<form action={formAction} className="p-5 space-y-4">
<input type="hidden" name="id" value={story.id} />
<input type="hidden" name="priority" value={story.priority} />
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Titel</label>
<Input name="title" defaultValue={story.title} required className={fieldError('title') ? 'border-error' : ''} />
{fieldError('title') && <p className="text-xs text-error">{fieldError('title')}</p>}
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Omschrijving</label>
<Textarea name="description" rows={4} defaultValue={story.description ?? ''} placeholder="Als… wil ik… zodat…" />
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Acceptatiecriteria</label>
<Textarea name="acceptance_criteria" rows={4} defaultValue={story.acceptance_criteria ?? ''} placeholder="- Gegeven… Als… Dan…" />
</div>
{typeof state?.error === 'string' && (
<p className="text-xs text-error">{state.error}</p>
)}
<div className="flex gap-2 pt-2">
<SaveButton />
<Button type="button" variant="ghost" onClick={onClose}>Annuleren</Button>
</div>
</form>
) : (
<div className="p-5 space-y-4">
{story.description && (
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">Omschrijving</p>
<p className="text-sm">{story.description}</p>
</div>
)}
{story.acceptance_criteria && (
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">Acceptatiecriteria</p>
<p className="text-sm whitespace-pre-line">{story.acceptance_criteria}</p>
</div>
)}
</div>
)}
</div>
{!isDemo && (
<div className="border-t border-border p-4">
{confirmDelete ? (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground flex-1">Weet je het zeker? Taken worden ook verwijderd.</span>
<Button variant="destructive" size="sm" disabled={isDeleting} onClick={handleDelete}>
{isDeleting ? 'Bezig…' : 'Verwijderen'}
</Button>
<Button variant="ghost" size="sm" onClick={() => setConfirmDelete(false)}>Annuleren</Button>
</div>
) : (
<Button
variant="ghost"
size="sm"
className="text-error hover:bg-error/10"
onClick={() => setConfirmDelete(true)}
>
Story verwijderen
</Button>
)}
</div>
)}
</SheetContent>
</Sheet>
)
}
function SaveButton() {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending}>
{pending ? 'Opslaan…' : 'Opslaan'}
</Button>
)
}
// --- Inline create form ---
function CreateStoryForm({
productId,
pbiId,
priority,
onDone,
}: {
productId: string
pbiId: string
priority: number
onDone: () => void
}) {
const [state, formAction] = useActionState(
async (_prev: unknown, fd: FormData) => {
const result = await createStoryAction(_prev, fd)
if (result?.success) onDone()
return result
},
undefined
)
return (
<form action={formAction} className="flex gap-2 items-center mt-2">
<input type="hidden" name="pbiId" value={pbiId} />
<input type="hidden" name="productId" value={productId} />
<input type="hidden" name="priority" value={priority} />
<Input name="title" autoFocus placeholder="Story titel…" className="h-7 text-sm flex-1" required />
<CreateStorySubmitButton />
<Button type="button" variant="ghost" size="sm" className="h-7" onClick={onDone}>×</Button>
{typeof state?.error === 'string' && <p className="text-xs text-error">{state.error}</p>}
</form>
)
}
function CreateStorySubmitButton() {
const { pending } = useFormStatus()
return (
<Button type="submit" size="sm" className="h-7" disabled={pending}>
{pending ? '…' : 'Toevoegen'}
</Button>
)
}
// --- Main component ---
export function StoryPanel({ productId, storiesByPbi, isDemo }: StoryPanelProps) {
const { selectedPbiId } = useSelectionStore()
const { storyOrder, initStories, reorderStories, rollbackStories } = usePlannerStore()
const [filterStatus, setFilterStatus] = useState<string | null>(null)
const [filterPriority, setFilterPriority] = useState<number | null>(null)
const [creatingPriority, setCreatingPriority] = useState<number | null>(null)
const [openStory, setOpenStory] = useState<Story | null>(null)
const [activeDragId, setActiveDragId] = useState<string | null>(null)
const [, startTransition] = useTransition()
const rawStories = selectedPbiId ? (storiesByPbi[selectedPbiId] ?? []) : []
// Sync into store — use stable string dep to avoid infinite loop
const storyIdKey = rawStories.map(s => s.id).join(',')
useEffect(() => {
if (selectedPbiId) {
initStories(selectedPbiId, storyIdKey ? storyIdKey.split(',') : [])
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedPbiId, storyIdKey])
const storyMap = Object.fromEntries(rawStories.map(s => [s.id, s]))
const order = (selectedPbiId ? storyOrder[selectedPbiId] : null) ?? rawStories.map(s => s.id)
const orderedStories = order.map(id => storyMap[id]).filter(Boolean)
const filtered = orderedStories
.filter(s => !filterStatus || s.status === filterStatus)
.filter(s => !filterPriority || s.priority === filterPriority)
const grouped = [1, 2, 3, 4].reduce<Record<number, Story[]>>((acc, p) => {
acc[p] = filtered.filter(s => s.priority === p)
return acc
}, {} as Record<number, Story[]>)
const visiblePriorities = [1, 2, 3, 4].filter(
p => grouped[p].length > 0 || creatingPriority === p
)
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }))
function handleDragStart(event: DragStartEvent) {
setActiveDragId(event.active.id as string)
}
function handleDragEnd(event: DragEndEvent) {
setActiveDragId(null)
const { active, over } = event
if (!over || active.id === over.id || !selectedPbiId) return
const activeStory = storyMap[active.id as string]
const overStory = storyMap[over.id as string]
if (!activeStory || !overStory) return
const prevOrder = [...order]
const oldIndex = order.indexOf(active.id as string)
const newIndex = order.indexOf(over.id as string)
const newOrder = arrayMove([...order], oldIndex, newIndex)
reorderStories(selectedPbiId, newOrder)
const priorityChanged = activeStory.priority !== overStory.priority
startTransition(async () => {
const result = await reorderStoriesAction(
selectedPbiId,
productId,
newOrder,
priorityChanged ? overStory.priority : undefined
)
if (!result.success) {
rollbackStories(selectedPbiId, prevOrder)
toast.error('Volgorde opslaan mislukt')
}
})
}
const hasActiveFilters = filterStatus !== null || filterPriority !== null
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<PanelNavBar <PanelNavBar
title="Stories" title="Stories"
actions={ actions={
selectedPbiId && !isDemo ? ( <>
<button className="text-xs text-primary hover:underline">+ Story</button> {hasActiveFilters && (
) : undefined <button onClick={() => { setFilterStatus(null); setFilterPriority(null) }} className="text-xs text-primary hover:underline">
Filter wissen ×
</button>
)}
<Select
value={filterStatus ?? 'all'}
onValueChange={(v) => setFilterStatus(!v || v === 'all' ? null : v)}
>
<SelectTrigger className="h-7 w-24 text-xs">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Alle</SelectItem>
<SelectItem value="OPEN">Open</SelectItem>
<SelectItem value="IN_SPRINT">In Sprint</SelectItem>
<SelectItem value="DONE">Klaar</SelectItem>
</SelectContent>
</Select>
{selectedPbiId && !isDemo && (
<Button
size="sm"
className="h-7 text-xs"
onClick={() => setCreatingPriority(creatingPriority ? null : 2)}
>
+ Story
</Button>
)}
</>
} }
/> />
<div className="flex-1 overflow-y-auto p-4"> <div className="flex-1 overflow-y-auto p-4">
{stories === null ? ( {selectedPbiId === null ? (
<p className="text-sm text-muted-foreground text-center mt-8"> <p className="text-sm text-muted-foreground text-center mt-8">
Selecteer een PBI om de stories te bekijken. Selecteer een PBI om de stories te bekijken.
</p> </p>
) : stories.length === 0 ? ( ) : rawStories.length === 0 && creatingPriority === null ? (
<p className="text-sm text-muted-foreground text-center mt-8"> <div className="text-center mt-8 space-y-3">
Nog geen stories voor dit PBI. <p className="text-sm text-muted-foreground">Nog geen stories voor dit PBI.</p>
</p> {!isDemo && (
) : ( <Button size="sm" variant="outline" onClick={() => setCreatingPriority(2)}>
<div className="space-y-2"> Maak je eerste story aan
{stories.map(story => ( </Button>
<div )}
key={story.id}
className="bg-surface-container-low border border-border rounded-lg p-3 text-sm"
>
{story.title}
</div>
))}
</div> </div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="space-y-4">
{visiblePriorities.map(priority => (
<div key={priority}>
<div className="flex items-center gap-2 mb-2">
<span className={cn('text-xs font-semibold px-2 py-0.5 rounded-full border', PRIORITY_COLORS[priority])}>
{PRIORITY_LABELS[priority]}
</span>
<div className="flex-1 h-px bg-border" />
{!isDemo && (
<button
onClick={() => setCreatingPriority(priority)}
className="text-xs text-muted-foreground hover:text-foreground"
>
+
</button>
)}
</div>
<SortableContext
items={grouped[priority].map(s => s.id)}
strategy={horizontalListSortingStrategy}
>
<div className="flex flex-wrap gap-2">
{grouped[priority].map(story => (
<SortableStoryBlock
key={story.id}
story={story}
onClick={() => setOpenStory(story)}
/>
))}
</div>
</SortableContext>
{creatingPriority === priority && selectedPbiId && (
<CreateStoryForm
productId={productId}
pbiId={selectedPbiId}
priority={priority}
onDone={() => setCreatingPriority(null)}
/>
)}
</div>
))}
{creatingPriority !== null && !visiblePriorities.includes(creatingPriority) && selectedPbiId && (
<div>
<div className="flex items-center gap-2 mb-2">
<span className={cn('text-xs font-semibold px-2 py-0.5 rounded-full border', PRIORITY_COLORS[creatingPriority])}>
{PRIORITY_LABELS[creatingPriority]}
</span>
<div className="flex-1 h-px bg-border" />
</div>
<CreateStoryForm
productId={productId}
pbiId={selectedPbiId}
priority={creatingPriority}
onDone={() => setCreatingPriority(null)}
/>
</div>
)}
</div>
<DragOverlay>
{activeDragId && storyMap[activeDragId] && (
<div className="w-28 bg-surface-container-low border border-primary rounded-lg p-2 shadow-lg opacity-90">
<p className="text-xs font-medium line-clamp-3">{storyMap[activeDragId].title}</p>
</div>
)}
</DragOverlay>
</DndContext>
)} )}
</div> </div>
{openStory && selectedPbiId && (
<StoryDetailSheet
story={openStory}
productId={productId}
pbiId={selectedPbiId}
isDemo={isDemo}
onClose={() => setOpenStory(null)}
/>
)}
</div> </div>
) )
} }

46
stores/planner-store.ts Normal file
View file

@ -0,0 +1,46 @@
import { create } from 'zustand'
interface PlannerStore {
// Order maps: productId → pbiId[]
pbiOrder: Record<string, string[]>
// Order maps: pbiId → storyId[]
storyOrder: Record<string, string[]>
// Priority maps: pbiId → priority
pbiPriority: Record<string, number>
initPbis: (productId: string, ids: string[]) => void
reorderPbis: (productId: string, ids: string[]) => void
rollbackPbis: (productId: string, ids: string[]) => void
updatePbiPriority: (pbiId: string, priority: number) => void
initStories: (pbiId: string, ids: string[]) => void
reorderStories: (pbiId: string, ids: string[]) => void
rollbackStories: (pbiId: string, ids: string[]) => void
}
export const usePlannerStore = create<PlannerStore>((set) => ({
pbiOrder: {},
storyOrder: {},
pbiPriority: {},
initPbis: (productId, ids) =>
set((state) => ({ pbiOrder: { ...state.pbiOrder, [productId]: ids } })),
reorderPbis: (productId, ids) =>
set((state) => ({ pbiOrder: { ...state.pbiOrder, [productId]: ids } })),
rollbackPbis: (productId, ids) =>
set((state) => ({ pbiOrder: { ...state.pbiOrder, [productId]: ids } })),
updatePbiPriority: (pbiId, priority) =>
set((state) => ({ pbiPriority: { ...state.pbiPriority, [pbiId]: priority } })),
initStories: (pbiId, ids) =>
set((state) => ({ storyOrder: { ...state.storyOrder, [pbiId]: ids } })),
reorderStories: (pbiId, ids) =>
set((state) => ({ storyOrder: { ...state.storyOrder, [pbiId]: ids } })),
rollbackStories: (pbiId, ids) =>
set((state) => ({ storyOrder: { ...state.storyOrder, [pbiId]: ids } })),
}))

View file

@ -2,10 +2,16 @@ import { create } from 'zustand'
interface SelectionStore { interface SelectionStore {
selectedPbiId: string | null selectedPbiId: string | null
selectedStoryId: string | null
selectPbi: (id: string | null) => void selectPbi: (id: string | null) => void
selectStory: (id: string | null) => void
clearSelection: () => void
} }
export const useSelectionStore = create<SelectionStore>((set) => ({ export const useSelectionStore = create<SelectionStore>((set) => ({
selectedPbiId: null, selectedPbiId: null,
selectPbi: (id) => set({ selectedPbiId: id }), selectedStoryId: null,
selectPbi: (id) => set({ selectedPbiId: id, selectedStoryId: null }),
selectStory: (id) => set({ selectedStoryId: id }),
clearSelection: () => set({ selectedPbiId: null, selectedStoryId: null }),
})) }))