diff --git a/actions/sprint-draft.ts b/actions/sprint-draft.ts index ddbe395..37beb54 100644 --- a/actions/sprint-draft.ts +++ b/actions/sprint-draft.ts @@ -26,8 +26,8 @@ const StoryOverridesSchema = z.object({ const DraftSchema = z.object({ goal: z.string().min(1), - startAt: z.string().datetime().optional(), - endAt: z.string().datetime().optional(), + 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() diff --git a/app/(app)/products/[id]/page.tsx b/app/(app)/products/[id]/page.tsx index 386fe56..8ea05fe 100644 --- a/app/(app)/products/[id]/page.tsx +++ b/app/(app)/products/[id]/page.tsx @@ -15,7 +15,8 @@ import { UrlTaskSync } from '@/components/backlog/url-task-sync' 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 { StartSprintButton } from '@/components/sprint/start-sprint-button' +import { NewSprintTrigger } from '@/components/backlog/new-sprint-trigger' +import { SprintDraftBanner } from '@/components/backlog/sprint-draft-banner' import { ActivateProductButton } from '@/components/shared/activate-product-button' import { EditProductButton } from '@/components/products/edit-product-button' import { SprintSwitcher } from '@/components/shared/sprint-switcher' @@ -118,13 +119,12 @@ export default async function ProductBacklogPage({ params, searchParams }: Props {!isActiveProduct && ( )} - {hasOpenSprint ? ( + {hasOpenSprint && ( Sprint actief → - ) : ( - !isDemo && )} + {!isDemo && } {!isDemo && product.user_id === session.userId && ( + {/* Sprint definition banner (state A′) */} + + {/* Split pane */} void +} + +function todayLocalDate(): string { + return new Date().toLocaleDateString('en-CA') +} + +function plusWeeks(weeks: number): string { + const d = new Date() + d.setDate(d.getDate() + weeks * 7) + return d.toLocaleDateString('en-CA') +} + +export function NewSprintMetadataDialog({ + open, + productId, + onOpenChange, +}: NewSprintMetadataDialogProps) { + const [sprintGoal, setSprintGoal] = useState('') + const [startDate, setStartDate] = useState(todayLocalDate()) + const [endDate, setEndDate] = useState(plusWeeks(2)) + const [error, setError] = useState(null) + const [dirty, setDirty] = useState(false) + const [isPending, startTransition] = useTransition() + const formRef = useRef(null) + const setPendingSprintDraft = useUserSettingsStore( + (s) => s.setPendingSprintDraft, + ) + + function reset() { + setSprintGoal('') + setStartDate(todayLocalDate()) + setEndDate(plusWeeks(2)) + setError(null) + setDirty(false) + } + + const closeGuard = useDirtyCloseGuard(dirty, () => { + onOpenChange(false) + reset() + }) + + function handleSubmit(e: React.FormEvent) { + e.preventDefault() + const goal = sprintGoal.trim() + if (!goal) return + setError(null) + startTransition(async () => { + try { + await setPendingSprintDraft(productId, { + goal, + startAt: startDate || undefined, + endAt: endDate || undefined, + pbiIntent: {}, + storyOverrides: {}, + }) + reset() + onOpenChange(false) + } catch (err) { + const message = + err instanceof Error ? err.message : 'Onbekende fout bij opslaan' + setError(message) + toast.error(message) + } + }) + } + + const handleKeyDown = useDialogSubmitShortcut(() => + formRef.current?.requestSubmit(), + ) + + return ( + <> + { + if (!o) closeGuard.attemptClose() + else onOpenChange(o) + }} + > + + + + Nieuwe sprint + + + Geef het sprint-doel en periode op. Je selecteert daarna PBI's + en stories via vinkjes in de backlog. + + + + setDirty(true)} + className="flex-1 overflow-y-auto px-6 py-6 space-y-6" + > + + + Sprint Goal * + + setSprintGoal(e.target.value)} + required + rows={3} + placeholder="Wat wil je aan het einde van deze Sprint bereikt hebben?" + autoFocus + /> + + + + + + Startdatum + + setStartDate(e.target.value)} + className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary" + /> + + + + Einddatum + + setEndDate(e.target.value)} + className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary" + /> + + + + {error && ( + + {error} + + )} + + + + + + Annuleren + + + {isPending ? 'Opslaan…' : 'Verder'} + + + + + + + + > + ) +} diff --git a/components/backlog/new-sprint-trigger.tsx b/components/backlog/new-sprint-trigger.tsx new file mode 100644 index 0000000..ccf07d7 --- /dev/null +++ b/components/backlog/new-sprint-trigger.tsx @@ -0,0 +1,46 @@ +'use client' + +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import { DemoTooltip } from '@/components/shared/demo-tooltip' +import { useUserSettingsStore } from '@/stores/user-settings/store' +import { NewSprintMetadataDialog } from './new-sprint-metadata-dialog' + +interface NewSprintTriggerProps { + productId: string + isDemo: boolean +} + +/** + * PBI-79 / ST-1337: trigger-knop voor de nieuwe sprint-flow. + * Verbergt zichzelf wanneer er al een pendingSprintDraft loopt — dan + * staat de SprintDefinitionBanner zelf de afronding te regelen. + */ +export function NewSprintTrigger({ productId, isDemo }: NewSprintTriggerProps) { + const [open, setOpen] = useState(false) + const hasDraft = useUserSettingsStore( + (s) => !!s.entities.settings.workflow?.pendingSprintDraft?.[productId], + ) + + if (hasDraft) return null + + return ( + <> + + setOpen(true)} + disabled={isDemo} + data-debug-id="new-sprint-trigger" + > + Nieuwe sprint + + + + > + ) +} diff --git a/components/backlog/pbi-list.tsx b/components/backlog/pbi-list.tsx index 48b007e..3752d95 100644 --- a/components/backlog/pbi-list.tsx +++ b/components/backlog/pbi-list.tsx @@ -21,7 +21,7 @@ import { } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { toast } from 'sonner' -import { CheckSquare, Square } from 'lucide-react' +import { CheckSquare, MinusSquare, Square } from 'lucide-react' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { @@ -32,7 +32,11 @@ import { import { useShallow } from 'zustand/react/shallow' import { useUserSettingsStore } from '@/stores/user-settings/store' import { useProductWorkspaceStore } from '@/stores/product-workspace/store' -import { selectVisiblePbis } from '@/stores/product-workspace/selectors' +import { + selectPbiTriState, + selectVisiblePbis, + type PbiTriState, +} from '@/stores/product-workspace/selectors' import type { BacklogPbi as WorkspacePbi } from '@/stores/product-workspace/types' import { deletePbiAction } from '@/actions/pbis' import { reorderPbisAction, updatePbiPriorityAction } from '@/actions/stories' @@ -41,7 +45,6 @@ import { debugProps } from '@/lib/debug' import { PbiDialog, type PbiDialogState } from './pbi-dialog' import { BacklogCard } from './backlog-card' import { EmptyPanel } from './empty-panel' -import { NewSprintDialog } from '@/components/sprint/new-sprint-dialog' import { DemoTooltip } from '@/components/shared/demo-tooltip' import { PRIORITY_COLORS } from '@/components/shared/priority-select' import { PBI_STATUS_LABELS, PBI_STATUS_COLORS } from '@/components/shared/pbi-status-select' @@ -80,12 +83,20 @@ interface PbiListProps { } // --- Sortable PBI row --- +function TriStateIcon({ state }: { state: PbiTriState }) { + if (state === 'full') + return + if (state === 'partial') + return + return +} + function SortablePbiRow({ pbi, isSelected, isDemo, selectionMode, - isChecked, + triState, onSelect, onToggleCheck, onEdit, @@ -95,7 +106,7 @@ function SortablePbiRow({ isSelected: boolean isDemo: boolean selectionMode: boolean - isChecked: boolean + triState: PbiTriState onSelect: () => void onToggleCheck: () => void onEdit: () => void @@ -112,6 +123,7 @@ function SortablePbiRow({ } if (selectionMode) { + const isChecked = triState !== 'empty' return ( - {isChecked ? : } + } /> @@ -216,23 +228,21 @@ export function PbiList({ productId, isDemo }: PbiListProps) { const [filterPopoverOpen, setFilterPopoverOpen] = useState(false) const [dialogState, setDialogState] = useState(null) const [activeDragId, setActiveDragId] = useState(null) - const [selectionMode, setSelectionMode] = useState(false) - const [selectedIds, setSelectedIds] = useState>(new Set()) - const [newSprintOpen, setNewSprintOpen] = useState(false) const [, startTransition] = useTransition() - function exitSelection() { - setSelectionMode(false) - setSelectedIds(new Set()) - } + // PBI-79 / ST-1337: selectionMode is afgeleid van pendingSprintDraft. + // Wanneer er een draft voor dit product bestaat zit de pagina in state A′ + // en tonen we tri-state-vinkjes; klik = upsertPbiIntent. + const hasDraft = useUserSettingsStore( + (s) => !!s.entities.settings.workflow?.pendingSprintDraft?.[productId], + ) + const upsertPbiIntent = useUserSettingsStore((s) => s.upsertPbiIntent) + const selectionMode = hasDraft - function toggleCheck(id: string) { - setSelectedIds(prev => { - const next = new Set(prev) - if (next.has(id)) next.delete(id) - else next.add(id) - return next - }) + function togglePbiInDraft(id: string, currentState: PbiTriState) { + // empty → all; partial → all; full → none. + const nextIntent = currentState === 'full' ? 'none' : 'all' + void upsertPbiIntent(productId, id, nextIntent) } // pbis komen al gesorteerd binnen via selectVisiblePbis (priority + sort_order). @@ -398,21 +408,6 @@ export function PbiList({ productId, isDemo }: PbiListProps) { setSortDir('asc') }} /> - - { - if (isDemo) return - if (selectionMode) exitSelection() - else setSelectionMode(true) - }} - > - {selectionMode ? 'Selecteren stoppen' : "Selecteer PBI's"} - - {filtered.map(pbi => ( - useProductWorkspaceStore.getState().setActivePbi(pbi.id)} - onToggleCheck={() => toggleCheck(pbi.id)} + onToggle={togglePbiInDraft} onEdit={() => setDialogState({ mode: 'edit', productId, pbi })} onDelete={() => handleDelete(pbi.id)} /> @@ -474,53 +469,72 @@ export function PbiList({ productId, isDemo }: PbiListProps) { )} - {selectionMode && ( - - - {selectedIds.size} geselecteerd - - - - Annuleer - - setNewSprintOpen(true)} - > - Nieuwe sprint - - - - )} - setDialogState(null)} isDemo={isDemo} /> - - { - setNewSprintOpen(open) - if (!open) { - // Sluit selectie bij geslaagde aanmaak; bij annuleren laat de selectie staan - } - }} - onCreated={() => { - setNewSprintOpen(false) - exitSelection() - }} - /> ) } + +// PBI-79 / ST-1337: wrapper rond SortablePbiRow die zijn tri-state uit de +// workspace-store leest. Subscribed per PBI zodat alleen de relevante rij +// re-rendert bij pbiIntent/storyOverrides-mutaties. +function SortablePbiRowWithTriState({ + pbi, + isSelected, + isDemo, + selectionMode, + productId, + onSelect, + onToggle, + onEdit, + onDelete, +}: { + pbi: Pbi + isSelected: boolean + isDemo: boolean + selectionMode: boolean + productId: string + onSelect: () => void + onToggle: (id: string, currentState: PbiTriState) => void + onEdit: () => void + onDelete: () => void +}) { + // Tri-state uit pendingSprintDraft (state A′) of pbiSummary (state B). + // Wanneer geen draft: leid af van pbiSummary; wanneer wel: uit pbiIntent. + const triState = useUserSettingsStore((s) => { + const draft = s.entities.settings.workflow?.pendingSprintDraft?.[productId] + if (draft) { + const intent = draft.pbiIntent[pbi.id] ?? 'none' + const override = draft.storyOverrides[pbi.id] + if (intent === 'all') { + if (override?.remove.length) return 'partial' + return 'full' + } + if (override?.add.length) return 'partial' + return 'empty' + } + return null + }) + const summaryTriState = useProductWorkspaceStore((s) => + selectPbiTriState(s, pbi.id), + ) + const effectiveTriState: PbiTriState = + triState ?? (selectionMode ? summaryTriState : 'empty') + + return ( + onToggle(pbi.id, effectiveTriState)} + onEdit={onEdit} + onDelete={onDelete} + /> + ) +} diff --git a/components/backlog/sprint-definition-banner.tsx b/components/backlog/sprint-definition-banner.tsx new file mode 100644 index 0000000..e013881 --- /dev/null +++ b/components/backlog/sprint-definition-banner.tsx @@ -0,0 +1,176 @@ +'use client' + +import { useMemo, useState, useTransition } from 'react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { useUserSettingsStore } from '@/stores/user-settings/store' +import { useProductWorkspaceStore } from '@/stores/product-workspace/store' +import type { PendingSprintDraft } from '@/lib/user-settings' +import { debugProps } from '@/lib/debug' + +interface SprintDefinitionBannerProps { + productId: string + draft: PendingSprintDraft +} + +type DraftCounts = { + pbiCount: number + storyCount: number + hasUnknownTotal: boolean +} + +function computeCounts( + draft: PendingSprintDraft, + pbiSummary: Record< + string, + { totalStoryCount: number; inActiveSprintStoryCount: number } + >, +): DraftCounts { + let pbiCount = 0 + let storyCount = 0 + let hasUnknownTotal = false + + const seenPbis = new Set() + + for (const [pbiId, intent] of Object.entries(draft.pbiIntent)) { + if (intent === 'all') { + seenPbis.add(pbiId) + const summary = pbiSummary[pbiId] + const override = draft.storyOverrides[pbiId] + if (!summary) { + hasUnknownTotal = true + continue + } + const removed = override?.remove.length ?? 0 + storyCount += Math.max(0, summary.totalStoryCount - removed) + } + } + + for (const [pbiId, override] of Object.entries(draft.storyOverrides)) { + if (override.add.length === 0) continue + seenPbis.add(pbiId) + storyCount += override.add.length + } + + pbiCount = seenPbis.size + return { pbiCount, storyCount, hasUnknownTotal } +} + +export function SprintDefinitionBanner({ + productId, + draft, +}: SprintDefinitionBannerProps) { + const clearPendingSprintDraft = useUserSettingsStore( + (s) => s.clearPendingSprintDraft, + ) + const pbiSummary = useProductWorkspaceStore((s) => s.sprintMembership.pbiSummary) + const [isPending, startTransition] = useTransition() + const [confirmCancel, setConfirmCancel] = useState(false) + + const counts = useMemo( + () => computeCounts(draft, pbiSummary), + [draft, pbiSummary], + ) + + function handleCancel() { + setConfirmCancel(true) + } + + function confirmCancelAction() { + setConfirmCancel(false) + startTransition(async () => { + try { + await clearPendingSprintDraft(productId) + } catch (err) { + const message = + err instanceof Error ? err.message : 'Annuleren mislukt' + toast.error(message) + } + }) + } + + function handleCreate() { + // PBI-79 ST-1339 wires de createSprintWithSelectionAction in. + toast.info( + 'Sprint aanmaken is nog niet aangesloten (wordt afgerond in ST-1339).', + ) + } + + const storyLabel = counts.hasUnknownTotal + ? `${counts.storyCount}+` + : counts.storyCount + const pbiSuffix = counts.pbiCount === 1 ? '' : "'s" + + return ( + + + + + Sprint definiëren — + + + {draft.goal} + + + + {counts.pbiCount} PBI{pbiSuffix} · {storyLabel} stor + {counts.storyCount === 1 ? 'y' : 'ies'} geselecteerd + + + + + Annuleren + + + Sprint aanmaken + + + + + + Sprint-definitie annuleren? + + Je conceptselectie gaat verloren. Het sprint-doel en de + gemarkeerde PBI/stories worden verwijderd. + + + + setConfirmCancel(false)}> + Doorgaan + + + Ja, annuleren + + + + + + ) +} diff --git a/components/backlog/sprint-draft-banner.tsx b/components/backlog/sprint-draft-banner.tsx new file mode 100644 index 0000000..3d3b9ff --- /dev/null +++ b/components/backlog/sprint-draft-banner.tsx @@ -0,0 +1,22 @@ +'use client' + +import { useUserSettingsStore } from '@/stores/user-settings/store' +import { SprintDefinitionBanner } from './sprint-definition-banner' + +interface SprintDraftBannerProps { + productId: string +} + +/** + * PBI-79 / ST-1337: client-wrapper die de SprintDefinitionBanner alleen rendert + * als er een pendingSprintDraft voor dit product staat. Hydratatie loopt via + * UserSettingsBridge — dit component subscribt op die store en is daarmee + * automatisch reactief op draft-mutaties (set/clear). + */ +export function SprintDraftBanner({ productId }: SprintDraftBannerProps) { + const draft = useUserSettingsStore( + (s) => s.entities.settings.workflow?.pendingSprintDraft?.[productId], + ) + if (!draft) return null + return +} diff --git a/components/backlog/story-panel.tsx b/components/backlog/story-panel.tsx index e16d23a..853c8b4 100644 --- a/components/backlog/story-panel.tsx +++ b/components/backlog/story-panel.tsx @@ -21,6 +21,13 @@ import { } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { toast } from 'sonner' +import { CheckSquare, Square } from 'lucide-react' +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' @@ -28,7 +35,10 @@ import { PanelNavBar } from '@/components/shared/panel-nav-bar' import { useShallow } from 'zustand/react/shallow' import { useUserSettingsStore } from '@/stores/user-settings/store' import { useProductWorkspaceStore } from '@/stores/product-workspace/store' -import { selectStoriesForActivePbi } from '@/stores/product-workspace/selectors' +import { + selectStoriesForActivePbi, + 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' @@ -73,11 +83,17 @@ interface StoryPanelProps { function SortableStoryBlock({ story, isSelected, + cherrypick, onSelect, onEdit, }: { story: Story isSelected: boolean + cherrypick: { + checked: boolean + blocked: { sprintName: string } | null + onToggle: () => void + } | null onSelect: () => void onEdit: () => void }) { @@ -109,21 +125,75 @@ function SortableStoryBlock({ } actions={ - { e.stopPropagation(); onEdit() }} - className="inline-flex items-center justify-center min-h-7 min-w-7 text-muted-foreground hover:text-foreground rounded transition-colors" - aria-label="Story bewerken" - > - - - - - + + {cherrypick && } + { e.stopPropagation(); onEdit() }} + className="inline-flex items-center justify-center min-h-7 min-w-7 text-muted-foreground hover:text-foreground rounded transition-colors" + aria-label="Story bewerken" + > + + + + + + } /> ) } +function StoryCherrypickButton({ + checked, + blocked, + onToggle, +}: { + checked: boolean + blocked: { sprintName: string } | null + onToggle: () => void +}) { + const icon = checked ? ( + + ) : ( + + ) + if (blocked) { + return ( + + + e.stopPropagation()} + className="inline-flex items-center justify-center min-h-7 min-w-7 rounded opacity-40 cursor-not-allowed text-muted-foreground" + > + {icon} + + Zit in sprint {blocked.sprintName} + + + ) + } + return ( + { + e.stopPropagation() + onToggle() + }} + aria-pressed={checked} + aria-label={ + checked ? 'Story uit sprint halen' : 'Story aan sprint toevoegen' + } + className={cn( + 'inline-flex items-center justify-center min-h-7 min-w-7 rounded transition-colors', + 'text-muted-foreground hover:text-foreground', + )} + > + {icon} + + ) +} + // --- Main component --- // PBI-74 / T-850: leest stories voor active PBI via selectStoriesForActivePbi // (useShallow). DnD via applyOptimisticMutation('story-order'). @@ -300,9 +370,10 @@ export function StoryPanel({ productId, isDemo }: StoryPanelProps) { s.id)} strategy={rectSortingStrategy}> {filtered.map(story => ( - useProductWorkspaceStore.getState().setActiveStory(story.id)} onEdit={() => setStoryDialogState({ mode: 'edit', story, productId })} @@ -332,3 +403,76 @@ export function StoryPanel({ productId, isDemo }: StoryPanelProps) { ) } + +// 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. +function StoryBlockWithCherrypick({ + story, + productId, + isSelected, + onSelect, + onEdit, +}: { + story: Story + productId: string + isSelected: boolean + onSelect: () => void + onEdit: () => void +}) { + const draft = useUserSettingsStore( + (s) => s.entities.settings.workflow?.pendingSprintDraft?.[productId], + ) + const upsertStoryOverride = useUserSettingsStore((s) => s.upsertStoryOverride) + const blocked = useProductWorkspaceStore((s) => + selectStoryIsBlocked(s, story.id), + ) + + const cherrypick = draft + ? (() => { + const intent = draft.pbiIntent[story.pbi_id] ?? 'none' + const override = draft.storyOverrides[story.pbi_id] ?? { + add: [], + remove: [], + } + const checked = + (intent === 'all' && !override.remove.includes(story.id)) || + override.add.includes(story.id) + const onToggle = () => { + // Resolve next state to cancel-out cleanly. + if (intent === 'all') { + // Default = checked; toggling means "remove this story from sprint". + const isCurrentlyChecked = checked + void upsertStoryOverride( + productId, + story.pbi_id, + story.id, + isCurrentlyChecked ? 'remove' : 'clear', + ) + } else { + void upsertStoryOverride( + productId, + story.pbi_id, + story.id, + checked ? 'clear' : 'add', + ) + } + } + return { + checked, + blocked: blocked ? { sprintName: blocked.sprintName } : null, + onToggle, + } + })() + : null + + return ( + + ) +} diff --git a/lib/user-settings.ts b/lib/user-settings.ts index 3abcb0a..5139261 100644 --- a/lib/user-settings.ts +++ b/lib/user-settings.ts @@ -57,8 +57,8 @@ const StoryOverrides = z.object({ const PendingSprintDraftSchema = z.object({ goal: z.string().min(1), - startAt: z.string().datetime().optional(), - endAt: z.string().datetime().optional(), + startAt: z.string().date().optional(), + endAt: z.string().date().optional(), pbiIntent: z.record(z.string(), PbiIntent).default({}), storyOverrides: z.record(z.string(), StoryOverrides).default({}), }).strict()
+ Geef het sprint-doel en periode op. Je selecteert daarna PBI's + en stories via vinkjes in de backlog. +