* feat(ST-1201): add Sprint start_date/end_date + claude_jobs index migration - Sprint model: optionele start_date en end_date (DATE) voor burndown x-as - CREATE INDEX claude_jobs(status, finished_at) voor agent-throughput-queries - Bestaande sprints houden NULL; burndown skipt die Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ST-1202): add lib/chart-colors.ts + vitest coverage MD3-token-to-CSS-var mappings for STATUS, PRIORITY, VERIFY, JOB_STATUS and SERIES_COLORS; all 5 tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ST-1203): add Insights link to NavBar Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ST-1204): move Insights NavBar link between Solo and Todo's Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * 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> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
211 lines
9.4 KiB
TypeScript
211 lines
9.4 KiB
TypeScript
'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>
|
||
)
|
||
}
|