Scrum4Me/components/sprint/sprint-header.tsx
Janpeter Visser 94f4f6ffd8
feat(PBI-33): chat-kanaal UI + lint cleanup (#145)
* feat(PBI-33): chat-kanaal UI — IdeaTimeline merge + UserChatInput

Voltooit de UI-laag van PLAN_CHAT (gebruikersvragen over plan, Claude
antwoordt async). Backend (UserQuestion model, createUserQuestionAction,
SSE-handling, server-side prop-passing) was al aanwezig — alleen de
UI-koppeling ontbrak waardoor userQuestions ongebruikt bleven.

- IdeaDetailLayout geeft userQuestions/planMd/ideaId/isDemo door aan
  IdeaTimeline en telt user-questions mee in de tab-count
- IdeaTimeline mergt user-questions chronologisch met logs+questions,
  rendert ze met MessageCircle-icoon en pending/answered status, en
  toont onderaan UserChatInput wanneer plan_md aanwezig is
- UserChatInput nieuw component met textarea + verzend-knop dat
  createUserQuestionAction aanroept en op success router.refresh()
  triggert zodat SSE de pending-state oppikt
- useNotificationsRealtime: router toegevoegd aan useEffect-deps zodat
  router.refresh() op user_question/idea-job events werkt zonder
  stale-closure waarschuwing

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(lint): unused vars/imports + react-hook-form watch incompatibility

Resolves de overige lint-warnings van de gefaalde sprint-build die los
staan van PBI-33. Eslint-config staat unused vars/args toe als ze met
'_' prefixen, dus required interface-params krijgen een prefix terwijl
losse dode constantes/imports verwijderd worden.

- sprint-header: productId is required prop maar nog niet gebruikt
  → prefix _productId i.p.v. verwijderen (caller passeert het door)
- agent-throughput: STATUSES-constante was dood — verwijderd, queries
  gebruiken hardcoded status-velden in de perDay-loop
- claude-jobs: productAccessFilter en enforceUserRateLimit waren
  dode imports — verwijderd
- story-log.test: ongebruikte 'data' binding vervangen door bare
  await res.json() zodat de stream nog wel geconsumeerd wordt
- product-dialog: form.watch('auto_pr') vervangen door useWatch met
  control-prop — useWatch is veilig voor React Compiler memoization

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:04:53 +02:00

308 lines
13 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 {
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: _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) }
})
}
function handleAllDone() {
startSettingAllDone(async () => {
const result = await setAllSprintTasksDoneAction(sprint.id)
if (!result.ok) {
toast.error(result.error ?? 'Alles op done mislukt')
} else {
const allDone: Record<string, 'DONE' | 'OPEN'> = {}
sprintStories.forEach(s => { allDone[s.id] = 'DONE' })
setDecisions(allDone)
}
setShowAllDoneConfirm(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="flex justify-end">
<Button
type="button"
size="sm"
variant="outline"
className="border-status-done/40 text-status-done hover:bg-status-done/10"
disabled={isSettingAllDone || isCompleting}
onClick={() => setShowAllDoneConfirm(true)}
>
{isSettingAllDone ? 'Bezig…' : 'Alles op done'}
</Button>
</div>
<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>
<AlertDialog open={showAllDoneConfirm} onOpenChange={setShowAllDoneConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Alles op done zetten?</AlertDialogTitle>
<AlertDialogDescription>
Alle taken én stories in de sprint inclusief taken met status
REVIEW worden op DONE gezet. De per-story toggles hieronder
worden daarna bijgewerkt. Je kunt daarna nog per story aanpassen
vóór je de sprint afrondt.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isSettingAllDone}>Annuleren</AlertDialogCancel>
<AlertDialogAction onClick={handleAllDone} disabled={isSettingAllDone}>
{isSettingAllDone ? 'Bezig…' : 'Alles op done'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}