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>
BIN
.icons/icon-512.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
92
.icons/icon-master.svg
Normal 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
|
|
@ -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
|
|
@ -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
|
After Width: | Height: | Size: 569 B |
BIN
.icons/icons/icon-120.png
Normal file
|
After Width: | Height: | Size: 7 KiB |
BIN
.icons/icons/icon-144.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
.icons/icons/icon-152.png
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
BIN
.icons/icons/icon-16.png
Normal file
|
After Width: | Height: | Size: 573 B |
BIN
.icons/icons/icon-180.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
.icons/icons/icon-192.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
.icons/icons/icon-32.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
.icons/icons/icon-48.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
.icons/icons/icon-512.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
.icons/icons/icon-76.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
92
.icons/icons/icon-master.svg
Normal 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 |
40
.icons/icons/icon-simple.svg
Normal 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 |
22
CLAUDE.md
|
|
@ -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
|
|
@ -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 · 3 · Iteratielus · 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
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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,10 +306,15 @@ export function PbiList({ productId, pbis, isDemo }: PbiListProps) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
<div className="py-2">
|
<div className="py-2">
|
||||||
{visiblePriorities.map(priority => (
|
{visiblePriorities.map(priority => (
|
||||||
<div key={priority}>
|
<div key={priority}>
|
||||||
{/* Priority group header */}
|
|
||||||
<div className="flex items-center gap-2 px-4 py-1.5">
|
<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[priority])}>
|
<span className={cn('text-xs font-semibold px-2 py-0.5 rounded-full border', PRIORITY_COLORS[priority])}>
|
||||||
{PRIORITY_LABELS[priority]}
|
{PRIORITY_LABELS[priority]}
|
||||||
|
|
@ -179,30 +330,22 @@ export function PbiList({ productId, pbis, isDemo }: PbiListProps) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* PBI items */}
|
<SortableContext
|
||||||
|
items={grouped[priority].map(p => p.id)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
{grouped[priority].map(pbi => (
|
{grouped[priority].map(pbi => (
|
||||||
<div
|
<SortablePbiRow
|
||||||
key={pbi.id}
|
key={pbi.id}
|
||||||
onClick={() => selectPbi(pbi.id)}
|
pbi={pbi}
|
||||||
className={cn(
|
isSelected={selectedPbiId === pbi.id}
|
||||||
'group flex items-center justify-between px-4 py-2 cursor-pointer transition-colors hover:bg-surface-container',
|
isDemo={isDemo}
|
||||||
selectedPbiId === pbi.id && 'bg-primary-container text-primary-container-foreground'
|
onSelect={() => selectPbi(pbi.id)}
|
||||||
)}
|
onDelete={() => handleDelete(pbi.id)}
|
||||||
>
|
/>
|
||||||
<span className="text-sm truncate flex-1">{pbi.title}</span>
|
|
||||||
{!isDemo && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); handleDelete(pbi.id) }}
|
|
||||||
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>
|
|
||||||
))}
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
|
||||||
{/* Inline create form for this priority */}
|
|
||||||
{creatingForPriority === priority && (
|
{creatingForPriority === priority && (
|
||||||
<CreatePbiForm
|
<CreatePbiForm
|
||||||
productId={productId}
|
productId={productId}
|
||||||
|
|
@ -213,7 +356,6 @@ export function PbiList({ productId, pbis, isDemo }: PbiListProps) {
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* If creating for a priority that has no items yet and isn't in visiblePriorities */}
|
|
||||||
{creatingForPriority !== null && !visiblePriorities.includes(creatingForPriority) && (
|
{creatingForPriority !== null && !visiblePriorities.includes(creatingForPriority) && (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 px-4 py-1.5">
|
<div className="flex items-center gap-2 px-4 py-1.5">
|
||||||
|
|
@ -230,6 +372,15 @@ export function PbiList({ productId, pbis, isDemo }: PbiListProps) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<DragOverlay>
|
||||||
|
{activePbi && (
|
||||||
|
<div className="bg-surface-container-low border border-primary rounded px-4 py-2 text-sm shadow-lg opacity-90">
|
||||||
|
{activePbi.title}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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)}>
|
||||||
|
Maak je eerste story aan
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<DndContext
|
||||||
{stories.map(story => (
|
sensors={sensors}
|
||||||
<div
|
collisionDetection={closestCenter}
|
||||||
key={story.id}
|
onDragStart={handleDragStart}
|
||||||
className="bg-surface-container-low border border-border rounded-lg p-3 text-sm"
|
onDragEnd={handleDragEnd}
|
||||||
>
|
>
|
||||||
{story.title}
|
<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>
|
</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>
|
||||||
)}
|
)}
|
||||||
</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>
|
||||||
|
|
||||||
|
{openStory && selectedPbiId && (
|
||||||
|
<StoryDetailSheet
|
||||||
|
story={openStory}
|
||||||
|
productId={productId}
|
||||||
|
pbiId={selectedPbiId}
|
||||||
|
isDemo={isDemo}
|
||||||
|
onClose={() => setOpenStory(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
46
stores/planner-store.ts
Normal 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 } })),
|
||||||
|
}))
|
||||||
|
|
@ -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 }),
|
||||||
}))
|
}))
|
||||||
|
|
|
||||||