diff --git a/app/(app)/products/[id]/solo/page.tsx b/app/(app)/products/[id]/solo/page.tsx new file mode 100644 index 0000000..e88773c --- /dev/null +++ b/app/(app)/products/[id]/solo/page.tsx @@ -0,0 +1,81 @@ +import { notFound, redirect } from 'next/navigation' +import { getSession } from '@/lib/auth' +import { getAccessibleProduct } from '@/lib/product-access' +import { setLastProductCookie } from '@/lib/cookies' +import { prisma } from '@/lib/prisma' +import { SoloBoard } from '@/components/solo/solo-board' +import { NoActiveSprint } from '@/components/solo/no-active-sprint' +import type { SoloTask } from '@/components/solo/solo-board' + +interface Props { + params: Promise<{ id: string }> +} + +export default async function SoloProductPage({ params }: Props) { + const { id } = await params + const session = await getSession() + if (!session.userId) redirect('/login') + + const product = await getAccessibleProduct(id, session.userId) + if (!product) notFound() + + await setLastProductCookie(id) + + const sprint = await prisma.sprint.findFirst({ + where: { product_id: id, status: 'ACTIVE' }, + }) + + if (!sprint) { + return ( +
+ +
+ ) + } + + const [rawTasks, unassignedCount] = await Promise.all([ + prisma.task.findMany({ + where: { + story: { + sprint_id: sprint.id, + assignee_id: session.userId, + }, + }, + include: { + story: { select: { id: true, title: true } }, + }, + orderBy: [ + { story: { sort_order: 'asc' } }, + { priority: 'asc' }, + { sort_order: 'asc' }, + ], + }), + prisma.story.count({ + where: { sprint_id: sprint.id, assignee_id: null }, + }), + ]) + + const tasks: SoloTask[] = rawTasks.map(t => ({ + id: t.id, + title: t.title, + description: t.description, + implementation_plan: t.implementation_plan, + priority: t.priority, + sort_order: t.sort_order, + status: t.status as SoloTask['status'], + story_id: t.story.id, + story_title: t.story.title, + })) + + return ( + + ) +} diff --git a/app/(app)/solo/page.tsx b/app/(app)/solo/page.tsx new file mode 100644 index 0000000..7991495 --- /dev/null +++ b/app/(app)/solo/page.tsx @@ -0,0 +1,26 @@ +import { redirect } from 'next/navigation' +import { getSession } from '@/lib/auth' +import { getLastProductCookie } from '@/lib/cookies' +import { getAccessibleProduct, productAccessFilter } from '@/lib/product-access' +import { prisma } from '@/lib/prisma' +import { ProductPicker } from '@/components/solo/product-picker' + +export default async function SoloPage() { + const session = await getSession() + if (!session.userId) redirect('/login') + + const lastProductId = await getLastProductCookie() + + if (lastProductId) { + const product = await getAccessibleProduct(lastProductId, session.userId) + if (product && !product.archived) redirect(`/products/${lastProductId}/solo`) + } + + const products = await prisma.product.findMany({ + where: { archived: false, ...productAccessFilter(session.userId) }, + orderBy: { created_at: 'desc' }, + select: { id: true, name: true, description: true }, + }) + + return +} diff --git a/components/solo/no-active-sprint.tsx b/components/solo/no-active-sprint.tsx new file mode 100644 index 0000000..12fab60 --- /dev/null +++ b/components/solo/no-active-sprint.tsx @@ -0,0 +1,25 @@ +import Link from 'next/link' + +interface NoActiveSprintProps { + productId: string + productName: string +} + +export function NoActiveSprint({ productId, productName }: NoActiveSprintProps) { + return ( +
+
🏃
+

Geen actieve sprint

+

+ Er is nog geen actieve sprint voor {productName}. + Start een sprint in het Sprint Board om hier je taken te zien. +

+ + Naar Sprint Board → + +
+ ) +} diff --git a/components/solo/product-picker.tsx b/components/solo/product-picker.tsx new file mode 100644 index 0000000..513675c --- /dev/null +++ b/components/solo/product-picker.tsx @@ -0,0 +1,46 @@ +import Link from 'next/link' + +interface Product { + id: string + name: string + description: string | null +} + +interface ProductPickerProps { + products: Product[] +} + +export function ProductPicker({ products }: ProductPickerProps) { + return ( +
+

Solo bord

+

+ Kies een product om je persoonlijke Kanban-bord te openen. +

+ + {products.length === 0 ? ( +
+

Je hebt nog geen producten.

+ + + Nieuw product aanmaken + +
+ ) : ( +
+ {products.map(product => ( + + {product.name} + {product.description && ( + {product.description} + )} + + ))} +
+ )} +
+ ) +} diff --git a/components/solo/solo-board.tsx b/components/solo/solo-board.tsx new file mode 100644 index 0000000..3ed956a --- /dev/null +++ b/components/solo/solo-board.tsx @@ -0,0 +1,32 @@ +'use client' + +export interface SoloTask { + id: string + title: string + description: string | null + implementation_plan: string | null + priority: number + sort_order: number + status: 'TO_DO' | 'IN_PROGRESS' | 'REVIEW' | 'DONE' + story_id: string + story_title: string +} + +export interface SoloBoardProps { + productId: string + productName: string + sprintGoal: string + tasks: SoloTask[] + unassignedCount: number + isDemo: boolean + currentUserId: string +} + +// Full implementation in ST-356 +export function SoloBoard(_props: SoloBoardProps) { + return ( +
+

Solo bord wordt geladen in ST-356…

+
+ ) +} diff --git a/lib/cookies.ts b/lib/cookies.ts new file mode 100644 index 0000000..c63bdc1 --- /dev/null +++ b/lib/cookies.ts @@ -0,0 +1,19 @@ +import { cookies } from 'next/headers' + +const LAST_PRODUCT_COOKIE = 'lastProductId' +const THIRTY_DAYS_SECONDS = 60 * 60 * 24 * 30 + +export async function setLastProductCookie(productId: string) { + const store = await cookies() + store.set(LAST_PRODUCT_COOKIE, productId, { + httpOnly: true, + sameSite: 'lax', + maxAge: THIRTY_DAYS_SECONDS, + path: '/', + }) +} + +export async function getLastProductCookie(): Promise { + const store = await cookies() + return store.get(LAST_PRODUCT_COOKIE)?.value ?? null +} diff --git a/proxy.ts b/proxy.ts index d16da0d..0a4e228 100644 --- a/proxy.ts +++ b/proxy.ts @@ -2,7 +2,7 @@ import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' import { sessionOptions } from '@/lib/session' -const protectedRoutes = ['/dashboard', '/products', '/todos', '/settings'] +const protectedRoutes = ['/dashboard', '/products', '/todos', '/settings', '/solo'] const authRoutes = ['/login', '/register'] export function proxy(request: NextRequest) {