feat(PBI-79/ST-1337): state A′ UI — metadata dialog + sticky banner + PbiList ombouw
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>
This commit is contained in:
parent
89c2356ff9
commit
947d970231
9 changed files with 708 additions and 100 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<ActivateProductButton productId={id} isDemo={isDemo} redirectTo={`/products/${id}`} />
|
||||
)}
|
||||
{hasOpenSprint ? (
|
||||
{hasOpenSprint && (
|
||||
<Link href={`/products/${id}/sprint`} className="text-xs text-primary hover:underline font-medium">
|
||||
Sprint actief →
|
||||
</Link>
|
||||
) : (
|
||||
!isDemo && <StartSprintButton productId={id} />
|
||||
)}
|
||||
{!isDemo && <NewSprintTrigger productId={id} isDemo={isDemo} />}
|
||||
{!isDemo && product.user_id === session.userId && (
|
||||
<EditProductButton
|
||||
product={{
|
||||
|
|
@ -147,6 +147,9 @@ export default async function ProductBacklogPage({ params, searchParams }: Props
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sprint definition banner (state A′) */}
|
||||
<SprintDraftBanner productId={id} />
|
||||
|
||||
{/* Split pane */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<BacklogHydrationWrapper
|
||||
|
|
|
|||
203
components/backlog/new-sprint-metadata-dialog.tsx
Normal file
203
components/backlog/new-sprint-metadata-dialog.tsx
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
'use client'
|
||||
|
||||
import { useRef, useState, useTransition } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
|
||||
import {
|
||||
useDirtyCloseGuard,
|
||||
DirtyCloseGuardDialog,
|
||||
} from '@/components/shared/use-dirty-close-guard'
|
||||
import { useDialogSubmitShortcut } from '@/components/shared/use-dialog-submit-shortcut'
|
||||
import {
|
||||
entityDialogContentClasses,
|
||||
entityDialogFooterClasses,
|
||||
entityDialogHeaderClasses,
|
||||
} from '@/components/shared/entity-dialog-layout'
|
||||
import { useUserSettingsStore } from '@/stores/user-settings/store'
|
||||
import { debugProps } from '@/lib/debug'
|
||||
|
||||
interface NewSprintMetadataDialogProps {
|
||||
open: boolean
|
||||
productId: string
|
||||
onOpenChange: (open: boolean) => 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<string | null>(null)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const formRef = useRef<HTMLFormElement>(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 (
|
||||
<>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) closeGuard.attemptClose()
|
||||
else onOpenChange(o)
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={entityDialogContentClasses}
|
||||
{...debugProps(
|
||||
'new-sprint-metadata-dialog',
|
||||
'NewSprintMetadataDialog',
|
||||
'components/backlog/new-sprint-metadata-dialog.tsx',
|
||||
)}
|
||||
>
|
||||
<div className={entityDialogHeaderClasses}>
|
||||
<DialogTitle className="text-xl font-semibold">
|
||||
Nieuwe sprint
|
||||
</DialogTitle>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Geef het sprint-doel en periode op. Je selecteert daarna PBI's
|
||||
en stories via vinkjes in de backlog.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
ref={formRef}
|
||||
id="new-sprint-metadata-form"
|
||||
onSubmit={handleSubmit}
|
||||
onChange={() => setDirty(true)}
|
||||
className="flex-1 overflow-y-auto px-6 py-6 space-y-6"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
Sprint Goal <span className="text-error">*</span>
|
||||
</label>
|
||||
<Textarea
|
||||
value={sprintGoal}
|
||||
onChange={(e) => setSprintGoal(e.target.value)}
|
||||
required
|
||||
rows={3}
|
||||
placeholder="Wat wil je aan het einde van deze Sprint bereikt hebben?"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
Startdatum
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
Einddatum
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-error-container text-error-container-foreground rounded-lg px-3 py-2 text-sm border-l-4 border-error">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<div className={entityDialogFooterClasses}>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={closeGuard.attemptClose}
|
||||
disabled={isPending}
|
||||
>
|
||||
Annuleren
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="new-sprint-metadata-form"
|
||||
disabled={isPending || !sprintGoal.trim()}
|
||||
data-debug-id="new-sprint-metadata-dialog__submit"
|
||||
>
|
||||
{isPending ? 'Opslaan…' : 'Verder'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<DirtyCloseGuardDialog guard={closeGuard} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
46
components/backlog/new-sprint-trigger.tsx
Normal file
46
components/backlog/new-sprint-trigger.tsx
Normal file
|
|
@ -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 (
|
||||
<>
|
||||
<DemoTooltip show={isDemo}>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setOpen(true)}
|
||||
disabled={isDemo}
|
||||
data-debug-id="new-sprint-trigger"
|
||||
>
|
||||
Nieuwe sprint
|
||||
</Button>
|
||||
</DemoTooltip>
|
||||
<NewSprintMetadataDialog
|
||||
open={open}
|
||||
productId={productId}
|
||||
onOpenChange={setOpen}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 <CheckSquare size={18} className="text-primary" />
|
||||
if (state === 'partial')
|
||||
return <MinusSquare size={18} className="text-primary" />
|
||||
return <Square size={18} />
|
||||
}
|
||||
|
||||
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 (
|
||||
<BacklogCard
|
||||
ref={setNodeRef}
|
||||
|
|
@ -135,7 +147,7 @@ function SortablePbiRow({
|
|||
className="inline-flex items-center justify-center min-h-7 min-w-7 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{isChecked ? <CheckSquare size={18} className="text-primary" /> : <Square size={18} />}
|
||||
<TriStateIcon state={triState} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
|
@ -216,23 +228,21 @@ export function PbiList({ productId, isDemo }: PbiListProps) {
|
|||
const [filterPopoverOpen, setFilterPopoverOpen] = useState(false)
|
||||
const [dialogState, setDialogState] = useState<PbiDialogState | null>(null)
|
||||
const [activeDragId, setActiveDragId] = useState<string | null>(null)
|
||||
const [selectionMode, setSelectionMode] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(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')
|
||||
}}
|
||||
/>
|
||||
<DemoTooltip show={isDemo}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={selectionMode ? 'default' : 'outline'}
|
||||
className="h-7 text-xs"
|
||||
disabled={isDemo}
|
||||
onClick={() => {
|
||||
if (isDemo) return
|
||||
if (selectionMode) exitSelection()
|
||||
else setSelectionMode(true)
|
||||
}}
|
||||
>
|
||||
{selectionMode ? 'Selecteren stoppen' : "Selecteer PBI's"}
|
||||
</Button>
|
||||
</DemoTooltip>
|
||||
<DemoTooltip show={isDemo}>
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
@ -445,15 +440,15 @@ export function PbiList({ productId, isDemo }: PbiListProps) {
|
|||
>
|
||||
<div className="p-3 flex flex-col gap-2" {...debugProps('pbi-list__items')}>
|
||||
{filtered.map(pbi => (
|
||||
<SortablePbiRow
|
||||
<SortablePbiRowWithTriState
|
||||
key={pbi.id}
|
||||
pbi={pbi}
|
||||
isSelected={selectedPbiId === pbi.id}
|
||||
isDemo={isDemo}
|
||||
selectionMode={selectionMode}
|
||||
isChecked={selectedIds.has(pbi.id)}
|
||||
productId={productId}
|
||||
onSelect={() => 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) {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{selectionMode && (
|
||||
<div className="border-t border-border bg-surface-container px-4 py-2 flex items-center justify-between gap-2 shrink-0">
|
||||
<span className="text-sm text-foreground">
|
||||
{selectedIds.size} geselecteerd
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 text-xs"
|
||||
onClick={exitSelection}
|
||||
>
|
||||
Annuleer
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
disabled={selectedIds.size === 0}
|
||||
onClick={() => setNewSprintOpen(true)}
|
||||
>
|
||||
Nieuwe sprint
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PbiDialog
|
||||
state={dialogState}
|
||||
onClose={() => setDialogState(null)}
|
||||
isDemo={isDemo}
|
||||
/>
|
||||
|
||||
<NewSprintDialog
|
||||
open={newSprintOpen}
|
||||
productId={productId}
|
||||
pbiIds={Array.from(selectedIds)}
|
||||
onOpenChange={(open) => {
|
||||
setNewSprintOpen(open)
|
||||
if (!open) {
|
||||
// Sluit selectie bij geslaagde aanmaak; bij annuleren laat de selectie staan
|
||||
}
|
||||
}}
|
||||
onCreated={() => {
|
||||
setNewSprintOpen(false)
|
||||
exitSelection()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<SortablePbiRow
|
||||
pbi={pbi}
|
||||
isSelected={isSelected}
|
||||
isDemo={isDemo}
|
||||
selectionMode={selectionMode}
|
||||
triState={effectiveTriState}
|
||||
onSelect={onSelect}
|
||||
onToggleCheck={() => onToggle(pbi.id, effectiveTriState)}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
176
components/backlog/sprint-definition-banner.tsx
Normal file
176
components/backlog/sprint-definition-banner.tsx
Normal file
|
|
@ -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<string>()
|
||||
|
||||
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 (
|
||||
<div
|
||||
className="sticky top-0 z-30 bg-tertiary-container text-tertiary-container-foreground border-b border-tertiary px-4 py-2.5 flex items-center gap-4"
|
||||
{...debugProps('sprint-definition-banner')}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-sm font-medium shrink-0">
|
||||
Sprint definiëren —
|
||||
</span>
|
||||
<span className="text-sm truncate" title={draft.goal}>
|
||||
{draft.goal}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs opacity-80 mt-0.5">
|
||||
{counts.pbiCount} PBI{pbiSuffix} · {storyLabel} stor
|
||||
{counts.storyCount === 1 ? 'y' : 'ies'} geselecteerd
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={handleCancel}
|
||||
disabled={isPending}
|
||||
data-debug-id="sprint-definition-banner__cancel"
|
||||
>
|
||||
Annuleren
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleCreate}
|
||||
disabled={isPending || counts.pbiCount === 0}
|
||||
data-debug-id="sprint-definition-banner__create"
|
||||
>
|
||||
Sprint aanmaken
|
||||
</Button>
|
||||
</div>
|
||||
<AlertDialog open={confirmCancel} onOpenChange={setConfirmCancel}>
|
||||
<AlertDialogContent size="sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Sprint-definitie annuleren?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Je conceptselectie gaat verloren. Het sprint-doel en de
|
||||
gemarkeerde PBI/stories worden verwijderd.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => setConfirmCancel(false)}>
|
||||
Doorgaan
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={confirmCancelAction}
|
||||
>
|
||||
Ja, annuleren
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
22
components/backlog/sprint-draft-banner.tsx
Normal file
22
components/backlog/sprint-draft-banner.tsx
Normal file
|
|
@ -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 <SprintDefinitionBanner productId={productId} draft={draft} />
|
||||
}
|
||||
|
|
@ -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({
|
|||
</Badge>
|
||||
}
|
||||
actions={
|
||||
<button
|
||||
onClick={(e) => { 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"
|
||||
>
|
||||
<svg className="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
{cherrypick && <StoryCherrypickButton {...cherrypick} />}
|
||||
<button
|
||||
onClick={(e) => { 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"
|
||||
>
|
||||
<svg className="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function StoryCherrypickButton({
|
||||
checked,
|
||||
blocked,
|
||||
onToggle,
|
||||
}: {
|
||||
checked: boolean
|
||||
blocked: { sprintName: string } | null
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const icon = checked ? (
|
||||
<CheckSquare size={16} className="text-primary" />
|
||||
) : (
|
||||
<Square size={16} />
|
||||
)
|
||||
if (blocked) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
data-disabled="true"
|
||||
aria-disabled="true"
|
||||
onClick={(e) => 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}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Zit in sprint {blocked.sprintName}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
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}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// --- 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) {
|
|||
<SortableContext items={filtered.map(s => s.id)} strategy={rectSortingStrategy}>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{filtered.map(story => (
|
||||
<SortableStoryBlock
|
||||
<StoryBlockWithCherrypick
|
||||
key={story.id}
|
||||
story={story}
|
||||
productId={productId}
|
||||
isSelected={selectedStoryId === story.id}
|
||||
onSelect={() => useProductWorkspaceStore.getState().setActiveStory(story.id)}
|
||||
onEdit={() => setStoryDialogState({ mode: 'edit', story, productId })}
|
||||
|
|
@ -332,3 +403,76 @@ export function StoryPanel({ productId, isDemo }: StoryPanelProps) {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<SortableStoryBlock
|
||||
story={story}
|
||||
isSelected={isSelected}
|
||||
cherrypick={cherrypick}
|
||||
onSelect={onSelect}
|
||||
onEdit={onEdit}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue