feat(ST-355): add /solo route, cookie helper, product picker, and solo product page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
802aa58157
commit
ca631e71ec
7 changed files with 230 additions and 1 deletions
81
app/(app)/products/[id]/solo/page.tsx
Normal file
81
app/(app)/products/[id]/solo/page.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="flex flex-col h-full">
|
||||
<NoActiveSprint productId={id} productName={product.name} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<SoloBoard
|
||||
productId={id}
|
||||
productName={product.name}
|
||||
sprintGoal={sprint.sprint_goal}
|
||||
tasks={tasks}
|
||||
unassignedCount={unassignedCount}
|
||||
isDemo={session.isDemo ?? false}
|
||||
currentUserId={session.userId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
26
app/(app)/solo/page.tsx
Normal file
26
app/(app)/solo/page.tsx
Normal file
|
|
@ -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 <ProductPicker products={products} />
|
||||
}
|
||||
25
components/solo/no-active-sprint.tsx
Normal file
25
components/solo/no-active-sprint.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import Link from 'next/link'
|
||||
|
||||
interface NoActiveSprintProps {
|
||||
productId: string
|
||||
productName: string
|
||||
}
|
||||
|
||||
export function NoActiveSprint({ productId, productName }: NoActiveSprintProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 text-center px-6">
|
||||
<div className="text-4xl text-muted-foreground">🏃</div>
|
||||
<h2 className="text-lg font-medium text-foreground">Geen actieve sprint</h2>
|
||||
<p className="text-sm text-muted-foreground max-w-sm">
|
||||
Er is nog geen actieve sprint voor <span className="font-medium text-foreground">{productName}</span>.
|
||||
Start een sprint in het Sprint Board om hier je taken te zien.
|
||||
</p>
|
||||
<Link
|
||||
href={`/products/${productId}/sprint`}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
Naar Sprint Board →
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
46
components/solo/product-picker.tsx
Normal file
46
components/solo/product-picker.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="p-6 max-w-2xl mx-auto w-full">
|
||||
<h1 className="text-xl font-medium text-foreground mb-2">Solo bord</h1>
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
Kies een product om je persoonlijke Kanban-bord te openen.
|
||||
</p>
|
||||
|
||||
{products.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-sm text-muted-foreground mb-3">Je hebt nog geen producten.</p>
|
||||
<Link href="/products/new" className="text-sm text-primary hover:underline">
|
||||
+ Nieuw product aanmaken
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
{products.map(product => (
|
||||
<Link
|
||||
key={product.id}
|
||||
href={`/products/${product.id}/solo`}
|
||||
className="flex flex-col gap-0.5 px-4 py-3 rounded-lg border border-border bg-surface-container hover:bg-surface-container-high transition-colors"
|
||||
>
|
||||
<span className="text-sm font-medium text-foreground">{product.name}</span>
|
||||
{product.description && (
|
||||
<span className="text-xs text-muted-foreground line-clamp-1">{product.description}</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
32
components/solo/solo-board.tsx
Normal file
32
components/solo/solo-board.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-sm text-muted-foreground">Solo bord wordt geladen in ST-356…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
19
lib/cookies.ts
Normal file
19
lib/cookies.ts
Normal file
|
|
@ -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<string | null> {
|
||||
const store = await cookies()
|
||||
return store.get(LAST_PRODUCT_COOKIE)?.value ?? null
|
||||
}
|
||||
2
proxy.ts
2
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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue