Foundation: route, recharts, sprint-dates migration, chart-colors helper (#46)
* feat(ST-1201): add Sprint start_date/end_date + claude_jobs index migration - Sprint model: optionele start_date en end_date (DATE) voor burndown x-as - CREATE INDEX claude_jobs(status, finished_at) voor agent-throughput-queries - Bestaande sprints houden NULL; burndown skipt die Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ST-1202): add lib/chart-colors.ts + vitest coverage MD3-token-to-CSS-var mappings for STATUS, PRIORITY, VERIFY, JOB_STATUS and SERIES_COLORS; all 5 tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ST-1203): add Insights link to NavBar Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ST-1204): move Insights NavBar link between Solo and Todo's Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ST-1205): add sprint start_date/end_date UI + server actions - createSprintAction + updateSprintDatesAction: Zod date validation with end_date >= start_date cross-check - start-sprint-button: date inputs in create dialog - sprint-header: date display button + edit dialog with updateSprintDatesAction - sprint page: select start_date/end_date for SprintHeader prop - Demo blokkade via bestaande isDemo checks - 6 tests groen (validation + demo guard) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
55a1ee035c
commit
ce94fb48c3
10 changed files with 333 additions and 7 deletions
|
|
@ -12,13 +12,15 @@ import {
|
|||
} from '@/components/ui/dialog'
|
||||
import { toast } from 'sonner'
|
||||
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
||||
import { updateSprintGoalAction, completeSprintAction } from '@/actions/sprints'
|
||||
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 {
|
||||
|
|
@ -34,8 +36,14 @@ function SaveGoalButton() {
|
|||
return <Button type="submit" size="sm" disabled={pending}>{pending ? 'Opslaan…' : 'Opslaan'}</Button>
|
||||
}
|
||||
|
||||
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()
|
||||
|
|
@ -50,6 +58,16 @@ export function SprintHeader({ productId: _productId, productName, sprint, isDem
|
|||
undefined
|
||||
)
|
||||
|
||||
const [datesState, datesFormAction] = useActionState(
|
||||
async (_prev: unknown, fd: FormData) => {
|
||||
const result = await updateSprintDatesAction(_prev, fd)
|
||||
if (result?.success) { setEditingDates(false); toast.success('Sprint datums 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 }))
|
||||
}
|
||||
|
|
@ -96,13 +114,57 @@ export function SprintHeader({ productId: _productId, productName, sprint, isDem
|
|||
)}
|
||||
</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 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={setEditingDates}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Sprint datums instellen</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form action={datesFormAction} className="space-y-4 p-1">
|
||||
<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" />
|
||||
{typeof datesState?.error === 'object' && (datesState.error as Record<string, string[]>).start_date && (
|
||||
<p className="text-xs text-error">{(datesState.error as Record<string, string[]>).start_date[0]}</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" />
|
||||
{typeof datesState?.error === 'object' && (datesState.error as Record<string, string[]>).end_date && (
|
||||
<p className="text-xs text-error">{(datesState.error as Record<string, string[]>).end_date[0]}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{typeof datesState?.error === 'string' && (
|
||||
<p className="text-xs text-error">{datesState.error}</p>
|
||||
)}
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button type="button" variant="ghost" onClick={() => setEditingDates(false)}>Annuleren</Button>
|
||||
<Button type="submit">Opslaan</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Complete sprint dialog */}
|
||||
<Dialog open={completeOpen} onOpenChange={setCompleteOpen}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
|
|
|
|||
|
|
@ -75,6 +75,23 @@ export function StartSprintButton({ productId }: StartSprintButtonProps) {
|
|||
)}
|
||||
</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" 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" />
|
||||
{typeof state?.error === 'object' && (state.error as Record<string, string[]>).start_date && (
|
||||
<p className="text-xs text-error">{(state.error as Record<string, string[]>).start_date[0]}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-foreground">Einddatum</label>
|
||||
<input type="date" name="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" />
|
||||
{typeof state?.error === 'object' && (state.error as Record<string, string[]>).end_date && (
|
||||
<p className="text-xs text-error">{(state.error as Record<string, string[]>).end_date[0]}</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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue