M14: TaskDialog (create/edit) + story auto-promotion (#21)

* chore(ST-1112): add deps for task dialog

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1112): add shared zod schema for task dialog

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1112): add missing MD3 tokens for task dialog

outline-variant, on-error-container, status-review (light + dark)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1112): add saveTask and deleteTask server actions for TaskDialog

Unified create/edit action (saveTask) replaces separate formData-based
actions for the new TaskDialog. Uses shared zod schema, structured
SaveTaskResult union type, and context-aware revalidatePath for both
sprint and backlog routes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1112): add TaskDialog component (create & edit mode)

Builds the full TaskDialog on top of the existing @base-ui/react
Dialog primitive. Covers create mode, edit mode (status field +
created_at metadata + delete), dirty-check AlertDialog, delete
confirm AlertDialog, Cmd+Enter submit, and per-field char counters.
Uses react-hook-form + zodResolver against the shared taskSchema.
Priority and status are extracted to PrioritySegmented and
StatusSelect sub-components using MD3 tokens throughout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1112): refactor task-list to open TaskDialog via URL params

Replaces inline create/edit forms with router.push navigation:
- Clicking a task row → ?editTask=<id>
- "+ Taak" button → ?newTask=1&storyId=<storyId>
Removes CreateTaskForm, EditSubmitButton, updateTaskAction, and
createTaskAction from the component. Status toggle and DnD remain
unchanged. Rows now have cursor-pointer and keyboard a11y.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1112): wire TaskDialog into sprint page via searchParams

Sprint page now reads ?newTask, ?storyId, and ?editTask query params.
For edit mode: fetches the task server-side with productAccessFilter
scope (invalid/foreign IDs redirect to closePath). Renders TaskDialog
when either param is present. closePath is the sprint route without
query params.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1112): add Suspense skeleton for edit-mode task loading

Extracts task fetch into EditTaskLoader (async server component) so
the sprint board renders immediately while the task loads.
TaskDialogSkeleton shows 3 grey bars during the fetch. Invalid or
out-of-scope task IDs redirect to closePath.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1112): render description as markdown in task-detail-dialog

Solo task detail now renders description via react-markdown +
remark-gfm with prose styling. Sanitizes script/iframe elements.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(ST-1112): add saveTask/deleteTask server action tests

Covers all three demo-policy layers and cross-tenant scope:
demo blocked (403), unauthenticated blocked, validation 422,
edit cross-tenant forbidden, create cross-tenant forbidden,
and happy-path for both edit and create.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1112): add updateTaskStatusWithStoryPromotion helper

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1112): wire story-promotion into saveTask and PATCH /api/tasks/:id

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(ST-1112): add task-dialog doc and architecture note

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: extend allowed tools in settings.local.json

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1113): add 200ms animation-delay to TaskDialogSkeleton to prevent flicker

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1114): add DirtyCloseGuard reusable component for dirty-form close confirmation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1114): add shared Markdown wrapper, apply to task-detail and story-dialog

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: allow grep -E pattern in settings.local.json

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-04-30 16:55:20 +02:00 committed by GitHub
parent 64e3f610a6
commit 6cd98129f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 3665 additions and 130 deletions

View file

@ -38,7 +38,24 @@
"Bash(git -C /Users/janpetervisser/Development/scrum4me-mcp/vendor/scrum4me log --oneline -3)", "Bash(git -C /Users/janpetervisser/Development/scrum4me-mcp/vendor/scrum4me log --oneline -3)",
"Bash(git -C /Users/janpetervisser/Development/scrum4me-mcp/vendor/scrum4me branch -a)", "Bash(git -C /Users/janpetervisser/Development/scrum4me-mcp/vendor/scrum4me branch -a)",
"Bash(git fetch *)", "Bash(git fetch *)",
"Bash(git reset *)" "Bash(git reset *)",
"mcp__scrum4me__update_task_plan",
"mcp__scrum4me__create_task",
"mcp__scrum4me__ask_user_question",
"Bash(git *)",
"mcp__scrum4me__create_pbi",
"mcp__scrum4me__create_story",
"mcp__scrum4me__health",
"Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\(json.dumps\\(d.get\\('mcpServers', {}\\), indent=2\\)\\)\")",
"Read(//Users/janpetervisser/.claude/**)",
"Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\(json.dumps\\(d, indent=2\\)\\)\")",
"Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\(json.dumps\\(d.get\\('mcpServers',{}\\), indent=2\\)\\)\")",
"Bash(python3 -m json.tool)",
"mcp__scrum4me__wait_for_job",
"Bash(npx ctx7@latest docs /websites/github_en_rest \"How to fetch Copilot bot pull request reviews and identify them by author\")",
"Bash(npm i *)",
"Bash(curl *)",
"Bash(grep -E \"\\\\.\\(tsx|ts\\)$\")"
] ]
} }
} }

View file

@ -289,6 +289,10 @@ Scrum4Me heeft een eigen MCP-server in repo [`madhura68/scrum4me-mcp`](https://g
- `mcp__scrum4me__wait_for_job` — blokkeert ≤600s, claimt atomisch een QUEUED-job via FOR UPDATE SKIP LOCKED; retourneert volledige task-context (implementation_plan, story, pbi, sprint, repo_url). Zet stale CLAIMED-jobs (>30min) eerst terug naar QUEUED. - `mcp__scrum4me__wait_for_job` — blokkeert ≤600s, claimt atomisch een QUEUED-job via FOR UPDATE SKIP LOCKED; retourneert volledige task-context (implementation_plan, story, pbi, sprint, repo_url). Zet stale CLAIMED-jobs (>30min) eerst terug naar QUEUED.
- `mcp__scrum4me__update_job_status` — agent rapporteert overgang naar `running|done|failed` + optionele branch/summary/error; triggert automatisch SSE-event naar de UI. Auth: Bearer-token moet matchen `claimed_by_token_id`. - `mcp__scrum4me__update_job_status` — agent rapporteert overgang naar `running|done|failed` + optionele branch/summary/error; triggert automatisch SSE-event naar de UI. Auth: Bearer-token moet matchen `claimed_by_token_id`.
**Code koppellen aan app**
- 'Pak de volgende job uit de Scrum4Me-queue' - geeft in claude_workers een record toe, tool wait_for_job
### Prompt ### Prompt
- `implement_next_story` (arg: `product_id`) — end-to-end workflow - `implement_next_story` (arg: `product_id`) — end-to-end workflow

View file

@ -0,0 +1,225 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) }))
vi.mock('iron-session', () => ({
getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }),
}))
vi.mock('@/lib/session', () => ({
sessionOptions: { cookieName: 'test', password: 'test' },
}))
vi.mock('@/lib/product-access', () => ({
productAccessFilter: vi.fn().mockReturnValue({}),
}))
vi.mock('@/lib/prisma', () => ({
prisma: {
task: {
findFirst: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
findMany: vi.fn(),
},
story: {
findFirst: vi.fn(),
findUniqueOrThrow: vi.fn(),
update: vi.fn(),
},
$transaction: vi.fn(),
},
}))
import { prisma } from '@/lib/prisma'
import { getIronSession } from 'iron-session'
import { saveTask, deleteTask } from '@/actions/tasks'
const mockPrisma = prisma as unknown as {
task: {
findFirst: ReturnType<typeof vi.fn>
create: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
delete: ReturnType<typeof vi.fn>
findMany: ReturnType<typeof vi.fn>
}
story: {
findFirst: ReturnType<typeof vi.fn>
findUniqueOrThrow: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
$transaction: ReturnType<typeof vi.fn>
}
const mockSession = getIronSession as ReturnType<typeof vi.fn>
const VALID_INPUT = {
title: 'Test taak',
description: 'Beschrijving',
implementation_plan: 'Plan',
priority: 3,
}
const TASK = {
id: 'task-1',
title: 'Test taak',
status: 'TO_DO',
}
const STORY = { sprint_id: 'sprint-1' }
beforeEach(() => {
vi.clearAllMocks()
mockSession.mockResolvedValue({ userId: 'user-1', isDemo: false })
// Pass-through transaction so saveTask's $transaction wrapper executes its callback inline.
mockPrisma.$transaction.mockImplementation(async (run: (tx: typeof prisma) => Promise<unknown>) => {
return run(prisma)
})
})
// ─── saveTask ────────────────────────────────────────────────────────────────
describe('saveTask — demo-readonly (laag 2)', () => {
it('blokkeert demo-sessie', async () => {
mockSession.mockResolvedValue({ userId: 'user-1', isDemo: true })
const result = await saveTask(VALID_INPUT, { productId: 'p-1' })
expect(result).toEqual({ ok: false, code: 403, error: 'demo_readonly' })
})
})
describe('saveTask — unauthenticated', () => {
it('blokkeert niet-ingelogde gebruiker', async () => {
mockSession.mockResolvedValue({ userId: undefined, isDemo: false })
const result = await saveTask(VALID_INPUT, { productId: 'p-1' })
expect(result).toEqual({ ok: false, code: 403, error: 'forbidden' })
})
})
describe('saveTask — validatie', () => {
it('retourneert 422 bij lege titel', async () => {
const result = await saveTask({ ...VALID_INPUT, title: '' }, { productId: 'p-1', storyId: 's-1' })
expect(result).toMatchObject({ ok: false, code: 422, error: 'validation' })
})
it('retourneert 422 bij te lange titel (>120 tekens)', async () => {
const result = await saveTask(
{ ...VALID_INPUT, title: 'a'.repeat(121) },
{ productId: 'p-1', storyId: 's-1' },
)
expect(result).toMatchObject({ ok: false, code: 422, error: 'validation' })
})
})
describe('saveTask — edit (cross-tenant scope)', () => {
it('retourneert forbidden als task buiten scope valt', async () => {
mockPrisma.task.findFirst.mockResolvedValue(null) // out-of-scope
const result = await saveTask(VALID_INPUT, { taskId: 'task-1', productId: 'p-1' })
expect(result).toEqual({ ok: false, code: 403, error: 'forbidden' })
})
it('update slaagt voor een geautoriseerde task', async () => {
mockPrisma.task.findFirst.mockResolvedValue(TASK)
mockPrisma.task.update.mockResolvedValue(TASK)
const result = await saveTask(VALID_INPUT, { taskId: 'task-1', productId: 'p-1' })
expect(result).toMatchObject({ ok: true })
// scope-filter is toegepast: findFirst bevat `story.product`
expect(mockPrisma.task.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ id: 'task-1', story: expect.anything() }),
}),
)
})
})
describe('saveTask — edit met status-promotie', () => {
it('promotes story naar DONE wanneer status flip naar DONE alle siblings DONE maakt', async () => {
mockPrisma.task.findFirst.mockResolvedValue({ id: 'task-1', status: 'IN_PROGRESS' })
mockPrisma.task.update.mockResolvedValue({
id: 'task-1',
title: 'Test taak',
status: 'IN_PROGRESS',
story_id: 'story-1',
implementation_plan: null,
})
// Wanneer de helper draait, gebruikt-ie tx.task.update voor de status-flip.
// Dezelfde mock vangt beide updates op; tweede return-value voor de status-update.
mockPrisma.task.update.mockResolvedValueOnce({
id: 'task-1',
title: 'Test taak',
status: 'IN_PROGRESS',
story_id: 'story-1',
implementation_plan: null,
}).mockResolvedValueOnce({
id: 'task-1',
title: 'Test taak',
status: 'DONE',
story_id: 'story-1',
implementation_plan: null,
})
mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }, { status: 'DONE' }])
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' })
const result = await saveTask(
{ ...VALID_INPUT, status: 'DONE' },
{ taskId: 'task-1', productId: 'p-1' },
)
expect(result).toMatchObject({ ok: true })
expect(mockPrisma.story.update).toHaveBeenCalledWith({
where: { id: 'story-1' },
data: { status: 'DONE' },
})
})
})
describe('saveTask — create (cross-tenant scope)', () => {
it('retourneert forbidden als story buiten scope valt', async () => {
mockPrisma.story.findFirst.mockResolvedValue(null)
const result = await saveTask(VALID_INPUT, { storyId: 's-1', productId: 'p-1' })
expect(result).toEqual({ ok: false, code: 403, error: 'forbidden' })
})
it('aanmaken slaagt voor een geautoriseerde story', async () => {
mockPrisma.story.findFirst.mockResolvedValue(STORY)
mockPrisma.task.findFirst.mockResolvedValue(null) // geen vorige taak
mockPrisma.task.create.mockResolvedValue(TASK)
const result = await saveTask(VALID_INPUT, { storyId: 's-1', productId: 'p-1' })
expect(result).toMatchObject({ ok: true })
})
})
// ─── deleteTask ──────────────────────────────────────────────────────────────
describe('deleteTask — demo-readonly (laag 2)', () => {
it('blokkeert demo-sessie', async () => {
mockSession.mockResolvedValue({ userId: 'user-1', isDemo: true })
const result = await deleteTask('task-1', { productId: 'p-1' })
expect(result).toEqual({ ok: false, code: 403, error: 'demo_readonly' })
})
})
describe('deleteTask — unauthenticated', () => {
it('blokkeert niet-ingelogde gebruiker', async () => {
mockSession.mockResolvedValue({ userId: undefined, isDemo: false })
const result = await deleteTask('task-1', { productId: 'p-1' })
expect(result).toEqual({ ok: false, code: 403, error: 'forbidden' })
})
})
describe('deleteTask — cross-tenant scope', () => {
it('retourneert forbidden als task buiten scope valt', async () => {
mockPrisma.task.findFirst.mockResolvedValue(null)
const result = await deleteTask('task-1', { productId: 'p-1' })
expect(result).toEqual({ ok: false, code: 403, error: 'forbidden' })
})
it('verwijderen slaagt voor een geautoriseerde task', async () => {
mockPrisma.task.findFirst.mockResolvedValue(TASK)
mockPrisma.task.delete.mockResolvedValue(TASK)
const result = await deleteTask('task-1', { productId: 'p-1' })
expect(result).toEqual({ ok: true })
// scope-filter toegepast
expect(mockPrisma.task.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ id: 'task-1', story: expect.anything() }),
}),
)
})
})

View file

@ -11,10 +11,13 @@ vi.mock('@/lib/prisma', () => ({
}, },
story: { story: {
findFirst: vi.fn(), findFirst: vi.fn(),
findUniqueOrThrow: vi.fn(),
update: vi.fn(),
}, },
task: { task: {
findFirst: vi.fn(), findFirst: vi.fn(),
update: vi.fn(), update: vi.fn(),
findMany: vi.fn(),
}, },
storyLog: { storyLog: {
create: vi.fn(), create: vi.fn(),
@ -43,8 +46,16 @@ import { POST as postTodo } from '@/app/api/todos/route'
const mockPrisma = prisma as unknown as { const mockPrisma = prisma as unknown as {
product: { findMany: ReturnType<typeof vi.fn>; findFirst: ReturnType<typeof vi.fn> } product: { findMany: ReturnType<typeof vi.fn>; findFirst: ReturnType<typeof vi.fn> }
sprint: { findFirst: ReturnType<typeof vi.fn> } sprint: { findFirst: ReturnType<typeof vi.fn> }
story: { findFirst: ReturnType<typeof vi.fn> } story: {
task: { findFirst: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } findFirst: ReturnType<typeof vi.fn>
findUniqueOrThrow: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
task: {
findFirst: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
findMany: ReturnType<typeof vi.fn>
}
storyLog: { create: ReturnType<typeof vi.fn> } storyLog: { create: ReturnType<typeof vi.fn> }
todo: { create: ReturnType<typeof vi.fn> } todo: { create: ReturnType<typeof vi.fn> }
$transaction: ReturnType<typeof vi.fn> $transaction: ReturnType<typeof vi.fn>
@ -85,6 +96,11 @@ function routeCtx(id: string) {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
// Pass-through transaction so callers can `prisma.$transaction(async tx => ...)` in routes.
mockPrisma.$transaction.mockImplementation(async (run: unknown) => {
if (typeof run === 'function') return (run as (tx: typeof prisma) => Promise<unknown>)(prisma)
return run
})
}) })
// ─── GET /api/products ──────────────────────────────────────────────────────── // ─── GET /api/products ────────────────────────────────────────────────────────
@ -386,7 +402,15 @@ describe('PATCH /api/tasks/:id', () => {
id: 'task-1', id: 'task-1',
story: { product: { user_id: 'user-1' } }, story: { product: { user_id: 'user-1' } },
}) })
mockPrisma.task.update.mockResolvedValue({ id: 'task-1', status: 'DONE' }) mockPrisma.task.update.mockResolvedValue({
id: 'task-1',
title: 'Task',
status: 'DONE',
story_id: 'story-1',
implementation_plan: null,
})
mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }])
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' })
const res = await patchTask( const res = await patchTask(
makePatch('http://localhost/api/tasks/task-1', { status: 'done' }), makePatch('http://localhost/api/tasks/task-1', { status: 'done' }),

View file

@ -5,7 +5,13 @@ vi.mock('@/lib/prisma', () => ({
task: { task: {
findFirst: vi.fn(), findFirst: vi.fn(),
update: vi.fn(), update: vi.fn(),
findMany: vi.fn(),
}, },
story: {
findUniqueOrThrow: vi.fn(),
update: vi.fn(),
},
$transaction: vi.fn(),
}, },
})) }))
@ -18,7 +24,16 @@ import { authenticateApiRequest } from '@/lib/api-auth'
import { PATCH as patchTask } from '@/app/api/tasks/[id]/route' import { PATCH as patchTask } from '@/app/api/tasks/[id]/route'
const mockPrisma = prisma as unknown as { const mockPrisma = prisma as unknown as {
task: { findFirst: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } task: {
findFirst: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
findMany: ReturnType<typeof vi.fn>
}
story: {
findUniqueOrThrow: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
$transaction: ReturnType<typeof vi.fn>
} }
const mockAuth = authenticateApiRequest as ReturnType<typeof vi.fn> const mockAuth = authenticateApiRequest as ReturnType<typeof vi.fn>
@ -55,6 +70,15 @@ describe('PATCH /api/tasks/:id', () => {
id: 'task-1', id: 'task-1',
status: 'DONE', status: 'DONE',
implementation_plan: null, implementation_plan: null,
title: 'Task',
story_id: 'story-1',
})
// Default sibling state: only this task, already DONE → no story-promotion
mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }])
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' })
// Pass-through for $transaction so tests behave as if Prisma ran the run-fn directly.
mockPrisma.$transaction.mockImplementation(async (run: (tx: typeof prisma) => Promise<unknown>) => {
return run(prisma)
}) })
}) })
@ -111,17 +135,28 @@ describe('PATCH /api/tasks/:id', () => {
// TC-T-10 // TC-T-10
it('updates both status and implementation_plan and returns 200', async () => { it('updates both status and implementation_plan and returns 200', async () => {
const plan = 'Full plan here.' const plan = 'Full plan here.'
mockPrisma.task.update.mockResolvedValue({ id: 'task-1', status: 'DONE', implementation_plan: plan }) // First update writes the implementation_plan; second is the helper's status write.
mockPrisma.task.update
.mockResolvedValueOnce({ id: 'task-1', status: 'TO_DO', implementation_plan: plan })
.mockResolvedValueOnce({
id: 'task-1',
title: 'Task',
status: 'DONE',
story_id: 'story-1',
implementation_plan: plan,
})
const res = await patchTask(...makeRequest({ status: 'done', implementation_plan: plan })) const res = await patchTask(...makeRequest({ status: 'done', implementation_plan: plan }))
const data = await res.json() const data = await res.json()
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(data).toMatchObject({ status: 'done', implementation_plan: plan }) expect(data).toMatchObject({ status: 'done', implementation_plan: plan })
// implementation_plan written via direct update; status written via helper update.
expect(mockPrisma.task.update).toHaveBeenCalledWith( expect(mockPrisma.task.update).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({ data: { implementation_plan: plan } }),
data: { status: 'DONE', implementation_plan: plan }, )
}) expect(mockPrisma.task.update).toHaveBeenCalledWith(
expect.objectContaining({ data: { status: 'DONE' } }),
) )
}) })
@ -146,6 +181,25 @@ describe('PATCH /api/tasks/:id', () => {
expect(reviewRes.status).toBe(422) expect(reviewRes.status).toBe(422)
}) })
it('promotes story to DONE when last sibling task transitions to DONE', async () => {
mockPrisma.task.update.mockResolvedValue({
id: 'task-1',
status: 'DONE',
implementation_plan: null,
title: 'Task',
story_id: 'story-1',
})
mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }, { status: 'DONE' }])
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' })
const res = await patchTask(...makeRequest({ status: 'done' }))
expect(res.status).toBe(200)
expect(mockPrisma.story.update).toHaveBeenCalledWith({
where: { id: 'story-1' },
data: { status: 'DONE' },
})
})
it('returns 400 for malformed JSON', async () => { it('returns 400 for malformed JSON', async () => {
const req = new Request('http://localhost/api/tasks/task-1', { const req = new Request('http://localhost/api/tasks/task-1', {
method: 'PATCH', method: 'PATCH',

View file

@ -0,0 +1,153 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@/lib/prisma', () => ({
prisma: {
task: {
update: vi.fn(),
findMany: vi.fn(),
},
story: {
findUniqueOrThrow: vi.fn(),
update: vi.fn(),
},
$transaction: vi.fn(),
},
}))
import { prisma } from '@/lib/prisma'
import { updateTaskStatusWithStoryPromotion } from '@/lib/tasks-status-update'
const mockPrisma = prisma as unknown as {
task: {
update: ReturnType<typeof vi.fn>
findMany: ReturnType<typeof vi.fn>
}
story: {
findUniqueOrThrow: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
$transaction: ReturnType<typeof vi.fn>
}
beforeEach(() => {
vi.clearAllMocks()
// Pass-through: $transaction(run) just calls run with the mocked prisma client.
mockPrisma.$transaction.mockImplementation(async (run: (tx: typeof prisma) => Promise<unknown>) => {
return run(prisma)
})
})
const TASK_BASE = {
id: 'task-1',
title: 'Task',
story_id: 'story-1',
implementation_plan: null,
}
describe('updateTaskStatusWithStoryPromotion', () => {
it('promotes story to DONE when last sibling task transitions to DONE', async () => {
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' })
mockPrisma.task.findMany.mockResolvedValue([
{ status: 'DONE' },
{ status: 'DONE' },
])
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' })
const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE')
expect(result.storyStatusChange).toBe('promoted')
expect(result.storyId).toBe('story-1')
expect(mockPrisma.story.update).toHaveBeenCalledWith({
where: { id: 'story-1' },
data: { status: 'DONE' },
})
})
it('does not promote when story is already DONE (idempotent)', async () => {
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' })
mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }])
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' })
const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE')
expect(result.storyStatusChange).toBe(null)
expect(mockPrisma.story.update).not.toHaveBeenCalled()
})
it('does not promote when not all siblings are DONE', async () => {
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' })
mockPrisma.task.findMany.mockResolvedValue([
{ status: 'DONE' },
{ status: 'IN_PROGRESS' },
])
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' })
const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE')
expect(result.storyStatusChange).toBe(null)
expect(mockPrisma.story.update).not.toHaveBeenCalled()
})
it('demotes story to IN_SPRINT when a task moves out of DONE on a DONE story', async () => {
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' })
mockPrisma.task.findMany.mockResolvedValue([
{ status: 'IN_PROGRESS' },
{ status: 'DONE' },
])
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' })
const result = await updateTaskStatusWithStoryPromotion('task-1', 'IN_PROGRESS')
expect(result.storyStatusChange).toBe('demoted')
expect(mockPrisma.story.update).toHaveBeenCalledWith({
where: { id: 'story-1' },
data: { status: 'IN_SPRINT' },
})
})
it('does not demote when story is not DONE', async () => {
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' })
mockPrisma.task.findMany.mockResolvedValue([{ status: 'IN_PROGRESS' }])
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' })
const result = await updateTaskStatusWithStoryPromotion('task-1', 'IN_PROGRESS')
expect(result.storyStatusChange).toBe(null)
expect(mockPrisma.story.update).not.toHaveBeenCalled()
})
it('updates the task regardless of story-status change', async () => {
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' })
mockPrisma.task.findMany.mockResolvedValue([{ status: 'IN_PROGRESS' }])
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' })
await updateTaskStatusWithStoryPromotion('task-1', 'IN_PROGRESS')
expect(mockPrisma.task.update).toHaveBeenCalledWith({
where: { id: 'task-1' },
data: { status: 'IN_PROGRESS' },
select: expect.any(Object),
})
})
it('uses the provided transaction client when passed', async () => {
const tx = {
task: { update: vi.fn(), findMany: vi.fn() },
story: { findUniqueOrThrow: vi.fn(), update: vi.fn() },
}
tx.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' })
tx.task.findMany.mockResolvedValue([{ status: 'DONE' }])
tx.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE', tx as any)
expect(result.storyStatusChange).toBe('promoted')
// $transaction should NOT be called when caller already provides a tx.
expect(mockPrisma.$transaction).not.toHaveBeenCalled()
expect(tx.story.update).toHaveBeenCalledWith({
where: { id: 'story-1' },
data: { status: 'DONE' },
})
})
})

View file

@ -8,11 +8,146 @@ import { prisma } from '@/lib/prisma'
import { SessionData, sessionOptions } from '@/lib/session' import { SessionData, sessionOptions } from '@/lib/session'
import { productAccessFilter } from '@/lib/product-access' import { productAccessFilter } from '@/lib/product-access'
import { requireProductWriter } from '@/lib/auth' import { requireProductWriter } from '@/lib/auth'
import { taskSchema as sharedTaskSchema, type TaskInput } from '@/lib/schemas/task'
import { updateTaskStatusWithStoryPromotion } from '@/lib/tasks-status-update'
async function getSession() { async function getSession() {
return getIronSession<SessionData>(await cookies(), sessionOptions) return getIronSession<SessionData>(await cookies(), sessionOptions)
} }
// Return types for TaskDialog actions
export type SaveTaskResult =
| { ok: true; task: { id: string; title: string; status: string } }
| { ok: false; code: 422; error: 'validation'; fieldErrors: Record<string, string[]> }
| { ok: false; code: 403; error: 'demo_readonly' | 'forbidden' }
| { ok: false; code: 500; error: 'server_error' }
export type DeleteTaskResult =
| { ok: true }
| { ok: false; code: 403; error: 'demo_readonly' | 'forbidden' }
| { ok: false; code: 500; error: 'server_error' }
// Unified create/edit action used by TaskDialog.
// context.taskId present → edit; context.storyId present → create.
export async function saveTask(
input: TaskInput,
context: { taskId?: string; storyId?: string; productId: string },
): Promise<SaveTaskResult> {
const session = await getSession()
if (!session.userId) return { ok: false, code: 403, error: 'forbidden' }
if (session.isDemo) return { ok: false, code: 403, error: 'demo_readonly' }
const parsed = sharedTaskSchema.safeParse(input)
if (!parsed.success) {
return {
ok: false,
code: 422,
error: 'validation',
fieldErrors: parsed.error.flatten().fieldErrors as Record<string, string[]>,
}
}
const { title, description, implementation_plan, priority, status } = parsed.data
const scope = productAccessFilter(session.userId)
try {
if (context.taskId) {
const existing = await prisma.task.findFirst({
where: { id: context.taskId, story: { product: scope } },
select: { id: true, status: true },
})
if (!existing) return { ok: false, code: 403, error: 'forbidden' }
const taskId = context.taskId
const statusChanged = status !== undefined && status !== existing.status
const task = await prisma.$transaction(async (tx) => {
const updated = await tx.task.update({
where: { id: taskId },
data: {
title,
description: description ?? null,
implementation_plan: implementation_plan ?? null,
priority,
},
select: { id: true, title: true, status: true },
})
if (statusChanged) {
const result = await updateTaskStatusWithStoryPromotion(taskId, status, tx)
return { id: result.task.id, title: result.task.title, status: result.task.status }
}
return updated
})
revalidatePath(`/products/${context.productId}/sprint`)
revalidatePath(`/products/${context.productId}`)
return { ok: true, task: { ...task, status: task.status.toString() } }
}
if (!context.storyId) {
return { ok: false, code: 422, error: 'validation', fieldErrors: { storyId: ['Verplicht'] } }
}
const story = await prisma.story.findFirst({
where: { id: context.storyId, product: scope },
select: { sprint_id: true },
})
if (!story) return { ok: false, code: 403, error: 'forbidden' }
const last = await prisma.task.findFirst({
where: { story_id: context.storyId },
orderBy: { sort_order: 'desc' },
select: { sort_order: true },
})
const task = await prisma.task.create({
data: {
story_id: context.storyId,
sprint_id: story.sprint_id ?? null,
title,
description: description ?? null,
implementation_plan: implementation_plan ?? null,
priority,
sort_order: (last?.sort_order ?? 0) + 1.0,
status: 'TO_DO',
},
select: { id: true, title: true, status: true },
})
revalidatePath(`/products/${context.productId}/sprint`)
revalidatePath(`/products/${context.productId}`)
return { ok: true, task: { ...task, status: task.status.toString() } }
} catch {
return { ok: false, code: 500, error: 'server_error' }
}
}
// Delete action used by TaskDialog (context-aware revalidation).
export async function deleteTask(
taskId: string,
context: { productId: string },
): Promise<DeleteTaskResult> {
const session = await getSession()
if (!session.userId) return { ok: false, code: 403, error: 'forbidden' }
if (session.isDemo) return { ok: false, code: 403, error: 'demo_readonly' }
try {
const task = await prisma.task.findFirst({
where: { id: taskId, story: { product: productAccessFilter(session.userId) } },
})
if (!task) return { ok: false, code: 403, error: 'forbidden' }
await prisma.task.delete({ where: { id: taskId } })
revalidatePath(`/products/${context.productId}/sprint`)
revalidatePath(`/products/${context.productId}`)
return { ok: true }
} catch {
return { ok: false, code: 500, error: 'server_error' }
}
}
const taskSchema = z.object({ const taskSchema = z.object({
title: z.string().min(1, 'Titel is verplicht').max(200), title: z.string().min(1, 'Titel is verplicht').max(200),
description: z.string().max(1000).optional(), description: z.string().max(1000).optional(),
@ -99,7 +234,7 @@ export async function updateTaskStatusAction(id: string, status: 'TO_DO' | 'IN_P
}) })
if (!task) return { error: 'Taak niet gevonden' } if (!task) return { error: 'Taak niet gevonden' }
await prisma.task.update({ where: { id }, data: { status } }) await updateTaskStatusWithStoryPromotion(id, status)
// /solo bewust niet revalideren: dat zou de page soft-navigaten en de // /solo bewust niet revalideren: dat zou de page soft-navigaten en de
// open SSE-stream sluiten. De Solo Paneel-flow leunt op optimistic // open SSE-stream sluiten. De Solo Paneel-flow leunt op optimistic

View file

@ -1,3 +1,4 @@
import { Suspense } from 'react'
import { notFound, redirect } from 'next/navigation' import { notFound, redirect } from 'next/navigation'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { getAccessibleProduct } from '@/lib/product-access' import { getAccessibleProduct } from '@/lib/product-access'
@ -6,14 +7,24 @@ import { SprintBoardClient } from '@/components/sprint/sprint-board-client'
import { SprintHeader } from '@/components/sprint/sprint-header' import { SprintHeader } from '@/components/sprint/sprint-header'
import type { SprintStory, PbiWithStories, ProductMember } from '@/components/sprint/sprint-backlog' import type { SprintStory, PbiWithStories, ProductMember } from '@/components/sprint/sprint-backlog'
import type { Task } from '@/components/sprint/task-list' import type { Task } from '@/components/sprint/task-list'
import { TaskDialog } from '@/app/_components/tasks/task-dialog'
import { EditTaskLoader } from '@/app/_components/tasks/edit-task-loader'
import { TaskDialogSkeleton } from '@/app/_components/tasks/task-dialog-skeleton'
import Link from 'next/link' import Link from 'next/link'
interface Props { interface Props {
params: Promise<{ id: string }> params: Promise<{ id: string }>
searchParams: Promise<{
newTask?: string
storyId?: string
editTask?: string
}>
} }
export default async function SprintBoardPage({ params }: Props) { export default async function SprintBoardPage({ params, searchParams }: Props) {
const { id } = await params const { id } = await params
const { newTask, storyId: storyIdParam, editTask } = await searchParams
const session = await getSession() const session = await getSession()
if (!session.userId) redirect('/login') if (!session.userId) redirect('/login')
@ -104,6 +115,7 @@ export default async function SprintBoardPage({ params }: Props) {
const sprintStoryIdList = sprintStories.map(s => s.id) const sprintStoryIdList = sprintStories.map(s => s.id)
const isDemo = session.isDemo ?? false const isDemo = session.isDemo ?? false
const closePath = `/products/${id}/sprint`
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
@ -134,6 +146,27 @@ export default async function SprintBoardPage({ params }: Props) {
Product Backlog Product Backlog
</Link> </Link>
</div> </div>
{newTask && (
<TaskDialog
storyId={storyIdParam}
productId={id}
closePath={closePath}
isDemo={isDemo}
/>
)}
{editTask && !newTask && (
<Suspense fallback={<TaskDialogSkeleton />}>
<EditTaskLoader
taskId={editTask}
userId={session.userId}
productId={id}
closePath={closePath}
isDemo={isDemo}
/>
</Suspense>
)}
</div> </div>
) )
} }

View file

@ -0,0 +1,47 @@
import { redirect } from 'next/navigation'
import { prisma } from '@/lib/prisma'
import { productAccessFilter } from '@/lib/product-access'
import { TaskDialog } from './task-dialog'
interface EditTaskLoaderProps {
taskId: string
userId: string
productId: string
closePath: string
isDemo: boolean
}
export async function EditTaskLoader({
taskId,
userId,
productId,
closePath,
isDemo,
}: EditTaskLoaderProps) {
const task = await prisma.task.findFirst({
where: {
id: taskId,
story: { product: productAccessFilter(userId) },
},
select: {
id: true,
title: true,
description: true,
implementation_plan: true,
priority: true,
status: true,
created_at: true,
},
})
if (!task) redirect(closePath)
return (
<TaskDialog
task={task}
productId={productId}
closePath={closePath}
isDemo={isDemo}
/>
)
}

View file

@ -0,0 +1,56 @@
'use client'
import { cn } from '@/lib/utils'
const PRIORITIES = [
{
value: 1,
label: 'P1 Critical',
selected: 'bg-error-container text-on-error-container border-transparent',
},
{
value: 2,
label: 'P2 High',
selected: 'bg-priority-high/15 text-priority-high border-priority-high/30',
},
{
value: 3,
label: 'P3 Medium',
selected: 'bg-primary text-primary-foreground border-transparent',
},
{
value: 4,
label: 'P4 Low',
selected: 'bg-muted text-foreground border-border',
},
]
interface PrioritySegmentedProps {
value: number
onChange: (value: number) => void
disabled?: boolean
}
export function PrioritySegmented({ value, onChange, disabled }: PrioritySegmentedProps) {
return (
<div className="flex gap-1 flex-wrap" role="group" aria-label="Prioriteit">
{PRIORITIES.map(p => (
<button
key={p.value}
type="button"
onClick={() => !disabled && onChange(p.value)}
aria-pressed={value === p.value}
disabled={disabled}
className={cn(
'rounded-lg border px-3 py-1.5 text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed',
value === p.value
? cn('font-medium', p.selected)
: 'border-border text-muted-foreground hover:bg-muted hover:text-foreground',
)}
>
{p.label}
</button>
))}
</div>
)
}

View file

@ -0,0 +1,55 @@
'use client'
import type { TaskStatus } from '@prisma/client'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from '@/components/ui/select'
import { cn } from '@/lib/utils'
const STATUS_CONFIG: Record<TaskStatus, { label: string; dot: string }> = {
TO_DO: { label: 'To Do', dot: 'bg-muted-foreground' },
IN_PROGRESS: { label: 'Bezig', dot: 'bg-status-in-progress' },
REVIEW: { label: 'Review', dot: 'bg-status-review' },
DONE: { label: 'Klaar', dot: 'bg-status-done' },
}
const STATUS_ORDER: TaskStatus[] = ['TO_DO', 'IN_PROGRESS', 'REVIEW', 'DONE']
function StatusIndicator({ status }: { status: TaskStatus }) {
return (
<span className="flex items-center gap-2">
<span className={cn('size-2.5 rounded-full shrink-0', STATUS_CONFIG[status].dot)} />
{STATUS_CONFIG[status].label}
</span>
)
}
interface StatusSelectProps {
value?: TaskStatus
onChange: (value: TaskStatus) => void
disabled?: boolean
}
export function StatusSelect({ value = 'TO_DO', onChange, disabled }: StatusSelectProps) {
return (
<Select
value={value}
onValueChange={(v) => onChange(v as TaskStatus)}
disabled={disabled}
>
<SelectTrigger className="w-48">
<StatusIndicator status={value} />
</SelectTrigger>
<SelectContent>
{STATUS_ORDER.map(status => (
<SelectItem key={status} value={status}>
<StatusIndicator status={status} />
</SelectItem>
))}
</SelectContent>
</Select>
)
}

View file

@ -0,0 +1,42 @@
import { Skeleton } from '@/components/ui/skeleton'
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
import { cn } from '@/lib/utils'
export function TaskDialogSkeleton() {
return (
<Dialog open>
<DialogContent
showCloseButton={false}
className={cn(
'flex flex-col p-0 gap-0',
'max-h-[90vh] w-full max-w-[calc(100%-2rem)]',
'sm:max-w-[90vw] sm:max-h-[85vh]',
'lg:max-w-[50vw] lg:min-w-[480px]',
'[animation-delay:200ms] [animation-fill-mode:backwards]',
)}
>
<DialogTitle className="sr-only">Taak laden</DialogTitle>
{/* Header */}
<div className="px-6 pt-5 pb-4 border-b border-outline-variant shrink-0">
<Skeleton className="h-7 w-40" />
</div>
{/* Body — 3 bars mimicking title + description + plan */}
<div className="flex-1 px-6 py-6 space-y-6">
<Skeleton className="h-14 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-32 w-full" />
</div>
{/* Footer */}
<div className="border-t border-outline-variant px-6 py-4 shrink-0">
<div className="flex justify-end gap-2">
<Skeleton className="h-8 w-24" />
<Skeleton className="h-8 w-24" />
</div>
</div>
</DialogContent>
</Dialog>
)
}

View file

@ -0,0 +1,424 @@
'use client'
import { useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import TextareaAutosize from 'react-textarea-autosize'
import { toast } from 'sonner'
import { Loader2 } from 'lucide-react'
import type { TaskStatus } from '@prisma/client'
import { taskSchema, type TaskInput } from '@/lib/schemas/task'
import { saveTask, deleteTask } from '@/actions/tasks'
import {
Dialog,
DialogContent,
DialogTitle,
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogAction,
AlertDialogCancel,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
import { PrioritySegmented } from './priority-segmented'
import { StatusSelect } from './status-select'
import { cn } from '@/lib/utils'
export interface TaskDialogTask {
id: string
title: string
description: string | null
implementation_plan: string | null
priority: number
status: TaskStatus
created_at: Date
}
interface TaskDialogProps {
task?: TaskDialogTask
storyId?: string
productId: string
closePath: string
isDemo?: boolean
}
function CharCount({ value, max }: { value: string; max: number }) {
const len = (value ?? '').length
if (len < Math.floor(max * 0.75)) return null
return (
<span className="text-xs text-muted-foreground text-right block mt-1">
{len} / {max}
</span>
)
}
const textareaClass = cn(
'flex w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-sm',
'transition-colors outline-none placeholder:text-muted-foreground resize-none',
'focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50',
'overflow-y-auto',
)
export function TaskDialog({ task, storyId, productId, closePath, isDemo = false }: TaskDialogProps) {
const router = useRouter()
const [isPending, startTransition] = useTransition()
const [confirmClose, setConfirmClose] = useState(false)
const [confirmDelete, setConfirmDelete] = useState(false)
const isEdit = !!task
const form = useForm<TaskInput>({
resolver: zodResolver(taskSchema),
mode: 'onTouched',
defaultValues: {
title: task?.title ?? '',
description: task?.description ?? '',
implementation_plan: task?.implementation_plan ?? '',
priority: task?.priority ?? 3,
status: task?.status,
},
})
function handleClose() {
router.push(closePath)
}
function handleAttemptClose() {
if (form.formState.isDirty) {
setConfirmClose(true)
} else {
handleClose()
}
}
function handleKeyDown(e: React.KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault()
form.handleSubmit(onSubmit)()
}
}
function onSubmit(data: TaskInput) {
startTransition(async () => {
const result = await saveTask(data, {
taskId: task?.id,
storyId,
productId,
})
if (result.ok) {
toast.success(isEdit ? 'Taak opgeslagen' : 'Taak aangemaakt')
router.push(closePath)
return
}
if (result.code === 422 && result.error === 'validation') {
for (const [field, errors] of Object.entries(result.fieldErrors)) {
form.setError(field as keyof TaskInput, { message: errors[0] })
}
const firstError = Object.keys(result.fieldErrors)[0] as keyof TaskInput
form.setFocus(firstError)
return
}
if (result.code === 403) {
toast.error(
result.error === 'demo_readonly'
? 'Demo-modus: opslaan uitgeschakeld'
: 'Geen toegang',
)
return
}
toast.error('Er ging iets mis. Probeer het opnieuw.', {
action: { label: 'Opnieuw', onClick: () => form.handleSubmit(onSubmit)() },
})
})
}
function handleDelete() {
if (!task) return
setConfirmDelete(false)
startTransition(async () => {
const result = await deleteTask(task.id, { productId })
if (result.ok) {
toast.success('Taak verwijderd')
router.push(closePath)
return
}
if (result.code === 403) {
toast.error(
result.error === 'demo_readonly'
? 'Demo-modus: verwijderen uitgeschakeld'
: 'Geen toegang',
)
return
}
toast.error('Verwijderen mislukt')
})
}
return (
<>
<Dialog open onOpenChange={(open) => { if (!open) handleAttemptClose() }}>
<DialogContent
showCloseButton={false}
onKeyDown={handleKeyDown}
className={cn(
'flex flex-col p-0 gap-0',
'max-h-[90vh] w-full max-w-[calc(100%-2rem)]',
'sm:max-w-[90vw] sm:max-h-[85vh]',
'lg:max-w-[50vw] lg:min-w-[480px]',
)}
>
{/* Sticky header */}
<div className="flex items-center justify-between px-6 pt-5 pb-4 border-b border-outline-variant shrink-0">
<DialogTitle className="text-xl font-semibold">
{isEdit ? 'Taak bewerken' : 'Nieuwe taak'}
</DialogTitle>
{isEdit && (
<span className="text-xs text-muted-foreground">
Aangemaakt:{' '}
{new Intl.DateTimeFormat('nl-NL', {
day: 'numeric',
month: 'short',
year: 'numeric',
}).format(new Date(task.created_at))}
</span>
)}
</div>
{/* Scrollable form body */}
<div className="flex-1 overflow-y-auto px-6 py-6 space-y-6">
{/* Title */}
<div>
<label className="text-sm font-medium mb-2 block">
Titel <span className="text-destructive">*</span>
</label>
<Input
{...form.register('title')}
aria-invalid={!!form.formState.errors.title}
autoFocus
placeholder="Taaknaam..."
className="h-14"
onKeyDown={(e) => { if (e.key === 'Enter') e.preventDefault() }}
/>
{form.formState.errors.title && (
<p className="text-xs text-destructive mt-1">
{form.formState.errors.title.message}
</p>
)}
</div>
{/* Description */}
<div>
<label className="text-sm font-medium mb-2 block">Omschrijving</label>
<Controller
control={form.control}
name="description"
render={({ field }) => (
<>
<TextareaAutosize
{...field}
value={field.value ?? ''}
aria-invalid={!!form.formState.errors.description}
minRows={3}
maxRows={6}
placeholder="Optionele omschrijving..."
className={cn(
textareaClass,
form.formState.errors.description &&
'border-destructive ring-3 ring-destructive/20',
)}
/>
<CharCount value={field.value ?? ''} max={2000} />
<p className="text-xs text-muted-foreground mt-1">
Markdown ondersteund (lijstjes, **vet**, `code`)
</p>
</>
)}
/>
{form.formState.errors.description && (
<p className="text-xs text-destructive mt-1">
{form.formState.errors.description.message}
</p>
)}
</div>
{/* Implementation plan */}
<div>
<label className="text-sm font-medium mb-2 block">Implementatieplan</label>
<Controller
control={form.control}
name="implementation_plan"
render={({ field }) => (
<>
<TextareaAutosize
{...field}
value={field.value ?? ''}
aria-invalid={!!form.formState.errors.implementation_plan}
minRows={5}
maxRows={12}
placeholder="Optioneel implementatieplan..."
className={cn(
textareaClass,
form.formState.errors.implementation_plan &&
'border-destructive ring-3 ring-destructive/20',
)}
/>
<CharCount value={field.value ?? ''} max={10000} />
<p className="text-xs text-muted-foreground mt-1">
Markdown ondersteund (lijstjes, **vet**, `code`)
</p>
</>
)}
/>
{form.formState.errors.implementation_plan && (
<p className="text-xs text-destructive mt-1">
{form.formState.errors.implementation_plan.message}
</p>
)}
</div>
{/* Priority */}
<div>
<label className="text-sm font-medium mb-2 block">Prioriteit</label>
<Controller
control={form.control}
name="priority"
render={({ field }) => (
<PrioritySegmented
value={field.value}
onChange={field.onChange}
disabled={isPending}
/>
)}
/>
</div>
{/* Status — edit only */}
{isEdit && (
<div>
<label className="text-sm font-medium mb-2 block">Status</label>
<Controller
control={form.control}
name="status"
render={({ field }) => (
<StatusSelect
value={field.value}
onChange={field.onChange}
disabled={isPending}
/>
)}
/>
</div>
)}
</div>
{/* Sticky footer */}
<div className="border-t border-outline-variant px-6 py-4 shrink-0">
<div className="flex items-center justify-between gap-2">
{isEdit ? (
<DemoTooltip show={isDemo}>
<Button
type="button"
variant="destructive"
disabled={isPending || isDemo}
onClick={() => setConfirmDelete(true)}
>
Verwijderen
</Button>
</DemoTooltip>
) : (
<div />
)}
<div className="flex gap-2">
<Button
type="button"
variant="ghost"
onClick={handleAttemptClose}
disabled={isPending}
>
Annuleren
</Button>
<DemoTooltip show={isDemo}>
<Button
type="button"
disabled={isPending || isDemo}
onClick={form.handleSubmit(onSubmit)}
>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
{isEdit ? 'Opslaan...' : 'Aanmaken...'}
</>
) : isEdit ? (
'Opslaan'
) : (
'Aanmaken'
)}
</Button>
</DemoTooltip>
</div>
</div>
</div>
</DialogContent>
</Dialog>
{/* Dirty-check confirm */}
<AlertDialog open={confirmClose} onOpenChange={setConfirmClose}>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Wijzigingen niet opgeslagen</AlertDialogTitle>
<AlertDialogDescription>
Wil je de wijzigingen weggooien?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setConfirmClose(false)}>
Terug
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => { setConfirmClose(false); handleClose() }}
>
Weggooien
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Delete confirm */}
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Taak verwijderen</AlertDialogTitle>
<AlertDialogDescription>
Weet je zeker dat je deze taak wilt verwijderen? Dit kan niet ongedaan worden gemaakt.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setConfirmDelete(false)}>
Annuleren
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={isPending}
onClick={handleDelete}
>
{isPending ? <Loader2 className="size-4 animate-spin" /> : 'Verwijderen'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View file

@ -2,6 +2,7 @@ import { authenticateApiRequest } from '@/lib/api-auth'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { z } from 'zod' import { z } from 'zod'
import { TASK_STATUS_API_VALUES, taskStatusFromApi, taskStatusToApi } from '@/lib/task-status' import { TASK_STATUS_API_VALUES, taskStatusFromApi, taskStatusToApi } from '@/lib/task-status'
import { updateTaskStatusWithStoryPromotion } from '@/lib/tasks-status-update'
// `review` is a valid TaskStatus in the DB and the kanban-board UI, but the // `review` is a valid TaskStatus in the DB and the kanban-board UI, but the
// sprint task list (components/sprint/task-list.tsx) does not yet render it. // sprint task list (components/sprint/task-list.tsx) does not yet render it.
@ -82,14 +83,28 @@ export async function PATCH(
} }
} }
const updated = await prisma.task.update({ const updated = await prisma.$transaction(async (tx) => {
where: { id }, const planUpdate = parsed.data.implementation_plan !== undefined
data: { ? await tx.task.update({
...(dbStatus !== undefined && dbStatus !== null && { status: dbStatus }), where: { id },
...(parsed.data.implementation_plan !== undefined && { data: { implementation_plan: parsed.data.implementation_plan },
implementation_plan: parsed.data.implementation_plan, select: { id: true, status: true, implementation_plan: true },
}), })
}, : null
if (dbStatus !== undefined && dbStatus !== null) {
const result = await updateTaskStatusWithStoryPromotion(id, dbStatus, tx)
return {
id: result.task.id,
status: result.task.status,
implementation_plan: result.task.implementation_plan,
}
}
if (planUpdate) return planUpdate
// Should not reach here — patchSchema rejects bodies without status or implementation_plan.
throw new Error('Geen wijzigingen')
}) })
return Response.json({ return Response.json({

View file

@ -1,4 +1,5 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
@plugin "@tailwindcss/typography";
@import "./styles/theme.css"; @import "./styles/theme.css";

View file

@ -73,9 +73,16 @@
--switch-background: #79767d; --switch-background: #79767d;
--ring: var(--primary); --ring: var(--primary);
/* MD3 Outline Variant */
--outline-variant: #c5c6d0;
/* MD3 On-Error-Container */
--on-error-container: #410002;
/* Project Management Specific Colors */ /* Project Management Specific Colors */
--status-todo: #6750a4; --status-todo: #6750a4;
--status-in-progress: #0061a4; --status-in-progress: #0061a4;
--status-review: #7b5ea7;
--status-done: #006e1c; --status-done: #006e1c;
--status-blocked: #ba1a1a; --status-blocked: #ba1a1a;
@ -177,9 +184,16 @@
--switch-background: #898790; --switch-background: #898790;
--ring: var(--primary); --ring: var(--primary);
/* MD3 Outline Variant */
--outline-variant: #45464f;
/* MD3 On-Error-Container */
--on-error-container: #ffdad6;
/* Project Management Specific Colors */ /* Project Management Specific Colors */
--status-todo: #cfbdfe; --status-todo: #cfbdfe;
--status-in-progress: #9fcbfa; --status-in-progress: #9fcbfa;
--status-review: #c9b6ef;
--status-done: #77db77; --status-done: #77db77;
--status-blocked: #ffb4ab; --status-blocked: #ffb4ab;
@ -256,6 +270,7 @@
--color-error-foreground: var(--error-foreground); --color-error-foreground: var(--error-foreground);
--color-error-container: var(--error-container); --color-error-container: var(--error-container);
--color-error-container-foreground: var(--error-container-foreground); --color-error-container-foreground: var(--error-container-foreground);
--color-on-error-container: var(--on-error-container);
--color-info: var(--info); --color-info: var(--info);
--color-info-foreground: var(--info-foreground); --color-info-foreground: var(--info-foreground);
@ -273,6 +288,7 @@
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive); --color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground); --color-destructive-foreground: var(--destructive-foreground);
--color-outline-variant: var(--outline-variant);
--color-border: var(--border); --color-border: var(--border);
--color-input: var(--input); --color-input: var(--input);
--color-input-background: var(--input-background); --color-input-background: var(--input-background);
@ -282,6 +298,7 @@
/* Project management colors */ /* Project management colors */
--color-status-todo: var(--status-todo); --color-status-todo: var(--status-todo);
--color-status-in-progress: var(--status-in-progress); --color-status-in-progress: var(--status-in-progress);
--color-status-review: var(--status-review);
--color-status-done: var(--status-done); --color-status-done: var(--status-done);
--color-status-blocked: var(--status-blocked); --color-status-blocked: var(--status-blocked);

View file

@ -1,6 +1,7 @@
'use client' 'use client'
import { useEffect, useRef, useState, useTransition } from 'react' import { useEffect, useRef, useState, useTransition } from 'react'
import { Markdown } from '@/components/markdown'
import { useActionState } from 'react' import { useActionState } from 'react'
import { useFormStatus } from 'react-dom' import { useFormStatus } from 'react-dom'
import { toast } from 'sonner' import { toast } from 'sonner'
@ -231,7 +232,7 @@ export function StoryDialog({ state, onClose, isDemo = false }: StoryDialogProps
{story?.description && ( {story?.description && (
<div> <div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">Omschrijving</p> <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">Omschrijving</p>
<p className="text-sm">{story.description}</p> <Markdown>{story.description}</Markdown>
</div> </div>
)} )}
{story?.acceptance_criteria && ( {story?.acceptance_criteria && (

View file

@ -0,0 +1,58 @@
'use client'
import { useState } from 'react'
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogAction,
AlertDialogCancel,
} from '@/components/ui/alert-dialog'
interface DirtyCloseGuardProps {
isDirty: boolean
onConfirm: () => void
children: (attemptClose: () => void) => React.ReactNode
}
export function DirtyCloseGuard({ isDirty, onConfirm, children }: DirtyCloseGuardProps) {
const [open, setOpen] = useState(false)
function attemptClose() {
if (isDirty) {
setOpen(true)
} else {
onConfirm()
}
}
return (
<>
{children(attemptClose)}
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Wijzigingen niet opgeslagen</AlertDialogTitle>
<AlertDialogDescription>
Wil je de wijzigingen weggooien?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setOpen(false)}>
Blijven
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => { setOpen(false); onConfirm() }}
>
Weggooien
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

21
components/markdown.tsx Normal file
View file

@ -0,0 +1,21 @@
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { cn } from '@/lib/utils'
interface MarkdownProps {
children: string
className?: string
}
export function Markdown({ children, className }: MarkdownProps) {
return (
<div className={cn('prose prose-sm dark:prose-invert max-w-none', className)}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
disallowedElements={['script', 'iframe']}
>
{children}
</ReactMarkdown>
</div>
)
}

View file

@ -3,6 +3,7 @@
import { useRef, useState, useTransition } from 'react' import { useRef, useState, useTransition } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Markdown } from '@/components/markdown'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@ -132,7 +133,7 @@ function TaskDetailContent({ task, productId, isDemo, onClose }: TaskDetailConte
{task.description && ( {task.description && (
<div> <div>
<p className="text-xs font-medium text-muted-foreground mb-1.5">Beschrijving</p> <p className="text-xs font-medium text-muted-foreground mb-1.5">Beschrijving</p>
<p className="text-sm text-foreground whitespace-pre-wrap">{task.description}</p> <Markdown className="text-foreground">{task.description}</Markdown>
</div> </div>
)} )}

View file

@ -1,7 +1,7 @@
'use client' 'use client'
import { useState, useTransition, useEffect, useActionState } from 'react' import { useState, useTransition, useEffect } from 'react'
import { useFormStatus } from 'react-dom' import { useRouter, usePathname } from 'next/navigation'
import { import {
DndContext, DragEndEvent, DragOverlay, DndContext, DragEndEvent, DragOverlay,
KeyboardSensor, PointerSensor, useSensor, useSensors, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, closestCenter,
@ -13,17 +13,13 @@ import {
import { CSS } from '@dnd-kit/utilities' import { CSS } from '@dnd-kit/utilities'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { CodeBadge } from '@/components/shared/code-badge' import { CodeBadge } from '@/components/shared/code-badge'
import { PanelNavBar } from '@/components/shared/panel-nav-bar' import { PanelNavBar } from '@/components/shared/panel-nav-bar'
import { PRIORITY_BORDER } from '@/components/backlog/backlog-card' import { PRIORITY_BORDER } from '@/components/backlog/backlog-card'
import { deriveTaskCode } from '@/lib/code' import { deriveTaskCode } from '@/lib/code'
import { useSprintStore } from '@/stores/sprint-store' import { useSprintStore } from '@/stores/sprint-store'
import { import { updateTaskStatusAction, reorderTasksAction } from '@/actions/tasks'
createTaskAction, updateTaskStatusAction, updateTaskAction,
deleteTaskAction, reorderTasksAction,
} from '@/actions/tasks'
import { DemoTooltip } from '@/components/shared/demo-tooltip' import { DemoTooltip } from '@/components/shared/demo-tooltip'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -60,69 +56,69 @@ interface TaskListProps {
} }
function SortableTaskRow({ function SortableTaskRow({
task, code, isDemo, onStatusToggle, onDelete, task, code, isDemo, onStatusToggle, onEdit,
}: { task: Task; code: string | null; isDemo: boolean; onStatusToggle: () => void; onDelete: () => void }) { }: {
const [editing, setEditing] = useState(false) task: Task
code: string | null
isDemo: boolean
onStatusToggle: () => void
onEdit: () => void
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id }) const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id })
const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.4 : 1 } const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.4 : 1 }
const [, formAction] = useActionState(
async (_prev: unknown, fd: FormData) => {
const result = await updateTaskAction(_prev, fd)
if (result?.success) setEditing(false)
return result
},
undefined
)
if (editing) {
return (
<div ref={setNodeRef} style={style} className="px-2 py-1">
<div className={cn('rounded border border-border px-3 py-2 bg-surface-container', PRIORITY_BORDER[task.priority])}>
<form action={formAction} className="space-y-2">
<input type="hidden" name="id" value={task.id} />
<input type="hidden" name="priority" value={task.priority} />
<Input name="title" defaultValue={task.title} className="h-7 text-sm" required autoFocus />
<div className="flex gap-2">
<EditSubmitButton />
<Button type="button" variant="ghost" size="sm" className="h-7" onClick={() => setEditing(false)}>Annuleren</Button>
</div>
</form>
</div>
</div>
)
}
return ( return (
<div ref={setNodeRef} style={style} className="group px-2 py-1"> <div ref={setNodeRef} style={style} className="group px-2 py-1">
<div className={cn( <div
'flex items-start gap-2 rounded border border-border px-3 py-2 transition-colors bg-surface-container hover:bg-surface-container-high', className={cn(
PRIORITY_BORDER[task.priority] 'flex items-start gap-2 rounded border border-border px-3 py-2 transition-colors bg-surface-container hover:bg-surface-container-high cursor-pointer',
)}> PRIORITY_BORDER[task.priority],
)}
onClick={() => onEdit()}
role="button"
tabIndex={0}
aria-label={`Bewerk taak: ${task.title}`}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onEdit()
}
}}
>
{!isDemo && ( {!isDemo && (
<span {...attributes} {...listeners} className="text-muted-foreground cursor-grab active:cursor-grabbing shrink-0 text-sm select-none mt-0.5" aria-hidden="true"></span> <span
{...attributes}
{...listeners}
onClick={(e) => e.stopPropagation()}
className="text-muted-foreground cursor-grab active:cursor-grabbing shrink-0 text-sm select-none mt-0.5"
aria-hidden="true"
>
</span>
)} )}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<p className={cn('text-sm leading-snug line-clamp-2 flex-1', task.status === 'DONE' && 'line-through text-muted-foreground')}> <p className={cn(
'text-sm leading-snug line-clamp-2 flex-1',
task.status === 'DONE' && 'line-through text-muted-foreground',
)}>
{task.title} {task.title}
</p> </p>
{code && <CodeBadge code={code} className="shrink-0 mt-0.5" />} {code && <CodeBadge code={code} className="shrink-0 mt-0.5" />}
</div> </div>
<div className="flex items-center justify-between gap-2 mt-1.5"> <div className="mt-1.5">
<button onClick={onStatusToggle} disabled={isDemo} aria-label={`Status: ${STATUS_LABELS[task.status]}`}> <button
<Badge className={cn('text-[10px] px-1.5 py-0 border cursor-pointer hover:opacity-80 transition-opacity', STATUS_COLORS[task.status])}> onClick={(e) => { e.stopPropagation(); onStatusToggle() }}
disabled={isDemo}
aria-label={`Status: ${STATUS_LABELS[task.status]}`}
>
<Badge className={cn(
'text-[10px] px-1.5 py-0 border cursor-pointer hover:opacity-80 transition-opacity',
STATUS_COLORS[task.status],
)}>
{STATUS_LABELS[task.status]} {STATUS_LABELS[task.status]}
</Badge> </Badge>
</button> </button>
<div className="opacity-0 group-hover:opacity-100 flex gap-1 shrink-0">
<DemoTooltip show={isDemo}>
<button onClick={() => !isDemo && setEditing(true)} disabled={isDemo} className="text-xs text-muted-foreground hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed">Bewerk</button>
</DemoTooltip>
<DemoTooltip show={isDemo}>
<button onClick={() => !isDemo && onDelete()} disabled={isDemo} aria-label="Verwijder taak" className="text-xs text-muted-foreground hover:text-error disabled:opacity-40 disabled:cursor-not-allowed">×</button>
</DemoTooltip>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -130,48 +126,12 @@ function SortableTaskRow({
) )
} }
function EditSubmitButton() { export function TaskList({ storyId, storyCode, sprintId: _sprintId, productId: _productId, tasks, isDemo }: TaskListProps) {
const { pending } = useFormStatus()
return <Button type="submit" size="sm" className="h-7" disabled={pending}>{pending ? '…' : 'Opslaan'}</Button>
}
function CreateTaskForm({ storyId, sprintId, onDone }: { storyId: string; sprintId: string; onDone: () => void }) {
const [state, formAction] = useActionState(
async (_prev: unknown, fd: FormData) => {
const result = await createTaskAction(_prev, fd)
if (result?.success) { onDone(); return result }
if (result?.error) toast.error(typeof result.error === 'string' ? result.error : 'Aanmaken mislukt')
return result
},
undefined
)
return (
<form action={formAction} className="flex flex-col gap-1.5 px-4 py-2 border-b border-border">
<input type="hidden" name="storyId" value={storyId} />
<input type="hidden" name="sprintId" value={sprintId} />
<input type="hidden" name="priority" value="2" />
<div className="flex gap-2">
<Input name="title" autoFocus placeholder="Taaknaam…" className="h-7 text-sm flex-1" required />
<CreateSubmitButton />
<Button type="button" variant="ghost" size="sm" className="h-7" aria-label="Annuleer" onClick={onDone}>×</Button>
</div>
{state && 'error' in state && typeof state.error === 'string' && (
<p className="text-xs text-destructive">{state.error}</p>
)}
</form>
)
}
function CreateSubmitButton() {
const { pending } = useFormStatus()
return <Button type="submit" size="sm" className="h-7" disabled={pending}>{pending ? '…' : 'Toevoegen'}</Button>
}
export function TaskList({ storyId, storyCode, sprintId, productId: _productId, tasks, isDemo }: TaskListProps) {
const { taskOrder, initTasks, reorderTasks, rollbackTasks } = useSprintStore() const { taskOrder, initTasks, reorderTasks, rollbackTasks } = useSprintStore()
const [creating, setCreating] = useState(false)
const [activeDragId, setActiveDragId] = useState<string | null>(null) const [activeDragId, setActiveDragId] = useState<string | null>(null)
const [, startTransition] = useTransition() const [, startTransition] = useTransition()
const router = useRouter()
const pathname = usePathname()
const idKey = tasks.map(t => t.id).join(',') const idKey = tasks.map(t => t.id).join(',')
useEffect(() => { useEffect(() => {
@ -187,7 +147,7 @@ export function TaskList({ storyId, storyCode, sprintId, productId: _productId,
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
) )
function handleDragEnd(event: DragEndEvent) { function handleDragEnd(event: DragEndEvent) {
@ -209,11 +169,12 @@ export function TaskList({ storyId, storyCode, sprintId, productId: _productId,
}) })
} }
function handleDelete(id: string) { function openCreateDialog() {
startTransition(async () => { router.push(`${pathname}?newTask=1&storyId=${storyId}`)
const result = await deleteTaskAction(id) }
if (result && 'error' in result) toast.error(result.error ?? 'Verwijderen mislukt')
}) function openEditDialog(taskId: string) {
router.push(`${pathname}?editTask=${taskId}`)
} }
return ( return (
@ -224,22 +185,32 @@ export function TaskList({ storyId, storyCode, sprintId, productId: _productId,
<> <>
<span className="text-xs text-muted-foreground">{doneCount}/{orderedTasks.length} klaar</span> <span className="text-xs text-muted-foreground">{doneCount}/{orderedTasks.length} klaar</span>
<DemoTooltip show={isDemo}> <DemoTooltip show={isDemo}>
<Button size="sm" className="h-7 text-xs" disabled={isDemo} onClick={() => !isDemo && setCreating(true)}>+ Taak</Button> <Button
size="sm"
className="h-7 text-xs"
disabled={isDemo}
onClick={() => !isDemo && openCreateDialog()}
>
+ Taak
</Button>
</DemoTooltip> </DemoTooltip>
</> </>
} }
/> />
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{creating && ( {orderedTasks.length === 0 ? (
<CreateTaskForm storyId={storyId} sprintId={sprintId} onDone={() => setCreating(false)} />
)}
{orderedTasks.length === 0 && !creating ? (
<div className="text-center mt-8 space-y-3"> <div className="text-center mt-8 space-y-3">
<p className="text-sm text-muted-foreground">Geen taken voor deze story.</p> <p className="text-sm text-muted-foreground">Geen taken voor deze story.</p>
<DemoTooltip show={isDemo}> <DemoTooltip show={isDemo}>
<Button size="sm" variant="outline" disabled={isDemo} onClick={() => !isDemo && setCreating(true)}>Maak eerste taak aan</Button> <Button
size="sm"
variant="outline"
disabled={isDemo}
onClick={() => !isDemo && openCreateDialog()}
>
Maak eerste taak aan
</Button>
</DemoTooltip> </DemoTooltip>
</div> </div>
) : ( ) : (
@ -258,7 +229,7 @@ export function TaskList({ storyId, storyCode, sprintId, productId: _productId,
code={deriveTaskCode(storyCode, idx + 1)} code={deriveTaskCode(storyCode, idx + 1)}
isDemo={isDemo} isDemo={isDemo}
onStatusToggle={() => handleStatusToggle(task)} onStatusToggle={() => handleStatusToggle(task)}
onDelete={() => handleDelete(task.id)} onEdit={() => openEditDialog(task.id)}
/> />
))} ))}
</SortableContext> </SortableContext>
@ -266,7 +237,7 @@ export function TaskList({ storyId, storyCode, sprintId, productId: _productId,
{activeDragId && taskMap[activeDragId] && ( {activeDragId && taskMap[activeDragId] && (
<div className={cn( <div className={cn(
'rounded border border-primary px-3 py-2 bg-surface-container shadow-lg opacity-90 text-sm', 'rounded border border-primary px-3 py-2 bg-surface-container shadow-lg opacity-90 text-sm',
PRIORITY_BORDER[taskMap[activeDragId].priority] PRIORITY_BORDER[taskMap[activeDragId].priority],
)}> )}>
{taskMap[activeDragId].title} {taskMap[activeDragId].title}
</div> </div>

View file

@ -157,6 +157,8 @@ Scrum4Me is een desktop-first Next.js 16 webapplicatie die server-side wordt ger
**Indexes:** `(pbi_id, priority, sort_order)`, `(sprint_id, sort_order)`, `(product_id, status)` **Indexes:** `(pbi_id, priority, sort_order)`, `(sprint_id, sort_order)`, `(product_id, status)`
**Auto-promotie/demotie via task-status:** zodra alle tasks van een story op `DONE` staan en de huidige story-status nog niet `DONE` is, promoot dezelfde transactie de story naar `DONE`. Wordt een task van een `DONE`-story uit `DONE` getrokken (heropening), dan demoot de story terug naar `IN_SPRINT` — niet naar `OPEN`, want `OPEN` betekent "terug in productbacklog" en is een sprint-management-actie. De logica zit in [lib/tasks-status-update.ts](../lib/tasks-status-update.ts) en wordt aangeroepen door alle drie de task-status-write-paden (`updateTaskStatusAction`, `saveTask` edit-mode, REST `PATCH /api/tasks/[id]`).
--- ---
### `story_logs` ### `story_logs`

View file

@ -0,0 +1,506 @@
# Scrum4Me — TaskDialog Spec
> Volledige design-spec voor de add/update task dialog van de inspannings monitor app.
> Resultaat van een grill-me sessie (15 vragen, alle beslissingen vastgelegd).
---
## Stack
- **Framework:** Next.js (App Router)
- **ORM:** Prisma
- **UI components:** shadcn/ui — wrappers rond `@base-ui/react` (zoals expliciet vastgelegd in `CLAUDE.md`)
- **Styling:** Tailwind CSS
- **Form:** react-hook-form + @hookform/resolvers/zod
- **Design language:** Material Design 3 als theming-laag (geen MUI components)
- **Theming:** `material-color-utilities` voor dynamic color, `next-themes` voor dark mode
- **Icons:** Lucide
- **Markdown rendering:** `react-markdown` + `remark-gfm`
- **Toasts:** sonner (shadcn default)
> **Composition-regel:** dit project gebruikt `@base-ui/react`, niet Radix. Composition gebeurt via de **`render`-prop**, niet via `asChild`. Zie ook `CLAUDE.md` "UI Library Conventions".
>
> ```tsx
> // ✅ goed
> <TooltipTrigger render={<button />}>...</TooltipTrigger>
> // ❌ fout — geeft TS-errors
> <TooltipTrigger asChild><button>...</button></TooltipTrigger>
> ```
> **Dialog-primitive:** bouw de TaskDialog op de bestaande wrapper in `components/ui/dialog.tsx` (shadcn rond `@base-ui/react`). **Geen** directe imports uit `@base-ui/react` voor dialog-primitives in deze feature — anders krijg je twee parallelle dialog-implementaties die uit de pas gaan lopen qua animatie, focus-trap en theming.
---
## Dependency-impact
De volgende packages staan **nog niet** in `package.json` en moeten direct als runtime-`dependencies` worden toegevoegd voordat de eerste commit van deze feature gemerged wordt (CLAUDE.md "Dependencies"-regel). Voeg ze in dezelfde change toe waarin ze geïmporteerd worden, en vermeld ze in de docs-sync.
| Package | Doel | Scope |
|---|---|---|
| `react-hook-form` | form-state management voor TaskDialog | runtime |
| `@hookform/resolvers` | zod-resolver voor `react-hook-form` | runtime |
| `react-textarea-autosize` | auto-grow textareas voor `description` / `implementation_plan` | runtime |
| `react-markdown` | markdown rendering elders in de app (taakdetail, hover-card) | runtime |
| `remark-gfm` | GFM-extensies (tabellen, taken, strikethrough) | runtime |
| `@tailwindcss/typography` | `prose`-classes voor markdown-styling | runtime (Tailwind v4 plugin) |
**Bewust niet meegenomen:**
- `material-color-utilities` — dynamic color valt buiten v1 (zie Theming hieronder).
- `nuqs` — start met **native `searchParams`**; als de URL-state-handling te omslachtig wordt, dan pas `nuqs` als losse refactor-task introduceren. Niet in deze feature mengen.
Reeds aanwezig en gebruikt: `@base-ui/react`, `next-themes`, `lucide-react`, `sonner`, `zod`, `prisma`.
---
## Component-API
Eén component `TaskDialog`, mode afgeleid uit `task?: Task` prop:
```tsx
<TaskDialog task={editTask} /> // task undefined = create mode, task aanwezig = edit mode
```
Open/close-state komt uit de URL via `nuqs` of `searchParams`. Taken leven binnen de context van een sprint of een PBI/story — er is **geen** zelfstandige `/tasks`-route:
```
/sprint/<sprintId>?newTask=1 → create-dialog open binnen sprint-context
/sprint/<sprintId>?editTask=<taskId> → edit-dialog open binnen sprint-context
/products/<productId>/backlog?newTask=1 → create-dialog open binnen backlog-context
/products/<productId>/backlog?editTask=<taskId>
```
Dialog sluit door dezelfde route opnieuw te pushen zonder de `newTask` / `editTask` query-params (bv. `router.push(\`/sprint/\${sprintId}\`)`).
---
## Velden die de dialog gebruikt
De dialog leest en schrijft uitsluitend deze velden van het `Task`-record. Het volledige datamodel valt buiten scope van deze spec.
| Veld | Type | Mode |
|---|---|---|
| `title` | `string` (required) | beide |
| `description` | `string \| null` | beide |
| `implementation_plan` | `string \| null` | beide |
| `priority` | `int` (1-4, P1 = hoogste) | beide |
| `status` | `TaskStatus` enum | alleen edit (default `TO_DO` op create, niet getoond) |
| `created_at` | `Date` | alleen edit, read-only metadata in header |
`TaskStatus` enum-waarden: `TO_DO | IN_PROGRESS | REVIEW | DONE`.
---
## Layout & responsive gedrag
| Breakpoint | Breedte | Hoogte |
|---|---|---|
| Mobiel (<640px) | full-screen | full-screen |
| Tablet (640-1024px) | `90vw` | `max-h-[85vh]` |
| Desktop (≥1024px) | `max-w-[50vw]`, `min-w-[480px]` | `max-h-[85vh]` |
- Padding: `p-6` rondom
- Veld-spacing binnen blok: `space-y-6` (24px)
- Sticky header (titel + close) en sticky footer (knoppen)
- Body scrollt als content de `max-h` overschrijdt
- Footer heeft top-border in `outline-variant` kleur
---
## Velden
In volgorde van boven naar beneden:
| Veld | Control | Mode | Validatie |
|---|---|---|---|
| `title` | `Input` (single-line) | beide | required, trim, 1-120 chars |
| `description` | `Textarea` (auto-grow, 3-6 regels) | beide | optional, max 2.000 chars, markdown |
| `implementation_plan` | `Textarea` (auto-grow, 5-12 regels) | beide | optional, max 10.000 chars, markdown |
| `priority` | Segmented buttons (P1/P2/P3/P4) | beide | int 1-4, default 3 |
| `status` | `Select` met gekleurde dot | alleen edit | enum, default TO_DO |
Verberg `status` in create-mode (default = TO_DO is genoeg).
### Auto-grow textareas
Gebruik `react-textarea-autosize`. Bereikt het veld zijn max-regels, dan `overflow-y-auto` (interne scroll). De **dialog-body** scrollt onafhankelijk; je krijgt zelden geneste scrolls.
### Karakter-counter
Alleen tonen vanaf 75% van de limiet. Klein, rechtsonder in het veld, `muted-foreground` kleur. Bv. `1547 / 2000`.
### Markdown hint
Onder elk textarea: `Markdown ondersteund (lijstjes, **vet**, \`code\`)` — klein, muted.
### Priority segmented buttons
```
[ P1 Critical ] [ P2 High ] [ P3 Medium ] [ P4 Low ]
error tertiary primary outline
```
- Lager getal = hoger prio (industriestandaard, Linear/Jira-conform)
- Default geselecteerd: P3 Medium
- Geen 0-waarde toestaan
### Status select (alleen edit)
- TO_DO — grijze dot
- IN_PROGRESS — blauwe dot
- REVIEW — paarse dot
- DONE — groene dot
### `created_at` als header-metadata
In edit-mode tonen in de dialog-header naast de titel:
```
Taak bewerken Aangemaakt: 23 apr 2026
```
Klein, `muted-foreground`, niet als form-veld.
---
## Validatie
- **Gedeeld zod-schema** in `lib/schemas/task.ts`, geïmporteerd door zowel form als server action
- react-hook-form mode: `onTouched` (eerste validatie bij blur, daarna onChange)
- Errors onder het veld, in error-color, met label en outline van het veld in dezelfde kleur
- Geen toasts voor field-level errors
- Submit-button blijft enabled bij errors — klik scrollt naar eerste error-veld + focus
```ts
// lib/schemas/task.ts (richtlijn)
export const taskSchema = z.object({
title: z.string().trim().min(1, "Verplicht").max(120),
description: z.string().max(2000).optional(),
implementation_plan: z.string().max(10000).optional(),
priority: z.number().int().min(1).max(4),
status: z.nativeEnum(TaskStatus).optional(), // alleen in edit
});
```
---
## Submission
### Auth-scoping (verplicht)
Elke server action — zowel `saveTask` als `deleteTask` — moet de operatie scope-en op de huidige user. Cross-tenant writes voorkomen via `productAccessFilter(userId)` (of het project-equivalent), zodat een user geen task kan schrijven of verwijderen die niet onder zijn product-scope valt.
> Concreet: de Prisma-mutatie staat nóóit alleen op `where: { id: taskId }`. De scope wordt verplicht gecombineerd in elke `update`/`delete`/`create`-call.
### Demo read-only enforcement (drie lagen — ST-1110)
Elke write-flow moet door deze drie lagen:
1. **Middleware-guard in `proxy.ts`** — blokkeert demo-sessies op write-routes vóór de server action überhaupt loopt. Returnt **403**.
2. **`session.isDemo`-check in de server action zelf** — defense-in-depth voor het geval een write-flow buiten een proxy-route loopt (bv. directe action-invocation). Returnt **403**.
3. **`<DemoTooltip>` op de save- en delete-knoppen** — UI-laag: knoppen zijn zichtbaar disabled met tooltip "Demo-modus: opslaan uitgeschakeld". Vermijdt onnodige round-trips.
### Server Action
```ts
// app/actions/tasks.ts
"use server"
export async function saveTask(
input: TaskInput,
context: { sprintId?: string; productId?: string }, // voor revalidatePath en scope
): Promise<SaveTaskResult> {
const session = await getSession();
if (session.isDemo) return { ok: false, code: 403, error: "demo_readonly" };
const scope = await productAccessFilter(session.userId); // verplicht
// ... validate met taskSchema → Prisma write binnen `scope`
}
type SaveTaskResult =
| { ok: true; task: Task }
| { ok: false; code: 422; error: "validation"; fieldErrors: Record<string, string> }
| { ok: false; code: 403; error: "demo_readonly" | "forbidden" }
| { ok: false; code: 500; error: "server_error" }
```
### Foutcodes (volgens `CLAUDE.md` "Foutcodes API")
| Code | Wanneer | UI-respons |
|---|---|---|
| **422** | zod-validatiefout (server-side dubbelcheck) | `fieldErrors` mappen naar `form.setError()`, geen toast |
| **403** | demo-sessie probeert te schrijven, of cross-tenant write geblokkeerd | toast "Niet toegestaan in demo-modus" / "Geen toegang", form blijft open |
| **500** | onverwachte serverfout | toast met "Opnieuw proberen"-knop, form-state behouden |
> Field-level errors zijn **alleen** geldig bij `code: 422`. Bij andere codes is `fieldErrors` ongedefinieerd.
### Revalidation
`revalidatePath` op de **context-route** waarin de dialog werd geopend, niet op een statische `/tasks`-path:
```ts
if (context.sprintId) revalidatePath(`/sprint/${context.sprintId}`);
if (context.productId) revalidatePath(`/products/${context.productId}/backlog`);
```
De aanroepende client geeft de relevante `sprintId` of `productId` mee als argument bij elke save/delete. Geen hard-coded paths in de action zelf.
### Flow
- Synchroon (geen optimistic update in v1)
- Tijdens submit: cancel- en save-knop disabled, spinner in save-knop met "Opslaan...", velden blijven enabled
- Server saniteert en valideert opnieuw met hetzelfde zod-schema
- Field-level server errors (bv. unique constraint op title binnen scope) → `code: 422` met `fieldErrors`, terugmappen naar `form.setError()`
### Error handling
- **422** → field errors inline tonen, geen toast
- **403** → toast met passende boodschap, form blijft open, ingevulde waarden behouden
- **500 / netwerk** → toast met "Opnieuw proberen"-knop, form-state behouden, knoppen weer enabled
---
## Dialog-gedrag
### Sluiten met dirty state
- Form niet aangeraakt → Esc / backdrop-klik / Cancel sluiten direct
- Form `isDirty` → Esc / backdrop-klik / Cancel triggeren `AlertDialog`: *"Wijzigingen niet opgeslagen — weggooien?"*
### Keyboard shortcuts
- **Esc** — sluit (met dirty-check)
- **Cmd/Ctrl+Enter** — submit vanuit elk veld
- **Enter in title-input** — submit niet (alleen Cmd/Ctrl+Enter)
- **Enter in textarea** — newline (default browser behavior, niet overriden)
- **Tab** — title → description → implementation_plan → priority → (status) → cancel → save
### Focus management
- Bij openen: focus op `title`-input
- Edit-mode: cursor aan einde van bestaande titel, **geen auto-select** (anders typt user per ongeluk de titel weg)
- Bij sluiten: focus terug naar het element dat de dialog opende (`@base-ui/react` doet dit by default — niet breken)
- Bij submit-error: focus naar eerste error-veld
### Motion
MD3-conform:
- Open: 250ms, easing `cubic-bezier(0.2, 0, 0, 1)`, scale 0.95→1 + opacity 0→1
- Close: 200ms, easing `cubic-bezier(0.4, 0, 1, 1)`
### Backdrop
Scrim `rgba(0,0,0,0.4)` (iets sterker dan MD3-default 0.32 voor betere contrast op licht/donker).
---
## Footer
### Edit-mode
```
[ Verwijderen ] [ Annuleren ] [ Opslaan ]
tonal (error-container) text filled (primary)
```
### Create-mode
```
[ Annuleren ] [ Aanmaken ]
text filled (primary)
```
### Delete-flow
- Klik op "Verwijderen" → `AlertDialog`: *"Weet je zeker? Dit kan niet ongedaan worden."*
- Bevestigen → `deleteTask` server action (zelfde auth-scoping en demo-checks als `saveTask`) → `revalidatePath` op de context-route (`/sprint/<sprintId>` of `/products/<productId>/backlog`) → dialog sluit → toast "Taak verwijderd"
- Geen undo in v1
---
## Triggers (hoe komt de user erbij?)
De dialog wordt vanuit twee context-pagina's geopend: een sprint-detail (`/sprint/<sprintId>`) of een product-backlog (`/products/<productId>/backlog`).
> **Vervangt bestaande create/edit-flows.** Deze TaskDialog is de **enige** flow voor het aanmaken en bewerken van taken in beide contexten. Bestaande inline-edit-paden in `components/sprint/task-list.tsx` (en eventueel in de backlog) worden door deze dialog vervangen — niet er naast geplaatst. De huidige task-row-rendering wordt aangepast om bij klik de dialog te openen via `?editTask=<id>`; geen aparte edit-icon, geen inline form. Een eventuele "+ Nieuwe taak"-knop in de bestaande tasklist-header wordt eveneens omgeleid naar `?newTask=1` op dezelfde route.
- **Create:** filled button `+ Nieuwe taak` rechtsboven in de tasklist-header van de huidige context (FAB op mobiel optioneel later). Klik zet de juiste query-param (`?newTask=1`) op de huidige route.
- **Edit:** klik op de hele rij in de tasklist (geen apart edit-icoon). Klik zet `?editTask=<id>` op de huidige route.
- **Loading edit-mode:** Suspense met minimale skeleton (3 grijze balken voor inputs), `200ms` delay zodat snelle fetches geen flicker tonen
### Server-fetch
Bij `?editTask=<id>`: server component fetcht de taak vóór render — **inclusief auth-scoping** via `productAccessFilter(userId)` zodat een user nooit een task uit een ander product kan openen via een geraden ID. Bestaat de taak niet of valt 'm buiten scope → toast + redirect naar de context-route zonder query-param (bv. `/sprint/<sprintId>`).
---
## Theming (Material Design 3 tokens)
> **Bron-of-truth in v1:** de bestaande **statische** tokens in `app/styles/theme.css` zijn canoniek. De TaskDialog **consumeert** deze tokens en voegt er geen nieuwe aan toe. Dynamic color (`material-color-utilities`) valt **buiten v1** — niet introduceren in deze feature.
### Color
- TaskDialog gebruikt de bestaande MD3-tokens uit `app/styles/theme.css`: `--primary`, `--on-primary`, `--surface-container`, `--surface-container-high`, `--surface-container-low`, `--error-container`, `--on-error-container`, `--outline-variant`, plus de project-specifieke `--status-*` en `--priority-*` tokens
- Eventueel ontbrekende tokens (bv. een specifieke `surface-container-high` als die er nog niet is) worden in **dezelfde commit** als de feature aan `theme.css` toegevoegd, niet ad-hoc per component gehard-codeerd
- **Verboden:** willekeurige Tailwind-kleuren (`bg-blue-500`, etc.). Altijd semantische tokens — zie `docs/scrum4me-styling.md`
### Dark mode
- `next-themes` is al in de stack; TaskDialog erft automatisch de actieve kleurmodus via de bestaande tokens
- Geen extra setup nodig in deze feature
### Surface elevation
Hybrid (tonal surface + zachte shadow):
- Dialog: `surface-container-high` background + `shadow-2xl` met getemperde opacity
- Form inputs: `surface-container-low` background, geen shadow
- Geen pure tonal-only (voelt te plat op desktop)
### Buttons
- **Filled** (Save/Aanmaken): `primary` background, `on-primary` tekst
- **Text** (Cancel): geen background, `primary` tekst
- **Tonal error** (Delete): `error-container` background, `on-error-container` tekst
### Density
Comfortable (geen compact):
- Single-line input-hoogte: 56px (MD3 outlined text field default)
- Veld-spacing: 24px (`space-y-6`)
- Dialog-padding: 24px alle kanten (`p-6`)
### Typography
- **Font:** Inter via `next/font/google` (geen Roboto-dwang)
- **Schaal (beperkt):**
- `headline-small` (24px) — dialog-titel
- `body-large` (16px) — form-input tekst
- `body-medium` (14px) — helptext, counter
- Geen Material-specifieke letter-spacing tweaks; Inter-defaults voldoen
### Iconen
Lucide (shadcn default). Geen Material Symbols importeren — ~150kb winst en visueel neutraal genoeg om in MD3-themed app te passen.
---
## Markdown rendering (buiten de dialog)
Voor weergave van `description` en `implementation_plan` elders in de app (taakdetail, hover-card, etc.):
```tsx
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
<ReactMarkdown
remarkPlugins={[remarkGfm]}
disallowedElements={["script", "iframe"]}
className="prose prose-sm dark:prose-invert"
>
{content}
</ReactMarkdown>
```
- Tailwind Typography (`prose prose-sm`) voor styling
- `remark-gfm` voor tabellen, taken, strikethrough
- `react-markdown` saniteert by default; `disallowedElements` als extra defense-in-depth
---
## Hergebruik & generalisatie
De TaskDialog is de eerste van naar verwachting meerdere entity-dialogs (PBI, Story, Todo volgen logisch). Bouw daarom **vanaf dag 1** een dunne scheiding tussen generic shell+primitives en entity-specifieke form-body. Dit is geen speculatieve abstractie: de breuklijn tussen "dialog-mechanica" en "welke velden horen bij deze entiteit" is natuurlijk en levert per nieuwe entiteit ~70% codebesparing op.
### Wat generic wordt (`components/entity-dialog/`)
| Component | Waarom generic |
|---|---|
| `entity-dialog.tsx` | Shell: sticky header/footer, responsive layout, motion, backdrop, dirty-close-guard, keyboard-shortcuts, focus-management. Slot-props voor body en footer-actions. |
| `priority-segmented.tsx` | P1-P4 segmented buttons; `priority: int 1-4` is identiek over Task / PBI / Story / Todo. |
| `auto-grow-textarea.tsx` | Wrapper rond `react-textarea-autosize` met char-counter (vanaf 75%) en markdown-hint. Generic — neemt min/max regels en max-chars als props. |
| `dirty-close-guard.tsx` | AlertDialog "Wijzigingen niet opgeslagen — weggooien?" — entity-agnostisch. |
Deze primitives importeren **alleen** uit `components/ui/*` en hebben geen kennis van Task / Story / PBI.
### Wat entity-specifiek blijft (`components/tasks/`)
| Component | Waarom niet generic |
|---|---|
| `task-dialog.tsx` | Dunne wrapper: kiest body, koppelt `saveTask`/`deleteTask`, levert label-strings ("Taak bewerken" / "Aangemaakt: …"). Geen mechanica meer in dit bestand. |
| `task-form.tsx` | Velden zijn task-specifiek (`title`, `description`, `implementation_plan`, `priority`, `status`). Andere entiteiten (Story heeft `acceptance_criteria`, PBI heeft alleen `description`) krijgen elk hun eigen `*-form.tsx`. |
| `task-status-select.tsx` | `TaskStatus` enum met 4 specifieke waarden + dot-kleurmapping. `StoryStatus` (`OPEN | IN_SPRINT | DONE`) en `PbiStatus` (`OPEN | IN_SPRINT | DONE` + `BLOCKED`) hebben andere enums en horen bij eigen select-componenten. |
### Wat **niet** abstraheren in v1
- **URL-state pattern**`?newTask=1` / `?editTask=<id>` per route. Een toekomstige PBI-dialog krijgt `?newPbi=1` / `?editPbi=<id>` op zijn eigen routes. Copy-paste tussen 2-3 pages is goedkoper dan een generic helper die je later toch moet generaliseren.
- **Save/delete-flows** — auth-scoping, demo-checks en revalidatePath verschillen subtiel per entiteit (verschillende productAccessFilter-paden, verschillende context-routes). Per entiteit een eigen actions-file in `app/actions/<entity>.ts`.
### Per-entiteit kostenplaatje
Wanneer er straks een PbiDialog of StoryDialog gebouwd wordt, kost dat alleen:
1. `components/<entity>/<entity>-form.tsx` — de velden + zod-schema
2. `components/<entity>/<entity>-status-select.tsx` — als de entiteit een status-veld heeft
3. `components/<entity>/<entity>-dialog.tsx` — dunne wrapper rond `EntityDialog` met de juiste form en save/delete-handler
4. `app/actions/<entity>.ts` — server actions
5. URL-state uitbreiding op de relevante page(s)
Geen herhaling van layout, motion, dirty-check, keyboard-shortcuts, of segmented/textarea-primitives.
---
## Bewust NIET in v1
Om scope te bewaken:
- ❌ Bulk-edit (meerdere taken tegelijk)
- ❌ Drag-and-drop herorderen
- ❌ Sub-tasks / parent-child relaties
- ❌ Tags / labels / categorieën
- ❌ Due dates / reminders
- ❌ Attachments / file uploads
- ❌ Comments / activity log
- ❌ Sharing / collaboration
- ❌ Undo na delete (toast met undo-actie)
- ❌ Cmd+K keyboard-driven creation zonder dialog
- ❌ Templates voor terugkerende taken
- ❌ Time tracking (uren-registratie) — wel relevant voor inspannings-monitor, maar apart feature
- ❌ Telemetrie / analytics
- ❌ Optimistic locking — niet geïmplementeerd in v1 (last-write-wins binnen scope)
- ❌ Tabs voor secties — alleen spacing-gebaseerde groepering
- ❌ Section-headers — implicit via spacing, geen labels
> Heroverweeg deze keuzes pas als de app groeit. Niet om je te beperken, maar om elke "ja maar moeten we niet ook…"-impuls een bewuste afweging te maken.
---
## File structuur (richtlijn)
```
app/
├── sprint/
│ └── [id]/
│ └── page.tsx # leest searchParams, rendert TaskDialog
├── products/
│ └── [id]/
│ └── backlog/
│ └── page.tsx # leest searchParams, rendert TaskDialog
├── actions/
│ └── tasks.ts # saveTask, deleteTask server actions (auth-scoped)
components/
├── ui/
│ ├── dialog.tsx # bestaande @base-ui/react-wrapper
│ └── demo-tooltip.tsx # wrapper voor save/delete-knoppen in demo-mode
├── entity-dialog/ # GENERIC — geen kennis van Task/Story/PBI
│ ├── entity-dialog.tsx # shell: header/footer/motion/dirty-check/keyboard
│ ├── priority-segmented.tsx # P1-P4 segmented buttons
│ ├── auto-grow-textarea.tsx # textarea met counter + markdown-hint
│ └── dirty-close-guard.tsx # AlertDialog bij dirty close
├── tasks/ # ENTITY-SPECIFIEK
│ ├── task-dialog.tsx # dunne wrapper rond EntityDialog
│ ├── task-form.tsx # task-velden + react-hook-form binding
│ └── task-status-select.tsx # TaskStatus enum + dot-kleuren
lib/
├── schemas/
│ └── task.ts # gedeeld zod-schema (form + server action)
├── auth/
│ └── product-access-filter.ts # scope-helper, gedeeld door page-fetches en actions
proxy.ts # demo-readonly middleware-guard (laag 1 van 3)
```
---
## Implementatie-volgorde (suggestie)
1. Dependencies toevoegen aan `package.json` (zie "Dependency-impact"); commit als `chore(ST-XXX): add deps for task dialog`
2. zod-schema in `lib/schemas/task.ts`
3. `productAccessFilter` helper checken/uitbreiden in `lib/auth/`
4. Server actions (`saveTask`, `deleteTask`) met **auth-scoping én demo-check** (laag 2) — testen via thunk
5. `proxy.ts` middleware-guard voor demo-routes (laag 1) — alleen als nog niet aanwezig voor deze routes
6. Eventueel ontbrekende MD3-tokens aanvullen in `app/styles/theme.css` (geen dynamic color in v1)
7. `<DemoTooltip>`-wrapper component (laag 3)
8. TaskDialog — create-mode eerst (minder edge cases), bovenop bestaande `components/ui/dialog.tsx`-wrapper
9. Edit-mode toevoegen (status field, delete-knop, `created_at`-metadata)
10. URL-state via native `searchParams` binnen sprint en backlog routes (geen `nuqs` in v1)
11. **Bestaande task-row / tasklist-trigger refactoren**`components/sprint/task-list.tsx` (en backlog-equivalent) klikbaar maken zodat ze de dialog openen via query-param; oude inline-edit-paden verwijderen
12. Suspense + skeleton voor edit-mode loading + scope-check op fetch
13. Dirty-check + AlertDialog
14. Keyboard shortcuts (Cmd+Enter)
15. Markdown rendering elders (out-of-scope voor dialog zelf, maar related)

12
lib/schemas/task.ts Normal file
View file

@ -0,0 +1,12 @@
import { z } from 'zod'
import { TaskStatus } from '@prisma/client'
export const taskSchema = z.object({
title: z.string().trim().min(1, 'Verplicht').max(120),
description: z.string().max(2000).optional(),
implementation_plan: z.string().max(10000).optional(),
priority: z.number().int().min(1).max(4),
status: z.nativeEnum(TaskStatus).optional(),
})
export type TaskInput = z.infer<typeof taskSchema>

View file

@ -0,0 +1,72 @@
import type { Prisma, TaskStatus } from '@prisma/client'
import { prisma } from '@/lib/prisma'
export type StoryStatusChange = 'promoted' | 'demoted' | null
export interface UpdateTaskStatusResult {
task: {
id: string
title: string
status: TaskStatus
story_id: string
implementation_plan: string | null
}
storyStatusChange: StoryStatusChange
storyId: string
}
// Update task.status atomically and auto-promote/demote the parent story:
// - All sibling tasks DONE → story.status = DONE
// - Story was DONE and a task moves out of DONE → story.status = IN_SPRINT
// Demote target is IN_SPRINT (not OPEN): OPEN means "back in product backlog",
// which is a sprint-management action, not a status side-effect.
export async function updateTaskStatusWithStoryPromotion(
taskId: string,
newStatus: TaskStatus,
client?: Prisma.TransactionClient,
): Promise<UpdateTaskStatusResult> {
const run = async (tx: Prisma.TransactionClient): Promise<UpdateTaskStatusResult> => {
const task = await tx.task.update({
where: { id: taskId },
data: { status: newStatus },
select: {
id: true,
title: true,
status: true,
story_id: true,
implementation_plan: true,
},
})
const siblings = await tx.task.findMany({
where: { story_id: task.story_id },
select: { status: true },
})
const allDone = siblings.every((s) => s.status === 'DONE')
const story = await tx.story.findUniqueOrThrow({
where: { id: task.story_id },
select: { status: true },
})
let storyStatusChange: StoryStatusChange = null
if (newStatus === 'DONE' && allDone && story.status !== 'DONE') {
await tx.story.update({
where: { id: task.story_id },
data: { status: 'DONE' },
})
storyStatusChange = 'promoted'
} else if (newStatus !== 'DONE' && story.status === 'DONE') {
await tx.story.update({
where: { id: task.story_id },
data: { status: 'IN_SPRINT' },
})
storyStatusChange = 'demoted'
}
return { task, storyStatusChange, storyId: task.story_id }
}
if (client) return run(client)
return prisma.$transaction(run)
}

1585
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -22,6 +22,7 @@
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^8.0.0", "@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.2",
"@prisma/adapter-pg": "^7.8.0", "@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0", "@prisma/client": "^7.8.0",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
@ -39,6 +40,10 @@
"qrcode.react": "^4.2.0", "qrcode.react": "^4.2.0",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"react-hook-form": "^7.74.0",
"react-markdown": "^10.1.0",
"react-textarea-autosize": "^8.5.9",
"remark-gfm": "^4.0.1",
"shadcn": "^4.4.0", "shadcn": "^4.4.0",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"sonner": "^1.7.4", "sonner": "^1.7.4",
@ -56,6 +61,7 @@
"devDependencies": { "devDependencies": {
"@mermaid-js/mermaid-cli": "^11.12.0", "@mermaid-js/mermaid-cli": "^11.12.0",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.19",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/node": "^20", "@types/node": "^20",
"@types/pg": "^8.20.0", "@types/pg": "^8.20.0",