Scrum4Me/components/sprint/sprint-header.tsx
Madhura68 784791d8f9 feat(sprint-dialogs): conform aan dialog-pattern + entity-profile
Story 5 van PBI "Alle dialogen conform docs/patterns/dialog.md".

- lib/schemas/sprint.ts — gedeelde zod-schemas (create/dates/goal)
- actions/sprints.ts — code+fieldErrors voor 422; code: 403 voor
  auth/demo errors
- StartSprintButton dialog: useDirtyCloseGuard, useDialogSubmitShortcut,
  entityDialog* layout-classes; DemoTooltip op trigger; veld-niveau
  errors via fieldErrors
- SprintHeader's date- en complete-dialogen: zelfde behandeling; date-
  dialog krijgt dirty-guard, complete-dialog krijgt DemoTooltip op
  bevestigen
- docs/specs/dialogs/sprint.md — entity-profile dat alle drie de modes
  documenteert; consolidatie naar één SprintDialog component bewust
  uitgesteld
- Sprint-dates tests aangepast aan nieuwe action-shape

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 07:30:46 +02:00

250 lines
11 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, useRef } from 'react'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import {
Dialog,
DialogContent,
DialogTitle,
} from '@/components/ui/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 } 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: _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 [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>
)
}