diff --git a/.icons/icon-512.png b/.icons/icon-512.png
new file mode 100644
index 0000000..dfd1099
Binary files /dev/null and b/.icons/icon-512.png differ
diff --git a/.icons/icon-master.svg b/.icons/icon-master.svg
new file mode 100644
index 0000000..f93d4a6
--- /dev/null
+++ b/.icons/icon-master.svg
@@ -0,0 +1,92 @@
+
diff --git a/.icons/icons/INSTALL.md b/.icons/icons/INSTALL.md
new file mode 100644
index 0000000..f2439e7
--- /dev/null
+++ b/.icons/icons/INSTALL.md
@@ -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 (
+
+ )
+}
+```
+
+## 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 |
diff --git a/.icons/icons/app-icon.tsx b/.icons/icons/app-icon.tsx
new file mode 100644
index 0000000..0393493
--- /dev/null
+++ b/.icons/icons/app-icon.tsx
@@ -0,0 +1,70 @@
+// components/shared/app-icon.tsx
+// Scrum4Me app icon — concept 5 (Rocket)
+// Gebruik: of
+
+interface AppIconProps {
+ size?: number
+ className?: string
+}
+
+export function AppIcon({ size = 32, className }: AppIconProps) {
+ return (
+
+ )
+}
diff --git a/.icons/icons/favicon.ico b/.icons/icons/favicon.ico
new file mode 100644
index 0000000..ab88226
Binary files /dev/null and b/.icons/icons/favicon.ico differ
diff --git a/.icons/icons/icon-120.png b/.icons/icons/icon-120.png
new file mode 100644
index 0000000..e130961
Binary files /dev/null and b/.icons/icons/icon-120.png differ
diff --git a/.icons/icons/icon-144.png b/.icons/icons/icon-144.png
new file mode 100644
index 0000000..2e15c3e
Binary files /dev/null and b/.icons/icons/icon-144.png differ
diff --git a/.icons/icons/icon-152.png b/.icons/icons/icon-152.png
new file mode 100644
index 0000000..88796b1
Binary files /dev/null and b/.icons/icons/icon-152.png differ
diff --git a/.icons/icons/icon-16.png b/.icons/icons/icon-16.png
new file mode 100644
index 0000000..9cc2d7a
Binary files /dev/null and b/.icons/icons/icon-16.png differ
diff --git a/.icons/icons/icon-180.png b/.icons/icons/icon-180.png
new file mode 100644
index 0000000..628e713
Binary files /dev/null and b/.icons/icons/icon-180.png differ
diff --git a/.icons/icons/icon-192.png b/.icons/icons/icon-192.png
new file mode 100644
index 0000000..79fd6ec
Binary files /dev/null and b/.icons/icons/icon-192.png differ
diff --git a/.icons/icons/icon-32.png b/.icons/icons/icon-32.png
new file mode 100644
index 0000000..52081af
Binary files /dev/null and b/.icons/icons/icon-32.png differ
diff --git a/.icons/icons/icon-48.png b/.icons/icons/icon-48.png
new file mode 100644
index 0000000..7f394f0
Binary files /dev/null and b/.icons/icons/icon-48.png differ
diff --git a/.icons/icons/icon-512.png b/.icons/icons/icon-512.png
new file mode 100644
index 0000000..dfd1099
Binary files /dev/null and b/.icons/icons/icon-512.png differ
diff --git a/.icons/icons/icon-76.png b/.icons/icons/icon-76.png
new file mode 100644
index 0000000..ed5c29e
Binary files /dev/null and b/.icons/icons/icon-76.png differ
diff --git a/.icons/icons/icon-master.svg b/.icons/icons/icon-master.svg
new file mode 100644
index 0000000..f93d4a6
--- /dev/null
+++ b/.icons/icons/icon-master.svg
@@ -0,0 +1,92 @@
+
diff --git a/.icons/icons/icon-simple.svg b/.icons/icons/icon-simple.svg
new file mode 100644
index 0000000..4e3ca22
--- /dev/null
+++ b/.icons/icons/icon-simple.svg
@@ -0,0 +1,40 @@
+
diff --git a/CLAUDE.md b/CLAUDE.md
index 899c39a..7fffd2b 100644
--- a/CLAUDE.md
+++ b/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-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-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
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)
dnd-kit (drag-and-drop)
Prisma v7 (ORM)
@@ -58,6 +62,10 @@ Zod (validatie)
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)
@@ -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 ``. Zie `scrum4me-styling.md` voor het ThemeToggle component.
+
## shadcn/ui componenten om te installeren
Voer deze uit na `npx shadcn@latest init`:
diff --git a/Srum4MeIcons.html b/Srum4MeIcons.html
new file mode 100644
index 0000000..0f116bf
--- /dev/null
+++ b/Srum4MeIcons.html
@@ -0,0 +1,384 @@
+
+
+
+
+
+Scrum4Me — Icoon Concepten
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 02 — S4M Lettermerk
+ S · 4 · M als geïntegreerd monogram
accent-dot als leesteken
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 03 — Iteratielus
+ Open boog · gradient cyan→indigo→violet
pijlpunt markeert de opening
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 05 — De Raket
+ 3 blokken (PBI · story · taak) die opstijgen
neus · vinnen · uitlaatpit
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/actions/stories.ts b/actions/stories.ts
new file mode 100644
index 0000000..d6b4345
--- /dev/null
+++ b/actions/stories.ts
@@ -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(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 }
+}
diff --git a/app/(app)/products/[id]/page.tsx b/app/(app)/products/[id]/page.tsx
index 3975f1f..03e6204 100644
--- a/app/(app)/products/[id]/page.tsx
+++ b/app/(app)/products/[id]/page.tsx
@@ -6,6 +6,8 @@ import { prisma } from '@/lib/prisma'
import { SplitPane } from '@/components/split-pane/split-pane'
import { PbiList } from '@/components/backlog/pbi-list'
import { StoryPanel } from '@/components/backlog/story-panel'
+import type { Story } from '@/components/backlog/story-panel'
+import Link from 'next/link'
interface Props {
params: Promise<{ id: string }>
@@ -28,16 +30,26 @@ export default async function ProductBacklogPage({ params }: Props) {
const stories = await prisma.story.findMany({
where: { product_id: id },
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
- const storiesByPbi: Record = {}
+ const storiesByPbi: Record = {}
for (const story of stories) {
if (!storiesByPbi[story.pbi_id]) storiesByPbi[story.pbi_id] = []
storiesByPbi[story.pbi_id].push(story)
}
+ const isDemo = session.isDemo ?? false
+
return (
{/* Product header */}
@@ -48,12 +60,12 @@ export default async function ProductBacklogPage({ params }: Props) {
{product.description}
)}
-
Instellingen
-
+
{/* Split pane */}
@@ -64,13 +76,14 @@ export default async function ProductBacklogPage({ params }: Props) {
({ id: p.id, title: p.title, priority: p.priority }))}
- isDemo={session.isDemo ?? false}
+ isDemo={isDemo}
/>
}
right={
}
/>
diff --git a/components/backlog/pbi-list.tsx b/components/backlog/pbi-list.tsx
index 011058e..f2e515a 100644
--- a/components/backlog/pbi-list.tsx
+++ b/components/backlog/pbi-list.tsx
@@ -1,15 +1,35 @@
'use client'
-import { useState, useTransition } from 'react'
+import { useState, useTransition, useEffect } from 'react'
import { useActionState } from 'react'
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 { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { PanelNavBar } from '@/components/shared/panel-nav-bar'
import { useSelectionStore } from '@/stores/selection-store'
+import { usePlannerStore } from '@/stores/planner-store'
import { createPbiAction, deletePbiAction } from '@/actions/pbis'
+import { reorderPbisAction, updatePbiPriorityAction } from '@/actions/stories'
import { cn } from '@/lib/utils'
const PRIORITY_LABELS: Record = {
@@ -38,7 +58,74 @@ interface PbiListProps {
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 (
+
+ {!isDemo && (
+ e.stopPropagation()}
+ >
+ ⠿
+
+ )}
+ {pbi.title}
+ {!isDemo && (
+
+ )}
+
+ )
+}
+
+// --- Inline create form ---
+function CreatePbiForm({
+ productId,
+ priority,
+ onDone,
+}: {
+ productId: string
+ priority: number
+ onDone: () => void
+}) {
const [state, formAction] = useActionState(
async (_prev: unknown, fd: FormData) => {
const result = await createPbiAction(_prev, fd)
@@ -53,16 +140,10 @@ function CreatePbiForm({ productId, priority, onDone }: { productId: string; pri