feat: ST-301-ST-312 M3 Sprint Backlog en Sprint Planning
- useSprintStore met sprintStoryOrder/taskOrder (ST-301) - Sprint aanmaken modal met Sprint Goal validatie (ST-302) - Sprint Backlog pagina SplitPane layout met Sprint Goal header (ST-303) - Stories toevoegen aan Sprint via knop in rechterpaneel (ST-304) - Sprint Backlog volgorde aanpassen via dnd-kit (ST-305) - Story uit Sprint verwijderen met status terug naar OPEN (ST-306) - Sprint Planning pagina SplitPane met story selectie (ST-307) - Taken aanmaken inline in rechterpaneel (ST-308) - Taak drag-and-drop verticaal met optimistische update (ST-309) - Taakstatus toggle TO_DO/IN_PROGRESS/DONE met voortgangsindicator (ST-310) - Taak inline bewerken en verwijderen (ST-311) - Sprint afronden dialoog met per-story Done/Terug keuze (ST-312) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4dd62c199c
commit
d92e548f88
12 changed files with 1480 additions and 6 deletions
144
components/sprint/sprint-header.tsx
Normal file
144
components/sprint/sprint-header.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useTransition, useActionState } from 'react'
|
||||
import { useFormStatus } from 'react-dom'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { updateSprintGoalAction, completeSprintAction } from '@/actions/sprints'
|
||||
import type { SprintStory } from './sprint-backlog'
|
||||
|
||||
interface Sprint {
|
||||
id: string
|
||||
sprint_goal: string
|
||||
status: string
|
||||
}
|
||||
|
||||
interface SprintHeaderProps {
|
||||
productId: string
|
||||
productName: string
|
||||
sprint: Sprint
|
||||
isDemo: boolean
|
||||
sprintStories: SprintStory[]
|
||||
}
|
||||
|
||||
function SaveGoalButton() {
|
||||
const { pending } = useFormStatus()
|
||||
return <Button type="submit" size="sm" disabled={pending}>{pending ? 'Opslaan…' : 'Opslaan'}</Button>
|
||||
}
|
||||
|
||||
export function SprintHeader({ productId, productName, sprint, isDemo, sprintStories }: SprintHeaderProps) {
|
||||
const [editingGoal, setEditingGoal] = useState(false)
|
||||
const [completeOpen, setCompleteOpen] = useState(false)
|
||||
const [decisions, setDecisions] = useState<Record<string, 'DONE' | 'OPEN'>>({})
|
||||
const [isCompleting, startCompleting] = useTransition()
|
||||
|
||||
const [, goalFormAction] = useActionState(
|
||||
async (_prev: unknown, fd: FormData) => {
|
||||
const result = await updateSprintGoalAction(_prev, fd)
|
||||
if (result?.success) setEditingGoal(false)
|
||||
return result
|
||||
},
|
||||
undefined
|
||||
)
|
||||
|
||||
function setDecision(storyId: string, value: 'DONE' | 'OPEN') {
|
||||
setDecisions(prev => ({ ...prev, [storyId]: value }))
|
||||
}
|
||||
|
||||
function handleComplete() {
|
||||
// Default: stories without explicit decision → OPEN
|
||||
const finalDecisions: Record<string, 'DONE' | 'OPEN'> = {}
|
||||
sprintStories.forEach(s => {
|
||||
finalDecisions[s.id] = decisions[s.id] ?? 'OPEN'
|
||||
})
|
||||
|
||||
startCompleting(async () => {
|
||||
await completeSprintAction(sprint.id, finalDecisions)
|
||||
setCompleteOpen(false)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 py-3 border-b border-border bg-surface-container-low shrink-0">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">{productName}</span>
|
||||
<span className="text-muted-foreground">›</span>
|
||||
<span className="text-xs font-medium text-primary">Sprint actief</span>
|
||||
</div>
|
||||
|
||||
{editingGoal ? (
|
||||
<form action={goalFormAction} className="flex gap-2 mt-1">
|
||||
<input type="hidden" name="id" value={sprint.id} />
|
||||
<Textarea name="sprint_goal" defaultValue={sprint.sprint_goal} rows={2} className="text-sm flex-1" autoFocus />
|
||||
<div className="flex flex-col gap-1">
|
||||
<SaveGoalButton />
|
||||
<Button type="button" size="sm" variant="ghost" onClick={() => setEditingGoal(false)}>×</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<button onClick={() => !isDemo && setEditingGoal(true)} className="text-left mt-0.5 group">
|
||||
<p className="text-sm font-medium text-foreground group-hover:text-primary transition-colors">
|
||||
{sprint.sprint_goal}
|
||||
</p>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isDemo && (
|
||||
<Button size="sm" variant="outline" className="shrink-0 border-warning/40 text-warning hover:bg-warning/10" onClick={() => setCompleteOpen(true)}>
|
||||
Sprint afronden
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Complete sprint dialog */}
|
||||
<Dialog open={completeOpen} onOpenChange={setCompleteOpen}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Sprint afronden</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 p-1">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Geef per story aan wat er mee moet gebeuren:
|
||||
</p>
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{sprintStories.map(story => (
|
||||
<div key={story.id} className="flex items-center justify-between gap-3 p-2 bg-surface-container-low rounded-lg">
|
||||
<span className="text-sm truncate flex-1">{story.title}</span>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<button
|
||||
onClick={() => setDecision(story.id, 'DONE')}
|
||||
className={`text-xs px-2 py-1 rounded transition-colors ${(decisions[story.id] ?? 'OPEN') === 'DONE' ? 'bg-status-done/20 text-status-done font-medium' : 'text-muted-foreground hover:bg-surface-container'}`}
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDecision(story.id, 'OPEN')}
|
||||
className={`text-xs px-2 py-1 rounded transition-colors ${(decisions[story.id] ?? 'OPEN') === 'OPEN' ? 'bg-status-todo/20 text-status-todo font-medium' : 'text-muted-foreground hover:bg-surface-container'}`}
|
||||
>
|
||||
Terug
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="ghost" onClick={() => setCompleteOpen(false)}>Annuleren</Button>
|
||||
<Button disabled={isCompleting} onClick={handleComplete}>
|
||||
{isCompleting ? 'Bezig…' : 'Sprint afronden'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue