Scrum4Me/components/sprint/sprint-header.tsx
janpeter visser d92e548f88 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>
2026-04-24 11:51:27 +02:00

144 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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>
)
}