diff --git a/app/(app)/insights/tokens/components/pbi-token-table.tsx b/app/(app)/insights/tokens/components/pbi-token-table.tsx
new file mode 100644
index 0000000..0d94ef2
--- /dev/null
+++ b/app/(app)/insights/tokens/components/pbi-token-table.tsx
@@ -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 Geen PBI-data voor deze sprint.
+ }
+
+ return (
+
+
+
+
+ | PBI |
+ Tokens |
+ Kosten (USD) |
+
+
+
+ {rows.map(r => (
+
+ |
+ {r.pbiCode}
+ {r.pbiTitle}
+ |
+ {r.totalTokens.toLocaleString()} |
+ ${r.totalCostUsd.toFixed(4)} |
+
+ ))}
+
+
+
+ )
+}
diff --git a/app/(app)/insights/tokens/components/sprint-token-history-table.tsx b/app/(app)/insights/tokens/components/sprint-token-history-table.tsx
new file mode 100644
index 0000000..c6d56de
--- /dev/null
+++ b/app/(app)/insights/tokens/components/sprint-token-history-table.tsx
@@ -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 Geen sprint-data met token-registratie.
+ }
+
+ return (
+
+
+
+
+ | Sprint |
+ Tokens |
+ Kosten (USD) |
+ Jobs |
+
+
+
+ {rows.map(r => (
+
+ | {r.sprintGoal} |
+ {r.totalTokens.toLocaleString()} |
+ ${r.totalCostUsd.toFixed(4)} |
+ {r.jobCount} |
+
+ ))}
+
+
+
+ )
+}
diff --git a/app/(app)/insights/tokens/components/token-day-chart.tsx b/app/(app)/insights/tokens/components/token-day-chart.tsx
new file mode 100644
index 0000000..8a95d1f
--- /dev/null
+++ b/app/(app)/insights/tokens/components/token-day-chart.tsx
@@ -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 Geen dagdata voor deze sprint.
+ }
+
+ return (
+
+
+ (v as string).slice(5)} />
+ `$${(v as number).toFixed(3)}`} />
+ [`$${(v as number).toFixed(4)}`, 'Kosten (USD)']} />
+
+
+
+ )
+}
diff --git a/app/(app)/insights/tokens/components/token-selectors.tsx b/app/(app)/insights/tokens/components/token-selectors.tsx
new file mode 100644
index 0000000..9ef8ca0
--- /dev/null
+++ b/app/(app)/insights/tokens/components/token-selectors.tsx
@@ -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 (
+
+ {productList.length > 0 && (
+
+ )}
+
+ {sprintList.length > 0 && (
+
+ )}
+
+ )
+}
diff --git a/app/(app)/insights/tokens/page.tsx b/app/(app)/insights/tokens/page.tsx
new file mode 100644
index 0000000..1fd5664
--- /dev/null
+++ b/app/(app)/insights/tokens/page.tsx
@@ -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(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 (
+
+
+
+
+
+ {/* Sprint historiek */}
+
+
+ {/* Dagelijkse kosten */}
+
+
+ {/* PBI-aggregaat */}
+
+
+ )
+}