feat(ST-d9sl8egw): /insights/tokens pagina — sprint-selector, historiek-tabel, dag-grafiek & PBI-aggregaat

Server Component met data-fetching via getSprintTokenHistory/getDayTokenData/
getPbiTokenAggregates. SprintTokenHistoryTable, TokenDayChart (Recharts LineChart),
PbiTokenTable en TokenSelectors (client, URL-params). Link toegevoegd in
insights/page.tsx.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scrum4Me Agent 2026-05-06 03:20:53 +02:00
parent d81f18149a
commit 51c8a86be4
6 changed files with 280 additions and 1 deletions

View file

@ -91,7 +91,12 @@ export default async function InsightsPage({ searchParams }: InsightsPageProps)
return (
<div className="p-6 space-y-8 max-w-6xl mx-auto w-full">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-semibold text-foreground">Insights</h1>
<a href="/insights/tokens" className="text-primary text-sm underline">
Historisch token-overzicht
</a>
</div>
{/* Sprint Health */}
<section className="space-y-3">

View file

@ -0,0 +1,37 @@
import type { PbiTokenRow } from '@/lib/insights/token-history'
interface Props {
rows: PbiTokenRow[]
}
export function PbiTokenTable({ rows }: Props) {
if (rows.length === 0) {
return <p className="text-muted-foreground text-sm">Geen PBI-data voor deze sprint.</p>
}
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted text-muted-foreground text-xs uppercase tracking-wide">
<th className="py-2 pr-4 text-left font-medium">PBI</th>
<th className="py-2 pr-4 text-right font-medium">Tokens</th>
<th className="py-2 text-right font-medium">Kosten (USD)</th>
</tr>
</thead>
<tbody>
{rows.map(r => (
<tr key={r.pbiId} className="border-b border-border last:border-0">
<td className="py-2 pr-4 text-foreground">
<span className="text-muted-foreground mr-2">{r.pbiCode}</span>
{r.pbiTitle}
</td>
<td className="py-2 pr-4 text-right tabular-nums">{r.totalTokens.toLocaleString()}</td>
<td className="py-2 text-right tabular-nums">${r.totalCostUsd.toFixed(4)}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}

View file

@ -0,0 +1,40 @@
import type { SprintTokenRow } from '@/lib/insights/token-history'
interface Props {
rows: SprintTokenRow[]
selectedSprintId: string
}
export function SprintTokenHistoryTable({ rows, selectedSprintId }: Props) {
if (rows.length === 0) {
return <p className="text-muted-foreground text-sm">Geen sprint-data met token-registratie.</p>
}
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted text-muted-foreground text-xs uppercase tracking-wide">
<th className="py-2 pr-4 text-left font-medium">Sprint</th>
<th className="py-2 pr-4 text-right font-medium">Tokens</th>
<th className="py-2 pr-4 text-right font-medium">Kosten (USD)</th>
<th className="py-2 text-right font-medium">Jobs</th>
</tr>
</thead>
<tbody>
{rows.map(r => (
<tr
key={r.sprintId}
className={`border-b border-border last:border-0 ${r.sprintId === selectedSprintId ? 'bg-muted/50' : ''}`}
>
<td className="py-2 pr-4 text-foreground max-w-72 truncate">{r.sprintGoal}</td>
<td className="py-2 pr-4 text-right tabular-nums">{r.totalTokens.toLocaleString()}</td>
<td className="py-2 pr-4 text-right tabular-nums">${r.totalCostUsd.toFixed(4)}</td>
<td className="py-2 text-right tabular-nums">{r.jobCount}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}

View file

@ -0,0 +1,32 @@
'use client'
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts'
import type { DayTokenRow } from '@/lib/insights/token-history'
interface Props {
data: DayTokenRow[]
}
export function TokenDayChart({ data }: Props) {
if (data.length === 0) {
return <p className="text-muted-foreground text-sm py-4 text-center">Geen dagdata voor deze sprint.</p>
}
return (
<ResponsiveContainer width="100%" height={220}>
<LineChart data={data}>
<XAxis dataKey="day" tick={{ fontSize: 11 }} tickFormatter={v => (v as string).slice(5)} />
<YAxis tick={{ fontSize: 11 }} tickFormatter={v => `$${(v as number).toFixed(3)}`} />
<Tooltip formatter={(v: unknown) => [`$${(v as number).toFixed(4)}`, 'Kosten (USD)']} />
<Line
type="monotone"
dataKey="totalCostUsd"
stroke="var(--primary)"
strokeWidth={2}
dot={false}
name="Kosten (USD)"
/>
</LineChart>
</ResponsiveContainer>
)
}

View file

@ -0,0 +1,81 @@
'use client'
import { useRouter, usePathname, useSearchParams } from 'next/navigation'
import { useTransition } from 'react'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
interface Sprint {
id: string
sprint_goal: string
}
interface Props {
productList: { id: string; name: string }[]
sprintList: Sprint[]
currentProductId?: string
currentSprintId?: string
}
export function TokenSelectors({ productList, sprintList, currentProductId, currentSprintId }: Props) {
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const [isPending, startTransition] = useTransition()
function setParam(key: string, value: string | null) {
startTransition(() => {
const params = new URLSearchParams(searchParams.toString())
if (value === null || value === '') {
params.delete(key)
} else {
params.set(key, value)
}
router.replace(`${pathname}?${params.toString()}`)
})
}
return (
<div className="flex gap-3 flex-wrap">
{productList.length > 0 && (
<Select
value={currentProductId ?? '__all__'}
onValueChange={v => setParam('product', v === '__all__' ? null : v)}
>
<SelectTrigger className="w-44" disabled={isPending}>
<SelectValue placeholder="Product" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">Alle producten</SelectItem>
{productList.map(p => (
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
))}
</SelectContent>
</Select>
)}
{sprintList.length > 0 && (
<Select
value={currentSprintId ?? ''}
onValueChange={v => setParam('sprint', v)}
>
<SelectTrigger className="w-64" disabled={isPending}>
<SelectValue placeholder="Sprint kiezen" />
</SelectTrigger>
<SelectContent>
{sprintList.map(s => (
<SelectItem key={s.id} value={s.id}>
{s.sprint_goal.length > 50 ? s.sprint_goal.slice(0, 47) + '…' : s.sprint_goal}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)
}

View file

@ -0,0 +1,84 @@
import { cookies } from 'next/headers'
import { getIronSession } from 'iron-session'
import { SessionData, sessionOptions } from '@/lib/session'
import { prisma } from '@/lib/prisma'
import { productAccessFilter } from '@/lib/product-access'
import { getSprintTokenHistory, getDayTokenData, getPbiTokenAggregates } from '@/lib/insights/token-history'
import { SprintTokenHistoryTable } from './components/sprint-token-history-table'
import { TokenDayChart } from './components/token-day-chart'
import { PbiTokenTable } from './components/pbi-token-table'
import { TokenSelectors } from './components/token-selectors'
interface TokensPageProps {
searchParams: Promise<{ product?: string; sprint?: string }>
}
export default async function TokensPage({ searchParams }: TokensPageProps) {
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
const userId = session.userId!
const { product: filterProductId, sprint: filterSprintId } = await searchParams
const [sprintHistory, productList, activeSprint, allSprints] = await Promise.all([
getSprintTokenHistory(userId, filterProductId),
prisma.product.findMany({
where: productAccessFilter(userId),
select: { id: true, name: true },
orderBy: { name: 'asc' },
}),
prisma.sprint.findFirst({
where: { status: 'ACTIVE', product: productAccessFilter(userId) },
select: { id: true, sprint_goal: true },
}),
prisma.sprint.findMany({
where: { product: productAccessFilter(userId) },
select: { id: true, sprint_goal: true },
orderBy: { created_at: 'desc' },
take: 20,
}),
])
const selectedSprintId = filterSprintId ?? activeSprint?.id ?? ''
const [dayData, pbiData] = selectedSprintId
? await Promise.all([
getDayTokenData(userId, selectedSprintId),
getPbiTokenAggregates(userId, selectedSprintId),
])
: [[], []]
return (
<div className="p-6 space-y-8 max-w-5xl mx-auto w-full">
<div className="flex items-center justify-between gap-4 flex-wrap">
<h1 className="text-2xl font-semibold text-foreground">Token-gebruik &amp; kosten</h1>
<a href="/insights" className="text-primary text-sm underline">
Terug naar Insights
</a>
</div>
<TokenSelectors
productList={productList}
sprintList={allSprints}
currentProductId={filterProductId}
currentSprintId={selectedSprintId}
/>
{/* Sprint historiek */}
<section className="space-y-3">
<h2 className="text-lg font-medium text-foreground">Sprint-historiek</h2>
<SprintTokenHistoryTable rows={sprintHistory} selectedSprintId={selectedSprintId} />
</section>
{/* Dagelijkse kosten */}
<section className="space-y-3">
<h2 className="text-lg font-medium text-foreground">Dagelijkse kosten</h2>
<TokenDayChart data={dayData} />
</section>
{/* PBI-aggregaat */}
<section className="space-y-3">
<h2 className="text-lg font-medium text-foreground">Per PBI</h2>
<PbiTokenTable rows={pbiData} />
</section>
</div>
)
}