* refactor: verplaats sprint-switcher van NavBar naar product-header
Sprint-pulldown zit nu in de bestaande balk op de product backlog
(naast Sprint starten / Instellingen) i.p.v. in het midden van de
NavBar. Alleen zichtbaar wanneer het product ook het actieve product
van de gebruiker is.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: sync package-lock.json version naar 1.2.0
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor: centreer sprint-switcher en verwijder badges uit dropdown items
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor: vervang sprint-status badge door subtle tekst
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: toon code + titel + status in sprint-switcher dropdown items
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: cookie-write uit Server Component (Next.js 16 verbiedt dit)
setActiveSprintCookie werd direct aangeroepen in app/(app)/products/[id]/sprint/[sprintId]/page.tsx,
wat in Next.js 16 een runtime-error oplevert ('Cookies can only be modified in a Server Action
or Route Handler'). Vervangen door een client-side bridge die syncActiveSprintCookieAction
aanroept na mount, zodat de active-sprint cookie nog steeds gesynced blijft met de URL.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: filter 'toon afgeronde sprints' in sprint-switcher dropdown
Default verbergt de switcher gesloten/gearchiveerde/mislukte sprints
(toont alleen open + de huidige actieve sprint). Toggle bovenaan de
lijst om alle sprints te tonen.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: nieuwe sprint wordt direct geselecteerd zonder redirect
createSprintAction zet nu de active-sprint cookie naar de zojuist
aangemaakte sprint, en de StartSprintButton refresht de huidige
pagina i.p.v. te redirecten naar /sprint. Resultaat: gebruiker blijft
op de product backlog en ziet de nieuwe sprint direct geselecteerd
in de sprint-pulldown.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor: verplaats Manual en Admin naar user-menu dropdown
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: voeg geselecteerde PBI automatisch toe aan nieuwe sprint
Bij sprint-aanmaak wordt de pbi_id uit de selection-store als hidden
form-field meegestuurd. Server-side worden alle stories van die PBI
(zonder sprint) en hun taken aan de nieuwe sprint gekoppeld; stories
krijgen status IN_SPRINT met incrementele sort_order.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: sprint-switcher op solo- en sprint-board pagina's
Sprint-switcher is nu beschikbaar op de drie hoofdpagina's: product
backlog, solo board en sprint board. Allen renderen 'm in een
gecentreerde balk net onder de NavBar. Sprint-data via gedeelde helper
getSprintSwitcherData.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
157 lines
5.8 KiB
TypeScript
157 lines
5.8 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useActionState, useRef } from 'react'
|
|
import { useRouter } from 'next/navigation'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog'
|
|
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 { createSprintAction } from '@/actions/sprints'
|
|
import { useSelectionStore } from '@/stores/selection-store'
|
|
|
|
interface StartSprintButtonProps {
|
|
productId: string
|
|
isDemo?: boolean
|
|
}
|
|
|
|
interface ActionResult {
|
|
success?: boolean
|
|
error?: string
|
|
code?: number
|
|
fieldErrors?: Record<string, string[]>
|
|
sprintId?: string
|
|
}
|
|
|
|
function todayLocalDate() {
|
|
return new Date().toLocaleDateString('en-CA')
|
|
}
|
|
|
|
export function StartSprintButton({ productId, isDemo = false }: StartSprintButtonProps) {
|
|
const [open, setOpen] = useState(false)
|
|
const [dirty, setDirty] = useState(false)
|
|
const formRef = useRef<HTMLFormElement>(null)
|
|
const router = useRouter()
|
|
const selectedPbiId = useSelectionStore((s) => s.selectedPbiId)
|
|
|
|
const [state, formAction, pending] = useActionState<ActionResult | undefined, FormData>(
|
|
async (_prev, fd) => {
|
|
const result = await createSprintAction(_prev, fd) as ActionResult
|
|
if (result?.success) {
|
|
setOpen(false)
|
|
setDirty(false)
|
|
router.refresh()
|
|
} else if (result?.code !== 422 && result?.error) {
|
|
// Toast handled by caller; here we just keep the form open
|
|
}
|
|
return result
|
|
},
|
|
undefined,
|
|
)
|
|
|
|
const fieldError = (field: string) => state?.fieldErrors?.[field]?.[0]
|
|
const globalError = state?.code !== 422 ? state?.error : undefined
|
|
|
|
const closeGuard = useDirtyCloseGuard(dirty, () => setOpen(false))
|
|
const handleKeyDown = useDialogSubmitShortcut(() => formRef.current?.requestSubmit())
|
|
|
|
return (
|
|
<>
|
|
<DemoTooltip show={isDemo}>
|
|
<Button size="sm" onClick={() => setOpen(true)} disabled={isDemo}>
|
|
Sprint starten
|
|
</Button>
|
|
</DemoTooltip>
|
|
|
|
<Dialog open={open} onOpenChange={(o) => { if (!o) closeGuard.attemptClose(); else setOpen(o) }}>
|
|
<DialogContent
|
|
showCloseButton={false}
|
|
onKeyDown={handleKeyDown}
|
|
className={entityDialogContentClasses}
|
|
>
|
|
<div className={entityDialogHeaderClasses}>
|
|
<DialogTitle className="text-xl font-semibold">Nieuwe Sprint starten</DialogTitle>
|
|
</div>
|
|
|
|
<form
|
|
ref={formRef}
|
|
id="start-sprint-form"
|
|
action={formAction}
|
|
onChange={() => setDirty(true)}
|
|
className="flex-1 overflow-y-auto px-6 py-6 space-y-6"
|
|
>
|
|
<input type="hidden" name="productId" value={productId} />
|
|
{selectedPbiId && <input type="hidden" name="pbi_id" value={selectedPbiId} />}
|
|
|
|
<div className="space-y-1.5">
|
|
<label className="text-sm font-medium text-foreground">
|
|
Sprint Goal <span className="text-error">*</span>
|
|
</label>
|
|
<Textarea
|
|
name="sprint_goal"
|
|
required
|
|
rows={3}
|
|
placeholder="Wat wil je aan het einde van deze Sprint bereikt hebben?"
|
|
autoFocus
|
|
aria-invalid={!!fieldError('sprint_goal')}
|
|
className={fieldError('sprint_goal') ? 'border-error' : ''}
|
|
/>
|
|
{fieldError('sprint_goal') && (
|
|
<p className="text-xs text-error">{fieldError('sprint_goal')}</p>
|
|
)}
|
|
</div>
|
|
|
|
<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={todayLocalDate()} 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" />
|
|
{fieldError('start_date') && (
|
|
<p className="text-xs text-error">{fieldError('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={todayLocalDate()} 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" />
|
|
{fieldError('end_date') && (
|
|
<p className="text-xs text-error">{fieldError('end_date')}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{globalError && (
|
|
<div className="bg-error-container text-error-container-foreground rounded-lg px-3 py-2 text-sm border-l-4 border-error">
|
|
{globalError}
|
|
</div>
|
|
)}
|
|
</form>
|
|
|
|
<div className={entityDialogFooterClasses}>
|
|
<div className="flex justify-end gap-2">
|
|
<Button type="button" variant="ghost" onClick={closeGuard.attemptClose} disabled={pending}>
|
|
Annuleren
|
|
</Button>
|
|
<Button type="submit" form="start-sprint-form" disabled={pending}>
|
|
{pending ? 'Aanmaken…' : 'Sprint starten'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<DirtyCloseGuardDialog guard={closeGuard} />
|
|
</>
|
|
)
|
|
}
|