Voegt AlertDialog-imports, setAllSprintTasksDoneAction-import, productId-prop (hernoemd van _productId) en showAllDoneConfirm/isSettingAllDone state toe aan sprint-header.tsx als voorbereiding op de Alles-op-done AlertDialog-knop. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
262 lines
11 KiB
TypeScript
262 lines
11 KiB
TypeScript
'use client'
|
||
|
||
import { useState, useTransition, useActionState, useRef } from 'react'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Textarea } from '@/components/ui/textarea'
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogTitle,
|
||
} from '@/components/ui/dialog'
|
||
import {
|
||
AlertDialog,
|
||
AlertDialogAction,
|
||
AlertDialogCancel,
|
||
AlertDialogContent,
|
||
AlertDialogDescription,
|
||
AlertDialogFooter,
|
||
AlertDialogHeader,
|
||
AlertDialogTitle,
|
||
} from '@/components/ui/alert-dialog'
|
||
import { toast } from 'sonner'
|
||
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
||
import {
|
||
useDirtyCloseGuard,
|
||
DirtyCloseGuardDialog,
|
||
} from '@/components/shared/use-dirty-close-guard'
|
||
import { useDialogSubmitShortcut } from '@/components/shared/use-dialog-submit-shortcut'
|
||
import {
|
||
entityDialogContentClasses,
|
||
entityDialogFooterClasses,
|
||
entityDialogHeaderClasses,
|
||
} from '@/components/shared/entity-dialog-layout'
|
||
import { updateSprintGoalAction, updateSprintDatesAction, completeSprintAction, setAllSprintTasksDoneAction } 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[]
|
||
}
|
||
|
||
interface ActionResult {
|
||
success?: boolean
|
||
error?: string
|
||
code?: number
|
||
fieldErrors?: Record<string, string[]>
|
||
}
|
||
|
||
function toDateInputValue(d: Date | null) {
|
||
if (!d) return ''
|
||
return d.toISOString().slice(0, 10)
|
||
}
|
||
|
||
export function SprintHeader({ 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 [showAllDoneConfirm, setShowAllDoneConfirm] = useState(false)
|
||
const [isSettingAllDone, startSettingAllDone] = useTransition()
|
||
const [datesDirty, setDatesDirty] = useState(false)
|
||
const datesFormRef = useRef<HTMLFormElement>(null)
|
||
|
||
const [, goalFormAction, goalPending] = useActionState<ActionResult | undefined, FormData>(
|
||
async (_prev, fd) => {
|
||
const result = await updateSprintGoalAction(_prev, fd) as ActionResult
|
||
if (result?.success) { setEditingGoal(false); toast.success('Sprint goal opgeslagen') }
|
||
else if (result?.error) toast.error(result.error)
|
||
return result
|
||
},
|
||
undefined,
|
||
)
|
||
|
||
const [datesState, datesFormAction, datesPending] = useActionState<ActionResult | undefined, FormData>(
|
||
async (_prev, fd) => {
|
||
const result = await updateSprintDatesAction(_prev, fd) as ActionResult
|
||
if (result?.success) { setEditingDates(false); setDatesDirty(false); toast.success('Sprint datums opgeslagen') }
|
||
else if (result?.code !== 422 && result?.error) toast.error(result.error)
|
||
return result
|
||
},
|
||
undefined,
|
||
)
|
||
|
||
const datesFieldError = (field: string) => datesState?.fieldErrors?.[field]?.[0]
|
||
|
||
const datesCloseGuard = useDirtyCloseGuard(datesDirty, () => setEditingDates(false))
|
||
const datesKeyDown = useDialogSubmitShortcut(() => datesFormRef.current?.requestSubmit())
|
||
|
||
function setDecision(storyId: string, value: 'DONE' | 'OPEN') {
|
||
setDecisions(prev => ({ ...prev, [storyId]: value }))
|
||
}
|
||
|
||
function handleComplete() {
|
||
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">
|
||
<Button type="submit" size="sm" disabled={goalPending}>
|
||
{goalPending ? 'Opslaan…' : 'Opslaan'}
|
||
</Button>
|
||
<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={(o) => { if (!o) datesCloseGuard.attemptClose(); else setEditingDates(o) }}>
|
||
<DialogContent
|
||
showCloseButton={false}
|
||
onKeyDown={datesKeyDown}
|
||
className={entityDialogContentClasses}
|
||
>
|
||
<div className={entityDialogHeaderClasses}>
|
||
<DialogTitle className="text-xl font-semibold">Sprint datums instellen</DialogTitle>
|
||
</div>
|
||
<form
|
||
ref={datesFormRef}
|
||
id="sprint-dates-form"
|
||
action={datesFormAction}
|
||
onChange={() => setDatesDirty(true)}
|
||
className="flex-1 overflow-y-auto px-6 py-6 space-y-6"
|
||
>
|
||
<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" />
|
||
{datesFieldError('start_date') && (
|
||
<p className="text-xs text-error">{datesFieldError('start_date')}</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" />
|
||
{datesFieldError('end_date') && (
|
||
<p className="text-xs text-error">{datesFieldError('end_date')}</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</form>
|
||
<div className={entityDialogFooterClasses}>
|
||
<div className="flex gap-2 justify-end">
|
||
<Button type="button" variant="ghost" onClick={datesCloseGuard.attemptClose} disabled={datesPending}>
|
||
Annuleren
|
||
</Button>
|
||
<Button type="submit" form="sprint-dates-form" disabled={datesPending}>
|
||
{datesPending ? '…' : 'Opslaan'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<DirtyCloseGuardDialog guard={datesCloseGuard} />
|
||
|
||
{/* Complete sprint dialog */}
|
||
<Dialog open={completeOpen} onOpenChange={setCompleteOpen}>
|
||
<DialogContent showCloseButton={false} className={entityDialogContentClasses}>
|
||
<div className={entityDialogHeaderClasses}>
|
||
<DialogTitle className="text-xl font-semibold">Sprint afronden</DialogTitle>
|
||
</div>
|
||
<div className="flex-1 overflow-y-auto px-6 py-6 space-y-6">
|
||
<p className="text-sm text-muted-foreground">
|
||
Geef per story aan wat er mee moet gebeuren:
|
||
</p>
|
||
<div className="space-y-2">
|
||
{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>
|
||
<div className={entityDialogFooterClasses}>
|
||
<div className="flex gap-2 justify-end">
|
||
<Button variant="ghost" onClick={() => setCompleteOpen(false)} disabled={isCompleting}>
|
||
Annuleren
|
||
</Button>
|
||
<DemoTooltip show={isDemo}>
|
||
<Button disabled={isCompleting || isDemo} onClick={handleComplete}>
|
||
{isCompleting ? 'Bezig…' : 'Sprint afronden'}
|
||
</Button>
|
||
</DemoTooltip>
|
||
</div>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
)
|
||
}
|