diff --git a/actions/pbis.ts b/actions/pbis.ts index 0f45dfc..ec09e07 100644 --- a/actions/pbis.ts +++ b/actions/pbis.ts @@ -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(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, diff --git a/actions/products.ts b/actions/products.ts index 960ed98..6eae56a 100644 --- a/actions/products.ts +++ b/actions/products.ts @@ -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, diff --git a/actions/stories.ts b/actions/stories.ts index 29119ae..32f1751 100644 --- a/actions/stories.ts +++ b/actions/stories.ts @@ -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(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, diff --git a/actions/todos.ts b/actions/todos.ts index c4252f5..ecfde5f 100644 --- a/actions/todos.ts +++ b/actions/todos.ts @@ -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 } diff --git a/app/(app)/products/[id]/page.tsx b/app/(app)/products/[id]/page.tsx index b41c25e..92a0e87 100644 --- a/app/(app)/products/[id]/page.tsx +++ b/app/(app)/products/[id]/page.tsx @@ -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={ ({ 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} /> } diff --git a/app/(app)/products/[id]/settings/page.tsx b/app/(app)/products/[id]/settings/page.tsx index 8150163..1ab3593 100644 --- a/app/(app)/products/[id]/settings/page.tsx +++ b/app/(app)/products/[id]/settings/page.tsx @@ -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, diff --git a/app/(app)/products/[id]/solo/page.tsx b/app/(app)/products/[id]/solo/page.tsx index 1e833c4..995aee2 100644 --- a/app/(app)/products/[id]/solo/page.tsx +++ b/app/(app)/products/[id]/solo/page.tsx @@ -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, diff --git a/app/(app)/products/[id]/sprint/page.tsx b/app/(app)/products/[id]/sprint/page.tsx index 16885c5..0365842 100644 --- a/app/(app)/products/[id]/sprint/page.tsx +++ b/app/(app)/products/[id]/sprint/page.tsx @@ -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, diff --git a/app/(app)/todos/page.tsx b/app/(app)/todos/page.tsx index b2c6fd3..27f07f4 100644 --- a/app/(app)/todos/page.tsx +++ b/app/(app)/todos/page.tsx @@ -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, diff --git a/components/backlog/backlog-card.tsx b/components/backlog/backlog-card.tsx index 81d5c66..7a93910 100644 --- a/components/backlog/backlog-card.tsx +++ b/components/backlog/backlog-card.tsx @@ -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 = { 1: 'border-l-4 border-l-priority-critical', @@ -13,6 +14,7 @@ export const PRIORITY_BORDER: Record = { interface BacklogCardProps extends React.HTMLAttributes { title: string priority: number + code?: string | null isSelected?: boolean isDragging?: boolean badge?: React.ReactNode @@ -20,7 +22,7 @@ interface BacklogCardProps extends React.HTMLAttributes { } export const BacklogCard = forwardRef(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(function )} {...rest} > -

{title}

+
+

{title}

+ {code && } +
{(badge || actions) && (
{badge}
diff --git a/components/backlog/pbi-dialog.tsx b/components/backlog/pbi-dialog.tsx index 166bb12..8174f27 100644 --- a/components/backlog/pbi-dialog.tsx +++ b/components/backlog/pbi-dialog.tsx @@ -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 && } -
- - +
+
+ + +
+
+ + +
diff --git a/components/backlog/pbi-list.tsx b/components/backlog/pbi-list.tsx index 30d2489..7e7cb20 100644 --- a/components/backlog/pbi-list.tsx +++ b/components/backlog/pbi-list.tsx @@ -49,6 +49,7 @@ const PRIORITY_COLORS: Record = { 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} diff --git a/components/backlog/story-dialog.tsx b/components/backlog/story-dialog.tsx index 5077d5d..068d47f 100644 --- a/components/backlog/story-dialog.tsx +++ b/components/backlog/story-dialog.tsx @@ -124,7 +124,14 @@ export function StoryDialog({ state, onClose, isDemo = false }: StoryDialogProps { if (!open) onClose() }}> - {isEdit ? story!.title : 'Nieuwe story'} +
+ {isEdit ? story!.title : 'Nieuwe story'} + {isEdit && story!.code && ( + + {story!.code} + + )} +
{isEdit && (
@@ -154,17 +161,30 @@ export function StoryDialog({ state, onClose, isDemo = false }: StoryDialogProps
{showForm ? (
-
- - - {fieldError('title') &&

{fieldError('title')}

} +
+
+ + + {fieldError('code') &&

{fieldError('code')}

} +
+
+ + + {fieldError('title') &&

{fieldError('title')}

} +
diff --git a/components/backlog/story-panel.tsx b/components/backlog/story-panel.tsx index 4ff82f8..f6a804f 100644 --- a/components/backlog/story-panel.tsx +++ b/components/backlog/story-panel.tsx @@ -52,6 +52,7 @@ const STATUS_LABELS: Record = { 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} diff --git a/components/dashboard/product-list.tsx b/components/dashboard/product-list.tsx index d9d0f19..4b41d54 100644 --- a/components/dashboard/product-list.tsx +++ b/components/dashboard/product-list.tsx @@ -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 >
-

- {product.name} -

+
+ {product.code && } +

+ {product.name} +

+
{product.description && (

{product.description.slice(0, 80)}{product.description.length > 80 ? '…' : ''} diff --git a/components/products/product-form.tsx b/components/products/product-form.tsx index f8910df..aa1d280 100644 --- a/components/products/product-form.tsx +++ b/components/products/product-form.tsx @@ -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 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 )} -

- - - {fieldError('name') && ( -

{fieldError('name')}

- )} +
+
+ + + {fieldError('code') && ( +

{fieldError('code')}

+ )} +
+
+ + + {fieldError('name') && ( +

{fieldError('name')}

+ )} +
diff --git a/components/settings/profile-editor.tsx b/components/settings/profile-editor.tsx index 5c81963..8eedb51 100644 --- a/components/settings/profile-editor.tsx +++ b/components/settings/profile-editor.tsx @@ -113,6 +113,7 @@ export function ProfileEditor({ email, bio, bioDetail, hasAvatar, avatarVersion E-mailadres + {code} + + ) +} diff --git a/components/solo/solo-board.tsx b/components/solo/solo-board.tsx index 149d7d5..60cddc4 100644 --- a/components/solo/solo-board.tsx +++ b/components/solo/solo-board.tsx @@ -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 { diff --git a/components/solo/solo-task-card.tsx b/components/solo/solo-task-card.tsx index 22ff289..6378010 100644 --- a/components/solo/solo-task-card.tsx +++ b/components/solo/solo-task-card.tsx @@ -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 = { @@ -39,8 +40,14 @@ export function SoloTaskCard({ task, isDemo, onClick }: SoloTaskCardProps) { )} {...(!isDemo ? { ...attributes, ...listeners } : {})} > -

{task.title}

-

{task.story_title}

+
+

{task.title}

+ {task.task_code && } +
+

+ {task.story_code && {task.story_code}} + {task.story_title} +

) } @@ -53,8 +60,14 @@ export function SoloTaskCardOverlay({ task }: { task: SoloTask }) { PRIORITY_BORDER[task.priority], )} > -

{task.title}

-

{task.story_title}

+
+

{task.title}

+ {task.task_code && } +
+

+ {task.story_code && {task.story_code}} + {task.story_title} +

) } diff --git a/components/solo/task-detail-dialog.tsx b/components/solo/task-detail-dialog.tsx index a325039..e2c4afe 100644 --- a/components/solo/task-detail-dialog.tsx +++ b/components/solo/task-detail-dialog.tsx @@ -77,11 +77,19 @@ function TaskDetailContent({ task, productId, isDemo, onClose }: TaskDetailConte {task.title} + {task.task_code && ( + + {task.task_code} + + )} {STATUS_LABELS[task.status]}
-

{task.story_title}

+

+ {task.story_code && {task.story_code}} + {task.story_title} +

{task.description && ( diff --git a/components/solo/unassigned-stories-sheet.tsx b/components/solo/unassigned-stories-sheet.tsx index b6ccbb5..6d84892 100644 --- a/components/solo/unassigned-stories-sheet.tsx +++ b/components/solo/unassigned-stories-sheet.tsx @@ -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({
-

{story.title}

+
+

{story.title}

+ {story.code && } +

{story.tasks.length} {story.tasks.length === 1 ? 'taak' : 'taken'}

diff --git a/components/sprint/sprint-backlog.tsx b/components/sprint/sprint-backlog.tsx index 7d5714b..bffc534 100644 --- a/components/sprint/sprint-backlog.tsx +++ b/components/sprint/sprint-backlog.tsx @@ -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 = { 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({ )}
-

{story.title}

+
+

{story.title}

+ {story.code && } +
{PRIORITY_LABELS[story.priority]} @@ -338,7 +344,10 @@ function DraggablePbiStoryRow({ )}
-

{story.title}

+
+

{story.title}

+ {story.code && } +
{STATUS_LABELS[story.status]} @@ -387,6 +396,7 @@ export function SprintBacklogRight({ pbisWithStories, sprintStoryIds, isDemo, on > {collapsed.has(pbi.id) ? '▶' : '▼'} {pbi.title} + {pbi.code && } {pbi.stories.length} @@ -400,7 +410,10 @@ export function SprintBacklogRight({ pbisWithStories, sprintStoryIds, isDemo, on >
-

{story.title}

+
+

{story.title}

+ {story.code && } +
{STATUS_LABELS[story.status]} diff --git a/components/sprint/sprint-board-client.tsx b/components/sprint/sprint-board-client.tsx index 9bb98ab..ce3e18d 100644 --- a/components/sprint/sprint-board-client.tsx +++ b/components/sprint/sprint-board-client.tsx @@ -228,6 +228,7 @@ export function SprintBoardClient({ selectedStoryId ? ( s.id === selectedStoryId)?.code ?? null} sprintId={sprintId} productId={productId} tasks={selectedTasks} diff --git a/components/sprint/sprint-header.tsx b/components/sprint/sprint-header.tsx index 5ae83a6..fcfa447 100644 --- a/components/sprint/sprint-header.tsx +++ b/components/sprint/sprint-header.tsx @@ -116,6 +116,7 @@ export function SprintHeader({ productId: _productId, productName, sprint, isDem
{sprintStories.map(story => (
+ {story.code && {story.code}} {story.title}
{!isDemo && ( -
+
@@ -150,7 +156,7 @@ function CreateSubmitButton() { return } -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(null) @@ -232,10 +238,11 @@ export function TaskList({ storyId, sprintId, productId: _productId, tasks, isDe onDragEnd={handleDragEnd} > t.id)} strategy={verticalListSortingStrategy}> - {orderedTasks.map(task => ( + {orderedTasks.map((task, idx) => ( handleStatusToggle(task)} onDelete={() => handleDelete(task.id)} diff --git a/components/todos/todo-list.tsx b/components/todos/todo-list.tsx index 2eb7497..40f2af3 100644 --- a/components/todos/todo-list.tsx +++ b/components/todos/todo-list.tsx @@ -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" />
+