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
97
__tests__/actions/sprint-dates.test.ts
Normal file
97
__tests__/actions/sprint-dates.test.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
|
||||||
|
vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) }))
|
||||||
|
vi.mock('iron-session', () => ({
|
||||||
|
getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }),
|
||||||
|
}))
|
||||||
|
vi.mock('@/lib/session', () => ({
|
||||||
|
sessionOptions: { cookieName: 'test', password: 'test' },
|
||||||
|
}))
|
||||||
|
vi.mock('@/lib/product-access', () => ({
|
||||||
|
productAccessFilter: vi.fn().mockReturnValue({}),
|
||||||
|
getAccessibleProduct: vi.fn().mockResolvedValue({ id: 'product-1', user_id: 'user-1' }),
|
||||||
|
}))
|
||||||
|
vi.mock('@/lib/prisma', () => ({
|
||||||
|
prisma: {
|
||||||
|
sprint: {
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { createSprintAction, updateSprintDatesAction } from '@/actions/sprints'
|
||||||
|
|
||||||
|
const mockSprint = prisma as unknown as { sprint: { findFirst: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } }
|
||||||
|
|
||||||
|
function makeFormData(data: Record<string, string | null>) {
|
||||||
|
const fd = new FormData()
|
||||||
|
for (const [k, v] of Object.entries(data)) {
|
||||||
|
if (v !== null) fd.append(k, v)
|
||||||
|
}
|
||||||
|
return fd
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('createSprintAction — date validation', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockSprint.sprint.findFirst.mockResolvedValue(null)
|
||||||
|
mockSprint.sprint.create.mockResolvedValue({ id: 'sprint-1' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts valid start_date + end_date', async () => {
|
||||||
|
const fd = makeFormData({ productId: 'product-1', sprint_goal: 'Doel', start_date: '2026-05-01', end_date: '2026-05-14' })
|
||||||
|
const result = await createSprintAction(undefined, fd)
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(mockSprint.sprint.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ data: expect.objectContaining({ start_date: new Date('2026-05-01'), end_date: new Date('2026-05-14') }) })
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects end_date before start_date', async () => {
|
||||||
|
const fd = makeFormData({ productId: 'product-1', sprint_goal: 'Doel', start_date: '2026-05-14', end_date: '2026-05-01' })
|
||||||
|
const result = await createSprintAction(undefined, fd)
|
||||||
|
expect(result.error).toBeTruthy()
|
||||||
|
const errors = result.error as Record<string, string[]>
|
||||||
|
expect(errors.end_date?.[0]).toContain('Einddatum')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts no dates (both optional)', async () => {
|
||||||
|
const fd = makeFormData({ productId: 'product-1', sprint_goal: 'Doel', start_date: '', end_date: '' })
|
||||||
|
const result = await createSprintAction(undefined, fd)
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('updateSprintDatesAction — date validation', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockSprint.sprint.findFirst.mockResolvedValue({ id: 'sprint-1', product_id: 'product-1' })
|
||||||
|
mockSprint.sprint.update.mockResolvedValue({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saves valid dates', async () => {
|
||||||
|
const fd = makeFormData({ id: 'sprint-1', start_date: '2026-05-01', end_date: '2026-05-14' })
|
||||||
|
const result = await updateSprintDatesAction(undefined, fd)
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects end_date before start_date', async () => {
|
||||||
|
const fd = makeFormData({ id: 'sprint-1', start_date: '2026-05-10', end_date: '2026-05-05' })
|
||||||
|
const result = await updateSprintDatesAction(undefined, fd)
|
||||||
|
expect(result.error).toBeTruthy()
|
||||||
|
const errors = result.error as Record<string, string[]>
|
||||||
|
expect(errors.end_date?.[0]).toContain('Einddatum')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blocks demo users', async () => {
|
||||||
|
const { getIronSession } = await import('iron-session')
|
||||||
|
vi.mocked(getIronSession).mockResolvedValueOnce({ userId: 'user-1', isDemo: true } as never)
|
||||||
|
const fd = makeFormData({ id: 'sprint-1', start_date: '', end_date: '' })
|
||||||
|
const result = await updateSprintDatesAction(undefined, fd)
|
||||||
|
expect(result.error).toBe('Niet beschikbaar in demo-modus')
|
||||||
|
})
|
||||||
|
})
|
||||||
52
__tests__/lib/chart-colors.test.ts
Normal file
52
__tests__/lib/chart-colors.test.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import {
|
||||||
|
STATUS_COLORS,
|
||||||
|
PRIORITY_COLORS,
|
||||||
|
VERIFY_COLORS,
|
||||||
|
JOB_STATUS_COLORS,
|
||||||
|
SERIES_COLORS,
|
||||||
|
} from '@/lib/chart-colors'
|
||||||
|
|
||||||
|
describe('chart-colors', () => {
|
||||||
|
it('STATUS_COLORS has all TaskStatus keys and non-empty values', () => {
|
||||||
|
const keys: (keyof typeof STATUS_COLORS)[] = ['TO_DO', 'IN_PROGRESS', 'REVIEW', 'DONE']
|
||||||
|
for (const key of keys) {
|
||||||
|
expect(STATUS_COLORS[key]).toBeTruthy()
|
||||||
|
expect(typeof STATUS_COLORS[key]).toBe('string')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('PRIORITY_COLORS has keys 1-4 with non-empty values', () => {
|
||||||
|
const keys = [1, 2, 3, 4] as const
|
||||||
|
for (const key of keys) {
|
||||||
|
expect(PRIORITY_COLORS[key]).toBeTruthy()
|
||||||
|
expect(typeof PRIORITY_COLORS[key]).toBe('string')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('VERIFY_COLORS has all VerifyResult keys and non-empty values', () => {
|
||||||
|
const keys: (keyof typeof VERIFY_COLORS)[] = ['ALIGNED', 'PARTIAL', 'EMPTY', 'DIVERGENT']
|
||||||
|
for (const key of keys) {
|
||||||
|
expect(VERIFY_COLORS[key]).toBeTruthy()
|
||||||
|
expect(typeof VERIFY_COLORS[key]).toBe('string')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('JOB_STATUS_COLORS has all ClaudeJobStatus keys and non-empty values', () => {
|
||||||
|
const keys: (keyof typeof JOB_STATUS_COLORS)[] = [
|
||||||
|
'queued', 'claimed', 'running', 'done', 'failed', 'cancelled',
|
||||||
|
]
|
||||||
|
for (const key of keys) {
|
||||||
|
expect(JOB_STATUS_COLORS[key]).toBeTruthy()
|
||||||
|
expect(typeof JOB_STATUS_COLORS[key]).toBe('string')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('SERIES_COLORS has 5 non-empty entries', () => {
|
||||||
|
expect(SERIES_COLORS).toHaveLength(5)
|
||||||
|
for (const color of SERIES_COLORS) {
|
||||||
|
expect(color).toBeTruthy()
|
||||||
|
expect(typeof color).toBe('string')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -16,6 +16,14 @@ function hasDuplicateIds(ids: string[]) {
|
||||||
return new Set(ids).size !== ids.length
|
return new Set(ids).size !== ids.length
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dateField = z.string().optional().nullable().transform(v => (v && v.trim() !== '' ? new Date(v) : null))
|
||||||
|
|
||||||
|
function validateDateOrder(data: { start_date: Date | null; end_date: Date | null }, ctx: z.RefinementCtx) {
|
||||||
|
if (data.start_date && data.end_date && data.end_date < data.start_date) {
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['end_date'], message: 'Einddatum moet na startdatum liggen' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function createSprintAction(_prevState: unknown, formData: FormData) {
|
export async function createSprintAction(_prevState: unknown, formData: FormData) {
|
||||||
const session = await getSession()
|
const session = await getSession()
|
||||||
if (!session.userId) return { error: 'Niet ingelogd' }
|
if (!session.userId) return { error: 'Niet ingelogd' }
|
||||||
|
|
@ -24,9 +32,13 @@ export async function createSprintAction(_prevState: unknown, formData: FormData
|
||||||
const parsed = z.object({
|
const parsed = z.object({
|
||||||
productId: z.string(),
|
productId: z.string(),
|
||||||
sprint_goal: z.string().min(1, 'Sprint Goal is verplicht').max(500),
|
sprint_goal: z.string().min(1, 'Sprint Goal is verplicht').max(500),
|
||||||
}).safeParse({
|
start_date: dateField,
|
||||||
|
end_date: dateField,
|
||||||
|
}).superRefine(validateDateOrder).safeParse({
|
||||||
productId: formData.get('productId'),
|
productId: formData.get('productId'),
|
||||||
sprint_goal: formData.get('sprint_goal'),
|
sprint_goal: formData.get('sprint_goal'),
|
||||||
|
start_date: formData.get('start_date'),
|
||||||
|
end_date: formData.get('end_date'),
|
||||||
})
|
})
|
||||||
if (!parsed.success) return { error: parsed.error.flatten().fieldErrors }
|
if (!parsed.success) return { error: parsed.error.flatten().fieldErrors }
|
||||||
|
|
||||||
|
|
@ -43,6 +55,8 @@ export async function createSprintAction(_prevState: unknown, formData: FormData
|
||||||
product_id: parsed.data.productId,
|
product_id: parsed.data.productId,
|
||||||
sprint_goal: parsed.data.sprint_goal,
|
sprint_goal: parsed.data.sprint_goal,
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
|
start_date: parsed.data.start_date,
|
||||||
|
end_date: parsed.data.end_date,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -50,6 +64,35 @@ export async function createSprintAction(_prevState: unknown, formData: FormData
|
||||||
return { success: true, sprintId: sprint.id }
|
return { success: true, sprintId: sprint.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateSprintDatesAction(_prevState: unknown, formData: FormData) {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session.userId) return { error: 'Niet ingelogd' }
|
||||||
|
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
|
||||||
|
|
||||||
|
const parsed = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
start_date: dateField,
|
||||||
|
end_date: dateField,
|
||||||
|
}).superRefine(validateDateOrder).safeParse({
|
||||||
|
id: formData.get('id'),
|
||||||
|
start_date: formData.get('start_date'),
|
||||||
|
end_date: formData.get('end_date'),
|
||||||
|
})
|
||||||
|
if (!parsed.success) return { error: parsed.error.flatten().fieldErrors }
|
||||||
|
|
||||||
|
const sprint = await prisma.sprint.findFirst({
|
||||||
|
where: { id: parsed.data.id, product: productAccessFilter(session.userId) },
|
||||||
|
})
|
||||||
|
if (!sprint) return { error: 'Sprint niet gevonden' }
|
||||||
|
|
||||||
|
await prisma.sprint.update({
|
||||||
|
where: { id: parsed.data.id },
|
||||||
|
data: { start_date: parsed.data.start_date, end_date: parsed.data.end_date },
|
||||||
|
})
|
||||||
|
revalidatePath(`/products/${sprint.product_id}/sprint`)
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateSprintGoalAction(_prevState: unknown, formData: FormData) {
|
export async function updateSprintGoalAction(_prevState: unknown, formData: FormData) {
|
||||||
const session = await getSession()
|
const session = await getSession()
|
||||||
if (!session.userId) return { error: 'Niet ingelogd' }
|
if (!session.userId) return { error: 'Niet ingelogd' }
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,13 @@ export default async function SprintBoardPage({ params, searchParams }: Props) {
|
||||||
|
|
||||||
const sprint = await prisma.sprint.findFirst({
|
const sprint = await prisma.sprint.findFirst({
|
||||||
where: { product_id: id, status: 'ACTIVE' },
|
where: { product_id: id, status: 'ACTIVE' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
sprint_goal: true,
|
||||||
|
status: true,
|
||||||
|
start_date: true,
|
||||||
|
end_date: true,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
if (!sprint) redirect(`/products/${id}`)
|
if (!sprint) redirect(`/products/${id}`)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,7 @@ export function NavBar({
|
||||||
pathname.includes('/solo')
|
pathname.includes('/solo')
|
||||||
)
|
)
|
||||||
: disabledSpan('Solo')}
|
: disabledSpan('Solo')}
|
||||||
|
{navLink('/insights', 'Insights', pathname.startsWith('/insights'))}
|
||||||
{navLink('/todos', "Todo's", pathname.startsWith('/todos'))}
|
{navLink('/todos', "Todo's", pathname.startsWith('/todos'))}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,15 @@ import {
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
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'
|
import type { SprintStory } from './sprint-backlog'
|
||||||
|
|
||||||
interface Sprint {
|
interface Sprint {
|
||||||
id: string
|
id: string
|
||||||
sprint_goal: string
|
sprint_goal: string
|
||||||
status: string
|
status: string
|
||||||
|
start_date: Date | null
|
||||||
|
end_date: Date | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SprintHeaderProps {
|
interface SprintHeaderProps {
|
||||||
|
|
@ -34,8 +36,14 @@ function SaveGoalButton() {
|
||||||
return <Button type="submit" size="sm" disabled={pending}>{pending ? 'Opslaan…' : 'Opslaan'}</Button>
|
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) {
|
export function SprintHeader({ productId: _productId, productName, sprint, isDemo, sprintStories }: SprintHeaderProps) {
|
||||||
const [editingGoal, setEditingGoal] = useState(false)
|
const [editingGoal, setEditingGoal] = useState(false)
|
||||||
|
const [editingDates, setEditingDates] = useState(false)
|
||||||
const [completeOpen, setCompleteOpen] = useState(false)
|
const [completeOpen, setCompleteOpen] = useState(false)
|
||||||
const [decisions, setDecisions] = useState<Record<string, 'DONE' | 'OPEN'>>({})
|
const [decisions, setDecisions] = useState<Record<string, 'DONE' | 'OPEN'>>({})
|
||||||
const [isCompleting, startCompleting] = useTransition()
|
const [isCompleting, startCompleting] = useTransition()
|
||||||
|
|
@ -50,6 +58,16 @@ export function SprintHeader({ productId: _productId, productName, sprint, isDem
|
||||||
undefined
|
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') {
|
function setDecision(storyId: string, value: 'DONE' | 'OPEN') {
|
||||||
setDecisions(prev => ({ ...prev, [storyId]: value }))
|
setDecisions(prev => ({ ...prev, [storyId]: value }))
|
||||||
}
|
}
|
||||||
|
|
@ -96,12 +114,56 @@ export function SprintHeader({ productId: _productId, productName, sprint, isDem
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<DemoTooltip show={isDemo}>
|
<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)}>
|
<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
|
Sprint afronden
|
||||||
</Button>
|
</Button>
|
||||||
</DemoTooltip>
|
</DemoTooltip>
|
||||||
</div>
|
</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 */}
|
{/* Complete sprint dialog */}
|
||||||
<Dialog open={completeOpen} onOpenChange={setCompleteOpen}>
|
<Dialog open={completeOpen} onOpenChange={setCompleteOpen}>
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,23 @@ export function StartSprintButton({ productId }: StartSprintButtonProps) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 && (
|
{globalError && (
|
||||||
<div className="bg-error-container text-error-container-foreground rounded-lg px-3 py-2 text-sm border-l-4 border-error">
|
<div className="bg-error-container text-error-container-foreground rounded-lg px-3 py-2 text-sm border-l-4 border-error">
|
||||||
{globalError}
|
{globalError}
|
||||||
|
|
|
||||||
39
lib/chart-colors.ts
Normal file
39
lib/chart-colors.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
// Mapping van MD3-tokens naar CSS-var-strings voor Recharts fill/stroke.
|
||||||
|
// Recharts accepteert gewone strings — 'var(--status-done)' werkt direct.
|
||||||
|
export const STATUS_COLORS = {
|
||||||
|
TO_DO: 'var(--status-todo)',
|
||||||
|
IN_PROGRESS: 'var(--status-in-progress)',
|
||||||
|
REVIEW: 'var(--status-in-progress)',
|
||||||
|
DONE: 'var(--status-done)',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const PRIORITY_COLORS = {
|
||||||
|
1: 'var(--priority-critical)',
|
||||||
|
2: 'var(--priority-high)',
|
||||||
|
3: 'var(--priority-medium)',
|
||||||
|
4: 'var(--priority-low)',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const VERIFY_COLORS = {
|
||||||
|
ALIGNED: 'var(--status-done)',
|
||||||
|
PARTIAL: 'var(--priority-medium)',
|
||||||
|
EMPTY: 'var(--priority-critical)',
|
||||||
|
DIVERGENT: 'var(--priority-high)',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const JOB_STATUS_COLORS = {
|
||||||
|
queued: 'var(--muted-foreground)',
|
||||||
|
claimed: 'var(--status-in-progress)',
|
||||||
|
running: 'var(--status-in-progress)',
|
||||||
|
done: 'var(--status-done)',
|
||||||
|
failed: 'var(--priority-critical)',
|
||||||
|
cancelled: 'var(--muted-foreground)',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const SERIES_COLORS = [
|
||||||
|
'var(--chart-1)',
|
||||||
|
'var(--chart-2)',
|
||||||
|
'var(--chart-3)',
|
||||||
|
'var(--chart-4)',
|
||||||
|
'var(--chart-5)',
|
||||||
|
] as const
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "sprints" ADD COLUMN "end_date" DATE,
|
||||||
|
ADD COLUMN "start_date" DATE;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX IF NOT EXISTS "claude_jobs_status_finished_at_idx" ON "claude_jobs"("status", "finished_at");
|
||||||
|
|
@ -223,6 +223,8 @@ model Sprint {
|
||||||
product_id String
|
product_id String
|
||||||
sprint_goal String
|
sprint_goal String
|
||||||
status SprintStatus @default(ACTIVE)
|
status SprintStatus @default(ACTIVE)
|
||||||
|
start_date DateTime? @db.Date
|
||||||
|
end_date DateTime? @db.Date
|
||||||
created_at DateTime @default(now())
|
created_at DateTime @default(now())
|
||||||
completed_at DateTime?
|
completed_at DateTime?
|
||||||
stories Story[]
|
stories Story[]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue