UI-laag voor de sprint-definitie-flow (state A′).
Nieuw:
- NewSprintMetadataDialog (stap 1): sprint_goal + optionele dates;
'Verder' schrijft via useUserSettingsStore.setPendingSprintDraft.
- SprintDefinitionBanner (sticky): toont doel + X PBI's / Y stories teller;
'Annuleren' → AlertDialog confirm → clearPendingSprintDraft;
'Sprint aanmaken' nog niet aangesloten (wacht op ST-1339).
- NewSprintTrigger: button in page header die de metadata-dialog opent;
verbergt zichzelf zolang er al een draft loopt.
- SprintDraftBanner: client-wrapper, rendert banner alleen als draft bestaat.
Wijzigingen:
- lib/user-settings.ts: pendingSprintDraft startAt/endAt → z.string().date().
- PbiList: oude selectionMode + selectedIds + NewSprintDialog vervangen door
hasDraft-afgeleide A′-mode met tri-state vinkjes; togglen muteert
upsertPbiIntent('all'|'none') en wist storyOverrides per PBI.
- StoryPanel: in A′-mode toont elke story een cherrypick-checkbox die
upsertStoryOverride('add'/'remove'/'clear') aanroept; cross-sprint-blocked
stories krijgen disabled-icoon met sprint-naam tooltip.
- app/(app)/products/[id]/page.tsx: StartSprintButton vervangen door
NewSprintTrigger; SprintDraftBanner gepositioneerd boven split-pane.
Tests: bestaande tests blijven groen (806 cases) — UI-specifieke component
tests volgen later. ST-1339 sluit createSprintWithSelectionAction aan.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
121 lines
3.7 KiB
TypeScript
121 lines
3.7 KiB
TypeScript
'use server'
|
|
|
|
import { revalidatePath } from 'next/cache'
|
|
import { cookies } from 'next/headers'
|
|
import { getIronSession } from 'iron-session'
|
|
import { z } from 'zod'
|
|
import type { Prisma } from '@prisma/client'
|
|
import { prisma } from '@/lib/prisma'
|
|
import { SessionData, sessionOptions } from '@/lib/session'
|
|
import { productAccessFilter } from '@/lib/product-access'
|
|
import {
|
|
mergeSettings,
|
|
parseUserSettings,
|
|
type PendingSprintDraft,
|
|
type UserSettings,
|
|
} from '@/lib/user-settings'
|
|
|
|
async function getSession() {
|
|
return getIronSession<SessionData>(await cookies(), sessionOptions)
|
|
}
|
|
|
|
const StoryOverridesSchema = z.object({
|
|
add: z.array(z.string()),
|
|
remove: z.array(z.string()),
|
|
}).strict()
|
|
|
|
const DraftSchema = z.object({
|
|
goal: z.string().min(1),
|
|
startAt: z.string().date().optional(),
|
|
endAt: z.string().date().optional(),
|
|
pbiIntent: z.record(z.string(), z.enum(['all', 'none'])).default({}),
|
|
storyOverrides: z.record(z.string(), StoryOverridesSchema).default({}),
|
|
}).strict()
|
|
|
|
const SetSchema = z.object({
|
|
productId: z.string().min(1),
|
|
draft: DraftSchema,
|
|
})
|
|
|
|
const ClearSchema = z.object({
|
|
productId: z.string().min(1),
|
|
})
|
|
|
|
async function ensureProductAccess(userId: string, productId: string) {
|
|
return prisma.product.findFirst({
|
|
where: { id: productId, ...productAccessFilter(userId) },
|
|
select: { id: true },
|
|
})
|
|
}
|
|
|
|
async function readUserSettings(userId: string): Promise<UserSettings> {
|
|
const user = await prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: { settings: true },
|
|
})
|
|
return parseUserSettings(user?.settings)
|
|
}
|
|
|
|
async function writeUserSettings(userId: string, next: UserSettings) {
|
|
await prisma.user.update({
|
|
where: { id: userId },
|
|
data: { settings: next as unknown as Prisma.InputJsonValue },
|
|
})
|
|
}
|
|
|
|
export async function setPendingSprintDraftAction(
|
|
productId: string,
|
|
draft: PendingSprintDraft,
|
|
) {
|
|
const session = await getSession()
|
|
if (!session.userId) return { error: 'Niet ingelogd' }
|
|
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
|
|
|
|
const parsed = SetSchema.safeParse({ productId, draft })
|
|
if (!parsed.success) {
|
|
return { error: 'Ongeldige draft', issues: parsed.error.issues }
|
|
}
|
|
|
|
const product = await ensureProductAccess(session.userId, parsed.data.productId)
|
|
if (!product) return { error: 'Product niet gevonden of niet toegankelijk' }
|
|
|
|
const current = await readUserSettings(session.userId)
|
|
const patch: Partial<UserSettings> = {
|
|
workflow: {
|
|
pendingSprintDraft: {
|
|
...(current.workflow?.pendingSprintDraft ?? {}),
|
|
[parsed.data.productId]: parsed.data.draft,
|
|
},
|
|
},
|
|
}
|
|
await writeUserSettings(session.userId, mergeSettings(current, patch))
|
|
revalidatePath('/', 'layout')
|
|
return { success: true }
|
|
}
|
|
|
|
export async function clearPendingSprintDraftAction(productId: string) {
|
|
const session = await getSession()
|
|
if (!session.userId) return { error: 'Niet ingelogd' }
|
|
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
|
|
|
|
const parsed = ClearSchema.safeParse({ productId })
|
|
if (!parsed.success) return { error: 'Ongeldig product-id' }
|
|
|
|
const product = await ensureProductAccess(session.userId, parsed.data.productId)
|
|
if (!product) return { error: 'Product niet gevonden of niet toegankelijk' }
|
|
|
|
const current = await readUserSettings(session.userId)
|
|
const existingMap = current.workflow?.pendingSprintDraft
|
|
if (!existingMap || !(parsed.data.productId in existingMap)) {
|
|
return { success: true }
|
|
}
|
|
const nextMap = { ...existingMap }
|
|
delete nextMap[parsed.data.productId]
|
|
const next: UserSettings = {
|
|
...current,
|
|
workflow: { ...current.workflow, pendingSprintDraft: nextMap },
|
|
}
|
|
await writeUserSettings(session.userId, next)
|
|
revalidatePath('/', 'layout')
|
|
return { success: true }
|
|
}
|