Scrum4Me/components/sprint/sprint-header.tsx
janpeter visser 080bdf39a0 feat(ST-1205): add sprint start_date/end_date UI + server actions
- createSprintAction + updateSprintDatesAction: Zod date validation
  with end_date >= start_date cross-check
- start-sprint-button: date inputs in create dialog
- sprint-header: date display button + edit dialog with updateSprintDatesAction
- sprint page: select start_date/end_date for SprintHeader prop
- Demo blokkade via bestaande isDemo checks
- 6 tests groen (validation + demo guard)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 15:54:30 +02:00

211 lines
9.4 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 { toast } from 'sonner'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
import { updateSprintGoalAction, updateSprintDatesAction, completeSprintAction } from '@/actions/sprints'
import type { SprintStory } from './sprint-backlog'
interface Sprint {
id: string
sprint_goal: string
status: string
start_date: Date | null
end_date: Date | null
}
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>
}
function toDateInputValue(d: Date | null) {
if (!d) return ''
return d.toISOString().slice(0, 10)
}
export function SprintHeader({ productId: _productId, productName, sprint, isDemo, sprintStories }: SprintHeaderProps) {
const [editingGoal, setEditingGoal] = useState(false)
const [editingDates, setEditingDates] = 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); toast.success('Sprint goal opgeslagen') }
else if (result?.error) toast.error(typeof result.error === 'string' ? result.error : 'Opslaan mislukt')
return result
},
undefined
)
const [datesState, datesFormAction] = useActionState(
async (_prev: unknown, fd: FormData) => {
const result = await updateSprintDatesAction(_prev, fd)
if (result?.success) { setEditingDates(false); toast.success('Sprint datums opgeslagen') }
else if (result?.error) toast.error(typeof result.error === 'string' ? result.error : 'Opslaan mislukt')
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 () => {
const result = await completeSprintAction(sprint.id, finalDecisions)
if ('error' in result) toast.error(result.error ?? 'Sprint afronden mislukt')
else { toast.success('Sprint afgerond'); 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" aria-label="Annuleer bewerken" 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>
<div className="flex items-center gap-2 shrink-0">
<DemoTooltip show={isDemo}>
<Button size="sm" variant="ghost" disabled={isDemo} className="text-muted-foreground" onClick={() => !isDemo && setEditingDates(true)}>
{sprint.start_date && sprint.end_date
? `${toDateInputValue(sprint.start_date)}${toDateInputValue(sprint.end_date)}`
: 'Datums instellen'}
</Button>
</DemoTooltip>
<DemoTooltip show={isDemo}>
<Button size="sm" variant="outline" disabled={isDemo} className="border-warning/40 text-warning hover:bg-warning/10" onClick={() => setCompleteOpen(true)}>
Sprint afronden
</Button>
</DemoTooltip>
</div>
</div>
{/* Dates edit dialog */}
<Dialog open={editingDates} onOpenChange={setEditingDates}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Sprint datums instellen</DialogTitle>
</DialogHeader>
<form action={datesFormAction} className="space-y-4 p-1">
<input type="hidden" name="id" value={sprint.id} />
<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={toDateInputValue(sprint.start_date)} 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" />
{typeof datesState?.error === 'object' && (datesState.error as Record<string, string[]>).start_date && (
<p className="text-xs text-error">{(datesState.error as Record<string, string[]>).start_date[0]}</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={toDateInputValue(sprint.end_date)} 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" />
{typeof datesState?.error === 'object' && (datesState.error as Record<string, string[]>).end_date && (
<p className="text-xs text-error">{(datesState.error as Record<string, string[]>).end_date[0]}</p>
)}
</div>
</div>
{typeof datesState?.error === 'string' && (
<p className="text-xs text-error">{datesState.error}</p>
)}
<div className="flex gap-2 justify-end">
<Button type="button" variant="ghost" onClick={() => setEditingDates(false)}>Annuleren</Button>
<Button type="submit">Opslaan</Button>
</div>
</form>
</DialogContent>
</Dialog>
{/* Complete sprint dialog */}
<Dialog open={completeOpen} onOpenChange={setCompleteOpen}>
<DialogContent className="sm:max-w-2xl">
<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">
{story.code && <span className="font-mono text-[11px] text-muted-foreground shrink-0">{story.code}</span>}
<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>
)
}