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 ( + + + + + + + + + + + + + {/* Background */} + + + {/* Block 1 — PBI */} + + + {/* Block 2 — Story */} + + + {/* Block 3 — Task */} + + + {/* Rocket nose */} + + + {/* Window */} + + + + + {/* Fins */} + + + + {/* Flame */} + + + + ) +} 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 + + + + +
+

Scrum4Me — Icoon Concepten

+

2 · S4M Lettermerk  ·  3 · Iteratielus  ·  5 · Raket

+
+ +
+ + +
+
+ + +
+ + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + + + + + + + + + + + + +
+
+ + + + + +
+
+ +
+
+ 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 +
+
+ +
+ +
Scrum4Me · Icoon Concepten v0.1 · april 2026
+ + + \ 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
- + {typeof error === 'string' && (

{error}

@@ -80,13 +161,33 @@ function CreateSubmitButton() { ) } +// --- Main component --- export function PbiList({ productId, pbis, isDemo }: PbiListProps) { const { selectedPbiId, selectPbi } = useSelectionStore() + const { pbiOrder, pbiPriority, initPbis, reorderPbis, rollbackPbis, updatePbiPriority } = usePlannerStore() const [filterPriority, setFilterPriority] = useState(null) const [creatingForPriority, setCreatingForPriority] = useState(null) + const [activeDragId, setActiveDragId] = useState(null) 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>((acc, 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 ) + 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) { startTransition(async () => { await deletePbiAction(id) @@ -104,6 +248,8 @@ export function PbiList({ productId, pbis, isDemo }: PbiListProps) { }) } + const activePbi = activeDragId ? pbiMap[activeDragId] : null + return (
) : ( -
- {visiblePriorities.map(priority => ( -
- {/* Priority group header */} -
- - {PRIORITY_LABELS[priority]} - -
- {!isDemo && ( - - )} -
- - {/* PBI items */} - {grouped[priority].map(pbi => ( -
selectPbi(pbi.id)} - className={cn( - 'group flex items-center justify-between px-4 py-2 cursor-pointer transition-colors hover:bg-surface-container', - selectedPbiId === pbi.id && 'bg-primary-container text-primary-container-foreground' - )} - > - {pbi.title} + +
+ {visiblePriorities.map(priority => ( +
+
+ + {PRIORITY_LABELS[priority]} + +
{!isDemo && ( )}
- ))} - {/* Inline create form for this priority */} - {creatingForPriority === priority && ( + p.id)} + strategy={verticalListSortingStrategy} + > + {grouped[priority].map(pbi => ( + selectPbi(pbi.id)} + onDelete={() => handleDelete(pbi.id)} + /> + ))} + + + {creatingForPriority === priority && ( + setCreatingForPriority(null)} + /> + )} +
+ ))} + + {creatingForPriority !== null && !visiblePriorities.includes(creatingForPriority) && ( +
+
+ + {PRIORITY_LABELS[creatingForPriority]} + +
+
setCreatingForPriority(null)} /> - )} -
- ))} - - {/* If creating for a priority that has no items yet and isn't in visiblePriorities */} - {creatingForPriority !== null && !visiblePriorities.includes(creatingForPriority) && ( -
-
- - {PRIORITY_LABELS[creatingForPriority]} - -
- setCreatingForPriority(null)} - /> -
- )} -
+ )} +
+ + + {activePbi && ( +
+ {activePbi.title} +
+ )} +
+ )}
diff --git a/components/backlog/story-panel.tsx b/components/backlog/story-panel.tsx index 4adb86f..ed3015f 100644 --- a/components/backlog/story-panel.tsx +++ b/components/backlog/story-panel.tsx @@ -1,56 +1,516 @@ '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 { 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 = { 1: 'Kritiek', 2: 'Hoog', 3: 'Gemiddeld', 4: 'Laag' } +const PRIORITY_COLORS: Record = { + 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 = { + 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 = { + OPEN: 'Open', + IN_SPRINT: 'In Sprint', + DONE: 'Klaar', +} + +export interface Story { id: string title: string + description: string | null + acceptance_criteria: string | null + priority: number status: string + pbi_id: string } interface StoryPanelProps { + productId: string storiesByPbi: Record isDemo: boolean } -export function StoryPanel({ storiesByPbi, isDemo }: StoryPanelProps) { - const { selectedPbiId } = useSelectionStore() +// --- Sortable story block --- +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 ( +
+

+ {story.title} +

+
+ + {PRIORITY_LABELS[story.priority]} + + + {STATUS_LABELS[story.status] ?? story.status} + +
+
+ ) +} + +// --- 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)[field]?.[0] + } + + return ( + { if (!open) onClose() }}> + + + {story.title} +
+ + {PRIORITY_LABELS[story.priority]} + + + {STATUS_LABELS[story.status]} + +
+
+ +
+ {!isDemo ? ( + + + + +
+ + + {fieldError('title') &&

{fieldError('title')}

} +
+ +
+ +