Merge pull request #1 from madhura68/feat/codes-todo-desc-misc

Entity codes (Product/Pbi/Story/Task), Todo description, M3.5 not-done seed, ProfileEditor email fix
This commit is contained in:
Janpeter Visser 2026-04-26 21:01:15 +02:00 committed by GitHub
commit a8adac127f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 1216 additions and 85 deletions

View file

@ -7,13 +7,18 @@ import { z } from 'zod'
import { prisma } from '@/lib/prisma'
import { SessionData, sessionOptions } from '@/lib/session'
import { getAccessibleProduct } from '@/lib/product-access'
import { isValidCode, MAX_CODE_LENGTH, normalizeCode } from '@/lib/code'
import { generateNextPbiCode } from '@/lib/code-server'
async function getSession() {
return getIronSession<SessionData>(await cookies(), sessionOptions)
}
const codeField = z.string().max(MAX_CODE_LENGTH).optional()
const createPbiSchema = z.object({
productId: z.string(),
code: codeField,
title: z.string().min(1, 'Titel is verplicht').max(200),
description: z.string().max(2000).optional(),
priority: z.coerce.number().int().min(1).max(4),
@ -21,6 +26,7 @@ const createPbiSchema = z.object({
const updatePbiSchema = z.object({
id: z.string(),
code: codeField,
title: z.string().min(1, 'Titel is verplicht').max(200),
description: z.string().max(2000).optional(),
priority: z.coerce.number().int().min(1).max(4),
@ -33,6 +39,7 @@ export async function createPbiAction(_prevState: unknown, formData: FormData) {
const parsed = createPbiSchema.safeParse({
productId: formData.get('productId'),
code: (formData.get('code') as string) || undefined,
title: formData.get('title'),
description: formData.get('description') || undefined,
priority: formData.get('priority'),
@ -42,6 +49,17 @@ export async function createPbiAction(_prevState: unknown, formData: FormData) {
const product = await getAccessibleProduct(parsed.data.productId, session.userId)
if (!product) return { error: 'Product niet gevonden' }
let code = normalizeCode(parsed.data.code)
if (code !== null && !isValidCode(code)) {
return { error: { code: ['Code mag alleen letters, cijfers, punten, koppeltekens of underscores bevatten'] } }
}
if (code === null) {
code = await generateNextPbiCode(parsed.data.productId)
} else {
const dup = await prisma.pbi.findFirst({ where: { product_id: parsed.data.productId, code } })
if (dup) return { error: { code: ['Deze code is al in gebruik binnen dit product'] } }
}
const last = await prisma.pbi.findFirst({
where: { product_id: parsed.data.productId, priority: parsed.data.priority },
orderBy: { sort_order: 'desc' },
@ -51,6 +69,7 @@ export async function createPbiAction(_prevState: unknown, formData: FormData) {
const pbi = await prisma.pbi.create({
data: {
product_id: parsed.data.productId,
code,
title: parsed.data.title,
description: parsed.data.description ?? null,
priority: parsed.data.priority,
@ -69,6 +88,7 @@ export async function updatePbiAction(_prevState: unknown, formData: FormData) {
const parsed = updatePbiSchema.safeParse({
id: formData.get('id'),
code: (formData.get('code') as string) || undefined,
title: formData.get('title'),
description: formData.get('description') || undefined,
priority: formData.get('priority'),
@ -83,9 +103,21 @@ export async function updatePbiAction(_prevState: unknown, formData: FormData) {
const accessible = await getAccessibleProduct(pbi.product_id, session.userId)
if (!accessible) return { error: 'PBI niet gevonden' }
const code = normalizeCode(parsed.data.code)
if (code !== null && !isValidCode(code)) {
return { error: { code: ['Code mag alleen letters, cijfers, punten, koppeltekens of underscores bevatten'] } }
}
if (code) {
const dup = await prisma.pbi.findFirst({
where: { product_id: pbi.product_id, code, NOT: { id: parsed.data.id } },
})
if (dup) return { error: { code: ['Deze code is al in gebruik binnen dit product'] } }
}
await prisma.pbi.update({
where: { id: parsed.data.id },
data: {
code,
title: parsed.data.title,
description: parsed.data.description ?? null,
priority: parsed.data.priority,

View file

@ -8,9 +8,14 @@ import { z } from 'zod'
import { prisma } from '@/lib/prisma'
import { SessionData, sessionOptions } from '@/lib/session'
import { Role } from '@prisma/client'
import { isValidCode, MAX_CODE_LENGTH, normalizeCode } from '@/lib/code'
const productSchema = z.object({
name: z.string().min(1, 'Naam is verplicht').max(100, 'Naam mag maximaal 100 tekens bevatten'),
code: z
.string()
.max(MAX_CODE_LENGTH, `Code mag maximaal ${MAX_CODE_LENGTH} tekens bevatten`)
.optional(),
description: z.string().max(1000, 'Beschrijving mag maximaal 1000 tekens bevatten').optional(),
repo_url: z
.string()
@ -34,6 +39,7 @@ export async function createProductAction(_prevState: unknown, formData: FormDat
const parsed = productSchema.safeParse({
name: formData.get('name'),
code: (formData.get('code') as string) || undefined,
description: formData.get('description') || undefined,
repo_url: formData.get('repo_url') || undefined,
definition_of_done: formData.get('definition_of_done'),
@ -43,15 +49,26 @@ export async function createProductAction(_prevState: unknown, formData: FormDat
return { error: parsed.error.flatten().fieldErrors }
}
const code = normalizeCode(parsed.data.code)
if (code !== null && !isValidCode(code)) {
return { error: { code: ['Code mag alleen letters, cijfers, punten, koppeltekens of underscores bevatten'] } }
}
const existing = await prisma.product.findFirst({
where: { user_id: session.userId, name: parsed.data.name },
})
if (existing) return { error: { name: ['Een product met deze naam bestaat al'] } }
if (code) {
const dup = await prisma.product.findFirst({ where: { user_id: session.userId, code } })
if (dup) return { error: { code: ['Deze code is al in gebruik'] } }
}
const product = await prisma.product.create({
data: {
user_id: session.userId,
name: parsed.data.name,
code,
description: parsed.data.description ?? null,
repo_url: parsed.data.repo_url || null,
definition_of_done: parsed.data.definition_of_done,
@ -71,6 +88,7 @@ export async function updateProductAction(_prevState: unknown, formData: FormDat
const parsed = productSchema.safeParse({
name: formData.get('name'),
code: (formData.get('code') as string) || undefined,
description: formData.get('description') || undefined,
repo_url: formData.get('repo_url') || undefined,
definition_of_done: formData.get('definition_of_done'),
@ -80,6 +98,11 @@ export async function updateProductAction(_prevState: unknown, formData: FormDat
return { error: parsed.error.flatten().fieldErrors }
}
const code = normalizeCode(parsed.data.code)
if (code !== null && !isValidCode(code)) {
return { error: { code: ['Code mag alleen letters, cijfers, punten, koppeltekens of underscores bevatten'] } }
}
// Verify ownership
const product = await prisma.product.findFirst({
where: { id, user_id: session.userId },
@ -92,10 +115,18 @@ export async function updateProductAction(_prevState: unknown, formData: FormDat
})
if (duplicate) return { error: { name: ['Een product met deze naam bestaat al'] } }
if (code) {
const dupCode = await prisma.product.findFirst({
where: { user_id: session.userId, code, NOT: { id } },
})
if (dupCode) return { error: { code: ['Deze code is al in gebruik'] } }
}
await prisma.product.update({
where: { id },
data: {
name: parsed.data.name,
code,
description: parsed.data.description ?? null,
repo_url: parsed.data.repo_url || null,
definition_of_done: parsed.data.definition_of_done,

View file

@ -8,6 +8,8 @@ import { prisma } from '@/lib/prisma'
import { SessionData, sessionOptions } from '@/lib/session'
import { getAccessibleProduct, productAccessFilter } from '@/lib/product-access'
import { requireProductWriter } from '@/lib/auth'
import { isValidCode, MAX_CODE_LENGTH, normalizeCode } from '@/lib/code'
import { generateNextStoryCode } from '@/lib/code-server'
async function getSession() {
return getIronSession<SessionData>(await cookies(), sessionOptions)
@ -24,9 +26,12 @@ function hasDuplicateIds(ids: string[]) {
return new Set(ids).size !== ids.length
}
const codeField = z.string().max(MAX_CODE_LENGTH).optional()
const createStorySchema = z.object({
pbiId: z.string(),
productId: z.string(),
code: codeField,
title: z.string().min(1, 'Titel is verplicht').max(200),
description: z.string().max(2000).optional(),
acceptance_criteria: z.string().max(2000).optional(),
@ -35,6 +40,7 @@ const createStorySchema = z.object({
const updateStorySchema = z.object({
id: z.string(),
code: codeField,
title: z.string().min(1, 'Titel is verplicht').max(200),
description: z.string().max(2000).optional(),
acceptance_criteria: z.string().max(2000).optional(),
@ -49,6 +55,7 @@ export async function createStoryAction(_prevState: unknown, formData: FormData)
const parsed = createStorySchema.safeParse({
pbiId: formData.get('pbiId'),
productId: formData.get('productId'),
code: (formData.get('code') as string) || undefined,
title: formData.get('title'),
description: formData.get('description') || undefined,
acceptance_criteria: formData.get('acceptance_criteria') || undefined,
@ -61,6 +68,17 @@ export async function createStoryAction(_prevState: unknown, formData: FormData)
})
if (!pbi) return { error: 'PBI niet gevonden' }
let code = normalizeCode(parsed.data.code)
if (code !== null && !isValidCode(code)) {
return { error: { code: ['Code mag alleen letters, cijfers, punten, koppeltekens of underscores bevatten'] } }
}
if (code === null) {
code = await generateNextStoryCode(pbi.product_id)
} else {
const dup = await prisma.story.findFirst({ where: { product_id: pbi.product_id, code } })
if (dup) return { error: { code: ['Deze code is al in gebruik binnen dit product'] } }
}
const last = await prisma.story.findFirst({
where: { pbi_id: parsed.data.pbiId, priority: parsed.data.priority },
orderBy: { sort_order: 'desc' },
@ -71,6 +89,7 @@ export async function createStoryAction(_prevState: unknown, formData: FormData)
data: {
pbi_id: parsed.data.pbiId,
product_id: pbi.product_id,
code,
title: parsed.data.title,
description: parsed.data.description ?? null,
acceptance_criteria: parsed.data.acceptance_criteria ?? null,
@ -91,6 +110,7 @@ export async function updateStoryAction(_prevState: unknown, formData: FormData)
const parsed = updateStorySchema.safeParse({
id: formData.get('id'),
code: (formData.get('code') as string) || undefined,
title: formData.get('title'),
description: formData.get('description') || undefined,
acceptance_criteria: formData.get('acceptance_criteria') || undefined,
@ -101,9 +121,21 @@ export async function updateStoryAction(_prevState: unknown, formData: FormData)
const story = await verifyStoryAccess(parsed.data.id, session.userId)
if (!story) return { error: 'Story niet gevonden' }
const code = normalizeCode(parsed.data.code)
if (code !== null && !isValidCode(code)) {
return { error: { code: ['Code mag alleen letters, cijfers, punten, koppeltekens of underscores bevatten'] } }
}
if (code) {
const dup = await prisma.story.findFirst({
where: { product_id: story.product_id, code, NOT: { id: parsed.data.id } },
})
if (dup) return { error: { code: ['Deze code is al in gebruik binnen dit product'] } }
}
await prisma.story.update({
where: { id: parsed.data.id },
data: {
code,
title: parsed.data.title,
description: parsed.data.description ?? null,
acceptance_criteria: parsed.data.acceptance_criteria ?? null,

View file

@ -18,10 +18,12 @@ export async function createTodoAction(_prevState: unknown, formData: FormData)
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
const title = (formData.get('title') as string)?.trim()
const description = (formData.get('description') as string)?.trim() || null
const raw = (formData.get('productId') as string)?.trim()
const productId = (raw && raw !== 'all') ? raw : null
if (!title) return { error: 'Titel is verplicht' }
if (description && description.length > 2000) return { error: 'Beschrijving is langer dan 2000 tekens' }
if (productId) {
const product = await prisma.product.findFirst({
@ -30,7 +32,9 @@ export async function createTodoAction(_prevState: unknown, formData: FormData)
if (!product) return { error: 'Product niet gevonden' }
}
await prisma.todo.create({ data: { user_id: session.userId, product_id: productId, title } })
await prisma.todo.create({
data: { user_id: session.userId, product_id: productId, title, description },
})
revalidatePath('/todos')
return { success: true }
}
@ -66,12 +70,14 @@ export async function updateTodoAction(_prevState: unknown, formData: FormData)
const id = (formData.get('id') as string)?.trim()
const title = (formData.get('title') as string)?.trim()
const description = (formData.get('description') as string)?.trim() || null
const raw = (formData.get('productId') as string)?.trim()
const productId = raw || null
const done = formData.get('done') === 'on'
if (!id) return { error: 'Ongeldige todo' }
if (!title) return { error: 'Titel is verplicht' }
if (description && description.length > 2000) return { error: 'Beschrijving is langer dan 2000 tekens' }
const todo = await prisma.todo.findFirst({
where: { id, user_id: session.userId },
@ -87,7 +93,7 @@ export async function updateTodoAction(_prevState: unknown, formData: FormData)
await prisma.todo.update({
where: { id },
data: { title, product_id: productId, done },
data: { title, description, product_id: productId, done },
})
revalidatePath('/todos')
return { success: true }

View file

@ -35,6 +35,7 @@ export default async function ProductBacklogPage({ params }: Props) {
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
select: {
id: true,
code: true,
title: true,
description: true,
acceptance_criteria: true,
@ -87,7 +88,7 @@ export default async function ProductBacklogPage({ params }: Props) {
left={
<PbiList
productId={id}
pbis={pbis.map((p: (typeof pbis)[number]) => ({ id: p.id, title: p.title, priority: p.priority, description: p.description }))}
pbis={pbis.map((p: (typeof pbis)[number]) => ({ id: p.id, code: p.code, title: p.title, priority: p.priority, description: p.description }))}
isDemo={isDemo}
/>
}

View file

@ -48,6 +48,7 @@ export default async function ProductSettingsPage({ params }: Props) {
defaultValues={{
id: product.id,
name: product.name,
code: product.code,
description: product.description ?? '',
repo_url: product.repo_url ?? '',
definition_of_done: product.definition_of_done,

View file

@ -40,7 +40,14 @@ export default async function SoloProductPage({ params }: Props) {
},
},
include: {
story: { select: { id: true, title: true } },
story: {
select: {
id: true,
code: true,
title: true,
tasks: { select: { id: true }, orderBy: { sort_order: 'asc' } },
},
},
},
orderBy: [
{ story: { sort_order: 'asc' } },
@ -52,6 +59,7 @@ export default async function SoloProductPage({ params }: Props) {
where: { sprint_id: sprint.id, assignee_id: null },
select: {
id: true,
code: true,
title: true,
tasks: {
select: { id: true, title: true, description: true, priority: true, status: true },
@ -62,20 +70,28 @@ export default async function SoloProductPage({ params }: Props) {
}),
])
const tasks: SoloTask[] = rawTasks.map(t => ({
id: t.id,
title: t.title,
description: t.description,
implementation_plan: t.implementation_plan,
priority: t.priority,
sort_order: t.sort_order,
status: t.status as SoloTask['status'],
story_id: t.story.id,
story_title: t.story.title,
}))
const tasks: SoloTask[] = rawTasks.map(t => {
const positionInStory = t.story.tasks.findIndex(st => st.id === t.id)
const taskCode =
t.story.code && positionInStory >= 0 ? `${t.story.code}.${positionInStory + 1}` : null
return {
id: t.id,
title: t.title,
description: t.description,
implementation_plan: t.implementation_plan,
priority: t.priority,
sort_order: t.sort_order,
status: t.status as SoloTask['status'],
story_id: t.story.id,
story_code: t.story.code,
story_title: t.story.title,
task_code: taskCode,
}
})
const unassignedStories: UnassignedStory[] = rawUnassigned.map(s => ({
id: s.id,
code: s.code,
title: s.title,
tasks: s.tasks.map(t => ({
id: t.id,

View file

@ -49,6 +49,7 @@ export default async function SprintBoardPage({ params }: Props) {
const sprintStoryItems: SprintStory[] = sprintStories.map(s => ({
id: s.id,
code: s.code,
title: s.title,
priority: s.priority,
status: s.status,
@ -86,9 +87,11 @@ export default async function SprintBoardPage({ params }: Props) {
.filter(pbi => pbi.stories.length > 0)
.map(pbi => ({
id: pbi.id,
code: pbi.code,
title: pbi.title,
stories: pbi.stories.map(s => ({
id: s.id,
code: s.code,
title: s.title,
priority: s.priority,
status: s.status,

View file

@ -29,6 +29,7 @@ export default async function TodosPage() {
todos={todos.map(t => ({
id: t.id,
title: t.title,
description: t.description ?? null,
done: t.done,
created_at: t.created_at.toISOString(),
product_id: t.product_id ?? null,

View file

@ -2,6 +2,7 @@
import { forwardRef } from 'react'
import { cn } from '@/lib/utils'
import { CodeBadge } from '@/components/shared/code-badge'
export const PRIORITY_BORDER: Record<number, string> = {
1: 'border-l-4 border-l-priority-critical',
@ -13,6 +14,7 @@ export const PRIORITY_BORDER: Record<number, string> = {
interface BacklogCardProps extends React.HTMLAttributes<HTMLDivElement> {
title: string
priority: number
code?: string | null
isSelected?: boolean
isDragging?: boolean
badge?: React.ReactNode
@ -20,7 +22,7 @@ interface BacklogCardProps extends React.HTMLAttributes<HTMLDivElement> {
}
export const BacklogCard = forwardRef<HTMLDivElement, BacklogCardProps>(function BacklogCard(
{ title, priority, isSelected, isDragging, badge, actions, className, ...rest },
{ title, priority, code, isSelected, isDragging, badge, actions, className, ...rest },
ref
) {
return (
@ -37,7 +39,10 @@ export const BacklogCard = forwardRef<HTMLDivElement, BacklogCardProps>(function
)}
{...rest}
>
<p className="text-sm leading-snug line-clamp-2">{title}</p>
<div className="flex items-start justify-between gap-2">
<p className="text-sm leading-snug line-clamp-2 flex-1">{title}</p>
{code && <CodeBadge code={code} className="shrink-0 mt-0.5" />}
</div>
{(badge || actions) && (
<div className="flex items-center justify-between gap-1 mt-1.5">
<div className="flex items-center gap-1">{badge}</div>

View file

@ -23,6 +23,7 @@ export interface PbiDialogPbi {
title: string
priority: number
description?: string | null
code?: string | null
}
type CreateState = { mode: 'create'; productId: string; defaultPriority?: number }
@ -101,17 +102,30 @@ export function PbiDialog({ state, onClose }: PbiDialogProps) {
{!isEdit && <input type="hidden" name="productId" value={(state as CreateState | null)?.productId ?? ''} />}
<input type="hidden" name="priority" value={priority} />
<div className="grid gap-1.5">
<label htmlFor="pbi-title" className="text-sm font-medium">Titel</label>
<Input
id="pbi-title"
ref={titleRef}
name="title"
defaultValue={pbi?.title ?? ''}
placeholder="PBI-titel…"
required
maxLength={200}
/>
<div className="grid grid-cols-[6rem_1fr] gap-3">
<div className="grid gap-1.5">
<label htmlFor="pbi-code" className="text-sm font-medium">Code</label>
<Input
id="pbi-code"
name="code"
defaultValue={pbi?.code ?? ''}
placeholder={isEdit ? '' : 'auto'}
maxLength={30}
className="font-mono text-sm"
/>
</div>
<div className="grid gap-1.5">
<label htmlFor="pbi-title" className="text-sm font-medium">Titel</label>
<Input
id="pbi-title"
ref={titleRef}
name="title"
defaultValue={pbi?.title ?? ''}
placeholder="PBI-titel…"
required
maxLength={200}
/>
</div>
</div>
<div className="grid gap-1.5">

View file

@ -49,6 +49,7 @@ const PRIORITY_COLORS: Record<number, string> = {
interface Pbi {
id: string
code: string | null
title: string
priority: number
description?: string | null
@ -92,6 +93,7 @@ function SortablePbiRow({
{...attributes}
{...listeners}
title={pbi.title}
code={pbi.code}
priority={pbi.priority}
isSelected={isSelected}
isDragging={isDragging}

View file

@ -124,7 +124,14 @@ export function StoryDialog({ state, onClose, isDemo = false }: StoryDialogProps
<Dialog open={!!state} onOpenChange={(open) => { if (!open) onClose() }}>
<DialogContent className="sm:max-w-lg flex flex-col gap-0 p-0 max-h-[90vh] overflow-hidden">
<DialogHeader className="px-5 pt-5 pb-4 border-b border-border shrink-0 pr-14">
<DialogTitle>{isEdit ? story!.title : 'Nieuwe story'}</DialogTitle>
<div className="flex items-start gap-2">
<DialogTitle className="flex-1">{isEdit ? story!.title : 'Nieuwe story'}</DialogTitle>
{isEdit && story!.code && (
<span className="font-mono text-[11px] text-muted-foreground border border-border rounded-md bg-surface-container px-1.5 py-0.5 shrink-0 mt-0.5">
{story!.code}
</span>
)}
</div>
{isEdit && (
<div className="flex gap-2 mt-1">
<Badge className={cn('text-xs border', PRIORITY_COLORS[priority])}>
@ -154,17 +161,30 @@ export function StoryDialog({ state, onClose, isDemo = false }: StoryDialogProps
<div className="flex-1 overflow-y-auto">
{showForm ? (
<div className="p-5 space-y-4">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Titel</label>
<Input
ref={titleRef}
name="title"
defaultValue={story?.title ?? ''}
required
maxLength={200}
className={fieldError('title') ? 'border-error' : ''}
/>
{fieldError('title') && <p className="text-xs text-error">{fieldError('title')}</p>}
<div className="grid grid-cols-[6rem_1fr] gap-3">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Code</label>
<Input
name="code"
defaultValue={story?.code ?? ''}
placeholder={isEdit ? '' : 'auto'}
maxLength={30}
className={cn('font-mono text-sm', fieldError('code') ? 'border-error' : '')}
/>
{fieldError('code') && <p className="text-xs text-error">{fieldError('code')}</p>}
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Titel</label>
<Input
ref={titleRef}
name="title"
defaultValue={story?.title ?? ''}
required
maxLength={200}
className={fieldError('title') ? 'border-error' : ''}
/>
{fieldError('title') && <p className="text-xs text-error">{fieldError('title')}</p>}
</div>
</div>
<div className="space-y-1.5">

View file

@ -52,6 +52,7 @@ const STATUS_LABELS: Record<string, string> = {
export interface Story {
id: string
code: string | null
title: string
description: string | null
acceptance_criteria: string | null
@ -91,6 +92,7 @@ function SortableStoryBlock({
{...attributes}
{...listeners}
title={story.title}
code={story.code}
priority={story.priority}
isDragging={isDragging}
onClick={onClick}

View file

@ -5,11 +5,13 @@ import { useRouter } from 'next/navigation'
import { useTransition } from 'react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { CodeBadge } from '@/components/shared/code-badge'
import { restoreProductAction } from '@/actions/products'
interface Product {
id: string
name: string
code: string | null
description: string | null
repo_url: string | null
}
@ -61,9 +63,12 @@ export function ProductList({ products, isDemo, showArchived = false }: ProductL
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<p className="font-medium text-foreground group-hover:text-primary transition-colors truncate">
{product.name}
</p>
<div className="flex items-center gap-2">
{product.code && <CodeBadge code={product.code} />}
<p className="font-medium text-foreground group-hover:text-primary transition-colors truncate">
{product.name}
</p>
</div>
{product.description && (
<p className="text-sm text-muted-foreground mt-1 line-clamp-1">
{product.description.slice(0, 80)}{product.description.length > 80 ? '…' : ''}

View file

@ -5,6 +5,7 @@ import { useFormStatus } from 'react-dom'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { cn } from '@/lib/utils'
type FieldErrors = Record<string, string[]>
type ActionResult = { error?: string | FieldErrors; success?: boolean } | undefined
@ -34,6 +35,7 @@ interface ProductFormProps {
defaultValues?: {
id?: string
name?: string
code?: string | null
description?: string
repo_url?: string
definition_of_done?: string
@ -52,21 +54,39 @@ export function ProductForm({ action, submitLabel, defaultValues }: ProductFormP
<input type="hidden" name="id" value={defaultValues.id} />
)}
<div className="space-y-1.5">
<label htmlFor="name" className="text-sm font-medium text-foreground">
Naam <span className="text-error">*</span>
</label>
<Input
id="name"
name="name"
required
defaultValue={defaultValues?.name}
placeholder="bijv. DevPlanner"
className={fieldError('name') ? 'border-error' : ''}
/>
{fieldError('name') && (
<p className="text-xs text-error">{fieldError('name')}</p>
)}
<div className="grid grid-cols-[8rem_1fr] gap-3">
<div className="space-y-1.5">
<label htmlFor="code" className="text-sm font-medium text-foreground">
Code
</label>
<Input
id="code"
name="code"
defaultValue={defaultValues?.code ?? ''}
placeholder="optioneel"
maxLength={30}
className={cn('font-mono text-sm', fieldError('code') ? 'border-error' : '')}
/>
{fieldError('code') && (
<p className="text-xs text-error">{fieldError('code')}</p>
)}
</div>
<div className="space-y-1.5">
<label htmlFor="name" className="text-sm font-medium text-foreground">
Naam <span className="text-error">*</span>
</label>
<Input
id="name"
name="name"
required
defaultValue={defaultValues?.name}
placeholder="bijv. DevPlanner"
className={fieldError('name') ? 'border-error' : ''}
/>
{fieldError('name') && (
<p className="text-xs text-error">{fieldError('name')}</p>
)}
</div>
</div>
<div className="space-y-1.5">

View file

@ -113,6 +113,7 @@ export function ProfileEditor({ email, bio, bioDetail, hasAvatar, avatarVersion
E-mailadres
</label>
<Input
key={email ?? ''}
id="email"
name="email"
type="email"

View file

@ -0,0 +1,20 @@
import { cn } from '@/lib/utils'
interface CodeBadgeProps {
code: string | null | undefined
className?: string
}
export function CodeBadge({ code, className }: CodeBadgeProps) {
if (!code) return null
return (
<span
className={cn(
'inline-flex items-center rounded-md border border-border bg-surface-container px-1.5 py-0.5 font-mono text-[11px] leading-none text-muted-foreground',
className,
)}
>
{code}
</span>
)
}

View file

@ -22,7 +22,9 @@ export interface SoloTask {
sort_order: number
status: 'TO_DO' | 'IN_PROGRESS' | 'REVIEW' | 'DONE'
story_id: string
story_code: string | null
story_title: string
task_code: string | null
}
export interface SoloBoardProps {

View file

@ -3,6 +3,7 @@
import { useDraggable } from '@dnd-kit/core'
import { CSS } from '@dnd-kit/utilities'
import { cn } from '@/lib/utils'
import { CodeBadge } from '@/components/shared/code-badge'
import type { SoloTask } from './solo-board'
const PRIORITY_BORDER: Record<number, string> = {
@ -39,8 +40,14 @@ export function SoloTaskCard({ task, isDemo, onClick }: SoloTaskCardProps) {
)}
{...(!isDemo ? { ...attributes, ...listeners } : {})}
>
<p className="text-sm text-foreground leading-snug">{task.title}</p>
<p className="text-xs text-muted-foreground mt-0.5 truncate">{task.story_title}</p>
<div className="flex items-start justify-between gap-2">
<p className="text-sm text-foreground leading-snug flex-1">{task.title}</p>
{task.task_code && <CodeBadge code={task.task_code} className="shrink-0 mt-0.5" />}
</div>
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{task.story_code && <span className="font-mono mr-1">{task.story_code}</span>}
{task.story_title}
</p>
</div>
)
}
@ -53,8 +60,14 @@ export function SoloTaskCardOverlay({ task }: { task: SoloTask }) {
PRIORITY_BORDER[task.priority],
)}
>
<p className="text-sm text-foreground leading-snug">{task.title}</p>
<p className="text-xs text-muted-foreground mt-0.5 truncate">{task.story_title}</p>
<div className="flex items-start justify-between gap-2">
<p className="text-sm text-foreground leading-snug flex-1">{task.title}</p>
{task.task_code && <CodeBadge code={task.task_code} className="shrink-0 mt-0.5" />}
</div>
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{task.story_code && <span className="font-mono mr-1">{task.story_code}</span>}
{task.story_title}
</p>
</div>
)
}

View file

@ -77,11 +77,19 @@ function TaskDetailContent({ task, productId, isDemo, onClose }: TaskDetailConte
<DialogTitle className="text-sm font-medium leading-snug flex-1">
{task.title}
</DialogTitle>
{task.task_code && (
<span className="font-mono text-[11px] text-muted-foreground border border-border rounded-md bg-surface-container px-1.5 py-0.5 shrink-0">
{task.task_code}
</span>
)}
<Badge className={cn('text-xs border shrink-0', STATUS_COLORS[task.status])}>
{STATUS_LABELS[task.status]}
</Badge>
</div>
<p className="text-xs text-muted-foreground">{task.story_title}</p>
<p className="text-xs text-muted-foreground">
{task.story_code && <span className="font-mono mr-1">{task.story_code}</span>}
{task.story_title}
</p>
</DialogHeader>
{task.description && (

View file

@ -7,6 +7,7 @@ import {
Sheet, SheetContent, SheetHeader, SheetTitle,
} from '@/components/ui/sheet'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
import { CodeBadge } from '@/components/shared/code-badge'
import { claimStoryAction } from '@/actions/stories'
import { cn } from '@/lib/utils'
@ -20,6 +21,7 @@ export interface UnassignedStoryTask {
export interface UnassignedStory {
id: string
code: string | null
title: string
tasks: UnassignedStoryTask[]
}
@ -119,7 +121,10 @@ function ClaimStoryRow({
<div className="rounded-lg border border-border bg-surface-container overflow-hidden">
<div className="flex items-center gap-3 px-3 py-2.5">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">{story.title}</p>
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium text-foreground truncate flex-1">{story.title}</p>
{story.code && <CodeBadge code={story.code} className="shrink-0 mt-0.5" />}
</div>
<p className="text-xs text-muted-foreground">
{story.tasks.length} {story.tasks.length === 1 ? 'taak' : 'taken'}
</p>

View file

@ -7,6 +7,7 @@ import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-
import { CSS } from '@dnd-kit/utilities'
import { toast } from 'sonner'
import { Badge } from '@/components/ui/badge'
import { CodeBadge } from '@/components/shared/code-badge'
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent,
@ -35,6 +36,7 @@ const PRIORITY_LABELS: Record<number, string> = { 1: 'Kritiek', 2: 'Hoog', 3: 'G
export interface SprintStory {
id: string
code: string | null
title: string
priority: number
status: string
@ -51,6 +53,7 @@ export interface ProductMember {
export interface PbiWithStories {
id: string
code: string | null
title: string
stories: SprintStory[]
}
@ -140,7 +143,10 @@ function SortableSprintRow({
</span>
)}
<div className="flex-1 min-w-0">
<p className="text-sm truncate">{story.title}</p>
<div className="flex items-start justify-between gap-2">
<p className="text-sm truncate flex-1">{story.title}</p>
{story.code && <CodeBadge code={story.code} className="shrink-0 mt-0.5" />}
</div>
<div className="flex items-center gap-1.5 mt-0.5">
<Badge className={cn('text-[10px] px-1.5 py-0 border', PRIORITY_COLORS[story.priority])}>
{PRIORITY_LABELS[story.priority]}
@ -338,7 +344,10 @@ function DraggablePbiStoryRow({
</span>
)}
<div className="flex-1 min-w-0">
<p className="text-sm truncate">{story.title}</p>
<div className="flex items-start justify-between gap-2">
<p className="text-sm truncate flex-1">{story.title}</p>
{story.code && <CodeBadge code={story.code} className="shrink-0 mt-0.5" />}
</div>
<Badge className={cn('text-[10px] px-1.5 py-0 border mt-0.5', STATUS_COLORS[story.status])}>
{STATUS_LABELS[story.status]}
</Badge>
@ -387,6 +396,7 @@ export function SprintBacklogRight({ pbisWithStories, sprintStoryIds, isDemo, on
>
<span className="text-xs">{collapsed.has(pbi.id) ? '▶' : '▼'}</span>
<span className="text-sm font-medium truncate flex-1">{pbi.title}</span>
{pbi.code && <CodeBadge code={pbi.code} />}
<span className="text-xs text-muted-foreground">{pbi.stories.length}</span>
</button>
@ -400,7 +410,10 @@ export function SprintBacklogRight({ pbisWithStories, sprintStoryIds, isDemo, on
>
<div className="w-[14px] shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm truncate">{story.title}</p>
<div className="flex items-start justify-between gap-2">
<p className="text-sm truncate flex-1">{story.title}</p>
{story.code && <CodeBadge code={story.code} className="shrink-0 mt-0.5" />}
</div>
<Badge className={cn('text-[10px] px-1.5 py-0 border mt-0.5', STATUS_COLORS[story.status])}>
{STATUS_LABELS[story.status]}
</Badge>

View file

@ -228,6 +228,7 @@ export function SprintBoardClient({
selectedStoryId ? (
<TaskList
storyId={selectedStoryId}
storyCode={stories.find(s => s.id === selectedStoryId)?.code ?? null}
sprintId={sprintId}
productId={productId}
tasks={selectedTasks}

View file

@ -116,6 +116,7 @@ export function SprintHeader({ productId: _productId, productName, sprint, isDem
<div className="space-y-2 max-h-64 overflow-y-auto">
{sprintStories.map(story => (
<div key={story.id} className="flex items-center justify-between gap-3 p-2 bg-surface-container-low rounded-lg">
{story.code && <span className="font-mono text-[11px] text-muted-foreground shrink-0">{story.code}</span>}
<span className="text-sm truncate flex-1">{story.title}</span>
<div className="flex gap-1 shrink-0">
<button

View file

@ -15,7 +15,9 @@ import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { CodeBadge } from '@/components/shared/code-badge'
import { PanelNavBar } from '@/components/shared/panel-nav-bar'
import { deriveTaskCode } from '@/lib/code'
import { useSprintStore } from '@/stores/sprint-store'
import {
createTaskAction, updateTaskStatusAction, updateTaskAction,
@ -49,6 +51,7 @@ export interface Task {
interface TaskListProps {
storyId: string
storyCode: string | null
sprintId: string
productId: string
tasks: Task[]
@ -56,8 +59,8 @@ interface TaskListProps {
}
function SortableTaskRow({
task, isDemo, onStatusToggle, onDelete,
}: { task: Task; isDemo: boolean; onStatusToggle: () => void; onDelete: () => void }) {
task, code, isDemo, onStatusToggle, onDelete,
}: { task: Task; code: string | null; isDemo: boolean; onStatusToggle: () => void; onDelete: () => void }) {
const [editing, setEditing] = useState(false)
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id })
const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.4 : 1 }
@ -88,23 +91,26 @@ function SortableTaskRow({
}
return (
<div ref={setNodeRef} style={style} className="group flex items-center gap-3 px-4 py-2.5 border-b border-border hover:bg-surface-container/50 transition-colors">
<div ref={setNodeRef} style={style} className="group flex items-start gap-3 px-4 py-2.5 border-b border-border hover:bg-surface-container/50 transition-colors">
{!isDemo && (
<span {...attributes} {...listeners} className="text-muted-foreground cursor-grab active:cursor-grabbing shrink-0 text-sm select-none"></span>
<span {...attributes} {...listeners} className="text-muted-foreground cursor-grab active:cursor-grabbing shrink-0 text-sm select-none mt-0.5"></span>
)}
<div className="flex-1 min-w-0">
<p className={cn('text-sm truncate', task.status === 'DONE' && 'line-through text-muted-foreground')}>
{task.title}
</p>
<div className="flex items-start justify-between gap-2">
<p className={cn('text-sm truncate flex-1', task.status === 'DONE' && 'line-through text-muted-foreground')}>
{task.title}
</p>
{code && <CodeBadge code={code} className="shrink-0 mt-0.5" />}
</div>
<span className="text-xs text-muted-foreground">{PRIORITY_LABELS[task.priority]}</span>
</div>
<button onClick={onStatusToggle} disabled={isDemo} aria-label={`Status: ${STATUS_LABELS[task.status]}`}>
<button onClick={onStatusToggle} disabled={isDemo} aria-label={`Status: ${STATUS_LABELS[task.status]}`} className="mt-0.5">
<Badge className={cn('text-xs border cursor-pointer hover:opacity-80 transition-opacity', STATUS_COLORS[task.status])}>
{STATUS_LABELS[task.status]}
</Badge>
</button>
{!isDemo && (
<div className="opacity-0 group-hover:opacity-100 flex gap-1">
<div className="opacity-0 group-hover:opacity-100 flex gap-1 mt-0.5">
<button onClick={() => setEditing(true)} className="text-xs text-muted-foreground hover:text-foreground">Bewerk</button>
<button onClick={onDelete} aria-label="Verwijder taak" className="text-xs text-muted-foreground hover:text-error">×</button>
</div>
@ -150,7 +156,7 @@ function CreateSubmitButton() {
return <Button type="submit" size="sm" className="h-7" disabled={pending}>{pending ? '…' : 'Toevoegen'}</Button>
}
export function TaskList({ storyId, sprintId, productId: _productId, tasks, isDemo }: TaskListProps) {
export function TaskList({ storyId, storyCode, sprintId, productId: _productId, tasks, isDemo }: TaskListProps) {
const { taskOrder, initTasks, reorderTasks, rollbackTasks } = useSprintStore()
const [creating, setCreating] = useState(false)
const [activeDragId, setActiveDragId] = useState<string | null>(null)
@ -232,10 +238,11 @@ export function TaskList({ storyId, sprintId, productId: _productId, tasks, isDe
onDragEnd={handleDragEnd}
>
<SortableContext items={orderedTasks.map(t => t.id)} strategy={verticalListSortingStrategy}>
{orderedTasks.map(task => (
{orderedTasks.map((task, idx) => (
<SortableTaskRow
key={task.id}
task={task}
code={deriveTaskCode(storyCode, idx + 1)}
isDemo={isDemo}
onStatusToggle={() => handleStatusToggle(task)}
onDelete={() => handleDelete(task.id)}

View file

@ -17,6 +17,7 @@ import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import {
@ -30,6 +31,7 @@ import {
interface Todo {
id: string
title: string
description: string | null
done: boolean
created_at: string
product_id: string | null
@ -292,6 +294,13 @@ function TodoCard({
autoComplete="off"
/>
</div>
<Textarea
name="description"
placeholder="Beschrijving (optioneel, max 2000 tekens)…"
disabled={isDemo}
maxLength={2000}
rows={4}
/>
{typeof createState?.error === 'string' && (
<p className="text-xs text-error">{createState.error}</p>
)}
@ -331,6 +340,14 @@ function TodoCard({
autoComplete="off"
/>
</div>
<Textarea
name="description"
defaultValue={activeTodo.description ?? ''}
placeholder="Beschrijving (optioneel, max 2000 tekens)…"
disabled={isDemo}
maxLength={2000}
rows={4}
/>
<label className="flex items-center gap-2 text-sm cursor-pointer w-fit select-none">
<input
type="checkbox"

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 324 KiB

After

Width:  |  Height:  |  Size: 334 KiB

Before After
Before After

771
docs/solo-paneel-spec.md Normal file
View file

@ -0,0 +1,771 @@
# Solo Paneel — Implementatie-specificatie (v2)
> **Doel:** een persoonlijk Kanban-bord per product dat de taken toont van stories die geclaimd zijn door de ingelogde developer. Bord werkt met drie kolommen (TO_DO / IN_PROGRESS / DONE), drag-and-drop tussen kolommen, en koppelt aan een nieuw `Story.assignee_id` veld.
>
> **Scope v1:** geen REVIEW-status, geen multi-product aggregatie, geen taak-level overrides. Story-level assignment volstaat. Desktop-first conform ST-606 — onder 1024px tonen we dezelfde "te smal scherm"-melding als de rest van de app.
>
> **Versie:** v2 — verwerkt antwoorden uit `scrum4me-backlog.md` over sessie-flag, bestaande Server Actions en desktop-first scope.
---
## Wat veranderde t.o.v. v1
| Onderdeel | v1 aanname | v2 (op basis van backlog) |
|---|---|---|
| `isDemo` toegang | DB-lookup of session, ambivalent | **Komt uit `session.isDemo` (ST-006, ST-604)** — geen DB-call |
| Implementation_plan editen | Bestaande Server Action of API | **Nieuwe `updateTaskPlanAction`** (gericht, optimistisch-vriendelijk) |
| Mobiel | Optionele chunk 13 (tab-strip) | **Geen mobile UI**; volg ST-606 desktop-first patroon |
| Toast | Algemeen genoemd | **Sonner is geïnstalleerd (ST-603)** — gebruik consistent |
| Pending states | Niet uitgewerkt | **`useFormStatus` of `useTransition`** zoals ST-601 voorschrijft |
| Demo-tooltip tekst | "Read-only in demo-modus" | **"Niet beschikbaar in demo-modus"** zoals ST-604 |
| Sprint Board referentie | Generieke "sprint board" | **ST-313 drie-panelen Sprint Board** — assignee-UI komt in middenpaneel |
---
## 1. Datamodel — Prisma migratie
Eén veld erbij, één index erbij. Geen enum-wijzigingen.
```prisma
model Story {
// ... bestaande velden ongewijzigd ...
assignee_id String?
assignee User? @relation("StoryAssignee", fields: [assignee_id], references: [id], onDelete: SetNull)
@@index([sprint_id, assignee_id]) // hot path: solo-bord query
// bestaande indexen ongewijzigd
}
model User {
// ... bestaande velden ongewijzigd ...
assigned_stories Story[] @relation("StoryAssignee")
}
```
**Migratie:**
```bash
npx prisma migrate dev --name add_story_assignee
```
**onDelete-keuze:** `SetNull` zodat verwijderen van een user de stories behoudt (assignee valt terug naar "team"). Cascade zou stories vernietigen — niet wat we willen.
**Named relation `"StoryAssignee"`:** voorkomt botsing met andere mogelijke User↔Story relations in de toekomst.
---
## 2. Auth-helper (`lib/auth.ts` uitbreiding)
`isDemo` zit al in de sessiecookie sinds ST-006 — geen DB-lookup nodig.
```typescript
import { getIronSession } from 'iron-session'
import { cookies } from 'next/headers'
import { sessionOptions, type SessionData } from '@/lib/session'
import { prisma } from '@/lib/prisma'
export async function getSession() {
return getIronSession<SessionData>(await cookies(), sessionOptions)
}
export async function requireUser() {
const session = await getSession()
if (!session?.userId) throw new Error('Niet ingelogd')
return session
}
export async function requireWriter() {
const session = await requireUser()
if (session.isDemo) throw new Error('Niet beschikbaar in demo-modus')
return session
}
export async function requireProductAccess(productId: string) {
const session = await requireUser()
const product = await prisma.product.findFirst({
where: {
id: productId,
OR: [
{ user_id: session.userId }, // owner
{ members: { some: { user_id: session.userId } } }, // member
],
},
select: { id: true },
})
if (!product) throw new Error('Geen toegang tot dit product')
return session
}
export async function requireProductWriter(productId: string) {
const session = await requireProductAccess(productId)
if (session.isDemo) throw new Error('Niet beschikbaar in demo-modus')
return session
}
```
**Patroon-uitleg:**
- `requireUser` — ingelogd, anders fout
- `requireWriter` — ingelogd én niet-demo
- `requireProductAccess` — ingelogd én lid (read)
- `requireProductWriter` — ingelogd én lid én niet-demo (write)
**Afhankelijkheid:** controleer of bestaande `actions/*.ts` een eigen lokale `getSession` definiëren. Zo ja, optioneel migreren bij gelegenheid (geen blocker).
---
## 3. Server Actions
### 3a. Story-claim acties (`actions/stories.ts` uitbreiding)
```typescript
'use server'
import { z } from 'zod'
import { revalidatePath } from 'next/cache'
import { prisma } from '@/lib/prisma'
import { requireProductWriter } from '@/lib/auth'
// ---------------------------------------------------------------------------
const claimSchema = z.object({
storyId: z.string().cuid(),
productId: z.string().cuid(),
})
export async function claimStoryAction(input: z.infer<typeof claimSchema>) {
const { storyId, productId } = claimSchema.parse(input)
const session = await requireProductWriter(productId)
await prisma.story.update({
where: { id: storyId, product_id: productId }, // tenant-guard
data: { assignee_id: session.userId },
})
revalidatePath(`/products/${productId}/sprint`)
revalidatePath(`/products/${productId}/solo`)
}
// ---------------------------------------------------------------------------
export async function unclaimStoryAction(input: z.infer<typeof claimSchema>) {
const { storyId, productId } = claimSchema.parse(input)
await requireProductWriter(productId)
await prisma.story.update({
where: { id: storyId, product_id: productId },
data: { assignee_id: null },
})
revalidatePath(`/products/${productId}/sprint`)
revalidatePath(`/products/${productId}/solo`)
}
// ---------------------------------------------------------------------------
const reassignSchema = z.object({
storyId: z.string().cuid(),
productId: z.string().cuid(),
targetUserId: z.string().cuid(),
})
export async function reassignStoryAction(input: z.infer<typeof reassignSchema>) {
const { storyId, productId, targetUserId } = reassignSchema.parse(input)
await requireProductWriter(productId)
// Valideer dat target-user lid is van het product (anders cross-tenant assignment)
const isMember = await prisma.product.findFirst({
where: {
id: productId,
OR: [
{ user_id: targetUserId },
{ members: { some: { user_id: targetUserId } } },
],
},
select: { id: true },
})
if (!isMember) throw new Error('Doel-gebruiker is geen lid van dit product')
await prisma.story.update({
where: { id: storyId, product_id: productId },
data: { assignee_id: targetUserId },
})
revalidatePath(`/products/${productId}/sprint`)
revalidatePath(`/products/${productId}/solo`)
}
// ---------------------------------------------------------------------------
const bulkClaimSchema = z.object({ productId: z.string().cuid() })
export async function claimAllUnassignedInActiveSprintAction(
input: z.infer<typeof bulkClaimSchema>,
) {
const { productId } = bulkClaimSchema.parse(input)
const session = await requireProductWriter(productId)
const activeSprint = await prisma.sprint.findFirst({
where: { product_id: productId, status: 'ACTIVE' },
select: { id: true },
})
if (!activeSprint) throw new Error('Geen actieve sprint gevonden')
const result = await prisma.story.updateMany({
where: {
sprint_id: activeSprint.id,
product_id: productId,
assignee_id: null,
},
data: { assignee_id: session.userId },
})
revalidatePath(`/products/${productId}/sprint`)
revalidatePath(`/products/${productId}/solo`)
return { claimed: result.count }
}
```
### 3b. Implementation plan editen (`actions/tasks.ts` uitbreiding)
Bestaande `updateTaskStatus` (ST-310) en `updateTask` (ST-311) blijven ongewijzigd. We voegen één nieuwe gerichte action toe:
```typescript
'use server'
const planSchema = z.object({
taskId: z.string().cuid(),
productId: z.string().cuid(), // voor tenant-guard
implementationPlan: z.string().max(20000),
})
export async function updateTaskPlanAction(input: z.infer<typeof planSchema>) {
const { taskId, productId, implementationPlan } = planSchema.parse(input)
await requireProductWriter(productId)
// Tenant-guard via geneste relatie
await prisma.task.update({
where: {
id: taskId,
story: { product_id: productId }, // verifieer dat task bij product hoort
},
data: { implementation_plan: implementationPlan },
})
revalidatePath(`/products/${productId}/solo`)
revalidatePath(`/products/${productId}/sprint`)
}
```
**Waarom een aparte action:** korter, optimistisch-vriendelijk (kleine payload, lage latency), past bij save-on-blur in de detail-dialoog. De bestaande `updateTask` is voor volledige edits via een formulier.
**Toast/UX:** geen success-toast (te frequent bij save-on-blur). Wel error-toast bij fout. Indicator in dialoog (*"Bezig met opslaan…"* / *"Opgeslagen"*).
---
## 4. Routes en pagina's
```
app/
├── solo/
│ └── page.tsx # /solo → redirect of picker
└── products/
└── [id]/
├── sprint/page.tsx # bestaand (ST-313 drie-panelen) — krijgt UI-uitbreidingen
└── solo/
└── page.tsx # /products/[id]/solo → het bord
```
### 4a. `/solo` — Redirect-pagina
Server Component. Leest cookie `lastProductId`, valideert toegang, redirect.
```typescript
// app/solo/page.tsx
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { prisma } from '@/lib/prisma'
import { requireUser } from '@/lib/auth'
import { ProductPicker } from '@/components/solo/product-picker'
export default async function SoloRedirectPage() {
const session = await requireUser()
const lastProductId = (await cookies()).get('lastProductId')?.value
if (lastProductId) {
const valid = await prisma.product.findFirst({
where: {
id: lastProductId,
archived: false,
OR: [
{ user_id: session.userId },
{ members: { some: { user_id: session.userId } } },
],
},
select: { id: true },
})
if (valid) redirect(`/products/${valid.id}/solo`)
}
// Geen valide cookie → toon picker
const products = await prisma.product.findMany({
where: {
archived: false,
OR: [
{ user_id: session.userId },
{ members: { some: { user_id: session.userId } } },
],
},
select: { id: true, name: true },
orderBy: { updated_at: 'desc' },
})
return <ProductPicker products={products} basePath="/solo" />
}
```
### 4b. `/products/[id]/solo` — Het Solo Bord
Server Component. Doet alle queries en geeft data door aan een client-side `<SoloBoard>`.
```typescript
// app/products/[id]/solo/page.tsx
import { notFound } from 'next/navigation'
import { prisma } from '@/lib/prisma'
import { requireUser } from '@/lib/auth'
import { setLastProductCookie } from '@/lib/cookies'
import { SoloBoard } from '@/components/solo/solo-board'
import { NoActiveSprint } from '@/components/solo/no-active-sprint'
export default async function SoloPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const session = await requireUser()
await setLastProductCookie(id)
const product = await prisma.product.findFirst({
where: {
id,
OR: [
{ user_id: session.userId },
{ members: { some: { user_id: session.userId } } },
],
},
select: { id: true, name: true },
})
if (!product) notFound()
const activeSprint = await prisma.sprint.findFirst({
where: { product_id: id, status: 'ACTIVE' },
select: { id: true, sprint_goal: true },
})
if (!activeSprint) return <NoActiveSprint product={product} />
// Parallel: eigen taken + count ongeclaimde stories
const [tasks, unassignedStoryCount] = await Promise.all([
prisma.task.findMany({
where: {
sprint_id: activeSprint.id,
story: { assignee_id: session.userId },
},
select: {
id: true,
title: true,
priority: true,
sort_order: true,
status: true,
description: true,
implementation_plan: true,
story: { select: { id: true, title: true } },
},
orderBy: [{ priority: 'desc' }, { sort_order: 'asc' }],
}),
prisma.story.count({
where: { sprint_id: activeSprint.id, assignee_id: null },
}),
])
return (
<SoloBoard
product={product}
sprint={activeSprint}
tasks={tasks}
unassignedStoryCount={unassignedStoryCount}
isDemo={session.isDemo}
/>
)
}
```
**Performance:**
- Query gebruikt `[sprint_id, assignee_id]` index die we toevoegen → snelle filter
- `Promise.all` parallelliseert de twee onafhankelijke queries
- `select` projectie houdt payload klein
---
## 5. Cookie-helper (`lib/cookies.ts`)
```typescript
'use server'
import { cookies } from 'next/headers'
const ONE_MONTH = 60 * 60 * 24 * 30
export async function setLastProductCookie(productId: string) {
const store = await cookies()
store.set('lastProductId', productId, {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
maxAge: ONE_MONTH,
path: '/',
})
}
```
---
## 6. Sprint Board (ST-313) uitbreidingen
In het middenpaneel (Sprint Backlog) van het drie-panelen Sprint Board komen de assignee-UI elementen.
### 6a. Story-kaart op het Sprint Backlog paneel
Nieuwe elementen op elke story-kaart:
```
┌──────────────────────────────────────────────────┐
│ ⚡ Story title [···] │ ← actie-menu rechts
│ Some PBI · 3 taken │
│ ───────────────────────────────────────────── │
│ [👤 jan.visser] of [— Niet geclaimd] │ ← assignee-chip
└──────────────────────────────────────────────────┘
```
**Assignee-chip:** klein component met `<UserAvatar size="xs">` + username, of een muted badge (`bg-muted text-muted-foreground`) als `assignee_id === null`.
**Actie-menu (shadcn `DropdownMenu`):**
- *Pak op*`claimStoryAction` — zichtbaar als ongeclaimd of niet-jij
- *Geef terug aan team*`unclaimStoryAction` — zichtbaar als geclaimd
- *Wijs toe aan ▶* (submenu met members) → `reassignStoryAction`
**Demo-modus:** hele dropdown disabled met tooltip *"Niet beschikbaar in demo-modus"* (consistent met ST-604).
### 6b. Bovenaan het Sprint Backlog paneel
```tsx
<div className="flex items-center justify-between">
<h2>Sprint Backlog</h2>
<Button
onClick={handleClaimAll}
disabled={unassignedCount === 0 || isDemo}
variant="outline"
>
Claim alle ongeclaimde stories ({unassignedCount})
</Button>
</div>
```
Na succes: Sonner-toast *"X stories geclaimd"* (gewone success-toast, niet drag-and-drop frequentie). Bij demo: knop disabled met tooltip *"Niet beschikbaar in demo-modus"*.
---
## 7. Solo Paneel componenten
```
components/solo/
├── solo-board.tsx # Client root, dnd context, layout
├── solo-column.tsx # Drop target per status
├── solo-task-card.tsx # Draggable kaart (bestaande task-card hergebruiken)
├── task-detail-dialog.tsx # Shadcn Dialog
├── unassigned-stories-sheet.tsx # Shadcn Sheet
├── no-active-sprint.tsx # Empty state
└── product-picker.tsx # Voor /solo zonder cookie
```
### 7a. `<SoloBoard>` — root component
```typescript
'use client'
interface Props {
product: { id: string; name: string }
sprint: { id: string; sprint_goal: string }
tasks: TaskWithStory[]
unassignedStoryCount: number
isDemo: boolean
}
export function SoloBoard({ product, sprint, tasks, unassignedStoryCount, isDemo }: Props) {
// Zustand store gehydrateerd met initiële taken
// DndContext (overslaan als isDemo) met sensor + collision detection
// Header: productnaam, sprint goal, knop "Toon openstaande stories (N)"
// Drie kolommen in een grid (md:grid-cols-3)
}
```
### 7b. Zustand store (`stores/solo-store.ts`)
Volgt het patroon van `usePlannerStore` (ST-201): `init*`, `optimistic*`, `rollback*`.
```typescript
import { create } from 'zustand'
import type { TaskStatus } from '@prisma/client'
interface SoloState {
tasks: TaskWithStory[]
initTasks: (tasks: TaskWithStory[]) => void
optimisticMove: (taskId: string, toStatus: TaskStatus) => TaskStatus | null // returns prev for rollback
rollback: (taskId: string, prevStatus: TaskStatus) => void
updatePlan: (taskId: string, plan: string) => void
}
export const useSoloStore = create<SoloState>((set, get) => ({
tasks: [],
initTasks: (tasks) => set({ tasks }),
optimisticMove: (taskId, toStatus) => {
const task = get().tasks.find(t => t.id === taskId)
if (!task) return null
const prev = task.status
set({ tasks: get().tasks.map(t => t.id === taskId ? { ...t, status: toStatus } : t) })
return prev
},
rollback: (taskId, prevStatus) => {
set({ tasks: get().tasks.map(t => t.id === taskId ? { ...t, status: prevStatus } : t) })
},
updatePlan: (taskId, plan) => {
set({ tasks: get().tasks.map(t => t.id === taskId ? { ...t, implementation_plan: plan } : t) })
},
}))
```
### 7c. Drag-and-drop (dnd-kit)
```typescript
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
if (!over) return
const taskId = String(active.id)
const toStatus = String(over.id) as TaskStatus // kolom-id = status enum-value
if (!['TO_DO', 'IN_PROGRESS', 'DONE'].includes(toStatus)) return
const prev = useSoloStore.getState().optimisticMove(taskId, toStatus)
if (prev === null || prev === toStatus) return
startTransition(async () => {
try {
await updateTaskStatusAction(taskId, toStatus)
} catch (err) {
useSoloStore.getState().rollback(taskId, prev)
toast.error('Status bijwerken mislukt — taak teruggeplaatst')
}
})
}
```
**Sensor-keuze:** `PointerSensor` met `activationConstraint: { distance: 5 }` om accidentele drags op klik te voorkomen. Klik = open dialog, drag = verplaats.
**Collision detection:** `closestCorners` voor kolom-niveau drops; geen sortering binnen kolom in v1.
**Toast-strategie:** consistent met ST-603 — geen success-toast bij drag (te frequent), wél error-toast bij rollback.
**Demo-user:** sla de hele DndContext over en wrap kaart-componenten zonder draggable. Klik werkt nog wel (lezen mag).
### 7d. `<SoloColumn>`
Status-token mapping (briefing):
| Status | Header background |
|---|---|
| `TO_DO` | `bg-status-todo/15 text-status-todo border-status-todo/30` |
| `IN_PROGRESS` | `bg-status-in-progress/15 text-status-in-progress border-status-in-progress/30` |
| `DONE` | `bg-status-done/15 text-status-done border-status-done/30` |
### 7e. `<SoloTaskCard>` — hergebruik bestaande task-card
Bestaande task-card uit het sprint board (ST-313 rechterpaneel) hergebruiken. Pas zo nodig aan:
- Linker-rand of dot met `bg-priority-{level}` voor prioriteit
- Taaktitel (`font-medium`, `truncate`)
- Story-titel (`text-sm text-muted-foreground`, `truncate`)
- Optionele `showProduct?: boolean` prop (off op product-specifieke pagina; reservering voor toekomstig multi-product bord)
Klik → opent `<TaskDetailDialog>` met deze taak.
### 7f. `<TaskDetailDialog>`
Shadcn `Dialog`. Inhoud:
- Header: taaktitel + statusbadge (gekleurd via MD3 tokens)
- Sectie *Beschrijving* (read-only `<p>` of formatted block — volg bestaand task-detailpatroon)
- Sectie *Implementatieplan*: `<Textarea>` met save-on-blur
- On blur: `updateTaskPlanAction({ taskId, productId, implementationPlan })`
- Indicator rechtsonder: *"Bezig met opslaan…"* tijdens transition, *"Opgeslagen"* daarna (vervaagt na 2s)
- Bij fout: error-toast + waarde rollback in store
- Footer: link *"Open in Sprint Board ↗"* naar `/products/[id]/sprint?storyId=...`
- Demo-modus: textarea heeft `readOnly` + tooltip *"Niet beschikbaar in demo-modus"*
```typescript
function handleBlur(plan: string) {
if (plan === task.implementation_plan) return // geen no-op call
startTransition(async () => {
try {
await updateTaskPlanAction({ taskId: task.id, productId, implementationPlan: plan })
useSoloStore.getState().updatePlan(task.id, plan)
} catch (err) {
toast.error('Opslaan mislukt')
}
})
}
```
Markdown-rendering voor implementatieplan kan in v2; voor v1 plain text in textarea — sneller te bouwen, past bij scope.
### 7g. `<UnassignedStoriesSheet>`
Shadcn `Sheet` (slide-out van rechts). Trigger: knop bovenaan het bord met badge `(N)`.
Inhoud:
- Lijst van ongeclaimde stories in actieve sprint, met titel + taakaantal
- Per item: knop *"Pak op"*`claimStoryAction` → revalidate → Sonner toast
- Sheet blijft open tot user 'm sluit (zodat meerdere achter elkaar claimen kan)
- Lege staat: *"Geen ongeclaimde stories. Lekker bezig!"*
`useFormStatus` op de claim-knoppen voor pending state (ST-601).
### 7h. `<NoActiveSprint>` — empty state
Geen ACTIVE sprint: nette empty-state met titel, korte uitleg en link naar productpagina om er een te starten (ST-302 stappen).
---
## 8. `<UserAvatar>` component (nieuw, herbruikbaar)
```
components/ui/user-avatar.tsx
```
```typescript
interface Props {
userId: string
username: string
size?: 'xs' | 'sm' | 'md' | 'lg'
className?: string
}
export function UserAvatar({ userId, username, size = 'md', className }: Props) {
const sizeClasses = {
xs: 'h-5 w-5 text-[10px]',
sm: 'h-6 w-6 text-xs',
md: 'h-8 w-8 text-sm',
lg: 'h-10 w-10 text-base',
}
const initials = username.slice(0, 2).toUpperCase()
return (
<Avatar className={cn(sizeClasses[size], className)}>
<AvatarImage
src={`/api/users/${userId}/avatar`}
alt={username}
/>
<AvatarFallback className="bg-primary-container text-primary">
{initials}
</AvatarFallback>
</Avatar>
)
}
```
Gebaseerd op shadcn `<Avatar>`. Fallback in MD3-token (`bg-primary-container`).
**Aandachtspunt:** als `/api/users/[id]/avatar` 404 returnt (user heeft geen avatar gezet), valt shadcn automatisch terug op `<AvatarFallback>` met initialen. Test dit gedrag — anders forceer je via `onError`.
**Hergebruik:** dit component is ook nuttig in toekomstige plekken (story-detail, instellingen, sprint board kaarten) — geen Solo-specifieke component.
---
## 9. Demo-modus
Eenvoudig nu we weten dat `isDemo` in de sessiecookie zit:
**Drie plekken waar `isDemo` ertoe doet:**
1. **Server Actions**`requireProductWriter` (en `requireWriter`) throwt early met *"Niet beschikbaar in demo-modus"*. Doe je niets, dan kan een demo-user via gespoofte requests toch wijzigen.
2. **UI-knoppen** — disabled + tooltip *"Niet beschikbaar in demo-modus"* (ST-604 conventie). Pass `isDemo` als prop door vanaf de Server Component.
3. **DndContext** — wrap kaarten zonder `useDraggable` als `isDemo`, of zet `disabled` op de hele context.
**Seed-vereiste:** in `prisma/seed.ts` (ST-004) zorgen dat de demo-user (`is_demo = true`) een product heeft met:
- Een ACTIVE sprint
- Stories met `assignee_id = demoUser.id` en bijbehorende taken in alle drie statussen (om bord werkend te tonen)
- Minstens 1 ongeclaimde story (om "Toon openstaande" te demonstreren — demo-user kan niet claimen, ziet wel hoe het werkt)
---
## 10. Navbar
```tsx
// components/navbar.tsx (uitbreiding)
<NavLink href="/solo" icon={<UserSquare className="h-4 w-4" />}>
Solo
</NavLink>
```
Plek: tussen "Producten" en "Todos" (of zoals layout het bepaalt). Altijd zichtbaar voor ingelogde users — geen product-context nodig, die kiest de redirect-handler zelf.
---
## 11. Werkvolgorde voor Claude Code (chunks)
Elke chunk komt overeen met een story uit M3.5 in de backlog en is **afzonderlijk reviewbaar en commitbaar**.
| # | Story | Inhoud | Verifiëer met |
|---|---|---|---|
| 1 | **ST-350** | Schema-migratie + auth-helpers | `prisma migrate dev` slaagt; helpers werken vanuit testbestand |
| 2 | **ST-351** | `<UserAvatar>` component | Visuele check op 4 sizes; fallback bij ontbrekende avatar |
| 3 | **ST-352** | Story-claim Server Actions (4 acties) | Aanroepen vanuit Sprint Board of test-route; demo-guard werkt |
| 4 | **ST-353** | Sprint Board: assignee-chip + dropdown | Klikken claimt; demo-user krijgt disabled tooltip |
| 5 | **ST-354** | Sprint Board: bulk-claim knop + count | Werkt in regular/demo (disabled) sessie + toast |
| 6 | **ST-355** | Solo route + queries + empty states + cookie | `/solo` redirect werkt; pagina toont juiste taken |
| 7 | **ST-356** | Solo Kanban + Zustand + DnD | Sleep tussen kolommen, status persisteert; netwerk-fail → rollback |
| 8 | **ST-357** | Task detail-dialoog + `updateTaskPlanAction` | Edit, blur, refresh: persisteert; demo: read-only |
| 9 | **ST-358** | Openstaande stories sheet | Sheet opent met N items; claimen werkt; lege staat correct |
| 10 | **ST-359** | Navbar-link "Solo" | Klik gaat naar `/solo` (en redirect verder) |
| 11 | **ST-360** | Demo-seed uitbreiden | Login als demo, Solo bord toont werkende staat |
**Bouwvolgorde-inzicht:** chunks 1-5 leveren al **op het Sprint Board** (ST-313) een werkend assignment-systeem. Daar is een natuurlijke release-grens. Chunks 6-9 vormen het Solo Paneel zelf. Chunks 10-11 zijn polish & demo.
---
## 12. Acceptatiecriteria (volledig v1)
**Functioneel:**
- [ ] Een user kan op het Sprint Board een story claimen, teruggeven, of aan een andere member toewijzen
- [ ] Een user kan met één klik alle ongeclaimde stories in de actieve sprint claimen
- [ ] `/solo` redirect naar laatst-bezochte product, met fallback naar product-picker
- [ ] Solo-bord toont alle taken van geclaimde stories in de actieve sprint, gegroepeerd in 3 kolommen
- [ ] Drag-and-drop tussen kolommen update status, met optimistische UI en rollback bij fout
- [ ] Klik op taakkaart opent dialoog met bewerkbaar implementatieplan (save-on-blur)
- [ ] Knop bovenaan toont openstaande stories en laat ze individueel claimen
- [ ] Navbar-link "Solo" altijd zichtbaar voor ingelogde users
**Niet-functioneel:**
- [ ] Demo-user kan lezen maar niets muteren — alle Server Actions throwen, alle knoppen disabled met tooltip *"Niet beschikbaar in demo-modus"*
- [ ] Membership-check werkt voor zowel owner (`Product.user_id`) als members (`ProductMember`)
- [ ] Reassignment kan alleen naar geldige product-members
- [ ] Foutberichten in het Nederlands voor eindgebruikers
- [ ] Stylingregels uit briefing (MD3-tokens) consistent toegepast
- [ ] Desktop-first; volgt ST-606 melding bij < 1024px
**Performance:**
- [ ] Solo-pagina laadt < 500ms voor sprint met 50 taken (lokaal)
- [ ] Optimistische update voelt direct (< 50ms)
---
## 13. Nog open / mogelijke v1.1
1. **Sortering binnen kolom** — drag binnen kolom is in v1 een no-op. Toekomstige uitbreiding via `solo_sort_order` veld of een aparte `UserTaskOrder`-tabel.
2. **Markdown-rendering implementatieplan** — v2; v1 is plain textarea.
3. **Multi-product Solo bord** — alle producten in één bord. Component is hier al op voorbereid via optionele `showProduct` prop op task card.
4. **REVIEW-status** — bewuste scope-uitstel; voegt later kolom + enum-migratie toe.
---
*Klaar om te valideren en aan Claude Code te geven.*

35
lib/code-server.ts Normal file
View file

@ -0,0 +1,35 @@
import { prisma } from '@/lib/prisma'
const STORY_AUTO_RE = /^ST-(\d+)$/
const PBI_AUTO_RE = /^PBI-(\d+)$/
function nextSequential(existing: (string | null)[], pattern: RegExp): number {
let max = 0
for (const c of existing) {
if (!c) continue
const m = c.match(pattern)
if (m) {
const n = Number.parseInt(m[1], 10)
if (!Number.isNaN(n) && n > max) max = n
}
}
return max + 1
}
export async function generateNextStoryCode(productId: string): Promise<string> {
const stories = await prisma.story.findMany({
where: { product_id: productId },
select: { code: true },
})
const next = nextSequential(stories.map((s) => s.code), STORY_AUTO_RE)
return `ST-${String(next).padStart(3, '0')}`
}
export async function generateNextPbiCode(productId: string): Promise<string> {
const pbis = await prisma.pbi.findMany({
where: { product_id: productId },
select: { code: true },
})
const next = nextSequential(pbis.map((p) => p.code), PBI_AUTO_RE)
return `PBI-${next}`
}

21
lib/code.ts Normal file
View file

@ -0,0 +1,21 @@
// Pure helpers — safe to import from client components.
// DB-backed helpers (generateNextStoryCode/PbiCode) live in lib/code-server.ts.
const VALID_CODE_RE = /^[A-Za-z0-9._-]+$/
export const MAX_CODE_LENGTH = 30
export function isValidCode(code: string): boolean {
return code.length > 0 && code.length <= MAX_CODE_LENGTH && VALID_CODE_RE.test(code)
}
export function normalizeCode(input: string | null | undefined): string | null {
if (input == null) return null
const trimmed = input.trim()
return trimmed === '' ? null : trimmed
}
export function deriveTaskCode(storyCode: string | null, indexOneBased: number): string | null {
if (!storyCode) return null
return `${storyCode}.${indexOneBased}`
}

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "todos" ADD COLUMN "description" VARCHAR(2000);

View file

@ -0,0 +1,9 @@
-- AlterTable
ALTER TABLE "products" ADD COLUMN "code" VARCHAR(30);
ALTER TABLE "pbis" ADD COLUMN "code" VARCHAR(30);
ALTER TABLE "stories" ADD COLUMN "code" VARCHAR(30);
-- CreateIndex
CREATE UNIQUE INDEX "products_user_id_code_key" ON "products"("user_id", "code");
CREATE UNIQUE INDEX "pbis_product_id_code_key" ON "pbis"("product_id", "code");
CREATE UNIQUE INDEX "stories_product_id_code_key" ON "stories"("product_id", "code");

View file

@ -95,6 +95,7 @@ model Product {
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
user_id String
name String
code String? @db.VarChar(30)
description String?
repo_url String?
definition_of_done String
@ -108,6 +109,7 @@ model Product {
members ProductMember[]
@@unique([user_id, name])
@@unique([user_id, code])
@@index([user_id, archived])
@@map("products")
}
@ -116,6 +118,7 @@ model Pbi {
id String @id @default(cuid())
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
product_id String
code String? @db.VarChar(30)
title String
description String?
priority Int
@ -124,6 +127,7 @@ model Pbi {
updated_at DateTime @updatedAt
stories Story[]
@@unique([product_id, code])
@@index([product_id, priority, sort_order])
@@map("pbis")
}
@ -138,6 +142,7 @@ model Story {
sprint_id String?
assignee User? @relation("StoryAssignee", fields: [assignee_id], references: [id], onDelete: SetNull)
assignee_id String?
code String? @db.VarChar(30)
title String
description String?
acceptance_criteria String?
@ -149,6 +154,7 @@ model Story {
logs StoryLog[]
tasks Task[]
@@unique([product_id, code])
@@index([pbi_id, priority, sort_order])
@@index([sprint_id, sort_order])
@@index([product_id, status])
@ -226,6 +232,7 @@ model Todo {
product Product? @relation(fields: [product_id], references: [id], onDelete: SetNull)
product_id String?
title String
description String? @db.VarChar(2000)
done Boolean @default(false)
archived Boolean @default(false)
created_at DateTime @default(now())

View file

@ -154,7 +154,7 @@ export async function loadBacklog(repoRoot: string): Promise<ParsedMilestone[]>
flushPending()
const story: ParsedStory = {
ref: taskMatch[2],
title: `${taskMatch[2]}: ${taskMatch[3]}`,
title: taskMatch[3],
acceptance_criteria: '',
status: taskMatch[1] === 'x' ? 'DONE' : 'OPEN',
sort_order: current.stories.length + 1,

View file

@ -103,6 +103,7 @@ async function main() {
data: {
user_id: demo.id,
name: 'Scrum4Me',
code: 'SCRUM4ME',
description:
'Een desktop-first fullstack webapplicatie voor solo developers en kleine Scrum Teams die meerdere softwareprojecten parallel beheren.',
repo_url: 'https://github.com/madhura68/Scrum4Me',
@ -126,7 +127,8 @@ async function main() {
const pbi = await prisma.pbi.create({
data: {
product_id: product.id,
title: `${ms.key}: ${ms.title}`,
code: ms.key,
title: ms.title,
description: ms.goal,
priority: ms.priority,
sort_order: ms.sort_order,
@ -145,11 +147,15 @@ async function main() {
` PBI ${pbi.title} (priority ${pbi.priority}) + sprint ${ms.sprint_status}`,
)
// M3.5 = de huidige sprint die nog moet beginnen — alle stories en taken
// worden geforceerd op niet-uitgevoerd, ongeacht de checkbox in de backlog.
const forceOpen = ms.key === 'M3.5'
for (const s of ms.stories) {
const isActive = ms.sprint_status === 'ACTIVE'
const inSprint = isActive || s.status === 'DONE'
const storyStatus =
s.status === 'DONE' ? 'DONE' : isActive ? 'IN_SPRINT' : 'OPEN'
const effectivelyDone = !forceOpen && s.status === 'DONE'
const inSprint = isActive || effectivelyDone
const storyStatus = effectivelyDone ? 'DONE' : isActive ? 'IN_SPRINT' : 'OPEN'
const storySummary = s.tasks.map((t) => t.title).join('; ')
const story = await prisma.story.create({
@ -157,6 +163,7 @@ async function main() {
pbi_id: pbi.id,
product_id: product.id,
sprint_id: inSprint ? sprint.id : null,
code: s.ref,
title: s.title,
description: storySummary,
acceptance_criteria: s.acceptance_criteria,
@ -175,7 +182,7 @@ async function main() {
description: t.description,
priority: ms.priority,
sort_order: t.sort_order,
status: s.status === 'DONE' ? 'DONE' : 'TO_DO',
status: effectivelyDone ? 'DONE' : 'TO_DO',
},
})
}