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:
parent
64e3f610a6
commit
6cd98129f2
27 changed files with 3665 additions and 130 deletions
|
|
@ -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 branch -a)",
|
||||
"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\\)$\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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__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
|
||||
|
||||
- `implement_next_story` (arg: `product_id`) — end-to-end workflow
|
||||
|
|
|
|||
225
__tests__/actions/tasks-dialog.test.ts
Normal file
225
__tests__/actions/tasks-dialog.test.ts
Normal 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() }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -11,10 +11,13 @@ vi.mock('@/lib/prisma', () => ({
|
|||
},
|
||||
story: {
|
||||
findFirst: vi.fn(),
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
task: {
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
storyLog: {
|
||||
create: vi.fn(),
|
||||
|
|
@ -43,8 +46,16 @@ import { POST as postTodo } from '@/app/api/todos/route'
|
|||
const mockPrisma = prisma as unknown as {
|
||||
product: { findMany: ReturnType<typeof vi.fn>; findFirst: ReturnType<typeof vi.fn> }
|
||||
sprint: { findFirst: ReturnType<typeof vi.fn> }
|
||||
story: { findFirst: ReturnType<typeof vi.fn> }
|
||||
task: { findFirst: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> }
|
||||
story: {
|
||||
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> }
|
||||
todo: { create: ReturnType<typeof vi.fn> }
|
||||
$transaction: ReturnType<typeof vi.fn>
|
||||
|
|
@ -85,6 +96,11 @@ function routeCtx(id: string) {
|
|||
|
||||
beforeEach(() => {
|
||||
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 ────────────────────────────────────────────────────────
|
||||
|
|
@ -386,7 +402,15 @@ describe('PATCH /api/tasks/:id', () => {
|
|||
id: 'task-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(
|
||||
makePatch('http://localhost/api/tasks/task-1', { status: 'done' }),
|
||||
|
|
|
|||
|
|
@ -5,7 +5,13 @@ vi.mock('@/lib/prisma', () => ({
|
|||
task: {
|
||||
findFirst: 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'
|
||||
|
||||
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>
|
||||
|
||||
|
|
@ -55,6 +70,15 @@ describe('PATCH /api/tasks/:id', () => {
|
|||
id: 'task-1',
|
||||
status: 'DONE',
|
||||
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
|
||||
it('updates both status and implementation_plan and returns 200', async () => {
|
||||
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 data = await res.json()
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
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.objectContaining({
|
||||
data: { status: 'DONE', implementation_plan: plan },
|
||||
})
|
||||
expect.objectContaining({ data: { 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)
|
||||
})
|
||||
|
||||
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 () => {
|
||||
const req = new Request('http://localhost/api/tasks/task-1', {
|
||||
method: 'PATCH',
|
||||
|
|
|
|||
153
__tests__/lib/tasks-status-update.test.ts
Normal file
153
__tests__/lib/tasks-status-update.test.ts
Normal 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' },
|
||||
})
|
||||
})
|
||||
})
|
||||
137
actions/tasks.ts
137
actions/tasks.ts
|
|
@ -8,11 +8,146 @@ import { prisma } from '@/lib/prisma'
|
|||
import { SessionData, sessionOptions } from '@/lib/session'
|
||||
import { productAccessFilter } from '@/lib/product-access'
|
||||
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() {
|
||||
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({
|
||||
title: z.string().min(1, 'Titel is verplicht').max(200),
|
||||
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' }
|
||||
|
||||
await prisma.task.update({ where: { id }, data: { status } })
|
||||
await updateTaskStatusWithStoryPromotion(id, status)
|
||||
|
||||
// /solo bewust niet revalideren: dat zou de page soft-navigaten en de
|
||||
// open SSE-stream sluiten. De Solo Paneel-flow leunt op optimistic
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { Suspense } from 'react'
|
||||
import { notFound, redirect } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
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 type { SprintStory, PbiWithStories, ProductMember } from '@/components/sprint/sprint-backlog'
|
||||
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'
|
||||
|
||||
interface Props {
|
||||
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 { newTask, storyId: storyIdParam, editTask } = await searchParams
|
||||
|
||||
const session = await getSession()
|
||||
if (!session.userId) redirect('/login')
|
||||
|
||||
|
|
@ -104,6 +115,7 @@ export default async function SprintBoardPage({ params }: Props) {
|
|||
|
||||
const sprintStoryIdList = sprintStories.map(s => s.id)
|
||||
const isDemo = session.isDemo ?? false
|
||||
const closePath = `/products/${id}/sprint`
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
|
|
@ -134,6 +146,27 @@ export default async function SprintBoardPage({ params }: Props) {
|
|||
← Product Backlog
|
||||
</Link>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
47
app/_components/tasks/edit-task-loader.tsx
Normal file
47
app/_components/tasks/edit-task-loader.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
56
app/_components/tasks/priority-segmented.tsx
Normal file
56
app/_components/tasks/priority-segmented.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
55
app/_components/tasks/status-select.tsx
Normal file
55
app/_components/tasks/status-select.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
42
app/_components/tasks/task-dialog-skeleton.tsx
Normal file
42
app/_components/tasks/task-dialog-skeleton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
424
app/_components/tasks/task-dialog.tsx
Normal file
424
app/_components/tasks/task-dialog.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import { authenticateApiRequest } from '@/lib/api-auth'
|
|||
import { prisma } from '@/lib/prisma'
|
||||
import { z } from 'zod'
|
||||
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
|
||||
// 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) => {
|
||||
const planUpdate = parsed.data.implementation_plan !== undefined
|
||||
? await tx.task.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dbStatus !== undefined && dbStatus !== null && { status: dbStatus }),
|
||||
...(parsed.data.implementation_plan !== undefined && {
|
||||
implementation_plan: parsed.data.implementation_plan,
|
||||
}),
|
||||
},
|
||||
data: { 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({
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@import "./styles/theme.css";
|
||||
|
|
|
|||
|
|
@ -73,9 +73,16 @@
|
|||
--switch-background: #79767d;
|
||||
--ring: var(--primary);
|
||||
|
||||
/* MD3 Outline Variant */
|
||||
--outline-variant: #c5c6d0;
|
||||
|
||||
/* MD3 On-Error-Container */
|
||||
--on-error-container: #410002;
|
||||
|
||||
/* Project Management Specific Colors */
|
||||
--status-todo: #6750a4;
|
||||
--status-in-progress: #0061a4;
|
||||
--status-review: #7b5ea7;
|
||||
--status-done: #006e1c;
|
||||
--status-blocked: #ba1a1a;
|
||||
|
||||
|
|
@ -177,9 +184,16 @@
|
|||
--switch-background: #898790;
|
||||
--ring: var(--primary);
|
||||
|
||||
/* MD3 Outline Variant */
|
||||
--outline-variant: #45464f;
|
||||
|
||||
/* MD3 On-Error-Container */
|
||||
--on-error-container: #ffdad6;
|
||||
|
||||
/* Project Management Specific Colors */
|
||||
--status-todo: #cfbdfe;
|
||||
--status-in-progress: #9fcbfa;
|
||||
--status-review: #c9b6ef;
|
||||
--status-done: #77db77;
|
||||
--status-blocked: #ffb4ab;
|
||||
|
||||
|
|
@ -256,6 +270,7 @@
|
|||
--color-error-foreground: var(--error-foreground);
|
||||
--color-error-container: var(--error-container);
|
||||
--color-error-container-foreground: var(--error-container-foreground);
|
||||
--color-on-error-container: var(--on-error-container);
|
||||
|
||||
--color-info: var(--info);
|
||||
--color-info-foreground: var(--info-foreground);
|
||||
|
|
@ -273,6 +288,7 @@
|
|||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-outline-variant: var(--outline-variant);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-input-background: var(--input-background);
|
||||
|
|
@ -282,6 +298,7 @@
|
|||
/* Project management colors */
|
||||
--color-status-todo: var(--status-todo);
|
||||
--color-status-in-progress: var(--status-in-progress);
|
||||
--color-status-review: var(--status-review);
|
||||
--color-status-done: var(--status-done);
|
||||
--color-status-blocked: var(--status-blocked);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState, useTransition } from 'react'
|
||||
import { Markdown } from '@/components/markdown'
|
||||
import { useActionState } from 'react'
|
||||
import { useFormStatus } from 'react-dom'
|
||||
import { toast } from 'sonner'
|
||||
|
|
@ -231,7 +232,7 @@ export function StoryDialog({ state, onClose, isDemo = false }: StoryDialogProps
|
|||
{story?.description && (
|
||||
<div>
|
||||
<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>
|
||||
)}
|
||||
{story?.acceptance_criteria && (
|
||||
|
|
|
|||
58
components/entity-dialog/dirty-close-guard.tsx
Normal file
58
components/entity-dialog/dirty-close-guard.tsx
Normal 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
21
components/markdown.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
import { useRef, useState, useTransition } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { toast } from 'sonner'
|
||||
import { Markdown } from '@/components/markdown'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -132,7 +133,7 @@ function TaskDetailContent({ task, productId, isDemo, onClose }: TaskDetailConte
|
|||
{task.description && (
|
||||
<div>
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useTransition, useEffect, useActionState } from 'react'
|
||||
import { useFormStatus } from 'react-dom'
|
||||
import { useState, useTransition, useEffect } from 'react'
|
||||
import { useRouter, usePathname } from 'next/navigation'
|
||||
import {
|
||||
DndContext, DragEndEvent, DragOverlay,
|
||||
KeyboardSensor, PointerSensor, useSensor, useSensors, closestCenter,
|
||||
|
|
@ -13,17 +13,13 @@ import {
|
|||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { CodeBadge } from '@/components/shared/code-badge'
|
||||
import { PanelNavBar } from '@/components/shared/panel-nav-bar'
|
||||
import { PRIORITY_BORDER } from '@/components/backlog/backlog-card'
|
||||
import { deriveTaskCode } from '@/lib/code'
|
||||
import { useSprintStore } from '@/stores/sprint-store'
|
||||
import {
|
||||
createTaskAction, updateTaskStatusAction, updateTaskAction,
|
||||
deleteTaskAction, reorderTasksAction,
|
||||
} from '@/actions/tasks'
|
||||
import { updateTaskStatusAction, reorderTasksAction } from '@/actions/tasks'
|
||||
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
|
|
@ -60,69 +56,69 @@ interface TaskListProps {
|
|||
}
|
||||
|
||||
function SortableTaskRow({
|
||||
task, code, isDemo, onStatusToggle, onDelete,
|
||||
}: { task: Task; code: string | null; isDemo: boolean; onStatusToggle: () => void; onDelete: () => void }) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
task, code, isDemo, onStatusToggle, onEdit,
|
||||
}: {
|
||||
task: Task
|
||||
code: string | null
|
||||
isDemo: boolean
|
||||
onStatusToggle: () => void
|
||||
onEdit: () => void
|
||||
}) {
|
||||
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 [, 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 (
|
||||
<div ref={setNodeRef} style={style} className="group px-2 py-1">
|
||||
<div className={cn(
|
||||
'flex items-start gap-2 rounded border border-border px-3 py-2 transition-colors bg-surface-container hover:bg-surface-container-high',
|
||||
PRIORITY_BORDER[task.priority]
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
'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 && (
|
||||
<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 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}
|
||||
</p>
|
||||
{code && <CodeBadge code={code} className="shrink-0 mt-0.5" />}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 mt-1.5">
|
||||
<button onClick={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])}>
|
||||
<div className="mt-1.5">
|
||||
<button
|
||||
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]}
|
||||
</Badge>
|
||||
</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>
|
||||
|
|
@ -130,48 +126,12 @@ function SortableTaskRow({
|
|||
)
|
||||
}
|
||||
|
||||
function EditSubmitButton() {
|
||||
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) {
|
||||
export function TaskList({ storyId, storyCode, sprintId: _sprintId, productId: _productId, tasks, isDemo }: TaskListProps) {
|
||||
const { taskOrder, initTasks, reorderTasks, rollbackTasks } = useSprintStore()
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [activeDragId, setActiveDragId] = useState<string | null>(null)
|
||||
const [, startTransition] = useTransition()
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
|
||||
const idKey = tasks.map(t => t.id).join(',')
|
||||
useEffect(() => {
|
||||
|
|
@ -187,7 +147,7 @@ export function TaskList({ storyId, storyCode, sprintId, productId: _productId,
|
|||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
)
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
|
|
@ -209,11 +169,12 @@ export function TaskList({ storyId, storyCode, sprintId, productId: _productId,
|
|||
})
|
||||
}
|
||||
|
||||
function handleDelete(id: string) {
|
||||
startTransition(async () => {
|
||||
const result = await deleteTaskAction(id)
|
||||
if (result && 'error' in result) toast.error(result.error ?? 'Verwijderen mislukt')
|
||||
})
|
||||
function openCreateDialog() {
|
||||
router.push(`${pathname}?newTask=1&storyId=${storyId}`)
|
||||
}
|
||||
|
||||
function openEditDialog(taskId: string) {
|
||||
router.push(`${pathname}?editTask=${taskId}`)
|
||||
}
|
||||
|
||||
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>
|
||||
<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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{creating && (
|
||||
<CreateTaskForm storyId={storyId} sprintId={sprintId} onDone={() => setCreating(false)} />
|
||||
)}
|
||||
|
||||
{orderedTasks.length === 0 && !creating ? (
|
||||
{orderedTasks.length === 0 ? (
|
||||
<div className="text-center mt-8 space-y-3">
|
||||
<p className="text-sm text-muted-foreground">Geen taken voor deze story.</p>
|
||||
<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>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -258,7 +229,7 @@ export function TaskList({ storyId, storyCode, sprintId, productId: _productId,
|
|||
code={deriveTaskCode(storyCode, idx + 1)}
|
||||
isDemo={isDemo}
|
||||
onStatusToggle={() => handleStatusToggle(task)}
|
||||
onDelete={() => handleDelete(task.id)}
|
||||
onEdit={() => openEditDialog(task.id)}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
|
|
@ -266,7 +237,7 @@ export function TaskList({ storyId, storyCode, sprintId, productId: _productId,
|
|||
{activeDragId && taskMap[activeDragId] && (
|
||||
<div className={cn(
|
||||
'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}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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)`
|
||||
|
||||
**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`
|
||||
|
|
|
|||
506
docs/scrum4me-task-dialog.md
Normal file
506
docs/scrum4me-task-dialog.md
Normal 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
12
lib/schemas/task.ts
Normal 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>
|
||||
72
lib/tasks-status-update.ts
Normal file
72
lib/tasks-status-update.ts
Normal 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
1585
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -22,6 +22,7 @@
|
|||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@prisma/adapter-pg": "^7.8.0",
|
||||
"@prisma/client": "^7.8.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
|
|
@ -39,6 +40,10 @@
|
|||
"qrcode.react": "^4.2.0",
|
||||
"react": "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",
|
||||
"sharp": "^0.34.5",
|
||||
"sonner": "^1.7.4",
|
||||
|
|
@ -56,6 +61,7 @@
|
|||
"devDependencies": {
|
||||
"@mermaid-js/mermaid-cli": "^11.12.0",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/node": "^20",
|
||||
"@types/pg": "^8.20.0",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue