Story 3 verplaatst alle UI-consumers van de oude vier stores
(useBacklogStore/usePlannerStore/useSelectionStore/useProductStore) naar de
nieuwe product-workspace-store. De oude stores blijven nog bestaan voor
hydration-wrapper en realtime-hook (dual-dispatch); Story 8 ruimt ze op.
- T-848 backlog-split-pane.tsx: leest activePbiId/activeStoryId uit
context-slice (primitives, geen useShallow nodig).
- T-849 pbi-list.tsx: selectVisiblePbis(useShallow); DnD via
applyOptimisticMutation('pbi-order' + optionele 'entity-patch' bij
cross-priority drag), met settle/rollback per server-result.
- T-850 story-panel.tsx: selectStoriesForActivePbi(useShallow); DnD via
applyOptimisticMutation('story-order' + entity-patch bij priority change).
- T-851 task-panel.tsx: selectTasksForActiveStory(useShallow); DnD via
applyOptimisticMutation('task-order'); detail-view (ensureTaskLoaded +
isDetail) zit in de task-dialog (apart component, niet in deze lijst).
- T-852 start-sprint-button.tsx: selectActivePbi + selectStoriesForActivePbi
voor free-story count.
- T-853 set-current-product.tsx: alleen workspace-store.setActiveProduct
(oude useProductStore-import verwijderd).
- T-854 G1/G2-audit: alle nieuwe selectors gebruiken module-level EMPTY
refs (G1) en useShallow voor lijsten (G2). Geen 'Maximum update depth'-
warnings tijdens npm test.
- T-855 tests bijgewerkt: backlog-split-pane.test, task-panel.test,
integration.test gebruiken nu setState op workspace-store (helpers
resetWorkspace/setActiveStoryAndTasks/selectPbi/selectStory).
Verify: lint+typecheck clean, 636/636 tests groen. UI-consumers van
oude stores zijn nu nul (uitgezonderd dual-dispatch in hydration-wrapper en
realtime-hook + dev-fingerprint-helper, die in Story 8/T-873/T-878 verdwijnen).
Refs: PBI-74, ST-1320, T-848..T-855
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
187 lines
7.6 KiB
TypeScript
187 lines
7.6 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useActionState, useRef } from 'react'
|
|
import { useRouter } from 'next/navigation'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog'
|
|
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
|
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 { createSprintAction } from '@/actions/sprints'
|
|
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
|
|
import {
|
|
selectActivePbi,
|
|
selectStoriesForActivePbi,
|
|
} from '@/stores/product-workspace/selectors'
|
|
import { useShallow } from 'zustand/react/shallow'
|
|
|
|
interface StartSprintButtonProps {
|
|
productId: string
|
|
isDemo?: boolean
|
|
}
|
|
|
|
interface ActionResult {
|
|
success?: boolean
|
|
error?: string
|
|
code?: number
|
|
fieldErrors?: Record<string, string[]>
|
|
sprintId?: string
|
|
}
|
|
|
|
function todayLocalDate() {
|
|
return new Date().toLocaleDateString('en-CA')
|
|
}
|
|
|
|
export function StartSprintButton({ productId, isDemo = false }: StartSprintButtonProps) {
|
|
const [open, setOpen] = useState(false)
|
|
const [dirty, setDirty] = useState(false)
|
|
const formRef = useRef<HTMLFormElement>(null)
|
|
const router = useRouter()
|
|
// PBI-74 / T-852: actief PBI + free-story count via workspace-store selectors.
|
|
const selectedPbiId = useProductWorkspaceStore((s) => s.context.activePbiId)
|
|
const selectedPbi = useProductWorkspaceStore(selectActivePbi)
|
|
const stories = useProductWorkspaceStore(useShallow(selectStoriesForActivePbi))
|
|
const freeStoryCount = stories.filter((story) => story.sprint_id === null).length
|
|
|
|
const [state, formAction, pending] = useActionState<ActionResult | undefined, FormData>(
|
|
async (_prev, fd) => {
|
|
const result = await createSprintAction(_prev, fd) as ActionResult
|
|
if (result?.success) {
|
|
setOpen(false)
|
|
setDirty(false)
|
|
router.refresh()
|
|
} else if (result?.code !== 422 && result?.error) {
|
|
// Toast handled by caller; here we just keep the form open
|
|
}
|
|
return result
|
|
},
|
|
undefined,
|
|
)
|
|
|
|
const fieldError = (field: string) => state?.fieldErrors?.[field]?.[0]
|
|
const globalError = state?.code !== 422 ? state?.error : undefined
|
|
|
|
const closeGuard = useDirtyCloseGuard(dirty, () => setOpen(false))
|
|
const handleKeyDown = useDialogSubmitShortcut(() => formRef.current?.requestSubmit())
|
|
|
|
return (
|
|
<>
|
|
<DemoTooltip show={isDemo}>
|
|
<Button size="sm" onClick={() => setOpen(true)} disabled={isDemo} data-debug-id="start-sprint-button">
|
|
Sprint starten
|
|
</Button>
|
|
</DemoTooltip>
|
|
|
|
<Dialog open={open} onOpenChange={(o) => { if (!o) closeGuard.attemptClose(); else setOpen(o) }}>
|
|
<DialogContent
|
|
showCloseButton={false}
|
|
onKeyDown={handleKeyDown}
|
|
className={entityDialogContentClasses}
|
|
data-debug-id="start-sprint-button__dialog"
|
|
>
|
|
<div className={entityDialogHeaderClasses}>
|
|
<DialogTitle className="text-xl font-semibold">Nieuwe Sprint starten</DialogTitle>
|
|
</div>
|
|
|
|
<form
|
|
ref={formRef}
|
|
id="start-sprint-form"
|
|
action={formAction}
|
|
onChange={() => setDirty(true)}
|
|
className="flex-1 overflow-y-auto px-6 py-6 space-y-6"
|
|
>
|
|
<input type="hidden" name="productId" value={productId} />
|
|
{selectedPbiId && <input type="hidden" name="pbi_id" value={selectedPbiId} />}
|
|
|
|
{!selectedPbi ? (
|
|
<div className="bg-warning-container text-warning-container-foreground rounded-lg px-3 py-2 text-sm border-l-4 border-warning">
|
|
Geen PBI geselecteerd — de sprint wordt leeg aangemaakt. Je kunt later stories
|
|
toevoegen via slepen.
|
|
</div>
|
|
) : freeStoryCount === 0 ? (
|
|
<div className="bg-warning-container text-warning-container-foreground rounded-lg px-3 py-2 text-sm border-l-4 border-warning">
|
|
PBI <strong>{selectedPbi.code ?? selectedPbi.id.slice(0, 8)}</strong> heeft geen
|
|
vrije stories (alle stories zitten al in een andere sprint of zijn afgerond) — de
|
|
sprint wordt leeg aangemaakt.
|
|
</div>
|
|
) : (
|
|
<div className="bg-primary-container text-primary-container-foreground rounded-lg px-3 py-2 text-sm border-l-4 border-primary">
|
|
<strong>{freeStoryCount}</strong> {freeStoryCount === 1 ? 'story' : 'stories'} van
|
|
PBI <strong>{selectedPbi.code ?? selectedPbi.id.slice(0, 8)}</strong>
|
|
{selectedPbi.title ? ` (${selectedPbi.title})` : ''} worden toegevoegd aan deze
|
|
sprint.
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-1.5">
|
|
<label className="text-sm font-medium text-foreground">
|
|
Sprint Goal <span className="text-error">*</span>
|
|
</label>
|
|
<Textarea
|
|
name="sprint_goal"
|
|
required
|
|
rows={3}
|
|
placeholder="Wat wil je aan het einde van deze Sprint bereikt hebben?"
|
|
autoFocus
|
|
aria-invalid={!!fieldError('sprint_goal')}
|
|
className={fieldError('sprint_goal') ? 'border-error' : ''}
|
|
/>
|
|
{fieldError('sprint_goal') && (
|
|
<p className="text-xs text-error">{fieldError('sprint_goal')}</p>
|
|
)}
|
|
</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" name="start_date" defaultValue={todayLocalDate()} 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" />
|
|
{fieldError('start_date') && (
|
|
<p className="text-xs text-error">{fieldError('start_date')}</p>
|
|
)}
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-sm font-medium text-foreground">Einddatum</label>
|
|
<input type="date" name="end_date" defaultValue={todayLocalDate()} 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" />
|
|
{fieldError('end_date') && (
|
|
<p className="text-xs text-error">{fieldError('end_date')}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{globalError && (
|
|
<div className="bg-error-container text-error-container-foreground rounded-lg px-3 py-2 text-sm border-l-4 border-error">
|
|
{globalError}
|
|
</div>
|
|
)}
|
|
</form>
|
|
|
|
<div className={entityDialogFooterClasses}>
|
|
<div className="flex justify-end gap-2">
|
|
<Button type="button" variant="ghost" onClick={closeGuard.attemptClose} disabled={pending}>
|
|
Annuleren
|
|
</Button>
|
|
<Button type="submit" form="start-sprint-form" disabled={pending} data-debug-id="start-sprint-button__submit">
|
|
{pending ? 'Aanmaken…' : 'Sprint starten'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<DirtyCloseGuardDialog guard={closeGuard} />
|
|
</>
|
|
)
|
|
}
|