diff --git a/__tests__/actions/ideas-crud.test.ts b/__tests__/actions/ideas-crud.test.ts index a19c663..bf1ba41 100644 --- a/__tests__/actions/ideas-crud.test.ts +++ b/__tests__/actions/ideas-crud.test.ts @@ -520,7 +520,7 @@ body .mockResolvedValueOnce({ id: 't-B1' }) }) - it('happy: creates PBI + 2 stories + 3 tasks, links idea, returns ids', async () => { + it('happy: creates PBI + 2 stories + 3 tasks, links idea, returns ids; sort_order = parseCodeNumber(code)', async () => { const r = await materializeIdeaPlanAction('idea-1') expect(r).toMatchObject({ success: true, @@ -534,6 +534,15 @@ body expect(m.pbi.create).toHaveBeenCalledTimes(1) expect(m.story.create).toHaveBeenCalledTimes(2) expect(m.task.create).toHaveBeenCalledTimes(3) + + // story sort_order = parseCodeNumber(auto-code): ST-001→1, ST-002→2 + expect(m.story.create.mock.calls[0][0].data.sort_order).toBe(1) + expect(m.story.create.mock.calls[1][0].data.sort_order).toBe(2) + + // task sort_order = parseCodeNumber(auto-code): T-1→1, T-2→2, T-3→3 + expect(m.task.create.mock.calls[0][0].data.sort_order).toBe(1) + expect(m.task.create.mock.calls[1][0].data.sort_order).toBe(2) + expect(m.task.create.mock.calls[2][0].data.sort_order).toBe(3) }) it('blocks when not PLAN_READY (e.g. GRILLED)', async () => { diff --git a/__tests__/api/next-story.test.ts b/__tests__/api/next-story.test.ts index 4c614e9..fc549d8 100644 --- a/__tests__/api/next-story.test.ts +++ b/__tests__/api/next-story.test.ts @@ -95,7 +95,7 @@ describe('GET /api/products/:id/next-story', () => { expect(data.tasks[0]).toMatchObject({ id: 'task-1', status: 'todo' }) }) - it('queries story ordered by priority then sort_order', async () => { + it('queries story ordered by sort_order only', async () => { mockPrisma.sprint.findFirst.mockResolvedValue(SPRINT) mockPrisma.story.findFirst.mockResolvedValue(STORY) @@ -103,7 +103,7 @@ describe('GET /api/products/:id/next-story', () => { expect(mockPrisma.story.findFirst).toHaveBeenCalledWith( expect.objectContaining({ - orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], + orderBy: [{ sort_order: 'asc' }], }) ) }) diff --git a/__tests__/api/reorder.test.ts b/__tests__/api/reorder.test.ts deleted file mode 100644 index cff62ae..0000000 --- a/__tests__/api/reorder.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -vi.mock('@/lib/prisma', () => ({ - prisma: { - story: { - findFirst: vi.fn(), - }, - task: { - update: vi.fn(), - }, - $transaction: vi.fn(), - }, -})) - -vi.mock('@/lib/api-auth', () => ({ - authenticateApiRequest: vi.fn(), -})) - -import { prisma } from '@/lib/prisma' -import { authenticateApiRequest } from '@/lib/api-auth' -import { PATCH as patchReorder } from '@/app/api/stories/[id]/tasks/reorder/route' - -const mockPrisma = prisma as unknown as { - story: { findFirst: ReturnType } - task: { update: ReturnType } - $transaction: ReturnType -} -const mockAuth = authenticateApiRequest as ReturnType - -function makeStory(taskIds: string[]) { - return { - id: 'story-1', - product_id: 'prod-1', - tasks: taskIds.map(id => ({ id })), - } -} - -function makeRequest(body: unknown, storyId = 'story-1'): [Request, { params: Promise<{ id: string }> }] { - return [ - new Request(`http://localhost/api/stories/${storyId}/tasks/reorder`, { - method: 'PATCH', - headers: { Authorization: 'Bearer test-token', 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }), - { params: Promise.resolve({ id: storyId }) }, - ] -} - -describe('PATCH /api/stories/:id/tasks/reorder', () => { - beforeEach(() => { - vi.clearAllMocks() - mockAuth.mockResolvedValue({ userId: 'user-1', isDemo: false }) - mockPrisma.$transaction.mockResolvedValue([]) - mockPrisma.task.update.mockResolvedValue({ id: 'task-1', sort_order: 1 }) - }) - - // TC-RO-06 — body validation fires before story lookup - it('returns 422 when task_ids is an empty array', async () => { - const res = await patchReorder(...makeRequest({ task_ids: [] })) - expect(res.status).toBe(422) - expect(mockPrisma.story.findFirst).not.toHaveBeenCalled() - }) - - // TC-RO-07 - it('returns 422 when task_ids is not an array', async () => { - const res = await patchReorder(...makeRequest({ task_ids: 'task-1' })) - expect(res.status).toBe(422) - expect(mockPrisma.story.findFirst).not.toHaveBeenCalled() - }) - - it('returns 422 when task_ids is missing entirely', async () => { - const res = await patchReorder(...makeRequest({})) - expect(res.status).toBe(422) - }) - - // TC-RO-08 - it('returns 422 when task_ids contains an ID not belonging to the story', async () => { - mockPrisma.story.findFirst.mockResolvedValue(makeStory(['task-1', 'task-2'])) - - const res = await patchReorder(...makeRequest({ task_ids: ['task-1', 'task-from-other-story'] })) - const data = await res.json() - - expect(res.status).toBe(422) - expect(data.error).toContain('task-from-other-story') - }) - - // TC-RO-09 - it('reorders tasks and returns 200 with success: true', async () => { - mockPrisma.story.findFirst.mockResolvedValue(makeStory(['task-1', 'task-2', 'task-3'])) - - const res = await patchReorder(...makeRequest({ task_ids: ['task-3', 'task-1', 'task-2'] })) - const data = await res.json() - - expect(res.status).toBe(200) - expect(data).toEqual({ success: true }) - expect(mockPrisma.$transaction).toHaveBeenCalled() - }) - - it('updates each task with its new sort_order index', async () => { - mockPrisma.story.findFirst.mockResolvedValue(makeStory(['task-1', 'task-2'])) - - await patchReorder(...makeRequest({ task_ids: ['task-2', 'task-1'] })) - - expect(mockPrisma.task.update).toHaveBeenCalledWith( - expect.objectContaining({ where: { id: 'task-2' }, data: { sort_order: 1 } }) - ) - expect(mockPrisma.task.update).toHaveBeenCalledWith( - expect.objectContaining({ where: { id: 'task-1' }, data: { sort_order: 2 } }) - ) - }) -}) diff --git a/__tests__/api/security.test.ts b/__tests__/api/security.test.ts index 467e248..9a1d508 100644 --- a/__tests__/api/security.test.ts +++ b/__tests__/api/security.test.ts @@ -54,7 +54,6 @@ import { authenticateApiRequest } from '@/lib/api-auth' import { GET as getProducts } from '@/app/api/products/route' import { GET as getNextStory } from '@/app/api/products/[id]/next-story/route' import { GET as getSprintTasks } from '@/app/api/sprints/[id]/tasks/route' -import { PATCH as patchReorder } from '@/app/api/stories/[id]/tasks/reorder/route' import { POST as postStoryLog } from '@/app/api/stories/[id]/log/route' import { PATCH as patchTask } from '@/app/api/tasks/[id]/route' @@ -276,56 +275,6 @@ describe('GET /api/sprints/:id/tasks', () => { }) }) -// ─── PATCH /api/stories/:id/tasks/reorder ──────────────────────────────────── - -describe('PATCH /api/stories/:id/tasks/reorder', () => { - const VALID_BODY = { task_ids: ['task-x'] } - - // TC-RO-01 - it('returns 401 when no valid token provided', async () => { - mockAuth.mockResolvedValue(UNAUTHORIZED) - const res = await patchReorder( - makePatch('http://localhost/api/stories/story-1/tasks/reorder', VALID_BODY), - routeCtx('story-1') - ) - expect(res.status).toBe(401) - }) - - // TC-RO-03 - it('returns 403 for demo users', async () => { - mockAuth.mockResolvedValue(DEMO_AUTH) - const res = await patchReorder( - makePatch('http://localhost/api/stories/story-1/tasks/reorder', VALID_BODY), - routeCtx('story-1') - ) - expect(res.status).toBe(403) - const data = await res.json() - expect(data.error).toBe('Niet beschikbaar in demo-modus') - }) - - // TC-RO-04 / TC-RO-05 - it('returns 404 when story is not accessible to the authenticated user', async () => { - mockAuth.mockResolvedValue(USER_2_AUTH) - mockPrisma.story.findFirst.mockResolvedValue(null) - - const res = await patchReorder( - makePatch('http://localhost/api/stories/story-1/tasks/reorder', VALID_BODY), - routeCtx('story-1') - ) - expect(res.status).toBe(404) - expect(mockPrisma.story.findFirst).toHaveBeenCalledWith( - expect.objectContaining({ - where: expect.objectContaining({ - id: 'story-1', - product: expect.objectContaining({ - OR: expect.arrayContaining([{ user_id: 'user-2' }]), - }), - }), - }) - ) - }) -}) - // ─── POST /api/stories/:id/log ──────────────────────────────────────────────── describe('POST /api/stories/:id/log', () => { diff --git a/__tests__/components/backlog/integration.test.tsx b/__tests__/components/backlog/integration.test.tsx index 703d229..0a41b94 100644 --- a/__tests__/components/backlog/integration.test.tsx +++ b/__tests__/components/backlog/integration.test.tsx @@ -25,18 +25,16 @@ Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, wri // Mock server actions vi.mock('@/actions/stories', () => ({ - reorderStoriesAction: vi.fn().mockResolvedValue({ success: true }), reorderPbisAction: vi.fn().mockResolvedValue({ success: true }), updatePbiPriorityAction: vi.fn().mockResolvedValue({ success: true }), })) vi.mock('@/actions/pbis', () => ({ deletePbiAction: vi.fn().mockResolvedValue({ success: true }) })) -vi.mock('@/actions/tasks', () => ({ reorderTasksAction: vi.fn().mockResolvedValue({ success: true }) })) vi.mock('@/actions/user-settings', () => ({ updateUserSettingsAction: vi.fn().mockResolvedValue({ success: true, settings: {} }), })) vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } })) -// Mock dnd-kit +// Mock dnd-kit (still needed for PBI panel which supports drag-and-drop) vi.mock('@dnd-kit/core', () => ({ DndContext: ({ children }: { children: React.ReactNode }) => <>{children}, PointerSensor: class {}, @@ -71,7 +69,7 @@ const STORIES: BacklogStory[] = [ { id: STORY_ID, code: 'ST-1', title: 'Eerste story', description: null, acceptance_criteria: null, priority: 2, sort_order: 1, status: 'OPEN', pbi_id: PBI_ID, sprint_id: null, created_at: new Date() }, ] const TASKS: BacklogTask[] = [ - { id: 'task-1', title: 'Eerste taak', description: null, priority: 2, status: 'TO_DO', sort_order: 1, story_id: STORY_ID, created_at: new Date() }, + { id: 'task-1', code: null, title: 'Eerste taak', description: null, priority: 2, status: 'TO_DO', sort_order: 1, story_id: STORY_ID, created_at: new Date() }, ] function resetStores() { diff --git a/__tests__/components/backlog/task-panel.test.tsx b/__tests__/components/backlog/task-panel.test.tsx index 69b844c..fc5cf7a 100644 --- a/__tests__/components/backlog/task-panel.test.tsx +++ b/__tests__/components/backlog/task-panel.test.tsx @@ -33,37 +33,8 @@ function setActiveStoryAndTasks(storyId: string | null, tasks: BacklogTask[] = [ const mockPush = vi.fn() vi.mock('next/navigation', () => ({ useRouter: () => ({ push: mockPush }) })) -// Mock reorderTasksAction -vi.mock('@/actions/tasks', () => ({ reorderTasksAction: vi.fn().mockResolvedValue({ success: true }) })) vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } })) -// Mock dnd-kit to avoid jsdom drag complexity -vi.mock('@dnd-kit/core', () => ({ - DndContext: ({ children }: { children: React.ReactNode }) => <>{children}, - PointerSensor: class {}, - KeyboardSensor: class {}, - useSensor: vi.fn(), - useSensors: vi.fn(() => []), - closestCenter: vi.fn(), - DragOverlay: () => null, -})) -vi.mock('@dnd-kit/sortable', () => ({ - SortableContext: ({ children }: { children: React.ReactNode }) => <>{children}, - useSortable: () => ({ - attributes: {}, listeners: {}, setNodeRef: vi.fn(), - transform: null, transition: undefined, isDragging: false, - }), - rectSortingStrategy: {}, - sortableKeyboardCoordinates: {}, - arrayMove: (arr: unknown[], from: number, to: number) => { - const next = [...arr] - next.splice(from, 1) - next.splice(to, 0, arr[from]) - return next - }, -})) -vi.mock('@dnd-kit/utilities', () => ({ CSS: { Transform: { toString: () => '' } } })) - import { TaskPanel } from '@/components/backlog/task-panel' const PRODUCT_ID = 'prod-1' @@ -71,8 +42,8 @@ const STORY_ID = 'story-1' const CLOSE_PATH = `/products/${PRODUCT_ID}` const TASKS = [ - { id: 'task-1', title: 'Eerste taak', description: null, priority: 2, status: 'TO_DO', sort_order: 1, story_id: STORY_ID, created_at: new Date() }, - { id: 'task-2', title: 'Tweede taak', description: null, priority: 3, status: 'IN_PROGRESS', sort_order: 2, story_id: STORY_ID, created_at: new Date() }, + { id: 'task-1', code: null, title: 'Eerste taak', description: null, priority: 2, status: 'TO_DO', sort_order: 1, story_id: STORY_ID, created_at: new Date() }, + { id: 'task-2', code: null, title: 'Tweede taak', description: null, priority: 3, status: 'IN_PROGRESS', sort_order: 2, story_id: STORY_ID, created_at: new Date() }, ] function renderPanel(isDemo = false) { @@ -141,12 +112,4 @@ describe('TaskPanel', () => { expect((btn as HTMLButtonElement).disabled).toBe(true) }) - it('cards have no drag listeners in demo mode (whole-card drag disabled)', () => { - setActiveStoryAndTasks(STORY_ID, TASKS) - // In demo mode, listeners ({} from useSortable mock) are not spread onto the card. - // The mock always returns empty listeners, so we just verify the cards render without error. - renderPanel(true) - expect(screen.getByText('Eerste taak')).toBeTruthy() - expect(screen.getByText('Tweede taak')).toBeTruthy() - }) }) diff --git a/__tests__/lib/code.test.ts b/__tests__/lib/code.test.ts new file mode 100644 index 0000000..7b83640 --- /dev/null +++ b/__tests__/lib/code.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest' + +import { parseCodeNumber } from '@/lib/code' + +describe('parseCodeNumber', () => { + it('parses a standard story code', () => { + expect(parseCodeNumber('ST-001')).toBe(1) + }) + + it('parses a task code', () => { + expect(parseCodeNumber('T-42')).toBe(42) + }) + + it('parses a large number', () => { + expect(parseCodeNumber('ST-1000')).toBe(1000) + }) + + it('returns MAX_SAFE_INTEGER for a code with no trailing digits', () => { + expect(parseCodeNumber('FOO')).toBe(Number.MAX_SAFE_INTEGER) + }) + + it('returns MAX_SAFE_INTEGER for an empty string', () => { + expect(parseCodeNumber('')).toBe(Number.MAX_SAFE_INTEGER) + }) +}) diff --git a/__tests__/stores/product-workspace/store.test.ts b/__tests__/stores/product-workspace/store.test.ts index f2db43b..ff86cfc 100644 --- a/__tests__/stores/product-workspace/store.test.ts +++ b/__tests__/stores/product-workspace/store.test.ts @@ -106,6 +106,7 @@ function makeStory(overrides: Partial & { id: string; pbi_id: stri function makeTask(overrides: Partial & { id: string; story_id: string }): BacklogTask { return { id: overrides.id, + code: overrides.code ?? null, title: overrides.title ?? `Task ${overrides.id}`, description: overrides.description ?? null, priority: overrides.priority ?? 2, diff --git a/__tests__/stores/sprint-workspace/store.test.ts b/__tests__/stores/sprint-workspace/store.test.ts index 6e7757a..5fa0502 100644 --- a/__tests__/stores/sprint-workspace/store.test.ts +++ b/__tests__/stores/sprint-workspace/store.test.ts @@ -852,56 +852,6 @@ describe('restore-hint flow — chain triggert na ensure*Loaded', () => { // ───────────────────────────────────────────────────────────────────────── describe('optimistic mutations', () => { - it('rollback herstelt vorige sprint-story-order', () => { - useSprintWorkspaceStore.getState().hydrateSnapshot( - snapshotWith( - makeSprint({ id: 'sp-1', product_id: 'prod-1' }), - [ - makeStory({ id: 'a', pbi_id: 'p', sprint_id: 'sp-1', sort_order: 1 }), - makeStory({ id: 'b', pbi_id: 'p', sprint_id: 'sp-1', sort_order: 2 }), - ], - ), - ) - const prevOrder = [ - ...useSprintWorkspaceStore.getState().relations.storyIdsBySprint['sp-1'], - ] - - const id = useSprintWorkspaceStore.getState().applyOptimisticMutation({ - kind: 'sprint-story-order', - sprintId: 'sp-1', - prevStoryIds: prevOrder, - }) - useSprintWorkspaceStore.setState((s) => { - s.relations.storyIdsBySprint['sp-1'] = ['b', 'a'] - }) - - useSprintWorkspaceStore.getState().rollbackMutation(id) - expect(useSprintWorkspaceStore.getState().relations.storyIdsBySprint['sp-1']).toEqual( - prevOrder, - ) - expect(useSprintWorkspaceStore.getState().pendingMutations[id]).toBeUndefined() - }) - - it('settle ruimt pending op zonder state te wijzigen', () => { - useSprintWorkspaceStore.getState().hydrateSnapshot( - snapshotWith(makeSprint({ id: 'sp-1', product_id: 'prod-1' }), [ - makeStory({ id: 'a', pbi_id: 'p', sprint_id: 'sp-1' }), - ]), - ) - const id = useSprintWorkspaceStore.getState().applyOptimisticMutation({ - kind: 'sprint-story-order', - sprintId: 'sp-1', - prevStoryIds: ['a'], - }) - expect(useSprintWorkspaceStore.getState().pendingMutations[id]).toBeDefined() - - useSprintWorkspaceStore.getState().settleMutation(id) - expect(useSprintWorkspaceStore.getState().pendingMutations[id]).toBeUndefined() - expect(useSprintWorkspaceStore.getState().relations.storyIdsBySprint['sp-1']).toEqual([ - 'a', - ]) - }) - it('SSE-echo van een al-bestaande sprint is idempotent', () => { useSprintWorkspaceStore.setState((s) => { s.context.activeProduct = { id: 'prod-1', name: 'P' } diff --git a/actions/ideas.ts b/actions/ideas.ts index 62dc4e2..63bae6d 100644 --- a/actions/ideas.ts +++ b/actions/ideas.ts @@ -21,6 +21,7 @@ import { canTransition, isGrillMdEditable, isIdeaEditable, isPlanMdEditable } fr import { nextIdeaCode } from '@/lib/idea-code-server' import { parsePlanMd } from '@/lib/idea-plan-parser' import { ACTIVE_JOB_STATUSES } from '@/lib/job-status' +import { parseCodeNumber } from '@/lib/code' import type { ClaudeJobKind, Idea, IdeaStatus } from '@prisma/client' @@ -720,16 +721,17 @@ export async function materializeIdeaPlanAction( for (let si = 0; si < plan.stories.length; si++) { const s = plan.stories[si] + const storyCode = `ST-${String(nextStoryN++).padStart(3, '0')}` const story = await tx.story.create({ data: { pbi_id: pbi.id, product_id: productId, - code: `ST-${String(nextStoryN++).padStart(3, '0')}`, + code: storyCode, title: s.title, description: s.description ?? null, acceptance_criteria: s.acceptance_criteria ?? null, priority: s.priority, - sort_order: si + 1, // sequential within PBI + sort_order: parseCodeNumber(storyCode), status: 'OPEN', }, select: { id: true }, @@ -738,11 +740,12 @@ export async function materializeIdeaPlanAction( for (let ti = 0; ti < s.tasks.length; ti++) { const t = s.tasks[ti] + const taskCode = `T-${nextTaskN++}` const task = await tx.task.create({ data: { story_id: story.id, product_id: productId, - code: `T-${nextTaskN++}`, + code: taskCode, title: t.title, description: t.description ?? null, implementation_plan: t.implementation_plan ?? null, @@ -751,7 +754,7 @@ export async function materializeIdeaPlanAction( // gemixte task-priorities binnen één story zouden anders de // YAML-volgorde verstoren (zie plan-fix task-volgorde-na-upload). priority: s.priority, - sort_order: ti + 1, + sort_order: parseCodeNumber(taskCode), status: 'TO_DO', verify_required: t.verify_required ?? 'ALIGNED_OR_PARTIAL', verify_only: t.verify_only ?? false, diff --git a/actions/sprint-runs.ts b/actions/sprint-runs.ts index a08118b..8b232d0 100644 --- a/actions/sprint-runs.ts +++ b/actions/sprint-runs.ts @@ -85,10 +85,10 @@ async function startSprintRunCore( // TO_DO, dus EXCLUDED/IN_PROGRESS/REVIEW/DONE/FAILED tasks komen niet // terecht in pre-flight blockers, jobs of SprintTaskExecution-rijen. where: { status: 'TO_DO' }, - orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], + orderBy: [{ sort_order: 'asc' }], }, }, - orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], + orderBy: [{ sort_order: 'asc' }], }) const blockers: PreFlightBlocker[] = [] @@ -167,7 +167,6 @@ async function startSprintRunCore( (a, b) => a.pbi.priority - b.pbi.priority || a.pbi.sort_order - b.pbi.sort_order || - a.priority - b.priority || a.sort_order - b.sort_order, ) .flatMap((s) => s.tasks) diff --git a/actions/sprints.ts b/actions/sprints.ts index 499a87e..8ccc80e 100644 --- a/actions/sprints.ts +++ b/actions/sprints.ts @@ -431,7 +431,7 @@ export async function createSprintAction(_prevState: unknown, formData: FormData if (pbi) { const stories = await prisma.story.findMany({ where: { pbi_id: pbi.id, sprint_id: null }, - orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], + orderBy: [{ sort_order: 'asc' }], select: { id: true }, }) if (stories.length > 0) { @@ -440,7 +440,7 @@ export async function createSprintAction(_prevState: unknown, formData: FormData ...stories.map((s, i) => prisma.story.update({ where: { id: s.id }, - data: { sprint_id: sprint.id, status: 'IN_SPRINT', sort_order: i + 1 }, + data: { sprint_id: sprint.id, status: 'IN_SPRINT' }, }), ), prisma.task.updateMany({ @@ -531,14 +531,9 @@ export async function addStoryToSprintAction(sprintId: string, storyId: string) if (!story) return { error: 'Story niet gevonden' } if (story.product_id !== sprint.product_id) return { error: 'Story hoort niet bij deze Sprint' } - const last = await prisma.story.findFirst({ - where: { sprint_id: sprintId }, - orderBy: { sort_order: 'desc' }, - }) - await prisma.story.update({ where: { id: storyId }, - data: { sprint_id: sprintId, status: 'IN_SPRINT', sort_order: (last?.sort_order ?? 0) + 1.0 }, + data: { sprint_id: sprintId, status: 'IN_SPRINT' }, }) revalidatePath(`/products/${sprint.product_id}/sprint`) @@ -567,32 +562,6 @@ export async function removeStoryFromSprintAction(storyId: string) { return { success: true } } -export async function reorderSprintStoriesAction(sprintId: string, orderedIds: string[]) { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd' } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } - if (hasDuplicateIds(orderedIds)) return { error: 'Ongeldige Sprint Backlog-volgorde' } - - const sprint = await prisma.sprint.findFirst({ - where: { id: sprintId, product: productAccessFilter(session.userId) }, - }) - if (!sprint) return { error: 'Sprint niet gevonden' } - - const stories = await prisma.story.findMany({ - where: { id: { in: orderedIds }, sprint_id: sprintId, product_id: sprint.product_id }, - select: { id: true }, - }) - if (stories.length !== orderedIds.length) return { error: 'Ongeldige Sprint Backlog-volgorde' } - - await prisma.$transaction( - orderedIds.map((id, i) => - prisma.story.update({ where: { id }, data: { sort_order: i + 1.0 } }) - ) - ) - - revalidatePath(`/products/${sprint.product_id}/sprint`) - return { success: true } -} export async function completeSprintAction( sprintId: string, diff --git a/actions/stories.ts b/actions/stories.ts index bcc88fc..dbac04a 100644 --- a/actions/stories.ts +++ b/actions/stories.ts @@ -7,7 +7,7 @@ import { prisma } from '@/lib/prisma' import { SessionData, sessionOptions } from '@/lib/session' import { getAccessibleProduct, productAccessFilter } from '@/lib/product-access' import { requireProductWriter } from '@/lib/auth' -import { isValidCode, normalizeCode } from '@/lib/code' +import { isValidCode, normalizeCode, parseCodeNumber } from '@/lib/code' import { createWithCodeRetry, generateNextStoryCode } from '@/lib/code-server' import { createStorySchema, updateStorySchema } from '@/lib/schemas/story' import { enforceUserRateLimit } from '@/lib/rate-limit' @@ -78,12 +78,6 @@ export async function createStoryAction(_prevState: unknown, formData: FormData) } } - const last = await prisma.story.findFirst({ - where: { pbi_id: parsed.data.pbiId, priority: parsed.data.priority }, - orderBy: { sort_order: 'desc' }, - }) - const sort_order = (last?.sort_order ?? 0) + 1.0 - const insert = (code: string) => prisma.story.create({ data: { @@ -94,7 +88,7 @@ export async function createStoryAction(_prevState: unknown, formData: FormData) description: parsed.data.description ?? null, acceptance_criteria: parsed.data.acceptance_criteria ?? null, priority: parsed.data.priority, - sort_order, + sort_order: parseCodeNumber(code), status: 'OPEN', }, }) @@ -167,7 +161,7 @@ export async function updateStoryAction(_prevState: unknown, formData: FormData) await prisma.story.update({ where: { id: parsed.data.id }, data: { - ...(code ? { code } : {}), + ...(code ? { code, sort_order: parseCodeNumber(code) } : {}), title: parsed.data.title, description: parsed.data.description ?? null, acceptance_criteria: parsed.data.acceptance_criteria ?? null, @@ -363,43 +357,3 @@ export async function claimAllUnassignedInActiveSprintAction(productId: string) return { success: true, count: result.count } } -export async function reorderStoriesAction( - pbiId: string, - productId: string, - orderedIds: string[], - newPriority?: number -) { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd' } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } - if (hasDuplicateIds(orderedIds)) return { error: 'Ongeldige story-volgorde' } - if (newPriority !== undefined && (!Number.isInteger(newPriority) || newPriority < 1 || newPriority > 4)) { - return { error: 'Ongeldige prioriteit' } - } - - const pbi = await prisma.pbi.findFirst({ - where: { id: pbiId, product: productAccessFilter(session.userId) }, - }) - if (!pbi) return { error: 'PBI niet gevonden' } - - const stories = await prisma.story.findMany({ - where: { id: { in: orderedIds }, pbi_id: pbiId, product_id: pbi.product_id }, - select: { id: true }, - }) - if (stories.length !== orderedIds.length) return { error: 'Ongeldige story-volgorde' } - - await prisma.$transaction( - orderedIds.map((id, i) => - prisma.story.update({ - where: { id }, - data: { - sort_order: i + 1.0, - ...(newPriority !== undefined ? { priority: newPriority } : {}), - }, - }) - ) - ) - - revalidatePath(`/products/${pbi.product_id}`) - return { success: true } -} diff --git a/actions/tasks.ts b/actions/tasks.ts index 7b83e03..1a4ef45 100644 --- a/actions/tasks.ts +++ b/actions/tasks.ts @@ -10,7 +10,7 @@ import { productAccessFilter } from '@/lib/product-access' import { requireProductWriter } from '@/lib/auth' import { taskSchema as sharedTaskSchema, type TaskInput } from '@/lib/schemas/task' import { propagateStatusUpwards } from '@/lib/tasks-status-update' -import { normalizeCode } from '@/lib/code' +import { normalizeCode, parseCodeNumber } from '@/lib/code' import { createWithCodeRetry, generateNextTaskCode, isCodeUniqueConflict } from '@/lib/code-server' import { enforceUserRateLimit } from '@/lib/rate-limit' @@ -80,6 +80,7 @@ export async function saveTask( description: description ?? null, implementation_plan: implementation_plan ?? null, priority, + ...(inputCode ? { code: inputCode, sort_order: parseCodeNumber(inputCode) } : {}), }, select: { id: true, title: true, status: true }, }) @@ -106,15 +107,8 @@ export async function saveTask( }) 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 productId = story.product_id const sprintId = story.sprint_id ?? null - const sortOrder = (last?.sort_order ?? 0) + 1.0 const storyId = context.storyId const task = await createWithCodeRetry( @@ -130,7 +124,7 @@ export async function saveTask( description: description ?? null, implementation_plan: implementation_plan ?? null, priority, - sort_order: sortOrder, + sort_order: parseCodeNumber(code), status: 'TO_DO', }, select: { id: true, title: true, status: true }, @@ -207,11 +201,6 @@ export async function createTaskAction(_prevState: unknown, formData: FormData) }) if (!story) return { error: 'Story niet gevonden' } - const last = await prisma.task.findFirst({ - where: { story_id: storyId }, - orderBy: { sort_order: 'desc' }, - }) - const productId = story.product_id const task = await createWithCodeRetry( () => generateNextTaskCode(productId), @@ -225,7 +214,7 @@ export async function createTaskAction(_prevState: unknown, formData: FormData) title: parsed.data.title, description: parsed.data.description ?? null, priority: parsed.data.priority, - sort_order: (last?.sort_order ?? 0) + 1.0, + sort_order: parseCodeNumber(code), status: 'TO_DO', }, }), @@ -333,22 +322,3 @@ export async function updateTaskPlanAction(taskId: string, productId: string, im return { success: true } } -export async function reorderTasksAction(storyId: string, orderedIds: string[]) { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd' } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } - - const story = await prisma.story.findFirst({ - where: { id: storyId, product: productAccessFilter(session.userId) }, - }) - if (!story) return { error: 'Story niet gevonden' } - - await prisma.$transaction( - orderedIds.map((id, i) => - prisma.task.update({ where: { id }, data: { sort_order: i + 1.0 } }) - ) - ) - - revalidatePath(`/products/${story.product_id}/sprint/planning`) - return { success: true } -} diff --git a/app/(app)/products/[id]/page.tsx b/app/(app)/products/[id]/page.tsx index 1b645bf..5157b73 100644 --- a/app/(app)/products/[id]/page.tsx +++ b/app/(app)/products/[id]/page.tsx @@ -56,7 +56,7 @@ export default async function ProductBacklogPage({ params, searchParams }: Props const [stories, tasks] = await Promise.all([ prisma.story.findMany({ where: { product_id: id }, - orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], + orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], select: { id: true, code: true, @@ -75,6 +75,7 @@ export default async function ProductBacklogPage({ params, searchParams }: Props where: { story: { pbi: { product_id: id } } }, select: { id: true, + code: true, title: true, description: true, priority: true, @@ -83,7 +84,7 @@ export default async function ProductBacklogPage({ params, searchParams }: Props story_id: true, created_at: true, }, - orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], + orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], }), ]) diff --git a/app/(app)/products/[id]/sprint/[sprintId]/page.tsx b/app/(app)/products/[id]/sprint/[sprintId]/page.tsx index 992c217..2981c47 100644 --- a/app/(app)/products/[id]/sprint/[sprintId]/page.tsx +++ b/app/(app)/products/[id]/sprint/[sprintId]/page.tsx @@ -72,7 +72,7 @@ export default async function SprintBoardPage({ params, searchParams }: Props) { where: { sprint_id: sprint.id }, orderBy: { sort_order: 'asc' }, include: { - tasks: { orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }] }, + tasks: { orderBy: [{ sort_order: 'asc' }] }, assignee: { select: { id: true, username: true } }, }, }), @@ -128,7 +128,7 @@ export default async function SprintBoardPage({ params, searchParams }: Props) { orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], include: { stories: { - orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], + orderBy: [{ sort_order: 'asc' }], }, }, }) diff --git a/app/(mobile)/m/products/[id]/page.tsx b/app/(mobile)/m/products/[id]/page.tsx index 479db27..4aa5815 100644 --- a/app/(mobile)/m/products/[id]/page.tsx +++ b/app/(mobile)/m/products/[id]/page.tsx @@ -42,7 +42,7 @@ export default async function MobileProductBacklogPage({ params, searchParams }: const [stories, tasks] = await Promise.all([ prisma.story.findMany({ where: { product_id: id }, - orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], + orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], select: { id: true, code: true, @@ -61,6 +61,7 @@ export default async function MobileProductBacklogPage({ params, searchParams }: where: { story: { pbi: { product_id: id } } }, select: { id: true, + code: true, title: true, description: true, priority: true, @@ -69,7 +70,7 @@ export default async function MobileProductBacklogPage({ params, searchParams }: story_id: true, created_at: true, }, - orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], + orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], }), ]) diff --git a/app/api/pbis/[id]/stories/route.ts b/app/api/pbis/[id]/stories/route.ts index 8cb760b..67de693 100644 --- a/app/api/pbis/[id]/stories/route.ts +++ b/app/api/pbis/[id]/stories/route.ts @@ -30,7 +30,7 @@ export async function GET( const stories = await prisma.story.findMany({ where: { pbi_id: id }, - orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }, { created_at: 'asc' }], + orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], select: { id: true, code: true, diff --git a/app/api/products/[id]/backlog/route.ts b/app/api/products/[id]/backlog/route.ts index 14ef956..9badc35 100644 --- a/app/api/products/[id]/backlog/route.ts +++ b/app/api/products/[id]/backlog/route.ts @@ -46,7 +46,7 @@ export async function GET( }), prisma.story.findMany({ where: { product_id: id }, - orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }, { created_at: 'asc' }], + orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], select: { id: true, code: true, @@ -66,6 +66,7 @@ export async function GET( orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], select: { id: true, + code: true, title: true, description: true, priority: true, diff --git a/app/api/products/[id]/claude-context/route.ts b/app/api/products/[id]/claude-context/route.ts index 3611c64..556d6d7 100644 --- a/app/api/products/[id]/claude-context/route.ts +++ b/app/api/products/[id]/claude-context/route.ts @@ -58,7 +58,7 @@ export async function GET( if (activeSprint) { const story = await prisma.story.findFirst({ where: { sprint_id: activeSprint.id, status: 'IN_SPRINT' }, - orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], + orderBy: [{ sort_order: 'asc' }], include: { tasks: { orderBy: { sort_order: 'asc' }, diff --git a/app/api/products/[id]/next-story/route.ts b/app/api/products/[id]/next-story/route.ts index 4ab4529..f2dd414 100644 --- a/app/api/products/[id]/next-story/route.ts +++ b/app/api/products/[id]/next-story/route.ts @@ -23,7 +23,7 @@ export async function GET( const story = await prisma.story.findFirst({ where: { sprint_id: sprint.id, status: 'IN_SPRINT' }, - orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], + orderBy: [{ sort_order: 'asc' }], include: { tasks: { orderBy: { sort_order: 'asc' }, diff --git a/app/api/sprints/[id]/tasks/route.ts b/app/api/sprints/[id]/tasks/route.ts index 6d1e2d3..73f88c0 100644 --- a/app/api/sprints/[id]/tasks/route.ts +++ b/app/api/sprints/[id]/tasks/route.ts @@ -28,7 +28,6 @@ export async function GET( where: { sprint_id: id }, orderBy: [ { story: { sort_order: 'asc' } }, - { priority: 'asc' }, { sort_order: 'asc' }, ], take: limit, diff --git a/app/api/sprints/[id]/workspace/route.ts b/app/api/sprints/[id]/workspace/route.ts index e3a19ab..8d48c7d 100644 --- a/app/api/sprints/[id]/workspace/route.ts +++ b/app/api/sprints/[id]/workspace/route.ts @@ -44,7 +44,7 @@ export async function GET( const [stories, tasks] = await Promise.all([ prisma.story.findMany({ where: { sprint_id: id }, - orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }, { created_at: 'asc' }], + orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], include: { tasks: { select: { id: true, status: true } }, assignee: { select: { id: true, username: true } }, diff --git a/app/api/stories/[id]/tasks/reorder/route.ts b/app/api/stories/[id]/tasks/reorder/route.ts deleted file mode 100644 index 53aeab5..0000000 --- a/app/api/stories/[id]/tasks/reorder/route.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { authenticateApiRequest } from '@/lib/api-auth' -import { prisma } from '@/lib/prisma' -import { productAccessFilter } from '@/lib/product-access' -import { z } from 'zod' - -const bodySchema = z.object({ - task_ids: z.array(z.string()).min(1), -}) - -export async function PATCH( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const auth = await authenticateApiRequest(request) - if ('error' in auth) { - return Response.json({ error: auth.error }, { status: auth.status }) - } - if (auth.isDemo) { - return Response.json({ error: 'Niet beschikbaar in demo-modus' }, { status: 403 }) - } - - const { id: storyId } = await params - - let body: unknown - try { - body = await request.json() - } catch { - return Response.json({ error: 'Malformed JSON' }, { status: 400 }) - } - const parsed = bodySchema.safeParse(body) - if (!parsed.success) { - return Response.json({ error: parsed.error.flatten() }, { status: 422 }) - } - - const story = await prisma.story.findFirst({ - where: { id: storyId, product: productAccessFilter(auth.userId) }, - include: { tasks: { select: { id: true } } }, - }) - if (!story) { - return Response.json({ error: 'Story niet gevonden' }, { status: 404 }) - } - - const storyTaskIds = new Set(story.tasks.map(t => t.id)) - const invalidId = parsed.data.task_ids.find(id => !storyTaskIds.has(id)) - if (invalidId) { - return Response.json({ error: `Ongeldig task_id: ${invalidId}` }, { status: 422 }) - } - - await prisma.$transaction( - parsed.data.task_ids.map((id, i) => - prisma.task.update({ where: { id }, data: { sort_order: i + 1.0 } }) - ) - ) - - return Response.json({ success: true }) -} diff --git a/app/api/stories/[id]/tasks/route.ts b/app/api/stories/[id]/tasks/route.ts index 9e437a4..2584ff7 100644 --- a/app/api/stories/[id]/tasks/route.ts +++ b/app/api/stories/[id]/tasks/route.ts @@ -33,6 +33,7 @@ export async function GET( orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], select: { id: true, + code: true, title: true, description: true, priority: true, diff --git a/components/backlog/story-panel.tsx b/components/backlog/story-panel.tsx index fdfd186..54e56db 100644 --- a/components/backlog/story-panel.tsx +++ b/components/backlog/story-panel.tsx @@ -1,26 +1,6 @@ 'use client' -import { useState, useTransition } from 'react' -import { - DndContext, - DragEndEvent, - DragOverlay, - DragStartEvent, - KeyboardSensor, - PointerSensor, - useSensor, - useSensors, - closestCenter, -} from '@dnd-kit/core' -import { - SortableContext, - useSortable, - rectSortingStrategy, - arrayMove, - sortableKeyboardCoordinates, -} from '@dnd-kit/sortable' -import { CSS } from '@dnd-kit/utilities' -import { toast } from 'sonner' +import { useState } from 'react' import { CheckSquare, Square } from 'lucide-react' import { Tooltip, @@ -40,7 +20,6 @@ import { selectStoryIsBlocked, } from '@/stores/product-workspace/selectors' import type { BacklogStory as WorkspaceStory } from '@/stores/product-workspace/types' -import { reorderStoriesAction } from '@/actions/stories' import { StoryDialog, type StoryDialogState } from './story-dialog' import { debugProps } from '@/lib/debug' import { BacklogCard } from './backlog-card' @@ -80,8 +59,7 @@ interface StoryPanelProps { activeSprintId?: string | null } -// --- Sortable story block --- -function SortableStoryBlock({ +function StoryBlock({ story, isSelected, cherrypick, @@ -98,26 +76,11 @@ function SortableStoryBlock({ onSelect: () => void onEdit: () => void }) { - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ - id: story.id, - }) - - const style = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.4 : 1, - } - return ( s.context.activePbiId) const selectedStoryId = useProductWorkspaceStore((s) => s.context.activeStoryId) @@ -210,14 +171,8 @@ export function StoryPanel({ productId, isDemo, activeSprintId = null }: StoryPa const setPref = useUserSettingsStore((s) => s.setPref) const setSortMode = (v: SortMode) => void setPref(['views', 'storyPanel', 'sort'], v) const [storyDialogState, setStoryDialogState] = useState(null) - const [activeDragId, setActiveDragId] = useState(null) - const [, startTransition] = useTransition() - // rawStories komt al gesorteerd binnen via selectStoriesForActivePbi. - const storyMap = Object.fromEntries(rawStories.map(s => [s.id, s])) - const orderedStories = rawStories - - const base = orderedStories + const base = rawStories .filter(s => !filterStatus || s.status === filterStatus) .filter(s => !filterPriority || s.priority === filterPriority) @@ -231,74 +186,6 @@ export function StoryPanel({ productId, isDemo, activeSprintId = null }: StoryPa return a.priority !== b.priority ? a.priority - b.priority : 0 }) - const sensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), - useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) - ) - - function handleDragStart(event: DragStartEvent) { - setActiveDragId(event.active.id as string) - } - - function handleDragEnd(event: DragEndEvent) { - setActiveDragId(null) - const { active, over } = event - if (!over || active.id === over.id || !selectedPbiId) return - - const activeStory = storyMap[active.id as string] - const overStory = storyMap[over.id as string] - if (!activeStory || !overStory) return - - const store = useProductWorkspaceStore.getState() - const prevOrder = [...(store.relations.storyIdsByPbi[selectedPbiId] ?? [])] - const oldIndex = prevOrder.indexOf(active.id as string) - const newIndex = prevOrder.indexOf(over.id as string) - if (oldIndex === -1 || newIndex === -1) return - const newOrder = arrayMove([...prevOrder], oldIndex, newIndex) - - const orderMutationId = store.applyOptimisticMutation({ - kind: 'story-order', - pbiId: selectedPbiId, - prevStoryIds: prevOrder, - }) - useProductWorkspaceStore.setState((s) => { - s.relations.storyIdsByPbi[selectedPbiId] = newOrder - }) - - const priorityChanged = activeStory.priority !== overStory.priority - let priorityMutationId: string | null = null - if (priorityChanged) { - priorityMutationId = store.applyOptimisticMutation({ - kind: 'entity-patch', - entity: 'story', - id: active.id as string, - prev: store.entities.storiesById[active.id as string], - }) - useProductWorkspaceStore.setState((s) => { - const story = s.entities.storiesById[active.id as string] - if (story) story.priority = overStory.priority - }) - } - - startTransition(async () => { - const result = await reorderStoriesAction( - selectedPbiId, - productId, - newOrder, - priorityChanged ? overStory.priority : undefined - ) - const st = useProductWorkspaceStore.getState() - if (result.success) { - if (priorityMutationId) st.settleMutation(priorityMutationId) - st.settleMutation(orderMutationId) - } else { - if (priorityMutationId) st.rollbackMutation(priorityMutationId) - st.rollbackMutation(orderMutationId) - toast.error('Volgorde opslaan mislukt') - } - }) - } - const hasActiveFilters = filterStatus !== null || filterPriority !== null return ( @@ -361,39 +248,19 @@ export function StoryPanel({ productId, isDemo, activeSprintId = null }: StoryPa action={{ label: 'Maak je eerste story aan', onClick: () => setStoryDialogState({ mode: 'create', pbiId: selectedPbiId, productId, defaultPriority: 2 }), disabled: isDemo }} /> ) : ( - - s.id)} strategy={rectSortingStrategy}> -
- {filtered.map(story => ( - useProductWorkspaceStore.getState().setActiveStory(story.id)} - onEdit={() => setStoryDialogState({ mode: 'edit', story, productId })} - /> - ))} -
-
- - - {activeDragId && storyMap[activeDragId] && ( - - )} - -
+
+ {filtered.map(story => ( + useProductWorkspaceStore.getState().setActiveStory(story.id)} + onEdit={() => setStoryDialogState({ mode: 'edit', story, productId })} + /> + ))} +
)} @@ -406,9 +273,7 @@ export function StoryPanel({ productId, isDemo, activeSprintId = null }: StoryPa ) } -// PBI-79 / ST-1337: wrapper rond SortableStoryBlock met cherrypick-handling. -// Subscribed per story zodat enkel de relevante rij re-rendert bij draft- of -// crossSprintBlocks-mutaties. +// PBI-79 / ST-1337: wrapper rond StoryBlock met cherrypick-handling. function StoryBlockWithCherrypick({ story, productId, @@ -443,7 +308,6 @@ function StoryBlockWithCherrypick({ } | null = null if (draft) { - // State A′: muteer draft via per-PBI overrides. const intent = draft.pbiIntent[story.pbi_id] ?? 'none' const override = draft.storyOverrides[story.pbi_id] ?? { add: [], @@ -474,7 +338,6 @@ function StoryBlockWithCherrypick({ }, } } else if (activeSprintId) { - // State B: muteer pending buffer via toggleStorySprintMembership. const inSprintDb = story.sprint_id === activeSprintId const inAdds = pending.adds.includes(story.id) const inRemoves = pending.removes.includes(story.id) @@ -489,7 +352,7 @@ function StoryBlockWithCherrypick({ } return ( - = { DONE: 'Klaar', } -function SortableTaskCard({ +function TaskCard({ task, - isDemo, onClick, }: { task: BacklogTask | TaskDetail - isDemo: boolean onClick: () => void }) { - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = - useSortable({ id: task.id }) - - const style = { - transform: CSS.Transform.toString(transform), - transition, - } - return ( s.context.activeStoryId) const rawTasks = useProductWorkspaceStore(useShallow(selectTasksForActiveStory)) as | (BacklogTask | TaskDetail)[] - const [activeDragId, setActiveDragId] = useState(null) const tasks: (BacklogTask | TaskDetail)[] | null = selectedStoryId ? rawTasks : null - const sensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), - useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), - ) - - function handleDragStart(event: DragStartEvent) { - setActiveDragId(event.active.id as string) - } - - function handleDragEnd(event: DragEndEvent) { - setActiveDragId(null) - if (!selectedStoryId || !tasks) return - const { active, over } = event - if (!over || active.id === over.id) return - - const store = useProductWorkspaceStore.getState() - const prevOrder = [...(store.relations.taskIdsByStory[selectedStoryId] ?? [])] - const oldIndex = prevOrder.indexOf(active.id as string) - const newIndex = prevOrder.indexOf(over.id as string) - if (oldIndex === -1 || newIndex === -1) return - const newOrder = arrayMove([...prevOrder], oldIndex, newIndex) - - const orderMutationId = store.applyOptimisticMutation({ - kind: 'task-order', - storyId: selectedStoryId, - prevTaskIds: prevOrder, - }) - useProductWorkspaceStore.setState((s) => { - s.relations.taskIdsByStory[selectedStoryId] = newOrder - }) - - startTransition(async () => { - const result = await reorderTasksAction(selectedStoryId, newOrder) - const st = useProductWorkspaceStore.getState() - if (result?.error) { - st.rollbackMutation(orderMutationId) - toast.error(result.error) - } else { - st.settleMutation(orderMutationId) - } - }) - } - const navActions = (