148 lines
6 KiB
TypeScript
148 lines
6 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, 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: _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); toast.success('Sprint goal 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" 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>
|
||
|
||
<DemoTooltip show={isDemo}>
|
||
<Button size="sm" variant="outline" disabled={isDemo} className="shrink-0 border-warning/40 text-warning hover:bg-warning/10" onClick={() => setCompleteOpen(true)}>
|
||
Sprint afronden
|
||
</Button>
|
||
</DemoTooltip>
|
||
</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>
|
||
)
|
||
}
|